## Context

### 当前架构（混乱状态）

```
internal/
├── polling/
│   ├── scheduler.go          (764行)
│   │   职责：调度循环 + 卡初始化 + 配置管理 + 生命周期回调
│   │         + 套餐激活触发 + 流量重置触发
│   │   问题：六职合一，改一处可能破坏另外五处
│   ├── callbacks.go          (228行)
│   │   职责：Worker进程卡生命周期回调（Redis队列操作）
│   │   问题：与 api_callback.go 大量重复
│   ├── api_callback.go       (107行)
│   │   职责：API进程卡生命周期回调（同上，只是所在进程不同）
│   │   问题：与 callbacks.go 几乎相同代码，双重维护
│   ├── package_activation_handler.go (505行) 套餐过期处理（直接DB）
│   └── data_reset_handler.go (116行)  流量重置调度
│
└── task/
    └── polling_handler.go    (1360行)
        职责：4类检查（实名/流量/套餐/保护期）
              + 停复机决策（与 stop_resume_service.go 重复）
              + 直接DB操作（13处，绕过Store层）
              + 直接Gateway调用（7处，无重试）
              + 缓存管理 + 队列管理
        问题：单文件承担8类职责，改动风险极高

service/iot_card/
└── stop_resume_service.go    (410行)
    职责：停复机逻辑（有3次重试）
    问题：与 polling_handler 中另一套停复机逻辑并存，
          hasAvailablePackage 重复定义，
          只查 iot_card_id，遗漏 device_id（Bug1）
```

**四个线上 Bug：**
```
Bug 1：设备套餐误停机
  PackageUsage.device_id 存设备套餐，
  但 hasAvailablePackage 只查 iot_card_id → 购买设备套餐后仍被误停机

Bug 2：停机条件不完整
  仅检查「无套餐」，未检查「流量耗尽」「非行业卡未实名」

Bug 3：protect 队列遗漏
  removeFromAllQueues 只清 realname/carddata/package 3个队列
  漏掉 protect → 删除卡后 protect 队列残留脏数据

Bug 4：出队竞态
  ZRANGEBYSCORE + ZREMRANGEBYSCORE 两步非原子
  高并发下同一张卡可能被多个 Worker 重复取出
```

### 目标架构（清晰）

```
internal/
├── polling/
│   ├── scheduler.go       (<200行)  纯调度循环
│   │   职责：从分片队列出队（Lua脚本原子出队）→ 批量推入 Asynq
│   │   依赖：PollingQueueManager、PollingConfigManager
│   │
│   ├── queue_manager.go   (<200行)  统一 Redis 队列操作
│   │   职责：DequeueReady（Lua脚本原子出队）
│   │         Requeue（ZADD重入队）
│   │         RemoveFromAllQueues（含protect，修复Bug3）
│   │         EnqueueManual（手动触发）
│   │         OnCardDeleted（卡删除清理）
│   │   特性：分片Sorted Set支持千万级
│   │
│   ├── config_manager.go  (<150行)  配置管理
│   │   职责：从DB加载PollingConfig → 内存缓存（读写锁）
│   │         → Redis同步（TTL 24h）→ 5分钟定时刷新
│   │
│   └── initializer.go     (<250行)  分片渐进式初始化
│       职责：分批（10万/批）从DB加载卡
│             → card_id % shard_count 分桶入队
│             → 跳过 enable_polling=false 的卡
│             → 暴露进度给 MonitoringService
│
└── task/                             （每个 < 300行）
    ├── polling_base.go    (<150行)   共享基类
    │   职责：并发控制（acquireConcurrency/releaseConcurrency）
    │         卡缓存（getCardWithCache/updateCardCache）
    │         重入队（requeueCard）
    │         配置匹配（getMatchedPollingInterval）
    │
    ├── polling_realname_handler.go  (<200行)  实名检查
    │   职责：调Gateway查实名 → 写DB → 触发首次实名激活任务
    │   不做：停复机决策
    │
    ├── polling_carddata_handler.go  (<300行)  流量检查
    │   职责：调Gateway查流量增量 → 写DB → 调DeductDataUsage
    │         → 调 StopResumeService.EvaluateAndAct()
    │   不做：判断是否停机（由StopResumeService决定）
    │
    ├── polling_package_handler.go   (<200行)  套餐检查
    │   职责：计算套餐流量使用
    │         → 调 StopResumeService.EvaluateAndAct()
    │   不做：停复机决策
    │
    └── polling_protect_handler.go   (<150行)  保护期检查
        职责：检查保护期Redis Key
              → 调 StopResumeService 执行保护期停复机
        不做：停复机决策

service/iot_card/
└── stop_resume_service.go    唯一停复机入口
    新增：EvaluateAndAct(ctx, card, carrierType, carrierID) error
    修复：设备套餐查询 Bug（device_id vs iot_card_id）
    新增：三条件停机（无套餐/流量耗尽/非行业卡未实名）
    完整：复机条件（含行业卡豁免）
    设备维度：停机覆盖所有卡，复机跳过未实名普通卡
```

**架构原则：**
```
Task Handler   = 数据采集层（调Gateway + Store，不做业务决策）
StopResumeService = 停复机的唯一决策和执行层
PollingQueueManager = Redis 操作的唯一封装
PollingConfigManager = 配置管理的唯一封装
CardInitializer = 初始化的唯一封装
PollingLifecycleService = 卡生命周期轮询管理（替代 callbacks.go）
```

## Goals / Non-Goals

