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

7
Cargo.lock generated
View File

@@ -1429,6 +1429,7 @@ dependencies = [
"handlebars",
"hex",
"redis",
"regex-lite",
"reqwest",
"sea-orm",
"serde",
@@ -3980,6 +3981,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.10"

View File

@@ -91,6 +91,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
aes = "0.8"
cbc = "0.1"
hex = "0.4"
regex-lite = "0.1"
# CSV and Excel export
csv = "1"

View File

@@ -25,3 +25,4 @@ dashmap.workspace = true
sha2.workspace = true
redis.workspace = true
hex.workspace = true
regex-lite.workspace = true

View File

@@ -15,6 +15,7 @@ pub struct Model {
pub workflow_instance_id: Option<Uuid>,
pub action_result: Option<serde_json::Value>,
pub baseline_snapshot: Option<serde_json::Value>,
pub references: Option<serde_json::Value>,
pub reanalysis_id: Option<Uuid>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,

View File

@@ -94,7 +94,12 @@ impl AnalysisService {
tracing::info!(analysis = %analysis_id, tenant = %tenant_id, r#type = %analysis_type.as_str(), "发起 AI 分析");
// 0.5 知识库上下文注入
// 0.5 知识库上下文注入 + 引用标注指令
let citation_instruction = "\n\n=== 引用标注规则 ===\n\
在回答中引用知识库条目时,请使用 [ref:id] 格式标注引用来源。\n\
例如:\"根据临床指南 [ref:uuid-of-guideline],建议...\"\n\
每个引用的知识库条目必须在回答中标注。如果没有引用任何知识库条目,则无需标注。";
let system_prompt = if let Some(ref ks) = self.knowledge_source {
let query = crate::knowledge::KnowledgeQuery {
tenant_id,
@@ -109,9 +114,20 @@ impl AnalysisService {
confidence = ctx.confidence,
"知识库上下文注入"
);
// 将引用的来源 ID 附加到上下文中
let refs_info = if ctx.references.is_empty() {
String::new()
} else {
let refs_list: Vec<String> = ctx
.references
.iter()
.map(|r| format!("- {} (ID: {})", r.title, r.source))
.collect();
format!("\n\n可用引用源:\n{}", refs_list.join("\n"))
};
format!(
"{}\n\n=== 知识库参考 ===\n{}",
system_prompt, ctx.context_text
"{}\n\n=== 知识库参考 ===\n{}{}{}",
system_prompt, ctx.context_text, refs_info, citation_instruction
)
}
Ok(_) => system_prompt,
@@ -121,7 +137,8 @@ impl AnalysisService {
}
}
} else {
system_prompt
// 无知识库时也添加引用指令(供通用场景使用)
format!("{}{}", system_prompt, citation_instruction)
};
// 1. 渲染 Prompt

View File

@@ -32,6 +32,29 @@ fn extract_section<'a>(raw: &'a str, start: &str, end: &str) -> Option<&'a str>
Some(&raw[content_start..content_end])
}
/// 从 AI 输出文本中提取 [ref:id] 格式的引用标注。
/// 返回所有匹配的引用 ID 列表(去重)。
pub fn extract_references(text: &str) -> Vec<String> {
let re = regex_lite::Regex::new(r"\[ref:([a-f0-9-]+)\]").unwrap_or_else(|_| {
// fallback: 不应该发生,但确保不 panic
panic!("引用提取正则编译失败");
});
let mut refs: Vec<String> = re
.captures_iter(text)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.collect();
refs.dedup();
refs
}
/// 从 AI 输出文本中移除 [ref:id] 标注,返回纯文本。
pub fn strip_references(text: &str) -> String {
let re = regex_lite::Regex::new(r"\[ref:[a-f0-9-]+\]").unwrap_or_else(|_| {
panic!("引用清除正则编译失败");
});
re.replace_all(text, "").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -78,4 +101,47 @@ mod tests {
assert!(!RiskLevel::Medium.is_auto_executable());
assert!(!RiskLevel::High.is_auto_executable());
}
// --- extract_references ---
#[test]
fn extract_single_reference() {
let text = "根据临床指南 [ref:01234567-abcd-ef01-2345-678901234567],建议...";
let refs = extract_references(text);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0], "01234567-abcd-ef01-2345-678901234567");
}
#[test]
fn extract_multiple_references() {
let text = "参考 [ref:aaa-bbb] 和 [ref:ccc-ddd],综合建议";
let refs = extract_references(text);
assert_eq!(refs.len(), 2);
}
#[test]
fn extract_no_references() {
let text = "纯文本,无引用标注";
let refs = extract_references(text);
assert!(refs.is_empty());
}
#[test]
fn extract_dedup_references() {
let text = "[ref:aaa-bbb] 再次引用 [ref:aaa-bbb]";
let refs = extract_references(text);
assert_eq!(refs.len(), 1);
}
#[test]
fn strip_references_removes_markers() {
let text = "根据指南 [ref:aaa-bbb],建议复查";
let clean = strip_references(text);
assert_eq!(clean, "根据指南 ,建议复查");
}
#[test]
fn strip_no_references_unchanged() {
let text = "无标注文本";
assert_eq!(strip_references(text), text);
}
}

View File

