- Add comprehensive docs/ directory with standardized structure - Include architecture specification and contract documents - Add prompt templates for development workflows - Establish M1 deliverables as frozen specifications Co-authored-by: Qwen-Coder <[email protected]>
122 lines
4.6 KiB
Markdown
122 lines
4.6 KiB
Markdown
# 异常处理契约 v1.0
|
||
|
||
## 1. 异常体系
|
||
|
||
DomainException(基类)
|
||
├── 携带 ErrorCode(枚举)
|
||
├── 携带 message(面向用户的描述)
|
||
└── 可选携带 cause(原始异常)
|
||
|
||
所有业务异常必须继承 DomainException 并携带 ErrorCode。
|
||
不允许直接抛出 RuntimeException / IllegalStateException 等通用异常表达业务规则。
|
||
|
||
## 2. ErrorCode 组织方式
|
||
|
||
按模块前缀分组(锁定):
|
||
|
||
COMMON_VALIDATION_ERROR
|
||
COMMON_INTERNAL_ERROR
|
||
COMMON_OPTIMISTIC_LOCK_CONFLICT
|
||
AUTH_INVALID_CREDENTIALS
|
||
AUTH_TOKEN_EXPIRED
|
||
AUTH_ACCESS_DENIED
|
||
ORDER_NOT_FOUND
|
||
ORDER_ALREADY_CANCELLED
|
||
ORDER_STATUS_INVALID
|
||
...
|
||
|
||
命名规则:{MODULE}_{SCENE},全大写蛇形。
|
||
新增模块时在 ErrorCode 枚举中添加对应前缀组。
|
||
|
||
## 3. ErrorCode → HTTP Status 映射表
|
||
|
||
| ErrorCode 前缀/具体值 | HTTP Status | 说明 |
|
||
|----------------------|-------------|------|
|
||
| COMMON_VALIDATION_ERROR | 400 | 参数校验失败 |
|
||
| AUTH_INVALID_CREDENTIALS | 401 | 认证失败 |
|
||
| AUTH_TOKEN_EXPIRED | 401 | 令牌过期 |
|
||
| AUTH_ACCESS_DENIED | 403 | 无权限 |
|
||
| *_NOT_FOUND | 404 | 资源不存在 |
|
||
| *_ALREADY_* / *_STATUS_INVALID | 409 | 状态冲突 |
|
||
| COMMON_OPTIMISTIC_LOCK_CONFLICT | 409 | 乐观锁冲突 |
|
||
| COMMON_INTERNAL_ERROR | 500 | 系统内部错误 |
|
||
|
||
## 4. 统一错误响应格式
|
||
|
||
{
|
||
"code": 404, // HTTP Status(固定,便于前端/网关)
|
||
"errorCode": "ORDER_NOT_FOUND", // 稳定枚举值(便于埋点/告警/统计)
|
||
"message": "订单不存在", // 面向用户,可本地化
|
||
"traceId": "abc-123-def", // 链路追踪 ID
|
||
"timestamp": "2026-02-11T10:30:00Z", // 服务器时间
|
||
"details": null // 仅 VALIDATION_ERROR 时填充字段级明细
|
||
}
|
||
|
||
各字段职责:
|
||
- code:前端快速判断错误大类(4xx / 5xx)
|
||
- errorCode:后端精确定位错误场景,用于日志检索 / 告警规则 / 监控大盘
|
||
- message:展示给终端用户
|
||
- details:VALIDATION_ERROR 时返回 [{"field": "xxx", "message": "xxx"}]
|
||
- 生产环境【禁止】在响应中返回堆栈信息
|
||
|
||
## 5. 异常转换规则
|
||
|
||
### 规则 1:异常只在 API 层的 GlobalExceptionHandler 转换为 HTTP 响应
|
||
- domain/application 层只抛 DomainException
|
||
- infrastructure 层将技术异常包装为 DomainException:
|
||
- DataAccessException → COMMON_INTERNAL_ERROR
|
||
- OptimisticLockingFailureException → COMMON_OPTIMISTIC_LOCK_CONFLICT
|
||
- ConnectException / TimeoutException → COMMON_INTERNAL_ERROR
|
||
|
||
### 规则 2:禁止吞没异常
|
||
- 任何 catch 块必须:要么重新抛出,要么记录 WARN/ERROR 日志
|
||
- 空 catch 块会被 SpotBugs 检测并阻断 CI
|
||
|
||
### 规则 3:参数校验 vs 业务校验
|
||
- 参数格式校验:Jakarta Validation(@NotNull, @Size, @Pattern 等),
|
||
Controller 层 @Valid/@Validated 拦截,GlobalExceptionHandler 捕获
|
||
MethodArgumentNotValidException → 400 + COMMON_VALIDATION_ERROR + details
|
||
- 业务规则校验:Domain 层方法内判断并抛出具体 DomainException
|
||
|
||
---
|
||
|
||
## ✅ 可自动检查项
|
||
|
||
| 规则 | 检查方式 |
|
||
|------|---------|
|
||
| 所有自定义异常继承 DomainException | ArchUnit: classes in ..exception.. that extend RuntimeException should also extend DomainException |
|
||
| 禁止空 catch 块 | SpotBugs: DE_MIGHT_IGNORE + REC_CATCH_EXCEPTION |
|
||
| GlobalExceptionHandler 存在且标注 @RestControllerAdvice | ArchUnit: 至少有一个类标注 @RestControllerAdvice 且在 ..api.exception.. 包下 |
|
||
| Controller 中 @Valid/@Validated 注解存在 | 人工评审 + Prompt 约束 |
|
||
|
||
## ❌ 典型违规示例
|
||
|
||
### 违规 1:直接抛 RuntimeException 表达业务规则
|
||
|
||
// 错误!
|
||
if (order == null) {
|
||
throw new RuntimeException("订单不存在"); // 无 ErrorCode,GlobalExceptionHandler 只能兜底 500
|
||
}
|
||
|
||
// 正确
|
||
if (order == null) {
|
||
throw new DomainException(ErrorCode.ORDER_NOT_FOUND);
|
||
}
|
||
|
||
### 违规 2:Infrastructure 层不包装技术异常
|
||
|
||
// 错误!让 DataAccessException 直接穿透到 API 层
|
||
public class OrderPersistenceAdapter implements OrderPersistencePort {
|
||
public Order findById(OrderId id, String customerId) {
|
||
return repo.findByIdAndCustomerId(id.value(), customerId)
|
||
.map(mapper::toDomain)
|
||
.orElse(null); // 返回 null,让上层 NPE → 500
|
||
}
|
||
}
|
||
|
||
// 正确:包装为领域异常
|
||
public Order findById(OrderId id, String customerId) {
|
||
return repo.findByIdAndCustomerId(id.value(), customerId)
|
||
.map(mapper::toDomain)
|
||
.orElseThrow(() -> new DomainException(ErrorCode.ORDER_NOT_FOUND));
|
||
} |