Files
junhong_cmp_fiber/openspec/changes/archive/2026-01-29-service-error-unify-support/design.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

12 KiB
Raw Blame History

设计文档Service 层错误语义统一 - 支持模块

概述

本设计文档描述了对剩余支持模块进行错误语义统一的技术方案,确保整个项目的错误处理一致性。

架构设计

错误处理流程

Service 层业务逻辑
├── 业务校验错误 (4xx)
│   ├── errors.New(errors.CodeNotFound, "xxx")
│   ├── errors.New(errors.CodeDuplicate, "xxx")
│   ├── errors.New(errors.CodeInvalidPassword, "xxx")
│   └── errors.New(errors.CodeInsufficientQuota, "xxx")
└── 系统依赖错误 (5xx)
    ├── errors.Wrap(errors.CodeInternalError, err, "xxx")
    └── errors.Wrap(errors.CodeServiceUnavailable, err, "xxx")

         ↓
         
Handler 层返回错误
         ↓
         
全局 ErrorHandler 统一处理
├── 提取错误码和消息
├── 根据错误码设置日志级别
│   ├── 4xx → WARN
│   └── 5xx → ERROR
└── 返回统一 JSON 格式

模块分类

1. 套餐分配系统4 个文件)

文件 错误点数 主要错误场景
shop_package_allocation/service.go 17 分配记录不存在、额度不足、数据库错误
shop_series_allocation/service.go 24 系列分配记录不存在、分配冲突、数据库错误
shop_package_batch_allocation/service.go 6 批量分配失败
shop_package_batch_pricing/service.go 3 批量定价失败

2. 权限与账号管理3 个文件)

文件 错误点数 主要错误场景
account/service.go 24 账号不存在、用户名重复、密码错误、状态不允许
role/service.go 15 角色不存在、角色已存在、角色被使用无法删除
permission/service.go 10 权限不存在、权限冲突

3. 卡与设备管理2 个文件)

文件 错误点数 主要错误场景
enterprise_card/service.go 9 卡不存在、卡状态不允许
enterprise_device/service.go 20 设备不存在、设备状态不允许、设备绑定卡数量超限

4. 其他支持服务5 个文件)

文件 错误点数 主要错误场景
carrier/service.go 9 运营商不存在
shop_commission/service.go 7 分佣设置不存在
commission_withdrawal_setting/service.go 4 提现设置不存在
email/service.go 6 邮件服务未配置、邮件发送失败
sync/service.go 4 同步任务失败

错误码映射

现有错误码

错误码 常量名 HTTP 状态码 使用场景
404 CodeNotFound 404 资源不存在
40003 CodeDuplicate 400 资源重复
40004 CodeInvalidPassword 400 密码错误
40008 CodeInvalidStatus 400 状态不允许
403 CodeForbidden 403 禁止操作
50000 CodeInternalError 500 内部错误
50003 CodeServiceUnavailable 503 服务不可用

新增错误码

错误码 常量名 HTTP 状态码 使用场景
40010 CodeInsufficientQuota 400 额度不足
40011 CodeExceedLimit 400 超过限制
40900 CodeConflict 409 资源冲突

实现示例

业务校验错误示例

场景 1资源不存在

// ❌ 修改前
func (s *ShopPackageAllocationService) GetByID(ctx context.Context, id uint) (*model.ShopPackageAllocation, error) {
    allocation, err := s.store.ShopPackageAllocation.GetByID(ctx, id)
    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, fmt.Errorf("分配记录不存在")
        }
        return nil, fmt.Errorf("查询分配记录失败: %w", err)
    }
    return allocation, nil
}

// ✅ 修改后
func (s *ShopPackageAllocationService) GetByID(ctx context.Context, id uint) (*model.ShopPackageAllocation, error) {
    allocation, err := s.store.ShopPackageAllocation.GetByID(ctx, id)
    if err != nil {
        if errors.Is(err, gorm.ErrRecordNotFound) {
            return nil, errors.New(errors.CodeNotFound, "分配记录不存在")
        }
        return nil, errors.Wrap(errors.CodeInternalError, err, "查询分配记录失败")
    }
    return allocation, nil
}

场景 2额度不足

