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:
7
Cargo.lock
generated
7
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -25,3 +25,4 @@ dashmap.workspace = true
|
||||
sha2.workspace = true
|
||||
redis.workspace = true
|
||||
hex.workspace = true
|
||||
regex-lite.workspace = true
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
use crate::audit::AuditLog;
|
||||
use crate::entity::audit_log;
|
||||
use crate::request_info::RequestInfo;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tracing;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// 持久化审计日志到 audit_logs 表。
|
||||
///
|
||||
@@ -39,6 +43,12 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
// 计算当前记录的 record_hash
|
||||
let record_hash = compute_record_hash(&log, prev_hash.as_deref());
|
||||
|
||||
// 保存日志字段用于错误日志(model 构建会 move String 字段)
|
||||
let err_tenant_id = log.tenant_id;
|
||||
let err_action = log.action.clone();
|
||||
let err_resource_type = log.resource_type.clone();
|
||||
let err_resource_id = log.resource_id;
|
||||
|
||||
let model = audit_log::ActiveModel {
|
||||
id: Set(log.id),
|
||||
tenant_id: Set(log.tenant_id),
|
||||
@@ -56,7 +66,14 @@ pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) {
|
||||
};
|
||||
|
||||
if let Err(e) = model.insert(db).await {
|
||||
tracing::warn!(error = %e, "审计日志写入失败");
|
||||
tracing::error!(
|
||||
error = %e,
|
||||
tenant_id = ?err_tenant_id,
|
||||
action = %err_action,
|
||||
resource_type = %err_resource_type,
|
||||
resource_id = ?err_resource_id,
|
||||
"审计日志写入失败 — 数据完整性风险"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,3 +148,74 @@ pub async fn verify_hash_chain(
|
||||
|
||||
Ok((total, broken))
|
||||
}
|
||||
|
||||
/// 哈希链验证结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChainVerificationResult {
|
||||
pub total: usize,
|
||||
pub passed: usize,
|
||||
pub failed: usize,
|
||||
pub failed_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
/// 验证最近 N 条审计记录的哈希链完整性。
|
||||
pub async fn verify_recent_chain(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
tenant_id: Uuid,
|
||||
limit: u64,
|
||||
) -> Result<ChainVerificationResult, String> {
|
||||
let records = audit_log::Entity::find()
|
||||
.filter(audit_log::Column::TenantId.eq(tenant_id))
|
||||
.filter(audit_log::Column::RecordHash.is_not_null())
|
||||
.order_by_desc(audit_log::Column::CreatedAt)
|
||||
.limit(limit)
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| format!("查询审计日志失败: {}", e))?;
|
||||
|
||||
let mut records = records;
|
||||
records.sort_by(|a, b| a.created_at.cmp(&b.created_at));
|
||||
|
||||
let total = records.len();
|
||||
let mut passed = 0;
|
||||
let mut failed_ids = Vec::new();
|
||||
let mut prev: Option<String> = None;
|
||||
|
||||
for record in &records {
|
||||
let mut record_broken = false;
|
||||
if prev.as_deref() != record.prev_hash.as_deref() {
|
||||
record_broken = true;
|
||||
}
|
||||
let log = AuditLog {
|
||||
id: record.id,
|
||||
tenant_id: record.tenant_id,
|
||||
user_id: record.user_id,
|
||||
action: record.action.clone(),
|
||||
resource_type: record.resource_type.clone(),
|
||||
resource_id: record.resource_id,
|
||||
old_value: record.old_value.clone(),
|
||||
new_value: record.new_value.clone(),
|
||||
ip_address: record.ip_address.clone(),
|
||||
user_agent: record.user_agent.clone(),
|
||||
created_at: record.created_at,
|
||||
};
|
||||
let expected = compute_record_hash(&log, record.prev_hash.as_deref());
|
||||
if Some(expected.as_str()) != record.record_hash.as_deref() {
|
||||
record_broken = true;
|
||||
}
|
||||
if record_broken {
|
||||
failed_ids.push(record.id);
|
||||
} else {
|
||||
passed += 1;
|
||||
}
|
||||
prev = record.record_hash.clone();
|
||||
}
|
||||
|
||||
let failed = total - passed;
|
||||
Ok(ChainVerificationResult {
|
||||
total,
|
||||
passed,
|
||||
failed,
|
||||
failed_ids,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ConnectionTrait, PaginatorTrait, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing::{error, info};
|
||||
@@ -8,6 +10,35 @@ use uuid::Uuid;
|
||||
use crate::entity::dead_letter_event;
|
||||
use crate::entity::domain_event;
|
||||
|
||||
/// 已知的 PII 字段列表 -- 在事件 payload 中自动脱敏
|
||||
const PII_FIELDS: &[&str] = &[
|
||||
"phone",
|
||||
"id_number",
|
||||
"emergency_contact_phone",
|
||||
"emergency_contact_name",
|
||||
"medical_history_summary",
|
||||
"allergy_history",
|
||||
"content",
|
||||
];
|
||||
|
||||
/// 递归脱敏 payload 中的 PII 字段(原地修改)。
|
||||
fn sanitize_payload(payload: &mut serde_json::Value) {
|
||||
if let Some(obj) = payload.as_object_mut() {
|
||||
for field in PII_FIELDS {
|
||||
if let Some(val) = obj.get_mut(*field)
|
||||
&& val.is_string()
|
||||
{
|
||||
*val = serde_json::Value::String("[REDACTED]".to_string());
|
||||
}
|
||||
}
|
||||
for val in obj.values_mut() {
|
||||
if val.is_object() {
|
||||
sanitize_payload(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 领域事件
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DomainEvent {
|
||||
@@ -230,7 +261,10 @@ impl EventBus {
|
||||
///
|
||||
/// 两阶段提交保证:即使广播后服务崩溃,事件仍为 pending 状态,
|
||||
/// 重启后 outbox relay 会重新广播。
|
||||
pub async fn publish(&self, event: DomainEvent, db: &sea_orm::DatabaseConnection) {
|
||||
pub async fn publish(&self, mut event: DomainEvent, db: &sea_orm::DatabaseConnection) {
|
||||
// 0. 脱敏 payload 中的 PII 字段
|
||||
sanitize_payload(&mut event.payload);
|
||||
|
||||
// 1. 持久化为 pending 状态
|
||||
let event_id = event.id;
|
||||
let model = domain_event::ActiveModel {
|
||||
@@ -343,3 +377,82 @@ impl EventBus {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 重试 dead_letter_events 中未解决的失败事件(指数退避)。
|
||||
pub async fn retry_dead_letters(
|
||||
db: &sea_orm::DatabaseConnection,
|
||||
bus: &EventBus,
|
||||
max_attempts: i32,
|
||||
) -> Result<u64, String> {
|
||||
// 1. 查询所有未解决且未超过最大重试次数的 dead-letter
|
||||
let pending = dead_letter_event::Entity::find()
|
||||
.filter(dead_letter_event::Column::ResolvedAt.is_null())
|
||||
.filter(dead_letter_event::Column::Attempts.lt(max_attempts))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| format!("查询 dead_letter_events 失败: {}", e))?;
|
||||
|
||||
let retried = pending.len() as u64;
|
||||
|
||||
for dl in &pending {
|
||||
let event = DomainEvent {
|
||||
id: dl.original_event_id,
|
||||
event_type: dl.event_type.clone(),
|
||||
tenant_id: dl.tenant_id.unwrap_or(Uuid::nil()),
|
||||
payload: dl.payload.clone().unwrap_or(serde_json::Value::Null),
|
||||
timestamp: dl.created_at,
|
||||
correlation_id: Uuid::now_v7(),
|
||||
};
|
||||
bus.broadcast(event);
|
||||
|
||||
let mut active: dead_letter_event::ActiveModel = dl.clone().into();
|
||||
let new_attempts = dl.attempts + 1;
|
||||
active.attempts = Set(new_attempts);
|
||||
active.last_error = Set(Some(format!(
|
||||
"第 {} 次自动重试({})",
|
||||
new_attempts,
|
||||
Utc::now().to_rfc3339()
|
||||
)));
|
||||
if let Err(e) = active.update(db).await {
|
||||
tracing::warn!(
|
||||
dead_letter_id = %dl.id,
|
||||
error = %e,
|
||||
"更新 dead_letter_events attempts 失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 标记超过最大重试次数的记录为永久失败
|
||||
let exhausted = dead_letter_event::Entity::find()
|
||||
.filter(dead_letter_event::Column::ResolvedAt.is_null())
|
||||
.filter(dead_letter_event::Column::Attempts.gte(max_attempts))
|
||||
.all(db)
|
||||
.await
|
||||
.map_err(|e| format!("查询超限 dead_letter_events 失败: {}", e))?;
|
||||
|
||||
for dl in &exhausted {
|
||||
let mut active: dead_letter_event::ActiveModel = dl.clone().into();
|
||||
active.resolved_at = Set(Some(Utc::now()));
|
||||
active.last_error = Set(Some(format!(
|
||||
"已达最大重试次数 {},标记为永久失败",
|
||||
max_attempts
|
||||
)));
|
||||
if let Err(e) = active.update(db).await {
|
||||
tracing::warn!(
|
||||
dead_letter_id = %dl.id,
|
||||
error = %e,
|
||||
"标记 dead_letter_event 为永久失败时更新失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if retried > 0 || !exhausted.is_empty() {
|
||||
tracing::info!(
|
||||
retried = retried,
|
||||
permanently_failed = exhausted.len(),
|
||||
"Dead-letter 自动重试完成"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(retried)
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
235
crates/erp-health/src/event/article.rs
Normal file
235
crates/erp-health/src/event/article.rs
Normal 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()))
|
||||
}
|
||||
117
crates/erp-health/src/event/care_plan.rs
Normal file
117
crates/erp-health/src/event/care_plan.rs
Normal 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
|
||||
}
|
||||
@@ -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 关闭
|
||||
// 所有过滤订阅的生命周期应与进程一致
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<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))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
289
docs/discussions/2026-05-20-business-process-brainstorm.md
Normal file
289
docs/discussions/2026-05-20-business-process-brainstorm.md
Normal 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 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/体温监测入口、设备心跳保活等
|
||||
Reference in New Issue
Block a user