fix(health): 三次审计批次B修复 — 12个HIGH问题
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- H-6: appointment_service 状态转换复用 validation.rs 函数
- H-7: 添加 validate_record_type (checkup/outpatient/inpatient)
- H-8: 添加 validate_patient_status + validate_verification_status 白名单
- H-9: 添加 validate_online_status + online_status 变更事件
- H-10: create_appointment 添加 doctor_id 存在性检查
- H-12/H-13/H-14: 添加 lab_report GIN/health_trend/follow_up_record 索引
This commit is contained in:
iven
2026-04-24 01:07:04 +08:00
parent 0c73927450
commit 6fbe7ec530
8 changed files with 125 additions and 21 deletions

View File

@@ -30,6 +30,7 @@ pub struct UpdateDoctorReq {
pub specialty: Option<String>,
pub license_number: Option<String>,
pub bio: Option<String>,
pub online_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]

View File

@@ -10,10 +10,10 @@ use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::appointment_dto::*;
use crate::entity::{appointment, doctor_schedule, patient};
use crate::entity::{appointment, doctor_profile, doctor_schedule, patient};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::{
validate_appointment_type,
validate_appointment_status_transition, validate_appointment_type,
validate_period_type, validate_schedule_status,
};
use crate::state::HealthState;
@@ -83,6 +83,15 @@ pub async fn create_appointment(
let doctor_id_val = req.doctor_id.ok_or(HealthError::Validation("doctor_id is required".to_string()))?;
// 校验医护存在
doctor_profile::Entity::find()
.filter(doctor_profile::Column::Id.eq(doctor_id_val))
.filter(doctor_profile::Column::TenantId.eq(tenant_id))
.filter(doctor_profile::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::DoctorNotFound)?;
// 事务包裹 CAS + INSERT防止 CAS 成功但 INSERT 失败产生幽灵占位
let txn = state.db.begin().await?;
@@ -170,16 +179,7 @@ pub async fn update_appointment_status(
.ok_or(HealthError::AppointmentNotFound)?;
// 状态机校验
let valid = match (model.status.as_str(), req.status.as_str()) {
("pending", "confirmed" | "cancelled") => true,
("confirmed", "completed" | "no_show" | "cancelled") => true,
_ => false,
};
if !valid {
return Err(HealthError::InvalidStatusTransition(format!(
"{} -> {}", model.status, req.status
)));
}
validate_appointment_status_transition(&model.status, &req.status)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;

View File

@@ -11,6 +11,7 @@ use erp_core::types::PaginatedResponse;
use crate::dto::doctor_dto::*;
use crate::entity::doctor_profile;
use crate::error::{HealthError, HealthResult};
use crate::service::validation::validate_online_status;
use crate::state::HealthState;
pub async fn list_doctors(
@@ -118,6 +119,7 @@ pub async fn update_doctor(
let model = find_doctor(&state.db, tenant_id, id).await?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
let old_online_status = model.online_status.clone();
let mut active: doctor_profile::ActiveModel = model.into();
if let Some(v) = req.name { active.name = Set(v); }
@@ -126,6 +128,18 @@ pub async fn update_doctor(
if let Some(v) = req.specialty { active.specialty = Set(Some(v)); }
if let Some(v) = req.license_number { active.license_number = Set(Some(v)); }
if let Some(v) = req.bio { active.bio = Set(Some(v)); }
if let Some(ref v) = req.online_status {
validate_online_status(v)?;
active.online_status = Set(v.clone());
if old_online_status != *v {
let event = erp_core::events::DomainEvent::new(
"doctor.online_status_changed",
tenant_id,
serde_json::json!({ "doctor_id": id, "old_status": old_online_status, "new_status": v }),
);
state.event_bus.publish(event, &state.db).await;
}
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);

View File

@@ -12,6 +12,7 @@ use erp_core::types::PaginatedResponse;
use crate::dto::health_data_dto::*;
use crate::entity::{health_record, health_trend, lab_report, patient, vital_signs};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::validate_record_type;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
@@ -393,11 +394,14 @@ pub async fn create_health_record(
.ok_or(HealthError::PatientNotFound)?;
let now = Utc::now();
let record_type = req.record_type.unwrap_or_else(|| "checkup".to_string());
validate_record_type(&record_type)?;
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(|| "checkup".to_string())),
record_type: Set(record_type),
record_date: Set(req.record_date),
source: Set(req.source),
overall_assessment: Set(req.overall_assessment),
@@ -425,7 +429,7 @@ pub async fn update_health_record(
patient_id: Uuid,
record_id: Uuid,
operator_id: Option<Uuid>,
req: CreateHealthRecordReq,
req: UpdateHealthRecordReq,
expected_version: i32,
) -> HealthResult<HealthRecordResp> {
let model = health_record::Entity::find()
@@ -440,12 +444,12 @@ pub async fn update_health_record(
.map_err(|_| HealthError::VersionMismatch)?;
let mut active: health_record::ActiveModel = model.into();
if let Some(v) = req.record_type { active.record_type = Set(v); }
active.record_date = Set(req.record_date);
active.source = Set(req.source);
active.overall_assessment = Set(req.overall_assessment);
active.report_file_url = Set(req.report_file_url);
active.notes = Set(req.notes);
if let Some(ref v) = req.record_type { validate_record_type(v)?; active.record_type = Set(v.clone()); }
if let Some(v) = req.record_date { active.record_date = Set(v); }
if let Some(v) = req.source { active.source = Set(Some(v)); }
if let Some(v) = req.overall_assessment { active.overall_assessment = Set(Some(v)); }
if let Some(v) = req.report_file_url { active.report_file_url = Set(Some(v)); }
if let Some(v) = req.notes { active.notes = Set(Some(v)); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);

View File

@@ -15,7 +15,7 @@ use crate::entity::patient_family_member;
use crate::entity::patient_tag_relation;
use crate::entity::patient_doctor_relation;
use crate::error::{HealthError, HealthResult};
use crate::service::validation::{validate_gender, validate_blood_type};
use crate::service::validation::{validate_gender, validate_blood_type, validate_patient_status, validate_verification_status};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
@@ -163,6 +163,8 @@ pub async fn update_patient(
if let Some(ref g) = req.gender { validate_gender(g)?; }
if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; }
if let Some(ref s) = req.status { validate_patient_status(s)?; }
if let Some(ref vs) = req.verification_status { validate_verification_status(vs)?; }
// 状态机验证: patient.status
if let Some(ref new_status) = req.status {

View File

@@ -106,3 +106,27 @@ pub fn validate_consultation_type(value: &str) -> HealthResult<()> {
]);
Ok(())
}
/// health_record.record_type
pub fn validate_record_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "record_type", ["checkup", "outpatient", "inpatient"]);
Ok(())
}
/// patient.status
pub fn validate_patient_status(value: &str) -> HealthResult<()> {
validate_enum!(value, "patient.status", ["active", "inactive", "deceased"]);
Ok(())
}
/// patient.verification_status
pub fn validate_verification_status(value: &str) -> HealthResult<()> {
validate_enum!(value, "verification_status", ["pending", "verified", "rejected"]);
Ok(())
}
/// doctor_profile.online_status
pub fn validate_online_status(value: &str) -> HealthResult<()> {
validate_enum!(value, "online_status", ["online", "offline", "busy"]);
Ok(())
}

View File

@@ -44,6 +44,7 @@ mod m20260419_000041_plugin_user_views;
mod m20260423_000042_create_health_tables;
mod m20260423_000043_create_wechat_users;
mod m20260423_000044_create_articles;
mod m20260424_000045_health_indexes;
pub struct Migrator;
@@ -95,6 +96,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260423_000042_create_health_tables::Migration),
Box::new(m20260423_000043_create_wechat_users::Migration),
Box::new(m20260423_000044_create_articles::Migration),
Box::new(m20260424_000045_health_indexes::Migration),
]
}
}

View File

@@ -0,0 +1,57 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// H-12: lab_report.indicators GIN 索引JSONB 查询加速)
manager
.get_connection()
.execute_unprepared(
"CREATE INDEX IF NOT EXISTS idx_lab_report_indicators_gin ON lab_report USING GIN (indicators)",
)
.await?;
// H-13: health_trend (patient_id, period_start) 联合索引(趋势查询加速)
manager
.create_index(
Index::create()
.name("idx_health_trend_patient_period")
.table(Alias::new("health_trend"))
.col(Alias::new("patient_id"))
.col(Alias::new("period_start"))
.to_owned(),
)
.await?;
// H-14: follow_up_record (task_id, executed_date) 联合索引(随访记录查询加速)
manager
.create_index(
Index::create()
.name("idx_follow_up_record_task_date")
.table(Alias::new("follow_up_record"))
.col(Alias::new("task_id"))
.col(Alias::new("executed_date"))
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared("DROP INDEX IF EXISTS idx_lab_report_indicators_gin")
.await?;
manager
.drop_index(Index::drop().name("idx_health_trend_patient_period").to_owned())
.await?;
manager
.drop_index(Index::drop().name("idx_follow_up_record_task_date").to_owned())
.await?;
Ok(())
}
}