Files
junhong_cmp_fiber/openspec/specs/error-handling/spec.md
huang 409a68d60b
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m45s
feat: OpenAPI 契约对齐与框架优化
主要变更:
1. OpenAPI 文档契约对齐
   - 统一错误响应字段名为 msg(非 message)
   - 规范 envelope 响应结构(code, msg, data, timestamp)
   - 个人客户路由纳入文档体系(使用 Register 机制)
   - 新增 BuildDocHandlers() 统一管理 handler 构造
   - 确保文档生成的幂等性

2. Service 层错误处理统一
   - 全面替换 fmt.Errorf 为 errors.New/Wrap
   - 统一错误码使用规范
   - Handler 层参数校验不泄露底层细节
   - 新增错误码验证集成测试

3. 代码质量提升
   - 删除未使用的 Task handler 和路由
   - 新增代码规范检查脚本(check-service-errors.sh)
   - 新增注释路径一致性检查(check-comment-paths.sh)
   - 更新 API 文档生成指南

4. OpenSpec 归档
   - 归档 openapi-contract-alignment 变更(63 tasks)
   - 归档 service-error-unify-core 变更
   - 归档 service-error-unify-support 变更
   - 归档 code-cleanup-docs-update 变更
   - 归档 handler-validation-security 变更
   - 同步 delta specs 到主规范文件

