ValidX 应用于 HR系统员工信息验证:入职、离职、社保数据

6 阅读13分钟

ValidX 应用于 HR系统员工信息验证:入职、离职、社保数据

📋 目录


引言

在 HR / 人事 / OA / 薪酬系统里,员工信息是最基础、最核心的数据对象。它看起来也不复杂——姓名、身份证、手机号、入职日期、部门、银行卡号……字段就那么几个,校验应该很简单?

恰恰相反。员工信息是中国企业信息化里规则最琐碎、政策变化最频繁、又最容易出错的一类数据:

  • 证件类型多:身份证、护照、军官证、港澳通行证、台湾通行证、外国人永久居留证……每种证件的校验规则完全不同;
  • 日期关系强:入职日期、转正日期、合同起止日期、离职日期之间存在时间线约束——离职日期必须晚于入职日期,合同结束日期必须晚于开始日期;
  • 金额勾稽多:社保基数、公积金基数、五险一金个人/企业缴纳金额之间存在数学关系,错一个数字就导致工资核算错误;
  • 合规要求高:涉及劳动合同法、社保法、个人信息保护法,错误数据可能引发劳动纠纷或行政处罚。

本文把 HR 系统的数据验证拆成四块讲透——入职信息(身份 + 联系方式 + 学历)、在职信息(银行卡 + 部门 + 岗位)、社保公积金(基数 + 勾稽)、离职信息(日期 + 交接),并给出可以直接落地的 ValidX 实现。文章最后会重点澄清几个常见误区:社保号就是身份证号、银行卡号有校验位算法、入职日期不能是未来日期还是过去日期取决于业务阶段。


一、HR系统数据验证的全景

1.1 一个员工从入职到离职的数据生命周期

先建立全局观。一个员工在 HR 系统里的数据生命周期,可以分为三个阶段——每个阶段需要校验的字段和规则各不相同:

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│   入职阶段    │ ──► │   在职阶段    │ ──► │   离职阶段    │
│              │     │              │     │              │
│ · 身份信息    │     │ · 工资卡     │     │ · 离职日期    │
│ · 联系方式    │     │ · 部门/岗位   │     │ · 离职类型    │
│ · 学历/证书   │     │ · 合同信息    │     │ · 交接清单    │
│ · 入职日期    │     │ · 社保公积金  │     │ · 离职证明    │
│ · 紧急联系人  │     │ · 考勤/假期   │     │ · 竞业协议    │
└──────────────┘     └──────────────┘     └──────────────┘
   身份验证为主          业务关系为主          时间线 + 完整性
阶段核心校验对象校验本质
入职身份证、姓名、手机号、学历、入职日期身份真实性 + 格式合法性
在职银行卡号、部门、职级、社保基数、合同日期业务规则 + 金额勾稽
离职离职日期、离职类型、交接状态时间线约束 + 完整性

1.2 HR数据验证的 4 个层次

层次做什么能挡住什么需要什么
L1 格式校验长度、字符集、正则手输错位、粘贴乱码正则 / ValidX
L2 算法校验身份证校验位、银行卡 Luhn伪造证件号、输错一位的卡号mod 11 / Luhn 算法
L3 关系校验日期先后、金额勾稽日期倒挂、金额不平跨字段校验
L4 业务合规年龄限制、试用期上限、社保基数上下限违法用工、违规缴纳业务规则引擎

关键认知:前 3 层是本地可做的(本文的重点),第 4 层需要结合企业政策和法规——本文会涉及,但会标注"这是业务规则,不是格式规则"。

1.3 为什么HR数据最容易"脏"

在企业数据治理领域有一个共识:HR 数据是最难治理的。原因不是技术复杂,而是:

  • 录入来源多:员工自助填写、HR 手动录入、招聘系统同步、第三方背调返回……每个入口都可能引入脏数据;
  • 变更频率高:手机号会换、银行卡会改、部门会调、职级会升——每次变更都是一次数据污染风险;
  • 历史包袱重:15 位老身份证号、手写入职表扫描 OCR、从 Excel 批量导入……历史数据的格式千奇百怪;
  • 校验意识弱:很多 HR 系统只做了"必填校验",没做"格式校验"——手机号 = 任意 11 位数字 这种写法随处可见。
// 反例:只校验长度
if (phone.length() != 11) {
    throw new BizException("手机号格式错误");
}
输入上面的代码能挡住吗应该挡住吗
"13800138000"✅ 通过✅ 应该通过
"138 0138 000"❌ 没挡住(含空格但长度 13…等等,长度不是 11,挡住了)—
"1380013800a"❌ 没挡住(长度 11,含字母)❌ 应该拦住
"20000000000"✅ 通过(长度 11,纯数字)❌ 应该拦住(不存在 200 号段)
"1380013800"✅ 通过(长度 10,等等…不,长度 10,被挡住了)—

真正的对策:把"长度"和"号段规则"一次性表达清楚——这正是 ValidX @ChinesePhone 的设计目标。


二、入职信息验证:身份、联系方式、学历

2.1 身份信息:不只是身份证号码

入职阶段需要校验的身份信息远不止"身份证号码"一个字段:

字段校验要点ValidX 注解
姓名中文字符、2-20 字、不含数字和特殊字符@ChineseName
身份证号18 位 + 校验位(mod 11)@ChineseIdCard
护照号(外籍员工)字母开头 + 6-8 位@ChinesePassport
军官证(军籍员工)特定格式@ChineseMilitaryOfficer
港澳通行证特定格式@HKMacauPass
台湾通行证特定格式@TaiwanPass
外国人永居证特定格式 + 校验位@ForeignerPermanentResidenceIdentity
民族中文、合法民族名@Chinese + 业务白名单
出生日期与身份证一致、过去日期@PastDate
年龄法定工作年龄(≥16 周岁)@Age(min = 16)
性别与身份证第 17 位一致跨字段校验
2.1.1 身份证:18 位 + mod 11 校验位

身份证校验是入职验证的第一道关。ValidX 的 @ChineseIdCard 内置了完整的校验逻辑:

┌────┬──────────┬────────────┬───────┐
│ 6位 │  8位     │   3位      │ 1位   │
│ 地址│  出生日期 │   顺序码   │ 校验位│
└────┴──────────┴────────────┴───────┘
  • 地址码(前 6 位):对应 GB/T 2260 行政区划代码;
  • 出生日期(第 7-14 位):必须是合法日期(含闰年判断);
  • 顺序码(第 15-17 位):奇数为男性,偶数为女性;
  • 校验位(第 18 位):mod 11 算法计算,可能为 X(代表 10)。
