Commit 8458fa5a32df1c4a96a1d53ddfe04896cf690fdb
1 parent
76218f38
feat(usr): 通用响应包装 + 全局异常处理 REQ-USR-001
Showing
5 changed files
with
217 additions
and
0 deletions
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 | +} | ... | ... |