From ba21f861f4473e7703efa3b0e1b7e1319ac9a17a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com>
Date: Fri, 17 Jul 2026 19:54:27 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=20Web=20=E5=BC=82?=
=?UTF-8?q?=E5=B8=B8=E7=9A=84=20HTTP=20=E9=94=99=E8=AF=AF=E8=AF=AD?=
=?UTF-8?q?=E4=B9=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 支持业务异常声明 HTTP 状态、业务错误码与原始根因
- 规范参数错误、框架异常和未知异常的安全响应
---
easyflow-commons/easyflow-common-web/pom.xml | 13 ++
.../common/web/error/GlobalErrorResolver.java | 128 +++++++++++++++---
.../web/exceptions/BusinessException.java | 82 ++++++++++-
.../jsonbody/JsonBodyArgumentResolver.java | 35 +++--
.../web/error/GlobalErrorResolverTest.java | 128 ++++++++++++++++++
.../JsonBodyArgumentResolverTest.java | 56 ++++++++
6 files changed, 410 insertions(+), 32 deletions(-)
create mode 100644 easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/error/GlobalErrorResolverTest.java
create mode 100644 easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/jsonbody/JsonBodyArgumentResolverTest.java
diff --git a/easyflow-commons/easyflow-common-web/pom.xml b/easyflow-commons/easyflow-common-web/pom.xml
index cc519276..7fb16163 100644
--- a/easyflow-commons/easyflow-common-web/pom.xml
+++ b/easyflow-commons/easyflow-common-web/pom.xml
@@ -72,6 +72,19 @@
jakarta.validation-api
+
+ org.springframework.boot
+ spring-boot-starter-test
+ ${spring-boot.version}
+ test
+
+
+ junit
+ junit
+ ${junit.version}
+ test
+
+
diff --git a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java
index 223b6845..9bc1113e 100644
--- a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java
+++ b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/error/GlobalErrorResolver.java
@@ -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;
+ }
}
diff --git a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/exceptions/BusinessException.java b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/exceptions/BusinessException.java
index 6fbd61c3..432ca530 100644
--- a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/exceptions/BusinessException.java
+++ b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/exceptions/BusinessException.java
@@ -1,14 +1,92 @@
package tech.easyflow.common.web.exceptions;
/**
- * 业务报错
+ * 可安全返回给客户端的业务异常。
+ *
+ *
业务异常默认使用 HTTP 400。需要表达冲突、无权限、资源不存在等语义时,
+ * 调用方应通过带状态码的构造函数显式指定 HTTP 状态和稳定业务错误码。
*/
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;
}
}
diff --git a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/jsonbody/JsonBodyArgumentResolver.java b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/jsonbody/JsonBodyArgumentResolver.java
index ebadf6a8..927dc98e 100644
--- a/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/jsonbody/JsonBodyArgumentResolver.java
+++ b/easyflow-commons/easyflow-common-web/src/main/java/tech/easyflow/common/web/jsonbody/JsonBodyArgumentResolver.java
@@ -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 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;
diff --git a/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/error/GlobalErrorResolverTest.java b/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/error/GlobalErrorResolverTest.java
new file mode 100644
index 00000000..0f95886d
--- /dev/null
+++ b/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/error/GlobalErrorResolverTest.java
@@ -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) {
+ }
+}
diff --git a/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/jsonbody/JsonBodyArgumentResolverTest.java b/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/jsonbody/JsonBodyArgumentResolverTest.java
new file mode 100644
index 00000000..c1aa0c2f
--- /dev/null
+++ b/easyflow-commons/easyflow-common-web/src/test/java/tech/easyflow/common/web/jsonbody/JsonBodyArgumentResolverTest.java
@@ -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) {
+ }
+}