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());
+ }
+}