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<byte[]> 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<byte[]> 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<String> 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<byte[]> 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);
|
}
|
}
|
}
|