fix: 补全三处轮询覆盖缺口
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m11s
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:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-02
|
||||
@@ -0,0 +1,81 @@
|
||||
## 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,与原设计一致
|
||||
@@ -0,0 +1,34 @@
|
||||
## Why
|
||||
|
||||
轮询系统审计发现 3 个覆盖盲点:`polling:protect` 队列从未被初始化导致保护期一致性检查完全失效;设备级套餐耗尽时停机逻辑分支为空导致绑定卡继续在线消耗流量;套餐过期后卡的网络状态不主动停机,依赖下一轮 carddata 轮询兜底存在最长 60s 的流量泄露窗口。这三个问题均直接影响计费准确性和资源管控。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **补全 `polling:protect` 调度器初始化**:在 `PollingConfig` 添加 `protect_check_interval` 字段,在 `tb_iot_card` 添加 `last_protect_check_at` 字段,在 `initCardsBatch`/`initCardPolling`/`requeueCard` 补全 protect 队列写入逻辑,使保护期一致性检查能够定时执行
|
||||
- **设备级套餐耗尽触发停机**:在 `UsageService.checkAndTriggerSuspension` 补全 `carrierType == "device"` 分支,查询设备绑定的所有卡并异步触发停机
|
||||
- **套餐过期时主动停机**:在 `PackageActivationHandler.processExpiredPackage` 过期主套餐且无后续套餐时,直接异步触发 `CheckAndStopCard`,消除依赖轮询兜底的延迟窗口
|
||||
- **修正 `getCardCondition` 死代码**:已实名+离线的卡永远返回 `"real_name"` 而非 `"suspended"`,移除永不可达的分支
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `device-package-exhausted-stop`:设备级套餐(`device_id` 绑定)流量耗尽时,自动触发设备绑定卡的停机操作
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `polling-protect-consistency`:补全调度器初始化逻辑,使保护期一致性检查能够按配置间隔定时调度(新增 DB 字段 + 初始化代码)
|
||||
- `auto-stop-resume`:新增套餐过期主动停机场景,不再完全依赖下一次 carddata 轮询触发停机
|
||||
|
||||
## Impact
|
||||
|
||||
- **数据库迁移**:`tb_polling_config` 新增 `protect_check_interval` 字段;`tb_iot_card` 新增 `last_protect_check_at` 字段
|
||||
- **受影响代码**:
|
||||
- `internal/polling/scheduler.go` — `initCardsBatch`、`initCardPolling`、`requeueCard`
|
||||
- `internal/service/package/usage_service.go` — `checkAndTriggerSuspension`
|
||||
- `internal/polling/package_activation_handler.go` — `processExpiredPackage`、`updateIotCardSuspendedStatus`
|
||||
- `internal/model/polling.go` — `PollingConfig` struct
|
||||
- `internal/model/iot_card.go` — `IotCard` struct
|
||||
- `internal/task/polling_handler.go` — `getCardCondition` 死代码清理
|
||||
- **无 API 变更**:所有修复为内部调度逻辑,不影响对外接口
|
||||
- **需要数据库迁移**:两张表各增一列,向下兼容(nullable)
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 套餐过期时立即触发绑定卡停机
|
||||
|
||||
当主套餐过期处理完成后,若该套餐的载体(卡或设备)无任何后续生效套餐,系统 SHALL 立即异步触发停机检查,不依赖下一次 carddata 轮询兜底。
|
||||
|
||||
**触发位置**:`PackageActivationHandler.processExpiredPackage` 在执行 `updateCarrierSuspendedStatus` 后,若载体确认无生效套餐,异步调用 `StopResumeCallback.CheckAndStopCard`(iot_card 类型)或遍历绑定卡逐一触发(device 类型)。
|
||||
|
||||
**幂等保护**:`CheckAndStopCard` 已有"卡已停机则跳过"逻辑,重复触发安全。
|
||||
|
||||
**与 carddata 轮询的关系**:此处主动触发为优化项(消除延迟窗口),carddata 轮询的 `checkStopResume` 仍作为兜底保障,两者共存不冲突。
|
||||
|
||||
#### Scenario: 主套餐过期且无后续套餐,载体为 iot_card
|
||||
|
||||
- **WHEN** `HandlePackageActivationCheck` 检测到一张卡的主套餐已过期(`expires_at <= NOW`),执行 `processExpiredPackage` 后确认该卡无待生效或生效中的套餐
|
||||
- **THEN** 系统异步调用 `CheckAndStopCard(cardID)`,将卡停机(`network_status = 0`,`stop_reason = "traffic_exhausted"`),不等待网关响应
|
||||
|
||||
#### Scenario: 主套餐过期且无后续套餐,载体为 device
|
||||
|
||||
- **WHEN** `HandlePackageActivationCheck` 检测到一个设备的主套餐已过期,确认该设备无后续套餐
|
||||
- **THEN** 系统查询设备绑定的所有在线已实名卡,逐一异步触发 `CheckAndStopCard`
|
||||
|
||||
#### Scenario: 主套餐过期但有排队待生效套餐
|
||||
|
||||
- **WHEN** `processExpiredPackage` 处理过期套餐,`activateNextPackage` 成功激活了下一个待生效套餐
|
||||
- **THEN** 系统不触发停机(因有生效套餐),`updateCarrierSuspendedStatus` 检查后确认有生效套餐即返回
|
||||
|
||||
#### Scenario: carddata 轮询兜底仍有效
|
||||
|
||||
- **WHEN** 套餐过期主动停机的异步触发因 gateway 异常失败,卡仍处于在线状态
|
||||
- **THEN** 下一次 `HandleCarddataCheck` 的 `checkStopResume` 检测到在线无套餐,再次尝试停机(兜底保障)
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 设备级套餐耗尽时触发绑定卡停机
|
||||
|
||||
当套餐载体为设备(`carrier_type = "device"`)且所有生效套餐流量耗尽时,系统 SHALL 查询该设备绑定的所有 IoT 卡,并对每张卡异步触发停机检查(`CheckAndStopCard`)。
|
||||
|
||||
**触发位置**:`UsageService.checkAndTriggerSuspension`,在确认无生效套餐后执行。
|
||||
|
||||
**幂等保护**:`CheckAndStopCard` 内部检查卡是否已为停机状态(`network_status = 0`),重复调用不会重复调网关。
|
||||
|
||||
**仅处理绑定卡**:通过 `DeviceSimBindingStore.ListByDeviceID` 获取当前绑定关系(`bind_status = 1`),未绑定或已解绑的卡不受影响。
|
||||
|
||||
#### Scenario: 设备套餐流量耗尽,绑定卡在线
|
||||
|
||||
- **WHEN** `DeductDataUsage` 在 `carrier_type = "device"` 场景下扣减流量后,`checkAndTriggerSuspension` 发现该设备无生效套餐(`status IN (0,1)`)
|
||||
- **THEN** 系统查询该设备的绑定卡列表,对每张在线(`network_status = 1`)且已实名(`real_name_status = 1`)的卡异步调用 `CheckAndStopCard`,将卡停机(`network_status = 0`,`stop_reason = "traffic_exhausted"`)
|
||||
|
||||
#### Scenario: 设备套餐流量耗尽,绑定卡已停机
|
||||
|
||||
- **WHEN** `checkAndTriggerSuspension` 触发设备级停机检查,但绑定卡已经是停机状态(`network_status = 0`)
|
||||
- **THEN** `CheckAndStopCard` 检查到卡已停机,跳过网关调用,不产生重复操作
|
||||
|
||||
#### Scenario: 设备无绑定卡
|
||||
|
||||
- **WHEN** `checkAndTriggerSuspension` 触发设备级停机检查,但该设备当前无有效绑定记录
|
||||
- **THEN** 系统静默跳过,不报错,记录 Debug 日志
|
||||
|
||||
#### Scenario: 套餐载体为 iot_card,不受影响
|
||||
|
||||
- **WHEN** `DeductDataUsage` 在 `carrier_type = "iot_card"` 场景下触发停机检查
|
||||
- **THEN** 仅对该卡本身执行 `CheckAndStopCard`,不查询设备绑定关系(与现有行为一致)
|
||||
@@ -0,0 +1,54 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 保护期一致性检查轮询任务
|
||||
|
||||
系统 SHALL 通过轮询调度器定时执行保护期一致性检查任务(`polling:protect`),检查绑定设备有保护期的已实名卡的网络状态,并在状态不一致时强制调网关修正。
|
||||
|
||||
**任务类型标识**: `protect`(与现有 `realname`、`carddata`、`package` 并列)
|
||||
|
||||
**Redis 队列 Key**: `RedisPollingQueueProtectKey()` → `"polling:queue:protect"`
|
||||
|
||||
**触发频率**: 由 `PollingConfig.ProtectCheckInterval`(新增字段,单位秒)控制,`NULL` 或 `0` 表示该配置不参与 protect 检查,建议默认 600s
|
||||
|
||||
**初始化**:调度器启动时,`initCardsBatch` 和 `initCardPolling` SHALL 读取匹配配置的 `ProtectCheckInterval`,将卡加入 `polling:queue:protect` Sorted Set;任务完成后通过 `requeueCard` 按间隔重新入队
|
||||
|
||||
**数据模型变更**:
|
||||
- `tb_polling_config` 新增 `protect_check_interval INT NULL` 字段
|
||||
- `tb_iot_card` 新增 `last_protect_check_at TIMESTAMP NULL` 字段(记录上次检查时间,用于计算下次入队 score)
|
||||
|
||||
**处理逻辑**(与原 spec 一致,不变):
|
||||
1. 卡未实名(`real_name_status = 0`)→ 跳过
|
||||
2. 卡未绑定设备(`is_standalone = true`)→ 跳过
|
||||
3. 设备有 stop 保护期且卡在线(`network_status = 1`)→ 调网关停机,更新 `network_status = 0`
|
||||
4. 设备有 start 保护期且卡停机(`network_status = 0`)→ 调网关复机,更新 `network_status = 1`
|
||||
5. 状态已一致 → 跳过
|
||||
|
||||
#### Scenario: stop 保护期内卡状态异常(开机)
|
||||
|
||||
- **WHEN** protect 轮询任务检查一张已实名、已绑定设备的卡,发现设备有 stop 保护期,且卡当前 `network_status = 1`(开机)
|
||||
- **THEN** 任务调网关停机,更新卡 `network_status = 0`,更新 `last_protect_check_at`,记录 Info 日志
|
||||
|
||||
#### Scenario: start 保护期内卡状态异常(停机)
|
||||
|
||||
- **WHEN** protect 轮询任务检查一张已实名、已绑定设备的卡,发现设备有 start 保护期,且卡当前 `network_status = 0`(停机)
|
||||
- **THEN** 任务调网关复机,更新卡 `network_status = 1`,更新 `last_protect_check_at`,记录 Info 日志
|
||||
|
||||
#### Scenario: 状态已一致,跳过
|
||||
|
||||
- **WHEN** protect 轮询任务检查一张卡,设备有 stop 保护期,卡已是停机状态(`network_status = 0`)
|
||||
- **THEN** 任务跳过,不调网关,不更新 DB,更新 `last_protect_check_at`
|
||||
|
||||
#### Scenario: 未实名卡跳过保护期逻辑
|
||||
|
||||
- **WHEN** protect 轮询任务遇到 `real_name_status = 0` 的卡
|
||||
- **THEN** 任务直接跳过,不检查保护期,不调网关
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)跳过
|
||||
|
||||
- **WHEN** protect 轮询任务遇到 `is_standalone = true` 的卡
|
||||
- **THEN** 任务直接跳过,不查询设备保护期
|
||||
|
||||
#### Scenario: PollingConfig 未配置 ProtectCheckInterval,卡不入 protect 队列
|
||||
|
||||
- **WHEN** 调度器初始化时,卡匹配到的 PollingConfig 的 `protect_check_interval` 为 NULL 或 0
|
||||
- **THEN** 该卡不被加入 `polling:queue:protect`,protect 任务不对该卡执行
|
||||
@@ -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=2(depleted)后,绑定卡的 `network_status` 变为 0,`stop_reason = 'traffic_exhausted'`
|
||||
- [x] 8.4 通过 PostgreSQL MCP 查询一张已过期套餐(`expires_at <= NOW`)且无后续套餐的卡,确认其 `network_status = 0`,`stop_reason = 'traffic_exhausted'`
|
||||
Reference in New Issue
Block a user