异常体系与统一响应
DDK 用
ErrorCode+AbstractException+ApiResponse三件套统一错误表达。这篇讲它们的契约、状态码规范,以及ddk-web-starter当前的全局异常处理行为。
一、三层结构
ErrorCode 错误码接口,业务方用枚举实现
AbstractException 异常基类,持有 ErrorCode 和格式化参数
├── BusinessException 业务异常 → HTTP 400
└── SystemException 系统异常 → HTTP 500
ApiResponse<T> 统一响应包装ErrorCode
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() 的默认实现直接返回枚举名,所以枚举常量名就是对外的错误码,命名要按第三节的规范来:
@Getter
@AllArgsConstructor
public enum OrderError implements ErrorCode {
ORDV001("订单不能为空"),
ORDV100("库存不足,当前库存 {0},需要 {1}"),
ORDE201("支付渠道连接失败");
private final String message;
}注意占位符用的是 MessageFormat 语法 {0}、{1},不是 slf4j 的 {}。写错了不会报错,只是占位符不被替换。
异常基类
@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) { ... }
}用法:
// 无参数
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
@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:
@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)
@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(),对枚举来说就是常量名。这意味着枚举常量必须直接命名成状态码:
// 符合规范,但可读性差
ORDV100("库存不足");
// 可读性好,但违反规范 —— getCode() 会返回 "INSUFFICIENT_STOCK"
INSUFFICIENT_STOCK("库存不足");两者不可兼得,除非显式覆盖 getCode():
@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 这种字面量,几个月后没人知道它是什么。
相关文档
- DDK Web Starter — 全局异常处理、Jackson、CORS 配置
- 领域模型基类 — 领域层如何抛业务异常