做小程序开发的人应该都会碰到这个需求:把某个页面变成一个二维码,放在海报、易拉宝、产品包装上,用户扫码直接进小程序。微信这边其实早就提供了现成接口,Java 后端只需要配好 AppID 和 AppSecret,用 HTTP 调一次接口,再把返回的图片字节流处理好,就能在 Spring Boot 项目里稳定输出所谓的“永久小程序码”。
这个需求看着简单,真做起来坑其实不少:access_token 要不要缓存、scene 参数只有 32 个可见字符怎么塞业务参数、page 路径要不要带斜杠、接口返回的到底是图片还是 JSON、check_path 怎么设、生成请求用错方法导致图片数据被转字符串损坏……我在几个项目里来回踩过,今天就把 Spring Boot + Java 调用微信接口生成永久小程序码的完整过程拆开讲清楚。手里已经有一个能跑的小程序后端、正要接二维码生成能力的同学,可以直接照着做。
1. 先搞懂“永久小程序码”和普通二维码到底差在哪
很多刚接触这个需求的同学,第一反应是在网上随便找个二维码生成工具,输入一个链接让它转成二维码。但这条路在小程序里走不通,原因在于微信根本不识别普通 URL 二维码里的链接跳转到小程序。微信生态里有自己的一套码体系:小程序码。
1.1 圆形码、方形码和普通 URL 码的区别
普通 URL 二维码,本质是编码了一段网址,任何扫码工具(微信、支付宝、浏览器内置扫码)扫到之后,都会去访问这个网址。小程序码不一样,它编码的是“打开某个小程序的某个页面”这个指令,只有微信扫一扫能识别,扫码后直接拉起小程序并进入指定页面。所以线下物料要放小程序入口,必须用微信官方接口生成的码。
微信官方提供了两种形态的码:
- 方形小程序二维码:看起来就是标准的黑白 QR Code,通过
createwxaqrcode接口生成。 - 圆形小程序码:就是大家常见的那种周围一圈圆点、中间一个小图标的码,通过
getwxacode和getwxacodeunlimit两个接口生成。
实际业务里,圆形小程序码出现频率更高,视觉上更像“微信官方背书”的东西,而且它还支持一个普通二维码做不到的能力:自定义 scene 参数。这也是实现“扫同一个码,不同用户进来看到不同内容”核心玩法的基础。
1.2 三个候选接口怎么选:为什么主力是用 getwxacodeunlimit
微信接口文档里其实给了三个生成入口,为了避免选错,我直接对照一下:
| 接口 | 码样式 | 是否支持 scene 参数 | page 要求 | 适用场景 |
|---|---|---|---|---|
| createwxaqrcode | 方形二维码 | 不支持,参数拼在 path 里 | 已发布页面 | 码量少的简单场景 |
| getwxacode | 圆形小程序码 | 不支持,参数拼在 path 里 | 已发布页面 | 码量较少、参数简单的场景 |
| getwxacodeunlimit | 圆形小程序码 | 支持 scene 参数 | 按 env_version 区分开发版/体验版/正式版 | 长期使用、大量生成、带业务参数的场景 |
项目标题里强调“永久小程序码”,这个“永久”一般指两个含义:一是图片本身长期有效,不会过期;二是业务上需要长期反复使用同一批码。这两种需求的答案基本都是getwxacodeunlimit。
原因也很直接:
getwxacode和createwxaqrcode生成的码有数量上限,官方描述是“适用于需要的码数量较少的业务场景”。getwxacodeunlimit虽然名字里带 unlimit,它真正厉害的地方是支持 scene 参数,同一个 page 路径可以搭配无数个不同的 scene,生成无数张功能不同的码。getwxacodeunlimit可以通过 env_version 参数指定生成开发版、体验版还是正式版的码,开发调试阶段不用等页面发布就能先扫起来。
所以我的结论很简单:除非有特殊限制,否则一律走getwxacodeunlimit,下面所有代码也都是基于这个接口写。
2. 调用前的准备工作:AppID、AppSecret 与 access_token
调微信接口之前,先得把凭证问题解决。微信开放平台接口的鉴权方式是 access_token,几乎所有小程序接口都要带它。这里的第一步不是写代码,而是去小程序后台把配置拿齐。
2.1 小程序后台要确认的三件事
第一,AppID 和 AppSecret。在小程序后台的“开发管理 - 开发设置”页面可以看到 AppID,AppSecret 需要自己生成,而且生成后只会完整显示一次,一定要复制保存好。这个 Secret 是调用 access_token 接口的关键凭证,泄露给无关人员等于把小程序接口权限交出去了。
第二,IP 白名单。access_token 接口对调用方的服务器 IP 有校验,需要在后台把生产环境出口 IP 加进白名单,否则调用会直接报错。本地调试时也得把本地公网 IP 加进去,不然你在家里连不上接口。
第三,确认小程序账号有相应接口权限。getwxacodeunlimit属于小程序基础能力,正常个人主体、企业主体的小程序都能用,但如果小程序类目特殊,或接口被限制,会报 48001 之类的权限错误,这种一般要去后台“功能设置”里检查。
2.2 获取 access_token 的代码实现
access_token 的获取地址是微信的通用接口:
https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET正常返回的 JSON 长这样:
{ "access_token": "60_xxxxx", "expires_in": 7200 }为了方便 Spring Boot 项目里统一管理配置,我用@ConfigurationProperties绑定一个小程序配置类,配置项放到 application.yml 里。
先建一个配置类:
@Data @Component @ConfigurationProperties(prefix = "wechat") public class WechatProperties { private String miniAppId; private String miniAppSecret; }然后在 application.yml 里对应填上:
wechat: mini-app-id: wx1234567890abcdef mini-app-secret: 1234567890abcdef1234567890abcdef接下来是获取 access_token 的核心方法。注意,这里不能每次请求都去微信换一个 token,原因后面单独讲,先看实现:
@Service public class WechatMiniService { private static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"; private static final String WXACODE_UNLIMIT_URL = "https://api.weixin.qq.com/wxa/getwxacodeunlimit"; private final WechatProperties wechatProperties; private String accessToken; private long tokenExpireTime; public WechatMiniService(WechatProperties wechatProperties) { this.wechatProperties = wechatProperties; } public synchronized String getAccessToken() { if (accessToken != null && System.currentTimeMillis() < tokenExpireTime) { return accessToken; } String url = TOKEN_URL + "?grant_type=client_credential" + "&appid=" + wechatProperties.getMiniAppId() + "&secret=" + wechatProperties.getMiniAppSecret(); String response = HttpUtil.get(url); JSONObject json = JSONUtil.parseObj(response); String errCode = json.getStr("errcode"); if (StrUtil.isNotBlank(errCode) && !"0".equals(errCode)) { throw new RuntimeException("获取 access_token 失败:" + response); } accessToken = json.getStr("access_token"); // 缓存时间留 200 秒余量,防止边界情况 tokenExpireTime = System.currentTimeMillis() + (json.getLong("expires_in", 7200L) - 200) * 1000; return accessToken; } }2.3 为什么必须缓存 access_token
微信的 access_token 有效期是 7200 秒,也就是两个小时,过期后要重新获取。听起来不复杂,但真正的问题是获取接口本身有调用次数限制,官方给的额度是每天 2000 次。
如果不缓存,每次生成小程序码都先去换一次 token,生产环境请求量大一点,2000 次额度很快就刷光了,然后整个系统所有依赖 token 的接口全部报 40001。更麻烦的是 token 是全局唯一的,如果项目里有多个服务各自去获取,新的 token 会覆盖旧的,导致其他服务手里的 token 突然失效。
所以缓存策略是必须的。我上面的写法是本地内存缓存,加 synchronized 防止并发重复获取,适用于单机部署。如果项目是多个实例部署,建议把 access_token 放到 Redis 里共享,用 appid 做 key,value 存 token,过期时间设置为 7000 秒,所有实例共用同一个 token。
3. Spring Boot 核心实现:一次完整调用拆解
准备工作做完,接下来就是核心环节:封装生成永久小程序码的服务方法。这里我分成几步讲,每一步都有讲究,尤其是“接口返回的是图片还是 JSON”的判断,这是最容易翻车的地方。
3.1 最小依赖:Spring Boot Web + Hutool
HTTP 调用微信接口,我习惯直接用 Hutool 的 HttpRequest 系列,简洁、依赖干净,不用手动拼 URL 编码和响应体解析。需要确认 pom.xml 里有这几个依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.24</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>3.2 生成永久小程序码的核心方法
在刚才的WechatMiniService里继续加方法。这里要用 Hutool 的HttpRequest而不是HttpUtil.post,原因我在下一节单独说,先看正确写法:
public byte[] createWxCode(String scene, String page, Integer width) { if (StrUtil.isBlank(scene)) { throw new IllegalArgumentException("scene 不能为空"); } String token = getAccessToken(); JSONObject params = new JSONObject(); params.set("scene", scene); if (StrUtil.isNotBlank(page)) { params.set("page", page); } params.set("width", width == null ? 430 : width); params.set("check_path", false); params.set("env_version", "release"); HttpResponse response = HttpRequest.post(WXACODE_UNLIMIT_URL + "?access_token=" + token) .header("Content-Type", "application/json;charset=UTF-8") .body(params.toString()) .execute(); String contentType = response.header("Content-Type"); if (StrUtil.isNotBlank(contentType) && contentType.contains("image")) { return response.bodyBytes(); } throw new RuntimeException("生成小程序码失败:" + response.body()); }这里说几个关键点:
- scene 是必填的,page 可以省略。如果 page 不传,微信会默认打开小程序主页。
- width 控制码的尺寸,合法范围是 280 到 1280,默认 430,单位是像素。
- check_path 我设成 false,这样开发阶段即使页面还没发布也能生成码。但这个参数不是随便设的,后面会再提醒。
- env_version 有三个值:release(正式版)、trial(体验版)、develop(开发版)。生产环境一定是 release。
3.3 为什么不能用 HttpUtil.post 直接拿字符串
这是这个需求里最典型的坑,我在第一版实现时踩过。
很多同学看到 Hutool 有HttpUtil.post(url, paramStr),顺手就用了,然后拿返回的 String 再getBytes()转成字节数组,结果图片打不开,或者前端拿到的 base64 就是坏的。
原因在于,微信小程序码接口成功时返回的不是 JSON,而是图片二进制流(image/jpeg)。HttpUtil.post方法内部会把 HTTP 响应体按字符串读取,二进制图片数据在转码过程中已经被破坏,你再怎么getBytes()也还原不出原始字节。
正确姿势是用HttpRequest拿到HttpResponse对象,然后用bodyBytes()直接取二进制字节流。这也提醒我们一件事:判断接口是否成功,不能先无脑把响应体当字符串读,而是先看响应头的 Content-Type。
3.4 图片怎么返回给前端:三种方式对比
拿到byte[]之后,如何把这个图片交给前端或存起来,实际项目里无非三种做法。
第一种,后端直接返回图片流。Spring MVC 里用ResponseEntity<byte[]>返回,设置 Content-Type 为image/jpeg,浏览器或小程序的wx.request({ responseType: 'arraybuffer' })可以直接接收。优点是简单,缺点是前端需要自己处理二进制到图片的转换。
@RestController @RequestMapping("/api/qrcode") public class QrCodeController { private final WechatMiniService wechatMiniService; public QrCodeController(WechatMiniService wechatMiniService) { this.wechatMiniService = wechatMiniService; } @GetMapping("/wxacode") public ResponseEntity<byte[]> wxacode( @RequestParam String scene, @RequestParam(required = false, defaultValue = "pages/index/index") String page, @RequestParam(required = false, defaultValue = "430") Integer width) { byte[] image = wechatMiniService.createWxCode(scene, page, width); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.IMAGE_JPEG); headers.setContentLength(image.length); return new ResponseEntity<>(image, headers, HttpStatus.OK); } }第二种,转成 Base64 字符串返回给前端。前端拿到后直接丢给<image src="data:image/jpeg;base64,xxx" />或小程序background-image用。这种方式对前端最友好,适合后台管理系统这种场景,缺点是体积比原图大约三分之一。
String base64 = Base64.getEncoder().encodeToString(image);第三种,保存到 OSS/CDN,返回图片 URL。这是线下物料、海报、长期业务页面的最佳选择。二维码生成一次,后续所有地方复用同一个 URL,不用反复调微信接口。我通常的做法是:字节流保存到 OSS,再把 URL 入库,下次同一业务码直接读库返回 URL。
4. scene 和 page 参数设计:永久码的“数据载体”
代码能跑通只是第一步。实际业务里,真正考验的是参数怎么设计。这一步设计得好,以后扩展业务少折腾很多。
4.1 scene 的 32 字符限制怎么破
getwxacodeunlimit的 scene 参数最大长度是 32 个可见字符,只看表面这个限制还好,但业务需求往往是“一个码要承载一堆信息”。比如我要生成一个商品推广码,希望扫码后知道是哪个商品、哪个渠道、哪个活动批次,如果直接拼productId=123&channel=wx&batch=88,很快就超过 32 字符了。
正确的做法不是硬怼参数长度,而是“短码映射”。scene 里只放一个紧凑的短标识,真正的业务参数扔到后端缓存或数据库里。比如:
A1001后端维护一张映射表:A1001对应{productId: 123, channel: 'wx', batch: 88}。前端扫码后拿到 scene 值A1001,再调后端接口查真实参数。
这样做还有个额外好处:如果物料已经印刷出去了,但是活动渠道、批次信息要改,只需要改数据库里的映射,不需要重印二维码。码没变,背后的业务含义可以随时变。
另外,scene 参数里如果要传中文,必须先 URL 编码,而且我记得微信那边返回给小程序端的时候是经过编码的,小程序端要做一次 decodeURIComponent。为了避免这套麻烦,我习惯直接规定 scene 里只允许数字、字母和少量安全字符,业务参数全部走映射,省心很多。
4.2 page 路径的正确写法
page 参数指的是小程序内页面路径,比如pages/goods/detail。
这里有三个细节项目里最容易出错:
- 不能带开头的
/。写pages/goods/detail是对的,写/pages/goods/detail可能报错或扫不出来。 - 不能带 query 参数。页面的业务参数只能放 scene 里,不能在 page 后面拼
?id=1,这个一定要注意。 - page 对应的页面必须是已存在路径。
check_path=false只是跳过微信端的路径校验,如果路径本身是瞎编的,码也能生成,但用户扫码后小程序会提示页面不存在。所以我在生成接口里做了一层白名单校验,page 只有在小程序真实存在的页面列表里才允许传。
4.3 小程序端如何接收 scene 参数
后端生成码只是链路的一半,另一半在小程序前端。用户扫码进入小程序后,页面onLoad(options)里的options.scene能拿到 scene 值,但这里有一个特别容易忽略的点:微信可能对 scene 做了编码,稳妥的做法是解码一次。
Page({ onLoad(options) { const scene = decodeURIComponent(options.scene || '') // scene = 'A1001' // 用 scene 去后端换真实参数 this.querySceneInfo(scene) } })如果 scene 内容是短码映射,小程序端拿到后调后端接口换取完整参数,再渲染页面。这一套链路下来,同一个 page 路径可以承载无数种不同的活动内容和商品信息,全部靠 scene 驱动。
5. 实战避坑:错误码与排查速查
这部分是实战价值的核心。我在接入过程中遇到的接口报错,基本都是集中在这么几个错误码上,整理成速查表方便排查。
| 错误码 | 含义 | 常见原因与处理 |
|---|---|---|
| 40001 | access_token 无效或过期 | token 被其他服务覆盖、本地缓存时间太长、IP 白名单没配,重新获取并检查缓存逻辑 |
| 40013 | 无效的 appid | 检查 application.yml 配置,是不是把别的应用的 AppID 填进来了 |
| 40125 | 无效的 appsecret | Secret 复制时多了空格,或者填的是重置前的旧值 |
| 41030 | page 路径不存在 | 页面路径写错、带了/前缀、页面未发布且 check_path=true |
| 41033 | 参数不合法 | 最常见是 scene 超过 32 字符,或 width 不在 280-1280 范围内 |
| 45009 | 接口调用超过限额 | 触发了频率限制,需要给已生成的码做缓存,减少重复调用 |
| 48001 | api 功能未授权 | 小程序类目或账号权限问题,去后台确认是否能用该接口 |
除了错误码,还有几个实践里很常见但不报错的诡异问题,单独说一下。
第一个是图片被转字符串损坏,前面已经讲过,不再重复。
第二个是 access_token 突然失效,但不是过期。常见场景是,开发环境、测试环境、生产环境用了同一个 AppID,各自都去调用 token 接口。由于 token 是全局唯一的,后获取的一方会替换掉先获取的一方,导致另一边所有请求突然 40001。解决办法是环境隔离,不同环境用不同小程序账号,或者所有环境统一走同一个 token 管理服务。
第三个是check_path=true在页面未发布时的报错。我建议开发阶段用check_path=false,但上线前一定要把真实页面路径测一遍,防止生成一堆打不开的脏码。
第四个是调用频率问题。虽然getwxacodeunlimit名称里带 unlimit,但不代表可以无限刷接口。微信对小程序码生成接口仍然有频率限额,一旦触达就会报 45009。我在代码里做了一个结果缓存:以 scene + page 作为 key,把生成的字节流或 URL 缓存起来,相同参数直接复用,绝不重复调微信。
这里给一个简单的缓存思路,实际项目里可以换成 Redis:
private final Map<String, byte[]> wxCodeCache = new ConcurrentHashMap<>(); public byte[] createWxCodeWithCache(String scene, String page, Integer width) { String key = page + ":" + scene; if (wxCodeCache.containsKey(key)) { return wxCodeCache.get(key); } byte[] bytes = createWxCode(scene, page, width); wxCodeCache.put(key, bytes); return bytes; }6. 实际业务落地建议:批量生成、物料准备与防刷
代码调试通过之后,真正上线前还有几件容易忽略的事。做完这几步,整个生成链路才算真正“能落地”。
6.1 提前批量生成并归档
如果用户扫码入口是线下易拉宝、产品包装、展会物料,那就不能等用户来扫的时候才临时调接口生成,而应该提前批量生成好,统一归档。
我之前的项目做过一次线下活动,需要生成几十个不同渠道的推广码。操作流程是:写一个一次性脚本,循环调用createWxCode方法,把所有码的文件名按渠道标识命名,传到 OSS,再把 URL 批量写进数据库。整个流程在活动前跑完,生产环境接口根本不暴露给前端。
批量生成时还要考虑接口频率,几百个码一次性串行调用可能触发限额,建议分批,每批之间休息一会儿,或者用线程池控制并发数。
6.2 生成接口的防刷与参数校验
如果生成接口是给前端页面调的,一定要做防护。原因很简单:接口背后消耗的是微信接口额度,被刷一次没关系,被脚本刷几千次,额度直接打爆,线上所有码都生成不了。
我的做法是三件事:
- 接口必须带登录态,没登录一律拒绝。
- 后端对 scene 和 page 做白名单校验,page 只能是预定义页面列表里的路径,scene 只能是符合规则的短码。
- 每个业务用户每天生成数量限制,超出返回错误。
另外,生成接口不要设计成长连接或大请求,尽量让前端只传业务标识,后端自己组装 scene 和 page。这样前端根本摸不到 scene 拼接规则,防刷压力小很多。
6.3 关于永久码的进一步思考
“永久”和“长期”要分清楚。小程序码图片本身不会过期,但前提是它指向的小程序页面一直在线、小程序账号一直有效。如果页面下线,码还是能生成,用户扫码只会看到页面不存在。所以做业务设计时,scene 映射的数据要保证长期可持续维护,不要因为某个活动结束了就删除映射表记录。
一个经常被忽略的小技巧是:在 scene 里留一个版本位。比如V1-A1001,以后如果参数格式升级,可以通过版本位区分新旧码,兼容处理老物料上的码,不至于老码全部作废。这个思路来自一次现实教训:当时我们改了 scene 映射结构,结果已经印刷出去的一批物料码全部无法解析,只能重印。
我个人在实际操作中的体会是:这个功能真正花时间的不是调通接口,而是把参数体系和缓存机制设计好。接口本身一次调用成功没什么难度,难的是让这一批码在几个月、半年后依然能稳定工作,依然能通过 scene 追溯到正确的业务数据。所以接入之前,建议先把业务参数映射、缓存策略、防刷机制这几个问题想清楚,再动手写代码,后面会省掉很多返工。