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

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

📋 目录

  • 引言
  • 一、HR系统数据验证的全景
    • [1.1 一个员工从入职到离职的数据生命周期](#1.1 一个员工从入职到离职的数据生命周期 "#11-%E4%B8%80%E4%B8%AA%E5%91%98%E5%B7%A5%E4%BB%8E%E5%85%A5%E8%81%8C%E5%88%B0%E7%A6%BB%E8%81%8C%E7%9A%84%E6%95%B0%E6%8D%AE%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F")
    • [1.2 HR数据验证的 4 个层次](#1.2 HR数据验证的 4 个层次 "#12-hr%E6%95%B0%E6%8D%AE%E9%AA%8C%E8%AF%81%E7%9A%84-4-%E4%B8%AA%E5%B1%82%E6%AC%A1")
    • [1.3 为什么HR数据最容易"脏"](#1.3 为什么HR数据最容易"脏" "#13-%E4%B8%BA%E4%BB%80%E4%B9%88hr%E6%95%B0%E6%8D%AE%E6%9C%80%E5%AE%B9%E6%98%93%E8%84%8F")
  • 二、入职信息验证:身份、联系方式、学历
    • [2.1 身份信息:不只是身份证号码](#2.1 身份信息:不只是身份证号码 "#21-%E8%BA%AB%E4%BB%BD%E4%BF%A1%E6%81%AF%E4%B8%8D%E5%8F%AA%E6%98%AF%E8%BA%AB%E4%BB%BD%E8%AF%81%E5%8F%B7%E7%A0%81")
    • [2.2 联系方式:手机、邮箱、紧急联系人](#2.2 联系方式:手机、邮箱、紧急联系人 "#22-%E8%81%94%E7%B3%BB%E6%96%B9%E5%BC%8F%E6%89%8B%E6%9C%BA%E9%82%AE%E7%AE%B1%E7%B4%A7%E6%80%A5%E8%81%94%E7%B3%BB%E4%BA%BA")
    • [2.3 学历与资格证书](#2.3 学历与资格证书 "#23-%E5%AD%A6%E5%8E%86%E4%B8%8E%E8%B5%84%E6%A0%BC%E8%AF%81%E4%B9%A6")
    • [2.4 入职日期与试用期](#2.4 入职日期与试用期 "#24-%E5%85%A5%E8%81%8C%E6%97%A5%E6%9C%9F%E4%B8%8E%E8%AF%95%E7%94%A8%E6%9C%9F")
    • [2.5 ValidX 实现](#2.5 ValidX 实现 "#25-validx-%E5%AE%9E%E7%8E%B0")
  • 三、在职信息验证:银行卡、部门、岗位
    • [3.1 工资卡:银行卡号 + Luhn 算法](#3.1 工资卡:银行卡号 + Luhn 算法 "#31-%E5%B7%A5%E8%B5%84%E5%8D%A1%E9%93%B6%E8%A1%8C%E5%8D%A1%E5%8F%B7--luhn-%E7%AE%97%E6%B3%95")
    • [3.2 组织架构字段:部门、职级、用工类型](#3.2 组织架构字段:部门、职级、用工类型 "#32-%E7%BB%84%E7%BB%87%E6%9E%B6%E6%9E%84%E5%AD%97%E6%AE%B5%E9%83%A8%E9%97%A8%E8%81%8C%E7%BA%A7%E7%94%A8%E5%B7%A5%E7%B1%BB%E5%9E%8B")
    • [3.3 工号规则](#3.3 工号规则 "#33-%E5%B7%A5%E5%8F%B7%E8%A7%84%E5%88%99")
    • [3.4 ValidX 实现](#3.4 ValidX 实现 "#34-validx-%E5%AE%9E%E7%8E%B0")
  • 四、社保与公积金数据验证
    • [4.1 社保号的"真相":就是身份证号](#4.1 社保号的"真相":就是身份证号 "#41-%E7%A4%BE%E4%BF%9D%E5%8F%B7%E7%9A%84%E7%9C%9F%E7%9B%B8%E5%B0%B1%E6%98%AF%E8%BA%AB%E4%BB%BD%E8%AF%81%E5%8F%B7")
    • [4.2 社保缴纳基数的数学关系](#4.2 社保缴纳基数的数学关系 "#42-%E7%A4%BE%E4%BF%9D%E7%BC%B4%E7%BA%B3%E5%9F%BA%E6%95%B0%E7%9A%84%E6%95%B0%E5%AD%A6%E5%85%B3%E7%B3%BB")
    • [4.3 五险一金的勾稽关系](#4.3 五险一金的勾稽关系 "#43-%E4%BA%94%E9%99%A9%E4%B8%80%E9%87%91%E7%9A%84%E5%8B%BE%E7%A8%BD%E5%85%B3%E7%B3%BB")
    • [4.4 公积金账号格式](#4.4 公积金账号格式 "#44-%E5%85%AC%E7%A7%AF%E9%87%91%E8%B4%A6%E5%8F%B7%E6%A0%BC%E5%BC%8F")
    • [4.5 ValidX 实现](#4.5 ValidX 实现 "#45-validx-%E5%AE%9E%E7%8E%B0")
  • 五、离职信息验证
    • [5.1 离职日期的约束](#5.1 离职日期的约束 "#51-%E7%A6%BB%E8%81%8C%E6%97%A5%E6%9C%9F%E7%9A%84%E7%BA%A6%E6%9D%9F")
    • [5.2 离职类型的枚举校验](#5.2 离职类型的枚举校验 "#52-%E7%A6%BB%E8%81%8C%E7%B1%BB%E5%9E%8B%E7%9A%84%E6%9E%9A%E4%B8%BE%E6%A0%A1%E9%AA%8C")
    • [5.3 离职交接的完整性校验](#5.3 离职交接的完整性校验 "#53-%E7%A6%BB%E8%81%8C%E4%BA%A4%E6%8E%A5%E7%9A%84%E5%AE%8C%E6%95%B4%E6%80%A7%E6%A0%A1%E9%AA%8C")
    • [5.4 ValidX 实现](#5.4 ValidX 实现 "#54-validx-%E5%AE%9E%E7%8E%B0")
  • [六、完整实现:员工 DTO + Service](#六、完整实现:员工 DTO + Service "#%E5%85%AD%E5%AE%8C%E6%95%B4%E5%AE%9E%E7%8E%B0%E5%91%98%E5%B7%A5-dto--service")
  • 七、常见坑与规避
  • 八、总结
  • 项目地址

引言

在 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 位数字 这种写法随处可见。
java 复制代码
// 反例:只校验长度
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)。
java 复制代码
// ValidX 一行搞定
@ChineseIdCard(message = "身份证号码格式不正确")
private String idCard;

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

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

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

java 复制代码
@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 外籍员工:多种证件并行

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

java 复制代码
@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 层处理:

java 复制代码
@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 这种不存在于任何运营商的号码会被直接拒绝。

java 复制代码
@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
java 复制代码
@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. 入职日期不能太晚(不能晚于今天------如果是"实际入职";或不能太晚------如果是"计划入职")。
java 复制代码
@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 层):

java 复制代码
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 组合起来,看一个完整的入职请求:

java 复制代码
@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 算法):

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

ValidX 的 @BankCard 内置了 Luhn 校验:

java 复制代码
@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
直属上级工号 必须存在于系统中 业务校验(数据库查询)
java 复制代码
@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
java 复制代码
@NotBlank(message = "工号不能为空")
@FixedLength(length = 6, message = "工号必须为 6 位")
private String employeeNo;

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

3.4 ValidX 实现

java 复制代码
@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;
}

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

java 复制代码
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
java 复制代码
@NotBlank(message = "社保号不能为空")
private String socialInsuranceNo;  // 根据员工类型走不同校验

Service 层按员工类型分支:

java 复制代码
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 社保缴纳基数的数学关系

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

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

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

4.3 五险一金的勾稽关系

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

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

校验实现:

java 复制代码
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 公积金账号格式

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

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

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

4.5 ValidX 实现

java 复制代码
@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 离职日期的约束

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

markdown 复制代码
入职日期 ≤ 转正日期 ≤ 离职日期 ≤ 今天
                    ≤ 合同结束日期
java 复制代码
@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;
}

跨字段时间线校验:

java 复制代码
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 离职类型的枚举校验

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

5.3 离职交接的完整性校验

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

java 复制代码
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:

java 复制代码
@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

java 复制代码
@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

java 复制代码
@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

java 复制代码
@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 层根据合同期限判断试用期上限:

java 复制代码
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 比较

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

java 复制代码
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 层里,业务规则也不该写进注解校验器。

项目地址

相关推荐
盛世宏博智慧档案1 小时前
恒湿净化一体机联动开发:档案馆温湿度阈值判断与启停控制实践
java·后端·struts·恒湿机·加湿除湿消毒净化一体机
兔子零10241 小时前
FastAPI 零基础入门教程:写过 Node.js,就能轻松学会 Python 后端开发(一)
前端·后端·python
huaweichenai1 小时前
spring boot实现base64上传
java·spring boot·后端
ThinkerQAQ_1 小时前
并发编程(九):BlockingQueue 与 Channel——语言层的原子性、可见性与有序性
后端
萧瑟余晖2 小时前
Spring Web综合实战与性能调优详解
java·spring
花间相见2 小时前
【计算基础|网络16】【工程实战】Nginx 部署与反向代理
后端
点纭2 小时前
spring ai1.0实战:ChatClient(2)
java·后端·spring
小马过河R2 小时前
EdgeOne深度解析:从原理、适用场景到COS接入的技术逻辑
java·开发语言·前端·spring·cos
知守观2 小时前
多模块工程没 install,跑起来的代码是上周的:Maven reactor 依赖解析踩坑
java·maven·intellij idea