diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/IdCardUtil.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/IdCardUtil.java new file mode 100644 index 0000000..d122d83 --- /dev/null +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/IdCardUtil.java @@ -0,0 +1,133 @@ +package cn.tianbaotong.fill.common.util; + +/** + * 身份证号校验(GB 11643-1999 / ISO 7064 MOD 11-2)。 + * + *

这个类的价值不在"能不能判对错",而在"能区分出三种完全不同的情形"。 + * 真实基准数据集上实测过(《PRD》§实测校正): + * + *

+ *   名册 28 人的证件号  → 校验位全部通过 → 名册可信,可作为权威基准
+ *   某两个学生的文件名证件号 → 校验位失败 → **确定性判定为号码错误,可自动改正**(无需问任何人)
+ *   另一名学生的两个号码   → 校验位**都通过** → 说明是两个真实存在的人
+ *                          → **串档实锤,不是打字错误,必须人工介入**
+ * 
+ * + * 后两种情形看起来都是"号码对不上",但处理策略完全相反: + * 一个可以放心自动修,另一个自动修就是灾难(把两个真人的材料合并到一起)。 + * 区分它们的唯一办法就是校验位 —— 这也是为什么拦截面从 0% 提到 100% 靠的是算法而不是人工。 + * + *

所以对外暴露的是 {@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'}; + + /** + * 校验结论。 + * + *

⚠️ {@link #CHECKSUM_FAILED} 与 {@link #VALID} 的区别是整个产品的关键: + * 前者说明这个号码不是一个真实的身份证号(可以放心自动改), + * 后者说明它是一个真人的号(两个都通过就是两个人,绝不能自动合并)。 + */ + 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; + } + + /** + * 规范化:去首尾空白 + 去内部空格 + 转大写。 + * + *

与 {@code HmacIndex.hash} 的规范化口径必须一致:门禁是按 + * {@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; + } + } +} diff --git a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java index 60c906d..354a1df 100644 --- a/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java +++ b/backend/xt-common/src/main/java/cn/tianbaotong/fill/common/util/MaskUtil.java @@ -31,7 +31,7 @@ public final class MaskUtil { * 身份证号脱敏。 * *

-     * 361029200912100010  →  3610292009******10
+     * 361029200912100015  →  3610292009******10
      * 
* *

保留前 10 位(含地区码与出生年)与后 2 位,中间 6 位打星。 diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpRosterController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpRosterController.java new file mode 100644 index 0000000..e409410 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpRosterController.java @@ -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)。 + * + *

为什么只要求"已登录"而不要求具体权限码: + * 门禁本身就是授权判定 —— 匹配不上名册就什么也做不了(后面所有 /mp/submissions + * 都要有绑定关系)。而"代学生核验"是一期就要支持的能力(`auditConfig.allowProxyUpload` + * 默认开),所以班主任、管理员也会调这个接口。挂一个 {@code student:*} 权限码 + * 反而会把本不该挡的人挡住。 + * + *

⚠️ 它是产品里唯一接收身份证号明文的入口,所以:不返回明文(只回脱敏值)、 + * 失败提示统一({@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 match(@Valid @RequestBody RosterMatchReq req, + HttpServletRequest request) { + return ApiResult.ok(mpRosterService.match(req, ClientIp.of(request))); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/dto/RosterMatchReq.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/dto/RosterMatchReq.java new file mode 100644 index 0000000..091ef85 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/dto/RosterMatchReq.java @@ -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)。 + * + *

只收三个字段:姓名、身份证号、任务。不多收任何东西 —— + * 这个接口是产品里唯一接收身份证号明文的入口,越窄越好。 + */ +@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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/dto/RosterMatchVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/dto/RosterMatchVO.java new file mode 100644 index 0000000..b12a76b --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/dto/RosterMatchVO.java @@ -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)。 + * + *

