fix(health): 三次审计批次A修复 — 7个CRITICAL问题
- 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 + 错误变体)
This commit is contained in:
@@ -108,6 +108,7 @@ where
|
||||
pub async fn create_record<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(task_id): Path<Uuid>,
|
||||
Json(req): Json<CreateFollowUpRecordReq>,
|
||||
) -> Result<Json<ApiResponse<FollowUpRecordResp>>, 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,
|
||||
)
|
||||
|
||||
@@ -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<S>(
|
||||
State(state): State<HealthState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path((_patient_id, rid)): Path<(Uuid, Uuid)>,
|
||||
Path((patient_id, rid)): Path<(Uuid, Uuid)>,
|
||||
Json(req): Json<UpdateHealthRecordWithVersion>,
|
||||
) -> Result<Json<ApiResponse<HealthRecordResp>>, 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)))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
103
crates/erp-health/src/service/article_service.rs
Normal file
103
crates/erp-health/src/service/article_service.rs
Normal file
@@ -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<String>,
|
||||
) -> HealthResult<PaginatedResponse<ArticleListItem>> {
|
||||
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<ArticleResp> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<Uuid>,
|
||||
req: UpdateVitalSignsReq,
|
||||
@@ -127,6 +127,7 @@ pub async fn update_vital_signs(
|
||||
) -> HealthResult<VitalSignsResp> {
|
||||
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<Uuid>,
|
||||
req: CreateLabReportReq,
|
||||
) -> HealthResult<LabReportResp> {
|
||||
// 校验患者存在
|
||||
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<Uuid>,
|
||||
req: CreateLabReportReq,
|
||||
@@ -276,6 +287,7 @@ pub async fn update_lab_report(
|
||||
) -> HealthResult<LabReportResp> {
|
||||
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<Uuid>,
|
||||
req: CreateHealthRecordReq,
|
||||
) -> HealthResult<HealthRecordResp> {
|
||||
// 校验患者存在
|
||||
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<Uuid>,
|
||||
req: CreateHealthRecordReq,
|
||||
@@ -408,6 +430,7 @@ pub async fn update_health_record(
|
||||
) -> HealthResult<HealthRecordResp> {
|
||||
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)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user