// ValidX 一行搞定
@ChineseIdCard(message = "身份证号码格式不正确")
private String idCard;

链式 API 版本(适合从 Map/JSON 动态校验):

ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE);
v.field("身份证号").isChineseIdCard(employeeData.get("idCard"));
2.1.2 从身份证提取信息:减少手工录入

身份证号码里藏着出生日期、性别、年龄三个字段的信息。与其让员工分别填写,不如直接从身份证提取——既减少录入错误,又保证数据一致性:

@Service
public class IdCardInfoExtractor {

    /**
     * 从身份证号提取出生日期
     * 前提:身份证号已经通过 @ChineseIdCard 校验
     */
    public String extractBirthDate(String idCard) {
        if (idCard == null || idCard.length() != 18) {
            return null;
        }
        return idCard.substring(6, 10) + "-"   // 年
             + idCard.substring(10, 12) + "-"   // 月
             + idCard.substring(12, 14);        // 日
    }

    /**
     * 从身份证号提取性别
     * 第 17 位:奇数 = 男,偶数 = 女
     */
    public String extractGender(String idCard) {
        if (idCard == null || idCard.length() != 18) {
            return null;
        }
        int seq = Character.getNumericValue(idCard.charAt(16));
        return seq % 2 == 1 ? "MALE" : "FEMALE";
    }
}

实战建议:提取后应该交叉校验——如果员工手动填写的出生日期与身份证提取的不一致,应该提示确认而不是直接覆盖(可能是身份证信息已更新但证件未换发)。

2.1.3 外籍员工:多种证件并行

中国企业雇佣外籍员工时,证件类型变得多样:

@Data
public class EmployeeIdentityDTO {

    /** 证件类型 */
    @NotBlank(message = "证件类型不能为空")
    @In({"ID_CARD", "PASSPORT", "MILITARY_OFFICER",
         "HK_MACAU_PASS", "TAIWAN_PASS",
         "FOREIGNER_PERMANENT_RESIDENCE"},
        message = "不支持的证件类型")
    private String idType;

    /** 证件号码(根据证件类型走不同校验规则) */
    @NotBlank(message = "证件号码不能为空")
    private String idNumber;
}

由于证件类型和证件号码是关联校验(类型决定号码规则),这属于跨字段校验,必须在 Service 层处理:

@Service
public class EmployeeIdentityValidator {

    public void validate(EmployeeIdentityDTO dto) {
        ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE)
                .field("证件号码");

        switch (dto.getIdType()) {
            case "ID_CARD":
                v.isChineseIdCard(dto.getIdNumber());
                break;
            case "PASSPORT":
                v.isChinesePassport(dto.getIdNumber());
                break;
            case "MILITARY_OFFICER":
                v.isChineseMilitaryOfficer(dto.getIdNumber());
                break;
            case "HK_MACAU_PASS":
                v.isHKMacauPass(dto.getIdNumber());
                break;
            case "TAIWAN_PASS":
                v.isTaiwanPass(dto.getIdNumber());
                break;
            case "FOREIGNER_PERMANENT_RESIDENCE":
                v.isForeignerPermanentResidenceIdentity(dto.getIdNumber());
                break;
            default:
                v.getErrors().add("不支持的证件类型:" + dto.getIdType());
        }

        if (!v.passed()) {
            throw new HRValidationException(v.getErrors());
        }
    }
}

2.2 联系方式:手机、邮箱、紧急联系人

字段校验要点ValidX 注解
手机号11 位、号段合法(13x/14x/15x/17x/18x/19x)@ChinesePhone
座机号(可选)区号 + 号码 + 分机号@ChineseLandline
手机或座机两者满足其一即可@ChinesePhoneOrLandline
个人邮箱RFC 5322 格式@Email
企业邮箱(可选)公司域名后缀@Email + @EndsWith
紧急联系人姓名中文字符@ChineseName
紧急联系人电话同手机号规则@ChinesePhone

关于手机号的常见误解:很多人以为手机号校验就是 ^1\d{10}$。但 ValidX 的 @ChinesePhone 会校验号段合法性——20000000000 这种不存在于任何运营商的号码会被直接拒绝。

@Data
public class EmployeeContactDTO {

    /** 手机号(必填) */
    @NotBlank(message = "手机号不能为空")
    @ChinesePhone(message = "手机号格式不正确")
    private String phone;

    /** 座机号(可选) */
    @ChineseLandline(message = "座机号格式不正确")
    private String landline;

    /** 个人邮箱 */
    @Email(message = "邮箱格式不正确")
    private String email;

    /** 企业邮箱(可选,若有则必须是公司域名) */
    @Email(message = "企业邮箱格式不正确")
    @EndsWith(value = "@company.com", message = "企业邮箱必须为公司域名")
    private String corporateEmail;

    /** 紧急联系人姓名 */
    @NotBlank(message = "紧急联系人姓名不能为空")
    @ChineseName(message = "紧急联系人姓名格式不正确")
    private String emergencyContactName;

    /** 紧急联系人手机号 */
    @NotBlank(message = "紧急联系人手机号不能为空")
    @ChinesePhone(message = "紧急联系人手机号格式不正确")
    private String emergencyContactPhone;
}

关于 @EndsWith:这是 ValidX v1.2.0 重构后的写法,参数名是 value(String 类型,单值)。如果需要匹配多个域名后缀,使用 @EndsWithAny。

2.3 学历与资格证书

入职时通常需要录入最高学历和相关资格证书:

字段校验要点ValidX 注解
最高学历枚举值(高中/大专/本科/硕士/博士)@In
学位证书编号特定格式@DegreeCertificate
教师资格证(教师岗)特定格式@Teacher
会计资格证(财务岗)特定格式@Accountant
建造师证(工程岗)特定格式@Constructor
法律职业资格(法务岗)特定格式@Lawyer
医师资格证(医疗岗)特定格式@Doctor
@Data
public class EmployeeEducationDTO {

    /** 最高学历 */
    @NotBlank(message = "学历不能为空")
    @In({"HIGH_SCHOOL", "COLLEGE", "BACHELOR", "MASTER", "DOCTOR"},
        message = "学历值不在合法范围内")
    private String education;

    /** 学位证书编号(本科及以上必填) */
    @DegreeCertificate(message = "学位证书编号格式不正确")
    private String degreeCertificateNo;

    /** 教师资格证编号(教师岗必填) */
    @Teacher(message = "教师资格证编号格式不正确")
    private String teacherCertNo;

