From eb40632c250cba2c84bd145d765ad8ee1ae63e44 Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 13:19:53 +0800 Subject: [PATCH] feat(usr): 统一响应体/错误码枚举/全局异常处理 REQ-USR-001 --- backend/src/main/java/com/xly/erp/common/exception/BusinessException.java | 29 +++++++++++++++++++++++++++++ backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/erp/common/response/Result.java | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ backend/src/main/java/com/xly/erp/common/response/ResultCode.java | 45 +++++++++++++++++++++++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java | 23 +++++++++++++++++++++++ backend/src/test/java/com/xly/erp/common/response/ResultTest.java | 26 ++++++++++++++++++++++++++ 6 files changed, 256 insertions(+), 0 deletions(-) create mode 100644 backend/src/main/java/com/xly/erp/common/exception/BusinessException.java create mode 100644 backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/xly/erp/common/response/Result.java create mode 100644 backend/src/main/java/com/xly/erp/common/response/ResultCode.java create mode 100644 backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java create mode 100644 backend/src/test/java/com/xly/erp/common/response/ResultTest.java diff --git a/backend/src/main/java/com/xly/erp/common/exception/BusinessException.java b/backend/src/main/java/com/xly/erp/common/exception/BusinessException.java new file mode 100644 index 0000000..4059c4a --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/exception/BusinessException.java @@ -0,0 +1,29 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.ResultCode; + +/** + * 业务异常(docs/04 § 1.5 SSoT)。 + * + *

REQ-USR-001 T2。业务错误统一抛本异常,由 {@link GlobalExceptionHandler} 捕获转 Result。

+ */ +public class BusinessException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final ResultCode resultCode; + + public BusinessException(ResultCode resultCode) { + super(resultCode.getMessage()); + this.resultCode = resultCode; + } + + public BusinessException(ResultCode resultCode, String message) { + super(message); + this.resultCode = resultCode; + } + + public ResultCode getResultCode() { + return resultCode; + } +} 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..327a87c --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,60 @@ +package com.xly.erp.common.exception; + +import com.xly.erp.common.response.Result; +import com.xly.erp.common.response.ResultCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DuplicateKeyException; +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; + +/** + * 全局异常处理(docs/04 § 1.5 SSoT)。 + * + *

REQ-USR-001 T2:把 BusinessException / 参数校验失败 / 唯一键冲突 / 未捕获异常 + * 统一转为 {@link Result},失败响应不抛栈到前端。

+ */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger LOG = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 业务异常 → 对应错误码。 + */ + @ExceptionHandler(BusinessException.class) + public Result handleBusinessException(BusinessException ex) { + return Result.fail(ex.getResultCode(), ex.getMessage()); + } + + /** + * Bean Validation(@Valid)失败 → 40001,message 取首个字段错误提示。 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public Result handleValidationException(MethodArgumentNotValidException ex) { + FieldError fieldError = ex.getBindingResult().getFieldError(); + String message = fieldError != null + ? fieldError.getField() + ": " + fieldError.getDefaultMessage() + : ResultCode.PARAM_INVALID.getMessage(); + return Result.fail(ResultCode.PARAM_INVALID, message); + } + + /** + * 唯一键冲突(并发兜底)→ 40901 用户名已存在。 + */ + @ExceptionHandler(DuplicateKeyException.class) + public Result handleDuplicateKeyException(DuplicateKeyException ex) { + return Result.fail(ResultCode.USERNAME_EXISTS, ResultCode.USERNAME_EXISTS.getMessage()); + } + + /** + * 兜底:未捕获异常记录 ERROR 日志(含栈),对外只返回通用错误码,不泄露内部细节。 + */ + @ExceptionHandler(Exception.class) + public Result handleException(Exception ex) { + LOG.error("系统异常", ex); + return Result.fail(ResultCode.SYSTEM_ERROR, ResultCode.SYSTEM_ERROR.getMessage()); + } +} 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..37930c4 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/response/Result.java @@ -0,0 +1,73 @@ +package com.xly.erp.common.response; + +import java.io.Serializable; + +/** + * 统一响应体(docs/04 § 1.4 SSoT)。 + * + *

REQ-USR-001 T2。所有接口返回 {@code Result}:{@code code=0} 成功,非 0 为错误码。

+ * + * @param 业务数据类型 + */ +public class Result implements Serializable { + + private static final long serialVersionUID = 1L; + + private int code; + private String message; + private T data; + + public Result() { + } + + public Result(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + /** + * 成功且携带数据。 + */ + public static Result success(T data) { + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); + } + + /** + * 成功且不携带数据。 + */ + public static Result success() { + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null); + } + + /** + * 失败,携带错误码与可读提示。 + */ + public static Result fail(ResultCode code, String message) { + return new Result<>(code.getCode(), message, null); + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} diff --git a/backend/src/main/java/com/xly/erp/common/response/ResultCode.java b/backend/src/main/java/com/xly/erp/common/response/ResultCode.java new file mode 100644 index 0000000..ae9d3ec --- /dev/null +++ b/backend/src/main/java/com/xly/erp/common/response/ResultCode.java @@ -0,0 +1,45 @@ +package com.xly.erp.common.response; + +/** + * 统一错误码枚举(docs/05 / spec § 6 SSoT)。 + * + *

REQ-USR-001 T2:一次性建好本枚举,含本 REQ 使用的 0/40001/40301/40901, + * 并预留后续 REQ 复用的 40101/40302/40401/42201,避免后续重复修改公共文件。

+ */ +public enum ResultCode { + + /** 成功。 */ + SUCCESS(0, "success"), + /** 参数校验失败(字段格式 / 必填 / 枚举越界 / 关联 id 不存在)。 */ + PARAM_INVALID(40001, "参数校验失败"), + /** 认证失败(用户名或密码错误;预留 REQ-USR-004)。 */ + UNAUTHORIZED(40101, "认证失败"), + /** 无权限(非管理员调用)。 */ + FORBIDDEN(40301, "无权限"), + /** 账号已禁用(预留 REQ-USR-004)。 */ + ACCOUNT_DISABLED(40302, "账号已禁用"), + /** 资源不存在(预留 REQ-USR-002)。 */ + NOT_FOUND(40401, "资源不存在"), + /** 用户名已存在(sUserName 全局唯一冲突)。 */ + USERNAME_EXISTS(40901, "用户名已存在"), + /** 分页参数非法(预留 REQ-USR-003)。 */ + PAGE_PARAM_INVALID(42201, "分页参数非法"), + /** 系统内部错误(兜底)。 */ + SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"); + + private final int code; + private final String message; + + ResultCode(int code, String message) { + this.code = code; + this.message = message; + } + + public int getCode() { + return code; + } + + public String getMessage() { + return message; + } +} 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..0a5ae89 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,23 @@ +package com.xly.erp.common.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.xly.erp.common.response.Result; +import com.xly.erp.common.response.ResultCode; +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-001 T2:全局异常处理器把 BusinessException 转为统一 Result。 + */ +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void businessExceptionMapsToResult() { + BusinessException ex = new BusinessException(ResultCode.USERNAME_EXISTS, "用户名已存在"); + Result r = handler.handleBusinessException(ex); + assertThat(r.getCode()).isEqualTo(40901); + assertThat(r.getMessage()).isEqualTo("用户名已存在"); + } +} diff --git a/backend/src/test/java/com/xly/erp/common/response/ResultTest.java b/backend/src/test/java/com/xly/erp/common/response/ResultTest.java new file mode 100644 index 0000000..f0a7469 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/common/response/ResultTest.java @@ -0,0 +1,26 @@ +package com.xly.erp.common.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * REQ-USR-001 T2:统一响应体 Result 行为校验。 + */ +class ResultTest { + + @Test + void successCarriesCodeZeroAndData() { + Result r = Result.success("hello"); + assertThat(r.getCode()).isEqualTo(0); + assertThat(r.getData()).isEqualTo("hello"); + } + + @Test + void failCarriesBusinessCodeAndMessage() { + Result r = Result.fail(ResultCode.USERNAME_EXISTS, "用户名已存在"); + assertThat(r.getCode()).isEqualTo(40901); + assertThat(r.getMessage()).isEqualTo("用户名已存在"); + assertThat(r.getData()).isNull(); + } +} -- libgit2 0.22.2