@@ -34,6 +34,14 @@ pub async fn post_process_analysis(
structured: None,
});
// 1.5 从完整 AI 输出中提取 [ref:id] 引用标注
let extracted_refs = output_parser::extract_references(full_content);
let references_json = if extracted_refs.is_empty() {
None
} else {
Some(serde_json::json!(extracted_refs))
};
// 2. 构建事件 payload
let mut event_payload = serde_json::json!({
"analysis_id": analysis_id,
@@ -42,6 +50,10 @@ pub async fn post_process_analysis(
"doctor_id": user_id,
});
if !extracted_refs.is_empty() {
event_payload["reference_count"] = serde_json::json!(extracted_refs.len());
}
let mut risk_level_str: Option<String> = None;
let mut suggestion_ids = Vec::new();
@@ -50,7 +62,7 @@ pub async fn post_process_analysis(
event_payload["risk_level"] = serde_json::json!(structured.risk_level.as_str());
event_payload["suggestion_count"] = serde_json::json!(structured.suggestions.len());
// 3. 创建建议记录
// 3. 创建建议记录(附带引用信息)
if !structured.suggestions.is_empty() {
match SuggestionService::create_suggestions(
&state.db,
@@ -60,6 +72,7 @@ pub async fn post_process_analysis(
structured.risk_level,
&structured.baseline_summary,
Some(user_id),
references_json.as_ref(),
)
.await
{

View File

@@ -8,6 +8,7 @@ pub struct SuggestionService;
impl SuggestionService {
/// 批量创建建议记录
#[allow(clippy::too_many_arguments)]
pub async fn create_suggestions(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
@@ -16,6 +17,7 @@ impl SuggestionService {
risk_level: RiskLevel,
baseline_snapshot: &serde_json::Value,
created_by: Option<Uuid>,
references: Option<&serde_json::Value>,
) -> AppResult<Vec<uuid::Uuid>> {
let mut ids = Vec::new();
for s in suggestions {
@@ -31,6 +33,7 @@ impl SuggestionService {
workflow_instance_id: Set(None),
action_result: Set(None),
baseline_snapshot: Set(Some(baseline_snapshot.clone())),
references: Set(references.cloned()),
reanalysis_id: Set(None),
created_by: Set(created_by),
updated_by: Set(created_by),

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

View File

@@ -3,6 +3,7 @@ use erp_core::sanitize::{sanitize_option, sanitize_string};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ConsentResp {
@@ -54,3 +55,11 @@ impl RevokeConsentReq {
self.notes = sanitize_option(self.notes.take());
}
}
/// 患者端知情同意签署请求体
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct PatientSignConsentReq {
#[validate(length(min = 1, max = 50))]
pub consent_method: String,
pub witness_name: Option<String>,
}

View File

@@ -2,6 +2,7 @@ use erp_core::sanitize::sanitize_string;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SessionResp {
@@ -15,6 +16,8 @@ pub struct SessionResp {
pub last_message_at: Option<chrono::DateTime<chrono::Utc>>,
pub unread_count_patient: i32,
pub unread_count_doctor: i32,
pub rating: Option<i16>,
pub feedback: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub version: i32,
@@ -28,6 +31,7 @@ pub struct MessageResp {
pub sender_role: String,
pub content_type: String,
pub content: String,
pub media_id: Option<Uuid>,
pub is_read: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
}
@@ -38,6 +42,8 @@ pub struct CreateMessageReq {
pub session_id: Uuid,
pub content_type: Option<String>,
pub content: String,
/// 关联的媒体文件 ID当 content_type 为 image/file/voice 时必填)
pub media_id: Option<Uuid>,
}
impl CreateMessageReq {
@@ -94,3 +100,12 @@ pub struct AiAnalysisTriggeredResp {
pub patient_id: Uuid,
pub analysis_type: String,
}
/// 咨询满意度评价请求体
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct RateSessionReq {
#[validate(range(min = 1, max = 5))]
pub rating: i16,
#[validate(length(max = 500))]
pub feedback: Option<String>,
}

View File

@@ -3,6 +3,7 @@ use erp_core::sanitize::{sanitize_option, sanitize_string, strip_html_tags};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreatePatientReq {
@@ -175,3 +176,70 @@ pub struct FamilyHealthSummaryResp {
pub recent_alerts_count: i64,
pub next_appointment: Option<serde_json::Value>,
}
// ---------------------------------------------------------------------------
// 批量导入 DTO
// ---------------------------------------------------------------------------
/// 批量导入患者请求体
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct BatchImportPatientReq {
#[validate(length(min = 1, max = 100))]
pub patients: Vec<CreatePatientReq>,
}
/// 批量导入/操作结果
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchResultResp {
pub succeeded: u32,
pub failed: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<BatchError>,
}
/// 批量操作单项错误
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BatchError {
pub index: usize,
pub message: String,
}
// ---------------------------------------------------------------------------
// 患者自助绑定 DTO
// ---------------------------------------------------------------------------
/// 患者通过手机号自助绑定请求体
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct BindByPhoneReq {
#[validate(length(min = 1))]
pub phone: String,
#[validate(length(min = 1))]
pub verification_code: String,
}
/// 绑定结果响应
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct BindResultResp {
pub patient_id: Uuid,
pub patient_name: String,
}
// ---------------------------------------------------------------------------
// 患者转诊 DTO
// ---------------------------------------------------------------------------
/// 患者转诊请求体
#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)]
pub struct ReferPatientReq {
pub to_doctor_id: Uuid,
#[validate(length(min = 1, max = 500))]
pub reason: String,
}
/// 转诊结果响应
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ReferResultResp {
pub patient_id: Uuid,
pub from_doctor_id: Option<Uuid>,
pub to_doctor_id: Uuid,
}

View File

@@ -12,6 +12,7 @@ pub struct Model {
pub sender_role: String,
pub content_type: String,
pub content: String,
pub media_id: Option<Uuid>,
pub is_read: bool,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,

View File

@@ -16,6 +16,10 @@ pub struct Model {
pub last_message_at: Option<DateTimeUtc>,
pub unread_count_patient: i32,
pub unread_count_doctor: i32,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub rating: Option<i16>,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub feedback: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(skip_serializing_if = "Option::is_none")]

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 关闭
// 所有过滤订阅的生命周期应与进程一致

View File

@@ -4,6 +4,7 @@ use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use serde::Deserialize;
use validator::Validate;
use crate::dto::consent_dto::*;
use crate::service::consent_service;
@@ -89,3 +90,35 @@ where
.await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 患者端签署知情同意 — 验证 consent 归属当前患者后更新状态为 granted
#[utoipa::path(
post,
path = "/health/consents/{consent_id}/patient-sign",
request_body = PatientSignConsentReq,
responses(
(status = 200, description = "签署成功"),
(status = 400, description = "状态不允许签署或不属于该患者"),
(status = 404, description = "知情同意记录不存在"),
),
tag = "知情同意",
security(("bearer_auth" = [])),
)]
pub async fn patient_sign_consent<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(consent_id): Path<uuid::Uuid>,
Json(req): Json<crate::dto::consent_dto::PatientSignConsentReq>,
) -> Result<Json<ApiResponse<crate::dto::consent_dto::ConsentResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
// 患者自己签署,只需认证,不需要特殊权限
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let result =
consent_service::patient_sign_consent(&state, ctx.tenant_id, ctx.user_id, consent_id, req)
.await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -1,9 +1,10 @@
use axum::Extension;
use axum::extract::{FromRef, Json, Path, Query, State};
use axum::extract::{FromRef, Json, Multipart, Path, Query, State};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
@@ -206,6 +207,7 @@ where
session_id: req.session_id,
content_type: req.content_type,
content: req.content,
media_id: None,
};
msg_req.sanitize();
let result = consultation_service::create_message(
@@ -372,3 +374,133 @@ where
.await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 咨询消息附件上传 — 接收 multipart 文件,调用媒体库上传,返回 media_id。
/// 前端先调用此端点上传文件获得 media_id再通过 create_message 发送消息。
#[utoipa::path(
post,
path = "/consultation-messages/attachment",
responses(
(status = 200, description = "附件上传成功"),
(status = 400, description = "文件无效"),
),
tag = "咨询管理",
)]
pub async fn upload_message_attachment<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
// 文件大小限制: 10MB
const MAX_UPLOAD_SIZE: usize = 10 * 1024 * 1024;
// 允许的 MIME 类型(咨询场景)
const ALLOWED_CONSULTATION_MIME_TYPES: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/webm",
];
let mut file_data = None;
let mut original_name = String::new();
let mut content_type = String::new();
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::Validation(format!("读取上传数据失败: {}", e)))?
{
if field.name().unwrap_or("") == "file" {
original_name = field.file_name().unwrap_or("file").to_string();
content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// MIME 类型白名单校验
if !ALLOWED_CONSULTATION_MIME_TYPES.contains(&content_type.as_str()) {
return Err(AppError::Validation(format!(
"不支持的文件类型: {}(允许: {}",
content_type,
ALLOWED_CONSULTATION_MIME_TYPES.join(", ")
)));
}
let data = field
.bytes()
.await
.map_err(|e| AppError::Validation(format!("读取文件数据失败: {}", e)))?;
if data.len() > MAX_UPLOAD_SIZE {
return Err(AppError::Validation(format!(
"文件大小超过限制 (最大 {}MB)",
MAX_UPLOAD_SIZE / 1024 / 1024
)));
}
file_data = Some(data);
}
}
let data = file_data.ok_or_else(|| AppError::Validation("未找到上传文件".to_string()))?;
let upload_dir = std::env::var("UPLOAD_DIR").unwrap_or_else(|_| "./uploads".to_string());
let result = crate::service::media_service::upload_media(
&state,
ctx.tenant_id,
Some(ctx.user_id),
&data,
&original_name,
&content_type,
None, // 不指定文件夹
false, // 咨询附件默认不公开
&upload_dir,
)
.await?;
Ok(Json(ApiResponse::ok(serde_json::json!({
"media_id": result.id,
"filename": result.filename,
"content_type": result.content_type,
"file_size": result.file_size,
}))))
}
/// 咨询满意度评价 — 只有已关闭会话的患者可以评价
#[utoipa::path(
post,
path = "/consultation-sessions/{id}/rate",
request_body = RateSessionReq,
responses(
(status = 200, description = "评价成功"),
(status = 400, description = "会话未关闭或不属于该患者"),
(status = 404, description = "会话不存在"),
),
tag = "咨询管理",
security(("bearer_auth" = [])),
)]
pub async fn rate_session<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<RateSessionReq>,
) -> Result<Json<ApiResponse<SessionResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let result =
consultation_service::rate_session(&state, ctx.tenant_id, id, ctx.user_id, req).await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -11,8 +11,9 @@ use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::DeleteWithVersion;
use crate::dto::patient_dto::{
CreatePatientReq, FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp,
UpdatePatientReq,
BatchImportPatientReq, BatchResultResp, BindByPhoneReq, BindResultResp, CreatePatientReq,
FamilyMemberReq, FamilyMemberResp, ManageTagsReq, PatientResp, ReferPatientReq,
ReferResultResp, UpdatePatientReq,
};
use crate::service::patient_service;
use crate::state::HealthState;
@@ -448,3 +449,90 @@ where
patient_service::delete_tag(&state, ctx.tenant_id, id, Some(ctx.user_id), req.version).await?;
Ok(Json(ApiResponse::ok(())))
}
/// 批量导入患者
#[utoipa::path(
post,
path = "/health/patients/import",
request_body = BatchImportPatientReq,
responses(
(status = 200, description = "批量导入结果"),
),
tag = "患者管理",
security(("bearer_auth" = [])),
)]
pub async fn batch_import_patients<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BatchImportPatientReq>,
) -> Result<Json<ApiResponse<BatchResultResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let result =
patient_service::batch_import_patients(&state, ctx.tenant_id, Some(ctx.user_id), req)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 患者通过手机号自助绑定
#[utoipa::path(
post,
path = "/health/patients/bind-by-phone",
request_body = BindByPhoneReq,
responses(
(status = 200, description = "绑定成功"),
(status = 404, description = "未找到匹配患者"),
),
tag = "患者管理",
security(("bearer_auth" = [])),
)]
pub async fn bind_by_phone<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BindByPhoneReq>,
) -> Result<Json<ApiResponse<BindResultResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
// 患者自己绑定,只需认证,不需要特殊权限
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let result = patient_service::bind_by_phone(&state, ctx.tenant_id, ctx.user_id, req).await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 患者转诊
#[utoipa::path(
post,
path = "/health/patients/{id}/refer",
request_body = ReferPatientReq,
responses(
(status = 200, description = "转诊成功"),
(status = 404, description = "患者或医生不存在"),
),
tag = "患者管理",
security(("bearer_auth" = [])),
)]
pub async fn refer_patient<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<ReferPatientReq>,
) -> Result<Json<ApiResponse<ReferResultResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let result =
patient_service::refer_patient(&state, ctx.tenant_id, id, req, Some(ctx.user_id)).await?;
Ok(Json(ApiResponse::ok(result)))
}

