## 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 与映射表重复的情况 - 暂不实施,后续根据需要添加