// ❌ 修改前
func (s *ShopPackageAllocationService) AllocatePackage(ctx context.Context, req *dto.AllocatePackageRequest) error {
    if req.Amount > available {
        return fmt.Errorf("可用额度不足,当前可用: %d", available)
    }
    // ...
}

// ✅ 修改后
func (s *ShopPackageAllocationService) AllocatePackage(ctx context.Context, req *dto.AllocatePackageRequest) error {
    if req.Amount > available {
        return errors.New(errors.CodeInsufficientQuota, fmt.Sprintf("可用额度不足,当前可用: %d", available))
    }
    // ...
}

场景 3角色被使用无法删除

// ❌ 修改前
func (s *RoleService) Delete(ctx context.Context, id uint) error {
    count, err := s.store.Account.CountByRoleID(ctx, id)
    if err != nil {
        return fmt.Errorf("查询角色使用情况失败: %w", err)
    }
    if count > 0 {
        return fmt.Errorf("角色被 %d 个账号使用,无法删除", count)
    }
    // ...
}

// ✅ 修改后
func (s *RoleService) Delete(ctx context.Context, id uint) error {
    count, err := s.store.Account.CountByRoleID(ctx, id)
    if err != nil {
        return errors.Wrap(errors.CodeInternalError, err, "查询角色使用情况失败")
    }
    if count > 0 {
        return errors.New(errors.CodeForbidden, fmt.Sprintf("角色被 %d 个账号使用,无法删除", count))
    }
    // ...
}

系统依赖错误示例

场景 4数据库操作失败

// ❌ 修改前
func (s *AccountService) Create(ctx context.Context, req *dto.CreateAccountRequest) error {
    account := &model.Account{
        Username: req.Username,
        // ...
    }
    if err := s.store.Account.Create(ctx, account); err != nil {
        return fmt.Errorf("创建账号失败: %w", err)
    }
    return nil
}

// ✅ 修改后
func (s *AccountService) Create(ctx context.Context, req *dto.CreateAccountRequest) error {
    account := &model.Account{
        Username: req.Username,
        // ...
    }
    if err := s.store.Account.Create(ctx, account); err != nil {
        return errors.Wrap(errors.CodeInternalError, err, "创建账号失败")
    }
    return nil
}

场景 5外部服务不可用

// ❌ 修改前
func (s *EmailService) Send(ctx context.Context, to, subject, body string) error {
    if s.smtpClient == nil {
        return fmt.Errorf("邮件服务未配置")
    }
    if err := s.smtpClient.Send(to, subject, body); err != nil {
        return fmt.Errorf("邮件发送失败: %w", err)
    }
    return nil
}

// ✅ 修改后
func (s *EmailService) Send(ctx context.Context, to, subject, body string) error {
    if s.smtpClient == nil {
        return errors.New(errors.CodeServiceUnavailable, "邮件服务未配置")
    }
    if err := s.smtpClient.Send(to, subject, body); err != nil {
        return errors.Wrap(errors.CodeInternalError, err, "邮件发送失败")
    }
    return nil
}

测试策略

单元测试覆盖

每个模块需要补充以下错误场景测试:

func TestService_ErrorHandling(t *testing.T) {
    tx := testutils.NewTestTransaction(t)
    rdb := testutils.GetTestRedis(t)
    testutils.CleanTestRedisKeys(t, rdb)
    
    store := postgres.NewStore(tx, rdb)
    service := NewService(store, nil)
    
    t.Run("资源不存在返回 404", func(t *testing.T) {
        _, err := service.GetByID(context.Background(), 99999)
        require.Error(t, err)
        assert.Equal(t, errors.CodeNotFound, errors.GetCode(err))
    })
    
    t.Run("额度不足返回 400", func(t *testing.T) {
        err := service.AllocatePackage(context.Background(), &dto.AllocatePackageRequest{
            Amount: 999999,
        })
        require.Error(t, err)
        assert.Equal(t, errors.CodeInsufficientQuota, errors.GetCode(err))
    })
    
    t.Run("数据库错误返回 500", func(t *testing.T) {
        // 模拟数据库错误(如外键约束违反)
        err := service.Create(context.Background(), invalidData)
        require.Error(t, err)
        assert.Equal(t, errors.CodeInternalError, errors.GetCode(err))
    })
}