View File

@@ -83,4 +83,8 @@ where
"/health/consents/{consent_id}/revoke",
axum::routing::put(consent_handler::revoke_consent),
)
.route(
"/health/consents/{consent_id}/patient-sign",
axum::routing::post(consent_handler::patient_sign_consent),
)
}

View File

@@ -47,10 +47,18 @@ where
"/health/consultation-sessions/{id}/ai-analysis",
axum::routing::post(consultation_handler::trigger_ai_analysis_from_session),
)
.route(
"/health/consultation-sessions/{id}/rate",
axum::routing::post(consultation_handler::rate_session),
)
.route(
"/health/consultation-messages",
axum::routing::post(consultation_handler::create_message),
)
.route(
"/health/consultation-messages/attachment",
axum::routing::post(consultation_handler::upload_message_attachment),
)
// 医生仪表盘
.route(
"/health/doctor/dashboard",

View File

@@ -53,6 +53,21 @@ where
"/health/patients/{id}/doctors/{did}",
axum::routing::delete(patient_handler::remove_doctor),
)
// 批量导入患者
.route(
"/health/patients/import",
axum::routing::post(patient_handler::batch_import_patients),
)
// 患者自助绑定
.route(
"/health/patients/bind-by-phone",
axum::routing::post(patient_handler::bind_by_phone),
)
// 患者转诊
.route(
"/health/patients/{id}/refer",
axum::routing::post(patient_handler::refer_patient),
)
// 家庭成员健康代理 — 管理端
.route(
"/health/patients/{patient_id}/family-members/{family_member_id}/grant-access",

View File

@@ -222,3 +222,96 @@ fn validate_consent_type(consent_type: &str) -> HealthResult<()> {
)))
}
}
/// 患者端签署知情同意 — 验证 consent 归属该患者后更新状态为 granted
pub async fn patient_sign_consent(
state: &HealthState,
tenant_id: Uuid,
patient_user_id: Uuid,
consent_id: Uuid,
req: crate::dto::consent_dto::PatientSignConsentReq,
) -> HealthResult<ConsentResp> {
tracing::info!(action = "patient_sign_consent", consent_id = %consent_id, user_id = %patient_user_id, "Patient signing consent");
let model = consent::Entity::find()
.filter(consent::Column::Id.eq(consent_id))
.filter(consent::Column::TenantId.eq(tenant_id))
.filter(consent::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::ConsentNotFound)?;
// 验证 consent 归属该患者(通过 user_id 查找 patient
let patient_model = patient::Entity::find()
.filter(patient::Column::UserId.eq(patient_user_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or_else(|| HealthError::Validation("当前用户无关联患者档案".to_string()))?;
if model.patient_id != patient_model.id {
return Err(HealthError::Validation(
"该知情同意记录不属于当前患者".to_string(),
));
}
// 验证当前状态允许签署pending 或 revoked 才能签署)
if model.status != "pending" && model.status != "revoked" {
return Err(HealthError::Validation(format!(
"当前状态 '{}' 不允许签署,仅 pending/revoked 状态可签署",
model.status
)));
}
let now = Utc::now();
let mut active: consent::ActiveModel = model.into();
active.status = Set("granted".to_string());
active.granted_at = Set(Some(now));
active.consent_method = Set(Some(req.consent_method));
active.witness_name = Set(req.witness_name);
active.updated_at = Set(now);
active.updated_by = Set(Some(patient_user_id));
let updated = active.update(&state.db).await?;
// 发布知情同意签署事件
let event = DomainEvent::new(
crate::event::CONSENT_GRANTED,
tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"consent_id": consent_id.to_string(),
"patient_id": patient_model.id.to_string(),
"consent_type": updated.consent_type,
})),
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(patient_user_id),
"consent.patient_signed",
"consent",
)
.with_resource_id(consent_id),
&state.db,
)
.await;
Ok(ConsentResp {
id: updated.id,
patient_id: updated.patient_id,
consent_type: updated.consent_type,
consent_scope: updated.consent_scope,
status: updated.status,
granted_at: updated.granted_at,
revoked_at: updated.revoked_at,
expiry_date: updated.expiry_date,
consent_method: updated.consent_method,
witness_name: updated.witness_name,
notes: updated.notes,
created_at: updated.created_at,
updated_at: updated.updated_at,
version: updated.version,
})
}

