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
|
||||
|
||||
Reference in New Issue
Block a user