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:
@@ -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="备注">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user