fix: 修复轮询停机连坐、实名逆转防抖及carrier_stopped原因写入缺失三个逻辑缺陷
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m37s

This commit is contained in:
2026-04-20 11:24:56 +08:00
parent fd795d8742
commit 868f5a8ef3
11 changed files with 514 additions and 10 deletions

View File

@@ -0,0 +1,44 @@
> **Delta Spec**:本文件仅描述对 `openspec/specs/polling-stop-resume-logic/spec.md` 的变更部分。未提及的需求保持不变。
---
### 变更not_realname 停机范围——从设备维度缩小为单卡维度
**原行为**`EvaluateAndAct` 在非独立卡(`is_standalone=false`)触发停机时,无论停机原因如何,均调用 `stopDeviceCards` 停机整个设备的所有在线卡。
**新行为**:仅当停机原因为 `no_package``traffic_exhausted`(设备共享资源)时,才调用 `stopDeviceCards`;当停机原因为 `not_realname` 时,调用 `stopCardWithRetry` 只停被评估的单卡。
#### Scenario: not_realname 只停单卡,不连坐已实名卡
- **GIVEN** 设备 D 下有卡 A`real_name_status=0`,在线)和卡 B`real_name_status=1`,在线)
- **WHEN** `EvaluateAndAct` 对卡 A 执行,`checkStopReasons` 返回 `["not_realname"]`
- **THEN** 调用 `stopCardWithRetry(cardA, "not_realname")`;卡 A 停机;卡 B 保持在线,`network_status` 不变
#### Scenario: no_package 仍走设备维度停机
- **GIVEN** 设备 D 下有卡 A 和卡 B 均在线,设备无有效套餐
- **WHEN** `EvaluateAndAct` 对卡 A 执行,`checkStopReasons` 返回 `["no_package"]`
- **THEN** 调用 `stopDeviceCards(deviceID, "no_package")`;卡 A 和卡 B 均被停机(设备维度停机保持不变)
#### Scenario: 优先级 no_package > not_realname 时仍走设备维度
- **GIVEN** 卡 A未实名 + 无套餐),卡 B已实名均在线
- **WHEN** 卡 A 的 `checkStopReasons` 返回 `["no_package", "not_realname"]``primaryReason = "no_package"`
- **THEN** `isDeviceScopeReason("no_package") = true`;调用 `stopDeviceCards`;卡 A 和卡 B 均停机
---
### 新增carrier_stopped——运营商侧停机原因
新增常量 `StopReasonCarrierStopped = "carrier_stopped"`,表示由运营商/Gateway 侧主动停机(非本系统发起)。
**与现有停机原因的区别**
| 原因 | 触发方 | 自动复机 |
|------|--------|----------|
| `no_package` | 本系统轮询 | ✅ 是 |
| `traffic_exhausted` | 本系统轮询 | ✅ 是 |
| `not_realname` | 本系统轮询 | ✅ 是(实名完成后) |
| `manual` | 管理员手动 | ❌ 否 |
| `carrier_stopped` | 运营商/Gateway | ❌ 否(需人工确认) |
#### Scenario: carrier_stopped 不触发自动复机
- **GIVEN** 卡停机,`stop_reason='carrier_stopped'`
- **WHEN** 下次轮询调用 `EvaluateAndAct`
- **THEN** `isPollingStopReason("carrier_stopped") = false`;跳过复机逻辑;卡保持停机状态直到管理员手动处理

View File

@@ -0,0 +1,58 @@
> **Delta Spec**:本文件仅描述对 `openspec/specs/polling-task-handlers/spec.md` 的变更部分。未提及的需求保持不变。
---
### 变更polling_realname_handler——实名逆转防抖
**原行为**:检测到实名状态 `1→0` 逆转时,立即更新 DB 并调用 `EvaluateAndAct` 触发停机。
**新行为**:检测到逆转时,通过 Redis 计数器累计,仅在**连续 3 次**检测到逆转后才触发 `EvaluateAndAct`;检测到 `true` 时重置计数器。DB 写入行为(`real_name_status` 同步)保持不变——只延迟停机决策,不延迟状态记录。
**Redis Key**`polling:card:realname:reversal:{card_id}``RedisPollingRealnameReversalCountKey`
**TTL**10 分钟(超时自动归零)
**阈值常量**`PollingRealnameReversalThreshold = 3`
#### Scenario: 单次逆转不触发停机
- **GIVEN** card_id=4`real_name_status=1`DBGateway 返回 `false`
- **WHEN** `polling_realname_handler` 处理该卡
- **THEN** DB 更新 `real_name_status=0`Redis 计数器 `reversal:4` 变为 1TTL=10min**不调用 EvaluateAndAct**;卡保持在线
#### Scenario: 连续 3 次逆转才触发停机
- **GIVEN** card_id=4前 2 次轮询 Gateway 均返回 false计数器为 2
- **WHEN** 第 3 次轮询 Gateway 仍返回 false
- **THEN** Redis 计数器变为 3达到阈值删除计数器调用 `EvaluateAndAct(freshCard)`;触发停机,`stop_reason='not_realname'`
#### Scenario: 中间出现 true 则重置计数
- **GIVEN** card_id=4已记录 2 次逆转(计数器=2
- **WHEN** 下次轮询 Gateway 返回 true`real_name_status` 重置为 1
- **THEN** 删除 Redis 计数器(归零);若状态从 0→1 则触发复机评估(逻辑不变)
#### Scenario: 10 分钟未触发则计数自动归零
- **GIVEN** card_id=4 记录了 1 次逆转(计数器=1之后 10 分钟内 Gateway 均返回 true
- **WHEN** 10 分钟 TTL 到期
- **THEN** Redis key 自动过期,计数归零;后续逆转需重新从 1 开始累计
---
### 变更polling_cardstatus_handler——网关侧停机补写 stop_reason
**原行为**:检测到 Gateway 报告卡为"停机"(在线→停机状态变化)时,仅更新 `network_status=0`,不设置 `stop_reason`
**新行为**:在写入 `network_status=0` 的同时,若当前 `stop_reason` 为空,补写 `stop_reason='carrier_stopped'`
> **说明**:只在状态变化时补写,若 `stop_reason` 已有值(如已被本系统停机)则不覆盖。
#### Scenario: 网关侧停机事件补写 carrier_stopped
- **GIVEN** 卡在线(`network_status=1``stop_reason=''`Gateway 本次返回"停机"
- **WHEN** `polling_cardstatus_handler` 处理,检测到 `statusChanged=true``newNetworkStatus=0`
- **THEN** DB 更新 `network_status=0, stop_reason='carrier_stopped'``isPollingStopReason('carrier_stopped')=false``EvaluateAndAct` 不触发自动复机
#### Scenario: 不覆盖已有 stop_reason
- **GIVEN** 卡已停机(`network_status=0``stop_reason='not_realname'`Gateway 本次仍返回"停机"
- **WHEN** `polling_cardstatus_handler` 处理,`statusChanged=false`(状态未变)
- **THEN** 不写入任何字段;`stop_reason` 保持 `not_realname` 不变
#### Scenario: carrier_stopped 的卡通过 EvaluateAndAct 调用时正确跳过复机
- **GIVEN** 卡停机,`stop_reason='carrier_stopped'`,套餐有效,已实名
- **WHEN** 其他轮询任务调用 `EvaluateAndAct(card)`
- **THEN** `isPollingStopReason('carrier_stopped')=false`;直接返回 nil不执行复机