Files
junhong_cmp_fiber/openspec/changes/archive/2026-04-02-fix-polling-coverage-gaps/tasks.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

53 lines
4.8 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.
## 1. 数据库迁移
- [x] 1.1 创建迁移文件,在 `tb_polling_config` 添加 `protect_check_interval INT NULL COMMENT '保护期一致性检查间隔NULL=不参与'`
- [x] 1.2 在同一迁移文件中,在 `tb_iot_card` 添加 `last_protect_check_at TIMESTAMP NULL COMMENT '上次保护期一致性检查时间'`
- [x] 1.3 执行迁移并验证:通过 PostgreSQL MCP 确认两列已成功添加,类型和注释正确
## 2. Model 更新
- [x] 2.1 在 `internal/model/polling.go``PollingConfig` struct 添加 `ProtectCheckInterval *int` 字段gorm tag 包含 `column:protect_check_interval`json tag 为 `protect_check_interval`,中文注释
- [x] 2.2 在 `internal/model/iot_card.go``IotCard` struct 添加 `LastProtectCheckAt *time.Time` 字段gorm tag 包含 `column:last_protect_check_at`json tag 为 `last_protect_check_at`,中文注释
- [x] 2.3 运行 `lsp_diagnostics` 确认两个 model 文件无错误
## 3. 轮询调度器protect 队列初始化
- [x] 3.1 在 `internal/polling/scheduler.go``initCardsBatch` 方法中,在 package 队列初始化块之后添加 protect 队列初始化:读取 `cfg.ProtectCheckInterval`,调用 `calculateNextCheckTime(card.LastProtectCheckAt, *cfg.ProtectCheckInterval)`,写入 `RedisPollingQueueProtectKey()`
- [x] 3.2 在 `initCardPolling` 方法中同步添加 protect 队列初始化逻辑(与 `initCardsBatch` 对称)
- [x] 3.3 在 `requeueCard` 方法中添加 `constants.TaskTypePollingProtect` case读取 `config.ProtectCheckInterval`,入队 `RedisPollingQueueProtectKey()`,更新 `last_protect_check_at`
- [x] 3.4 在 `HandleProtectConsistencyCheck` 任务处理完成后调用 `requeueCard(cardID, TaskTypePollingProtect)` 使卡重新入队
- [x] 3.5 运行 `lsp_diagnostics` 确认 `scheduler.go``polling_handler.go` 无错误
## 4. 为 protect 配置设置检查间隔
- [x] 4.1 通过数据库执行 SQL为现有已启用的 `tb_polling_config` 配置行(`status = 1` 的行)设置 `protect_check_interval = 600`10 分钟)
- [x] 4.2 通过 PostgreSQL MCP 验证配置已更新,查询 `SELECT id, config_name, protect_check_interval FROM tb_polling_config WHERE status = 1`
## 5. 设备套餐耗尽触发停机
- [x] 5.1 在 `internal/service/package/usage_service.go``UsageService` struct 中添加 `deviceSimBindingStore *postgres.DeviceSimBindingStore` 字段
- [x] 5.2 更新 `NewUsageService` 构造函数,接收 `deviceSimBindingStore` 参数并赋值
- [x] 5.3 更新 `bootstrap``UsageService` 的初始化调用,传入 `deviceSimBindingStore`
- [x] 5.4 在 `checkAndTriggerSuspension``if activeCount == 0` 块内,补全 `carrierType == "device"` 分支:查询设备绑定卡列表(`ListByDeviceID`),对每张卡异步启动 goroutine 调用 `s.stopResumeCallback.CheckAndStopCard`;若 `stopResumeCallback == nil` 则仅记录 Warn 日志
- [x] 5.5 运行 `lsp_diagnostics` 确认 `usage_service.go` 无错误
## 6. 套餐过期主动触发停机
- [x] 6.1 在 `internal/polling/package_activation_handler.go``PackageActivationHandler` struct 中添加 `stopResumeCallback StopResumeCallback` 字段(复用 `usage_service.go` 已有的同名接口)
- [x] 6.2 更新 `NewPackageActivationHandler` 构造函数,接收 `stopResumeCallback` 参数并赋值
- [x] 6.3 更新 `Scheduler``PackageActivationHandler` 的初始化,传入 `stopResumeCallback`
- [x] 6.4 在 `processExpiredPackage` 中,`updateCarrierSuspendedStatus` 调用之后,若载体类型为 `iot_card``stopResumeCallback != nil`,异步调用 `CheckAndStopCard(cardID)`;若载体类型为 `device`,查询绑定卡并逐一异步触发
- [x] 6.5 运行 `lsp_diagnostics` 确认 `package_activation_handler.go` 无错误
## 7. 死代码清理
- [x] 7.1 在 `internal/polling/scheduler.go``getCardCondition` 函数中,删除永不可达的 `if card.NetworkStatus == 0 { return "suspended" }``return ""` 两行(已实名+离线永远走 `"real_name"` 分支)
- [x] 7.2 运行 `lsp_diagnostics` 确认 `scheduler.go` 无错误
## 8. 端到端验证
- [x] 8.1 重启服务,通过日志确认调度器初始化时 protect 队列有卡入队(关键日志:`完成一批卡初始化`
- [x] 8.2 通过 PostgreSQL MCP 查询一张卡的 `last_protect_check_at`,确认 protect 任务执行后字段有更新:`SELECT id, iccid, last_protect_check_at FROM tb_iot_card WHERE last_protect_check_at IS NOT NULL LIMIT 5`
- [x] 8.3 通过 PostgreSQL MCP 构造一条设备级套餐耗尽场景:确认设备套餐 status=2depleted绑定卡的 `network_status` 变为 0`stop_reason = 'traffic_exhausted'`
- [x] 8.4 通过 PostgreSQL MCP 查询一张已过期套餐(`expires_at <= NOW`)且无后续套餐的卡,确认其 `network_status = 0``stop_reason = 'traffic_exhausted'`