feat(qrcode): M1-06 填报二维码(接入真实微信小程序)
学生端的入口终于通了:班主任把码发到班级群,学生扫码进填报页。
这条 E2E 是本仓库唯一打外网的测试 —— 真实调微信生成小程序码并取回图片。
交付:
· GET /tasks/{id}/qrcode?type=MINI[&orgId] (§7.12)
· GET /qrcodes/{scene} 匿名图片接口(<img src> 带不了 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 项断言全通过。
This commit is contained in:
@@ -9,11 +9,17 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
* 在 dev 走 {@code application-dev.yml}(开发专用,可入库),
|
||||
* 在 test/prod <b>必须</b>走环境变量,见 {@code application-prod.yml}。
|
||||
*
|
||||
* @param jwt JWT 相关
|
||||
* @param crypto 对称加密与 HMAC 索引
|
||||
* @param rsa 登录密码传输解密
|
||||
* @param security 登录安全策略
|
||||
* @param upload 上传限制
|
||||
* <p>⚠️ {@code mini-program.app-secret} 与上面几个<b>性质不同</b>:
|
||||
* 那几把密钥是我们自己生成的,泄露只影响本系统;而 appsecret 是<b>微信账号的凭据</b>,
|
||||
* 泄露等于别人能以这个小程序的身份调用所有接口(发订阅消息、拿用户信息)。
|
||||
* 生产环境必须走环境变量,且建议为该账号单独申请一套生产用凭据。
|
||||
*
|
||||
* @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。<b>生产必须走环境变量</b>
|
||||
* @param envVersion 生成的小程序码指向哪个版本:{@code release} 正式 /
|
||||
* {@code trial} 体验 / {@code develop} 开发。
|
||||
* <b>开发期要显式设成 trial 或 develop</b> —— 用 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+127
@@ -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)。
|
||||
*
|
||||
* <p>两个接口,权限完全不同,这是刻意的:
|
||||
* <ul>
|
||||
* <li>{@code GET /tasks/{id}/qrcode} —— <b>要登录</b>({@code task:view})。
|
||||
* 它生成/取回码并返回地址,是"管理端要发码"的动作。</li>
|
||||
* <li>{@code GET /qrcodes/{scene}} —— <b>匿名</b>。因为 {@code <img src>}
|
||||
* 带不了 Authorization 头。图片里只有一串场景值(如 {@code t1-k17}),
|
||||
* 不含任何学生信息,公开读的风险可接受;但场景值必须是
|
||||
* "我们已经生成过的"(只从缓存取,不现场调微信),
|
||||
* 这样就挡住了拿这个接口去刷微信额度。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<QrcodeVO> 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.<CollectTaskOrg>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);
|
||||
|
||||
// 这里**主动生成一次**(而不是等前端拉图片时才生成):
|
||||
// 生成失败时能把微信的错误码以接口错误的形式讲清楚;
|
||||
// 若等到 <img> 加载,用户看到的只是一个碎图标,什么信息都没有
|
||||
wxMiniProgramService.getOrCreateCode(scene);
|
||||
|
||||
QrcodeVO vo = new QrcodeVO();
|
||||
vo.setScene(scene);
|
||||
vo.setQrcodeUrl("/api/v1/qrcodes/" + scene);
|
||||
vo.setExpireTime(null);
|
||||
return ApiResult.ok(vo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 二维码图片(匿名)。
|
||||
*
|
||||
* <p>只从缓存取 —— 不在这个匿名接口里现场调微信。否则任何人都能
|
||||
* 用构造出来的 scene 刷爆微信的日调用额度,而额度用完是**全租户的码都生成不出来**。
|
||||
*/
|
||||
@PublicApi
|
||||
@GetMapping("/qrcodes/{scene}")
|
||||
@Operation(summary = "二维码图片(匿名)",
|
||||
description = "因为 <img src> 带不了 Authorization 头,必须匿名。"
|
||||
+ "图片只有一串场景值,不含学生信息;且只服务已生成过的场景")
|
||||
public ResponseEntity<byte[]> 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
/**
|
||||
* 二维码图片地址。
|
||||
*
|
||||
* <p>当前<b>没有接对象存储</b>,图存在 Redis 里、由本服务的
|
||||
* {@code GET /api/v1/qrcodes/{scene}} 提供。配了 {@code public-base-url}
|
||||
* 就是完整 URL,否则是相对路径,由前端用当前域名拼。
|
||||
*/
|
||||
private String qrcodeUrl;
|
||||
|
||||
/**
|
||||
* H5 短链。
|
||||
*
|
||||
* <p>⚠️ <b>当前恒为 {@code null}</b>:点对点短链需要一个短链服务
|
||||
* (发号 + 跳转表 + 域名),而"H5 保底"这条路线本身还没定
|
||||
* (见交接摘要待办 C1)。这里不返回一个"其实很长"的链接冒充短链 ——
|
||||
* 字段名叫 shortUrl 却给一个 80 字符的地址,会让调用方以为可以直接打印。
|
||||
*/
|
||||
private String shortUrl;
|
||||
|
||||
/** 场景值,如 {@code t1-k17} 或带班级的 {@code t1-k17-o30}。 */
|
||||
private String scene;
|
||||
|
||||
/** 过期时间。二维码<b>永久有效</b>,任务截止后扫码会提示"已截止",不做失效。 */
|
||||
private String expireTime;
|
||||
}
|
||||
+301
@@ -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 填报二维码)。
|
||||
*
|
||||
* <p>学生端的入口就是这个码:班主任把码发到班级群,学生扫码进填报页。
|
||||
* 所以它挂了等于"没人能填",可用性要求比其他接口高 —— 三处刻意设计:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>access_token 必须走 Redis 缓存</b>。它 7200 秒有效,而<b>获取接口有日调用限额</b>
|
||||
* (约 2000 次)。每次生成码都取一次 token,几十个班一起用就会撞限额,
|
||||
* 表现是"早上还能用,下午全班的码都生成不出来"。缓存 7000 秒(比官方少 200 秒,
|
||||
* 避免边界上拿到马上过期的 token)。</li>
|
||||
* <li><b>同一个 scene 的图是一样的,必须缓存</b>。生成接口本身也有日限额,
|
||||
* 而一个任务的码会被反复请求(列表页每渲染一次就可能请求一次)。</li>
|
||||
* <li><b>微信的错误码要翻译成人话</b>。原始返回只有一个 {@code errcode},
|
||||
* 而 {@code 40164}(调用方 IP 不在白名单)与 {@code 40001}(appsecret 错)
|
||||
* 是完全不同的排查方向 —— 不翻译的话,运维看到的是"生成二维码失败"。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>用 JDK 的 {@link HttpClient} 而不是 Spring 的 RestClient:
|
||||
* 这个项目的 classpath 上<b>同时存在 Jackson 2 与 Jackson 3</b>
|
||||
* (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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼场景值。
|
||||
*
|
||||
* <p>格式与 M0 开通租户时约定的 {@code miniProgramScene}({@code t{租户id}})
|
||||
* <b>保持同一套前缀语义</b>,便于小程序端用同一个解析器:
|
||||
* <pre>
|
||||
* t{tenantId}-k{taskId} 任务级(不带班级)
|
||||
* t{tenantId}-k{taskId}-o{orgId} 带班级,扫码后直接落到该班
|
||||
* </pre>
|
||||
*
|
||||
* <p>⚠️ 微信限制 scene 最长 <b>32 字符</b>,且只允许
|
||||
* {@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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只读缓存:给匿名的图片接口用。
|
||||
*
|
||||
* <p><b>绝不在这里调微信</b>。这是匿名接口,若允许"传什么 scene 就现生成什么",
|
||||
* 任何人构造一批 scene 就能把当天的调用额度刷完 ——
|
||||
* 而额度用完的表现是<b>全租户的码都生成不出来</b>,等于整个填报入口瘫痪。
|
||||
*
|
||||
* <p>缓存键与 {@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<String, Object> 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<byte[]> 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<String, Object> 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<String, Object> getJson(String url) {
|
||||
HttpResponse<byte[]> resp = send(url, null);
|
||||
return JSONUtil.toBean(new String(resp.body(), StandardCharsets.UTF_8), Map.class);
|
||||
}
|
||||
|
||||
private HttpResponse<byte[]> 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 翻译成能直接照着做的提示。
|
||||
*
|
||||
* <p>不翻译的话只有一个数字,而 {@code 40164}(调用方 IP 不在白名单)与
|
||||
* {@code 40001}(appsecret 错)是完全不同的两个排查方向。
|
||||
*/
|
||||
private BizException wxError(String action, Map<String, Object> r) {
|
||||
int code = r.get("errcode") instanceof Number n ? n.intValue() : -1;
|
||||
String msg = str(r.get("errmsg"));
|
||||
String hint = switch (code) {
|
||||
case 40001, 40125 -> "appsecret 不对(检查 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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+58
-1
@@ -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 的前端范围。当前可通过接口取码 |
|
||||
|
||||
---
|
||||
|
||||
**关键实现决策(不要在后继开发中改回去)**
|
||||
|
||||
| 决策 | 理由 |
|
||||
|
||||
@@ -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("匿名可访问(<img src> 带不了 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)
|
||||
Reference in New Issue
Block a user