From b4cbbb6939aef44a3047abff62e0f08b497f2309 Mon Sep 17 00:00:00 2001 From: MaDaLei Date: Tue, 14 Apr 2026 21:04:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BE=AE=E4=BF=A1=E5=B0=8F=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E7=99=BB=E5=BD=95=E6=9C=8D=E5=8A=A1=E5=8F=8A=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../petstore/controller/UserController.java | 34 ++- .../service/WechatMiniProgramService.java | 201 ++++++++++++++---- src/main/resources/application.yml | 6 +- 3 files changed, 187 insertions(+), 54 deletions(-) diff --git a/src/main/java/com/petstore/controller/UserController.java b/src/main/java/com/petstore/controller/UserController.java index 521e593..99c46ae 100644 --- a/src/main/java/com/petstore/controller/UserController.java +++ b/src/main/java/com/petstore/controller/UserController.java @@ -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 wxPhoneLogin(@RequestBody Map params) { - String phoneCode = params.get("phoneCode"); - if (phoneCode == null || phoneCode.isBlank()) { - return Map.of("code", 400, "message", "缺少手机号授权码"); + public Map wxPhoneLogin(@RequestBody(required = false) Map 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 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 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); } /** 员工注册(邀请码方式) */ diff --git a/src/main/java/com/petstore/service/WechatMiniProgramService.java b/src/main/java/com/petstore/service/WechatMiniProgramService.java index 20eb2f2..574ac63 100644 --- a/src/main/java/com/petstore/service/WechatMiniProgramService.java +++ b/src/main/java/com/petstore/service/WechatMiniProgramService.java @@ -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("微信返回非 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); + 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> 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: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() { @@ -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 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 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; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ba735ee..b8eee32 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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