# Spec: 流量扣减优先级机制

## 业务背景

现有套餐系统在流量扣减时不区分主套餐和加油包，导致：
1. **用户体验差**：用户购买加油包后，主套餐仍在扣减，加油包未生效
2. **停机逻辑错误**：主套餐流量用完即停机，加油包剩余流量浪费
3. **流量统计混乱**：多套餐同时扣减，无法追溯流量消耗路径

本规范引入流量扣减优先级机制，确保：
- **加油包优先扣减**：购买加油包后，优先消耗加油包流量
- **主套餐兜底**：加油包用完后，再扣减主套餐流量
- **全部用完停机**：主套餐 + 所有加油包流量都用完才停机

## 业务规则

### 扣减优先级规则（多维度排序）
```
优先级（从高到低）：
1. 加油包（按 priority ASC, expires_at ASC, activated_at ASC）
2. 主套餐
```

**多维度排序规则**（按优先级递减）：
1. **主键：priority ASC** - 数字越小优先级越高（1 > 2 > 3）
2. **次键：expires_at ASC** - 先到期的优先扣减（避免流量浪费）
3. **兜底：activated_at ASC** - 先激活的优先扣减（相同到期时间时）

**SQL 示例**：
```sql
SELECT * FROM tb_package_usage
WHERE card_id = ?
  AND status = 'active'
  AND remaining_data_amount > 0
ORDER BY
  priority ASC,           -- 加油包（priority=1）在正式套餐（priority=10）前
  expires_at ASC,         -- 同优先级：3天后到期的在7天后到期的前
  activated_at ASC        -- 同到期时间：早激活的在晚激活的前
LIMIT 10;
```

**业务意义**：
- **先用即将到期的**：避免流量过期浪费
- **确定性排序**：相同条件下结果稳定，便于问题排查

**示例**：
```
载体有：主套餐（剩余10GB）+ 加油包A（priority=1, 剩余5GB）+ 加油包B（priority=2, 剩余3GB）
产生 12GB 流量：
1. 扣减加油包A：5GB → 0GB（用完）
2. 扣减加油包B：3GB → 0GB（用完）
3. 扣减主套餐：4GB → 6GB（剩余6GB）
```

### 停机条件规则
- **旧逻辑**：主套餐流量用完即停机
- **新逻辑**：主套餐 + 所有加油包流量都用完才停机

**判断逻辑**：
```sql
SELECT COUNT(*) FROM tb_package_usage
WHERE (iot_card_id/device_id)=? AND status=1
  AND data_usage_mb < data_limit_mb;

-- 如果 COUNT = 0，则触发停机
```

### 流量扣减算法
```
输入：上游返回的累计流量（upstream_cumulative_mb）
输出：更新各套餐的 data_usage_mb

1. 查询载体当前生效套餐（status=1），按优先级排序：
   加油包（priority ASC）→ 主套餐
2. 计算本次流量增量：
   increment = upstream_cumulative_mb - 上次记录的累计流量
3. 依次扣减：
   FOR EACH 套餐 IN 优先级列表:
     可扣减量 = MIN(increment, 套餐剩余额度)
     UPDATE data_usage_mb += 可扣减量
     记录到 PackageUsageDailyRecord
     increment -= 可扣减量
     IF data_usage_mb >= data_limit_mb:
       UPDATE status=2（已用完）
     IF increment == 0:
       BREAK
4. 检查停机条件：
   IF 所有套餐 status=2:
     触发停机操作
```

### 并发控制
- **场景**：轮询系统同时检测到多张卡的流量增加
- **机制**：数据库事务 + 行锁（SELECT FOR UPDATE）
- **保证**：同一套餐不会被并发扣减导致负数流量

### 性能要求
- 单次流量扣减 < 100ms（包含数据库更新 + 日记录写入）
- 批量扣减（1000张卡）< 10秒

## ADDED Requirements

### Requirement: 流量优先扣减加油包
系统 SHALL 在扣减流量时，优先扣减加油包流量，再扣减主套餐流量。

**业务价值**：用户购买加油包后，立即生效，优先消耗加油包流量，避免浪费。

**技术实现**：
- 查询时按 `master_usage_id IS NOT NULL, priority ASC` 排序
- 主套餐（master_usage_id=NULL）排在最后

