fix: 统一 Web 异常的 HTTP 错误语义
- 支持业务异常声明 HTTP 状态、业务错误码与原始根因 - 规范参数错误、框架异常和未知异常的安全响应
This commit is contained in:
@@ -72,6 +72,19 @@
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<version>${spring-boot.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -1,53 +1,141 @@
|
||||
package tech.easyflow.common.web.error;
|
||||
|
||||
import cn.dev33.satoken.exception.NotLoginException;
|
||||
import cn.dev33.satoken.exception.NotPermissionException;
|
||||
import cn.dev33.satoken.exception.NotRoleException;
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import cn.dev33.satoken.exception.NotLoginException;
|
||||
import cn.dev33.satoken.exception.NotPermissionException;
|
||||
import cn.dev33.satoken.exception.NotRoleException;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.annotation.AnnotatedElementUtils;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.web.ErrorResponse;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.servlet.HandlerExceptionResolver;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import tech.easyflow.common.domain.Result;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
/**
|
||||
* 将 Web 层异常转换为具有真实 HTTP 语义的统一 JSON 响应。
|
||||
*/
|
||||
public class GlobalErrorResolver implements HandlerExceptionResolver {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(GlobalErrorResolver.class);
|
||||
private static final String INTERNAL_ERROR_MESSAGE = "服务暂时不可用,请稍后重试";
|
||||
|
||||
/**
|
||||
* 解析控制器异常并写入 HTTP 状态和统一错误体。
|
||||
*
|
||||
* @param request 当前请求
|
||||
* @param response 当前响应
|
||||
* @param handler 发生异常的处理器
|
||||
* @param ex 原始异常
|
||||
* @return JSON 视图
|
||||
*/
|
||||
@Override
|
||||
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||
ex.printStackTrace();
|
||||
Result<?> error;
|
||||
if (ex instanceof MissingServletRequestParameterException) {
|
||||
error = Result.fail(1, ((MissingServletRequestParameterException) ex).getParameterName() + " 不能为空.");
|
||||
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||
error = Result.fail(400, ((MissingServletRequestParameterException) ex).getParameterName() + " 不能为空");
|
||||
} else if (ex instanceof NotLoginException) {
|
||||
response.setStatus(401);
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
error = Result.fail(401, "请登录");
|
||||
} else if (ex instanceof NotPermissionException || ex instanceof NotRoleException) {
|
||||
error = Result.fail(4010, "无权操作");
|
||||
} else if (ex instanceof ConstraintViolationException) {
|
||||
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||
error = Result.fail(403, "无权操作");
|
||||
} else if (ex instanceof ConstraintViolationException || ex instanceof MethodArgumentNotValidException) {
|
||||
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||
error = Result.fail(400, ex.getMessage());
|
||||
} else if (ex instanceof BusinessException) {
|
||||
String message = ex.getMessage();
|
||||
if (message != null && message.matches("^\\d{4,}:.+$")) {
|
||||
int delimiterIndex = message.indexOf(':');
|
||||
int errorCode = Integer.parseInt(message.substring(0, delimiterIndex));
|
||||
error = Result.fail(errorCode, message.substring(delimiterIndex + 1));
|
||||
} else {
|
||||
error = Result.fail(1, message);
|
||||
} else if (ex instanceof MethodArgumentTypeMismatchException) {
|
||||
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||
error = Result.fail(400, "请求参数格式不正确");
|
||||
} else if (ex instanceof MaxUploadSizeExceededException) {
|
||||
response.setStatus(HttpStatus.PAYLOAD_TOO_LARGE.value());
|
||||
error = Result.fail(413, "上传文件超过大小限制");
|
||||
} else if (ex instanceof BusinessException businessException) {
|
||||
response.setStatus(businessException.getHttpStatus());
|
||||
if (businessException.getHttpStatus() >= 500) {
|
||||
LOG.error("服务端业务处理异常,method={}, uri={}, errorCode={}",
|
||||
request.getMethod(), request.getRequestURI(), businessException.getErrorCode(), businessException);
|
||||
}
|
||||
error = Result.fail(businessException.getErrorCode(), businessException.getMessage());
|
||||
} else if (ex instanceof ResponseStatusException responseStatusException) {
|
||||
response.setStatus(responseStatusException.getStatusCode().value());
|
||||
error = Result.fail(responseStatusException.getStatusCode().value(), safeReason(responseStatusException));
|
||||
} else if (ex instanceof ErrorResponse errorResponse) {
|
||||
HttpStatusCode statusCode = errorResponse.getStatusCode();
|
||||
response.setStatus(statusCode.value());
|
||||
error = Result.fail(statusCode.value(), safeClientMessage(ex, statusCode));
|
||||
} else {
|
||||
LOG.error(ex.toString(), ex);
|
||||
error = Result.fail(1, "错误信息:" + ex.getMessage());
|
||||
ResponseStatus responseStatus = AnnotatedElementUtils.findMergedAnnotation(ex.getClass(), ResponseStatus.class);
|
||||
if (responseStatus != null) {
|
||||
int status = resolveResponseStatus(responseStatus);
|
||||
response.setStatus(status);
|
||||
error = Result.fail(status, responseStatus.reason().isBlank()
|
||||
? safeClientMessage(ex, HttpStatusCode.valueOf(status)) : responseStatus.reason());
|
||||
} else {
|
||||
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
|
||||
LOG.error("未处理的 Web 请求异常,method={}, uri={}", request.getMethod(), request.getRequestURI(), ex);
|
||||
error = Result.fail(500, INTERNAL_ERROR_MESSAGE);
|
||||
}
|
||||
}
|
||||
JSONObject object = JSON.parseObject(JSON.toJSONString(error));
|
||||
return new ModelAndView(new JakartaJsonView())
|
||||
.addAllObjects(object);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取注解声明的 HTTP 状态。
|
||||
*
|
||||
* @param responseStatus 状态注解
|
||||
* @return HTTP 状态码
|
||||
*/
|
||||
private int resolveResponseStatus(ResponseStatus responseStatus) {
|
||||
return responseStatus.code().value();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回框架状态异常中可公开的错误原因。
|
||||
*
|
||||
* @param exception 状态异常
|
||||
* @return 安全客户端消息
|
||||
*/
|
||||
private String safeReason(ResponseStatusException exception) {
|
||||
if (exception.getStatusCode().is5xxServerError()) {
|
||||
LOG.error("Web 请求处理失败,status={}", exception.getStatusCode().value(), exception);
|
||||
return INTERNAL_ERROR_MESSAGE;
|
||||
}
|
||||
if (exception.getReason() != null && !exception.getReason().isBlank()) {
|
||||
return exception.getReason();
|
||||
}
|
||||
HttpStatus status = HttpStatus.resolve(exception.getStatusCode().value());
|
||||
return status == null ? "请求处理失败" : status.getReasonPhrase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据状态码选择可公开消息,并记录服务端异常。
|
||||
*
|
||||
* @param exception 原始异常
|
||||
* @param statusCode HTTP 状态
|
||||
* @return 安全客户端消息
|
||||
*/
|
||||
private String safeClientMessage(Exception exception, HttpStatusCode statusCode) {
|
||||
if (statusCode.is5xxServerError()) {
|
||||
LOG.error("Web 请求处理失败,status={}", statusCode.value(), exception);
|
||||
return INTERNAL_ERROR_MESSAGE;
|
||||
}
|
||||
String message = exception.getMessage();
|
||||
return message == null || message.isBlank() ? "请求处理失败" : message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,92 @@
|
||||
package tech.easyflow.common.web.exceptions;
|
||||
|
||||
/**
|
||||
* 业务报错
|
||||
* 可安全返回给客户端的业务异常。
|
||||
*
|
||||
* <p>业务异常默认使用 HTTP 400。需要表达冲突、无权限、资源不存在等语义时,
|
||||
* 调用方应通过带状态码的构造函数显式指定 HTTP 状态和稳定业务错误码。</p>
|
||||
*/
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final int httpStatus;
|
||||
private final int errorCode;
|
||||
|
||||
/**
|
||||
* 创建默认的 HTTP 400 业务异常。
|
||||
*/
|
||||
public BusinessException() {
|
||||
this(400, 1, "请求处理失败");
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认的 HTTP 400 业务异常。
|
||||
*
|
||||
* @param msg 可安全展示给客户端的错误消息
|
||||
*/
|
||||
public BusinessException(String msg) {
|
||||
super(msg);
|
||||
this(400, 1, msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带 HTTP 状态和业务错误码的业务异常。
|
||||
*
|
||||
* @param httpStatus HTTP 状态码,必须为 400 到 599
|
||||
* @param errorCode 稳定业务错误码,不能为成功码 0
|
||||
* @param msg 可安全展示给客户端的错误消息
|
||||
* @throws IllegalArgumentException 状态码或业务错误码不合法时抛出
|
||||
*/
|
||||
public BusinessException(int httpStatus, int errorCode, String msg) {
|
||||
this(httpStatus, errorCode, msg, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带 HTTP 状态、业务错误码和根因的业务异常。
|
||||
*
|
||||
* @param httpStatus HTTP 状态码,必须为 400 到 599
|
||||
* @param errorCode 稳定业务错误码,不能为成功码 0
|
||||
* @param msg 可安全展示给客户端的错误消息
|
||||
* @param cause 原始失败根因
|
||||
* @throws IllegalArgumentException 状态码或业务错误码不合法时抛出
|
||||
*/
|
||||
public BusinessException(int httpStatus, int errorCode, String msg, Throwable cause) {
|
||||
super(requireMessage(msg), cause);
|
||||
if (httpStatus < 400 || httpStatus > 599) {
|
||||
throw new IllegalArgumentException("HTTP 状态码必须位于 400 到 599 之间");
|
||||
}
|
||||
if (errorCode == 0) {
|
||||
throw new IllegalArgumentException("业务错误码不能为成功码 0");
|
||||
}
|
||||
this.httpStatus = httpStatus;
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应返回的 HTTP 状态码。
|
||||
*
|
||||
* @return HTTP 状态码
|
||||
*/
|
||||
public int getHttpStatus() {
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取稳定业务错误码。
|
||||
*
|
||||
* @return 业务错误码
|
||||
*/
|
||||
public int getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化可安全返回给客户端的错误消息。
|
||||
*
|
||||
* @param message 原始消息
|
||||
* @return 非空安全消息
|
||||
*/
|
||||
private static String requireMessage(String message) {
|
||||
return message == null || message.isBlank() ? "请求处理失败" : message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package tech.easyflow.common.web.jsonbody;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import tech.easyflow.common.util.RequestUtil;
|
||||
import com.mybatisflex.core.util.ConvertUtil;
|
||||
import com.mybatisflex.core.util.StringUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -12,6 +11,8 @@ import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
|
||||
import tech.easyflow.common.util.RequestUtil;
|
||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.lang.reflect.TypeVariable;
|
||||
@@ -19,16 +20,26 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 将带 {@link JsonBody} 注解的方法参数从共享 JSON 请求体中解析出来。
|
||||
*/
|
||||
@Component
|
||||
public class JsonBodyArgumentResolver implements HandlerMethodArgumentResolver, SmartInitializingSingleton {
|
||||
|
||||
private RequestMappingHandlerAdapter requestMappingHandlerAdapter;
|
||||
|
||||
private final RequestMappingHandlerAdapter requestMappingHandlerAdapter;
|
||||
|
||||
/**
|
||||
* 创建 JSON 请求体参数解析器。
|
||||
*
|
||||
* @param requestMappingHandlerAdapter Spring MVC 处理器适配器
|
||||
*/
|
||||
public JsonBodyArgumentResolver(RequestMappingHandlerAdapter requestMappingHandlerAdapter) {
|
||||
this.requestMappingHandlerAdapter = requestMappingHandlerAdapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public void afterSingletonsInstantiated() {
|
||||
List<HandlerMethodArgumentResolver> argumentResolvers = requestMappingHandlerAdapter.getArgumentResolvers();
|
||||
@@ -37,13 +48,17 @@ public class JsonBodyArgumentResolver implements HandlerMethodArgumentResolver,
|
||||
requestMappingHandlerAdapter.setArgumentResolvers(resolvers);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public boolean supportsParameter(MethodParameter parameter) {
|
||||
return parameter.hasParameterAnnotation(JsonBody.class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer
|
||||
, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
|
||||
@@ -56,8 +71,6 @@ public class JsonBodyArgumentResolver implements HandlerMethodArgumentResolver,
|
||||
return null;
|
||||
}
|
||||
|
||||
Object jsonObjectOrArray = RequestUtil.readJsonObjectOrArray(request);
|
||||
|
||||
Object result = null;
|
||||
Type paraType = parameter.getGenericParameterType();
|
||||
if (paraType instanceof TypeVariable) {
|
||||
@@ -69,12 +82,13 @@ public class JsonBodyArgumentResolver implements HandlerMethodArgumentResolver,
|
||||
}
|
||||
}
|
||||
try {
|
||||
Object jsonObjectOrArray = RequestUtil.readJsonObjectOrArray(request);
|
||||
result = JsonBodyParser.parseJsonBody(jsonObjectOrArray, paraClass, paraType, jsonBody.value());
|
||||
} catch (Exception e) {
|
||||
if (jsonBody.skipConvertError()) {
|
||||
//ignore
|
||||
} else {
|
||||
throw new IllegalArgumentException(e.getMessage(), e);
|
||||
throw new BusinessException(400, 400, "请求参数格式不正确", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +97,8 @@ public class JsonBodyArgumentResolver implements HandlerMethodArgumentResolver,
|
||||
}
|
||||
|
||||
if ((result == null) && jsonBody.required()) {
|
||||
throw new IllegalArgumentException(jsonBody.value() + " must not be null or blank");
|
||||
String field = StringUtil.hasText(jsonBody.value()) ? jsonBody.value() : "请求体";
|
||||
throw new BusinessException(400, 400, field + " 不能为空");
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user