fix(health): 二次审计修复 — 状态机/枚举校验/归属验证/事件补全
- 状态机验证: 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:
@@ -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)]
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -5,3 +5,4 @@ pub mod follow_up_service;
|
||||
pub mod health_data_service;
|
||||
pub mod patient_service;
|
||||
pub mod seed;
|
||||
pub mod validation;
|
||||
|
||||
@@ -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
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
108
crates/erp-health/src/service/validation.rs
Normal file
108
crates/erp-health/src/service/validation.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user