Files
junhong_cmp_fiber/openspec/changes/fix-polling-stop-logic/design.md
huang 868f5a8ef3
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m37s
fix: 修复轮询停机连坐、实名逆转防抖及carrier_stopped原因写入缺失三个逻辑缺陷
2026-04-20 11:24:56 +08:00

135 lines
6.3 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.
## Context
轮询停复机系统(`stop_resume_service.go` + 三个轮询 Handler存在三个已在生产环境触发的缺陷均已通过日志 + DB 分析定位到根因:
1. **连坐停机**(已复现):设备 862639076038233 下 card_id=5未实名触发 `checkStopReasons → not_realname → stopDeviceCards`,将同设备已实名的 card_id=2、card_id=4 一并停机。
2. **无防抖误停**(潜在风险):`polling_realname_handler` 单次检测到实名逆转即触发停机,上游运营商同步延迟时单次 false 可导致误停。
3. **carrier 停机状态不完整**(已复现):`polling_cardstatus_handler` 检测到网关侧停机时仅更新 `network_status=0``stop_reason` 留空,导致卡进入无法被自动复机的死锁状态。
涉及文件:
- `internal/service/iot_card/stop_resume_service.go`EvaluateAndAct
- `internal/task/polling_realname_handler.go`
- `internal/task/polling_cardstatus_handler.go`
- `pkg/constants/iot.go`
- `pkg/constants/redis.go`
## Goals / Non-Goals
**Goals:**
- `not_realname` 停机仅影响被评估的单卡,不扩散至设备维度
- 实名逆转需连续 3 次才触发停机,消除上游单次误报
- 网关侧停机事件写入明确的 `stop_reason`,确保卡状态完整可追踪
**Non-Goals:**
- 不修改 `no_package` / `traffic_exhausted` 的设备维度停机逻辑(仍保持连带停机)
- 不引入数据库 schema 变更
- 不回填存量 `stop_reason=''` 的历史数据
- `carrier_stopped` 不纳入 `isPollingStopReason`(不自动复机,运营商停机需人工确认)
## Decisions
### 决策 1isDeviceScopeReason——停机范围判断
**背景**`not_realname` 是单卡属性(每张卡独立实名),`no_package` / `traffic_exhausted` 是设备共享资源,两者停机范围语义完全不同。
**方案**:新增 `isDeviceScopeReason(reason string) bool`,仅 `no_package` / `traffic_exhausted` 返回 true。`EvaluateAndAct` 中,只有当 `isDeviceScopeReason(primaryReason)` 为 true 时才调用 `stopDeviceCards`;否则走 `stopCardWithRetry`(单卡)。
```go
// 停机范围为设备维度的原因(套餐/流量是设备共享资源)
func isDeviceScopeReason(reason string) bool {
return reason == constants.StopReasonNoPackage ||
reason == constants.StopReasonTrafficExhausted
}
// EvaluateAndAct 修改后的在线分支
primaryReason := reasons[0]
if !card.IsStandalone && isDeviceScopeReason(primaryReason) {
deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
if lookupErr == nil && bound {
s.stopDeviceCards(ctx, deviceID, primaryReason)
return nil
}
}
return s.stopCardWithRetry(ctx, card, primaryReason)
```
**边界确认**:若卡同时满足 `no_package + not_realname``primaryReason = "no_package"`(优先级更高),走设备维度——符合预期(设备整体无套餐)。
---
### 决策 2实名逆转防抖——Redis 滑动计数器
**背景**:运营商实名接口存在同步延迟,单次返回 false 不足以确认实名已失效。
**方案**:在 `polling_realname_handler` 检测到 `1→0` 逆转时,写入 Redis 计数器INCR + 独立 TTL仅当计数 `>= 3` 时才触发 `EvaluateAndAct`;检测到 `true` 时清除计数器。**不修改 DB 写入逻辑**`real_name_status` 仍实时同步,只延迟停机决策)。
```
Redis Key: polling:card:realname:reversal:{card_id}
TTL: 10 分钟(若 10 分钟内未连续触发,计数归零)
阈值: 3 次(常量 PollingRealnameReversalThreshold = 3
```
**流程**
```
gateway 返回 false已更新 DB real_name_status=0
INCR polling:card:realname:reversal:{card_id}TTL=10min
├─ count < 3仅记录不触发停机返回
└─ count >= 3DEL key → EvaluateAndAct触发停机
gateway 返回 true已更新 DB real_name_status=1 或无变化)
DEL polling:card:realname:reversal:{card_id}
若 DB 为 0→1触发 EvaluateAndAct复机评估逻辑不变
```
**注意**`INCR` 仅在当前 `newStatus == RealNameStatusNotVerified` 时执行,不影响 true 的处理路径。
---
### 决策 3carrier_stopped——网关侧停机原因
**背景**`polling_cardstatus_handler` 通过轮询 Gateway 卡状态(非主动停机操作)检测到在线→停机变化,当前未写入 `stop_reason`,导致 `isPollingStopReason('')=false`,卡永久无法自动复机。
**方案**
- 新增常量 `StopReasonCarrierStopped = "carrier_stopped"`
- `polling_cardstatus_handler` 在检测到在线→停机变化时,在 `fields` 中补写 `stop_reason = carrier_stopped`
- `carrier_stopped` **不**加入 `isPollingStopReason`(运营商停机原因不明,不自动复机)
- `carrier_stopped` 作为合法状态存储,管理员可在后台查看并手动决策
```go
// polling_cardstatus_handler.go 修改处
if statusChanged {
fields["network_status"] = newNetworkStatus
if newNetworkStatus == constants.NetworkStatusOffline {
fields["stop_reason"] = constants.StopReasonCarrierStopped
}
}
```
**关于存量数据**:已处于 `stop_reason=''` 的停机卡不回填,需运维人工处理(可通过 SQL 批量检查后决策)。
---
### 关于 resumeDeviceCards 的兼容性
现有 `resumeDeviceCards` 在遍历停机卡时已对 `isRealnameOK` 做过滤:
```go
for _, card := range cards {
if !s.isRealnameOK(card) {
s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname)
continue
}
s.resumeSingleCard(ctx, card.ID)
}
```
修复后,`not_realname` 只停单卡,触发 `resumeDeviceCards` 的场景(`no_package` / `traffic_exhausted` 复机)不会遇到 `not_realname` 卡,此处逻辑仍保留作为防御性检查,无需修改。
## Risks / Trade-offs
| 风险 | 评估 | 缓解 |
|------|------|------|
| 防抖计数 3 次意味着最多 ~3 分钟延迟停机 | 低风险,实名验证丢失通常是持续状态 | 阈值做成常量,可快速调整 |
| carrier_stopped 卡无法自动复机,需人工处理 | 已接受(运营商停机原因不明,保守处理) | 后台日志/状态可见,管理员可手动触发复机 |
| 不回填存量 stop_reason='' 数据 | 存量问题继续存在 | 运维可执行一次性 SQL 修复,超出本次 change 范围 |