fix(api): map Spring MVC client errors to 4xx

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-08-04 16:12:01 +08:00
parent 7b3b4c9337
commit a92bb89ceb
4 changed files with 150 additions and 0 deletions

View file

@ -10,17 +10,25 @@ import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.security.SensitiveLogSanitizer;
import com.iflytek.skillhub.storage.StorageAccessException;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* Translates application, domain, auth, and infrastructure exceptions into the platform's JSON API
@ -83,6 +91,48 @@ public class GlobalExceptionHandler {
apiResponseFactory.error(400, "error.badRequest"));
}
@ExceptionHandler({
MissingServletRequestParameterException.class,
HttpMessageNotReadableException.class,
MethodArgumentTypeMismatchException.class
})
public ResponseEntity<ApiResponse<Void>> handleMvcBadRequest(Exception ex, HttpServletRequest request) {
logHandledException(HttpStatus.BAD_REQUEST, "error.badRequest", request);
return ResponseEntity.badRequest().body(
apiResponseFactory.error(400, "error.badRequest"));
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodNotAllowed(
HttpRequestMethodNotSupportedException ex,
HttpServletRequest request) {
logHandledException(HttpStatus.METHOD_NOT_ALLOWED, "error.methodNotAllowed", request);
ResponseEntity.BodyBuilder response = ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED);
Set<HttpMethod> supportedMethods = ex.getSupportedHttpMethods();
if (supportedMethods != null && !supportedMethods.isEmpty()) {
response.allow(supportedMethods.toArray(HttpMethod[]::new));
}
return response.body(apiResponseFactory.error(405, "error.methodNotAllowed"));
}
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleUnsupportedMediaType(
HttpMediaTypeNotSupportedException ex,
HttpServletRequest request) {
logHandledException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "error.unsupportedMediaType", request);
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(
apiResponseFactory.error(415, "error.unsupportedMediaType"));
}
@ExceptionHandler(HttpMediaTypeNotAcceptableException.class)
public ResponseEntity<ApiResponse<Void>> handleNotAcceptable(
HttpMediaTypeNotAcceptableException ex,
HttpServletRequest request) {
logHandledException(HttpStatus.NOT_ACCEPTABLE, "error.notAcceptable", request);
return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).body(
apiResponseFactory.error(406, "error.notAcceptable"));
}
@ExceptionHandler(SecurityException.class)
public ResponseEntity<ApiResponse<Void>> handleForbidden(SecurityException ex, HttpServletRequest request) {
logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request);

View file

@ -47,6 +47,9 @@ error.auth.sessionBootstrap.disabled=Session bootstrap is disabled
error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap provider: {0}
error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found
error.badRequest=Invalid request
error.methodNotAllowed=HTTP method is not supported
error.unsupportedMediaType=Unsupported media type
error.notAcceptable=Requested response media type is not acceptable
error.forbidden=Forbidden
error.apiToken.scope.missing=API token is missing required scope: {0}
error.apiToken.endpoint.unsupported=API token cannot access endpoint: {0}

View file

@ -47,6 +47,9 @@ error.auth.sessionBootstrap.disabled=会话引导能力未启用
error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供方:{0}
error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话
error.badRequest=请求参数不合法
error.methodNotAllowed=不支持的请求方法
error.unsupportedMediaType=不支持的请求内容类型
error.notAcceptable=无法返回客户端接受的内容类型
error.forbidden=没有权限执行该操作
error.apiToken.scope.missing=API 令牌缺少所需权限范围:{0}
error.apiToken.endpoint.unsupported=API 令牌无法访问接口:{0}

View file

@ -29,10 +29,18 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.context.support.StaticMessageSource;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.HttpMediaTypeNotAcceptableException;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {
@ -59,6 +67,11 @@ class GlobalExceptionHandlerTest {
void setUp() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out");
messageSource.addMessage("error.badRequest", java.util.Locale.getDefault(), "Invalid request");
messageSource.addMessage("error.methodNotAllowed", java.util.Locale.getDefault(), "HTTP method is not supported");
messageSource.addMessage("error.unsupportedMediaType", java.util.Locale.getDefault(), "Unsupported media type");
messageSource.addMessage("error.notAcceptable", java.util.Locale.getDefault(),
"Requested response media type is not acceptable");
requestIdAccessor = new RequestIdAccessor();
ApiResponseFactory responseFactory = new ApiResponseFactory(
messageSource,
@ -175,6 +188,82 @@ class GlobalExceptionHandlerTest {
.isSameAs(ex);
}
@Test
void handleMvcBadRequest_shouldReturn400WithoutUnhandledErrorLog() {
attachAppender();
prepareClientErrorRequest("POST", "/api/v1/skills?bad=value");
List<Exception> exceptions = List.of(
new MissingServletRequestParameterException("namespace", "String"),
new HttpMessageNotReadableException("Malformed request body"),
new MethodArgumentTypeMismatchException(
"bad-value", Long.class, "id", null, new NumberFormatException("bad-value"))
);
for (Exception exception : exceptions) {
ResponseEntity<ApiResponse<Void>> response = handler.handleMvcBadRequest(exception, request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(400);
}
assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
.contains("status=400")
.contains("code=error.badRequest")
.doesNotContain("Unhandled API exception"));
}
@Test
void handleMethodNotAllowed_shouldReturn405AndAllowHeader() {
attachAppender();
prepareClientErrorRequest("DELETE", "/api/v1/skills/demo");
ResponseEntity<ApiResponse<Void>> response = handler.handleMethodNotAllowed(
new HttpRequestMethodNotSupportedException("DELETE", List.of("GET", "POST")), request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.METHOD_NOT_ALLOWED);
assertThat(response.getHeaders().getAllow()).containsExactlyInAnyOrder(HttpMethod.GET, HttpMethod.POST);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(405);
assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
.contains("status=405")
.contains("code=error.methodNotAllowed"));
}
@Test
void handleUnsupportedMediaType_shouldReturn415() {
attachAppender();
prepareClientErrorRequest("POST", "/api/v1/skills");
ResponseEntity<ApiResponse<Void>> response = handler.handleUnsupportedMediaType(
new HttpMediaTypeNotSupportedException(
MediaType.APPLICATION_XML,
List.of(MediaType.APPLICATION_JSON)), request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNSUPPORTED_MEDIA_TYPE);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(415);
assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
.contains("status=415")
.contains("code=error.unsupportedMediaType"));
}
@Test
void handleNotAcceptable_shouldReturn406() {
attachAppender();
prepareClientErrorRequest("GET", "/api/v1/skills");
ResponseEntity<ApiResponse<Void>> response = handler.handleNotAcceptable(
new HttpMediaTypeNotAcceptableException(List.of(MediaType.APPLICATION_JSON)), request);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_ACCEPTABLE);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo(406);
assertThat(loggedMessages()).anySatisfy(message -> assertThat(message)
.contains("status=406")
.contains("code=error.notAcceptable"));
}
private void authenticateRequest() {
PlatformPrincipal principal = new PlatformPrincipal(
STABLE_USER_ID,
@ -188,6 +277,11 @@ class GlobalExceptionHandlerTest {
new UsernamePasswordAuthenticationToken(principal, null, List.of()));
}
private void prepareClientErrorRequest(String method, String sanitizedTarget) {
when(request.getMethod()).thenReturn(method);
when(sensitiveLogSanitizer.sanitizeRequestTarget(request)).thenReturn(sanitizedTarget);
}
private void attachAppender() {
logger.setLevel(Level.INFO);
appender = new ListAppender<>();