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() {