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