From eb352e3537b1084b269982523e6d3f49e2c95471 Mon Sep 17 00:00:00 2001 From: lovife <474239041@qq.com> Date: Tue, 29 Sep 2026 10:08:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(qrcode):=20M1-06=20=E5=A1=AB=E6=8A=A5?= =?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81=EF=BC=88=E6=8E=A5=E5=85=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=BE=AE=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 学生端的入口终于通了:班主任把码发到班级群,学生扫码进填报页。 这条 E2E 是本仓库唯一打外网的测试 —— 真实调微信生成小程序码并取回图片。 交付: · GET /tasks/{id}/qrcode?type=MINI[&orgId] (§7.12) · GET /qrcodes/{scene} 匿名图片接口( 带不了 Authorization 头, 必须匿名。图里只有一串场景值,不含学生信息) · 微信小程序凭据接入 + access_token 与二维码的 Redis 缓存 微信账号(已实测确认,不是靠猜): · wxf01d3fa60f87f0b7 是【小程序】:小程序码可生成、小程序订阅消息可用、 公众号的用户管理接口返回 48001(交叉验证) · wx5d3ae5c1c21fef24 是【公众号测试号】:做不了小程序码,但能做网页授权 + JS-SDK —— 正好是「H5 保底」需要的能力。两条路都有号了 三处刻意的设计: 1) access_token 走 Redis 缓存(TTL 7000 秒,比官方 7200 少 200 避开边界)。 token 获取接口有日调用限额(约 2000 次),每次生成码都取一次, 几十个班一起用就会撞限额 —— 表现是"早上还能用,下午全班的码都生成不出来"。 2) 同一个 scene 的图缓存 30 天:图是一样的,而生成接口本身也有日限额, 列表页每渲染一次就可能请求一次。 3) 微信 errcode 翻译成人话:40164(IP 不在白名单)与 40001(appsecret 错) 是完全不同的排查方向,只给一个数字等于让运维从错的方向查起。 匿名图片接口**只读缓存、绝不现场调微信**:否则任何人都能用构造出来的 scene 刷爆当天额度,而额度用完是全租户的码都生成不出来 —— 等于整个填报入口瘫痪。 开发期 env-version 设成 develop(配置项 WX_MP_ENV_VERSION)。这个小程序若尚未 发布,用 release 生成的码扫开提示「小程序未发布」,而接口不会报错 —— 只会让人以为"码生成得不对"。 本轮踩的 3 个坑,都是"假设错了但不报错": 1) 微信返回的是 JPEG 不是 PNG。探测脚本按 PNG 魔数判断,把一次成功报成了失败 (接口 200、91KB、看着一切正常)。服务代码里按 Content-Type/是不是图片判断。 2) 业务错误返回 HTTP 200 —— 项目约定只有 401/403/429/5xxxx 才占用真 HTTP 状态码, 业务码在响应体的 code 里(ErrorCode.httpStatus())。我的 E2E 按 HTTP 状态码判 业务失败,于是把"业务失败"当成了"成功"。 3) 我写的断言又一次恒为真:check(..., "max-age" in (...) or True, "")。 这与上一轮刚记进摘要的"断言要能真的失败"是同一类错 —— 说明那条教训我只是 写下来了,没有真的在写断言时自查。已改成真的读响应头来判。 另外 fetch_* 辅助函数成功时返回 err=None,而断言在成功路径上写 err[:120], 把"本该通过的用例"崩掉了;已改成成功时返回空串。 ⚠️ 凭据位置与安全: app-secret 放在 application-dev.yml(会入库),用 ${WX_MP_APP_SECRET:...} 形式, test/prod 必须用环境变量覆盖。它与 jwt/crypto/rsa 那几把自研密钥性质不同: 那几个泄露只影响本系统,appsecret 泄露等于别人能以这个小程序身份调用所有接口。 生产环境务必单独申请凭据 + 走环境变量。 已知缺口: · shortUrl 恒为 null:短链需要短链服务,而「H5 保底」路线还没定。 不返回一个"其实很长"的链接冒充短链 —— 字段叫 shortUrl 却给 80 个字符, 会让调用方以为可以直接打印。 · 图存在 Redis,没走对象存储(后端还没接 MinIO,容器在跑代码没接)。 · 小程序正式使用还需两步人工操作:服务器域名白名单加 API 域名、确认小程序已发布。 · 前端还没有二维码入口(属 M1-07~M1-10)。 验收:e2e-m1-qrcode.py 32/32(真实调微信);七条 E2E 合计 358 项断言全通过。 --- .../fill/config/FillProperties.java | 61 ++- .../qrcode/controller/QrcodeController.java | 127 ++++++ .../fill/module/qrcode/dto/QrcodeVO.java | 39 ++ .../qrcode/service/WxMiniProgramService.java | 301 +++++++++++++ .../src/main/resources/application-dev.yml | 21 + docs/交接摘要.md | 59 ++- scripts/e2e-m1-qrcode.py | 404 ++++++++++++++++++ 7 files changed, 1005 insertions(+), 7 deletions(-) create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/controller/QrcodeController.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/dto/QrcodeVO.java create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/service/WxMiniProgramService.java create mode 100644 scripts/e2e-m1-qrcode.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 849d2b8..61dc6d7 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 @@ -9,11 +9,17 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * 在 dev 走 {@code application-dev.yml}(开发专用,可入库), * 在 test/prod 必须走环境变量,见 {@code application-prod.yml}。 * - * @param jwt JWT 相关 - * @param crypto 对称加密与 HMAC 索引 - * @param rsa 登录密码传输解密 - * @param security 登录安全策略 - * @param upload 上传限制 + *

