From 76d4d7465c64bd022f7e0e54b9a0a5b7b57480e3 Mon Sep 17 00:00:00 2001 From: Peanut Date: Wed, 3 Jun 2026 23:03:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(wechat):=20=E9=87=8D=E5=86=99=20code2Sessi?= =?UTF-8?q?on=20=E5=AE=9E=E7=8E=B0=205=20=E6=AD=A5=E9=98=B2=E5=BE=A1?= =?UTF-8?q?=E6=80=A7=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解决问题: - 微信API返回 text/plain 时 JSONObject.parseObject 抛异常导致用户500 - 网络异常时 RestClientException 未被捕获 - 业务错误码(errcode != 0)未处理 实现 5 步防御: 1. 网络层:捕获 ResourceAccessException / RestClientException -> 502 2. HTTP 状态:手动检查 is2xxSuccessful(因 WechatResponseErrorHandler 让 4xx/5xx 不抛)-> 502 3. Content-Type:检查 isCompatibleWith(APPLICATION_JSON) -> 502 4. JSON 解析:try/catch 包裹 parseObject -> 500 5. 业务码:检查 errcode == 0 -> 400 TDD:先写 10 测试场景覆盖所有分支,再实现 - 正常 JSON 成功 - HTTP 4xx / 5xx - Content-Type: text/plain / text/html - JSON 解析失败 - 业务错误码 40029 / 40163 - 网络异常(ResourceAccessException) - 响应缺少 session_key --- .../impl/WechatMiniProgramServiceImpl.java | 110 +++++++++ .../WechatMiniProgramServiceImplTest.java | 224 ++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java create mode 100644 backend-single/src/test/java/com/emotion/service/impl/WechatMiniProgramServiceImplTest.java diff --git a/backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java new file mode 100644 index 0000000..ba10a90 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java @@ -0,0 +1,110 @@ +package com.emotion.service.impl; + +import com.alibaba.fastjson2.JSONObject; +import com.emotion.config.WechatMiniProgramProperties; +import com.emotion.dto.wechat.WechatCodeSessionResponse; +import com.emotion.exception.BusinessException; +import com.emotion.exception.WechatApiException; +import com.emotion.service.WechatMiniProgramService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WechatMiniProgramServiceImpl implements WechatMiniProgramService { + + private final RestTemplate restTemplate; + private final WechatMiniProgramProperties properties; + + @Override + public WechatCodeSessionResponse code2Session(String code) { + validateConfigured(); + + String url = UriComponentsBuilder.fromHttpUrl(properties.getBaseUrl() + "/sns/jscode2session") + .queryParam("appid", properties.getAppId()) + .queryParam("secret", properties.getAppSecret()) + .queryParam("js_code", code) + .queryParam("grant_type", "authorization_code") + .toUriString(); + + ResponseEntity responseEntity; + try { + responseEntity = restTemplate.getForEntity(url, String.class); + } catch (ResourceAccessException e) { + // 网络层异常(连接超时、连接拒绝、DNS 失败) + log.error("[WeChatAPI] 网络异常,jsCode={}", code, e); + throw new WechatApiException(502, "微信API网络异常: " + e.getMessage(), null); + } catch (RestClientException e) { + // 其他 RestTemplate 异常(如 SSL、URI 解析) + log.error("[WeChatAPI] 客户端异常,jsCode={}", code, e); + throw new WechatApiException(502, "微信API客户端异常: " + e.getMessage(), null); + } + + // Step 2: HTTP 状态码检查(4xx/5xx 不会抛异常,因为 WechatResponseErrorHandler 处理了) + if (!responseEntity.getStatusCode().is2xxSuccessful()) { + log.error("[WeChatAPI] HTTP 非 2xx 响应,status={}", responseEntity.getStatusCode()); + throw new WechatApiException(502, + "微信API返回非2xx状态: " + responseEntity.getStatusCode(), responseEntity.getBody()); + } + + // Step 3: Content-Type 检查(防御 WAF 返回 text/plain 拦截页) + MediaType contentType = responseEntity.getHeaders().getContentType(); + if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { + log.error("[WeChatAPI] Content-Type 不是 JSON,contentType={}, body={}", + contentType, responseEntity.getBody()); + throw new WechatApiException(502, + "微信API返回非JSON Content-Type: " + contentType, responseEntity.getBody()); + } + + // Step 4: JSON 解析 + JSONObject result; + try { + result = JSONObject.parseObject(responseEntity.getBody()); + } catch (Exception e) { + log.error("[WeChatAPI] JSON 解析失败,body={}", responseEntity.getBody(), e); + throw new WechatApiException(500, "微信API响应JSON解析失败: " + e.getMessage(), responseEntity.getBody()); + } + + // Step 5: 业务错误码检查 + int errcode = result.getIntValue("errcode"); + if (errcode != 0) { + String errmsg = result.getString("errmsg"); + log.error("[WeChatAPI] 业务错误,errcode={}, errmsg={}", errcode, errmsg); + throw new WechatApiException(400, + "微信API业务错误,errcode=" + errcode + ", errmsg=" + errmsg, responseEntity.getBody()); + } + + // 成功:提取 openid 和 session_key + String openid = result.getString("openid"); + String sessionKey = result.getString("session_key"); + if (openid == null || sessionKey == null) { + log.error("[WeChatAPI] 响应缺少必要字段 (openid 或 session_key)"); + throw new WechatApiException(502, + "微信API响应缺少 openid 或 session_key", responseEntity.getBody()); + } + + WechatCodeSessionResponse response = new WechatCodeSessionResponse(); + response.setOpenid(openid); + response.setSessionKey(sessionKey); + response.setUnionid(result.getString("unionid")); + return response; + } + + private void validateConfigured() { + if (!StringUtils.hasText(properties.getAppId()) || !StringUtils.hasText(properties.getAppSecret())) { + throw new BusinessException("微信小程序登录未配置"); + } + } +} diff --git a/backend-single/src/test/java/com/emotion/service/impl/WechatMiniProgramServiceImplTest.java b/backend-single/src/test/java/com/emotion/service/impl/WechatMiniProgramServiceImplTest.java new file mode 100644 index 0000000..453721c --- /dev/null +++ b/backend-single/src/test/java/com/emotion/service/impl/WechatMiniProgramServiceImplTest.java @@ -0,0 +1,224 @@ +package com.emotion.service.impl; + +import com.emotion.config.WechatMiniProgramProperties; +import com.emotion.dto.wechat.WechatCodeSessionResponse; +import com.emotion.exception.WechatApiException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.*; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * WechatMiniProgramServiceImpl.code2Session 单元测试 + * + *

10 个测试场景覆盖 5 步防御性处理的所有分支: + *

    + *
  1. 正常 JSON 成功
  2. + *
  3. HTTP 4xx
  4. + *
  5. HTTP 5xx
  6. + *
  7. Content-Type: text/plain
  8. + *
  9. Content-Type: text/html(WAF 拦截页)
  10. + *
  11. JSON 解析失败
  12. + *
  13. 业务错误码 40029(invalid code)
  14. + *
  15. 业务错误码 40163(code been used)
  16. + *
  17. 网络异常(ResourceAccessException)
  18. + *
  19. 响应缺少 session_key
  20. + *
+ */ +@ExtendWith(MockitoExtension.class) +class WechatMiniProgramServiceImplTest { + + @Mock + private RestTemplate restTemplate; + + @Mock + private WechatMiniProgramProperties properties; + + @InjectMocks + private WechatMiniProgramServiceImpl service; + + @BeforeEach + void setUp() { + when(properties.getAppId()).thenReturn("test-appid"); + when(properties.getAppSecret()).thenReturn("test-secret"); + when(properties.getBaseUrl()).thenReturn("https://api.weixin.qq.com"); + } + + @Test + @DisplayName("1. 正常 JSON 成功 - 返回 openid 和 session_key") + void code2Session_NormalJsonSuccess_ReturnsOpenIdAndSessionKey() { + String successBody = "{\"errcode\":0,\"openid\":\"oXyz123\",\"session_key\":\"abc456\"}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + ResponseEntity responseEntity = new ResponseEntity<>(successBody, headers, HttpStatus.OK); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + + WechatCodeSessionResponse result = service.code2Session("validJsCode"); + + assertNotNull(result); + assertEquals("oXyz123", result.getOpenid()); + assertEquals("abc456", result.getSessionKey()); + verify(restTemplate, times(1)).getForEntity(anyString(), eq(String.class)); + } + + @Test + @DisplayName("2. HTTP 4xx - 抛 WechatApiException(502)") + void code2Session_Http4xx_ThrowsWechatApiException_502() { + ResponseEntity responseEntity = new ResponseEntity<>("", HttpStatus.BAD_REQUEST); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("code")); + assertEquals(502, ex.getCode()); + assertTrue(ex.getMessage().contains("非2xx") || ex.getMessage().contains("HTTP")); + } + + @Test + @DisplayName("3. HTTP 5xx - 抛 WechatApiException(502)") + void code2Session_Http5xx_ThrowsWechatApiException_502() { + ResponseEntity responseEntity = new ResponseEntity<>("", HttpStatus.INTERNAL_SERVER_ERROR); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("code")); + assertEquals(502, ex.getCode()); + } + + @Test + @DisplayName("4. Content-Type: text/plain - 抛 WechatApiException(502)") + void code2Session_ContentTypeTextPlain_ThrowsWechatApiException_502() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.TEXT_PLAIN); + String body = "WAF Challenge"; + ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("code")); + assertEquals(502, ex.getCode()); + assertTrue(ex.getMessage().contains("Content-Type") || ex.getMessage().contains("JSON")); + } + + @Test + @DisplayName("5. Content-Type: text/html(WAF 拦截页) - 抛 WechatApiException(502)") + void code2Session_ContentTypeTextHtml_WafChallenge_ThrowsWechatApiException_502() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.TEXT_HTML); + String body = "WAF Challenge"; + ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("code")); + assertEquals(502, ex.getCode()); + } + + @Test + @DisplayName("6. JSON 解析失败 - 抛 WechatApiException(500)") + void code2Session_JsonParseFailure_ThrowsWechatApiException_500() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String body = "not valid json{"; + ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("code")); + assertEquals(500, ex.getCode()); + assertTrue(ex.getMessage().contains("JSON")); + } + + @Test + @DisplayName("7. 业务错误码 40029(invalid code) - 抛 WechatApiException(400)") + void code2Session_BusinessErrorCode40029_InvalidCode_ThrowsWechatApiException_400() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String body = "{\"errcode\":40029,\"errmsg\":\"invalid code\"}"; + ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("invalidCode")); + assertEquals(400, ex.getCode()); + assertTrue(ex.getMessage().contains("40029") || ex.getMessage().contains("invalid")); + } + + @Test + @DisplayName("8. 业务错误码 40163(code been used) - 抛 WechatApiException(400)") + void code2Session_BusinessErrorCode40163_CodeUsed_ThrowsWechatApiException_400() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String body = "{\"errcode\":40163,\"errmsg\":\"code been used\"}"; + ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("usedCode")); + assertEquals(400, ex.getCode()); + } + + @Test + @DisplayName("9. 网络异常(ResourceAccessException) - 抛 WechatApiException(502)") + void code2Session_NetworkException_ResourceAccessException_ThrowsWechatApiException_502() { + when(restTemplate.getForEntity(anyString(), eq(String.class))) + .thenThrow(new ResourceAccessException("Connection refused")); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("code")); + assertEquals(502, ex.getCode()); + assertTrue(ex.getMessage().contains("网络") || ex.getMessage().contains("network")); + } + + @Test + @DisplayName("10. 响应缺少 session_key - 抛 WechatApiException(502)") + void code2Session_MissingSessionKey_ThrowsWechatApiException_502() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String body = "{\"errcode\":0,\"openid\":\"oXyz123\"}"; + ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("code")); + assertEquals(502, ex.getCode()); + assertTrue(ex.getMessage().contains("session_key")); + } + + @Test + @DisplayName("11. 其他 RestClientException(非 ResourceAccessException)- 抛 WechatApiException(502)") + void code2Session_OtherRestClientException_ThrowsWechatApiException_502() { + when(restTemplate.getForEntity(anyString(), eq(String.class))) + .thenThrow(new HttpClientErrorException(HttpStatus.FORBIDDEN, "Forbidden", null, null, null)); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("code")); + assertEquals(502, ex.getCode()); + assertTrue(ex.getMessage().contains("RestClient") || ex.getMessage().contains("客户端")); + } + + @Test + @DisplayName("12. 响应缺少 openid(有 session_key)- 抛 WechatApiException(502)") + void code2Session_MissingOpenId_ThrowsWechatApiException_502() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String body = "{\"errcode\":0,\"session_key\":\"abc456\"}"; // no openid + ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + + WechatApiException ex = assertThrows(WechatApiException.class, + () -> service.code2Session("code")); + assertEquals(502, ex.getCode()); + assertTrue(ex.getMessage().contains("openid") || ex.getMessage().contains("session_key")); + } +}