View File

@@ -36,6 +36,8 @@ fn model_to_session_resp(m: consultation_session::Model) -> SessionResp {
last_message_at: m.last_message_at,
unread_count_patient: m.unread_count_patient,
unread_count_doctor: m.unread_count_doctor,
rating: m.rating,
feedback: m.feedback,
created_at: m.created_at,
updated_at: m.updated_at,
version: m.version,
@@ -100,6 +102,8 @@ pub async fn create_session(
last_message_at: Set(None),
unread_count_patient: Set(0),
unread_count_doctor: Set(0),
rating: Set(None),
feedback: Set(None),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
@@ -442,6 +446,7 @@ pub async fn list_messages(
sender_role: m.sender_role,
content_type: m.content_type,
content,
media_id: m.media_id,
is_read: m.is_read,
created_at: m.created_at,
}
@@ -544,6 +549,34 @@ pub async fn create_message(
let is_patient = sender_role == "patient";
let should_activate = session.status == "waiting";
// 文件类型消息校验 media_idimage/file/voice 需关联媒体库文件
let media_id = match content_type.as_str() {
"image" | "file" | "voice" => {
let mid = req.media_id.ok_or_else(|| {
HealthError::Validation(format!(
"content_type 为 '{}' 时必须提供 media_id关联已上传的媒体文件",
content_type
))
})?;
// 验证 media_item 存在且属于当前租户
use crate::entity::media_item;
let exists = media_item::Entity::find()
.filter(media_item::Column::Id.eq(mid))
.filter(media_item::Column::TenantId.eq(tenant_id))
.filter(media_item::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.is_some();
if !exists {
return Err(HealthError::Validation(
"关联的媒体文件不存在或已删除".to_string(),
));
}
Some(mid)
}
_ => None,
};
// 事务包裹:消息 INSERT + 会话 CAS 更新,保证原子性
let txn = state.db.begin().await?;
@@ -556,6 +589,7 @@ pub async fn create_message(
sender_role: Set(sender_role),
content_type: Set(content_type),
content: Set(pii::encrypt(state.crypto.kek(), &req.content)?),
media_id: Set(media_id),
is_read: Set(false),
created_at: Set(now),
updated_at: Set(now),
@@ -655,6 +689,7 @@ pub async fn create_message(
sender_role: m.sender_role,
content_type: m.content_type,
content: decrypted_content,
media_id: m.media_id,
is_read: m.is_read,
created_at: m.created_at,
})
@@ -1020,3 +1055,68 @@ pub async fn trigger_ai_analysis_from_session(
analysis_type,
})
}
/// 咨询满意度评价 — 只有已关闭的会话可以被患者评价
pub async fn rate_session(
state: &HealthState,
tenant_id: Uuid,
session_id: Uuid,
patient_user_id: Uuid,
req: RateSessionReq,
) -> HealthResult<SessionResp> {
tracing::info!(action = "rate_session", session_id = %session_id, rating = req.rating, "Rating consultation session");
let model = consultation_session::Entity::find()
.filter(consultation_session::Column::Id.eq(session_id))
.filter(consultation_session::Column::TenantId.eq(tenant_id))
.filter(consultation_session::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::ConsultationNotFound)?;
// 校验会话已关闭
if model.status != "closed" {
return Err(HealthError::Validation("只能评价已关闭的会话".to_string()));
}
// 校验评价者是会话的患者
let patient_model = patient::Entity::find()
.filter(patient::Column::UserId.eq(patient_user_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?;
let is_patient = patient_model
.as_ref()
.map(|p| p.id == model.patient_id)
.unwrap_or(false);
if !is_patient {
return Err(HealthError::Validation(
"只有会话的患者可以评价".to_string(),
));
}
// 更新 rating + feedback
let mut active: consultation_session::ActiveModel = model.into();
active.rating = Set(Some(req.rating));
active.feedback = Set(req.feedback);
active.updated_at = Set(Utc::now());
active.updated_by = Set(Some(patient_user_id));
let updated = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(
tenant_id,
Some(patient_user_id),
"consultation.rated",
"consultation_session",
)
.with_resource_id(session_id),
&state.db,
)
.await;
Ok(model_to_session_resp(updated))
}

View File

@@ -440,3 +440,113 @@ pub async fn delete_patient(
Ok(())
}
/// 批量导入患者 — 逐条校验 + PII 加密 + HMAC 盲索引去重(身份证号已存在则跳过)
pub async fn batch_import_patients(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: BatchImportPatientReq,
) -> HealthResult<BatchResultResp> {
tracing::info!(action = "batch_import_patients", tenant_id = %tenant_id, count = req.patients.len(), "Batch importing patients");
let mut succeeded: u32 = 0;
let mut failed: u32 = 0;
let mut errors: Vec<crate::dto::patient_dto::BatchError> = Vec::new();
for (idx, mut patient_req) in req.patients.into_iter().enumerate() {
patient_req.sanitize();
if patient_req.name.trim().is_empty() {
failed += 1;
errors.push(crate::dto::patient_dto::BatchError {
index: idx,
message: "患者姓名不能为空".to_string(),
});
continue;
}
if patient_req.name.len() > 255 {
failed += 1;
errors.push(crate::dto::patient_dto::BatchError {
index: idx,
message: "患者姓名长度不能超过255个字符".to_string(),
});
continue;
}
if let Some(ref bd) = patient_req.birth_date
&& *bd > chrono::Utc::now().date_naive()
{
failed += 1;
errors.push(crate::dto::patient_dto::BatchError {
index: idx,
message: "出生日期不能是未来日期".to_string(),
});
continue;
}
match create_patient(state, tenant_id, operator_id, patient_req).await {
Ok(_) => succeeded += 1,
Err(e) => {
failed += 1;
errors.push(crate::dto::patient_dto::BatchError {
index: idx,
message: e.to_string(),
});
}
}
}
tracing::info!(action = "batch_import_patients", tenant_id = %tenant_id, succeeded, failed, "Batch import completed");
Ok(BatchResultResp {
succeeded,
failed,
errors,
})
}
/// 患者通过手机号自助绑定 — HMAC 查找盲索引,匹配后更新 user_id
pub async fn bind_by_phone(
state: &HealthState,
tenant_id: Uuid,
user_id: Uuid,
req: BindByPhoneReq,
) -> HealthResult<BindResultResp> {
tracing::info!(action = "bind_by_phone", tenant_id = %tenant_id, user_id = %user_id, "Patient binding by phone");
let phone_hash = erp_core::crypto::hmac_hash(state.crypto.hmac_key(), &req.phone);
// 在盲索引表中查找匹配 phone_hash 的患者emergency_contact_phone 字段)
let blind_index = crate::entity::blind_index::Entity::find()
.filter(crate::entity::blind_index::Column::TenantId.eq(tenant_id))
.filter(crate::entity::blind_index::Column::EntityType.eq("patient"))
.filter(crate::entity::blind_index::Column::FieldName.eq("emergency_contact_phone"))
.filter(crate::entity::blind_index::Column::BlindHash.eq(phone_hash.as_str()))
.one(&state.db)
.await?
.ok_or_else(|| HealthError::Validation("未找到匹配该手机号的患者档案".to_string()))?;
let patient_model = find_patient(&state.db, tenant_id, blind_index.entity_id).await?;
if patient_model.user_id.is_some() {
return Err(HealthError::Validation("该患者已绑定其他账号".to_string()));
}
// 更新 patient.user_id
let mut active: patient::ActiveModel = patient_model.into();
active.user_id = Set(Some(user_id));
active.updated_at = Set(Utc::now());
active.updated_by = Set(Some(user_id));
let updated = active.update(&state.db).await?;
audit_service::record(
AuditLog::new(tenant_id, Some(user_id), "patient.bind_by_phone", "patient")
.with_resource_id(updated.id),
&state.db,
)
.await;
Ok(BindResultResp {
patient_id: updated.id,
patient_name: updated.name,
})
}

View File

@@ -12,10 +12,13 @@ mod relation;
mod tag;
// 从各子模块重新导出所有公开函数,保持 handler 层调用路径不变
pub use crud::{create_patient, delete_patient, get_patient, list_patients, update_patient};
pub use crud::{
batch_import_patients, bind_by_phone, create_patient, delete_patient, get_patient,
list_patients, update_patient,
};
pub use relation::{
assign_doctor, create_family_member, delete_family_member, get_health_summary,
list_family_members, manage_patient_tags, remove_doctor, update_family_member,
list_family_members, manage_patient_tags, refer_patient, remove_doctor, update_family_member,
};
pub use tag::{CreateTagReq, TagResp, UpdateTagReq};
pub use tag::{create_tag, delete_tag, list_tags, update_tag};

View File

@@ -21,6 +21,7 @@ use crate::service::masking::mask_phone;
use crate::state::HealthState;
use super::helper::find_patient;
use erp_core::events::DomainEvent;
// ---------------------------------------------------------------------------
// 标签管理(患者关联)
@@ -547,3 +548,90 @@ pub async fn remove_doctor(
Ok(())
}
/// 患者转诊 — 将当前主治医生改为 referral_from目标医生设为新主治
pub async fn refer_patient(
state: &HealthState,
tenant_id: Uuid,
patient_id: Uuid,
req: ReferPatientReq,
operator_id: Option<Uuid>,
) -> HealthResult<ReferResultResp> {
tracing::info!(action = "refer_patient", patient_id = %patient_id, to_doctor = %req.to_doctor_id, "Referring patient");
find_patient(&state.db, tenant_id, patient_id).await?;
// 验证目标医生存在
doctor_profile::Entity::find()
.filter(doctor_profile::Column::Id.eq(req.to_doctor_id))
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
.filter(doctor_profile::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::DoctorNotFound)?;
// 查找当前主治医生关系
let current_attending = patient_doctor_relation::Entity::find()
.filter(patient_doctor_relation::Column::TenantId.eq(tenant_id))
.filter(patient_doctor_relation::Column::PatientId.eq(patient_id))
.filter(patient_doctor_relation::Column::RelationshipType.eq("attending"))
.filter(patient_doctor_relation::Column::DeletedAt.is_null())
.one(&state.db)
.await?;
let from_doctor_id = current_attending.as_ref().map(|m| m.doctor_id);
let now = Utc::now();
// 将当前主治关系更新为 referral_from
if let Some(model) = current_attending {
let mut active: patient_doctor_relation::ActiveModel = model.into();
active.relationship_type = Set("referral_from".to_string());
active.updated_at = Set(now);
active.updated_by = Set(operator_id);
active.version = Set(active.version.take().unwrap_or(0) + 1);
active.update(&state.db).await?;
}
// 创建新的主治关系到目标医生
let new_relation = patient_doctor_relation::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(patient_id),
doctor_id: Set(req.to_doctor_id),
relationship_type: Set("attending".to_string()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),
updated_by: Set(operator_id),
deleted_at: Set(None),
version: Set(1),
};
new_relation.insert(&state.db).await?;
// 发布转诊通知事件
let event = DomainEvent::new(
"patient.referred",
tenant_id,
erp_core::events::build_event_payload(serde_json::json!({
"patient_id": patient_id.to_string(),
"from_doctor_id": from_doctor_id.map(|d| d.to_string()),
"to_doctor_id": req.to_doctor_id.to_string(),
"reason": req.reason,
})),
);
state.event_bus.publish(event, &state.db).await;
audit_service::record(
AuditLog::new(tenant_id, operator_id, "patient.referred", "patient")
.with_resource_id(patient_id),
&state.db,
)
.await;
Ok(ReferResultResp {
patient_id,
from_doctor_id,
to_doctor_id: req.to_doctor_id,
})
}

View File

@@ -271,6 +271,36 @@ pub fn validate_condition_type(value: &str) -> HealthResult<()> {
Ok(())
}
/// patient.source
pub fn validate_source(value: &str) -> HealthResult<()> {
validate_enum!(
value,
"source",
[
"manual_import",
"health_check",
"wechat",
"referral",
"community",
"device_auto",
"system",
]
);
Ok(())
}
/// patient_family_member.relationship
pub fn validate_relationship(value: &str) -> HealthResult<()> {
validate_enum!(
value,
"relationship",
[
"spouse", "parent", "child", "sibling", "other", "self", "guardian"
]
);
Ok(())
}
/// alert.severity
pub fn validate_alert_severity(value: &str) -> HealthResult<()> {
validate_enum!(
@@ -883,6 +913,58 @@ mod tests {
}
}
// --- source ---
#[test]
fn source_manual_import() {
assert!(validate_source("manual_import").is_ok());
}
#[test]
fn source_wechat() {
assert!(validate_source("wechat").is_ok());
}
#[test]
fn source_referral() {
assert!(validate_source("referral").is_ok());
}
#[test]
fn source_community() {
assert!(validate_source("community").is_ok());
}
#[test]
fn source_system() {
assert!(validate_source("system").is_ok());
}
#[test]
fn source_invalid() {
assert!(validate_source("unknown_source").is_err());
}
// --- relationship ---
#[test]
fn relationship_spouse() {
assert!(validate_relationship("spouse").is_ok());
}
#[test]
fn relationship_parent() {
assert!(validate_relationship("parent").is_ok());
}
#[test]
fn relationship_child() {
assert!(validate_relationship("child").is_ok());
}
#[test]
fn relationship_guardian() {
assert!(validate_relationship("guardian").is_ok());
}
#[test]
fn relationship_self() {
assert!(validate_relationship("self").is_ok());
}
#[test]
fn relationship_invalid() {
assert!(validate_relationship("cousin").is_err());
}
/// 校验状态机定义的初始状态seed 数据可用的第一个状态)必须合法。
/// 防止 seed 数据使用未注册的状态值。
#[test]

View File

@@ -162,6 +162,8 @@ mod m20260520_000157_follow_up_source_and_points_rules;
mod m20260521_000158_alerts_add_source_columns;
mod m20260521_000159_patient_phone_and_consent_seed;
mod m20260521_000160_follow_up_task_template_id_and_record_form_data;
mod m20260521_000161_consultation_media_id_and_suggestion_references;
mod m20260521_000162_consultation_session_rating_feedback;
pub struct Migrator;
@@ -331,6 +333,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260521_000158_alerts_add_source_columns::Migration),
Box::new(m20260521_000159_patient_phone_and_consent_seed::Migration),
Box::new(m20260521_000160_follow_up_task_template_id_and_record_form_data::Migration),
Box::new(m20260521_000161_consultation_media_id_and_suggestion_references::Migration),
Box::new(m20260521_000162_consultation_session_rating_feedback::Migration),
]
}
}

