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:
@@ -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 填报端(微信网页授权)。
|
||||
*
|
||||
* <p>⚠️ <b>网页授权必须用<b>公众号</b>的 appid,不能用小程序的</b> ——
|
||||
* 两者是不同的账号类型,接口不通用。所以这里是独立的一段配置,
|
||||
* 与 {@code mini-program}(小程序码用)分开。
|
||||
*
|
||||
* <p>⚠️ <b>同一个人的小程序 openid 与公众号 openid 是不同的</b>(不同 appid 各自签发),
|
||||
* 只有 {@code union_id} 一致 —— 而 union_id 需要两个账号都绑定同一个微信开放平台账号
|
||||
* 才会下发。这意味着:如果学生先用 H5 填、后来又用小程序填,系统会认为是<b>两个用户</b>,
|
||||
* 第二个渠道会撞上 {@code 40072(已被登记)}。见 {@code MpH5AuthService} 的说明。
|
||||
*
|
||||
* @param appId 公众号 appid(web 授权用)
|
||||
* @param appSecret 公众号 appsecret
|
||||
* @param callbackUrl 授权回调地址,必须是配置在微信后台的那个域名下的完整 URL
|
||||
* @param h5BaseUrl H5 应用地址。授权成功后 302 回这里(token 放在 fragment 里)
|
||||
* @param stubEnabled <b>仅开发/测试</b>:跳过微信授权直接给一个 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+156
@@ -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)。
|
||||
*
|
||||
* <p>流程:H5 页面 → {@code /h5/authorize} 拿授权地址 → 跳去微信 → 用户点"允许"
|
||||
* → 微信跳回 {@code /h5/callback?code=..&state=..} → 我们换 openid、建会话
|
||||
* → 302 回 H5 页面(token 放在 <b>fragment</b> 里)。
|
||||
*
|
||||
* <p><b>为什么 token 放 fragment 而不是 query</b>: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<Map<String, Object>> authorize(@RequestParam(required = false) String scene) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("authorizeUrl", authService.buildAuthorizeUrl(scene));
|
||||
return ApiResult.ok(out);
|
||||
}
|
||||
|
||||
@PublicApi
|
||||
@GetMapping("/callback")
|
||||
@Operation(summary = "微信授权回调(匿名)",
|
||||
description = "code 换 openid → 找/建学生 → 下发会话 → 302 回 H5(token 在 URL fragment 里)")
|
||||
public ResponseEntity<Void> callback(@RequestParam(required = false) String code,
|
||||
@RequestParam(required = false) String state) {
|
||||
String scene = authService.consumeScene(state);
|
||||
try {
|
||||
if (code == null || code.isBlank()) {
|
||||
// 用户在微信授权页点了"拒绝"。这不是系统故障,给一个能看懂的落点
|
||||
return redirectToApp(null, scene, "你取消了微信授权,请重新进入");
|
||||
}
|
||||
String openId = authService.exchangeOpenId(code);
|
||||
return loginAndRedirect(openId, scene);
|
||||
} catch (BizException e) {
|
||||
// ⚠️ 这个地址是**浏览器直接跳转**过来的。抛业务错误的话,用户看到的是
|
||||
// 一屏 {"code":40001,"msg":"..."} 的 JSON —— 一个学生完全无法理解的画面,
|
||||
// 而且他也不知道该怎么办。所以这里必须转成"回应用 + 带一句人话"。
|
||||
log.warn("授权回调失败:{}", e.getMessage());
|
||||
return redirectToApp(null, scene, e.getMessage());
|
||||
} catch (Exception e) {
|
||||
// 同理,任何异常都不能把堆栈或 JSON 甩给用户
|
||||
log.error("授权回调异常", e);
|
||||
return redirectToApp(null, scene, "登录失败,请重新进入或联系班主任");
|
||||
}
|
||||
}
|
||||
|
||||
@PublicApi
|
||||
@GetMapping("/stub-login")
|
||||
@Operation(summary = "【仅开发/测试】跳过微信授权的登录",
|
||||
description = "用于自动化测试:不需要真人在微信里点授权。"
|
||||
+ "受 fill.mp-h5.stub-enabled 开关 + profile 双重保护,生产环境不可用")
|
||||
public ResponseEntity<Void> stubLogin(@RequestParam String openid,
|
||||
@RequestParam(required = false) String scene,
|
||||
HttpServletRequest request) {
|
||||
Long tenantId = MpSceneService.tenantIdOf(scene);
|
||||
if (tenantId == null) {
|
||||
return redirectToApp(null, scene, "打桩登录需要带合法的 scene(t{租户id}...)");
|
||||
}
|
||||
SysUser user = authService.stubLogin(tenantId, openid, request);
|
||||
return redirectWithToken(user, scene);
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
private ResponseEntity<Void> loginAndRedirect(String openId, String scene) {
|
||||
Long tenantId = MpSceneService.tenantIdOf(scene);
|
||||
if (tenantId == null) {
|
||||
// 没带 scene 就没法定租户:这个人属于哪个学校还不知道。
|
||||
// 不能猜一个默认租户 —— 在多租户系统里"猜"就是越权
|
||||
return redirectToApp(null, scene, "请扫描班主任发的二维码进入");
|
||||
}
|
||||
SysUser user = authService.resolveUser(tenantId, openId);
|
||||
return redirectWithToken(user, scene);
|
||||
}
|
||||
|
||||
private ResponseEntity<Void> redirectWithToken(SysUser user, String scene) {
|
||||
String token = jwtService.issue(
|
||||
new LoginUser(user.getId(), user.getTenantId(), List.of(), java.util.Set.of("STUDENT"), 4),
|
||||
JwtService.Client.MP);
|
||||
return redirectToApp(token, scene, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 302 回 H5 页面。token 放 <b>fragment</b>(不发给服务器),错误信息放 query。
|
||||
*/
|
||||
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 = "";
|
||||
if (token != null) {
|
||||
frag += "token=" + enc(token);
|
||||
}
|
||||
if (scene != null && !scene.isBlank()) {
|
||||
frag += (frag.isEmpty() ? "" : "&") + "scene=" + enc(scene);
|
||||
}
|
||||
if (error != null) {
|
||||
// 错误放 query:它不敏感,而且我们要让页面能读到
|
||||
sb.append("?authError=").append(enc(error));
|
||||
}
|
||||
if (!frag.isEmpty()) {
|
||||
sb.append("#").append(frag);
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header("Location", sb.toString())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String enc(String s) {
|
||||
return URLEncoder.encode(s, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package cn.tianbaotong.fill.module.mp.mapper;
|
||||
|
||||
import cn.tianbaotong.fill.module.user.entity.SysUser;
|
||||
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* H5 网页授权的用户查询/创建。
|
||||
*
|
||||
* <p>⚠️ 授权回调是<b>匿名请求</b>(学生还没登录),而多租户拦截器在匿名下直接抛 40101,
|
||||
* 所以每条查询都必须 {@code @InterceptorIgnore} + <b>显式带 tenant_id</b>。
|
||||
* 租户从 scene 里解析出来(FR-105),不是从登录态里取。
|
||||
*/
|
||||
public interface MpH5Mapper {
|
||||
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT * FROM sys_user
|
||||
WHERE tenant_id = #{tenantId} AND open_id = #{openId} AND deleted = 0
|
||||
LIMIT 1
|
||||
""")
|
||||
SysUser selectByOpenId(@Param("tenantId") Long tenantId, @Param("openId") String openId);
|
||||
|
||||
/**
|
||||
* 建一个微信用户(学生)。
|
||||
*
|
||||
* <p>⚠️ {@code username} 在库里是 <b>NOT NULL</b>,而微信用户本来没有登录名 ——
|
||||
* 所以这里生成一个占位名。不要试图把它改成可空:
|
||||
* 它上面有唯一约束,占位名保证了"同一个人只会有一行"。
|
||||
* 占位名用 openid 前缀,保证唯一且可追溯。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Insert("""
|
||||
INSERT INTO sys_user (tenant_id, username, real_name, open_id, user_type, status)
|
||||
VALUES (#{tenantId}, #{username}, #{realName}, #{openId}, 4, 1)
|
||||
""")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "user.id")
|
||||
int insertWxUser(@Param("tenantId") Long tenantId,
|
||||
@Param("username") String username,
|
||||
@Param("realName") String realName,
|
||||
@Param("openId") String openId,
|
||||
@Param("user") SysUser user);
|
||||
|
||||
/** 重新读一次刚插入的用户(拿 id 与其他列)。 */
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT * FROM sys_user
|
||||
WHERE tenant_id = #{tenantId} AND open_id = #{openId} AND deleted = 0
|
||||
LIMIT 1
|
||||
""")
|
||||
SysUser selectJustInserted(@Param("tenantId") Long tenantId, @Param("openId") String openId);
|
||||
|
||||
/**
|
||||
* 取角色 id。
|
||||
*
|
||||
* <p>⚠️ <b>不能用 {@code RoleProvisioner.bindRole}</b>(虽然它看起来正合适):
|
||||
* 它内部走的是 MyBatis-Plus 的 BaseMapper,插入时会经过租户拦截器 ——
|
||||
* 而授权回调是<b>匿名请求</b>,拦截器拿不到 tenantId,直接抛「未登录」。
|
||||
* 症状是 500,而角色行根本没建上(用户建了、却没有角色)。
|
||||
* 所以这里自己来:关掉拦截器 + 显式带 tenant_id。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT id FROM sys_role
|
||||
WHERE tenant_id = #{tenantId} AND role_code = #{roleCode} AND deleted = 0
|
||||
LIMIT 1
|
||||
""")
|
||||
Long selectRoleId(@Param("tenantId") Long tenantId, @Param("roleCode") String roleCode);
|
||||
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM sys_user_role
|
||||
WHERE tenant_id = #{tenantId} AND user_id = #{userId} AND role_id = #{roleId}
|
||||
""")
|
||||
Long countUserRole(@Param("tenantId") Long tenantId,
|
||||
@Param("userId") Long userId,
|
||||
@Param("roleId") Long roleId);
|
||||
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@org.apache.ibatis.annotations.Insert("""
|
||||
INSERT INTO sys_user_role (tenant_id, user_id, role_id)
|
||||
VALUES (#{tenantId}, #{userId}, #{roleId})
|
||||
""")
|
||||
int insertUserRole(@Param("tenantId") Long tenantId,
|
||||
@Param("userId") Long userId,
|
||||
@Param("roleId") Long roleId);
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package cn.tianbaotong.fill.module.mp.service;
|
||||
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||
import cn.tianbaotong.fill.common.crypto.HmacIndex;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
import cn.tianbaotong.fill.config.FillProperties;
|
||||
import cn.tianbaotong.fill.module.mp.mapper.MpH5Mapper;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysUser;
|
||||
import cn.tianbaotong.fill.security.BuiltinRole;
|
||||
import cn.tianbaotong.fill.support.ClientIp;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* H5 微信网页授权(公众号 OAuth)。
|
||||
*
|
||||
* <p>小程序里 openid 由 {@code wx.login} 的 code 换取,免授权;H5 没有这个能力,
|
||||
* 必须把用户送去微信授权页点一下"允许",微信再跳回我们的回调地址并带上 {@code code}。
|
||||
*
|
||||
* <p>⚠️ <b>必须用公众号的 appid</b>({@code fill.mp-h5.*}),小程序的 appid 做不了网页授权。
|
||||
*
|
||||
* <p>⚠️ <b>同一个人的小程序 openid 与公众号 openid 是不同的</b>(不同 appid 各自签发),
|
||||
* 只有 {@code union_id} 一致 —— 而 union_id 需要两个账号都绑到同一个微信开放平台账号才有。
|
||||
* 后果:学生先用 H5 填、后来又用小程序填,系统会当成<b>两个用户</b>,
|
||||
* 第二个渠道撞 {@code 40072(已被登记)}。
|
||||
* 一期只走 H5(见交接摘要),所以现在不会碰到;但**两条腿都上线前必须解决** ——
|
||||
* 解决办法是把两个账号绑到同一个开放平台账号拿 union_id,然后按 union_id 认人。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MpH5AuthService {
|
||||
|
||||
private static final String WX_OAUTH_AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize";
|
||||
private static final String WX_OAUTH_TOKEN = "https://api.weixin.qq.com/sns/oauth2/access_token";
|
||||
|
||||
/** 授权 URL 里 state 的有效期(防 CSRF,也用来带 scene)。 */
|
||||
private static final Duration STATE_TTL = Duration.ofMinutes(10);
|
||||
private static final String STATE_KEY = "xt:mp:h5:state:";
|
||||
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
private final FillProperties props;
|
||||
private final MpH5Mapper h5Mapper;
|
||||
private final StringRedisTemplate redis;
|
||||
private final Environment environment;
|
||||
|
||||
public MpH5AuthService(FillProperties props, MpH5Mapper h5Mapper,
|
||||
StringRedisTemplate redis,
|
||||
Environment environment) {
|
||||
this.props = props;
|
||||
this.h5Mapper = h5Mapper;
|
||||
this.redis = redis;
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
private FillProperties.MpH5 cfg() {
|
||||
FillProperties.MpH5 c = props.mpH5();
|
||||
if (c == null || !c.configured()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"H5 网页授权未配置(fill.mp-h5.app-id / app-secret / callback-url)");
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── 第一步:拼授权 URL ────────────────────────────
|
||||
|
||||
/**
|
||||
* 生成微信授权地址。
|
||||
*
|
||||
* <p>{@code scene} 不直接放在 {@code state} 里裸传,而是存进 Redis 用一次性随机串指代 ——
|
||||
* state 会出现在 URL 里、进浏览器历史与日志,放业务参数容易被人拿来构造请求。
|
||||
*/
|
||||
public String buildAuthorizeUrl(String scene) {
|
||||
FillProperties.MpH5 c = cfg();
|
||||
String state = java.util.UUID.randomUUID().toString().replace("-", "");
|
||||
redis.opsForValue().set(STATE_KEY + state, scene == null ? "" : scene, STATE_TTL);
|
||||
|
||||
return WX_OAUTH_AUTHORIZE
|
||||
+ "?appid=" + c.appId()
|
||||
+ "&redirect_uri=" + URLEncoder.encode(c.callbackUrl(), StandardCharsets.UTF_8)
|
||||
// snsapi_base:静默授权,用户看不到授权页,只拿 openid。
|
||||
// 我们只需要 openid 认人,不需要头像昵称 —— 用 snsapi_userinfo 会多一次弹窗,
|
||||
// 而学生对这个流程的耐心很有限
|
||||
+ "&response_type=code&scope=snsapi_base"
|
||||
+ "&state=" + state
|
||||
+ "#wechat_redirect";
|
||||
}
|
||||
|
||||
/** 取出并消费 state 里带的 scene(一次性)。 */
|
||||
public String consumeScene(String state) {
|
||||
if (state == null || state.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String key = STATE_KEY + state;
|
||||
String scene = redis.opsForValue().get(key);
|
||||
redis.delete(key);
|
||||
return scene == null ? "" : scene;
|
||||
}
|
||||
|
||||
// ── 第二步:code 换 openid ────────────────────────
|
||||
|
||||
public String exchangeOpenId(String code) {
|
||||
FillProperties.MpH5 c = cfg();
|
||||
String url = WX_OAUTH_TOKEN
|
||||
+ "?appid=" + c.appId()
|
||||
+ "&secret=" + c.appSecret()
|
||||
+ "&code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
|
||||
+ "&grant_type=authorization_code";
|
||||
Map<String, Object> r = getJson(url);
|
||||
String openId = str(r.get("openid"));
|
||||
if (openId == null) {
|
||||
throw wxError("网页授权换 openid", r);
|
||||
}
|
||||
return openId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 openid 找或建用户。
|
||||
*
|
||||
* <p>租户从 scene 解析(FR-105:统一应用 + scene 识别租户)。scene 缺失时说明
|
||||
* 用户不是扫码进来的 —— 那就没法定租户,因为他属于哪个学校还不知道。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public SysUser resolveUser(Long tenantId, String openId) {
|
||||
SysUser exist = h5Mapper.selectByOpenId(tenantId, openId);
|
||||
if (exist != null) {
|
||||
return exist;
|
||||
}
|
||||
// 微信用户没有登录名,但 username 在库里是 NOT NULL。
|
||||
// 用 openid 前缀做占位:唯一、可追溯,且满足唯一约束
|
||||
String username = "wx" + openId.substring(0, Math.min(16, openId.length()));
|
||||
SysUser probe = new SysUser();
|
||||
h5Mapper.insertWxUser(tenantId, username, "微信用户", openId, probe);
|
||||
SysUser created = h5Mapper.selectJustInserted(tenantId, openId);
|
||||
if (created == null) {
|
||||
throw new BizException(ErrorCode.INTERNAL_ERROR, "创建微信用户失败");
|
||||
}
|
||||
// 授予学生角色。角色码与学生类型是一对一的(见 BuiltinRole.UserType),
|
||||
// 所以用户类型由角色推导,不让调用方传。
|
||||
//
|
||||
// ⚠️ 这里**不能**用 RoleProvisioner:它走 MP 的 BaseMapper,插入时租户拦截器
|
||||
// 在匿名请求下拿不到 tenantId、直接抛「未登录」(实测踩过:500 且角色没建上)。
|
||||
Long roleId = h5Mapper.selectRoleId(tenantId, BuiltinRole.STUDENT.getCode());
|
||||
if (roleId == null) {
|
||||
// 租户开通时应该已经建好内置角色。没有说明租户数据不完整 —— 明确报出来,
|
||||
// 不要静默跳过(静默跳过的结果是"学生登录成功但没有任何权限",
|
||||
// 而后面每个接口都会以"无权限"拒绝他,排查起来毫无头绪)
|
||||
throw new BizException(ErrorCode.INTERNAL_ERROR,
|
||||
"租户缺少内置角色 STUDENT,请检查租户开通流程");
|
||||
}
|
||||
if (nz(h5Mapper.countUserRole(tenantId, created.getId(), roleId)) == 0) {
|
||||
h5Mapper.insertUserRole(tenantId, created.getId(), roleId);
|
||||
}
|
||||
log.info("H5 首次登录,已建学生账号:tenant={} uid={} openid={}***",
|
||||
tenantId, created.getId(), openId.substring(0, Math.min(6, openId.length())));
|
||||
return created;
|
||||
}
|
||||
|
||||
private static long nz(Long v) {
|
||||
return v == null ? 0L : v;
|
||||
}
|
||||
|
||||
// ── 开发/测试用:跳过微信 ─────────────────────────
|
||||
|
||||
/**
|
||||
* 打桩:直接用一个给定的 openid 完成登录。<b>仅供开发/测试</b>。
|
||||
*
|
||||
* <p>四道约束(少一道都可能变成"任何人可登录任何学生"的后门):
|
||||
* <ol>
|
||||
* <li>配置开关 {@code fill.mp-h5.stub-enabled} 默认 <b>false</b>,只有 dev 打开;</li>
|
||||
* <li>再判一次 profile 不含 prod —— 配置写错也进不来(双保险);</li>
|
||||
* <li>接口路径独立({@code /mp/h5/stub-login}),不是"真实回调里加个 if" ——
|
||||
* 那种写法一旦判断写反就是灾难;</li>
|
||||
* <li>每次调用都 <b>WARN 日志</b>,不留静默通道。</li>
|
||||
* </ol>
|
||||
*/
|
||||
public SysUser stubLogin(Long tenantId, String openId, HttpServletRequest request) {
|
||||
FillProperties.MpH5 c = props.mpH5();
|
||||
if (c == null || !c.stubOn()) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"该接口未启用(fill.mp-h5.stub-enabled=false)");
|
||||
}
|
||||
for (String p : environment.getActiveProfiles()) {
|
||||
if (p.toLowerCase().contains("prod")) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"该接口不得在生产环境使用");
|
||||
}
|
||||
}
|
||||
log.warn("⚠️ 走了 H5 登录打桩(跳过微信授权):tenant={} openid={} ip={} —— "
|
||||
+ "这是开发/测试路径,生产环境绝不应出现这行日志",
|
||||
tenantId, openId, ClientIp.of(request));
|
||||
return resolveUser(tenantId, openId);
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
private Map<String, Object> getJson(String url) {
|
||||
try {
|
||||
HttpResponse<byte[]> resp = HTTP.send(
|
||||
HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(20)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
return JSONUtil.toBean(new String(resp.body(), StandardCharsets.UTF_8), Map.class);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"调用微信接口失败(网络层):" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private BizException wxError(String action, Map<String, Object> r) {
|
||||
int code = r.get("errcode") instanceof Number n ? n.intValue() : -1;
|
||||
String msg = str(r.get("errmsg"));
|
||||
String hint = switch (code) {
|
||||
case 40001, 40125 -> "appsecret 不对";
|
||||
case 40013 -> "appid 不对(网页授权要用**公众号**的 appid,小程序的不行)";
|
||||
case 40164 -> "调用方 IP 不在公众号的 IP 白名单里";
|
||||
case 40029 -> "code 无效或已使用过(code 只能用一次,且 5 分钟过期)";
|
||||
case 40163 -> "code 已被使用过";
|
||||
case 10003 -> "redirect_uri 域名与后台配置不一致(要在公众号后台填授权回调页面域名)";
|
||||
default -> "微信返回 errcode=" + code;
|
||||
};
|
||||
log.error("{}失败:errcode={} errmsg={}", action, code, msg);
|
||||
return new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
action + "失败:" + hint + "(微信原文:" + code + " " + msg + ")");
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
}
|
||||
+10
@@ -40,6 +40,16 @@ public class MpSceneService {
|
||||
|
||||
private static final Pattern SCENE = Pattern.compile("^t(\\d+)(?:-k(\\d+))?(?:-o(\\d+))?$");
|
||||
|
||||
/**
|
||||
* 从场景值里取租户 id;格式不对返回 {@code null}。
|
||||
*
|
||||
* <p>授权回调需要它:学生是匿名进来的,租户只能从 scene 解析(FR-105)。
|
||||
*/
|
||||
public static Long tenantIdOf(String scene) {
|
||||
Matcher m = scene == null ? null : SCENE.matcher(scene.trim());
|
||||
return (m != null && m.matches()) ? Long.parseLong(m.group(1)) : null;
|
||||
}
|
||||
|
||||
/** 同一 IP 每分钟最多查多少次。正常学生一次扫描只会调 1~2 次(含重试)。 */
|
||||
private static final int RATE_LIMIT_PER_MINUTE = 20;
|
||||
|
||||
|
||||
@@ -62,6 +62,26 @@ fill:
|
||||
public-base-url: ${WX_MP_PUBLIC_BASE_URL:}
|
||||
code-cache-days: 30
|
||||
|
||||
# ── H5 填报端(微信网页授权)──────────────────────────────────────────────
|
||||
#
|
||||
# ⚠️ 网页授权必须用**公众号**的 appid,不能用小程序的 —— 两者接口不通用。
|
||||
# 所以这里与上面的 mini-program 是两套凭据。
|
||||
# 这里用的是公众平台的**接口测试号**(已实测:网页授权 appid 有效、
|
||||
# JS-SDK ticket 可取、用户管理可用)。
|
||||
mp-h5:
|
||||
app-id: ${WX_H5_APP_ID:wx5d3ae5c1c21fef24}
|
||||
app-secret: ${WX_H5_APP_SECRET:0e0367fd39a968866a3c59f6c641dda2}
|
||||
# 授权回调地址。必须是「授权回调页面域名」下的完整 URL,
|
||||
# 而那个域名要填在公众号后台(只能填域名,不能带端口/路径/IP)。
|
||||
# 本地开发:把域名用 hosts 指到 127.0.0.1,或用内网穿透给一个公网域名。
|
||||
callback-url: ${WX_H5_CALLBACK:http://127.0.0.1:8080/api/v1/mp/h5/callback}
|
||||
# H5 应用地址:授权成功后 302 回这里
|
||||
h5-base-url: ${WX_H5_BASE_URL:http://127.0.0.1:5174}
|
||||
# ⚠️⚠️ 跳过微信授权直接登录的开关。**生产必须为 false**。
|
||||
# 开了它就是"任何人可登录任何学生"的后门。
|
||||
# 代码里还会再判一次 profile 不含 prod(双保险)。
|
||||
stub-enabled: ${WX_H5_STUB:true}
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
enabled: true
|
||||
|
||||
Reference in New Issue
Block a user