package com.sinata.appointment.integration; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.sinata.appointment.config.WechatMiniAppProperties; import com.sinata.appointment.constants.BizErrorCode; import com.sinata.appointment.exception.BizException; import java.util.Map; import java.util.concurrent.locks.ReentrantLock; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; /** * 微信小程序服务端:js_code → openId;access_token + phoneCode → 手机号。 * *
官方文档:code2session、getAccessToken(client_credential)、getuserphonenumber。
*/
@Slf4j
@Service
public class WechatMiniGateway {
private static final String JSCODE2SESSION =
"https://api.weixin.qq.com/sns/jscode2session";
private static final String CGI_BIN_TOKEN =
"https://api.weixin.qq.com/cgi-bin/token";
private static final String GET_USER_PHONE =
"https://api.weixin.qq.com/wxa/business/getuserphonenumber";
private static final String SEND_SUBSCRIBE_MESSAGE =
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send";
private final WechatMiniAppProperties props;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
private final ReentrantLock tokenLock = new ReentrantLock();
private volatile String cachedAccessToken;
private volatile long accessTokenExpireAtEpochMs;
public WechatMiniGateway(
WechatMiniAppProperties props,
@Qualifier("wechatRestTemplate") RestTemplate restTemplate,
ObjectMapper objectMapper) {
this.props = props;
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
}
public WxCode2SessionResult code2Session(String jsCode) {
if (jsCode == null || jsCode.isBlank()) {
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "wxCode 不能为空");
}
if (props.useStub()) {
log.debug("Wechat mini stub: code2Session");
return WxCode2SessionResult.stub(jsCode);
}
String url =
UriComponentsBuilder.fromHttpUrl(JSCODE2SESSION)
.queryParam("appid", props.getAppId())
.queryParam("secret", props.getAppSecret())
.queryParam("js_code", jsCode)
.queryParam("grant_type", "authorization_code")
.build()
.toUriString();
try {
String body = restTemplate.getForObject(url, String.class);
JsonNode root = objectMapper.readTree(body);
int err = root.path("errcode").asInt(0);
if (err != 0) {
String msg = root.path("errmsg").asText("wx error");
log.warn("code2session errcode={} msg={}", err, msg);
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信登录失败: " + msg);
}
String openId = root.path("openid").asText(null);
if (openId == null || openId.isBlank()) {
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信未返回 openid");
}
return WxCode2SessionResult.builder()
.openId(openId)
.unionId(root.path("unionid").asText(null))
.sessionKey(root.path("session_key").asText(null))
.build();
} catch (BizException e) {
throw e;
} catch (Exception e) {
log.error("code2session http error", e);
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信接口调用异常");
}
}
/**
* @param phoneCode 前端 button open-type=getPhoneNumber 回调中的 code(非手机号明文)
*/
public String getPhoneNumber(String phoneCode) {
if (phoneCode == null || phoneCode.isBlank()) {
throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "phoneCode 不能为空");
}
if (props.useStub()) {
int h = Math.abs(phoneCode.hashCode() % 100_000_000);
String stubPhone = "138" + String.format("%08d", h);
log.debug("Wechat mini stub: phoneCode -> {}", stubPhone);
return stubPhone;
}
String token = getAccessToken();
String url =
UriComponentsBuilder.fromHttpUrl(GET_USER_PHONE)
.queryParam("access_token", token)
.build()
.toUriString();
try {
String reqJson = objectMapper.createObjectNode().put("code", phoneCode).toString();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body =
restTemplate.postForObject(url, new HttpEntity<>(reqJson, headers), String.class);
JsonNode root = objectMapper.readTree(body);
int err = root.path("errcode").asInt(-1);
if (err != 0) {
String msg = root.path("errmsg").asText("wx error");
log.warn("getuserphonenumber errcode={} msg={}", err, msg);
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "获取手机号失败: " + msg);
}
String phone = root.path("phone_info").path("purePhoneNumber").asText(null);
if (phone == null || phone.isBlank()) {
phone = root.path("phone_info").path("phoneNumber").asText(null);
}
if (phone == null || phone.isBlank()) {
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信未返回手机号");
}
return phone;
} catch (BizException e) {
throw e;
} catch (Exception e) {
log.error("getuserphonenumber http error", e);
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信手机号接口异常");
}
}
public void sendSubscribeMessage(
String openId,
String templateId,
String page,
Map