feat(health+core+ai): 业务流程全面修复 Phase 4-6 + 集成测试修复

Phase 4 — Dead-letter 重试 + 内容推送 + 安全加固:
- erp-core: retry_dead_letters() 定时重试 + PII payload 脱敏
- erp-core: audit_service 哈希链定时验证 + 写入失败告警
- erp-health: article.published 消费者匹配 patient_tag 推送消息
- erp-health: care_plan 事件消费者 (激活通知 + 完成积分)

Phase 5 — 患者批量操作 + 咨询增强 + 护理事件:
- patient: batch_import_patients + bind_by_phone + refer_patient
- consultation: rate_session 满意度评价 (rating + feedback)
- consent: patient_sign_consent 患者端签署
- validation: source 枚举 (7值) + relationship 枚举 (7值) + 12 单元测试

Phase 6 — 咨询文件上传 + AI 引用标注:
- consultation_message: media_id 附件上传端点
- ai_suggestion: references JSONB + [ref:id] 格式引用标注
- AI system prompt 增加引用指令 + output_parser 提取逻辑

迁移: 000161 (media_id + references) + 000162 (rating + feedback)
集成测试: consultation/follow_up/pii_encryption 新字段同步修复
讨论文档: 2026-05-20-business-process-brainstorm.md (10域审核报告)
This commit is contained in:
iven
2026-05-21 01:34:20 +08:00
parent 9033ec8ca2
commit 41a865cf68
37 changed files with 1929 additions and 14 deletions

View File

