From 6e7597531ae987e20cd5f1c2dcf0bf9bc502e4c6 Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Tue, 29 Sep 2026 11:00:18 +0800
Subject: [PATCH] =?UTF-8?q?feat(mp):=20M3-02=20H5=20=E5=BE=AE=E4=BF=A1?=
=?UTF-8?q?=E7=BD=91=E9=A1=B5=E6=8E=88=E6=9D=83=EF=BC=88=E5=85=AC=E4=BC=97?=
=?UTF-8?q?=E5=8F=B7=20OAuth=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
方向决定了 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)。
---
.../fill/config/FillProperties.java | 44 +++-
.../mp/controller/MpH5AuthController.java | 156 +++++++++++
.../fill/module/mp/mapper/MpH5Mapper.java | 90 +++++++
.../module/mp/service/MpH5AuthService.java | 245 ++++++++++++++++++
.../module/mp/service/MpSceneService.java | 10 +
.../src/main/resources/application-dev.yml | 20 ++
docs/交接摘要.md | 63 +++++
scripts/e2e-m3-h5-auth.py | 220 ++++++++++++++++
8 files changed, 847 insertions(+), 1 deletion(-)
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpH5Mapper.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpH5AuthService.java
create mode 100644 scripts/e2e-m3-h5-auth.py
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java
index 61dc6d7..4b691c8 100644
--- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/config/FillProperties.java
@@ -20,6 +20,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
* @param security 登录安全策略
* @param upload 上传限制
* @param miniProgram 微信小程序(填报二维码)
+ * @param mpH5 H5 填报端(网页授权)
*/
@ConfigurationProperties(prefix = "fill")
public record FillProperties(
@@ -28,7 +29,8 @@ public record FillProperties(
Rsa rsa,
Security security,
Upload upload,
- MiniProgram miniProgram
+ MiniProgram miniProgram,
+ MpH5 mpH5
) {
/**
@@ -138,4 +140,44 @@ public record FillProperties(
return appId != null && !appId.isBlank() && appSecret != null && !appSecret.isBlank();
}
}
+
+ /**
+ * H5 填报端(微信网页授权)。
+ *
+ *
⚠️ 网页授权必须用公众号的 appid,不能用小程序的 ——
+ * 两者是不同的账号类型,接口不通用。所以这里是独立的一段配置,
+ * 与 {@code mini-program}(小程序码用)分开。
+ *
+ *
⚠️ 同一个人的小程序 openid 与公众号 openid 是不同的(不同 appid 各自签发),
+ * 只有 {@code union_id} 一致 —— 而 union_id 需要两个账号都绑定同一个微信开放平台账号
+ * 才会下发。这意味着:如果学生先用 H5 填、后来又用小程序填,系统会认为是两个用户,
+ * 第二个渠道会撞上 {@code 40072(已被登记)}。见 {@code MpH5AuthService} 的说明。
+ *
+ * @param appId 公众号 appid(web 授权用)
+ * @param appSecret 公众号 appsecret
+ * @param callbackUrl 授权回调地址,必须是配置在微信后台的那个域名下的完整 URL
+ * @param h5BaseUrl H5 应用地址。授权成功后 302 回这里(token 放在 fragment 里)
+ * @param stubEnabled 仅开发/测试:跳过微信授权直接给一个 openid。
+ * 生产必须为 false —— 开了它就是"任何人可登录任何学生"的后门。
+ * 代码里还会额外判断 profile 不含 prod,双重保护。
+ */
+ public record MpH5(
+ String appId,
+ String appSecret,
+ String callbackUrl,
+ String h5BaseUrl,
+ Boolean stubEnabled
+ ) {
+ public boolean configured() {
+ return appId != null && !appId.isBlank() && appSecret != null && !appSecret.isBlank();
+ }
+
+ public boolean stubOn() {
+ return Boolean.TRUE.equals(stubEnabled);
+ }
+
+ public String h5BaseUrlOrDefault() {
+ return h5BaseUrl == null || h5BaseUrl.isBlank() ? "http://127.0.0.1:5174" : h5BaseUrl;
+ }
+ }
}
diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java
new file mode 100644
index 0000000..dcc2244
--- /dev/null
+++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpH5AuthController.java
@@ -0,0 +1,156 @@
+package cn.tianbaotong.fill.module.mp.controller;
+
+import cn.tianbaotong.fill.common.api.ApiResult;
+import cn.tianbaotong.fill.common.api.ErrorCode;
+import cn.tianbaotong.fill.common.context.LoginUser;
+import cn.tianbaotong.fill.common.exception.BizException;
+import cn.tianbaotong.fill.module.mp.service.MpH5AuthService;
+import cn.tianbaotong.fill.module.mp.service.MpSceneService;
+import cn.tianbaotong.fill.module.user.entity.SysUser;
+import cn.tianbaotong.fill.security.JwtService;
+import cn.tianbaotong.fill.security.PublicApi;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * H5 微信网页授权(公众号 OAuth)。
+ *
+ *
流程:H5 页面 → {@code /h5/authorize} 拿授权地址 → 跳去微信 → 用户点"允许"
+ * → 微信跳回 {@code /h5/callback?code=..&state=..} → 我们换 openid、建会话
+ * → 302 回 H5 页面(token 放在 fragment 里)。
+ *
+ *
为什么 token 放 fragment 而不是 query:fragment 不会发给服务器,
+ * 所以它不会进 Nginx 的 access log、不会进 Referer、不会进浏览器历史里的请求行。
+ * token 放 query 等于把它写进了四五处日志。
+ */
+@Slf4j
+@Tag(name = "小程序/H5 · 网页授权")
+@RestController
+@RequestMapping("/api/v1/mp/h5")
+@RequiredArgsConstructor
+public class MpH5AuthController {
+
+ private final MpH5AuthService authService;
+ private final JwtService jwtService;
+ private final cn.tianbaotong.fill.config.FillProperties props;
+
+ @PublicApi
+ @GetMapping("/authorize")
+ @Operation(summary = "取微信授权地址(匿名)",
+ description = "H5 页面拿到后直接 location.href 跳过去。scene 会随 state 一起走一圈再回来")
+ public ApiResult