    /** 会计资格证编号(财务岗必填) */
    @Accountant(message = "会计资格证编号格式不正确")
    private String accountantCertNo;
}

注意:资格证书是否"必填"取决于岗位类型——这是业务规则,不是格式规则。格式校验器(注解)只负责"如果填了,格式必须对";"是否必须填"由 Service 层按岗位类型判断。

2.4 入职日期与试用期

入职日期相关的校验有两个关键点:

  1. 入职日期不能太早(不能早于公司成立日期);
  2. 入职日期不能太晚(不能晚于今天——如果是"实际入职";或不能太晚——如果是"计划入职")。
@Data
public class EmployeeOnboardDTO {

    /** 入职日期(不能是未来日期) */
    @NotNull(message = "入职日期不能为空")
    @PastDate(includeToday = true, message = "入职日期不能晚于今天")
    @Date(pattern = "yyyy-MM-dd", message = "入职日期格式不正确")
    private String onboardDate;

    /** 试用期开始日期(= 入职日期,跨字段校验) */
    @NotNull(message = "试用期开始日期不能为空")
    @Date(pattern = "yyyy-MM-dd")
    private String probationStartDate;

    /** 试用期结束日期(必须晚于开始日期) */
    @NotNull(message = "试用期结束日期不能为空")
    @Date(pattern = "yyyy-MM-dd")
    private String probationEndDate;

    /** 试用期月数 */
    @NotNull(message = "试用期月数不能为空")
    @DecimalMin(value = "1", message = "试用期至少 1 个月")
    @DecimalMax(value = "6", message = "试用期不能超过 6 个月")
    private Integer probationMonths;

    /** 计划转正日期 */
    @Date(pattern = "yyyy-MM-dd")
    private String regularDate;
}

跨字段校验(Service 层):

public void validateProbation(EmployeeOnboardDTO dto) {
    ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE);

    // 试用期开始日期 = 入职日期
    if (!Objects.equals(dto.getOnboardDate(), dto.getProbationStartDate())) {
        v.field("试用期开始日期").getErrors()
         .add("试用期开始日期应与入职日期一致");
    }

    // 试用期结束日期 > 开始日期
    if (dto.getProbationStartDate() != null && dto.getProbationEndDate() != null) {
        LocalDate start = LocalDate.parse(dto.getProbationStartDate());
        LocalDate end = LocalDate.parse(dto.getProbationEndDate());
        if (!end.isAfter(start)) {
            v.field("试用期结束日期").getErrors()
             .add("试用期结束日期必须晚于开始日期");
        }
    }

    // 试用期月数与日期范围一致性
    if (dto.getProbationMonths() != null
            && dto.getProbationStartDate() != null
            && dto.getProbationEndDate() != null) {
        LocalDate start = LocalDate.parse(dto.getProbationStartDate());
        LocalDate end = LocalDate.parse(dto.getProbationEndDate());
        long actualMonths = ChronoUnit.MONTHS.between(start, end);
        if (actualMonths != dto.getProbationMonths()) {
            v.field("试用期月数").getErrors()
             .add("试用期月数与起止日期不一致(期望 " + actualMonths + " 个月)");
        }
    }

    // 劳动合同法第 19 条:合同期限与试用期上限
    // ← 这是业务合规规则,不是格式规则
    // 详见"常见坑与规避"章节

    if (!v.passed()) {
        throw new HRValidationException(v.getErrors());
    }
}

2.5 ValidX 实现

把上面的入职 DTO 组合起来,看一个完整的入职请求:

@Data
public class EmployeeOnboardRequest {

    // ==================== 身份信息 ====================

    @NotBlank(message = "姓名不能为空")
    @ChineseName(message = "姓名格式不正确")
    private String name;

    @NotBlank(message = "证件类型不能为空")
    @In({"ID_CARD", "PASSPORT", "MILITARY_OFFICER",
         "HK_MACAU_PASS", "TAIWAN_PASS",
         "FOREIGNER_PERMANENT_RESIDENCE"})
    private String idType;

    @NotBlank(message = "证件号码不能为空")
    private String idNumber;  // 跨字段校验:根据 idType 走不同规则

    @NotBlank(message = "性别不能为空")
    @In({"MALE", "FEMALE"})
    private String gender;

    @NotNull(message = "出生日期不能为空")
    @PastDate(includeToday = true, message = "出生日期不能是未来日期")
    @Date(pattern = "yyyy-MM-dd")
    private String birthDate;

    @Chinese(message = "民族格式不正确")
    private String ethnicity;

    @ChineseZipCode(message = "籍贯邮编格式不正确")
    private String nativePlaceZip;

    // ==================== 联系方式 ====================

    @NotBlank(message = "手机号不能为空")
    @ChinesePhone(message = "手机号格式不正确")
    private String phone;

    @ChineseLandline(message = "座机号格式不正确")
    private String landline;

    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;

    @ChineseZipCode(message = "现居住地邮编格式不正确")
    private String currentZip;

    @ChineseAlphaDash(message = "详细地址格式不正确")
    private String address;

    // ==================== 紧急联系人 ====================

    @NotBlank(message = "紧急联系人姓名不能为空")
    @ChineseName(message = "紧急联系人姓名格式不正确")
    private String emergencyContactName;

    @NotBlank(message = "紧急联系人手机号不能为空")
    @ChinesePhone(message = "紧急联系人手机号格式不正确")
    private String emergencyContactPhone;

    @In({"SELF", "SPOUSE", "PARENT", "SIBLING", "FRIEND", "OTHER"},
        message = "与本人关系不在合法范围内")
    private String emergencyRelation;

    // ==================== 学历 ====================

    @NotBlank(message = "学历不能为空")
    @In({"HIGH_SCHOOL", "COLLEGE", "BACHELOR", "MASTER", "DOCTOR"})
    private String education;

    @DegreeCertificate(message = "学位证书编号格式不正确")
    private String degreeCertNo;

    // ==================== 入职日期 ====================

    @NotNull(message = "入职日期不能为空")
    @PastDate(includeToday = true)
    @Date(pattern = "yyyy-MM-dd")
    private String onboardDate;

    @NotNull(message = "试用期月数不能为空")
    @DecimalMin(value = "0")
    @DecimalMax(value = "6")
    private Integer probationMonths;

    // ==================== 年龄校验 ====================

    @Age(min = 16, max = 65, message = "员工年龄必须在 16-65 岁之间")
    private String ageFromIdCard;  // 从身份证号自动计算
}

