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

6.3 KiB
Raw Blame History

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=0stop_reason 留空,导致卡进入无法被自动复机的死锁状态。

涉及文件:

  • internal/service/iot_card/stop_resume_service.goEvaluateAndAct
  • 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(单卡)。

// 停机范围为设备维度的原因(套餐/流量是设备共享资源)
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_realnameprimaryReason = "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 作为合法状态存储,管理员可在后台查看并手动决策
// 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 做过滤:

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