feat: 轮询系统重构(分片队列 + 停复机统一 + Handler 拆分)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s

【核心变更】

1. 停复机逻辑统一(StopResumeService)
   - 新增 EvaluateAndAct 统一入口,封装三条件停复机判断
   - 停机条件:无套餐(no_package) / 流量耗尽(traffic_exhausted) / 未实名(not_realname)
   - 复机条件:stop_reason 合规 + 有套餐且未耗尽 + 已实名或行业卡
   - 修复设备套餐 Bug:hasValidPackage 按 device_id 查套餐,而非仅 iot_card_id
   - 设备维度停复机加幂等锁(Redis SetNX,TTL 30s),防止多卡并发重复调 Gateway

2. Redis 分片队列(PollingQueueManager)
   - 新建 queue_manager.go,封装所有轮询 Redis 操作
   - 16 分片 Sorted Set,Key 格式:polling:shard:{shardID}:queue:{taskType}
   - Lua 脚本原子出队(ZRANGEBYSCORE + 分批 ZREM),消除竞态窗口
   - 新增背压检测:队列深度超 50 万时 Scheduler 跳过该分片
   - RemoveFromAllQueues 覆盖 4 种任务类型(含 protect)

3. Handler 拆分(polling_handler.go 1360行 → 5个专注文件)
   - polling_base.go:共享基类(并发控制/卡缓存/重入队)
   - polling_realname_handler.go:实名采集,实名 0→1 时立即触发复机
   - polling_carddata_handler.go:流量采集,保留跨月边界检测逻辑
   - polling_package_handler.go:套餐采集,委托 EvaluateAndAct 决策
   - polling_protect_handler.go:保护期一致性检查,保护期内强制修正

4. 配置管理(PollingConfigManager)
   - 新建 config_manager.go,从 scheduler.go 提取配置职责
   - 内存缓存 + 5 分钟定时刷新,刷新失败保留原缓存
   - 修复 getCardCondition:停机卡返回 suspended,不再错配 activated 配置

5. 渐进式初始化(CardInitializer)
   - 新建 initializer.go,分批加载(每批 10 万),批次间 sleep 500ms
   - 过滤 enable_polling=false 的卡,初始化完成前 Scheduler 不出队

6. 卡生命周期服务(PollingLifecycleService)
   - 新建 lifecycle_service.go,替代已删除的 callbacks.go 和 api_callback.go
   - OnCardCreated/OnCardEnabled/OnCardStatusChanged 入队前检查 enable_polling

7. Scheduler 精简(1000+行 → 227行)
   - 保留纯调度循环:scheduleLoop + processShardSchedule + enqueueBatch
   - 保留每 10 秒触发套餐过期检测和流量重置
   - 移除所有 DB 操作、配置加载、卡初始化逻辑

8. 轮询管控 API(enable_polling)
   - 新增 PUT /api/admin/assets/:id/polling-status 接口
   - 支持对设备/卡维度开关轮询,关闭后从所有分片队列移除

9. 数据库迁移
   - 000103:tb_device 新增 enable_polling 字段(boolean, NOT NULL, DEFAULT true)
   - 000104:新增 suspended 轮询配置,为 activated 配置补全 protect_check_interval

【文件统计】
- 新增:19 个文件(handler × 5、polling 组件 × 4、迁移 × 3 等)
- 修改:20 个文件(bootstrap 注入、store 接口、monitoring 适配分片等)
- 删除:3 个文件(polling_handler.go、callbacks.go、api_callback.go)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-07 12:27:04 +08:00
parent 10fcc0b3c9
commit 434a8b0349
62 changed files with 7496 additions and 3023 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-02

View File

@@ -0,0 +1,837 @@
## 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_idBug1
```
**四个线上 Bug**
```
Bug 1设备套餐误停机
PackageUsage.device_id 存设备套餐,
但 hasAvailablePackage 只查 iot_card_id → 购买设备套餐后仍被误停机
Bug 2停机条件不完整
仅检查「无套餐」,未检查「流量耗尽」「非行业卡未实名」
Bug 3protect 队列遗漏
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 队列操作
│ │ 职责DequeueReadyLua脚本原子出队
│ │ RequeueZADD重入队
│ │ 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
修复:设备套餐查询 Bugdevice_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次重试
- 修复原子性 BugLua 脚本原子出队替代 ZRANGEBYSCORE+ZREMRANGEBYSCORE
- 修复 protect 队列遗漏 BugRemoveFromAllQueues 覆盖4个队列
- 修复设备套餐查询 Bugdevice_id vs iot_card_id
- 实现三条件停机判断(无套餐/流量耗尽/非行业卡未实名)
- 支持千万级规模(分片 Sorted Set16分片默认背压机制
- 新增 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 命名规范**
```
分片队列 Keypolling:shard:{shardID}:queue:{taskType}
shardID0 到 N-1默认 N=16
taskTyperealname | carddata | package | protect
示例polling:shard:3:queue:carddata
手动触发 KeyListpolling: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() 并行写入各分片。
---
### 决策 2Lua 脚本原子出队替代 ZRANGEBYSCORE+ZREMRANGEBYSCORE
**问题**:当前两步操作存在两层风险:
```
风险1重复处理
Worker AZRANGEBYSCORE → 得到 [card1, card2]
Worker BZRANGEBYSCORE → 得到 [card1, card2](未被移除)
结果card1 和 card2 被重复入队 Asynq触发重复停复机 Gateway 调用
风险2卡丢失更严重
ZRANGEBYSCORE 有 LIMIT如 50000ZREMRANGEBYSCORE 无 LIMIT
当到期卡数 > 50000 时ZRANGEBYSCORE 只读前 50000 张
ZREMRANGEBYSCORE 删除全部到期卡(含未读取的后 50000 张)
结果:超出批次的卡永久丢失,不再被轮询
```
**解决方案**:使用 Lua 脚本在 Redis 服务端原子执行 ZRANGEBYSCORE + ZREM
```lua
-- polling_dequeue.lua
-- KEYS[1]: 分片队列 Key
-- ARGV[1]: 当前时间戳score 上限,只取到期卡)
-- ARGV[2]: 批次大小LIMITGo 层已限制 ≤ 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.1Redis 内置版本)中受 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 为 []CardEntryRedis 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 秒间隔的调度循环,此差异无实际影响
---
### 决策 3StopResumeService.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)
```
---
### 决策 4Task 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()` 的语句
---
### 决策 5PollingQueueManager 接口设计
```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 时间戳(到期时间)
}
```
---
### 决策 6Scheduler 精简设计(伪代码)
```
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)
```
---
### 决策 7CardInitializer 分片初始化伪代码
```
CardInitializer:
依赖StoreDB查询、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:
// ZADDscore = 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}
```
### 决策 8Phase 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`
---
### 决策 9Scheduler 保留套餐过期检测和流量重置触发
**问题**:当前 `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 行,因保留两个触发器)。
---
### 决策 10PollingLifecycleService 替代 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` 无需感知替换。
---
### 决策 11MonitoringService 适配分片队列
**问题**`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 出队的并发冲突
---
### 决策 13carddata 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 2StopResumeService 重写(停复机逻辑统一)
**变更内容**
- 新增 `hasValidPackage`(修复设备套餐 Bug1
- 新增 `isTrafficExhausted``isRealnameOK``checkStopReasons`
- 新增 `EvaluateAndAct` 统一入口
- 修改 `stopCardWithRetry` 接收 stopReason 参数
- 修改 `resumeSingleCard``resumeDeviceCards` 按新复机条件
- 删除旧 `hasAvailablePackage`
**可部署状态**:✅ StopResumeService 兼容旧调用,新方法独立,可安全部署
**回滚方案**:删除新增方法(`EvaluateAndAct``hasValidPackage` 等),恢复旧 `hasAvailablePackage`。新方法在 Phase 3 之前无调用者,回滚不影响现有功能。
### Phase 3PollingQueueManager + 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 2StopResumeService+ Phase 3QueueManager、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轮询管控 APIenable_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-Ashard 3→ EvaluateAndAct → stopDeviceCards(deviceID=10) ← 同时
card-Bshard 7→ EvaluateAndAct → stopDeviceCards(deviceID=10) ← 同时
card-Cshard 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` 无需感知替换。

View File

