# 设计文档: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:资源不存在 ```go // ❌ 修改前 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:额度不足 ```go // ❌ 修改前 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:角色被使用无法删除 ```go // ❌ 修改前 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:数据库操作失败 ```go // ❌ 修改前 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:外部服务不可用 ```go // ❌ 修改前 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 } ``` ## 测试策略 ### 单元测试覆盖 每个模块需要补充以下错误场景测试: ```go 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 接口验证错误码: ```bash # 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/...` ## 向后兼容性 ### 错误消息保持中文 所有错误消息保持中文描述,确保客户端和日志的可读性: ```go // ✅ 正确:保持中文消息 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,客户端需要适配 - **新增错误码**:如 `CodeInsufficientQuota`、`CodeExceedLimit`,客户端需要处理 ### 缓解措施 - **文档更新**:在 `docs/003-error-handling/使用指南.md` 中补充新增错误码说明 - **日志验证**:运行集成测试后检查 `logs/access.log` 和 `logs/app.log`,确认错误码正确分类(4xx 为 WARN,5xx 为 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. **可观测性**:错误日志分级合理,便于监控和告警 通过分批执行和充分测试,确保变更的安全性和质量。