fix(health): 身份证号18位校验位验证 + 手机号1[3-9]格式校验

后端:
- validation.rs: 新增 validate_id_number(含加权校验位)和 validate_phone(1[3-9]\d{9})
- patient_dto.rs: CreatePatientReq/UpdatePatientReq/FamilyMemberReq 添加 Validate derive
- patient_handler.rs: create/update/family_member handler 调用格式校验

前端:
- PatientList/PatientDetail/FamilyMembersTab: Form.Item 添加 pattern rules + maxLength

测试:15 个新测试用例全部通过
This commit is contained in:
iven
2026-05-21 18:16:41 +08:00
parent 58afc59676
commit 8e616f2210
6 changed files with 176 additions and 17 deletions

View File

@@ -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<String>,
pub birth_date: Option<NaiveDate>,
@@ -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<String>,
pub gender: Option<String>,
pub birth_date: Option<NaiveDate>,
@@ -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<String>,

View File

@@ -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?;

View File

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