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); }
|
}
|