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