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.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 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, "微信手机号接口异常"); } } 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(); } } }