# Spec: 客户视图流量查询

## 业务背景

### 为什么需要客户视图流量查询

**现状问题**：
- 客户无法清晰看到主套餐和加油包的分别使用情况
- 流量汇总不准确（包含已失效加油包）
- 客户端需要多次调用 API 才能获取完整流量信息

**业务目标**：
- 提供统一的流量查询 API
- 区分主套餐和加油包流量
- 自动汇总总计流量
- 仅显示当前有效套餐

---

## 业务规则

### 1. 流量汇总规则

```
总计流量 = 主套餐流量 + 所有生效中/已用完加油包流量

包含的套餐：
- status=1（生效中）
- status=2（已用完但未过期）

不包含的套餐：
- status=0（待生效）
- status=3（已过期）
- status=4（已失效）
```

### 2. 主套餐优先显示

- **规则**：如果有多个主套餐（理论上只有1个生效中），优先显示 status=1 的主套餐
- **待生效主套餐**：不在客户视图中显示

### 3. 加油包按优先级排序

- **排序规则**：按 priority ASC 排序（优先扣减的加油包排在前面）
- **失效加油包**：不在客户视图中显示

---

## ADDED Requirements

### Requirement: 提供客户视图流量查询 API

系统 SHALL 提供 GET /api/h5/packages/my-usage API，返回客户的套餐流量使用情况。

#### Scenario: 查询单个主套餐流量
- **GIVEN** 客户有1个主套餐（已用 8GB，总量 10GB），无加油包
- **WHEN** 客户调用 GET /api/h5/packages/my-usage
- **THEN** 系统返回：
  ```json
  {
    "code": 200,
    "data": {
      "main_package": {
        "package_id": 123,
        "package_name": "月度套餐10GB",
        "used_mb": 8192,
        "total_mb": 10240,
        "status": 1,
        "status_text": "生效中",
        "expires_at": "2026-02-28T23:59:59Z"
      },
      "addon_packages": [],
      "total": {
        "used_mb": 8192,
        "total_mb": 10240
      }
    }
  }
  ```

#### Scenario: 查询主套餐和加油包流量
- **GIVEN** 客户有：
  - 主套餐：已用 9GB，总量 10GB
  - 加油包1（priority=1）：已用 3GB，总量 5GB
  - 加油包2（priority=2）：已用 1GB，总量 3GB
- **WHEN** 客户调用 GET /api/h5/packages/my-usage
- **THEN** 系统返回 main_package, addon_packages（2个加油包，按 priority 排序）, total: {used: 13GB, total: 18GB}

#### Scenario: 主套餐用完但加油包有剩余
- **GIVEN** 客户主套餐已用 10GB/总量 10GB（status=2），加油包已用 2GB/总量 5GB（status=1）
- **WHEN** 客户调用 API
- **THEN** 系统返回：
  - main_package: status=2, status_text="已用完"
  - addon_packages: status=1, status_text="生效中"
  - total: {used: 12GB, total: 15GB}

### Requirement: 客户视图区分主套餐和加油包

系统 SHALL 在响应中明确区分主套餐（main_package）和加油包（addon_packages）的流量信息。

#### Scenario: 响应包含主套餐信息
- **WHEN** 客户查询流量使用情况
- **THEN** 响应的 main_package 字段包含：
  - package_id, package_name
  - used_mb, total_mb
  - status, status_text
  - expires_at, activated_at

#### Scenario: 响应包含加油包列表
- **GIVEN** 客户有3个加油包
- **WHEN** 客户查询
- **THEN** 响应的 addon_packages 字段为数组，按 priority 排序，每个元素包含：
  - package_id, package_name
  - used_mb, total_mb
  - status, status_text
  - expires_at, activated_at
  - priority

### Requirement: 客户视图显示总计流量

系统 SHALL 在响应中提供 total 字段，汇总主套餐和所有加油包的流量。

#### Scenario: 总计流量计算正确
- **GIVEN** 主套餐 used=8GB/total=10GB，加油包1 used=2GB/total=5GB，加油包2 used=1GB/total=3GB
- **WHEN** 计算总计
- **THEN** total: {used_mb: 11GB, total_mb: 18GB}

