package com.ruoyi.auth.service; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Base64; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import com.ruoyi.auth.config.WeChatMiniappProperties; import com.ruoyi.common.core.constant.CacheConstants; import com.ruoyi.common.core.exception.ServiceException; import com.ruoyi.common.core.utils.StringUtils; import com.ruoyi.common.redis.service.RedisService; /** * 微信小程序服务 * * 负责获取小程序全局 access_token,以及用手机号快速验证 code 换取用户手机号。 * * @author hugray */ @Service public class WeChatMiniappService { private static final Logger log = LoggerFactory.getLogger(WeChatMiniappService.class); /** 微信接口根地址 */ private static final String WECHAT_API_HOST = "https://api.weixin.qq.com"; /** 获取全局 access_token 路径 */ private static final String ACCESS_TOKEN_PATH = "/cgi-bin/token"; /** 手机号快速验证路径 */ private static final String PHONE_NUMBER_PATH = "/wxa/business/getuserphonenumber"; /** access_token 缓存时长(秒),微信有效期 7200s,预留 200s 余量 */ private static final long ACCESS_TOKEN_CACHE_SECONDS = 7000L; /** 微信接口调用超时(秒) */ private static final long WECHAT_REQUEST_TIMEOUT_SECONDS = 10L; /** 换取失败统一提示 */ private static final String PHONE_FAIL_MESSAGE = "微信手机号获取失败,请重试"; /** 获取不限量小程序码路径 */ private static final String QRCODE_PATH = "/wxa/getwxacodeunlimit"; /** 小程序码扫码直达页面 */ private static final String QRCODE_PAGE = "pages/login/register"; /** 小程序码图片宽度(px) */ private static final int QRCODE_WIDTH = 430; /** 小程序码缓存时长(秒),30 天,避免重复消耗微信 getUnlimited 日配额 */ private static final long QRCODE_CACHE_SECONDS = 30L * 24 * 60 * 60; /** 小程序码生成失败统一提示 */ private static final String QRCODE_FAIL_MESSAGE = "邀请码二维码生成失败,请重试"; private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); @Autowired private RedisService redisService; @Autowired private WeChatMiniappProperties weChatMiniappProperties; /** * 使用微信手机号快速验证 code 换取手机号 * * @param code 微信 getPhoneNumber 回调返回的动态令牌(e.detail.code) * @return 微信绑定的手机号 */ public String getPhoneNumber(String code) { // STEP 1: 校验配置是否完整 String appid = weChatMiniappProperties.getAppid(); String secret = weChatMiniappProperties.getSecret(); if (StringUtils.isEmpty(appid) || StringUtils.isEmpty(secret)) { log.error("[WECHAT] miniapp appid/secret 未配置"); throw new ServiceException(PHONE_FAIL_MESSAGE); } // STEP 2: 获取全局 access_token String accessToken = getAccessToken(appid, secret); // STEP 3: 调用微信接口换取手机号 String requestBody = "{\"code\":\"" + code + "\"}"; String responseBody = doPost(WECHAT_API_HOST + PHONE_NUMBER_PATH + "?access_token=" + accessToken, requestBody); JSONObject json = JSON.parseObject(responseBody); Integer errcode = json.getInteger("errcode"); String phoneNumber = parsePhoneNumber(json); if (errcode == null || errcode != 0 || StringUtils.isEmpty(phoneNumber)) { log.error("[WECHAT] getPhoneNumber failed, errcode={}, errmsg={}", errcode, json.getString("errmsg")); throw new ServiceException(PHONE_FAIL_MESSAGE); } return phoneNumber; } /** * 生成不限量小程序码(wxacode.getUnlimited) * * 以邀请码作为 scene,扫码直达注册页并由前端自动回填邀请码。 * 结果按 scene 缓存 30 天,命中缓存直接返回,避免重复消耗微信接口日配额。 * * @param scene 场景值,即邀请码(8 位 A-Z0-9) * @return PNG 图片的 base64 DataURI(data:image/png;base64,...) */ public String getUnlimitedQrcode(String scene) { // STEP 1: 命中缓存直接返回 String cacheKey = CacheConstants.WECHAT_QRCODE_KEY + scene; String cached = redisService.getCacheObject(cacheKey); if (StringUtils.isNotEmpty(cached)) { return cached; } // STEP 2: 校验配置是否完整 String appid = weChatMiniappProperties.getAppid(); String secret = weChatMiniappProperties.getSecret(); if (StringUtils.isEmpty(appid) || StringUtils.isEmpty(secret)) { log.error("[WECHAT] miniapp appid/secret 未配置"); throw new ServiceException(QRCODE_FAIL_MESSAGE); } // STEP 3: 获取全局 access_token String accessToken = getAccessToken(appid, secret); // STEP 4: 调用微信生成小程序码(scene 为邀请码,已由入参正则限定为 A-Z0-9,可安全拼接) String url = WECHAT_API_HOST + QRCODE_PATH + "?access_token=" + accessToken; String requestBody = "{\"scene\":\"" + scene + "\",\"page\":\"" + QRCODE_PAGE + "\",\"check_path\":false,\"env_version\":\"release\",\"width\":" + QRCODE_WIDTH + "}"; HttpResponse response = doPostForBytes(url, requestBody); // STEP 5: 按响应类型解析(图片转 base64,JSON 则说明失败) String dataUri = parseQrcodeResponse(response, scene); // STEP 6: 缓存生成结果并返回 redisService.setCacheObject(cacheKey, dataUri, QRCODE_CACHE_SECONDS, TimeUnit.SECONDS); return dataUri; } /** * 解析小程序码响应 * * 微信成功返回 PNG 字节流(Content-Type: image/*),失败返回 JSON(含 errcode/errmsg)。 * * @param response 微信响应 * @param scene 邀请码(仅用于日志) * @return PNG 的 base64 DataURI */ private String parseQrcodeResponse(HttpResponse response, String scene) { String contentType = response.headers().firstValue("Content-Type").orElse(""); byte[] body = response.body(); // 图片响应:PNG 二进制转 base64 DataURI if (contentType.startsWith("image/")) { if (body == null || body.length == 0) { log.error("[WECHAT] getUnlimitedQrcode empty image, scene={}", scene); throw new ServiceException(QRCODE_FAIL_MESSAGE); } return "data:image/png;base64," + Base64.getEncoder().encodeToString(body); } // 非图片响应:微信以 JSON 返回错误码,解析并记日志 Integer errcode = null; String errmsg = null; try { String text = body == null ? null : new String(body, StandardCharsets.UTF_8); JSONObject json = JSON.parseObject(text); if (StringUtils.isNotNull(json)) { errcode = json.getInteger("errcode"); errmsg = json.getString("errmsg"); } } catch (Exception e) { log.error("[WECHAT] getUnlimitedQrcode parse error, scene={}", scene, e); } log.error("[WECHAT] getUnlimitedQrcode failed, scene={}, errcode={}, errmsg={}", scene, errcode, errmsg); throw new ServiceException(QRCODE_FAIL_MESSAGE); } /** * 获取全局 access_token,优先从缓存读取 * * @param appid 小程序 appid * @param secret 小程序 secret * @return access_token */ private String getAccessToken(String appid, String secret) { String cacheKey = CacheConstants.WECHAT_ACCESS_TOKEN_KEY + appid; String accessToken = redisService.getCacheObject(cacheKey); if (StringUtils.isNotEmpty(accessToken)) { return accessToken; } String url = WECHAT_API_HOST + ACCESS_TOKEN_PATH + "?grant_type=client_credential&appid=" + appid + "&secret=" + secret; String responseBody = doPost(url, null); JSONObject json = JSON.parseObject(responseBody); String token = json.getString("access_token"); if (StringUtils.isEmpty(token)) { log.error("[WECHAT] getAccessToken failed, errcode={}, errmsg={}", json.getInteger("errcode"), json.getString("errmsg")); throw new ServiceException(PHONE_FAIL_MESSAGE); } redisService.setCacheObject(cacheKey, token, ACCESS_TOKEN_CACHE_SECONDS, TimeUnit.SECONDS); return token; } /** * 从响应中解析手机号(兼容 phoneNumber / purePhoneNumber) * * @param json 微信响应 * @return 手机号,不存在返回 null */ private String parsePhoneNumber(JSONObject json) { JSONObject phoneInfo = json.getJSONObject("phone_info"); if (StringUtils.isNull(phoneInfo)) { return null; } String phoneNumber = phoneInfo.getString("phoneNumber"); if (StringUtils.isEmpty(phoneNumber)) { phoneNumber = phoneInfo.getString("purePhoneNumber"); } return phoneNumber; } /** * 发送 HTTP POST 请求(body 为空时发送空体) * * @param url 请求地址 * @param body 请求体 JSON,可为空 * @return 响应文本 */ private String doPost(String url, String body) { try { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(WECHAT_REQUEST_TIMEOUT_SECONDS)) .header("Content-Type", "application/json"); if (StringUtils.isEmpty(body)) { builder.POST(HttpRequest.BodyPublishers.noBody()); } else { builder.POST(HttpRequest.BodyPublishers.ofString(body)); } HttpResponse response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); return response.body(); } catch (Exception e) { log.error("[WECHAT] request failed, url={}", url, e); throw new ServiceException(PHONE_FAIL_MESSAGE); } } /** * 发送 HTTP POST 请求并按字节数组接收响应(用于小程序码等二进制接口) * * @param url 请求地址 * @param body 请求体 JSON,可为空 * @return 原始响应(含状态头与字节体) */ private HttpResponse doPostForBytes(String url, String body) { try { HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(url)) .timeout(Duration.ofSeconds(WECHAT_REQUEST_TIMEOUT_SECONDS)) .header("Content-Type", "application/json"); if (StringUtils.isEmpty(body)) { builder.POST(HttpRequest.BodyPublishers.noBody()); } else { builder.POST(HttpRequest.BodyPublishers.ofString(body)); } return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); } catch (Exception e) { log.error("[WECHAT] request failed, url={}", url, e); throw new ServiceException(QRCODE_FAIL_MESSAGE); } } }