关于 @Age:ValidX 的 @Age 注解可以接受出生日期字符串或身份证号,自动计算年龄并校验是否在 [min, max] 范围内。这比手动解析身份证再算年龄优雅得多。


三、在职信息验证:银行卡、部门、岗位

3.1 工资卡:银行卡号 + Luhn 算法

每个员工入职必须录入工资卡信息。银行卡号的校验是 HR 系统里最容易被忽视、但最有价值的校验之一。

3.1.1 Luhn 算法(mod 10)

银行卡号不是随机数字——它遵循 ISO/IEC 7812 标准的 Luhn 算法(也叫 mod 10 算法):

校验步骤:
1. 从右往左,偶数位数字 × 2
2. 如果 × 2 结果 > 9,则减去 9
3. 所有位求和
4. 总和 mod 10 == 0 则合法

ValidX 的 @BankCard 内置了 Luhn 校验:

@NotBlank(message = "工资卡号不能为空")
@BankCard(message = "银行卡号格式不正确")
private String bankCardNo;
3.1.2 BIN 码识别:判断发卡行

银行卡号的前 6-8 位是 BIN 码(Bank Identification Number),可以识别发卡行和卡种:

BIN 段发卡行卡种
622202工商银行借记卡
622848农业银行借记卡
621661中国银行借记卡
622230建设银行借记卡
622588招商银行借记卡
62中国银联通用前缀

实战建议:如果系统需要"校验银行卡是否属于指定银行"(比如只支持工商银行和招商银行),这属于业务规则——@BankCard 只校验 Luhn 合法性,银行匹配需要在 Service 层做。

3.2 组织架构字段:部门、职级、用工类型

字段校验要点ValidX 注解
部门编码公司内部编码规则@AlphaNumber 或 @Pattern
职级枚举值(P1-P10 / M1-M5)@In
用工类型正式/实习/劳务派遣/外包@In
工作城市枚举值@In
直属上级工号必须存在于系统中业务校验(数据库查询)
@Data
public class EmployeePositionDTO {

    /** 部门编码 */
    @NotBlank(message = "部门编码不能为空")
    @AlphaNumber(message = "部门编码必须为字母数字组合")
    private String deptCode;

    /** 职级 */
    @NotBlank(message = "职级不能为空")
    @In({"P1","P2","P3","P4","P5","P6","P7","P8","P9","P10",
         "M1","M2","M3","M4","M5"},
        message = "职级不在合法范围内")
    private String jobLevel;

    /** 用工类型 */
    @NotBlank(message = "用工类型不能为空")
    @In({"FULL_TIME", "PART_TIME", "INTERN", "DISPATCH", "OUTSOURCE"},
        message = "用工类型不在合法范围内")
    private String employmentType;

    /** 工作城市 */
    @NotBlank(message = "工作城市不能为空")
    @In({"BEIJING", "SHANGHAI", "GUANGZHOU", "SHENZHEN",
         "HANGZHOU", "CHENGDU", "NANJING", "WUHAN"},
        message = "工作城市不在合法范围内")
    private String workCity;
}

3.3 工号规则

工号是员工在企业内部的唯一标识,不同企业有不同的工号规则。常见的几种:

规则示例校验方式
纯数字定长100234@FixedLength(length = 6)
前缀 + 年份 + 序号EMP20260001@Pattern(regexp = "^EMP\\d{8}$")
字母数字组合A10234@AlphaNumber
@NotBlank(message = "工号不能为空")
@FixedLength(length = 6, message = "工号必须为 6 位")
private String employeeNo;

关于 @FixedLength:这是 ValidX v1.2.1 引入的注解,专门解决"定长校验"需求。它比 @Pattern(regexp = "^\\d{6}$") 更具可读性。

3.4 ValidX 实现

@Data
public class EmployeeSalaryDTO {

    /** 工号 */
    @NotBlank(message = "工号不能为空")
    @FixedLength(length = 6, message = "工号必须为 6 位")
    private String employeeNo;

    /** 工资卡号 */
    @NotBlank(message = "工资卡号不能为空")
    @BankCard(message = "银行卡号格式不正确")
    private String bankCardNo;

    /** 开户行 */
    @NotBlank(message = "开户行不能为空")
    @Size(min = 2, max = 50, message = "开户行长度需在 2-50 字之间")
    private String bankName;

    /** 开户名(应与员工姓名一致——跨字段校验) */
    @NotBlank(message = "开户名不能为空")
    @ChineseName(message = "开户名格式不正确")
    private String bankAccountName;

    /** 基本工资 */
    @NotNull(message = "基本工资不能为空")
    @DecimalMin(value = "0.01", message = "基本工资必须大于 0")
    @Digits(integer = 10, fraction = 2, message = "基本工资最多 2 位小数")
    private BigDecimal baseSalary;
}

跨字段校验(开户名 = 员工姓名):

public void validateBankAccount(EmployeeSalaryDTO dto, String employeeName) {
    ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE);

    // 开户名应与员工姓名一致
    if (dto.getBankAccountName() != null
            && !dto.getBankAccountName().equals(employeeName)) {
        v.field("开户名").getErrors()
         .add("开户名(" + dto.getBankAccountName()
            + ")与员工姓名(" + employeeName + ")不一致");
    }

    if (!v.passed()) {
        throw new HRValidationException(v.getErrors());
    }
}

四、社保与公积金数据验证

4.1 社保号的"真相":就是身份证号

这是本文最想澄清的一点。

中国社保号 = 居民身份证号(对于中国籍员工)。社保系统里的"个人社保编号"就是身份证号码——没有独立的算法、没有额外的校验位。

员工类型社保号规则ValidX 注解
中国籍员工= 身份证号(18 位)@ChineseIdCard
外籍员工= 外国人永久居留身份证 / 护照号@ForeignerPermanentResidenceIdentity / @ChinesePassport
港澳台员工= 港澳/台湾居民居住证@HKMacauResidence / @TaiwanResidence
@NotBlank(message = "社保号不能为空")
private String socialInsuranceNo;  // 根据员工类型走不同校验

Service 层按员工类型分支:

public void validateSocialInsuranceNo(ValidX v, String no, String employeeType) {
    switch (employeeType) {
        case "DOMESTIC":
            v.field("社保号").isChineseIdCard(no);
            break;
        case "FOREIGNER":
            v.field("社保号").isForeignerPermanentResidenceIdentity(no);
            break;
        case "HK_MACAU":
            v.field("社保号").isHKMacauResidence(no);
            break;
        case "TAIWAN":
            v.field("社保号").isTaiwanResidence(no);
            break;
        default:
            v.field("社保号").getErrors().add("未知员工类型:" + employeeType);
    }
}

