# 设计文档：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. **可观测性**：错误日志分级合理，便于监控和告警

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