## 任务清单（openspec 跟踪用）

- [x] 1.1 创建 DB 迁移文件（tb_device 新增 enable_polling 字段）
- [x] 1.2 model/device.go 新增 EnablePolling 字段
- [x] 1.3 pkg/constants/iot.go 新增停机原因常量
- [x] 1.4 pkg/constants/polling.go 新增轮询系统常量
- [x] 1.5 pkg/constants/redis.go 新增分片队列 Key 函数和设备锁 Key 函数
- [x] 1.6 执行迁移并验证（DB 操作）
- [x] 1.7 新增 "suspended" 轮询配置（DB 操作）
- [x] 1.8 为现有活跃配置添加 protect_check_interval（DB 操作）
- [x] 1.9 清理 real_name 存量配置条件（DB 操作）
- [x] 2.0 前置评估：not_realname 停机迁移影响（门控任务）
- [x] 2.1 新增 hasValidPackage 方法（修复设备套餐 Bug）
- [x] 2.2 新增 isTrafficExhausted 方法
- [x] 2.3 新增 isRealnameOK 方法
- [x] 2.4 新增 checkStopReasons 方法
- [x] 2.5 新增 EvaluateAndAct 统一入口方法
- [x] 2.6 修改 stopCardWithRetry 接收 stopReason 参数
- [x] 2.7 修改 resumeSingleCard：复机条件使用新逻辑
- [x] 2.8 修改 stopDeviceCards 和 resumeDeviceCards：加设备维度幂等锁
- [x] 2.9 定义 StopResumeServiceInterface 接口
- [x] 2.10 删除旧 hasAvailablePackage 方法
- [x] 2.11 PostgreSQL MCP 验证设备套餐停复机正确性
- [x] 3.1 新建 queue_manager.go（统一 Redis 队列操作）
- [x] 3.2 新建 config_manager.go（配置管理）
- [x] 4.1 新建 polling_realname_handler.go
- [x] 4.2 新建 polling_carddata_handler.go
- [x] 4.3 新建 polling_package_handler.go
- [x] 4.4 新建 polling_protect_handler.go
- [x] 4.5 消除所有直接 DB 操作（13处）
- [x] 4.6 提取 PollingBase 共享基类
- [x] 4.7 更新 pkg/queue/handler.go 注册 4 个新 Handler
 - [x] 4.8 删除旧 polling_handler.go（Phase 4+5 原子部署完成）
 - [x] 4.9 验证 4 种轮询任务均能正常触发和执行
 - [x] 5.1 新建 initializer.go（分片渐进式初始化）
 - [x] 5.2 精简 scheduler.go（目标 < 250行，实际 227行）
 - [x] 5.3 删除 callbacks.go
 - [x] 5.4 删除 api_callback.go
 - [x] 5.5 新建 lifecycle_service.go（替代 callbacks.go）
 - [x] 5.6 更新 MonitoringService 适配分片队列
 - [x] 5.7 更新 cmd/worker/main.go 启动流程（接入新轮询组件）
 - [x] 5.8 更新 PollingCallback 注入点（bootstrap/services.go）
- [x] 6.1 新增 DTO（UpdateAssetPollingStatusRequest/Response）
 - [x] 6.2 新增 Store 方法（DeviceStore.UpdatePollingStatus）
 - [x] 6.3 新增 Service 方法（AssetPollingService）
 - [x] 6.4 新增 Handler 方法（AssetHandler.UpdatePollingStatus）
 - [x] 6.5 注册路由
 - [x] 6.6 更新文档生成器
- [x] 7.1 DB 验证：设备套餐停复机正确性
 - [x] 7.2 DB 验证：非行业卡未实名有套餐触发停机
 - [x] 7.3 DB 验证：行业卡未实名不停机
 - [x] 7.4 Redis 验证：protect 队列 Bug 修复
 - [x] 7.5 接口验证：enable_polling 管控接口
 - [x] 7.6 代码行数验证
 - [x] 7.7 Redis 原子性验证
 - [x] 7.8 兼容性验证：36 个已有接口零改动
 - [x] 7.9 not_realname 停机迁移影响评估（已前移至 2.0）

---

## Phase 1：基础准备（DB 迁移 + 常量 + Redis Key）

### 1.1 创建 DB 迁移文件（tb_device 新增 enable_polling 字段）

**文件**：`migrations/YYYYMMDDHHMMSS_add_enable_polling_to_device.sql`

**内容**：
```sql
-- 上行迁移
ALTER TABLE tb_device ADD COLUMN IF NOT EXISTS enable_polling BOOLEAN NOT NULL DEFAULT TRUE;
COMMENT ON COLUMN tb_device.enable_polling IS '是否参与轮询，false 时不加入轮询队列';

-- 下行迁移（回滚）
-- ALTER TABLE tb_device DROP COLUMN IF EXISTS enable_polling;
```

**验证条件**：
- `\d tb_device` 输出中包含 `enable_polling boolean not null default true`
- 存量数据全部为 `TRUE`（`SELECT COUNT(*) FROM tb_device WHERE enable_polling IS NULL` = 0）

---

### 1.2 model/device.go 新增 EnablePolling 字段

**文件**：`internal/model/device.go`

**变更**：在 `Device` 结构体中新增字段，完整 GORM 标签：
```go
// EnablePolling 是否参与轮询，false 时不加入轮询队列
EnablePolling bool `json:"enable_polling" gorm:"column:enable_polling;not null;default:true"`
```

**验证条件**：
- 字段标签与数据库列名一致（`enable_polling`）
- 有中文注释说明用途
- `not null` 和 `default:true` 标签齐全

---

### 1.3 pkg/constants/iot.go 新增停机原因常量

**文件**：`pkg/constants/iot.go`

**新增常量**（在现有 `StopReasonTrafficExhausted` 等常量附近）：
```go
// StopReasonNoPackage 停机原因：无有效套餐
StopReasonNoPackage = "no_package"

// StopReasonNotRealname 停机原因：非行业卡且未完成实名认证
StopReasonNotRealname = "not_realname"
```

**验证条件**：
- 两个常量均有中文注释
- 与现有 `StopReason*` 常量在同一分组下
- 常量值全小写下划线格式

---

### 1.4 pkg/constants/polling.go 新增轮询系统常量

**文件**：`pkg/constants/polling.go`（若不存在则新建，否则追加）

**新增常量**：

```go
// PollingShardCount 轮询队列默认分片数
// 千万级规模下，16 分片可将单队列深度控制在 ~60 万以内
const PollingShardCount = 16

// PollingBackpressureThreshold 单分片队列背压阈值
// 超过此深度时，Scheduler 跳过该分片本轮出队，防止 Asynq 积压过载
// 默认 50 万，可根据 Asynq Worker 处理速度调整
const PollingBackpressureThreshold = 500_000

// PollingDequeueMaxBatchSize Lua 脚本单次出队上限
// 受 Lua unpack() 的 LUAI_MAXCSTACK 约 8000 限制，保守取 7000
const PollingDequeueMaxBatchSize = 7000
```

**验证条件**：
- 三个常量均有中文注释说明含义和取值依据
- `PollingShardCount`、`PollingBackpressureThreshold` 在 `PollingQueueManager` 和 `Scheduler` 中引用（而非硬编码 magic number）
- `go build ./...` 编译通过

---

### 1.5 pkg/constants/redis.go 新增分片队列 Key 函数和设备锁 Key 函数

**文件**：`pkg/constants/redis.go`

**新增函数**：
```go
// RedisPollingShardQueueKey 轮询分片队列 Key
// shardID: 分片编号（0 到 N-1）
// taskType: 任务类型（realname/carddata/package/protect）
// 格式：polling:shard:{shardID}:queue:{taskType}
func RedisPollingShardQueueKey(shardID int, taskType string) string {
    return fmt.Sprintf("polling:shard:%d:queue:%s", shardID, taskType)
}

// RedisPollingDeviceOpLockKey 设备维度停复机操作锁 Key
// 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 调用
// TTL 建议 30 秒（覆盖 stopDeviceCards/resumeDeviceCards 最长执行时间）
// 格式：polling:device:op_lock:{deviceID}
func RedisPollingDeviceOpLockKey(deviceID uint) string {
    return fmt.Sprintf("polling:device:op_lock:%d", deviceID)
}
```

**验证条件**：
- 两个函数均有中文 doc 注释，说明参数含义和 Key 格式
- 通过单元验证：`RedisPollingShardQueueKey(3, "carddata")` = `"polling:shard:3:queue:carddata"`
- `RedisPollingDeviceOpLockKey(10)` = `"polling:device:op_lock:10"`

