feat(mp): M3-02 H5 微信网页授权(公众号 OAuth)

方向决定了 H5 优先:资质门槛低得多、且前端可自动化测试。
小程序端代码不浪费 —— uni-app 一套代码多端,build:h5 直接出 H5。

交付:
· GET /mp/h5/authorize —— 生成微信授权地址(公众号 appid,snsapi_base 静默授权)
· GET /mp/h5/callback —— code 换 openid → 找/建学生 → 下发会话 → 302 回 H5
· GET /mp/h5/stub-login —— 【仅开发/测试】跳过微信授权的登录,供自动化测试
· fill.mp-h5 配置段(公众号凭据)

关键事实:H5 必须用**公众号**的 appid —— 网页授权与小程序码是两套凭据、两套接口,
不能混用。所以配置分成 fill.mini-program(小程序码用)与 fill.mp-h5(网页授权用)。

授权流程的三处设计:
1) token 放在 URL 的 fragment(#)里,不放 query。fragment 不发给服务器 ——
   不会进 Nginx access log、不会进 Referer、不会进浏览器历史。
   放 query 等于把 token 写进四五处日志。
2) scene 不裸放在 state 里,而是存 Redis 用一次性随机串指代 ——
   state 会进 URL、浏览器历史与日志,放业务参数容易被人拿来构造请求。
3) 回调必须"永远重定向回应用",绝不抛业务错误。那是浏览器直接跳转的地址,
   抛错用户看到的是一屏 {"code":40001,...} JSON —— 一个学生完全无法理解、
   也不知道该怎么办的画面。

打桩(为了自动化)与四道约束:
   微信授权那一步必须真人在微信里点,脚本永远做不了,所以要有打桩;
   而打桩是危险动作 —— 一个"跳过微信授权"的开关漏进生产就是
   "任何人可登录任何学生"的后门。约束:
   ① 配置开关默认 false,只有 dev 打开;
   ② 代码里再判一次 profile 不含 prod(配置写错也进不来,双保险);
   ③ 接口路径独立(/mp/h5/stub-login),不是"真实回调里加个 if"——
      那种写法一旦判断写反就是灾难;
   ④ 每次调用都 WARN 日志,不留静默通道。

本轮修掉 2 个真问题:
1) RoleProvisioner.bindRole 在匿名请求下不可用:它内部走 MP 的 BaseMapper,
   插入时租户拦截器拿不到 tenantId、直接抛「未登录」。症状是 500 且角色没建上
   (用户建了却没角色,后面每个接口都会以"无权限"拒绝他,排查毫无头绪)。
   已改为自己写:@InterceptorIgnore + 显式 tenant_id。
2) 授权回调抛业务错误 → 用户看到一屏 JSON。已改成 try/catch 后重定向回应用,
   把 e.getMessage() 作为 authError 带上(那句话本来就是写给人看的)。
   E2E 实测:用户看到的是「网页授权换 openid失败:code 无效或已使用过
   (code 只能用一次,且 5 分钟过期)(微信原文:40029 invalid code...)」。

⚠️ 上线前必须解决:H5 与小程序是**两个 openid**
   同一个人的小程序 openid 与公众号 openid 是不同的(不同 appid 各自签发),
   只有 union_id 一致 —— 而它需要两个账号都绑到同一个微信开放平台账号。
   后果:学生先用 H5 填、后来又用小程序填,系统当成两个用户,
   第二个渠道撞 40072(已被登记)。一期只走 H5 不会碰到,
   但两条腿都上线前必须解决(按 union_id 认人)。

已知缺口:
· H5 核验页未做(落地页的「开始填报」仍是"开发中"提示)。
· H5 桌面全宽铺开,该加 max-width 居中。
· 真实微信授权未走通:需要公众号后台填「授权回调页面域名」+ 域名解析到能跑服务的
  机器(或内网穿透)。打桩跳过的是微信那一步,后面全走真逻辑。
· /mp/login(小程序登录)仍未做,可与 H5 授权共用 resolveUser。

顺带确认:frontend-mp 的 build:h5 已能出 H5 产物,
已在无头浏览器里跑通「URL 带 scene → 读 scene → 跨域调后端 → 显示学校/任务/状态」,
CORS(后端早已配 localhost:*)也通。这条是选 H5 的核心收益:前端交互层可自动化验证。

