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

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;

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) {
}
}