feat(health+ai): P2 咨询联动 + AI 巡检消费 — 全链路打通

业务链路打通 5/5 断点全部完成:
- 咨询→随访:医生端新增"创建随访"按钮,从咨询会话直接创建随访任务
- 咨询→AI:医生端新增"AI 分析"按钮,对咨询上下文触发 AI 分析
- 告警→咨询:小程序告警详情页新增"在线咨询"快捷入口
- AI 巡检消费:erp-ai 新增 patrol_consumer,订阅 ai.patrol.requested 事件
- 前端联动:Web ConsultationDetail + 小程序 alerts 页面联动实现

后端:2 新 API + 2 handler + 1 service + AI event consumer
前端:Web 2 API + 1 页面改造 + 小程序 2 页面改造
测试:Web consultations.test.ts 9/9 通过
This commit is contained in:
iven
2026-05-20 17:50:49 +08:00
parent 5f34e5715a
commit fa1dc764a3
15 changed files with 888 additions and 8 deletions

View File

@@ -61,3 +61,36 @@ pub struct SessionQuery {
pub page: Option<u64>,
pub page_size: Option<u64>,
}
/// 从咨询会话创建随访任务请求体 — 仅需填写随访类型和计划日期,
/// patient_id / source_type / source_id 由服务端自动填充。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateFollowUpFromConsultationReq {
pub follow_up_type: String,
pub planned_date: chrono::NaiveDate,
pub assigned_to: Option<Uuid>,
pub content_template: Option<String>,
}
/// 从咨询会话触发 AI 分析请求体 — 可选指定分析类型。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct TriggerAiAnalysisReq {
/// 分析类型,默认 "consultation_summary"
pub analysis_type: Option<String>,
}
/// 从咨询会话创建随访任务响应体。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct FollowUpFromConsultationResp {
pub task_id: Uuid,
pub session_id: Uuid,
pub patient_id: Uuid,
}
/// 从咨询会话触发 AI 分析响应体。
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AiAnalysisTriggeredResp {
pub session_id: Uuid,
pub patient_id: Uuid,
pub analysis_type: String,
}

View File