View File

@@ -0,0 +1,65 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[derive(DeriveIden)]
enum ConsultationMessage {
Table,
MediaId,
}
#[derive(DeriveIden)]
enum AiSuggestion {
Table,
References,
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// 咨询消息添加 media_id 字段(关联媒体库文件)
manager
.alter_table(
Table::alter()
.table(ConsultationMessage::Table)
.add_column(ColumnDef::new(ConsultationMessage::MediaId).uuid().null())
.to_owned(),
)
.await?;
// AI 建议添加 references 字段(存储引用来源 ID 数组)
manager
.alter_table(
Table::alter()
.table(AiSuggestion::Table)
.add_column(ColumnDef::new(AiSuggestion::References).json().null())
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(AiSuggestion::Table)
.drop_column(AiSuggestion::References)
.to_owned(),
)
.await?;
manager
.alter_table(
Table::alter()
.table(ConsultationMessage::Table)
.drop_column(ConsultationMessage::MediaId)
.to_owned(),
)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,33 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
ALTER TABLE consultation_session ADD COLUMN IF NOT EXISTS rating SMALLINT;
ALTER TABLE consultation_session ADD COLUMN IF NOT EXISTS feedback TEXT;
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
ALTER TABLE consultation_session DROP COLUMN IF EXISTS rating;
ALTER TABLE consultation_session DROP COLUMN IF EXISTS feedback;
"#,
)
.await?;
Ok(())
}
}

