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:
+110
@@ -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 不是 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("微信小程序登录未配置");
|
||||
}
|
||||
}
|
||||
}
|
||||
+224
@@ -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/html(WAF 拦截页)</li>
|
||||
* <li>JSON 解析失败</li>
|
||||
* <li>业务错误码 40029(invalid code)</li>
|
||||
* <li>业务错误码 40163(code 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/html(WAF 拦截页) - 抛 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. 业务错误码 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<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. 业务错误码 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<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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user