feat(health,ai): 后端服务优化 + 媒体文件处理

- erp-health: article/banner/consultation/media 服务层优化
- erp-ai: analysis/insight/prompt 服务增强
- erp-auth: auth/role/token 服务改进
- erp-workflow: executor 执行引擎修复
- erp-plugin: 服务层改进
- 新增媒体上传文件样例
This commit is contained in:
iven
2026-05-13 23:28:57 +08:00
parent e4e5ef04d4
commit 212c08b7ae
30 changed files with 320 additions and 3 deletions

View File

@@ -170,6 +170,7 @@ impl AnalysisService {
active.result_content = Set(Some(content));
active.result_metadata = Set(Some(metadata));
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.unwrap() + 1);
active.update(&self.db).await?;
Ok(())
}
@@ -185,6 +186,7 @@ impl AnalysisService {
active.status = Set("failed".into());
active.error_message = Set(Some(error));
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.unwrap() + 1);
active.update(&self.db).await?;
Ok(())
}

View File

@@ -123,6 +123,7 @@ impl InsightService {
let mut active: copilot_insights::ActiveModel = model.into();
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.version_lock = Set(active.version_lock.unwrap() + 1);
active.update(db).await?;
}
Ok(count)

View File

@@ -161,6 +161,7 @@ impl PromptService {
.filter(ai_prompt::Column::Category.eq(&entity.category))
.filter(ai_prompt::Column::IsActive.eq(true))
.filter(ai_prompt::Column::DeletedAt.is_null())
.filter(ai_prompt::Column::Id.ne(id))
.all(&self.db)
.await?;
@@ -168,6 +169,7 @@ impl PromptService {
let mut active: ai_prompt::ActiveModel = sibling.into();
active.is_active = Set(false);
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.unwrap() + 1);
active.update(&self.db).await?;
}
@@ -175,6 +177,7 @@ impl PromptService {
let mut active: ai_prompt::ActiveModel = entity.into();
active.is_active = Set(true);
active.updated_at = Set(chrono::Utc::now());
active.version_lock = Set(active.version_lock.unwrap() + 1);
Ok(active.update(&self.db).await?)
}

View File