#### Scenario: 存在加油包时优先扣减
- **GIVEN** 载体有主套餐（data_usage_mb=0, data_limit_mb=10240）和加油包（data_usage_mb=0, data_limit_mb=5120, priority=1）
- **WHEN** 上游返回累计流量 3072MB（本次增量 3GB）
- **THEN** 系统执行：
  1. 扣减加油包：data_usage_mb=3072
  2. 主套餐不扣减：data_usage_mb=0
- **AND** PackageUsageDailyRecord 记录加油包增量 3072MB

#### Scenario: 加油包用完后扣减主套餐
- **GIVEN** 载体有主套餐（data_usage_mb=0, data_limit_mb=10240）和加油包（data_usage_mb=3072, data_limit_mb=5120）
- **WHEN** 上游返回累计流量 8192MB（本次增量 5GB）
- **THEN** 系统执行：
  1. 扣减加油包：5120 - 3072 = 2048MB 可用，扣减 2048MB → data_usage_mb=5120（用完）
  2. 更新加油包 status=2（已用完）
  3. 剩余流量 5GB - 2GB = 3GB
  4. 扣减主套餐：data_usage_mb=3072
- **AND** PackageUsageDailyRecord 记录加油包增量 2048MB、主套餐增量 3072MB

#### Scenario: 只有主套餐时直接扣减
- **GIVEN** 载体只有主套餐（data_usage_mb=0, data_limit_mb=10240），无加油包
- **WHEN** 上游返回累计流量 3072MB
- **THEN** 系统直接扣减主套餐：data_usage_mb=3072
- **AND** PackageUsageDailyRecord 记录主套餐增量 3072MB

#### Scenario: 加油包已用完自动跳过（边界条件）
- **GIVEN** 载体有主套餐（data_usage_mb=0, data_limit_mb=10240）和加油包（data_usage_mb=5120, data_limit_mb=5120, status=2）
- **WHEN** 上游返回累计流量 3072MB
- **THEN** 系统跳过已用完的加油包，直接扣减主套餐：data_usage_mb=3072
- **AND** 加油包 data_usage_mb 保持 5120（不再扣减）

#### Scenario: 流量增量为 0 不扣减（边界条件）
- **GIVEN** 载体有主套餐和加油包
- **WHEN** 上游返回累计流量与上次记录相同（增量=0）
- **THEN** 系统不更新任何套餐的 data_usage_mb
- **AND** 不创建 PackageUsageDailyRecord

#### Scenario: 流量增量为负数拒绝扣减（异常处理）
- **GIVEN** 载体上次记录累计流量 10GB
- **WHEN** 上游返回累计流量 8GB（负增量，异常情况）
- **THEN** 系统记录 Warning 日志："上游流量异常，累计流量减少"
- **AND** 不更新套餐 data_usage_mb
- **AND** 告警通知运维团队

### Requirement: 多个加油包按多维度排序扣减

系统 SHALL 当存在多个加油包时，按 **priority ASC, expires_at ASC, activated_at ASC** 多维度排序扣减流量。

**业务价值**：
- 按购买顺序消耗加油包（priority）
- 优先消耗即将到期的流量（expires_at）
- 确定性排序便于问题排查（activated_at）

**技术实现**：
- 查询时：`ORDER BY (master_usage_id IS NOT NULL) DESC, priority ASC, expires_at ASC, activated_at ASC`
- 确保加油包按多维度排序排在主套餐前

#### Scenario: 按到期时间优先扣减（多维度排序验证）
- **GIVEN** 载体有2个加油包，相同 priority：
  - 加油包A：priority=1, data_limit_mb=5120, expires_at=2026-02-15 23:59:59
  - 加油包B：priority=1, data_limit_mb=3072, expires_at=2026-02-12 23:59:59（先到期）
- **WHEN** 上游返回累计流量 4096MB（本次增量 4GB）
- **THEN** 系统执行：
  1. 扣减加油包B（先到期）：3072MB → data_usage_mb=3072（用完），status=2
  2. 剩余流量 4GB - 3GB = 1GB
  3. 扣减加油包A：1024MB → data_usage_mb=1024
- **AND** PackageUsageDailyRecord 记录加油包B增量 3072MB、加油包A增量 1024MB

#### Scenario: 完整多维度排序示例
- **GIVEN** 载体有：
  - 主套餐：priority=10, data_limit_mb=10240, expires_at=2026-03-31
  - 加油包A：priority=1, data_limit_mb=2048, expires_at=2026-02-15, activated_at=2026-02-01
  - 加油包B：priority=2, data_limit_mb=3072, expires_at=2026-02-20, activated_at=2026-02-03
  - 加油包C：priority=1, data_limit_mb=4096, expires_at=2026-02-15, activated_at=2026-02-05（与A同priority和expires_at，但晚激活）
