Files
junhong_cmp_fiber/openspec/changes/fix-polling-stop-logic/tasks.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

7.1 KiB
Raw Blame History

实现任务清单

Fix 1not_realname 连坐修复

1.1 新增 isDeviceScopeReason 函数

  • 文件internal/service/iot_card/stop_resume_service.go
  • 位置:紧接 isPollingStopReason 函数之后
  • 内容
    // isDeviceScopeReason 判断停机原因是否应扩散到设备维度
    // 套餐/流量是设备共享资源,需整体停机;实名是单卡属性,只停该卡
    func isDeviceScopeReason(reason string) bool {
        return reason == constants.StopReasonNoPackage ||
            reason == constants.StopReasonTrafficExhausted
    }
    

1.2 修改 EvaluateAndAct 在线分支的停机判断

  • 文件internal/service/iot_card/stop_resume_service.go
  • 修改位置EvaluateAndAct 函数的 NetworkStatusOnline 分支
  • 原逻辑
    if !card.IsStandalone {
        deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
        if lookupErr != nil { ... }
        else if bound {
            s.stopDeviceCards(ctx, deviceID, primaryReason)
            return nil
        }
    }
    return s.stopCardWithRetry(ctx, card, primaryReason)
    
  • 改为
    if !card.IsStandalone && isDeviceScopeReason(primaryReason) {
        deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card)
        if lookupErr != nil { ... }
        else if bound {
            s.stopDeviceCards(ctx, deviceID, primaryReason)
            return nil
        }
    }
    return s.stopCardWithRetry(ctx, card, primaryReason)
    
  • 注意:仅在条件中增加 && isDeviceScopeReason(primaryReason),其余代码不变

1.3 数据库验证

  • 使用 PostgreSQL MCP 确认设备 862639076038233 下 card_id=5未实名触发停机时card_id=2 和 card_id=4 不再被停机

Fix 2实名逆转防抖

2.1 新增 Redis Key 常量

  • 文件pkg/constants/redis.go
  • 新增
    // RedisPollingRealnameReversalCountKey 实名逆转连续计数器,防止上游单次误报触发停机
    // TTL: 10分钟
    func RedisPollingRealnameReversalCountKey(cardID uint) string {
        return fmt.Sprintf("polling:card:realname:reversal:%d", cardID)
    }
    

2.2 新增防抖阈值常量

  • 文件pkg/constants/iot.go
  • 新增(放在实名状态常量附近):
    // PollingRealnameReversalThreshold 实名逆转防抖阈值:连续 N 次检测到逆转才触发停机
    PollingRealnameReversalThreshold = 3
    

2.3 修改 polling_realname_handler.go 逆转处理逻辑

  • 文件internal/task/polling_realname_handler.go
  • 修改位置Handle 函数中 else if newStatus == constants.RealNameStatusNotVerified 分支
  • 原逻辑(约第 154-167 行):
    } else if newStatus == constants.RealNameStatusNotVerified {
        h.base.logger.Warn("检测到实名逆转,立即触发停机评估", ...)
        if h.stopResumeSvc != nil {
            freshCard, _ := h.iotCardStore.GetByID(ctx, cardID)
            h.stopResumeSvc.EvaluateAndAct(ctx, freshCard)
        }
    }
    
  • 改为
    } else if newStatus == constants.RealNameStatusNotVerified {
        reversalKey := constants.RedisPollingRealnameReversalCountKey(cardID)
        count, incrErr := h.base.redis.Incr(ctx, reversalKey).Result()
        if incrErr == nil {
            h.base.redis.Expire(ctx, reversalKey, 10*time.Minute)
        }
        h.base.logger.Warn("检测到实名逆转",
            zap.Uint("card_id", cardID),
            zap.Int64("reversal_count", count),
            zap.Int("threshold", constants.PollingRealnameReversalThreshold))
        if count >= int64(constants.PollingRealnameReversalThreshold) {
            h.base.redis.Del(ctx, reversalKey)
            h.base.logger.Warn("实名逆转达到阈值,触发停机评估",
                zap.Uint("card_id", cardID))
            if h.stopResumeSvc != nil {
                freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID)
                if loadErr == nil {
                    if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil {
                        h.base.logger.Warn("实名逆转后触发停机评估失败",
                            zap.Uint("card_id", cardID), zap.Error(evalErr))
                    }
                }
            }
        }
    }
    
  • 同时:在 isFirstRealname 分支0→1复机成功前清除计数器
    // 0→1 时清除逆转计数器
    h.base.redis.Del(ctx, constants.RedisPollingRealnameReversalCountKey(cardID))
    
    放在 triggerFirstRealnameActivation 调用之前

2.4 确认 PollingBase 有 redis 字段可访问

  • 检查 internal/task/polling_base.go(或同类文件),确认 h.base.redis 可用
  • 若不可用,通过 PollingRealnameHandler 直接持有 redis *redis.Client 字段,在 NewPollingRealnameHandler 中注入

Fix 3carrier_stopped 停机原因

3.1 新增 StopReasonCarrierStopped 常量

  • 文件pkg/constants/iot.go
  • 新增(放在现有 StopReason 常量组中):
    StopReasonCarrierStopped = "carrier_stopped" // 停机原因:运营商/Gateway 侧主动停机
    

3.2 修改 polling_cardstatus_handler.go 补写 stop_reason

  • 文件internal/task/polling_cardstatus_handler.go
  • 修改位置:第 97-103 行,statusChanged 判断块内
  • 原逻辑
    fields := map[string]any{"last_card_status_check_at": now}
    if statusChanged {
        fields["network_status"] = newNetworkStatus
    }
    
  • 改为
    fields := map[string]any{"last_card_status_check_at": now}
    if statusChanged {
        fields["network_status"] = newNetworkStatus
        // 网关侧停机事件:补写 stop_reason便于状态追踪和区分手动停机
        if newNetworkStatus == constants.NetworkStatusOffline && card.StopReason == "" {
            fields["stop_reason"] = constants.StopReasonCarrierStopped
        }
    }
    
  • 注意card.StopReason == "" 条件确保不覆盖已有停机原因(如 not_realname

3.3 确认 isPollingStopReason 不包含 carrier_stopped

  • 文件internal/service/iot_card/stop_resume_service.go
  • 确认isPollingStopReason 函数的 switch case 中不包含 carrier_stopped
  • 无需修改(已符合预期),仅做代码 review 确认

验证

V1验证 Fix 1连坐修复

-- 通过 PostgreSQL MCP 检查card_id=5 停机时card_id=2、card_id=4 应保持在线
SELECT id, iccid, real_name_status, network_status, stop_reason
FROM tb_iot_card
WHERE device_virtual_no = '862639076038233'
ORDER BY id;

预期card_id=5 被停机card_id=2 和 card_id=4 不受影响(或按各自状态独立判断)

V2验证 Fix 2防抖

  • 在 Redis 中观察 polling:card:realname:reversal:* key 的变化
  • 确认单次 false 后 key 计数为 1 且不触发停机日志
  • 确认 3 次 false 后出现"实名逆转达到阈值"日志并触发停机

V3验证 Fix 3carrier_stopped

-- 检查状态为 carrier_stopped 的卡能被正确识别
SELECT id, iccid, network_status, stop_reason, stopped_at
FROM tb_iot_card
WHERE stop_reason = 'carrier_stopped';

预期:stop_reason='carrier_stopped' 的卡不会被轮询系统自动复机(需观察日志)