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,
})
}