package com.tourguide.integration.wechat; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.tourguide.common.exception.BusinessException; import com.tourguide.config.properties.WechatMiniProgramProperties; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; import java.time.Duration; import java.util.Collections; import java.util.Map; @Component public class WechatMiniProgramClient { private static final Logger LOGGER = LoggerFactory.getLogger(WechatMiniProgramClient.class); private static final String CODE_SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session"; private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token"; private static final String PHONE_NUMBER_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber"; private static final String ACCESS_TOKEN_CACHE_PREFIX = "tourguide:wechat:mini:access-token:"; private final WechatMiniProgramProperties properties; private final RestTemplate restTemplate; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; public WechatMiniProgramClient(WechatMiniProgramProperties properties, @Qualifier("wechatMiniProgramRestTemplate") RestTemplate restTemplate, StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { this.properties = properties; this.restTemplate = restTemplate; this.redisTemplate = redisTemplate; this.objectMapper = objectMapper; } public WechatPhoneAuthorization authorizePhoneLogin(String loginCode, String phoneCode) { ensureConfigured(); CodeSessionResponse codeSession = exchangeCode(loginCode); PhoneNumberResponse phoneNumber = exchangePhoneNumber(phoneCode); String mobile = phoneNumber.getPhoneInfo() == null ? null : phoneNumber.getPhoneInfo().getPurePhoneNumber(); if (!StringUtils.hasText(mobile)) { throw new BusinessException(502, "微信未返回有效手机号,请重新授权"); } return new WechatPhoneAuthorization(codeSession.getOpenid(), mobile); } private CodeSessionResponse exchangeCode(String loginCode) { URI uri = UriComponentsBuilder.fromHttpUrl(CODE_SESSION_URL) .queryParam("appid", properties.getAppId()) .queryParam("secret", properties.getAppSecret()) .queryParam("js_code", loginCode) .queryParam("grant_type", "authorization_code") .build().encode().toUri(); CodeSessionResponse response = get(uri, CodeSessionResponse.class, "微信登录服务暂不可用"); ensureWechatSuccess(response.getErrcode(), response.getErrmsg(), "微信登录凭证无效,请重新登录"); if (!StringUtils.hasText(response.getOpenid())) { throw new BusinessException(502, "微信未返回用户标识,请重新登录"); } return response; } private PhoneNumberResponse exchangePhoneNumber(String phoneCode) { String accessToken = getAccessToken(); URI uri = UriComponentsBuilder.fromHttpUrl(PHONE_NUMBER_URL) .queryParam("access_token", accessToken) .build().encode().toUri(); try { Map body = Collections.singletonMap("code", phoneCode); String responseBody = restTemplate.postForObject(uri, body, String.class); PhoneNumberResponse response = readJson(responseBody, PhoneNumberResponse.class, "微信手机号服务未返回结果"); ensureWechatSuccess(response.getErrcode(), response.getErrmsg(), "手机号授权码无效,请重新授权"); return response; } catch (BusinessException exception) { throw exception; } catch (RestClientException exception) { LOGGER.error("调用微信手机号服务失败:{}", exception.getMessage(), exception); throw new BusinessException(502, "微信手机号服务暂不可用"); } } private String getAccessToken() { String cacheKey = ACCESS_TOKEN_CACHE_PREFIX + properties.getAppId(); String cachedToken = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.hasText(cachedToken)) { return cachedToken; } URI uri = UriComponentsBuilder.fromHttpUrl(ACCESS_TOKEN_URL) .queryParam("grant_type", "client_credential") .queryParam("appid", properties.getAppId()) .queryParam("secret", properties.getAppSecret()) .build().encode().toUri(); AccessTokenResponse response = get(uri, AccessTokenResponse.class, "微信访问令牌服务暂不可用"); ensureWechatSuccess(response.getErrcode(), response.getErrmsg(), "微信访问令牌获取失败"); if (!StringUtils.hasText(response.getAccessToken())) { throw new BusinessException(502, "微信访问令牌为空"); } int expiresIn = response.getExpiresIn() == null ? 7200 : response.getExpiresIn(); redisTemplate.opsForValue().set(cacheKey, response.getAccessToken(), Duration.ofSeconds(Math.max(60, expiresIn - 300))); return response.getAccessToken(); } private T get(URI uri, Class responseType, String unavailableMessage) { try { String responseBody = restTemplate.getForObject(uri, String.class); return readJson(responseBody, responseType, unavailableMessage); } catch (BusinessException exception) { throw exception; } catch (RestClientException exception) { LOGGER.error("调用微信 GET 接口失败:{}", exception.getMessage(), exception); throw new BusinessException(502, unavailableMessage); } } private T readJson(String responseBody, Class responseType, String unavailableMessage) { if (!StringUtils.hasText(responseBody)) { throw new BusinessException(502, unavailableMessage); } try { return objectMapper.readValue(responseBody, responseType); } catch (JsonProcessingException exception) { LOGGER.error("解析微信接口响应失败,响应类型:{},异常:{}", responseType.getSimpleName(), exception.getMessage(), exception); throw new BusinessException(502, unavailableMessage); } } private void ensureConfigured() { if (!StringUtils.hasText(properties.getAppId()) || !StringUtils.hasText(properties.getAppSecret())) { throw new BusinessException(503, "微信小程序登录配置未完成"); } } private void ensureWechatSuccess(Integer errcode, String errmsg, String fallbackMessage) { if (errcode == null || errcode == 0) { return; } if (errcode == 40029 || errcode == 40163) { throw new BusinessException(401, fallbackMessage); } if (errcode == 40013 || errcode == 40125 || errcode == 41002) { throw new BusinessException(503, "微信小程序 AppID 或 AppSecret 配置错误(微信错误码:" + errcode + ")"); } if (errcode == 45011) { throw new BusinessException(429, "微信请求过于频繁,请稍后重试"); } throw new BusinessException(502, fallbackMessage + "(微信错误码:" + errcode + ")"); } @JsonIgnoreProperties(ignoreUnknown = true) public static class CodeSessionResponse { private String openid; private Integer errcode; private String errmsg; public String getOpenid() { return openid; } public void setOpenid(String openid) { this.openid = openid; } public Integer getErrcode() { return errcode; } public void setErrcode(Integer errcode) { this.errcode = errcode; } public String getErrmsg() { return errmsg; } public void setErrmsg(String errmsg) { this.errmsg = errmsg; } } @JsonIgnoreProperties(ignoreUnknown = true) public static class AccessTokenResponse { @JsonProperty("access_token") private String accessToken; @JsonProperty("expires_in") private Integer expiresIn; private Integer errcode; private String errmsg; public String getAccessToken() { return accessToken; } public void setAccessToken(String accessToken) { this.accessToken = accessToken; } public Integer getExpiresIn() { return expiresIn; } public void setExpiresIn(Integer expiresIn) { this.expiresIn = expiresIn; } public Integer getErrcode() { return errcode; } public void setErrcode(Integer errcode) { this.errcode = errcode; } public String getErrmsg() { return errmsg; } public void setErrmsg(String errmsg) { this.errmsg = errmsg; } } @JsonIgnoreProperties(ignoreUnknown = true) public static class PhoneNumberResponse { private Integer errcode; private String errmsg; @JsonProperty("phone_info") private PhoneInfo phoneInfo; public Integer getErrcode() { return errcode; } public void setErrcode(Integer errcode) { this.errcode = errcode; } public String getErrmsg() { return errmsg; } public void setErrmsg(String errmsg) { this.errmsg = errmsg; } public PhoneInfo getPhoneInfo() { return phoneInfo; } public void setPhoneInfo(PhoneInfo phoneInfo) { this.phoneInfo = phoneInfo; } } public static class PhoneInfo { @JsonProperty("purePhoneNumber") private String purePhoneNumber; public String getPurePhoneNumber() { return purePhoneNumber; } public void setPurePhoneNumber(String purePhoneNumber) { this.purePhoneNumber = purePhoneNumber; } } }