**Goals:**
- 每个文件职责单一，Handler 类控制在 < 300 行，Scheduler < 250 行
- 停复机逻辑有且只有一处（StopResumeService.EvaluateAndAct）
- 消除所有直接 DB 访问（polling_handler → Store 层方法）
- 消除无重试的 Gateway 直调（统一通过 StopResumeService 3次重试）
- 修复原子性 Bug（Lua 脚本原子出队替代 ZRANGEBYSCORE+ZREMRANGEBYSCORE）
- 修复 protect 队列遗漏 Bug（RemoveFromAllQueues 覆盖4个队列）
- 修复设备套餐查询 Bug（device_id vs iot_card_id）
- 实现三条件停机判断（无套餐/流量耗尽/非行业卡未实名）
- 支持千万级规模（分片 Sorted Set，16分片默认，背压机制）
- 新增 Device.enable_polling 字段和轮询管控 HTTP 接口

**Non-Goals:**
- 不改变 Asynq 任务类型常量（保持任务名称向后兼容）
- 不改变已有 Redis Key 结构（仅新增分片 Key，旧 Key 不变）
- 不改变 HTTP API 接口契约（32个接口零改动；4个监控接口内部实现适配分片队列，对外响应格式不变）
- 不改变 `StopResumeCallback` 接口（`triggerStopAfterExpiry()` 和 `checkAndTriggerSuspension()` 不受影响）
- 不修改 PackageActivationHandler 内部逻辑（另立提案）
- 不引入新的外部依赖（Lua 脚本、ZRANGEBYSCORE、ZREM 均为 Redis 原生命令）

## Decisions

### 决策 1：分片队列设计（千万级核心）

**问题背景**：单个 Sorted Set 存千万级卡 ID，单节点出队成为瓶颈。

**数学估算**：
```
目标规模：1000万张卡，4种任务类型
单队列深度：1000万 / 4 = 250万
单次出队：每秒 1 次，每次取 1000条 → 2500秒处理完一轮 ≈ 41分钟
16个分片后：每分片 ~15.6万张卡，每分片每次取 1000条
并行消费：16分片同时 Lua 脚本原子出队，吞吐量提升 16x
```

**Key 命名规范**：
```
分片队列 Key：polling:shard:{shardID}:queue:{taskType}
  shardID：0 到 N-1（默认 N=16）
  taskType：realname | carddata | package | protect
  示例：polling:shard:3:queue:carddata

手动触发 Key（List）：polling:manual:{taskType}
  示例：polling:manual:realname

背压监控 Key（已有）：polling:stats:queue_depth:{taskType}
```

**分片路由算法**：
```go
// 入队时按 card_id 取模分桶，确保同一张卡始终在同一分片
shardID := cardID % uint(shardCount)
key := fmt.Sprintf("polling:shard:%d:queue:%s", shardID, taskType)
```

**背压机制**：
```go
// 单分片队列深度超过阈值（默认 500K）时，跳过该分片本轮调度
depth, _ := redis.ZCard(ctx, shardKey).Result()
if depth > BackpressureThreshold {
    continue // 跳过该分片，防止 Asynq 过载
}
```

**初始化阶段兼容**：初始化完成前（initCompleted=false），分片队列保持为空，Scheduler 不出队；初始化通过 CardInitializer.Run() 并行写入各分片。

---

### 决策 2：Lua 脚本原子出队替代 ZRANGEBYSCORE+ZREMRANGEBYSCORE

**问题**：当前两步操作存在两层风险：
```
风险1（重复处理）：
  Worker A：ZRANGEBYSCORE → 得到 [card1, card2]
  Worker B：ZRANGEBYSCORE → 得到 [card1, card2]（未被移除）
  结果：card1 和 card2 被重复入队 Asynq，触发重复停复机 Gateway 调用

风险2（卡丢失，更严重）：
  ZRANGEBYSCORE 有 LIMIT（如 50000），ZREMRANGEBYSCORE 无 LIMIT
  当到期卡数 > 50000 时，ZRANGEBYSCORE 只读前 50000 张
  ZREMRANGEBYSCORE 删除全部到期卡（含未读取的后 50000 张）
  结果：超出批次的卡永久丢失，不再被轮询
```

**解决方案**：使用 Lua 脚本在 Redis 服务端原子执行 ZRANGEBYSCORE + ZREM：
```lua
-- polling_dequeue.lua
-- KEYS[1]: 分片队列 Key
-- ARGV[1]: 当前时间戳（score 上限，只取到期卡）
-- ARGV[2]: 批次大小（LIMIT，Go 层已限制 ≤ 7000，即 DequeueMaxBatchSize）
local results = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
-- 注意：由于 Go 层已将 batchSize 限制在 7000 以内，results 最多 7000 个。
-- 以下分批 ZREM 循环实际只执行一次，是防御性代码（应对未来放宽 batchSize 上限的情况）。
-- Lua unpack() 在 Lua 5.1（Redis 内置版本）中受 LUAI_MAXCSTACK 约 8000 限制。
for i = 1, #results, 7000 do
    local j = math.min(i + 6999, #results)
    redis.call('ZREM', KEYS[1], unpack(results, i, j))
end
return results
```

