feat(h5): 身份核验页(门禁)—— 学生流程首个可用闭环

学生填材料之前的唯一一道门:姓名 + 身份证号与老师提交的名单核对,
通过后把微信账号与名单里那一行钉死。后面所有材料都归属那个人。

交付:
· pages/verify/verify.vue —— 核验页
· 落地页「开始填报」→ 核验页(带 scene)
· 两页都加 max-width 居中(桌面查看/自动化验证时不至于拉满全屏)

三处刻意的设计:
1) 失败提示不区分"姓名错"还是"号码错"(后端统一 40007),前端也只显示
   后端那一句话 —— **不在前端加"可能是身份证号错了"这类推断**。
   分开提示等于给出一个身份证号枚举器。
2) 预填项标注「老师已填,请核对」并明确「学生不能改」:报考职业/工种/等级
   由老师按报考计划确定(名单里实测 28/28 都有值)。让学生重填只会制造错误,
   而报错工种在机构那边是直接不受理。
3) 前端只做"非空 + 长度"这类明显笔误的提示,真正的判定(校验位、名册匹配、
   绑定状态)全交给后端。

修掉一个 H5 特有的坑:
   **H5 的页面跳转不保留 URL 参数** —— 直接 navigateTo 到核验页的话,
   那边读不到 scene、就不知道是哪个任务(小程序没有这个问题)。
   所以落地页跳转时显式拼上 scene,核验页再从自己的 URL 里读出来。

验收(全程无头浏览器自动完成,除"微信授权"那一步)。
现在 task 17 已发布(收集中)并带测试名册,手机上可走完整流程。

注:task 17 现在是「收集中」并带 1 行测试名册(张同学 / 361029200912100015),
   是我为验证留的演示数据。
This commit is contained in:
2026-09-29 11:24:32 +08:00
parent 2659cd5a8c
commit aa2f7f175c
4 changed files with 472 additions and 18 deletions
@@ -125,29 +125,44 @@ public class MpH5AuthController {
/**
* 302 回 H5 页面。token 放 <b>fragment</b>(不发给服务器),错误信息放 query。
*/
/**
* 302 回 H5 页面。
*
* <p>token 放在 <b>hash 路由的 query</b> 里,形如
* {@code http://host/#/pages/index/index?token=X&scene=Y}。这个格式同时满足两件事:
* <ul>
* <li><b>安全</b>:整段在 {@code #} 之后,<b>不会发给服务器</b> ——
* 不进 Nginx access log、不进 Referer。</li>
* <li><b>能用</b>:uni-app H5 的 {@code onLoad} 解析的是<b>路由</b>的 query。
* 实测写成裸 hash({@code #token=X&scene=Y})时,它既不是外层查询串、
* 也不是合法的路由路径 —— 两边都不认,页面读不到任何参数,
* 表现是"授权明明成功了,落地页却说 scene 没获取到"。</li>
* </ul>
* 换句话说:<b>参数要么放外层 query(会进服务器日志),要么放 hash 路由的 query</b>,
* 别放裸 hash。
*/
private ResponseEntity<Void> redirectToApp(String token, String scene, String error) {
String base = props.mpH5() == null ? null : props.mpH5().h5BaseUrlOrDefault();
if (base == null || base.isBlank()) {
base = "http://127.0.0.1:5174";
}
StringBuilder sb = new StringBuilder(base);
String frag = "";
StringBuilder q = new StringBuilder();
if (token != null) {
frag += "token=" + enc(token);
q.append("token=").append(enc(token));
}
if (scene != null && !scene.isBlank()) {
frag += (frag.isEmpty() ? "" : "&") + "scene=" + enc(scene);
q.append(q.isEmpty() ? "" : "&").append("scene=").append(enc(scene));
}
String url = base;
if (error != null) {
// 错误放 query:它不敏感,而且我们要让页面能读到
sb.append("?authError=").append(enc(error));
// 错误放外层 query:它不敏感,而且页面要能读到
url += "?authError=" + enc(error);
}
if (!frag.isEmpty()) {
sb.append("#").append(frag);
if (!q.isEmpty()) {
// hash 路由格式:uni-app 能解析,且整段不发给服务器
url += "#/pages/index/index?" + q;
}
return ResponseEntity.status(HttpStatus.FOUND)
.header("Location", sb.toString())
.build();
return ResponseEntity.status(HttpStatus.FOUND).header("Location", url).build();
}
private static String enc(String s) {