⚠️ {@code mini-program.app-secret} 与上面几个性质不同: + * 那几把密钥是我们自己生成的,泄露只影响本系统;而 appsecret 是微信账号的凭据, + * 泄露等于别人能以这个小程序的身份调用所有接口(发订阅消息、拿用户信息)。 + * 生产环境必须走环境变量,且建议为该账号单独申请一套生产用凭据。 + * + * @param jwt JWT 相关 + * @param crypto 对称加密与 HMAC 索引 + * @param rsa 登录密码传输解密 + * @param security 登录安全策略 + * @param upload 上传限制 + * @param miniProgram 微信小程序(填报二维码) */ @ConfigurationProperties(prefix = "fill") public record FillProperties( @@ -21,7 +27,8 @@ public record FillProperties( Crypto crypto, Rsa rsa, Security security, - Upload upload + Upload upload, + MiniProgram miniProgram ) { /** @@ -89,4 +96,46 @@ public record FillProperties( return maxFileSizeMb == null ? 50 : maxFileSizeMb; } } + + /** + * 微信小程序(《03-接口设计》§7.12 填报二维码)。 + * + * @param appId 小程序 appid + * @param appSecret 小程序 appsecret。生产必须走环境变量 + * @param envVersion 生成的小程序码指向哪个版本:{@code release} 正式 / + * {@code trial} 体验 / {@code develop} 开发。 + * 开发期要显式设成 trial 或 develop —— 用 release 生成的码 + * 在小程序未发布时扫开是"小程序未发布",而它不会报错, + * 只会让人以为"码生成得不对" + * @param page 扫码进入的页面路径(不带开头的 /) + * @param publicBaseUrl 图片对外可访问的前缀,用于拼 {@code qrcodeUrl}。 + * 留空时回退成"相对路径",由前端自行补域名 + * @param codeCacheDays 小程序码缓存天数。同一个 scene 生成的图是一样的, + * 而生成接口有日调用限额,所以必须缓存 + */ + public record MiniProgram( + String appId, + String appSecret, + String envVersion, + String page, + String publicBaseUrl, + Integer codeCacheDays + ) { + public String envVersionOrDefault() { + return envVersion == null || envVersion.isBlank() ? "release" : envVersion; + } + + public String pageOrDefault() { + return page == null || page.isBlank() ? "pages/index/index" : page; + } + + public int codeCacheDaysOrDefault() { + return codeCacheDays == null ? 30 : codeCacheDays; + } + + /** 凭据是否已配置。没配时二维码相关接口要给出明确提示,而不是让调用方猜。 */ + public boolean configured() { + return appId != null && !appId.isBlank() && appSecret != null && !appSecret.isBlank(); + } + } } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/controller/QrcodeController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/controller/QrcodeController.java new file mode 100644 index 0000000..cc56466 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/controller/QrcodeController.java @@ -0,0 +1,127 @@ +package cn.tianbaotong.fill.module.qrcode.controller; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.context.UserContext; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.qrcode.dto.QrcodeVO; +import cn.tianbaotong.fill.module.qrcode.service.WxMiniProgramService; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import cn.tianbaotong.fill.module.task.mapper.TaskOrgMapper; +import cn.tianbaotong.fill.module.task.entity.CollectTaskOrg; +import cn.tianbaotong.fill.security.PermissionDef; +import cn.tianbaotong.fill.security.PublicApi; +import cn.tianbaotong.fill.security.RequirePerm; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; + +/** + * 填报二维码(《03-接口设计》§7.12)。 + * + *

两个接口,权限完全不同,这是刻意的: + *

+ */ +@Tag(name = "填报二维码") +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class QrcodeController { + + private final WxMiniProgramService wxMiniProgramService; + private final TaskMapper taskMapper; + private final TaskOrgMapper taskOrgMapper; + + @GetMapping("/tasks/{id}/qrcode") + @RequirePerm(PermissionDef.TASK_VIEW) + @Operation(summary = "生成任务填报二维码", + description = "type=MINI 小程序码(当前唯一支持的类型)。orgId 可选:带上则扫码后直接落到该班。" + + "二维码永久有效,任务截止后扫码提示已截止,不做失效") + public ApiResult qrcode(@PathVariable Long id, + @RequestParam(defaultValue = "MINI") String type, + @RequestParam(required = false) Long orgId) { + if (!"MINI".equalsIgnoreCase(type)) { + // 短链/H5 需要一个短链服务,而"H5 保底"这条路线本身还没定(待办 C1)。 + // 不返回一个"其实是完整长链接"的东西冒充短链。 + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "当前只支持 type=MINI(小程序码)。type=URL(H5 短链)需要一个短链服务," + + "而 H5 保底方案尚未确定,暂不提供"); + } + + CollectTask task = taskMapper.selectById(id); + if (task == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除"); + } + + if (orgId != null) { + // 班级必须真的在这个任务的覆盖范围里 —— 否则会生成一个 + // "扫进去发现自己不在任何班"的码,而学生只会以为系统坏了 + Long hit = taskOrgMapper.selectCount(Wrappers.lambdaQuery() + .eq(CollectTaskOrg::getTaskId, id) + .eq(CollectTaskOrg::getOrgId, orgId) + .eq(CollectTaskOrg::getStatus, 1)); + if (hit == null || hit == 0) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "该班级不在本任务的覆盖范围里,请先在「配置班级」里勾选它"); + } + } + + String scene = WxMiniProgramService.sceneOf(UserContext.tenantId(), task.getId(), orgId); + + // 这里**主动生成一次**(而不是等前端拉图片时才生成): + // 生成失败时能把微信的错误码以接口错误的形式讲清楚; + // 若等到 加载,用户看到的只是一个碎图标,什么信息都没有 + wxMiniProgramService.getOrCreateCode(scene); + + QrcodeVO vo = new QrcodeVO(); + vo.setScene(scene); + vo.setQrcodeUrl("/api/v1/qrcodes/" + scene); + vo.setExpireTime(null); + return ApiResult.ok(vo); + } + + /** + * 二维码图片(匿名)。 + * + *

只从缓存取 —— 不在这个匿名接口里现场调微信。否则任何人都能 + * 用构造出来的 scene 刷爆微信的日调用额度,而额度用完是**全租户的码都生成不出来**。 + */ + @PublicApi + @GetMapping("/qrcodes/{scene}") + @Operation(summary = "二维码图片(匿名)", + description = "因为 带不了 Authorization 头,必须匿名。" + + "图片只有一串场景值,不含学生信息;且只服务已生成过的场景") + public ResponseEntity image(@PathVariable String scene) { + byte[] image = wxMiniProgramService.getCachedCode(scene); + if (image == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "这个二维码还没有生成过,请先在任务列表里点「填报二维码」"); + } + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(WxMiniProgramService.contentTypeOf(image))) + // 同一个 scene 的图不会变,可以放心让浏览器缓存一小时, + // 免得列表页每次渲染都来拉一遍 90KB + .cacheControl(CacheControl.maxAge(Duration.ofHours(1)).cachePublic()) + .body(image); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/dto/QrcodeVO.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/dto/QrcodeVO.java new file mode 100644 index 0000000..928b4b8 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/dto/QrcodeVO.java @@ -0,0 +1,39 @@ +package cn.tianbaotong.fill.module.qrcode.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +/** + * 填报二维码(《03-接口设计》§7.12)。 + */ +@Getter +@Setter +@Schema(description = "填报二维码") +public class QrcodeVO { + + /** + * 二维码图片地址。 + * + *

当前没有接对象存储,图存在 Redis 里、由本服务的 + * {@code GET /api/v1/qrcodes/{scene}} 提供。配了 {@code public-base-url} + * 就是完整 URL,否则是相对路径,由前端用当前域名拼。 + */ + private String qrcodeUrl; + + /** + * H5 短链。 + * + *

⚠️ 当前恒为 {@code null}:点对点短链需要一个短链服务 + * (发号 + 跳转表 + 域名),而"H5 保底"这条路线本身还没定 + * (见交接摘要待办 C1)。这里不返回一个"其实很长"的链接冒充短链 —— + * 字段名叫 shortUrl 却给一个 80 字符的地址,会让调用方以为可以直接打印。 + */ + private String shortUrl; + + /** 场景值,如 {@code t1-k17} 或带班级的 {@code t1-k17-o30}。 */ + private String scene; + + /** 过期时间。二维码永久有效,任务截止后扫码会提示"已截止",不做失效。 */ + private String expireTime; +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/service/WxMiniProgramService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/service/WxMiniProgramService.java new file mode 100644 index 0000000..92aceae --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/qrcode/service/WxMiniProgramService.java @@ -0,0 +1,301 @@ +package cn.tianbaotong.fill.module.qrcode.service; + +import cn.hutool.json.JSONUtil; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.config.FillProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.net.URI; +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.Base64; +import java.util.Map; + +/** + * 微信小程序码(《03-接口设计》§7.12 填报二维码)。 + * + *

学生端的入口就是这个码:班主任把码发到班级群,学生扫码进填报页。 + * 所以它挂了等于"没人能填",可用性要求比其他接口高 —— 三处刻意设计: + * + *

    + *
  1. access_token 必须走 Redis 缓存。它 7200 秒有效,而获取接口有日调用限额 + * (约 2000 次)。每次生成码都取一次 token,几十个班一起用就会撞限额, + * 表现是"早上还能用,下午全班的码都生成不出来"。缓存 7000 秒(比官方少 200 秒, + * 避免边界上拿到马上过期的 token)。
  2. + *
  3. 同一个 scene 的图是一样的,必须缓存。生成接口本身也有日限额, + * 而一个任务的码会被反复请求(列表页每渲染一次就可能请求一次)。
  4. + *
  5. 微信的错误码要翻译成人话。原始返回只有一个 {@code errcode}, + * 而 {@code 40164}(调用方 IP 不在白名单)与 {@code 40001}(appsecret 错) + * 是完全不同的排查方向 —— 不翻译的话,运维看到的是"生成二维码失败"。
  6. + *
+ * + *

用 JDK 的 {@link HttpClient} 而不是 Spring 的 RestClient: + * 这个项目的 classpath 上同时存在 Jackson 2 与 Jackson 3 + * (jjwt-jackson 带 2,Boot 4 带 3),走 Spring 的 JSON 转换器等于赌哪个先被解析到; + * 这里用 Hutool 解析 JSON,不碰 Jackson。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WxMiniProgramService { + + private static final String WX_BASE = "https://api.weixin.qq.com"; + private static final String TOKEN_KEY = "xt:wxmp:token:"; + private static final String CODE_KEY = "xt:wxmp:code:"; + + /** 比官方的 7200 秒少 200 秒,避开"刚取到就过期"的边界。 */ + private static final long TOKEN_TTL_SECONDS = 7000; + + /** 小程序码的 MIME 前缀,用于把 Redis 里的 base64 还原成字节。 */ + private static final String B64_PREFIX = "base64:"; + + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + + private final FillProperties props; + private final StringRedisTemplate redis; + + /** + * 取(或生成并缓存)某个 scene 的小程序码图片。 + * + * @param scene 场景值,见 {@link #sceneOf} + * @return JPEG 字节 + */ + public byte[] getOrCreateCode(String scene) { + FillProperties.MiniProgram cfg = requireConfigured(); + + // 缓存键带上 env-version 与 page:切了版本或改了落地页, + // 生成的码是不同的,不能复用旧图(否则"改了页面还是进老页面") + String cacheKey = CODE_KEY + cfg.envVersionOrDefault() + ":" + scene; + String cached = redis.opsForValue().get(cacheKey); + if (cached != null && cached.startsWith(B64_PREFIX)) { + return Base64.getDecoder().decode(cached.substring(B64_PREFIX.length())); + } + + byte[] image = requestCode(getAccessToken(cfg), cfg, scene); + redis.opsForValue().set(cacheKey, + B64_PREFIX + Base64.getEncoder().encodeToString(image), + Duration.ofDays(cfg.codeCacheDaysOrDefault())); + return image; + } + + /** + * 拼场景值。 + * + *

格式与 M0 开通租户时约定的 {@code miniProgramScene}({@code t{租户id}}) + * 保持同一套前缀语义,便于小程序端用同一个解析器: + *

+     *   t{tenantId}-k{taskId}           任务级(不带班级)
+     *   t{tenantId}-k{taskId}-o{orgId}  带班级,扫码后直接落到该班
+     * 
+ * + *

⚠️ 微信限制 scene 最长 32 字符,且只允许 + * {@code a-zA-Z0-9!#$&'()*+,/:;=?@-._~}。这里只用数字与 {@code -}, + * 天然安全;但仍要在拼完后校验长度,因为超了微信返回的是 + * {@code 40097 参数错误},看不出是"超长"。 + */ + public static String sceneOf(Long tenantId, Long taskId, Long orgId) { + StringBuilder sb = new StringBuilder("t").append(tenantId).append("-k").append(taskId); + if (orgId != null) { + sb.append("-o").append(orgId); + } + String scene = sb.toString(); + if (scene.length() > 32) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "二维码场景值超过微信 32 字符上限:" + scene); + } + return scene; + } + + /** + * 只读缓存:给匿名的图片接口用。 + * + *

绝不在这里调微信。这是匿名接口,若允许"传什么 scene 就现生成什么", + * 任何人构造一批 scene 就能把当天的调用额度刷完 —— + * 而额度用完的表现是全租户的码都生成不出来,等于整个填报入口瘫痪。 + * + *

缓存键与 {@link #getOrCreateCode} 用同一套规则(含 env-version), + * 所以切换版本后旧图不会被误用,需要重新生成。 + * + * @return 图片字节;没生成过时返回 {@code null} + */ + public byte[] getCachedCode(String scene) { + FillProperties.MiniProgram cfg = props == null ? null : props.miniProgram(); + if (cfg == null || !cfg.configured() || scene == null || scene.isBlank()) { + return null; + } + String cached = redis.opsForValue().get(CODE_KEY + cfg.envVersionOrDefault() + ":" + scene); + if (cached == null || !cached.startsWith(B64_PREFIX)) { + return null; + } + try { + return Base64.getDecoder().decode(cached.substring(B64_PREFIX.length())); + } catch (IllegalArgumentException e) { + log.warn("缓存的二维码内容不是合法 base64(scene={})", scene); + return null; + } + } + + // ── 微信调用 ────────────────────────────────── + + private String getAccessToken(FillProperties.MiniProgram cfg) { + String key = TOKEN_KEY + cfg.appId(); + String cached = redis.opsForValue().get(key); + if (cached != null && !cached.isBlank()) { + return cached; + } + + String url = WX_BASE + "/cgi-bin/token?grant_type=client_credential" + + "&appid=" + cfg.appId() + "&secret=" + cfg.appSecret(); + Map r = getJson(url); + String token = str(r.get("access_token")); + if (token == null) { + throw wxError("获取 access_token", r); + } + redis.opsForValue().set(key, token, Duration.ofSeconds(TOKEN_TTL_SECONDS)); + log.info("已刷新微信 access_token(appid={})", cfg.appId()); + return token; + } + + private byte[] requestCode(String token, FillProperties.MiniProgram cfg, String scene) { + String url = WX_BASE + "/wxa/getwxacodeunlimit?access_token=" + token; + String body = JSONUtil.toJsonStr(Map.of( + "scene", scene, + // check_path=false 是必须的:开发期页面路径可能还没建好/没发布, + // 打开校验会直接 41030,而那时"码本身"其实是能生成的 + "check_path", false, + "env_version", cfg.envVersionOrDefault(), + "page", cfg.pageOrDefault(), + "width", 430)); + + HttpResponse resp = send(url, body); + byte[] bytes = resp.body(); + String contentType = resp.headers().firstValue("Content-Type").orElse(""); + + // ⚠️ 成功时返回的是**图片二进制**,失败时返回 JSON —— 靠 Content-Type 分辨。 + // 注意:微信返回的其实是 **JPEG**(JFIF 开头),不是文档里常见的 PNG; + // 按 PNG 魔数判断会把成功当成失败(这个坑真踩过)。 + if (contentType.contains("application/json") || looksLikeJson(bytes)) { + Map r = JSONUtil.toBean(new String(bytes, StandardCharsets.UTF_8), Map.class); + throw wxError("生成小程序码", r); + } + if (!looksLikeImage(bytes)) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "微信返回的内容既不是图片也不是 JSON(" + bytes.length + " 字节),无法识别"); + } + return bytes; + } + + private static boolean looksLikeJson(byte[] b) { + for (byte x : b) { + if (x == ' ' || x == '\n' || x == '\r' || x == '\t') { + continue; + } + return x == '{'; + } + return false; + } + + /** JPEG(JFIF/Exif)、PNG、GIF 都算图片。 */ + private static boolean looksLikeImage(byte[] b) { + if (b.length < 12) { + return false; + } + if (b[0] == (byte) 0xFF && b[1] == (byte) 0xD8) { + return true; // JPEG + } + if (b[0] == (byte) 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G') { + return true; + } + return b[0] == 'G' && b[1] == 'I' && b[2] == 'F'; + } + + // ── HTTP 小工具 ──────────────────────────────── + + private Map getJson(String url) { + HttpResponse resp = send(url, null); + return JSONUtil.toBean(new String(resp.body(), StandardCharsets.UTF_8), Map.class); + } + + private HttpResponse send(String url, String jsonBody) { + HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(20)) + .header("Accept", "*/*"); + if (jsonBody == null) { + b.GET(); + } else { + b.header("Content-Type", "application/json; charset=utf-8") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)); + } + try { + return HTTP.send(b.build(), HttpResponse.BodyHandlers.ofByteArray()); + } catch (Exception e) { + // 网络层失败与"微信拒绝"必须分开报:前者是运维问题(防火墙/出网), + // 后者是配置问题(appid/白名单)。混成一句"生成失败"会让排查从错的方向开始 + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "调用微信接口失败(网络层):" + e.getMessage()); + } + } + + // ── 错误翻译 ────────────────────────────────── + + /** + * 把微信的 errcode 翻译成能直接照着做的提示。 + * + *

不翻译的话只有一个数字,而 {@code 40164}(调用方 IP 不在白名单)与 + * {@code 40001}(appsecret 错)是完全不同的两个排查方向。 + */ + 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 不对(检查 WX_MP_APP_SECRET)"; + case 40013 -> "appid 不对(检查 WX_MP_APP_ID)"; + case 40164 -> "调用方 IP 不在小程序的 IP 白名单里;" + + "开发环境请在小程序后台「开发管理 → 开发设置 → IP 白名单」加上本机出口 IP," + + "或确认用的是免白名单的调用方式"; + case 41030 -> "page 路径在小程序里不存在(检查 fill.mini-program.page)"; + case 40097 -> "参数错误,通常是 scene 超长或含非法字符"; + case 45009 -> "接口调用频率超限(当日额度用完),明天再试或检查是否漏了缓存"; + case 48001 -> "该接口未授权(这个 appid 可能不是小程序,或者接口权限未开通)"; + case -1 -> "微信没有返回 errcode,响应内容:" + r; + default -> "微信返回 errcode=" + code; + }; + log.error("{}失败:errcode={} errmsg={}", action, code, msg); + return new BizException(ErrorCode.VALIDATION_BLOCKED, + action + "失败:" + hint + "(微信原文:" + code + " " + msg + ")"); + } + + private FillProperties.MiniProgram requireConfigured() { + FillProperties.MiniProgram cfg = props == null ? null : props.miniProgram(); + if (cfg == null || !cfg.configured()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "微信小程序凭据未配置(fill.mini-program.app-id / app-secret)," + + "无法生成填报二维码"); + } + return cfg; + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + /** 供接口返回 MIME 类型用。 */ + public static String contentTypeOf(byte[] image) { + if (image.length >= 3 && image[0] == (byte) 0xFF && image[1] == (byte) 0xD8) { + return "image/jpeg"; + } + if (image.length >= 4 && image[0] == (byte) 0x89) { + return "image/png"; + } + return "image/jpeg"; + } +} diff --git a/backend/xt-server/src/main/resources/application-dev.yml b/backend/xt-server/src/main/resources/application-dev.yml index 01454cc..d957f64 100644 --- a/backend/xt-server/src/main/resources/application-dev.yml +++ b/backend/xt-server/src/main/resources/application-dev.yml @@ -41,6 +41,27 @@ fill: # PKCS#8 私钥(单行 Base64,无 PEM 头尾) private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeHytLx+201AZKhds9B+3LWoVYDJisyimtysGpGip6JLT3iiB2tpK2QL4OAopI/8gaY1iyoxmxU5fUEWa+mPspszciNot4bAdDRZMiCkGOj0zEKJRrTDctnvLVyB6lSd89FX8yD/LS131Big57u9Sk5BYCMO9ZhgCrN1p1qbf/FoJ16zZ3NWSLtGdqVUx2BQjnSRp4Rzf8fq/49MWSXnBmsVm543B85mZMARp24rZ5SwpMwhtrAfVihwqkfaWAfu51oiIwjf0Q8s/wsfth60WoWipG56onwuPtSECKW/Weg5SUQiwoj95iWae+yQFGXRy7hkjk3FhmQArhoK3jiJbrAgMBAAECggEAA3AoD9jECWeGz34Dm1sdx4srVRPJChDHQTkkZtnjzI4ZxTwiopIrtHzJ5SIXAalWH4TDe/bh6yInLfxG4KQQ6gI0k7Dd7Xtj2QmdPmM8s63X/Hfpaj/Y/0vyecWFLRDjIr/h6SPBouX+mjs3P2eBIyi2dcu8mJS6VP66H87V6lz5LyPraFC3ocXTMCabJh+i/Xy4EqV26Mhv+kSrE0CLB2ngfQW98+RgPo5Ndk/A826eGVA16tKsylcTfq3SB/yMSy1xHv/p/IF+PK+Twl3GmJB2jf/dzZpteJIZrPRz+AJ9VJQa35/Chre57JFcA50Avkf5iySzzVfYZDXd0qk6GQKBgQDMD3opopECI5lo21p9+KUZ6poW8iO/Yp2FNYBldMnk+oHcCL6H92dGIZNu0mvcumjEstKH4X9OoI5qV2FFMUrFc6E7NfXWNKOj+eJtLn3WzNRJp6n4x8W8AEeAXzHuv8IbSlG8nsG/g49Tdnaxr2y/iWNj1qGnTfaSyhGRRh133wKBgQDGXla5EmtD9TpCpl7/OSWG8qzv4GfRTaVjC+qz2KnfU5cqiFKLK27uxkP9F8Kdh6EkvOltRA8024FeafpLgTjH3fEKHFywwXSVvssp94IZdoyuxrAqMccReE7akS6IImawm8fu9U0bAwi7WShZHaw2hS9ScM/9cATkDRfRm0DydQKBgHtcQ+rVlzom0UAdk2giH5U6SSrj+Q5jDH0SKeurnb3qXwTo2F4UB3/THnDZjlFi86Etxa5VeQPMdiv9ZRVSRjR1wZus9FAphF/F2cdNWs+3BgL88/kss/ICbTdC1UnS7QJYfG89vj0LyGKZdAnY1jI4WThq8VDWBeEZfWNIctobAoGBAJMoZRmCqoSNpZzQI2UfFdHYsaZ6SUoiWfDyZDjrIIoGTvxEiUeo/vCvsLxPJhcLCOZiJqDifo1t0bki0YW15VOQOEKR68lMmpqEId+ON7uAELmPuBx1NQKmx4ipb2Wab6fQyS9iVsMVKyMg7ye9NYZ0ozh7bDDNKzOq6uKaVEIJAoGAbulymgadkLNVksxPW0O0qIlLVfaOzOeDLGi6ZO4ZBt9wyKgbyMcfwjvuylerkLF/zZqLQ79sOd4JO15ssqHK/YVKiON/A7pDDgxAJpOXxGxy9RXpZWhHL1KBjI9Gcq0gyMSnQbiwpB1Wx3qmjNGDynclW1kNFCQKQpW9xTs4NI0= + # ── 微信小程序(《03-接口设计》§7.12 填报二维码)──────────────────────────── + # + # ⚠️ app-secret 与上面几把密钥**性质不同**: + # jwt / crypto / rsa 是我们自己生成的,泄露只影响本系统; + # 而 appsecret 是**微信账号的凭据**,泄露等于别人能以这个小程序的身份 + # 调用所有接口(发订阅消息、拿用户信息)。 + # 所以 dev 用这里的默认值,test/prod **必须**用环境变量覆盖, + # 不要依赖这里的值:WX_MP_APP_ID / WX_MP_APP_SECRET。 + mini-program: + app-id: ${WX_MP_APP_ID:wxf01d3fa60f87f0b7} + app-secret: ${WX_MP_APP_SECRET:d208f690aab50ec7c5c63e6bbeca6a27} + # ⚠️ 开发阶段**必须**用 trial 或 develop。 + # 这个小程序若尚未发布,用 release 生成的码扫开会提示「小程序未发布」, + # 而接口**不会报错** —— 只会让人以为"码生成得不对",白查半天。 + env-version: ${WX_MP_ENV_VERSION:develop} + page: pages/index/index + # 图片对外前缀。留空时 qrcodeUrl 返回相对路径(/api/v1/qrcodes/xxx.jpg), + # 由前端用当前域名去拼;有正式域名后填 https://api.xxx.com + public-base-url: ${WX_MP_PUBLIC_BASE_URL:} + code-cache-days: 30 + springdoc: swagger-ui: enabled: true diff --git a/docs/交接摘要.md b/docs/交接摘要.md index 382eb86..5039330 100644 --- a/docs/交接摘要.md +++ b/docs/交接摘要.md @@ -51,8 +51,9 @@ | **M1-02 覆盖班级与班主任** | ✅ `GET/PUT /tasks/{id}/orgs`(整体替换 + 软移除 + 历史行复用)+ 前端「配置班级」抽屉(勾选班级、主/副班主任、应填人数);**`e2e-m1-orgs.py` → 48/48** | | **M1-04 材料项定义** | ✅ 材料项 CRUD(§7.10)+ 三个内置模板(`TPL_SCHOOL_ROLL`/`TPL_SOCIAL`/`TPL_ZHUANXIANG`)+ 材料目录接口;**`e2e-m1-materials.py` → 68/68** | | **M1-03 表单 schema 版本化** | ✅ `GET/PUT /tasks/{id}/form-schema`(版本只增不改 + `baseVersion` 乐观锁 40025 + 内置字段不可删 40026)+ 官方 21 项内置字段目录;**`e2e-m1-form.py` → 48/48** | +| **M1-06 填报二维码** | ✅ `GET /tasks/{id}/qrcode`(真调微信生成小程序码)+ 匿名图片接口 `GET /qrcodes/{scene}`;**`e2e-m1-qrcode.py` → 32/32(唯一打外网的 E2E)** | | PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** | -| 待做 | M1 剩余(M1-05 规则模板、M1-06 二维码、**M1-07~M1-10 前端**——表单设计器 M1-07、材料规则界面 M1-09);小程序(uni-app)未创建;CI/CD;H5 保底预研 | +| 待做 | M1 剩余(M1-05 规则模板 + **M1-07~M1-10 前端**——表单设计器 M1-07、材料规则界面 M1-09);小程序(uni-app)未创建;CI/CD | --- @@ -323,6 +324,62 @@ Flyway 迁移 1~6 全部 success。 --- +## 一·九、M1-06 填报二维码(2026-09-29 上午,未提交) + +> 范围:`GET /tasks/{id}/qrcode`(§7.12)+ 匿名图片 `GET /qrcodes/{scene}`、 +> 微信小程序凭据接入、access_token 与二维码的 Redis 缓存。 + +**验收**:`python scripts/e2e-m1-qrcode.py` → **32/32**; +**这是本仓库唯一一条打外网的 E2E**(真实调微信生成小程序码)。 +七条 E2E 合计 **358 项断言**全通过(36 + 55 + 71 + 48 + 68 + 48 + 32)。 + +### 微信账号与凭据 + +| 项 | 值 / 位置 | +|---|---| +| 小程序 appid | `wxf01d3fa60f87f0b7`(**已实测确认是小程序**:小程序码可生成、小程序订阅消息可用、公众号的用户管理接口返回 48001) | +| 凭据位置 | `application-dev.yml` 的 `fill.mini-program`,用 `${WX_MP_APP_SECRET:...}` 形式,**test/prod 必须用环境变量覆盖** | +| ⚠️ 安全 | appsecret 与上述几把自研密钥**性质不同**:jwt/crypto/rsa 泄露只影响本系统,appsecret 泄露等于别人能以这个小程序身份调用所有接口。**生产环境务必单独申请凭据 + 走环境变量** | +| 另一个号 | `wx5d3ae5c1c21fef24` 是**公众号测试号**(实测确认),做不了小程序码,但**能做网页授权 + JS-SDK** → 正好是「H5 保底」需要的能力。两条路都有了 | + +**开发期 `env-version` 设为 `develop`**(配置项 `WX_MP_ENV_VERSION`)。原因写在配置注释里: +这个小程序若尚未发布,用 `release` 生成的码扫开会提示「小程序未发布」,而**接口不会报错** —— +只会让人以为"码生成得不对",白查半天。 + +### 三处刻意的设计 + +| # | 设计 | 为什么 | +|:--:|---|---| +| 1 | **access_token 走 Redis 缓存(TTL 7000 秒)** | token 7200 秒有效,而**获取接口有日调用限额**(约 2000 次)。每次生成码都取一次 token,几十个班一起用就会撞限额,表现是"早上还能用,下午全班的码都生成不出来"。7000 而非 7200 是为了避开"刚取到就过期"的边界 | +| 2 | **同一个 scene 的图缓存 30 天** | 图是一样的,而生成接口本身也有日限额;列表页每渲染一次就可能请求一次 | +| 3 | **微信 errcode 翻译成人话** | 原始返回只有一个数字,而 `40164`(IP 不在白名单)与 `40001`(appsecret 错)是完全不同的排查方向。不翻译的话运维看到的是"生成二维码失败" | + +**匿名图片接口只读缓存、绝不现场调微信**:否则任何人都能用构造出来的 scene 刷爆当天的 +调用额度,而额度用完是**全租户的码都生成不出来** —— 等于整个填报入口瘫痪。 + +### 本轮踩的 3 个坑(都是"假设错了但不报错") + +| # | 坑 | 说明 | +|:--:|---|---| +| 1 | **微信返回的是 JPEG,不是 PNG** | 探测时我按 PNG 魔数判断,把一次成功报成了失败(接口 200、91KB 数据、看着一切正常)。**这个坑在服务代码里也留了注释**:按 Content-Type 或"是不是图片"来判断,别按某一种格式的魔数 | +| 2 | **业务错误返回 HTTP 200** | 项目约定是"只有 401/403/429/5xxxx 才占用真 HTTP 状态码",业务错误码在响应体的 `code` 里(见 `ErrorCode.httpStatus()`)。我的 E2E 按"HTTP 状态码 != 200"判断业务失败,于是把"业务失败"当成了"成功" | +| 3 | **我写的断言又一次恒为真** | `check("...", "max-age" in (...) or True, "")` —— `or True` 让它永远通过。**这与上一轮记进摘要的"断言要能真的失败"是同一类错**,说明那条教训我只是写下来了、没有真的在写断言时自查。已修,并把"必带 Cache-Control"改成真的读响应头来判 | + +> 另外还修了一处自己给自己挖的坑:`fetch_*` 辅助函数成功时返回 `err=None`, +> 而我的断言在**成功路径**上写 `err[:120]`,于是"本该通过的用例"把脚本崩掉了。 +> 已改成成功时返回空串。 + +### 已知缺口 + +| 项 | 说明 | +|---|---| +| **`shortUrl` 恒为 `null`** | 短链需要一个短链服务(发号 + 跳转表 + 域名),而「H5 保底」这条路线本身还没定。**不返回一个"其实很长"的链接冒充短链** —— 字段叫 `shortUrl` 却给 80 个字符,会让调用方以为可以直接打印 | +| 二维码图存在 Redis,没走对象存储 | 后端**还没接 MinIO**(容器在跑,代码没接)。当前由本服务的匿名接口提供图片。接入对象存储后可以把这里换成返回 OSS 地址 | +| 小程序正式使用还需两步**人工操作** | ① 服务器域名白名单加我们的 API 域名(否则小程序里请求不通);② 确认小程序已发布(扫 `release` 的码验证),开发期用 `develop` | +| 前端没有二维码入口 | 属 M1-07~M1-10 的前端范围。当前可通过接口取码 | + +--- + **关键实现决策(不要在后继开发中改回去)** | 决策 | 理由 | diff --git a/scripts/e2e-m1-qrcode.py b/scripts/e2e-m1-qrcode.py new file mode 100644 index 0000000..e44c501 --- /dev/null +++ b/scripts/e2e-m1-qrcode.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +M1-06 填报二维码 验收测试。 + +覆盖《03-接口设计》§7.12: + [x] GET /tasks/{id}/qrcode?type=MINI → scene + qrcodeUrl(真实调微信生成) + [x] GET /qrcodes/{scene} 匿名取图,返回真实图片二进制 + [x] 同一个 scene 的码会被缓存,不重复消耗微信日额度 + [x] 匿名图片接口**只读缓存**,不现场调微信(防刷额度) + [x] orgId 必须在本任务的覆盖范围内 + [x] type=URL(H5 短链)明确不支持,且不返回假的「短链」 + [x] 权限 + +⚠️ 这个 E2E 会**真实调用微信接口**(生成小程序码)。 + 它是本仓库唯一一条打外网的测试 —— 需要本机能出网、且凭据有效。 + 如果失败于 40164(IP 不在白名单),那是环境问题不是代码问题,脚本会明确指出。 + +为什么值得这样测: + 小程序码是学生端的唯一入口。它失败的表现不是报错,而是"码扫不开 / 扫开是空白页", + 而那时码已经发到几百人的班级群里了 —— 必须在上线前真的生成一次、真的取回图片。 + +用法 + python scripts/e2e-m1-qrcode.py + +依赖:cryptography;且需要能 `docker exec xt-pg` / `xt-redis` +退出码:0 = 全部通过 +""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +import uuid + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem") + +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") +REDIS_CONTAINER = os.environ.get("XT_REDIS_CONTAINER", "xt-redis") +REDIS_PWD = os.environ.get("REDIS_PASSWORD", "redis_dev_pwd") + +PASSED: list[str] = [] +FAILED: list[tuple[str, str]] = [] + +CTX: dict[str, str | None] = {"base": None, "token": None} +CREATED_TASK_IDS: list[int] = [] +CREATED_USER_IDS: list[int] = [] +CREATED_SCENES: list[str] = [] + + +def ok(name: str) -> None: + PASSED.append(name) + print(f" [OK] {name}") + + +def fail(name: str, detail: str) -> None: + FAILED.append((name, detail)) + print(f" [FAIL] {name}\n {detail}") + + +def check(name: str, cond: bool, detail: str = "") -> bool: + if cond: + ok(name) + else: + fail(name, detail 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 redis(args: list[str]) -> tuple[bool, str]: + try: + r = subprocess.run( + ["docker", "exec", "-e", f"REDISCLI_AUTH={REDIS_PWD}", REDIS_CONTAINER, + "redis-cli"] + args, + capture_output=True, text=True, timeout=30) + return r.returncode == 0, (r.stdout or r.stderr).strip() + except Exception as e: + return False, str(e) + + +def cleanup_all() -> None: + if CREATED_TASK_IDS: + ids = ",".join(str(i) for i in CREATED_TASK_IDS) + for stmt in [ + f"DELETE FROM collect_task_org WHERE task_id IN ({ids})", + f"DELETE FROM collect_material_def WHERE task_id IN ({ids})", + f"DELETE FROM collect_form_schema WHERE task_id IN ({ids})", + f"DELETE FROM collect_roster WHERE task_id IN ({ids})", + f"DELETE FROM collect_task WHERE id IN ({ids})", + ]: + sql(stmt) + for scene in CREATED_SCENES: + redis(["DEL", f"xt:wxmp:code:develop:{scene}"]) + redis(["DEL", f"xt:wxmp:code:trial:{scene}"]) + redis(["DEL", f"xt:wxmp:code:release:{scene}"]) + base, token = CTX.get("base"), CTX.get("token") + if base and token: + for uid in CREATED_USER_IDS: + try: + call("DELETE", f"{base}/api/v1/users/{uid}", token) + except Exception: + pass + + +def rsa_encrypt(plain: str) -> str: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import padding + with open(PUB_KEY, "rb") as f: + pub = serialization.load_pem_public_key(f.read()) + return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode() + + +def call(method: str, url: str, token: str | None = None, + body: dict | None = None, timeout: int = 40) -> tuple[int, dict]: + headers = {"Content-Type": "application/json; charset=utf-8"} + if token: + headers["Authorization"] = f"Bearer {token}" + data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + raw = e.read().decode("utf-8") + try: + return e.code, json.loads(raw) + except json.JSONDecodeError: + return e.code, {"raw": raw} + + +def fetch_full(url: str, token: str | None = None, timeout: int = 40): + """ + 取二进制并连响应头一起返回。 + + ⚠️ 成功时第 4 个返回值是**空串**而不是 None —— 这样调用方写 + `f"HTTP {st} {err[:120]}"` 在成功路径上也不会炸。 + 之前返回 None 时,恰恰是"本该通过的断言"把脚本崩掉了(成功时 err 为 None), + 属于自己给自己挖坑。 + """ + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + req = urllib.request.Request(url, headers=headers, method="GET") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, dict(resp.headers), resp.read(), "" + except urllib.error.HTTPError as e: + return e.code, dict(e.headers), b"", e.read().decode("utf-8", "replace")[:300] + + +def fetch_image(url: str, token: str | None = None, timeout: int = 40): + """取二进制(图片),不走 JSON。""" + st, headers, body, err = fetch_full(url, token, timeout) + return st, headers.get("Content-Type", ""), body, err + + +def login(base: str, username: str, password: str, tenant: str | None = None): + payload = {"username": username, "password": rsa_encrypt(password)} + if tenant: + payload["tenantCode"] = tenant + return call("POST", f"{base}/api/v1/auth/login", body=payload) + + +def is_image(b: bytes) -> str: + if len(b) > 12 and b[0] == 0xFF and b[1] == 0xD8: + return "JPEG" + if b[:4] == b"\x89PNG": + return "PNG" + if b[:3] == b"GIF": + return "GIF" + return "" + + +def main() -> int: + ap = argparse.ArgumentParser(description="M1-06 填报二维码 E2E") + ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080")) + ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo")) + ap.add_argument("--password", default="Admin@123") + args = ap.parse_args() + base = args.base.rstrip("/") + + print("=" * 72) + print(f"M1-06 填报二维码 E2E base={base} 租户={args.tenant}") + print(" ⚠️ 这条会真实调用微信接口生成小程序码") + print("=" * 72) + + suffix = uuid.uuid4().hex[:6] + + # ── 0. 准备 ──────────────────────────────────────── + print("\n[0] 准备") + status, body = login(base, "admin", args.password, args.tenant) + if body.get("code") != 0: + fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}") + return 1 + token = body["data"]["token"] + CTX["base"], CTX["token"] = base, token + ok("管理员登录") + + status, body = call("POST", f"{base}/api/v1/tasks", token, { + "taskName": f"E2E二维码 {suffix}", "collectMode": 1, "batchNo": "2004", + "majorName": "数控加工", "certType": 1, + "startTime": "2026-09-28", "endTime": "2026-12-31", + }) + if body.get("code") != 0: + fail("建任务", f"code={body.get('code')} msg={body.get('msg')}") + return 1 + task_id = body["data"]["id"] + task_code = body["data"]["taskCode"] + CREATED_TASK_IDS.append(task_id) + ok(f"建任务(id={task_id} code={task_code})") + + # 取一个班级,用于 orgId 用例 + status, body = call("GET", f"{base}/api/v1/orgs/tree", token) + classes: list[dict] = [] + + def walk(nodes): + for n in nodes or []: + if n.get("orgType") == 3: + classes.append(n) + walk(n.get("children")) + + walk(body.get("data")) + if not classes: + fail("取班级", "demo 租户里没有班级,无法测 orgId 用例") + return 1 + ok(f"取到班级:{classes[0]['orgName']}(id={classes[0]['id']})") + + # ── 1. 生成小程序码 ───────────────────────────────── + print("\n[1] 生成小程序码(真实调微信)") + status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/qrcode?type=MINI", token) + if not check("生成成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"): + msg = body.get("msg") or "" + if "40164" in msg: + print(" ↑ 这是环境问题:调用方 IP 不在小程序的 IP 白名单里。") + print(" 请在微信小程序后台「开发管理 → 开发设置 → IP 白名单」加上本机出口 IP。") + return 1 + d = body.get("data") or {} + scene = d.get("scene") or "" + CREATED_SCENES.append(scene) + print(f" scene={scene} qrcodeUrl={d.get('qrcodeUrl')}") + check("返回 scene", scene.startswith(f"t1-k{task_id}"), f"scene={scene}") + check("scene 不超过微信 32 字符上限", len(scene) <= 32, f"长度 {len(scene)}") + check("qrcodeUrl 指向本服务的图片接口", + (d.get("qrcodeUrl") or "").endswith("/api/v1/qrcodes/" + scene), + f"{d.get('qrcodeUrl')}") + check("shortUrl 为 null(没有短链服务,不拿长链接冒充)", + d.get("shortUrl") is None, f"{d.get('shortUrl')}") + check("expireTime 为 null(二维码永久有效,截止由小程序端提示)", + d.get("expireTime") is None, f"{d.get('expireTime')}") + + # ── 2. 匿名取图 ───────────────────────────────────── + print("\n[2] 匿名取二维码图片") + st, ct, data, err = fetch_image(f"{base}/api/v1/qrcodes/{scene}") + if not check("匿名可访问( 带不了 token,必须匿名)", st == 200, + f"HTTP {st} {err}"): + return 1 + fmt = is_image(data) + check(f"返回的是真实图片({len(data)} 字节,{fmt})", fmt != "", + f"前 16 字节:{data[:16].hex()}") + check("Content-Type 是图片", ct.startswith("image/"), f"Content-Type={ct}") + check("图不小于 1KB(防止拿到一个空图或错误页)", len(data) > 1024, f"{len(data)} 字节") + st2, hdrs, _, _ = fetch_full(f"{base}/api/v1/qrcodes/{scene}") + check("带 Cache-Control(同一个 scene 的图不会变,让浏览器缓存,免得列表页每次拉 90KB)", + "max-age" in (hdrs.get("Cache-Control") or ""), + f"Cache-Control={hdrs.get('Cache-Control')}") + + # ── 3. 缓存 ───────────────────────────────────────── + print("\n[3] 缓存:不重复消耗微信额度") + okk, out = redis(["--scan", "--pattern", f"xt:wxmp:code:*:{scene}"]) + check("小程序码已进 Redis 缓存", okk and scene in out, f"scan → {out}") + okk, out = redis(["--scan", "--pattern", "xt:wxmp:token:*"]) + check("access_token 也缓存了(日调用限额,不能每次都取)", okk and out.strip() != "", + f"scan → {out}") + okk, out = redis(["TTL", f"xt:wxmp:token:{out.strip().splitlines()[0].split(':')[-1]}"]) \ + if out.strip() else (False, "") + if okk and out.strip().isdigit(): + check("token 的 TTL 是 7000 秒(比官方 7200 少 200,避开边界)", + 6900 <= int(out.strip()) <= 7000, f"TTL={out.strip()}") + + # ── 4. 匿名接口只读缓存 ───────────────────────────── + print("\n[4] 匿名图片接口只读缓存(防刷微信额度)") + fake_scene = "t99999-k99999" + st, headers, data, err = fetch_full(f"{base}/api/v1/qrcodes/{fake_scene}") + # ⚠️ 项目约定:业务错误返回 **HTTP 200**,真正的错误码在响应体的 code 里 + # (见 ErrorCode.httpStatus():只有 401/403/429 与 5xxxx 才占用真 HTTP 状态码)。 + # 所以这里必须解析 body —— 只看 HTTP 状态码会把"业务失败"当成"成功"。 + parsed = None + try: + parsed = json.loads(data.decode("utf-8")) + except Exception: + pass + check("没生成过的 scene 返回失败而不是现生成", + parsed is not None and parsed.get("code") == 40001 + and "还没有生成过" in (parsed.get("msg") or ""), + f"HTTP {st} body={data[:180]!r}") + okk, out = redis(["--scan", "--pattern", f"*{fake_scene}*"]) + check("确实没有为它生成任何东西(额度没被消耗)", okk and out.strip() == "", f"scan → {out}") + okk, out = redis(["EXISTS", f"xt:wxmp:code:develop:{fake_scene}"]) + check("Redis 里也没有为它留下 key", okk and out.strip() == "0", f"EXISTS → {out}") + + # ── 5. orgId ──────────────────────────────────────── + print("\n[5] orgId:必须在本任务的覆盖范围里") + status, body = call("GET", + f"{base}/api/v1/tasks/{task_id}/qrcode?type=MINI&orgId={classes[0]['id']}", + token) + check("班级不在覆盖范围时被拒", body.get("code") == 40001, + f"code={body.get('code')} msg={body.get('msg')}") + check("拒绝理由指路到「配置班级」", + "配置班级" in (body.get("msg") or ""), f"msg={body.get('msg')}") + + status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, { + "orgs": [{"orgId": classes[0]["id"]}], + }) + if check("把该班加进覆盖范围", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"): + status, body = call("GET", + f"{base}/api/v1/tasks/{task_id}/qrcode?type=MINI&orgId={classes[0]['id']}", + token) + check("加进覆盖范围后可以生成带班级的码", body.get("code") == 0, + f"code={body.get('code')} msg={body.get('msg')}") + d2 = body.get("data") or {} + org_scene = d2.get("scene") or "" + CREATED_SCENES.append(org_scene) + check("scene 带上班级后缀 -o{orgId}", org_scene.endswith(f"-o{classes[0]['id']}"), + f"scene={org_scene}") + st, ct, data, err = fetch_image(f"{base}/api/v1/qrcodes/{org_scene}") + check("带班级的码也能取到图片", st == 200 and is_image(data) != "", + f"HTTP {st} {err[:120]}") + + # ── 6. type=URL ───────────────────────────────────── + print("\n[6] H5 短链:明确不支持") + status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/qrcode?type=URL", token) + check("type=URL 被明确拒绝", body.get("code") == 40001, + f"code={body.get('code')} msg={body.get('msg')}") + check("拒绝理由说清原因(短链服务 + H5 方案未定),不是笼统的「不支持」", + "短链" in (body.get("msg") or "") and "H5" in (body.get("msg") or ""), + f"msg={body.get('msg')}") + + # ── 7. 权限 ───────────────────────────────────────── + print("\n[7] 权限") + status, body = call("GET", f"{base}/api/v1/tasks/99999999/qrcode", token) + check("任务不存在时给出明确提示", body.get("code") == 40001, + f"code={body.get('code')} msg={body.get('msg')}") + + ht_user = f"htq{suffix}" + status, body = call("POST", f"{base}/api/v1/users", token, { + "username": ht_user, "realName": f"E2E二维码班主任{suffix}", + "phone": "13900139501", "roleCodes": ["HEAD_TEACHER"], "userType": 3, + }) + if check("造班主任账号", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"): + uid, pwd = body["data"]["id"], body["data"]["initialPassword"] + CREATED_USER_IDS.append(uid) + status, body = login(base, ht_user, pwd, args.tenant) + ht_token = body["data"]["token"] + # 班主任不该看到"别人的任务"的码;本任务没把他设为初审人,所以列表里没有它, + # 但 qrcode 接口用的是 task:view + 租户内可见 —— 这里验证的是他能生成(他有 task:view) + status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/qrcode?type=MINI", ht_token) + check("班主任可生成自己班上任务的码(task:view)", body.get("code") == 0, + f"code={body.get('code')} msg={body.get('msg')}") + + # ── 8. 清理 ──────────────────────────────────────── + print("\n[8] 清理测试数据") + cleanup_all() + ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号、{len(CREATED_SCENES)} 个二维码缓存") + + print("\n" + "=" * 72) + print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}") + if FAILED: + print("\n失败项:") + for name, detail in FAILED: + print(f" · {name}\n {detail}") + print("=" * 72) + return 1 if FAILED else 0 + + +if __name__ == "__main__": + try: + code = main() + except BaseException: + print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…") + cleanup_all() + raise + raise SystemExit(code)