fix: 修复轮询停机连坐、实名逆转防抖及carrier_stopped原因写入缺失三个逻辑缺陷
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m37s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m37s
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
openspec/changes/fix-polling-stop-logic/.openspec.yaml
Normal file
2
openspec/changes/fix-polling-stop-logic/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-20
|
||||
134
openspec/changes/fix-polling-stop-logic/design.md
Normal file
134
openspec/changes/fix-polling-stop-logic/design.md
Normal file
@@ -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 范围 |
|
||||
42
openspec/changes/fix-polling-stop-logic/proposal.md
Normal file
42
openspec/changes/fix-polling-stop-logic/proposal.md
Normal file
@@ -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` 才能自动复机,本次代码修复不回填存量数据。
|
||||
@@ -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`;跳过复机逻辑;卡保持停机状态直到管理员手动处理
|
||||
@@ -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,不执行复机
|
||||
190
openspec/changes/fix-polling-stop-logic/tasks.md
Normal file
190
openspec/changes/fix-polling-stop-logic/tasks.md
Normal file
@@ -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'` 的卡不会被轮询系统自动复机(需观察日志)
|
||||
@@ -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 侧主动停机
|
||||
)
|
||||
|
||||
// 套餐流量类型
|
||||
|
||||
@@ -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 最长执行时间)
|
||||
|
||||
Reference in New Issue
Block a user