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

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>
2026-04-07 12:27:04 +08:00

41 KiB
Raw Permalink Blame History

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}

分片路由算法

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

背压机制

// 单分片队列深度超过阈值(默认 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

-- 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 端封装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

  • checkStopResumeshouldStopCardhasAvailablePackage
  • stopCardByUsageExhaustedresumeCardByPackageAvailable
  • 任何直接调用 h.db.Model() 的语句

决策 5PollingQueueManager 接口设计

// 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:typePhase 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.goprocessSchedule() 每 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,封装「配置匹配 + 队列操作」组合逻辑。

// 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 调用
// 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.goprocessCard 方法须完整迁移以下逻辑:

  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 新增 StopReasonNoPackageStopReasonNotRealname
  • 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
  • 新增 isTrafficExhaustedisRealnameOKcheckStopReasons
  • 新增 EvaluateAndAct 统一入口
  • 修改 stopCardWithRetry 接收 stopReason 参数
  • 修改 resumeSingleCardresumeDeviceCards 按新复机条件
  • 删除旧 hasAvailablePackage

可部署状态 StopResumeService 兼容旧调用,新方法独立,可安全部署 回滚方案:删除新增方法(EvaluateAndActhasValidPackage 等),恢复旧 hasAvailablePackage。新方法在 Phase 3 之前无调用者,回滚不影响现有功能。

Phase 3PollingQueueManager + PollingConfigManager基础组件

变更内容

  • 新建 queue_manager.go(含分片 Sorted Set、Lua 脚本原子出队、分批 ZREM
  • 新建 config_manager.goDB 加载 → 内存缓存 → Redis 同步 → 5分钟定时刷新

前置依赖Phase 1 常量(RedisPollingShardQueueKey 被依赖方Phase 4 的 PollingBase 和 Phase 5 的 Scheduler 均依赖这两个组件

可部署状态 新增文件,无功能变化(旧 Scheduler 和 callbacks.go 仍在运行),新组件创建但尚未被调用 回滚方案:删除新建的 queue_manager.goconfig_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 到 AsynqMaxRetry(0) 不变,详见决策 12
  • 删除旧 polling_handler.go

Phase 5 变更内容

  • 新建 initializer.go(分片渐进式初始化)
  • 新建 lifecycle_service.go(替代 callbacks.go 的卡生命周期方法,详见决策 10
  • 精简 scheduler.go< 250行保留调度循环 + 套餐过期检测触发 + 流量重置触发,详见决策 9
  • 更新 MonitoringService(适配分片队列,详见决策 11
  • 删除 callbacks.goapi_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.goMonitoringService此为最高风险节点,涉及出队机制变更和队列数据结构变更。建议:① 部署前清空旧队列(初始化器会重建);② 灰度观察 48 小时。

Phase 6轮询管控 APIenable_polling 接口)

变更内容

  • DTOUpdateAssetPollingStatusRequest/Response
  • Storedevice_store.UpdatePollingStatus
  • ServiceAssetPollingService.UpdatePollingStatus
  • Handlerasset.UpdatePollingStatus
  • RoutePATCH /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 调用。

决定:在 stopDeviceCardsresumeDeviceCards 执行前,使用 Redis 分布式锁(SetNX)确保设备维度操作的幂等性。

实现方案

// 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

// 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修复 getCardConditionsuspended 永远不返回的问题M1

问题背景
现有 getCardCondition 逻辑如下:

if card.RealNameStatus != RealNameStatusVerified { return "not_real_name" }
if card.NetworkStatus == 1 { return "activated" }
return "real_name"   // 停机+已实名卡落到这里
  • 停机且已实名的卡返回 "real_name",而 "real_name" 配置通常 carddata_check_interval=nullpackage_check_interval=null
  • 结果:停机卡不再被 carddata/package 轮询,EvaluateAndAct 不会被调用,无法自动复机
  • card_condition='suspended' 在 DTO 中作为合法值存在,但 getCardCondition 从未返回该值,是死代码

修复方案:在 PollingConfigManager.getCardCondition 中,最先检查网络状态:

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 相同或更低频率),确保停机卡能持续被轮询以便自动复机。示例:

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 写入 RedisPollingConfigManager 读取,修改无需重启。

  2. polling_handler.goHandleCarddataCheck 调用的 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 无需感知替换。