Files
hms/crates/erp-health/src/handler/consultation_handler.rs
iven 4b40d47b71 fix(health): DTO 输入校验补全 + handler .validate() 调用
- daily_monitoring_dto: Create/Update 添加 Validate derive + 血压/体重/血糖/入液量范围校验
- health_data_dto: LabReport/HealthRecord Create/Update/Review 添加 Validate derive
- consultation_dto: CreateSessionReq/CreateMessageReq 添加 Validate + content length
- article_dto: title max=500→200 匹配 DB VARCHAR(200)
- health_data_handler: 7 个 create/update handler 添加 .validate() 调用
- consultation_handler: create_session/create_message 添加 .validate() 调用
- daily_monitoring_handler: create/update 添加 .validate() 调用
2026-05-21 22:37:26 +08:00

512 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::Extension;
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;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::consultation_dto::*;
use crate::service::consultation_service;
use crate::state::HealthState;
#[derive(Debug, Deserialize, IntoParams)]
pub struct SessionListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub status: Option<String>,
pub patient_id: Option<Uuid>,
pub doctor_id: Option<Uuid>,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct MessageListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
pub after_id: Option<Uuid>,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct PollMessagesParams {
pub after_id: Option<Uuid>,
/// 超时秒数,默认 25最大 30
pub timeout: Option<u64>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CloseSessionReq {
pub version: i32,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateConsultationMessageReq {
pub session_id: Uuid,
pub content_type: Option<String>,
pub content: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ExportSessionsParams {
pub status: Option<String>,
pub patient_id: Option<Uuid>,
pub doctor_id: Option<Uuid>,
pub page: Option<u64>,
pub page_size: Option<u64>,
}
pub async fn create_session<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateSessionReq>,
) -> Result<Json<ApiResponse<SessionResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.manage")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let result =
consultation_service::create_session(&state, ctx.tenant_id, Some(ctx.user_id), req).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn list_sessions<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<SessionListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<SessionResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = consultation_service::list_sessions(
&state,
ctx.tenant_id,
page,
page_size,
params.status,
params.patient_id,
params.doctor_id,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn get_session<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<SessionResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
let result = consultation_service::get_session(&state, ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn list_messages<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(session_id): Path<Uuid>,
Query(params): Query<MessageListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<MessageResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20).min(100);
let result = consultation_service::list_messages(
&state,
ctx.tenant_id,
session_id,
page,
page_size,
params.after_id,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 长轮询咨询消息 — 有新消息立即返回,否则挂起等待(最多 timeout 秒)。
pub async fn poll_messages<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(session_id): Path<Uuid>,
Query(params): Query<PollMessagesParams>,
) -> Result<Json<ApiResponse<Vec<MessageResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
let timeout_secs = params.timeout.unwrap_or(25).min(30);
let result = consultation_service::poll_new_messages(
&state,
ctx.tenant_id,
session_id,
params.after_id,
timeout_secs,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn close_session<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<CloseSessionReq>,
) -> Result<Json<ApiResponse<SessionResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.manage")?;
let result = consultation_service::close_session(
&state,
ctx.tenant_id,
id,
Some(ctx.user_id),
req.version,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn create_message<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateConsultationMessageReq>,
) -> Result<Json<ApiResponse<MessageResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
// 从 JWT 身份推导 sender_role不信任客户端输入
let is_doctor = crate::entity::doctor_profile::Entity::find()
.filter(crate::entity::doctor_profile::Column::UserId.eq(ctx.user_id))
.filter(crate::entity::doctor_profile::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::doctor_profile::Column::DeletedAt.is_null())
.one(&state.db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?
.is_some();
let sender_role = if is_doctor { "doctor" } else { "patient" }.to_string();
let mut msg_req = CreateMessageReq {
session_id: req.session_id,
content_type: req.content_type,
content: req.content,
media_id: None,
};
msg_req
.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
msg_req.sanitize();
let result = consultation_service::create_message(
&state,
ctx.tenant_id,
Some(ctx.user_id),
ctx.user_id,
sender_role,
msg_req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
pub async fn export_sessions<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<ExportSessionsParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<SessionResp>>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
let result = consultation_service::export_sessions(
&state,
ctx.tenant_id,
params.status,
params.patient_id,
params.doctor_id,
params.page,
params.page_size,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 标记会话消息为已读。
#[utoipa::path(
put,
path = "/consultation-sessions/{id}/read",
responses(
(status = 200, description = "标记成功"),
(status = 404, description = "会话不存在"),
),
tag = "咨询管理",
)]
pub async fn mark_session_read<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
let is_doctor = crate::entity::doctor_profile::Entity::find()
.filter(crate::entity::doctor_profile::Column::UserId.eq(ctx.user_id))
.filter(crate::entity::doctor_profile::Column::TenantId.eq(ctx.tenant_id))
.filter(crate::entity::doctor_profile::Column::DeletedAt.is_null())
.one(&state.db)
.await
.map_err(|e| AppError::Internal(e.to_string()))?
.is_some();
let role = if is_doctor { "doctor" } else { "patient" };
consultation_service::mark_session_read(&state, ctx.tenant_id, id, ctx.user_id, role).await?;
Ok(Json(ApiResponse::ok(())))
}
/// 获取当前医生的仪表盘数据。
#[utoipa::path(
get,
path = "/doctor/dashboard",
responses(
(status = 200, description = "仪表盘数据"),
),
tag = "医护端",
)]
pub async fn get_doctor_dashboard<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<consultation_service::DoctorDashboard>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.list")?;
let mut result =
consultation_service::get_doctor_dashboard(&state, ctx.tenant_id, ctx.user_id).await?;
consultation_service::enrich_doctor_dashboard_health(
&state,
ctx.tenant_id,
ctx.user_id,
&mut result,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 从咨询会话创建随访任务 — 自动从 session 中提取 patient_id
/// source_type = "consultation", source_id = session_id。
#[utoipa::path(
post,
path = "/consultation-sessions/{id}/follow-up",
request_body = CreateFollowUpFromConsultationReq,
responses(
(status = 200, description = "随访任务已创建"),
(status = 404, description = "会话不存在"),
),
tag = "咨询联动",
)]
pub async fn create_follow_up_from_session<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<CreateFollowUpFromConsultationReq>,
) -> Result<Json<ApiResponse<FollowUpFromConsultationResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
let result = consultation_service::create_follow_up_from_session(
&state,
ctx.tenant_id,
Some(ctx.user_id),
id,
req,
)
.await?;
Ok(Json(ApiResponse::ok(result)))
}
/// 从咨询会话触发 AI 分析 — 加载最近消息作为上下文,发布事件。
#[utoipa::path(
post,
path = "/consultation-sessions/{id}/ai-analysis",
request_body = TriggerAiAnalysisReq,
responses(
(status = 200, description = "AI 分析已触发"),
(status = 404, description = "会话不存在"),
),
tag = "咨询联动",
)]
pub async fn trigger_ai_analysis_from_session<S>(
State(state): State<HealthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<TriggerAiAnalysisReq>,
) -> Result<Json<ApiResponse<AiAnalysisTriggeredResp>>, AppError>
where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.manage")?;
let result = consultation_service::trigger_ai_analysis_from_session(
&state,
ctx.tenant_id,
Some(ctx.user_id),
id,
req,
)
.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)))
}