feat: 微信小程序登录服务及配置更新
This commit is contained in:
parent
d6c41f5db1
commit
b4cbbb6939
@ -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) {
|
||||
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", "缺少手机号授权码");
|
||||
}
|
||||
String phone = wechatMiniProgramService.getPhoneNumberByCode(phoneCode);
|
||||
if (phone == null || phone.isBlank()) {
|
||||
return Map.of("code", 400, "message", "获取手机号失败,请检查服务端微信小程序 AppID/AppSecret 配置,或使用验证码登录");
|
||||
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;
|
||||
}
|
||||
return userService.loginByVerifiedPhone(phone);
|
||||
}
|
||||
|
||||
/** 员工注册(邀请码方式) */
|
||||
|
||||
@ -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,44 +62,99 @@ 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");
|
||||
}
|
||||
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
String accessToken = getAccessToken();
|
||||
if (accessToken == null) {
|
||||
return null;
|
||||
return WxPhoneExchangeResult.fail("无法获取微信 access_token,请核对服务端 AppID/AppSecret 是否与微信公众平台一致");
|
||||
}
|
||||
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;
|
||||
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 = objectMapper.readTree(body);
|
||||
JsonNode root;
|
||||
try {
|
||||
root = objectMapper.readTree(body);
|
||||
} catch (Exception parseEx) {
|
||||
log.warn("解析微信响应失败 body={}", body, parseEx);
|
||||
return WxPhoneExchangeResult.fail("微信返回非 JSON(HTTP " + 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);
|
||||
if (errcode != 0) {
|
||||
log.warn("getuserphonenumber 失败: {}", body);
|
||||
return null;
|
||||
String errmsg = root.path("errmsg").asText("");
|
||||
log.warn("getuserphonenumber 无 phone_info: {}", body);
|
||||
if ((errcode == 40001 || errcode == 42001) && attempt == 0) {
|
||||
invalidateAccessToken();
|
||||
continue;
|
||||
}
|
||||
String raw = root.path("phone_info").path("phoneNumber").asText("");
|
||||
return normalizeMainlandPhone(raw);
|
||||
if (errcode <= 0 && errmsg.isBlank()) {
|
||||
return WxPhoneExchangeResult.fail("微信未返回手机号,请重新点击授权");
|
||||
}
|
||||
return WxPhoneExchangeResult.fail(formatWxError(errcode, errmsg));
|
||||
} catch (Exception e) {
|
||||
log.warn("换取手机号异常", e);
|
||||
return null;
|
||||
return WxPhoneExchangeResult.fail("连接微信服务异常: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return WxPhoneExchangeResult.fail("获取手机号失败,请重试");
|
||||
}
|
||||
|
||||
private static String emptyBodyHint(String api, int status) {
|
||||
return "微信 " + api + " 返回空内容(HTTP " + status + ")。"
|
||||
+ "请在该机器上测试能否访问 api.weixin.qq.com:443(curl/浏览器),检查防火墙、安全组、出站 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() {
|
||||
long now = System.currentTimeMillis();
|
||||
@ -86,18 +166,29 @@ public class WechatMiniProgramService {
|
||||
if (cachedAccessToken != null && now < tokenExpiresAtEpochMs - 60_000) {
|
||||
return cachedAccessToken;
|
||||
}
|
||||
try {
|
||||
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",
|
||||
wechatConfig.getAppid(),
|
||||
wechatConfig.getAppsecret());
|
||||
try {
|
||||
String body = restTemplate.getForObject(url, String.class);
|
||||
if (body == null) {
|
||||
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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user