- **WHEN** 上游返回累计流量 12288MB（本次增量 12GB）
- **THEN** 系统按以下顺序扣减：
  1. 加油包A（priority=1, expires_at=2026-02-15, activated_at=2026-02-01 最早）
  2. 加油包C（priority=1, expires_at=2026-02-15, activated_at=2026-02-05）
  3. 加油包B（priority=2）
  4. 主套餐（priority=10）
- **AND** 扣减结果：
  - 加油包A：2048MB → status=2（用完）
  - 加油包C：4096MB → status=2（用完）
  - 加油包B：3072MB → status=2（用完）
  - 主套餐：3072MB（剩余 12GB - 2GB - 4GB - 3GB）

#### Scenario: 按购买顺序扣减多个加油包
- **GIVEN** 载体有加油包A（priority=1, data_usage_mb=0, data_limit_mb=3072）和加油包B（priority=2, data_usage_mb=0, data_limit_mb=5120）
- **WHEN** 上游返回累计流量 4096MB（本次增量 4GB）
- **THEN** 系统执行：
  1. 扣减加油包A：3072MB → data_usage_mb=3072（用完），status=2
  2. 剩余流量 4GB - 3GB = 1GB
  3. 扣减加油包B：1024MB → data_usage_mb=1024
- **AND** PackageUsageDailyRecord 记录加油包A增量 3072MB、加油包B增量 1024MB

#### Scenario: Priority 最小的加油包用完后扣减下一个
- **GIVEN** 载体有3个加油包（priority=1/2/3），priority=1 已用完（status=2）
- **WHEN** 上游返回累计流量增量 2GB
- **THEN** 系统跳过 priority=1，扣减 priority=2 的加油包 2GB

#### Scenario: 所有加油包用完后扣减主套餐
- **GIVEN** 载体有主套餐和2个加油包（priority=1/2），两个加油包都已用完（status=2）
- **WHEN** 上游返回累计流量增量 5GB
- **THEN** 系统跳过所有加油包，扣减主套餐 5GB

#### Scenario: 3个加油包和主套餐的完整扣减流程
- **GIVEN** 载体有：
  - 主套餐（data_limit_mb=10240, data_usage_mb=0）
  - 加油包A（priority=1, data_limit_mb=2048, data_usage_mb=0）
  - 加油包B（priority=2, data_limit_mb=3072, data_usage_mb=0）
  - 加油包C（priority=3, data_limit_mb=4096, data_usage_mb=0）
- **WHEN** 上游返回累计流量 12288MB（本次增量 12GB）
- **THEN** 系统执行：
  1. 扣减加油包A：2048MB → status=2（用完）
  2. 扣减加油包B：3072MB → status=2（用完）
  3. 扣减加油包C：4096MB → status=2（用完）
  4. 扣减主套餐：3072MB（剩余 12GB - 2GB - 3GB - 4GB）
- **AND** PackageUsageDailyRecord 记录 4 条记录

#### Scenario: 并发扣减同一套餐（并发控制）
- **GIVEN** 两个轮询任务同时检测到同一张卡的流量增加
- **WHEN** 两个任务同时尝试扣减加油包A
- **THEN** 第一个任务获取行锁（SELECT FOR UPDATE），执行扣减
- **AND** 第二个任务等待锁释放，检测到已扣减，跳过（幂等性保证）
- **AND** 加油包A的 data_usage_mb 只增加一次

### Requirement: 所有流量用完时触发停机
系统 SHALL 在主套餐和所有加油包流量都用完时，触发停机操作。

**业务价值**：充分利用加油包流量，避免提前停机，提升用户体验。

**技术实现**：
```sql
-- 停机条件检查
SELECT COUNT(*) FROM tb_package_usage
WHERE (iot_card_id/device_id)=? AND status=1 AND master_usage_id IS NULL;

-- 如果 COUNT=0（主套餐已过期或用完），检查加油包
SELECT COUNT(*) FROM tb_package_usage
WHERE (iot_card_id/device_id)=? AND status=1 AND master_usage_id IS NOT NULL
  AND data_usage_mb < data_limit_mb;

-- 如果两个 COUNT 都=0，触发停机
```