4.2 社保缴纳基数的数学关系

社保缴费基数是薪酬核算中最容易出错的部分。核心规则:

┌─────────────────────────────────────────────────────────┐
│  社保基数 = 员工上年度月平均工资                          │
│                                                         │
│  但有上下限:                                            │
│  · 下限 = 当地社平工资的 60%                             │
│  · 上限 = 当地社平工资的 300%                            │
│                                                         │
│  实际基数 = MAX(下限, MIN(上限, 月平均工资))              │
└─────────────────────────────────────────────────────────┘
险种企业比例(参考)个人比例(参考)基数来源
养老保险16%8%同一基数
医疗保险9.5%2%+3元同一基数
失业保险0.5%0.5%同一基数
工伤保险0.2%-1.9%0同一基数
生育保险0.8%0同一基数(已与医疗合并)

⚠️ 重要提示:以上比例为参考值,各城市、各年度比例不同。生产环境必须使用当地社保局公布的最新比例。本文重点讲"如何做校验",不是给出永久的费率表。

4.3 五险一金的勾稽关系

这是社保数据校验中最有价值的部分——和发票的金额勾稽一样,五险一金也有数学关系:

① 个人缴纳总额 = 养老个人 + 医疗个人 + 失业个人 + 公积金个人
② 企业缴纳总额 = 养老企业 + 医疗企业 + 失业企业 + 工伤企业 + 生育企业
③ 社保个人 = 社保基数 × (养老个人比例 + 医疗个人比例 + 失业个人比例)
④ 公积金个人 = 公积金基数 × 公积金个人比例

校验实现:

public final class SocialInsuranceReconciliation {

    /** 金额容差(分位四舍五入) */
    private static final BigDecimal TOLERANCE = new BigDecimal("0.01");

    /**
     * 校验:个人缴纳 = 基数 × 个人比例之和
     */
    public static boolean checkPersonalAmount(BigDecimal base,
                                              BigDecimal pensionRate,
                                              BigDecimal medicalRate,
                                              BigDecimal unemploymentRate,
                                              BigDecimal personalTotal) {
        if (base == null || pensionRate == null
                || medicalRate == null || unemploymentRate == null
                || personalTotal == null) {
            return false;
        }
        BigDecimal totalRate = pensionRate.add(medicalRate).add(unemploymentRate);
        BigDecimal expected = base.multiply(totalRate)
                .setScale(2, RoundingMode.HALF_UP);
        return personalTotal.subtract(expected).abs()
                .compareTo(TOLERANCE) <= 0;
    }

    /**
     * 校验:个人缴纳合计 = 各险种个人缴纳之和
     */
    public static boolean checkPersonalSum(BigDecimal pensionPersonal,
                                           BigDecimal medicalPersonal,
                                           BigDecimal unemploymentPersonal,
                                           BigDecimal housingFundPersonal,
                                           BigDecimal totalPersonal) {
        BigDecimal sum = pensionPersonal
                .add(medicalPersonal)
                .add(unemploymentPersonal)
                .add(housingFundPersonal);
        return totalPersonal.subtract(sum).abs()
                .compareTo(TOLERANCE) <= 0;
    }

    /**
     * 校验:基数在上下限范围内
     */
    public static boolean checkBaseRange(BigDecimal base,
                                         BigDecimal lowerLimit,
                                         BigDecimal upperLimit) {
        if (base == null || lowerLimit == null || upperLimit == null) {
            return false;
        }
        return base.compareTo(lowerLimit) >= 0
            && base.compareTo(upperLimit) <= 0;
    }
}

compareTo 而不是 equals:和财务系统一样,BigDecimal 的 equals 会比较精度——new BigDecimal("5000.0").equals(new BigDecimal("5000.00")) 返回 false。金额比较必须用 compareTo。

4.4 公积金账号格式

住房公积金账号格式因城市而异,但通常是纯数字定长或字母数字组合:

/** 公积金账号(纯数字,12 位) */
@Pattern(regexp = "^\\d{12}$", message = "公积金账号必须为 12 位数字")
private String housingFundNo;

注意:公积金账号格式各城市不同(北京 12 位纯数字、上海 10 位纯数字、深圳 10 位纯数字),没有全国统一的校验算法。如果你的系统覆盖多个城市,建议把公积金账号的正则做成可配置项。

4.5 ValidX 实现

@Data
public class SocialInsuranceDTO {

    // ==================== 社保号 ====================

    @NotBlank(message = "社保号不能为空")
    private String socialInsuranceNo;  // 按员工类型走不同校验

    // ==================== 缴费基数 ====================

    /** 社保缴费基数 */
    @NotNull(message = "社保基数不能为空")
    @DecimalMin(value = "0.01", message = "社保基数必须大于 0")
    @Digits(integer = 10, fraction = 2, message = "社保基数最多 2 位小数")
    private BigDecimal socialBase;

    /** 公积金缴费基数 */
    @NotNull(message = "公积金基数不能为空")
    @DecimalMin(value = "0.01", message = "公积金基数必须大于 0")
    @Digits(integer = 10, fraction = 2, message = "公积金基数最多 2 位小数")
    private BigDecimal housingFundBase;

    // ==================== 个人缴纳 ====================

