feat(mp): M3-01 门禁核验(名单是闸门)

M3 的第一块:学生必须先过名单核验,才能开始填报任何东西。

交付:
· POST /api/v1/mp/roster/match(§5.4)—— 姓名+证件号与名册匹配,命中才放行并绑定
· common/util/IdCardUtil —— 身份证校验位(三态裁决)
· support/ClientIp —— 取真实调用方 IP(限流用,X-Forwarded-For 第一段)
· module/roster/entity/CollectRoster —— 名册实体(M2 会继续用)

四条守住的规则:
1) 失败提示必须统一(40007),不区分"姓名错"还是"号码错"。
   分开提示等于交付了一个身份证号枚举器:用任意姓名+目标号码试,
   若提示"姓名不对"就说明号码在名单里,一个个试就能把全校号码试出来。
   这不是体验取舍,是安全底线。E2E 专门断言两种失败的 msg 字符串完全一致。
2) 绑定并发安全:UPDATE ... WHERE bind_user_id IS NULL + 看影响行数。
   不要改成"先 SELECT 判空再 UPDATE"——中间有窗口,两人会同时通过检查,
   后绑的人静默覆盖先绑的人,而先绑的学生之后再也进不去。
   E2E 用两个线程同时冲同一行,断言"恰好一个成功、一个 40072"(实测 [0, 40072])。
3) 证件号明文只在 match() 里出现一次,失败日志只记 HMAC 与 uid(日志被翻出来
   同样是泄露);响应里永远只有 idCardMask,E2E 断言整个响应 JSON 不含明文。
4) 已绑定自己的人重复核验是幂等的 —— 学生刷新页面不该看到"该信息已被登记"。

IdCardUtil 的价值在三态而不在对错:
   真实数据集实测:名册 28 人校验位全通过(可信基准);某两个学生的文件名证件号
   校验位失败(确定是号码错,可自动改正);另一学生的两个号码都通过(是两个真人,
   串档实锤,必须人工介入)。后两种看起来都是"号码对不上",处理策略却完全相反——
   一个能放心自动修,另一个自动修就是把两个真人的材料合并。区分它们的唯一办法
   就是校验位。所以对外暴露 Verdict{VALID,CHECKSUM_FAILED,MALFORMED} 而非 boolean。

   1900 年下界是业务判断,不是国标要求:网上流传的"示例有效身份证号"里有一批
   出生年份在 1800 年代(如常被引用的 440524188001010014 → 1880 年),它们校验位
   合法但不可能是认定考生。只算校验位会把它们判成 VALID。这个坑写 E2E 时真踩到了。

E2E 怎么拿 token:门禁要求已登录的学生,而登录(/mp/login)需要真实的 wx.login
code,脚本拿不到。所以用 dev 配置里的 JWT 密钥自己签一个 MP token(HS512,
claims 与 JwtService.issue 同构)—— 与 e2e-m0 用 dev RSA 公钥加密登录密码是同一类
做法:用开发配置里的凭据手搓测试夹具,不是绕过认证逻辑。prod 密钥不在仓库里,
这个手法自然失效。顺带:读配置要读两个文件,issuer 在主 application.yml 里,
只读 dev 会漏掉它,而症状是 401,完全指不到"配置没读到"。

回归抓到我一条脆断言:
   e2e-m1-qrcode.py 的"token TTL 是 7000 秒"在连续第二次运行时失败(TTL=4633,
   正好是两次运行的时间差)——它假设 token 是刚取的,实际沿用了缓存。
   已改成"先清缓存强制重取,再断言 TTL∈[6900,7000]",连跑两次都稳定通过。
   教训与前面几次同类:断言要确定性地反映被测行为,而不是碰巧在某种顺序下通过。

已知缺口(写进交接摘要):
· /mp/login 未做:它需要真实 wx.login code,脚本测不了,只能在小程序里测,
  下一轮连同小程序核验页一起做。
· 形近字候选(FR-504c)未实现,needConfirmName 恒为 false。
· 名册导入未做(M2),E2E 用 SQL 直接造名册行。
· 小程序端还没有核验页(落地页的「开始填报」目前是明确的"开发中"提示)。

