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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user