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> * <pre>
* 361029200912100010 → 3610292009******10 * 361029200912100015 → 3610292009******10
* </pre> * </pre>
* *
* <p>保留前 10 位(含地区码与出生年)与后 2 位,中间 6 位打星。 * <p>保留前 10 位(含地区码与出生年)与后 2 位,中间 6 位打星。
@@ -0,0 +1,47 @@
package cn.tianbaotong.fill.module.mp.controller;
import cn.tianbaotong.fill.common.api.ApiResult;
import cn.tianbaotong.fill.module.mp.dto.RosterMatchReq;
import cn.tianbaotong.fill.module.mp.dto.RosterMatchVO;
import cn.tianbaotong.fill.module.mp.service.MpRosterService;
import cn.tianbaotong.fill.support.ClientIp;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 门禁核验(《03-接口设计》§5.4)。
*
* <p><b>为什么只要求"已登录"而不要求具体权限码</b>:
* 门禁本身就是授权判定 —— 匹配不上名册就什么也做不了(后面所有 /mp/submissions
* 都要有绑定关系)。而"代学生核验"是一期就要支持的能力(`auditConfig.allowProxyUpload`
* 默认开),所以班主任、管理员也会调这个接口。挂一个 {@code student:*} 权限码
* 反而会把本不该挡的人挡住。
*
* <p>⚠️ 它是产品里<b>唯一接收身份证号明文</b>的入口,所以:不返回明文(只回脱敏值)、
* 失败提示统一({@code 40007},不区分姓名错还是号码错,防枚举)、双维度限流。
*/
@Tag(name = "小程序 · 门禁核验")
@RestController
@RequestMapping("/api/v1/mp")
@RequiredArgsConstructor
public class MpRosterController {
private final MpRosterService mpRosterService;
@PostMapping("/roster/match")
@Operation(summary = "门禁核验(核心接口)",
description = "填写「姓名 + 身份证号」与名册匹配,命中才放行并绑定。"
+ "失败一律返回 40007 且**不区分姓名错还是号码错**(防身份证号枚举);"
+ "已被他人绑定返回 40072;限流同 openid 10 次/小时、同 IP 20 次/分钟(40071)")
public ApiResult<RosterMatchVO> match(@Valid @RequestBody RosterMatchReq req,
HttpServletRequest request) {
return ApiResult.ok(mpRosterService.match(req, ClientIp.of(request)));
}
}
@@ -0,0 +1,35 @@
package cn.tianbaotong.fill.module.mp.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 门禁核验请求(《03-接口设计》§5.4)。
*
* <p>只收三个字段:姓名、身份证号、任务。<b>不多收任何东西</b> ——
* 这个接口是产品里唯一接收身份证号明文的入口,越窄越好。
*/
@Getter
@Setter
@Schema(description = "门禁核验请求")
public class RosterMatchReq {
@NotBlank(message = "请填写姓名")
@Size(max = 64, message = "姓名过长")
@Schema(description = "姓名", example = "曾伊豪", requiredMode = Schema.RequiredMode.REQUIRED)
private String realName;
@NotBlank(message = "请填写身份证号")
@Size(max = 32, message = "身份证号过长")
@Schema(description = "身份证号(HTTPS 传输;服务端只存 HMAC,不落明文)",
example = "361029200912100015", requiredMode = Schema.RequiredMode.REQUIRED)
private String idCardNo;
@NotNull(message = "缺少任务 ID")
@Schema(description = "任务 ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long taskId;
}
@@ -0,0 +1,43 @@
package cn.tianbaotong.fill.module.mp.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 门禁核验结果(《03-接口设计》§5.4)。
*
* <p>⚠️ <b>返回的身份证号永远是脱敏值</b>({@code idCardMask}),一律不返回明文 ——
* 这是硬规则(见 {@code MaskUtil}):审核场景真要看完整号,走单独的授权接口。
*/
@Getter
@Setter
@Schema(description = "门禁核验结果")
public class RosterMatchVO {
private Boolean matched;
private Long rosterId;
private String realName;
/** 脱敏证件号,如 {@code 3610292009******10}。 */
private String idCardMask;
private String orgName;
/**
* 名册里的预填值。
*
* <p>名单里"报考职业/工种/等级"这三列老师已经填好了(实测 28/28 都有值),
* 所以学生端不该让他重填 —— 预填 + 标注"老师已填,请核对"。
* 键名用 camelCase,与小程序端和表单定义一致。
*/
private Map<String, Object> prefill = new LinkedHashMap<>();
/** 姓名是形近字、需要用户确认(FR-504c)。本轮未实现,恒为 false。 */
private Boolean needConfirmName = Boolean.FALSE;
}
@@ -0,0 +1,102 @@
package cn.tianbaotong.fill.module.mp.mapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
/**
* 门禁核验的名册查询(《03-接口设计》§5.4)。
*
* <p>这些查询走原生 SQL 而不是 MP 的条件构造器,原因有二:
* <ol>
* <li><b>绑定必须是并发安全的</b>,靠的是 {@code WHERE bind_user_id IS NULL}
* 与<b>影响行数</b>,条件构造器表达不了这个语义(也没有"影响几行"的语义)。</li>
* <li>关键条件({@code tenant_id})写成显式参数,让"租户从哪来"只有一个来源,
* 不依赖"拦截器应该会加上"。</li>
* </ol>
*/
public interface MpRosterMapper {
/**
* 按 {@code (task, id_card_hash)} 定位名册项。
*
* <p>⚠️ 用 {@code id_card_hash}(HMAC)而不是 {@code id_card_no}(AES):
* 后者每次加密的 IV 都不同,<b>做不了等值查询</b>。用错的症状是"永远查不到"。
*
* <p><b>{@code LIMIT 2} 而不是 {@code LIMIT 1} 是刻意的</b>:
* 同一个任务下出现两条同证件号,说明名册本身有问题(重复导入)。
* 取 1 条会把这个事实悄悄盖住,然后两个学生里有一个填不进来、
* 而谁也说不清为什么。返回 2 条时调用方会明确报"名册里这个证件号有重复"。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_roster
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND id_card_hash = #{idCardHash} AND deleted = 0
LIMIT 2
""")
List<CollectRoster> selectByIdCardHash(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("idCardHash") String idCardHash);
/**
* <b>并发安全的绑定</b>:只在还没人绑的时候绑上。
*
* <p>关键在 {@code AND bind_user_id IS NULL} —— 两个手机同时扫同一个证件号时,
* 数据库保证只有一条 UPDATE 能成功(行锁 + 条件),另一条影响 0 行。
* 调用方据影响行数判断"是不是被别人抢了",返回 {@code 40072}。
*
* <p>⚠️ 不要改成"先 SELECT 判断没绑定,再 UPDATE":那中间有窗口,
* 两个人会同时通过检查、先后都绑上,结果是<b>后绑的人静默覆盖了先绑的人</b>,
* 而先绑的那个学生之后再也进不去(他的号被别人的 openid 占了)。
* 这种 bug 只在两个人同时操作时出现,测试极难撞上。
*
* @return 影响行数:1 = 绑定成功,0 = 已被他人绑定
*/
@InterceptorIgnore(tenantLine = "true")
@Update("""
UPDATE collect_roster
SET bind_user_id = #{userId}, bind_time = now(), bind_type = #{bindType}
WHERE id = #{rosterId} AND tenant_id = #{tenantId} AND bind_user_id IS NULL
""")
int bindIfUnbound(@Param("tenantId") Long tenantId,
@Param("rosterId") Long rosterId,
@Param("userId") Long userId,
@Param("bindType") int bindType);
/**
* 查这个用户在这个任务下已绑定的名册项 —— 用于<b>免门禁直入</b>。
*
* <p>门禁只需过一次:绑定关系长期有效,学生第二次进来不该再填一遍姓名和证件号。
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT * FROM collect_roster
WHERE tenant_id = #{tenantId} AND task_id = #{taskId}
AND bind_user_id = #{userId} AND deleted = 0
LIMIT 1
""")
CollectRoster selectBoundByUser(@Param("tenantId") Long tenantId,
@Param("taskId") Long taskId,
@Param("userId") Long userId);
/** 取班级名(返回给学生的确认信息)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT org_name FROM sys_org
WHERE id = #{orgId} AND tenant_id = #{tenantId} AND deleted = 0
""")
String selectOrgName(@Param("tenantId") Long tenantId, @Param("orgId") Long orgId);
/** 任务是否存在且属于该租户(防止拿别的租户的 taskId 撞库)。 */
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT COUNT(*) FROM collect_task
WHERE id = #{taskId} AND tenant_id = #{tenantId} AND deleted = 0
""")
Long countTask(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
}
@@ -0,0 +1,250 @@
package cn.tianbaotong.fill.module.mp.service;
import cn.tianbaotong.fill.common.api.ErrorCode;
import cn.tianbaotong.fill.common.context.LoginUser;
import cn.tianbaotong.fill.common.context.UserContext;
import cn.tianbaotong.fill.common.exception.BizException;
import cn.tianbaotong.fill.module.mp.dto.RosterMatchReq;
import cn.tianbaotong.fill.module.mp.dto.RosterMatchVO;
import cn.tianbaotong.fill.module.mp.mapper.MpRosterMapper;
import cn.tianbaotong.fill.module.roster.entity.CollectRoster;
import cn.tianbaotong.fill.common.crypto.HmacIndex;
import cn.tianbaotong.fill.common.util.IdCardUtil;
import cn.tianbaotong.fill.config.FillProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 门禁核验(《03-接口设计》§5.4)—— 本产品的核心机制。
*
* <p><b>名单是闸门</b>:填报人必须填写「姓名 + 身份证号」与名册匹配,命中才放行。
* 这不是"注册",也不是"验证码"——它是把填报人<b>钉死在一份权威名单上</b>,
* 从而让"这份材料是谁的"不再依赖任何人的自觉。
*
* <p>四条守住的规则:
* <ol>
* <li><b>失败提示必须统一({@code 40007}),不区分"姓名错"还是"号码错"</b>。
* 分开提示等于给了一个<b>身份证号枚举器</b>:先用任意姓名 + 目标号码试,
* 若提示"姓名不对"就说明号码在名单里 —— 一个一个试就能把全校学生的
* 身份证号试出来。这条不是体验取舍,是安全底线。</li>
* <li><b>绑定必须并发安全</b>:靠 {@code UPDATE ... WHERE bind_user_id IS NULL}
* 的影响行数判断,不是"先查后写"。见 {@code MpRosterMapper.bindIfUnbound}。</li>
* <li><b>证件号明文只在这个方法里出现过一次</b>,之后只留 HMAC。
* 失败日志<b>只记 hash 与 uid</b>,不记明文 —— 日志被翻出来同样是泄露。</li>
* <li><b>已绑定自己的人重复核验是幂等的</b>(返回成功)。学生刷新页面、
* 返回上一页再进来,都不该看到"该信息已被登记"这种吓人的提示。</li>
* </ol>
*/
@Slf4j
@Service
public class MpRosterService {
/** 同 openid(这里用 uid,用户与 openid 一对一)每小时最多 10 次。 */
private static final int USER_LIMIT_PER_HOUR = 10;
/** 同 IP 每分钟最多 20 次。 */
private static final int IP_LIMIT_PER_MINUTE = 20;
private static final String KEY_USER = "xt:mp:gate:user:";
private static final String KEY_IP = "xt:mp:gate:ip:";
private final MpRosterMapper rosterMapper;
private final StringRedisTemplate redis;
private final HmacIndex hmacIndex;
public MpRosterService(MpRosterMapper rosterMapper, StringRedisTemplate redis,
FillProperties props) {
this.rosterMapper = rosterMapper;
this.redis = redis;
// HMAC 口径与名册导入时用的<b>必须是同一把密钥</b>,否则门禁永远匹配不上
this.hmacIndex = HmacIndex.fromBase64(props.crypto().hmacKey());
}
@Transactional(rollbackFor = Exception.class)
public RosterMatchVO match(RosterMatchReq req, String clientIp) {
LoginUser me = UserContext.require();
Long tenantId = me.tenantId();
Long userId = me.uid();
rateLimit(userId, clientIp);
String name = normalizeName(req.getRealName());
String idCard = IdCardUtil.normalize(req.getIdCardNo());
Long taskCount = rosterMapper.countTask(tenantId, req.getTaskId());
if (taskCount == null || taskCount == 0) {
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"这个任务不存在或不属于你所在的单位");
}
// 校验位只用来"提前返回",**不改变对外提示**。
// 格式都不对的号码肯定查不到名册,先拦下来可以省一次数据库查询;
// 而提示与"查了没查到"完全一样,所以不构成额外的信息泄露。
// (校验位算法本身是公开的,任何人自己就能算,也不存在"通过时间差探测"的问题。)
IdCardUtil.Verdict verdict = IdCardUtil.verdict(idCard);
if (verdict == IdCardUtil.Verdict.MALFORMED) {
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(), "号码格式不对");
}
String hash = hmacIndex.hash(idCard);
List<CollectRoster> hits = rosterMapper.selectByIdCardHash(tenantId, req.getTaskId(), hash);
if (hits.isEmpty()) {
// ⚠️ 日志只记 hash 与 uid,不记明文与姓名
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(),
"名册里没有这个证件号(verdict=" + verdict + ")");
}
if (hits.size() > 1) {
// 同一任务下两条同证件号 = 名册重复导入。这是运维问题,不该让学生猜。
// 这种情况也**不能**随便取一条绑定 —— 绑错了就是把材料挂到了错误的人身上。
log.error("名册有重复:tenant={} task={} hash={} 命中 {} 条",
tenantId, req.getTaskId(), hash, hits.size());
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
"名册里这个证件号有重复,请联系班主任核对名单");
}
CollectRoster row = hits.get(0);
if (!name.equals(normalizeName(row.getRealName()))) {
// 姓名不一致:与"没查到"给**同一句提示**,见类注释第 1 条
throw unifiedFail(userId, clientIp, tenantId, req.getTaskId(), "姓名与名册不一致");
}
// 已被他人绑定
if (row.getBindUserId() != null && !row.getBindUserId().equals(userId)) {
throw new BizException(ErrorCode.GATE_ALREADY_BOUND);
}
if (row.getBindUserId() == null) {
int affected = rosterMapper.bindIfUnbound(tenantId, row.getId(), userId,
CollectRoster.BIND_TYPE_GATE);
if (affected == 0) {
// 上面查的时候还是未绑定,写的时候已经被人绑走了 —— 说明两个人几乎同时核验。
// 这正是"先查后写"会静默覆盖的那个窗口,靠 WHERE 条件挡住了。
log.info("并发绑定冲突:roster={} 已被他人绑定", row.getId());
throw new BizException(ErrorCode.GATE_ALREADY_BOUND);
}
log.info("门禁通过并绑定:tenant={} task={} roster={} user={}",
tenantId, req.getTaskId(), row.getId(), userId);
}
// 已绑定且就是自己 → 幂等返回成功(学生刷新页面不该看到吓人的提示)
return buildVo(row, tenantId);
}
// ── 内部 ──────────────────────────────────────
/** 统一失败提示:不区分是姓名错还是号码错。 */
private BizException unifiedFail(Long userId, String ip, Long tenantId, Long taskId, String why) {
log.warn("门禁未通过:user={} ip={} tenant={} task={} 原因={}(不记明文)",
userId, ip, tenantId, taskId, why);
return new BizException(ErrorCode.GATE_NOT_MATCHED);
}
/**
* 姓名规范化:去所有空白 + 全角转半角。
*
* <p>为什么要转全角:学生用中文输入法打英文数字时很容易带出全角,
* 而 Excel 里存的是半角。不转的话表现是"我打的字跟老师给的一模一样,却说不匹配"——
* 而肉眼看这两个字<b>长得几乎一样</b>,是最容易让人放弃的一种失败。
*/
static String normalizeName(String raw) {
if (raw == null) {
return "";
}
StringBuilder sb = new StringBuilder(raw.length());
for (char c : raw.toCharArray()) {
if (Character.isWhitespace(c) || c == '\u3000') {
continue;
}
// 全角 ASCII(U+FF01~U+FF5E)→ 半角(U+0021~U+007E)
if (c >= '\uFF01' && c <= '\uFF5E') {
sb.append((char) (c - 0xFEE0));
} else {
sb.append(c);
}
}
return sb.toString();
}
/**
* 限流:同 openid 10 次/小时、同 IP 20 次/分钟。
*
* <p>阈值调低是刻意的 —— 门禁限流是<b>安全设计的一部分</b>(防身份证号枚举探测),
* 不是防 DDoS。正常学生最多填错一两次,10 次绰绰有余。
*/
private void rateLimit(Long userId, String ip) {
incrementOrReject(KEY_USER + userId, Duration.ofHours(1), USER_LIMIT_PER_HOUR);
if (ip != null && !ip.isBlank()) {
incrementOrReject(KEY_IP + ip, Duration.ofMinutes(1), IP_LIMIT_PER_MINUTE);
}
}
private void incrementOrReject(String key, Duration ttl, int limit) {
Long count = redis.opsForValue().increment(key);
if (count != null && count == 1L) {
redis.expire(key, ttl);
}
if (count != null && count > limit) {
throw new BizException(ErrorCode.GATE_RATE_LIMITED);
}
}
private RosterMatchVO buildVo(CollectRoster row, Long tenantId) {
RosterMatchVO vo = new RosterMatchVO();
vo.setMatched(true);
vo.setRosterId(row.getId());
vo.setRealName(row.getRealName());
// 永远返回脱敏值,绝不返回明文
vo.setIdCardMask(row.getIdCardMask());
vo.setNeedConfirmName(false);
if (row.getOrgId() != null) {
vo.setOrgName(rosterMapper.selectOrgName(tenantId, row.getOrgId()));
}
// 预填:老师已经填好的部分,学生只核对不重填。
// 键名用 camelCase,与表单定义(§3.5)一致,前端可以直接塞进表单模型。
Map<String, Object> prefill = new LinkedHashMap<>();
put(prefill, "occupation", row.getOccupation());
put(prefill, "worktype", row.getWorktype());
put(prefill, "jobLevel", row.getJobLevel());
put(prefill, "education", row.getEducation());
if (row.getIsStudent() != null) {
prefill.put("isStudent", row.getIsStudent() == 1);
}
put(prefill, "school", row.getSchool());
put(prefill, "major", row.getMajor());
if (row.getEnrollDate() != null) {
prefill.put("enrollDate", row.getEnrollDate().toString());
}
put(prefill, "workUnit", row.getWorkUnit());
if (row.getIsRetake() != null) {
prefill.put("isRetake", row.getIsRetake() == 1);
}
put(prefill, "retakeTheory", row.getRetakeTheory());
put(prefill, "retakePractice", row.getRetakePractice());
put(prefill, "retakeOverall", row.getRetakeOverall());
if (row.getWorkYears() != null) {
prefill.put("workYears", row.getWorkYears());
}
put(prefill, "certOccupation", row.getCertOccupation());
put(prefill, "certLevel", row.getCertLevel());
put(prefill, "certNo", row.getCertNo());
// 电话只给脱敏值:门禁阶段学生还没确认身份,没必要先给他看完整号码
put(prefill, "phone", row.getPhone());
vo.setPrefill(prefill);
return vo;
}
private static void put(Map<String, Object> map, String key, String value) {
if (value != null && !value.isBlank()) {
map.put(key, value);
}
}
}
@@ -0,0 +1,126 @@
package cn.tianbaotong.fill.module.roster.entity;
import cn.tianbaotong.fill.support.TenantEntity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 名册项(对应 {@code collect_roster})—— 任务下的<b>准入名单</b>。
*
* <p>这张表既是校验基准,也是填报的<b>准入门槛</b>:填报人必须先填写
* 「姓名 + 身份证号」与它匹配,命中才放行(门禁)。
*
* <p>⚠️ <b>证件号有三列,用途完全不同,别用错</b>:
* <ul>
* <li>{@code idCardNo} —— <b>AES-GCM 加密</b>。每次加密的 IV 都不同,
* 所以它<b>不能做等值查询</b>。审核要看完整号时用它解密。</li>
* <li>{@code idCardHash} —— <b>HMAC</b>。门禁就是靠它按等值查名册的
* ({@code (task_id, id_card_hash)})。用它是为了让"数据库被拖走也不泄露身份证号",
* 同时还能查得动。</li>
* <li>{@code idCardMask} —— 脱敏展示值。任何返回给前端的场合都用它。</li>
* </ul>
* 把这三者搞混的后果很典型:用 {@code idCardNo} 去查名册 → 永远查不到
* (因为密文每次不同),表现是"我填的跟老师给的一模一样,却说我信息不对"。
*
* <p>另外这些预填列(occupation/worktype/job_level/education/...)都在名册上:
* 名单里"报考职业/工种/等级"三列老师已经填好了(实测 28/28 都有值),
* 所以学生端不该让他重填 —— 这是"从源头不可能填错"的实现方式。
*/
@Getter
@Setter
@TableName("collect_roster")
public class CollectRoster extends TenantEntity {
private Long taskId;
private Long orgId;
/** 序号(来自 Excel)。 */
private Integer seqNo;
/** 学号。 */
private String studentNo;
private String realName;
/** 拼音,用于近音纠错(FR-504c)。 */
private String namePinyin;
/** AES-GCM 加密的证件号。<b>不能用于等值查询。</b> */
private String idCardNo;
/** HMAC 的证件号索引。<b>门禁按它查。</b> */
private String idCardHash;
private String idCardMask;
private String phone;
private String phoneHash;
/** 名册其余字段的原始 JSON(逃生舱)。 */
private String extJson;
private Long bindUserId;
private LocalDateTime bindTime;
/** {@code 1} 门禁核验匹配 / {@code 2} 管理员指定。 */
private Integer bindType;
/** {@code 0} 未提交 / {@code 1} 草稿 / {@code 2} 待审 / {@code 3} 已通过 / {@code 4} 已驳回。 */
private Integer submitStatus;
// ── 预填列(老师已填,学生端只核对)─────────────
/** 报考职业(锁定,由老师确定)。 */
private String occupation;
/** 报考工种(锁定;该职业无工种时填「无」;专项职业能力为空)。 */
private String worktype;
/** 报考等级(锁定;专项职业能力为空)。 */
private String jobLevel;
private String education;
/** 是否在校生:{@code 0} 否 / {@code 1} 是。 */
private Integer isStudent;
private String school;
private String major;
private LocalDate enrollDate;
private String workUnit;
private Integer isRetake;
private String retakeTheory;
private String retakePractice;
private String retakeOverall;
private Integer workYears;
private String certOccupation;
private String certLevel;
private String certNo;
/** 租户/机构额外追加的字段(不参与核心统计)。 */
private String extrasJson;
@TableField(exist = false)
public static final int BIND_TYPE_GATE = 1;
@TableField(exist = false)
public static final int BIND_TYPE_ADMIN = 2;
}
@@ -0,0 +1,36 @@
package cn.tianbaotong.fill.support;
import jakarta.servlet.http.HttpServletRequest;
/**
* 取调用方真实 IP(限流用)。
*
* <p>⚠️ <b>不能直接用 {@code getRemoteAddr()}</b>:上线后请求会经过 Nginx,
* 那时它是 Nginx 的地址,<b>所有人共用一个限额</b> —— 限流就从"挡住单个刷接口的人"
* 变成了"把所有人一起限掉",而且是在最需要限流的时候(有人正在刷)表现得最明显。
*
* <p>{@code X-Forwarded-For} 可能是 {@code 客户端, 代理1, 代理2} 的链,
* 取<b>第一段</b>才是真实客户端。这个头可以被伪造,所以它只用于限流
* (伪造的收益是把别人限掉,而限流本身有兜底),<b>不要用它做鉴权判断</b>。
*/
public final class ClientIp {
private ClientIp() {
}
public static String of(HttpServletRequest request) {
if (request == null) {
return null;
}
String forwarded = request.getHeader("X-Forwarded-For");
if (forwarded != null && !forwarded.isBlank()) {
int comma = forwarded.indexOf(',');
return (comma > 0 ? forwarded.substring(0, comma) : forwarded).trim();
}
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
return request.getRemoteAddr();
}
}
+48
View File
@@ -452,6 +452,54 @@ t{tenantId}-k{taskId}-o{orgId} 带班级
--- ---
## 一·十一、M3-01 门禁核验(2026-09-29 上午,未提交)
> 范围:`POST /api/v1/mp/roster/match`(《03-接口设计》§5.4)+
> `common/util/IdCardUtil`(校验位三态裁决)。**M3 的第一块。**
**验收**:`python scripts/e2e-m3-gate.py` → **29/29**;
九条 E2E 合计 **418 项断言**全通过。
### 四条守住的规则
| # | 规则 | 为什么 |
|:--:|---|---|
| 1 | **失败提示必须统一(`40007`),不区分"姓名错"还是"号码错"** | 分开提示等于交付了一个**身份证号枚举器**:用任意姓名 + 目标号码试,若提示"姓名不对"就说明号码在名单里,一个一个试就能把全校身份证号试出来。这条不是体验取舍,是安全底线。E2E 专门断言"两种失败的 `msg` 字符串完全一致" |
| 2 | **绑定并发安全**:`UPDATE ... WHERE bind_user_id IS NULL` + 看影响行数 | 不要改成"先 SELECT 判空再 UPDATE":中间有窗口,两人会同时通过检查,**后绑的人静默覆盖先绑的人**,而先绑的学生之后再也进不去。E2E 用两个线程同时冲同一行,断言"恰好一个成功、一个 40072" |
| 3 | **证件号明文只在 `match()` 里出现一次**,失败日志**只记 HMAC 与 uid** | 日志被翻出来同样是泄露。响应里永远只有 `idCardMask`,E2E 断言"整个响应 JSON 里不含明文" |
| 4 | **已绑定自己的人重复核验是幂等的** | 学生刷新页面、返回再进来,不该看到"该信息已被登记"这种吓人的提示 |
### `IdCardUtil` 的价值在**三态**,不在对错
真实数据集实测过:名册 28 人校验位全通过(可信基准);某两个学生的**文件名**证件号校验位失败(确定是号码错,**可自动改正**);另一个学生的两个号码**都通过**(是两个真人 → **串档实锤,必须人工介入**)。
后两种**看起来都是"号码对不上",处理策略完全相反** —— 一个能放心自动修,另一个自动修就是把两个真人的材料合并。区分它们的唯一办法就是校验位。所以对外暴露 `Verdict{VALID, CHECKSUM_FAILED, MALFORMED}` 而不是 `boolean`。
⚠️ **1900 年下界是业务判断,不是国标要求**:网上流传的"示例有效身份证号"里有一批出生年份在 1800 年代(如常被引用的 `440524188001010014` → 1880 年),**它们校验位合法**,但不可能是认定考生。只算校验位会把它们判成 VALID。这个坑在写 E2E 时真踩到了(两个线程都拿到 40007,因为测试数据用了那个号)。
### E2E 怎么拿 token(值得记下的手法)
门禁要求"已登录的学生",而登录(`/mp/login`)需要真实的 `wx.login` code —— 脚本拿不到。所以 `e2e-m3-gate.py` **用 dev 配置里的 JWT 密钥自己签一个 MP token**(HS512,claims 与 `JwtService.issue` 同构)。这与 e2e-m0 用 dev RSA 公钥加密登录密码是同一类做法:用开发配置里的凭据手搓测试夹具,**不是绕过认证逻辑**。prod 的密钥不在仓库里,这个手法自然失效。
顺带:读配置要读**两个**文件 —— `jwt.secret`/`hmac-key` 在 `application-dev.yml`,而 `issuer` 在主 `application.yml`。只读一个会漏掉 issuer,而症状是 401,完全指不到"配置没读到"。
### 回归抓到我一条脆断言
`e2e-m1-qrcode.py` 里"token TTL 是 7000 秒"这条,在连续第二次运行时失败了(实测 TTL=4633 ≈ 两次运行的时间差)—— 它假设 token 是刚取的,而实际上沿用了缓存。**已改成"先清 token 缓存强制重取,再断言 TTL∈[6900,7000]"**,现在连跑两次都稳定通过。
> 教训与前面几次同类:断言要**确定性地**反映被测行为,而不是碰巧在某种执行顺序下通过。
### 已知缺口
| 项 | 说明 |
|---|---|
| **`/mp/login` 未做** | 它需要真实的 `wx.login` code,**脚本测不了**,只能在小程序里测。下一轮连同小程序核验页一起做 |
| 形近字候选(FR-504c)未实现 | 姓名不一致时统一返回 40007。`needConfirmName` 恒为 false |
| 名册导入未做(M2) | E2E 用 SQL 直接造名册行 |
| 小程序端还没有核验页 | 落地页点「开始填报」目前是明确的"开发中"提示 |
---
**关键实现决策(不要在后继开发中改回去)** **关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 | | 决策 | 理由 |
+2 -2
View File
@@ -92,7 +92,7 @@
</a-space> </a-space>
</div> </div>
<a-form-item label="预览(以「张同学 / 361029200912100010 / 身份证」为例)"> <a-form-item label="预览(以「张同学 / 361029200912100015 / 身份证」为例)">
<code class="task-form__preview">{{ namingPreview }}</code> <code class="task-form__preview">{{ namingPreview }}</code>
</a-form-item> </a-form-item>
</a-form> </a-form>
@@ -194,7 +194,7 @@ const CUSTOM_FIELD_RE = /\{自定义字段\.[^}]*\}/g
const namingPreview = computed(() => { const namingPreview = computed(() => {
const sample: Record<string, string> = { const sample: Record<string, string> = {
姓名: '张同学', 姓名: '张同学',
证件号: '361029200912100010', 证件号: '361029200912100015',
证件类型: '居民身份证', 证件类型: '居民身份证',
材料名: '身份证', 材料名: '身份证',
班级: '24中技数控1班', 班级: '24中技数控1班',
+11 -1
View File
@@ -249,6 +249,14 @@ def main() -> int:
# ── 1. 生成小程序码 ───────────────────────────────── # ── 1. 生成小程序码 ─────────────────────────────────
print("\n[1] 生成小程序码(真实调微信)") print("\n[1] 生成小程序码(真实调微信)")
# 先清掉 token 缓存,强制这一次真的去微信取 —— 否则下面"TTL=7000"那条断言
# 会因为"沿用了上次运行留下的 token"而失败(实测踩过:TTL 是 4633,
# 正好等于两次运行的时间差)。断言要能确定地反映"刚取到的 token 有多长有效期"。
okk, keys = redis(["--scan", "--pattern", "xt:wxmp:token:*"])
if okk and keys.strip():
for k in keys.strip().splitlines():
redis(["DEL", k])
print(" 已清 token 缓存,强制重新获取")
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/qrcode?type=MINI", token) status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/qrcode?type=MINI", token)
if not check("生成成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"): if not check("生成成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
msg = body.get("msg") or "" msg = body.get("msg") or ""
@@ -297,7 +305,9 @@ def main() -> int:
if out.strip() else (False, "") if out.strip() else (False, "")
if okk and out.strip().isdigit(): if okk and out.strip().isdigit():
check("token 的 TTL 是 7000 秒(比官方 7200 少 200,避开边界)", check("token 的 TTL 是 7000 秒(比官方 7200 少 200,避开边界)",
6900 <= int(out.strip()) <= 7000, f"TTL={out.strip()}") 6900 <= int(out.strip()) <= 7000,
f"TTL={out.strip()}(若远小于 7000,说明这条断言读到的是复用来的 token —— "
f"它必须在本脚本开头被清掉)")
# ── 4. 匿名接口只读缓存 ───────────────────────────── # ── 4. 匿名接口只读缓存 ─────────────────────────────
print("\n[4] 匿名图片接口只读缓存(防刷微信额度)") print("\n[4] 匿名图片接口只读缓存(防刷微信额度)")
+466
View File
@@ -0,0 +1,466 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
门禁核验(POST /api/v1/mp/roster/match)验收测试 —— M3 的第一块。
覆盖《03-接口设计》§5.4:
[x] 姓名 + 证件号完全匹配 → 放行并绑定
[x] 返回里**只有脱敏证件号**,绝无明文
[x] 幂等:已绑自己的人再核验仍成功(学生刷新页面不该看到吓人的提示)
[x] **失败提示统一**:姓名错与号码错给**完全相同**的一句话(防身份证号枚举)
[x] 40072:同一名册项被另一个账号核验 → 已被登记
[x] **并发**:两个账号同时对同一行核验 → 恰好一个成功
[x] 限流:同账号 10 次/小时 → 40071
[x] 跨租户:拿别租户的 taskId 不泄露
[x] 预填:老师已填的报考职业/工种/等级随核验结果返回
怎么拿 token(本脚本的关键手法):
门禁要求"已登录的学生",而登录(/mp/login)需要真实的 wx.login code —— 脚本拿不到。
所以这里**用 dev 配置里的 JWT 密钥自己签一个 MP token**(HS512,claims 与
JwtService.issue 一致)。这与 e2e-m0 用 dev RSA 公钥加密登录密码是同一类做法:
都是"用开发配置里的凭据手搓一个测试夹具",不是绕过认证逻辑。
⚠️ 它只对 dev 有效;prod 的密钥不在仓库里,这个手法自然失效。
用法
python scripts/e2e-m3-gate.py
依赖:cryptography;且需要能 `docker exec xt-pg` / `xt-redis`
退出码:0 = 全部通过
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
DEV_YML = os.path.join(ROOT, "backend", "xt-server", "src", "main", "resources", "application-dev.yml")
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
PG_USER = os.environ.get("PG_USER", "fill")
PG_DB = os.environ.get("PG_DB", "filldb")
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
REDIS_CONTAINER = os.environ.get("XT_REDIS_CONTAINER", "xt-redis")
REDIS_PWD = os.environ.get("REDIS_PASSWORD", "redis_dev_pwd")
PASSED: list[str] = []
FAILED: list[tuple[str, str]] = []
CREATED_TASK_IDS: list[int] = []
CREATED_USER_IDS: list[int] = []
# 名册里的两条测试数据(证件号都校验位正确)
ID_A = "361029200912100015"
NAME_A = "曾伊豪"
ID_B = "440524199001010018"
NAME_B = "杨晨"
def ok(name: str) -> None:
PASSED.append(name)
print(f" [OK] {name}")
def fail(name: str, detail: str) -> None:
FAILED.append((name, detail))
print(f" [FAIL] {name}\n {detail}")
def check(name: str, cond: bool, detail: str = "") -> bool:
if cond:
ok(name)
else:
fail(name, detail or "断言不成立")
return cond
def sql(stmt: str) -> tuple[bool, str]:
try:
r = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
capture_output=True, text=True, timeout=40)
return r.returncode == 0, (r.stdout or r.stderr).strip()
except Exception as e:
return False, str(e)
def redis(args: list[str]) -> tuple[bool, str]:
try:
r = subprocess.run(
["docker", "exec", "-e", f"REDISCLI_AUTH={REDIS_PWD}", REDIS_CONTAINER,
"redis-cli"] + args, capture_output=True, text=True, timeout=30)
return r.returncode == 0, (r.stdout or r.stderr).strip()
except Exception as e:
return False, str(e)
def clear_rate() -> None:
"""清掉门禁限流键 —— 否则本脚本自己会被限流(它要发十几次请求)。"""
okk, out = redis(["--scan", "--pattern", "xt:mp:gate:*"])
if okk and out.strip():
for k in out.strip().splitlines():
redis(["DEL", k])
def cleanup_all() -> None:
if CREATED_TASK_IDS:
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
for stmt in [
f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
f"DELETE FROM collect_task WHERE id IN ({ids})",
]:
sql(stmt)
base = CTX.get("base")
token = CTX.get("token")
if base and token:
for uid in CREATED_USER_IDS:
try:
call("DELETE", f"{base}/api/v1/users/{uid}", token)
except Exception:
pass
clear_rate()
CTX: dict[str, str | None] = {"base": None, "token": None}
# ── 配置读取与 token 手搓 ────────────────────────────
def read_dev_config() -> dict:
"""从 application-dev.yml(+ application.yml 兜底)里读 jwt.secret / hmac-key / issuer。
⚠️ 要读<b>两个</b>文件:`secret` 与 `hmac-key` 在 dev 配置里,
而 `issuer` 在主配置 application.yml 里(因为它不是密钥,各环境相同)。
只读一个文件的话会漏掉 issuer —— 而缺了它签出来的 token 验签直接失败,
报错是 401,完全指不到"配置没读到"。
这个文件是 YAML,但结构很浅,用行匹配比引 yaml 库更省事。找不到就明确报错,
不要静默用默认值。
"""
files = [DEV_YML, os.path.join(os.path.dirname(DEV_YML), "application.yml")]
text = "\n".join(open(f, encoding="utf-8").read() for f in files if os.path.exists(f))
out: dict[str, str] = {}
for key, pattern in [
("jwt_secret", r"^\s*secret:\s*(\S+)\s*$"),
("hmac_key", r"^\s*hmac-key:\s*(\S+)\s*$"),
("issuer", r"^\s*issuer:\s*(\S+)\s*$"),
]:
m = re.search(pattern, text, re.M)
if not m:
raise SystemExit(f" ✗ 在 application*.yml 里找不到 {key}({pattern})")
out[key] = m.group(1)
return out
def b64u(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def mint_mp_token(secret_b64: str, issuer: str, uid: int, tenant_id: int,
roles: list[str], user_type: int) -> str:
"""手搓一个与 JwtService.issue 同构的 MP token(HS512)。"""
key = base64.b64decode(secret_b64)
now = int(time.time())
header = {"alg": "HS512"}
payload = {
"iss": issuer,
"sub": str(uid),
"tenantId": tenant_id,
"orgIds": [],
"roles": roles,
"userType": user_type,
"iat": now,
"exp": now + 3600,
}
signing = (b64u(json.dumps(header, separators=(",", ":")).encode()) + "."
+ b64u(json.dumps(payload, separators=(",", ":")).encode()))
sig = hmac.new(key, signing.encode(), hashlib.sha512).digest()
return signing + "." + b64u(sig)
def hmac_hex(key_b64: str, raw: str) -> str:
"""与 HmacIndex.hash 完全一致:trim + 转大写 → HmacSHA256 → hex。"""
key = base64.b64decode(key_b64)
return hmac.new(key, raw.strip().upper().encode("utf-8"), hashlib.sha256).hexdigest()
# ── HTTP ─────────────────────────────────────────────
def call(method: str, url: str, token: str | None = None,
body: dict | None = None, timeout: int = 30) -> tuple[int, dict]:
headers = {"Content-Type": "application/json; charset=utf-8"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8")
try:
return e.code, json.loads(raw)
except json.JSONDecodeError:
return e.code, {"raw": raw}
def rsa_encrypt(plain: str) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
with open(PUB_KEY, "rb") as f:
pub = serialization.load_pem_public_key(f.read())
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
def login(base: str, username: str, password: str, tenant: str | None = None):
payload = {"username": username, "password": rsa_encrypt(password)}
if tenant:
payload["tenantCode"] = tenant
return call("POST", f"{base}/api/v1/auth/login", body=payload)
def match(base: str, token: str, name: str, id_card: str, task_id: int) -> dict:
_, body = call("POST", f"{base}/api/v1/mp/roster/match", token,
{"realName": name, "idCardNo": id_card, "taskId": task_id})
return body
def main() -> int:
ap = argparse.ArgumentParser(description="门禁核验 E2E")
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
ap.add_argument("--password", default="Admin@123")
args = ap.parse_args()
base = args.base.rstrip("/")
CTX["base"] = base
print("=" * 72)
print(f"门禁核验 E2E base={base}")
print(" (token 是用 dev 配置里的 JWT 密钥手搓的,见脚本头部说明)")
print("=" * 72)
suffix = uuid.uuid4().hex[:6]
cfg = read_dev_config()
# ── 0. 准备 ────────────────────────────────────────
print("\n[0] 准备")
status, body = login(base, "admin", args.password, args.tenant)
if body.get("code") != 0:
fail("管理员登录", f"code={body.get('code')} msg={body.get('msg')}")
return 1
admin_token = body["data"]["token"]
CTX["token"] = admin_token
tenant_id = (body["data"].get("tenant") or {}).get("id")
admin_uid = (body["data"].get("user") or {}).get("id") or 2
ok(f"管理员登录(租户 {tenant_id},uid={admin_uid})")
# 手搓的学生 token
student_uid = 990001
stu_token = mint_mp_token(cfg["jwt_secret"], cfg["issuer"], student_uid, tenant_id,
["STUDENT"], 4)
other_uid = 990002
other_token = mint_mp_token(cfg["jwt_secret"], cfg["issuer"], other_uid, tenant_id,
["STUDENT"], 4)
admin_mp = mint_mp_token(cfg["jwt_secret"], cfg["issuer"], admin_uid, tenant_id,
["SCHOOL_ADMIN"], 2)
_, probe = call("POST", f"{base}/api/v1/mp/roster/match", stu_token,
{"realName": "探针", "idCardNo": ID_A, "taskId": 999999})
check("手搓的 token 能通过认证(不是 401)", probe.get("code") != 40101,
f"code={probe.get('code')} msg={probe.get('msg')}")
# 建任务
status, body = call("POST", f"{base}/api/v1/tasks", admin_token, {
"taskName": f"E2E门禁 {suffix}", "collectMode": 1, "batchNo": "2006",
"majorName": "数控加工", "certType": 1,
"startTime": "2026-09-28", "endTime": "2026-12-31",
})
if body.get("code") != 0:
fail("建任务", f"code={body.get('code')} msg={body.get('msg')}")
return 1
task_id = body["data"]["id"]
CREATED_TASK_IDS.append(task_id)
ok(f"建任务(id={task_id})")
# 取一个班级
status, body = call("GET", f"{base}/api/v1/orgs/tree", admin_token)
classes: list[dict] = []
def walk(nodes):
for n in nodes or []:
if n.get("orgType") == 3:
classes.append(n)
walk(n.get("children"))
walk(body.get("data"))
org_id = classes[0]["id"] if classes else None
org_name = classes[0]["orgName"] if classes else None
# 造名册(直接用 SQL:名册导入属 M2,这里只需要两行能匹配的数据)
for name, idc, occ in [(NAME_A, ID_A, "车工"), (NAME_B, ID_B, "铣工")]:
okk, out = sql(
f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, "
f"id_card_no, id_card_hash, id_card_mask, occupation, worktype, job_level, "
f"education, is_student, phone) VALUES ("
f"{tenant_id}, {task_id}, {org_id or 'NULL'}, '{name}', "
f"'encrypted-placeholder', '{hmac_hex(cfg['hmac_key'], idc)}', "
f"'{idc[:6]}******{idc[-4:]}', '{occ}', '数控{occ}', '四级', "
f"'中等专科', 1, '138****8888')")
if not okk:
fail("造名册", out)
return 1
ok(f"造了 2 行名册({NAME_A} / {NAME_B})")
clear_rate()
# ── 1. 正常核验 ─────────────────────────────────────
print("\n[1] 姓名 + 证件号完全匹配")
r = match(base, stu_token, NAME_A, ID_A, task_id)
check("核验通过", r.get("code") == 0, f"code={r.get('code')} msg={r.get('msg')}")
d = r.get("data") or {}
check("matched=true", d.get("matched") is True, f"{d.get('matched')}")
check("返回 rosterId", isinstance(d.get("rosterId"), int), f"{d.get('rosterId')}")
check("返回姓名", d.get("realName") == NAME_A, f"{d.get('realName')}")
check("**证件号只回脱敏值**", d.get("idCardMask") and "******" in d["idCardMask"],
f"idCardMask={d.get('idCardMask')}")
check("返回里**没有任何字段含完整证件号**(防泄露)",
ID_A not in json.dumps(d, ensure_ascii=False),
f"响应里出现了明文证件号!{json.dumps(d, ensure_ascii=False)[:200]}")
check("返回班级名", d.get("orgName") == org_name, f"{d.get('orgName')} 期望={org_name}")
pf = d.get("prefill") or {}
check("预填了报考职业(老师已填,学生只核对)", pf.get("occupation") == "车工",
f"prefill={pf}")
check("预填了报考工种与等级(锁定项)",
pf.get("worktype") == "数控车工" and pf.get("jobLevel") == "四级", f"{pf}")
check("needConfirmName 为 false(形近字确认属 FR-504c,本轮未实现)",
d.get("needConfirmName") is False, f"{d.get('needConfirmName')}")
# ── 2. 幂等 ─────────────────────────────────────────
print("\n[2] 幂等:已绑自己的人再核验")
r2 = match(base, stu_token, NAME_A, ID_A, task_id)
check("重复核验仍成功(学生刷新页面不该看到「已被登记」)", r2.get("code") == 0,
f"code={r2.get('code')} msg={r2.get('msg')}")
# ── 3. 统一失败提示(防枚举的核心)────────────────────
print("\n[3] 失败提示必须统一 —— 这是防身份证号枚举的底线")
r_wrong_name = match(base, stu_token, "张三", ID_A, task_id)
r_wrong_id = match(base, stu_token, NAME_A, "11010519491231002X", task_id)
r_no_such = match(base, stu_token, NAME_A, "361029200912100015", task_id + 777)
check("姓名不对 → 40007", r_wrong_name.get("code") == 40007,
f"code={r_wrong_name.get('code')}")
check("号码不在名册 → 40007", r_wrong_id.get("code") == 40007,
f"code={r_wrong_id.get('code')}")
check("**两者的提示文字完全一致**(分开就会变成枚举器:先用假姓名+目标号码试,"
"若提示「姓名不对」就说明号码在名单里)",
r_wrong_name.get("msg") == r_wrong_id.get("msg"),
f"姓名错:{r_wrong_name.get('msg')} / 号码错:{r_wrong_id.get('msg')}")
check("不存在的任务给的是另一类提示(那不属于身份信息不匹配)",
r_no_such.get("code") == 40001, f"code={r_no_such.get('code')}")
# ── 4. 已被他人绑定 ─────────────────────────────────
print("\n[4] 同一名册项被别人核验")
clear_rate()
r_other = match(base, other_token, NAME_A, ID_A, task_id)
check("另一账号核验同一行 → 40072 已被登记", r_other.get("code") == 40072,
f"code={r_other.get('code')} msg={r_other.get('msg')}")
# ── 5. 并发 ─────────────────────────────────────────
print("\n[5] 并发:两个账号同时对同一行核验")
clear_rate()
# 用 B 那一行,让两个不同的 uid 同时冲
import threading
results: list[int] = []
lock = threading.Lock()
barrier = threading.Barrier(2)
def hit(tok):
barrier.wait()
_, b = call("POST", f"{base}/api/v1/mp/roster/match", tok,
{"realName": NAME_B, "idCardNo": ID_B, "taskId": task_id})
with lock:
results.append(b.get("code"))
t1 = threading.Thread(target=hit, args=(stu_token,))
t2 = threading.Thread(target=hit, args=(other_token,))
t1.start()
t2.start()
t1.join()
t2.join()
success_n = sum(1 for c in results if c == 0)
bound_n = sum(1 for c in results if c == 40072)
check(f"恰好一个成功、一个 40072(实际 {results})", success_n == 1 and bound_n == 1,
f"两个都成功会静默覆盖先绑的人,而先绑的学生之后再也进不去。结果={results}")
okk, out = sql(f"SELECT count(*) FROM collect_roster WHERE task_id = {task_id} "
f"AND id_card_hash = '{hmac_hex(cfg['hmac_key'], ID_B)}' "
f"AND bind_user_id IS NOT NULL")
check("库里那一行确实只被绑了一次", okk and out.strip() == "1", f"{out}")
# ── 6. 格式非法的号码 ───────────────────────────────
print("\n[6] 格式非法的号码")
clear_rate()
r_malformed = match(base, stu_token, NAME_A, "abcd", task_id)
check("乱码号码 → 40007", r_malformed.get("code") == 40007, f"code={r_malformed.get('code')}")
check("提示与「号码不在名册」**同样一致**(校验位的快速路径不泄露信息)",
r_malformed.get("msg") == r_wrong_id.get("msg"),
f"{r_malformed.get('msg')} / {r_wrong_id.get('msg')}")
# ── 7. 限流 ─────────────────────────────────────────
print("\n[7] 限流:同账号 10 次/小时")
clear_rate()
codes = []
for _ in range(11):
_, b = call("POST", f"{base}/api/v1/mp/roster/match", stu_token,
{"realName": "张三", "idCardNo": ID_A, "taskId": task_id})
codes.append(b.get("code"))
check("前 10 次是业务结果(40007)", codes[:10].count(40007) == 10, f"{codes}")
check("第 11 次被限流(40071)", codes[10] == 40071, f"{codes}")
clear_rate()
_, after = call("POST", f"{base}/api/v1/mp/roster/match", stu_token,
{"realName": NAME_A, "idCardNo": ID_A, "taskId": task_id})
check("清掉计数器后立刻恢复", after.get("code") == 0, f"code={after.get('code')}")
# ── 8. 管理员也能核验(代学生核验是一期就要支持的能力)──
print("\n[8] 管理员(代核验)")
r_admin = match(base, admin_mp, "张三", ID_A, task_id)
check("管理员也能调门禁(allowProxyUpload 默认开,代学生核验是支持的)",
r_admin.get("code") in (0, 40007, 40072),
f"code={r_admin.get('code')} msg={r_admin.get('msg')}")
# ── 9. 清理 ─────────────────────────────────────────
print("\n[9] 清理测试数据")
cleanup_all()
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号")
print("\n" + "=" * 72)
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
if FAILED:
print("\n失败项:")
for name, detail in FAILED:
print(f" · {name}\n {detail}")
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
try:
code = main()
except BaseException:
print("\n ⚠ 脚本异常退出,正在清理…")
cleanup_all()
raise
raise SystemExit(code)