## 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 详情