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 44c9a004..851f6683 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 @@ -21,6 +21,7 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.async.AsyncRequestTimeoutException; /** * Translates application, domain, auth, and infrastructure exceptions into the platform's JSON API @@ -111,6 +112,19 @@ public class GlobalExceptionHandler { apiResponseFactory.error(503, "error.storage.unavailable")); } + @ExceptionHandler(AsyncRequestTimeoutException.class) + public ResponseEntity handleAsyncRequestTimeout(AsyncRequestTimeoutException ex, HttpServletRequest request) { + String path = request.getRequestURI(); + if (path != null && path.endsWith("/sse")) { + logger.debug("SSE timeout [requestId={}, path={}]", MDC.get("requestId"), path); + return ResponseEntity.noContent().build(); + } + + logHandledException(HttpStatus.REQUEST_TIMEOUT, "error.request.timeout", request); + return ResponseEntity.status(HttpStatus.REQUEST_TIMEOUT).body( + apiResponseFactory.error(408, "error.request.timeout")); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleGlobalException(Exception ex, HttpServletRequest request) { logger.error( @@ -122,7 +136,7 @@ public class GlobalExceptionHandler { ex ); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( - apiResponseFactory.error(500, "error.internal")); + apiResponseFactory.error(500, "error.internal")); } private void logHandledException(HttpStatus status, String messageCode, HttpServletRequest request) { diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 2a9acc9e..83ac024f 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -47,6 +47,7 @@ error.auth.sessionBootstrap.providerUnsupported=Unsupported session bootstrap pr error.auth.sessionBootstrap.notAuthenticated=No authenticated external session found error.badRequest=Invalid request error.forbidden=Forbidden +error.request.timeout=Request timed out error.rateLimit.exceeded=Rate limit exceeded error.storage.unavailable=Object storage is temporarily unavailable. Please try again later. error.internal=An unexpected error occurred diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 2f249b71..abb834c3 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -47,6 +47,7 @@ error.auth.sessionBootstrap.providerUnsupported=不支持的会话引导提供 error.auth.sessionBootstrap.notAuthenticated=未检测到已认证的外部会话 error.badRequest=请求参数不合法 error.forbidden=没有权限执行该操作 +error.request.timeout=请求超时 error.rateLimit.exceeded=请求过于频繁,请稍后再试 error.storage.unavailable=对象存储暂时不可用,请稍后再试 error.internal=服务器内部错误 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 new file mode 100644 index 00000000..05b7ed16 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,73 @@ +package com.iflytek.skillhub.exception; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.metrics.SkillHubMetrics; +import com.iflytek.skillhub.security.SensitiveLogSanitizer; +import jakarta.servlet.http.HttpServletRequest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.support.StaticMessageSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.context.request.async.AsyncRequestTimeoutException; + +@ExtendWith(MockitoExtension.class) +class GlobalExceptionHandlerTest { + + @Mock + private SensitiveLogSanitizer sensitiveLogSanitizer; + + @Mock + private SkillHubMetrics metrics; + + @Mock + private HttpServletRequest request; + + private GlobalExceptionHandler handler; + + @BeforeEach + void setUp() { + StaticMessageSource messageSource = new StaticMessageSource(); + messageSource.addMessage("error.request.timeout", java.util.Locale.getDefault(), "Request timed out"); + ApiResponseFactory responseFactory = new ApiResponseFactory( + messageSource, + Clock.fixed(Instant.parse("2026-03-20T00:00:00Z"), ZoneOffset.UTC) + ); + handler = new GlobalExceptionHandler(responseFactory, sensitiveLogSanitizer, metrics); + } + + @Test + void handleAsyncRequestTimeout_shouldReturnNoContentForSseRequests() { + when(request.getRequestURI()).thenReturn("/api/v1/notifications/sse"); + + ResponseEntity response = handler.handleAsyncRequestTimeout(new AsyncRequestTimeoutException(), request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + } + + @Test + void handleAsyncRequestTimeout_shouldReturnApiEnvelopeForNonSseRequests() { + when(request.getRequestURI()).thenReturn("/api/v1/publish"); + when(request.getMethod()).thenReturn("POST"); + when(sensitiveLogSanitizer.sanitizeRequestTarget(request)).thenReturn("/api/v1/publish"); + + ResponseEntity response = handler.handleAsyncRequestTimeout(new AsyncRequestTimeoutException(), request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.REQUEST_TIMEOUT); + assertThat(response.getBody()).isInstanceOf(ApiResponse.class); + ApiResponse body = (ApiResponse) response.getBody(); + assertThat(body.code()).isEqualTo(408); + assertThat(body.msg()).isEqualTo("Request timed out"); + } +}