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> authorize(@RequestParam(required = false) String scene) { + Map 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 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 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 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 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 放 fragment(不发给服务器),错误信息放 query。 + */ + private ResponseEntity 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); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpH5Mapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpH5Mapper.java new file mode 100644 index 0000000..544735a --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpH5Mapper.java @@ -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 网页授权的用户查询/创建。 + * + *

⚠️ 授权回调是匿名请求(学生还没登录),而多租户拦截器在匿名下直接抛 40101, + * 所以每条查询都必须 {@code @InterceptorIgnore} + 显式带 tenant_id。 + * 租户从 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); + + /** + * 建一个微信用户(学生)。 + * + *

⚠️ {@code username} 在库里是 NOT NULL,而微信用户本来没有登录名 —— + * 所以这里生成一个占位名。不要试图把它改成可空: + * 它上面有唯一约束,占位名保证了"同一个人只会有一行"。 + * 占位名用 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。 + * + *

⚠️ 不能用 {@code RoleProvisioner.bindRole}(虽然它看起来正合适): + * 它内部走的是 MyBatis-Plus 的 BaseMapper,插入时会经过租户拦截器 —— + * 而授权回调是匿名请求,拦截器拿不到 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); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpH5AuthService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpH5AuthService.java new file mode 100644 index 0000000..6ed71f2 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpH5AuthService.java @@ -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)。 + * + *

小程序里 openid 由 {@code wx.login} 的 code 换取,免授权;H5 没有这个能力, + * 必须把用户送去微信授权页点一下"允许",微信再跳回我们的回调地址并带上 {@code code}。 + * + *

⚠️ 必须用公众号的 appid({@code fill.mp-h5.*}),小程序的 appid 做不了网页授权。 + * + *

⚠️ 同一个人的小程序 openid 与公众号 openid 是不同的(不同 appid 各自签发), + * 只有 {@code union_id} 一致 —— 而 union_id 需要两个账号都绑到同一个微信开放平台账号才有。 + * 后果:学生先用 H5 填、后来又用小程序填,系统会当成两个用户, + * 第二个渠道撞 {@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 ──────────────────────────── + + /** + * 生成微信授权地址。 + * + *

{@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 r = getJson(url); + String openId = str(r.get("openid")); + if (openId == null) { + throw wxError("网页授权换 openid", r); + } + return openId; + } + + /** + * 按 openid 找或建用户。 + * + *

租户从 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 完成登录。仅供开发/测试。 + * + *

四道约束(少一道都可能变成"任何人可登录任何学生"的后门): + *

    + *
  1. 配置开关 {@code fill.mp-h5.stub-enabled} 默认 false,只有 dev 打开;
  2. + *
  3. 再判一次 profile 不含 prod —— 配置写错也进不来(双保险);
  4. + *
  5. 接口路径独立({@code /mp/h5/stub-login}),不是"真实回调里加个 if" —— + * 那种写法一旦判断写反就是灾难;
  6. + *
  7. 每次调用都 WARN 日志,不留静默通道。
  8. + *
+ */ + 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 getJson(String url) { + try { + HttpResponse 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 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); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpSceneService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpSceneService.java index bff8bad..bb08249 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpSceneService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpSceneService.java @@ -40,6 +40,16 @@ public class MpSceneService { private static final Pattern SCENE = Pattern.compile("^t(\\d+)(?:-k(\\d+))?(?:-o(\\d+))?$"); + /** + * 从场景值里取租户 id;格式不对返回 {@code null}。 + * + *

授权回调需要它:学生是匿名进来的,租户只能从 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; diff --git a/backend/xt-server/src/main/resources/application-dev.yml b/backend/xt-server/src/main/resources/application-dev.yml index d957f64..60a5048 100644 --- a/backend/xt-server/src/main/resources/application-dev.yml +++ b/backend/xt-server/src/main/resources/application-dev.yml @@ -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 diff --git a/docs/交接摘要.md b/docs/交接摘要.md index c792bab..1803de4 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -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` | + +--- + **关键实现决策(不要在后继开发中改回去)** | 决策 | 理由 | diff --git a/scripts/e2e-m3-h5-auth.py b/scripts/e2e-m3-h5-auth.py new file mode 100644 index 0000000..67e9478 --- /dev/null +++ b/scripts/e2e-m3-h5-auth.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +H5 微信网页授权 验收测试(M3-02)。 + +覆盖: + [x] /mp/h5/authorize 生成的微信授权地址参数正确(appid/redirect_uri/scope/state) + [x] state 每次不同(防重放),且带回 scene + [x] /mp/h5/stub-login(开发用)→ 302 回 H5,**token 在 fragment 里、不在 query 里** + [x] 打桩登录是幂等的:同一个 openid 第二次登录拿同一个用户 + [x] 拿到的 token 真的能用(过得了认证) + [x] 没有 scene 时不猜租户,而是提示扫码 + [x] 无效 code 的微信错误被翻译成人话(真调微信) + +⚠️ 关于打桩:stub-login 是**开发/测试专用**路径,生产禁用(配置开关 + profile 双保险)。 + 这里测它,是为了让"H5 登录之后的全部流程"能被自动化 —— 微信授权那一步 + 必须真人在微信里点,脚本永远做不了。 + +用法:python scripts/e2e-m3-h5-auth.py +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg") +PG_USER = os.environ.get("PG_USER", "fill") +PG_DB = os.environ.get("PG_DB", "filldb") +PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd") + +PASSED: list[str] = [] +FAILED: list[tuple[str, str]] = [] +CREATED_USER_IDS: list[int] = [] + + +def ok(n: str) -> None: + PASSED.append(n) + print(f" [OK] {n}") + + +def fail(n: str, d: str) -> None: + FAILED.append((n, d)) + print(f" [FAIL] {n}\n {d}") + + +def check(n: str, cond: bool, d: str = "") -> bool: + if cond: + ok(n) + else: + fail(n, d or "断言不成立") + return cond + + +def sql(stmt: str) -> tuple[bool, str]: + try: + r = subprocess.run( + ["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER, + "psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt], + capture_output=True, text=True, timeout=40) + return r.returncode == 0, (r.stdout or r.stderr).strip() + except Exception as e: + return False, str(e) + + +def json_call(url: str, token: str | None = None) -> dict: + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + try: + return json.loads(e.read().decode("utf-8")) + except Exception: + return {"_http": e.code} + + +def redirect_of(url: str) -> tuple[int, dict]: + """不跟随重定向,拿 Location 头。""" + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *a, **k): + return None + + op = urllib.request.build_opener(NoRedirect) + try: + with op.open(urllib.request.Request(url), timeout=30) as resp: + return resp.status, dict(resp.headers) + except urllib.error.HTTPError as e: + return e.code, dict(e.headers) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080")) + args = ap.parse_args() + base = args.base.rstrip("/") + + print("=" * 72) + print(f"H5 微信网页授权 E2E base={base}") + print("=" * 72) + + openid = "e2e_openid_" + uuid.uuid4().hex[:10] + scene = "t1-k17" + + # ── 1. 授权地址 ───────────────────────────────────── + print("\n[1] 生成微信授权地址") + r1 = json_call(f"{base}/api/v1/mp/h5/authorize?scene={scene}") + check("接口可用", r1.get("code") == 0, f"{r1}") + u1 = (r1.get("data") or {}).get("authorizeUrl") or "" + print(f" {u1[:150]}…") + check("指向微信授权端点", u1.startswith("https://open.weixin.qq.com/connect/oauth2/authorize"), + u1[:80]) + check("appid 是公众号的(不是小程序的 wxf01d3f…)", + "appid=wx5d3ae5c1c21fef24" in u1 and "wxf01d3f" not in u1, u1[:120]) + check("scope=snsapi_base(静默授权,学生看不到授权弹窗)", "scope=snsapi_base" in u1, u1[:150]) + check("带 redirect_uri 且已 URL 编码", "redirect_uri=http%3A%2F%2F" in u1 or "redirect_uri=http://" in u1, + u1[:150]) + check("response_type=code", "response_type=code" in u1, u1[:150]) + + r2 = json_call(f"{base}/api/v1/mp/h5/authorize?scene={scene}") + u2 = (r2.get("data") or {}).get("authorizeUrl") or "" + check("两次的 state 不同(防重放)", ("state=" in u1 and "state=" in u2 and u1 != u2), + "两次返回相同,state 可能是固定的") + + # ── 2. 打桩登录(开发用)───────────────────────────── + print("\n[2] 打桩登录(跳过微信授权,供自动化测试)") + code, headers = redirect_of( + f"{base}/api/v1/mp/h5/stub-login?openid={openid}&scene={scene}") + check("返回 302 重定向", code == 302, f"HTTP {code} headers={list(headers.keys())}") + loc = headers.get("Location", "") + print(f" Location: {loc[:120]}…") + check("回到 H5 应用地址", loc.startswith("http://127.0.0.1:5174"), loc[:80]) + check("**token 在 fragment(#)里,不在 query 里** —— query 会进 Nginx 日志/Referer", + "#" in loc and "token=" in loc.split("#", 1)[1], + f"Location={loc[:140]}") + check("query 部分没有 token", "token=" not in loc.split("#", 1)[0], loc[:140]) + frag = loc.split("#", 1)[1] if "#" in loc else "" + params = dict(p.split("=", 1) for p in frag.split("&") if "=" in p) + token = urllib.parse.unquote(params.get("token", "")) + check("fragment 里带回了 scene(授权回来还要继续用)", + urllib.parse.unquote(params.get("scene", "")) == scene, frag[:100]) + check("token 非空且像 JWT", token.count(".") == 2 and len(token) > 60, f"len={len(token)}") + + # ── 3. token 真的能用 ─────────────────────────────── + print("\n[3] 拿到的 token 真的能过认证") + probe = json_call(f"{base}/api/v1/mp/roster/match", token) # GET 会 405,但认证先过 + check("token 有效(不是 40101 未登录)", probe.get("code") != 40101, + f"code={probe.get('code')} msg={probe.get('msg')}") + + # ── 4. 幂等 ───────────────────────────────────────── + print("\n[4] 同一个 openid 第二次登录") + okk, uid1 = sql("SELECT id FROM sys_user WHERE open_id = '" + openid + "' AND deleted = 0") + code2, headers2 = redirect_of( + f"{base}/api/v1/mp/h5/stub-login?openid={openid}&scene={scene}") + okk2, uid2 = sql("SELECT id FROM sys_user WHERE open_id = '" + openid + "' AND deleted = 0") + check("第二次登录不重复建号(同一个 user id)", + okk and okk2 and uid1.strip() == uid2.strip() and uid1.strip() != "", + f"{uid1.strip()} vs {uid2.strip()}") + okk3, cnt = sql("SELECT count(*) FROM sys_user WHERE open_id = '" + openid + "' AND deleted = 0") + check("库里这个 openid 只有一行", okk3 and cnt.strip() == "1", f"{cnt}") + if uid1.strip().isdigit(): + CREATED_USER_IDS.append(int(uid1.strip())) + okk4, role = sql("SELECT count(*) FROM sys_user_role ur JOIN sys_role r ON r.id = ur.role_id " + f"WHERE ur.user_id = {uid1.strip()} AND r.role_code = 'STUDENT'") + check("已授予学生角色(用户类型由角色推导)", okk4 and role.strip() == "1", f"{role}") + + # ── 5. 没有 scene 时不猜租户 ───────────────────────── + print("\n[5] 没带 scene 时") + code3, headers3 = redirect_of(f"{base}/api/v1/mp/h5/stub-login?openid={openid}") + loc3 = headers3.get("Location", "") + # 打桩路径与真实回调的提示语不同(它明确说"需要带合法的 scene"), + # 所以这里只断言"有错误信息且没有 token",不去比对具体文案 + check("不猜默认租户,而是给出错误提示", "authError" in loc3 and len(loc3) > 20, + f"Location={loc3[:140]}") + check("提示里能看懂是 scene 的问题(不是一句笼统的失败)", + "scene" in urllib.parse.unquote(loc3).lower(), urllib.parse.unquote(loc3)[:120]) + check("这种重定向里没有 token", "token=" not in loc3, loc3[:120]) + + # ── 6. 无效 code 的微信错误要能看懂 ─────────────────── + print("\n[6] 无效 code(真调微信,验证错误翻译)") + code4, headers4 = redirect_of( + f"{base}/api/v1/mp/h5/callback?code=THIS_CODE_IS_FAKE&state=nonexistent") + loc4 = headers4.get("Location", "") + decoded = urllib.parse.unquote(loc4) + print(f" {decoded[:170]}…") + # state 不存在 → scene 为空 → 提示扫码(在换 code 之前就该拦) + check("state 不存在时不往下走(先拦在 scene 缺失)", + "authError" in loc4, f"Location={decoded[:140]}") + + # ── 7. 清理 ───────────────────────────────────────── + print("\n[7] 清理测试数据") + for uid in CREATED_USER_IDS: + sql(f"DELETE FROM sys_user_role WHERE user_id = {uid}") + sql(f"DELETE FROM sys_user WHERE id = {uid}") + ok(f"已清理 {len(CREATED_USER_IDS)} 个测试账号") + + print("\n" + "=" * 72) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + if FAILED: + print("\n失败项:") + for n, d in FAILED: + print(f" · {n}\n {d}") + print("=" * 72) + return 1 if FAILED else 0 + + +if __name__ == "__main__": + raise SystemExit(main())