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 入职日期与试用期
入职日期相关的校验有两个关键点:
- 入职日期不能太早(不能早于公司成立日期);
- 入职日期不能太晚(不能晚于今天------如果是"实际入职";或不能太晚------如果是"计划入职")。
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 层里,业务规则也不该写进注解校验器。
项目地址
- GitHub: github.com/vipxieliang...
- Gitee: gitee.com/vipxieliang...