diff --git a/apps/web/src/pages/health/PatientDetail.tsx b/apps/web/src/pages/health/PatientDetail.tsx index e2b1e6c..90f834a 100644 --- a/apps/web/src/pages/health/PatientDetail.tsx +++ b/apps/web/src/pages/health/PatientDetail.tsx @@ -409,8 +409,10 @@ export default function PatientDetail() { - - + + @@ -422,8 +424,10 @@ export default function PatientDetail() { - - + + diff --git a/apps/web/src/pages/health/PatientList.tsx b/apps/web/src/pages/health/PatientList.tsx index 14060dc..72b2407 100644 --- a/apps/web/src/pages/health/PatientList.tsx +++ b/apps/web/src/pages/health/PatientList.tsx @@ -441,8 +441,10 @@ export default function PatientList() { title: '联系方式', fields: ( <> - - + + @@ -473,8 +475,10 @@ export default function PatientList() { - - + + ), diff --git a/apps/web/src/pages/health/components/FamilyMembersTab.tsx b/apps/web/src/pages/health/components/FamilyMembersTab.tsx index 08fd3e7..b2b78d2 100644 --- a/apps/web/src/pages/health/components/FamilyMembersTab.tsx +++ b/apps/web/src/pages/health/components/FamilyMembersTab.tsx @@ -37,6 +37,7 @@ export function FamilyMembersTab({ patientId }: Props) { setLoading(false); }, [patientId]); + // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { fetchMembers(); }, [fetchMembers]); const handleSubmit = async (values: CreateFamilyMemberReq) => { @@ -139,11 +140,15 @@ export function FamilyMembersTab({ patientId }: Props) { + + - - + + diff --git a/crates/erp-health/src/dto/patient_dto.rs b/crates/erp-health/src/dto/patient_dto.rs index 8eb07b7..277fc5d 100644 --- a/crates/erp-health/src/dto/patient_dto.rs +++ b/crates/erp-health/src/dto/patient_dto.rs @@ -5,8 +5,9 @@ use utoipa::ToSchema; use uuid::Uuid; use validator::Validate; -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct CreatePatientReq { + #[validate(length(min = 1, max = 255, message = "患者姓名长度须在1-255之间"))] pub name: String, pub gender: Option, pub birth_date: Option, @@ -33,8 +34,9 @@ impl CreatePatientReq { } } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct UpdatePatientReq { + #[validate(length(min = 1, max = 255, message = "患者姓名长度须在1-255之间"))] pub name: Option, pub gender: Option, pub birth_date: Option, @@ -87,8 +89,9 @@ pub struct PatientResp { pub version: i32, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Validate)] pub struct FamilyMemberReq { + #[validate(length(min = 1, max = 255, message = "姓名长度须在1-255之间"))] pub name: String, pub relationship: String, pub phone: Option, diff --git a/crates/erp-health/src/handler/patient_handler.rs b/crates/erp-health/src/handler/patient_handler.rs index 26220f1..8c80beb 100644 --- a/crates/erp-health/src/handler/patient_handler.rs +++ b/crates/erp-health/src/handler/patient_handler.rs @@ -68,12 +68,19 @@ where { require_permission(&ctx, "health.patient.manage")?; let mut req = req; + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; req.sanitize(); if req.name.trim().is_empty() { return Err(AppError::Validation("患者姓名不能为空".into())); } - if req.name.len() > 255 { - return Err(AppError::Validation("患者姓名长度不能超过255个字符".into())); + if let Some(ref id_num) = req.id_number { + crate::service::validation::validate_id_number(id_num) + .map_err(|e| AppError::Validation(e.to_string()))?; + } + if let Some(ref phone) = req.emergency_contact_phone { + crate::service::validation::validate_phone(phone) + .map_err(|e| AppError::Validation(e.to_string()))?; } if let Some(ref bd) = req.birth_date && *bd > chrono::Utc::now().date_naive() @@ -126,7 +133,18 @@ where status: req.status, verification_status: req.verification_status, }; + update + .validate() + .map_err(|e| AppError::Validation(e.to_string()))?; update.sanitize(); + if let Some(ref id_num) = update.id_number { + crate::service::validation::validate_id_number(id_num) + .map_err(|e| AppError::Validation(e.to_string()))?; + } + if let Some(ref phone) = update.emergency_contact_phone { + crate::service::validation::validate_phone(phone) + .map_err(|e| AppError::Validation(e.to_string()))?; + } if let Some(ref bd) = update.birth_date && *bd > chrono::Utc::now().date_naive() { @@ -215,7 +233,13 @@ where { require_permission(&ctx, "health.patient.manage")?; let mut req = req; + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; req.sanitize(); + if let Some(ref phone) = req.phone { + crate::service::validation::validate_phone(phone) + .map_err(|e| AppError::Validation(e.to_string()))?; + } let result = patient_service::create_family_member(&state, ctx.tenant_id, id, Some(ctx.user_id), req) .await?; diff --git a/crates/erp-health/src/service/validation.rs b/crates/erp-health/src/service/validation.rs index bbeb5d9..adb377e 100644 --- a/crates/erp-health/src/service/validation.rs +++ b/crates/erp-health/src/service/validation.rs @@ -301,6 +301,59 @@ pub fn validate_relationship(value: &str) -> HealthResult<()> { Ok(()) } +/// 中国身份证号格式校验(18 位,含校验位验证) +pub fn validate_id_number(value: &str) -> HealthResult<()> { + if value.len() != 18 { + return Err(HealthError::Validation("身份证号必须为18位".into())); + } + let bytes = value.as_bytes(); + for (i, &b) in bytes.iter().enumerate() { + if i < 17 { + if !b.is_ascii_digit() { + return Err(HealthError::Validation("身份证号前17位必须为数字".into())); + } + } else if !(b.is_ascii_digit() || b == b'X' || b == b'x') { + return Err(HealthError::Validation( + "身份证号最后一位必须为数字或X".into(), + )); + } + } + // 校验位验证 + let weights: [u32; 17] = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]; + let checksum_chars: [u8; 11] = [ + b'1', b'0', b'X', b'9', b'8', b'7', b'6', b'5', b'4', b'3', b'2', + ]; + let sum: u32 = bytes[..17] + .iter() + .zip(weights.iter()) + .map(|(&d, &w)| (d - b'0') as u32 * w) + .sum(); + let expected = checksum_chars[(sum % 11) as usize]; + let actual = bytes[17].to_ascii_uppercase(); + if expected != actual { + return Err(HealthError::Validation("身份证号校验位不正确".into())); + } + Ok(()) +} + +/// 中国手机号格式校验(1[3-9]开头,共11位) +pub fn validate_phone(value: &str) -> HealthResult<()> { + if value.len() != 11 { + return Err(HealthError::Validation("手机号必须为11位".into())); + } + let bytes = value.as_bytes(); + if bytes[0] != b'1' { + return Err(HealthError::Validation("手机号必须以1开头".into())); + } + if !(bytes[1] >= b'3' && bytes[1] <= b'9') { + return Err(HealthError::Validation("手机号第二位必须为3-9".into())); + } + if !bytes.iter().all(|b| b.is_ascii_digit()) { + return Err(HealthError::Validation("手机号必须全部为数字".into())); + } + Ok(()) +} + /// alert.severity pub fn validate_alert_severity(value: &str) -> HealthResult<()> { validate_enum!( @@ -939,6 +992,72 @@ mod tests { assert!(validate_source("unknown_source").is_err()); } + // --- id_number --- + #[test] + fn id_number_valid() { + // sum=106, 106%11=7, checksum_chars[7]='5' + assert!(validate_id_number("110101199001010015").is_ok()); + } + #[test] + fn id_number_valid_x() { + // sum%11=2 → checksum_chars[2]='X' + assert!(validate_id_number("11010119900101004X").is_ok()); + } + #[test] + fn id_number_too_short() { + assert!(validate_id_number("1101011990030").is_err()); + } + #[test] + fn id_number_too_long() { + assert!(validate_id_number("1101011990030752789").is_err()); + } + #[test] + fn id_number_bad_checksum() { + assert!(validate_id_number("110101199003075279").is_err()); + } + #[test] + fn id_number_non_digit() { + assert!(validate_id_number("11010119900307A278").is_err()); + } + + // --- phone --- + #[test] + fn phone_valid_13() { + assert!(validate_phone("13012345678").is_ok()); + } + #[test] + fn phone_valid_15() { + assert!(validate_phone("15012345678").is_ok()); + } + #[test] + fn phone_valid_18() { + assert!(validate_phone("18012345678").is_ok()); + } + #[test] + fn phone_valid_19() { + assert!(validate_phone("19012345678").is_ok()); + } + #[test] + fn phone_short() { + assert!(validate_phone("1381234567").is_err()); + } + #[test] + fn phone_long() { + assert!(validate_phone("138123456789").is_err()); + } + #[test] + fn phone_starts_with_10() { + assert!(validate_phone("10012345678").is_err()); + } + #[test] + fn phone_starts_with_12() { + assert!(validate_phone("12012345678").is_err()); + } + #[test] + fn phone_non_digit() { + assert!(validate_phone("1381234567a").is_err()); + } + // --- relationship --- #[test] fn relationship_spouse() {