View File

@@ -129,6 +129,7 @@ async fn test_consultation_message_send() {
session_id: session.id,
content_type: Some("text".to_string()),
content: "您好,有什么可以帮您?".to_string(),
media_id: None,
},
)
.await
@@ -161,6 +162,7 @@ async fn test_consultation_message_list() {
session_id: session.id,
content_type: None,
content: format!("消息{}", i + 1),
media_id: None,
},
)
.await

View File

@@ -17,6 +17,7 @@ fn default_create_task(patient_id: uuid::Uuid) -> CreateFollowUpTaskReq {
related_appointment_id: None,
source_type: None,
source_id: None,
template_id: None,
}
}
@@ -330,6 +331,7 @@ async fn test_follow_up_record_create() {
patient_condition: Some("血压正常".to_string()),
medical_advice: Some("继续服药".to_string()),
next_follow_up_date: Some(chrono::NaiveDate::from_ymd_opt(2026, 6, 16).unwrap()),
form_data: None,
},
)
.await

View File

@@ -264,6 +264,7 @@ async fn test_consultation_message_content_encrypted() {
session_id: session.id,
content_type: Some("text".to_string()),
content: plain_content.to_string(),
media_id: None,
},
)
.await
@@ -376,6 +377,7 @@ async fn test_follow_up_record_fields_encrypted() {
related_appointment_id: None,
source_type: None,
source_id: None,
template_id: None,
},
)
.await
@@ -393,6 +395,7 @@ async fn test_follow_up_record_fields_encrypted() {
patient_condition: Some("血压控制良好".to_string()),
medical_advice: Some("继续服药,定期复查".to_string()),
next_follow_up_date: None,
form_data: None,
},
)
.await

View File

