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

@@ -1,9 +1,13 @@
use crate::audit::AuditLog;
use crate::entity::audit_log;
use crate::request_info::RequestInfo;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tracing;
use uuid::Uuid;
/// 持久化审计日志到 audit_logs 表。
///
@@ -39,6 +43,12 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
// 计算当前记录的 record_hash
let record_hash = compute_record_hash(&log, prev_hash.as_deref());
// 保存日志字段用于错误日志model 构建会 move String 字段)
let err_tenant_id = log.tenant_id;
let err_action = log.action.clone();
let err_resource_type = log.resource_type.clone();
let err_resource_id = log.resource_id;
let model = audit_log::ActiveModel {
id: Set(log.id),
tenant_id: Set(log.tenant_id),
@@ -56,7 +66,14 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
};
if let Err(e) = model.insert(db).await {
tracing::warn!(error = %e, "审计日志写入失败");
tracing::error!(
error = %e,
tenant_id = ?err_tenant_id,
action = %err_action,
resource_type = %err_resource_type,
resource_id = ?err_resource_id,
"审计日志写入失败 — 数据完整性风险"
);
}
}
@@ -131,3 +148,74 @@ pub async fn verify_hash_chain(
Ok((total, broken))
}
/// 哈希链验证结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainVerificationResult {
pub total: usize,
pub passed: usize,
pub failed: usize,
pub failed_ids: Vec<Uuid>,
}
/// 验证最近 N 条审计记录的哈希链完整性。
pub async fn verify_recent_chain(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
limit: u64,
) -> Result<ChainVerificationResult, String> {
let records = audit_log::Entity::find()
.filter(audit_log::Column::TenantId.eq(tenant_id))
.filter(audit_log::Column::RecordHash.is_not_null())
.order_by_desc(audit_log::Column::CreatedAt)
.limit(limit)
.all(db)
.await
.map_err(|e| format!("查询审计日志失败: {}", e))?;
let mut records = records;
records.sort_by(|a, b| a.created_at.cmp(&b.created_at));
let total = records.len();
let mut passed = 0;
let mut failed_ids = Vec::new();
let mut prev: Option<String> = None;
for record in &records {
let mut record_broken = false;
if prev.as_deref() != record.prev_hash.as_deref() {
record_broken = true;
}
let log = AuditLog {
id: record.id,
tenant_id: record.tenant_id,
user_id: record.user_id,
action: record.action.clone(),
resource_type: record.resource_type.clone(),
resource_id: record.resource_id,
old_value: record.old_value.clone(),
new_value: record.new_value.clone(),
ip_address: record.ip_address.clone(),
user_agent: record.user_agent.clone(),
created_at: record.created_at,
};
let expected = compute_record_hash(&log, record.prev_hash.as_deref());
if Some(expected.as_str()) != record.record_hash.as_deref() {
record_broken = true;
}
if record_broken {
failed_ids.push(record.id);
} else {
passed += 1;
}
prev = record.record_hash.clone();
}
let failed = total - passed;
Ok(ChainVerificationResult {
total,
passed,
failed,
failed_ids,
})
}

View File

@@ -1,5 +1,7 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ConnectionTrait, PaginatorTrait, Set};
use sea_orm::{
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
};
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, mpsc};
use tracing::{error, info};
@@ -8,6 +10,35 @@ use uuid::Uuid;
use crate::entity::dead_letter_event;
use crate::entity::domain_event;
/// 已知的 PII 字段列表 -- 在事件 payload 中自动脱敏
const PII_FIELDS: &[&str] = &[
"phone",
"id_number",
"emergency_contact_phone",
"emergency_contact_name",
"medical_history_summary",
"allergy_history",
"content",
];
/// 递归脱敏 payload 中的 PII 字段(原地修改)。
fn sanitize_payload(payload: &mut serde_json::Value) {
if let Some(obj) = payload.as_object_mut() {
for field in PII_FIELDS {
if let Some(val) = obj.get_mut(*field)
&& val.is_string()
{
*val = serde_json::Value::String("[REDACTED]".to_string());
}
}
for val in obj.values_mut() {
if val.is_object() {
sanitize_payload(val);
}
}
}
}
/// 领域事件
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainEvent {
@@ -230,7 +261,10 @@ impl EventBus {
///
/// 两阶段提交保证:即使广播后服务崩溃,事件仍为 pending 状态,
/// 重启后 outbox relay 会重新广播。
pub async fn publish(&self, event: DomainEvent, db: &sea_orm::DatabaseConnection) {
pub async fn publish(&self, mut event: DomainEvent, db: &sea_orm::DatabaseConnection) {
// 0. 脱敏 payload 中的 PII 字段
sanitize_payload(&mut event.payload);
// 1. 持久化为 pending 状态
let event_id = event.id;
let model = domain_event::ActiveModel {
@@ -343,3 +377,82 @@ impl EventBus {
)
}
}
/// 重试 dead_letter_events 中未解决的失败事件(指数退避)。
pub async fn retry_dead_letters(
db: &sea_orm::DatabaseConnection,
bus: &EventBus,
max_attempts: i32,
) -> Result<u64, String> {
// 1. 查询所有未解决且未超过最大重试次数的 dead-letter
let pending = dead_letter_event::Entity::find()
.filter(dead_letter_event::Column::ResolvedAt.is_null())
.filter(dead_letter_event::Column::Attempts.lt(max_attempts))
.all(db)
.await
.map_err(|e| format!("查询 dead_letter_events 失败: {}", e))?;
let retried = pending.len() as u64;
for dl in &pending {
let event = DomainEvent {
id: dl.original_event_id,
event_type: dl.event_type.clone(),
tenant_id: dl.tenant_id.unwrap_or(Uuid::nil()),
payload: dl.payload.clone().unwrap_or(serde_json::Value::Null),
timestamp: dl.created_at,
correlation_id: Uuid::now_v7(),
};
bus.broadcast(event);
let mut active: dead_letter_event::ActiveModel = dl.clone().into();
let new_attempts = dl.attempts + 1;
active.attempts = Set(new_attempts);
active.last_error = Set(Some(format!(
"{} 次自动重试({}",
new_attempts,
Utc::now().to_rfc3339()
)));
if let Err(e) = active.update(db).await {
tracing::warn!(
dead_letter_id = %dl.id,
error = %e,
"更新 dead_letter_events attempts 失败"
);
}
}
// 2. 标记超过最大重试次数的记录为永久失败
let exhausted = dead_letter_event::Entity::find()
.filter(dead_letter_event::Column::ResolvedAt.is_null())
.filter(dead_letter_event::Column::Attempts.gte(max_attempts))
.all(db)
.await
.map_err(|e| format!("查询超限 dead_letter_events 失败: {}", e))?;
for dl in &exhausted {
let mut active: dead_letter_event::ActiveModel = dl.clone().into();
active.resolved_at = Set(Some(Utc::now()));
active.last_error = Set(Some(format!(
"已达最大重试次数 {},标记为永久失败",
max_attempts
)));
if let Err(e) = active.update(db).await {
tracing::warn!(
dead_letter_id = %dl.id,
error = %e,
"标记 dead_letter_event 为永久失败时更新失败"
);
}
}
if retried > 0 || !exhausted.is_empty() {
tracing::info!(
retried = retried,
permanently_failed = exhausted.len(),
"Dead-letter 自动重试完成"
);
}
Ok(retried)
}