## Context
### 背景
项目中错误处理使用 `pkg/errors/` 包,核心组件:
| 组件 | 职责 |
|------|------|
| `codes.go` | 定义错误码常量 `Code*` 和消息映射表 `errorMessages` |
| `errors.go` | 定义 `AppError` 结构和 `New()`/`Wrap()` 构造函数 |
| `handler.go` | Fiber 全局 ErrorHandler,处理错误响应 |
### 当前问题
```go
// errors.go 的设计意图:当 message 为空时自动使用映射表
func New(code int, message string) *AppError {
if message == "" {
message = GetMessage(code, "zh-CN") // 理论上的自动填充
}
return &AppError{Code: code, Message: message}
}
// 实际业务代码:100% 传入硬编码消息,映射表从未被使用
errors.New(errors.CodeNotFound, "提现申请不存在") // 不是空字符串
```
**量化数据**(通过 grep 统计):
- `errors.New(code, "硬编码")` 调用:291 处
- `errors.New(code, "")` 调用:0 处
- 映射表使用率:0%
### 约束
- 必须向后兼容,不能破坏现有 API 响应格式
- 业务特定消息(如 "提现申请不存在")优于通用消息("资源未找到")
- 不能引入运行时性能损耗
## Goals / Non-Goals
### Goals
1. **单一数据源原则**:错误码与消息映射表作为默认消息来源
2. **编译时/启动时校验**:缺失映射条目会立即暴露,而非运行时默默失败
3. **向后兼容**:保留业务特定消息覆盖能力
4. **开发体验优化**:简化 API,减少样板代码
### Non-Goals
1. ❌ 多语言支持(保留 `lang` 参数但不实现)
2. ❌ 修改 API 响应格式
3. ❌ 强制所有错误使用映射表消息(保留覆盖能力)
## Decisions
### Decision 1: 改造 `errors.New()` 函数签名
**选项**:
| 选项 | 实现 | 优缺点 |
|------|------|--------|
| A. 删除 message 参数 | `New(code int)` | ✅ 强制单一数据源
❌ 丢失业务上下文 |
| B. message 改为可选 | `New(code int, msg ...string)` | ✅ 默认用映射表
✅ 允许覆盖
✅ 向后兼容 |
| C. 新增 NewWithMsg | `New(code)` + `NewWithMsg(code, msg)` | ✅ 清晰区分
❌ 需改所有调用点 |
**决策**:选项 B - 使用可变参数
```go
// 新签名
func New(code int, customMsg ...string) *AppError {
msg := GetMessage(code, "zh-CN") // 默认从映射表取
if len(customMsg) > 0 && customMsg[0] != "" {
msg = customMsg[0] // 允许覆盖
}
return &AppError{Code: code, Message: msg}
}
// 使用方式
errors.New(errors.CodeNotFound) // 使用映射表: "资源未找到"
errors.New(errors.CodeNotFound, "提现申请不存在") // 覆盖: "提现申请不存在"
```
**理由**:
- 向后兼容:现有代码无需修改即可编译
- 渐进式迁移:可逐步清理冗余硬编码
- 保留灵活性:业务特定消息仍可使用
### Decision 2: 启动时校验机制
**选项**:
| 选项 | 时机 | 优缺点 |
|------|------|--------|
| A. init() panic | 程序启动 | ✅ 立即发现
❌ 启动失败 |
| B. init() 日志警告 | 程序启动 | ✅ 不阻塞启动
❌ 可能被忽略 |
| C. 仅测试校验 | CI 运行 | ✅ 不影响生产
❌ 本地开发可能遗漏 |
**决策**:选项 A + C 组合
1. **init() 严格校验**:缺失映射立即 panic,阻止服务启动
2. **CI 测试兜底**:测试覆盖所有错误码,防止遗漏
```go
func init() {
for code := range allCodes() { // 遍历所有 Code* 常量
if _, ok := errorMessages[code]; !ok {
panic(fmt.Sprintf("错误码 %d 缺少映射消息", code))
}
}
}
```
**理由**:
- 快速失败:问题在部署前暴露,而非运行时
- 强制一致性:不允许"遗漏"状态存在
### Decision 3: 错误码常量收集方式
**选项**:
| 选项 | 实现 | 优缺点 |
|------|------|--------|
| A. 手动维护列表 | `allCodes = []int{CodeSuccess, CodeNotFound, ...}` | ❌ 容易忘记更新 |
| B. 反射扫描 | 运行时反射遍历 const | ❌ Go 不支持反射 const |
| C. 代码生成 | `go generate` 扫描生成 | ✅ 自动化
❌ 增加构建复杂度 |
| D. 基于映射表反向校验 | 遍历 errorMessages 的 key | ✅ 简单
❌ 无法检测缺失 |
| E. 定义错误码注册表 | 显式注册每个错误码 | ✅ 清晰
✅ 编译时检查 |
**决策**:选项 E - 定义完整的错误码切片
```go
// 所有错误码必须在此列表中注册
var allErrorCodes = []int{
CodeSuccess,
CodeInvalidParam,
CodeMissingToken,
// ... 所有错误码
}
func init() {
for _, code := range allErrorCodes {
if _, ok := errorMessages[code]; !ok {
panic(fmt.Sprintf("错误码 %d 缺少映射消息", code))
}
}
}
```
**理由**:
- 显式优于隐式:所有错误码一目了然
- 新增错误码时必须同时更新两处(常量 + 列表),测试会强制检查映射表
### Decision 4: 业务代码清理策略
**选项**:
| 选项 | 范围 | 优缺点 |
|------|------|--------|
| A. 全量清理 | 所有 291 处 | ✅ 彻底
❌ 风险高,改动大 |
| B. 仅清理冗余 | 消息与映射表一致的 | ✅ 安全
✅ 保留业务上下文 |
| C. 不清理 | 保持现状 | ✅ 零风险
❌ 技术债未还 |
**决策**:选项 B - 仅清理冗余硬编码
清理规则:
```go
// 清理前:消息与映射表完全一致
errors.New(errors.CodeUnauthorized, "未授权访问")
// 清理后:使用映射表默认值
errors.New(errors.CodeUnauthorized)
// 保留:业务特定消息,比映射表更精确
errors.New(errors.CodeNotFound, "提现申请不存在") // 保留,比 "资源未找到" 更清晰
```
**理由**:
- 最小改动原则:只改必要的
- 保留业务价值:精确消息优于通用消息
## Risks / Trade-offs
### 风险矩阵
| 风险 | 等级 | 缓解措施 |
|------|------|----------|
| 启动时 panic 影响部署 | 中 | 本地开发和 CI 会先发现;错误消息清晰指明缺失的错误码 |
| 消息文案变化影响前端 | 低 | 仅清理与映射表一致的;业务特定消息保留 |
| 大规模代码改动引入 bug | 中 | 分阶段实施:先加校验和测试,再清理代码 |
| 遗漏错误码导致 panic | 低 | allErrorCodes 列表 + 测试双重保障 |
### Trade-offs
1. **启动时 panic vs 运行时容错**
- 选择 panic:快速失败,问题在部署前暴露
- 代价:如果遗漏会阻止服务启动
2. **强制映射表 vs 允许覆盖**
- 选择允许覆盖:保留业务灵活性
- 代价:无法 100% 保证消息一致性
## Migration Plan
### Phase 1: 基础设施(本次实施)
1. 改造 `errors.New()` 函数签名(向后兼容)
2. 添加 `allErrorCodes` 注册表
3. 添加 `init()` 启动校验
4. 添加 `TestAllCodesHaveMessages` 测试
5. 补充可能缺失的 errorMessages 条目
### Phase 2: 代码清理(本次实施)
1. 清理与映射表一致的冗余硬编码
2. 保留业务特定消息
### Rollback Strategy
如出现问题,回滚方式:
1. `errors.New()` 签名改动是向后兼容的,无需回滚
2. 如果 init() panic 导致问题,临时注释校验代码
3. Git revert 整个变更
## Open Questions
1. **是否需要 linter 规则?**
- 可考虑添加 golangci-lint 自定义规则,检测 `errors.New(code, msg)` 中 msg 与映射表重复的情况
- 暂不实施,后续根据需要添加