@@ -0,0 +1,289 @@
# HMS 全业务流程合理性审核 — 多专家组头脑风暴
> 日期: 2026-05-20 | 参与者: 6 专家组(患者管理/预约排班/健康数据与告警/随访咨询积分/AI内容透析/跨流程整合与合规)
## 1. 背景
HMS 健康管理平台已完成核心功能开发59 业务实体、376+ API 端点、990+ 测试),进入 V1 CONDITIONAL GO 阶段。在投入试运行前,需要从**真实医疗业务场景**角度审视所有业务流程的合理性和完整性,而非仅关注技术实现质量。
核心问题:**这些流程是否真正满足体检中心/血透中心/社区卫生中心的日常运营需求?**
## 2. 审核方法
6 个专家组并行工作每位专家深入阅读实际代码handler/service/dto/前端页面/小程序页面),从以下维度审核:
- 流程完整性(端到端是否闭合)
- 真实场景覆盖度(体检/血透/社区/多科室)
- 业务规则合理性(状态机/并发/权限)
- 缺失功能清单(按 P0/P1/P2 分级)
## 3. 综合评分矩阵
| 业务域 | 评分 | 核心优势 | 最大缺失 |
|--------|------|----------|----------|
| 患者管理 | **7.5** | PII 加密+盲索引+知情同意体系 | 无批量导入、无自助绑定、无转诊流程 |
| 预约排班 | **6.5** | CAS 原子并发控制+状态机 | 无排班模板、无改期、无候补 |
| 健康数据采集 | **7.0** | 双链路摄入+三层降采样+BLE 4 适配器 | 两套告警系统不互通 |
| 告警系统 | **7.0** | 规则引擎 3 条件+降噪+升级机制 | 通知渠道单一(仅站内) |
| 随访管理 | **7.0** | 闭环完整+批量操作+5 种方式 | 模板与任务关联断裂 |
| 咨询管理 | **7.5** | 长轮询+PII 加密+咨询→随访联动 | 缺文件上传流程、缺满意度评价 |
| 积分商城 | **8.0** | FIFO 消费+CAS 防超卖+活动联动 | 积分触发点太少(仅签到) |
| AI 智能分析 | **7.5** | ReAct Agent+9 Tool+角色沙箱 | 缺引用来源、Token 计量不精确 |
| 内容管理 | **8.0** | 审核状态机+媒体库安全+轮播图 | 缺内容推送机制 |
| 透析管理 | **6.5** | 数据字段完整+KDIGO 规则引擎 | 无排位管理、无 Kt/V 计算、无小程序端 |
| 跨流程整合 | **7.5** | 事件驱动+Outbox+幂等消费 | 49% 事件无消费者 |
| 安全合规 | **7.0** | AES-256-GCM+KEK/DEK+哈希链审计 | 知情同意未在数据访问层强制执行 |
| 多角色协作 | **7.5** | 四级数据权限+AI 建议闭环 | 护士角色在事件流中缺失 |
| 异常处理 | **6.5** | Dead-letter+幂等+乐观锁 | Dead-letter 无自动重试 |
**综合评分: 7.1 / 10 (B)**
---
## 4. 各域详细审核结论
### 4.1 患者管理 (7.5/10)
**已实现且合理:**
- PII 加密体系AES-256-GCM + HMAC 盲索引)设计优秀,身份证/过敏史/病史/紧急联系人全覆盖
- 盲索引去重机制防止跨系统重复建档
- 患者与用户账号解耦(先建档后绑定),符合体检中心实际
- 家庭成员管理完善支持多级访问控制summary/full/limited
- 知情同意 6 种类型,授权/撤回完整流程
- 标签系统多对多关系,事务保证一致性
**P0 缺失:**
- **无批量导入** — 体检中心每天 200-500 人,只有单条创建不可接受
- **无患者自助绑定** — 患者无法通过手机号+验证码匹配已有档案
- **无转诊流程** — 只有添加/删除医生关系,无"转诊"语义
**P1 缺失:**
- 患者本人电话号码字段缺失DTO 和 Entity 均无 phone 列)
- source 字段无枚举校验
- 列表搜索不支持电话号码
- 小程序知情同意缺少签署入口
### 4.2 预约排班 (6.5/10)
**已实现且合理:**
- CAS 原子并发控制,事务内 `UPDATE current + 1 WHERE current < max`,取消反向释放
- 预约状态机pending→confirmed→completed/cancelled/no_show完善
- 乐观锁保护(排班/预约均带 version
- 前端排班余量实时显示
**P0 缺失:**
- **无排班模板** — 没有周期性排班能力,必须逐天手动创建
- **无批量排班** — 只有单条创建
- **无改期功能** — 只能取消再重新预约,存在中间态风险
**真实场景覆盖度:**
- 体检套餐预约:未覆盖(模型是单医生+单时段)
- 血透固定排位:未覆盖(无周期性预约)
- 候补排队:未覆盖
- 当天加号:未覆盖
### 4.3 健康数据与告警 (7.0/10)
**已实现且合理:**
- 双链路数据摄入(手动录入 + BLE 设备同步)独立运行
- 三层降采样(原始→小时聚合→日聚合含 P95
- 规则引擎 3 条件类型single_threshold / consecutive / trend
- 告警降噪(患者级升级 + 系统级聚合抑制critical 不抑制
- 危急值阈值差异化配置(科室/年龄维度)
- 告警→随访闭环critical 1 天内、warning 3 天内)
- 告警→咨询智能关联
**最严重问题 — 两套告警系统不互通:**
- 链路一(手动录入)→ `critical_value_threshold``critical_alert`
- 链路二(设备同步)→ `alert_rules``alerts`
- 两张表、两套检测逻辑、两个生命周期
- 设备同步双写 `vital_signs` 后不触发危急值检测
- 手动录入不走规则引擎的 consecutive/trend 评估
### 4.4 随访管理 (7.0/10)
**已实现且合理:**
- 模板字段自定义7 种类型text/number/date/select/checkbox/textarea/scale
- 5 种随访方式phone/outpatient/home_visit/online/wechat
- 批量操作(批量创建/分配/完成,上限 100
- 闭环完整:创建→执行→完成→自动创建后续→逾期催办
- 随访记录 PII 加密
- 工作流事件驱动自动完成
**核心断裂 — 模板与任务关联断裂:**
- `follow_up_task``template_id` 外键
- `follow_up_record` 无 JSONB 字段存储结构化表单答案
- 模板定义了字段但任务/记录无法使用
- 缺少周期性随访计划规则
### 4.5 咨询管理 (7.5/10)
**已实现且合理:**
- 会话生命周期waiting→active→closed完整
- 长轮询 + EventBus 混合模式
- 消息 PII 加密
- 双端未读计数(独立计数 CAS 更新)
- 咨询→随访联动 + 咨询→AI 分析联动
- sender_role 服务端推导(不信任客户端)
- 医生仪表盘 7 项指标聚合
**缺失:**
- 消息附件上传流程未实现DTO 支持 image/file/voice 但无上传链路)
- 满意度评价缺失
- 多人会诊不支持
- 咨询转介不支持
### 4.6 积分商城 (8.0/10)
**已实现且合理:**
- FIFO 积分消费(最早到期的先扣)
- CAS 防超卖(商品库存 + 积分余额)
- 阶梯签到奖励7/14/30 天)
- 订单核销QR 码 + 扫码验证)
- 线下活动联动(报名+签到+自动积分发放)
- 积分过期清理(每 24h
**核心缺失 — 积分触发点太少:**
- `earn_points` 通用方法已就绪但只在 `daily_checkin` 调用
- 缺少:上报体征→积分、完成随访→积分、上传化验→积分
- 这是"积分激励持续上报数据"核心价值的前提
### 4.7 AI 智能分析 (7.5/10)
**已实现且合理:**
- ReAct Agent + 9 Tool + 角色沙箱
- 多 Provider + fallback chainclaude→openai→ollama
- 配额管理(租户月 Token + 患者日分析次数)
- 事件驱动自动分析化验→解读、告警→趋势、透析→KDIGO
- 知识库上下文注入
- AI 建议→审批→执行→反馈闭环
**缺失:**
- AI 输出无临床引用来源标注
- 药物相互作用检查 Tool 缺失
- Token 用量估算不精确SSE 模式 `len/4` 估算,输入记 0
- Ollama FC 降级丢失 Tool 能力
### 4.8 内容管理 (8.0/10)
**已实现且合理:**
- 审核状态机draft→pending_review→published/rejected完善
- 权限分离(编辑 vs 审核)
- 媒体库安全MIME 白名单 + 路径遍历防护 + 10MB 限制)
- 缩略图自动生成 + 手动裁剪
- 轮播图公开端点 + 时间范围过滤
**核心断裂 — 无内容推送机制:**
- `article.published` 事件已发布但无消费者
- 无法自动将文章推送给标签匹配的患者
### 4.9 透析管理 (6.5/10)
**已实现且合理:**
- 数据字段完整(透前/透后体重、血压、心率、超滤量、血流量、时长、症状)
- 透析处方管理(透析器、膜面积、透析液配方、抗凝剂、血管通路)
- KDIGO 风险评估 12 条规则 + CKD 分期
- 统计报表完整
**P0 缺失:**
- **无排位管理** — 血透中心运营核心能力完全缺失
- **Kt/V 和 URR 不自动计算** — 透析充分性指标需手动输入
- **小程序端零入口** — 患者无法查看自己的透析记录
- **透析记录与处方无关联** — 无法追溯"这次透析按哪个处方执行"
- **透析记录与预约无整合** — 透析预约未走统一排班系统
---
## 5. 全系统 TOP 20 改进建议
### P0 — 影响核心业务可用性(建议 V1 前完成)
| # | 建议 | 域 | 工时 | 影响 |
|---|------|-----|------|------|
| 1 | **统一两套告警系统** — 合并 `critical_alert``alerts` 为单一管线 | 告警 | 5d | 消除告警漏报风险 |
| 2 | **患者批量导入** — CSV/JSON 上传 + 异步处理 + 去重合并 | 患者 | 3d | 体检中心基本需求 |
| 3 | **患者自助绑定** — 手机号+验证码匹配已有档案 | 患者 | 2d | 患者端核心链路 |
| 4 | **排班模板+批量排班** — 周期性模板 + 批量创建 + 复制到下周 | 排班 | 5d | 排班管理基本需求 |
| 5 | **透析排位管理** — 床位/机位 + 固定/临时排位 + 周期性分配 | 透析 | 5d | 血透中心运营核心 |
| 6 | **知情同意数据访问拦截** — service 层校验 consent 状态 | 合规 | 3d | 医疗合规底线 |
| 7 | **随访模板关联** — task 增加 template_id + record 增加 form_data JSONB | 随访 | 3d | 随访表单核心能力 |
| 8 | **积分触发扩展** — 上报体征/完成随访/上传化验 均触发积分 | 积分 | 2d | 积分激励价值前提 |
### P1 — 影响日常使用效率(建议 V1.1 完成)
| # | 建议 | 域 | 工时 |
|---|------|-----|------|
| 9 | **预约改期+去重+候补** — 原子改期 + 重复预约检测 + 候补队列 | 排班 | 3d |
| 10 | **咨询文件上传** — 集成媒体库,支持图片/语音/文件消息 | 咨询 | 2d |
| 11 | **Kt/V + URR 自动计算** — 后端从 BUN 自动计算充分性指标 | 透析 | 3d |
| 12 | **透析小程序端** — 患者查看记录/趋势,医护查看排位/审阅 | 透析 | 5d |
| 13 | **Dead-letter 自动重试** — 后台任务定期扫描 + 指数退避 | 可靠性 | 2d |
| 14 | **内容推送闭环** — article.published → 匹配 patient_tag → 推送 | 内容 | 3d |
| 15 | **患者电话号码+搜索** — Entity 新增 phone + 盲索引 + 搜索支持 | 患者 | 2d |
### P2 — 影响特定场景(建议 V2 完成)
| # | 建议 | 域 | 工时 |
|---|------|-----|------|
| 16 | **AI 引用来源标注** — 知识库注入后要求 LLM 标注 reference_id | AI | 4d |
| 17 | **告警多渠道通知** — critical 级别增加微信模板消息+短信 | 告警 | 3d |
| 18 | **生产 KEK 防护** — dev_default 添加编译守卫,运行时检测 | 安全 | 0.5d |
| 19 | **满意度评价** — 咨询关闭后 1-5 星 + 文字评价 | 咨询 | 2d |
| 20 | **护理计划事件消费者** — care_plan 激活→通知医护+积分 | 协作 | 2d |
---
## 6. 三个核心架构问题
### 问题一:两套告警系统并行(影响最大)
**现状:** 手动录入走 `critical_value_threshold``critical_alert`,设备同步走 `alert_rules``alerts`。两套检测逻辑、两张表、两个生命周期。
**风险:** 设备同步的血压 200mmHg 可能不触发任何告警(如果没配 alert_rules医护需要看两个告警列表。
**建议:** 统一为单一告警管线所有数据摄入点先做危急值阈值检测fast path再做规则引擎评估slow path输出到统一的 `alerts` 表。
### 问题二49% 事件无业务消费者
**现状:** 51 个事件中 25 个为 FIRE-AND-FORGETauth/config/workflow 模块的 17 个事件完全没有消费者。care_plan/care_action 4 个事件已定义常量但无消费者。
**风险:** 用户删除后相关数据不清理、角色权限变更后缓存不失效、护理计划激活无后续动作。
**建议:** 按影响分批升级。P0`appointment.cancelled`(号源回补)、`consent.revoked`数据阻断。P1`user.deleted`/`role.*`缓存失效。P2care_plan/care_action。
### 问题三:知情同意未在数据访问层强制执行
**现状:** `consent_service` 实现了同意的 CRUD但其他 service 在返回患者数据时不检查同意状态。撤回同意后AI 分析、统计、导出仍可访问该患者数据。
**风险:** 违反《个人信息保护法》"单独同意"要求和医疗数据使用授权原则。
**建议:**`patient_service`/`health_data_service`/`lab_report_service` 的查询层添加 consent 状态校验,`consent.revoked` 后阻断敏感数据访问。
---
## 7. 结论与行动计划
### 综合评估
HMS 平台的**技术实现质量较高**CAS 并发控制、PII 加密、事件 Outbox、审计哈希链但**业务流程覆盖深度不足**,尤其在面向真实医疗场景时暴露了多个核心流程断裂。
**整体评分7.1/10 (B)**
-**做得好的:** 安全基础设施、事件驱动架构、数据加密、并发控制
-**做得不够的:** 透析排位、批量导入、排班模板、告警统一、内容推送、知情同意执行
### 行动建议
**阶段一V1 上线前P0约 28 人天)**
- 统一告警系统5d+ 知情同意拦截3d+ Dead-letter 重试2d= 可靠性 10d
- 患者批量导入3d+ 自助绑定2d+ 电话号码2d= 患者 7d
- 排班模板5d= 排班 5d
- 积分触发扩展2d+ 随访模板关联3d+ 内容推送1d= 流程闭环 6d
**阶段二V1.1P1约 28 人天)**
- 透析排位5d+ Kt/V 自动计算3d+ 透析小程序5d= 透析 13d
- 预约改期+候补3d+ 咨询文件上传2d+ 满意度2d= 交互 7d
- AI 引用来源4d+ 告警多渠道3d+ 生产 KEK 防护0.5d= 增强 7.5d
**阶段三V2 持续优化P2**
- 周期性随访计划、多人会诊、药物交互检查、SpO2/体温监测入口、设备心跳保活等