@@ -1,3 +1,5 @@
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
/// alert.triggered → 告警消息通知 + 告警聚合
pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::SubscriptionHandle> {
let mut handles = Vec::new();
@@ -28,7 +30,54 @@ pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::Subscri
.get("rule_name")
.and_then(|v| v.as_str())
.unwrap_or("健康告警");
if let Some(pid) = patient_id {
// 检查患者是否有活跃咨询会话active / waiting
let patient_uuid = uuid::Uuid::parse_str(pid).ok();
let active_session = if let Some(puid) = patient_uuid {
crate::entity::consultation_session::Entity::find()
.filter(
crate::entity::consultation_session::Column::PatientId.eq(puid),
)
.filter(
crate::entity::consultation_session::Column::TenantId
.eq(event.tenant_id),
)
.filter(
crate::entity::consultation_session::Column::DeletedAt
.is_null(),
)
.filter(
sea_orm::Condition::any()
.add(
crate::entity::consultation_session::Column::Status
.eq("active"),
)
.add(
crate::entity::consultation_session::Column::Status
.eq("waiting"),
),
)
.one(&alert_db)
.await
.ok()
.flatten()
} else {
None
};
let consultation_session_id =
active_session.as_ref().map(|s| s.id.to_string());
let mut params = serde_json::json!({
"rule_name": rule_name,
"severity": severity,
"suggested_action": "consult",
});
if let Some(ref sid) = consultation_session_id {
params["consultation_session_id"] = serde_json::json!(sid);
}
let notify_event = erp_core::events::DomainEvent::new(
"message.send",
event.tenant_id,
@@ -37,14 +86,16 @@ pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::Subscri
"recipient_type": "patient",
"recipient_id": pid,
"template_key": if severity == "critical" { "CRITICAL_HEALTH_ALERT" } else { "HEALTH_DATA_ABNORMAL" },
"params": {
"rule_name": rule_name,
"severity": severity,
}
"params": params,
})),
);
alert_bus.publish(notify_event, &alert_db).await;
tracing::info!(patient_id = %pid, severity = %severity, "告警通知已发送");
tracing::info!(
patient_id = %pid,
severity = %severity,
consultation_session_id = ?consultation_session_id,
"告警通知已发送(含咨询联动建议)"
);
}
let _ = erp_core::events::mark_event_processed(
&alert_db,

View File

@@ -305,3 +305,70 @@ where
.await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 从咨询会话创建随访任务 — 自动从 session 中提取 patient_id
/// source_type = "consultation", source_id = session_id。
#[utoipa::path(
post,
path = "/consultation-sessions/{id}/follow-up",
request_body = CreateFollowUpFromConsultationReq,
responses(
(status = 200, description = "随访任务已创建"),
(status = 404, description = "会话不存在"),
),
tag = "咨询联动",
)]
pub async fn create_follow_up_from_session<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<CreateFollowUpFromConsultationReq>,
) -> Result<Json<ApiResponse<FollowUpFromConsultationResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
let result = consultation_service::create_follow_up_from_session(
&state,
ctx.tenant_id,
Some(ctx.user_id),
id,
req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 从咨询会话触发 AI 分析 — 加载最近消息作为上下文,发布事件。
#[utoipa::path(
post,
path = "/consultation-sessions/{id}/ai-analysis",
request_body = TriggerAiAnalysisReq,
responses(
(status = 200, description = "AI 分析已触发"),
(status = 404, description = "会话不存在"),
),
tag = "咨询联动",
)]
pub async fn trigger_ai_analysis_from_session<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<TriggerAiAnalysisReq>,
) -> Result<Json<ApiResponse<AiAnalysisTriggeredResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.manage")?;
let result = consultation_service::trigger_ai_analysis_from_session(
&state,
ctx.tenant_id,
Some(ctx.user_id),
id,
req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -38,6 +38,15 @@ where
"/health/consultation-sessions/{id}/read",
axum::routing::put(consultation_handler::mark_session_read),
)
// 咨询联动
.route(
"/health/consultation-sessions/{id}/follow-up",
axum::routing::post(consultation_handler::create_follow_up_from_session),
)
.route(
"/health/consultation-sessions/{id}/ai-analysis",
axum::routing::post(consultation_handler::trigger_ai_analysis_from_session),
)
.route(
"/health/consultation-messages",
axum::routing::post(consultation_handler::create_message),

View File

@@ -857,3 +857,165 @@ pub async fn enrich_doctor_dashboard_health(
Ok(())
}
// ---------------------------------------------------------------------------
// 咨询联动 — 创建随访 / 触发 AI 分析
// ---------------------------------------------------------------------------
/// 从咨询会话创建随访任务。
///
/// 自动从 session 中提取 patient_id设置 source_type = "consultation",
/// source_id = session_id。
pub async fn create_follow_up_from_session(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
session_id: Uuid,
req: CreateFollowUpFromConsultationReq,
) -> HealthResult<FollowUpFromConsultationResp> {
tracing::info!(
action = "create_follow_up_from_session",
session_id = %session_id,
"Creating follow-up task from consultation session"
);
// 1. 查询会话,获取 patient_id
let session = consultation_session::Entity::find()
.filter(consultation_session::Column::Id.eq(session_id))
.filter(consultation_session::Column::TenantId.eq(tenant_id))
.filter(consultation_session::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::ConsultationNotFound)?;
let patient_id = session.patient_id;
// 2. 构造 CreateFollowUpTaskReq 并委托给 follow_up_service
let follow_up_req = crate::dto::follow_up_dto::CreateFollowUpTaskReq {
patient_id,
assigned_to: req.assigned_to,
follow_up_type: req.follow_up_type,
planned_date: req.planned_date,
content_template: req.content_template,
related_appointment_id: None,
source_type: Some("consultation".to_string()),
source_id: Some(session_id),
};
let task = crate::service::follow_up_service::create_task(
state,
tenant_id,
operator_id,
follow_up_req,
)
.await?;
Ok(FollowUpFromConsultationResp {
task_id: task.id,
session_id,
patient_id,
})
}
/// 从咨询会话触发 AI 分析。
///
/// 加载会话最近消息作为上下文,发布 `ai.analysis.requested` 事件。
pub async fn trigger_ai_analysis_from_session(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
session_id: Uuid,
req: TriggerAiAnalysisReq,
) -> HealthResult<AiAnalysisTriggeredResp> {
tracing::info!(
action = "trigger_ai_analysis_from_session",
session_id = %session_id,
"Triggering AI analysis from consultation session"
);
// 1. 查询会话,获取 patient_id
let session = consultation_session::Entity::find()
.filter(consultation_session::Column::Id.eq(session_id))
.filter(consultation_session::Column::TenantId.eq(tenant_id))
.filter(consultation_session::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::ConsultationNotFound)?;
let patient_id = session.patient_id;
let doctor_id = session.doctor_id;
let analysis_type = req
.analysis_type
.unwrap_or_else(|| "consultation_summary".to_string());
// 2. 加载最近消息作为上下文(最多 50 条)
let recent_messages = consultation_message::Entity::find()
.filter(consultation_message::Column::SessionId.eq(session_id))
.filter(consultation_message::Column::TenantId.eq(tenant_id))
.filter(consultation_message::Column::DeletedAt.is_null())
.order_by_desc(consultation_message::Column::CreatedAt)
.limit(50)
.all(&state.db)
.await?;
// 解密消息内容,构造上下文摘要
let kek = state.crypto.kek();
let context_messages: Vec<serde_json::Value> = recent_messages
.into_iter()
.rev() // 恢复时间正序
.map(|m| {
let content = pii::decrypt(kek, &m.content).unwrap_or(m.content);
serde_json::json!({
"sender_role": m.sender_role,
"content_type": m.content_type,
"content": content,
"created_at": m.created_at.to_rfc3339(),
})
})
.collect();
// 3. 发布 ai.analysis.requested 事件
let event = DomainEvent::new(
"ai.analysis.requested",
tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"analysis_type": analysis_type,
"patient_id": patient_id.to_string(),
"doctor_id": doctor_id.map(|id| id.to_string()).unwrap_or_default(),
"source": "consultation",
"source_id": session_id.to_string(),
"triggered_by": operator_id.map(|id| id.to_string()).unwrap_or_default(),
"context": {
"session_id": session_id.to_string(),
"consultation_type": session.consultation_type,
"messages": context_messages,
}
})),
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(
tenant_id,
operator_id,
"consultation.ai_analysis_triggered",
"consultation",
)
.with_resource_id(session_id),
&state.db,
)
.await;
tracing::info!(
session_id = %session_id,
patient_id = %patient_id,
analysis_type = %analysis_type,
"AI 分析已从咨询会话触发"
);
Ok(AiAnalysisTriggeredResp {
session_id,
patient_id,
analysis_type,
})
}