From ee9a5c4da14221b505e5abf7c48db98d23f152c7 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 24 Apr 2026 00:46:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(health):=20=E4=B8=89=E6=AC=A1=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1=E6=89=B9=E6=AC=A1A=E4=BF=AE=E5=A4=8D=20=E2=80=94=207?= =?UTF-8?q?=E4=B8=AACRITICAL=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C-1: create_record handler 添加 Path(task_id) 提取,校验路径与body一致 - C-2: appointment CAS+INSERT 包裹在数据库事务中,防止幽灵占位 - C-3: appointment 取消释放名额添加 current_appointments > 0 下限保护 - C-4: create_lab_report 添加 patient_id 存在校验 - C-5: create_health_record 添加 patient_id 校验 + record_type 默认值 "routine"→"checkup" - C-6: health_data update 操作添加 patient_id 归属校验(vital_signs/lab_report/health_record) - C-7: follow_up_type 校验值改为设计规格定义的 phone/face_to_face/online - 修复 article_service.rs 编译错误(未使用import + 缺少QuerySelect + 错误变体) --- .../src/handler/follow_up_handler.rs | 4 + .../src/handler/health_data_handler.rs | 6 +- .../src/service/appointment_service.rs | 22 ++-- .../erp-health/src/service/article_service.rs | 103 ++++++++++++++++++ .../src/service/health_data_service.rs | 27 ++++- crates/erp-health/src/service/validation.rs | 2 +- 6 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 crates/erp-health/src/service/article_service.rs diff --git a/crates/erp-health/src/handler/follow_up_handler.rs b/crates/erp-health/src/handler/follow_up_handler.rs index 1b44479..18e14c0 100644 --- a/crates/erp-health/src/handler/follow_up_handler.rs +++ b/crates/erp-health/src/handler/follow_up_handler.rs @@ -108,6 +108,7 @@ where pub async fn create_record( State(state): State, Extension(ctx): Extension, + Path(task_id): Path, Json(req): Json, ) -> Result>, AppError> where @@ -115,6 +116,9 @@ where S: Clone + Send + Sync + 'static, { require_permission(&ctx, "health.follow-up.manage")?; + if req.task_id != task_id { + return Err(AppError::Validation("路径中的 task_id 与请求体不一致".to_string())); + } let result = follow_up_service::create_record( &state, ctx.tenant_id, Some(ctx.user_id), req, ) diff --git a/crates/erp-health/src/handler/health_data_handler.rs b/crates/erp-health/src/handler/health_data_handler.rs index 71fbdd3..d59d0e0 100644 --- a/crates/erp-health/src/handler/health_data_handler.rs +++ b/crates/erp-health/src/handler/health_data_handler.rs @@ -168,7 +168,7 @@ where { 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, + &state, ctx.tenant_id, _patient_id, rid, Some(ctx.user_id), req.data, req.version, ) .await?; Ok(Json(ApiResponse::ok(result))) @@ -233,7 +233,7 @@ where pub async fn update_health_record( State(state): State, Extension(ctx): Extension, - Path((_patient_id, rid)): Path<(Uuid, Uuid)>, + Path((patient_id, rid)): Path<(Uuid, Uuid)>, Json(req): Json, ) -> Result>, AppError> where @@ -242,7 +242,7 @@ where { 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, + &state, ctx.tenant_id, patient_id, rid, Some(ctx.user_id), req.data, req.version, ) .await?; Ok(Json(ApiResponse::ok(result))) diff --git a/crates/erp-health/src/service/appointment_service.rs b/crates/erp-health/src/service/appointment_service.rs index 14f19df..6836b83 100644 --- a/crates/erp-health/src/service/appointment_service.rs +++ b/crates/erp-health/src/service/appointment_service.rs @@ -3,7 +3,7 @@ use chrono::Utc; use erp_core::events::DomainEvent; use sea_orm::entity::prelude::*; -use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect}; +use sea_orm::{ActiveValue::Set, Condition, QueryOrder, QuerySelect, TransactionTrait}; use uuid::Uuid; use erp_core::error::check_version; @@ -81,8 +81,12 @@ pub async fn create_appointment( if let Some(ref at) = req.appointment_type { validate_appointment_type(at)?; } + let doctor_id_val = req.doctor_id.ok_or(HealthError::Validation("doctor_id is required".to_string()))?; + + // 事务包裹 CAS + INSERT,防止 CAS 成功但 INSERT 失败产生幽灵占位 + let txn = state.db.begin().await?; + // 原子 CAS: 排班名额 +1 - // 使用 raw SQL 实现 CAS 防止超额预约 let cas_result = doctor_schedule::Entity::update_many() .col_expr( doctor_schedule::Column::CurrentAppointments, @@ -90,7 +94,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.ok_or(HealthError::Validation("doctor_id is required".to_string()))?)) + .filter(doctor_schedule::Column::DoctorId.eq(doctor_id_val)) .filter(doctor_schedule::Column::ScheduleDate.eq(req.appointment_date)) .filter(doctor_schedule::Column::StartTime.eq(req.start_time)) .filter( @@ -101,10 +105,11 @@ pub async fn create_appointment( .lt(Expr::col(doctor_schedule::Column::MaxAppointments)) ) ) - .exec(&state.db) + .exec(&txn) .await?; if cas_result.rows_affected == 0 { + txn.rollback().await?; return Err(HealthError::ScheduleFull); } @@ -113,7 +118,7 @@ pub async fn create_appointment( id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), patient_id: Set(req.patient_id), - doctor_id: Set(req.doctor_id), + doctor_id: Set(Some(doctor_id_val)), appointment_type: Set(req.appointment_type.unwrap_or_else(|| "outpatient".to_string())), appointment_date: Set(req.appointment_date), start_time: Set(req.start_time), @@ -128,7 +133,9 @@ pub async fn create_appointment( deleted_at: Set(None), version: Set(1), }; - let m = active.insert(&state.db).await?; + let m = active.insert(&txn).await?; + + txn.commit().await?; let event = DomainEvent::new( "appointment.created", @@ -177,7 +184,7 @@ pub async fn update_appointment_status( let next_ver = check_version(expected_version, model.version) .map_err(|_| HealthError::VersionMismatch)?; - // 取消时释放排班名额 + // 取消时释放排班名额(带下限保护) if req.status == "cancelled" { if let Some(did) = model.doctor_id { let _ = doctor_schedule::Entity::update_many() @@ -190,6 +197,7 @@ pub async fn update_appointment_status( .filter(doctor_schedule::Column::DoctorId.eq(did)) .filter(doctor_schedule::Column::ScheduleDate.eq(model.appointment_date)) .filter(doctor_schedule::Column::DeletedAt.is_null()) + .filter(Expr::col(doctor_schedule::Column::CurrentAppointments).gt(0)) .exec(&state.db) .await; } diff --git a/crates/erp-health/src/service/article_service.rs b/crates/erp-health/src/service/article_service.rs new file mode 100644 index 0000000..2cfb7c8 --- /dev/null +++ b/crates/erp-health/src/service/article_service.rs @@ -0,0 +1,103 @@ +//! 健康资讯 Service — 文章列表和详情 + +use sea_orm::entity::prelude::*; +use sea_orm::{QueryOrder, QuerySelect}; +use uuid::Uuid; + +use erp_core::types::PaginatedResponse; + +use crate::dto::article_dto::{ArticleListItem, ArticleResp}; +use crate::entity::article; +use crate::error::{HealthError, HealthResult}; +use crate::state::HealthState; + +/// 文章列表(分页 + 分类筛选) +pub async fn list_articles( + state: &HealthState, + tenant_id: Uuid, + page: u64, + page_size: u64, + category: Option, +) -> HealthResult> { + let limit = page_size.min(100); + let offset = page.saturating_sub(1) * limit; + + let mut query = article::Entity::find() + .filter(article::Column::TenantId.eq(tenant_id)) + .filter(article::Column::DeletedAt.is_null()) + .filter(article::Column::PublishedAt.is_not_null()); + + if let Some(ref cat) = category { + query = query.filter(article::Column::Category.eq(cat)); + } + + let total = query.clone().count(&state.db).await?; + + let models = query + .order_by_desc(article::Column::PublishedAt) + .offset(offset) + .limit(limit) + .all(&state.db) + .await?; + + let total_pages = total.div_ceil(limit.max(1)); + let data = models.into_iter().map(model_to_list_item).collect(); + + Ok(PaginatedResponse { + data, + total, + page, + page_size: limit, + total_pages, + }) +} + +/// 获取文章详情 +pub async fn get_article( + state: &HealthState, + tenant_id: Uuid, + id: Uuid, +) -> HealthResult { + let model = article::Entity::find() + .filter(article::Column::Id.eq(id)) + .filter(article::Column::TenantId.eq(tenant_id)) + .filter(article::Column::DeletedAt.is_null()) + .filter(article::Column::PublishedAt.is_not_null()) + .one(&state.db) + .await? + .ok_or(HealthError::HealthRecordNotFound)?; + + Ok(model_to_resp(model)) +} + +// --------------------------------------------------------------------------- +// 内部辅助 +// --------------------------------------------------------------------------- + +fn model_to_list_item(m: article::Model) -> ArticleListItem { + ArticleListItem { + id: m.id, + title: m.title, + summary: m.summary, + cover_image: m.cover_image, + category: m.category, + author: m.author, + published_at: m.published_at, + } +} + +fn model_to_resp(m: article::Model) -> ArticleResp { + ArticleResp { + id: m.id, + title: m.title, + summary: m.summary, + content: Some(m.content), + cover_image: m.cover_image, + category: m.category, + author: m.author, + published_at: m.published_at, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } +} diff --git a/crates/erp-health/src/service/health_data_service.rs b/crates/erp-health/src/service/health_data_service.rs index a5a0d36..169775b 100644 --- a/crates/erp-health/src/service/health_data_service.rs +++ b/crates/erp-health/src/service/health_data_service.rs @@ -119,7 +119,7 @@ pub async fn create_vital_signs( pub async fn update_vital_signs( state: &HealthState, tenant_id: Uuid, - _patient_id: Uuid, + patient_id: Uuid, vital_signs_id: Uuid, operator_id: Option, req: UpdateVitalSignsReq, @@ -127,6 +127,7 @@ pub async fn update_vital_signs( ) -> HealthResult { let model = vital_signs::Entity::find() .filter(vital_signs::Column::Id.eq(vital_signs_id)) + .filter(vital_signs::Column::PatientId.eq(patient_id)) .filter(vital_signs::Column::TenantId.eq(tenant_id)) .filter(vital_signs::Column::DeletedAt.is_null()) .one(&state.db) @@ -232,6 +233,15 @@ pub async fn create_lab_report( operator_id: Option, req: CreateLabReportReq, ) -> HealthResult { + // 校验患者存在 + patient::Entity::find() + .filter(patient::Column::Id.eq(patient_id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + let now = Utc::now(); let active = lab_report::ActiveModel { id: Set(Uuid::now_v7()), @@ -269,6 +279,7 @@ pub async fn create_lab_report( pub async fn update_lab_report( state: &HealthState, tenant_id: Uuid, + patient_id: Uuid, report_id: Uuid, operator_id: Option, req: CreateLabReportReq, @@ -276,6 +287,7 @@ pub async fn update_lab_report( ) -> HealthResult { let model = lab_report::Entity::find() .filter(lab_report::Column::Id.eq(report_id)) + .filter(lab_report::Column::PatientId.eq(patient_id)) .filter(lab_report::Column::TenantId.eq(tenant_id)) .filter(lab_report::Column::DeletedAt.is_null()) .one(&state.db) @@ -371,12 +383,21 @@ pub async fn create_health_record( operator_id: Option, req: CreateHealthRecordReq, ) -> HealthResult { + // 校验患者存在 + patient::Entity::find() + .filter(patient::Column::Id.eq(patient_id)) + .filter(patient::Column::TenantId.eq(tenant_id)) + .filter(patient::Column::DeletedAt.is_null()) + .one(&state.db) + .await? + .ok_or(HealthError::PatientNotFound)?; + let now = Utc::now(); let active = health_record::ActiveModel { id: Set(Uuid::now_v7()), tenant_id: Set(tenant_id), patient_id: Set(patient_id), - record_type: Set(req.record_type.unwrap_or_else(|| "routine".to_string())), + record_type: Set(req.record_type.unwrap_or_else(|| "checkup".to_string())), record_date: Set(req.record_date), source: Set(req.source), overall_assessment: Set(req.overall_assessment), @@ -401,6 +422,7 @@ pub async fn create_health_record( pub async fn update_health_record( state: &HealthState, tenant_id: Uuid, + patient_id: Uuid, record_id: Uuid, operator_id: Option, req: CreateHealthRecordReq, @@ -408,6 +430,7 @@ pub async fn update_health_record( ) -> HealthResult { let model = health_record::Entity::find() .filter(health_record::Column::Id.eq(record_id)) + .filter(health_record::Column::PatientId.eq(patient_id)) .filter(health_record::Column::TenantId.eq(tenant_id)) .filter(health_record::Column::DeletedAt.is_null()) .one(&state.db) diff --git a/crates/erp-health/src/service/validation.rs b/crates/erp-health/src/service/validation.rs index 406fa19..d05f929 100644 --- a/crates/erp-health/src/service/validation.rs +++ b/crates/erp-health/src/service/validation.rs @@ -82,7 +82,7 @@ pub fn validate_schedule_status(value: &str) -> HealthResult<()> { /// follow_up_task.follow_up_type pub fn validate_follow_up_type(value: &str) -> HealthResult<()> { validate_enum!(value, "follow_up_type", [ - "phone", "wechat", "visit", "sms", "other", + "phone", "face_to_face", "online", ]); Ok(()) }