Files
junhong_cmp_fiber/openspec/specs/polling-stop-resume-logic/spec.md
huang 434a8b0349
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s
feat: 轮询系统重构(分片队列 + 停复机统一 + Handler 拆分)
【核心变更】

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>
2026-04-07 12:27:04 +08:00

163 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
### 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` 成功获取锁,正常执行停机流程