⚠️ 返回的身份证号永远是脱敏值({@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; + + /** + * 名册里的预填值。 + * + *

名单里"报考职业/工种/等级"这三列老师已经填好了(实测 28/28 都有值), + * 所以学生端不该让他重填 —— 预填 + 标注"老师已填,请核对"。 + * 键名用 camelCase,与小程序端和表单定义一致。 + */ + private Map prefill = new LinkedHashMap<>(); + + /** 姓名是形近字、需要用户确认(FR-504c)。本轮未实现,恒为 false。 */ + private Boolean needConfirmName = Boolean.FALSE; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpRosterMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpRosterMapper.java new file mode 100644 index 0000000..f25ef25 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpRosterMapper.java @@ -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)。 + * + *

这些查询走原生 SQL 而不是 MP 的条件构造器,原因有二: + *

    + *
  1. 绑定必须是并发安全的,靠的是 {@code WHERE bind_user_id IS NULL} + * 与影响行数,条件构造器表达不了这个语义(也没有"影响几行"的语义)。
  2. + *
  3. 关键条件({@code tenant_id})写成显式参数,让"租户从哪来"只有一个来源, + * 不依赖"拦截器应该会加上"。
  4. + *
+ */ +public interface MpRosterMapper { + + /** + * 按 {@code (task, id_card_hash)} 定位名册项。 + * + *

⚠️ 用 {@code id_card_hash}(HMAC)而不是 {@code id_card_no}(AES): + * 后者每次加密的 IV 都不同,做不了等值查询。用错的症状是"永远查不到"。 + * + *

{@code LIMIT 2} 而不是 {@code LIMIT 1} 是刻意的: + * 同一个任务下出现两条同证件号,说明名册本身有问题(重复导入)。 + * 取 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 selectByIdCardHash(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId, + @Param("idCardHash") String idCardHash); + + /** + * 并发安全的绑定:只在还没人绑的时候绑上。 + * + *

关键在 {@code AND bind_user_id IS NULL} —— 两个手机同时扫同一个证件号时, + * 数据库保证只有一条 UPDATE 能成功(行锁 + 条件),另一条影响 0 行。 + * 调用方据影响行数判断"是不是被别人抢了",返回 {@code 40072}。 + * + *

⚠️ 不要改成"先 SELECT 判断没绑定,再 UPDATE":那中间有窗口, + * 两个人会同时通过检查、先后都绑上,结果是后绑的人静默覆盖了先绑的人, + * 而先绑的那个学生之后再也进不去(他的号被别人的 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); + + /** + * 查这个用户在这个任务下已绑定的名册项 —— 用于免门禁直入。 + * + *

门禁只需过一次:绑定关系长期有效,学生第二次进来不该再填一遍姓名和证件号。 + */ + @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); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java new file mode 100644 index 0000000..d2a5796 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java @@ -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)—— 本产品的核心机制。 + * + *

名单是闸门:填报人必须填写「姓名 + 身份证号」与名册匹配,命中才放行。 + * 这不是"注册",也不是"验证码"——它是把填报人钉死在一份权威名单上, + * 从而让"这份材料是谁的"不再依赖任何人的自觉。 + * + *

四条守住的规则: + *

    + *
  1. 失败提示必须统一({@code 40007}),不区分"姓名错"还是"号码错"。 + * 分开提示等于给了一个身份证号枚举器:先用任意姓名 + 目标号码试, + * 若提示"姓名不对"就说明号码在名单里 —— 一个一个试就能把全校学生的 + * 身份证号试出来。这条不是体验取舍,是安全底线。
  2. + *
  3. 绑定必须并发安全:靠 {@code UPDATE ... WHERE bind_user_id IS NULL} + * 的影响行数判断,不是"先查后写"。见 {@code MpRosterMapper.bindIfUnbound}。
  4. + *
  5. 证件号明文只在这个方法里出现过一次,之后只留 HMAC。 + * 失败日志只记 hash 与 uid,不记明文 —— 日志被翻出来同样是泄露。
  6. + *
  7. 已绑定自己的人重复核验是幂等的(返回成功)。学生刷新页面、 + * 返回上一页再进来,都不该看到"该信息已被登记"这种吓人的提示。
  8. + *
