| | |
| | | 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; |
| | |
| | | |
| | | /** 换取失败统一提示 */ |
| | | 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)) |
| | |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |