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:
@@ -392,6 +392,40 @@ func (s *Service) ResumeCardIfStopped(ctx context.Context, cardID uint) error {
|
||||
|
||||
---
|
||||
|
||||
## 迭代更新(fix-polling-coverage-gaps)
|
||||
|
||||
### ADDED 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` 检测到在线无套餐,再次尝试停机(兜底保障)
|
||||
|
||||
---
|
||||
|
||||
## 迭代更新(fix-stop-resume-lifecycle-engine)
|
||||
|
||||
### MODIFIED Requirement: 流量耗尽自动复机
|
||||
|
||||
61
openspec/specs/device-package-exhausted-stop/spec.md
Normal file
61
openspec/specs/device-package-exhausted-stop/spec.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Spec: 设备级套餐耗尽触发绑定卡停机
|
||||
|
||||
## 业务背景
|
||||
|
||||
### 为什么需要设备级套餐耗尽停机
|
||||
|
||||
**现状问题**:
|
||||
- 设备套餐(`carrier_type = "device"`)流量耗尽时,`checkAndTriggerSuspension` 缺少对绑定卡的停机逻辑
|
||||
- 仅有 `iot_card` 类型载体的停机分支,设备类型无对应处理
|
||||
- 导致设备套餐流量耗尽后,绑定卡仍保持在线状态,无法及时停机
|
||||
|
||||
**业务目标**:
|
||||
- 设备套餐流量耗尽时,自动对所有绑定卡触发停机检查
|
||||
- 与 iot_card 类型保持行为一致性
|
||||
- 幂等安全,重复触发不产生副作用
|
||||
|
||||
---
|
||||
|
||||
## 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`,不查询设备绑定关系(与现有行为一致)
|
||||
|
||||
---
|
||||
|
||||
## 实现说明
|
||||
|
||||
### 依赖注入变更
|
||||
|
||||
`UsageService` 新增 `deviceSimBindingStore *postgres.DeviceSimBindingStore` 字段,通过构造函数注入,`bootstrap` 层负责传入。
|
||||
|
||||
### 异步触发模式
|
||||
|
||||
对每张绑定卡启动独立 goroutine 异步执行 `CheckAndStopCard`,不阻塞当前流量扣减流程。若 `stopResumeCallback == nil`,仅记录 Warn 日志,不报错。
|
||||
@@ -50,3 +50,60 @@
|
||||
|
||||
- **WHEN** 轮询任务遇到 is_standalone=true 的卡
|
||||
- **THEN** 任务直接跳过,不查询设备保护期
|
||||
|
||||
---
|
||||
|
||||
## 迭代更新(fix-polling-coverage-gaps)
|
||||
|
||||
### MODIFIED Requirement: 保护期一致性检查轮询任务
|
||||
|
||||
系统 SHALL 通过轮询调度器定时执行保护期一致性检查任务(`polling:protect`),检查绑定设备有保护期的已实名卡的网络状态,并在状态不一致时强制调网关修正。
|
||||
|
||||
**任务类型标识**: `protect`(与现有 `realname`、`carddata`、`package` 并列)
|
||||
|
||||
**Redis 队列 Key**: `RedisPollingQueueProtectKey()` → `"polling:queue:protect"`
|
||||
|
||||
**触发频率**(变更): 由 `PollingConfig.ProtectCheckInterval`(新增字段,单位秒)控制,`NULL` 或 `0` 表示该配置不参与 protect 检查,建议默认 600s(原:与流量检查任务同频固定 10 分钟)
|
||||
|
||||
**初始化**:调度器启动时,`initCardsBatch` 和 `initCardPolling` SHALL 读取匹配配置的 `ProtectCheckInterval`,将卡加入 `polling:queue:protect` Sorted Set;任务完成后通过 `requeueCard` 按间隔重新入队,并更新 `last_protect_check_at`
|
||||
|
||||
**数据模型变更**:
|
||||
- `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 任务不对该卡执行
|
||||
|
||||
Reference in New Issue
Block a user