fix(health): 三次审计批次B修复 — 12个HIGH问题
- 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:
@@ -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)]
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user