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:
@@ -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 java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
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 {
|
||||
int code = conn.getResponseCode();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
|
||||
|
||||
@@ -16,6 +16,7 @@ wx:
|
||||
appid: wx50fe0c5c28dd3060
|
||||
secret: e82fa407fad13a9df35503f2d176e5a4
|
||||
subscribe-template-id: Csf_dJU7DhvVFt_03sphPPBCGlnmcWQSPhgqfxHZ5RQ
|
||||
env: develop # 环境版本: release(正式版), trial(体验版), develop(开发版)
|
||||
corp: # 企业ID
|
||||
corpid: ww257614cff8a1b61b
|
||||
# 应用Secret
|
||||
|
||||
Reference in New Issue
Block a user