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

@@ -409,8 +409,10 @@ export default function PatientDetail() {
<Form.Item name="birth_date" label="出生日期">
<DatePicker style={{ width: '100%' }} placeholder="请选择" />
</Form.Item>
<Form.Item name="id_number" label="身份证号">
<Input placeholder="请输入身份证号" />
<Form.Item name="id_number" label="身份证号" rules={[
{ pattern: /^[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/, message: '请输入有效的18位身份证号' },
]}>
<Input placeholder="请输入身份证号" maxLength={18} />
</Form.Item>
<Form.Item name="allergy_history" label="过敏史">
<Input.TextArea rows={2} placeholder="请输入过敏史" />
@@ -422,8 +424,10 @@ export default function PatientDetail() {
<Form.Item name="emergency_contact_name" label="紧急联系人" style={{ flex: 1 }}>
<Input placeholder="联系人姓名" />
</Form.Item>
<Form.Item name="emergency_contact_phone" label="紧急联系电话" style={{ flex: 1 }}>
<Input placeholder="联系电话" />
<Form.Item name="emergency_contact_phone" label="紧急联系电话" style={{ flex: 1 }} rules={[
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的11位手机号' },
]}>
<Input placeholder="联系电话" maxLength={11} />
</Form.Item>
</div>
<Form.Item name="notes" label="备注">

View File

@@ -441,8 +441,10 @@ export default function PatientList() {
title: '联系方式',
fields: (
<>
<Form.Item name="id_number" label="身份证号">
<Input placeholder="请输入身份证号" />
<Form.Item name="id_number" label="身份证号" rules={[
{ pattern: /^[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/, message: '请输入有效的18位身份证号' },
]}>
<Input placeholder="请输入身份证号" maxLength={18} />
</Form.Item>
<Form.Item name="source" label="来源">
<Input placeholder="请输入患者来源" />
@@ -473,8 +475,10 @@ export default function PatientList() {
<Form.Item name="emergency_contact_name" label="联系人姓名">
<Input placeholder="请输入紧急联系人姓名" />
</Form.Item>
<Form.Item name="emergency_contact_phone" label="联系电话">
<Input placeholder="请输入紧急联系人电话" />
<Form.Item name="emergency_contact_phone" label="联系电话" rules={[
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的11位手机号' },
]}>
<Input placeholder="请输入紧急联系人电话" maxLength={11} />
</Form.Item>
</>
),

View File

@@ -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) {
<Form.Item name="relationship" label="关系" rules={[{ required: true, message: '请选择关系' }]}>
<Select options={RELATIONSHIP_OPTIONS} placeholder="选择关系" />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
<Form.Item name="phone" label="电话" rules={[
{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的11位手机号' },
]}>
<Input maxLength={11} />
</Form.Item>
<Form.Item name="id_number" label="身份证号">
<Input />
<Form.Item name="id_number" label="身份证号" rules={[
{ pattern: /^[1-9]\d{5}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/, message: '请输入有效的18位身份证号' },
]}>
<Input maxLength={18} />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />

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