Commit 8458fa5a32df1c4a96a1d53ddfe04896cf690fdb

Authored by zichun
1 parent 76218f38

feat(usr): 通用响应包装 + 全局异常处理 REQ-USR-001

backend/src/main/java/com/xly/erp/common/exception/BizException.java 0 → 100644
  1 +package com.xly.erp.common.exception;
  2 +
  3 +import lombok.Getter;
  4 +
  5 +/**
  6 + * 业务异常 — 由 service 层抛出,由 GlobalExceptionHandler 统一转 Result.fail。
  7 + * docs/04 § 1.4。
  8 + */
  9 +@Getter
  10 +public class BizException extends RuntimeException {
  11 + private final int code;
  12 +
  13 + public BizException(int code, String message) {
  14 + super(message);
  15 + this.code = code;
  16 + }
  17 +
  18 + public BizException(int code, String message, Throwable cause) {
  19 + super(message, cause);
  20 + this.code = code;
  21 + }
  22 +}
... ...
backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java 0 → 100644
  1 +package com.xly.erp.common.exception;
  2 +
  3 +import com.xly.erp.common.response.ErrorCode;
  4 +import com.xly.erp.common.response.Result;
  5 +import jakarta.validation.ConstraintViolationException;
  6 +import lombok.extern.slf4j.Slf4j;
  7 +import org.springframework.http.ResponseEntity;
  8 +import org.springframework.web.bind.MethodArgumentNotValidException;
  9 +import org.springframework.web.bind.annotation.ExceptionHandler;
  10 +import org.springframework.web.bind.annotation.RestControllerAdvice;
  11 +
  12 +/**
  13 + * 全局异常处理器。
  14 + * 把 BizException / 参数校验异常 / 兜底异常转 Result.fail 统一响应。
  15 + * docs/04 § 1.4。
  16 + */
  17 +@RestControllerAdvice
  18 +@Slf4j
  19 +public class GlobalExceptionHandler {
  20 +
  21 + @ExceptionHandler(BizException.class)
  22 + public ResponseEntity<Result<Void>> handleBiz(BizException e) {
  23 + log.warn("[BizException] code={} message={}", e.getCode(), e.getMessage());
  24 + return ResponseEntity
  25 + .status(ErrorCode.toHttpStatus(e.getCode()))
  26 + .body(Result.fail(e.getCode(), e.getMessage()));
  27 + }
  28 +
  29 + @ExceptionHandler(MethodArgumentNotValidException.class)
  30 + public ResponseEntity<Result<Void>> handleValidation(MethodArgumentNotValidException e) {
  31 + String msg = e.getBindingResult().getFieldErrors().stream()
  32 + .findFirst()
  33 + .map(fe -> fe.getField() + " " + fe.getDefaultMessage())
  34 + .orElse("参数校验失败");
  35 + return ResponseEntity
  36 + .status(400)
  37 + .body(Result.fail(ErrorCode.BAD_REQUEST, msg));
  38 + }
  39 +
  40 + @ExceptionHandler(ConstraintViolationException.class)
  41 + public ResponseEntity<Result<Void>> handleConstraint(ConstraintViolationException e) {
  42 + return ResponseEntity
  43 + .status(400)
  44 + .body(Result.fail(ErrorCode.BAD_REQUEST, e.getMessage()));
  45 + }
  46 +
  47 + @ExceptionHandler(Exception.class)
  48 + public ResponseEntity<Result<Void>> handleFallback(Exception e) {
  49 + log.error("[Unhandled] {}", e.getMessage(), e);
  50 + return ResponseEntity
  51 + .status(500)
  52 + .body(Result.fail(ErrorCode.INTERNAL_ERROR, "服务器内部错误"));
  53 + }
  54 +}
... ...
backend/src/main/java/com/xly/erp/common/response/ErrorCode.java 0 → 100644
  1 +package com.xly.erp.common.response;
  2 +
  3 +/**
  4 + * 全局错误码定义。
  5 + * 段位约定见 docs/04 § 1.3。
  6 + */
  7 +public final class ErrorCode {
  8 +
  9 + private ErrorCode() {}
  10 +
  11 + public static final int OK = 200;
  12 +
  13 + public static final int BAD_REQUEST = 40001;
  14 + public static final int COMPANY_NOT_FOUND = 40004;
  15 +
  16 + public static final int BAD_CREDENTIALS = 40101;
  17 + public static final int ACCOUNT_DELETED = 40103;
  18 +
  19 + public static final int ACCOUNT_LOCKED = 42301;
  20 +
  21 + public static final int INTERNAL_ERROR = 50000;
  22 +
  23 + /**
  24 + * 业务 code → HTTP 状态码映射。
  25 + */
  26 + public static int toHttpStatus(int code) {
  27 + if (code == OK) return 200;
  28 + if (code == ACCOUNT_LOCKED) return 423;
  29 + int hundreds = code / 100;
  30 + if (hundreds == 400) return 400;
  31 + if (hundreds == 401) return 401;
  32 + if (hundreds == 403) return 403;
  33 + if (hundreds == 404) return 404;
  34 + if (hundreds == 423) return 423;
  35 + if (hundreds == 500) return 500;
  36 + return 500;
  37 + }
  38 +}
