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<String, String> 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> T get(URI uri, Class<T> 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> T readJson(String responseBody, Class<T> 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; }
|
}
|
}
|