#### Scenario: 已失效加油包不计入总计
- **GIVEN** 主套餐 used=8GB/total=10GB，加油包 status=4（已失效）used=2GB/total=5GB
- **WHEN** 计算总计
- **THEN** total: {used_mb: 8GB, total_mb: 10GB}（不包含已失效加油包）

#### Scenario: 已用完套餐计入总计
- **GIVEN** 主套餐 status=2（已用完）used=10GB/total=10GB，加油包 status=1 used=2GB/total=5GB
- **WHEN** 计算总计
- **THEN** total: {used_mb: 12GB, total_mb: 15GB}（已用完套餐仍计入）

### Requirement: 客户视图仅返回当前生效套餐

系统 SHALL 仅返回 status=1（生效中）或 status=2（已用完但未过期）的套餐信息。

#### Scenario: 不返回待生效套餐
- **GIVEN** 客户有1个生效中主套餐（status=1）和1个待生效主套餐（status=0）
- **WHEN** 客户查询
- **THEN** 响应仅包含生效中的主套餐，不包含待生效套餐

#### Scenario: 不返回已过期套餐
- **GIVEN** 客户的主套餐已过期（status=3）
- **WHEN** 客户查询
- **THEN** 响应 main_package=null，提示"无有效套餐"

#### Scenario: 不返回已失效加油包
- **GIVEN** 客户有生效中主套餐和1个已失效加油包（status=4）
- **WHEN** 客户查询
- **THEN** 响应 addon_packages 不包含已失效加油包

### Requirement: 客户视图性能要求

系统 SHALL 确保客户视图 API 响应时间 P95 < 200ms。

#### Scenario: 查询性能达标
- **GIVEN** 客户有1个主套餐和5个加油包
- **WHEN** 客户调用 API
- **THEN** API 响应时间 < 200ms（P95）

#### Scenario: 使用索引优化查询
- **GIVEN** 系统有索引 idx_carrier_status（carrier_id + status）
- **WHEN** 查询套餐时
- **THEN** 数据库使用索引，查询时间 < 50ms

---

## 边界条件

### 1. 无任何套餐

- **场景**：客户没有购买任何套餐
- **处理**：返回 main_package=null, addon_packages=[], total={used_mb:0, total_mb:0}

### 2. 主套餐过期但加油包未过期

- **场景**：主套餐过期，加油包有独立有效期且未过期
- **处理**：主套餐过期时，加油包被级联失效（status=4），不显示在客户视图

### 3. 并发查询

- **场景**：客户短时间内多次调用查询 API
- **处理**：使用只读事务，确保数据一致性

---

## 数据一致性保证

### 1. 只读事务

- **查询套餐**：使用只读事务，确保数据一致性

### 2. 索引优化

- **必需索引**：
  - `idx_carrier_status`（carrier_id + status）
  - `idx_package_type_priority`（package_type + priority）

---

## 性能指标

| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|------|-------------|---------|--------|
| 客户视图查询 | < 200ms (P95) | 500 QPS | 单载体查询（1主套餐+5加油包） |
| 数据库查询 | < 50ms | 1000 QPS | 索引查询 |

---

## 错误码定义

| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|--------|------------|---------|------|
| `NO_VALID_PACKAGE` | 404 | 无有效套餐 | 客户无任何生效中套餐 |
| `CARRIER_NOT_FOUND` | 404 | 载体不存在 | 载体ID不存在 |

---

## 数据迁移策略

**激进策略**（开发阶段，保证干净性）：

### 1. ❌ 要删除的字段

无（新增 API，不涉及数据迁移）

### 2. ✅ 新增的字段

无（使用现有字段）

### 3. ❌ 要废弃的逻辑

- **废弃旧的客户端流量查询 API**：如果存在旧的流量查询接口，统一替换为新接口

### 4. ✅ 索引优化

```sql
-- 确保必需索引存在
CREATE INDEX IF NOT EXISTS idx_carrier_status
ON package_usage(carrier_id, status);

CREATE INDEX IF NOT EXISTS idx_package_type_priority
ON package_usage(package_type, priority);
```

---

## 测试场景矩阵