---

### 1.6 执行迁移并验证

**操作**：
1. 运行迁移命令（参考 `db-migration` Skill 中的迁移规范）
2. 用 PostgreSQL MCP 验证列已创建

**验证 SQL（PostgreSQL MCP 执行）**：
```sql
-- 验证列存在
SELECT column_name, data_type, column_default, is_nullable
FROM information_schema.columns
WHERE table_name = 'tb_device' AND column_name = 'enable_polling';

-- 验证存量数据
SELECT COUNT(*) as total,
       COUNT(*) FILTER (WHERE enable_polling = true) as enabled,
       COUNT(*) FILTER (WHERE enable_polling = false) as disabled
FROM tb_device;
```

**通过条件**：查询返回 `enable_polling | boolean | true | NO`，存量数据 `enabled = total`。

---

### 1.7 新增 "suspended" 轮询配置（M1 修复：停机卡持续轮询）

**背景**：`getCardCondition` 修复后（决策 14），停机卡返回 `"suspended"` 条件。若数据库中无对应配置，停机卡将无法被入队，也就无法自动复机。

**操作**：执行以下 SQL，新增"停机卡轮询"配置：

```sql
-- 停机卡轮询配置：每小时检查一次实名/流量/套餐，以便检测复机条件
-- priority=25 介于 'not_real_name'(10) 和 'real_name'(20) 之间
-- ⚠️ realname_check_interval 必须设置：
--   停机且未实名的卡（因 not_realname 停机）需要 realname 检查，
--   实名完成后 realname Handler 触发 EvaluateAndAct → 自动复机；
--   若 realname_check_interval=NULL，此类卡永远不会被实名检查，无法复机！
INSERT INTO tb_polling_config (
    config_name, card_condition, card_category, carrier_id,
    priority, realname_check_interval, carddata_check_interval, package_check_interval, protect_check_interval,
    status, description, created_at, updated_at
) VALUES (
    '停机卡轮询', 'suspended', NULL, NULL,
    25, 3600, 3600, 3600, NULL,
    1, '停机卡每小时检查实名/流量/套餐，满足条件时自动复机', NOW(), NOW()
)
ON CONFLICT DO NOTHING;
```

**验证（PostgreSQL MCP）**：
```sql
SELECT id, config_name, card_condition,
       realname_check_interval, carddata_check_interval, package_check_interval, status
FROM tb_polling_config
WHERE card_condition = 'suspended';
-- 预期返回1行，realname_check_interval=3600，carddata_check_interval=3600，status=1
```

**通过条件**：查询返回停机卡配置，且 `realname_check_interval IS NOT NULL`（停机未实名卡必须能被实名检查，否则无法复机）。

---

### 1.8 为现有活跃配置添加 protect_check_interval（M2 修复）

**背景**：`protect_check_interval` 字段由迁移 000102 添加，但现有所有配置均为 NULL，导致 protect 任务从未入队，`polling_protect_handler.go` 无法生效。

**操作**：为 "已激活在线卡" 和 "默认轮询配置" 添加 protect 检查间隔（与 carddata 保持一致）：

```sql
-- 为已激活卡添加保护期检查（每小时）
UPDATE tb_polling_config
SET protect_check_interval = 3600, updated_at = NOW()
WHERE card_condition = 'activated' AND protect_check_interval IS NULL;

-- 为停机卡配置也添加（新增配置天然包含，此处保险更新）
UPDATE tb_polling_config
SET protect_check_interval = 3600, updated_at = NOW()
WHERE card_condition = 'suspended' AND protect_check_interval IS NULL;
```

**验证（PostgreSQL MCP）**：
```sql
SELECT config_name, card_condition, protect_check_interval
FROM tb_polling_config
WHERE status = 1
ORDER BY priority;
-- 预期：至少 activated 和 suspended 的 protect_check_interval 非 NULL
```

**通过条件**：至少存在一条 `protect_check_interval IS NOT NULL` 的启用配置。

---

### 1.9 清理 `real_name` 存量配置条件（getCardCondition 兼容性）

**背景**：决策 14 修复后，`getCardCondition` 不再返回 `"real_name"`，只返回 `not_real_name / activated / suspended`。  
若数据库中存在 `card_condition='real_name'` 的配置，这些配置将**永远无法匹配**任何卡，但也不会报错——原本依赖此配置轮询的卡会悄悄退出轮询队列。

**操作**：

**第一步：评估影响范围（执行 PostgreSQL MCP）**

```sql
-- 查看现有 real_name 配置
SELECT id, config_name, card_condition, priority, status,
       realname_check_interval, carddata_check_interval, package_check_interval
FROM tb_polling_config
WHERE card_condition = 'real_name'
ORDER BY priority;
```

**决策逻辑**：
- 若返回 0 行：无需操作，继续下一任务。
- 若返回 ≥ 1 行：分析其轮询间隔含义——
  - 若这些配置是"已实名在线卡"的配置 → 语义等同于 `activated`，执行下面的更新 SQL
  - 若有其他特殊含义 → 与业务方确认后再操作

**第二步：迁移 real_name 配置（若存在）**

```sql
-- 将 card_condition='real_name' 迁移到 'activated'
-- 注意：若 activated 配置已存在，需检查优先级是否冲突
UPDATE tb_polling_config
SET card_condition = 'activated',
    description   = COALESCE(description, '') || '（原 real_name 条件，已迁移至 activated）',
    updated_at    = NOW()
WHERE card_condition = 'real_name';
```

**验证（PostgreSQL MCP）**：

```sql
-- 确认无残留 real_name 配置
SELECT COUNT(*) FROM tb_polling_config WHERE card_condition = 'real_name';
-- 预期：0

-- 确认 activated 配置存在且启用
SELECT id, config_name, card_condition, priority, status
FROM tb_polling_config
WHERE card_condition = 'activated' AND status = 1;
-- 预期：至少 1 行
```

**通过条件**：`card_condition='real_name'` 的行数为 0。

---

## Phase 2：StopResumeService 重写（停复机逻辑统一）

> ⚠️ **Phase 2 前置门控（必须在 2.1 之前完成）**：Phase 2 新增 `not_realname` 停机条件，部署后所有"在线+未实名+普通卡"将在下一轮询周期被批量停机。必须先执行影响评估并与业务方确认，再继续后续任务。

### 2.0 前置评估：not_realname 停机迁移影响（门控任务）

> **⚠️ 此任务必须在 Phase 2 实施前完成，结果决定是否继续 Phase 2 或调整策略。**

**评估 SQL（PostgreSQL MCP 执行）**：

```sql
-- 统计"在线 + 未实名 + 普通卡"（部署后将被停机的卡）
SELECT
    COUNT(*) AS total_at_risk,
    COUNT(DISTINCT carrier_id) AS affected_carriers
FROM tb_iot_card
WHERE network_status = 1          -- 当前在线
  AND real_name_status = 0        -- 未实名
  AND (card_category IS NULL OR card_category != 'industry')  -- 非行业卡
  AND deleted_at IS NULL;

-- 按运营商分布查看，判断是否适合分批上线
SELECT carrier_id, COUNT(*) AS count
FROM tb_iot_card
WHERE network_status = 1
  AND real_name_status = 0
  AND (card_category IS NULL OR card_category != 'industry')
  AND deleted_at IS NULL
GROUP BY carrier_id
ORDER BY count DESC;
```

**评估结果（2026-04-03）**：
- `total_at_risk = 1`，仅 1 张卡受影响（carrier_id=1566）
- 影响极小，满足 `< 100` 门控条件，**可直接继续 Phase 2**

**决策门控**：
- 若 `total_at_risk = 0`：可直接继续 Phase 2，无迁移影响
- 若 `total_at_risk < 100`：与业务方确认后可直接继续，记录受影响卡列表
- 若 `total_at_risk ≥ 100`：**必须**与业务方讨论，可选方案：
  a. 灰度上线：先仅对特定 `carrier_id` 生效
  b. 宽限期：新增配置字段"not_realname 停机启用日期"，未到日期不执行条件 C
  c. 直接接受：通知受影响用户，批量停机作为业务策略
- **评估结果及业务方决策必须在此任务中记录，才能标记任务完成**

---

### 2.1 新增 hasValidPackage 方法（修复设备套餐 Bug）

**文件**：`internal/service/iot_card/stop_resume_service.go`

