From 41a865cf68dd12f134d2dd0c2abc88ecbc87ebf1 Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 21 May 2026 01:34:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(health+core+ai):=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=85=A8=E9=9D=A2=E4=BF=AE=E5=A4=8D=20Phase?= =?UTF-8?q?=204-6=20+=20=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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域审核报告) --- Cargo.lock | 7 + Cargo.toml | 1 + crates/erp-ai/Cargo.toml | 1 + crates/erp-ai/src/entity/ai_suggestion.rs | 1 + crates/erp-ai/src/service/analysis.rs | 25 +- crates/erp-ai/src/service/output_parser.rs | 66 ++++ crates/erp-ai/src/service/post_process.rs | 15 +- crates/erp-ai/src/service/suggestion.rs | 3 + crates/erp-core/src/audit_service.rs | 92 +++++- crates/erp-core/src/events.rs | 117 ++++++- crates/erp-health/src/dto/consent_dto.rs | 9 + crates/erp-health/src/dto/consultation_dto.rs | 15 + crates/erp-health/src/dto/patient_dto.rs | 68 +++++ .../src/entity/consultation_message.rs | 1 + .../src/entity/consultation_session.rs | 4 + crates/erp-health/src/event/article.rs | 235 ++++++++++++++ crates/erp-health/src/event/care_plan.rs | 117 +++++++ crates/erp-health/src/event/mod.rs | 2 + .../erp-health/src/handler/consent_handler.rs | 33 ++ .../src/handler/consultation_handler.rs | 134 +++++++- .../erp-health/src/handler/patient_handler.rs | 92 +++++- crates/erp-health/src/routes/care.rs | 4 + crates/erp-health/src/routes/consultation.rs | 8 + crates/erp-health/src/routes/patient.rs | 15 + .../erp-health/src/service/consent_service.rs | 93 ++++++ .../src/service/consultation_service.rs | 100 ++++++ .../src/service/patient_service/crud.rs | 110 +++++++ .../src/service/patient_service/mod.rs | 7 +- .../src/service/patient_service/relation.rs | 88 ++++++ crates/erp-health/src/service/validation.rs | 82 +++++ crates/erp-server/migration/src/lib.rs | 4 + ...tion_media_id_and_suggestion_references.rs | 65 ++++ ...62_consultation_session_rating_feedback.rs | 33 ++ .../integration/health_consultation_tests.rs | 2 + .../integration/health_follow_up_tests.rs | 2 + .../health_pii_encryption_tests.rs | 3 + .../2026-05-20-business-process-brainstorm.md | 289 ++++++++++++++++++ 37 files changed, 1929 insertions(+), 14 deletions(-) create mode 100644 crates/erp-health/src/event/article.rs create mode 100644 crates/erp-health/src/event/care_plan.rs create mode 100644 crates/erp-server/migration/src/m20260521_000161_consultation_media_id_and_suggestion_references.rs create mode 100644 crates/erp-server/migration/src/m20260521_000162_consultation_session_rating_feedback.rs create mode 100644 docs/discussions/2026-05-20-business-process-brainstorm.md diff --git a/Cargo.lock b/Cargo.lock index adf5ef9..41b839b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 4a85b6c..415ded2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/erp-ai/Cargo.toml b/crates/erp-ai/Cargo.toml index 3bf8ad9..0648253 100644 --- a/crates/erp-ai/Cargo.toml +++ b/crates/erp-ai/Cargo.toml @@ -25,3 +25,4 @@ dashmap.workspace = true sha2.workspace = true redis.workspace = true hex.workspace = true +regex-lite.workspace = true diff --git a/crates/erp-ai/src/entity/ai_suggestion.rs b/crates/erp-ai/src/entity/ai_suggestion.rs index c63842b..aba12dd 100644 --- a/crates/erp-ai/src/entity/ai_suggestion.rs +++ b/crates/erp-ai/src/entity/ai_suggestion.rs @@ -15,6 +15,7 @@ pub struct Model { pub workflow_instance_id: Option, pub action_result: Option, pub baseline_snapshot: Option, + pub references: Option, pub reanalysis_id: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, diff --git a/crates/erp-ai/src/service/analysis.rs b/crates/erp-ai/src/service/analysis.rs index 36f7e21..63b948c 100644 --- a/crates/erp-ai/src/service/analysis.rs +++ b/crates/erp-ai/src/service/analysis.rs @@ -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 = 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 diff --git a/crates/erp-ai/src/service/output_parser.rs b/crates/erp-ai/src/service/output_parser.rs index 1480c2e..44add51 100644 --- a/crates/erp-ai/src/service/output_parser.rs +++ b/crates/erp-ai/src/service/output_parser.rs @@ -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 { + let re = regex_lite::Regex::new(r"\[ref:([a-f0-9-]+)\]").unwrap_or_else(|_| { + // fallback: 不应该发生,但确保不 panic + panic!("引用提取正则编译失败"); + }); + let mut refs: Vec = 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); + } } diff --git a/crates/erp-ai/src/service/post_process.rs b/crates/erp-ai/src/service/post_process.rs index 07f402e..fba9632 100644 --- a/crates/erp-ai/src/service/post_process.rs +++ b/crates/erp-ai/src/service/post_process.rs @@ -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 = 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 { diff --git a/crates/erp-ai/src/service/suggestion.rs b/crates/erp-ai/src/service/suggestion.rs index 57c248e..c4469e9 100644 --- a/crates/erp-ai/src/service/suggestion.rs +++ b/crates/erp-ai/src/service/suggestion.rs @@ -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, + references: Option<&serde_json::Value>, ) -> AppResult> { 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), diff --git a/crates/erp-core/src/audit_service.rs b/crates/erp-core/src/audit_service.rs index 5238fa0..6d1304c 100644 --- a/crates/erp-core/src/audit_service.rs +++ b/crates/erp-core/src/audit_service.rs @@ -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, +} + +/// 验证最近 N 条审计记录的哈希链完整性。 +pub async fn verify_recent_chain( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + limit: u64, +) -> Result { + 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 = 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, + }) +} diff --git a/crates/erp-core/src/events.rs b/crates/erp-core/src/events.rs index fa67462..2c49afb 100644 --- a/crates/erp-core/src/events.rs +++ b/crates/erp-core/src/events.rs @@ -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 { + // 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) +} diff --git a/crates/erp-health/src/dto/consent_dto.rs b/crates/erp-health/src/dto/consent_dto.rs index 4560430..ffadb80 100644 --- a/crates/erp-health/src/dto/consent_dto.rs +++ b/crates/erp-health/src/dto/consent_dto.rs @@ -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, +} diff --git a/crates/erp-health/src/dto/consultation_dto.rs b/crates/erp-health/src/dto/consultation_dto.rs index 25bf4df..cafe407 100644 --- a/crates/erp-health/src/dto/consultation_dto.rs +++ b/crates/erp-health/src/dto/consultation_dto.rs @@ -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>, pub unread_count_patient: i32, pub unread_count_doctor: i32, + pub rating: Option, + pub feedback: Option, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, 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, pub is_read: bool, pub created_at: chrono::DateTime, } @@ -38,6 +42,8 @@ pub struct CreateMessageReq { pub session_id: Uuid, pub content_type: Option, pub content: String, + /// 关联的媒体文件 ID(当 content_type 为 image/file/voice 时必填) + pub media_id: Option, } 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, +} diff --git a/crates/erp-health/src/dto/patient_dto.rs b/crates/erp-health/src/dto/patient_dto.rs index fc2d36e..8eb07b7 100644 --- a/crates/erp-health/src/dto/patient_dto.rs +++ b/crates/erp-health/src/dto/patient_dto.rs @@ -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, } + +// --------------------------------------------------------------------------- +// 批量导入 DTO +// --------------------------------------------------------------------------- + +/// 批量导入患者请求体 +#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)] +pub struct BatchImportPatientReq { + #[validate(length(min = 1, max = 100))] + pub patients: Vec, +} + +/// 批量导入/操作结果 +#[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, +} + +/// 批量操作单项错误 +#[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, + pub to_doctor_id: Uuid, +} diff --git a/crates/erp-health/src/entity/consultation_message.rs b/crates/erp-health/src/entity/consultation_message.rs index 12ffc69..08ce685 100644 --- a/crates/erp-health/src/entity/consultation_message.rs +++ b/crates/erp-health/src/entity/consultation_message.rs @@ -12,6 +12,7 @@ pub struct Model { pub sender_role: String, pub content_type: String, pub content: String, + pub media_id: Option, pub is_read: bool, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, diff --git a/crates/erp-health/src/entity/consultation_session.rs b/crates/erp-health/src/entity/consultation_session.rs index 0b4d0c6..90a8274 100644 --- a/crates/erp-health/src/entity/consultation_session.rs +++ b/crates/erp-health/src/entity/consultation_session.rs @@ -16,6 +16,10 @@ pub struct Model { pub last_message_at: Option, pub unread_count_patient: i32, pub unread_count_doctor: i32, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub rating: Option, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub feedback: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, #[sea_orm(skip_serializing_if = "Option::is_none")] diff --git a/crates/erp-health/src/event/article.rs b/crates/erp-health/src/event/article.rs new file mode 100644 index 0000000..874c3be --- /dev/null +++ b/crates/erp-health/src/event/article.rs @@ -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 { + 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, 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, 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 = 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 { + 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())) +} diff --git a/crates/erp-health/src/event/care_plan.rs b/crates/erp-health/src/event/care_plan.rs new file mode 100644 index 0000000..5022ef7 --- /dev/null +++ b/crates/erp-health/src/event/care_plan.rs @@ -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 { + 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 +} diff --git a/crates/erp-health/src/event/mod.rs b/crates/erp-health/src/event/mod.rs index a2be03b..9d1909b 100644 --- a/crates/erp-health/src/event/mod.rs +++ b/crates/erp-health/src/event/mod.rs @@ -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 关闭 // 所有过滤订阅的生命周期应与进程一致 diff --git a/crates/erp-health/src/handler/consent_handler.rs b/crates/erp-health/src/handler/consent_handler.rs index b66572e..825b8e3 100644 --- a/crates/erp-health/src/handler/consent_handler.rs +++ b/crates/erp-health/src/handler/consent_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, + Path(consent_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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))) +} diff --git a/crates/erp-health/src/handler/consultation_handler.rs b/crates/erp-health/src/handler/consultation_handler.rs index fb66823..5dfd5b6 100644 --- a/crates/erp-health/src/handler/consultation_handler.rs +++ b/crates/erp-health/src/handler/consultation_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, + mut multipart: Multipart, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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))) +} diff --git a/crates/erp-health/src/handler/patient_handler.rs b/crates/erp-health/src/handler/patient_handler.rs index cb4372a..26220f1 100644 --- a/crates/erp-health/src/handler/patient_handler.rs +++ b/crates/erp-health/src/handler/patient_handler.rs @@ -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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + HealthState: FromRef, + 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))) +} diff --git a/crates/erp-health/src/routes/care.rs b/crates/erp-health/src/routes/care.rs index 96c9f8e..072491d 100644 --- a/crates/erp-health/src/routes/care.rs +++ b/crates/erp-health/src/routes/care.rs @@ -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), + ) } diff --git a/crates/erp-health/src/routes/consultation.rs b/crates/erp-health/src/routes/consultation.rs index 3fe9170..2d25a4d 100644 --- a/crates/erp-health/src/routes/consultation.rs +++ b/crates/erp-health/src/routes/consultation.rs @@ -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", diff --git a/crates/erp-health/src/routes/patient.rs b/crates/erp-health/src/routes/patient.rs index 277d6d7..c56275d 100644 --- a/crates/erp-health/src/routes/patient.rs +++ b/crates/erp-health/src/routes/patient.rs @@ -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", diff --git a/crates/erp-health/src/service/consent_service.rs b/crates/erp-health/src/service/consent_service.rs index 61c481c..b28bf69 100644 --- a/crates/erp-health/src/service/consent_service.rs +++ b/crates/erp-health/src/service/consent_service.rs @@ -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 { + 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, + }) +} diff --git a/crates/erp-health/src/service/consultation_service.rs b/crates/erp-health/src/service/consultation_service.rs index 74b25ea..1b2412b 100644 --- a/crates/erp-health/src/service/consultation_service.rs +++ b/crates/erp-health/src/service/consultation_service.rs @@ -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_id:image/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 { + 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)) +} diff --git a/crates/erp-health/src/service/patient_service/crud.rs b/crates/erp-health/src/service/patient_service/crud.rs index 5dbb9d4..70b9fb5 100644 --- a/crates/erp-health/src/service/patient_service/crud.rs +++ b/crates/erp-health/src/service/patient_service/crud.rs @@ -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, + req: BatchImportPatientReq, +) -> HealthResult { + 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 = 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 { + 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, + }) +} diff --git a/crates/erp-health/src/service/patient_service/mod.rs b/crates/erp-health/src/service/patient_service/mod.rs index 9ad27b0..bd4abec 100644 --- a/crates/erp-health/src/service/patient_service/mod.rs +++ b/crates/erp-health/src/service/patient_service/mod.rs @@ -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}; diff --git a/crates/erp-health/src/service/patient_service/relation.rs b/crates/erp-health/src/service/patient_service/relation.rs index 49003f9..79763d4 100644 --- a/crates/erp-health/src/service/patient_service/relation.rs +++ b/crates/erp-health/src/service/patient_service/relation.rs @@ -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, +) -> HealthResult { + 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, + }) +} diff --git a/crates/erp-health/src/service/validation.rs b/crates/erp-health/src/service/validation.rs index a435139..bbeb5d9 100644 --- a/crates/erp-health/src/service/validation.rs +++ b/crates/erp-health/src/service/validation.rs @@ -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] diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 24acc2e..1b34d47 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -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), ] } } diff --git a/crates/erp-server/migration/src/m20260521_000161_consultation_media_id_and_suggestion_references.rs b/crates/erp-server/migration/src/m20260521_000161_consultation_media_id_and_suggestion_references.rs new file mode 100644 index 0000000..9dc4bae --- /dev/null +++ b/crates/erp-server/migration/src/m20260521_000161_consultation_media_id_and_suggestion_references.rs @@ -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(()) + } +} diff --git a/crates/erp-server/migration/src/m20260521_000162_consultation_session_rating_feedback.rs b/crates/erp-server/migration/src/m20260521_000162_consultation_session_rating_feedback.rs new file mode 100644 index 0000000..1d97fb7 --- /dev/null +++ b/crates/erp-server/migration/src/m20260521_000162_consultation_session_rating_feedback.rs @@ -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(()) + } +} diff --git a/crates/erp-server/tests/integration/health_consultation_tests.rs b/crates/erp-server/tests/integration/health_consultation_tests.rs index a49aae7..f99dcd3 100644 --- a/crates/erp-server/tests/integration/health_consultation_tests.rs +++ b/crates/erp-server/tests/integration/health_consultation_tests.rs @@ -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 diff --git a/crates/erp-server/tests/integration/health_follow_up_tests.rs b/crates/erp-server/tests/integration/health_follow_up_tests.rs index 0b4576f..4cbd3bc 100644 --- a/crates/erp-server/tests/integration/health_follow_up_tests.rs +++ b/crates/erp-server/tests/integration/health_follow_up_tests.rs @@ -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 diff --git a/crates/erp-server/tests/integration/health_pii_encryption_tests.rs b/crates/erp-server/tests/integration/health_pii_encryption_tests.rs index 4b1142e..1abec7b 100644 --- a/crates/erp-server/tests/integration/health_pii_encryption_tests.rs +++ b/crates/erp-server/tests/integration/health_pii_encryption_tests.rs @@ -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 diff --git a/docs/discussions/2026-05-20-business-process-brainstorm.md b/docs/discussions/2026-05-20-business-process-brainstorm.md new file mode 100644 index 0000000..4ce4fdb --- /dev/null +++ b/docs/discussions/2026-05-20-business-process-brainstorm.md @@ -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 chain(claude→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-FORGET,auth/config/workflow 模块的 17 个事件完全没有消费者。care_plan/care_action 4 个事件已定义常量但无消费者。 + +**风险:** 用户删除后相关数据不清理、角色权限变更后缓存不失效、护理计划激活无后续动作。 + +**建议:** 按影响分批升级。P0:`appointment.cancelled`(号源回补)、`consent.revoked`(数据阻断)。P1:`user.deleted`/`role.*`(缓存失效)。P2:care_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.1(P1,约 28 人天)** +- 透析排位(5d)+ Kt/V 自动计算(3d)+ 透析小程序(5d)= 透析 13d +- 预约改期+候补(3d)+ 咨询文件上传(2d)+ 满意度(2d)= 交互 7d +- AI 引用来源(4d)+ 告警多渠道(3d)+ 生产 KEK 防护(0.5d)= 增强 7.5d + +**阶段三:V2 持续优化(P2)** +- 周期性随访计划、多人会诊、药物交互检查、SpO2/体温监测入口、设备心跳保活等