From c218a720f17d94a46d098931f66617a96c21c2b4 Mon Sep 17 00:00:00 2001 From: zichun Date: Wed, 6 May 2026 17:15:02 +0800 Subject: [PATCH] feat(common): biz exception and global handler REQ-MOD-001 --- backend/src/main/java/com/xly/erp/common/exception/BizException.java | 19 +++++++++++++++++++ backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java | 35 +++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/common/exception/BizException.java create mode 100644 backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java create mode 100644 backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java 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..0df0066 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/exception/BizException.java @@ -0,0 +1,19 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.ErrorCode; +import lombok.Getter; + +@Getter +public class BizException extends RuntimeException { + private final int code; + + public BizException(ErrorCode ec) { + super(ec.getMessage()); + this.code = ec.getCode(); + } + + public BizException(ErrorCode ec, String detail) { + super(detail); + this.code = ec.getCode(); + } +} 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..1281b9d --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.ApiResponse; +import com.xly.erp.common.response.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BizException.class) + public ApiResponse handleBiz(BizException e) { + log.warn("BizException code={} message={}", e.getCode(), e.getMessage()); + return ApiResponse.fail(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ApiResponse handleValidation(MethodArgumentNotValidException e) { + FieldError fe = e.getBindingResult().getFieldError(); + String detail = fe == null + ? ErrorCode.PARAM_INVALID.getMessage() + : fe.getField() + ": " + fe.getDefaultMessage(); + return ApiResponse.fail(ErrorCode.PARAM_INVALID, detail); + } + + @ExceptionHandler(Exception.class) + public ApiResponse handleAll(Exception e) { + log.error("Uncaught exception", e); + return ApiResponse.fail(ErrorCode.INTERNAL_ERROR); + } +} 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..cf73c76 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,86 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.ErrorCode; +import jakarta.validation.constraints.NotBlank; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class GlobalExceptionHandlerTest { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new DummyController()) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @RestController + @RequestMapping("/api/__dummy") + static class DummyController { + @GetMapping("/biz") + public Object biz() { throw new BizException(ErrorCode.MOD_PARENT_NOT_FOUND); } + + @GetMapping("/runtime") + public Object runtime() { throw new RuntimeException("internal boom\nstack-line-1\nstack-line-2"); } + + @PostMapping(value = "/validation", consumes = MediaType.APPLICATION_JSON_VALUE) + public Object validation(@jakarta.validation.Valid @RequestBody Payload p) { return "ok"; } + } + + static class Payload { + @NotBlank String name; + public String getName() { return name; } + public void setName(String n) { this.name = n; } + } + + @Test + void bizException_returns200WithBizCode() throws Exception { + mockMvc.perform(get("/api/__dummy/biz")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40411)) + .andExpect(jsonPath("$.message").value("父模块不存在或已删除")); + } + + @Test + void validationException_returns200WithParamInvalidCode() throws Exception { + mockMvc.perform(post("/api/__dummy/validation") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(40010)); + } + + @Test + void uncaughtException_returns200WithInternalErrorCode() throws Exception { + mockMvc.perform(get("/api/__dummy/runtime")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(50000)); + } + + @Test + void response_doesNotContainStackTrace() throws Exception { + String body = mockMvc.perform(get("/api/__dummy/runtime")) + .andReturn().getResponse().getContentAsString(); + assertThat(body) + .doesNotContain("stack-line-1") + .doesNotContain("internal boom") + .doesNotContain("at java.") + .doesNotContain("Caused by"); + } +} -- libgit2 0.22.2