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