@@ -0,0 +1,235 @@
/// article.published → 推送通知给匹配标签的患者
///
/// 文章发布后:
/// 1. 从 payload 提取 article_id
/// 2. 查询文章关联的 article_tag通过 article_article_tag 表)
/// 3. 查询匹配这些 tag 的 patient_tag_relation 关联的患者
/// 4. 为每个匹配患者发布 message.send 事件
pub fn spawn(state: &crate::state::HealthState) -> Vec<erp_core::events::SubscriptionHandle> {
let mut handles = Vec::new();
let (mut article_rx, article_handle) =
state.event_bus.subscribe_filtered("article.".to_string());
handles.push(article_handle);
let article_db = state.db.clone();
let article_bus = state.event_bus.clone();
tokio::spawn(async move {
loop {
match article_rx.recv().await {
Some(event) if event.event_type == super::ARTICLE_PUBLISHED => {
if erp_core::events::is_event_processed(
&article_db,
event.id,
"article_published_push",
)
.await
.unwrap_or(false)
{
continue;
}
let article_id = event
.payload
.get("article_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
let Some(aid) = article_id else {
tracing::warn!(
event_id = %event.id,
"article.published 事件缺少 article_id跳过推送"
);
let _ = erp_core::events::mark_event_processed(
&article_db,
event.id,
"article_published_push",
)
.await;
continue;
};
// 1. 查询文章关联的 article_tag ID 列表
let tag_ids = match find_article_tag_ids(&article_db, aid).await {
Ok(ids) => ids,
Err(e) => {
tracing::warn!(
article_id = %aid,
error = %e,
"查询文章标签失败,跳过推送"
);
let _ = erp_core::events::mark_event_processed(
&article_db,
event.id,
"article_published_push",
)
.await;
continue;
}
};
if tag_ids.is_empty() {
tracing::info!(
article_id = %aid,
"文章未关联标签,跳过患者推送"
);
let _ = erp_core::events::mark_event_processed(
&article_db,
event.id,
"article_published_push",
)
.await;
continue;
}
// 2. 查询匹配这些 tag 的患者 ID通过 patient_tag_relation
let patient_ids =
match find_patients_by_tags(&article_db, event.tenant_id, &tag_ids).await {
Ok(ids) => ids,
Err(e) => {
tracing::warn!(
article_id = %aid,
error = %e,
"查询匹配标签的患者失败,跳过推送"
);
let _ = erp_core::events::mark_event_processed(
&article_db,
event.id,
"article_published_push",
)
.await;
continue;
}
};
if patient_ids.is_empty() {
tracing::info!(
article_id = %aid,
tag_count = tag_ids.len(),
"无匹配标签的患者,跳过推送"
);
let _ = erp_core::events::mark_event_processed(
&article_db,
event.id,
"article_published_push",
)
.await;
continue;
}
// 3. 获取文章标题用于推送消息
let article_title = find_article_title(&article_db, aid)
.await
.unwrap_or_else(|_| "新文章".to_string());
// 4. 为每个匹配患者发布 message.send 事件(批量)
let mut pushed = 0u64;
for pid in &patient_ids {
let notify = erp_core::events::DomainEvent::new(
"message.send",
event.tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"channel": "in_app",
"recipient_type": "patient",
"recipient_id": pid.to_string(),
"template_key": "ARTICLE_PUBLISHED",
"params": {
"article_id": aid.to_string(),
"article_title": article_title,
}
})),
);
article_bus.publish(notify, &article_db).await;
pushed += 1;
}
tracing::info!(
article_id = %aid,
article_title = %article_title,
tag_count = tag_ids.len(),
patient_count = pushed,
"文章发布推送完成"
);
let _ = erp_core::events::mark_event_processed(
&article_db,
event.id,
"article_published_push",
)
.await;
}
Some(_) => {}
None => break,
}
}
});
handles
}
/// 查询文章关联的 article_tag ID 列表
async fn find_article_tag_ids(
db: &sea_orm::DatabaseConnection,
article_id: uuid::Uuid,
) -> Result<Vec<uuid::Uuid>, sea_orm::DbErr> {
use crate::entity::article_article_tag;
use sea_orm::ColumnTrait;
use sea_orm::EntityTrait;
use sea_orm::QueryFilter;
let relations = article_article_tag::Entity::find()
.filter(article_article_tag::Column::ArticleId.eq(article_id))
.filter(article_article_tag::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(relations.into_iter().map(|r| r.tag_id).collect())
}
/// 查询匹配指定 tag 集合的患者 ID去重
async fn find_patients_by_tags(
db: &sea_orm::DatabaseConnection,
tenant_id: uuid::Uuid,
tag_ids: &[uuid::Uuid],
) -> Result<Vec<uuid::Uuid>, sea_orm::DbErr> {
use crate::entity::patient_tag_relation;
use sea_orm::ColumnTrait;
use sea_orm::EntityTrait;
use sea_orm::QueryFilter;
let relations = patient_tag_relation::Entity::find()
.filter(patient_tag_relation::Column::TenantId.eq(tenant_id))
.filter(patient_tag_relation::Column::TagId.is_in(tag_ids.to_vec()))
.filter(patient_tag_relation::Column::DeletedAt.is_null())
.all(db)
.await?;
// 去重
let mut seen = std::collections::HashSet::new();
let patient_ids: Vec<uuid::Uuid> = relations
.into_iter()
.filter_map(|r| {
if seen.insert(r.patient_id) {
Some(r.patient_id)
} else {
None
}
})
.collect();
Ok(patient_ids)
}
/// 获取文章标题
async fn find_article_title(
db: &sea_orm::DatabaseConnection,
article_id: uuid::Uuid,
) -> Result<String, sea_orm::DbErr> {
use crate::entity::article;
use sea_orm::EntityTrait;
let article = article::Entity::find_by_id(article_id).one(db).await?;
Ok(article
.map(|a| a.title)
.unwrap_or_else(|| "新文章".to_string()))
}

View File

@@ -0,0 +1,117 @@
//! 护理计划事件消费者 — 激活通知 + 完成积分
use crate::state::HealthState;
/// 订阅 care_plan. 前缀事件:
/// - CARE_PLAN_ACTIVATED → 发送站内通知给患者
/// - CARE_PLAN_COMPLETED → 触发积分 earn_points("care_plan_completion")
pub fn spawn(state: &HealthState) -> Vec<erp_core::events::SubscriptionHandle> {
let mut handles = Vec::new();
let (mut rx, handle) = state.event_bus.subscribe_filtered("care_plan.".to_string());
handles.push(handle);
let s = state.clone();
tokio::spawn(async move {
loop {
match rx.recv().await {
Some(event) if event.event_type == super::CARE_PLAN_ACTIVATED => {
if erp_core::events::is_event_processed(
&s.db,
event.id,
"care_plan_activated_notifier",
)
.await
.unwrap_or(false)
{
continue;
}
let patient_id = event.payload.get("patient_id").and_then(|v| v.as_str());
if let Some(pid) = patient_id {
let notify = erp_core::events::DomainEvent::new(
"message.send",
event.tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"channel": "in_app",
"recipient_type": "patient",
"recipient_id": pid,
"template_key": "CARE_PLAN_ACTIVATED",
"params": { "message": "您的护理计划已激活" }
})),
);
s.event_bus.publish(notify, &s.db).await;
tracing::info!(patient_id = pid, "护理计划激活通知已发送");
}
let _ = erp_core::events::mark_event_processed(
&s.db,
event.id,
"care_plan_activated_notifier",
)
.await;
}
Some(event) if event.event_type == super::CARE_PLAN_COMPLETED => {
if erp_core::events::is_event_processed(
&s.db,
event.id,
"care_plan_completed_points",
)
.await
.unwrap_or(false)
{
continue;
}
let patient_id = event
.payload
.get("patient_id")
.and_then(|v| v.as_str())
.and_then(|s| uuid::Uuid::parse_str(s).ok());
if let Some(pid) = patient_id {
match crate::service::points_service::earn_points(
&s,
event.tenant_id,
pid,
"care_plan_completion",
None,
)
.await
{
Ok(tx) => {
tracing::info!(
patient_id = %pid,
points = tx.amount,
"护理计划完成积分已发放"
);
}
Err(e) => {
let err_str = e.to_string();
if !err_str.contains("无匹配的积分规则") {
tracing::warn!(
patient_id = %pid,
error = %e,
"护理计划完成积分发放失败"
);
}
}
}
}
let _ = erp_core::events::mark_event_processed(
&s.db,
event.id,
"care_plan_completed_points",
)
.await;
}
Some(_) => {}
None => break,
}
}
});
handles
}

View File

@@ -3,6 +3,7 @@ use erp_core::events::EventBus;
mod ai;
mod alert;
mod appointment;
mod care_plan;
mod consent;
mod consultation;
mod device;
@@ -99,6 +100,7 @@ pub fn register_handlers_with_state(state: crate::state::HealthState) {
handles.extend(consultation::spawn(&state));
handles.extend(points::spawn(&state));
handles.extend(lab_report::spawn(&state));
handles.extend(care_plan::spawn(&state));
// 防止 SubscriptionHandle 被 drop 导致 cancel channel 关闭
// 所有过滤订阅的生命周期应与进程一致