fix(health): 二次审计修复 — 状态机/枚举校验/归属验证/事件补全
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

- 状态机验证: patient.status (active→inactive/deceased/inactive→active),
  patient.verification_status (pending→verified/rejected), follow_up_task.status
  (pending→in_progress/cancelled, in_progress→completed/cancelled)
- 枚举白名单: gender/blood_type/appointment_type/period_type/schedule_status/
  follow_up_type/sender_role/content_type/consultation_type
- 归属验证: family_member update/delete 校验 patient_id 匹配
- 事件补全: patient.deceased/verified 条件事件, consultation close 允许 waiting
- 默认值修正: appointment_type "regular"→"outpatient", period_type "morning"→"am",
  consultation_type "text"→"customer_service"
- 新增 validation.rs 通用校验模块
This commit is contained in:
iven
2026-04-24 00:21:05 +08:00
parent ba132921cc
commit 47817bae7d
11 changed files with 635 additions and 20 deletions

View File

@@ -31,6 +31,8 @@ pub struct UpdatePatientReq {
pub emergency_contact_phone: Option<String>,
pub source: Option<String>,
pub notes: Option<String>,
pub status: Option<String>,
pub verification_status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]

View File

@@ -17,6 +17,21 @@ pub enum HealthError {
#[error("排班不存在")]
ScheduleNotFound,
#[error("体征记录不存在")]
VitalSignsNotFound,
#[error("化验报告不存在")]
LabReportNotFound,
#[error("健康档案不存在")]
HealthRecordNotFound,
#[error("家庭成员不存在")]
FamilyMemberNotFound,
#[error("标签不存在")]
TagNotFound,
#[error("排班已满,无法预约")]
ScheduleFull,
@@ -44,6 +59,11 @@ impl From<HealthError> for AppError {
| HealthError::DoctorNotFound
| HealthError::AppointmentNotFound
| HealthError::ScheduleNotFound
| HealthError::VitalSignsNotFound
| HealthError::LabReportNotFound
| HealthError::HealthRecordNotFound
| HealthError::FamilyMemberNotFound
| HealthError::TagNotFound
| HealthError::FollowUpTaskNotFound
| HealthError::ConsultationNotFound => AppError::NotFound(err.to_string()),
HealthError::ScheduleFull => AppError::Validation(err.to_string()),

View File

@@ -104,6 +104,8 @@ where
emergency_contact_phone: req.emergency_contact_phone,
source: req.source,
notes: req.notes,
status: req.status,
verification_status: req.verification_status,
};
let result = patient_service::update_patient(
&state, ctx.tenant_id, id, Some(ctx.user_id), update, version,
@@ -281,6 +283,8 @@ pub struct UpdatePatientWithVersion {
pub emergency_contact_phone: Option<String>,
pub source: Option<String>,
pub notes: Option<String>,
pub status: Option<String>,
pub verification_status: Option<String>,
pub version: i32,
}

View File

@@ -10,8 +10,12 @@ use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::appointment_dto::*;
use crate::entity::{appointment, doctor_schedule};
use crate::entity::{appointment, doctor_schedule, patient};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::{
validate_appointment_type,
validate_period_type, validate_schedule_status,
};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
@@ -66,6 +70,17 @@ pub async fn create_appointment(
operator_id: Option<Uuid>,
req: CreateAppointmentReq,
) -> HealthResult<AppointmentResp> {
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(req.patient_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
if let Some(ref at) = req.appointment_type { validate_appointment_type(at)?; }
// 原子 CAS: 排班名额 +1
// 使用 raw SQL 实现 CAS 防止超额预约
let cas_result = doctor_schedule::Entity::update_many()
@@ -99,7 +114,7 @@ pub async fn create_appointment(
tenant_id: Set(tenant_id),
patient_id: Set(req.patient_id),
doctor_id: Set(req.doctor_id),
appointment_type: Set(req.appointment_type.unwrap_or_else(|| "regular".to_string())),
appointment_type: Set(req.appointment_type.unwrap_or_else(|| "outpatient".to_string())),
appointment_date: Set(req.appointment_date),
start_time: Set(req.start_time),
end_time: Set(req.end_time),
@@ -254,12 +269,14 @@ pub async fn create_schedule(
req: CreateScheduleReq,
) -> HealthResult<ScheduleResp> {
let now = Utc::now();
let period_type = req.period_type.unwrap_or_else(|| "am".to_string());
validate_period_type(&period_type)?;
let active = doctor_schedule::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
doctor_id: Set(req.doctor_id),
schedule_date: Set(req.schedule_date),
period_type: Set(req.period_type.unwrap_or_else(|| "morning".to_string())),
period_type: Set(period_type),
start_time: Set(req.start_time),
end_time: Set(req.end_time),
max_appointments: Set(req.max_appointments),
@@ -300,6 +317,8 @@ pub async fn update_schedule(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
if let Some(ref s) = req.status { validate_schedule_status(s)?; }
let mut active: doctor_schedule::ActiveModel = model.into();
if let Some(v) = req.start_time { active.start_time = Set(v); }
if let Some(v) = req.end_time { active.end_time = Set(v); }

View File

@@ -10,8 +10,9 @@ use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::consultation_dto::*;
use crate::entity::{consultation_message, consultation_session};
use crate::entity::{consultation_message, consultation_session, patient};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::{validate_sender_role, validate_content_type, validate_consultation_type};
use crate::state::HealthState;
// ---------------------------------------------------------------------------
@@ -25,12 +26,25 @@ pub async fn create_session(
req: CreateSessionReq,
) -> HealthResult<SessionResp> {
let now = Utc::now();
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(req.patient_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
let consultation_type = req.consultation_type.unwrap_or_else(|| "customer_service".to_string());
validate_consultation_type(&consultation_type)?;
let active = consultation_session::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
patient_id: Set(req.patient_id),
doctor_id: Set(req.doctor_id),
consultation_type: Set(req.consultation_type.unwrap_or_else(|| "text".to_string())),
consultation_type: Set(consultation_type),
status: Set("waiting".to_string()),
last_message_at: Set(None),
unread_count_patient: Set(0),
@@ -113,7 +127,11 @@ pub async fn close_session(
.filter(consultation_session::Column::Id.eq(session_id))
.filter(consultation_session::Column::TenantId.eq(tenant_id))
.filter(consultation_session::Column::DeletedAt.is_null())
.filter(consultation_session::Column::Status.eq("active"))
.filter(
Condition::any()
.add(consultation_session::Column::Status.eq("active"))
.add(consultation_session::Column::Status.eq("waiting")),
)
.one(&state.db)
.await?
.ok_or(HealthError::ConsultationNotFound)?;
@@ -234,6 +252,9 @@ pub async fn create_message(
.ok_or(HealthError::ConsultationNotFound)?;
let now = Utc::now();
validate_sender_role(&req.sender_role)?;
let content_type = req.content_type.unwrap_or_else(|| "text".to_string());
validate_content_type(&content_type)?;
let is_patient = req.sender_role == "patient";
let should_activate = session.status == "waiting";
@@ -244,7 +265,7 @@ pub async fn create_message(
session_id: Set(req.session_id),
sender_id: Set(req.sender_id),
sender_role: Set(req.sender_role),
content_type: Set(req.content_type.unwrap_or_else(|| "text".to_string())),
content_type: Set(content_type),
content: Set(req.content),
is_read: Set(false),
created_at: Set(now),

View File

@@ -10,8 +10,9 @@ use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::follow_up_dto::*;
use crate::entity::{follow_up_record, follow_up_task};
use crate::entity::{follow_up_record, follow_up_task, patient};
use crate::error::{HealthError, HealthResult};
use crate::service::validation::validate_follow_up_type;
use crate::state::HealthState;
// ---------------------------------------------------------------------------
@@ -65,6 +66,18 @@ pub async fn create_task(
req: CreateFollowUpTaskReq,
) -> HealthResult<FollowUpTaskResp> {
let now = Utc::now();
validate_follow_up_type(&req.follow_up_type)?;
// 校验患者存在
patient::Entity::find()
.filter(patient::Column::Id.eq(req.patient_id))
.filter(patient::Column::TenantId.eq(tenant_id))
.filter(patient::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
let active = follow_up_task::ActiveModel {
id: Set(Uuid::now_v7()),
tenant_id: Set(tenant_id),
@@ -119,6 +132,13 @@ pub async fn update_task(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
if let Some(ref ft) = req.follow_up_type { validate_follow_up_type(ft)?; }
// 状态机验证: follow_up_task.status
if let Some(ref new_status) = req.status {
validate_follow_up_status_transition(&model.status, new_status)?;
}
let mut active: follow_up_task::ActiveModel = model.into();
if let Some(v) = req.assigned_to { active.assigned_to = Set(Some(v)); }
if let Some(v) = req.follow_up_type { active.follow_up_type = Set(v); }
@@ -301,3 +321,22 @@ pub async fn list_records(
Ok(PaginatedResponse { data, total, page, page_size: limit, total_pages })
}
/// 随访任务状态机: pending → in_progress/cancelled, in_progress → completed/cancelled
fn validate_follow_up_status_transition(current: &str, new_status: &str) -> HealthResult<()> {
if current == new_status {
return Ok(());
}
let allowed = match current {
"pending" => matches!(new_status, "in_progress" | "cancelled"),
"in_progress" => matches!(new_status, "completed" | "cancelled"),
_ => false,
};
if allowed {
Ok(())
} else {
Err(HealthError::InvalidStatusTransition(format!(
"follow_up_task.status: 不允许从 '{}' 转换到 '{}'", current, new_status
)))
}
}

View File

@@ -10,7 +10,7 @@ use erp_core::error::check_version;
use erp_core::types::PaginatedResponse;
use crate::dto::health_data_dto::*;
use crate::entity::{health_record, health_trend, lab_report, vital_signs};
use crate::entity::{health_record, health_trend, lab_report, patient, vital_signs};
use crate::error::{HealthError, HealthResult};
use crate::state::HealthState;
@@ -71,6 +71,15 @@ pub async fn create_vital_signs(
operator_id: Option<Uuid>,
req: CreateVitalSignsReq,
) -> HealthResult<VitalSignsResp> {
// 校验患者存在
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 = vital_signs::ActiveModel {
id: Set(Uuid::now_v7()),
@@ -122,7 +131,7 @@ pub async fn update_vital_signs(
.filter(vital_signs::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::VitalSignsNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
@@ -167,7 +176,7 @@ pub async fn delete_vital_signs(
.filter(vital_signs::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::VitalSignsNotFound)?;
let mut active: vital_signs::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
@@ -271,7 +280,7 @@ pub async fn update_lab_report(
.filter(lab_report::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::LabReportNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
@@ -306,7 +315,7 @@ pub async fn delete_lab_report(
.filter(lab_report::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::LabReportNotFound)?;
let mut active: lab_report::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
@@ -403,7 +412,7 @@ pub async fn update_health_record(
.filter(health_record::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::HealthRecordNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
@@ -439,7 +448,7 @@ pub async fn delete_health_record(
.filter(health_record::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::HealthRecordNotFound)?;
let mut active: health_record::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));

View File

@@ -5,3 +5,4 @@ pub mod follow_up_service;
pub mod health_data_service;
pub mod patient_service;
pub mod seed;
pub mod validation;

View File

@@ -15,6 +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::state::HealthState;
// ---------------------------------------------------------------------------
@@ -97,6 +98,9 @@ pub async fn create_patient(
let now = Utc::now();
let id = Uuid::now_v7();
if let Some(ref g) = req.gender { validate_gender(g)?; }
if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; }
let active = patient::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
@@ -157,6 +161,26 @@ pub async fn update_patient(
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
if let Some(ref g) = req.gender { validate_gender(g)?; }
if let Some(ref bt) = req.blood_type { validate_blood_type(bt)?; }
// 状态机验证: patient.status
if let Some(ref new_status) = req.status {
validate_status_transition("patient.status", &model.status, new_status, &[
("active", "inactive"),
("active", "deceased"),
("inactive", "active"),
])?;
}
// 状态机验证: patient.verification_status
if let Some(ref new_vs) = req.verification_status {
validate_status_transition("patient.verification_status", &model.verification_status, new_vs, &[
("pending", "verified"),
("pending", "rejected"),
("rejected", "pending"),
])?;
}
let mut active: patient::ActiveModel = model.into();
if let Some(v) = req.name { active.name = Set(v); }
@@ -170,6 +194,8 @@ pub async fn update_patient(
if let Some(v) = req.emergency_contact_phone { active.emergency_contact_phone = Set(Some(v)); }
if let Some(v) = req.source { active.source = Set(Some(v)); }
if let Some(v) = req.notes { active.notes = Set(Some(v)); }
if let Some(ref v) = req.status { active.status = Set(v.clone()); }
if let Some(ref v) = req.verification_status { active.verification_status = Set(v.clone()); }
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
@@ -177,8 +203,16 @@ pub async fn update_patient(
let updated = active.update(&state.db).await?;
// 根据状态变更发布不同事件
let event_type = if req.status.as_deref() == Some("deceased") {
"patient.deceased"
} else if req.verification_status.as_deref() == Some("verified") {
"patient.verified"
} else {
"patient.updated"
};
let event = DomainEvent::new(
"patient.updated",
event_type,
tenant_id,
serde_json::json!({ "patient_id": updated.id }),
);
@@ -398,7 +432,7 @@ pub async fn create_family_member(
pub async fn update_family_member(
state: &HealthState,
tenant_id: Uuid,
_patient_id: Uuid,
patient_id: Uuid,
family_member_id: Uuid,
operator_id: Option<Uuid>,
req: FamilyMemberReq,
@@ -406,11 +440,12 @@ pub async fn update_family_member(
) -> HealthResult<FamilyMemberResp> {
let model = patient_family_member::Entity::find()
.filter(patient_family_member::Column::Id.eq(family_member_id))
.filter(patient_family_member::Column::PatientId.eq(patient_id))
.filter(patient_family_member::Column::TenantId.eq(tenant_id))
.filter(patient_family_member::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::FamilyMemberNotFound)?;
let next_ver = check_version(expected_version, model.version)
.map_err(|_| HealthError::VersionMismatch)?;
@@ -444,17 +479,18 @@ pub async fn update_family_member(
pub async fn delete_family_member(
state: &HealthState,
tenant_id: Uuid,
_patient_id: Uuid,
patient_id: Uuid,
family_member_id: Uuid,
operator_id: Option<Uuid>,
) -> HealthResult<()> {
let model = patient_family_member::Entity::find()
.filter(patient_family_member::Column::Id.eq(family_member_id))
.filter(patient_family_member::Column::PatientId.eq(patient_id))
.filter(patient_family_member::Column::TenantId.eq(tenant_id))
.filter(patient_family_member::Column::DeletedAt.is_null())
.one(&state.db)
.await?
.ok_or(HealthError::PatientNotFound)?;
.ok_or(HealthError::FamilyMemberNotFound)?;
let mut active: patient_family_member::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
@@ -563,3 +599,23 @@ fn model_to_resp(m: patient::Model) -> PatientResp {
version: m.version,
}
}
/// 状态机转换校验: 检查 (current → new) 是否在 allowed_transitions 中
fn validate_status_transition(
field_name: &str,
current: &str,
new_status: &str,
allowed_transitions: &[(&str, &str)],
) -> HealthResult<()> {
if current == new_status {
return Ok(());
}
if allowed_transitions.iter().any(|(from, to)| *from == current && *to == new_status) {
Ok(())
} else {
Err(HealthError::InvalidStatusTransition(format!(
"{}: 不允许从 '{}' 转换到 '{}'",
field_name, current, new_status
)))
}
}

View File

@@ -0,0 +1,108 @@
//! 通用字段校验 — 枚举白名单、输入格式校验
use crate::error::{HealthError, HealthResult};
macro_rules! validate_enum {
($value:expr, $field:expr, [$($allowed:expr),* $(,)?]) => {
{
let v: &str = $value;
let allowed: &[&str] = &[$($allowed),*];
let mut found = false;
let mut _i = 0;
while _i < allowed.len() {
if allowed[_i] == v {
found = true;
break;
}
_i += 1;
}
if !found {
return Err(HealthError::Validation(format!(
"{}: '{}' 不是有效值,允许值: [{}]",
$field, v, allowed.join(", ")
)));
}
}
};
}
/// patient.gender
pub fn validate_gender(value: &str) -> HealthResult<()> {
validate_enum!(value, "gender", ["male", "female", "other"]);
Ok(())
}
/// patient.blood_type
pub fn validate_blood_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "blood_type", [
"A", "B", "AB", "O", "A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-",
]);
Ok(())
}
/// appointment.appointment_type
pub fn validate_appointment_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "appointment_type", [
"dialysis", "recheck", "outpatient", "health_checkup", "consultation",
]);
Ok(())
}
/// appointment.status transitions
pub fn validate_appointment_status_transition(current: &str, new: &str) -> HealthResult<()> {
if current == new {
return Ok(());
}
let allowed = match current {
"pending" => matches!(new, "confirmed" | "cancelled"),
"confirmed" => matches!(new, "completed" | "cancelled" | "no_show"),
_ => false,
};
if allowed {
Ok(())
} else {
Err(HealthError::InvalidStatusTransition(format!(
"appointment.status: 不允许从 '{}' 转换到 '{}'", current, new
)))
}
}
/// doctor_schedule.period_type
pub fn validate_period_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "period_type", ["am", "pm", "night", "full_day"]);
Ok(())
}
/// doctor_schedule.status
pub fn validate_schedule_status(value: &str) -> HealthResult<()> {
validate_enum!(value, "schedule.status", ["enabled", "disabled"]);
Ok(())
}
/// 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",
]);
Ok(())
}
/// consultation.sender_role
pub fn validate_sender_role(value: &str) -> HealthResult<()> {
validate_enum!(value, "sender_role", ["patient", "doctor", "system"]);
Ok(())
}
/// consultation.content_type
pub fn validate_content_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "content_type", ["text", "image", "voice", "file"]);
Ok(())
}
/// consultation.consultation_type
pub fn validate_consultation_type(value: &str) -> HealthResult<()> {
validate_enum!(value, "consultation_type", [
"customer_service", "doctor", "nutritionist", "psychologist",
]);
Ok(())
}