fix(health): 修复审计发现的 10 个 CRITICAL 问题
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

权限与安全:
- 为全部 51 个 handler 端点添加 require_permission 权限检查
- 修复 CAS 预约操作中 doctor_id 为 None 时使用 Uuid::nil() 的问题

状态机修复:
- 预约初始状态从 "scheduled" 改为 "pending"(匹配设计规格)
- 排班状态从 "active" 改为 "enabled"
- 咨询会话添加 waiting→active 自动触发(首条消息时)
- 新增 create_session 端点和 DTO

数据完整性:
- doctor_profile 表添加 name 列(entity + migration + service)
- lab_report/health_trend 的 json 列改为 json_binary(支持 GIN 索引)
- 添加关键索引:patient.id_number UNIQUE、patient_tag UNIQUE、
  doctor_schedule 唯一排班槽位、health_trend、doctor_profile.name
- 随访记录完成后自动检查 next_follow_up_date 创建后续任务

事件总线:
- 实现 10 种核心事件发布(patient/appointment/follow_up/consultation/lab_report)
- 实现 workflow.task.completed 和 message.sent 事件订阅框架

种子数据:
- 实现 seed_tenant_health(8 个默认患者标签)
- 实现 soft_delete_tenant_data(16 张表级联软删除)
This commit is contained in:
iven
2026-04-23 23:25:53 +08:00
parent d6678d001e
commit 2e9eb55f2c
18 changed files with 423 additions and 31 deletions

View File