#### Scenario: 主套餐和加油包都用完触发停机
- **GIVEN** 主套餐 data_usage_mb=10240, data_limit_mb=10240（用完），加油包 data_usage_mb=5120, data_limit_mb=5120（用完）
- **WHEN** 轮询系统检查停机条件
- **THEN** 系统查询生效中套餐剩余流量，结果为 0
- **AND** 触发停机操作：
  1. 调用运营商 API 停机
  2. 更新 IotCard.network_status=0（已停机）
  3. 记录操作日志
- **AND** 主套餐和加油包 status 更新为 2（已用完）

#### Scenario: 有加油包剩余流量时不停机
- **GIVEN** 主套餐 data_usage_mb=10240, data_limit_mb=10240（用完），加油包 data_usage_mb=4096, data_limit_mb=5120（剩余1GB）
- **WHEN** 轮询系统检查停机条件
- **THEN** 系统查询生效中套餐剩余流量，结果 > 0
- **AND** 不触发停机，继续提供服务

#### Scenario: 主套餐未用完但加油包都用完（不停机）
- **GIVEN** 主套餐 data_usage_mb=8192, data_limit_mb=10240（剩余2GB），所有加油包都用完（status=2）
- **WHEN** 轮询系统检查停机条件
- **THEN** 系统查询主套餐剩余流量 > 0
- **AND** 不触发停机

#### Scenario: 主套餐过期但加油包有剩余（不停机）
- **GIVEN** 主套餐 status=3（已过期），加油包 data_usage_mb=2048, data_limit_mb=5120（剩余3GB）, status=1
- **WHEN** 轮询系统检查停机条件
- **THEN** 系统查询生效中加油包剩余流量 > 0
- **AND** 不触发停机

#### Scenario: 停机后续费加油包自动复机（业务理解）
- **GIVEN** 载体已停机（所有套餐流量用完）
- **WHEN** 用户购买新加油包（立即激活，status=1）
- **THEN** 下次轮询检查时，发现有剩余流量 > 0
- **AND** 自动触发复机操作：
  1. 调用运营商 API 复机
  2. 更新 IotCard.network_status=1（已开机）
  3. 记录操作日志

#### Scenario: 停机 API 调用失败（异常处理）
- **GIVEN** 载体所有套餐流量用完，需要停机
- **WHEN** 调用运营商停机 API 失败（例如网络超时）
- **THEN** 系统记录 Error 日志，包含卡号、错误信息
- **AND** 停机任务进入重试队列（Asynq 重试 3 次，间隔 10 秒）
- **AND** 如果 3 次重试都失败，进入死信队列（DLQ）
- **AND** 告警通知运维团队

### Requirement: 流量扣减记录到日记录表
系统 SHALL 在扣减流量时，更新 PackageUsage 的 data_usage_mb，并创建或更新 PackageUsageDailyRecord。

**业务价值**：
- 精细化流量统计（按套餐、按日）
- 支持流量详单查询
- 数据可追溯、可审计

**技术实现**：
- 扣减流量后，创建或更新当日 PackageUsageDailyRecord
- 使用 UPSERT（ON CONFLICT UPDATE）避免重复记录
- 记录字段：`package_usage_id`, `date`, `daily_usage_mb`, `cumulative_usage_mb`

#### Scenario: 扣减主套餐流量并记录
- **GIVEN** 主套餐 data_usage_mb=0, data_limit_mb=10240
- **WHEN** 扣减主套餐 2048MB 流量
- **THEN** PackageUsage 更新：data_usage_mb=2048
- **AND** PackageUsageDailyRecord 创建记录：
  - package_usage_id=主套餐ID
  - date=2026-02-10
  - daily_usage_mb=2048
  - cumulative_usage_mb=2048

#### Scenario: 扣减加油包流量并记录
- **GIVEN** 加油包 data_usage_mb=0, data_limit_mb=5120
- **WHEN** 扣减加油包 3072MB 流量
- **THEN** PackageUsage 更新：data_usage_mb=3072
- **AND** PackageUsageDailyRecord 创建记录：
  - package_usage_id=加油包ID
  - date=2026-02-10
  - daily_usage_mb=3072
  - cumulative_usage_mb=3072

