diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java index c2c807b6..e2b90929 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/exception/GlobalExceptionHandler.java @@ -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> 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> handleMethodNotAllowed( + HttpRequestMethodNotSupportedException ex, + HttpServletRequest request) { + logHandledException(HttpStatus.METHOD_NOT_ALLOWED, "error.methodNotAllowed", request); + ResponseEntity.BodyBuilder response = ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED); + Set 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> 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> 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> handleForbidden(SecurityException ex, HttpServletRequest request) { logHandledException(HttpStatus.FORBIDDEN, "error.forbidden", request); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 79195af7..249644c7 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index d7b6b11b..b133c661 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java index b29c5428..1cfcee32 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java @@ -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 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> 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> 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> 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> 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<>();