diff --git a/backend/src/main/java/com/xly/erp/common/exception/BizException.java b/backend/src/main/java/com/xly/erp/common/exception/BizException.java new file mode 100644 index 0000000..3391bc0 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/exception/BizException.java @@ -0,0 +1,22 @@ +package com.xly.erp.common.exception; + +import lombok.Getter; + +/** + * 业务异常 — 由 service 层抛出,由 GlobalExceptionHandler 统一转 Result.fail。 + * docs/04 § 1.4。 + */ +@Getter +public class BizException extends RuntimeException { + private final int code; + + public BizException(int code, String message) { + super(message); + this.code = code; + } + + public BizException(int code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8bc6a3b --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,54 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.common.response.Result; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 全局异常处理器。 + * 把 BizException / 参数校验异常 / 兜底异常转 Result.fail 统一响应。 + * docs/04 § 1.4。 + */ +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @ExceptionHandler(BizException.class) + public ResponseEntity> handleBiz(BizException e) { + log.warn("[BizException] code={} message={}", e.getCode(), e.getMessage()); + return ResponseEntity + .status(ErrorCode.toHttpStatus(e.getCode())) + .body(Result.fail(e.getCode(), e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + String msg = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(fe -> fe.getField() + " " + fe.getDefaultMessage()) + .orElse("参数校验失败"); + return ResponseEntity + .status(400) + .body(Result.fail(ErrorCode.BAD_REQUEST, msg)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraint(ConstraintViolationException e) { + return ResponseEntity + .status(400) + .body(Result.fail(ErrorCode.BAD_REQUEST, e.getMessage())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleFallback(Exception e) { + log.error("[Unhandled] {}", e.getMessage(), e); + return ResponseEntity + .status(500) + .body(Result.fail(ErrorCode.INTERNAL_ERROR, "服务器内部错误")); + } +} diff --git a/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java b/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java new file mode 100644 index 0000000..387dc5b --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/response/ErrorCode.java @@ -0,0 +1,38 @@ +package com.xly.erp.common.response; + +/** + * 全局错误码定义。 + * 段位约定见 docs/04 § 1.3。 + */ +public final class ErrorCode { + + private ErrorCode() {} + + public static final int OK = 200; + + public static final int BAD_REQUEST = 40001; + public static final int COMPANY_NOT_FOUND = 40004; + + public static final int BAD_CREDENTIALS = 40101; + public static final int ACCOUNT_DELETED = 40103; + + public static final int ACCOUNT_LOCKED = 42301; + + public static final int INTERNAL_ERROR = 50000; + + /** + * 业务 code → HTTP 状态码映射。 + */ + public static int toHttpStatus(int code) { + if (code == OK) return 200; + if (code == ACCOUNT_LOCKED) return 423; + int hundreds = code / 100; + if (hundreds == 400) return 400; + if (hundreds == 401) return 401; + if (hundreds == 403) return 403; + if (hundreds == 404) return 404; + if (hundreds == 423) return 423; + if (hundreds == 500) return 500; + return 500; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/response/Result.java b/backend/src/main/java/com/xly/erp/common/response/Result.java new file mode 100644 index 0000000..e76b181 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/response/Result.java @@ -0,0 +1,34 @@ +package com.xly.erp.common.response; + +import lombok.Getter; + +/** + * 统一响应包装。 + * docs/04 § 1.3。 + */ +@Getter +public class Result { + private final int code; + private final String message; + private final T data; + private final long timestamp; + + private Result(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + this.timestamp = System.currentTimeMillis(); + } + + public static Result ok(T data) { + return new Result<>(ErrorCode.OK, "操作成功", data); + } + + public static Result ok() { + return new Result<>(ErrorCode.OK, "操作成功", null); + } + + public static Result fail(int code, String message) { + return new Result<>(code, message, null); + } +} diff --git a/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..3f4c461 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,69 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.ErrorCode; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +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 +@ActiveProfiles("test") +@org.springframework.context.annotation.Import(GlobalExceptionHandlerTest.ThrowingTestController.class) +class GlobalExceptionHandlerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void bizException_locked_returns423_withCode42301() throws Exception { + mockMvc.perform(get("/_test/throw/locked")) + .andExpect(status().isLocked()) + .andExpect(jsonPath("$.code").value(ErrorCode.ACCOUNT_LOCKED)) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + void bizException_badCredentials_returns401_withCode40101() throws Exception { + mockMvc.perform(get("/_test/throw/bad-credentials")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); + } + + @Test + void unexpectedException_returns500_doesNotLeakStackTrace() throws Exception { + mockMvc.perform(get("/_test/throw/runtime")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value(ErrorCode.INTERNAL_ERROR)) + .andExpect(jsonPath("$.message").value(not(containsString("java.")))) + .andExpect(jsonPath("$.message").value(not(containsString("Exception")))); + } + + @RestController + static class ThrowingTestController { + @GetMapping("/_test/throw/locked") + public void locked() { + throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试"); + } + + @GetMapping("/_test/throw/bad-credentials") + public void badCredentials() { + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误"); + } + + @GetMapping("/_test/throw/runtime") + public void runtime() { + throw new RuntimeException("internal boom java.lang.NullPointerException"); + } + } +}