shinnosuke
2026-08-12 fa232086731fbabcb6df070246d1b09f68930096
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
package com.talentcard.server.business.auth;
 
import java.net.URI;
import java.net.URLEncoder;
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.LinkedHashMap;
import java.util.Map;
 
import com.alibaba.fastjson.JSON;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
 
@Service
public class WeChatMiniProgramClient {
 
    private static final String API_BASE = "https://api.weixin.qq.com";
    private static final Logger log = LoggerFactory.getLogger(WeChatMiniProgramClient.class);
 
    private final WeChatMiniProgramProperties properties;
    private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(8)).build();
    private volatile String accessToken = "";
    private volatile long accessTokenExpiresAt = 0;
 
    public WeChatMiniProgramClient(WeChatMiniProgramProperties properties) {
        this.properties = properties;
    }
 
    public String exchangeOpenId(String code) {
        Map<String, Object> response = get("/sns/jscode2session", Map.of(
                "appid", required(properties.getAppId()),
                "secret", required(properties.getAppSecret()),
                "js_code", code.trim(),
                "grant_type", "authorization_code"));
        ensureSuccess(response, "微信登录已失效,请重新登录");
        String openId = text(response.get("openid"));
        if (openId.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "未获取到微信用户标识,请重新登录");
        return openId;
    }
 
    public String exchangePhoneNumber(String code) {
        String token = clientAccessToken(false);
        Map<String, Object> response = post("/wxa/business/getuserphonenumber?access_token=" + encode(token), Map.of("code", code.trim()));
        // 微信返回 40001 时,缓存的 access_token 可能已被其它实例刷新。使用稳定
        // 令牌接口强制刷新一次并重试,避免把令牌问题误报成手机号授权失效。
        if (number(response.get("errcode"), 0) == 40001) {
            token = clientAccessToken(true);
            response = post("/wxa/business/getuserphonenumber?access_token=" + encode(token), Map.of("code", code.trim()));
        }
        ensureSuccess(response, "手机号授权已失效,请重新授权");
        Object phoneInfo = response.get("phone_info");
        if (!(phoneInfo instanceof Map<?, ?> raw)) throw new BusinessException(ErrorCode.CONFLICT, "未获取到微信手机号,请重新授权");
        String phone = text(raw.get("phoneNumber"));
        if (phone.isBlank()) phone = text(raw.get("purePhoneNumber"));
        if (phone.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "未获取到微信手机号,请重新授权");
        return phone;
    }
 
    private String clientAccessToken(boolean forceRefresh) {
        long now = System.currentTimeMillis();
        if (!forceRefresh && !accessToken.isBlank() && now < accessTokenExpiresAt) return accessToken;
        synchronized (this) {
            now = System.currentTimeMillis();
            if (!forceRefresh && !accessToken.isBlank() && now < accessTokenExpiresAt) return accessToken;
            Map<String, Object> response = post("/cgi-bin/stable_token", Map.of(
                    "appid", required(properties.getAppId()),
                    "secret", required(properties.getAppSecret()),
                    "grant_type", "client_credential",
                    "force_refresh", forceRefresh));
            // 兼容旧微信接口/网关:稳定令牌接口不可用时再回退到旧接口。
            if (number(response.get("errcode"), 0) != 0 && !forceRefresh) {
                response = get("/cgi-bin/token", Map.of(
                "grant_type", "client_credential",
                "appid", required(properties.getAppId()),
                "secret", required(properties.getAppSecret())));
            }
            ensureSuccess(response, "微信服务暂不可用,请稍后重试");
            String token = text(response.get("access_token"));
            long expiresIn = number(response.get("expires_in"), 7200);
            if (token.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "微信服务暂不可用,请稍后重试");
            accessToken = token;
            accessTokenExpiresAt = now + Math.max(60, expiresIn - 120) * 1000;
            return token;
        }
    }
 
    private Map<String, Object> get(String path, Map<String, String> parameters) {
        StringBuilder query = new StringBuilder();
        for (Map.Entry<String, String> entry : parameters.entrySet()) {
            if (!query.isEmpty()) query.append('&');
            query.append(encode(entry.getKey())).append('=').append(encode(entry.getValue()));
        }
        return request(HttpRequest.newBuilder(URI.create(API_BASE + path + '?' + query))
                .timeout(Duration.ofSeconds(10)).GET().build());
    }
 
    private Map<String, Object> post(String path, Map<String, Object> body) {
        return request(HttpRequest.newBuilder(URI.create(API_BASE + path))
                .timeout(Duration.ofSeconds(10))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(JSON.toJSONString(body), StandardCharsets.UTF_8))
                .build());
    }
 
    @SuppressWarnings("unchecked")
    private Map<String, Object> request(HttpRequest request) {
        try {
            HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
            if (response.statusCode() < 200 || response.statusCode() >= 300) {
                throw new BusinessException(ErrorCode.CONFLICT, "微信服务暂不可用,请稍后重试");
            }
            Object parsed = JSON.parse(response.body());
            if (!(parsed instanceof Map<?, ?> map)) throw new BusinessException(ErrorCode.CONFLICT, "微信服务响应异常,请稍后重试");
            return (Map<String, Object>) map;
        } catch (BusinessException exception) {
            throw exception;
        } catch (Exception exception) {
            throw new BusinessException(ErrorCode.CONFLICT, "微信服务暂不可用,请稍后重试");
        }
    }
 
    private void ensureSuccess(Map<String, Object> response, String fallback) {
        Object code = response.get("errcode");
        if (code == null || number(code, 0) == 0) return;
        // 微信响应不记录授权 code、手机号或 OpenID,仅保留平台错误信息用于联调定位。
        log.warn("WeChat Mini Program API rejected the request: errcode={}, errmsg={}", code, text(response.get("errmsg")));
        throw new BusinessException(ErrorCode.CONFLICT, fallback);
    }
 
    private String required(String value) {
        if (value == null || value.isBlank()) throw new BusinessException(ErrorCode.CONFLICT, "微信小程序登录配置不完整");
        return value.trim();
    }
 
    private long number(Object value, long fallback) {
        if (value instanceof Number number) return number.longValue();
        try { return Long.parseLong(String.valueOf(value)); } catch (Exception ignored) { return fallback; }
    }
 
    private String text(Object value) { return value == null ? "" : String.valueOf(value).trim(); }
    private String encode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8); }
}