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:
+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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user