**方法签名**：
```go
// hasValidPackage 检查卡是否有有效套餐（status IN 0,1）
// 修复Bug：绑定设备的卡查 device_id，独立卡查 iot_card_id
func (s *Service) hasValidPackage(ctx context.Context, card *model.IotCard) (bool, error)
```

**实现要点**：
- 若 `card.DeviceID != nil`：查 `tb_package_usage WHERE device_id=? AND status IN(0,1)`
- 否则：查 `tb_package_usage WHERE iot_card_id=? AND status IN(0,1)`
- status=0（待激活）和 status=1（激活中）均视为有效

**验证条件（PostgreSQL MCP）**：
```sql
-- 场景1：为设备购买套餐后，hasValidPackage 应返回 true
SELECT COUNT(*) FROM tb_package_usage
WHERE device_id = (SELECT device_id FROM tb_iot_card WHERE id = :cardID)
  AND status IN (0, 1);
-- 预期结果 > 0

-- 场景2：仅有 iot_card_id 套餐的独立卡
SELECT COUNT(*) FROM tb_package_usage
WHERE iot_card_id = :cardID AND status IN (0, 1);
```

---

### 2.2 新增 isTrafficExhausted 方法

**文件**：`internal/service/iot_card/stop_resume_service.go`

**方法签名**：
```go
// isTrafficExhausted 检查卡的流量是否已耗尽
// 条件：活跃套餐 status=2，或 data_usage_mb >= data_limit_mb（data_limit_mb > 0）
func (s *Service) isTrafficExhausted(ctx context.Context, card *model.IotCard) (bool, error)
```

**实现要点**：
- 查询活跃套餐（status=1），同样区分 device_id 和 iot_card_id
- `status=2` 直接返回 true
- `data_limit_mb > 0 && data_usage_mb >= data_limit_mb` 返回 true
- `data_limit_mb == 0`（无限流量）不判断用量，返回 false
- 无活跃套餐时返回 false

**验证条件（PostgreSQL MCP）**：
```sql
-- 验证流量耗尽场景
SELECT id, data_usage_mb, data_limit_mb, status
FROM tb_package_usage
WHERE iot_card_id = :cardID AND status IN(1,2)
ORDER BY created_at DESC LIMIT 1;
-- status=2 或 data_usage_mb >= data_limit_mb 时应停机
```

---

### 2.3 新增 isRealnameOK 方法

**文件**：`internal/service/iot_card/stop_resume_service.go`

**方法签名**：
```go
// isRealnameOK 检查卡是否满足实名要求
// 行业卡（card_category='industry'）无需实名；其他卡需 real_name_status=1
func (s *Service) isRealnameOK(card *model.IotCard) bool
```

**实现要点**：
- `card.CardCategory == "industry"` 返回 true（行业卡豁免）
- `card.RealNameStatus == 1` 返回 true
- 其他情况返回 false

**验证条件（PostgreSQL MCP）**：
```sql
-- 验证行业卡数据
SELECT id, card_category, real_name_status
FROM tb_iot_card
WHERE card_category = 'industry' AND real_name_status = 0
LIMIT 5;
-- 这些卡不应被停机（行业卡豁免）
```

---

### 2.4 新增 checkStopReasons 方法

**文件**：`internal/service/iot_card/stop_resume_service.go`

**方法签名**：
```go
// checkStopReasons 检查卡的停机原因列表，按优先级排序
// 优先级：no_package > traffic_exhausted > not_realname
func (s *Service) checkStopReasons(ctx context.Context, card *model.IotCard) ([]string, error)
```

**实现要点**：
- 依次检查三个条件，满足则追加到 reasons 切片
- 已按优先级顺序检查（第一个即为最高优先级）
- 返回空切片表示无停机原因

**验证条件**：
- 同时满足三个条件时，返回 `["no_package", "traffic_exhausted", "not_realname"]`
- 仅满足 traffic_exhausted 时，返回 `["traffic_exhausted"]`

---

### 2.5 新增 EvaluateAndAct 统一入口方法

**文件**：`internal/service/iot_card/stop_resume_service.go`

**方法签名（Mi2 修复：删除冗余的 carrierType/carrierID 参数）**：
```go
// EvaluateAndAct 停复机统一入口
// 根据卡的当前网络状态，自动判断并执行停机或复机操作
// 设备/单卡维度通过 card.DeviceID 推导（非 nil 则为设备维度，覆盖设备下所有卡）
func (s *Service) EvaluateAndAct(ctx context.Context, card *model.IotCard) error
```

**实现要点**：
- 在线（`network_status=1`）：调 `checkStopReasons`，有原因则停机（优先级最高的原因）
- 停机（`network_status=0`）：检查 `stop_reason IN(...)` + `shouldResume`，满足则复机
- 设备维度：`card.DeviceID != nil` 时调 `stopDeviceCards`/`resumeDeviceCards`
- 其他网络状态：直接返回 nil（不处理）
- 日志中通过 `zap.Uint("card_id", card.ID)` 和 `zap.Any("device_id", card.DeviceID)` 记录足够的上下文，无需外部传入 carrierType/carrierID

**验证条件（PostgreSQL MCP）**：
```sql
-- 验证停机后字段更新
SELECT id, network_status, stop_reason
FROM tb_iot_card
WHERE id = :cardID;
-- 停机后 network_status=0, stop_reason 为对应原因

-- 验证复机后字段清除
SELECT id, network_status, stop_reason
FROM tb_iot_card
WHERE id = :cardID;
-- 复机后 network_status=1, stop_reason=''
```

---

### 2.6 修改 stopCardWithRetry 接收 stopReason 参数

**文件**：`internal/service/iot_card/stop_resume_service.go`

**变更**：
- 方法签名增加 `stopReason string` 参数
- 停机成功后将 `stop_reason` 写入 DB（通过 Store 方法）

**验证条件（PostgreSQL MCP）**：
```sql
-- 验证停机原因写入
SELECT id, stop_reason, network_status, updated_at
FROM tb_iot_card
WHERE id = :cardID;
-- stop_reason 应为 'no_package' 或 'traffic_exhausted' 或 'not_realname'
```

---

### 2.7 修改 resumeSingleCard：复机条件使用新逻辑

**文件**：`internal/service/iot_card/stop_resume_service.go`

**变更**：
- 复机前检查 `stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')`
- 调用 `shouldResume(ctx, card)` = `hasValidPackage && !isTrafficExhausted && isRealnameOK`
- 复机成功后清空 `stop_reason`（写入 `''`）

**验证条件（PostgreSQL MCP）**：
```sql
-- 验证 stop_reason='manual' 的卡不被自动复机
SELECT COUNT(*) FROM tb_iot_card
WHERE stop_reason = 'manual' AND network_status = 1;
-- 预期 = 0（手动停机不应被自动复机）
```

---

### 2.8 修改 stopDeviceCards 和 resumeDeviceCards：加设备维度幂等锁

**文件**：`internal/service/iot_card/stop_resume_service.go`

**背景**：设备下多张卡可能同时被轮询系统触发 `EvaluateAndAct`（不同分片同一调度周期），导致多次并发调用 `stopDeviceCards` / `resumeDeviceCards`，同一张卡被重复 Gateway 停/复机。

**变更（stopDeviceCards）**：
- 进入函数后，先通过 `Redis SetNX` 获取设备操作锁（`constants.RedisPollingDeviceOpLockKey(deviceID)`，TTL 30 秒）
- 获取失败（其他协程正在处理同一设备）：记录 Debug 日志「设备停机操作已在进行中，跳过: deviceID=xxx」，直接返回 nil
- 获取成功：执行停机逻辑，`defer` 释放锁

**变更（resumeDeviceCards）**：
- 同上，使用同一把锁（`RedisPollingDeviceOpLockKey`）
- 同时：遍历设备下停机卡时，对每张卡调用 `isRealnameOK(card)`
- 不满足（普通卡未实名）的卡：更新 `stop_reason='not_realname'`，跳过复机
- 遍历中单张卡失败不中断整体（记录 Error 日志，继续处理其他卡）

```go
// stopDeviceCards 停机设备下所有在线卡（含幂等锁）
func (s *Service) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) {
    lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
    locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
    if !locked {
        s.logger.Debug("设备停机操作已在进行中，跳过重复调用", zap.Uint("device_id", deviceID))
        return
    }
    defer s.redis.Del(ctx, lockKey)
    // ... 遍历设备下在线卡，逐一调 stopCardWithRetry
}
```

