feat: 实现 IoT 卡轮询系统(支持千万级卡规模)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m35s

实现功能:
- 实名状态检查轮询(可配置间隔)
- 卡流量检查轮询(支持跨月流量追踪)
- 套餐检查与超额自动停机
- 分布式并发控制(Redis 信号量)
- 手动触发轮询(单卡/批量/条件筛选)
- 数据清理配置与执行
- 告警规则与历史记录
- 实时监控统计(队列/性能/并发)

性能优化:
- Redis 缓存卡信息,减少 DB 查询
- Pipeline 批量写入 Redis
- 异步流量记录写入
- 渐进式初始化(10万卡/批)

压测工具(scripts/benchmark/):
- Mock Gateway 模拟上游服务
- 测试卡生成器
- 配置初始化脚本
- 实时监控脚本

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 17:32:44 +08:00
parent b11edde720
commit 931e140e8e
104 changed files with 16883 additions and 87 deletions

View File

@@ -0,0 +1,254 @@
## 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 Setpolling: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 Hashpolling: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 详情