diff --git a/pom.xml b/pom.xml index 2502b92..d170367 100644 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,11 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-webmvc-test + test + org.springframework.security spring-security-test diff --git a/src/main/java/com/yau/digitalrmb/shared/api/ApiResponse.java b/src/main/java/com/yau/digitalrmb/shared/api/ApiResponse.java new file mode 100644 index 0000000..913465d --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/api/ApiResponse.java @@ -0,0 +1,19 @@ +package com.yau.digitalrmb.shared.api; + +import java.time.Instant; + +public record ApiResponse( + String code, + String message, + T data, + String traceId, + Instant timestamp +) { + public static ApiResponse success(T data, String traceId) { + return new ApiResponse<>(ErrorCode.SUCCESS.name(), "success", data, traceId, Instant.now()); + } + + public static ApiResponse failure(ErrorCode errorCode, String message, String traceId) { + return new ApiResponse<>(errorCode.name(), message, null, traceId, Instant.now()); + } +} diff --git a/src/main/java/com/yau/digitalrmb/shared/api/ErrorCode.java b/src/main/java/com/yau/digitalrmb/shared/api/ErrorCode.java new file mode 100644 index 0000000..96a8b91 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/api/ErrorCode.java @@ -0,0 +1,10 @@ +package com.yau.digitalrmb.shared.api; + +public enum ErrorCode { + SUCCESS, + VALIDATION_ERROR, + UNAUTHORIZED, + FORBIDDEN, + RESOURCE_NOT_FOUND, + INTERNAL_ERROR +} diff --git a/src/main/java/com/yau/digitalrmb/shared/exception/BusinessException.java b/src/main/java/com/yau/digitalrmb/shared/exception/BusinessException.java new file mode 100644 index 0000000..d255555 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/exception/BusinessException.java @@ -0,0 +1,16 @@ +package com.yau.digitalrmb.shared.exception; + +import com.yau.digitalrmb.shared.api.ErrorCode; + +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/com/yau/digitalrmb/shared/interfaces/DiagnosticController.java b/src/main/java/com/yau/digitalrmb/shared/interfaces/DiagnosticController.java new file mode 100644 index 0000000..8a7646a --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/interfaces/DiagnosticController.java @@ -0,0 +1,21 @@ +package com.yau.digitalrmb.shared.interfaces; + +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.web.TraceIdFilter; +import jakarta.validation.constraints.NotBlank; +import org.slf4j.MDC; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/diagnostics") +public class DiagnosticController { + @GetMapping("/validation") + public ApiResponse validate(@RequestParam @NotBlank String value) { + return ApiResponse.success(value, MDC.get(TraceIdFilter.MDC_KEY)); + } +} diff --git a/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java b/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java new file mode 100644 index 0000000..5f49348 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/web/GlobalExceptionHandler.java @@ -0,0 +1,39 @@ +package com.yau.digitalrmb.shared.web; + +import com.yau.digitalrmb.shared.api.ApiResponse; +import com.yau.digitalrmb.shared.api.ErrorCode; +import com.yau.digitalrmb.shared.exception.BusinessException; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.MDC; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.UUID; + +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler({ConstraintViolationException.class, MethodArgumentNotValidException.class}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleValidation(Exception exception) { + return ApiResponse.failure(ErrorCode.VALIDATION_ERROR, exception.getMessage(), traceId()); + } + + @ExceptionHandler(BusinessException.class) + public ApiResponse handleBusiness(BusinessException exception) { + return ApiResponse.failure(exception.getErrorCode(), exception.getMessage(), traceId()); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleUnexpected(Exception exception) { + return ApiResponse.failure(ErrorCode.INTERNAL_ERROR, "系统内部错误", traceId()); + } + + private String traceId() { + String traceId = MDC.get(TraceIdFilter.MDC_KEY); + return traceId == null ? UUID.randomUUID().toString() : traceId; + } +} diff --git a/src/main/java/com/yau/digitalrmb/shared/web/TraceIdFilter.java b/src/main/java/com/yau/digitalrmb/shared/web/TraceIdFilter.java new file mode 100644 index 0000000..fccd3d6 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/shared/web/TraceIdFilter.java @@ -0,0 +1,34 @@ +package com.yau.digitalrmb.shared.web; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +@Component +public class TraceIdFilter extends OncePerRequestFilter { + public static final String HEADER_NAME = "X-Trace-Id"; + public static final String MDC_KEY = "traceId"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String traceId = request.getHeader(HEADER_NAME); + if (traceId == null || traceId.isBlank()) { + traceId = UUID.randomUUID().toString(); + } + MDC.put(MDC_KEY, traceId); + response.setHeader(HEADER_NAME, traceId); + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove(MDC_KEY); + } + } +} diff --git a/src/test/java/com/yau/digitalrmb/shared/GlobalExceptionHandlerTest.java b/src/test/java/com/yau/digitalrmb/shared/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..5e19e4f --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/shared/GlobalExceptionHandlerTest.java @@ -0,0 +1,29 @@ +package com.yau.digitalrmb.shared; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class GlobalExceptionHandlerTest { + + @Autowired + private MockMvc mvc; + + @Test + void blankParameterReturnsStandardBadRequest() throws Exception { + mvc.perform(get("/api/v1/diagnostics/validation").param("value", "")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("VALIDATION_ERROR")) + .andExpect(jsonPath("$.traceId").isNotEmpty()); + } +}