**验证条件（PostgreSQL MCP）**：
```sql
-- 验证设备下停机卡状态
SELECT id, card_category, real_name_status, network_status, stop_reason
FROM tb_iot_card
WHERE device_id = :deviceID AND network_status = 0;
-- 行业卡或已实名卡应已复机 (network_status=1)
-- 未实名普通卡应保持停机 (stop_reason='not_realname')
```

**验证条件（PostgreSQL MCP）**：
```sql
-- 验证设备下停机卡状态
SELECT id, card_category, real_name_status, network_status, stop_reason
FROM tb_iot_card
WHERE device_id = :deviceID AND network_status = 0;
-- 行业卡或已实名卡应已复机 (network_status=1)
-- 未实名普通卡应保持停机 (stop_reason='not_realname')
```

---

### 2.9 定义 StopResumeServiceInterface 接口（Mi3）

**文件**：`internal/service/iot_card/stop_resume_service.go`（或同包下新建 `interfaces.go`）

**背景**：Phase 4 的 4 个 Task Handler 通过接口注入 `StopResumeService`，需要事先定义接口，否则 Phase 4 的构造函数无法编译。

**新增接口**：
```go
// StopResumeServiceInterface 停复机服务接口
// 供 4 个 Task Handler 通过接口注入，避免循环依赖
type StopResumeServiceInterface interface {
    // EvaluateAndAct 停复机统一入口，根据卡的当前状态自动判断并执行停机或复机
    EvaluateAndAct(ctx context.Context, card *model.IotCard) error
}
```

**验证条件**：
- `internal/service/iot_card/Service` 实现了 `StopResumeServiceInterface`（编译时验证：`var _ StopResumeServiceInterface = (*Service)(nil)`）
- `go build ./...` 编译通过

---

### 2.10 删除旧 hasAvailablePackage 方法

**文件**：`internal/service/iot_card/stop_resume_service.go`

**变更**：删除原 `hasAvailablePackage` 函数（仅查 `iot_card_id`，存在 Bug）。

**验证条件**：
- 文件中不再出现 `hasAvailablePackage` 函数定义
- 所有原来调用 `hasAvailablePackage` 的地方已改为调用 `hasValidPackage`
- 编译通过，无未使用函数

---

### 2.11 PostgreSQL MCP 验证设备套餐停复机正确性

**验证脚本**（使用 PostgreSQL MCP 工具）：

```sql
-- 验证1：购买设备套餐的卡不被误停机
-- 选取一张绑定了设备且有设备套餐的在线卡
SELECT c.id, c.device_id, c.network_status, pu.device_id as pkg_device_id, pu.status
FROM tb_iot_card c
JOIN tb_package_usage pu ON pu.device_id = c.device_id
WHERE c.device_id IS NOT NULL
  AND pu.status IN (0,1)
  AND c.network_status = 1
LIMIT 5;
-- 以上卡在重构后不应被停机

-- 验证2：行业卡有套餐未实名不被停机
SELECT id, card_category, real_name_status, network_status
FROM tb_iot_card
WHERE card_category = 'industry'
  AND real_name_status = 0
  AND network_status = 1
LIMIT 5;
-- 以上卡不应有 stop_reason='not_realname'
```

---

## Phase 3：PollingQueueManager + PollingConfigManager（基础组件）

### 3.1 新建 queue_manager.go（统一 Redis 队列操作）

**文件**：`internal/polling/queue_manager.go`（< 200行）

**实现要点**：
- `DequeueReady`：使用 Lua 脚本原子执行 ZRANGEBYSCORE + ZREM（保留时间过滤语义，只取 score ≤ now 的到期卡）：
  ```go
  var dequeueScript = redis.NewScript(`
      local results = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
      -- 分批 ZREM：Lua unpack() 受 LUAI_MAXCSTACK 限制（约 8000），按 7000 分批避免溢出
      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
  `)
  ```
- `Requeue`：使用 `redis.ZAdd(ctx, key, redis.Z{Score: float64(nextCheckAt.Unix()), Member: cardID})` 
- `RemoveFromAllQueues`：遍历所有分片（0 到 shardCount-1）× 4 个任务类型，执行 ZREM
- `EnqueueManual`：使用 `redis.RPush(ctx, manualKey, cardID)` 推入 List
- `OnCardDeleted`：调 `RemoveFromAllQueues` + 清理卡信息缓存（`redis.Del(ctx, cacheKey)`）
- `GetQueueDepth`：使用 `redis.ZCard(ctx, shardKey).Result()`

**Key 生成**：统一调用 `constants.RedisPollingShardQueueKey(shardID, taskType)`

**验证条件**：
- 文件行数 < 200 行
- `DequeueReady` 使用 Lua 脚本（`redis.NewScript`）且采用**分批 ZREM**，**不使用 ZPOPMIN**
- `RemoveFromAllQueues` 覆盖 4 个任务类型（含 protect）
- `go build ./...` 编译通过

---

### 3.2 新建 config_manager.go（配置管理）

**文件**：`internal/polling/config_manager.go`（< 150行）

**从 scheduler.go 提取**：`loadConfigs`、`syncConfigsToRedis`、`matchConfig`、`matchConfigConditions`、`getCardCondition`

**新增**：
- 内存缓存（`sync.RWMutex` 保护的 `[]PollingConfig` 切片）
- `Start(ctx)` 方法：goroutine 每 5 分钟调用 `Load(ctx)` 刷新
- `Load(ctx)` 加载失败时保留原缓存不清空

**⚠️ 必须修复 getCardCondition（M1 修复）**：  
原 `getCardCondition` 从不返回 `"suspended"`，导致停机卡匹配到 carddata/package 间隔为 null 的配置，无法自动复机。  
新实现**最先**判断网络状态：

```go
// getCardCondition 获取卡的状态条件（用于匹配轮询配置）
// ⚠️ 注意判断顺序：停机优先，避免停机卡错误匹配 activated 或 real_name 配置
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"
}
```

**验证条件**：
- 文件行数 < 150 行
- `scheduler.go` 中不再出现 `loadConfigs`、`syncConfigsToRedis` 字样
- `getCardCondition` 对 `NetworkStatus=0` 的卡返回 `"suspended"`（编写单函数验证或日志观察）
- `go build ./...` 编译通过

---

## Phase 4：Task Handler 拆分（4个专注 Handler）

### 4.1 新建 polling_realname_handler.go（< 200行）

**文件**：`internal/task/polling_realname_handler.go`

**从 polling_handler.go 提取**：`HandleRealnameCheck` 相关逻辑

**构造函数**：
```go
// NewPollingRealnameHandler 创建实名检查任务处理器
func NewPollingRealnameHandler(
    base        *PollingBase,
    gateway     GatewayClient,
    iotCardStore IotCardStore,
    queueClient QueueClient,
) *PollingRealnameHandler
```

**方法列表**：`Handle(ctx, task)`, `processCard(ctx, cardID)`

**关键要求**：
- `triggerFirstRealnameActivation` 保留（首次实名激活任务入队）
- **⚠️ S1 修复**：当 Gateway 返回实名状态由 0→1 时，在入队激活任务后，**必须调用** `stopResumeService.EvaluateAndAct(ctx, card)`  
  理由：卡可能因 `not_realname` 处于停机状态，实名完成后应立即触发复机，不能等下一次 carddata/package 轮询（可能长达 1 小时）  
  调用时机：`UpdateRealNameStatus` Store 写入成功后，从 Store 重新加载卡信息（确保状态最新），再调 EvaluateAndAct
- 文件中**不出现**无条件的 `stopCard`、`resumeCard`（仅允许通过 EvaluateAndAct 委托）
- 所有 DB 操作通过 `iotCardStore` 方法（无 `h.db.Model()`）

**验证条件**：
- 文件行数 < 200 行
- 文件中包含 `EvaluateAndAct` 字样（搜索应有匹配，且在 `real_name_status 0→1` 的条件分支内）
- `go build ./...` 编译通过

---

### 4.2 新建 polling_carddata_handler.go（< 300行）

**文件**：`internal/task/polling_carddata_handler.go`

**从 polling_handler.go 提取**：`HandleCarddataCheck` 相关逻辑

**构造函数**：
```go
// NewPollingCarddataHandler 创建流量检查任务处理器
func NewPollingCarddataHandler(
    base             *PollingBase,
    gateway          GatewayClient,
    iotCardStore     IotCardStore,
    packageStore     PackageUsageStore,
    usageService     UsageService,
    stopResumeService StopResumeServiceInterface,
) *PollingCarddataHandler
```