```go
// Go 端封装（redis.NewScript 自动处理 EVALSHA 缓存）
// DequeueMaxBatchSize Lua 脚本单次出队上限（受 Lua unpack 栈限制，不超过 7000）
// 实际上限制在此值时，Lua 内部的 7000 分批 ZREM 循环只执行一次（防御性代码）
const DequeueMaxBatchSize = 7000

var dequeueScript = redis.NewScript(`
    local results = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
    for i = 1, #results, 7000 do
        local j = math.min(i + 6999, #results)
        redis.call('ZREM', KEYS[1], unpack(results, i, j))
    end
    return results
`)

func (m *PollingQueueManager) DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error) {
    // 防御：batchSize 不超过 Lua unpack 栈限制，保证 results ≤ DequeueMaxBatchSize
    if batchSize > DequeueMaxBatchSize {
        batchSize = DequeueMaxBatchSize
    }
    key := constants.RedisPollingShardQueueKey(shardID, taskType)
    now := time.Now().Unix()
    results, err := dequeueScript.Run(ctx, m.redis, []string{key}, now, batchSize).StringSlice()
    // 解析 results 为 []CardEntry（Redis ZRANGEBYSCORE 返回 string，需 strconv.ParseUint 转换为 uint）
}
```

**语义保持说明**：
- 保留 ZRANGEBYSCORE 的时间过滤语义：只取 score ≤ now 的到期卡，不触碰未来项
- 保留 LIMIT 语义：每次最多取 batchSize 条，不多删
- 原子性保证：Lua 脚本在 Redis 单线程中执行，ZRANGEBYSCORE 和 ZREM 之间无竞态窗口
- 重入队由 Task Handler 在处理完成后通过 `PollingQueueManager.Requeue()` 负责

**为何不用 ZPOPMIN**：
- ZPOPMIN 弹出 score 最小的 N 个成员，**不区分 score 是否 ≤ now**
- 卡的 score 是下次检查时间戳，未到期卡（score > now）会被提前取出处理，违反轮询间隔设计
- 初始化阶段大量卡 score 在未来，ZPOPMIN 会将它们全部错误地提前处理

**Lua 脚本性能说明**：
- Redis Lua 在服务端单线程执行，与原生命令性能差异在微秒级
- 脚本首次执行后被 Redis 缓存（SHA1），后续通过 EVALSHA 执行，等效于原生命令
- 对于 1 秒间隔的调度循环，此差异无实际影响

---

### 决策 3：StopResumeService.EvaluateAndAct() 完整伪代码设计

> **签名变更（Mi2 修复）**：删除原设计中的 `carrierType string, carrierID uint` 参数。  
> 这两个参数原来"仅用于日志"，放在方法签名里会误导实现者认为它们影响业务逻辑。  
> 设备/单卡维度的判断通过 `card.DeviceID` 完全可推导，日志上下文通过 `zap.Field` 传递。

```
EvaluateAndAct(ctx, card):
  // 停复机维度通过 card.DeviceID 推导：
  //   card.DeviceID != nil → 设备维度（停/复机覆盖设备下所有卡）
  //   card.DeviceID == nil → 单卡维度
  if card.NetworkStatus == Online(1):
    // 卡在线，检查是否需要停机
    reasons = checkStopReasons(ctx, card)
    if len(reasons) > 0:
      // 取最高优先级原因（no_package > traffic_exhausted > not_realname）
      primaryReason = reasons[0]
      if card.DeviceID != nil:
        // 设备维度：停机覆盖设备下所有在线卡
        stopDeviceCards(ctx, card.DeviceID, primaryReason)
      else:
        // 单卡维度
        stopCardWithRetry(ctx, card, primaryReason)
  else if card.NetworkStatus == Offline(0):
    // 卡停机，检查是否可以复机
    if card.StopReason NOT IN [traffic_exhausted, no_package, not_realname]:
      return nil // 手动停机等非轮询原因，不自动复机
    if shouldResume(ctx, card):
      if card.DeviceID != nil:
        resumeDeviceCards(ctx, card.DeviceID)
      else:
        resumeSingleCard(ctx, card)

checkStopReasons(ctx, card) → []string:
  reasons = []
  // 条件A：无有效套餐（优先级最高）
  if !hasValidPackage(ctx, card):
    reasons += ["no_package"]
  // 条件B：虚流量耗尽
  if isTrafficExhausted(ctx, card):
    reasons += ["traffic_exhausted"]
  // 条件C：非行业卡且未实名
  if !isRealnameOK(card):
    reasons += ["not_realname"]
  return reasons // 已按优先级排序

hasValidPackage(ctx, card) → bool:
  if card.DeviceID != nil:
    // 修复Bug1：设备套餐查 device_id
    count = db.Count(tb_package_usage WHERE device_id=card.DeviceID AND status IN(0,1))
  else:
    count = db.Count(tb_package_usage WHERE iot_card_id=card.ID AND status IN(0,1))
  return count > 0

isTrafficExhausted(ctx, card) → bool:
  // 查询活跃（status=1）或已耗尽（status=2）的套餐
  // ORDER BY status ASC: 优先取 status=1（活跃），再取 status=2（耗尽）
  // 语义：只要存在一个活跃套餐（status=1）且未超限，就不判定为流量耗尽
  if card.DeviceID != nil:
    pkg = db.Get(tb_package_usage WHERE device_id=card.DeviceID AND status IN(1,2) ORDER BY status ASC LIMIT 1)
  else:
    pkg = db.Get(tb_package_usage WHERE iot_card_id=card.ID AND status IN(1,2) ORDER BY status ASC LIMIT 1)
  if pkg == nil: return false  // 无活跃或耗尽套餐，不判定为流量耗尽
  // status=2 表示系统已标记虚流量耗尽
  if pkg.Status == 2: return true
  // 活跃套餐（status=1）：用量 >= 上限（data_limit_mb > 0 防止无限流量卡误判）
  // 业务约定：data_limit_mb=0 表示无限流量套餐，永远不判定为耗尽
  return pkg.DataLimitMB > 0 && pkg.DataUsageMB >= pkg.DataLimitMB

isRealnameOK(card) → bool:
  // 行业卡无需实名
  return card.CardCategory == "industry" || card.RealNameStatus == 1

shouldResume(ctx, card) → bool:
  return hasValidPackage(ctx, card) &&
         !isTrafficExhausted(ctx, card) &&
         isRealnameOK(card)

stopDeviceCards(ctx, deviceID, stopReason):
  cards = db.List(tb_iot_card WHERE device_id=deviceID AND network_status=1)
  for card in cards:
    stopCardWithRetry(ctx, card, stopReason)  // 3次重试

resumeDeviceCards(ctx, deviceID):
  cards = db.List(tb_iot_card WHERE device_id=deviceID AND network_status=0
                  AND stop_reason IN(traffic_exhausted, no_package, not_realname))
  for card in cards:
    if isRealnameOK(card):     // 跳过未实名普通卡
      resumeSingleCard(ctx, card)
```

---

### 决策 4：Task Handler 职责边界表

> **S1 修复**：`polling_realname_handler.go` 在检测到实名状态 0→1 时，**必须**调用 `EvaluateAndAct`，  
> 以确保因 `not_realname` 停机的卡在实名完成后能立即复机，不依赖下一个 carddata/package 轮询周期（可能长达 1 小时）。  
> 这不是"一般性停复机决策"，而是实名事件驱动的精确复机触发。

| Handler | 调用 Gateway | 调用 Store | 调用 StopResumeService | 禁止 |
|---------|-------------|-----------|----------------------|------|
| `polling_realname_handler.go` | ✅ QueryRealname | ✅ UpdateRealNameStatus | ⚡ **仅当实名状态 0→1 时**调 `EvaluateAndAct`（触发 not_realname 复机） | 无条件停复机判断 |
| `polling_carddata_handler.go` | ✅ QueryDataUsage | ✅ UpdateDataUsage | ✅ EvaluateAndAct | 直接停复机判断 |
| `polling_package_handler.go` | ✅ QueryPackageInfo | ✅ UpdatePackageUsage | ✅ EvaluateAndAct | 直接停复机判断 |
| `polling_protect_handler.go` | ✅ **保护期内强制停复机**（StopCard/StartCard） | ✅ GetCard | ⚡ **仅保护期结束后**调 `EvaluateAndAct`（重新评估） | 将保护期内操作走 EvaluateAndAct 三条件判断 |
| `polling_base.go`（共享基类） | ❌ | ✅ GetCard（缓存） | ❌ | 业务逻辑 |

> **protect Handler 双路径说明**：
> - 保护期**内**（保护期 Key 存在）+ 状态不一致 → 直接调 Gateway 强制修正（绕过 EvaluateAndAct，保护期期间强制执行，无论套餐/流量/实名状态如何）
> - 保护期**结束后**（保护期 Key 不存在） → 调 `EvaluateAndAct` 重新评估正常停复机条件
> - 两种路径不可混淆：保护期内的强制修正是"一致性保障"，不能被三条件判断覆盖

**Handler 不再包含的函数**（全部迁移到 StopResumeService）：
- `checkStopResume`、`shouldStopCard`、`hasAvailablePackage`
- `stopCardByUsageExhausted`、`resumeCardByPackageAvailable`
- 任何直接调用 `h.db.Model()` 的语句

---

### 决策 5：PollingQueueManager 接口设计

```go
// PollingQueueManager 统一 Redis 轮询队列操作
// 两个进程（API 进程和 Worker 进程）共享，仅依赖 Redis Client
type PollingQueueManager interface {
  // DequeueReady 原子出队到期卡（Lua 脚本：ZRANGEBYSCORE + ZREM 服务端原子执行）
  // 只取 score ≤ now 的到期卡，不触碰未来项
  // taskType: realname | carddata | package | protect
  // shardID: 0 到 shardCount-1
  // batchSize: 每次出队数量上限
  DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error)

  // Requeue 将卡重新入队指定时间
  Requeue(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) error

  // RemoveFromAllQueues 从所有分片的所有4个队列（含protect）移除
  RemoveFromAllQueues(ctx context.Context, cardID uint) error

  // EnqueueManual 手动触发入队（List RPUSH，调度器优先消费）
  EnqueueManual(ctx context.Context, cardID uint, taskType string) error

  // OnCardDeleted 卡删除事件处理（移除队列 + 清理缓存）
  OnCardDeleted(ctx context.Context, cardID uint) error

  // GetQueueDepth 获取分片队列深度（用于背压检测和监控统计）
  GetQueueDepth(ctx context.Context, shardID int, taskType string) (int64, error)
}

// CardEntry 出队卡信息
type CardEntry struct {
  CardID    uint
  Score     float64 // Unix 时间戳（到期时间）
}
```

---

### 决策 6：Scheduler 精简设计（伪代码）

```
Scheduler:
  依赖：PollingQueueManager、PollingConfigManager、Asynq Client、
        PackageActivationHandler、DataResetHandler
  不再包含：DB查询、配置加载、卡初始化、Gateway调用、Callback注册

Start(ctx):
  cardInitializer.Run(ctx)   // 异步，后台渐进式初始化
  configManager.Start(ctx)   // 定时刷新配置
  go scheduleLoop(ctx)       // 调度主循环

scheduleLoop(ctx):
  ticker = time.NewTicker(1秒)
  activationTicker = time.NewTicker(10秒)  // 套餐过期检测周期
  for {
    select {
    case <-ticker.C:
      for shardID in 0..shardCount-1:
        go processShardSchedule(ctx, shardID)  // 并行消费分片
    case <-activationTicker.C:
      // ⚠️ 必须保留：套餐过期检测和流量重置（决策 9）
      packageActivationHandler.HandlePackageActivationCheck(ctx)
      dataResetHandler.HandleDataReset(ctx)
    }
  }

processShardSchedule(ctx, shardID):
  for taskType in [realname, carddata, package, protect]:
    // 背压检测
    depth = queueMgr.GetQueueDepth(ctx, shardID, taskType)
    if depth > BackpressureThreshold:
      continue // 跳过，Asynq 已有大量待处理任务
    // 优先消费手动触发队列
    processManualQueue(ctx, taskType, MaxManualBatch)
    // Lua 脚本原子出队到期卡（score ≤ now）
    entries = queueMgr.DequeueReady(ctx, shardID, taskType, ScheduleBatchSize)
    enqueueBatch(ctx, entries, taskType)  // 批量推入 Asynq

enqueueBatch(ctx, entries, taskType):
  for entry in entries:
    task = asynq.NewTask(taskType, payload{CardID: entry.CardID})
    // ⚠️ MaxRetry(0) 保持不变（决策 12）：避免与 Scheduler 出队产生并发双重处理
    err = asynqClient.Enqueue(task, asynq.Queue("polling"), asynq.MaxRetry(0))
    if err != nil:
      // 回退：Asynq 入队失败时，将卡重新放回分片队列，防止卡丢失
      queueMgr.Requeue(ctx, entry.CardID, taskType, time.Now())
      logger.Error("Asynq入队失败，已回退到分片队列", cardID=entry.CardID, error=err)
```

---

### 决策 7：CardInitializer 分片初始化伪代码

```
CardInitializer:
  依赖：Store（DB查询）、PollingQueueManager、PollingConfigManager
  状态：progress（已处理卡数）、total（总卡数）、completed（是否完成）

Run(ctx):
  total = store.CountIotCards(ctx)  // 查询总卡数
  offset = 0
  for offset < total:
    // 分批加载，每批 10万张
    cards = store.ListIotCards(ctx, offset, BatchSize=100000)
    for card in cards:
      if !card.EnablePolling: continue  // 跳过禁用轮询的卡
      cfg = configManager.MatchConfig(card)
      if cfg == nil: continue           // 无匹配配置，跳过
      shardID = card.ID % shardCount   // 分片路由
      for taskType in cfg.EnabledTaskTypes:
        // ZADD：score = now + initialDelay（随机散列，避免同时触发）
        initialDelay = rand.Intn(cfg.Interval)
        queueMgr.Requeue(ctx, card.ID, taskType, now.Add(initialDelay))
    progress += len(cards)
    offset += BatchSize
    time.Sleep(500ms)  // 批次间休眠，减少对 DB 和 Redis 的压力
  completed = true

GetProgress() → InitProgress:
  return {Total: total, Processed: progress, Completed: completed}
```

### 决策 8：Phase 4/5 原子部署（解决迁移断层）

**问题**：Phase 4 新 Handler 使用 `PollingBase.requeueCard()` 写入分片键（`polling:shard:N:queue:type`），Phase 5 新 Scheduler 从分片键读取。但若 Phase 4 和 Phase 5 分开部署，中间窗口期旧 Scheduler 仍从非分片键读取 → 新 Handler 重入队到分片键的卡永久消失。

**决定**：Phase 4 和 Phase 5 **必须原子部署**（同一次上线），取消中间 24 小时观察窗口。

**理由**：
- 双写方案（同时写新旧两套键）增加代码复杂度且引入清理问题
- Phase 4 和 Phase 5 共同构成"新出队/入队管线"，拆开部署无意义
- 原子部署配合 Phase 1-3 的逐步准备，风险可控

**回滚方案**：原子回滚 Phase 4+5 → 恢复旧 `polling_handler.go` + 旧 `scheduler.go` + 旧 `callbacks.go`。

---

### 决策 9：Scheduler 保留套餐过期检测和流量重置触发

**问题**：当前 `scheduler.go` 的 `processSchedule()` 每 10 秒调用 `PackageActivationHandler.HandlePackageActivationCheck()` 和 `DataResetHandler.HandleDataReset()`。精简 Scheduler 为"纯调度循环"会丢失这两个核心业务触发器。

**决定**：精简后的 Scheduler 保留这两个定时触发调用。

**Scheduler 精简后完整职责**：
1. 启动 CardInitializer.Run(ctx)（异步）
2. 启动 PollingConfigManager.Start(ctx)（定时刷新）
3. 运行 scheduleLoop：
   - 分片出队 → 推入 Asynq（核心调度）
   - 每 10 秒调用 `PackageActivationHandler.HandlePackageActivationCheck(ctx)`
   - 每 10 秒调用 `DataResetHandler.HandleDataReset(ctx)`
4. 背压检测

**目标行数调整**：< 250 行（原 < 200 行，因保留两个触发器）。

---

### 决策 10：PollingLifecycleService 替代 callbacks.go 生命周期方法

**问题**：`callbacks.go` 实现了 `PollingCallback` 接口，被 `iot_card/service.go` 在卡创建/状态变更/启用/禁用/删除时调用。`PollingQueueManager` 设计为"只依赖 Redis Client，无 DB 依赖"，无法承担需要配置匹配和 DB 查询的生命周期方法。

**决定**：新增 `PollingLifecycleService`，封装「配置匹配 + 队列操作」组合逻辑。

```go
// PollingLifecycleService 卡生命周期轮询管理
// 替代 callbacks.go 和 api_callback.go 中的生命周期方法
// 两个进程（API 和 Worker）共享同一实现
type PollingLifecycleService struct {
    queueMgr   *PollingQueueManager
    configMgr  *PollingConfigManager
    cardStore  IotCardStore
    logger     *zap.Logger
}

// OnCardCreated 新卡创建后初始化轮询
func (s *PollingLifecycleService) OnCardCreated(ctx context.Context, cardID uint) error
// OnBatchCardsCreated 批量导入后批量初始化
func (s *PollingLifecycleService) OnBatchCardsCreated(ctx context.Context, cardIDs []uint) error
// OnCardStatusChanged 卡状态变化后重新匹配配置
func (s *PollingLifecycleService) OnCardStatusChanged(ctx context.Context, cardID uint) error
// OnCardEnabled 卡启用后初始化轮询
func (s *PollingLifecycleService) OnCardEnabled(ctx context.Context, cardID uint) error
// OnCardDisabled 卡禁用后移除所有队列
func (s *PollingLifecycleService) OnCardDisabled(ctx context.Context, cardID uint) error
// OnCardDeleted 卡删除后移除所有队列并清理缓存
func (s *PollingLifecycleService) OnCardDeleted(ctx context.Context, cardID uint) error
```

**PollingLifecycleService 实现 `PollingCallback` 接口**，`iot_card/service.go` 无需感知替换。

---

### 决策 11：MonitoringService 适配分片队列

**问题**：`MonitoringService` 直接读取非分片键（`polling:queue:realname` 等）。分片后这些键不再有数据，监控指标全部返回 0。

**决定**：
1. `PollingQueueManager` 新增 `GetTotalQueueDepth(ctx, taskType) (int64, error)` 方法，聚合所有分片的 `ZCard`
2. `MonitoringService` 注入 `PollingQueueManager`，替代直接 Redis 调用

```go
// GetTotalQueueDepth 获取指定任务类型的总队列深度（聚合所有分片）
func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error) {
    var total int64
    for i := 0; i < m.shardCount; i++ {
        depth, err := m.GetQueueDepth(ctx, i, taskType)
        if err != nil { continue }
        total += depth
    }
    return total, nil
}
```

---

### 决策 12：保持 Asynq MaxRetry(0)

**问题**：当前设计使用 `MaxRetry(0)`，失败后通过 `requeueCard` 放回 Redis Sorted Set 延后处理。若改为 `MaxRetry(3)`，Asynq 重试期间同一张卡可能被 Scheduler 从 Redis 队列再次取出，导致并发双重处理。

**决定**：保持 `MaxRetry(0)` 不变。

**理由**：
- 当前的"软重试"机制（失败 → requeueCard → 下个轮询周期重新处理）更安全
- 避免 Gateway 重复调用（停机已成功但 DB 更新失败 → 重试再次停机）
- 避免 Asynq 重试窗口与 Scheduler 出队的并发冲突

---

### 决策 13：carddata Handler 必须保留跨月流量边界检测逻辑

**问题**：当前 `HandleCarddataCheck` 包含复杂的跨月检测逻辑（月份切换检测、`current_month_start_date` 比较、上月总量保存到 `last_month_total_mb`、当月计数器重置）。这不是"自然包含"在 Gateway 数据采集步骤中的，需要显式保留。

**决定**：`polling_carddata_handler.go` 的 `processCard` 方法须完整迁移以下逻辑：
1. Gateway 返回月度总流量后，与 `card.CurrentMonthStartDate` 比较检测跨月
2. 跨月时：保存 `card.LastMonthTotalMB`、重置 `card.CurrentMonthUsageMB`、更新 `card.CurrentMonthStartDate`
3. 同月时：计算增量 delta = gateway值 - card.LastMonthTotalMB
4. 记录流量历史到 `data_usage_records`

---

## Risks / Trade-offs

| 风险 | 严重度 | 缓解措施 |
|------|--------|---------|
| 重构范围大（7个文件新建/重写），引入回归 Bug | 高 | 逐 Phase 替换，每个 Phase 独立验证；使用 PostgreSQL MCP 验证关键数据路径 |
| polling_handler.go 重写可能遗漏边界逻辑 | 高 | 对照旧代码逐行比对；重点检查 cardCondition、matchPollingConfig、retry 逻辑 |
| **新增 `not_realname` 停机条件导致存量卡批量停机（S3）** | **高** | **Phase 2 实施前，用 PostgreSQL MCP 统计「在线+未实名+普通卡」的数量；与业务方确认是否允许部署后批量停机；若影响较大，可通过给不匹配的配置添加豁免期或先灰度单一运营商** |
| Lua 脚本出队的消费性语义（卡被移除后须重入队）| 中 | 验证重入队逻辑：Handler 处理完成后必须调 Requeue；异常时由 Asynq 重试覆盖 |
| 分片队列需要一次性全量重新初始化 | 中 | 初始化完成前 Scheduler 不出队；提供进度监控接口；初始化幂等（重复加入同一队列只更新 score） |
| Scheduler 拆分后依赖注入复杂度增加 | 低 | 在 cmd/worker/main.go 中按固定顺序组装：QueueMgr → ConfigMgr → Initializer → Scheduler |
| Lua 原子出队后 Asynq 入队失败，卡可能丢失 | 中 | `enqueueBatch` 入队失败时立即调 `Requeue` 回退到分片队列；极端情况（Redis 连接断开）依赖 Worker 重启后 `CardInitializer` 全量重建 |
| 现有 36 个 HTTP 接口兼容性 | 低 | 32个接口只依赖 Redis + Store，零改动；4个监控接口需适配分片队列（内部聚合 ZCard，对外响应格式不变） |

**Trade-off 说明**：
- 分片引入后，`RemoveFromAllQueues` 需要遍历所有 N 个分片的所有 4 个队列 = 4N 次 Redis 操作。16分片 = 64次操作，对于删除卡这种低频操作可以接受。
- Lua 脚本原子出队的消费性语义要求每个 Task Handler 都必须在完成后调用 `Requeue`，否则卡将永久从队列消失。需在代码 Review 时重点检查。

## Migration Plan

### Phase 1：基础准备（DB 迁移 + 常量）
**变更内容**：
- `tb_device` 新增 `enable_polling BOOLEAN NOT NULL DEFAULT TRUE` 列
- `internal/model/device.go` 新增 `EnablePolling bool` 字段
- `pkg/constants/iot.go` 新增 `StopReasonNoPackage`、`StopReasonNotRealname`
- `pkg/constants/redis.go` 新增 `RedisPollingShardQueueKey(shardID int, taskType string)`
- 执行迁移验证

**可部署状态**：✅ 无功能变化，仅数据库结构和常量变更
**回滚方案**：`ALTER TABLE tb_device DROP COLUMN IF EXISTS enable_polling;` 回滚迁移，删除新增常量和 Redis Key 函数。新增字段有默认值（true），回滚前无代码依赖。

### Phase 2：StopResumeService 重写（停复机逻辑统一）
**变更内容**：
- 新增 `hasValidPackage`（修复设备套餐 Bug1）
- 新增 `isTrafficExhausted`、`isRealnameOK`、`checkStopReasons`
- 新增 `EvaluateAndAct` 统一入口
- 修改 `stopCardWithRetry` 接收 stopReason 参数
- 修改 `resumeSingleCard`、`resumeDeviceCards` 按新复机条件
- 删除旧 `hasAvailablePackage`

**可部署状态**：✅ StopResumeService 兼容旧调用，新方法独立，可安全部署
**回滚方案**：删除新增方法（`EvaluateAndAct`、`hasValidPackage` 等），恢复旧 `hasAvailablePackage`。新方法在 Phase 3 之前无调用者，回滚不影响现有功能。

### Phase 3：PollingQueueManager + PollingConfigManager（基础组件）
**变更内容**：
- 新建 `queue_manager.go`（含分片 Sorted Set、Lua 脚本原子出队、分批 ZREM）
- 新建 `config_manager.go`（DB 加载 → 内存缓存 → Redis 同步 → 5分钟定时刷新）

**前置依赖**：Phase 1 常量（`RedisPollingShardQueueKey`）
**被依赖方**：Phase 4 的 `PollingBase` 和 Phase 5 的 `Scheduler` 均依赖这两个组件

**可部署状态**：✅ 新增文件，无功能变化（旧 Scheduler 和 callbacks.go 仍在运行），新组件创建但尚未被调用
**回滚方案**：删除新建的 `queue_manager.go` 和 `config_manager.go`。纯新增文件，回滚不影响现有功能。

### Phase 4+5（原子部署）：Task Handler 拆分 + Scheduler 精简 + CardInitializer + 删除旧文件

> ⚠️ **Phase 4 和 Phase 5 必须原子部署**：新 Handler 的 `PollingBase.requeueCard()` 写入分片键，新 Scheduler 从分片键读取。若分开部署，中间窗口期旧 Scheduler 仍读非分片键 → 卡永久丢失轮询。详见决策 8。

**Phase 4 变更内容**：
- 新建 `polling_realname/carddata/package/protect_handler.go`
- 新建 `polling_base.go`（共享基类，依赖 Phase 3 的 QueueManager 和 ConfigManager）
- `polling_carddata_handler.go` 须完整迁移跨月流量边界检测逻辑（详见决策 13）
- 所有直接 DB 操作替换为 Store 方法调用
- 所有停复机决策改为调用 `StopResumeService.EvaluateAndAct()`
- 注册 4 个新 Handler 到 Asynq（`MaxRetry(0)` 不变，详见决策 12）
- 删除旧 `polling_handler.go`

**Phase 5 变更内容**：
- 新建 `initializer.go`（分片渐进式初始化）
- 新建 `lifecycle_service.go`（替代 callbacks.go 的卡生命周期方法，详见决策 10）
- 精简 `scheduler.go`（< 250行，保留调度循环 + 套餐过期检测触发 + 流量重置触发，详见决策 9）
- 更新 `MonitoringService`（适配分片队列，详见决策 11）
- 删除 `callbacks.go`、`api_callback.go`（队列操作由 PollingQueueManager 替代，生命周期方法由 PollingLifecycleService 替代）
- 更新 `cmd/worker/main.go` 启动流程
- 更新所有 `PollingCallback` 引用 → `PollingLifecycleService`

**前置依赖**：Phase 2（StopResumeService）+ Phase 3（QueueManager、ConfigManager）

**可部署状态**：✅ Asynq 任务类型常量不变；32个HTTP接口零改动，4个监控接口适配分片
**回滚方案**：原子回滚 Phase 4+5 → 恢复旧 `polling_handler.go` + 旧 `scheduler.go` + 旧 `callbacks.go` + 旧 `api_callback.go`，回退 `cmd/worker/main.go` 和 `MonitoringService`。**此为最高风险节点**，涉及出队机制变更和队列数据结构变更。建议：① 部署前清空旧队列（初始化器会重建）；② 灰度观察 48 小时。

### Phase 6：轮询管控 API（enable_polling 接口）
**变更内容**：
- DTO：`UpdateAssetPollingStatusRequest/Response`
- Store：`device_store.UpdatePollingStatus`
- Service：`AssetPollingService.UpdatePollingStatus`
- Handler：`asset.UpdatePollingStatus`
- Route：`PATCH /api/admin/assets/:asset_type/:id/polling-status`
- Docs：更新 docs.go 和 gendocs/main.go

**可部署状态**：✅ 新增接口，不影响现有功能
**回滚方案**：删除新增路由、Handler、Service、Store 方法和 DTO。纯新增接口，回滚不影响现有功能。

### Phase 7：全面验证
- DB 验证（PostgreSQL MCP）：设备套餐停复机、行业卡实名豁免
- Redis 验证：Lua 脚本原子性、protect 队列清理
- 接口验证：enable_polling 接口、分片队列监控
- 兼容性验证：36个已有接口全量回归

---

### 决策 14（新增）：设备维度停复机幂等锁（防止多卡并发重复调 Gateway）

**问题背景**：设备下的多张卡分布在不同分片，可能在同一调度周期内同时被 Scheduler 出队并触发 `EvaluateAndAct`：

```
card-A（shard 3）→ EvaluateAndAct → stopDeviceCards(deviceID=10)  ← 同时
card-B（shard 7）→ EvaluateAndAct → stopDeviceCards(deviceID=10)  ← 同时
card-C（shard 2）→ EvaluateAndAct → stopDeviceCards(deviceID=10)  ← 同时
```

三次 `stopDeviceCards` 均遍历设备下所有在线卡，对每张卡调 Gateway 停机 → **每张卡被停机 3 次**，大量重复 Gateway 调用。

**决定**：在 `stopDeviceCards` 和 `resumeDeviceCards` 执行前，使用 Redis 分布式锁（`SetNX`）确保设备维度操作的幂等性。

**实现方案**：

```go
// RedisPollingDeviceOpLockKey 设备维度停复机操作锁
// TTL 建议 30 秒（覆盖 stopDeviceCards 最长执行时间）
func RedisPollingDeviceOpLockKey(deviceID uint) string {
    return fmt.Sprintf("polling:device:op_lock:%d", deviceID)
}

// stopDeviceCards 中加锁
func (s *Service) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) {
    lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
    locked, _ := s.redis.SetNX(ctx, lockKey, time.Now().String(), 30*time.Second).Result()
    if !locked {
        s.logger.Debug("设备停复机操作已在进行中，跳过重复调用", zap.Uint("device_id", deviceID))
        return // 其他协程正在处理，本次跳过
    }
    defer s.redis.Del(ctx, lockKey)
    // ... 执行停机逻辑
}
```

同样适用于 `resumeDeviceCards`，使用同一把锁（`op_lock` 覆盖停机和复机，防止同一设备同时触发相反操作）。

**需要新增的常量**（`pkg/constants/redis.go`）：

```go
// RedisPollingDeviceOpLockKey 设备维度停复机操作锁 Key
// TTL 30 秒，防止设备下多张卡并发触发重复 Gateway 调用
func RedisPollingDeviceOpLockKey(deviceID uint) string {
    return fmt.Sprintf("polling:device:op_lock:%d", deviceID)
}
```

**需要新增的任务**（在 tasks.md Phase 2.5 和 2.8 中分别加入加锁逻辑，并在 Phase 1 的 redis.go 中新增 Key 函数）。

---

### 决策 15：修复 `getCardCondition` 中 `suspended` 永远不返回的问题（M1）

**问题背景**：  
现有 `getCardCondition` 逻辑如下：
```go
if card.RealNameStatus != RealNameStatusVerified { return "not_real_name" }
if card.NetworkStatus == 1 { return "activated" }
return "real_name"   // 停机+已实名卡落到这里
```
- 停机且已实名的卡返回 `"real_name"`，而 `"real_name"` 配置通常 `carddata_check_interval=null`、`package_check_interval=null`
- 结果：停机卡不再被 carddata/package 轮询，`EvaluateAndAct` 不会被调用，**无法自动复机**
- `card_condition='suspended'` 在 DTO 中作为合法值存在，但 `getCardCondition` 从未返回该值，是死代码

**修复方案**：在 `PollingConfigManager.getCardCondition` 中，**最先**检查网络状态：
```go
func getCardCondition(card *model.IotCard) string {
    // 停机卡（无论实名状态），使用独立的 suspended 配置
    // 停机卡需要继续轮询 carddata/package 以便检测复机条件
    if card.NetworkStatus == 0 {
        return "suspended"
    }
    // 在线卡按实名状态细分
    if card.RealNameStatus != constants.RealNameStatusVerified {
        return "not_real_name"
    }
    return "activated"
}
```

**配套数据库配置**：需为 `suspended` 条件添加对应的 PollingConfig，包含 carddata/package 检查间隔（建议与 `activated` 相同或更低频率），确保停机卡能持续被轮询以便自动复机。示例：
```sql
INSERT INTO tb_polling_config (config_name, card_condition, priority, carddata_check_interval, package_check_interval, status)
VALUES ('停机卡轮询', 'suspended', 25, 3600, 3600, 1);
```

**注意**：`"real_name"` 条件从 `getCardCondition` 的返回值中删除（仅保留 not_real_name / activated / suspended）。现有配置中 `card_condition='real_name'` 的条目不再被匹配，可按需清理或转换。

---

## Open Questions

1. **分片数量配置化**：shardCount 是否需要可配置（当前设计为常量16）？
   建议：Phase 4 实现时作为 `PollingConfig.ShardCount` 写入 Redis，`PollingConfigManager` 读取，修改无需重启。

2. `polling_handler.go` 中 `HandleCarddataCheck` 调用的 `usageService.DeductDataUsage()`，在拆分后应归属于 `carddata_handler` 还是 `StopResumeService`？
   → **决定**：保留在 `carddata_handler`，仅为数据计算，不是停复机决策。

3. `PackageActivationHandler` 的直接 DB 操作（4处）是否在本次重构范围内？
   → **不在本次范围**，计划在单独提案中处理，避免本次范围蔓延。

4. ~~初始化期间（initCompleted=false），新注册卡如何处理？~~ **已解决**（决策 10）
   通过 `PollingLifecycleService.OnCardCreated()` 处理：匹配配置 → 调用 `PollingQueueManager.Requeue()` 写入分片队列。与初始化并发进行，ZADD 的幂等性保证不重复入队。`PollingLifecycleService` 实现 `PollingCallback` 接口，`iot_card/service.go` 无需感知替换。
