feat: 实现微信小程序码生成后端接口

- 新增 WxController 提供 /api/wx-mini/wxacode 接口
- 新增 WxService 实现 access_token 缓存和小程序码生成
- 新增 HttpUtil.postJsonBytes() 方法处理二进制响应
- 配置 application.yml 支持 env 环境参数

主要功能:
1. access_token 自动缓存,5分钟过期缓冲避免频繁调用
2. 支持生成任意页面小程序码,最大宽度 430px
3. 支持环境版本参数(release/trial/develop)
4. 返回 Base64 图片数据,避免服务器文件存储
5. 使用 Jackson ObjectMapper 处理 JSON 序列化

技术细节:
- HttpURLConnection 请求微信 API
- Base64 编码图片数据
- 场景参数限制 32 字符
- 错误处理和异常捕获
This commit is contained in:
ws
2026-04-27 18:36:40 +08:00
parent c88b047d01
commit d80f64268e
4 changed files with 231 additions and 0 deletions
@@ -0,0 +1,61 @@
package com.example.mini_program.controller;
import com.example.mini_program.common.Result;
import com.example.mini_program.service.WxService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/wx-mini")
@RequiredArgsConstructor
public class WxController {
private final WxService wxService;
/**
* 生成小程序码
*/
@PostMapping("/wxacode")
public Result<String> getWxacode(@RequestBody Map<String, String> request) {
String scene = request.get("scene");
String page = request.get("page");
String envVersion = request.get("envVersion");
if (scene == null || scene.trim().isEmpty()) {
return Result.error("scene不能为空");
}
page = (page != null && !page.trim().isEmpty()) ? page : "pages/scan/result/index";
Integer width = parseWidth(request.get("width"));
if (envVersion == null || envVersion.trim().isEmpty()) {
envVersion = wxService.getDefaultEnvVersion();
}
try {
String base64Image = wxService.getUnlimitedWxacode(scene, page, width, envVersion);
return Result.success(base64Image);
} catch (Exception e) {
log.error("生成小程序码失败", e);
return Result.error("生成小程序码失败: " + e.getMessage());
}
}
private Integer parseWidth(String widthStr) {
if (widthStr == null || widthStr.trim().isEmpty()) {
return 430;
}
try {
int width = Integer.parseInt(widthStr);
return width > 0 ? width : 430;
} catch (NumberFormatException e) {
log.warn("width参数格式错误: {}", widthStr);
return 430;
}
}
}
@@ -0,0 +1,128 @@
package com.example.mini_program.service;
import com.example.mini_program.config.WxMiniAppConfig;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
@Slf4j
@Service
public class WxService {
private static final String WXACODE_URL = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=%s";
private static final String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
private static final int TOKEN_EXPIRE_BUFFER = 300; // access_token提前过期时间(秒)
private final WxMiniAppConfig wxMiniAppConfig;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
@Value("${wx.miniapp.env:release}")
private String defaultEnvVersion;
private String cachedAccessToken;
private long tokenExpireTime;
public WxService(WxMiniAppConfig wxMiniAppConfig, RestTemplate restTemplate) {
this.wxMiniAppConfig = wxMiniAppConfig;
this.restTemplate = restTemplate;
this.objectMapper = new ObjectMapper();
}
/**
* 生成小程序码
*/
public String getUnlimitedWxacode(String scene, String page, int width, String envVersion) throws Exception {
// 参数校验
if (scene == null || scene.trim().isEmpty()) {
throw new IllegalArgumentException("scene不能为空");
}
if (scene.length() > 32) {
throw new IllegalArgumentException("scene长度不能超过32个字符");
}
// 使用传入的环境版本,若为空则使用默认值
if (envVersion == null || envVersion.trim().isEmpty()) {
envVersion = defaultEnvVersion;
}
String accessToken = getAccessToken();
String requestBody = objectMapper.writeValueAsString(new WxacodeRequest(scene, page, width, envVersion));
log.info("生成小程序码: scene={}, page={}, width={}, env={}", scene, page, width, envVersion);
byte[] response = com.example.mini_program.util.HttpUtil.postJsonBytes(
String.format(WXACODE_URL, accessToken),
requestBody
);
if (response.length > 0 && response[0] == '{') {
String errorMsg = new String(response, StandardCharsets.UTF_8);
log.error("微信API返回错误: {}", errorMsg);
throw new RuntimeException("微信小程序码生成失败: " + errorMsg);
}
log.info("小程序码生成成功,大小: {} bytes", response.length);
return java.util.Base64.getEncoder().encodeToString(response);
}
public String getDefaultEnvVersion() {
return defaultEnvVersion;
}
/**
* 获取微信access_token(带缓存)
*/
private String getAccessToken() throws Exception {
if (cachedAccessToken != null && System.currentTimeMillis() < tokenExpireTime) {
return cachedAccessToken;
}
String url = String.format(TOKEN_URL, wxMiniAppConfig.getAppid(), wxMiniAppConfig.getSecret());
log.info("刷新access_token");
String response = restTemplate.getForObject(url, String.class);
JsonNode json = objectMapper.readTree(response);
String accessToken = json.get("access_token").asText();
int expiresIn = json.get("expires_in").asInt();
cachedAccessToken = accessToken;
tokenExpireTime = System.currentTimeMillis() + (expiresIn - TOKEN_EXPIRE_BUFFER) * 1000L;
log.info("access_token已更新,有效期: {} 秒", expiresIn);
return accessToken;
}
/**
* 小程序码请求参数
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
private static class WxacodeRequest {
private String scene;
private String page;
private Integer width;
private Boolean check_path;
private String env_version;
private Boolean auto_color;
private Boolean is_hyaline;
WxacodeRequest(String scene, String page, int width, String envVersion) {
this.scene = scene;
this.page = page;
this.width = width;
this.check_path = false;
this.env_version = envVersion;
this.auto_color = false;
this.is_hyaline = false;
}
}
}
@@ -3,6 +3,7 @@ package com.example.mini_program.util;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
@@ -55,6 +56,46 @@ public class HttpUtil {
} }
} }
public static byte[] postJsonBytes(String urlStr, String jsonBody) throws Exception {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setConnectTimeout(CONNECT_TIMEOUT);
conn.setReadTimeout(READ_TIMEOUT);
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
conn.setRequestProperty("Expect", ""); // 禁用 100-continue
try (OutputStream os = conn.getOutputStream()) {
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
os.flush();
}
int code = conn.getResponseCode();
if (code >= 300) {
String errorBody = readStringFromStream(conn.getErrorStream());
throw new RuntimeException("HTTP error " + code + ": " + errorBody);
}
try (InputStream is = conn.getInputStream()) {
return is.readAllBytes();
}
} finally {
if (conn != null) conn.disconnect();
}
}
private static String readStringFromStream(InputStream is) throws Exception {
if (is == null) return "";
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
}
}
private static String readResponse(HttpURLConnection conn) throws Exception { private static String readResponse(HttpURLConnection conn) throws Exception {
int code = conn.getResponseCode(); int code = conn.getResponseCode();
try (BufferedReader reader = new BufferedReader(new InputStreamReader( try (BufferedReader reader = new BufferedReader(new InputStreamReader(
+1
View File
@@ -16,6 +16,7 @@ wx:
appid: wx50fe0c5c28dd3060 appid: wx50fe0c5c28dd3060
secret: e82fa407fad13a9df35503f2d176e5a4 secret: e82fa407fad13a9df35503f2d176e5a4
subscribe-template-id: Csf_dJU7DhvVFt_03sphPPBCGlnmcWQSPhgqfxHZ5RQ subscribe-template-id: Csf_dJU7DhvVFt_03sphPPBCGlnmcWQSPhgqfxHZ5RQ
env: develop # 环境版本: release(正式版), trial(体验版), develop(开发版)
corp: # 企业ID corp: # 企业ID
corpid: ww257614cff8a1b61b corpid: ww257614cff8a1b61b
# 应用Secret # 应用Secret