**方法列表**：`Handle(ctx, task)`, `processCard(ctx, cardID)`, `collectUsageData(ctx, card)`

**关键要求**：
- 删除原有 `checkStopResume`、`shouldStopCard`、`stopCardByUsageExhausted`、`resumeCardByPackageAvailable` 函数
- 改为调用 `stopResumeService.EvaluateAndAct(ctx, card)`（Mi2：无 carrierType/carrierID 参数）
- 删除全部 13 处 `h.db.Model()` 直接 DB 操作
- **⚠️ 必须完整迁移跨月流量边界检测逻辑**（详见决策 13）：
  - 比较 Gateway 返回的月度总流量与 `card.CurrentMonthStartDate` 判断是否跨月
  - 跨月：保存 `LastMonthTotalMB`、重置 `CurrentMonthUsageMB`、更新 `CurrentMonthStartDate`
  - 同月：计算增量 delta = Gateway值 - `LastMonthTotalMB`
  - 记录流量历史到 `data_usage_records` 表

**验证条件**：
- 文件行数 < 300 行
- 文件中不出现 `h.db.`、`.db.Model()`、`shouldStop`、`hasAvailable`
- 文件中包含跨月检测逻辑（搜索 `CurrentMonthStartDate` 应有匹配）
- `go build ./...` 编译通过

---

### 4.3 新建 polling_package_handler.go（< 200行）

**文件**：`internal/task/polling_package_handler.go`

**从 polling_handler.go 提取**：`HandlePackageCheck` 相关逻辑

**构造函数**：
```go
// NewPollingPackageHandler 创建套餐检查任务处理器
func NewPollingPackageHandler(
    base             *PollingBase,
    gateway          GatewayClient,
    packageStore     PackageUsageStore,
    stopResumeService StopResumeServiceInterface,
) *PollingPackageHandler
```

**方法列表**：`Handle(ctx, task)`, `processCard(ctx, cardID)`

**关键要求**：
- 删除原有直接 Gateway 停复机调用（无重试），改为 `EvaluateAndAct`
- 所有 DB 操作通过 `packageStore` 方法

**验证条件**：
- 文件行数 < 200 行
- `go build ./...` 编译通过

---

### 4.4 新建 polling_protect_handler.go（< 200行）

**文件**：`internal/task/polling_protect_handler.go`

**从 polling_handler.go 提取**：`HandleProtectConsistencyCheck` 相关逻辑

**构造函数**：
```go
// NewPollingProtectHandler 创建保护期一致性检查任务处理器
func NewPollingProtectHandler(
    base             *PollingBase,
    gateway          GatewayClient,     // ⚠️ 保护期内需要直接调 Gateway 强制停复机
    iotCardStore     IotCardStore,
    stopResumeService StopResumeServiceInterface,
) *PollingProtectHandler
```

**方法列表**：`Handle(ctx, task)`, `processCard(ctx, cardID)`

**处理流程（恢复原始保护期一致性语义）**：

```
processCard(ctx, cardID):
  1. 查 Store 获取卡信息
  2. 检查前置跳过条件：
     - 卡未实名（real_name_status=0）→ 直接 requeue，跳过（未实名卡不参与保护期逻辑）
     - 卡未绑定设备（is_standalone=true）→ 直接 requeue，跳过
  3. 读取设备保护期 Redis Key：
     - stop 保护期 Key（polling:protect:stop:{deviceID}）
     - start 保护期 Key（polling:protect:start:{deviceID}）
  4. 根据保护期状态执行一致性修正：
     a. 有 stop 保护期 + 卡在线（network_status=1）
        → 直接调 gateway.StopCard（不经过 EvaluateAndAct，强制修正）
        → 更新 DB：network_status=0，stop_reason='protect'
     b. 有 start 保护期 + 卡停机（network_status=0）
        → 直接调 gateway.StartCard（不经过 EvaluateAndAct，强制修正）
        → 更新 DB：network_status=1，stop_reason=''
     c. 无保护期 Key（保护期已结束）
        → 调 stopResumeService.EvaluateAndAct(ctx, card)
        → 重新评估当前停复机状态（套餐/流量/实名）
     d. 有保护期 + 状态已一致 → 跳过（不调 Gateway，不调 EvaluateAndAct）
  5. 调 base.requeueCard 按间隔重新入队
```

**关键要求**：
- **步骤 4a/4b 必须直接调 Gateway**（强制一致性，绕过三条件判断——保护期期间无论是否有套餐/流量都强制执行）
- **步骤 4c 调 EvaluateAndAct**（保护期结束后按正常停复机逻辑重新评估）
- **两种调用路径不可混淆**：保护期内=强制修正；保护期结束=重新评估

**验证条件**：
- 文件行数 < 200 行
- 文件中同时包含 `gateway.StopCard`（或等价停机调用）和 `EvaluateAndAct`（两种路径）
- `go build ./...` 编译通过

---

### 4.5 消除所有直接 DB 操作（13处）

**文件**：所有新建的 4 个 Handler 文件

**变更**：将原 `polling_handler.go` 中的 `h.db.Model(&model.IotCard{}).Where(...).Update(...)` 等直接 GORM 操作，替换为对应的 Store 方法：
- `iotCardStore.UpdateRealNameStatus(ctx, cardID, status)`
- `iotCardStore.UpdateNetworkStatus(ctx, cardID, status, stopReason)`
- `packageStore.UpdateUsage(ctx, packageID, usageMB)`
- 等（按实际 Store 接口定义）

**验证条件**：
- 在 4 个新 Handler 文件中 grep `\.db\.`，结果为空
- `go vet ./...` 通过

---

### 4.6 提取 PollingBase 共享基类

**文件**：`internal/task/polling_base.go`（< 150行）

**提取的公共方法**（所有 4 个 Handler 均需要）：
- `acquireConcurrency(taskType string) bool`
- `releaseConcurrency(taskType string)`
- `getCardWithCache(ctx, cardID uint) (*model.IotCard, error)`
- `updateCardCache(ctx, card *model.IotCard)`
- `requeueCard(ctx, cardID uint, taskType string, interval time.Duration) error`
- `getMatchedPollingInterval(card *model.IotCard) time.Duration`

**构造函数**：
```go
// NewPollingBase 创建轮询共享基类
func NewPollingBase(
    redis        *redis.Client,
    queueMgr     *PollingQueueManager,
    configMgr    *PollingConfigManager,
) *PollingBase
```

**⚠️ acquireConcurrency 并发满时必须 requeue（关键正确性约束）**：

Lua 脚本原子出队后，卡已从 Redis Sorted Set 中删除。若并发满时直接返回而不 requeue，该卡将永久消失（不再被轮询）。

**正确用法**：每个 Handler 在 `acquireConcurrency` 返回 false 时，必须先调 `requeueCard` 再返回：

```go
// ✅ 正确：并发满时先 requeue，再返回
if !base.acquireConcurrency(taskType) {
    base.logger.Debug("并发数已满，已重新入队", zap.Uint("card_id", cardID))
    return base.requeueCard(ctx, cardID, taskType, time.Now()) // 立即重入队，不延迟
}

// ❌ 错误：直接返回，卡从队列消失
if !base.acquireConcurrency(taskType) {
    base.logger.Debug("并发数已满，跳过本次执行", zap.Uint("card_id", cardID))
    return nil // 卡已被 Lua 原子删除，此处 return 后卡永久丢失！
}
```

**验证条件**：
- 4 个 Handler 文件中不出现重复的 `acquireConcurrency`、`getCardWithCache` 等函数定义
- 4 个 Handler 中 `acquireConcurrency` 返回 false 的分支**必须包含 `requeueCard` 调用**（grep 验证）
- `polling_base.go` 行数 < 150 行

---

### 4.7 更新 pkg/queue/handler.go 注册 4 个新 Handler

**文件**：`pkg/queue/handler.go`（或 Asynq Worker 注册入口）

**变更**：
- 注册 `polling_realname_handler`：`mux.HandleFunc(constants.TaskTypeRealnameCheck, realnameHandler.Handle)`
- 注册 `polling_carddata_handler`：`mux.HandleFunc(constants.TaskTypeCarddataCheck, carddataHandler.Handle)`
- 注册 `polling_package_handler`：`mux.HandleFunc(constants.TaskTypePackageCheck, packageHandler.Handle)`
- 注册 `polling_protect_handler`：`mux.HandleFunc(constants.TaskTypeProtectCheck, protectHandler.Handle)`