@@ -0,0 +1,141 @@
## 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 次重试,同一业务,不同可靠性。
**问题 3Scheduler764行身兼六职**
调度循环 + 配置管理 + 卡初始化 + 生命周期回调 + 套餐激活触发 + 流量重置触发。`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 4Task Handler 拆分)和 Phase 5Scheduler 精简)必须**原子部署**,不可分开上线(详见迁移计划)
## 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 HashTTL 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个监控接口需适配** |

View File

@@ -0,0 +1,118 @@
## ADDED Requirements
### Requirement: 独立配置管理模块PollingConfigManager
新建 `internal/polling/config_manager.go`,提供 `PollingConfigManager` 类型,从 `scheduler.go` 中拆分配置相关职责。
**文件目标**< 150 行职责DB 加载 → 内存缓存 → Redis 同步 → 定时刷新。
**从 Scheduler 提取的方法**
- `loadConfigs(ctx)`:从 DB 加载所有启用的 `tb_polling_config`
- `syncConfigsToRedis(configs)`:写入 Redis HashTTL 24小时
- `MatchConfig(card)`:按优先级顺序返回第一个匹配的配置
- `matchConfigConditions(config, card)`:判断卡是否满足配置条件
- `getCardCondition(card)`获取卡的匹配条件carrier、simtype 等)
**Scheduler 修改后**:不包含任何 DB 查询或配置加载逻辑,通过 `configManager.MatchConfig(card)` 获取配置。
#### Scenario: 启动时加载配置并同步 Redis
- **GIVEN** Worker 进程启动DB 中有 5 条启用的 PollingConfig
- **WHEN** `PollingConfigManager.Load(ctx)` 被调用
- **THEN** 从 DB 加载 5 条配置写入内存缓存sync.RWMutex 保护),同步到 Redis HashTTL 24小时加载完成日志记录「已加载 5 条轮询配置」
#### Scenario: 配置按优先级匹配
- **GIVEN** 内存中有 3 条配置,优先级分别为 1、5、10数字越小优先级越高
- **WHEN** 调用 `MatchConfig(card)` 匹配某张卡
- **THEN** 按优先级升序遍历,返回第一个满足所有条件的配置;无匹配时返回 nil该卡不加入轮询队列
#### Scenario: 配置匹配使用读锁不阻塞
- **GIVEN** Scheduler 高频调用 `MatchConfig(card)`(每秒可能数千次)
- **WHEN** 同时有定时刷新正在写入新配置(写锁)
- **THEN** 读取操作等待写锁释放后继续,不发生数据竞争;写锁持有时间极短(内存赋值),不影响 Scheduler 调度性能
---
### Requirement: 定时刷新配置5分钟周期
`PollingConfigManager.Start(ctx)` 启动后台 goroutine每 5 分钟自动重新加载配置。
#### Scenario: 定时刷新不重启 Worker
- **GIVEN** Worker 进程已运行 10 分钟,管理员在控制台修改了某条 PollingConfig 的轮询间隔
- **WHEN** 距上次加载超过 5 分钟,定时器触发
- **THEN** 自动重新从 DB 加载配置,更新内存缓存,新配置在下一轮调度中生效;无需重启 Worker 进程
#### Scenario: 刷新失败不影响当前配置
- **GIVEN** 定时刷新时 DB 连接超时
- **WHEN** `Load(ctx)` 返回 error
- **THEN** 内存缓存保持原有配置不变(不清空),记录 Error 日志「配置刷新失败: xxx」下一轮5分钟后重试
---
### Requirement: 独立卡初始化模块CardInitializer
新建 `internal/polling/initializer.go`,提供 `CardInitializer` 类型,从 `scheduler.go` 中拆分渐进式初始化职责。
**文件目标**< 250 行,职责:分批 DB 查询 → 分片路由入队 → 进度追踪 → enable_polling 过滤。
**从 Scheduler 提取的方法**
- `progressiveInit(ctx)`:分批加载卡并入队
- `initCardsBatch(ctx, offset, limit)`:加载单批次卡
- `initCardPolling(ctx, card)`:为单张卡匹配配置并入队
**Scheduler 修改后**:不包含任何初始化逻辑,通过 `cardInitializer.Run(ctx)` 异步启动,通过 `cardInitializer.GetProgress()` 查询进度。
#### Scenario: 渐进式分批初始化避免 DB 过载
- **GIVEN** DB 中有 500 万张启用轮询的卡
- **WHEN** `CardInitializer.Run(ctx)` 被调用
- **THEN** 分批加载(每批 10 万张),批次间 Sleep 500ms约 500 批次,每批处理后记录进度日志「初始化进度: 100000/5000000」全部完成后 `initCompleted=true`Scheduler 开始正常出队
#### Scenario: 跳过 enable_polling=false 的卡
- **GIVEN** 10 万张卡中5000 张卡的 `enable_polling=false`
- **WHEN** 批次处理时遇到这 5000 张卡
- **THEN** 这 5000 张卡不加入任何轮询队列ZADD 跳过);其余 95000 张卡按正常流程入队
#### Scenario: 初始化幂等(重启不重复)
- **GIVEN** Worker 重启,部分卡已在分片队列中(上次初始化留下)
- **WHEN** 重新执行 `CardInitializer.Run(ctx)`
- **THEN** ZADD 操作幂等:若卡已在队列中,只更新 score 为新的初始延迟时间不添加重复条目Redis Sorted Set 中不出现重复成员
#### Scenario: Scheduler 只在初始化完成后出队
- **GIVEN** Worker 刚启动,初始化尚未完成(`initCompleted=false`
- **WHEN** Scheduler 的 `scheduleLoop` 执行
- **THEN** 检查 `cardInitializer.GetProgress().Completed`,若为 false 则跳过本轮所有分片的出队;避免初始化中途 Scheduler 取到不完整队列
#### Scenario: 进度暴露给监控接口
- **GIVEN** 管理员调用轮询状态监控接口
- **WHEN** `MonitoringService` 调用 `cardInitializer.GetProgress()`
- **THEN** 返回 `{Total: 5000000, Processed: 1500000, Completed: false}`;前端可展示初始化进度百分比
---
### Requirement: Scheduler 精简到 < 250 行Mi1 修复)
> **注意**:本 spec 原写 `< 200 行`,已与 `design.md` 决策 9 和 `tasks.md` 统一更正为 **`< 250 行`**。
> 原因:精简后的 Scheduler 保留了套餐过期检测和流量重置两个触发器(见决策 9额外占用约 50 行。
`internal/polling/scheduler.go` 精简后只保留纯调度循环逻辑,所有子职责委托给新建的三个模块。
**精简后 Scheduler 的完整职责**
1. 启动 `CardInitializer.Run(ctx)`(异步)
2. 启动 `PollingConfigManager.Start(ctx)`(定时刷新)
3. 运行 `scheduleLoop`:每秒 × N分片 × 4任务类型 的出队循环
4. 背压检测:`GetQueueDepth > 阈值` 时跳过该分片
**禁止出现在精简后 Scheduler 中的内容**
- 任何 `db.Find()``db.Where()` 等 DB 操作
- 配置加载逻辑(`loadConfigs``syncConfigsToRedis`
- 卡初始化逻辑(`progressiveInit``initCardsBatch`
- 任何 Callback 注册(`callbacks.go` 已删除)
#### Scenario: Scheduler 保留套餐过期检测和流量重置触发
- **GIVEN** Phase 4+5 原子部署完成,精简后的 Scheduler 运行中
- **WHEN** 每 10 秒定时器触发
- **THEN** 调用 `PackageActivationHandler.HandlePackageActivationCheck(ctx)` 检查过期套餐;调用 `DataResetHandler.HandleDataReset(ctx)` 检查流量重置;这两个触发器在精简后 Scheduler 中**必须保留**(决策 9
#### Scenario: Scheduler 精简后行数验证
- **GIVEN** Phase 4+5 重构完成
- **WHEN** 统计 `internal/polling/scheduler.go` 的代码行数
- **THEN** 文件行数(含注释)**< 250 行**(因保留套餐过期/流量重置触发器,此处统一以 design.md 决策 9 和 tasks.md 的 <250 为准);文件中不出现 `db.Find``db.Where``loadConfig` 等字样;文件中出现 `HandlePackageActivationCheck``HandleDataReset`

View File

@@ -0,0 +1,173 @@
## ADDED Requirements
### Requirement: 统一 Redis 队列操作封装PollingQueueManager
新建 `internal/polling/queue_manager.go`,提供 `PollingQueueManager` 类型,封装所有轮询相关的 Redis 队列操作。
**文件目标**< 200 行,只依赖 `redis.Client`,无 DB 依赖两个进程API 进程和 Worker 进程)共享同一实现。
以下代码的 Redis 操作均须通过 `PollingQueueManager`
- `internal/polling/callbacks.go`(删除后由此替代)
- `internal/polling/api_callback.go`(删除后由此替代)
- `internal/polling/scheduler.go` 中的所有队列出队/入队操作
- `internal/task/polling_handler.go` 中的 `requeueCard`
**接口定义(方法签名)**
```go
// PollingQueueManager 轮询队列统一管理器
type PollingQueueManager struct {
redis *redis.Client
shardCount int // 默认16
}
// DequeueReady 从指定分片出队到期卡Lua 脚本ZRANGEBYSCORE + ZREM 原子执行)
// 只取 score ≤ now 的到期卡,不触碰未来项
// 返回 []CardEntry包含 CardID从 ZRANGEBYSCORE 结果解析)
func (m *PollingQueueManager) DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error)
// Requeue 将卡重新入队ZADDscore=nextCheckAt Unix时间戳
func (m *PollingQueueManager) Requeue(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) error
// RemoveFromAllQueues 从所有分片的4个队列含protect移除该卡
func (m *PollingQueueManager) RemoveFromAllQueues(ctx context.Context, cardID uint) error
// EnqueueManual 手动触发入队List RPUSH调度器优先消费
func (m *PollingQueueManager) EnqueueManual(ctx context.Context, cardID uint, taskType string) error
// OnCardDeleted 卡删除事件:移除所有队列 + 清理缓存
func (m *PollingQueueManager) OnCardDeleted(ctx context.Context, cardID uint) error
// GetQueueDepth 获取分片队列深度(用于背压检测)
func (m *PollingQueueManager) GetQueueDepth(ctx context.Context, shardID int, taskType string) (int64, error)
// GetTotalQueueDepth 获取指定任务类型的总队列深度(聚合所有分片 ZCard 之和)
// 供 MonitoringService 使用,替代直接读取旧的非分片 Redis Key
func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error)
```
#### Scenario: Lua 脚本原子出队替换竞态操作
- **GIVEN** 调度器从分片队列出队
- **WHEN** Scheduler 调用 `DequeueReady(ctx, shardID=3, taskType="carddata", batchSize=1000)`
- **THEN** Lua 脚本在 Redis 服务端原子执行 `ZRANGEBYSCORE + ZREM`,只取 score ≤ now 的到期卡;不存在原来 ZRANGEBYSCORE + ZREMRANGEBYSCORE 分步操作的竞态窗口和卡丢失风险返回卡ID列表每张卡保证只被取出一次
#### Scenario: 高并发下不重复出队
- **GIVEN** 2个 Scheduler Worker 同时消费同一分片
- **WHEN** 两个 Worker 同时调用 `DequeueReady(ctx, shardID=0, taskType="realname", batchSize=500)`
- **THEN** 两次调用返回的 CardID 集合不相交Lua 脚本在 Redis 单线程中串行执行,保证原子性),不存在同一张卡被两个 Worker 同时处理的情况
#### Scenario: 未到期卡不被提前取出
- **GIVEN** 分片队列中有 100 张到期卡score ≤ now和 50 张未到期卡score > now
- **WHEN** Scheduler 调用 `DequeueReady(ctx, shardID=0, taskType="carddata", batchSize=200)`
- **THEN** 只返回 100 张到期卡50 张未到期卡保持在队列中不受影响;这是 Lua 脚本方案相比 ZPOPMIN 的关键优势ZPOPMIN 会错误取出未到期卡)
---
### Requirement: 分片 Sorted Set 支持千万级规模
`PollingQueueManager` 的存储结构使用分片 Sorted SetKey 格式为 `polling:shard:{shardID}:queue:{taskType}`
**分片路由**:卡入队时按 `cardID % shardCount` 分桶,确保同一张卡的同一任务类型始终在固定分片。
**Key 命名**:通过 `pkg/constants/redis.go``RedisPollingShardQueueKey(shardID int, taskType string) string` 生成。
#### Scenario: 分片路由一致性
- **GIVEN** 默认 16 个分片
- **WHEN** cardID=1000 的卡执行 `Requeue(ctx, 1000, "carddata", nextTime)`
- **THEN** 写入 `polling:shard:8:queue:carddata`1000 % 16 = 8每次入队该卡都写同一个分片
#### Scenario: 背压跳过深度超限的分片
- **GIVEN** 分片 5 的 `carddata` 队列深度达到 600,000超过阈值 500,000
- **WHEN** Scheduler 调用 `GetQueueDepth(ctx, 5, "carddata")`
- **THEN** 返回 600000Scheduler 跳过该分片本轮出队,等待 Asynq 消化积压后恢复
---
### Requirement: 从所有队列移除(修复 protect 队列遗漏 Bug
`RemoveFromAllQueues` 须覆盖 **4 个任务类型**realname、carddata、package、**protect**× N 个分片 = 4N 次 Redis ZREM 操作。
#### Scenario: 删除卡后 protect 队列不残留
- **GIVEN** cardID=500 的卡在 realname/carddata/package/protect 4个队列均有条目
- **WHEN** 调用 `RemoveFromAllQueues(ctx, 500)`
- **THEN** 4 类任务类型 × 16 分片共 64 个 Key 均执行 ZREMRedis CLI 验证 `ZRANK polling:shard:*:queue:protect 500` 均不存在
#### Scenario: 不存在的卡调用无副作用
- **GIVEN** cardID=999 的卡从未入队
- **WHEN** 调用 `RemoveFromAllQueues(ctx, 999)`
- **THEN** 无报错返回,各队列 ZREM 操作幂等(不存在成员 ZREM 返回 0不视为错误
---
### Requirement: 手动触发入队
`EnqueueManual` 使用 List 的 `RPUSH` 将卡 ID 推入手动触发队列Scheduler 在每轮调度中优先消费该队列LPOP
#### Scenario: 手动触发优先于定时队列
- **GIVEN** 某卡定时轮询间隔为 30 分钟,距下次定时触发还有 25 分钟
- **WHEN** 管理员调用手动触发接口 → `EnqueueManual(ctx, cardID, "carddata")`
- **THEN** 卡被推入 `polling:manual:carddata` 列表下一个调度周期1秒内即被 Scheduler 取出推入 Asynq无需等待 25 分钟
#### Scenario: 手动队列不影响分片定时队列
- **GIVEN** cardID=200 的卡在手动队列和定时分片队列均有条目
- **WHEN** Scheduler 先消费手动队列取出 cardID=200推入 Asynq
- **THEN** 分片队列中的条目不受影响仍按原定时间等待Task Handler 执行完后通过 `Requeue` 更新分片队列的 score
---
### Requirement: 合并两个 Callback 实现
删除 `internal/polling/callbacks.go``internal/polling/api_callback.go`,统一通过 `PollingQueueManager` 处理所有卡生命周期事件。
两个进程API 进程和 Worker 进程)共享同一个 `PollingQueueManager` 实例,均只需要 Redis Client无需 Scheduler 实例。
#### Scenario: API 进程处理卡删除不依赖 Scheduler
- **GIVEN** API 进程没有启动 Scheduler只有 Worker 进程有)
- **WHEN** API 进程处理卡删除请求,调用 `pollingQueueMgr.OnCardDeleted(ctx, cardID)`
- **THEN** 卡从所有 4 类任务 × 16 分片队列中移除,卡信息 Redis 缓存清理;操作不依赖 Scheduler 对象API 进程独立完成
#### Scenario: Worker 进程卡状态变化重新入队
- **GIVEN** Worker 进程检测到卡状态变化(如卡由停机变为在线)
- **WHEN** 调用 `pollingQueueMgr.Requeue(ctx, cardID, taskType, time.Now())`
- **THEN** 卡按 `cardID % shardCount` 路由到对应分片score=当前时间戳Scheduler 下一轮即可出队处理
#### Scenario: 两个进程不产生重复清理
- **GIVEN** API 进程和 Worker 进程同时响应同一卡的删除事件(极端情况)
- **WHEN** 两者同时调用 `OnCardDeleted(ctx, cardID)`
- **THEN** Redis ZREM 操作幂等,不产生副作用;缓存 DEL 操作幂等,不报错
---
### Requirement: LifecycleService 入队前检查 enable_pollingM3
`PollingLifecycleService``OnCardCreated``OnCardEnabled``OnCardStatusChanged` 触发重新入队前,**必须**检查资产的 enable_polling 状态,防止禁用轮询的设置因生命周期事件被意外绕过。
#### Scenario: 设备禁用轮询后状态变更不重新入队
- **GIVEN** deviceID=10 已被设置 `enable_polling=false`,设备下 card-AcardID=100因状态变化触发 `OnCardStatusChanged`
- **WHEN** `PollingLifecycleService.OnCardStatusChanged(ctx, 100)` 执行
- **THEN** 先调 `RemoveFromAllQueues(ctx, 100)` 清理队列;再检查 `card.DeviceID` → 查 `device.EnablePolling=false`**跳过** Requeue记录 Debug 日志「设备轮询已禁用,跳过重新入队: deviceID=10, cardID=100」
#### Scenario: 卡自身禁用轮询后不重新入队
- **GIVEN** cardID=200 的 `enable_polling=false`(无绑定设备),触发 `OnCardEnabled`
- **WHEN** `PollingLifecycleService.OnCardEnabled(ctx, 200)` 执行
- **THEN** 检查 `card.EnablePolling=false` → 跳过 Requeue记录 Debug 日志「卡轮询已禁用,跳过入队: cardID=200」
#### Scenario: 删除和禁用事件无需检查 enable_polling
- **GIVEN** `OnCardDeleted``OnCardDisabled` 被调用
- **WHEN** 执行队列清理操作
- **THEN** 直接执行 `RemoveFromAllQueues`,不需要检查 enable_polling清理操作本身是正确行为
---
### Requirement: 监控服务适配分片队列
`PollingQueueManager` 新增 `GetTotalQueueDepth(ctx, taskType)` 方法,聚合所有分片的 `ZCard` 之和,供 `MonitoringService` 替代直接读取旧的非分片 Redis Key。
#### Scenario: MonitoringService 读取分片后的队列深度
- **GIVEN** 16 个分片中 `carddata` 队列分别有 100、200、300...1600 条数据
- **WHEN** `MonitoringService` 调用 `queueMgr.GetTotalQueueDepth(ctx, "carddata")`
- **THEN** 返回 13600所有分片 ZCard 之和);与重构前直接读 `polling:queue:carddata` 的语义等价
#### Scenario: 部分分片 ZCard 失败不影响总体
- **GIVEN** 16 个分片中 1 个分片 Redis 读取超时
- **WHEN** `GetTotalQueueDepth` 执行
- **THEN** 跳过超时分片,返回其余 15 个分片之和;记录 Warn 日志「分片 X 队列深度查询失败」

View File

@@ -0,0 +1,164 @@
## MODIFIED Requirements
### Requirement: EvaluateAndAct——停复机统一入口
`StopResumeService` 新增 `EvaluateAndAct(ctx context.Context, card *model.IotCard) error` 方法,封装完整的停复机判断和执行逻辑。
> **签名说明**:删除原设计的 `carrierType string, carrierID uint` 参数(仅用于日志,放入签名会误导实现者)。
> 设备/单卡维度通过 `card.DeviceID` 推导,日志上下文通过函数内部的 `zap.Field` 记录。
**删除** `internal/task/polling_handler.go` 中的以下函数(迁移到 StopResumeService
- `checkStopResume`
- `shouldStopCard`
- `hasAvailablePackage`(旧版,被新 `hasValidPackage` 替代)
- `stopCardByUsageExhausted`
- `resumeCardByPackageAvailable`
所有 Task Handlercarddata、package、protect在需要停复机决策时统一调用 `stopResumeService.EvaluateAndAct()`
#### Scenario: Task Handler 调用统一停复机入口
- **GIVEN** `carddata_handler.go` 完成流量数据采集和 DB 写入
- **WHEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card)`
- **THEN** StopResumeService 完整执行停复机判断逻辑Handler 不包含任何无条件停复机相关代码(`shouldStop``hasPackage` 等函数不出现在 Handler 文件中)
#### Scenario: 停机原因按优先级记录
- **GIVEN** 卡在线,同时满足「无套餐」和「流量耗尽」和「未实名」三个条件
- **WHEN** `EvaluateAndAct` 执行 `checkStopReasons`
- **THEN** 按优先级取最高:`no_package > traffic_exhausted > not_realname``stop_reason` 字段记录 `no_package`;停机后 DB 中该卡 `stop_reason='no_package'`
#### Scenario: EvaluateAndAct 幂等
- **GIVEN** 卡已停机,`stop_reason='no_package'`,无套餐状态未变化
- **WHEN** 下次轮询再次调用 `EvaluateAndAct`
- **THEN** 检测到卡已停机(`network_status=0`),进入复机判断分支;复机条件不满足(仍无套餐),不发起 Gateway 调用,返回 nilDB 状态不变
---
### Requirement: 停机条件——三种场景全面覆盖
卡在线(`network_status=1`)时,满足以下**任一**条件触发停机:
**条件 Ano_package**:无有效套餐
- 独立卡:`tb_package_usage` 中无 `iot_card_id=卡ID AND status IN (0,1)` 的记录
- 绑定设备的卡:`tb_package_usage` 中无 `device_id=设备ID AND status IN (0,1)` 的记录
- status=0待激活和 status=1激活中均视为有效套餐
**条件 Btraffic_exhausted**:虚流量耗尽
- 活跃套餐 `status=2`(系统标记为虚流量耗尽),或
- 活跃套餐 `data_usage_mb >= data_limit_mb`(且 `data_limit_mb > 0`,防止无限流量卡误判)
**条件 Cnot_realname**:非行业卡且未实名
- `card_category != 'industry'``real_name_status = 0`
#### Scenario: 无套餐触发停机(独立卡)
- **GIVEN** cardID=100 的独立卡(无 device_id`network_status=1`
- **WHEN** `tb_package_usage` 中无任何 `iot_card_id=100 AND status IN(0,1)` 的记录
- **THEN** `checkStopReasons` 返回 `["no_package"]`;发起 Gateway 停机调用3次重试DB 更新 `network_status=0, stop_reason='no_package'`
#### Scenario: 无套餐触发停机设备卡修复Bug1
- **GIVEN** cardID=200 的卡绑定 deviceID=50`network_status=1`
- **WHEN** `tb_package_usage` 中无 `iot_card_id=200` 的记录,但有 `device_id=50 AND status=1` 的记录(购买了设备套餐)
- **THEN** `hasValidPackage` 检测到设备套餐存在,返回 true不触发停机卡保持在线状态修复之前只查 iot_card_id 导致误停机的 Bug
#### Scenario: 流量耗尽触发停机
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=1000, data_usage_mb=1001`
- **WHEN** `isTrafficExhausted` 检测
- **THEN** `data_usage_mb(1001) >= data_limit_mb(1000)` 条件满足;返回 true触发停机`stop_reason='traffic_exhausted'`
#### Scenario: 套餐 status=2 触发停机
- **GIVEN** 卡在线,活跃套餐 `status=2`(系统已标记虚流量耗尽)
- **WHEN** `isTrafficExhausted` 检测
- **THEN** 检测到 `status=2`;返回 true触发停机`stop_reason='traffic_exhausted'`
#### Scenario: 无限流量套餐不误判流量耗尽
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=0`(无限流量),`data_usage_mb=9999`
- **WHEN** `isTrafficExhausted` 检测
- **THEN** `data_limit_mb=0` 时跳过用量比较(防止无限流量卡被误停机);返回 false不触发停机
#### Scenario: 未实名普通卡触发停机
- **GIVEN** 卡在线,`card_category='normal'``real_name_status=0`,有有效套餐且流量未耗尽
- **WHEN** `checkStopReasons` 检测条件 C
- **THEN** `!isRealnameOK(card)` 为 true触发停机`stop_reason='not_realname'`
#### Scenario: 行业卡无需实名不停机
- **GIVEN** 卡在线,`card_category='industry'``real_name_status=0`,有有效套餐
- **WHEN** `checkStopReasons` 检测
- **THEN** `isRealnameOK(card)` 返回 true行业卡豁免实名要求不触发停机卡保持在线
---
### Requirement: 复机条件——全部满足才复机
卡停机(`network_status=0`)时,以下**全部**条件满足才触发自动复机:
1. `stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')`(排除手动停机、运营商停机等其他原因)
2. 有有效套餐且流量未耗尽(`hasValidPackage=true``isTrafficExhausted=false`
3. 行业卡 OR 已实名(`card_category='industry'` OR `real_name_status=1`
#### Scenario: 购买套餐后自动复机
- **GIVEN** cardID=300 因 `no_package` 停机,`stop_reason='no_package'`现在购买了套餐status=1
- **WHEN** 下次轮询执行 `EvaluateAndAct`
- **THEN** 三个复机条件全部满足stop_reason合规 + 有套餐 + 已实名或行业卡);发起 Gateway 复机调用3次重试DB 更新 `network_status=1, stop_reason=''`
#### Scenario: 手动停机不自动复机
- **GIVEN** 卡 `stop_reason='manual'`(管理员手动停机)
- **WHEN** 该卡购买了套餐,完成实名认证,轮询执行 `EvaluateAndAct`
- **THEN** 条件1不满足`'manual'` 不在 IN 列表中);不触发自动复机;卡保持停机状态;需管理员手动复机
#### Scenario: 流量耗尽停机后购买新套餐复机
- **GIVEN** 卡因 `traffic_exhausted` 停机,`stop_reason='traffic_exhausted'`旧套餐已过期status=3新购套餐 status=1usage=0
- **WHEN** 轮询执行 `EvaluateAndAct`
- **THEN** 条件1满足traffic_exhausted条件2满足新套餐有效usage<limit条件3满足已实名触发复机
#### Scenario: 未实名卡实名后复机
- **GIVEN** 普通卡因 `not_realname` 停机,用户完成实名认证(`real_name_status=1`
- **WHEN** 实名检查轮询执行,更新 DB 后调用 `EvaluateAndAct`
- **THEN** 条件1满足条件2满足有套餐且未耗尽条件3满足`real_name_status=1`);触发复机
---
### Requirement: 设备维度停复机——覆盖所有绑定卡
**停机**操作覆盖设备下所有在线卡(`network_status=1`**复机**操作对满足条件的卡执行,跳过未满足 `isRealnameOK` 的普通卡。
#### Scenario: 设备套餐耗尽停所有绑定卡
- **GIVEN** deviceID=10 下有 3 张在线卡cardID=1,2,3设备套餐 `data_usage_mb >= data_limit_mb`
- **WHEN** 任一绑定卡触发 `EvaluateAndAct`,检测到设备套餐流量耗尽
- **THEN** 调用 `stopDeviceCards(ctx, deviceID=10, "traffic_exhausted")`3 张卡均发起 Gateway 停机调用3 张卡均更新 `stop_reason='traffic_exhausted'`
#### Scenario: 设备停机调用 Gateway 带3次重试
- **GIVEN** 设备下有 3 张卡需要停机Gateway 第一次调用返回超时
- **WHEN** `stopCardWithRetry` 执行
- **THEN** 自动重试至多 3 次;至少 1 次成功则记录成功,最终失败则记录 Error 日志「卡停机失败: cardID=xxx, error=xxx」
#### Scenario: 购买设备套餐后全卡复机
- **GIVEN** deviceID=10 下 3 张卡均因 `traffic_exhausted` 停机,购买新套餐后 status=1
- **WHEN** 轮询执行 `EvaluateAndAct``shouldResume` 返回 true
- **THEN** `resumeDeviceCards(ctx, deviceID=10)` 被调用;检查所有 `stop_reason IN(...)` 的停机卡3 张卡均满足 `isRealnameOK`均已实名3 张卡均触发复机
#### Scenario: 设备复机跳过未实名普通卡
- **GIVEN** deviceID=20 下有 3 张卡card-A已实名、card-B已实名、card-C`card_category='normal'`, `real_name_status=0`
- **WHEN** 设备复机条件满足,执行 `resumeDeviceCards`
- **THEN** card-A 和 card-B 触发复机(各执行 Gateway 复机调用card-C 因 `isRealnameOK=false` 被跳过保持停机card-C 的 `stop_reason` 自动更新为 `not_realname`
#### Scenario: 设备复机中单卡 Gateway 失败不阻止其他卡
- **GIVEN** 设备下 3 张卡需要复机card-B 的 Gateway 复机调用失败(含重试)
- **WHEN** `resumeDeviceCards` 遍历执行
- **THEN** card-A 和 card-C 正常复机card-B 记录 Error 日志并跳过,不影响其他卡的复机;`resumeDeviceCards` 整体不返回 error尽力复机语义
---
### Requirement: 设备维度操作幂等锁——防止并发重复 Gateway 调用
设备下多张卡分布在不同分片队列,同一调度周期内可能同时触发 `EvaluateAndAct`,进而并发调用 `stopDeviceCards` / `resumeDeviceCards`,导致同一张卡被 Gateway 重复停/复机。
`stopDeviceCards``resumeDeviceCards` 均在执行前通过 Redis `SetNX` 获取设备操作锁(`polling:device:op_lock:{deviceID}`TTL 30 秒)。获取失败时视为"其他协程正在处理",直接跳过。
#### Scenario: 多卡并发触发不重复调 Gateway
- **GIVEN** deviceID=10 下有 card-Ashard 3和 card-Bshard 7同一调度周期被并发出队
- **WHEN** card-A 和 card-B 同时触发 `EvaluateAndAct` → 均尝试调用 `stopDeviceCards(deviceID=10)`
- **THEN** 第一个调用获取设备锁成功,执行完整的停机流程;第二个调用 `SetNX` 返回 false记录 Debug 日志「设备停机操作已在进行中,跳过: deviceID=10」直接返回设备下各卡只被 Gateway 停机一次
#### Scenario: 设备锁 TTL 超时后可重新获取
- **GIVEN** 设备操作锁已过期(上次操作完成后 `Del` 释放,或 TTL 30 秒到期)
- **WHEN** 下一轮轮询再次触发 `stopDeviceCards`
- **THEN** `SetNX` 成功获取锁,正常执行停机流程

View File

@@ -0,0 +1,269 @@
## ADDED Requirements
### Requirement: polling_handler.go 拆分为 4 个专注 Handler
`internal/task/polling_handler.go`1360行拆分为 4 个职责单一的文件,每个文件只负责一种任务类型的数据采集,停复机决策统一委托给 `StopResumeService.EvaluateAndAct()`
**文件目标行数**
| 文件 | 职责 | 目标行数 |
|------|------|---------|
| `polling_base.go` | 共享基类(并发/缓存/重入队) | < 150行 |
| `polling_realname_handler.go` | 实名状态采集 | < 200行 |
| `polling_carddata_handler.go` | 流量数据采集 | < 300行 |
| `polling_package_handler.go` | 套餐数据采集 | < 200行 |
| `polling_protect_handler.go` | 保护期一致性检查 | < 150行 |
**删除**:拆分完成后,`internal/task/polling_handler.go` 整体删除。
**Handler 边界原则**
- ✅ 允许:调 Gateway 采集数据、写 StoreDB更新、调 StopResumeService.EvaluateAndAct()
- ❌ 禁止:直接 `h.db.Model()`(绕过 Store 层)、停复机判断逻辑、直接调 Gateway 停复机接口
#### Scenario: Asynq 任务类型常量不变(向后兼容)
- **GIVEN** 现有 Asynq Worker 已注册 4 种任务类型常量
- **WHEN** 拆分后注册 4 个新 Handler
- **THEN** 任务类型常量名称完全不变(`TaskTypeRealnameCheck``TaskTypeCarddataCheck` 等);已在 Asynq 队列中的待处理任务无需清空,新 Handler 直接接管处理
#### Scenario: 4 个 Handler 并行处理不干扰
- **GIVEN** 同时有 realname、carddata、package、protect 四种任务在处理
- **WHEN** 各 Handler 独立执行
- **THEN** 各 Handler 使用独立的并发锁(`PollingBase.acquireConcurrency` 按任务类型隔离realname 任务的并发数不占用 carddata 任务的并发配额
---
### Requirement: 共享基类 PollingBase
新建 `internal/task/polling_base.go`,提供 `PollingBase` 结构体,供 4 个 Handler 组合使用。
**提取的公共方法**(原来在 `polling_handler.go` 中重复出现):
- `acquireConcurrency(taskType) bool`:获取并发控制信号量
- `releaseConcurrency(taskType)`:释放并发控制信号量
- `getCardWithCache(ctx, cardID) (*model.IotCard, error)`:带 Redis 缓存的卡查询
- `updateCardCache(ctx, card)`:更新 Redis 卡信息缓存
- `requeueCard(ctx, cardID, taskType, interval)`:按间隔重新入队(调 `PollingQueueManager.Requeue`
- `getMatchedPollingInterval(card) time.Duration`:获取该卡匹配的轮询间隔
#### Scenario: 缓存命中减少 DB 压力
- **GIVEN** cardID=100 的卡信息已缓存在 RedisTTL 5分钟
- **WHEN** `polling_realname_handler.go` 调用 `base.getCardWithCache(ctx, 100)`
- **THEN** 直接从 Redis 读取,不查询 DB缓存命中后更新 TTL滑动窗口
#### Scenario: 缓存未命中回源 DB
- **GIVEN** cardID=200 的卡信息不在 Redis 缓存中
- **WHEN** 任一 Handler 调用 `base.getCardWithCache(ctx, 200)`
- **THEN** 查询 DB将结果写入 RedisTTL 5分钟返回卡信息
#### Scenario: 并发控制防止过载——并发满时必须 requeue
- **GIVEN** `carddata` 任务最大并发数配置为 50
- **WHEN** 同时有 60 个 carddata 任务尝试执行
- **THEN** 前 50 个获取到信号量正常执行;后 10 个 `acquireConcurrency` 返回 false**后 10 个任务调 `requeueCard(ctx, cardID, taskType, time.Now())` 立即重入队**,记录 Debug 日志「并发数已满,已重新入队: cardID=xxx」返回 nilAsynq 不报错,不重试)
> **⚠️ 正确性约束**Lua 脚本原子出队时卡已从 Redis Sorted Set 中删除。若并发满时直接 return nil 而不 requeue该卡将永久消失不再被轮询。所有 Handler 必须在 `acquireConcurrency` 返回 false 时先调 `requeueCard` 再返回。
---
### Requirement: polling_realname_handler.go——实名数据采集
**文件**`internal/task/polling_realname_handler.go`< 200行
**构造函数依赖**(通过构造函数注入,禁止全局变量):
```go
func NewPollingRealnameHandler(
base *PollingBase,
gateway GatewayClient,
iotCardStore IotCardStore,
queueClient QueueClient, // 用于触发首次实名激活任务
) *PollingRealnameHandler
```
**方法列表**
- `Handle(ctx, task) error`Asynq 任务入口
- `processCard(ctx, cardID) error`:核心处理逻辑
**处理流程**
1.`base.acquireConcurrency("realname")`,获取失败则跳过
2.`base.getCardWithCache(ctx, cardID)` 获取卡信息
3. 调 Gateway 查询实名状态
4. 写 Store更新 `real_name_status`
5. 若实名状态变为已实名0→1
a. 入队首次实名激活 Asynq 任务(`triggerFirstRealnameActivation`
b. **调 `stopResumeService.EvaluateAndAct(ctx, card)` 触发复机判断**(卡可能因 `not_realname` 停机,需立即复机)
6.`base.requeueCard(ctx, cardID, "realname", interval)` 重新入队
7.`base.releaseConcurrency("realname")`
#### Scenario: 实名状态由未实名变为已实名触发激活和复机
- **GIVEN** cardID=100`real_name_status=0`(未实名),且卡因 `not_realname` 处于停机状态(`network_status=0, stop_reason='not_realname'`
- **WHEN** Gateway 返回实名已完成,`HandleRealnameCheck` 执行
- **THEN** Store 更新 `real_name_status=1`;入队首次实名激活 Asynq 任务;**立即调用 `EvaluateAndAct` 检测复机条件**;若有有效套餐且流量未耗尽,发起 Gateway 复机调用DB 更新 `network_status=1, stop_reason=''`;记录日志「卡实名状态变更: cardID=100, 0→1触发激活任务和复机评估」
#### Scenario: 实名状态未变化不触发激活
- **GIVEN** cardID=200`real_name_status=1`已实名Gateway 返回仍已实名
- **WHEN** `HandleRealnameCheck` 执行
- **THEN** Store 不执行更新(无变化);不入队激活任务;调 `requeueCard` 按正常间隔重新入队
#### Scenario: realname Handler 仅在实名 0→1 时调用 EvaluateAndActS1 修复)
- **GIVEN** `polling_realname_handler.go` 代码 Review
- **WHEN** 检查文件内容
- **THEN** 文件中**不出现**无条件的 `stopCard``resumeCard` 等停复机操作;但**允许且必须**在实名状态由 0→1 时调用 `stopResumeService.EvaluateAndAct(ctx, card)` 触发复机判断;原因:若卡因 `not_realname` 停机,实名完成后应立即复机,不能等下一个 carddata/package 轮询周期(可能长达 1 小时)
---
### Requirement: polling_carddata_handler.go——流量数据采集
**文件**`internal/task/polling_carddata_handler.go`< 300行
**构造函数依赖**
```go
func NewPollingCarddataHandler(
base *PollingBase,
gateway GatewayClient,
iotCardStore IotCardStore,
packageStore PackageUsageStore,
usageService UsageService, // DeductDataUsage
stopResumeService StopResumeServiceInterface,
) *PollingCarddataHandler
```
**方法列表**
- `Handle(ctx, task) error`Asynq 任务入口
- `processCard(ctx, cardID) error`:核心处理逻辑
- `collectUsageData(ctx, card) (*UsageData, error)`:调 Gateway 获取流量增量
**处理流程**
1. 获取并发控制
2. 调 Gateway 查询流量增量
3. 写 Store更新 `data_usage_mb`
4.`usageService.DeductDataUsage()`(套餐流量扣减计算)
5.`stopResumeService.EvaluateAndAct(ctx, card, ...)`(停复机决策)
6. 重新入队,释放并发控制
#### Scenario: 流量更新后委托停复机决策
- **GIVEN** cardID=100流量更新后 `data_usage_mb` 达到 `data_limit_mb`
- **WHEN** `HandleCarddataCheck` 执行完流量写入
- **THEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card, "iot_card", 100)`;由 StopResumeService 决定是否停机Handler 不包含 `if usage >= limit { stop() }` 这类判断代码
#### Scenario: 消除直接 DB 操作
- **GIVEN** 原 `polling_handler.go` 中有 13 处 `h.db.Model()` 直接 DB 操作
- **WHEN** 拆分后的 `polling_carddata_handler.go` 代码 Review
- **THEN** 文件中不出现 `h.db.``db.Model()``db.Where()` 等直接 GORM 调用;所有 DB 操作通过 Store 接口(`iotCardStore.UpdateXxx()``packageStore.UpdateXxx()`
#### Scenario: 跨月流量边界检测(必须保留)
- **GIVEN** cardID=100`current_month_start_date=2026-03-01`,当前日期为 `2026-04-02`
- **WHEN** Gateway 返回月度总流量 `500MB``processCard` 检测到跨月(当前月份首日 != current_month_start_date
- **THEN** 保存 `last_month_total_mb = 旧 current_month_total`;重置 `current_month_usage_mb = 500`(以 Gateway 新月份值为准);更新 `current_month_start_date = 2026-04-01`;记录流量历史到 `data_usage_records`
#### Scenario: 同月流量增量计算
- **GIVEN** cardID=200`current_month_start_date=2026-04-01``last_month_total_mb=100`,当前日期为 `2026-04-02`
- **WHEN** Gateway 返回月度总流量 `150MB`
- **THEN** 计算增量 `delta = 150 - 100 = 50MB`;更新 `current_month_usage_mb += 50`;更新 `last_month_total_mb = 150`
#### Scenario: Gateway 调用失败不丢失数据
- **GIVEN** cardID=300Gateway 返回网络超时
- **WHEN** `collectUsageData` 调用 Gateway 失败
- **THEN** 不更新 DB不写入错误数据记录 Warn 日志「流量查询失败: cardID=300, error=xxx」`requeueCard` 按较短间隔重试间隔重新入队Asynq 层面不触发重试Handler 返回 nilMaxRetry=0
---
### Requirement: polling_package_handler.go——套餐数据采集
**文件**`internal/task/polling_package_handler.go`< 200行
**构造函数依赖**
```go
func NewPollingPackageHandler(
base *PollingBase,
gateway GatewayClient,
packageStore PackageUsageStore,
stopResumeService StopResumeServiceInterface,
) *PollingPackageHandler
```
**方法列表**
- `Handle(ctx, task) error`Asynq 任务入口
- `processCard(ctx, cardID) error`:核心处理逻辑
**处理流程**
1. 获取并发控制
2. 调 Gateway 查询套餐信息(剩余流量、状态)
3. 写 Store更新 `tb_package_usage` 套餐状态/用量)
4.`stopResumeService.EvaluateAndAct(ctx, card, ...)`(停复机决策)
5. 重新入队,释放并发控制
#### Scenario: 套餐到期后触发复机判断
- **GIVEN** 卡因 `traffic_exhausted` 停机旧套餐已过期status=3Gateway 返回新套餐已激活
- **WHEN** `HandlePackageCheck` 执行,更新套餐状态后
- **THEN** 调 `EvaluateAndAct`StopResumeService 检测到新套餐有效、流量未耗尽触发复机Handler 不直接调用 resumeCard
#### Scenario: package Handler 不做停复机判断
- **GIVEN** `polling_package_handler.go` 代码 Review
- **WHEN** 检查文件内容
- **THEN** 文件中不出现 `shouldStopCard``hasAvailablePackage``stopCard``resumeCard` 等函数;套餐处理逻辑仅限于数据采集和 Store 写入
---
### Requirement: polling_protect_handler.go——保护期一致性检查
**文件**`internal/task/polling_protect_handler.go`< 200行
> **业务语义说明**:本 Handler 的核心目的是"确保保护期内卡状态与保护期方向一致,防止状态漂移"。
> 保护期**内**:强制修正状态(直接调 Gateway不走 EvaluateAndAct 三条件判断)。
> 保护期**结束后**:调 EvaluateAndAct 重新评估(按正常停复机逻辑处理)。
**构造函数依赖**
```go
func NewPollingProtectHandler(
base *PollingBase,
gateway GatewayClient, // 保护期内强制停复机需要直接调 Gateway
iotCardStore IotCardStore,
stopResumeService StopResumeServiceInterface,
) *PollingProtectHandler
```
**方法列表**
- `Handle(ctx, task) error`Asynq 任务入口
- `processCard(ctx, cardID) error`:核心处理逻辑
**处理流程**
1. 获取并发控制(并发满时 **requeue 后返回**,不丢弃)
2. 查 Store 获取卡信息
3. 前置跳过检查:
- 卡未实名(`real_name_status=0`)→ requeue直接返回
- 卡未绑定设备(`is_standalone=true`)→ requeue直接返回
4. 读取设备保护期 Redis Key`stop` 保护期 Key + `start` 保护期 Key
5. 根据保护期状态执行对应逻辑:
- **有 stop 保护期 + 卡在线network_status=1**:直接调 `gateway.StopCard`(强制修正),写 DB `stop_reason='protect'`
- **有 start 保护期 + 卡停机network_status=0**:直接调 `gateway.StartCard`(强制修正),清空 `stop_reason`
- **有保护期 + 状态已一致**:跳过(不调 Gateway不调 EvaluateAndAct
- **无保护期(保护期已结束)**:调 `stopResumeService.EvaluateAndAct(ctx, card)` 重新评估
6.`base.requeueCard` 按间隔重新入队
7. 释放并发控制
#### Scenario: stop 保护期内卡在线——强制停机
- **GIVEN** 卡已实名且绑定设备,设备有 stop 保护期(`polling:protect:stop:{deviceID}` Key 存在),但卡当前 `network_status=1`(在线,与保护期方向不一致)
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到 stop 保护期存在且状态不一致;直接调 `gateway.StopCard`(不走 EvaluateAndAct强制修正DB 更新 `network_status=0, stop_reason='protect'`;记录 Info 日志「保护期强制停机: cardID=xxx, deviceID=xxx」
#### Scenario: start 保护期内卡停机——强制复机
- **GIVEN** 卡已实名且绑定设备,设备有 start 保护期(`polling:protect:start:{deviceID}` Key 存在),但卡当前 `network_status=0`(停机,与保护期方向不一致)
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到 start 保护期存在且状态不一致;直接调 `gateway.StartCard`(不走 EvaluateAndAct强制修正DB 更新 `network_status=1, stop_reason=''`;记录 Info 日志「保护期强制复机: cardID=xxx, deviceID=xxx」
#### Scenario: 保护期内状态已一致——跳过
- **GIVEN** 设备有 stop 保护期,卡已是停机状态(`network_status=0`
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到保护期存在且状态已一致;跳过,不调 Gateway不调 EvaluateAndAct直接 requeue
#### Scenario: 保护期已结束——调 EvaluateAndAct 重新评估
- **GIVEN** 卡在保护期内已停机(`stop_reason='protect'`),保护期 Key 已过期TTL = 0
- **WHEN** `HandleProtectConsistencyCheck` 执行,读取保护期 Key 不存在
- **THEN** 调 `stopResumeService.EvaluateAndAct(ctx, card)` 重新评估;若有有效套餐且已实名,触发复机;否则保持停机(`stop_reason` 更新为实际原因)
#### Scenario: 未实名卡跳过保护期逻辑
- **GIVEN** 卡 `real_name_status=0`(未实名)
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到未实名,直接 requeue不检查保护期不调 Gateway
#### Scenario: 独立卡(未绑定设备)跳过
- **GIVEN** 卡 `is_standalone=true`(未绑定设备)
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到独立卡,直接 requeue设备保护期与独立卡无关

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
### Requirement: 独立配置管理模块PollingConfigManager
新建 `internal/polling/config_manager.go`,提供 `PollingConfigManager` 类型,从 `scheduler.go` 中拆分配置相关职责。
**文件目标**< 150 行职责DB 加载 → 内存缓存 → Redis 同步 → 定时刷新。
**从 Scheduler 提取的方法**
- `loadConfigs(ctx)`:从 DB 加载所有启用的 `tb_polling_config`
- `syncConfigsToRedis(configs)`:写入 Redis HashTTL 24小时
- `MatchConfig(card)`:按优先级顺序返回第一个匹配的配置
- `matchConfigConditions(config, card)`:判断卡是否满足配置条件
- `getCardCondition(card)`获取卡的匹配条件carrier、simtype 等)
**Scheduler 修改后**:不包含任何 DB 查询或配置加载逻辑,通过 `configManager.MatchConfig(card)` 获取配置。
#### Scenario: 启动时加载配置并同步 Redis
- **GIVEN** Worker 进程启动DB 中有 5 条启用的 PollingConfig
- **WHEN** `PollingConfigManager.Load(ctx)` 被调用
- **THEN** 从 DB 加载 5 条配置写入内存缓存sync.RWMutex 保护),同步到 Redis HashTTL 24小时加载完成日志记录「已加载 5 条轮询配置」
#### Scenario: 配置按优先级匹配
- **GIVEN** 内存中有 3 条配置,优先级分别为 1、5、10数字越小优先级越高
- **WHEN** 调用 `MatchConfig(card)` 匹配某张卡
- **THEN** 按优先级升序遍历,返回第一个满足所有条件的配置;无匹配时返回 nil该卡不加入轮询队列
#### Scenario: 配置匹配使用读锁不阻塞
- **GIVEN** Scheduler 高频调用 `MatchConfig(card)`(每秒可能数千次)
- **WHEN** 同时有定时刷新正在写入新配置(写锁)
- **THEN** 读取操作等待写锁释放后继续,不发生数据竞争;写锁持有时间极短(内存赋值),不影响 Scheduler 调度性能
---
### Requirement: 定时刷新配置5分钟周期
`PollingConfigManager.Start(ctx)` 启动后台 goroutine每 5 分钟自动重新加载配置。
#### Scenario: 定时刷新不重启 Worker
- **GIVEN** Worker 进程已运行 10 分钟,管理员在控制台修改了某条 PollingConfig 的轮询间隔
- **WHEN** 距上次加载超过 5 分钟,定时器触发
- **THEN** 自动重新从 DB 加载配置,更新内存缓存,新配置在下一轮调度中生效;无需重启 Worker 进程
#### Scenario: 刷新失败不影响当前配置
- **GIVEN** 定时刷新时 DB 连接超时
- **WHEN** `Load(ctx)` 返回 error
- **THEN** 内存缓存保持原有配置不变(不清空),记录 Error 日志「配置刷新失败: xxx」下一轮5分钟后重试
---
### Requirement: 独立卡初始化模块CardInitializer
新建 `internal/polling/initializer.go`,提供 `CardInitializer` 类型,从 `scheduler.go` 中拆分渐进式初始化职责。
**文件目标**< 250 行,职责:分批 DB 查询 → 分片路由入队 → 进度追踪 → enable_polling 过滤。
**从 Scheduler 提取的方法**
- `progressiveInit(ctx)`:分批加载卡并入队
- `initCardsBatch(ctx, offset, limit)`:加载单批次卡
- `initCardPolling(ctx, card)`:为单张卡匹配配置并入队
**Scheduler 修改后**:不包含任何初始化逻辑,通过 `cardInitializer.Run(ctx)` 异步启动,通过 `cardInitializer.GetProgress()` 查询进度。
#### Scenario: 渐进式分批初始化避免 DB 过载
- **GIVEN** DB 中有 500 万张启用轮询的卡
- **WHEN** `CardInitializer.Run(ctx)` 被调用
- **THEN** 分批加载(每批 10 万张),批次间 Sleep 500ms约 500 批次,每批处理后记录进度日志「初始化进度: 100000/5000000」全部完成后 `initCompleted=true`Scheduler 开始正常出队
#### Scenario: 跳过 enable_polling=false 的卡
- **GIVEN** 10 万张卡中5000 张卡的 `enable_polling=false`
- **WHEN** 批次处理时遇到这 5000 张卡
- **THEN** 这 5000 张卡不加入任何轮询队列ZADD 跳过);其余 95000 张卡按正常流程入队
#### Scenario: 初始化幂等(重启不重复)
- **GIVEN** Worker 重启,部分卡已在分片队列中(上次初始化留下)
- **WHEN** 重新执行 `CardInitializer.Run(ctx)`
- **THEN** ZADD 操作幂等:若卡已在队列中,只更新 score 为新的初始延迟时间不添加重复条目Redis Sorted Set 中不出现重复成员
#### Scenario: Scheduler 只在初始化完成后出队
- **GIVEN** Worker 刚启动,初始化尚未完成(`initCompleted=false`
- **WHEN** Scheduler 的 `scheduleLoop` 执行
- **THEN** 检查 `cardInitializer.GetProgress().Completed`,若为 false 则跳过本轮所有分片的出队;避免初始化中途 Scheduler 取到不完整队列
#### Scenario: 进度暴露给监控接口
- **GIVEN** 管理员调用轮询状态监控接口
- **WHEN** `MonitoringService` 调用 `cardInitializer.GetProgress()`
- **THEN** 返回 `{Total: 5000000, Processed: 1500000, Completed: false}`;前端可展示初始化进度百分比
---
### Requirement: Scheduler 精简到 < 250 行Mi1 修复)
> **注意**:本 spec 原写 `< 200 行`,已与 `design.md` 决策 9 和 `tasks.md` 统一更正为 **`< 250 行`**。
> 原因:精简后的 Scheduler 保留了套餐过期检测和流量重置两个触发器(见决策 9额外占用约 50 行。
`internal/polling/scheduler.go` 精简后只保留纯调度循环逻辑,所有子职责委托给新建的三个模块。
**精简后 Scheduler 的完整职责**
1. 启动 `CardInitializer.Run(ctx)`(异步)
2. 启动 `PollingConfigManager.Start(ctx)`(定时刷新)
3. 运行 `scheduleLoop`:每秒 × N分片 × 4任务类型 的出队循环
4. 背压检测:`GetQueueDepth > 阈值` 时跳过该分片
**禁止出现在精简后 Scheduler 中的内容**
- 任何 `db.Find()``db.Where()` 等 DB 操作
- 配置加载逻辑(`loadConfigs``syncConfigsToRedis`
- 卡初始化逻辑(`progressiveInit``initCardsBatch`
- 任何 Callback 注册(`callbacks.go` 已删除)
#### Scenario: Scheduler 保留套餐过期检测和流量重置触发
- **GIVEN** Phase 4+5 原子部署完成,精简后的 Scheduler 运行中
- **WHEN** 每 10 秒定时器触发
- **THEN** 调用 `PackageActivationHandler.HandlePackageActivationCheck(ctx)` 检查过期套餐;调用 `DataResetHandler.HandleDataReset(ctx)` 检查流量重置;这两个触发器在精简后 Scheduler 中**必须保留**(决策 9
#### Scenario: Scheduler 精简后行数验证
- **GIVEN** Phase 4+5 重构完成
- **WHEN** 统计 `internal/polling/scheduler.go` 的代码行数
- **THEN** 文件行数(含注释)**< 250 行**(因保留套餐过期/流量重置触发器,此处统一以 design.md 决策 9 和 tasks.md 的 <250 为准);文件中不出现 `db.Find``db.Where``loadConfig` 等字样;文件中出现 `HandlePackageActivationCheck``HandleDataReset`

View File

@@ -0,0 +1,171 @@
### Requirement: 统一 Redis 队列操作封装PollingQueueManager
新建 `internal/polling/queue_manager.go`,提供 `PollingQueueManager` 类型,封装所有轮询相关的 Redis 队列操作。
**文件目标**< 200 行,只依赖 `redis.Client`,无 DB 依赖两个进程API 进程和 Worker 进程)共享同一实现。
以下代码的 Redis 操作均须通过 `PollingQueueManager`
- `internal/polling/callbacks.go`(删除后由此替代)
- `internal/polling/api_callback.go`(删除后由此替代)
- `internal/polling/scheduler.go` 中的所有队列出队/入队操作
- `internal/task/polling_handler.go` 中的 `requeueCard`
**接口定义(方法签名)**
```go
// PollingQueueManager 轮询队列统一管理器
type PollingQueueManager struct {
redis *redis.Client
shardCount int // 默认16
}
// DequeueReady 从指定分片出队到期卡Lua 脚本ZRANGEBYSCORE + ZREM 原子执行)
// 只取 score ≤ now 的到期卡,不触碰未来项
// 返回 []CardEntry包含 CardID从 ZRANGEBYSCORE 结果解析)
func (m *PollingQueueManager) DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error)
// Requeue 将卡重新入队ZADDscore=nextCheckAt Unix时间戳
func (m *PollingQueueManager) Requeue(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) error
// RemoveFromAllQueues 从所有分片的4个队列含protect移除该卡
func (m *PollingQueueManager) RemoveFromAllQueues(ctx context.Context, cardID uint) error
// EnqueueManual 手动触发入队List RPUSH调度器优先消费
func (m *PollingQueueManager) EnqueueManual(ctx context.Context, cardID uint, taskType string) error
// OnCardDeleted 卡删除事件:移除所有队列 + 清理缓存
func (m *PollingQueueManager) OnCardDeleted(ctx context.Context, cardID uint) error
// GetQueueDepth 获取分片队列深度(用于背压检测)
func (m *PollingQueueManager) GetQueueDepth(ctx context.Context, shardID int, taskType string) (int64, error)
// GetTotalQueueDepth 获取指定任务类型的总队列深度(聚合所有分片 ZCard 之和)
// 供 MonitoringService 使用,替代直接读取旧的非分片 Redis Key
func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error)
```
#### Scenario: Lua 脚本原子出队替换竞态操作
- **GIVEN** 调度器从分片队列出队
- **WHEN** Scheduler 调用 `DequeueReady(ctx, shardID=3, taskType="carddata", batchSize=1000)`
- **THEN** Lua 脚本在 Redis 服务端原子执行 `ZRANGEBYSCORE + ZREM`,只取 score ≤ now 的到期卡;不存在原来 ZRANGEBYSCORE + ZREMRANGEBYSCORE 分步操作的竞态窗口和卡丢失风险返回卡ID列表每张卡保证只被取出一次
#### Scenario: 高并发下不重复出队
- **GIVEN** 2个 Scheduler Worker 同时消费同一分片
- **WHEN** 两个 Worker 同时调用 `DequeueReady(ctx, shardID=0, taskType="realname", batchSize=500)`
- **THEN** 两次调用返回的 CardID 集合不相交Lua 脚本在 Redis 单线程中串行执行,保证原子性),不存在同一张卡被两个 Worker 同时处理的情况
#### Scenario: 未到期卡不被提前取出
- **GIVEN** 分片队列中有 100 张到期卡score ≤ now和 50 张未到期卡score > now
- **WHEN** Scheduler 调用 `DequeueReady(ctx, shardID=0, taskType="carddata", batchSize=200)`
- **THEN** 只返回 100 张到期卡50 张未到期卡保持在队列中不受影响;这是 Lua 脚本方案相比 ZPOPMIN 的关键优势ZPOPMIN 会错误取出未到期卡)
---
### Requirement: 分片 Sorted Set 支持千万级规模
`PollingQueueManager` 的存储结构使用分片 Sorted SetKey 格式为 `polling:shard:{shardID}:queue:{taskType}`
**分片路由**:卡入队时按 `cardID % shardCount` 分桶,确保同一张卡的同一任务类型始终在固定分片。
**Key 命名**:通过 `pkg/constants/redis.go``RedisPollingShardQueueKey(shardID int, taskType string) string` 生成。
#### Scenario: 分片路由一致性
- **GIVEN** 默认 16 个分片
- **WHEN** cardID=1000 的卡执行 `Requeue(ctx, 1000, "carddata", nextTime)`
- **THEN** 写入 `polling:shard:8:queue:carddata`1000 % 16 = 8每次入队该卡都写同一个分片
#### Scenario: 背压跳过深度超限的分片
- **GIVEN** 分片 5 的 `carddata` 队列深度达到 600,000超过阈值 500,000
- **WHEN** Scheduler 调用 `GetQueueDepth(ctx, 5, "carddata")`
- **THEN** 返回 600000Scheduler 跳过该分片本轮出队,等待 Asynq 消化积压后恢复
---
### Requirement: 从所有队列移除(修复 protect 队列遗漏 Bug
`RemoveFromAllQueues` 须覆盖 **4 个任务类型**realname、carddata、package、**protect**× N 个分片 = 4N 次 Redis ZREM 操作。
#### Scenario: 删除卡后 protect 队列不残留
- **GIVEN** cardID=500 的卡在 realname/carddata/package/protect 4个队列均有条目
- **WHEN** 调用 `RemoveFromAllQueues(ctx, 500)`
- **THEN** 4 类任务类型 × 16 分片共 64 个 Key 均执行 ZREMRedis CLI 验证 `ZRANK polling:shard:*:queue:protect 500` 均不存在
#### Scenario: 不存在的卡调用无副作用
- **GIVEN** cardID=999 的卡从未入队
- **WHEN** 调用 `RemoveFromAllQueues(ctx, 999)`
- **THEN** 无报错返回,各队列 ZREM 操作幂等(不存在成员 ZREM 返回 0不视为错误
---
### Requirement: 手动触发入队
`EnqueueManual` 使用 List 的 `RPUSH` 将卡 ID 推入手动触发队列Scheduler 在每轮调度中优先消费该队列LPOP
#### Scenario: 手动触发优先于定时队列
- **GIVEN** 某卡定时轮询间隔为 30 分钟,距下次定时触发还有 25 分钟
- **WHEN** 管理员调用手动触发接口 → `EnqueueManual(ctx, cardID, "carddata")`
- **THEN** 卡被推入 `polling:manual:carddata` 列表下一个调度周期1秒内即被 Scheduler 取出推入 Asynq无需等待 25 分钟
#### Scenario: 手动队列不影响分片定时队列
- **GIVEN** cardID=200 的卡在手动队列和定时分片队列均有条目
- **WHEN** Scheduler 先消费手动队列取出 cardID=200推入 Asynq
- **THEN** 分片队列中的条目不受影响仍按原定时间等待Task Handler 执行完后通过 `Requeue` 更新分片队列的 score
---
### Requirement: 合并两个 Callback 实现
删除 `internal/polling/callbacks.go``internal/polling/api_callback.go`,统一通过 `PollingQueueManager` 处理所有卡生命周期事件。
两个进程API 进程和 Worker 进程)共享同一个 `PollingQueueManager` 实例,均只需要 Redis Client无需 Scheduler 实例。
#### Scenario: API 进程处理卡删除不依赖 Scheduler
- **GIVEN** API 进程没有启动 Scheduler只有 Worker 进程有)
- **WHEN** API 进程处理卡删除请求,调用 `pollingQueueMgr.OnCardDeleted(ctx, cardID)`
- **THEN** 卡从所有 4 类任务 × 16 分片队列中移除,卡信息 Redis 缓存清理;操作不依赖 Scheduler 对象API 进程独立完成
#### Scenario: Worker 进程卡状态变化重新入队
- **GIVEN** Worker 进程检测到卡状态变化(如卡由停机变为在线)
- **WHEN** 调用 `pollingQueueMgr.Requeue(ctx, cardID, taskType, time.Now())`
- **THEN** 卡按 `cardID % shardCount` 路由到对应分片score=当前时间戳Scheduler 下一轮即可出队处理
#### Scenario: 两个进程不产生重复清理
- **GIVEN** API 进程和 Worker 进程同时响应同一卡的删除事件(极端情况)
- **WHEN** 两者同时调用 `OnCardDeleted(ctx, cardID)`
- **THEN** Redis ZREM 操作幂等,不产生副作用;缓存 DEL 操作幂等,不报错
---
### Requirement: LifecycleService 入队前检查 enable_pollingM3
`PollingLifecycleService``OnCardCreated``OnCardEnabled``OnCardStatusChanged` 触发重新入队前,**必须**检查资产的 enable_polling 状态,防止禁用轮询的设置因生命周期事件被意外绕过。
#### Scenario: 设备禁用轮询后状态变更不重新入队
- **GIVEN** deviceID=10 已被设置 `enable_polling=false`,设备下 card-AcardID=100因状态变化触发 `OnCardStatusChanged`
- **WHEN** `PollingLifecycleService.OnCardStatusChanged(ctx, 100)` 执行
- **THEN** 先调 `RemoveFromAllQueues(ctx, 100)` 清理队列;再检查 `card.DeviceID` → 查 `device.EnablePolling=false`**跳过** Requeue记录 Debug 日志「设备轮询已禁用,跳过重新入队: deviceID=10, cardID=100」
#### Scenario: 卡自身禁用轮询后不重新入队
- **GIVEN** cardID=200 的 `enable_polling=false`(无绑定设备),触发 `OnCardEnabled`
- **WHEN** `PollingLifecycleService.OnCardEnabled(ctx, 200)` 执行
- **THEN** 检查 `card.EnablePolling=false` → 跳过 Requeue记录 Debug 日志「卡轮询已禁用,跳过入队: cardID=200」
#### Scenario: 删除和禁用事件无需检查 enable_polling
- **GIVEN** `OnCardDeleted``OnCardDisabled` 被调用
- **WHEN** 执行队列清理操作
- **THEN** 直接执行 `RemoveFromAllQueues`,不需要检查 enable_polling清理操作本身是正确行为
---
### Requirement: 监控服务适配分片队列
`PollingQueueManager` 新增 `GetTotalQueueDepth(ctx, taskType)` 方法,聚合所有分片的 `ZCard` 之和,供 `MonitoringService` 替代直接读取旧的非分片 Redis Key。
#### Scenario: MonitoringService 读取分片后的队列深度
- **GIVEN** 16 个分片中 `carddata` 队列分别有 100、200、300...1600 条数据
- **WHEN** `MonitoringService` 调用 `queueMgr.GetTotalQueueDepth(ctx, "carddata")`
- **THEN** 返回 13600所有分片 ZCard 之和);与重构前直接读 `polling:queue:carddata` 的语义等价
#### Scenario: 部分分片 ZCard 失败不影响总体
- **GIVEN** 16 个分片中 1 个分片 Redis 读取超时
- **WHEN** `GetTotalQueueDepth` 执行
- **THEN** 跳过超时分片,返回其余 15 个分片之和;记录 Warn 日志「分片 X 队列深度查询失败」

View File

@@ -0,0 +1,162 @@
### Requirement: EvaluateAndAct——停复机统一入口
`StopResumeService` 新增 `EvaluateAndAct(ctx context.Context, card *model.IotCard) error` 方法,封装完整的停复机判断和执行逻辑。
> **签名说明**:删除原设计的 `carrierType string, carrierID uint` 参数(仅用于日志,放入签名会误导实现者)。
> 设备/单卡维度通过 `card.DeviceID` 推导,日志上下文通过函数内部的 `zap.Field` 记录。
**删除** `internal/task/polling_handler.go` 中的以下函数(迁移到 StopResumeService
- `checkStopResume`
- `shouldStopCard`
- `hasAvailablePackage`(旧版,被新 `hasValidPackage` 替代)
- `stopCardByUsageExhausted`
- `resumeCardByPackageAvailable`
所有 Task Handlercarddata、package、protect在需要停复机决策时统一调用 `stopResumeService.EvaluateAndAct()`
#### Scenario: Task Handler 调用统一停复机入口
- **GIVEN** `carddata_handler.go` 完成流量数据采集和 DB 写入
- **WHEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card)`
- **THEN** StopResumeService 完整执行停复机判断逻辑Handler 不包含任何无条件停复机相关代码(`shouldStop``hasPackage` 等函数不出现在 Handler 文件中)
#### Scenario: 停机原因按优先级记录
- **GIVEN** 卡在线,同时满足「无套餐」和「流量耗尽」和「未实名」三个条件
- **WHEN** `EvaluateAndAct` 执行 `checkStopReasons`
- **THEN** 按优先级取最高:`no_package > traffic_exhausted > not_realname``stop_reason` 字段记录 `no_package`;停机后 DB 中该卡 `stop_reason='no_package'`
#### Scenario: EvaluateAndAct 幂等
- **GIVEN** 卡已停机,`stop_reason='no_package'`,无套餐状态未变化
- **WHEN** 下次轮询再次调用 `EvaluateAndAct`
- **THEN** 检测到卡已停机(`network_status=0`),进入复机判断分支;复机条件不满足(仍无套餐),不发起 Gateway 调用,返回 nilDB 状态不变
---
### Requirement: 停机条件——三种场景全面覆盖
卡在线(`network_status=1`)时,满足以下**任一**条件触发停机:
**条件 Ano_package**:无有效套餐
- 独立卡:`tb_package_usage` 中无 `iot_card_id=卡ID AND status IN (0,1)` 的记录
- 绑定设备的卡:`tb_package_usage` 中无 `device_id=设备ID AND status IN (0,1)` 的记录
- status=0待激活和 status=1激活中均视为有效套餐
**条件 Btraffic_exhausted**:虚流量耗尽
- 活跃套餐 `status=2`(系统标记为虚流量耗尽),或
- 活跃套餐 `data_usage_mb >= data_limit_mb`(且 `data_limit_mb > 0`,防止无限流量卡误判)
**条件 Cnot_realname**:非行业卡且未实名
- `card_category != 'industry'``real_name_status = 0`
#### Scenario: 无套餐触发停机(独立卡)
- **GIVEN** cardID=100 的独立卡(无 device_id`network_status=1`
- **WHEN** `tb_package_usage` 中无任何 `iot_card_id=100 AND status IN(0,1)` 的记录
- **THEN** `checkStopReasons` 返回 `["no_package"]`;发起 Gateway 停机调用3次重试DB 更新 `network_status=0, stop_reason='no_package'`
#### Scenario: 无套餐触发停机设备卡修复Bug1
- **GIVEN** cardID=200 的卡绑定 deviceID=50`network_status=1`
- **WHEN** `tb_package_usage` 中无 `iot_card_id=200` 的记录,但有 `device_id=50 AND status=1` 的记录(购买了设备套餐)
- **THEN** `hasValidPackage` 检测到设备套餐存在,返回 true不触发停机卡保持在线状态修复之前只查 iot_card_id 导致误停机的 Bug
#### Scenario: 流量耗尽触发停机
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=1000, data_usage_mb=1001`
- **WHEN** `isTrafficExhausted` 检测
- **THEN** `data_usage_mb(1001) >= data_limit_mb(1000)` 条件满足;返回 true触发停机`stop_reason='traffic_exhausted'`
#### Scenario: 套餐 status=2 触发停机
- **GIVEN** 卡在线,活跃套餐 `status=2`(系统已标记虚流量耗尽)
- **WHEN** `isTrafficExhausted` 检测
- **THEN** 检测到 `status=2`;返回 true触发停机`stop_reason='traffic_exhausted'`
#### Scenario: 无限流量套餐不误判流量耗尽
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=0`(无限流量),`data_usage_mb=9999`
- **WHEN** `isTrafficExhausted` 检测
- **THEN** `data_limit_mb=0` 时跳过用量比较(防止无限流量卡被误停机);返回 false不触发停机
#### Scenario: 未实名普通卡触发停机
- **GIVEN** 卡在线,`card_category='normal'``real_name_status=0`,有有效套餐且流量未耗尽
- **WHEN** `checkStopReasons` 检测条件 C
- **THEN** `!isRealnameOK(card)` 为 true触发停机`stop_reason='not_realname'`
#### Scenario: 行业卡无需实名不停机
- **GIVEN** 卡在线,`card_category='industry'``real_name_status=0`,有有效套餐
- **WHEN** `checkStopReasons` 检测
- **THEN** `isRealnameOK(card)` 返回 true行业卡豁免实名要求不触发停机卡保持在线
---
### Requirement: 复机条件——全部满足才复机
卡停机(`network_status=0`)时,以下**全部**条件满足才触发自动复机:
1. `stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')`(排除手动停机、运营商停机等其他原因)
2. 有有效套餐且流量未耗尽(`hasValidPackage=true``isTrafficExhausted=false`
3. 行业卡 OR 已实名(`card_category='industry'` OR `real_name_status=1`
#### Scenario: 购买套餐后自动复机
- **GIVEN** cardID=300 因 `no_package` 停机,`stop_reason='no_package'`现在购买了套餐status=1
- **WHEN** 下次轮询执行 `EvaluateAndAct`
- **THEN** 三个复机条件全部满足stop_reason合规 + 有套餐 + 已实名或行业卡);发起 Gateway 复机调用3次重试DB 更新 `network_status=1, stop_reason=''`
#### Scenario: 手动停机不自动复机
- **GIVEN** 卡 `stop_reason='manual'`(管理员手动停机)
- **WHEN** 该卡购买了套餐,完成实名认证,轮询执行 `EvaluateAndAct`
- **THEN** 条件1不满足`'manual'` 不在 IN 列表中);不触发自动复机;卡保持停机状态;需管理员手动复机
#### Scenario: 流量耗尽停机后购买新套餐复机
- **GIVEN** 卡因 `traffic_exhausted` 停机,`stop_reason='traffic_exhausted'`旧套餐已过期status=3新购套餐 status=1usage=0
- **WHEN** 轮询执行 `EvaluateAndAct`
- **THEN** 条件1满足traffic_exhausted条件2满足新套餐有效usage<limit条件3满足已实名触发复机
#### Scenario: 未实名卡实名后复机
- **GIVEN** 普通卡因 `not_realname` 停机,用户完成实名认证(`real_name_status=1`
- **WHEN** 实名检查轮询执行,更新 DB 后调用 `EvaluateAndAct`
- **THEN** 条件1满足条件2满足有套餐且未耗尽条件3满足`real_name_status=1`);触发复机
---
### Requirement: 设备维度停复机——覆盖所有绑定卡
**停机**操作覆盖设备下所有在线卡(`network_status=1`**复机**操作对满足条件的卡执行,跳过未满足 `isRealnameOK` 的普通卡。
#### Scenario: 设备套餐耗尽停所有绑定卡
- **GIVEN** deviceID=10 下有 3 张在线卡cardID=1,2,3设备套餐 `data_usage_mb >= data_limit_mb`
- **WHEN** 任一绑定卡触发 `EvaluateAndAct`,检测到设备套餐流量耗尽
- **THEN** 调用 `stopDeviceCards(ctx, deviceID=10, "traffic_exhausted")`3 张卡均发起 Gateway 停机调用3 张卡均更新 `stop_reason='traffic_exhausted'`
#### Scenario: 设备停机调用 Gateway 带3次重试
- **GIVEN** 设备下有 3 张卡需要停机Gateway 第一次调用返回超时
- **WHEN** `stopCardWithRetry` 执行
- **THEN** 自动重试至多 3 次;至少 1 次成功则记录成功,最终失败则记录 Error 日志「卡停机失败: cardID=xxx, error=xxx」
#### Scenario: 购买设备套餐后全卡复机
- **GIVEN** deviceID=10 下 3 张卡均因 `traffic_exhausted` 停机,购买新套餐后 status=1
- **WHEN** 轮询执行 `EvaluateAndAct``shouldResume` 返回 true
- **THEN** `resumeDeviceCards(ctx, deviceID=10)` 被调用;检查所有 `stop_reason IN(...)` 的停机卡3 张卡均满足 `isRealnameOK`均已实名3 张卡均触发复机
#### Scenario: 设备复机跳过未实名普通卡
- **GIVEN** deviceID=20 下有 3 张卡card-A已实名、card-B已实名、card-C`card_category='normal'`, `real_name_status=0`
- **WHEN** 设备复机条件满足,执行 `resumeDeviceCards`
- **THEN** card-A 和 card-B 触发复机(各执行 Gateway 复机调用card-C 因 `isRealnameOK=false` 被跳过保持停机card-C 的 `stop_reason` 自动更新为 `not_realname`
#### Scenario: 设备复机中单卡 Gateway 失败不阻止其他卡
- **GIVEN** 设备下 3 张卡需要复机card-B 的 Gateway 复机调用失败(含重试)
- **WHEN** `resumeDeviceCards` 遍历执行
- **THEN** card-A 和 card-C 正常复机card-B 记录 Error 日志并跳过,不影响其他卡的复机;`resumeDeviceCards` 整体不返回 error尽力复机语义
---
### Requirement: 设备维度操作幂等锁——防止并发重复 Gateway 调用
设备下多张卡分布在不同分片队列,同一调度周期内可能同时触发 `EvaluateAndAct`,进而并发调用 `stopDeviceCards` / `resumeDeviceCards`,导致同一张卡被 Gateway 重复停/复机。
`stopDeviceCards``resumeDeviceCards` 均在执行前通过 Redis `SetNX` 获取设备操作锁(`polling:device:op_lock:{deviceID}`TTL 30 秒)。获取失败时视为"其他协程正在处理",直接跳过。
#### Scenario: 多卡并发触发不重复调 Gateway
- **GIVEN** deviceID=10 下有 card-Ashard 3和 card-Bshard 7同一调度周期被并发出队
- **WHEN** card-A 和 card-B 同时触发 `EvaluateAndAct` → 均尝试调用 `stopDeviceCards(deviceID=10)`
- **THEN** 第一个调用获取设备锁成功,执行完整的停机流程;第二个调用 `SetNX` 返回 false记录 Debug 日志「设备停机操作已在进行中,跳过: deviceID=10」直接返回设备下各卡只被 Gateway 停机一次
#### Scenario: 设备锁 TTL 超时后可重新获取
- **GIVEN** 设备操作锁已过期(上次操作完成后 `Del` 释放,或 TTL 30 秒到期)
- **WHEN** 下一轮轮询再次触发 `stopDeviceCards`
- **THEN** `SetNX` 成功获取锁,正常执行停机流程

View File

@@ -0,0 +1,267 @@
### Requirement: polling_handler.go 拆分为 4 个专注 Handler
`internal/task/polling_handler.go`1360行拆分为 4 个职责单一的文件,每个文件只负责一种任务类型的数据采集,停复机决策统一委托给 `StopResumeService.EvaluateAndAct()`
**文件目标行数**
| 文件 | 职责 | 目标行数 |
|------|------|---------|
| `polling_base.go` | 共享基类(并发/缓存/重入队) | < 150行 |
| `polling_realname_handler.go` | 实名状态采集 | < 200行 |
| `polling_carddata_handler.go` | 流量数据采集 | < 300行 |
| `polling_package_handler.go` | 套餐数据采集 | < 200行 |
| `polling_protect_handler.go` | 保护期一致性检查 | < 150行 |
**删除**:拆分完成后,`internal/task/polling_handler.go` 整体删除。
**Handler 边界原则**
- ✅ 允许:调 Gateway 采集数据、写 StoreDB更新、调 StopResumeService.EvaluateAndAct()
- ❌ 禁止:直接 `h.db.Model()`(绕过 Store 层)、停复机判断逻辑、直接调 Gateway 停复机接口
#### Scenario: Asynq 任务类型常量不变(向后兼容)
- **GIVEN** 现有 Asynq Worker 已注册 4 种任务类型常量
- **WHEN** 拆分后注册 4 个新 Handler
- **THEN** 任务类型常量名称完全不变(`TaskTypeRealnameCheck``TaskTypeCarddataCheck` 等);已在 Asynq 队列中的待处理任务无需清空,新 Handler 直接接管处理
#### Scenario: 4 个 Handler 并行处理不干扰
- **GIVEN** 同时有 realname、carddata、package、protect 四种任务在处理
- **WHEN** 各 Handler 独立执行
- **THEN** 各 Handler 使用独立的并发锁(`PollingBase.acquireConcurrency` 按任务类型隔离realname 任务的并发数不占用 carddata 任务的并发配额
---
### Requirement: 共享基类 PollingBase
新建 `internal/task/polling_base.go`,提供 `PollingBase` 结构体,供 4 个 Handler 组合使用。
**提取的公共方法**(原来在 `polling_handler.go` 中重复出现):
- `acquireConcurrency(taskType) bool`:获取并发控制信号量
- `releaseConcurrency(taskType)`:释放并发控制信号量
- `getCardWithCache(ctx, cardID) (*model.IotCard, error)`:带 Redis 缓存的卡查询
- `updateCardCache(ctx, card)`:更新 Redis 卡信息缓存
- `requeueCard(ctx, cardID, taskType, interval)`:按间隔重新入队(调 `PollingQueueManager.Requeue`
- `getMatchedPollingInterval(card) time.Duration`:获取该卡匹配的轮询间隔
#### Scenario: 缓存命中减少 DB 压力
- **GIVEN** cardID=100 的卡信息已缓存在 RedisTTL 5分钟
- **WHEN** `polling_realname_handler.go` 调用 `base.getCardWithCache(ctx, 100)`
- **THEN** 直接从 Redis 读取,不查询 DB缓存命中后更新 TTL滑动窗口
#### Scenario: 缓存未命中回源 DB
- **GIVEN** cardID=200 的卡信息不在 Redis 缓存中
- **WHEN** 任一 Handler 调用 `base.getCardWithCache(ctx, 200)`
- **THEN** 查询 DB将结果写入 RedisTTL 5分钟返回卡信息
#### Scenario: 并发控制防止过载——并发满时必须 requeue
- **GIVEN** `carddata` 任务最大并发数配置为 50
- **WHEN** 同时有 60 个 carddata 任务尝试执行
- **THEN** 前 50 个获取到信号量正常执行;后 10 个 `acquireConcurrency` 返回 false**后 10 个任务调 `requeueCard(ctx, cardID, taskType, time.Now())` 立即重入队**,记录 Debug 日志「并发数已满,已重新入队: cardID=xxx」返回 nilAsynq 不报错,不重试)
> **⚠️ 正确性约束**Lua 脚本原子出队时卡已从 Redis Sorted Set 中删除。若并发满时直接 return nil 而不 requeue该卡将永久消失不再被轮询。所有 Handler 必须在 `acquireConcurrency` 返回 false 时先调 `requeueCard` 再返回。
---
### Requirement: polling_realname_handler.go——实名数据采集
**文件**`internal/task/polling_realname_handler.go`< 200行
**构造函数依赖**(通过构造函数注入,禁止全局变量):
```go
func NewPollingRealnameHandler(
base *PollingBase,
gateway GatewayClient,
iotCardStore IotCardStore,
queueClient QueueClient, // 用于触发首次实名激活任务
) *PollingRealnameHandler
```
**方法列表**
- `Handle(ctx, task) error`Asynq 任务入口
- `processCard(ctx, cardID) error`:核心处理逻辑
**处理流程**
1.`base.acquireConcurrency("realname")`,获取失败则跳过
2.`base.getCardWithCache(ctx, cardID)` 获取卡信息
3. 调 Gateway 查询实名状态
4. 写 Store更新 `real_name_status`
5. 若实名状态变为已实名0→1
a. 入队首次实名激活 Asynq 任务(`triggerFirstRealnameActivation`
b. **调 `stopResumeService.EvaluateAndAct(ctx, card)` 触发复机判断**(卡可能因 `not_realname` 停机,需立即复机)
6.`base.requeueCard(ctx, cardID, "realname", interval)` 重新入队
7.`base.releaseConcurrency("realname")`
#### Scenario: 实名状态由未实名变为已实名触发激活和复机
- **GIVEN** cardID=100`real_name_status=0`(未实名),且卡因 `not_realname` 处于停机状态(`network_status=0, stop_reason='not_realname'`
- **WHEN** Gateway 返回实名已完成,`HandleRealnameCheck` 执行
- **THEN** Store 更新 `real_name_status=1`;入队首次实名激活 Asynq 任务;**立即调用 `EvaluateAndAct` 检测复机条件**;若有有效套餐且流量未耗尽,发起 Gateway 复机调用DB 更新 `network_status=1, stop_reason=''`;记录日志「卡实名状态变更: cardID=100, 0→1触发激活任务和复机评估」
#### Scenario: 实名状态未变化不触发激活
- **GIVEN** cardID=200`real_name_status=1`已实名Gateway 返回仍已实名
- **WHEN** `HandleRealnameCheck` 执行
- **THEN** Store 不执行更新(无变化);不入队激活任务;调 `requeueCard` 按正常间隔重新入队
#### Scenario: realname Handler 仅在实名 0→1 时调用 EvaluateAndActS1 修复)
- **GIVEN** `polling_realname_handler.go` 代码 Review
- **WHEN** 检查文件内容
- **THEN** 文件中**不出现**无条件的 `stopCard``resumeCard` 等停复机操作;但**允许且必须**在实名状态由 0→1 时调用 `stopResumeService.EvaluateAndAct(ctx, card)` 触发复机判断;原因:若卡因 `not_realname` 停机,实名完成后应立即复机,不能等下一个 carddata/package 轮询周期(可能长达 1 小时)
---
### Requirement: polling_carddata_handler.go——流量数据采集
**文件**`internal/task/polling_carddata_handler.go`< 300行
**构造函数依赖**
```go
func NewPollingCarddataHandler(
base *PollingBase,
gateway GatewayClient,
iotCardStore IotCardStore,
packageStore PackageUsageStore,
usageService UsageService, // DeductDataUsage
stopResumeService StopResumeServiceInterface,
) *PollingCarddataHandler
```
**方法列表**
- `Handle(ctx, task) error`Asynq 任务入口
- `processCard(ctx, cardID) error`:核心处理逻辑
- `collectUsageData(ctx, card) (*UsageData, error)`:调 Gateway 获取流量增量
**处理流程**
1. 获取并发控制
2. 调 Gateway 查询流量增量
3. 写 Store更新 `data_usage_mb`
4.`usageService.DeductDataUsage()`(套餐流量扣减计算)
5.`stopResumeService.EvaluateAndAct(ctx, card, ...)`(停复机决策)
6. 重新入队,释放并发控制
#### Scenario: 流量更新后委托停复机决策
- **GIVEN** cardID=100流量更新后 `data_usage_mb` 达到 `data_limit_mb`
- **WHEN** `HandleCarddataCheck` 执行完流量写入
- **THEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card, "iot_card", 100)`;由 StopResumeService 决定是否停机Handler 不包含 `if usage >= limit { stop() }` 这类判断代码
#### Scenario: 消除直接 DB 操作
- **GIVEN** 原 `polling_handler.go` 中有 13 处 `h.db.Model()` 直接 DB 操作
- **WHEN** 拆分后的 `polling_carddata_handler.go` 代码 Review
- **THEN** 文件中不出现 `h.db.``db.Model()``db.Where()` 等直接 GORM 调用;所有 DB 操作通过 Store 接口(`iotCardStore.UpdateXxx()``packageStore.UpdateXxx()`
#### Scenario: 跨月流量边界检测(必须保留)
- **GIVEN** cardID=100`current_month_start_date=2026-03-01`,当前日期为 `2026-04-02`
- **WHEN** Gateway 返回月度总流量 `500MB``processCard` 检测到跨月(当前月份首日 != current_month_start_date
- **THEN** 保存 `last_month_total_mb = 旧 current_month_total`;重置 `current_month_usage_mb = 500`(以 Gateway 新月份值为准);更新 `current_month_start_date = 2026-04-01`;记录流量历史到 `data_usage_records`
#### Scenario: 同月流量增量计算
- **GIVEN** cardID=200`current_month_start_date=2026-04-01``last_month_total_mb=100`,当前日期为 `2026-04-02`
- **WHEN** Gateway 返回月度总流量 `150MB`
- **THEN** 计算增量 `delta = 150 - 100 = 50MB`;更新 `current_month_usage_mb += 50`;更新 `last_month_total_mb = 150`
#### Scenario: Gateway 调用失败不丢失数据
- **GIVEN** cardID=300Gateway 返回网络超时
- **WHEN** `collectUsageData` 调用 Gateway 失败
- **THEN** 不更新 DB不写入错误数据记录 Warn 日志「流量查询失败: cardID=300, error=xxx」`requeueCard` 按较短间隔重试间隔重新入队Asynq 层面不触发重试Handler 返回 nilMaxRetry=0
---
### Requirement: polling_package_handler.go——套餐数据采集
**文件**`internal/task/polling_package_handler.go`< 200行
**构造函数依赖**
```go
func NewPollingPackageHandler(
base *PollingBase,
gateway GatewayClient,
packageStore PackageUsageStore,
stopResumeService StopResumeServiceInterface,
) *PollingPackageHandler
```
**方法列表**
- `Handle(ctx, task) error`Asynq 任务入口
- `processCard(ctx, cardID) error`:核心处理逻辑
**处理流程**
1. 获取并发控制
2. 调 Gateway 查询套餐信息(剩余流量、状态)
3. 写 Store更新 `tb_package_usage` 套餐状态/用量)
4.`stopResumeService.EvaluateAndAct(ctx, card, ...)`(停复机决策)
5. 重新入队,释放并发控制
#### Scenario: 套餐到期后触发复机判断
- **GIVEN** 卡因 `traffic_exhausted` 停机旧套餐已过期status=3Gateway 返回新套餐已激活
- **WHEN** `HandlePackageCheck` 执行,更新套餐状态后
- **THEN** 调 `EvaluateAndAct`StopResumeService 检测到新套餐有效、流量未耗尽触发复机Handler 不直接调用 resumeCard
#### Scenario: package Handler 不做停复机判断
- **GIVEN** `polling_package_handler.go` 代码 Review
- **WHEN** 检查文件内容
- **THEN** 文件中不出现 `shouldStopCard``hasAvailablePackage``stopCard``resumeCard` 等函数;套餐处理逻辑仅限于数据采集和 Store 写入
---
### Requirement: polling_protect_handler.go——保护期一致性检查
**文件**`internal/task/polling_protect_handler.go`< 200行
> **业务语义说明**:本 Handler 的核心目的是"确保保护期内卡状态与保护期方向一致,防止状态漂移"。
> 保护期**内**:强制修正状态(直接调 Gateway不走 EvaluateAndAct 三条件判断)。
> 保护期**结束后**:调 EvaluateAndAct 重新评估(按正常停复机逻辑处理)。
**构造函数依赖**
```go
func NewPollingProtectHandler(
base *PollingBase,
gateway GatewayClient, // 保护期内强制停复机需要直接调 Gateway
iotCardStore IotCardStore,
stopResumeService StopResumeServiceInterface,
) *PollingProtectHandler
```
**方法列表**
- `Handle(ctx, task) error`Asynq 任务入口
- `processCard(ctx, cardID) error`:核心处理逻辑
**处理流程**
1. 获取并发控制(并发满时 **requeue 后返回**,不丢弃)
2. 查 Store 获取卡信息
3. 前置跳过检查:
- 卡未实名(`real_name_status=0`)→ requeue直接返回
- 卡未绑定设备(`is_standalone=true`)→ requeue直接返回
4. 读取设备保护期 Redis Key`stop` 保护期 Key + `start` 保护期 Key
5. 根据保护期状态执行对应逻辑:
- **有 stop 保护期 + 卡在线network_status=1**:直接调 `gateway.StopCard`(强制修正),写 DB `stop_reason='protect'`
- **有 start 保护期 + 卡停机network_status=0**:直接调 `gateway.StartCard`(强制修正),清空 `stop_reason`
- **有保护期 + 状态已一致**:跳过(不调 Gateway不调 EvaluateAndAct
- **无保护期(保护期已结束)**:调 `stopResumeService.EvaluateAndAct(ctx, card)` 重新评估
6.`base.requeueCard` 按间隔重新入队
7. 释放并发控制
#### Scenario: stop 保护期内卡在线——强制停机
- **GIVEN** 卡已实名且绑定设备,设备有 stop 保护期(`polling:protect:stop:{deviceID}` Key 存在),但卡当前 `network_status=1`(在线,与保护期方向不一致)
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到 stop 保护期存在且状态不一致;直接调 `gateway.StopCard`(不走 EvaluateAndAct强制修正DB 更新 `network_status=0, stop_reason='protect'`;记录 Info 日志「保护期强制停机: cardID=xxx, deviceID=xxx」
#### Scenario: start 保护期内卡停机——强制复机
- **GIVEN** 卡已实名且绑定设备,设备有 start 保护期(`polling:protect:start:{deviceID}` Key 存在),但卡当前 `network_status=0`(停机,与保护期方向不一致)
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到 start 保护期存在且状态不一致;直接调 `gateway.StartCard`(不走 EvaluateAndAct强制修正DB 更新 `network_status=1, stop_reason=''`;记录 Info 日志「保护期强制复机: cardID=xxx, deviceID=xxx」
#### Scenario: 保护期内状态已一致——跳过
- **GIVEN** 设备有 stop 保护期,卡已是停机状态(`network_status=0`
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到保护期存在且状态已一致;跳过,不调 Gateway不调 EvaluateAndAct直接 requeue
#### Scenario: 保护期已结束——调 EvaluateAndAct 重新评估
- **GIVEN** 卡在保护期内已停机(`stop_reason='protect'`),保护期 Key 已过期TTL = 0
- **WHEN** `HandleProtectConsistencyCheck` 执行,读取保护期 Key 不存在
- **THEN** 调 `stopResumeService.EvaluateAndAct(ctx, card)` 重新评估;若有有效套餐且已实名,触发复机;否则保持停机(`stop_reason` 更新为实际原因)
#### Scenario: 未实名卡跳过保护期逻辑
- **GIVEN** 卡 `real_name_status=0`(未实名)
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到未实名,直接 requeue不检查保护期不调 Gateway
#### Scenario: 独立卡(未绑定设备)跳过
- **GIVEN** 卡 `is_standalone=true`(未绑定设备)
- **WHEN** `HandleProtectConsistencyCheck` 执行
- **THEN** 检测到独立卡,直接 requeue设备保护期与独立卡无关