Files
junhong_cmp_fiber/openspec/changes/archive/2026-01-22-unify-error-message-source/design.md
huang 6821e5abcf
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m36s
refactor: 统一错误消息数据源,优化错误码与映射表管理
主要改动:
- 改造 errors.New() 和 Wrap() 函数签名为可变参数,优先使用 errorMessages 映射表
- 添加 allErrorCodes 注册表和 init() 启动时校验,确保错误码与映射表一致
- 添加 TestAllCodesHaveMessages 和 TestNoOrphanMessages 测试防止映射表腐化
- 清理 109 处与映射表一致的冗余硬编码(service 层)
- 保留业务特定消息覆盖能力

新增 API 用法:
- errors.New(errors.CodeUnauthorized) // 使用映射表默认消息
- errors.New(errors.CodeNotFound, "提现申请不存在") // 覆盖为自定义消息
2026-01-22 18:27:42 +08:00

7.3 KiB
Raw Blame History

Context

背景

项目中错误处理使用 pkg/errors/ 包,核心组件:

组件 职责
codes.go 定义错误码常量 Code* 和消息映射表 errorMessages
errors.go 定义 AppError 结构和 New()/Wrap() 构造函数
handler.go Fiber 全局 ErrorHandler处理错误响应

当前问题

// 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 - 使用可变参数

// 新签名
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 测试兜底:测试覆盖所有错误码,防止遗漏
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 - 定义完整的错误码切片

// 所有错误码必须在此列表中注册
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 - 仅清理冗余硬编码

清理规则:

// 清理前:消息与映射表完全一致
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 与映射表重复的情况
    • 暂不实施,后续根据需要添加