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:
@@ -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 位打星。
|
||||
|
||||
+47
@@ -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;
|
||||
}
|
||||
+102
@@ -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);
|
||||
}
|
||||
+250
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+126
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 直接造名册行 |
|
||||
| 小程序端还没有核验页 | 落地页点「开始填报」目前是明确的"开发中"提示 |
|
||||
|
||||
---
|
||||
|
||||
**关键实现决策(不要在后继开发中改回去)**
|
||||
|
||||
| 决策 | 理由 |
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-form-item label="预览(以「张同学 / 361029200912100010 / 身份证」为例)">
|
||||
<a-form-item label="预览(以「张同学 / 361029200912100015 / 身份证」为例)">
|
||||
<code class="task-form__preview">{{ namingPreview }}</code>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
@@ -194,7 +194,7 @@ const CUSTOM_FIELD_RE = /\{自定义字段\.[^}]*\}/g
|
||||
const namingPreview = computed(() => {
|
||||
const sample: Record<string, string> = {
|
||||
姓名: '张同学',
|
||||
证件号: '361029200912100010',
|
||||
证件号: '361029200912100015',
|
||||
证件类型: '居民身份证',
|
||||
材料名: '身份证',
|
||||
班级: '24中技数控1班',
|
||||
|
||||
@@ -249,6 +249,14 @@ def main() -> int:
|
||||
|
||||
# ── 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)
|
||||
if not check("生成成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
|
||||
msg = body.get("msg") or ""
|
||||
@@ -297,7 +305,9 @@ def main() -> int:
|
||||
if out.strip() else (False, "")
|
||||
if okk and out.strip().isdigit():
|
||||
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. 匿名接口只读缓存 ─────────────────────────────
|
||||
print("\n[4] 匿名图片接口只读缓存(防刷微信额度)")
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user