fix: 统一 Web 异常的 HTTP 错误语义

- 支持业务异常声明 HTTP 状态、业务错误码与原始根因

- 规范参数错误、框架异常和未知异常的安全响应
This commit is contained in:
2026-07-17 19:54:27 +08:00
parent 1e6158be77
commit ba21f861f4
6 changed files with 410 additions and 32 deletions

View File

@@ -0,0 +1,128 @@
package tech.easyflow.common.web.error;
import org.junit.Test;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.ModelAndView;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import static org.junit.Assert.assertEquals;
/**
* {@link GlobalErrorResolver} HTTP 状态与安全错误体回归测试。
*/
public class GlobalErrorResolverTest {
private final GlobalErrorResolver resolver = new GlobalErrorResolver();
/**
* 验证业务冲突不会被包装为 HTTP 200。
*/
@Test
public void shouldPreserveBusinessHttpStatusAndErrorCode() {
Resolution resolution = resolve(new BusinessException(409, 4091, "文件版本冲突"));
assertEquals(409, resolution.response.getStatus());
assertEquals(4091, resolution.modelAndView.getModel().get("errorCode"));
assertEquals("文件版本冲突", resolution.modelAndView.getModel().get("message"));
}
/**
* 验证 Spring 标准状态异常保留 4xx 语义和安全原因。
*/
@Test
public void shouldPreserveResponseStatusException() {
Resolution resolution = resolve(new ResponseStatusException(HttpStatus.NOT_FOUND, "Skill 不存在"));
assertEquals(404, resolution.response.getStatus());
assertEquals(404, resolution.modelAndView.getModel().get("errorCode"));
assertEquals("Skill 不存在", resolution.modelAndView.getModel().get("message"));
}
/**
* 验证框架级 5xx 异常不会向客户端泄露 reason。
*/
@Test
public void shouldHideResponseStatusServerErrorReason() {
Resolution resolution = resolve(new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "database-secret-detail"));
assertEquals(500, resolution.response.getStatus());
assertEquals(500, resolution.modelAndView.getModel().get("errorCode"));
assertEquals("服务暂时不可用,请稍后重试", resolution.modelAndView.getModel().get("message"));
}
/**
* 验证注解声明的 HTTP 状态不会被统一异常处理覆盖。
*/
@Test
public void shouldPreserveAnnotatedResponseStatus() {
Resolution resolution = resolve(new AnnotatedConflictException());
assertEquals(409, resolution.response.getStatus());
assertEquals("状态冲突", resolution.modelAndView.getModel().get("message"));
}
/**
* 验证未知异常返回真实 500且不会向客户端泄露内部异常信息。
*/
@Test
public void shouldHideUnexpectedExceptionDetails() {
Resolution resolution = resolve(new IllegalStateException("database-secret-detail"));
assertEquals(500, resolution.response.getStatus());
assertEquals(500, resolution.modelAndView.getModel().get("errorCode"));
assertEquals("服务暂时不可用,请稍后重试", resolution.modelAndView.getModel().get("message"));
}
/**
* 验证请求参数类型错误返回安全的 HTTP 400。
*
* @throws Exception 构造反射参数失败
*/
@Test
public void shouldReturnSafeBadRequestForTypeMismatch() throws Exception {
MethodParameter parameter = new MethodParameter(
GlobalErrorResolverTest.class.getDeclaredMethod("sampleParameter", BigInteger.class), 0);
MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException(
"not-an-id", BigInteger.class, "id", parameter,
new NumberFormatException("sensitive-converter-detail"));
Resolution resolution = resolve(exception);
assertEquals(400, resolution.response.getStatus());
assertEquals(400, resolution.modelAndView.getModel().get("errorCode"));
assertEquals("请求参数格式不正确", resolution.modelAndView.getModel().get("message"));
}
/**
* 提供反射参数签名。
*
* @param id 示例 ID
*/
private static void sampleParameter(BigInteger id) {
// 仅用于构造 Spring MethodParameter 测试数据。
}
private Resolution resolve(Exception exception) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/skill/file/save");
MockHttpServletResponse response = new MockHttpServletResponse();
ModelAndView modelAndView = resolver.resolveException(request, response, this, exception);
return new Resolution(response, modelAndView);
}
@ResponseStatus(code = HttpStatus.CONFLICT, reason = "状态冲突")
private static final class AnnotatedConflictException extends RuntimeException {
private static final long serialVersionUID = 1L;
}
private record Resolution(MockHttpServletResponse response, ModelAndView modelAndView) {
}
}

View File

@@ -0,0 +1,56 @@
package tech.easyflow.common.web.jsonbody;
import org.junit.Test;
import org.springframework.core.MethodParameter;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
/**
* {@link JsonBodyArgumentResolver} 请求体错误语义回归测试。
*/
public class JsonBodyArgumentResolverTest {
/**
* 验证畸形 JSON 返回安全的 HTTP 400 业务异常。
*
* @throws Exception 构造反射参数失败时抛出
*/
@Test
public void shouldRejectMalformedJsonAsBadRequest() throws Exception {
RequestMappingHandlerAdapter adapter = new RequestMappingHandlerAdapter();
JsonBodyArgumentResolver resolver = new JsonBodyArgumentResolver(adapter);
Method method = JsonBodyArgumentResolverTest.class.getDeclaredMethod("sampleBody", SampleRequest.class);
MethodParameter parameter = new MethodParameter(method, 0);
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/skill/update");
request.setContentType("application/json");
request.setCharacterEncoding(StandardCharsets.UTF_8.name());
request.setContent("{\"name\":".getBytes(StandardCharsets.UTF_8));
BusinessException exception = assertThrows(BusinessException.class,
() -> resolver.resolveArgument(parameter, null, new ServletWebRequest(request), null));
assertEquals(400, exception.getHttpStatus());
assertEquals(400, exception.getErrorCode());
assertEquals("请求参数格式不正确", exception.getMessage());
}
/**
* 提供反射参数签名。
*
* @param request 示例请求
*/
private static void sampleBody(@JsonBody(skipConvertError = false) SampleRequest request) {
// 仅用于构造 MethodParameter。
}
private record SampleRequest(String name) {
}
}