liujie
12 小时以前 fddf3d913d47f391f84f9e460cb76d427ef02c44
hugray-server/ruoyi-auth/src/main/java/com/ruoyi/auth/service/WeChatMiniappService.java
@@ -4,7 +4,9 @@
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;
@@ -47,6 +49,21 @@
    /** 换取失败统一提示 */
    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))
@@ -91,6 +108,97 @@
            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);
    }
    /**
@@ -177,4 +285,36 @@
            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);
        }
    }
}