feat: 轮询系统重构(分片队列 + 停复机统一 + Handler 拆分)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s

【核心变更】

1. 停复机逻辑统一(StopResumeService)
   - 新增 EvaluateAndAct 统一入口,封装三条件停复机判断
   - 停机条件:无套餐(no_package) / 流量耗尽(traffic_exhausted) / 未实名(not_realname)
   - 复机条件:stop_reason 合规 + 有套餐且未耗尽 + 已实名或行业卡
   - 修复设备套餐 Bug:hasValidPackage 按 device_id 查套餐,而非仅 iot_card_id
   - 设备维度停复机加幂等锁(Redis SetNX,TTL 30s),防止多卡并发重复调 Gateway

2. Redis 分片队列(PollingQueueManager)
   - 新建 queue_manager.go,封装所有轮询 Redis 操作
   - 16 分片 Sorted Set,Key 格式:polling:shard:{shardID}:queue:{taskType}
   - Lua 脚本原子出队(ZRANGEBYSCORE + 分批 ZREM),消除竞态窗口
   - 新增背压检测:队列深度超 50 万时 Scheduler 跳过该分片
   - RemoveFromAllQueues 覆盖 4 种任务类型(含 protect)

3. Handler 拆分(polling_handler.go 1360行 → 5个专注文件)
   - polling_base.go:共享基类(并发控制/卡缓存/重入队)
   - polling_realname_handler.go:实名采集,实名 0→1 时立即触发复机
   - polling_carddata_handler.go:流量采集,保留跨月边界检测逻辑
   - polling_package_handler.go:套餐采集,委托 EvaluateAndAct 决策
   - polling_protect_handler.go:保护期一致性检查,保护期内强制修正

4. 配置管理(PollingConfigManager)
   - 新建 config_manager.go,从 scheduler.go 提取配置职责
   - 内存缓存 + 5 分钟定时刷新,刷新失败保留原缓存
   - 修复 getCardCondition:停机卡返回 suspended,不再错配 activated 配置

5. 渐进式初始化(CardInitializer)
   - 新建 initializer.go,分批加载(每批 10 万),批次间 sleep 500ms
   - 过滤 enable_polling=false 的卡,初始化完成前 Scheduler 不出队

6. 卡生命周期服务(PollingLifecycleService)
   - 新建 lifecycle_service.go,替代已删除的 callbacks.go 和 api_callback.go
   - OnCardCreated/OnCardEnabled/OnCardStatusChanged 入队前检查 enable_polling

7. Scheduler 精简(1000+行 → 227行)
   - 保留纯调度循环:scheduleLoop + processShardSchedule + enqueueBatch
   - 保留每 10 秒触发套餐过期检测和流量重置
   - 移除所有 DB 操作、配置加载、卡初始化逻辑

8. 轮询管控 API(enable_polling)
   - 新增 PUT /api/admin/assets/:id/polling-status 接口
   - 支持对设备/卡维度开关轮询,关闭后从所有分片队列移除

9. 数据库迁移
   - 000103:tb_device 新增 enable_polling 字段(boolean, NOT NULL, DEFAULT true)
   - 000104:新增 suspended 轮询配置,为 activated 配置补全 protect_check_interval

【文件统计】
- 新增:19 个文件(handler × 5、polling 组件 × 4、迁移 × 3 等)
- 修改:20 个文件(bootstrap 注入、store 接口、monitoring 适配分片等)
- 删除:3 个文件(polling_handler.go、callbacks.go、api_callback.go)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-07 12:27:04 +08:00
parent 10fcc0b3c9
commit 434a8b0349
62 changed files with 7496 additions and 3023 deletions

View File

