All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m45s
主要变更: 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 个主规范文件 破坏性变更:无 向后兼容:是
12 KiB
12 KiB
设计文档: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 |
每批次执行步骤
- 扫描错误点:使用 grep 查找所有
fmt.Errorf使用点 - 分类错误场景:区分业务校验错误和系统依赖错误
- 替换错误处理:使用
errors.New()或errors.Wrap() - 补充单元测试:覆盖新的错误场景
- 运行测试验证:
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,客户端需要适配
- 新增错误码:如
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(添加实际案例)
总结
本设计通过系统化的错误语义统一,实现了以下目标:
- 一致性:所有支持模块遵循统一的错误处理规范
- 可维护性:错误分类清晰,便于问题定位和排查
- 可测试性:错误场景覆盖完整,单元测试覆盖率高
- 可观测性:错误日志分级合理,便于监控和告警
通过分批执行和充分测试,确保变更的安全性和质量。