## ADDED Requirements

### Requirement: 套餐流量汇总

系统 SHALL 根据套餐类型（单卡套餐、设备级套餐）汇总流量使用情况。

#### Scenario: 单卡套餐流量读取

- **WHEN** 检查套餐A，套餐类型为单卡套餐（单张卡绑定）
- **THEN** 系统直接读取该卡的 current_month_usage_mb，作为套餐已用流量

#### Scenario: 设备级套餐流量汇总

- **WHEN** 检查套餐B，套餐类型为设备级套餐，设备D下有 3 张卡
- **THEN** 系统查询设备下所有卡的 current_month_usage_mb，求和得到套餐已用流量

#### Scenario: 设备级套餐部分卡无流量数据

- **WHEN** 设备级套餐，部分卡从未查询过流量（current_month_usage_mb 为 NULL 或 0）
- **THEN** 系统将 NULL 视为 0，继续汇总其他卡的流量

#### Scenario: 套餐无关联卡

- **WHEN** 检查套餐C，套餐下没有关联的卡
- **THEN** 系统记录警告日志，套餐已用流量为 0

### Requirement: 虚流量对比

系统 SHALL 对比套餐的实际流量使用与虚流量，判断是否超额。

#### Scenario: 未超额

- **WHEN** 套餐A的实际流量为 800 MB，虚流量为 1000 MB
- **THEN** 系统判断未超额，更新 package_usage 状态为正常（status=1）

#### Scenario: 已超额

- **WHEN** 套餐A的实际流量为 1200 MB，虚流量为 1000 MB
- **THEN** 系统判断已超额，更新 package_usage 状态为超额（status=2），触发停机流程

#### Scenario: 临近超额（预警）

- **WHEN** 套餐A的实际流量为 950 MB，虚流量为 1000 MB，超过 95% 阈值
- **THEN** 系统更新 package_usage 状态为预警（status=3），发送告警通知，但不停机

#### Scenario: 虚流量为 0 或 NULL

- **WHEN** 套餐的虚流量字段为 0 或 NULL
- **THEN** 系统记录警告日志，跳过超额检查（视为无限流量套餐）

### Requirement: 自动停机

系统 SHALL 在套餐超额时自动调用 Gateway 停机。

#### Scenario: 单卡套餐停机

- **WHEN** 套餐A超额，套餐类型为单卡套餐
- **THEN** 系统调用 Gateway.StopCard(ICCID)，停机该卡

#### Scenario: 设备级套餐批量停机

- **WHEN** 套餐B超额，套餐类型为设备级套餐，设备下有 3 张卡
- **THEN** 系统调用 Gateway.StopCard() 停机所有 3 张卡（串行或并行）

#### Scenario: Gateway 停机成功

- **WHEN** Gateway.StopCard() 返回成功
- **THEN** 系统更新卡的 network_status=2（已停机），记录操作日志

#### Scenario: Gateway 停机失败

- **WHEN** Gateway.StopCard() 返回失败（如卡已停机、网络超时）
- **THEN** 系统记录错误日志，任务失败，卡重新入队（下次继续尝试停机）

#### Scenario: 部分卡停机失败

- **WHEN** 设备级套餐停机，3 张卡中 2 张成功、1 张失败
- **THEN** 系统记录成功的卡，对失败的卡单独重试或记录错误

### Requirement: 数据库更新

系统 SHALL 更新套餐使用状态和卡的网络状态到数据库。

#### Scenario: 更新套餐使用状态

- **WHEN** 套餐超额，执行停机后
- **THEN** 系统执行 `UPDATE package_usage SET status=2, used_mb=1200, last_check_at=NOW() WHERE id=?`

#### Scenario: 更新卡网络状态

- **WHEN** 卡A被停机
- **THEN** 系统执行 `UPDATE iot_cards SET network_status=2, last_status_change_at=NOW() WHERE id=?`

#### Scenario: 数据库更新失败

- **WHEN** 数据库更新失败（如连接断开）
- **THEN** 系统记录错误日志，任务失败，套餐重新入队

### Requirement: Redis 缓存更新

系统 SHALL 同步更新 Redis 缓存中的套餐和卡状态。

#### Scenario: 更新套餐缓存

- **WHEN** 数据库更新成功
- **THEN** 系统使用 HSET 更新 Redis 缓存（polling:package:{package_id}）的 status、used_mb、last_check_at 字段

#### Scenario: 更新卡缓存

- **WHEN** 卡状态更新成功
- **THEN** 系统使用 HSET 更新 Redis 缓存（polling:card:{card_id}）的 network_status 字段

#### Scenario: 缓存更新失败不影响主流程

- **WHEN** Redis 更新失败
- **THEN** 系统记录警告日志，但任务仍视为成功（缓存可以通过定时同步或懒加载恢复）

### Requirement: 操作日志记录

系统 SHALL 记录停机操作到操作日志表。

#### Scenario: 记录停机操作