集成测试验证

通过 HTTP 接口验证错误码:

# 1. 资源不存在返回 404
curl -X GET http://localhost:8080/api/admin/allocations/99999 \
  -H "Authorization: Bearer $TOKEN"
# 期望: {"code": 404, "msg": "分配记录不存在", ...}

# 2. 额度不足返回 400
curl -X POST http://localhost:8080/api/admin/allocations \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount": 999999}'
# 期望: {"code": 40010, "msg": "可用额度不足", ...}

# 3. 角色被使用无法删除返回 403
curl -X DELETE http://localhost:8080/api/admin/roles/1 \
  -H "Authorization: Bearer $TOKEN"
# 期望: {"code": 403, "msg": "角色被使用,无法删除", ...}

执行计划

分批执行策略

批次 模块 文件数 预估时间
第 1 批 权限与账号管理 3 1.5h
第 2 批 套餐分配系统 4 1.5h
第 3 批 卡与设备管理 2 1h
第 4 批 其他支持服务 5 1h
验证 全量测试和文档更新 - 1h

每批次执行步骤

  1. 扫描错误点:使用 grep 查找所有 fmt.Errorf 使用点
  2. 分类错误场景:区分业务校验错误和系统依赖错误
  3. 替换错误处理:使用 errors.New()errors.Wrap()
  4. 补充单元测试:覆盖新的错误场景
  5. 运行测试验证source .env.local && go test -v ./internal/service/xxx/...

向后兼容性

错误消息保持中文

所有错误消息保持中文描述,确保客户端和日志的可读性:

// ✅ 正确:保持中文消息
return errors.New(errors.CodeNotFound, "分配记录不存在")

// ❌ 错误:不要改成英文
return errors.New(errors.CodeNotFound, "allocation not found")

错误码升级路径

部分接口的错误码会从 500 调整为 4xx客户端需要处理新的错误码

原错误码 新错误码 场景
500 404 资源不存在
500 400 业务校验失败(如额度不足)
500 403 禁止操作(如角色被使用)
500 409 资源冲突

风险评估

低风险

  • 错误消息保持中文:不影响客户端和日志的可读性
  • 向后兼容:新错误码是对现有 500 错误的细化,不破坏现有功能
  • 测试覆盖:每个模块补充错误场景测试,确保质量

中风险

  • 错误码调整:部分接口错误码从 500 调整为 4xx客户端需要适配
  • 新增错误码:如 CodeInsufficientQuotaCodeExceedLimit,客户端需要处理

缓解措施

  • 文档更新:在 docs/003-error-handling/使用指南.md 中补充新增错误码说明
  • 日志验证:运行集成测试后检查 logs/access.loglogs/app.log确认错误码正确分类4xx 为 WARN5xx 为 ERROR

验收标准

代码质量

  • 所有文件已移除 fmt.Errorf 对外返回
  • 业务错误使用 errors.New(Code4xx)
  • 系统错误使用 errors.Wrap(Code5xx, err)
  • 错误消息保持中文描述

测试覆盖

  • 每个模块补充错误场景单元测试
  • 编译通过:go build -o /tmp/test_api ./cmd/api
  • 单元测试通过:source .env.local && go test -v ./internal/service/xxx/...
  • 集成测试通过:source .env.local && go test -v ./tests/integration/...

日志验证

  • 4xx 错误记录为 WARN 级别
  • 5xx 错误记录为 ERROR 级别
  • 错误日志包含完整堆栈跟踪5xx

文档更新

  • 更新 openspec/specs/error-handling/spec.md(补充新增错误码)
  • 更新 docs/003-error-handling/使用指南.md(添加实际案例)

总结

本设计通过系统化的错误语义统一,实现了以下目标:

  1. 一致性:所有支持模块遵循统一的错误处理规范
  2. 可维护性:错误分类清晰,便于问题定位和排查
  3. 可测试性:错误场景覆盖完整,单元测试覆盖率高
  4. 可观测性:错误日志分级合理,便于监控和告警

通过分批执行和充分测试,确保变更的安全性和质量。