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>(
|
pub async fn create_record<S>(
|
||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(task_id): Path<Uuid>,
|
||||||
Json(req): Json<CreateFollowUpRecordReq>,
|
Json(req): Json<CreateFollowUpRecordReq>,
|
||||||
) -> Result<Json<ApiResponse<FollowUpRecordResp>>, AppError>
|
) -> Result<Json<ApiResponse<FollowUpRecordResp>>, AppError>
|
||||||
where
|
where
|
||||||
@@ -115,6 +116,9 @@ where
|
|||||||
S: Clone + Send + Sync + 'static,
|
S: Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
require_permission(&ctx, "health.follow-up.manage")?;
|
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(
|
let result = follow_up_service::create_record(
|
||||||
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
&state, ctx.tenant_id, Some(ctx.user_id), req,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ where
|
|||||||
{
|
{
|
||||||
require_permission(&ctx, "health.health-data.manage")?;
|
require_permission(&ctx, "health.health-data.manage")?;
|
||||||
let result = health_data_service::update_lab_report(
|
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?;
|
.await?;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
@@ -233,7 +233,7 @@ where
|
|||||||
pub async fn update_health_record<S>(
|
pub async fn update_health_record<S>(
|
||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
Path((_patient_id, rid)): Path<(Uuid, Uuid)>,
|
Path((patient_id, rid)): Path<(Uuid, Uuid)>,
|
||||||
Json(req): Json<UpdateHealthRecordWithVersion>,
|
Json(req): Json<UpdateHealthRecordWithVersion>,
|
||||||
) -> Result<Json<ApiResponse<HealthRecordResp>>, AppError>
|
) -> Result<Json<ApiResponse<HealthRecordResp>>, AppError>
|
||||||
where
|
where
|
||||||
@@ -242,7 +242,7 @@ where
|
|||||||
{
|
{
|
||||||
require_permission(&ctx, "health.health-data.manage")?;
|
require_permission(&ctx, "health.health-data.manage")?;
|
||||||
let result = health_data_service::update_health_record(
|
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?;
|
.await?;
|
||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use erp_core::events::DomainEvent;
|
use erp_core::events::DomainEvent;
|
||||||
use sea_orm::entity::prelude::*;
|
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 uuid::Uuid;
|
||||||
|
|
||||||
use erp_core::error::check_version;
|
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)?; }
|
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
|
// 原子 CAS: 排班名额 +1
|
||||||
// 使用 raw SQL 实现 CAS 防止超额预约
|
|
||||||
let cas_result = doctor_schedule::Entity::update_many()
|
let cas_result = doctor_schedule::Entity::update_many()
|
||||||
.col_expr(
|
.col_expr(
|
||||||
doctor_schedule::Column::CurrentAppointments,
|
doctor_schedule::Column::CurrentAppointments,
|
||||||
@@ -90,7 +94,7 @@ pub async fn create_appointment(
|
|||||||
)
|
)
|
||||||
.col_expr(doctor_schedule::Column::UpdatedAt, Expr::value(Utc::now()))
|
.col_expr(doctor_schedule::Column::UpdatedAt, Expr::value(Utc::now()))
|
||||||
.filter(doctor_schedule::Column::TenantId.eq(tenant_id))
|
.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::ScheduleDate.eq(req.appointment_date))
|
||||||
.filter(doctor_schedule::Column::StartTime.eq(req.start_time))
|
.filter(doctor_schedule::Column::StartTime.eq(req.start_time))
|
||||||
.filter(
|
.filter(
|
||||||
@@ -101,10 +105,11 @@ pub async fn create_appointment(
|
|||||||
.lt(Expr::col(doctor_schedule::Column::MaxAppointments))
|
.lt(Expr::col(doctor_schedule::Column::MaxAppointments))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.exec(&state.db)
|
.exec(&txn)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if cas_result.rows_affected == 0 {
|
if cas_result.rows_affected == 0 {
|
||||||
|
txn.rollback().await?;
|
||||||
return Err(HealthError::ScheduleFull);
|
return Err(HealthError::ScheduleFull);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ pub async fn create_appointment(
|
|||||||
id: Set(Uuid::now_v7()),
|
id: Set(Uuid::now_v7()),
|
||||||
tenant_id: Set(tenant_id),
|
tenant_id: Set(tenant_id),
|
||||||
patient_id: Set(req.patient_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_type: Set(req.appointment_type.unwrap_or_else(|| "outpatient".to_string())),
|
||||||
appointment_date: Set(req.appointment_date),
|
appointment_date: Set(req.appointment_date),
|
||||||
start_time: Set(req.start_time),
|
start_time: Set(req.start_time),
|
||||||
@@ -128,7 +133,9 @@ pub async fn create_appointment(
|
|||||||
deleted_at: Set(None),
|
deleted_at: Set(None),
|
||||||
version: Set(1),
|
version: Set(1),
|
||||||
};
|
};
|
||||||
let m = active.insert(&state.db).await?;
|
let m = active.insert(&txn).await?;
|
||||||
|
|
||||||
|
txn.commit().await?;
|
||||||
|
|
||||||
let event = DomainEvent::new(
|
let event = DomainEvent::new(
|
||||||
"appointment.created",
|
"appointment.created",
|
||||||
@@ -177,7 +184,7 @@ pub async fn update_appointment_status(
|
|||||||
let next_ver = check_version(expected_version, model.version)
|
let next_ver = check_version(expected_version, model.version)
|
||||||
.map_err(|_| HealthError::VersionMismatch)?;
|
.map_err(|_| HealthError::VersionMismatch)?;
|
||||||
|
|
||||||
// 取消时释放排班名额
|
// 取消时释放排班名额(带下限保护)
|
||||||
if req.status == "cancelled" {
|
if req.status == "cancelled" {
|
||||||
if let Some(did) = model.doctor_id {
|
if let Some(did) = model.doctor_id {
|
||||||
let _ = doctor_schedule::Entity::update_many()
|
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::DoctorId.eq(did))
|
||||||
.filter(doctor_schedule::Column::ScheduleDate.eq(model.appointment_date))
|
.filter(doctor_schedule::Column::ScheduleDate.eq(model.appointment_date))
|
||||||
.filter(doctor_schedule::Column::DeletedAt.is_null())
|
.filter(doctor_schedule::Column::DeletedAt.is_null())
|
||||||
|
.filter(Expr::col(doctor_schedule::Column::CurrentAppointments).gt(0))
|
||||||
.exec(&state.db)
|
.exec(&state.db)
|
||||||
.await;
|
.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(
|
pub async fn update_vital_signs(
|
||||||
state: &HealthState,
|
state: &HealthState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
_patient_id: Uuid,
|
patient_id: Uuid,
|
||||||
vital_signs_id: Uuid,
|
vital_signs_id: Uuid,
|
||||||
operator_id: Option<Uuid>,
|
operator_id: Option<Uuid>,
|
||||||
req: UpdateVitalSignsReq,
|
req: UpdateVitalSignsReq,
|
||||||
@@ -127,6 +127,7 @@ pub async fn update_vital_signs(
|
|||||||
) -> HealthResult<VitalSignsResp> {
|
) -> HealthResult<VitalSignsResp> {
|
||||||
let model = vital_signs::Entity::find()
|
let model = vital_signs::Entity::find()
|
||||||
.filter(vital_signs::Column::Id.eq(vital_signs_id))
|
.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::TenantId.eq(tenant_id))
|
||||||
.filter(vital_signs::Column::DeletedAt.is_null())
|
.filter(vital_signs::Column::DeletedAt.is_null())
|
||||||
.one(&state.db)
|
.one(&state.db)
|
||||||
@@ -232,6 +233,15 @@ pub async fn create_lab_report(
|
|||||||
operator_id: Option<Uuid>,
|
operator_id: Option<Uuid>,
|
||||||
req: CreateLabReportReq,
|
req: CreateLabReportReq,
|
||||||
) -> HealthResult<LabReportResp> {
|
) -> 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 now = Utc::now();
|
||||||
let active = lab_report::ActiveModel {
|
let active = lab_report::ActiveModel {
|
||||||
id: Set(Uuid::now_v7()),
|
id: Set(Uuid::now_v7()),
|
||||||
@@ -269,6 +279,7 @@ pub async fn create_lab_report(
|
|||||||
pub async fn update_lab_report(
|
pub async fn update_lab_report(
|
||||||
state: &HealthState,
|
state: &HealthState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
|
patient_id: Uuid,
|
||||||
report_id: Uuid,
|
report_id: Uuid,
|
||||||
operator_id: Option<Uuid>,
|
operator_id: Option<Uuid>,
|
||||||
req: CreateLabReportReq,
|
req: CreateLabReportReq,
|
||||||
@@ -276,6 +287,7 @@ pub async fn update_lab_report(
|
|||||||
) -> HealthResult<LabReportResp> {
|
) -> HealthResult<LabReportResp> {
|
||||||
let model = lab_report::Entity::find()
|
let model = lab_report::Entity::find()
|
||||||
.filter(lab_report::Column::Id.eq(report_id))
|
.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::TenantId.eq(tenant_id))
|
||||||
.filter(lab_report::Column::DeletedAt.is_null())
|
.filter(lab_report::Column::DeletedAt.is_null())
|
||||||
.one(&state.db)
|
.one(&state.db)
|
||||||
@@ -371,12 +383,21 @@ pub async fn create_health_record(
|
|||||||
operator_id: Option<Uuid>,
|
operator_id: Option<Uuid>,
|
||||||
req: CreateHealthRecordReq,
|
req: CreateHealthRecordReq,
|
||||||
) -> HealthResult<HealthRecordResp> {
|
) -> 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 now = Utc::now();
|
||||||
let active = health_record::ActiveModel {
|
let active = health_record::ActiveModel {
|
||||||
id: Set(Uuid::now_v7()),
|
id: Set(Uuid::now_v7()),
|
||||||
tenant_id: Set(tenant_id),
|
tenant_id: Set(tenant_id),
|
||||||
patient_id: Set(patient_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),
|
record_date: Set(req.record_date),
|
||||||
source: Set(req.source),
|
source: Set(req.source),
|
||||||
overall_assessment: Set(req.overall_assessment),
|
overall_assessment: Set(req.overall_assessment),
|
||||||
@@ -401,6 +422,7 @@ pub async fn create_health_record(
|
|||||||
pub async fn update_health_record(
|
pub async fn update_health_record(
|
||||||
state: &HealthState,
|
state: &HealthState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
|
patient_id: Uuid,
|
||||||
record_id: Uuid,
|
record_id: Uuid,
|
||||||
operator_id: Option<Uuid>,
|
operator_id: Option<Uuid>,
|
||||||
req: CreateHealthRecordReq,
|
req: CreateHealthRecordReq,
|
||||||
@@ -408,6 +430,7 @@ pub async fn update_health_record(
|
|||||||
) -> HealthResult<HealthRecordResp> {
|
) -> HealthResult<HealthRecordResp> {
|
||||||
let model = health_record::Entity::find()
|
let model = health_record::Entity::find()
|
||||||
.filter(health_record::Column::Id.eq(record_id))
|
.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::TenantId.eq(tenant_id))
|
||||||
.filter(health_record::Column::DeletedAt.is_null())
|
.filter(health_record::Column::DeletedAt.is_null())
|
||||||
.one(&state.db)
|
.one(&state.db)
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ pub fn validate_schedule_status(value: &str) -> HealthResult<()> {
|
|||||||
/// follow_up_task.follow_up_type
|
/// follow_up_task.follow_up_type
|
||||||
pub fn validate_follow_up_type(value: &str) -> HealthResult<()> {
|
pub fn validate_follow_up_type(value: &str) -> HealthResult<()> {
|
||||||
validate_enum!(value, "follow_up_type", [
|
validate_enum!(value, "follow_up_type", [
|
||||||
"phone", "wechat", "visit", "sms", "other",
|
"phone", "face_to_face", "online",
|
||||||
]);
|
]);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user