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 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 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 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 get(String path, Map parameters) { StringBuilder query = new StringBuilder(); for (Map.Entry 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 post(String path, Map 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 request(HttpRequest request) { try { HttpResponse 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) map; } catch (BusinessException exception) { throw exception; } catch (Exception exception) { throw new BusinessException(ErrorCode.CONFLICT, "微信服务暂不可用,请稍后重试"); } } private void ensureSuccess(Map 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); } }