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 → 手机号。
|
*
|
* <p>官方文档: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<String, String> templateData) {
|
if (openId == null || openId.isBlank()) {
|
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "openId 不能为空");
|
}
|
if (templateId == null || templateId.isBlank()) {
|
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "templateId 不能为空");
|
}
|
if (templateData == null || templateData.isEmpty()) {
|
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "订阅消息参数不能为空");
|
}
|
if (props.useStub()) {
|
log.info(
|
"Wechat mini stub: subscribe message openId={} templateId={} page={} data={}",
|
openId,
|
templateId,
|
page,
|
templateData);
|
return;
|
}
|
String token = getAccessToken();
|
String url =
|
UriComponentsBuilder.fromHttpUrl(SEND_SUBSCRIBE_MESSAGE)
|
.queryParam("access_token", token)
|
.build()
|
.toUriString();
|
try {
|
var root = objectMapper.createObjectNode();
|
root.put("touser", openId);
|
root.put("template_id", templateId);
|
if (page != null && !page.isBlank()) {
|
root.put("page", page);
|
}
|
var dataNode = root.putObject("data");
|
templateData.forEach(
|
(key, value) -> dataNode.putObject(key).put("value", value == null ? "" : value));
|
HttpHeaders headers = new HttpHeaders();
|
headers.setContentType(MediaType.APPLICATION_JSON);
|
String body =
|
restTemplate.postForObject(
|
url, new HttpEntity<>(root.toString(), headers), String.class);
|
JsonNode response = objectMapper.readTree(body);
|
int err = response.path("errcode").asInt(-1);
|
if (err != 0) {
|
String msg = response.path("errmsg").asText("wx error");
|
log.warn("sendSubscribeMessage errcode={} msg={}", err, msg);
|
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "发送订阅消息失败: " + msg);
|
}
|
} catch (BizException e) {
|
throw e;
|
} catch (Exception e) {
|
log.error("sendSubscribeMessage http error", e);
|
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信订阅消息接口异常");
|
}
|
}
|
|
private String getAccessToken() {
|
tokenLock.lock();
|
try {
|
long now = System.currentTimeMillis();
|
if (cachedAccessToken != null && now < accessTokenExpireAtEpochMs - 120_000) {
|
return cachedAccessToken;
|
}
|
String url =
|
UriComponentsBuilder.fromHttpUrl(CGI_BIN_TOKEN)
|
.queryParam("grant_type", "client_credential")
|
.queryParam("appid", props.getAppId())
|
.queryParam("secret", props.getAppSecret())
|
.build()
|
.toUriString();
|
String body = restTemplate.getForObject(url, String.class);
|
JsonNode root = objectMapper.readTree(body);
|
int err = root.path("errcode").asInt(0);
|
if (err != 0) {
|
throw new BizException(
|
BizErrorCode.MINI_WX_API_ERROR,
|
"获取 access_token 失败: " + root.path("errmsg").asText());
|
}
|
String token = root.path("access_token").asText(null);
|
int expiresIn = root.path("expires_in").asInt(7200);
|
if (token == null || token.isBlank()) {
|
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信未返回 access_token");
|
}
|
cachedAccessToken = token;
|
accessTokenExpireAtEpochMs = now + expiresIn * 1000L;
|
return token;
|
} catch (BizException e) {
|
throw e;
|
} catch (Exception e) {
|
log.error("getAccessToken error", e);
|
throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "获取 access_token 异常");
|
} finally {
|
tokenLock.unlock();
|
}
|
}
|
}
|