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 | +} |