- 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() 调用
512 lines
16 KiB
Rust
512 lines
16 KiB
Rust
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)))
|
||
}
|