All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m37s
135 lines
6.3 KiB
Markdown
135 lines
6.3 KiB
Markdown
## 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
|
||
|
||
### 决策 1:isDeviceScopeReason——停机范围判断
|
||
|
||
**背景**:`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 >= 3:DEL key → EvaluateAndAct(触发停机)
|
||
|
||
gateway 返回 true(已更新 DB real_name_status=1 或无变化)
|
||
↓
|
||
DEL polling:card:realname:reversal:{card_id}
|
||
若 DB 为 0→1:触发 EvaluateAndAct(复机评估,逻辑不变)
|
||
```
|
||
|
||
**注意**:`INCR` 仅在当前 `newStatus == RealNameStatusNotVerified` 时执行,不影响 true 的处理路径。
|
||
|
||
---
|
||
|
||
### 决策 3:carrier_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 范围 |
|