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:
2026-09-29 10:08:02 +08:00
parent 870c388287
commit eb352e3537
7 changed files with 1005 additions and 7 deletions
@@ -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();
}
}
}
@@ -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;
}
@@ -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