发布 v1.10 #5
@@ -72,6 +72,19 @@
|
|||||||
<artifactId>jakarta.validation-api</artifactId>
|
<artifactId>jakarta.validation-api</artifactId>
|
||||||
</dependency>
|
</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>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -1,53 +1,141 @@
|
|||||||
package tech.easyflow.common.web.error;
|
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.JSON;
|
||||||
import com.alibaba.fastjson.JSONObject;
|
import com.alibaba.fastjson.JSONObject;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import tech.easyflow.common.domain.Result;
|
import jakarta.validation.ConstraintViolationException;
|
||||||
import cn.dev33.satoken.exception.NotLoginException;
|
|
||||||
import cn.dev33.satoken.exception.NotPermissionException;
|
|
||||||
import cn.dev33.satoken.exception.NotRoleException;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
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.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.HandlerExceptionResolver;
|
||||||
import org.springframework.web.servlet.ModelAndView;
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
import jakarta.validation.ConstraintViolationException;
|
import tech.easyflow.common.domain.Result;
|
||||||
import tech.easyflow.common.web.exceptions.BusinessException;
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Web 层异常转换为具有真实 HTTP 语义的统一 JSON 响应。
|
||||||
|
*/
|
||||||
public class GlobalErrorResolver implements HandlerExceptionResolver {
|
public class GlobalErrorResolver implements HandlerExceptionResolver {
|
||||||
|
|
||||||
private static final Logger LOG = LoggerFactory.getLogger(GlobalErrorResolver.class);
|
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
|
@Override
|
||||||
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||||
ex.printStackTrace();
|
|
||||||
Result<?> error;
|
Result<?> error;
|
||||||
if (ex instanceof MissingServletRequestParameterException) {
|
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) {
|
} else if (ex instanceof NotLoginException) {
|
||||||
response.setStatus(401);
|
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||||
error = Result.fail(401, "请登录");
|
error = Result.fail(401, "请登录");
|
||||||
} else if (ex instanceof NotPermissionException || ex instanceof NotRoleException) {
|
} else if (ex instanceof NotPermissionException || ex instanceof NotRoleException) {
|
||||||
error = Result.fail(4010, "无权操作");
|
response.setStatus(HttpStatus.FORBIDDEN.value());
|
||||||
} else if (ex instanceof ConstraintViolationException) {
|
error = Result.fail(403, "无权操作");
|
||||||
|
} else if (ex instanceof ConstraintViolationException || ex instanceof MethodArgumentNotValidException) {
|
||||||
|
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||||
error = Result.fail(400, ex.getMessage());
|
error = Result.fail(400, ex.getMessage());
|
||||||
} else if (ex instanceof BusinessException) {
|
} else if (ex instanceof MethodArgumentTypeMismatchException) {
|
||||||
String message = ex.getMessage();
|
response.setStatus(HttpStatus.BAD_REQUEST.value());
|
||||||
if (message != null && message.matches("^\\d{4,}:.+$")) {
|
error = Result.fail(400, "请求参数格式不正确");
|
||||||
int delimiterIndex = message.indexOf(':');
|
} else if (ex instanceof MaxUploadSizeExceededException) {
|
||||||
int errorCode = Integer.parseInt(message.substring(0, delimiterIndex));
|
response.setStatus(HttpStatus.PAYLOAD_TOO_LARGE.value());
|
||||||
error = Result.fail(errorCode, message.substring(delimiterIndex + 1));
|
error = Result.fail(413, "上传文件超过大小限制");
|
||||||
} else {
|
} else if (ex instanceof BusinessException businessException) {
|
||||||
error = Result.fail(1, message);
|
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 {
|
} else {
|
||||||
LOG.error(ex.toString(), ex);
|
ResponseStatus responseStatus = AnnotatedElementUtils.findMergedAnnotation(ex.getClass(), ResponseStatus.class);
|
||||||
error = Result.fail(1, "错误信息:" + ex.getMessage());
|
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));
|
JSONObject object = JSON.parseObject(JSON.toJSONString(error));
|
||||||
return new ModelAndView(new JakartaJsonView())
|
return new ModelAndView(new JakartaJsonView())
|
||||||
.addAllObjects(object);
|
.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;
|
package tech.easyflow.common.web.exceptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 业务报错
|
* 可安全返回给客户端的业务异常。
|
||||||
|
*
|
||||||
|
* <p>业务异常默认使用 HTTP 400。需要表达冲突、无权限、资源不存在等语义时,
|
||||||
|
* 调用方应通过带状态码的构造函数显式指定 HTTP 状态和稳定业务错误码。</p>
|
||||||
*/
|
*/
|
||||||
public class BusinessException extends RuntimeException {
|
public class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private final int httpStatus;
|
||||||
|
private final int errorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建默认的 HTTP 400 业务异常。
|
||||||
|
*/
|
||||||
public BusinessException() {
|
public BusinessException() {
|
||||||
|
this(400, 1, "请求处理失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建默认的 HTTP 400 业务异常。
|
||||||
|
*
|
||||||
|
* @param msg 可安全展示给客户端的错误消息
|
||||||
|
*/
|
||||||
public BusinessException(String 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;
|
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.ConvertUtil;
|
||||||
import com.mybatisflex.core.util.StringUtil;
|
import com.mybatisflex.core.util.StringUtil;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
import org.springframework.beans.factory.SmartInitializingSingleton;
|
||||||
import org.springframework.core.MethodParameter;
|
import org.springframework.core.MethodParameter;
|
||||||
import org.springframework.stereotype.Component;
|
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.HandlerMethodArgumentResolver;
|
||||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
|
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.Type;
|
||||||
import java.lang.reflect.TypeVariable;
|
import java.lang.reflect.TypeVariable;
|
||||||
@@ -19,16 +20,26 @@ import java.util.ArrayList;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将带 {@link JsonBody} 注解的方法参数从共享 JSON 请求体中解析出来。
|
||||||
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class JsonBodyArgumentResolver implements HandlerMethodArgumentResolver, SmartInitializingSingleton {
|
public class JsonBodyArgumentResolver implements HandlerMethodArgumentResolver, SmartInitializingSingleton {
|
||||||
|
|
||||||
private RequestMappingHandlerAdapter requestMappingHandlerAdapter;
|
private final RequestMappingHandlerAdapter requestMappingHandlerAdapter;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 JSON 请求体参数解析器。
|
||||||
|
*
|
||||||
|
* @param requestMappingHandlerAdapter Spring MVC 处理器适配器
|
||||||
|
*/
|
||||||
public JsonBodyArgumentResolver(RequestMappingHandlerAdapter requestMappingHandlerAdapter) {
|
public JsonBodyArgumentResolver(RequestMappingHandlerAdapter requestMappingHandlerAdapter) {
|
||||||
this.requestMappingHandlerAdapter = requestMappingHandlerAdapter;
|
this.requestMappingHandlerAdapter = requestMappingHandlerAdapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void afterSingletonsInstantiated() {
|
public void afterSingletonsInstantiated() {
|
||||||
List<HandlerMethodArgumentResolver> argumentResolvers = requestMappingHandlerAdapter.getArgumentResolvers();
|
List<HandlerMethodArgumentResolver> argumentResolvers = requestMappingHandlerAdapter.getArgumentResolvers();
|
||||||
@@ -37,13 +48,17 @@ public class JsonBodyArgumentResolver implements HandlerMethodArgumentResolver,
|
|||||||
requestMappingHandlerAdapter.setArgumentResolvers(resolvers);
|
requestMappingHandlerAdapter.setArgumentResolvers(resolvers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsParameter(MethodParameter parameter) {
|
public boolean supportsParameter(MethodParameter parameter) {
|
||||||
return parameter.hasParameterAnnotation(JsonBody.class);
|
return parameter.hasParameterAnnotation(JsonBody.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer
|
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer
|
||||||
, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
|
, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
|
||||||
@@ -56,8 +71,6 @@ public class JsonBodyArgumentResolver implements HandlerMethodArgumentResolver,
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object jsonObjectOrArray = RequestUtil.readJsonObjectOrArray(request);
|
|
||||||
|
|
||||||
Object result = null;
|
Object result = null;
|
||||||
Type paraType = parameter.getGenericParameterType();
|
Type paraType = parameter.getGenericParameterType();
|
||||||
if (paraType instanceof TypeVariable) {
|
if (paraType instanceof TypeVariable) {
|
||||||
@@ -69,12 +82,13 @@ public class JsonBodyArgumentResolver implements HandlerMethodArgumentResolver,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
Object jsonObjectOrArray = RequestUtil.readJsonObjectOrArray(request);
|
||||||
result = JsonBodyParser.parseJsonBody(jsonObjectOrArray, paraClass, paraType, jsonBody.value());
|
result = JsonBodyParser.parseJsonBody(jsonObjectOrArray, paraClass, paraType, jsonBody.value());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
if (jsonBody.skipConvertError()) {
|
if (jsonBody.skipConvertError()) {
|
||||||
//ignore
|
//ignore
|
||||||
} else {
|
} 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()) {
|
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;
|
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