## Why（已合并 polling-logic-redesign，可废弃该提案）

轮询系统经过多次迭代后出现了四类问题，须同步解决：

### 一、结构性问题（影响长期可维护性）

**问题 1：`polling_handler.go`（1360行）职责爆炸**
Asynq Task Handler 本应只做「数据采集」，但它同时承担：停复机决策（与 stop_resume_service.go 重复）、直接 DB 操作（15处绕过 Store 层）、直接 Gateway 调用（7处无重试）、缓存管理、队列管理。

**问题 2：停复机逻辑分裂，可靠性不一致**
`polling_handler.go` 和 `stop_resume_service.go` 各有一套停复机实现，`hasAvailablePackage` 重复定义；前者 Gateway 调用无重试，后者有 3 次重试，同一业务，不同可靠性。

**问题 3：Scheduler（764行）身兼六职**
调度循环 + 配置管理 + 卡初始化 + 生命周期回调 + 套餐激活触发 + 流量重置触发。`callbacks.go` 与 `api_callback.go` 大量重复代码（两套相同的 Redis 队列操作逻辑）。

### 二、线上 Bug（需立即修复）

**Bug 1（停复机判断错误）：设备套餐被忽略 → 误停机**
`PackageUsage` 设备套餐存 `device_id`，但 `shouldStopCard` / `hasAvailablePackage` 只查 `iot_card_id`，购买设备套餐后卡仍被误停机。

**Bug 2（停复机条件不完整）：停机条件缺失**
当前停机条件仅为「无有效套餐」，未判断虚流量是否耗尽，未区分行业卡/非行业卡的实名要求。

**Bug 3（保护期队列遗漏）：protect 队列残留脏数据**
`removeFromAllQueues` 只清 3 个队列（realname、carddata、package），漏掉 `protect` 队列，删除卡后 protect 队列残留。

**Bug 4（出队竞态）：ZRANGEBYSCORE + ZREMRANGEBYSCORE 非原子**
两层风险：① 高并发下多 Worker 读取同一批卡，导致重复停机/复机 Gateway 调用；② `ZRANGEBYSCORE` 有 LIMIT 但 `ZREMRANGEBYSCORE` 无 LIMIT，当到期卡数超过批次大小时，超出部分被删除但从未被读取——**卡永久丢失，不再被轮询**。

### 三、规模挑战（千万级支撑设计）

当前设计假设百万级（100K 初始化批次、50K 调度批次、50 并发）。千万级规模下，单个 Sorted Set 深度达千万条，需引入：**分片队列**（Sharded Sorted Set）、**多 Worker 分片消费**（无协调开销）、**分片并行初始化**。

### 四、管理接口兼容性确认

现有 36 个轮询管理 HTTP 接口（配置/手动触发/告警/监控/并发/清理）经全量检查，**绝大部分 Service 只依赖 Redis Client 和 Store，不依赖 Scheduler 对象**。其中 **32 个接口全部兼容，零改动**；**4 个监控统计接口需适配分片队列**（`MonitoringService` 读取队列深度的 Key 从 `polling:queue:{type}` 变更为聚合所有分片 `polling:shard:{N}:queue:{type}` 的 `ZCard` 之和）。

## What Changes

**功能修复（来自 polling-logic-redesign，合并入本提案）：**
- 修复 `hasAvailablePackage` / `shouldStopCard`：根据卡绑定关系动态切换 `iot_card_id` / `device_id` 查询
- 重写停复机判断：新增三条件停机（无套餐/流量耗尽/未实名），区分行业卡
- 新增停机原因精细化常量：`no_package`、`not_realname`
- 新增 `Device.enable_polling` 字段
- 新增 HTTP 接口：`PATCH /api/admin/assets/:asset_type/:id/polling-status`