- **WHEN** 卡A被停机
- **THEN** 系统插入操作日志（card_id、operation_type='stop'、reason='套餐超额'、operator='系统自动'、created_at=NOW()）

#### Scenario: 操作日志包含套餐信息

- **WHEN** 记录停机操作
- **THEN** 操作日志包含 package_id、used_mb、virtual_flow 等上下文信息

#### Scenario: 操作日志插入失败

- **WHEN** 操作日志插入失败
- **THEN** 系统记录警告日志，但任务仍视为成功（操作日志非关键路径）

### Requirement: 手动触发队列

系统 SHALL 支持从手动触发队列获取套餐并优先处理。

#### Scenario: 卡流量更新后触发

- **WHEN** 卡A的流量检查完成，卡A有关联套餐
- **THEN** 系统将套餐ID加入 polling:manual:package 手动触发队列

#### Scenario: 手动触发队列优先处理

- **WHEN** 调度循环执行，手动触发队列有 10 个套餐
- **THEN** 系统先从手动触发队列取出所有套餐，生成高优先级任务（ProcessIn(0)），清空手动触发队列

#### Scenario: 再处理定时队列

- **WHEN** 手动触发队列处理完成后
- **THEN** 系统继续处理定时队列中到期的套餐

### Requirement: 定时队列扫描

系统 SHALL 定期扫描所有套餐的流量使用情况，防止遗漏。

#### Scenario: 周期性扫描套餐

- **WHEN** 调度循环执行，当前时间为 T
- **THEN** 系统从 Redis Sorted Set（polling:queue:package）获取 score <= T 的套餐（最多 1000 个）

#### Scenario: 生成 Asynq 任务

- **WHEN** 获取到 100 个到期的套餐
- **THEN** 系统为每个套餐生成 Asynq 任务（TaskTypeIotPackageCheck），入队到 iot_polling_package 队列

#### Scenario: 从队列移除已调度的套餐

- **WHEN** 任务生成完成
- **THEN** 系统使用 ZREM 从 Redis 队列移除这些套餐（检查完成后会重新加入）

### Requirement: 并发控制

系统 SHALL 使用 Redis 信号量控制套餐检查的并发数。

#### Scenario: 获取并发信号量成功

- **WHEN** 套餐检查任务开始执行，当前并发数为 20，配置的最大并发数为 30
- **THEN** 系统使用 INCR 增加计数，获取信号量成功，执行任务

#### Scenario: 并发已满

- **WHEN** 套餐检查任务开始执行，当前并发数为 30，配置的最大并发数为 30
- **THEN** 系统 INCR 后发现超过限制，DECR 归还，任务返回 SkipRetry（不执行，等待下次调度）

#### Scenario: 任务完成释放信号量

- **WHEN** 套餐检查任务完成（成功或失败）
- **THEN** 系统使用 DECR 释放信号量

### Requirement: 重新入队

系统 SHALL 在检查完成后，将套餐重新加入轮询队列。

#### Scenario: 计算下次检查时间

- **WHEN** 套餐检查完成，当前配置的检查间隔为 3600 秒
- **THEN** 系统计算 next_check_time = NOW() + 3600秒

#### Scenario: 加回队列

- **WHEN** 计算出下次检查时间
- **THEN** 系统使用 ZADD 将套餐ID和时间戳加入 polling:queue:package

#### Scenario: 任务失败也重新入队

- **WHEN** 任务因 Gateway 超时失败
- **THEN** 系统仍然将套餐重新入队（按原计划下次检查），不阻塞后续检查

### Requirement: 监控统计

系统 SHALL 记录套餐检查的成功率和耗时。

#### Scenario: 记录成功统计

- **WHEN** 套餐检查成功完成，耗时 345 毫秒
- **THEN** 系统更新 Redis Hash（polling:stats:package），增加 success_count_1h，累加 total_duration_1h

#### Scenario: 记录失败统计

- **WHEN** 套餐检查失败（Gateway 超时）
- **THEN** 系统更新 Redis Hash，增加 failure_count_1h

#### Scenario: 每小时重置计数器

- **WHEN** 每小时整点（如 10:00:00）
- **THEN** 系统重置计数器（success_count_1h、failure_count_1h、total_duration_1h），保持时间窗口滚动

### Requirement: 日志记录

系统 SHALL 记录详细的套餐检查日志，便于排查问题。

#### Scenario: 记录开始日志

- **WHEN** 套餐检查任务开始执行
- **THEN** 系统记录 Info 日志，包含 package_id、package_type、virtual_flow

#### Scenario: 记录成功日志

- **WHEN** 套餐检查成功完成
- **THEN** 系统记录 Info 日志，包含 package_id、used_mb、virtual_flow、is_exceeded、duration_ms

#### Scenario: 记录停机日志

- **WHEN** 执行停机操作
- **THEN** 系统记录 Warn 日志，包含 package_id、card_ids、reason='套餐超额'

#### Scenario: 记录失败日志

- **WHEN** 套餐检查失败
- **THEN** 系统记录 Error 日志，包含 package_id、error 详情