@@ -0,0 +1,162 @@
### Requirement: EvaluateAndAct——停复机统一入口
`StopResumeService` 新增 `EvaluateAndAct(ctx context.Context, card *model.IotCard) error` 方法,封装完整的停复机判断和执行逻辑。
> **签名说明**:删除原设计的 `carrierType string, carrierID uint` 参数(仅用于日志,放入签名会误导实现者)。
> 设备/单卡维度通过 `card.DeviceID` 推导,日志上下文通过函数内部的 `zap.Field` 记录。
**删除** `internal/task/polling_handler.go` 中的以下函数(迁移到 StopResumeService
- `checkStopResume`
- `shouldStopCard`
- `hasAvailablePackage`(旧版,被新 `hasValidPackage` 替代)
- `stopCardByUsageExhausted`
- `resumeCardByPackageAvailable`
所有 Task Handlercarddata、package、protect在需要停复机决策时统一调用 `stopResumeService.EvaluateAndAct()`
#### Scenario: Task Handler 调用统一停复机入口
- **GIVEN** `carddata_handler.go` 完成流量数据采集和 DB 写入
- **WHEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card)`
- **THEN** StopResumeService 完整执行停复机判断逻辑Handler 不包含任何无条件停复机相关代码(`shouldStop``hasPackage` 等函数不出现在 Handler 文件中)
#### Scenario: 停机原因按优先级记录
- **GIVEN** 卡在线,同时满足「无套餐」和「流量耗尽」和「未实名」三个条件
- **WHEN** `EvaluateAndAct` 执行 `checkStopReasons`
- **THEN** 按优先级取最高:`no_package > traffic_exhausted > not_realname``stop_reason` 字段记录 `no_package`;停机后 DB 中该卡 `stop_reason='no_package'`
#### Scenario: EvaluateAndAct 幂等
- **GIVEN** 卡已停机,`stop_reason='no_package'`,无套餐状态未变化
- **WHEN** 下次轮询再次调用 `EvaluateAndAct`
- **THEN** 检测到卡已停机(`network_status=0`),进入复机判断分支;复机条件不满足(仍无套餐),不发起 Gateway 调用,返回 nilDB 状态不变
---
### Requirement: 停机条件——三种场景全面覆盖
卡在线(`network_status=1`)时,满足以下**任一**条件触发停机:
**条件 Ano_package**:无有效套餐
- 独立卡:`tb_package_usage` 中无 `iot_card_id=卡ID AND status IN (0,1)` 的记录
- 绑定设备的卡:`tb_package_usage` 中无 `device_id=设备ID AND status IN (0,1)` 的记录
- status=0待激活和 status=1激活中均视为有效套餐
**条件 Btraffic_exhausted**:虚流量耗尽
- 活跃套餐 `status=2`(系统标记为虚流量耗尽),或
- 活跃套餐 `data_usage_mb >= data_limit_mb`(且 `data_limit_mb > 0`,防止无限流量卡误判)
**条件 Cnot_realname**:非行业卡且未实名
- `card_category != 'industry'``real_name_status = 0`
#### Scenario: 无套餐触发停机(独立卡)
- **GIVEN** cardID=100 的独立卡(无 device_id`network_status=1`
- **WHEN** `tb_package_usage` 中无任何 `iot_card_id=100 AND status IN(0,1)` 的记录
- **THEN** `checkStopReasons` 返回 `["no_package"]`;发起 Gateway 停机调用3次重试DB 更新 `network_status=0, stop_reason='no_package'`
#### Scenario: 无套餐触发停机设备卡修复Bug1
- **GIVEN** cardID=200 的卡绑定 deviceID=50`network_status=1`
- **WHEN** `tb_package_usage` 中无 `iot_card_id=200` 的记录,但有 `device_id=50 AND status=1` 的记录(购买了设备套餐)
- **THEN** `hasValidPackage` 检测到设备套餐存在,返回 true不触发停机卡保持在线状态修复之前只查 iot_card_id 导致误停机的 Bug
#### Scenario: 流量耗尽触发停机
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=1000, data_usage_mb=1001`
- **WHEN** `isTrafficExhausted` 检测
- **THEN** `data_usage_mb(1001) >= data_limit_mb(1000)` 条件满足;返回 true触发停机`stop_reason='traffic_exhausted'`
#### Scenario: 套餐 status=2 触发停机
- **GIVEN** 卡在线,活跃套餐 `status=2`(系统已标记虚流量耗尽)
- **WHEN** `isTrafficExhausted` 检测
- **THEN** 检测到 `status=2`;返回 true触发停机`stop_reason='traffic_exhausted'`
#### Scenario: 无限流量套餐不误判流量耗尽
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=0`(无限流量),`data_usage_mb=9999`
- **WHEN** `isTrafficExhausted` 检测
- **THEN** `data_limit_mb=0` 时跳过用量比较(防止无限流量卡被误停机);返回 false不触发停机
#### Scenario: 未实名普通卡触发停机
- **GIVEN** 卡在线,`card_category='normal'``real_name_status=0`,有有效套餐且流量未耗尽
- **WHEN** `checkStopReasons` 检测条件 C
- **THEN** `!isRealnameOK(card)` 为 true触发停机`stop_reason='not_realname'`
#### Scenario: 行业卡无需实名不停机
- **GIVEN** 卡在线,`card_category='industry'``real_name_status=0`,有有效套餐
- **WHEN** `checkStopReasons` 检测
- **THEN** `isRealnameOK(card)` 返回 true行业卡豁免实名要求不触发停机卡保持在线
---
### Requirement: 复机条件——全部满足才复机
卡停机(`network_status=0`)时,以下**全部**条件满足才触发自动复机:
1. `stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')`(排除手动停机、运营商停机等其他原因)
2. 有有效套餐且流量未耗尽(`hasValidPackage=true``isTrafficExhausted=false`
3. 行业卡 OR 已实名(`card_category='industry'` OR `real_name_status=1`
#### Scenario: 购买套餐后自动复机
- **GIVEN** cardID=300 因 `no_package` 停机,`stop_reason='no_package'`现在购买了套餐status=1
- **WHEN** 下次轮询执行 `EvaluateAndAct`
- **THEN** 三个复机条件全部满足stop_reason合规 + 有套餐 + 已实名或行业卡);发起 Gateway 复机调用3次重试DB 更新 `network_status=1, stop_reason=''`
#### Scenario: 手动停机不自动复机
- **GIVEN** 卡 `stop_reason='manual'`(管理员手动停机)
- **WHEN** 该卡购买了套餐,完成实名认证,轮询执行 `EvaluateAndAct`
- **THEN** 条件1不满足`'manual'` 不在 IN 列表中);不触发自动复机;卡保持停机状态;需管理员手动复机
#### Scenario: 流量耗尽停机后购买新套餐复机
- **GIVEN** 卡因 `traffic_exhausted` 停机,`stop_reason='traffic_exhausted'`旧套餐已过期status=3新购套餐 status=1usage=0
- **WHEN** 轮询执行 `EvaluateAndAct`
- **THEN** 条件1满足traffic_exhausted条件2满足新套餐有效usage<limit条件3满足已实名触发复机
#### Scenario: 未实名卡实名后复机
- **GIVEN** 普通卡因 `not_realname` 停机,用户完成实名认证(`real_name_status=1`
- **WHEN** 实名检查轮询执行,更新 DB 后调用 `EvaluateAndAct`
- **THEN** 条件1满足条件2满足有套餐且未耗尽条件3满足`real_name_status=1`);触发复机
---
### Requirement: 设备维度停复机——覆盖所有绑定卡
**停机**操作覆盖设备下所有在线卡(`network_status=1`**复机**操作对满足条件的卡执行,跳过未满足 `isRealnameOK` 的普通卡。
#### Scenario: 设备套餐耗尽停所有绑定卡
- **GIVEN** deviceID=10 下有 3 张在线卡cardID=1,2,3设备套餐 `data_usage_mb >= data_limit_mb`
- **WHEN** 任一绑定卡触发 `EvaluateAndAct`,检测到设备套餐流量耗尽
- **THEN** 调用 `stopDeviceCards(ctx, deviceID=10, "traffic_exhausted")`3 张卡均发起 Gateway 停机调用3 张卡均更新 `stop_reason='traffic_exhausted'`
#### Scenario: 设备停机调用 Gateway 带3次重试
- **GIVEN** 设备下有 3 张卡需要停机Gateway 第一次调用返回超时
- **WHEN** `stopCardWithRetry` 执行
- **THEN** 自动重试至多 3 次;至少 1 次成功则记录成功,最终失败则记录 Error 日志「卡停机失败: cardID=xxx, error=xxx」
#### Scenario: 购买设备套餐后全卡复机
- **GIVEN** deviceID=10 下 3 张卡均因 `traffic_exhausted` 停机,购买新套餐后 status=1
- **WHEN** 轮询执行 `EvaluateAndAct``shouldResume` 返回 true
- **THEN** `resumeDeviceCards(ctx, deviceID=10)` 被调用;检查所有 `stop_reason IN(...)` 的停机卡3 张卡均满足 `isRealnameOK`均已实名3 张卡均触发复机
#### Scenario: 设备复机跳过未实名普通卡
- **GIVEN** deviceID=20 下有 3 张卡card-A已实名、card-B已实名、card-C`card_category='normal'`, `real_name_status=0`
- **WHEN** 设备复机条件满足,执行 `resumeDeviceCards`
- **THEN** card-A 和 card-B 触发复机(各执行 Gateway 复机调用card-C 因 `isRealnameOK=false` 被跳过保持停机card-C 的 `stop_reason` 自动更新为 `not_realname`
#### Scenario: 设备复机中单卡 Gateway 失败不阻止其他卡
- **GIVEN** 设备下 3 张卡需要复机card-B 的 Gateway 复机调用失败(含重试)
- **WHEN** `resumeDeviceCards` 遍历执行
- **THEN** card-A 和 card-C 正常复机card-B 记录 Error 日志并跳过,不影响其他卡的复机;`resumeDeviceCards` 整体不返回 error尽力复机语义
---
### Requirement: 设备维度操作幂等锁——防止并发重复 Gateway 调用
设备下多张卡分布在不同分片队列,同一调度周期内可能同时触发 `EvaluateAndAct`,进而并发调用 `stopDeviceCards` / `resumeDeviceCards`,导致同一张卡被 Gateway 重复停/复机。
`stopDeviceCards``resumeDeviceCards` 均在执行前通过 Redis `SetNX` 获取设备操作锁(`polling:device:op_lock:{deviceID}`TTL 30 秒)。获取失败时视为"其他协程正在处理",直接跳过。
#### Scenario: 多卡并发触发不重复调 Gateway
- **GIVEN** deviceID=10 下有 card-Ashard 3和 card-Bshard 7同一调度周期被并发出队
- **WHEN** card-A 和 card-B 同时触发 `EvaluateAndAct` → 均尝试调用 `stopDeviceCards(deviceID=10)`
- **THEN** 第一个调用获取设备锁成功,执行完整的停机流程;第二个调用 `SetNX` 返回 false记录 Debug 日志「设备停机操作已在进行中,跳过: deviceID=10」直接返回设备下各卡只被 Gateway 停机一次
#### Scenario: 设备锁 TTL 超时后可重新获取
- **GIVEN** 设备操作锁已过期(上次操作完成后 `Del` 释放,或 TTL 30 秒到期)
- **WHEN** 下一轮轮询再次触发 `stopDeviceCards`
- **THEN** `SetNX` 成功获取锁,正常执行停机流程