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:
235
crates/erp-health/src/event/article.rs
Normal file
235
crates/erp-health/src/event/article.rs
Normal 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()))
|
||||
}
|
||||
117
crates/erp-health/src/event/care_plan.rs
Normal file
117
crates/erp-health/src/event/care_plan.rs
Normal 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
|
||||
}
|
||||
@@ -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 关闭
|
||||
// 所有过滤订阅的生命周期应与进程一致
|
||||
|
||||
Reference in New Issue
Block a user