Files
junhong_cmp_fiber/openspec/changes/archive/2026-04-02-fix-polling-coverage-gaps/design.md
huang 8980e6d999
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m11s
fix: 补全三处轮询覆盖缺口
问题一:protect 轮询任务从未被调度器初始化入队
PollingConfig 新增 protect_check_interval 字段(NULL=不参与),
调度器 initCardsBatch/initCardPolling/requeueCard 补全 protect 队列
初始化逻辑,IotCard 新增 last_protect_check_at 记录上次检查时间。
迁移文件:000102_add_polling_protect_fields

问题二:设备套餐流量耗尽时绑定卡未被停机
UsageService.checkAndTriggerSuspension 新增 carrier_type=device 分支,
通过 DeviceSimBindingStore.ListByDeviceID 查询绑定卡,对每张卡异步
触发 CheckAndStopCard,同步注入 Bootstrap 依赖。

问题三:主套餐过期后不立即停机,依赖下次轮询兜底
PackageActivationHandler.processExpiredPackage 在 updateCarrierSuspended
Status 后,若载体无后续生效套餐,立即异步调用 CheckAndStopCard(iot_card
类型直接触发,device 类型遍历绑定卡),消除停机延迟窗口。

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-02 14:36:24 +08:00

4.3 KiB
Raw Blame History

Context

轮询系统由 Scheduler(调度器)+ 4 种 PollingHandler 任务类型构成,以卡(IotCard)为单位驱动停复机、流量扣减、套餐激活等核心业务。本次修复 3 个审计发现的覆盖盲点:

编号 问题 严重度
G1 polling:protect 队列初始化缺失,保护期一致性检查完全失效 P1
G2 checkAndTriggerSuspension device 分支为空,设备套餐耗尽不停机 P1
G3 套餐过期后不主动停机,依赖 carddata 轮询兜底(最长 60s 窗口) P2
G4 getCardCondition"suspended" 分支是死代码 P2

Goals / Non-Goals

Goals:

  • 使 polling:protect 任务能够按配置间隔定时执行
  • 设备级套餐(device_id 绑定)流量耗尽时自动停机
  • 套餐过期时立即触发停机,消除 60s 延迟窗口
  • 清理 getCardCondition 死代码

Non-Goals:

  • 设备维度的主动轮询(查询设备实时状态)
  • 轮询性能优化(并发数、批量大小调整)
  • 新增轮询任务类型

Decisions

决策 1protect_check_interval 放在 PollingConfig 还是全局配置

选择:放在 PollingConfig,与现有 realname_check_interval/carddata_check_interval/package_check_interval 保持一致。

理由:不同运营商/卡类型的保护期检查频率可能不同(例如高价值卡检查更频繁);统一用 PollingConfig 管理,不引入新的配置层级。

放弃方案:全局配置(viper)——灵活性不足,无法按卡类型差异化。


决策 2设备套餐耗尽停机的触发方式

选择:在 UsageService.checkAndTriggerSuspension 补全 device 分支,查询设备绑定的所有卡并逐一调用 StopResumeService.CheckAndStopCard(异步 goroutine

理由

  • 和现有 iot_card 分支对称,逻辑清晰
  • 通过 DeviceSimBindingStore.ListByDeviceID 获取绑定卡列表,不引入跨包依赖
  • 异步执行避免长事务

放弃方案:在 checkStopResume 里增加 device 判断——checkStopResume 以卡为单位工作,不感知 device 维度的套餐,逻辑不自然。


决策 3套餐过期主动停机的触发位置

选择:在 PackageActivationHandler.processExpiredPackageupdateCarrierSuspendedStatus 后,若载体仍无生效套餐,异步调用 StopResumeCallback.CheckAndStopCard

理由processExpiredPackage 已经在判断"是否还有生效套餐",复用这个判断结果,减少重复查询。直接在此处触发停机是最短的调用路径。

放弃方案:新增一个独立的停机触发任务——多一个异步任务链路,延迟不减反增。


决策 4IotCard.LastProtectCheckAt 字段类型

选择*time.Timenullable与现有 LastRealNameCheckAt/LastDataCheckAt 保持一致,表示"未检查过"。

Risks / Trade-offs

  • 重复停机风险:套餐过期时 processExpiredPackage 会触发停机60s 后 checkStopResume 也会尝试停机。CheckAndStopCard 内部已有"卡已停机则跳过"的幂等保护,不会重复调网关。→ 已有幂等保护,风险可控

  • DB 迁移风险:新增两列均为 nullable无需迁移存量数据迁移后旧代码路径不受影响。→ 向下兼容,风险低

  • StopResumeCallback 接口循环依赖PackageActivationHandlerpolling 包)需要调用 StopResumeServiceiot_card 包)。当前已通过接口注入解耦(StopResumeCallback),直接复用该模式。→ 无新依赖

Migration Plan

  1. 执行数据库迁移:tb_polling_config 添加 protect_check_interval INTtb_iot_card 添加 last_protect_check_at TIMESTAMP
  2. 部署新版本调度器重启后自动重新初始化轮询队列protect 队列开始填充)
  3. tb_polling_config 为需要保护期检查的配置行设置 protect_check_interval(建议 600s
  4. 回滚策略:回滚代码后 protect 队列不再填充,已入队任务执行后不再重新入队,不影响其他三种任务

Open Questions

  • protect_check_interval 的默认推荐值:当前代码注释写"与流量检查同频(默认 10 分钟)",是否沿用 600s→ 建议 600s与原设计一致