feat(health+core+ai): 业务流程全面修复 Phase 4-6 + 集成测试修复

Phase 4 — Dead-letter 重试 + 内容推送 + 安全加固:
- erp-core: retry_dead_letters() 定时重试 + PII payload 脱敏
- erp-core: audit_service 哈希链定时验证 + 写入失败告警
- erp-health: article.published 消费者匹配 patient_tag 推送消息
- erp-health: care_plan 事件消费者 (激活通知 + 完成积分)

Phase 5 — 患者批量操作 + 咨询增强 + 护理事件:
- patient: batch_import_patients + bind_by_phone + refer_patient
- consultation: rate_session 满意度评价 (rating + feedback)
- consent: patient_sign_consent 患者端签署
- validation: source 枚举 (7值) + relationship 枚举 (7值) + 12 单元测试

Phase 6 — 咨询文件上传 + AI 引用标注:
- consultation_message: media_id 附件上传端点
- ai_suggestion: references JSONB + [ref:id] 格式引用标注
- AI system prompt 增加引用指令 + output_parser 提取逻辑

迁移: 000161 (media_id + references) + 000162 (rating + feedback)
集成测试: consultation/follow_up/pii_encryption 新字段同步修复
讨论文档: 2026-05-20-business-process-brainstorm.md (10域审核报告)
This commit is contained in:
iven
2026-05-21 01:34:20 +08:00
parent 9033ec8ca2
commit 41a865cf68
37 changed files with 1929 additions and 14 deletions

View File

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

View File

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

View File

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