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)。 + * + *
两个接口,权限完全不同,这是刻意的: + *
只从缓存取 —— 不在这个匿名接口里现场调微信。否则任何人都能
+ * 用构造出来的 scene 刷爆微信的日调用额度,而额度用完是**全租户的码都生成不出来**。
+ */
+ @PublicApi
+ @GetMapping("/qrcodes/{scene}")
+ @Operation(summary = "二维码图片(匿名)",
+ description = "因为 当前没有接对象存储,图存在 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 填报二维码)。
+ *
+ * 学生端的入口就是这个码:班主任把码发到班级群,学生扫码进填报页。
+ * 所以它挂了等于"没人能填",可用性要求比其他接口高 —— 三处刻意设计:
+ *
+ * 用 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}})
+ * 保持同一套前缀语义,便于小程序端用同一个解析器:
+ * ⚠️ 微信限制 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 不翻译的话只有一个数字,而 {@code 40164}(调用方 IP 不在白名单)与
+ * {@code 40001}(appsecret 错)是完全不同的两个排查方向。
+ */
+ private BizException wxError(String action, Map 带不了 Authorization 头,必须匿名。"
+ + "图片只有一串场景值,不含学生信息;且只服务已生成过的场景")
+ public ResponseEntity
+ *
+ *
+ *
+ * t{tenantId}-k{taskId} 任务级(不带班级)
+ * t{tenantId}-k{taskId}-o{orgId} 带班级,扫码后直接落到该班
+ *
+ *
+ * 带不了 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)