搞小程序推广的时候,最烦的就是给每个渠道单独准备一张专属的微信永久小程序码。前阵子做电商商城的某项目,运营隔三差五跑来问我要“这次的推广码能不能带渠道ID”,一天要几十个,还得按商品、用户、活动维度拆开。手动去微信后台生成只能生成固定页面,根本做不到一码一场景。后来我把整套逻辑接到了 Spring Boot 服务上,用 Java 直接调微信服务端接口,把生成永久小程序码的能力封装成一个通用接口,前端把 scene 和 page 传进来,后端调微信接口,图片落盘或丢图床,运营在后台点一下按钮就能拿到码。
这篇内容就是把那次完整实现的过程、核心代码、参数设计原则和踩过的坑全部理一遍。不管你是刚开始接触微信小程序服务端对接,还是已经在写类似功能但偶尔被报错卡住,都可以直接参考这里的实现方案。
1. 项目概述与核心思路
1.1 微信小程序码能做什么
微信小程序码,本质上是一张带有多边形码眼和信息区的二维码,但它跟普通二维码有区别:普通二维码跳的是一个 URL,小程序码直接打开指定的小程序页面,并且可以在码里塞一段自定义参数,也就是 scene。扫码进入页面后,小程序能拿到 scene,再结合后端接口查表映射,就能知道用户是从哪个渠道来的、对应了哪个商品、是谁的邀请链接被打点上了。
这个特性在推广场景里几乎是标配。我的项目里把每个渠道的编码拼进 scene,比如channel=144&goodsId=8192,用户扫码进入首页或商品页后,前端把 scene 字段取出来传给后端,后端解析后打点记录。在小程序码出现之前,这么做要么用长链接二次跳转,要么得调用限制极多的二维码接口。有了永久小程序码,一次生成,长期可用,真正能做到“一码一场景”。
1.2 为什么选择后端生成而不是前端生成
先回答一个很多人问过的问题:为什么不在小程序前端直接调接口生成?
第一,小程序前端拿不到 AppSecret,也通常不会把 AppSecret 下发到客户端。如果使用云开发,倒是可以在云函数里调用 getUnlimited 的云调用版本,但要额外接入云开发体系,很多存量项目并不想为此重构。第二,前端生成只能满足“当前用户要一张码”这种零散需求,如果运营一天要在后台批量生成几百张不同场景的码,前端方案根本不可行。第三,后端生成方便做统一的缓存、鉴权、水印和统计。把生成接口收口在后端,不管前端、H5、还是管理后台,都走同一个服务入口,逻辑清晰得多。
也有人会觉得小程序端有官方的小程序码组件可以直接生成。但那个组件本身也依赖后端提供参数,最终还是逃不掉服务端算 scene 这一环,只不过生成动作落在了前端。从维护角度看,后端生成是更适合业务系统集成的方式。
1.3 技术选型与整体流程
整个项目用的是 Spring Boot,网络请求直接用 Spring 的 RestTemplate,JSON 处理用 Hutool。调用小程序码接口这件事本身不复杂,没必要为了它引入 OpenFeign 或者重型网关。RestTemplate 够用,而且可读性好。如果你的项目里已经有 HttpClient 或者 OkHttp,也完全可以替代。
整体流程可以按四步拆开:
- 第一步,后端向微信接口换取 access_token;
- 第二步,业务接口收到 scene、page 等参数后,把参数拼成请求体,调用 getUnlimited 接口;
- 第三步,微信返回的是图片二进制流,先校验返回类型,再把字节写到本地目录或 OSS;
- 第四步,接口把图片 URL 或者 Base64 返回给调用方。
最关键的是第二步里请求体参数要拼对,第三步里要区分“图片流”和“JSON 错误”,这俩是我开发时被坑得最多的地方,后面的章节会单独拆开讲。
2. 准备工作:账号配置与项目搭建
2.1 小程序账号 AppID 与 AppSecret
调用微信任何服务端接口之前,都必须先在小程序后台拿到 AppID 和 AppSecret。路径在微信公众平台小程序的开发管理 -> 开发设置,AppID 是公开的,AppSecret 只在第一次获取时完整显示,之后就看不到了,只能重置。
这里有一个特别容易犯的错误:AppSecret 被随手贴在代码里然后提交到了 Git,结果微信平台直接报警。这个 Secret 一旦泄露,外部就能拿它获取 access_token,进而调用你名下的敏感接口。所以项目里尽量把它放到配置中心或者环境变量,然后把配置文件加入 Git 忽略。
还需要留意的点是,如果小程序要配置微信服务器域名白名单,那只是针对小程序端发起的请求。后端服务器直接调用 api.weixin.qq.com 不受这个限制,只要服务器出网通就行。另外如果你调用的机器在某个公司内网,记得让网管放通出站到微信服务器的 HTTPS 443,这个问题在排查时最容易忽略。
2.2 Spring Boot 项目基础依赖
先用 Spring Initializr 建一个简单的 Spring Boot 项目,用 Java 8 或者 17 都行。核心只需要 web 和 lombok,另外引入 Hutool 是为了解析 JSON 和做文件操作,少写一点样板代码。如果你是洁癖项目,也可以用 Jackson 手动解析。
在 pom.xml 里加上这几项:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.25</version> </dependency>为什么要 web 依赖?因为我们需要 Spring 的 RestTemplate 及其消息转换器支持。你也可以只用 spring-boot-starter 加一个 HTTP 客户端,但 web 依赖会让 RestTemplate 的默认配置更完整,项目里如果还有其他 HTTP 接口也能一起复用。
2.3 配置类与全局参数
小程序相关的配置不要硬编码,放进 application.yml 是基本操作。我习惯把 AppID、AppSecret、小程序码保存目录、图片访问的基础地址都放在配置里,方便部署时切换环境。
wx: appid: 你的appid secret: 你的appsecret qrcode: save-path: /data/wx-qrcode base-url: https://cdn.example.com/qrcode再写一个配置属性类,整体风格干净不少:
@Component @ConfigurationProperties(prefix = "wx") @Data public class WxProperties { private String appid; private String secret; private Qrcode qrcode = new Qrcode(); @Data public static class Qrcode { private String savePath; private String baseUrl; } }这样之后获取 access_token 的代码里,直接注入 WxProperties 就能拿到 appid 和 secret。不要小看这一步,后面写单元测试、切换正式环境和体验环境,都靠这里的配置。
3. 核心实现:获取 access_token 与生成小程序码
3.1 获取 access_token 的代码实现(带缓存)
获取 access_token 的接口是 GET 请求,参数就是 grant_type、appid、secret,返回 JSON 里包含 access_token 和 expires_in。expires_in 默认是 7200 秒,也就是 2 小时。
问题在于,这个接口有调用次数限制,如果每次生成小程序码都重新请求一次,频率一高就被微信限流。所以要缓存 token。实现方式有两种:单机用本地缓存足够,分布式就用 Redis。我给的示例是本地双检锁缓存,生产环境只要多实例,建议直接用 Redis,逻辑完全一致,只是存储介质换成 Redis 而已。
@Service @RequiredArgsConstructor public class WxAccessTokenService { private final WxProperties wxProperties; private final RestTemplate restTemplate; private volatile String accessToken; private volatile long expiresAt; public String getToken() { if (accessToken != null && System.currentTimeMillis() < expiresAt - 300_000) { return accessToken; } synchronized (this) { if (accessToken != null && System.currentTimeMillis() < expiresAt - 300_000) { return accessToken; } refreshToken(); return accessToken; } } private void refreshToken() { String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}"; Map<String, String> params = new HashMap<>(); params.put("appid", wxProperties.getAppid()); params.put("secret", wxProperties.getSecret()); String resp = restTemplate.getForObject(url, String.class, params); JSONObject json = JSONUtil.parseObj(resp); if (json.getInt("errcode") != null && json.getInt("errcode") != 0) { throw new RuntimeException("获取access_token失败: " + resp); } accessToken = json.getStr("access_token"); expiresAt = System.currentTimeMillis() + json.getLong("expires_in") * 1000; } }注意这里预留了 5 分钟提前量,也就是提前 5 分钟刷新 token,避免快到期的 token 拿去请求微信时刚好过期。这属于实操经验,很多人第一次写不会留这个提前量,结果线上偶尔报 40001。
3.2 调用 getUnlimited 接口生成永久小程序码
启动服务后,下一步就是真正调用微信的 getUnlimited 接口,官方路径是/wxa/getwxacodeunlimit。为什么叫“永久”?因为这个接口生成的码没有过期时间,而且不像createwxaqrcode接口那样有历史累计 10 万张的限制。getUnlimited 虽然也有单日频率限制,但正常业务很难触顶。
请求时要重点处理两件事:请求头必须是 JSON,返回时可能是 PNG 图片流,也可能是 JSON 错误信息。很多教程会忽略第二点,直接拿返回 String 去解析,结果图片被转成了乱码字符串。
我封装的核心请求方法如下:
public byte[] fetchWxCode(String scene, String page, boolean isHyaline) { String token = wxAccessTokenService.getToken(); String url = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + token; JSONObject body = new JSONObject(); body.set("scene", scene); body.set("page", page); body.set("check_path", false); body.set("env_version", "release"); body.set("width", 430); body.set("is_hyaline", isHyaline); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<String> entity = new HttpEntity<>(body.toString(), headers); ResponseEntity<byte[]> response = restTemplate.exchange(url, HttpMethod.POST, entity, byte[].class); String contentType = response.getHeaders().getContentType() == null ? "" : response.getHeaders().getContentType().toString(); if (!contentType.contains("image")) { String errMsg = new String(response.getBody(), StandardCharsets.UTF_8); throw new RuntimeException("生成小程序码失败: " + errMsg); } return response.getBody(); }为什么要设check_path=false?因为联调阶段 page 可能还没发布到正式版,默认 true 会报 41030。但注意,产品上线后最好改回 true,避免生成一些根本打不开的废码。生产上我建议由调用方明确传入 check_path 参数,而不是固定写死在代码里。
3.3 处理返回二进制图片数据并落盘保存
拿到 byte[] 之后,接下来就是把图片保存到磁盘。这里有个细节:getUnlimited 默认返回 JPEG,如果设置了 is_hyaline=true,则返回 PNG。保存文件的后缀要和实际图片格式保持一致,否则有的工具打不开,或者上传图床后类型判断错误。
保存文件的代码我直接用了时间戳加随机数,确保同一个 scene 多次生成的码不会互相覆盖。如果你想按 scene 固定文件名,方便运营在后台识别,也可以直接用 MD5(scene+page) 作为文件名,这样重复生成同一场景的码不会产生废文件,还能顺带做缓存。
public String saveImage(byte[] bytes, String scene, String page, boolean isHyaline) throws IOException { String suffix = isHyaline ? ".png" : ".jpg"; String fileName = System.currentTimeMillis() + "_" + RandomUtil.randomString(8) + suffix; String saveDir = wxProperties.getQrcode().getSavePath(); File dir = new File(saveDir); if (!dir.exists()) { dir.mkdirs(); } File target = new File(dir, fileName); try (FileOutputStream out = new FileOutputStream(target)) { out.write(bytes); } return wxProperties.getQrcode().getBaseUrl() + "/" + fileName; }有几个坑我上线那几天都踩过。第一,不要把 byte[] 转成 String 再写文件,这会导致二进制被破坏,图片完全打不开。第二,如果图片目录是分布式多节点共享文件系统,文件名尽量唯一,避免多个节点同时写同一个文件冲突。第三,如果是 Windows 开发机,路径分隔符用File.separator或者直接让配置项写绝对路径。
3.4 完整可运行的 Service 代码
综合上面的代码,一个完整的生成流程通常包含两层:第一层是业务 Service 负责参数校验和落盘,第二层是 HTTP 请求负责调用微信。我没有把这两个逻辑混在一个方法里,主要是方便区分“微信交互失败”和“本地文件失败”。
下面给一个完整可跑的 WxCodeService:
@Service @RequiredArgsConstructor public class WxCodeService { private final WxAccessTokenService wxAccessTokenService; private final WxProperties wxProperties; private final RestTemplate restTemplate; public String generateAndSave(String scene, String page) throws IOException { byte[] bytes = fetchWxCode(scene, page, false); String suffix = ".jpg"; String fileName = System.currentTimeMillis() + "_" + RandomUtil.randomString(8) + suffix; File dir = new File(wxProperties.getQrcode().getSavePath()); if (!dir.exists()) { dir.mkdirs(); } File file = new File(dir, fileName); try (FileOutputStream out = new FileOutputStream(file)) { out.write(bytes); } return wxProperties.getQrcode().getBaseUrl() + "/" + fileName; } public byte[] fetchWxCode(String scene, String page, boolean isHyaline) { String token = wxAccessTokenService.getToken(); String url = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + token; JSONObject body = new JSONObject(); body.set("scene", scene); body.set("page", page); body.set("check_path", false); body.set("env_version", "release"); body.set("width", 430); body.set("is_hyaline", isHyaline); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<String> entity = new HttpEntity<>(body.toString(), headers); ResponseEntity<byte[]> response = restTemplate.exchange(url, HttpMethod.POST, entity, byte[].class); String contentType = response.getHeaders().getContentType() == null ? "" : response.getHeaders().getContentType().toString(); if (!contentType.contains("image")) { String errMsg = new String(response.getBody(), StandardCharsets.UTF_8); throw new RuntimeException("生成小程序码失败: " + errMsg); } return response.getBody(); } }Controller 层很简单,提供一个 GET 接口,接收 scene 和 page,返回图片访问地址。如果业务实际需要前端直接拿到图片数据,也可以返回 Base64,让前端绘制到 canvas 上,这种方式更省静态文件存储空间。
@RestController @RequestMapping("/api/wx") @RequiredArgsConstructor public class WxCodeController { private final WxCodeService wxCodeService; @GetMapping("/code") public String code(@RequestParam String scene, @RequestParam String page) throws IOException { return wxCodeService.generateAndSave(scene, page); } @GetMapping("/code/base64") public String codeBase64(@RequestParam String scene, @RequestParam String page) throws IOException { byte[] bytes = wxCodeService.fetchWxCode(scene, page, true); return Base64.getEncoder().encodeToString(bytes); } }4. 参数深挖与业务场景适配
4.1 getUnlimited 关键参数逐个拆解
这里把 getUnlimited 的请求参数逐个过一遍,很多坑都藏在这里。直接放表格看得最清楚。
| 参数 | 是否必填 | 说明 | 经验值 |
|---|---|---|---|
| scene | 是 | 最大 32 个可见字符,只支持数字、大小写英文以及部分特殊字符,不能出现中文 | 渠道ID、商品ID、活动ID等 |
| page | 否 | 要跳转的小程序页面路径,不能带 query 参数,不传默认首页 | release 版本页面路径 |
| check_path | 否 | 是否校验 page 对应页面已发布 | 联调通常 false,生产建议 true |
| env_version | 否 | release / trial / develop,对应正式版、体验版、开发版 | 联调用 trial |
| width | 否 | 小程序码图片宽度,默认 430,合法范围 280~1280 | 一般不用调 |
| auto_color | 否 | 是否自动配置线条颜色 | 尽量 false |
| line_color | 否 | 指定线条颜色,RGB 格式,如 {"r":0,"g":0,"b":0} | 配合品牌视觉 |
| is_hyaline | 否 | 透明底,true 时返回 PNG | 海报背景用 true |
scene 是最容易踩坑的地方。它不能直接放中文,我们一般会在调用前对 scene 做 URLEncoder.encode,然后前端拿到 options.scene 后再 decodeURIComponent。这么做以后,scene 里的&、=这类字符就不会在小程序端解析时被截断。另外,如果你后端生成时对 scene 做了 URL 编码,业务数据库里存储场景值时建议存原始值,否则每次看统计都要解码一遍,太费劲。
4.2 不同业务场景下的 scene 设计
场景设计是生成小程序码最有发挥空间的地方,也是很多人上来就糊涂的地方。scene 相当于你放在码里的“暗号”,你需要预先约定好暗号的格式,并在后端和小程序端维持同一套解析规则。
我做商城项目时,常用的三种 scene 格式如下:
- 渠道投放:
channel=144&goodsId=8192 - 分销员邀请:
inviter=uid1001&ref=doorknob - 活动海报:
act=anniversary&openid=oX123
这些字符串没有中文,字符数也在 32 以内。后端解析时再映射到具体数据库记录。小程序端代码里可以这样处理:
onLoad(options) { const scene = decodeURIComponent(options.scene || ''); const params = parseScene(scene); // params.goodsId / params.channel 等等 }这里要提醒:如果你使用了 URLEncoder.encode(scene) 再塞到 JSON,小程序端 options.scene 里拿到的可能是已经解码过一次的原始值,也可能还需要再 decode 一次。稳妥的办法是自己写一个 parseScene,拿到值后做一次 try/catch 的 decodeURIComponent,兼容两种情况。
4.3 小程序码和普通二维码怎么选
开始写代码前,我也犹豫过到底用哪种码。简单列个对比表:
| 维度 | 普通小程序二维码 | 微信永久小程序码 |
|---|---|---|
| 生成接口 | createwxaqrcode | getUnlimited |
| 数量限制 | 历史累计 10 万张 | 永久有效,无总量限制 |
| 是否可带自定义参数 | 只能带 path 参数,受限 | scene 携带,灵活强大 |
| 图片样式 | 传统二维码,容易被误识别 | 码眼特殊,识别率高,支持透明底 |
| 使用场景 | 临时工具、后台展示 | 渠道推广、一码一场景 |
结论很明确:只要你是做推广、分享、追踪,直接用 getUnlimited 生成的永久小程序码。唯一需要考虑的是,如果业务要“扫普通二维码跳小程序”这种微信外部场景,普通二维码才有优势,因为普通二维码可以在微信外通过链接跳转小程序,而小程序码只能在微信内扫。
5. 高频问题与排查实录
5.1 报错清单:高频错误码解析
小程序码接口对接中,常见的错误码就那么几个。整理成速查表,遇到直接对号入座。
| 错误码 | 含义 | 解决办法 |
|---|---|---|
| 40001 | access_token 无效或过期 | 重新获取 token,检查 appid/secret 是否配,确认缓存提前刷新 |
| 41030 | page 路径不正确 | 检查 page 是否以/开头,页面是否已发布 |
| 45009 | 接口调用超过限额 | 降低调用频率,小程序码图片做缓存,必要时走异步生成 |
| 48001 | api 未授权 | 检查小程序是否已认证,或是否开通相应接口权限 |
| -1 | 微信系统繁忙 | 加一分钟内的重试策略,指数退避 |
有一次线上突然大量报 40001,排查半天发现是某同事把 AppSecret 手动重置了,老配置直接失效。这种问题日志里根本不会告诉你 AppSecret 具体是啥,只能靠团队内部沟通解决。所以建议把密钥的变更行为记录到内部变更系统,省得排查时满头大汗。
5.2 图片数据正确但打不开怎么办
生成过程看着都成功,但保存下来的图片打不开,这个问题出现频率也不低。我第一次遇到时保存的是一张 jpg,用工具打开却提示“文件损坏”。
常见原因大概是这几个:
- 把 byte[] 强转成 String 之后再写入文件。微信返回的图片字节流不是 UTF-8 文本,转成 String 就彻底破坏了数据。
- 保存文件的后缀写死为
.jpg,但请求时设置了is_hyaline=true,实际返回是 PNG,很多工具会根据后缀解析,于是报格式错误。 - 写文件时没关闭输出流,数据没 flush 完整,尤其批量生成几十张码时最容易出现这种情况。用 try-with-resources 就能规避。
- 文件名里带中文或者特殊字符,在某些 Linux 文件系统或图床 CDN 上访问拿到的路径不正确。
调试时最靠谱的办法是直接用 curl 调微信接口,先把返回内容保存出来看是否图片。这样能分清问题到底在微信侧还是在你自己的文件写入侧。
5.3 接口性能与缓存优化建议
如果这个接口会被运营后台频繁调用,性能优化必须提前想好。首当其冲的是 access_token 的缓存,当然还有小程序码图片本身的缓存。同一场景的码一般不会变,第二次请求完全可以直接命中缓存,不必再调微信接口。
我项目里用的是按scene + page + isHyaline拼接 key 做 Redis 缓存,value 直接存文件路径或 Base64。运营后台点“生成”时,如果发现 Redis 里有对应 key,直接返回,否则再调微信同步生成。这样接口的响应时间从 300ms 直接降到个位数毫秒。
如果单张码的生成比较慢,且操作量很大的场景,可以用线程池做异步生成,前端轮询结果。不过大多数业务用同步就能扛住,没必要过度设计。真正需要关注的是 RestTemplate 的连接池和超时时间,默认实现没有连接池,高并发下会频繁创建连接,建议替换成 HttpClient 的连接池实现,或者用 OkHttp 客户端。
6. 实测心得与扩展玩法
6.1 实战踩坑与心得
把整个过程梳理一遍,这个功能本身不算难,但联调阶段有几个细节特别值得注意。第一个是联调环境必须用体验版小程序,也就是 env_version 传 trial,否则你改完页面还没发布,生成码的时候一直报 41030。第二个是 scene 设计要提前定规范,最好写进接口文档,不然前后端各写各的,线上会出现解析失败。第三个是 access_token 千万不能打印在日志里,它跟账号权限一样重要。
我还发现一个小技巧:后端生成码时,可以在业务数据库里存一份 scene 到业务ID的映射表。运营在后台只需要选择“商品”和“渠道”,系统自动拼好 scene,不需要手填。这样即使用户把码截图转发,后台也能通过 scene 唯一确定来源。
6.2 扩展玩法建议
搞定了小程序码生成之后,后续能扩展的地方其实很多。比如结合 Canvas 把小程序码贴到商品海报上,生成分享图;比如在码上叠加用户头像,生成分销专属码;再比如把生成好的码上传到 OSS,用 CDN 加速用户扫码时的图片加载。这些都不需要改核心生成逻辑,只是在返回图片之前做了一层包装。
如果后面业务出现大量临时活动页,可以让生成接口支持传入临时 page 和对应 env_version,这样活动一结束把记录删掉就能下线,码的数量和可管理性都更可控。
就我个人的体会,微信永久小程序码生成这个功能,开发难度不大,真正的价值在于把它融入业务系统后带来的操作效率提升。把接口、参数规范、缓存策略都沉淀成一套可复用的服务,后面再来新场景,基本就是加几条数据库记录的事。