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 只收三个字段:姓名、身份证号、任务。不多收任何东西 ——
+ * 这个接口是产品里唯一接收身份证号明文的入口,越窄越好。
+ */
+@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 这些查询走原生 SQL 而不是 MP 的条件构造器,原因有二:
+ * ⚠️ 用 {@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 关键在 {@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)—— 本产品的核心机制。
+ *
+ * 名单是闸门:填报人必须填写「姓名 + 身份证号」与名册匹配,命中才放行。
+ * 这不是"注册",也不是"验证码"——它是把填报人钉死在一份权威名单上,
+ * 从而让"这份材料是谁的"不再依赖任何人的自觉。
+ *
+ * 四条守住的规则:
+ * 为什么要转全角:学生用中文输入法打英文数字时很容易带出全角,
+ * 而 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 这张表既是校验基准,也是填报的准入门槛:填报人必须先填写
+ * 「姓名 + 身份证号」与它匹配,命中才放行(门禁)。
+ *
+ * ⚠️ 证件号有三列,用途完全不同,别用错:
+ * 另外这些预填列(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 @@
-
+ *
+ */
+public interface MpRosterMapper {
+
+ /**
+ * 按 {@code (task, id_card_hash)} 定位名册项。
+ *
+ *
+ *
+ */
+@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
+ *
+ * 把这三者搞混的后果很典型:用 {@code idCardNo} 去查名册 → 永远查不到
+ * (因为密文每次不同),表现是"我填的跟老师给的一模一样,却说我信息不对"。
+ *
+ * {{ namingPreview }}