| 场景分类 | 测试用例 | 预期结果 |
|---------|---------|---------|
| **单个主套餐** | 查询单个主套餐流量 | 返回 main_package, addon_packages=[], total |
| **主套餐+加油包** | 查询主套餐和加油包 | 返回 main_package, addon_packages（按 priority 排序）, total |
| **总计流量** | 总计流量计算正确 | total = 主套餐 + 所有加油包 |
| | 已失效加油包不计入总计 | 不包含 status=4 的加油包 |
| | 已用完套餐计入总计 | 包含 status=2 的套餐 |
| **筛选套餐** | 不返回待生效套餐 | 仅返回 status IN (1,2) |
| | 不返回已过期套餐 | main_package=null |
| | 不返回已失效加油包 | addon_packages 不含 status=4 |
| **性能** | 查询性能达标 | 响应时间 < 200ms (P95) |
| | 使用索引优化 | 数据库查询 < 50ms |
| **边界** | 无任何套餐 | main_package=null, addon_packages=[], total={0,0} |
| | 主套餐过期加油包未过期 | 加油包被级联失效，不显示 |

---

## 实现参考

### Handler: GetMyUsage

```go
// Handler: GetMyUsage
func (h *Handler) GetMyUsage(c *fiber.Ctx) error {
    // 从上下文获取载体信息
    carrierType := middleware.GetCarrierTypeFromContext(c.UserContext())
    carrierID := middleware.GetCarrierIDFromContext(c.UserContext())

    // 查询流量使用情况
    usage, err := h.service.GetMyUsage(c.UserContext(), carrierType, carrierID)
    if err != nil {
        return err
    }

    return response.Success(c, usage)
}

// Service 层：GetMyUsage
func (s *Service) GetMyUsage(ctx context.Context, carrierType string, carrierID uint) (*dto.MyUsageResponse, error) {
    // 查询生效中或已用完的套餐
    usages, err := s.store.ListActiveUsages(ctx, carrierType, carrierID)
    if err != nil {
        return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐失败")
    }

    // 分类套餐
    var mainPackage *model.PackageUsage
    var addonPackages []*model.PackageUsage

    for _, usage := range usages {
        if usage.PackageType == constants.PackageTypeFormal {
            if mainPackage == nil || usage.Status == constants.PackageStatusActive {
                mainPackage = usage  // 优先选择生效中的主套餐
            }
        } else if usage.PackageType == constants.PackageTypeAddon {
            addonPackages = append(addonPackages, usage)
        }
    }

    // 按优先级排序加油包
    sort.Slice(addonPackages, func(i, j int) bool {
        return addonPackages[i].Priority < addonPackages[j].Priority
    })

    // 构造响应
    resp := &dto.MyUsageResponse{
        Total: &dto.TotalUsage{
            UsedMB:  0,
            TotalMB: 0,
        },
    }

    // 主套餐
    if mainPackage != nil {
        resp.MainPackage = s.toPackageUsageVO(mainPackage)
        resp.Total.UsedMB += mainPackage.DataUsageMB
        resp.Total.TotalMB += mainPackage.TotalDataMB
    }

    // 加油包
    for _, addon := range addonPackages {
        resp.AddonPackages = append(resp.AddonPackages, s.toPackageUsageVO(addon))
        resp.Total.UsedMB += addon.DataUsageMB
        resp.Total.TotalMB += addon.TotalDataMB
    }

    return resp, nil
}

// Store 层：ListActiveUsages
func (s *Store) ListActiveUsages(ctx context.Context, carrierType string, carrierID uint) ([]*model.PackageUsage, error) {
    var usages []*model.PackageUsage
    err := s.db.WithContext(ctx).
        Where("carrier_type = ? AND carrier_id = ? AND status IN (?, ?)",
            carrierType, carrierID,
            constants.PackageStatusActive,
            constants.PackageStatusUsedUp).
        Order("package_type ASC, priority ASC").
        Find(&usages).Error
    return usages, err
}
```

---

**本 Spec 完成**（简化版），包含：
- ✅ 业务背景和业务规则
- ✅ 详细场景（主套餐、加油包、总计流量）
- ✅ 边界条件
- ✅ 数据一致性保证和性能指标
- ✅ 错误码定义
- ✅ **激进的数据迁移策略**（索引优化）
- ✅ 测试场景矩阵和实现参考