@@ -137,6 +137,7 @@ impl AuthService {
let mut user_active: user::ActiveModel = user_model.clone().into();
user_active.last_login_at = Set(Some(Utc::now()));
user_active.updated_at = Set(Utc::now());
user_active.version = Set(user_active.version.unwrap() + 1);
user_active
.update(db)
.await

View File

@@ -299,6 +299,7 @@ impl RoleService {
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active
.update(db)
.await

View File

@@ -168,6 +168,7 @@ impl TokenService {
let mut active: user_token::ActiveModel = token_row.into();
active.revoked_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.version = Set(active.version.unwrap() + 1);
active
.update(db)
.await
@@ -199,6 +200,10 @@ impl TokenService {
user_token::Column::UpdatedAt,
sea_orm::sea_query::Expr::value(now.naive_utc()),
)
.col_expr(
user_token::Column::Version,
sea_orm::sea_query::Expr::col(user_token::Column::Version).add(1),
)
.filter(user_token::Column::TokenHash.eq(&hash))
.filter(user_token::Column::UserId.eq(claims.sub))
.filter(user_token::Column::TenantId.eq(claims.tid))
@@ -233,6 +238,7 @@ impl TokenService {
let mut active: user_token::ActiveModel = token.into();
active.revoked_at = Set(Some(now));
active.updated_at = Set(now);
active.version = Set(active.version.unwrap() + 1);
active
.update(db)
.await

View File

@@ -372,6 +372,7 @@ impl MenuService {
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active
.update(db)
.await

View File

@@ -392,6 +392,7 @@ impl NumberingService {
active.seq_current = Set(next_seq);
active.last_reset_date = Set(Some(today));
active.updated_at = Set(Utc::now());
active.version = Set(active.version.unwrap() + 1);
active
.update(txn)
.await

View File

@@ -101,6 +101,12 @@ pub enum HealthError {
#[error("交接记录不存在")]
HandoffLogNotFound,
#[error("媒体文件不存在")]
MediaNotFound,
#[error("媒体文件夹不存在")]
MediaFolderNotFound,
#[error("状态转换无效: {0}")]
InvalidStatusTransition(String),
@@ -148,7 +154,9 @@ impl From<HealthError> for AppError {
| HealthError::CarePlanOutcomeNotFound
| HealthError::ShiftNotFound
| HealthError::PatientAssignmentNotFound
| HealthError::HandoffLogNotFound => AppError::NotFound(err.to_string()),
| HealthError::HandoffLogNotFound
| HealthError::MediaNotFound
| HealthError::MediaFolderNotFound => AppError::NotFound(err.to_string()),
HealthError::ScheduleFull => AppError::Validation(err.to_string()),
HealthError::InvalidStatusTransition(s) => AppError::Validation(s),
HealthError::VersionMismatch => AppError::VersionMismatch,

View File

@@ -72,6 +72,15 @@ pub async fn list_public_articles(
Ok(Json(ApiResponse::ok(result)))
}
/// GET /public/articles/{id} — 公开文章详情(无需认证,仅返回已发布文章)
pub async fn get_public_article(
State(state): State<HealthState>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<ApiResponse<ArticleResp>>, AppError> {
let result = article_service::get_public_article(&state, id).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn get_article<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,

View File

@@ -133,3 +133,37 @@ where
let result = banner_service::list_public_banners(&state, tenant_id).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// GET /public/banner-image/{banner_id} — 公开轮播图图片(无需认证,供小程序下载)
pub async fn serve_banner_image(
State(state): State<HealthState>,
Path(banner_id): Path<uuid::Uuid>,
) -> Result<axum::response::Response, AppError> {
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
let path = banner_service::get_banner_image_path(&state, banner_id).await?;
let data = tokio::fs::read(&path)
.await
.map_err(|e| AppError::Internal(format!("读取图片文件失败: {}", e)))?;
let mime = if path.ends_with(".png") {
"image/png"
} else if path.ends_with(".gif") {
"image/gif"
} else if path.ends_with(".webp") {
"image/webp"
} else {
"image/jpeg"
};
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, mime),
(header::CACHE_CONTROL, "public, max-age=3600"),
],
data,
)
.into_response())
}

View File

@@ -29,6 +29,13 @@ pub struct MessageListParams {
pub after_id: Option<Uuid>,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct PollMessagesParams {
pub after_id: Option<Uuid>,
/// 超时秒数,默认 25最大 30
pub timeout: Option<u64>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CloseSessionReq {
pub version: i32,
@@ -129,6 +136,30 @@ where
Ok(Json(ApiResponse::ok(result)))
}
/// 长轮询咨询消息 — 有新消息立即返回,否则挂起等待(最多 timeout 秒)。
pub async fn poll_messages<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(session_id): Path<Uuid>,
Query(params): Query<PollMessagesParams>,
) -> Result<Json<ApiResponse<Vec<MessageResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
let timeout_secs = params.timeout.unwrap_or(25).min(30);
let result = consultation_service::poll_new_messages(
&state,
ctx.tenant_id,
session_id,
params.after_id,
timeout_secs,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn close_session<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,

View File

@@ -302,6 +302,7 @@ impl OAuthService {
let mut active: api_client::ActiveModel = client.into();
active.deleted_at = Set(Some(Utc::now().into()));
active.version = Set(active.version.unwrap() + 1);
active.update(db).await?;
Ok(())

View File

@@ -202,6 +202,10 @@ pub async fn create_appointment(
Expr::col(doctor_schedule::Column::CurrentAppointments).add(1),
)
.col_expr(doctor_schedule::Column::UpdatedAt, Expr::value(Utc::now()))
.col_expr(
doctor_schedule::Column::Version,
Expr::col(doctor_schedule::Column::Version).add(1),
)
.filter(doctor_schedule::Column::TenantId.eq(tenant_id))
.filter(doctor_schedule::Column::DoctorId.eq(doctor_id_val))
.filter(doctor_schedule::Column::ScheduleDate.eq(req.appointment_date))
@@ -332,6 +336,10 @@ pub async fn update_appointment_status(
Expr::col(doctor_schedule::Column::CurrentAppointments).sub(1),
)
.col_expr(doctor_schedule::Column::UpdatedAt, Expr::value(Utc::now()))
.col_expr(
doctor_schedule::Column::Version,
Expr::col(doctor_schedule::Column::Version).add(1),
)
.filter(doctor_schedule::Column::TenantId.eq(tenant_id))
.filter(doctor_schedule::Column::DoctorId.eq(did))
.filter(doctor_schedule::Column::ScheduleDate.eq(model.appointment_date))

View File

@@ -338,6 +338,7 @@ pub async fn increment_view_count(
let mut active: article::ActiveModel = model.into();
active.view_count = Set(active.view_count.take().unwrap_or(0) + 1);
active.updated_at = Set(Utc::now());
active.version = Set(active.version.unwrap() + 1);
active.update(&state.db).await?;
Ok(())
}

View File

@@ -254,6 +254,10 @@ pub async fn sort_banners(
banner::Entity::update_many()
.col_expr(banner::Column::SortOrder, Expr::value(item.sort_order))
.col_expr(banner::Column::UpdatedAt, Expr::value(Utc::now()))
.col_expr(
banner::Column::Version,
Expr::col(banner::Column::Version).add(1),
)
.filter(banner::Column::Id.eq(item.id))
.filter(banner::Column::TenantId.eq(tenant_id))
.filter(banner::Column::DeletedAt.is_null())
@@ -303,13 +307,12 @@ pub async fn list_public_banners(
if media.deleted_at.is_some() {
return None;
}
let image_url = media.storage_path.trim_start_matches("./").to_string();
Some(PublicBannerResp {
id: b.id,
title: b.title,
subtitle: b.subtitle,
image_url: Some(image_url),
image_url: Some(format!("/public/banner-image/{}", b.id)),
link_type: b.link_type,
link_target: b.link_target,
})
@@ -334,6 +337,21 @@ pub fn generate_signed_url(path: &str, secret_key: &str, ttl_secs: u64) -> (Stri
(token, expires)
}
/// 根据 banner_id 获取关联媒体文件的本地磁盘路径(供公开图片端点使用)
pub async fn get_banner_image_path(state: &HealthState, banner_id: Uuid) -> HealthResult<String> {
let banner = banner::Entity::find_by_id(banner_id)
.one(&state.db)
.await?
.ok_or_else(|| HealthError::Validation("轮播图不存在".to_string()))?;
let media = media_item::Entity::find_by_id(banner.media_item_id)
.one(&state.db)
.await?
.ok_or_else(|| HealthError::Validation("媒体文件不存在".to_string()))?;
Ok(media.storage_path.clone())
}
// ---------------------------------------------------------------------------
// 内部辅助函数
// ---------------------------------------------------------------------------

View File

@@ -264,6 +264,7 @@ pub async fn heartbeat(
active.ip_address = Set(Some(v));
}
active.updated_at = Set(now);
active.version = Set(active.version.unwrap() + 1);
active.update(&state.db).await?;
Ok(())

View File

@@ -457,6 +457,63 @@ pub async fn list_messages(
})
}
/// 长轮询:等待咨询会话的新消息。
///
/// 先查 DB有新消息立即返回否则订阅 EventBus 等待 `consultation.new_message` 事件,
/// 匹配当前 session_id 后再查一次 DB 返回。超时返回空列表。
pub async fn poll_new_messages(
state: &HealthState,
tenant_id: Uuid,
session_id: Uuid,
after_id: Option<Uuid>,
timeout_secs: u64,
) -> HealthResult<Vec<MessageResp>> {
// 1. 先查 DB有新消息立即返回
let initial = list_messages(state, tenant_id, session_id, 1, 50, after_id).await?;
if !initial.data.is_empty() {
return Ok(initial.data);
}
// 2. 订阅咨询相关事件,等待新消息
let (mut rx, _handle) = state
.event_bus
.subscribe_filtered(crate::event::CONSULTATION_NEW_MESSAGE.to_string());
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Ok(vec![]);
}
let result = tokio::time::timeout(remaining, rx.recv()).await;
match result {
Ok(Some(event)) => {
// 匹配 session_id
let event_session_id = event
.payload
.get("session_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok());
if event_session_id == Some(session_id) {
// 收到匹配事件,再查一次 DB 获取完整消息
let fresh =
list_messages(state, tenant_id, session_id, 1, 50, after_id).await?;
if !fresh.data.is_empty() {
return Ok(fresh.data);
}
}
// 事件不匹配当前会话,继续等待
}
Ok(None) => return Ok(vec![]), // channel 关闭
Err(_) => return Ok(vec![]), // 超时
}
}
}
pub async fn create_message(
state: &HealthState,
tenant_id: Uuid,

View File

@@ -173,6 +173,7 @@ pub async fn delete_threshold(
let mut active: critical_value_threshold::ActiveModel = existing.into();
active.deleted_at = Set(Some(chrono::Utc::now()));
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.update(db).await?;
Ok(())
}

View File

@@ -207,6 +207,7 @@ pub async fn update_template(
for old in old_fields {
let mut af: follow_up_template_field::ActiveModel = old.into();
af.deleted_at = Set(Some(Utc::now()));
af.version = Set(af.version.unwrap() + 1);
af.update(&state.db).await?;
}
// 插入新字段
@@ -269,6 +270,7 @@ pub async fn delete_template(
for f in fields {
let mut af: follow_up_template_field::ActiveModel = f.into();
af.deleted_at = Set(Some(Utc::now()));
af.version = Set(af.version.unwrap() + 1);
af.update(&state.db).await?;
}

View File

@@ -238,6 +238,10 @@ pub async fn delete_media_item(
// 级联:关联的 banner 全部设为 inactive
banner::Entity::update_many()
.col_expr(banner::Column::Status, Expr::value("inactive"))
.col_expr(
banner::Column::Version,
Expr::col(banner::Column::Version).add(1),
)
.filter(banner::Column::MediaItemId.eq(id))
.filter(banner::Column::TenantId.eq(tenant_id))
.exec(&state.db)
@@ -282,6 +286,10 @@ pub async fn batch_delete(
.col_expr(media_item::Column::DeletedAt, Expr::value(Some(now)))
.col_expr(media_item::Column::UpdatedAt, Expr::value(now))
.col_expr(media_item::Column::UpdatedBy, Expr::value(operator_id))
.col_expr(
media_item::Column::Version,
Expr::col(media_item::Column::Version).add(1),
)
.filter(media_item::Column::Id.is_in(req.ids.clone()))
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::DeletedAt.is_null())
@@ -291,6 +299,10 @@ pub async fn batch_delete(
// 级联:停用关联 banner
banner::Entity::update_many()
.col_expr(banner::Column::Status, Expr::value("inactive"))
.col_expr(
banner::Column::Version,
Expr::col(banner::Column::Version).add(1),
)
.filter(banner::Column::MediaItemId.is_in(req.ids.clone()))
.filter(banner::Column::TenantId.eq(tenant_id))
.exec(&state.db)

View File

@@ -65,6 +65,10 @@ pub async fn manage_patient_tags(
Expr::value(Some(now)),
)
.col_expr(patient_tag_relation::Column::UpdatedAt, Expr::value(now))
.col_expr(
patient_tag_relation::Column::Version,
Expr::col(patient_tag_relation::Column::Version).add(1),
)
.filter(patient_tag_relation::Column::TenantId.eq(tenant_id))
.filter(patient_tag_relation::Column::PatientId.eq(patient_id))
.filter(patient_tag_relation::Column::DeletedAt.is_null())
@@ -526,6 +530,7 @@ pub async fn remove_doctor(
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(active.version.unwrap() + 1);
active.update(&state.db).await?;
audit_service::record(

View File

@@ -542,6 +542,8 @@ pub async fn exchange_product(
// 关联消费流水的 order_id
let mut spend_active: points_transaction::ActiveModel = spend.into();
spend_active.order_id = Set(Some(inserted_order.id));
spend_active.version = Set(spend_active.version.unwrap() + 1);
spend_active.updated_at = Set(Utc::now());
spend_active.update(&txn).await?;
txn.commit().await?;

View File

@@ -193,6 +193,7 @@ impl PluginService {
// 更新状态
let mut active: plugin::ActiveModel = model.into();
active.version = Set(bump_version(&active.version));
active.status = Set("installed".to_string());
active.installed_at = Set(Some(now));
active.updated_at = Set(now);
@@ -246,6 +247,7 @@ impl PluginService {
let now = Utc::now();
let mut active: plugin::ActiveModel = model.into();
active.version = Set(bump_version(&active.version));
active.status = Set("running".to_string());
active.enabled_at = Set(Some(now));
active.updated_at = Set(now);
@@ -276,6 +278,7 @@ impl PluginService {
let now = Utc::now();
let mut active: plugin::ActiveModel = model.into();
active.version = Set(bump_version(&active.version));
active.status = Set("disabled".to_string());
active.updated_at = Set(now);
active.updated_by = Set(Some(operator_id));
@@ -313,6 +316,7 @@ impl PluginService {
for entity in &tenant_entities {
let mut active: plugin_entity::ActiveModel = entity.clone().into();
active.version = Set(bump_version(&active.version));
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(Some(operator_id));
@@ -346,6 +350,7 @@ impl PluginService {
unregister_plugin_permissions(db, tenant_id, &manifest.metadata.id).await?;
let mut active: plugin::ActiveModel = model.into();
active.version = Set(bump_version(&active.version));
active.status = Set("uninstalled".to_string());
active.updated_at = Set(now);
active.updated_by = Set(Some(operator_id));
@@ -459,6 +464,7 @@ impl PluginService {
let now = Utc::now();
let manifest_id = manifest.metadata.id.clone();
let mut active: plugin::ActiveModel = model.into();
active.version = Set(bump_version(&active.version));
active.config_json = Set(config);
active.updated_at = Set(now);
active.updated_by = Set(Some(operator_id));
@@ -563,6 +569,7 @@ impl PluginService {
validate_status_any(&model.status, &["uninstalled", "uploaded"])?;
let now = Utc::now();
let mut active: plugin::ActiveModel = model.into();
active.version = Set(bump_version(&active.version));
active.deleted_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(Some(operator_id));
@@ -693,6 +700,7 @@ impl PluginService {
if let Some(em) = entity_model {
let mut active: plugin_entity::ActiveModel = em.into();
active.version = Set(bump_version(&active.version));
active.schema_json = Set(serde_json::to_value(entity)
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?);
active.updated_at = Set(now);

View File

@@ -108,7 +108,9 @@ async fn handle_dialysis_record_created(
.ok_or("透析记录不存在")?;
let mut active: erp_dialysis::entity::dialysis_record::ActiveModel = record.into();
active.version = Set(active.version.unwrap() + 1);
active.workflow_instance_id = Set(Some(result.id));
active.updated_at = Set(chrono::Utc::now());
active.update(db).await?;
tracing::info!(

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -89,6 +89,7 @@ impl FlowExecutor {
// 消费当前 token
let mut active: token::ActiveModel = current_token.into();
active.version = Set(active.version.unwrap() + 1);
active.status = Set("consumed".to_string());
active.consumed_at = Set(Some(Utc::now()));
active
@@ -599,6 +600,7 @@ impl FlowExecutor {
.ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {instance_id}")))?;
let mut active: process_instance::ActiveModel = instance.into();
active.version = Set(active.version.unwrap() + 1);
active.status = Set("completed".to_string());
active.completed_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());