## 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 范围 |