@@ -36,6 +36,13 @@ pub struct CreateMessageReq {
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct CreateSessionReq {
pub patient_id: Uuid,
pub doctor_id: Option<Uuid>,
pub consultation_type: Option<String>,
}
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
pub struct SessionQuery {
pub status: Option<String>,

View File

@@ -9,6 +9,7 @@ pub struct Model {
pub tenant_id: Uuid,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub user_id: Option<Uuid>,
pub name: String,
#[sea_orm(skip_serializing_if = "Option::is_none")]
pub department: Option<String>,
#[sea_orm(skip_serializing_if = "Option::is_none")]

View File

@@ -1,7 +1,39 @@
use erp_core::events::EventBus;
pub fn register_handlers(_bus: &EventBus) {
// Health 模块订阅的事件处理器
// - workflow.task.completed → 更新随访任务状态
// - message.sent → 联动咨询会话 last_message_at
pub fn register_handlers(bus: &EventBus) {
// workflow.task.completed → 更新随访任务状态
let (mut workflow_rx, _wf_handle) = bus.subscribe_filtered("workflow.task.".to_string());
tokio::spawn(async move {
loop {
match workflow_rx.recv().await {
Some(event) if event.event_type == "workflow.task.completed" => {
tracing::info!(
event_id = %event.id,
"健康模块收到工作流任务完成事件"
);
// 后续可通过 db 连接更新 follow_up_task 状态
}
Some(_) => {}
None => break,
}
}
});
// message.sent → 联动咨询会话 last_message_at
let (mut msg_rx, _msg_handle) = bus.subscribe_filtered("message.".to_string());
tokio::spawn(async move {
loop {
match msg_rx.recv().await {
Some(event) if event.event_type == "message.sent" => {
tracing::info!(
event_id = %event.id,
"健康模块收到消息发送事件"
);
// 后续可通过 db 连接更新 consultation_session.last_message_at
}
Some(_) => {}
None => break,
}
}
});
}

View File

@@ -5,6 +5,7 @@ use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::appointment_dto::*;
@@ -59,6 +60,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.appointment.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = appointment_service::list_appointments(
@@ -78,6 +80,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.appointment.manage")?;
let result = appointment_service::create_appointment(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
@@ -95,6 +98,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.appointment.manage")?;
let update_req = UpdateAppointmentStatusReq {
status: req.status,
cancel_reason: req.cancel_reason,
@@ -115,6 +119,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.appointment.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = appointment_service::list_schedules(
@@ -133,6 +138,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.appointment.manage")?;
let result = appointment_service::create_schedule(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
@@ -150,6 +156,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.appointment.manage")?;
let result = appointment_service::update_schedule(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.data, req.version,
)
@@ -166,6 +173,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.appointment.list")?;
let result = appointment_service::calendar_view(
&state, ctx.tenant_id, params.start_date, params.end_date, params.doctor_id,
)

View File

@@ -5,6 +5,7 @@ use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::consultation_dto::*;
@@ -47,6 +48,23 @@ pub struct ExportSessionsParams {
pub doctor_id: Option<Uuid>,
}
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")?;
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>,
@@ -56,6 +74,7 @@ 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);
let result = consultation_service::list_sessions(
@@ -76,6 +95,7 @@ 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);
let result = consultation_service::list_messages(
@@ -95,6 +115,7 @@ 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,
)
@@ -111,6 +132,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.consultation.manage")?;
let msg_req = CreateMessageReq {
session_id: req.session_id,
sender_id: req.sender_id,
@@ -134,6 +156,7 @@ 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,
)

View File

@@ -5,6 +5,7 @@ use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::doctor_dto::*;
@@ -36,6 +37,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.doctor.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = doctor_service::list_doctors(
@@ -54,6 +56,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.doctor.manage")?;
let result = doctor_service::create_doctor(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
@@ -70,6 +73,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.doctor.list")?;
let result = doctor_service::get_doctor(&state, ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -84,6 +88,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.doctor.manage")?;
let result = doctor_service::update_doctor(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.data, req.version,
)
@@ -100,6 +105,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.doctor.manage")?;
doctor_service::delete_doctor(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -5,6 +5,7 @@ use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::follow_up_dto::*;
@@ -44,6 +45,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = follow_up_service::list_tasks(
@@ -63,6 +65,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
let result = follow_up_service::create_task(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
@@ -80,6 +83,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
let result = follow_up_service::update_task(
&state, ctx.tenant_id, id, Some(ctx.user_id), req.data, req.version,
)
@@ -96,6 +100,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
follow_up_service::delete_task(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?;
Ok(Json(ApiResponse::ok(())))
}
@@ -109,6 +114,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.manage")?;
let result = follow_up_service::create_record(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
@@ -125,6 +131,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.follow-up.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = follow_up_service::list_records(

View File

@@ -5,6 +5,7 @@ use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::health_data_dto::*;
@@ -53,6 +54,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = health_data_service::list_vital_signs(
@@ -72,6 +74,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let result = health_data_service::create_vital_signs(
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req,
)
@@ -89,6 +92,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let result = health_data_service::update_vital_signs(
&state, ctx.tenant_id, patient_id, vid, Some(ctx.user_id), req.data, req.version,
)
@@ -105,6 +109,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
health_data_service::delete_vital_signs(&state, ctx.tenant_id, vid, Some(ctx.user_id)).await?;
Ok(Json(ApiResponse::ok(())))
}
@@ -123,6 +128,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = health_data_service::list_lab_reports(
@@ -142,6 +148,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let result = health_data_service::create_lab_report(
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req,
)
@@ -159,6 +166,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let result = health_data_service::update_lab_report(
&state, ctx.tenant_id, rid, Some(ctx.user_id), req.data, req.version,
)
@@ -175,6 +183,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
health_data_service::delete_lab_report(&state, ctx.tenant_id, rid, Some(ctx.user_id)).await?;
Ok(Json(ApiResponse::ok(())))
}
@@ -193,6 +202,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = health_data_service::list_health_records(
@@ -212,6 +222,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let result = health_data_service::create_health_record(
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req,
)
@@ -229,6 +240,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let result = health_data_service::update_health_record(
&state, ctx.tenant_id, rid, Some(ctx.user_id), req.data, req.version,
)
@@ -245,6 +257,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
health_data_service::delete_health_record(&state, ctx.tenant_id, rid, Some(ctx.user_id)).await?;
Ok(Json(ApiResponse::ok(())))
}
@@ -263,6 +276,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = health_data_service::list_trends(
@@ -282,6 +296,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.manage")?;
let result = health_data_service::generate_trend(
&state, ctx.tenant_id, patient_id, Some(ctx.user_id), req.period_start, req.period_end,
)
@@ -299,6 +314,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.health-data.list")?;
let result = health_data_service::get_indicator_timeseries(
&state, ctx.tenant_id, patient_id, indicator, params.start_date, params.end_date,
)

View File

@@ -5,6 +5,7 @@ use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::patient_dto::{
@@ -38,6 +39,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.list")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20);
let result = patient_service::list_patients(
@@ -56,6 +58,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
let result = patient_service::create_patient(
&state, ctx.tenant_id, Some(ctx.user_id), req,
)
@@ -72,6 +75,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.list")?;
let result = patient_service::get_patient(&state, ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -86,6 +90,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
let version = req.version;
let update = UpdatePatientReq {
name: req.name,
@@ -116,6 +121,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
patient_service::delete_patient(&state, ctx.tenant_id, id, Some(ctx.user_id)).await?;
Ok(Json(ApiResponse::ok(())))
}
@@ -130,6 +136,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
patient_service::manage_patient_tags(&state, ctx.tenant_id, id, req, Some(ctx.user_id)).await?;
Ok(Json(ApiResponse::ok(())))
}
@@ -143,6 +150,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.list")?;
let result = patient_service::get_health_summary(&state, ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -156,6 +164,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.list")?;
let result = patient_service::list_family_members(&state, ctx.tenant_id, id).await?;
Ok(Json(ApiResponse::ok(result)))
}
@@ -170,6 +179,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
let result = patient_service::create_family_member(
&state, ctx.tenant_id, id, Some(ctx.user_id), req,
)
@@ -187,6 +197,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
let version = req.version;
let update = FamilyMemberReq {
name: req.name,
@@ -211,6 +222,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
patient_service::delete_family_member(
&state, ctx.tenant_id, patient_id, member_id, Some(ctx.user_id),
)
@@ -228,6 +240,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
patient_service::assign_doctor(
&state,
ctx.tenant_id,
@@ -249,6 +262,7 @@ where
HealthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "health.patient.manage")?;
patient_service::remove_doctor(&state, ctx.tenant_id, patient_id, doctor_id).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -157,7 +157,8 @@ impl HealthModule {
// 咨询管理
.route(
"/health/consultation-sessions",
axum::routing::get(consultation_handler::list_sessions),
axum::routing::get(consultation_handler::list_sessions)
.post(consultation_handler::create_session),
)
.route(
"/health/consultation-sessions/{id}/messages",

View File

@@ -1,6 +1,7 @@
//! 预约排班 Service — 预约CRUD、排班管理、日历视图、原子CAS预约
use chrono::Utc;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect};
use uuid::Uuid;
@@ -74,7 +75,7 @@ pub async fn create_appointment(
)
.col_expr(doctor_schedule::Column::UpdatedAt, Expr::value(Utc::now()))
.filter(doctor_schedule::Column::TenantId.eq(tenant_id))
.filter(doctor_schedule::Column::DoctorId.eq(req.doctor_id.unwrap_or_default()))
.filter(doctor_schedule::Column::DoctorId.eq(req.doctor_id.ok_or(HealthError::Validation("doctor_id is required".to_string()))?))
.filter(doctor_schedule::Column::ScheduleDate.eq(req.appointment_date))
.filter(doctor_schedule::Column::StartTime.eq(req.start_time))
.filter(
@@ -102,7 +103,7 @@ pub async fn create_appointment(
appointment_date: Set(req.appointment_date),
start_time: Set(req.start_time),
end_time: Set(req.end_time),
status: Set("scheduled".to_string()),
status: Set("pending".to_string()),
cancel_reason: Set(None),
notes: Set(req.notes),
created_at: Set(now),
@@ -113,6 +114,14 @@ pub async fn create_appointment(
version: Set(1),
};
let m = active.insert(&state.db).await?;
let event = DomainEvent::new(
"appointment.created",
tenant_id,
serde_json::json!({ "appointment_id": m.id, "patient_id": m.patient_id, "status": m.status }),
);
state.event_bus.publish(event, &state.db).await;
Ok(AppointmentResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
appointment_type: m.appointment_type, appointment_date: m.appointment_date,
@@ -140,7 +149,7 @@ pub async fn update_appointment_status(
// 状态机校验
let valid = match (model.status.as_str(), req.status.as_str()) {
("scheduled", "confirmed" | "cancelled") => true,
("pending", "confirmed" | "cancelled") => true,
("confirmed", "completed" | "no_show" | "cancelled") => true,
_ => false,
};
@@ -179,6 +188,15 @@ pub async fn update_appointment_status(
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
let event_type = format!("appointment.{}", m.status);
let event = DomainEvent::new(
event_type,
tenant_id,
serde_json::json!({ "appointment_id": m.id, "patient_id": m.patient_id, "status": m.status }),
);
state.event_bus.publish(event, &state.db).await;
Ok(AppointmentResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
appointment_type: m.appointment_type, appointment_date: m.appointment_date,
@@ -246,7 +264,7 @@ pub async fn create_schedule(
end_time: Set(req.end_time),
max_appointments: Set(req.max_appointments),
current_appointments: Set(0),
status: Set("active".to_string()),
status: Set("enabled".to_string()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(operator_id),

View File

@@ -1,8 +1,9 @@
//! 咨询管理 Service — 会话管理、消息收发、会话关闭、导出
use chrono::Utc;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect};
use uuid::Uuid;
use erp_core::error::check_version;
@@ -17,6 +18,49 @@ use crate::state::HealthState;
// 咨询会话
// ---------------------------------------------------------------------------
pub async fn create_session(
state: &HealthState,
tenant_id: Uuid,
operator_id: Option<Uuid>,
req: CreateSessionReq,
) -> HealthResult<SessionResp> {
let now = Utc::now();
let active = consultation_session::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(req.patient_id),
doctor_id: Set(req.doctor_id),
consultation_type: Set(req.consultation_type.unwrap_or_else(|| "text".to_string())),
status: Set("waiting".to_string()),
last_message_at: Set(None),
unread_count_patient: Set(0),
unread_count_doctor: Set(0),
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),
};
let m = active.insert(&state.db).await?;
let event = DomainEvent::new(
"consultation.opened",
tenant_id,
serde_json::json!({ "session_id": m.id, "patient_id": m.patient_id }),
);
state.event_bus.publish(event, &state.db).await;
Ok(SessionResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
consultation_type: m.consultation_type, status: m.status,
last_message_at: m.last_message_at,
unread_count_patient: m.unread_count_patient,
unread_count_doctor: m.unread_count_doctor,
created_at: m.created_at,
})
}
pub async fn list_sessions(
state: &HealthState,
tenant_id: Uuid,
@@ -84,6 +128,14 @@ pub async fn close_session(
active.version = Set(next_ver);
let m = active.update(&state.db).await?;
let event = DomainEvent::new(
"consultation.closed",
tenant_id,
serde_json::json!({ "session_id": m.id, "patient_id": m.patient_id }),
);
state.event_bus.publish(event, &state.db).await;
Ok(SessionResp {
id: m.id, patient_id: m.patient_id, doctor_id: m.doctor_id,
consultation_type: m.consultation_type, status: m.status,
@@ -138,7 +190,7 @@ pub async fn list_messages(
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = consultation_message::Entity::find()
let query = consultation_message::Entity::find()
.filter(consultation_message::Column::TenantId.eq(tenant_id))
.filter(consultation_message::Column::SessionId.eq(session_id))
.filter(consultation_message::Column::DeletedAt.is_null());
@@ -167,18 +219,23 @@ pub async fn create_message(
operator_id: Option<Uuid>,
req: CreateMessageReq,
) -> HealthResult<MessageResp> {
// 校验会话存在且状态为 active
// 校验会话存在且状态为 active 或 waiting
let session = consultation_session::Entity::find()
.filter(consultation_session::Column::Id.eq(req.session_id))
.filter(consultation_session::Column::TenantId.eq(tenant_id))
.filter(consultation_session::Column::DeletedAt.is_null())
.filter(consultation_session::Column::Status.eq("active"))
.filter(
Condition::any()
.add(consultation_session::Column::Status.eq("active"))
.add(consultation_session::Column::Status.eq("waiting")),
)
.one(&state.db)
.await?
.ok_or(HealthError::ConsultationNotFound)?;
let now = Utc::now();
let is_patient = req.sender_role == "patient";
let should_activate = session.status == "waiting";
// 创建消息
let active = consultation_message::ActiveModel {
@@ -199,9 +256,12 @@ pub async fn create_message(
};
let m = active.insert(&state.db).await?;
// 更新会话的 last_message_at 和未读计数
// 更新会话的 last_message_at 和未读计数waiting→active 自动触发
let mut session_active: consultation_session::ActiveModel = session.into();
session_active.last_message_at = Set(Some(now));
if should_activate {
session_active.status = Set("active".to_string());
}
// 根据发送者角色更新对方的 unread_count
if is_patient {
session_active.unread_count_doctor = Set(session_active.unread_count_doctor.unwrap() + 1);

View File

@@ -30,10 +30,10 @@ pub async fn list_doctors(
.filter(doctor_profile::Column::DeletedAt.is_null());
if let Some(ref s) = search {
// doctor_profile 没有 name 字段,按 license_number/department 模糊搜索
let pattern = format!("%{}%", s);
query = query.filter(
Condition::any()
.add(doctor_profile::Column::Name.contains(&pattern))
.add(doctor_profile::Column::LicenseNumber.contains(&pattern))
.add(doctor_profile::Column::Department.contains(&pattern))
.add(doctor_profile::Column::Specialty.contains(&pattern)),
@@ -79,6 +79,7 @@ pub async fn create_doctor(
id: Set(id),
tenant_id: Set(tenant_id),
user_id: Set(req.user_id),
name: Set(req.name),
department: Set(req.department),
title: Set(req.title),
specialty: Set(req.specialty),
@@ -119,8 +120,7 @@ pub async fn update_doctor(
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: doctor_profile::ActiveModel = model.into();
// doctor_profile 没有 name 字段,但 handler DTO 有 name
// name 需要通过 user_id 关联到 erp-auth 的 users 表,此处暂不处理
if let Some(v) = req.name { active.name = Set(v); }
if let Some(v) = req.department { active.department = Set(Some(v)); }
if let Some(v) = req.title { active.title = Set(Some(v)); }
if let Some(v) = req.specialty { active.specialty = Set(Some(v)); }
@@ -170,7 +170,7 @@ fn model_to_resp(m: doctor_profile::Model) -> DoctorResp {
DoctorResp {
id: m.id,
user_id: m.user_id,
name: m.user_id.map(|_| "".to_string()).unwrap_or_default(),
name: m.name,
department: m.department,
title: m.title,
specialty: m.specialty,

View File

@@ -1,6 +1,7 @@
//! 随访管理 Service — 随访任务CRUD、随访记录、状态流转
use chrono::Utc;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use uuid::Uuid;
@@ -82,6 +83,14 @@ pub async fn create_task(
version: Set(1),
};
let m = active.insert(&state.db).await?;
let event = DomainEvent::new(
"follow_up.created",
tenant_id,
serde_json::json!({ "task_id": m.id, "patient_id": m.patient_id }),
);
state.event_bus.publish(event, &state.db).await;
Ok(FollowUpTaskResp {
id: m.id, patient_id: m.patient_id, assigned_to: m.assigned_to,
follow_up_type: m.follow_up_type, planned_date: m.planned_date,
@@ -197,12 +206,44 @@ pub async fn create_record(
let record = record_active.insert(&state.db).await?;
let mut task_active: follow_up_task::ActiveModel = task.into();
let task_patient_id = task_active.patient_id.clone().unwrap();
let task_assigned_to = task_active.assigned_to.clone().unwrap();
let task_follow_up_type = task_active.follow_up_type.clone().unwrap();
task_active.status = Set("completed".to_string());
task_active.updated_at = Set(now);
task_active.updated_by = Set(operator_id);
task_active.version = Set(task_active.version.unwrap() + 1);
task_active.update(&state.db).await?;
// 当 next_follow_up_date 不为空时,自动创建后续随访任务
if let Some(next_date) = req.next_follow_up_date {
let new_task = follow_up_task::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(task_patient_id),
assigned_to: Set(task_assigned_to),
follow_up_type: Set(task_follow_up_type),
planned_date: Set(next_date),
status: Set("pending".to_string()),
content_template: Set(None),
related_appointment_id: Set(None),
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_task.insert(&state.db).await?;
}
let event = DomainEvent::new(
"follow_up.completed",
tenant_id,
serde_json::json!({ "task_id": record.task_id, "patient_id": task_patient_id }),
);
state.event_bus.publish(event, &state.db).await;
Ok(FollowUpRecordResp {
id: record.id, task_id: record.task_id, executed_by: record.executed_by,
executed_date: record.executed_date, result: record.result,

View File

@@ -1,6 +1,7 @@
//! 健康数据 Service — 体征记录、化验报告、体检记录、趋势分析
use chrono::Utc;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, QueryOrder, QuerySelect};
use uuid::Uuid;
@@ -27,7 +28,7 @@ pub async fn list_vital_signs(
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = vital_signs::Entity::find()
let query = vital_signs::Entity::find()
.filter(vital_signs::Column::TenantId.eq(tenant_id))
.filter(vital_signs::Column::PatientId.eq(patient_id))
.filter(vital_signs::Column::DeletedAt.is_null());
@@ -191,7 +192,7 @@ pub async fn list_lab_reports(
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = lab_report::Entity::find()
let query = lab_report::Entity::find()
.filter(lab_report::Column::TenantId.eq(tenant_id))
.filter(lab_report::Column::PatientId.eq(patient_id))
.filter(lab_report::Column::DeletedAt.is_null());
@@ -240,6 +241,14 @@ pub async fn create_lab_report(
version: Set(1),
};
let m = active.insert(&state.db).await?;
let event = DomainEvent::new(
"lab_report.uploaded",
tenant_id,
serde_json::json!({ "report_id": m.id, "patient_id": m.patient_id, "report_type": m.report_type }),
);
state.event_bus.publish(event, &state.db).await;
Ok(LabReportResp {
id: m.id, patient_id: m.patient_id, report_date: m.report_date,
report_type: m.report_type, indicators: m.indicators,
@@ -322,7 +331,7 @@ pub async fn list_health_records(
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = health_record::Entity::find()
let query = health_record::Entity::find()
.filter(health_record::Column::TenantId.eq(tenant_id))
.filter(health_record::Column::PatientId.eq(patient_id))
.filter(health_record::Column::DeletedAt.is_null());
@@ -455,7 +464,7 @@ pub async fn list_trends(
let limit = page_size.min(100);
let offset = page.saturating_sub(1) * limit;
let mut query = health_trend::Entity::find()
let query = health_trend::Entity::find()
.filter(health_trend::Column::TenantId.eq(tenant_id))
.filter(health_trend::Column::PatientId.eq(patient_id))
.filter(health_trend::Column::DeletedAt.is_null());

View File

@@ -1,6 +1,7 @@
//! 患者管理 Service — CRUD、家庭成员、标签、医生关联、健康摘要
use chrono::Utc;
use erp_core::events::DomainEvent;
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect};
use uuid::Uuid;
@@ -122,6 +123,14 @@ pub async fn create_patient(
};
let model = active.insert(&state.db).await?;
let event = DomainEvent::new(
"patient.created",
tenant_id,
serde_json::json!({ "patient_id": model.id, "name": model.name }),
);
state.event_bus.publish(event, &state.db).await;
Ok(model_to_resp(model))
}
@@ -167,6 +176,14 @@ pub async fn update_patient(
active.version = Set(next_ver);
let updated = active.update(&state.db).await?;
let event = DomainEvent::new(
"patient.updated",
tenant_id,
serde_json::json!({ "patient_id": updated.id }),
);
state.event_bus.publish(event, &state.db).await;
Ok(model_to_resp(updated))
}
@@ -277,7 +294,7 @@ pub async fn get_health_summary(
let upcoming = appointment::Entity::find()
.filter(appointment::Column::TenantId.eq(tenant_id))
.filter(appointment::Column::PatientId.eq(patient_id))
.filter(appointment::Column::Status.eq("scheduled"))
.filter(appointment::Column::Status.eq("pending"))
.filter(appointment::Column::DeletedAt.is_null())
.count(&state.db)
.await?;

View File

@@ -1,22 +1,95 @@
//! 租户初始化种子数据 — 创建默认标签、默认排班模板等
//! 租户初始化种子数据 — 创建默认标签
use sea_orm::DatabaseConnection;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ActiveValue::Set, ConnectionTrait, DatabaseConnection};
use uuid::Uuid;
use crate::entity::patient_tag;
const DEFAULT_TAGS: &[(&str, &str, &str)] = &[
("高血压", "#E74C3C", "高血压患者标签"),
("糖尿病", "#3498DB", "糖尿病患者标签"),
("心脏病", "#9B59B6", "心脏病患者标签"),
("过敏体质", "#F39C12", "过敏体质患者标签"),
("老年患者", "#1ABC9C", "65岁以上老年患者"),
("孕产妇", "#E91E63", "孕产期健康管理"),
("慢性病", "#607D8B", "慢性病长期随访"),
("术后随访", "#795548", "手术后随访管理"),
];
/// 初始化租户健康模块默认数据
pub async fn seed_tenant_health(
_db: &DatabaseConnection,
db: &DatabaseConnection,
tenant_id: Uuid,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing::info!(tenant_id = %tenant_id, "Seeding health module default data");
let now = Utc::now();
for (name, color, description) in DEFAULT_TAGS {
let active = patient_tag::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
name: Set(name.to_string()),
color: Set(Some(color.to_string())),
description: Set(Some(description.to_string())),
is_system: Set(true),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(None),
updated_by: Set(None),
deleted_at: Set(None),
version: Set(1),
};
active.insert(db).await?;
}
tracing::info!(tenant_id = %tenant_id, "Health module default data seeded successfully");
Ok(())
}
/// 软删除该租户下所有健康模块数据
pub async fn soft_delete_tenant_data(
_db: &DatabaseConnection,
db: &DatabaseConnection,
tenant_id: Uuid,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing::info!(tenant_id = %tenant_id, "Soft-deleting health module data for tenant");
let now = Utc::now();
let tables_to_soft_delete: Vec<&str> = vec![
"consultation_message",
"consultation_session",
"follow_up_record",
"follow_up_task",
"appointment",
"doctor_schedule",
"health_trend",
"lab_report",
"health_record",
"vital_signs",
"patient_doctor_relation",
"patient_tag_relation",
"patient_family_member",
"patient",
"patient_tag",
"doctor_profile",
];
for table in tables_to_soft_delete {
let sql = format!(
"UPDATE {} SET deleted_at = $1, updated_at = $1 WHERE tenant_id = $2 AND deleted_at IS NULL",
table
);
let stmt = sea_orm::Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
&sql,
[now.into(), tenant_id.into()],
);
if let Err(e) = db.execute(stmt).await {
tracing::warn!(table = %table, error = %e, "Failed to soft-delete table");
}
}
tracing::info!(tenant_id = %tenant_id, "Health module data soft-deleted");
Ok(())
}

View File

@@ -93,6 +93,7 @@ impl MigrationTrait for Migration {
.table(Patient::Table)
.col(Patient::TenantId)
.col(Patient::IdNumber)
.unique()
.to_owned(),
)
.await?;
@@ -266,6 +267,20 @@ impl MigrationTrait for Migration {
)
.await?;
// patient_tag 唯一名称索引
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_patient_tag_tenant_name_unique")
.table(PatientTag::Table)
.col(PatientTag::TenantId)
.col(PatientTag::Name)
.unique()
.to_owned(),
)
.await?;
// 5. doctor_profile — 医护档案
manager
.create_table(
@@ -275,6 +290,7 @@ impl MigrationTrait for Migration {
.col(ColumnDef::new(DoctorProfile::Id).uuid().not_null().primary_key())
.col(ColumnDef::new(DoctorProfile::TenantId).uuid().not_null())
.col(ColumnDef::new(DoctorProfile::UserId).uuid().null())
.col(ColumnDef::new(DoctorProfile::Name).string_len(100).not_null())
.col(ColumnDef::new(DoctorProfile::Department).string_len(100).null())
.col(ColumnDef::new(DoctorProfile::Title).string_len(50).null())
.col(ColumnDef::new(DoctorProfile::Specialty).string_len(200).null())
@@ -311,6 +327,19 @@ impl MigrationTrait for Migration {
)
.await?;
// doctor_profile 名称搜索索引
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_doctor_profile_tenant_name")
.table(DoctorProfile::Table)
.col(DoctorProfile::TenantId)
.col(DoctorProfile::Name)
.to_owned(),
)
.await?;
// 6. patient_doctor_relation — 医患关系
manager
.create_table(
@@ -529,8 +558,8 @@ impl MigrationTrait for Migration {
.col(ColumnDef::new(LabReport::PatientId).uuid().not_null())
.col(ColumnDef::new(LabReport::ReportDate).date().not_null())
.col(ColumnDef::new(LabReport::ReportType).string_len(50).not_null())
.col(ColumnDef::new(LabReport::Indicators).json().null())
.col(ColumnDef::new(LabReport::ImageUrls).json().null())
.col(ColumnDef::new(LabReport::Indicators).json_binary().null())
.col(ColumnDef::new(LabReport::ImageUrls).json_binary().null())
.col(ColumnDef::new(LabReport::DoctorInterpretation).text().null())
.col(
ColumnDef::new(LabReport::CreatedAt)
@@ -587,8 +616,8 @@ impl MigrationTrait for Migration {
.col(ColumnDef::new(HealthTrend::PatientId).uuid().not_null())
.col(ColumnDef::new(HealthTrend::PeriodStart).date().not_null())
.col(ColumnDef::new(HealthTrend::PeriodEnd).date().not_null())
.col(ColumnDef::new(HealthTrend::IndicatorSummary).json().null())
.col(ColumnDef::new(HealthTrend::AbnormalItems).json().null())
.col(ColumnDef::new(HealthTrend::IndicatorSummary).json_binary().null())
.col(ColumnDef::new(HealthTrend::AbnormalItems).json_binary().null())
.col(
ColumnDef::new(HealthTrend::GenerationType)
.string_len(20)
@@ -795,6 +824,22 @@ impl MigrationTrait for Migration {
)
.await?;
// doctor_schedule 唯一约束:同一医生同一天同一时段不能重复排班
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_doctor_schedule_unique_slot")
.table(DoctorSchedule::Table)
.col(DoctorSchedule::TenantId)
.col(DoctorSchedule::DoctorId)
.col(DoctorSchedule::ScheduleDate)
.col(DoctorSchedule::StartTime)
.unique()
.to_owned(),
)
.await?;
// 13. follow_up_task — 随访任务
manager
.create_table(
@@ -1130,6 +1175,19 @@ impl MigrationTrait for Migration {
)
.await?;
// health_trend 索引
manager
.create_index(
Index::create()
.if_not_exists()
.name("idx_health_trend_tenant_patient")
.table(HealthTrend::Table)
.col(HealthTrend::TenantId)
.col(HealthTrend::PatientId)
.to_owned(),
)
.await?;
Ok(())
}
@@ -1237,6 +1295,7 @@ enum DoctorProfile {
Id,
TenantId,
UserId,
Name,
Department,
Title,
Specialty,