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, pub page_size: Option, pub status: Option, pub patient_id: Option, pub doctor_id: Option, } #[derive(Debug, Deserialize, IntoParams)] pub struct MessageListParams { pub page: Option, pub page_size: Option, pub after_id: Option, } #[derive(Debug, Deserialize, IntoParams)] pub struct PollMessagesParams { pub after_id: Option, /// 超时秒数,默认 25,最大 30 pub timeout: Option, } #[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, pub content: String, } #[derive(Debug, Deserialize, IntoParams)] pub struct ExportSessionsParams { pub status: Option, pub patient_id: Option, pub doctor_id: Option, pub page: Option, pub page_size: Option, } pub async fn create_session( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Query(params): Query, ) -> Result>>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> Result>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Path(session_id): Path, Query(params): Query, ) -> Result>>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Path(session_id): Path, Query(params): Query, ) -> Result>>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Path(id): Path, Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Query(params): Query, ) -> Result>>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Path(id): Path, ) -> Result>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, ) -> Result>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Path(id): Path, Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Path(id): Path, Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, mut multipart: Multipart, ) -> Result>, AppError> where HealthState: FromRef, 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( State(state): State, Extension(ctx): Extension, Path(id): Path, Json(req): Json, ) -> Result>, AppError> where HealthState: FromRef, 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))) }