Skip to content

异常体系与统一响应 ​

DDK 用 ErrorCode + AbstractException + ApiResponse 三件套统一错误表达。这篇讲它们的契约、状态码规范,以及 ddk-web-starter 当前的全局异常处理行为。

一、三层结构 ​

ErrorCode          错误码接口,业务方用枚举实现
AbstractException  异常基类,持有 ErrorCode 和格式化参数
  ├── BusinessException   业务异常 → HTTP 400
  └── SystemException     系统异常 → HTTP 500
ApiResponse<T>     统一响应包装

ErrorCode ​

java
public interface ErrorCode {

    String getMessage();

    /** 默认取 toString(),枚举实现时即枚举常量名 */
    default String getCode() {
        return this.toString();
    }

    /** 用 MessageFormat 做占位符替换 */
    default String getMessage(Object... args) {
        return MessageFormat.format(this.getMessage(), args);
    }
}

业务侧用枚举实现,getCode() 的默认实现直接返回枚举名,所以枚举常量名就是对外的错误码,命名要按第三节的规范来:

java
@Getter
@AllArgsConstructor
public enum OrderError implements ErrorCode {

    ORDV001("订单不能为空"),
    ORDV100("库存不足,当前库存 {0},需要 {1}"),
    ORDE201("支付渠道连接失败");

    private final String message;
}

注意占位符用的是 MessageFormat 语法 {0}、{1},不是 slf4j 的 {}。写错了不会报错,只是占位符不被替换。

异常基类 ​

java
@Getter
public abstract class AbstractException extends RuntimeException {

    private final ErrorCode errorCode;
    private Object[] args;

    public AbstractException(ErrorCode errorCode) { ... }
    public AbstractException(ErrorCode errorCode, Object... args) { ... }
    public AbstractException(ErrorCode errorCode, Throwable t) { ... }
    public AbstractException(ErrorCode errorCode, Throwable t, Object... args) { ... }
}

用法:

java
// 无参数
throw new BusinessException(OrderError.ORDV001);

// 带占位符参数
throw new BusinessException(OrderError.ORDV100, currentStock, required);

// 包装底层异常
throw new SystemException(OrderError.ORDE201, e);

选 BusinessException 还是 SystemException,判断标准是这个错误是否可以由调用方通过修改请求来避免:库存不足、参数非法、状态不允许 → 业务异常;数据库连不上、下游超时、序列化失败 → 系统异常。

这个区分不是分类癖好,它决定了两件实际的事:HTTP 状态码(400 vs 500),以及日志级别(warn 不带栈 vs error 带栈)。

ApiResponse ​

java
@Data
public class ApiResponse<T> {
    private String code;
    private String message;
    private T data;
    private Long timestamp;

    public static <T> ApiResponse<T> ofSuccess() { ... }
    public static <T> ApiResponse<T> ofSuccess(T data) { ... }
    public static <T> ApiResponse<T> ofFail(ErrorCode code, Object... args) { ... }
}

成功时 code 固定是字符串 "SUCCESS",失败时是 ErrorCode.getCode()。

二、全局异常处理 ​

ddk-web-starter 里的 BaseExceptionHandler 已标注 @RestControllerAdvice,并由自动配置注册成 Bean:

java
@Slf4j
@RestControllerAdvice
public class BaseExceptionHandler {

    @ResponseBody
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(BusinessException.class)
    public ApiResponse<Void> handleBusinessException(BusinessException e) {
        log.warn("BusinessException: {}", e.getMessage());
        return ApiResponse.ofFail(e.getErrorCode());
    }
    // ... 还有 SystemException、MethodArgumentNotValidException、BindException、Exception
}

引入 ddk-web-starter 后,Controller 抛出的 BusinessException 会被转换为 HTTP 400 + ApiResponse,SystemException 会被转换为 HTTP 500 + ApiResponse。

仍需补一个 @WebMvcTest 测试,实际发一个请求验证响应体是 ApiResponse 结构。断言 Bean 存在是不够的,必须断言 HTTP 响应。

另一个隐患:兜底的 @ExceptionHandler(Exception.class) ​

java
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception e) {
    log.error("Unexpected exception: {}", e.getMessage(), e);
    return new ApiResponse<>("SYSTEM_ERROR", "Internal Server Error", null);
}

这个兜底会吞掉 Spring MVC 自己的异常(HttpRequestMethodNotSupportedException、HttpMediaTypeNotSupportedException 等),把本该是 405/415 的响应变成 500。修复时应该让 BaseExceptionHandler 继承 ResponseEntityExceptionHandler,把框架异常交回给 Spring 处理。

三、状态码规范 ​

所有状态码为字符串,格式:

[系统前缀 3 位][类型 1 位][错误码 3 位]

总长 7 位,只用大写字母和数字。成功是唯一例外,固定为 SUCCESS。

字段长度说明
系统前缀3标识所属系统或模块
类型1标识错误级别
错误码3区分同类型下的具体错误

系统前缀 ​

按模块划分,示例:

  • SYS 核心系统
  • AUT 认证模块
  • ORD 订单模块
  • PAY 支付模块
  • CMS 内容管理

类型 ​

  • E 错误(Error)——系统级失败:三方调用异常、数据库操作失败
  • V 校验失败(Validation)——参数缺失、格式错误、业务规则不满足
  • W 警告(Warning)——异步/多线程场景下的异常告警

错误码 ​

三位数字,建议按段分配以便快速定位:

  • 0xx 参数与校验
  • 1xx 业务规则
  • 2xx 外部依赖
  • 9xx 未分类

示例 ​

状态码含义
SUCCESS成功
AUTE001认证失败,用户名或密码错误
ORDV100订单创建失败,库存不足
PAYE201支付失败,支付渠道连接错误
CMSV001内容校验失败,标题不能为空
SYSW002日志文件创建异常

规范落地的一个问题 ​

ErrorCode.getCode() 默认返回 this.toString(),对枚举来说就是常量名。这意味着枚举常量必须直接命名成状态码:

java
// 符合规范,但可读性差
ORDV100("库存不足");

// 可读性好,但违反规范 —— getCode() 会返回 "INSUFFICIENT_STOCK"
INSUFFICIENT_STOCK("库存不足");

两者不可兼得,除非显式覆盖 getCode():

java
@Getter
@AllArgsConstructor
public enum OrderError implements ErrorCode {

    INSUFFICIENT_STOCK("ORDV100", "库存不足,当前库存 {0},需要 {1}"),
    EMPTY_ORDER("ORDV001", "订单不能为空");

    private final String code;      // 覆盖默认实现,常量名可以取得可读
    private final String message;
}

推荐后一种写法:常量名给开发者看,code 给前端和日志用。前一种写法在代码里满屏 ORDV100 这种字面量,几个月后没人知道它是什么。

相关文档 ​

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权