## 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 范围 |
