From 868f5a8ef3e9d595b62e583a913f4c0658087f28 Mon Sep 17 00:00:00 2001 From: huang Date: Mon, 20 Apr 2026 11:24:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E5=81=9C=E6=9C=BA=E8=BF=9E=E5=9D=90=E3=80=81=E5=AE=9E=E5=90=8D?= =?UTF-8?q?=E9=80=86=E8=BD=AC=E9=98=B2=E6=8A=96=E5=8F=8Acarrier=5Fstopped?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E5=86=99=E5=85=A5=E7=BC=BA=E5=A4=B1=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=E9=80=BB=E8=BE=91=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/iot_card/stop_resume_service.go | 9 +- internal/task/polling_cardstatus_handler.go | 4 + internal/task/polling_realname_handler.go | 31 ++- .../fix-polling-stop-logic/.openspec.yaml | 2 + .../changes/fix-polling-stop-logic/design.md | 134 ++++++++++++ .../fix-polling-stop-logic/proposal.md | 42 ++++ .../specs/polling-stop-resume-logic/spec.md | 44 ++++ .../specs/polling-task-handlers/spec.md | 58 ++++++ .../changes/fix-polling-stop-logic/tasks.md | 190 ++++++++++++++++++ pkg/constants/iot.go | 4 + pkg/constants/redis.go | 6 + 11 files changed, 514 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/fix-polling-stop-logic/.openspec.yaml create mode 100644 openspec/changes/fix-polling-stop-logic/design.md create mode 100644 openspec/changes/fix-polling-stop-logic/proposal.md create mode 100644 openspec/changes/fix-polling-stop-logic/specs/polling-stop-resume-logic/spec.md create mode 100644 openspec/changes/fix-polling-stop-logic/specs/polling-task-handlers/spec.md create mode 100644 openspec/changes/fix-polling-stop-logic/tasks.md diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index 04a61a3..b791380 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -78,7 +78,7 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC } // 取最高优先级原因(第一个) primaryReason := reasons[0] - if !card.IsStandalone { + if !card.IsStandalone && isDeviceScopeReason(primaryReason) { deviceID, bound, lookupErr := s.getCardDeviceID(ctx, card) if lookupErr != nil { s.logger.Error("查询设备绑定关系失败,降级为单卡停机", @@ -132,6 +132,13 @@ func isPollingStopReason(reason string) bool { return false } +// isDeviceScopeReason 判断停机原因是否应扩散到设备维度 +// 套餐/流量是设备共享资源,需整体停机;实名是单卡属性,只停该卡 +func isDeviceScopeReason(reason string) bool { + return reason == constants.StopReasonNoPackage || + reason == constants.StopReasonTrafficExhausted +} + // getCardDeviceID 获取卡所属设备ID(非独立卡时有效) // 返回:deviceID, isDeviceBound, error func (s *StopResumeService) getCardDeviceID(ctx context.Context, card *model.IotCard) (uint, bool, error) { diff --git a/internal/task/polling_cardstatus_handler.go b/internal/task/polling_cardstatus_handler.go index 0002fe4..10d93f0 100644 --- a/internal/task/polling_cardstatus_handler.go +++ b/internal/task/polling_cardstatus_handler.go @@ -97,6 +97,10 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, t *asynq.Task) er 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 + } } if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, fields); updateErr != nil { h.base.logger.Warn("更新卡状态信息失败", zap.Uint("card_id", cardID), zap.Error(updateErr)) diff --git a/internal/task/polling_realname_handler.go b/internal/task/polling_realname_handler.go index 647d33b..eb260a0 100644 --- a/internal/task/polling_realname_handler.go +++ b/internal/task/polling_realname_handler.go @@ -139,6 +139,8 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, t *asynq.Task) erro zap.Int("new_status", newStatus)) if isFirstRealname { + // 0→1 时清除逆转计数器 + h.base.redis.Del(ctx, constants.RedisPollingRealnameReversalCountKey(cardID)) // 0→1:首次实名,触发套餐激活并立即复机评估(不等待下一个 carddata/package 周期) h.triggerFirstRealnameActivation(ctx, cardID) h.triggerDeviceRealnameActivation(ctx, cardID) @@ -152,15 +154,26 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, t *asynq.Task) erro } } } else if newStatus == constants.RealNameStatusNotVerified { - // 1→0:实名逆转,立即触发停机评估(不等待下一个 carddata/package 周期,否则最长延迟 1 小时) - 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)) + 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)) + } } } } diff --git a/openspec/changes/fix-polling-stop-logic/.openspec.yaml b/openspec/changes/fix-polling-stop-logic/.openspec.yaml new file mode 100644 index 0000000..c4036b7 --- /dev/null +++ b/openspec/changes/fix-polling-stop-logic/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-20 diff --git a/openspec/changes/fix-polling-stop-logic/design.md b/openspec/changes/fix-polling-stop-logic/design.md new file mode 100644 index 0000000..290eea2 --- /dev/null +++ b/openspec/changes/fix-polling-stop-logic/design.md @@ -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 + +### 决策 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 范围 | diff --git a/openspec/changes/fix-polling-stop-logic/proposal.md b/openspec/changes/fix-polling-stop-logic/proposal.md new file mode 100644 index 0000000..8c71280 --- /dev/null +++ b/openspec/changes/fix-polling-stop-logic/proposal.md @@ -0,0 +1,42 @@ +## Why + +轮询停复机系统存在三个已确认的逻辑缺陷,导致已实名、有套餐的正常卡被错误停机: + +1. **连坐问题**:设备下任意一张未实名卡触发 `stopDeviceCards`,会将同设备所有在线卡(包括已实名卡)一并停机,`not_realname` 是单卡属性,不应扩散至设备维度。 +2. **无防抖**:`polling_realname_handler` 单次检测到实名逆转(1→0)即立刻触发停机,上游运营商接口在数据同步延迟期间可能短暂返回 false,导致误停。 +3. **carrier 停机无 stop_reason**:`polling_cardstatus_handler` 检测到网关侧停机(在线→停机状态变化)时,只更新 `network_status=0`,不写 `stop_reason`,导致卡的 `stop_reason=''`,`isPollingStopReason` 判断为非轮询原因,卡永久无法被自动复机。 + +## What Changes + +- **修改 `stop_resume_service.go`(EvaluateAndAct)**:引入 `isDeviceScopeReason()` 判断,`not_realname` 不再触发 `stopDeviceCards`,只停被评估的单卡;`no_package` / `traffic_exhausted` 保持设备维度停机不变。 +- **修改 `polling_realname_handler.go`**:实名逆转时不立即停机,改为写入 Redis 计数器;连续 N 次(默认 3 次)检测到逆转后才触发 `EvaluateAndAct`;检测到 true 时重置计数器。 +- **新增常量 `StopReasonCarrierStopped`**(`pkg/constants/iot.go`):值为 `"carrier_stopped"`,用于标记运营商侧主动停机。 +- **修改 `polling_cardstatus_handler.go`**:检测到在线→停机状态变化时,补写 `stop_reason='carrier_stopped'`;`carrier_stopped` 不进入 `isPollingStopReason`(运营商停机不自动复机,需人工确认)。 +- **新增 Redis Key 常量**(`pkg/constants/redis.go`):`RedisPollingRealnameReversalCountKey(cardID)` 用于实名逆转计数。 + +## Capabilities + +### New Capabilities + +无新能力,本次为纯缺陷修复。 + +### Modified Capabilities + +- `polling-stop-resume-logic`:`not_realname` 停机范围从设备维度缩小为单卡维度;新增 `carrier_stopped` 作为合法停机原因(不参与自动复机)。 +- `polling-task-handlers`:实名检查 handler 新增逆转防抖逻辑;卡状态检查 handler 新增 carrier 停机原因写入。 + +## Impact + +**受影响代码:** +- `internal/service/iot_card/stop_resume_service.go` +- `internal/task/polling_realname_handler.go` +- `internal/task/polling_cardstatus_handler.go` +- `pkg/constants/iot.go` +- `pkg/constants/redis.go` + +**无 DB 变更**(无迁移文件)。 + +**无 API 变更**(纯内部逻辑调整)。 + +**现存数据影响:** +- 已处于 `stop_reason=''` 停机状态的卡(如 card_id=2)需人工干预设置正确 `stop_reason` 才能自动复机,本次代码修复不回填存量数据。 diff --git a/openspec/changes/fix-polling-stop-logic/specs/polling-stop-resume-logic/spec.md b/openspec/changes/fix-polling-stop-logic/specs/polling-stop-resume-logic/spec.md new file mode 100644 index 0000000..006509c --- /dev/null +++ b/openspec/changes/fix-polling-stop-logic/specs/polling-stop-resume-logic/spec.md @@ -0,0 +1,44 @@ +> **Delta Spec**:本文件仅描述对 `openspec/specs/polling-stop-resume-logic/spec.md` 的变更部分。未提及的需求保持不变。 + +--- + +### 变更:not_realname 停机范围——从设备维度缩小为单卡维度 + +**原行为**:`EvaluateAndAct` 在非独立卡(`is_standalone=false`)触发停机时,无论停机原因如何,均调用 `stopDeviceCards` 停机整个设备的所有在线卡。 + +**新行为**:仅当停机原因为 `no_package` 或 `traffic_exhausted`(设备共享资源)时,才调用 `stopDeviceCards`;当停机原因为 `not_realname` 时,调用 `stopCardWithRetry` 只停被评估的单卡。 + +#### Scenario: not_realname 只停单卡,不连坐已实名卡 +- **GIVEN** 设备 D 下有卡 A(`real_name_status=0`,在线)和卡 B(`real_name_status=1`,在线) +- **WHEN** `EvaluateAndAct` 对卡 A 执行,`checkStopReasons` 返回 `["not_realname"]` +- **THEN** 调用 `stopCardWithRetry(cardA, "not_realname")`;卡 A 停机;卡 B 保持在线,`network_status` 不变 + +#### Scenario: no_package 仍走设备维度停机 +- **GIVEN** 设备 D 下有卡 A 和卡 B 均在线,设备无有效套餐 +- **WHEN** `EvaluateAndAct` 对卡 A 执行,`checkStopReasons` 返回 `["no_package"]` +- **THEN** 调用 `stopDeviceCards(deviceID, "no_package")`;卡 A 和卡 B 均被停机(设备维度停机保持不变) + +#### Scenario: 优先级 no_package > not_realname 时仍走设备维度 +- **GIVEN** 卡 A(未实名 + 无套餐),卡 B(已实名)均在线 +- **WHEN** 卡 A 的 `checkStopReasons` 返回 `["no_package", "not_realname"]`,`primaryReason = "no_package"` +- **THEN** `isDeviceScopeReason("no_package") = true`;调用 `stopDeviceCards`;卡 A 和卡 B 均停机 + +--- + +### 新增:carrier_stopped——运营商侧停机原因 + +新增常量 `StopReasonCarrierStopped = "carrier_stopped"`,表示由运营商/Gateway 侧主动停机(非本系统发起)。 + +**与现有停机原因的区别**: +| 原因 | 触发方 | 自动复机 | +|------|--------|----------| +| `no_package` | 本系统轮询 | ✅ 是 | +| `traffic_exhausted` | 本系统轮询 | ✅ 是 | +| `not_realname` | 本系统轮询 | ✅ 是(实名完成后) | +| `manual` | 管理员手动 | ❌ 否 | +| `carrier_stopped` | 运营商/Gateway | ❌ 否(需人工确认) | + +#### Scenario: carrier_stopped 不触发自动复机 +- **GIVEN** 卡停机,`stop_reason='carrier_stopped'` +- **WHEN** 下次轮询调用 `EvaluateAndAct` +- **THEN** `isPollingStopReason("carrier_stopped") = false`;跳过复机逻辑;卡保持停机状态直到管理员手动处理 diff --git a/openspec/changes/fix-polling-stop-logic/specs/polling-task-handlers/spec.md b/openspec/changes/fix-polling-stop-logic/specs/polling-task-handlers/spec.md new file mode 100644 index 0000000..a4b63c2 --- /dev/null +++ b/openspec/changes/fix-polling-stop-logic/specs/polling-task-handlers/spec.md @@ -0,0 +1,58 @@ +> **Delta Spec**:本文件仅描述对 `openspec/specs/polling-task-handlers/spec.md` 的变更部分。未提及的需求保持不变。 + +--- + +### 变更:polling_realname_handler——实名逆转防抖 + +**原行为**:检测到实名状态 `1→0` 逆转时,立即更新 DB 并调用 `EvaluateAndAct` 触发停机。 + +**新行为**:检测到逆转时,通过 Redis 计数器累计,仅在**连续 3 次**检测到逆转后才触发 `EvaluateAndAct`;检测到 `true` 时重置计数器。DB 写入行为(`real_name_status` 同步)保持不变——只延迟停机决策,不延迟状态记录。 + +**Redis Key**:`polling:card:realname:reversal:{card_id}`(`RedisPollingRealnameReversalCountKey`) +**TTL**:10 分钟(超时自动归零) +**阈值常量**:`PollingRealnameReversalThreshold = 3` + +#### Scenario: 单次逆转不触发停机 +- **GIVEN** card_id=4,`real_name_status=1`(DB),Gateway 返回 `false` +- **WHEN** `polling_realname_handler` 处理该卡 +- **THEN** DB 更新 `real_name_status=0`;Redis 计数器 `reversal:4` 变为 1(TTL=10min);**不调用 EvaluateAndAct**;卡保持在线 + +#### Scenario: 连续 3 次逆转才触发停机 +- **GIVEN** card_id=4,前 2 次轮询 Gateway 均返回 false(计数器为 2) +- **WHEN** 第 3 次轮询 Gateway 仍返回 false +- **THEN** Redis 计数器变为 3(达到阈值);删除计数器;调用 `EvaluateAndAct(freshCard)`;触发停机,`stop_reason='not_realname'` + +#### Scenario: 中间出现 true 则重置计数 +- **GIVEN** card_id=4,已记录 2 次逆转(计数器=2) +- **WHEN** 下次轮询 Gateway 返回 true(`real_name_status` 重置为 1) +- **THEN** 删除 Redis 计数器(归零);若状态从 0→1 则触发复机评估(逻辑不变) + +#### Scenario: 10 分钟未触发则计数自动归零 +- **GIVEN** card_id=4 记录了 1 次逆转(计数器=1),之后 10 分钟内 Gateway 均返回 true +- **WHEN** 10 分钟 TTL 到期 +- **THEN** Redis key 自动过期,计数归零;后续逆转需重新从 1 开始累计 + +--- + +### 变更:polling_cardstatus_handler——网关侧停机补写 stop_reason + +**原行为**:检测到 Gateway 报告卡为"停机"(在线→停机状态变化)时,仅更新 `network_status=0`,不设置 `stop_reason`。 + +**新行为**:在写入 `network_status=0` 的同时,若当前 `stop_reason` 为空,补写 `stop_reason='carrier_stopped'`。 + +> **说明**:只在状态变化时补写,若 `stop_reason` 已有值(如已被本系统停机)则不覆盖。 + +#### Scenario: 网关侧停机事件补写 carrier_stopped +- **GIVEN** 卡在线(`network_status=1`,`stop_reason=''`),Gateway 本次返回"停机" +- **WHEN** `polling_cardstatus_handler` 处理,检测到 `statusChanged=true`,`newNetworkStatus=0` +- **THEN** DB 更新 `network_status=0, stop_reason='carrier_stopped'`;`isPollingStopReason('carrier_stopped')=false`;`EvaluateAndAct` 不触发自动复机 + +#### Scenario: 不覆盖已有 stop_reason +- **GIVEN** 卡已停机(`network_status=0`,`stop_reason='not_realname'`),Gateway 本次仍返回"停机" +- **WHEN** `polling_cardstatus_handler` 处理,`statusChanged=false`(状态未变) +- **THEN** 不写入任何字段;`stop_reason` 保持 `not_realname` 不变 + +#### Scenario: carrier_stopped 的卡通过 EvaluateAndAct 调用时正确跳过复机 +- **GIVEN** 卡停机,`stop_reason='carrier_stopped'`,套餐有效,已实名 +- **WHEN** 其他轮询任务调用 `EvaluateAndAct(card)` +- **THEN** `isPollingStopReason('carrier_stopped')=false`;直接返回 nil,不执行复机 diff --git a/openspec/changes/fix-polling-stop-logic/tasks.md b/openspec/changes/fix-polling-stop-logic/tasks.md new file mode 100644 index 0000000..319e944 --- /dev/null +++ b/openspec/changes/fix-polling-stop-logic/tasks.md @@ -0,0 +1,190 @@ +# 实现任务清单 + +## Fix 1:not_realname 连坐修复 + +### 1.1 新增 isDeviceScopeReason 函数 +- **文件**:`internal/service/iot_card/stop_resume_service.go` +- **位置**:紧接 `isPollingStopReason` 函数之后 +- **内容**: + ```go + // 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` 分支 +- **原逻辑**: + ```go + 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) + ``` +- **改为**: + ```go + 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` +- **新增**: + ```go + // RedisPollingRealnameReversalCountKey 实名逆转连续计数器,防止上游单次误报触发停机 + // TTL: 10分钟 + func RedisPollingRealnameReversalCountKey(cardID uint) string { + return fmt.Sprintf("polling:card:realname:reversal:%d", cardID) + } + ``` + +### 2.2 新增防抖阈值常量 +- **文件**:`pkg/constants/iot.go` +- **新增**(放在实名状态常量附近): + ```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 行): + ```go + } else if newStatus == constants.RealNameStatusNotVerified { + h.base.logger.Warn("检测到实名逆转,立即触发停机评估", ...) + if h.stopResumeSvc != nil { + freshCard, _ := h.iotCardStore.GetByID(ctx, cardID) + h.stopResumeSvc.EvaluateAndAct(ctx, freshCard) + } + } + ``` +- **改为**: + ```go + } 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)中,复机成功前清除计数器: + ```go + // 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 3:carrier_stopped 停机原因 + +### 3.1 新增 StopReasonCarrierStopped 常量 +- **文件**:`pkg/constants/iot.go` +- **新增**(放在现有 StopReason 常量组中): + ```go + StopReasonCarrierStopped = "carrier_stopped" // 停机原因:运营商/Gateway 侧主动停机 + ``` + +### 3.2 修改 polling_cardstatus_handler.go 补写 stop_reason +- **文件**:`internal/task/polling_cardstatus_handler.go` +- **修改位置**:第 97-103 行,`statusChanged` 判断块内 +- **原逻辑**: + ```go + fields := map[string]any{"last_card_status_check_at": now} + if statusChanged { + fields["network_status"] = newNetworkStatus + } + ``` +- **改为**: + ```go + 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(连坐修复) +```sql +-- 通过 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 3(carrier_stopped) +```sql +-- 检查状态为 carrier_stopped 的卡能被正确识别 +SELECT id, iccid, network_status, stop_reason, stopped_at +FROM tb_iot_card +WHERE stop_reason = 'carrier_stopped'; +``` +预期:`stop_reason='carrier_stopped'` 的卡不会被轮询系统自动复机(需观察日志) diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index ce0ef2d..e579ccc 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -48,6 +48,9 @@ const ( RealNameStatusVerified = 1 // 已实名 ) +// PollingRealnameReversalThreshold 实名逆转防抖阈值:连续 N 次检测到逆转才触发停机 +const PollingRealnameReversalThreshold = 3 + // IoT 卡网络状态 const ( NetworkStatusOffline = 0 // 停机 @@ -62,6 +65,7 @@ const ( StopReasonProtectPeriod = "protect_period" // 保护期一致性检查自动停机 StopReasonNoPackage = "no_package" // 停机原因:无有效套餐 StopReasonNotRealname = "not_realname" // 停机原因:非行业卡且未完成实名认证 + StopReasonCarrierStopped = "carrier_stopped" // 停机原因:运营商/Gateway 侧主动停机 ) // 套餐流量类型 diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index d7158b5..152f35d 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -420,6 +420,12 @@ func RedisPollingShardQueueKey(shardID int, taskType string) string { return fmt.Sprintf("polling:shard:%d:queue:%s", shardID, taskType) } +// RedisPollingRealnameReversalCountKey 实名逆转连续计数器,防止上游单次误报触发停机 +// TTL: 10分钟 +func RedisPollingRealnameReversalCountKey(cardID uint) string { + return fmt.Sprintf("polling:card:realname:reversal:%d", cardID) +} + // RedisPollingDeviceOpLockKey 设备维度停复机操作锁 Key // 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 调用 // TTL 建议 30 秒(覆盖 stopDeviceCards/resumeDeviceCards 最长执行时间)