... ...
backend/src/main/java/com/xly/erp/common/response/Result.java 0 → 100644
  1 +package com.xly.erp.common.response;
  2 +
  3 +import lombok.Getter;
  4 +
  5 +/**
  6 + * 统一响应包装。
  7 + * docs/04 § 1.3。
  8 + */
  9 +@Getter
  10 +public class Result<T> {
  11 + private final int code;
  12 + private final String message;
  13 + private final T data;
  14 + private final long timestamp;
  15 +
  16 + private Result(int code, String message, T data) {
  17 + this.code = code;
  18 + this.message = message;
  19 + this.data = data;
  20 + this.timestamp = System.currentTimeMillis();
  21 + }
  22 +
  23 + public static <T> Result<T> ok(T data) {
  24 + return new Result<>(ErrorCode.OK, "操作成功", data);
  25 + }
  26 +
  27 + public static Result<Void> ok() {
  28 + return new Result<>(ErrorCode.OK, "操作成功", null);
  29 + }
  30 +
  31 + public static <T> Result<T> fail(int code, String message) {
  32 + return new Result<>(code, message, null);
  33 + }
  34 +}
... ...
backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java 0 → 100644
  1 +package com.xly.erp.common.exception;
  2 +
  3 +import com.xly.erp.common.response.ErrorCode;
  4 +import org.junit.jupiter.api.Test;
  5 +import org.springframework.beans.factory.annotation.Autowired;
  6 +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  7 +import org.springframework.boot.test.context.SpringBootTest;
  8 +import org.springframework.test.context.ActiveProfiles;
  9 +import org.springframework.test.web.servlet.MockMvc;
  10 +import org.springframework.web.bind.annotation.GetMapping;
  11 +import org.springframework.web.bind.annotation.RestController;
  12 +
  13 +import static org.hamcrest.Matchers.containsString;
  14 +import static org.hamcrest.Matchers.not;
  15 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  16 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  17 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  18 +
  19 +@SpringBootTest
  20 +@AutoConfigureMockMvc
  21 +@ActiveProfiles("test")
  22 +@org.springframework.context.annotation.Import(GlobalExceptionHandlerTest.ThrowingTestController.class)
  23 +class GlobalExceptionHandlerTest {
  24 +
  25 + @Autowired
  26 + private MockMvc mockMvc;
  27 +
  28 + @Test
  29 + void bizException_locked_returns423_withCode42301() throws Exception {
  30 + mockMvc.perform(get("/_test/throw/locked"))
  31 + .andExpect(status().isLocked())
  32 + .andExpect(jsonPath("$.code").value(ErrorCode.ACCOUNT_LOCKED))
  33 + .andExpect(jsonPath("$.data").doesNotExist());
  34 + }
  35 +
  36 + @Test
  37 + void bizException_badCredentials_returns401_withCode40101() throws Exception {
  38 + mockMvc.perform(get("/_test/throw/bad-credentials"))
  39 + .andExpect(status().isUnauthorized())
  40 + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS));
  41 + }
  42 +
  43 + @Test
  44 + void unexpectedException_returns500_doesNotLeakStackTrace() throws Exception {
  45 + mockMvc.perform(get("/_test/throw/runtime"))
  46 + .andExpect(status().isInternalServerError())
  47 + .andExpect(jsonPath("$.code").value(ErrorCode.INTERNAL_ERROR))
  48 + .andExpect(jsonPath("$.message").value(not(containsString("java."))))
  49 + .andExpect(jsonPath("$.message").value(not(containsString("Exception"))));
  50 + }
  51 +
  52 + @RestController
  53 + static class ThrowingTestController {
  54 + @GetMapping("/_test/throw/locked")
  55 + public void locked() {
  56 + throw new BizException(ErrorCode.ACCOUNT_LOCKED, "账号已锁定,请稍后再试");
  57 + }
  58 +
  59 + @GetMapping("/_test/throw/bad-credentials")
  60 + public void badCredentials() {
  61 + throw new BizException(ErrorCode.BAD_CREDENTIALS, "用户名或密码错误");
  62 + }
  63 +
  64 + @GetMapping("/_test/throw/runtime")
  65 + public void runtime() {
  66 + throw new RuntimeException("internal boom java.lang.NullPointerException");
  67 + }
  68 + }
  69 +}
... ...