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,134 @@
## 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 范围 |