**架构重构：**
- 拆分 `polling_handler.go`（1360行）→ 4 个专注 Handler（实名/流量/套餐/保护期）+ 共享基类，每个 < 300行
- 新增 `PollingQueueManager`：统一 Redis 队列操作，Lua 脚本原子出队（ZRANGEBYSCORE + ZREM 原子执行），修复 protect 遗漏 Bug
- 新增 `PollingConfigManager`：独立配置管理，支持 5 分钟定时刷新
- 新增 `CardInitializer`：独立初始化模块，支持分片并行
- 精简 `Scheduler`（目标 < 250行）：保留调度循环 + 套餐过期检测触发 + 流量重置触发（`PackageActivationHandler.HandlePackageActivationCheck` 和 `DataResetHandler.HandleDataReset` 必须保留在调度主循环中）
- 新增 `PollingLifecycleService`：替代 `callbacks.go` + `api_callback.go` 中卡生命周期方法（`OnCardCreated`/`OnCardStatusChanged`/`OnCardEnabled`/`OnCardDisabled`/`OnCardDeleted`），依赖 `PollingQueueManager` + `PollingConfigManager`，封装「匹配配置 + 入队」组合逻辑
- 删除 `callbacks.go` + `api_callback.go`（队列操作由 PollingQueueManager 替代，生命周期方法由 PollingLifecycleService 替代）
- `MonitoringService` 适配分片队列：队列深度查询改为聚合所有分片的 `ZCard` 之和

**千万级规模设计：**
- 分片 Sorted Set：`polling:shard:{0..N-1}:queue:{taskType}`，默认 16 分片
- `CardInitializer` 按 `card_id % shard_count` 分桶入队
- Scheduler 并行消费 N 个分片（每个分片独立 Lua 脚本原子出队）
- 背压机制：单分片队列深度超阈值时，跳过该分片本轮调度

**重要设计约束：**
- Asynq 任务提交保持 `MaxRetry(0)`（与当前设计一致），失败后通过 `requeueCard` 放回 Redis Sorted Set 延后处理，避免 Asynq 重试与 Scheduler 出队产生并发双重处理
- `StopResumeCallback` 接口不在本次变更范围内（仅替换 `PollingCallback`），`triggerStopAfterExpiry()` 和 `checkAndTriggerSuspension()` 不受影响
- Phase 4（Task Handler 拆分）和 Phase 5（Scheduler 精简）必须**原子部署**，不可分开上线（详见迁移计划）

## Capabilities

### New Capabilities

- `polling-queue-manager`: 统一 Redis 队列操作封装——Lua 脚本原子出队（ZRANGEBYSCORE + ZREM 服务端原子执行，保留时间过滤语义）、分片 Sorted Set（支持千万级）、修复 protect 遗漏 Bug、入队/重入队/手动触发/删除的统一接口；替代 callbacks.go、api_callback.go、polling_handler.go 中所有分散的队列操作
- `polling-config-manager`: 独立配置管理模块——从 DB 加载 `tb_polling_config`、同步到 Redis Hash（TTL 24h）、内存缓存（读写锁）、5 分钟定时自动刷新、`MatchConfig(card)` 按优先级返回第一个匹配配置
- `card-initializer`: 独立卡初始化模块——渐进式分批（100K/批）、按分片入队（`card_id % shard_count`）、`enable_polling=false` 过滤、进度状态暴露给 MonitoringService
- `polling-lifecycle-service`: 卡生命周期轮询管理——替代 `callbacks.go` 和 `api_callback.go` 中的 `OnCardCreated`/`OnBatchCardsCreated`/`OnCardStatusChanged`/`OnCardEnabled`/`OnCardDisabled`/`OnCardDeleted`，依赖 `PollingQueueManager`（队列操作）+ `PollingConfigManager`（配置匹配），封装「匹配配置 → 分片入队」组合逻辑；两个进程（API 和 Worker）共享
- `asset-polling-control`: 资产轮询管控 HTTP 接口——`PATCH /api/admin/assets/:asset_type/:id/polling-status`，支持 card/device 两种资产类型，启用/禁用轮询

### Modified Capabilities

- `polling-stop-resume-logic`: 停复机逻辑统一到 `StopResumeService`——新增 `EvaluateAndAct()` 统一入口、修复设备套餐查询 Bug（`device_id` vs `iot_card_id`）、实现三条件停机判断（无套餐/流量耗尽/未实名）、完整复机条件（含行业卡豁免）、停机原因精细化
- `polling-task-handlers`: `polling_handler.go` 拆分为 4 个专注文件——每个 Handler 只做数据采集，停复机通过 `StopResumeService.EvaluateAndAct()` 完成，消除所有直接 DB 操作和无重试 Gateway 调用；`polling_carddata_handler.go` 须完整保留当前 `HandleCarddataCheck` 中的跨月流量边界检测逻辑（月份切换检测、上月总量保存、当月计数器重置）
- `polling-monitoring-service`: `MonitoringService` 适配分片队列——队列深度查询改为调用 `PollingQueueManager.GetTotalQueueDepth(taskType)` 聚合所有分片，替代直接读取旧的非分片 Redis Key

