fix: 补全三处轮询覆盖缺口
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m11s

问题一: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>
This commit is contained in:
2026-04-02 14:36:24 +08:00
parent 322ded0012
commit 8980e6d999
24 changed files with 617 additions and 54 deletions

View File

@@ -0,0 +1,52 @@
## 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'`