验收:e2e-m3-h5-auth.py 22/22;受影响的四条回归全绿
(落地页 31 / 门禁 29 / M0 账号 55 / 二维码 32)。
This commit is contained in:
2026-09-29 11:00:18 +08:00
parent 15b819746b
commit 6e7597531a
8 changed files with 847 additions and 1 deletions
+63
View File
@@ -500,6 +500,69 @@ t{tenantId}-k{taskId}-o{orgId} 带班级
---
## 一·十二、M3-02 H5 微信网页授权(2026-09-29,未提交)
> 方向决定:**H5 优先**(资质门槛低得多、且前端可自动化测试)。小程序端代码不浪费 ——
> uni-app 一套代码多端,`build:h5` 直接出 H5。
**验收**:`python scripts/e2e-m3-h5-auth.py` → **22/22**;
(受影响的四条回归全绿:落地页 31 / 门禁 29 / M0 账号 55 / 二维码 32)
### 关键事实:H5 必须用**公众号**的 appid
网页授权与小程序码是**两套凭据、两套接口**,不能混用。所以配置分成了两段:
`fill.mini-program`(小程序,生成小程序码用)与 `fill.mp-h5`(公众号,网页授权用)。
### 授权流程与三处设计
```
H5 页面 → GET /mp/h5/authorize?scene=X → 拿微信授权地址 → 跳微信
→ 用户点"允许" → 微信 302 回 /mp/h5/callback?code&state
→ 换 openid → 找/建学生 → 下发会话 → 302 回 H5
```
| # | 设计 | 为什么 |
|:--:|---|---|
| 1 | **token 放在 URL 的 fragment(`#`)里,不放 query** | fragment 不发给服务器 —— 不会进 Nginx access log、不会进 Referer、不会进浏览器历史。放 query 等于把 token 写进四五处日志 |
| 2 | **`scene` 不裸放在 `state` 里**,而是存 Redis 用一次性随机串指代 | state 会进 URL、浏览器历史与日志;放业务参数容易被人拿来构造请求 |
| 3 | **回调必须"永远重定向回应用"**,绝不抛业务错误 | 那是浏览器直接跳转的地址,抛错用户看到的是一屏 `{"code":40001,...}` JSON —— 一个学生完全无法理解、也不知道该怎么办的画面 |
### 打桩(为了自动化)与四道约束
微信授权那一步**必须真人在微信里点**,脚本永远做不了。所以要有打桩,而打桩是**危险动作**:
1. 配置开关 `fill.mp-h5.stub-enabled` 默认 **false**,只有 dev 打开;
2. 代码里**再判一次** profile 不含 prod(配置写错也进不来,双保险);
3. **接口路径独立**(`/mp/h5/stub-login`),不是"真实回调里加个 if" —— 那种写法一旦判断写反就是灾难;
4. 每次调用都 **WARN 日志**,不留静默通道。
### 本轮修掉的 2 个真问题
| # | 问题 | 说明 |
|:--:|---|---|
| 1 | **`RoleProvisioner.bindRole` 在匿名请求下不可用** | 它内部走 MP 的 BaseMapper,插入时租户拦截器拿不到 tenantId、直接抛「未登录」。症状是 **500 且角色没建上**(用户建了却没角色,后面每个接口都会以"无权限"拒绝他,排查毫无头绪)。已改为自己写:`@InterceptorIgnore` + 显式 tenant_id |
| 2 | **授权回调抛业务错误 → 用户看到一屏 JSON** | 见上表第 3 条。已改成 try/catch 后重定向回应用,把 `e.getMessage()` 作为 `authError` 带上(那句话本来就是写给人看的,如「code 无效或已使用过(code 只能用一次,且 5 分钟过期)」) |
### ⚠️ 一个上线前必须解决的问题:H5 与小程序是**两个 openid**
同一个人的小程序 openid 与公众号 openid **是不同的**(不同 appid 各自签发),
只有 `union_id` 一致 —— 而 union_id 需要两个账号都绑到**同一个微信开放平台账号**才有。
后果:学生先用 H5 填、后来又用小程序填,系统会当成**两个用户**,第二个渠道撞 `40072(已被登记)`。
一期只走 H5,所以现在不会碰到;**但两条腿都上线前必须解决** ——
办法是把两个账号绑到同一个开放平台账号拿 union_id,然后按 union_id 认人。
### 已知缺口
| 项 | 说明 |
|---|---|
| **H5 核验页未做** | 落地页点「开始填报」目前仍是"开发中"提示。下一轮:核验页(姓名+证件号→门禁)→ 认领会话 token |
| H5 桌面全宽铺开 | 该加 `max-width: 480px` 居中(主战场是手机,但自动化验证在桌面浏览器里跑) |
| 真实微信授权未走通 | 需要:公众号后台填「授权回调页面域名」+ 域名解析到能跑服务的机器(或内网穿透)。**打桩跳过的是微信那一步,后面全走真逻辑** |
| `/mp/login`(小程序登录)仍未做 | 与 H5 授权是同一件事的两个入口,可共用 `resolveUser` |
---
**关键实现决策(不要在后继开发中改回去)**
| 决策 | 理由 |