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

82 lines
4.3 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.
## 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
1. 执行数据库迁移:`tb_polling_config` 添加 `protect_check_interval INT``tb_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与原设计一致