    @NotNull(message = "养老保险(个人)不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal pensionPersonal;

    @NotNull(message = "医疗保险(个人)不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal medicalPersonal;

    @NotNull(message = "失业保险(个人)不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal unemploymentPersonal;

    @NotNull(message = "公积金(个人)不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal housingFundPersonal;

    /** 个人缴纳合计 */
    @NotNull(message = "个人缴纳合计不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal personalTotal;

    // ==================== 企业缴纳 ====================

    @NotNull(message = "养老保险(企业)不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal pensionCompany;

    @NotNull(message = "医疗保险(企业)不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal medicalCompany;

    @NotNull(message = "失业保险(企业)不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal unemploymentCompany;

    @NotNull(message = "工伤保险(企业)不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal injuryCompany;

    @NotNull(message = "公积金(企业)不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal housingFundCompany;

    /** 企业缴纳合计 */
    @NotNull(message = "企业缴纳合计不能为空")
    @DecimalMin(value = "0.00")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal companyTotal;

    // ==================== 公积金账号 ====================

    @Pattern(regexp = "^\\d{10,12}$", message = "公积金账号格式不正确")
    private String housingFundNo;
}

五、离职信息验证

5.1 离职日期的约束

离职日期不是随便填的——它必须满足一系列时间线约束:

入职日期 ≤ 转正日期 ≤ 离职日期 ≤ 今天
                    ≤ 合同结束日期
@Data
public class EmployeeResignDTO {

    /** 离职日期 */
    @NotNull(message = "离职日期不能为空")
    @PastDate(includeToday = true, message = "离职日期不能晚于今天")
    @Date(pattern = "yyyy-MM-dd")
    private String resignDate;

    /** 实际最后工作日(通常 = 离职日期 - 1 或 = 离职日期) */
    @NotNull(message = "最后工作日不能为空")
    @Date(pattern = "yyyy-MM-dd")
    private String lastWorkDate;

    /** 年假余额(≥ 0) */
    @DecimalMin(value = "0.0", message = "年假余额不能为负数")
    @Digits(integer = 5, fraction = 1, message = "年假余额最多 1 位小数")
    private BigDecimal annualLeaveBalance;

    /** 离职补偿金(≥ 0) */
    @DecimalMin(value = "0.00", message = "离职补偿金不能为负数")
    @Digits(integer = 10, fraction = 2, message = "离职补偿金最多 2 位小数")
    private BigDecimal severancePay;
}

跨字段时间线校验:

public void validateResignTimeline(EmployeeResignDTO dto,
                                    String onboardDate,
                                    String contractEndDate) {
    ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE);

    LocalDate resign = LocalDate.parse(dto.getResignDate());
    LocalDate onboard = LocalDate.parse(onboardDate);

    // 离职日期 ≥ 入职日期
    if (resign.isBefore(onboard)) {
        v.field("离职日期").getErrors()
         .add("离职日期不能早于入职日期(" + onboardDate + ")");
    }

    // 最后工作日 ≤ 离职日期
    if (dto.getLastWorkDate() != null) {
        LocalDate lastWork = LocalDate.parse(dto.getLastWorkDate());
        if (lastWork.isAfter(resign)) {
            v.field("最后工作日").getErrors()
             .add("最后工作日不能晚于离职日期");
        }
    }

    // 离职日期 ≤ 合同结束日期(如果是合同到期离职)
    if (contractEndDate != null) {
        LocalDate contractEnd = LocalDate.parse(contractEndDate);
        if (resign.isAfter(contractEnd)) {
            v.field("离职日期").getErrors()
             .add("离职日期不能晚于合同结束日期(" + contractEndDate + ")");
        }
    }

    if (!v.passed()) {
        throw new HRValidationException(v.getErrors());
    }
}

5.2 离职类型的枚举校验

@NotBlank(message = "离职类型不能为空")
@In({"RESIGNATION",           // 主动辞职
     "TERMINATION",           // 辞退/解雇
     "CONTRACT_EXPIRED",      // 合同到期
     "RETIREMENT",            // 退休
     "MUTUAL_AGREEMENT",      // 协商解除
     "DEATH",                 // 死亡/宣告死亡
     "OTHER"},
    message = "离职类型不在合法范围内")
private String resignType;

5.3 离职交接的完整性校验

离职不只是一个日期——还涉及一系列交接事项。系统需要校验"所有必选交接项是否已完成":

public void validateResignHandover(Map<String, Boolean> handoverItems) {
    ValidX v = ValidX.init()
            .config(ValidXConfig.GLOBAL_NOT_NULL);

    // 必选交接项
    v.field("工作交接").isIn(handoverItems.get("workHandover"),
            new Boolean[]{true});
    v.field("设备归还").isIn(handoverItems.get("equipmentReturn"),
            new Boolean[]{true});
    v.field("账号注销").isIn(handoverItems.get("accountCancellation"),
            new Boolean[]{true});
    v.field("门禁卡归还").isIn(handoverItems.get("accessCardReturn"),
            new Boolean[]{true});

    // 可选交接项(不做强制要求)
    // ...

    if (!v.passed()) {
        throw new HRValidationException(v.getErrors());
    }
}

设计思路:把交接项做成 Map<String, Boolean> 而不是固定字段,是因为不同岗位、不同部门的交接清单不同——这种灵活性正是链式 API 处理 Map 数据的优势所在。

5.4 ValidX 实现

完整的离职请求 DTO:

@Data
public class EmployeeResignRequest {

    @NotBlank(message = "员工工号不能为空")
    @FixedLength(length = 6, message = "工号必须为 6 位")
    private String employeeNo;

    @NotBlank(message = "离职类型不能为空")
    @In({"RESIGNATION", "TERMINATION", "CONTRACT_EXPIRED",
         "RETIREMENT", "MUTUAL_AGREEMENT", "DEATH", "OTHER"})
    private String resignType;

    @NotNull(message = "离职日期不能为空")
    @PastDate(includeToday = true)
    @Date(pattern = "yyyy-MM-dd")
    private String resignDate;

    @NotNull(message = "最后工作日不能为空")
    @Date(pattern = "yyyy-MM-dd")
    private String lastWorkDate;

    @Size(max = 500, message = "离职原因不能超过 500 字")
    private String resignReason;

    @DecimalMin(value = "0.0", message = "年假余额不能为负数")
    @Digits(integer = 5, fraction = 1)
    private BigDecimal annualLeaveBalance;

    @DecimalMin(value = "0.00", message = "离职补偿金不能为负数")
    @Digits(integer = 10, fraction = 2)
    private BigDecimal severancePay;

    /** 是否有竞业限制 */
    @NotNull(message = "竞业限制标识不能为空")
    private Boolean hasNonCompete;

    /** 竞业限制月数(有竞业限制时必填) */
    @DecimalMin(value = "1")
    @DecimalMax(value = "24")
    private Integer nonCompeteMonths;
}

六、完整实现:员工 DTO + Service

6.1 入职校验 Service

@Service
public class EmployeeOnboardValidationService {

    /** 合法学历 */
    private static final Set<String> VALID_EDUCATION = Set.of(
            "HIGH_SCHOOL", "COLLEGE", "BACHELOR", "MASTER", "DOCTOR");

    /** 合法证件类型 */
    private static final Set<String> VALID_ID_TYPES = Set.of(
            "ID_CARD", "PASSPORT", "MILITARY_OFFICER",
            "HK_MACAU_PASS", "TAIWAN_PASS",
            "FOREIGNER_PERMANENT_RESIDENCE");

    public void validate(EmployeeOnboardRequest req) {
        ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE);

        // ============ 1. 证件号码按类型分支校验 ============
        validateIdentity(v, req.getIdType(), req.getIdNumber());

        // ============ 2. 性别与身份证一致性 ============
        if ("ID_CARD".equals(req.getIdType())
                && req.getGender() != null
                && req.getIdNumber() != null
                && req.getIdNumber().length() == 18) {
            String expectedGender = new IdCardInfoExtractor()
                    .extractGender(req.getIdNumber());
            if (!req.getGender().equals(expectedGender)) {
                v.field("性别").getErrors()
                 .add("性别与身份证第 17 位不一致");
            }
        }

        // ============ 3. 出生日期与身份证一致性 ============
        if ("ID_CARD".equals(req.getIdType())
                && req.getBirthDate() != null
                && req.getIdNumber() != null
                && req.getIdNumber().length() == 18) {
            String expectedBirth = new IdCardInfoExtractor()
                    .extractBirthDate(req.getIdNumber());
            if (!req.getBirthDate().equals(expectedBirth)) {
                v.field("出生日期").getErrors()
                 .add("出生日期与身份证号中的出生日期不一致");
            }
        }

        // ============ 4. 年龄合规 ============
        if ("ID_CARD".equals(req.getIdType()) && req.getIdNumber() != null) {
            v.field("年龄").isAge(req.getIdNumber(), 16, 65);
        }

        // ============ 5. 紧急联系人不能和本人相同 ============
        if (req.getPhone() != null
                && req.getEmergencyContactPhone() != null
                && req.getPhone().equals(req.getEmergencyContactPhone())) {
            v.field("紧急联系人手机号").getErrors()
             .add("紧急联系人手机号不能与本人手机号相同");
        }

        // ============ 6. 试用期月数与劳动合同期限的合规关系 ============
        // 劳动合同法第 19 条(业务规则,非格式规则)
        // ← 此处省略,实际项目中应结合合同期限判断

        if (!v.passed()) {
            throw new HRValidationException(v.getErrors());
        }
    }

    private void validateIdentity(ValidX v, String idType, String idNumber) {
        if (!VALID_ID_TYPES.contains(idType)) {
            v.field("证件类型").getErrors().add("不支持的证件类型:" + idType);
            return;
        }
        switch (idType) {
            case "ID_CARD":
                v.field("身份证号码").isChineseIdCard(idNumber);
                break;
            case "PASSPORT":
                v.field("护照号码").isChinesePassport(idNumber);
                break;
            case "MILITARY_OFFICER":
                v.field("军官证号码").isChineseMilitaryOfficer(idNumber);
                break;
            case "HK_MACAU_PASS":
                v.field("港澳通行证号码").isHKMacauPass(idNumber);
                break;
            case "TAIWAN_PASS":
                v.field("台湾通行证号码").isTaiwanPass(idNumber);
                break;
            case "FOREIGNER_PERMANENT_RESIDENCE":
                v.field("外国人永居证号码")
                 .isForeignerPermanentResidenceIdentity(idNumber);
                break;
        }
    }
}

6.2 社保校验 Service

@Service
public class SocialInsuranceValidationService {

    public void validate(SocialInsuranceDTO dto,
                         BigDecimal socialLowerLimit,
                         BigDecimal socialUpperLimit,
                         BigDecimal pensionRate,
                         BigDecimal medicalRate,
                         BigDecimal unemploymentRate) {
        ValidX v = ValidX.init().withLocale(Locale.SIMPLIFIED_CHINESE);

        // ============ 1. 基数范围校验 ============
        if (!SocialInsuranceReconciliation.checkBaseRange(
                dto.getSocialBase(), socialLowerLimit, socialUpperLimit)) {
            v.field("社保基数").getErrors()
             .add("社保基数应在 " + socialLowerLimit + " ~ "
                + socialUpperLimit + " 之间");
        }

        // ============ 2. 个人缴纳金额与基数 × 比例的一致性 ============
        if (!SocialInsuranceReconciliation.checkPersonalAmount(
                dto.getSocialBase(), pensionRate, medicalRate,
                unemploymentRate, dto.getPersonalTotal())) {
            v.field("个人缴纳合计").getErrors()
             .add("个人缴纳合计与「基数 × 个人比例之和」不匹配"
                + "(允许 ±0.01 尾差)");
        }

        // ============ 3. 个人缴纳各项之和 = 个人缴纳合计 ============
        if (!SocialInsuranceReconciliation.checkPersonalSum(
                dto.getPensionPersonal(),
                dto.getMedicalPersonal(),
                dto.getUnemploymentPersonal(),
                dto.getHousingFundPersonal(),
                dto.getPersonalTotal())) {
            v.field("个人缴纳合计").getErrors()
             .add("个人缴纳合计应等于各险种个人缴纳之和");
        }

        if (!v.passed()) {
            throw new HRValidationException(v.getErrors());
        }
    }
}

6.3 Controller

@RestController
@RequestMapping("/api/v1/employees")
public class EmployeeController {

    @Autowired
    private EmployeeOnboardValidationService onboardValidationService;
    @Autowired
    private SocialInsuranceValidationService socialInsuranceValidationService;
    @Autowired
    private EmployeeService employeeService;

    /** 入职 */
    @PostMapping("/onboard")
    public Result<EmployeeVO> onboard(
            @Valid @RequestBody EmployeeOnboardRequest req) {
        // 第一层:注解校验(@Valid 自动触发)
        // 第二层:跨字段校验
        onboardValidationService.validate(req);
        // 第三层:业务处理
        return Result.success(employeeService.onboard(req));
    }

    /** 调薪(社保基数变更) */
    @PutMapping("/{id}/social-insurance")
    public Result<Void> updateSocialInsurance(
            @PathVariable String id,
            @Valid @RequestBody SocialInsuranceDTO req) {
        // 获取当地社保基数上下限(从配置中心或数据库)
        SocialPolicy policy = socialInsuranceValidationService
                .getCurrentPolicy(req.getWorkCity());
        // 跨字段校验
        socialInsuranceValidationService.validate(req,
                policy.getLowerLimit(), policy.getUpperLimit(),
                policy.getPensionRate(), policy.getMedicalRate(),
                policy.getUnemploymentRate());
        return Result.success();
    }

    /** 离职 */
    @PostMapping("/{id}/resign")
    public Result<Void> resign(
            @PathVariable String id,
            @Valid @RequestBody EmployeeResignRequest req) {
        // 注解校验 + 时间线校验
        employeeService.resign(id, req);
        return Result.success();
    }
}

七、常见坑与规避

7.1 坑一:身份证号 15 位老格式未兼容

现象:部分老员工的身份证仍是 15 位(一代证),被 @ChineseIdCard(只接受 18 位)拒掉。

根因:2013 年起一代证已停用,但历史数据中仍可能存在。

规避:

  • 入职新数据一律要求 18 位(@ChineseIdCard 只接受 18 位,这是正确的);
  • 历史数据迁移时做一次15 位 → 18 位升级(补全校验位);
  • 如果必须兼容 15 位,在 Service 层做分支处理,不要把 15 位逻辑写进格式校验器。

7.2 坑二:银行卡号用 Long 接收

现象:银行卡号 6222021234567890123 入库后变成了 6222021234567890000——末尾精度丢失。

根因:银行卡号最长 19 位,超过了 Long.MAX_VALUE(约 18.4 位),用 Long 接收必然溢出。

规避:

  • DTO 用 String 接收;
  • 数据库用 VARCHAR(20);
  • 永远不要用数值类型存储标识性数字(手机号、身份证号、银行卡号同理)。

7.3 坑三:社保基数上下限写死

现象:换了城市后,社保基数校验大面积报错——因为新城市的上下限不同。

根因:社保基数上下限各城市不同、每年调整。

规避:

  • 上下限做成可配置项(数据库 / 配置中心);
  • 按工作城市 + 年度选择对应的上下限;
  • 保留历史数据以兼容存量薪资记录。

7.4 坑四:把"社保号"当成独立编号

现象:系统设计了一个"社保号"字段,让员工手动填写,结果填什么的都有——有的填身份证号,有的填社保卡物理卡号,有的填社保局发的随机编号。

根因:没有明确"社保号 = 身份证号"这个事实。

规避:

  • 系统层面:社保号不需要员工手动填写,直接从身份证号自动带出;
  • 如果必须手动填(如外籍员工),在字段标签上明确标注"社保号 = 您的身份证/永居证号码";
  • 校验时根据员工类型走不同规则(见 4.1 节)。

7.5 坑五:试用期超过法定上限

现象:签了 1 年合同,约定了 6 个月试用期——违反劳动合同法。

根因:劳动合同法第 19 条对试用期上限有明确规定:

合同期限试用期上限
< 3 个月(含完成一定任务为期限)不得约定试用期
3 个月 ~ 1 年(不含 1 年)1 个月
1 年 ~ 3 年(不含 3 年)2 个月
≥ 3 年 或 无固定期限6 个月

规避:这是业务合规规则,不是格式规则。在 Service 层根据合同期限判断试用期上限:

public int getMaxProbationMonths(LocalDate contractStart,
                                  LocalDate contractEnd) {
    long months = ChronoUnit.MONTHS.between(contractStart, contractEnd);
    if (months < 3) {
        return 0;   // 不得约定试用期
    } else if (months < 12) {
        return 1;
    } else if (months < 36) {
        return 2;
    } else {
        return 6;
    }
}

7.6 坑六:离职日期填成了"未来"

现象:HR 提前录入了下个月的离职日期,导致当月社保无法正常缴纳。

根因:离职日期校验用了 @PastDate,但实际业务中"计划离职日期"可以是未来日期。

规避:

  • 区分**"实际离职日期"(必须 ≤ 今天)和"计划离职日期"**(可以是未来);
  • 前者用 @PastDate(includeToday = true),后者用 @FutureDate(includeToday = true) 或不做时间方向校验;
  • 在字段标签上明确标注。

7.7 坑七:紧急联系人和本人是同一号码

现象:员工填写紧急联系人时,手机号填了自己的号码——紧急情况下打不通。

根因:没有做"紧急联系人手机号 ≠ 本人手机号"的校验。

规避:在 Service 层加一行判断(见 6.1 节示例)。这种"两个字段不能相同"的规则是典型的跨字段校验,注解做不了,必须用链式 API。

7.8 坑八:BigDecimal 用 equals 比较

与财务系统完全相同的问题:

new BigDecimal("5000.0").equals(new BigDecimal("5000.00"))  // false!
new BigDecimal("5000.0").compareTo(new BigDecimal("5000.00")) // 0 → 相等

规避:金额比较一律用 compareTo(...) == 0。


八、总结

HR 系统的数据验证是"看起来简单、做对很难"的典型。本文把四块核心内容梳理如下:

核心知识点回顾

模块关键结论
身份证号18 位 + mod 11 校验位;可提取出生日期和性别;外籍员工走其他证件规则
手机号不只是 11 位数字——号段合法性才是关键;用 @ChinesePhone 而不是 ^\d{11}$
银行卡号有 Luhn 校验位(mod 10);必须用 String 接收,不能用 Long
社保号= 身份证号(中国籍);不需要员工手动填写
社保基数有上下限(社平工资 60% ~ 300%);上下限按城市、按年度配置
五险一金勾稽个人缴纳 = 基数 × 个人比例之和;必用 BigDecimal,必用 compareTo
离职日期区分"实际"(≤ 今天)和"计划"(可未来);必须满足时间线约束
试用期劳动合同法规定上限——这是业务合规规则,不是格式规则

ValidX 在本场景的使用矩阵

需求推荐用法
身份证号@ChineseIdCard
护照 / 军官证 / 通行证@ChinesePassport / @ChineseMilitaryOfficer / @HKMacauPass / @TaiwanPass
外国人永居证@ForeignerPermanentResidenceIdentity
姓名@ChineseName
手机号@ChinesePhone
座机号@ChineseLandline
邮箱@Email
企业邮箱域名@Email + @EndsWith
银行卡号@BankCard
入职日期(已入职)@PastDate(includeToday = true)
计划离职日期@FutureDate(includeToday = true)
年龄@Age(min = 16, max = 65)
学历 / 职级 / 用工类型@In({...})
工号定长@FixedLength(length = 6)
薪资 / 社保金额@DecimalMin + @Digits
性别与身份证一致性链式 API(跨字段)
社保基数 × 比例链式 API + BigDecimal(跨字段)
紧急联系人 ≠ 本人链式 API(跨字段)
试用期合规Service 层业务规则

最关键的一条设计原则(和财务系统完全一致):

字段级校验用注解,跨字段校验用链式 API,业务合规用 Service 层规则。

HR 系统的校验天然分三层——格式(注解)、关系(链式)、合规(业务规则)。把三层混在一起是万恶之源:格式规则不该出现在 Service 层里,业务规则也不该写进注解校验器。

项目地址