**验证条件**：
- 4 种任务类型常量不变（向后兼容）
- Asynq 任务提交保持 `MaxRetry(0)`（不可改为 MaxRetry(3)，详见决策 12）
- `go build ./cmd/worker/...` 编译通过

---

### 4.8 删除旧 polling_handler.go

**文件**：`internal/task/polling_handler.go`

**前提**：4.1 ～ 4.7 全部完成且验证通过后执行。

**验证条件**：
- 文件不存在
- `go build ./...` 编译通过（无遗留引用）

---

### 4.9 验证 4 种轮询任务均能正常触发和执行

**验证方式**：Redis CLI + 日志观察

```bash
# 手动将测试卡推入各队列，验证任务被处理
redis-cli ZADD polling:shard:0:queue:realname $(date +%s) "testCardID"
redis-cli ZADD polling:shard:0:queue:carddata $(date +%s) "testCardID"
redis-cli ZADD polling:shard:0:queue:package $(date +%s) "testCardID"
redis-cli ZADD polling:shard:0:queue:protect $(date +%s) "testCardID"

# 观察 Worker 日志，应出现 4 种任务的处理日志
# 验证 Lua 脚本原子出队后队列中不再有该卡
redis-cli ZRANK polling:shard:0:queue:realname "testCardID"
# 预期返回 nil（已被 Lua 脚本原子移除）
```

---

## Phase 5：Scheduler 精简 + CardInitializer + LifecycleService + 删除旧文件

> ⚠️ **Phase 4 和 Phase 5 必须原子部署**（同一次上线）。新 Handler 写分片键，新 Scheduler 读分片键，拆开部署会导致卡永久丢失轮询。详见 design.md 决策 8。

### 5.1 新建 initializer.go（分片渐进式初始化）

**文件**：`internal/polling/initializer.go`（< 250行）

**从 scheduler.go 提取**：`progressiveInit`、`initCardsBatch`、`initCardPolling`

**变更**：
- 入队改为调用 `PollingQueueManager.Requeue()`（分片路由）
- 新增 `enable_polling=false` 过滤（跳过不参与轮询的卡）
- 新增 `GetProgress()` 方法暴露进度

**新增 DB 查询**（Store 方法）：
- `store.ListIotCardsForPolling(ctx, offset, limit)` — 返回 `enable_polling=true` 的卡

**验证条件**：
- 文件行数 < 250 行
- `scheduler.go` 中不再出现 `progressiveInit`、`initCardsBatch` 字样
- 初始化时 `enable_polling=false` 的卡不写入 Redis 队列（Redis CLI 验证）
- `go build ./...` 编译通过

---

### 5.2 精简 scheduler.go（目标 < 250行）

**文件**：`internal/polling/scheduler.go`

**保留**：
- `Start(ctx)`：启动初始化器、配置管理器、调度循环
- `scheduleLoop(ctx)`：每秒 ticker，并行消费 N 个分片
- `processShardSchedule(ctx, shardID)`：背压检测 + 手动队列 + 定时队列出队
- `enqueueBatch(ctx, entries, taskType)`：批量推入 Asynq（**`MaxRetry(0)` 不变**）
- **⚠️ 必须保留**（决策 9）：
  - 每 10 秒调用 `packageActivationHandler.HandlePackageActivationCheck(ctx)`
  - 每 10 秒调用 `dataResetHandler.HandleDataReset(ctx)`

**删除**（已迁移到其他模块）：
- 所有 `db.Find()`、`db.Where()` 等 DB 查询
- `loadConfigs`、`syncConfigsToRedis`（→ ConfigManager）
- `progressiveInit`、`initCardsBatch`、`initCardPolling`（→ Initializer）
- 所有 Callback 相关代码（→ LifecycleService + QueueManager）
- 任何直接的 `ZRANGEBYSCORE`、`ZREMRANGEBYSCORE` 操作

**验证条件**：
- 文件行数 < 250 行（含注释，因保留套餐过期/流量重置触发）
- `grep "\.db\." scheduler.go` 结果为空
- `grep "loadConfigs\|progressiveInit" scheduler.go` 结果为空
- `grep "HandlePackageActivationCheck\|HandleDataReset" scheduler.go` 结果非空（确认保留）
- `go build ./...` 编译通过

---

### 5.3 删除 callbacks.go

**文件**：`internal/polling/callbacks.go`

**前提**：所有调用 callbacks.go 中方法的地方已改为调用 `PollingQueueManager`。

**验证条件**：
- 文件不存在
- `go build ./...` 编译通过（无遗留引用）

---

### 5.4 删除 api_callback.go

**文件**：`internal/polling/api_callback.go`

**前提**：所有调用 api_callback.go 中方法的地方已改为调用 `PollingQueueManager`。

**验证条件**：
- 文件不存在
- `go build ./...` 编译通过（无遗留引用）

---

### 5.5 新建 lifecycle_service.go（替代 callbacks.go 生命周期方法）

**文件**：`internal/polling/lifecycle_service.go`（< 200行）

**从 callbacks.go 提取**：`OnCardCreated`、`OnBatchCardsCreated`、`OnCardStatusChanged`、`OnCardEnabled`、`OnCardDisabled`、`OnCardDeleted`、`LazyLoad`

**新组件**：
```go
// PollingLifecycleService 卡生命周期轮询管理
// 实现 PollingCallback 接口，替代 callbacks.go 和 api_callback.go
type PollingLifecycleService struct {
    queueMgr   *PollingQueueManager
    configMgr  *PollingConfigManager
    cardStore  IotCardStore
    logger     *zap.Logger
}
```

**关键要求**：
- 实现 `PollingCallback` 接口（确保 `iot_card/service.go` 无需改动注入类型）
- **⚠️ M3 修复**：`OnCardCreated`/`OnCardEnabled`/`OnCardStatusChanged` 在调用 `queueMgr.Requeue()` 前，必须检查卡所属设备的 `enable_polling` 状态：  
  - 若卡绑定设备（`card.DeviceID != nil`），查询 `device.EnablePolling`，若为 `false` 则跳过入队（仅记录 Debug 日志）  
  - 若卡无绑定设备，检查 `card.EnablePolling`，若为 `false` 则跳过入队  
  - 目的：防止"禁用轮询"设置因生命周期事件被绕过，导致禁用后重新入队
- `OnCardCreated`/`OnCardEnabled`：检查 enable_polling → 通过则 `configMgr.MatchConfig(card)` → `queueMgr.Requeue()` 分片入队
- `OnCardStatusChanged`：`queueMgr.RemoveFromAllQueues()` → 检查 enable_polling → 通过则重新匹配配置 → `queueMgr.Requeue()`
- `OnCardDeleted`：`queueMgr.OnCardDeleted()` 移除所有队列 + 清理缓存（删除不需要检查 enable_polling）
- `OnCardDisabled`：`queueMgr.RemoveFromAllQueues()`（禁用，不需要检查 enable_polling）
- `OnBatchCardsCreated`：批量调用 `OnCardCreated`

**验证条件**：
- 文件行数 < 200 行
- 实现了 `PollingCallback` 接口的所有方法
- `go build ./...` 编译通过
- grep `PollingCallback` 在 `iot_card/service.go` 中的注入类型未变
- 设备 enable_polling=false 后，触发 OnCardStatusChanged 不会将卡重新入队（Redis CLI 验证）

---

### 5.6 更新 MonitoringService 适配分片队列

**文件**：`internal/service/polling/monitoring_service.go`

**变更**：
- 注入 `PollingQueueManager`（新增依赖）
- `GetOverview()` 中队列深度查询改为 `queueMgr.GetTotalQueueDepth(ctx, taskType)`
- `GetQueueStatuses()` 中队列指标改为聚合所有分片
- 删除直接 `s.redis.ZCard(ctx, constants.RedisPollingQueueXxxKey())` 调用

**PollingQueueManager 新增方法**（Phase 3 的 queue_manager.go 补充）：
```go
// GetTotalQueueDepth 获取指定任务类型的总队列深度（聚合所有分片）
func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error)
```

**验证条件**：
- `grep "RedisPollingQueueRealnameKey\|RedisPollingQueueCarddataKey\|RedisPollingQueuePackageKey" monitoring_service.go` 结果为空（不再直接读旧 Key）
- 调用监控 API 返回的队列深度值与 `redis-cli` 手动聚合所有分片 `ZCARD` 之和一致
- `go build ./...` 编译通过

---

### 5.7 更新 cmd/worker/main.go 和 cmd/api/main.go 启动流程

**文件**：`cmd/worker/main.go`、`cmd/api/main.go`