影响范围:
- pkg/openapi: 新增 handlers.go,优化 generator.go
- internal/service/*: 48 个 service 文件错误处理统一
- internal/handler/admin: 优化参数校验错误提示
- internal/routes: 个人客户路由改造,删除 task 路由
- scripts: 新增 3 个代码检查脚本
- docs: 更新 OpenAPI 文档(15750+ 行)
- openspec/specs: 同步 3 个主规范文件

破坏性变更:无
向后兼容:是
2026-01-30 11:40:36 +08:00

9.8 KiB
Raw Blame History

error-handling Specification

Purpose

定义本项目“错误产生、错误传递、错误返回”的统一规范,确保:

  • 对外响应结构一致({code, data, msg, timestamp}
  • 业务语义一致(可预期业务错误返回 4xx非预期系统错误返回 5xx
  • 不泄露内部细节(校验细节、数据库/第三方错误细节仅写日志)
  • 分层职责明确Handler 只负责输入/输出Service 负责业务与结构化错误)

Requirements

Requirement: Simplified AppError Structure

系统 SHALL 简化 AppError 结构,删除冗余的 HTTPStatus 字段。

Scenario: AppError 字段

  • WHEN 创建 AppError
  • THEN 结构体只包含 3 个字段:
    • Code: 业务错误码
    • Message: 错误消息
    • Err: 底层错误(可选)

Scenario: HTTP 状态码获取

  • WHEN ErrorHandler 处理 AppError
  • THEN 通过 GetHTTPStatus(code) 实时获取 HTTP 状态码
  • AND 不从 AppError 字段中读取

Scenario: 禁止手动设置状态码

  • WHEN 创建 AppError
  • THEN 不提供 WithHTTPStatus() 方法
  • AND Code 和 HTTPStatus 始终保持一致

Requirement: Unified Error Response Format

系统 SHALL 使用统一的 JSON 响应格式(错误和成功均使用相同字段)。

Scenario: 响应结构

  • WHEN 返回任何响应时
  • THEN JSON 结构仅包含 4 个字段:
    • code: 业务错误码0 表示成功)
    • msg: 消息(错误消息或 "success"
    • data: 响应数据(成功时有数据,错误时为 null
    • timestamp: ISO 8601 时间戳

Scenario: 不返回 HTTP 状态码字段

  • WHEN 返回响应时
  • THEN JSON 不包含 httpstatus 或 http_status 字段
  • AND HTTP 状态码仅在响应头中体现

Scenario: Handler 返回错误

  • WHEN Handler 函数返回 error
  • THEN 全局 ErrorHandler 拦截错误
  • AND 根据错误类型构造统一格式响应

Requirement: Handler Error Return Convention

所有 Handler 函数 SHALL 通过返回 error 传递错误,由全局 ErrorHandler 统一处理。

Scenario: 业务错误

  • WHEN Handler 遇到业务错误
  • THEN 返回 errors.New(code, message) 创建的 AppError
  • AND 不直接调用 response.Error()

Scenario: 参数验证错误

  • WHEN 请求参数验证失败
  • THEN 返回 errors.New(CodeInvalidParam)
  • AND 不将 validator 的 err.Error() 直接返回给客户端(避免泄露内部字段和规则)
  • AND 详细校验错误 SHALL 记录到日志(用于排查)

Requirement: Service Error Output Convention

Service 层 SHALL 对外输出结构化错误,禁止把普通 error 直接冒泡到 Handler。

Scenario: 预期业务错误

  • WHEN 业务校验失败(例如:验证码错误、资源不存在、状态不允许)
  • THEN 返回 errors.New(<4xx-code>[, message])

Scenario: 非预期系统错误

  • WHEN 发生数据库/缓存/队列/第三方依赖错误
  • THEN 返回 errors.Wrap(<5xx-code>, err, "业务动作失败")
  • AND 客户端 msg 由全局错误映射表提供通用描述

Scenario: 禁止 fmt.Errorf 作为对外错误

  • WHEN Service 需要对外返回错误
  • THEN 不使用 fmt.Errorf(...) 作为返回值
  • AND 必须转换为 AppErrorerrors.New/Wrap

Scenario: 成功响应

  • WHEN Handler 执行成功
  • THEN 调用 response.Success(c, data)
  • AND 返回 nil

Requirement: Standardized Error Codes

系统 SHALL 使用标准化的错误码,删除向后兼容的别名。

Scenario: 参数验证错误码

  • WHEN 参数验证失败
  • THEN 使用 CodeInvalidParam
  • AND 不使用 CodeBadRequest别名已删除

Scenario: 服务不可用错误码

  • WHEN 服务不可用
  • THEN 使用 CodeServiceUnavailable
  • AND 不使用 CodeAuthServiceUnavailable别名已删除

错误报错规范(必须遵守)

Handler 层

  • 禁止直接返回/拼接底层错误信息给客户端
    • 例如:"参数验证失败: " + err.Error()、直接返回 err.Error()
    • 原因:泄露内部字段名和校验规则,造成安全风险
  • 参数校验失败统一返回 errors.New(errors.CodeInvalidParam)
    • 详细校验错误写日志,对外返回通用消息
  • 详细错误信息记录到日志,用于排查问题
    • 日志级别:参数错误使用 WARN 级别(客户端错误)
    • 必须包含:pathmethod、完整错误信息
    • 使用结构化日志(zap.Stringzap.Error

Service 层

  • 禁止对外返回 fmt.Errorf(...)
    • 原因:未结构化的错误消息会泄露实现细节
  • 业务错误使用 errors.New(code[, msg])
    • 适用场景:资源不存在、状态不允许、参数错误等预期错误
  • 系统错误使用 errors.Wrap(code, err[, msg])
    • 适用场景数据库错误、Redis 错误、队列错误等非预期错误

示例对比

Handler 层参数校验

// ❌ 错误:泄露校验细节
if err := c.BodyParser(&req); err != nil {
    return errors.New(errors.CodeInvalidParam, "参数解析失败: "+err.Error())
}

// ✅ 正确:通用消息 + 结构化日志
if err := c.BodyParser(&req); err != nil {
    logger.GetAppLogger().Warn("参数解析失败",
        zap.String("path", c.Path()),
        zap.String("method", c.Method()),
        zap.Error(err),
    )
    return errors.New(errors.CodeInvalidParam, "请求参数格式错误")
}

// ✅ 参数验证失败示例
if err := h.validator.Struct(&req); err != nil {
    logger.GetAppLogger().Warn("参数验证失败",
        zap.String("path", c.Path()),
        zap.String("method", c.Method()),
        zap.Error(err),
    )
    return errors.New(errors.CodeInvalidParam)  // 使用默认消息
}

Service 层错误处理

// ❌ 错误:使用 fmt.Errorf
if err := s.store.Create(ctx, data); err != nil {
    return fmt.Errorf("创建失败: %w", err)
}

// ✅ 正确:使用 errors.Wrap
if err := s.store.Create(ctx, data); err != nil {
    return errors.Wrap(errors.CodeInternalError, err, "创建失败")
}

Service 层错误处理规范

错误分类与映射表

场景分类 错误码 HTTP 状态码 使用方式
资源不存在 CodeNotFound 404 errors.New(errors.CodeNotFound, "资源不存在")
状态不允许 CodeInvalidStatus 400 errors.New(errors.CodeInvalidStatus, "状态不允许此操作")
参数错误 CodeInvalidParam 400 errors.New(errors.CodeInvalidParam)
重复操作 CodeDuplicate 409 errors.New(errors.CodeDuplicate, "资源已存在")
余额不足 CodeInsufficientBalance 400 errors.New(errors.CodeInsufficientBalance)
额度不足 CodeInsufficientQuota 400 errors.New(errors.CodeInsufficientQuota, "分配额度不足")
超过限制 CodeExceedLimit 400 errors.New(errors.CodeExceedLimit, "超过系统限制")
资源冲突 CodeConflict 409 errors.New(errors.CodeConflict, "资源冲突")
数据库错误 CodeInternalError 500 errors.Wrap(errors.CodeInternalError, err, "操作失败")
队列错误 CodeInternalError 500 errors.Wrap(errors.CodeInternalError, err, "任务提交失败")

实际案例

案例 1套餐服务package/service.go

场景:获取套餐

// ❌ 错误:使用 fmt.Errorf
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
    pkg, err := s.packageStore.GetByID(ctx, id)
    if err != nil {
        if err == gorm.ErrRecordNotFound {
            return nil, errors.New(errors.CodeNotFound, "套餐不存在")
        }
        return nil, fmt.Errorf("获取套餐失败: %w", err)  // ❌ 直接返回系统错误
    }
    return s.toResponse(ctx, pkg), nil
}

// ✅ 正确:使用 errors.Wrap
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
    pkg, err := s.packageStore.GetByID(ctx, id)
    if err != nil {
        if err == gorm.ErrRecordNotFound {
            return nil, errors.New(errors.CodeNotFound, "套餐不存在")
        }
        return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")  // ✅
    }
    return s.toResponse(ctx, pkg), nil
}

案例 2分佣提现commission_withdrawal/service.go

场景:余额不足

// ✅ 业务错误使用 errors.New
if wallet.FrozenBalance < amount {
    return nil, errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
}

// ✅ 事务中的数据库错误使用 errors.Wrap
err = s.db.Transaction(func(tx *gorm.DB) error {
    if err := s.walletStore.DeductFrozenBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil {
        return errors.Wrap(errors.CodeInternalError, err, "扣除冻结余额失败")
    }
    // ...
})

案例 3店铺管理shop/service.go

场景:层级限制和重复检查

// ✅ 业务校验
if level > 7 {
    return nil, errors.New(errors.CodeInvalidParam, "店铺层级超过限制")
}

// ✅ 重复检查
existing, _ := s.shopStore.GetByCode(ctx, req.ShopCode)
if existing != nil {
    return nil, errors.New(errors.CodeDuplicate, "店铺代码已存在")
}

// ✅ 数据库操作
if err := s.shopStore.Create(ctx, shop); err != nil {
    return nil, errors.Wrap(errors.CodeInternalError, err, "创建店铺失败")
}

统一原则

  1. 业务错误4xx:使用 errors.New(Code4xx, msg)

    • 资源不存在、状态不允许、参数错误、重复操作等
  2. 系统错误5xx:使用 errors.Wrap(Code5xx, err, msg)

    • 数据库错误、Redis 错误、队列错误、外部服务错误等
  3. 错误消息保持中文:便于日志排查和问题定位

  4. 禁止 fmt.Errorf 对外返回:避免泄露内部实现细节