#### Scenario: 同一天多次扣减更新日记录
- **GIVEN** PackageUsageDailyRecord 已有记录（date=2026-02-10, daily_usage_mb=2048, cumulative_usage_mb=2048）
- **WHEN** 再次扣减主套餐 1024MB 流量
- **THEN** PackageUsage 更新：data_usage_mb=3072
- **AND** PackageUsageDailyRecord 更新记录：
  - daily_usage_mb=3072（2048 + 1024）
  - cumulative_usage_mb=3072
- **AND** 使用 UPSERT 更新而非插入新记录

#### Scenario: 跨天扣减创建新日记录
- **GIVEN** PackageUsageDailyRecord 有 2026-02-10 的记录（daily_usage_mb=5120, cumulative_usage_mb=5120）
- **WHEN** 2026-02-11 扣减主套餐 2048MB 流量
- **THEN** PackageUsageDailyRecord 创建新记录：
  - date=2026-02-11
  - daily_usage_mb=2048
  - cumulative_usage_mb=7168（5120 + 2048）

#### Scenario: 日记录写入失败不影响扣减（容错性）
- **GIVEN** 数据库主表正常，日记录表存在问题（例如磁盘满）
- **WHEN** 扣减主套餐流量，PackageUsage 更新成功，但 PackageUsageDailyRecord 写入失败
- **THEN** 系统记录 Error 日志，包含套餐ID、日期、增量
- **AND** PackageUsage 的 data_usage_mb 仍然更新（不回滚）
- **AND** 告警通知运维团队修复日记录表

#### Scenario: 批量扣减写入日记录（性能优化）
- **GIVEN** 轮询系统同时检测到 1000 张卡的流量增加
- **WHEN** 批量扣减流量
- **THEN** 使用批量 INSERT ON CONFLICT UPDATE 写入日记录
- **AND** 1000 条记录写入时间 < 5 秒

## 数据一致性保证

### 1. 扣减流量事务保证
- **机制**：数据库事务包含：
  1. UPDATE PackageUsage SET data_usage_mb += increment
  2. INSERT/UPDATE PackageUsageDailyRecord
- **回滚条件**：任一步骤失败，整个事务回滚

### 2. 并发扣减行锁
- **机制**：`SELECT * FROM tb_package_usage WHERE id=? FOR UPDATE`
- **保证**：同一套餐不会被并发扣减

### 3. 负数流量保护
- **机制**：数据库约束 `CHECK (data_usage_mb >= 0)`
- **保证**：扣减后不会出现负数流量

### 4. 日记录唯一索引
- **机制**：`UNIQUE INDEX (package_usage_id, date) WHERE deleted_at IS NULL`
- **保证**：同一套餐同一天只有一条记录

## 性能指标

| 操作 | 性能要求 | 监控指标 |
|------|---------|---------|
| 单次流量扣减 | < 100ms | 数据库事务耗时 |
| 批量扣减（1000张卡） | < 10秒 | 轮询任务执行时间 |
| 日记录写入 | < 50ms | INSERT/UPDATE 耗时 |
| 停机条件检查 | < 50ms | SELECT 查询耗时 |

## 错误码定义

| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|--------|------------|---------|------|
| CodeInternal | 500 | 流量扣减失败，请重试 | 数据库更新失败 |
| CodeInternal | 500 | 停机操作失败，请重试 | 运营商 API 调用失败 |

## 测试场景矩阵

| 维度 | 场景 | 预期结果 |
|------|------|---------|
| **基础扣减** | 只有主套餐 | 直接扣减主套餐 |
| | 有1个加油包 | 优先扣减加油包 |
| | 有3个加油包 | 按 priority 顺序扣减 |
| **扣减完整流程** | 加油包用完 → 主套餐 | 先扣完所有加油包，再扣主套餐 |
| | 所有套餐用完 | 触发停机 |
| **边界条件** | 流量增量=0 | 不扣减 |
| | 流量增量<0（异常） | 拒绝扣减，告警 |
| | 加油包已用完 | 自动跳过 |
| **并发场景** | 并发扣减同一套餐 | 行锁保证只扣减一次 |
| **停机条件** | 主套餐用完+加油包剩余 | 不停机 |
| | 所有套餐用完 | 停机 |
| | 停机后购买加油包 | 自动复机 |
| **日记录** | 首次扣减 | 创建日记录 |
| | 同一天多次扣减 | 更新日记录 |
| | 跨天扣减 | 创建新日记录 |
| **异常处理** | 停机 API 失败 | 重试 3 次，失败进 DLQ |
| | 日记录写入失败 | 告警，不影响扣减 |