#### Worker 进程（cmd/worker/main.go）

**变更**：按以下顺序初始化新组件：
```go
// 1. 基础组件
queueMgr := polling.NewPollingQueueManager(rdb, shardCount)

// 2. 配置管理器（依赖 DB）
configMgr := polling.NewPollingConfigManager(store, rdb)
configMgr.Load(ctx)  // 启动时同步加载（阻塞，确保配置就绪后再启动 Scheduler）
configMgr.Start(ctx) // 启动 goroutine 每 5 分钟定时刷新

// 3. 初始化器（依赖 DB、QueueMgr、ConfigMgr）
initializer := polling.NewCardInitializer(store, queueMgr, configMgr)

// 4. 卡生命周期服务（替代 callbacks.go，实现 PollingCallback 接口）
lifecycleService := polling.NewPollingLifecycleService(queueMgr, configMgr, iotCardStore, logger)

// 5. 共享基类（供 4 个 Handler 使用）
pollingBase := task.NewPollingBase(rdb, queueMgr, configMgr)

// 6. 4 个 Task Handler
realnameHandler := task.NewPollingRealnameHandler(pollingBase, gateway, iotCardStore, queueClient)
carddataHandler := task.NewPollingCarddataHandler(pollingBase, gateway, iotCardStore, packageStore, usageService, stopResumeService)
packageHandler  := task.NewPollingPackageHandler(pollingBase, gateway, packageStore, stopResumeService)
protectHandler  := task.NewPollingProtectHandler(pollingBase, gateway, iotCardStore, stopResumeService)

// 7. Scheduler（依赖所有上层组件，⚠️ 保留套餐过期/流量重置触发器）
scheduler := polling.NewScheduler(queueMgr, configMgr, initializer, asynqClient,
    packageActivationHandler, dataResetHandler)
scheduler.Start(ctx)
```

#### API 进程（cmd/api/main.go）

**背景**：API 进程处理卡创建、删除、状态变更等事件，需要 `PollingLifecycleService`（依赖 `PollingConfigManager`）来处理卡生命周期的队列操作。

**变更**：在 API 进程的 bootstrap 中同样初始化 ConfigManager 和 LifecycleService：

```go
// API 进程也需要配置管理器（LifecycleService.OnCardCreated 需要 MatchConfig）
apiConfigMgr := polling.NewPollingConfigManager(store, rdb)
apiConfigMgr.Load(ctx) // 启动时同步加载一次（API 进程不需要 Scheduler，但需要当前配置）
apiConfigMgr.Start(ctx) // 启动 goroutine 定时刷新，确保新建卡时使用最新配置

// API 进程也需要 QueueManager（处理卡删除、禁用等事件）
apiQueueMgr := polling.NewPollingQueueManager(rdb, shardCount)

// API 进程的 LifecycleService（实现 PollingCallback 接口）
apiLifecycleService := polling.NewPollingLifecycleService(apiQueueMgr, apiConfigMgr, iotCardStore, logger)
```

> **注意**：API 进程的 `ConfigManager` 必须调用 `Start(ctx)` 定时刷新，否则管理员在后台修改轮询配置后，API 进程的 `MatchConfig` 会使用过期配置，导致新建卡匹配到错误的轮询间隔。

**验证条件**：
- `go build ./cmd/worker/...` 编译通过
- `go build ./cmd/api/...` 编译通过
- Worker 进程启动日志出现「配置管理器已启动」「开始渐进式初始化」
- API 进程启动日志出现「配置管理器已启动」（来自 apiConfigMgr）
- `lifecycleService` 和 `apiLifecycleService` 分别注入到对应进程的 `iot_card/service.go` 作为 `PollingCallback` 实现

---

### 5.8 更新所有 PollingCallback 注入点

**文件**：所有注入 `PollingCallback` 接口的地方

**变更**：将注入的 `PollingCallback` **实现**从旧 `callbacks.go` 的实例改为新 `PollingLifecycleService` 实例。**注意：`PollingCallback` 接口本身不变**，因为 `PollingLifecycleService` 实现了该接口。

需要更新的注入点：
- `cmd/worker/main.go`：注入 `lifecycleService` 到 `iot_card/service.go`
- `cmd/api/main.go`：注入 `lifecycleService` 到 API 进程的 `iot_card/service.go`（API 进程也需要处理卡删除等事件）
- `bootstrap/services.go`：如有 bootstrap 层的注入逻辑，同步更新

**⚠️ 不要变更的**：
- `StopResumeCallback` 接口和注入点（与 `PollingCallback` 是不同接口，不在本次范围）
- `PollingCallback` 接口定义（`PollingLifecycleService` 实现它）

**验证条件**：
- `grep -r "callbacks\.New\|NewPollingCallbacks\|NewAPICallback" internal/` 结果为空（旧实现不再被引用）
- `grep -r "PollingCallback" internal/` 仍有结果（接口定义和注入类型保留）
- `go build ./...` 编译通过

---

## Phase 6：轮询管控 API（enable_polling 接口）

### 6.1 新增 DTO

**文件**：`internal/model/dto/asset.go`（或新建 `internal/model/dto/polling_control.go`）

**新增**：
```go
// UpdateAssetPollingStatusRequest 更新资产轮询状态请求
type UpdateAssetPollingStatusRequest struct {
    // EnablePolling 是否启用轮询
    EnablePolling bool `json:"enable_polling" description:"是否启用轮询，false 时卡不加入轮询队列"`
}

// UpdateAssetPollingStatusResponse 更新资产轮询状态响应
type UpdateAssetPollingStatusResponse struct {
    // AssetType 资产类型（card/device）
    AssetType string `json:"asset_type"`
    // AssetID 资产ID
    AssetID   uint   `json:"asset_id"`
    // EnablePolling 更新后的轮询状态
    EnablePolling bool `json:"enable_polling"`
}
```

**验证条件**：
- 两个 DTO 均有 description 标签（符合 dto-standards Skill 规范）
- `go build ./...` 编译通过

---

### 6.2 新增 Store 方法

**文件**：`internal/store/postgres/device_store.go`

**新增方法**：
```go
// UpdatePollingStatus 更新设备的轮询启用状态
// deviceID: 设备ID
// enablePolling: 是否启用轮询
func (s *DeviceStore) UpdatePollingStatus(ctx context.Context, deviceID uint, enablePolling bool) error
```

**验证条件（PostgreSQL MCP）**：
```sql
-- 验证接口调用后 DB 更新
SELECT id, enable_polling FROM tb_device WHERE id = :deviceID;
-- 应反映接口传入的 enable_polling 值
```

---

### 6.3 新增 Service 方法（遵循 Handler → Service → Store 分层）

**文件**：`internal/service/polling/asset_polling_service.go`（新建）

**方法签名（S2 修复：card 类型委托给 IotCardService）**：
```go
// AssetPollingService 资产轮询管控服务
// 注意：card 类型委托给已有的 IotCardService.UpdatePollingStatus，避免绕过 DB 更新和 callback
type AssetPollingService struct {
    deviceStore    DeviceStoreInterface
    iotCardService IotCardServiceInterface  // 复用卡轮询状态更新（含 DB 写入 + callback）
    pollingQueueMgr *PollingQueueManager
    logger         *zap.Logger
}

// UpdatePollingStatus 更新资产轮询状态
// assetType: "card" 或 "device"
// assetID: 资产ID
// enablePolling: 是否启用轮询
func (s *AssetPollingService) UpdatePollingStatus(ctx context.Context, assetType string, assetID uint, enablePolling bool) error
```

**实现要点**：
- `assetType` 校验：不在 `[card, device]` 时返回 `errors.New(errors.CodeInvalidParam)`
- **card 类型**（S2 修复）：
  - 调用 `iotCardService.UpdatePollingStatus(ctx, assetID, enablePolling)`  
  - 该方法已有完整实现（DB 更新 `tb_iot_card.enable_polling` + pollingCallback 通知），**不要重复实现**
- **device 类型**：
  - 调用 `deviceStore.UpdatePollingStatus(ctx, assetID, enablePolling)` 更新 `tb_device.enable_polling`
  - 若 `enablePolling=false`：查询设备下所有卡，逐一调用 `pollingQueueMgr.RemoveFromAllQueues(ctx, cardID)`
  - 若 `enablePolling=true`：LifecycleService 的 `OnCardEnabled` 负责重新入队（通过 iotCardService 触发 callback）

