简介:围绕微信小程序二维码生成整理的 Java 实战工程,面向需要实现裂变分享、渠道推广的开发者,重点解决服务端调用微信接口获取永久有效、数量不限的小程序码(getUnlimitedQRCode)问题,并给出 5 种可选实现方式,适合具备 Java 与 HTTP 基础、希望快速实现邀请二维码功能的中级开发者参考。压缩包共 69 个文件、约 53KB,以 50 个 XML 配置(Maven 构建与 IDE 工程配置)、4 个 Java 源文件、5 个 class 字节码、3 个 properties 配置及 1 个依赖 jar 为主,另含少量 lst 清单与 iml 工程描述,构成可直接导入 IDE 运行的 Maven 工程骨架;源码按 main 与 test 分层,便于对照阅读与二次改造。已有 2650 人学习,读者可获得前端—后端 API—微信 API 三段式调用链路的完整实现、token 与 secret 的安全处理思路、五种生成方式的代码对照,以及开箱即用的源码包,便于按业务快速选型集成。
1. 服务端出码不是前端画图:Java HTTP 生成微信小程序二维码到底在做什么
第一次接 Java HTTP 生成微信小程序二维码的需求,很多人会下意识让前端用 canvas 画一张,结果活动页一上线就发现:不同机型扫出来的参数对不上,图片清晰度也不稳定。服务端出码的本质是调微信接口拿回二进制图片流,再用 Java 的 HTTP 客户端把流落盘或转存对象存储,前端只负责展示。这份源码示例把五种 Java HTTP 调用方式拆成可运行的代码,覆盖 HttpURLConnection、HttpClient、OkHttp、RestTemplate 和 WebClient,适合正在做小程序海报、地推码、商品分享码的后端和全栈。它不解决“二维码长得好不好看”,解决的是“能不能稳定生成、参数能不能带过去、失败能不能定位”。
2. 三个接口先分清:createQRCode、get、getUnlimited 的边界与选型
微信小程序侧常见的出码接口有三个:createQRCode、get、getUnlimited。名字看起来都能出图,实际边界差别很大。createQRCode适合固定路径的普通小程序二维码,生成的是传统黑白方块码;get支持自定义页面路径和宽度,但 scene 参数能力有限;getUnlimited是现在最常用的无限量小程序码,支持 scene 携带业务参数、支持透明底和自定义线条颜色,适合地推码、商品分享码、活动海报码。选型时先问一句:扫码后要不要带业务参数?要带就优先getUnlimited,不要为了省事把参数拼在 page 上。
2.1 三个接口返回的都是二进制图片流,不是 JSON
这三个接口在成功时返回的是图片二进制流,常见格式是 PNG 或 JPEG,响应头可能是image/jpeg、image/png,也可能是application/octet-stream。失败时却经常返回 HTTP 200,响应体是{"errcode":40001,"errmsg":"invalid credential..."}这样的 JSON。很多翻车就发生在这里:代码把响应体当字符串读,或者直接写进.jpg文件,最后图片打不开,日志里只剩一串“乱码”。正确做法是统一按byte[]接收,先判断首字节是不是{,再判断 PNG/JPEG 魔数,最后才落盘。这个判断逻辑放在第 4 章和第 5 章会反复用到。
| 接口 | 路径 | 适合场景 | 关键限制 |
|---|---|---|---|
| createQRCode | /cgi-bin/wxaapp/createwxaqrcode | 固定页面、普通小程序二维码 | 参数能力弱,样式简单 |
| get | /wxa/getwxacode | 需要指定 page、width 的小程序码 | scene 能力有限,适合短参数 |
| getUnlimited | /wxa/getwxacodeunlimit | 地推码、商品码、活动码、带业务参数 | scene 最长 32 个可见字符,page 不能带 query |
选型不用纠结“哪个新就用哪个”。如果扫码后只是打开一个固定页面,createQRCode就够;如果扫码后要跳转到商品详情并带上id,用getUnlimited,把id放进 scene,page 只写pages/goods/detail。我一般会把接口路径、请求体构造、图片校验封装成一个WxQrCodeService,上层业务只传scene、page、envVersion三个参数,避免每个业务方各写一套 HTTP 调用。
2.2 access_token 是入口:缓存、刷新与并发控制
不管选哪个接口,都绕不开access_token。它由appid和secret换取,默认有效期 7200 秒,接口有调用频控。最常见的错误是每次生成二维码都重新取一次 token,流量一上来就触发频控,或者多个线程同时刷新导致旧 token 被顶掉。常见做法是本地缓存 + 提前刷新 + 双重检查锁;多副本部署时用集中缓存或分布式锁,保证同一时间只有一个实例在刷新。下面这段代码用volatile和synchronized做单机双检,提前 5 分钟过期,避免边界时间拿到刚失效的 token。
public final class AccessTokenStore { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s"; private static volatile String token; private static volatile long expireAt; private static final Object LOCK = new Object(); public static String get(String appId, String secret) throws IOException { long now = System.currentTimeMillis(); if (token != null && now < expireAt - 300_000L) { return token; } synchronized (LOCK) { now = System.currentTimeMillis(); if (token != null && now < expireAt - 300_000L) { return token; } HttpURLConnection conn = (HttpURLConnection) new URL( String.format(TOKEN_URL, appId, secret)).openConnection(); conn.setConnectTimeout(3000); conn.setReadTimeout(5000); conn.setRequestMethod("GET"); try (InputStream in = conn.getInputStream()) { JsonNode node = MAPPER.readTree(in); if (node.has("errcode")) { throw new IOException("token error: " + node); } token = node.get("access_token").asText(); expireAt = now + node.get("expires_in").asLong() * 1000L; return token; } finally { conn.disconnect(); } } } }逻辑说明:第一次检查不加锁,命中缓存直接返回;未命中才进入同步块,进入后再检查一次,避免多个线程排队重复刷新。expireAt - 300_000L是提前 5 分钟过期,给网络抖动和时钟偏差留余量。参数说明:appId、secret从小程序后台配置读取,不要硬编码;expires_in单位是秒,乘以 1000 转毫秒;connectTimeout设 3 秒,readTimeout设 5 秒,token 接口本身很快,超时太长会拖住业务线程。多实例部署时,这段单机缓存不够用,常见做法是把 token 放集中缓存,刷新时加短锁,锁内再查一次缓存,避免并发刷新。
2.3 请求体别拼字符串,字段顺序和类型都要稳
getUnlimited的请求体是 JSON,字段包括scene、page、check_path、env_version、width、auto_color、line_color、is_hyaline。很多人用字符串拼接,结果line_color里的引号、布尔值、数字类型出错,接口返回 40097 之类的参数错误。用Map或 DTO 序列化更稳,字段顺序不影响结果,但类型要正确:width是数字,check_path和auto_color是布尔值,line_color是对象。下面这段构造逻辑把默认值收在一处,业务侧只传必要参数。
public static String buildUnlimitedPayload(String scene, String page, String envVersion) throws JsonProcessingException { Map<String, Object> body = new LinkedHashMap<>(); body.put("scene", scene); body.put("page", page); body.put("check_path", true); body.put("env_version", envVersion); body.put("width", 430); body.put("auto_color", false); body.put("line_color", Map.of("r", 0, "g", 0, "b", 0)); body.put("is_hyaline", false); return new ObjectMapper().writeValueAsString(body); }逻辑说明:LinkedHashMap只是让日志里的字段顺序稳定,方便排查;check_path=true表示校验 page 是否已发布,联调阶段可以临时改 false,但上线必须打开;env_version用release、trial、develop三个值区分环境。参数说明:width默认 430,范围 280 到 1280,海报场景可以调到 750 或 1000,但不要超过 1280;auto_color=false时line_color才生效;is_hyaline=true会生成透明底,适合深色海报,但扫码识别率要看背景对比度。
提示:
scene是给扫码后页面读的参数,不是给接口做 URL 查询的。不要把page写成pages/goods/detail?id=1,参数统一放scene,否则check_path=true时容易报页面不存在。
3. 五种 Java HTTP 调用方式:从 HttpURLConnection 到 WebClient 的落地代码
同一套请求体,换五种 HTTP 客户端都能调通,差别在依赖、连接池、超时配置和二进制响应处理。下面五种写法都围绕getUnlimited接口,入参是 token 和 JSON 字符串,出参是byte[]。代码里省略了 import,关键点写在注释和代码后的说明里。不要小看“读二进制”这件事,很多翻车不是接口不通,而是把图片流当字符串读了一遍,字节被字符集转换破坏,最后图片只有上半截能显示。
3.1 HttpURLConnection:不引依赖,但要把二进制流读干净
HttpURLConnection是 JDK 自带的,适合不想加依赖的小工具或老项目。它的坑在于:POST 请求要setDoOutput(true),错误响应要从getErrorStream()读,读流要用ByteArrayOutputStream一直接到-1,不能只读一次。下面这段代码把成功流和错误流统一处理,读完先判断首字节是不是{,是就按 JSON 错误抛出去。
public static byte[] getUnlimitedByUrlConnection(String token, String json) throws IOException { URL url = new URL("https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + token); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setConnectTimeout(5000); conn.setReadTimeout(10000); conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); try (OutputStream out = conn.getOutputStream()) { out.write(json.getBytes(StandardCharsets.UTF_8)); } int status = conn.getResponseCode(); InputStream in = (status >= 200 && status < 300) ? conn.getInputStream() : conn.getErrorStream(); try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) { byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) != -1) { bos.write(buf, 0, n); } byte[] bytes = bos.toByteArray(); if (bytes.length > 0 && bytes[0] == '{') { throw new IOException("wechat error: " + new String(bytes, StandardCharsets.UTF_8)); } return bytes; } finally { conn.disconnect(); } }逻辑说明:先写请求体,再拿状态码,再根据状态码选输入流;错误流也要读,否则拿不到微信返回的 errcode。bytes[0] == '{'是快速判断 JSON 错误体,PNG 首字节是0x89,JPEG 首字节是0xFF,不会误判。参数说明:连接超时 5 秒,读超时 10 秒;二维码生成偶尔会慢,读超时太短会频繁超时,建议 10 到 15 秒;Content-Type必须带charset=UTF-8,避免中文 scene 出问题。这个写法适合低频调用,高并发场景连接复用能力弱,还是换连接池客户端。
3.2 Apache HttpClient:连接池和超时参数别用默认值
Apache HttpClient 5 的写法比 4.x 更现代,核心是CloseableHttpClient、RequestConfig、StringEntity。生产环境不要每次请求都新建 client,应该把CloseableHttpClient做成单例 Bean,内部用PoolingHttpClientConnectionManager管连接。下面示例为了展示完整流程,把 client 放在 try-with-resources 里,真实项目里应该复用。EntityUtils.toByteArray是读二进制响应的关键,不要用EntityUtils.toString。
public static byte[] getUnlimitedByHttpClient(String token, String json) throws Exception { RequestConfig config = RequestConfig.custom() .setConnectTimeout(Timeout.ofSeconds(5)) .setResponseTimeout(Timeout.ofSeconds(10)) .build(); try (CloseableHttpClient client = HttpClients.custom() .setDefaultRequestConfig(config) .setConnectionManager(new PoolingHttpClientConnectionManager()) .build()) { HttpPost post = new HttpPost( "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + token); post.setHeader("Content-Type", "application/json; charset=UTF-8"); post.setEntity(new StringEntity(json, ContentType.APPLICATION_JSON)); try (CloseableHttpResponse resp = client.execute(post)) { byte[] bytes = EntityUtils.toByteArray(resp.getEntity()); if (bytes.length > 0 && bytes[0] == '{') { throw new IOException(new String(bytes, StandardCharsets.UTF_8)); } return bytes; } } }逻辑说明:RequestConfig同时设连接超时和响应超时,Timeout.ofSeconds是 HttpClient 5 的 API;StringEntity指定ContentType.APPLICATION_JSON,避免服务端按表单解析。参数说明:连接池默认每个路由 2 个连接,高并发下要调setMaxTotal和setDefaultMaxPerRoute;responseTimeout是等响应的最长时间,不是 socket read timeout,读大图时还要留余量。错误响应也可能带 200 状态码,所以仍然要判断首字节。HttpClient 的好处是连接复用和重试策略可配,但重试要谨慎,二维码接口有频控,失败重试一次就够。
3.3 OkHttp:ResponseBody.bytes 只能调一次
OkHttp 在移动端和后端都常见,API 简洁,拦截器好加。它的坑是ResponseBody.string()和ResponseBody.bytes()只能调用一次,调完流就关了;如果先string()打日志,再bytes()拿图片,第二次会抛IllegalStateException。下面代码直接bytes(),先判断 JSON 再返回。RequestBody.create的第二个参数是MediaType,不要漏掉 charset。
public static byte[] getUnlimitedByOkHttp(String token, String json) throws IOException { OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.SECONDS) .retryOnConnectionFailure(false) .build(); RequestBody body = RequestBody.create(json, MediaType.get("application/json; charset=utf-8")); Request request = new Request.Builder() .url("https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + token) .post(body) .build(); try (Response resp = client.newCall(request).execute()) { byte[] bytes = resp.body() == null ? new byte[0] : resp.body().bytes(); if (bytes.length > 0 && bytes[0] == '{') { throw new IOException(new String(bytes, StandardCharsets.UTF_8)); } return bytes; } }逻辑说明:retryOnConnectionFailure(false)关掉连接失败自动重试,避免重复请求触发频控;resp.body().bytes()一次读完,后面不要再读 body。参数说明:连接超时 5 秒,读超时 10 秒;OkHttp 默认连接池会自动复用连接,生产环境把OkHttpClient做成单例,不要每次 new。如果业务侧要记录响应头,可以在execute()后拿resp.header("Content-Type"),但不要因此把 body 转字符串。OkHttp 的拦截器适合统一加日志、加 traceId,但日志里不要打印完整图片字节,打印长度和前 16 个字节的十六进制就够。
3.4 RestTemplate:必须用 byte[].class 接响应
Spring 项目里 RestTemplate 很常见,坑在于消息转换器。如果你写String.class,StringHttpMessageConverter会把图片字节按字符集解码,得到一堆乱码;必须用byte[].class,让ByteArrayHttpMessageConverter原样接。下面代码用RestTemplateBuilder设超时,请求体用HttpEntity<String>包 JSON,响应类型写byte[].class。
public static byte[] getUnlimitedByRestTemplate(String token, String json) { RestTemplate rt = new RestTemplateBuilder() .setConnectTimeout(Duration.ofSeconds(5)) .setReadTimeout(Duration.ofSeconds(10)) .build(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntity<String> entity = new HttpEntity<>(json, headers); ResponseEntity<byte[]> resp = rt.postForEntity( "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=" + token, entity, byte[].class); byte[] bytes = resp.getBody(); if (bytes != null && bytes.length > 0 && bytes[0] == '{') { throw new IllegalStateException(new String(bytes, StandardCharsets.UTF_8)); } return bytes; }逻辑说明:HttpHeaders.setContentType(MediaType.APPLICATION_JSON)让请求按 JSON 发;postForEntity的第三个参数决定消息转换器,byte[].class是唯一稳妥的选择。参数说明:RestTemplateBuilder在 Spring Boot 2.x 和 3.x 的超时 API 有差异,老版本用setConnectTimeout(int),新版本用Duration;生产环境把RestTemplate做成 Bean 注入,不要每次 new,连接池和超时统一管理。如果返回 4xx,RestTemplate默认抛HttpClientErrorException,异常里能拿到byte[]响应体,记得在全局异常处理里把它转成可读的 errcode。
3.5 WebClient:响应式栈里别把 byte[] 写成 String
WebClient 属于 Spring WebFlux,适合响应式链路或高并发 IO。它的坑是bodyToMono(String.class)会把图片流转成字符串,必须bodyToMono(byte[].class)。另外,exchangeToMono能拿到完整响应,包括状态码和 body,比retrieve()更适合处理“HTTP 200 + JSON 错误体”的情况。下面代码返回Mono<byte[]>,业务侧可以继续组合,不要在响应式线程里直接block()。
public static Mono<byte[]> getUnlimitedByWebClient(String token, String json) { WebClient webClient = WebClient.builder() .clientConnector(new ReactorClientHttpConnector( HttpClient.create().responseTimeout(Duration.ofSeconds(10)))) .build(); return webClient.post() .uri("https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={token}", token) .contentType(MediaType.APPLICATION_JSON) .bodyValue(json) .exchangeToMono(resp -> resp.bodyToMono(byte[].class) .defaultIfEmpty(new byte[0]) .map(bytes -> { if (bytes.length > 0 && bytes[0] == '{') { throw new IllegalStateException( new String(bytes, StandardCharsets.UTF_8)); } return bytes; })); }逻辑说明:HttpClient.create().responseTimeout设响应超时,连接超时可以在HttpClient上继续配option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000);exchangeToMono保留响应对象,方便以后加状态码判断。参数说明:uri里的{token}是 URI 变量,WebClient 会做编码,不要把 token 拼进字符串再传;bodyValue(json)需要contentType明确为 JSON。响应式链路里落盘要用DataBufferUtils或先收byte[]再异步写,别在map里做阻塞 IO。五种方式没有绝对优劣,老项目用 HttpURLConnection,Spring 项目用 RestTemplate 或 WebClient,追求轻量和拦截器就用 OkHttp。
4. 场景参数与图片流落盘:scene、page、width、透明底怎么配
接口调通只是第一步,真正上线后出问题最多的不是 HTTP 客户端,而是参数。scene超长、page带参数、env_version配错、透明底导致扫码识别率低,这些都属于“生成成功但扫不对”的玄学问题。下面把参数拆成三块:scene 的编码策略、page 与环境版本的联调顺序、图片落盘前的校验。每块都给可抄的代码和参数边界,照着改就能少走弯路。
4.1 scene 的 32 字符限制与编码策略
getUnlimited的 scene 最大 32 个可见字符,支持数字、大小写字母和部分特殊字符。很多人把 JSON、中文、长 URL 直接塞进去,生成时可能成功,扫码后页面拿到的options.scene却被截断或为空。常见做法是只放业务主键,比如id=10086或sku=ABC&from=poster,页面拿到 scene 后再请求服务端查详情。中文和空格不要直接放,确实要放就统一 URL 编码,但编码后会膨胀,很容易超过 32 字符。下面这段校验放在业务入口,超长直接拒绝,比生成后扫码才发现问题好。
public static String normalizeScene(String scene) { if (scene == null) { return ""; } String s = scene.trim(); if (s.length() > 32) { throw new IllegalArgumentException("scene 最多 32 个可见字符"); } if (!s.matches("[0-9a-zA-Z&=_\\-]+")) { throw new IllegalArgumentException("scene 只建议放字母数字和 &=_-"); } return s; }逻辑说明:trim()去掉首尾空格,避免看不见的字符占长度;length()按字符数算,中文也是一个字符,但编码后会变长,所以直接用正则限制字符集更稳。参数说明:32是接口上限,不是建议值;业务上尽量控制在 20 字符以内,给后续加字段留空间。如果 scene 里必须带中文,先URLEncoder.encode(value, StandardCharsets.UTF_8),再拼k=v,同时确认编码后的总长度不超过 32,并且小程序页面里用decodeURIComponent解码。不要对整段 scene 做二次编码,否则页面拿到的值会多一层%25。
4.2 page 路径、env_version 与 check_path 的联调顺序
page是小程序页面路径,不能带 query,不能以/开头。正确写法是pages/goods/detail,错误写法是/pages/goods/detail或pages/goods/detail?id=1。env_version有三个值:release正式版、trial体验版、develop开发版。check_path为 true 时会校验 page 是否已发布,联调阶段页面还没发布就设 false,提测和上线必须设 true。常见联调顺序是:开发阶段用develop + check_path=false先看图片能不能出来;提测用trial + check_path=true看真机能不能打开;上线前切release + check_path=true再扫一遍。
| 参数 | 作用 | 建议值 | 常见坑 |
|---|---|---|---|
| page | 扫码打开的页面路径 | pages/goods/detail | 带 query、带前导斜杠、页面未发布 |
| env_version | 打开哪个版本 | 开发 develop、提测 trial、上线 release | 上线包用了 trial,用户扫不开 |
| check_path | 是否校验页面已发布 | 联调 false,上线 true | 一直 false,上线后页面不存在 |
| width | 图片宽度 | 430 到 1000 | 超过 1280 或太小导致识别率低 |
| auto_color | 自动配色 | false | true 时 line_color 不生效 |
| line_color | 线条颜色 | 深色,如{r:0,g:0,b:0} | 颜色太浅,扫码识别失败 |
| is_hyaline | 透明底 | 海报深色底时 true | 透明底放在浅色背景上对比度不足 |
构造请求体时把环境变量收在配置里,不要写死在代码里。下面这段代码把check_path和env_version作为参数传入,业务侧根据环境切换,避免上线前漏改。
public static String buildPayload(String scene, String page, String envVersion, boolean checkPath) throws JsonProcessingException { Map<String, Object> body = new LinkedHashMap<>(); body.put("scene", normalizeScene(scene)); body.put("page", page); body.put("check_path", checkPath); body.put("env_version", envVersion); body.put("width", 430); body.put("auto_color", false); body.put("line_color", Map.of("r", 0, "g", 0, "b", 0)); body.put("is_hyaline", false); return new ObjectMapper().writeValueAsString(body); }逻辑说明:normalizeScene先校验再放入,避免把脏参数发给接口;check_path显式传入,不依赖默认值,代码 review 时能一眼看到环境差异。参数说明:page不要带参数,业务参数走 scene;env_version只传三个合法值,不要传空字符串;width先固定 430,海报场景再调大。联调时如果返回41030 invalid page,先查 page 是否拼错、是否已发布、env_version是否对应,不要急着换 HTTP 客户端。
4.3 图片落盘与 Content-Type 判断:先验 JSON 再写文件
拿到byte[]后不要直接Files.write,先做三件事:判空、判 JSON 错误体、判图片魔数。PNG 的魔数是0x89 0x50 0x4E 0x47,JPEG 是0xFF 0xD8。微信小程序码常见 PNG
本文还有配套的精品资源,点击获取