## Impact

### 新建文件

| 文件 | 职责 | 目标行数 |
|------|------|---------|
| `internal/task/polling_realname_handler.go` | 实名检查 Task Handler | < 200行 |
| `internal/task/polling_carddata_handler.go` | 流量检查 Task Handler | < 300行 |
| `internal/task/polling_package_handler.go` | 套餐检查 Task Handler | < 200行 |
| `internal/task/polling_protect_handler.go` | 保护期一致性 Task Handler | < 200行 |
| `internal/task/polling_base.go` | 共享基类（并发控制/缓存/重入队） | < 150行 |
| `internal/polling/queue_manager.go` | 统一 Redis 队列操作 | < 200行 |
| `internal/polling/config_manager.go` | 配置加载管理 | < 150行 |
| `internal/polling/initializer.go` | 分片渐进式初始化 | < 250行 |
| `internal/polling/lifecycle_service.go` | 卡生命周期轮询管理（替代 callbacks.go） | < 200行 |

### 修改文件

| 文件 | 变更类型 | 变更内容 |
|------|---------|---------|
| `internal/polling/scheduler.go` | 精简重写 | 保留调度循环 + 套餐过期/流量重置触发，< 250行 |
| `internal/service/iot_card/stop_resume_service.go` | 扩展+修复 | 新增 EvaluateAndAct + 修复设备套餐 Bug + 三条件停机 |
| `internal/model/device.go` | 字段新增 | `EnablePolling bool` |
| `internal/handler/admin/asset.go` | 方法新增 | `UpdatePollingStatus` handler |
| `internal/routes/asset.go` | 路由新增 | `PATCH /assets/:asset_type/:id/polling-status` |
| `internal/store/postgres/device_store.go` | 方法新增 | `UpdatePollingStatus(ctx, id, enabled)` |
| `pkg/constants/iot.go` | 常量新增 | `StopReasonNoPackage`、`StopReasonNotRealname` |
| `pkg/constants/redis.go` | 函数新增 | `RedisPollingShardQueueKey(shard, taskType)` |
| `internal/service/polling/monitoring_service.go` | 适配更新 | 队列深度查询改为聚合分片 ZCard |
| `cmd/worker/main.go` | 启动流程更新 | 使用新组件，注册 4 个 Handler |
| `pkg/queue/handler.go` | Handler 注册更新 | 注册 4 个新 Task Handler |
| `cmd/api/docs.go` + `cmd/gendocs/main.go` | 文档更新 | 注册新 AssetHandler 方法 |

### 删除文件

| 文件 | 删除原因 |
|------|---------|
| `internal/task/polling_handler.go` | 拆分为 4 个专注文件后废弃 |
| `internal/polling/callbacks.go` | 职责转移到 PollingQueueManager |
| `internal/polling/api_callback.go` | 职责转移到 PollingQueueManager |

### 数据库迁移

| 表 | 变更 |
|----|------|
| `tb_device` | 新增 `enable_polling BOOLEAN NOT NULL DEFAULT TRUE` |

### 管理接口兼容性（零改动）

| 模块 | 接口数 | 兼容性 |
|------|--------|--------|
| 轮询配置管理（polling-configs） | 7个 | ✅ 完全兼容 |
| 手动触发（polling-manual-trigger） | 6个 | ✅ 完全兼容 |
| 告警管理（polling-alert-rules） | 6个 | ✅ 完全兼容 |
| 监控统计（polling-stats） | 4个 | ⚠️ 需适配分片队列（聚合 ZCard） |
| 并发控制（polling-concurrency） | 4个 | ✅ 完全兼容 |
| 数据清理（data-cleanup） | 9个 | ✅ 完全兼容 |
| **合计** | **36个** | **32个零改动 + 4个监控接口需适配** |