**验证条件**：
- `card` 类型调用后，`tb_iot_card.enable_polling` 已更新（PostgreSQL MCP 验证）
- `device` 类型调用后，`tb_device.enable_polling` 已更新
- Service 不依赖 Fiber 上下文（纯业务逻辑）
- `go build ./...` 编译通过

---

### 6.4 新增 Handler 方法

**文件**：`internal/handler/admin/asset.go`

**新增方法**：
```go
// UpdatePollingStatus 更新资产轮询状态
// PATCH /api/admin/assets/:asset_type/:id/polling-status
func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error
```

**实现要点**：
- 解析路由参数 `asset_type` 和 `id`
- 解析请求体 `UpdateAssetPollingStatusRequest`
- **委托 `assetPollingService.UpdatePollingStatus()`**（Handler 不直接调用 Store 或 QueueManager）
- 返回 `UpdateAssetPollingStatusResponse`

**验证条件**：
- Handler 中不出现 `store.`、`queueMgr.` 等直接调用（全部通过 Service）
- 参数校验失败返回 `errors.New(errors.CodeInvalidParam)`，不泄露内部细节

---

### 6.5 注册路由

**文件**：`internal/routes/asset.go`（或相关路由文件）

**新增路由**：
```go
assetGroup.Patch("/:asset_type/:id/polling-status", assetHandler.UpdatePollingStatus)
// 完整路径：PATCH /api/admin/assets/:asset_type/:id/polling-status
```

**验证条件**：
- 路由注册到正确的路由组（需认证的 admin 路由）
- `go build ./cmd/api/...` 编译通过

---

### 6.6 更新文档生成器

**文件**：`cmd/api/docs.go` 和 `cmd/gendocs/main.go`

**变更**（参考 api-routing Skill 规范）：
```go
// 在 Handlers 初始化中新增
handlers := &bootstrap.Handlers{
    // ... 现有 handlers
    AssetHandler: admin.NewAssetHandler(nil), // 新增 UpdatePollingStatus 方法
}
```

**验证条件**：
- `go run cmd/gendocs/main.go` 运行通过
- 生成的 OpenAPI 文档中包含 `PATCH /api/admin/assets/{asset_type}/{id}/polling-status` 接口

---

## Phase 7：全面验证

### 7.1 DB 验证：设备套餐停复机正确性（PostgreSQL MCP）

```sql
-- 找一张绑定设备且有设备套餐的卡
SELECT c.id AS card_id, c.device_id, c.network_status, c.stop_reason,
       pu.id AS pkg_id, pu.device_id AS pkg_device_id, pu.status AS pkg_status
FROM tb_iot_card c
JOIN tb_package_usage pu ON pu.device_id = c.device_id
WHERE c.device_id IS NOT NULL
  AND pu.status IN (0,1)
  AND c.network_status = 1
LIMIT 5;
-- 预期：这些卡 stop_reason 不为 'no_package'（修复前的误停机）
```

**通过条件**：查询返回行，所有卡均未被误停机（`stop_reason IS NULL OR stop_reason = ''`）

---

### 7.2 DB 验证：非行业卡未实名有套餐触发停机（PostgreSQL MCP）

```sql
-- 验证未实名普通卡被正确停机
SELECT id, card_category, real_name_status, network_status, stop_reason
FROM tb_iot_card
WHERE card_category != 'industry'
  AND real_name_status = 0
  AND network_status = 0
  AND stop_reason = 'not_realname'
LIMIT 10;
-- 预期：存在此类记录（说明新逻辑生效）
```

---

### 7.3 DB 验证：行业卡未实名不停机（PostgreSQL MCP）

```sql
-- 验证行业卡不被未实名停机
SELECT COUNT(*) FROM tb_iot_card
WHERE card_category = 'industry'
  AND real_name_status = 0
  AND stop_reason = 'not_realname';
-- 预期：COUNT = 0（行业卡不应有此停机原因）
```

---

### 7.4 Redis 验证：protect 队列 Bug 修复

**操作步骤**：
1. 选取一张测试卡 ID（如 testCardID=999）
2. 手动向 4 个队列写入：
   ```bash
   redis-cli ZADD polling:shard:7:queue:realname 9999999999 "999"
   redis-cli ZADD polling:shard:7:queue:carddata 9999999999 "999"
   redis-cli ZADD polling:shard:7:queue:package 9999999999 "999"
   redis-cli ZADD polling:shard:7:queue:protect 9999999999 "999"
   ```
3. 触发 `OnCardDeleted(ctx, 999)` 或调用删除卡接口
4. 验证：
   ```bash
   redis-cli ZRANK polling:shard:7:queue:protect "999"
   # 预期返回 nil（已从 protect 队列移除，修复前这里会返回一个数字）
   ```

---

### 7.5 接口验证：enable_polling 管控接口

**步骤**：
1. 调用 `PATCH /api/admin/assets/device/1/polling-status` Body: `{"enable_polling": false}`
2. 验证 DB：`SELECT enable_polling FROM tb_device WHERE id=1` 应为 false
3. 验证 Redis：设备下所有卡从分片队列移除
4. 调用 `PATCH /api/admin/assets/device/1/polling-status` Body: `{"enable_polling": true}`
5. 验证 DB：`enable_polling` 恢复为 true

---

### 7.6 代码行数验证（合规性）

```bash
# 验证各文件行数
wc -l internal/polling/scheduler.go        # 应 < 200
wc -l internal/polling/queue_manager.go    # 应 < 200
wc -l internal/polling/config_manager.go   # 应 < 150
wc -l internal/polling/initializer.go      # 应 < 250
wc -l internal/task/polling_base.go        # 应 < 150
wc -l internal/task/polling_realname_handler.go  # 应 < 200
wc -l internal/task/polling_carddata_handler.go  # 应 < 300
wc -l internal/task/polling_package_handler.go   # 应 < 200
wc -l internal/task/polling_protect_handler.go   # 应 < 200（含 Gateway 强制停复机逻辑）

# 验证旧文件已删除
ls internal/task/polling_handler.go        # 应不存在
ls internal/polling/callbacks.go           # 应不存在
ls internal/polling/api_callback.go        # 应不存在
```

---

### 7.7 Redis 原子性验证（Lua 脚本出队）

```bash
# 准备测试数据：向分片0的carddata队列写入10张卡（score=当前时间，即立即到期）
for i in $(seq 1 10); do
    redis-cli ZADD polling:shard:0:queue:carddata $(date +%s) "test_card_$i"
done

# 同时写入5张未到期卡（score=未来时间戳）
for i in $(seq 11 15); do
    redis-cli ZADD polling:shard:0:queue:carddata 9999999999 "test_card_$i"
done

# 验证 Lua 脚本只取到期卡，不触碰未来项
# Lua 脚本等效命令（实际由 PollingQueueManager 封装调用）：
redis-cli EVAL "local r=redis.call('ZRANGEBYSCORE',KEYS[1],'-inf',ARGV[1],'LIMIT',0,tonumber(ARGV[2])) if #r>0 then redis.call('ZREM',KEYS[1],unpack(r)) end return r" 1 polling:shard:0:queue:carddata $(date +%s) 20

# 预期返回：10张到期卡（test_card_1 ~ test_card_10）

redis-cli ZCARD polling:shard:0:queue:carddata
# 预期：5（只剩5张未到期卡，到期卡已被原子移除）

# 验证未到期卡未被触碰
redis-cli ZRANK polling:shard:0:queue:carddata "test_card_11"
# 预期：非 nil（仍在队列中，未被错误取出）

# 验证到期卡已被移除
redis-cli ZRANK polling:shard:0:queue:carddata "test_card_1"
# 预期：nil（已被 Lua 脚本原子移除）
```

---

### 7.8 兼容性验证：36 个已有接口零改动

**验证方式**：使用现有接口的 Hurl 测试文件或 Postman Collection，逐一调用：
- 轮询配置管理（7个）：增删改查配置
- 手动触发（6个）：手动触发各类轮询任务
- 告警管理（6个）：告警规则 CRUD
- 监控统计（4个）：队列深度、处理统计
- 并发控制（4个）：并发数调整
- 数据清理（9个）：清理各类历史数据

**通过条件**：所有接口返回 HTTP 200，业务逻辑与重构前一致。

---

### 7.9 not_realname 停机迁移影响评估（已前移至 Phase 2 门控）

> 此任务已前移至 **Phase 2 任务 2.0**，作为 Phase 2 的强制前置门控。  
> 请参见 `### 2.0 前置评估：not_realname 停机迁移影响（门控任务）`，完成后方可继续 Phase 2 后续任务。