+ */ +@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 口径与名册导入时用的必须是同一把密钥,否则门禁永远匹配不上 + 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 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); + } + + /** + * 姓名规范化:去所有空白 + 全角转半角。 + * + *

为什么要转全角:学生用中文输入法打英文数字时很容易带出全角, + * 而 Excel 里存的是半角。不转的话表现是"我打的字跟老师给的一模一样,却说不匹配"—— + * 而肉眼看这两个字长得几乎一样,是最容易让人放弃的一种失败。 + */ + 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 次/分钟。 + * + *

阈值调低是刻意的 —— 门禁限流是安全设计的一部分(防身份证号枚举探测), + * 不是防 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 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 map, String key, String value) { + if (value != null && !value.isBlank()) { + map.put(key, value); + } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/entity/CollectRoster.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/entity/CollectRoster.java new file mode 100644 index 0000000..4096657 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/roster/entity/CollectRoster.java @@ -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})—— 任务下的准入名单。 + * + *

这张表既是校验基准,也是填报的准入门槛:填报人必须先填写 + * 「姓名 + 身份证号」与它匹配,命中才放行(门禁)。 + * + *

⚠️ 证件号有三列,用途完全不同,别用错: + *

+ * 把这三者搞混的后果很典型:用 {@code idCardNo} 去查名册 → 永远查不到 + * (因为密文每次不同),表现是"我填的跟老师给的一模一样,却说我信息不对"。 + * + *

另外这些预填列(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 加密的证件号。不能用于等值查询。 */ + private String idCardNo; + + /** HMAC 的证件号索引。门禁按它查。 */ + 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; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/ClientIp.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/ClientIp.java new file mode 100644 index 0000000..80c4b45 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/support/ClientIp.java @@ -0,0 +1,36 @@ +package cn.tianbaotong.fill.support; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * 取调用方真实 IP(限流用)。 + * + *

⚠️ 不能直接用 {@code getRemoteAddr()}:上线后请求会经过 Nginx, + * 那时它是 Nginx 的地址,所有人共用一个限额 —— 限流就从"挡住单个刷接口的人" + * 变成了"把所有人一起限掉",而且是在最需要限流的时候(有人正在刷)表现得最明显。 + * + *

{@code X-Forwarded-For} 可能是 {@code 客户端, 代理1, 代理2} 的链, + * 取第一段才是真实客户端。这个头可以被伪造,所以它只用于限流 + * (伪造的收益是把别人限掉,而限流本身有兜底),不要用它做鉴权判断。 + */ +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(); + } +} diff --git a/docs/交接摘要.md b/docs/交接摘要.md index 895f8a9..c792bab 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -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 直接造名册行 | +| 小程序端还没有核验页 | 落地页点「开始填报」目前是明确的"开发中"提示 | + +--- + **关键实现决策(不要在后继开发中改回去)** | 决策 | 理由 | diff --git a/frontend-pc/src/views/task/TaskFormModal.vue b/frontend-pc/src/views/task/TaskFormModal.vue index 23c573f..cda6635 100644 --- a/frontend-pc/src/views/task/TaskFormModal.vue +++ b/frontend-pc/src/views/task/TaskFormModal.vue @@ -92,7 +92,7 @@ - + {{ namingPreview }} @@ -194,7 +194,7 @@ const CUSTOM_FIELD_RE = /\{自定义字段\.[^}]*\}/g const namingPreview = computed(() => { const sample: Record = { 姓名: '张同学', - 证件号: '361029200912100010', + 证件号: '361029200912100015', 证件类型: '居民身份证', 材料名: '身份证', 班级: '24中技数控1班', diff --git a/scripts/e2e-m1-qrcode.py b/scripts/e2e-m1-qrcode.py index e44c501..5926fd4 100644 --- a/scripts/e2e-m1-qrcode.py +++ b/scripts/e2e-m1-qrcode.py @@ -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] 匿名图片接口只读缓存(防刷微信额度)") diff --git a/scripts/e2e-m3-gate.py b/scripts/e2e-m3-gate.py new file mode 100644 index 0000000..18d159f --- /dev/null +++ b/scripts/e2e-m3-gate.py @@ -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。 + + ⚠️ 要读两个文件:`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)