问题一: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>
4.3 KiB
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
决策 1:protect_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.processExpiredPackage 的 updateCarrierSuspendedStatus 后,若载体仍无生效套餐,异步调用 StopResumeCallback.CheckAndStopCard。
理由:processExpiredPackage 已经在判断"是否还有生效套餐",复用这个判断结果,减少重复查询。直接在此处触发停机是最短的调用路径。
放弃方案:新增一个独立的停机触发任务——多一个异步任务链路,延迟不减反增。
决策 4:IotCard.LastProtectCheckAt 字段类型
选择:*time.Time(nullable),与现有 LastRealNameCheckAt/LastDataCheckAt 保持一致,表示"未检查过"。
Risks / Trade-offs
-
重复停机风险:套餐过期时
processExpiredPackage会触发停机,60s 后checkStopResume也会尝试停机。CheckAndStopCard内部已有"卡已停机则跳过"的幂等保护,不会重复调网关。→ 已有幂等保护,风险可控 -
DB 迁移风险:新增两列均为 nullable,无需迁移存量数据,迁移后旧代码路径不受影响。→ 向下兼容,风险低
-
StopResumeCallback接口循环依赖:PackageActivationHandler(polling 包)需要调用StopResumeService(iot_card 包)。当前已通过接口注入解耦(StopResumeCallback),直接复用该模式。→ 无新依赖
Migration Plan
- 执行数据库迁移:
tb_polling_config添加protect_check_interval INT;tb_iot_card添加last_protect_check_at TIMESTAMP - 部署新版本(调度器重启后自动重新初始化轮询队列,protect 队列开始填充)
- 在
tb_polling_config为需要保护期检查的配置行设置protect_check_interval(建议 600s) - 回滚策略:回滚代码后 protect 队列不再填充,已入队任务执行后不再重新入队,不影响其他三种任务
Open Questions
protect_check_interval的默认推荐值:当前代码注释写"与流量检查同频(默认 10 分钟)",是否沿用 600s?→ 建议 600s,与原设计一致