# 实现任务清单

## 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'` 的卡不会被轮询系统自动复机（需观察日志）
