Commit c218a720f17d94a46d098931f66617a96c21c2b4

Authored by zichun
1 parent 0f1f3ce1

feat(common): biz exception and global handler REQ-MOD-001

backend/src/main/java/com/xly/erp/common/exception/BizException.java 0 → 100644
  1 +package com.xly.erp.common.exception;
  2 +
  3 +import com.xly.erp.common.response.ErrorCode;
  4 +import lombok.Getter;
  5 +
  6 +@Getter
  7 +public class BizException extends RuntimeException {
  8 + private final int code;
  9 +
  10 + public BizException(ErrorCode ec) {
  11 + super(ec.getMessage());
  12 + this.code = ec.getCode();
  13 + }
  14 +
  15 + public BizException(ErrorCode ec, String detail) {
  16 + super(detail);
  17 + this.code = ec.getCode();
  18 + }
  19 +}
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.ApiResponse;
  4 +import com.xly.erp.common.response.ErrorCode;
  5 +import lombok.extern.slf4j.Slf4j;
  6 +import org.springframework.validation.FieldError;
  7 +import org.springframework.web.bind.MethodArgumentNotValidException;
  8 +import org.springframework.web.bind.annotation.ExceptionHandler;
  9 +import org.springframework.web.bind.annotation.RestControllerAdvice;
  10 +
  11 +@Slf4j
  12 +@RestControllerAdvice
  13 +public class GlobalExceptionHandler {
  14 +
  15 + @ExceptionHandler(BizException.class)
  16 + public ApiResponse<Void> handleBiz(BizException e) {
  17 + log.warn("BizException code={} message={}", e.getCode(), e.getMessage());
  18 + return ApiResponse.fail(e.getCode(), e.getMessage());
  19 + }
  20 +
  21 + @ExceptionHandler(MethodArgumentNotValidException.class)
  22 + public ApiResponse<Void> handleValidation(MethodArgumentNotValidException e) {
  23 + FieldError fe = e.getBindingResult().getFieldError();
  24 + String detail = fe == null
  25 + ? ErrorCode.PARAM_INVALID.getMessage()
  26 + : fe.getField() + ": " + fe.getDefaultMessage();
  27 + return ApiResponse.fail(ErrorCode.PARAM_INVALID, detail);
  28 + }
  29 +
  30 + @ExceptionHandler(Exception.class)
  31 + public ApiResponse<Void> handleAll(Exception e) {
  32 + log.error("Uncaught exception", e);
  33 + return ApiResponse.fail(ErrorCode.INTERNAL_ERROR);
  34 + }
  35 +}
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 jakarta.validation.constraints.NotBlank;
  5 +import org.junit.jupiter.api.BeforeEach;
  6 +import org.junit.jupiter.api.Test;
  7 +import org.springframework.http.MediaType;
  8 +import org.springframework.test.web.servlet.MockMvc;
  9 +import org.springframework.test.web.servlet.setup.MockMvcBuilders;
  10 +import org.springframework.web.bind.annotation.GetMapping;
  11 +import org.springframework.web.bind.annotation.PostMapping;
  12 +import org.springframework.web.bind.annotation.RequestBody;
  13 +import org.springframework.web.bind.annotation.RequestMapping;
  14 +import org.springframework.web.bind.annotation.RestController;
  15 +
  16 +import static org.assertj.core.api.Assertions.assertThat;
  17 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
  18 +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
  19 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
  20 +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
  21 +
  22 +class GlobalExceptionHandlerTest {
  23 +
  24 + private MockMvc mockMvc;
  25 +
  26 + @BeforeEach
  27 + void setUp() {
  28 + mockMvc = MockMvcBuilders.standaloneSetup(new DummyController())
  29 + .setControllerAdvice(new GlobalExceptionHandler())
  30 + .build();
  31 + }
  32 +
  33 + @RestController
  34 + @RequestMapping("/api/__dummy")
  35 + static class DummyController {
  36 + @GetMapping("/biz")
  37 + public Object biz() { throw new BizException(ErrorCode.MOD_PARENT_NOT_FOUND); }
  38 +
  39 + @GetMapping("/runtime")
  40 + public Object runtime() { throw new RuntimeException("internal boom\nstack-line-1\nstack-line-2"); }
  41 +
  42 + @PostMapping(value = "/validation", consumes = MediaType.APPLICATION_JSON_VALUE)
  43 + public Object validation(@jakarta.validation.Valid @RequestBody Payload p) { return "ok"; }
  44 + }
  45 +
  46 + static class Payload {
  47 + @NotBlank String name;
  48 + public String getName() { return name; }
  49 + public void setName(String n) { this.name = n; }
  50 + }
  51 +
  52 + @Test
  53 + void bizException_returns200WithBizCode() throws Exception {
  54 + mockMvc.perform(get("/api/__dummy/biz"))
  55 + .andExpect(status().isOk())
  56 + .andExpect(jsonPath("$.code").value(40411))
  57 + .andExpect(jsonPath("$.message").value("父模块不存在或已删除"));
  58 + }
  59 +
  60 + @Test
  61 + void validationException_returns200WithParamInvalidCode() throws Exception {
  62 + mockMvc.perform(post("/api/__dummy/validation")
  63 + .contentType(MediaType.APPLICATION_JSON)
  64 + .content("{\"name\":\"\"}"))
  65 + .andExpect(status().isOk())
  66 + .andExpect(jsonPath("$.code").value(40010));
  67 + }
  68 +
  69 + @Test
  70 + void uncaughtException_returns200WithInternalErrorCode() throws Exception {
  71 + mockMvc.perform(get("/api/__dummy/runtime"))
  72 + .andExpect(status().isOk())
  73 + .andExpect(jsonPath("$.code").value(50000));
  74 + }
  75 +
  76 + @Test
  77 + void response_doesNotContainStackTrace() throws Exception {
  78 + String body = mockMvc.perform(get("/api/__dummy/runtime"))
  79 + .andReturn().getResponse().getContentAsString();
  80 + assertThat(body)
  81 + .doesNotContain("stack-line-1")
  82 + .doesNotContain("internal boom")
  83 + .doesNotContain("at java.")
  84 + .doesNotContain("Caused by");
  85 + }
  86 +}