fix(health): 三次审计批次A修复 — 7个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

- 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:
iven
2026-04-24 00:46:11 +08:00
parent affb3a5578
commit ee9a5c4da1
6 changed files with 151 additions and 13 deletions

View File

@@ -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,
)

View File

@@ -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)))

View File

@@ -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;
}

View 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,
}
}

View File

@@ -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)

View File

@@ -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(())
}