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