Commit eb40632c250cba2c84bd145d765ad8ee1ae63e44
1 parent
7b98b08b
feat(usr): 统一响应体/错误码枚举/全局异常处理 REQ-USR-001
Showing
6 changed files
with
256 additions
and
0 deletions
backend/src/main/java/com/xly/erp/common/exception/BusinessException.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | |
| 2 | + | |
| 3 | +import com.xly.erp.common.response.ResultCode; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 业务异常(docs/04 § 1.5 SSoT)。 | |
| 7 | + * | |
| 8 | + * <p>REQ-USR-001 T2。业务错误统一抛本异常,由 {@link GlobalExceptionHandler} 捕获转 Result。</p> | |
| 9 | + */ | |
| 10 | +public class BusinessException extends RuntimeException { | |
| 11 | + | |
| 12 | + private static final long serialVersionUID = 1L; | |
| 13 | + | |
| 14 | + private final ResultCode resultCode; | |
| 15 | + | |
| 16 | + public BusinessException(ResultCode resultCode) { | |
| 17 | + super(resultCode.getMessage()); | |
| 18 | + this.resultCode = resultCode; | |
| 19 | + } | |
| 20 | + | |
| 21 | + public BusinessException(ResultCode resultCode, String message) { | |
| 22 | + super(message); | |
| 23 | + this.resultCode = resultCode; | |
| 24 | + } | |
| 25 | + | |
| 26 | + public ResultCode getResultCode() { | |
| 27 | + return resultCode; | |
| 28 | + } | |
| 29 | +} | ... | ... |
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.Result; | |
| 4 | +import com.xly.erp.common.response.ResultCode; | |
| 5 | +import org.slf4j.Logger; | |
| 6 | +import org.slf4j.LoggerFactory; | |
| 7 | +import org.springframework.dao.DuplicateKeyException; | |
| 8 | +import org.springframework.validation.FieldError; | |
| 9 | +import org.springframework.web.bind.MethodArgumentNotValidException; | |
| 10 | +import org.springframework.web.bind.annotation.ExceptionHandler; | |
| 11 | +import org.springframework.web.bind.annotation.RestControllerAdvice; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 全局异常处理(docs/04 § 1.5 SSoT)。 | |
| 15 | + * | |
| 16 | + * <p>REQ-USR-001 T2:把 BusinessException / 参数校验失败 / 唯一键冲突 / 未捕获异常 | |
| 17 | + * 统一转为 {@link Result},失败响应不抛栈到前端。</p> | |
| 18 | + */ | |
| 19 | +@RestControllerAdvice | |
| 20 | +public class GlobalExceptionHandler { | |
| 21 | + | |
| 22 | + private static final Logger LOG = LoggerFactory.getLogger(GlobalExceptionHandler.class); | |
| 23 | + | |
| 24 | + /** | |
| 25 | + * 业务异常 → 对应错误码。 | |
| 26 | + */ | |
| 27 | + @ExceptionHandler(BusinessException.class) | |
| 28 | + public <T> Result<T> handleBusinessException(BusinessException ex) { | |
| 29 | + return Result.fail(ex.getResultCode(), ex.getMessage()); | |
| 30 | + } | |
| 31 | + | |
| 32 | + /** | |
| 33 | + * Bean Validation(@Valid)失败 → 40001,message 取首个字段错误提示。 | |
| 34 | + */ | |
| 35 | + @ExceptionHandler(MethodArgumentNotValidException.class) | |
| 36 | + public <T> Result<T> handleValidationException(MethodArgumentNotValidException ex) { | |
| 37 | + FieldError fieldError = ex.getBindingResult().getFieldError(); | |
| 38 | + String message = fieldError != null | |
| 39 | + ? fieldError.getField() + ": " + fieldError.getDefaultMessage() | |
| 40 | + : ResultCode.PARAM_INVALID.getMessage(); | |
| 41 | + return Result.fail(ResultCode.PARAM_INVALID, message); | |
| 42 | + } | |
| 43 | + | |
| 44 | + /** | |
| 45 | + * 唯一键冲突(并发兜底)→ 40901 用户名已存在。 | |
| 46 | + */ | |
| 47 | + @ExceptionHandler(DuplicateKeyException.class) | |
| 48 | + public <T> Result<T> handleDuplicateKeyException(DuplicateKeyException ex) { | |
| 49 | + return Result.fail(ResultCode.USERNAME_EXISTS, ResultCode.USERNAME_EXISTS.getMessage()); | |
| 50 | + } | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * 兜底:未捕获异常记录 ERROR 日志(含栈),对外只返回通用错误码,不泄露内部细节。 | |
| 54 | + */ | |
| 55 | + @ExceptionHandler(Exception.class) | |
| 56 | + public <T> Result<T> handleException(Exception ex) { | |
| 57 | + LOG.error("系统异常", ex); | |
| 58 | + return Result.fail(ResultCode.SYSTEM_ERROR, ResultCode.SYSTEM_ERROR.getMessage()); | |
| 59 | + } | |
| 60 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/Result.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import java.io.Serializable; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 统一响应体(docs/04 § 1.4 SSoT)。 | |
| 7 | + * | |
| 8 | + * <p>REQ-USR-001 T2。所有接口返回 {@code Result<T>}:{@code code=0} 成功,非 0 为错误码。</p> | |
| 9 | + * | |
| 10 | + * @param <T> 业务数据类型 | |
| 11 | + */ | |
| 12 | +public class Result<T> implements Serializable { | |
| 13 | + | |
| 14 | + private static final long serialVersionUID = 1L; | |
| 15 | + | |
| 16 | + private int code; | |
| 17 | + private String message; | |
| 18 | + private T data; | |
| 19 | + | |
| 20 | + public Result() { | |
| 21 | + } | |
| 22 | + | |
| 23 | + public Result(int code, String message, T data) { | |
| 24 | + this.code = code; | |
| 25 | + this.message = message; | |
| 26 | + this.data = data; | |
| 27 | + } | |
| 28 | + | |
| 29 | + /** | |
| 30 | + * 成功且携带数据。 | |
| 31 | + */ | |
| 32 | + public static <T> Result<T> success(T data) { | |
| 33 | + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data); | |
| 34 | + } | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * 成功且不携带数据。 | |
| 38 | + */ | |
| 39 | + public static <T> Result<T> success() { | |
| 40 | + return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null); | |
| 41 | + } | |
| 42 | + | |
| 43 | + /** | |
| 44 | + * 失败,携带错误码与可读提示。 | |
| 45 | + */ | |
| 46 | + public static <T> Result<T> fail(ResultCode code, String message) { | |
| 47 | + return new Result<>(code.getCode(), message, null); | |
| 48 | + } | |
| 49 | + | |
| 50 | + public int getCode() { | |
| 51 | + return code; | |
| 52 | + } | |
| 53 | + | |
| 54 | + public void setCode(int code) { | |
| 55 | + this.code = code; | |
| 56 | + } | |
| 57 | + | |
| 58 | + public String getMessage() { | |
| 59 | + return message; | |
| 60 | + } | |
| 61 | + | |
| 62 | + public void setMessage(String message) { | |
| 63 | + this.message = message; | |
| 64 | + } | |
| 65 | + | |
| 66 | + public T getData() { | |
| 67 | + return data; | |
| 68 | + } | |
| 69 | + | |
| 70 | + public void setData(T data) { | |
| 71 | + this.data = data; | |
| 72 | + } | |
| 73 | +} | ... | ... |
backend/src/main/java/com/xly/erp/common/response/ResultCode.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * 统一错误码枚举(docs/05 / spec § 6 SSoT)。 | |
| 5 | + * | |
| 6 | + * <p>REQ-USR-001 T2:一次性建好本枚举,含本 REQ 使用的 0/40001/40301/40901, | |
| 7 | + * 并预留后续 REQ 复用的 40101/40302/40401/42201,避免后续重复修改公共文件。</p> | |
| 8 | + */ | |
| 9 | +public enum ResultCode { | |
| 10 | + | |
| 11 | + /** 成功。 */ | |
| 12 | + SUCCESS(0, "success"), | |
| 13 | + /** 参数校验失败(字段格式 / 必填 / 枚举越界 / 关联 id 不存在)。 */ | |
| 14 | + PARAM_INVALID(40001, "参数校验失败"), | |
| 15 | + /** 认证失败(用户名或密码错误;预留 REQ-USR-004)。 */ | |
| 16 | + UNAUTHORIZED(40101, "认证失败"), | |
| 17 | + /** 无权限(非管理员调用)。 */ | |
| 18 | + FORBIDDEN(40301, "无权限"), | |
| 19 | + /** 账号已禁用(预留 REQ-USR-004)。 */ | |
| 20 | + ACCOUNT_DISABLED(40302, "账号已禁用"), | |
| 21 | + /** 资源不存在(预留 REQ-USR-002)。 */ | |
| 22 | + NOT_FOUND(40401, "资源不存在"), | |
| 23 | + /** 用户名已存在(sUserName 全局唯一冲突)。 */ | |
| 24 | + USERNAME_EXISTS(40901, "用户名已存在"), | |
| 25 | + /** 分页参数非法(预留 REQ-USR-003)。 */ | |
| 26 | + PAGE_PARAM_INVALID(42201, "分页参数非法"), | |
| 27 | + /** 系统内部错误(兜底)。 */ | |
| 28 | + SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"); | |
| 29 | + | |
| 30 | + private final int code; | |
| 31 | + private final String message; | |
| 32 | + | |
| 33 | + ResultCode(int code, String message) { | |
| 34 | + this.code = code; | |
| 35 | + this.message = message; | |
| 36 | + } | |
| 37 | + | |
| 38 | + public int getCode() { | |
| 39 | + return code; | |
| 40 | + } | |
| 41 | + | |
| 42 | + public String getMessage() { | |
| 43 | + return message; | |
| 44 | + } | |
| 45 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/exception/GlobalExceptionHandlerTest.java
0 → 100644
| 1 | +package com.xly.erp.common.exception; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import com.xly.erp.common.response.Result; | |
| 6 | +import com.xly.erp.common.response.ResultCode; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * REQ-USR-001 T2:全局异常处理器把 BusinessException 转为统一 Result。 | |
| 11 | + */ | |
| 12 | +class GlobalExceptionHandlerTest { | |
| 13 | + | |
| 14 | + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); | |
| 15 | + | |
| 16 | + @Test | |
| 17 | + void businessExceptionMapsToResult() { | |
| 18 | + BusinessException ex = new BusinessException(ResultCode.USERNAME_EXISTS, "用户名已存在"); | |
| 19 | + Result<Void> r = handler.handleBusinessException(ex); | |
| 20 | + assertThat(r.getCode()).isEqualTo(40901); | |
| 21 | + assertThat(r.getMessage()).isEqualTo("用户名已存在"); | |
| 22 | + } | |
| 23 | +} | ... | ... |
backend/src/test/java/com/xly/erp/common/response/ResultTest.java
0 → 100644
| 1 | +package com.xly.erp.common.response; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.Test; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * REQ-USR-001 T2:统一响应体 Result 行为校验。 | |
| 9 | + */ | |
| 10 | +class ResultTest { | |
| 11 | + | |
| 12 | + @Test | |
| 13 | + void successCarriesCodeZeroAndData() { | |
| 14 | + Result<String> r = Result.success("hello"); | |
| 15 | + assertThat(r.getCode()).isEqualTo(0); | |
| 16 | + assertThat(r.getData()).isEqualTo("hello"); | |
| 17 | + } | |
| 18 | + | |
| 19 | + @Test | |
| 20 | + void failCarriesBusinessCodeAndMessage() { | |
| 21 | + Result<Void> r = Result.fail(ResultCode.USERNAME_EXISTS, "用户名已存在"); | |
| 22 | + assertThat(r.getCode()).isEqualTo(40901); | |
| 23 | + assertThat(r.getMessage()).isEqualTo("用户名已存在"); | |
| 24 | + assertThat(r.getData()).isNull(); | |
| 25 | + } | |
| 26 | +} | ... | ... |