liujie
13 小时以前 fddf3d913d47f391f84f9e460cb76d427ef02c44
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
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);
        }
    }
}