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

191 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 实现任务清单
## Fix 1not_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 3carrier_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 3carrier_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'` 的卡不会被轮询系统自动复机(需观察日志)