feat(wechat): 重写 code2Session 实现 5 步防御性处理

解决问题:
- 微信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
This commit is contained in:
2026-06-03 23:03:44 +08:00
parent 012fa46d3a
commit 76d4d7465c
2 changed files with 334 additions and 0 deletions
@@ -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<String> 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 不是 JSONcontentType={}, 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("微信小程序登录未配置");
}
}
}
@@ -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 单元测试
*
* <p>10 个测试场景覆盖 5 步防御性处理的所有分支:
* <ol>
* <li>正常 JSON 成功</li>
* <li>HTTP 4xx</li>
* <li>HTTP 5xx</li>
* <li>Content-Type: text/plain</li>
* <li>Content-Type: text/htmlWAF 拦截页)</li>
* <li>JSON 解析失败</li>
* <li>业务错误码 40029invalid code</li>
* <li>业务错误码 40163code been used</li>
* <li>网络异常(ResourceAccessException</li>
* <li>响应缺少 session_key</li>
* </ol>
*/
@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<String> 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<String> 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<String> 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 = "<html>WAF Challenge</html>";
ResponseEntity<String> 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/htmlWAF 拦截页) - 抛 WechatApiException(502)")
void code2Session_ContentTypeTextHtml_WafChallenge_ThrowsWechatApiException_502() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_HTML);
String body = "<html>WAF Challenge</html>";
ResponseEntity<String> 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<String> 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. 业务错误码 40029invalid 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<String> 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. 业务错误码 40163code 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<String> 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<String> 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<String> 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"));
}
}