验收:e2e-m3-gate.py 29/29(含并发绑定与限流);
九条 E2E 合计 418 项断言全通过。
This commit is contained in:
2026-09-29 10:46:21 +08:00
parent 0bc7ee02dc
commit 15b819746b
13 changed files with 1300 additions and 4 deletions
@@ -0,0 +1,133 @@
package cn.tianbaotong.fill.common.util;
/**
* 身份证号校验(GB 11643-1999 / ISO 7064 MOD 11-2)。
*
* <p><b>这个类的价值不在"能不能判对错",而在"能区分出三种完全不同的情形"。</b>
* 真实基准数据集上实测过(《PRD》§实测校正):
*
* <pre>
* 名册 28 人的证件号 → 校验位全部通过 → 名册可信,可作为权威基准
* 某两个学生的文件名证件号 → 校验位失败 → **确定性判定为号码错误,可自动改正**(无需问任何人)
* 另一名学生的两个号码 → 校验位**都通过** → 说明是两个真实存在的人
* → **串档实锤,不是打字错误,必须人工介入**
* </pre>
*
* 后两种情形<b>看起来都是"号码对不上",但处理策略完全相反</b>:
* 一个可以放心自动修,另一个自动修就是灾难(把两个真人的材料合并到一起)。
* 区分它们的唯一办法就是校验位 —— 这也是为什么拦截面从 0% 提到 100% 靠的是算法而不是人工。
*
* <p>所以对外暴露的是 {@link Verdict} 三态,而不是一个 {@code boolean}。
* 只回 true/false 会把这个区分丢掉,调用方就只能一律"人工确认",
* 而那等于把 0% 拦截率换成"100% 拦截但 100% 要人工",收益大半没了。
*/
public final class IdCardUtil {
private IdCardUtil() {
}
/** 加权因子(前 17 位)。 */
private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
/** 余数 → 校验位字符。 */
private static final char[] CHECK_CHARS = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'};
/**
* 校验结论。
*
* <p>⚠️ <b>{@link #CHECKSUM_FAILED} 与 {@link #VALID} 的区别是整个产品的关键</b>:
* 前者说明这个号码<b>不是一个真实的身份证号</b>(可以放心自动改),
* 后者说明它<b>是一个真人的号</b>(两个都通过就是两个人,绝不能自动合并)。
*/
public enum Verdict {
/** 格式与校验位都通过 —— 这是一个真实存在的号码。 */
VALID,
/** 格式像身份证号,但校验位不对 —— 确定不是真实号码,可自动改正。 */
CHECKSUM_FAILED,
/** 格式就不对(长度、字符、出生日期非法)—— 连"像"都算不上。 */
MALFORMED
}
/**
* 裁决一个身份证号。
*
* @param raw 原始输入(会先做规范化:去首尾空白 + 转大写)
* @return 三态结论;{@code null} 输入返回 {@link Verdict#MALFORMED}
*/
public static Verdict verdict(String raw) {
String s = normalize(raw);
if (s.length() != 18) {
return Verdict.MALFORMED;
}
for (int i = 0; i < 17; i++) {
if (s.charAt(i) < '0' || s.charAt(i) > '9') {
return Verdict.MALFORMED;
}
}
char last = s.charAt(17);
if (!(last >= '0' && last <= '9') && last != 'X') {
return Verdict.MALFORMED;
}
// 出生日期要真的存在。20260230 这种"看着像"的号码会被这里挡掉 ——
// 只算校验位是不够的,1 月 32 日也能算出个校验位来
if (!birthDateLooksReal(s)) {
return Verdict.MALFORMED;
}
return last == checkChar(s) ? Verdict.VALID : Verdict.CHECKSUM_FAILED;
}
/** 是否是一个校验通过的身份证号(最常用的一问)。 */
public static boolean isValid(String raw) {
return verdict(raw) == Verdict.VALID;
}
/**
* 规范化:去首尾空白 + 去内部空格 + 转大写。
*
* <p>与 {@code HmacIndex.hash} 的规范化口径<b>必须一致</b>:门禁是按
* {@code id_card_hash} 查名册的,如果这里少做一步(比如不转大写),
* 学生填的 {@code ...x} 就查不到名册里存的 {@code ...X},
* 表现是"我填的跟老师给的一模一样,却说我信息不对"。
*/
public static String normalize(String raw) {
if (raw == null) {
return "";
}
StringBuilder sb = new StringBuilder(raw.length());
for (char c : raw.toCharArray()) {
if (!Character.isWhitespace(c)) {
sb.append(c);
}
}
return sb.toString().toUpperCase();
}
private static char checkChar(String s) {
int sum = 0;
for (int i = 0; i < 17; i++) {
sum += (s.charAt(i) - '0') * WEIGHTS[i];
}
return CHECK_CHARS[sum % 11];
}
/** 出生日期段(第 7~14 位)是否是一个真实存在的日期。 */
private static boolean birthDateLooksReal(String s) {
try {
int year = Integer.parseInt(s.substring(6, 10));
int month = Integer.parseInt(s.substring(10, 12));
int day = Integer.parseInt(s.substring(12, 14));
// ⚠️ 1900 这个下界是**业务判断**,不是国标要求(国标只规定 YYYYMMDD 格式)。
// 加它的原因:网上流传的"示例有效身份证号"里有一批出生年份在 1800 年代的
// (例如常被引用的 440524188001010014 → 1880 年出生),
// 它们的校验位**是合法的**,但显然不可能是职业技能认定考生。
// 只算校验位会把这些号码判成 VALID,然后门禁去找一个不可能存在的名册项。
// 代价是:真有一位 1890 年出生的考生会被判格式非法(现实中不存在)。
if (year < 1900 || year > 2100 || month < 1 || month > 12 || day < 1) {
return false;
}
return day <= java.time.YearMonth.of(year, month).lengthOfMonth();
} catch (Exception e) {
return false;
}
}
}
@@ -31,7 +31,7 @@ public final class MaskUtil {
* 身份证号脱敏。
*
* <pre>
* 361029200912100010 → 3610292009******10
* 361029200912100015 → 3610292009******10
* </pre>
*
* <p>保留前 10 位(含地区码与出生年)与后 2 位,中间 6 位打星。