feat: 微信小程序登录服务及配置更新

This commit is contained in:
MaDaLei 2026-04-14 21:04:19 +08:00
parent d6c41f5db1
commit b4cbbb6939
3 changed files with 187 additions and 54 deletions

View File

@ -4,12 +4,14 @@ import com.petstore.entity.User;
import com.petstore.service.UserService;
import com.petstore.service.WechatMiniProgramService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
@ -40,16 +42,30 @@ public class UserController {
* 微信小程序button open-type=getPhoneNumber 拿到的 phoneCode 换手机号并登录
*/
@PostMapping("/wx-phone-login")
public Map<String, Object> wxPhoneLogin(@RequestBody Map<String, String> params) {
String phoneCode = params.get("phoneCode");
if (phoneCode == null || phoneCode.isBlank()) {
return Map.of("code", 400, "message", "缺少手机号授权码");
public Map<String, Object> wxPhoneLogin(@RequestBody(required = false) Map<String, String> params) {
try {
if (params == null) {
return Map.of("code", 400, "message", "请求体不能为空");
}
String phoneCode = params.get("phoneCode");
if (phoneCode == null || phoneCode.isBlank()) {
return Map.of("code", 400, "message", "缺少手机号授权码");
}
var wx = wechatMiniProgramService.exchangePhoneCode(phoneCode);
if (!wx.isOk()) {
Map<String, Object> err = new HashMap<>();
err.put("code", 400);
err.put("message", wx.errorMessage() != null ? wx.errorMessage() : "微信登录失败");
return err;
}
return userService.loginByVerifiedPhone(wx.phone());
} catch (Exception e) {
log.error("wx-phone-login 异常", e);
Map<String, Object> err = new HashMap<>();
err.put("code", 500);
err.put("message", "登录处理失败: " + e.getMessage());
return err;
}
String phone = wechatMiniProgramService.getPhoneNumberByCode(phoneCode);
if (phone == null || phone.isBlank()) {
return Map.of("code", 400, "message", "获取手机号失败,请检查服务端微信小程序 AppID/AppSecret 配置,或使用验证码登录");
}
return userService.loginByVerifiedPhone(phone);
}
/** 员工注册(邀请码方式) */

View File

@ -5,16 +5,20 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.petstore.config.WechatConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
/**
* 微信小程序通过 getPhoneNumber 返回的 code 换取用户手机号
* 使用 JDK HttpClient 直连微信避免部分环境下 RestTemplate 对响应解析为空
* 文档https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/phonenumber/phonenumber.getPhoneNumber.html
*/
@Service
@ -23,11 +27,32 @@ import java.util.Map;
public class WechatMiniProgramService {
private final WechatConfig wechatConfig;
private final ObjectMapper objectMapper = new ObjectMapper();
private final RestTemplate restTemplate = new RestTemplate();
/** 部分代理/网关对 HTTP/2 支持差,固定 HTTP/1.1 更稳 */
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.NORMAL)
.version(HttpClient.Version.HTTP_1_1)
.build();
private volatile String cachedAccessToken;
private volatile long tokenExpiresAtEpochMs;
public record WxPhoneExchangeResult(String phone, String errorMessage) {
public static WxPhoneExchangeResult ok(String phone) {
return new WxPhoneExchangeResult(phone, null);
}
public static WxPhoneExchangeResult fail(String errorMessage) {
String msg = (errorMessage != null && !errorMessage.isBlank()) ? errorMessage : "未知错误";
return new WxPhoneExchangeResult(null, msg);
}
public boolean isOk() {
return phone != null && !phone.isBlank();
}
}
private boolean isConfigured() {
String id = wechatConfig.getAppid();
String sec = wechatConfig.getAppsecret();
@ -37,43 +62,98 @@ public class WechatMiniProgramService {
&& !"YOUR_APPSECRET".equals(sec);
}
private void invalidateAccessToken() {
cachedAccessToken = null;
tokenExpiresAtEpochMs = 0;
}
/**
* @param phoneCode 前端 button getPhoneNumber 回调中的 detail.code
* @return 11 位大陆手机号失败返回 null
*/
public String getPhoneNumberByCode(String phoneCode) {
public WxPhoneExchangeResult exchangePhoneCode(String phoneCode) {
if (phoneCode == null || phoneCode.isBlank()) {
return null;
return WxPhoneExchangeResult.fail("缺少手机号授权码");
}
if (!isConfigured()) {
log.warn("微信 appid/appsecret 未配置,无法换取手机号");
return null;
log.warn("微信 appid/appsecret 未配置或为占位符,无法换取手机号");
return WxPhoneExchangeResult.fail("服务端未配置微信小程序 AppID/AppSecret或仍为占位符 YOUR_APPID");
}
try {
String accessToken = getAccessToken();
if (accessToken == null) {
return null;
for (int attempt = 0; attempt < 2; attempt++) {
try {
String accessToken = getAccessToken();
if (accessToken == null) {
return WxPhoneExchangeResult.fail("无法获取微信 access_token请核对服务端 AppID/AppSecret 是否与微信公众平台一致");
}
String url = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken;
String jsonBody = objectMapper.writeValueAsString(Map.of("code", phoneCode));
HttpResult hr = httpPostJson(url, jsonBody);
String body = hr.body();
if (body == null || body.isBlank()) {
log.warn("getuserphonenumber 响应为空, httpStatus={}, urlLen={}", hr.statusCode(), url.length());
return WxPhoneExchangeResult.fail(emptyBodyHint("getuserphonenumber", hr.statusCode()));
}
JsonNode root;
try {
root = objectMapper.readTree(body);
} catch (Exception parseEx) {
log.warn("解析微信响应失败 body={}", body, parseEx);
return WxPhoneExchangeResult.fail("微信返回非 JSONHTTP " + hr.statusCode() + "),可能被网关替换");
}
if (root.hasNonNull("phone_info")) {
JsonNode pi = root.path("phone_info");
String raw = pi.path("phoneNumber").asText("");
if (raw.isBlank()) {
raw = pi.path("purePhoneNumber").asText("");
}
String phone = normalizeMainlandPhone(raw);
if (phone == null) {
log.warn("无法解析手机号,原始响应: {}", body);
return WxPhoneExchangeResult.fail("微信返回的手机号格式异常");
}
return WxPhoneExchangeResult.ok(phone);
}
int errcode = root.path("errcode").asInt(-1);
String errmsg = root.path("errmsg").asText("");
log.warn("getuserphonenumber 无 phone_info: {}", body);
if ((errcode == 40001 || errcode == 42001) && attempt == 0) {
invalidateAccessToken();
continue;
}
if (errcode <= 0 && errmsg.isBlank()) {
return WxPhoneExchangeResult.fail("微信未返回手机号,请重新点击授权");
}
return WxPhoneExchangeResult.fail(formatWxError(errcode, errmsg));
} catch (Exception e) {
log.warn("换取手机号异常", e);
return WxPhoneExchangeResult.fail("连接微信服务异常: " + e.getMessage());
}
String url = "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> entity = new HttpEntity<>(Map.of("code", phoneCode), headers);
String body = restTemplate.postForObject(url, entity, String.class);
if (body == null) {
return null;
}
JsonNode root = objectMapper.readTree(body);
int errcode = root.path("errcode").asInt(-1);
if (errcode != 0) {
log.warn("getuserphonenumber 失败: {}", body);
return null;
}
String raw = root.path("phone_info").path("phoneNumber").asText("");
return normalizeMainlandPhone(raw);
} catch (Exception e) {
log.warn("换取手机号异常", e);
return null;
}
return WxPhoneExchangeResult.fail("获取手机号失败,请重试");
}
private static String emptyBodyHint(String api, int status) {
return "微信 " + api + " 返回空内容HTTP " + status + ")。"
+ "请在该机器上测试能否访问 api.weixin.qq.com:443curl/浏览器),检查防火墙、安全组、出站 HTTPS、公司代理"
+ "需代理时可设置 JVM 参数 https.proxyHost / https.proxyPort若 IPv6 异常可试 -Djava.net.preferIPv4Stack=true";
}
private static String formatWxError(int errcode, String errmsg) {
String base = (errmsg != null && !errmsg.isBlank()) ? errmsg : "未知错误";
String hint = switch (errcode) {
case 40029 -> "code 无效或已过期,请重新点击「微信授权登录」)";
case 40163 -> "code 已被使用,请重新授权)";
case 40001, 42001 -> "access_token 无效,已自动重试;若仍失败请检查 AppSecret";
case 40125 -> "AppSecret 错误,请登录微信公众平台核对后更新服务端配置)";
case 40013 -> "AppID 无效,请核对是否为小程序 AppID";
default -> "";
};
if (errcode > 0) {
return "微信接口错误 " + errcode + "" + base + hint;
}
return "获取手机号失败:" + base;
}
private String getAccessToken() {
@ -86,18 +166,29 @@ public class WechatMiniProgramService {
if (cachedAccessToken != null && now < tokenExpiresAtEpochMs - 60_000) {
return cachedAccessToken;
}
String url = String.format(
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
wechatConfig.getAppid(),
wechatConfig.getAppsecret());
try {
String body = restTemplate.getForObject(url, String.class);
if (body == null) {
String appid = URLEncoder.encode(wechatConfig.getAppid(), StandardCharsets.UTF_8);
String secret = URLEncoder.encode(wechatConfig.getAppsecret(), StandardCharsets.UTF_8);
String url = String.format(
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
appid, secret);
HttpResult hr = httpGet(url);
String body = hr.body();
if (body == null || body.isBlank()) {
log.warn("获取 access_token 微信返回空 body, http={}", hr.statusCode());
return null;
}
JsonNode root;
try {
root = objectMapper.readTree(body);
} catch (Exception parseEx) {
log.warn("解析 token 响应失败 body={}", body, parseEx);
return null;
}
JsonNode root = objectMapper.readTree(body);
if (root.hasNonNull("errcode") && root.path("errcode").asInt() != 0) {
log.warn("获取 access_token 失败: {}", body);
int ec = root.path("errcode").asInt();
String em = root.path("errmsg").asText("");
log.warn("获取 access_token 失败: errcode={} errmsg={} body={}", ec, em, body);
return null;
}
String token = root.path("access_token").asText(null);
@ -115,7 +206,33 @@ public class WechatMiniProgramService {
}
}
/** 微信可能返回带区号的号码,统一为 11 位 */
private record HttpResult(int statusCode, String body) {}
private HttpResult httpGet(String url) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(25))
.header("Accept", "application/json, text/plain, */*")
.header("User-Agent", "PetstoreBackend/1.0")
.GET()
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return new HttpResult(resp.statusCode(), resp.body());
}
private HttpResult httpPostJson(String url, String jsonBody) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(25))
.header("Content-Type", "application/json; charset=UTF-8")
.header("Accept", "application/json, text/plain, */*")
.header("User-Agent", "PetstoreBackend/1.0")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
return new HttpResult(resp.statusCode(), resp.body());
}
private String normalizeMainlandPhone(String raw) {
if (raw == null || raw.isBlank()) {
return null;

View File

@ -35,8 +35,8 @@ logging:
level:
com.petstore: debug
# 微信小程序(与 manifest / 微信后台一致
# 微信小程序(与 manifest / 微信后台一致;生产环境建议用环境变量覆盖
wechat:
appid: wx8ca2dfa89af72edf
appsecret: 7afb49f3a31fe9b5a083c7c40be45c5b
appid: ${WECHAT_APPID:wx8ca2dfa89af72edf}
appsecret: ${WECHAT_APPSECRET:7afb49f3a31fe9b5a083c7c40be45c5b}
redirect_uri: http://localhost:8080/api/wechat/callback