## Context ### 当前架构(混乱状态) ``` internal/ ├── polling/ │ ├── scheduler.go (764行) │ │ 职责:调度循环 + 卡初始化 + 配置管理 + 生命周期回调 │ │ + 套餐激活触发 + 流量重置触发 │ │ 问题:六职合一,改一处可能破坏另外五处 │ ├── callbacks.go (228行) │ │ 职责:Worker进程卡生命周期回调(Redis队列操作) │ │ 问题:与 api_callback.go 大量重复 │ ├── api_callback.go (107行) │ │ 职责:API进程卡生命周期回调(同上,只是所在进程不同) │ │ 问题:与 callbacks.go 几乎相同代码,双重维护 │ ├── package_activation_handler.go (505行) 套餐过期处理(直接DB) │ └── data_reset_handler.go (116行) 流量重置调度 │ └── task/ └── polling_handler.go (1360行) 职责:4类检查(实名/流量/套餐/保护期) + 停复机决策(与 stop_resume_service.go 重复) + 直接DB操作(13处,绕过Store层) + 直接Gateway调用(7处,无重试) + 缓存管理 + 队列管理 问题:单文件承担8类职责,改动风险极高 service/iot_card/ └── stop_resume_service.go (410行) 职责:停复机逻辑(有3次重试) 问题:与 polling_handler 中另一套停复机逻辑并存, hasAvailablePackage 重复定义, 只查 iot_card_id,遗漏 device_id(Bug1) ``` **四个线上 Bug:** ``` Bug 1:设备套餐误停机 PackageUsage.device_id 存设备套餐, 但 hasAvailablePackage 只查 iot_card_id → 购买设备套餐后仍被误停机 Bug 2:停机条件不完整 仅检查「无套餐」,未检查「流量耗尽」「非行业卡未实名」 Bug 3:protect 队列遗漏 removeFromAllQueues 只清 realname/carddata/package 3个队列 漏掉 protect → 删除卡后 protect 队列残留脏数据 Bug 4:出队竞态 ZRANGEBYSCORE + ZREMRANGEBYSCORE 两步非原子 高并发下同一张卡可能被多个 Worker 重复取出 ``` ### 目标架构(清晰) ``` internal/ ├── polling/ │ ├── scheduler.go (<200行) 纯调度循环 │ │ 职责:从分片队列出队(Lua脚本原子出队)→ 批量推入 Asynq │ │ 依赖:PollingQueueManager、PollingConfigManager │ │ │ ├── queue_manager.go (<200行) 统一 Redis 队列操作 │ │ 职责:DequeueReady(Lua脚本原子出队) │ │ Requeue(ZADD重入队) │ │ RemoveFromAllQueues(含protect,修复Bug3) │ │ EnqueueManual(手动触发) │ │ OnCardDeleted(卡删除清理) │ │ 特性:分片Sorted Set支持千万级 │ │ │ ├── config_manager.go (<150行) 配置管理 │ │ 职责:从DB加载PollingConfig → 内存缓存(读写锁) │ │ → Redis同步(TTL 24h)→ 5分钟定时刷新 │ │ │ └── initializer.go (<250行) 分片渐进式初始化 │ 职责:分批(10万/批)从DB加载卡 │ → card_id % shard_count 分桶入队 │ → 跳过 enable_polling=false 的卡 │ → 暴露进度给 MonitoringService │ └── task/ (每个 < 300行) ├── polling_base.go (<150行) 共享基类 │ 职责:并发控制(acquireConcurrency/releaseConcurrency) │ 卡缓存(getCardWithCache/updateCardCache) │ 重入队(requeueCard) │ 配置匹配(getMatchedPollingInterval) │ ├── polling_realname_handler.go (<200行) 实名检查 │ 职责:调Gateway查实名 → 写DB → 触发首次实名激活任务 │ 不做:停复机决策 │ ├── polling_carddata_handler.go (<300行) 流量检查 │ 职责:调Gateway查流量增量 → 写DB → 调DeductDataUsage │ → 调 StopResumeService.EvaluateAndAct() │ 不做:判断是否停机(由StopResumeService决定) │ ├── polling_package_handler.go (<200行) 套餐检查 │ 职责:计算套餐流量使用 │ → 调 StopResumeService.EvaluateAndAct() │ 不做:停复机决策 │ └── polling_protect_handler.go (<150行) 保护期检查 职责:检查保护期Redis Key → 调 StopResumeService 执行保护期停复机 不做:停复机决策 service/iot_card/ └── stop_resume_service.go 唯一停复机入口 新增:EvaluateAndAct(ctx, card, carrierType, carrierID) error 修复:设备套餐查询 Bug(device_id vs iot_card_id) 新增:三条件停机(无套餐/流量耗尽/非行业卡未实名) 完整:复机条件(含行业卡豁免) 设备维度:停机覆盖所有卡,复机跳过未实名普通卡 ``` **架构原则:** ``` Task Handler = 数据采集层(调Gateway + Store,不做业务决策) StopResumeService = 停复机的唯一决策和执行层 PollingQueueManager = Redis 操作的唯一封装 PollingConfigManager = 配置管理的唯一封装 CardInitializer = 初始化的唯一封装 PollingLifecycleService = 卡生命周期轮询管理(替代 callbacks.go) ``` ## Goals / Non-Goals **Goals:** - 每个文件职责单一,Handler 类控制在 < 300 行,Scheduler < 250 行 - 停复机逻辑有且只有一处(StopResumeService.EvaluateAndAct) - 消除所有直接 DB 访问(polling_handler → Store 层方法) - 消除无重试的 Gateway 直调(统一通过 StopResumeService 3次重试) - 修复原子性 Bug(Lua 脚本原子出队替代 ZRANGEBYSCORE+ZREMRANGEBYSCORE) - 修复 protect 队列遗漏 Bug(RemoveFromAllQueues 覆盖4个队列) - 修复设备套餐查询 Bug(device_id vs iot_card_id) - 实现三条件停机判断(无套餐/流量耗尽/非行业卡未实名) - 支持千万级规模(分片 Sorted Set,16分片默认,背压机制) - 新增 Device.enable_polling 字段和轮询管控 HTTP 接口 **Non-Goals:** - 不改变 Asynq 任务类型常量(保持任务名称向后兼容) - 不改变已有 Redis Key 结构(仅新增分片 Key,旧 Key 不变) - 不改变 HTTP API 接口契约(32个接口零改动;4个监控接口内部实现适配分片队列,对外响应格式不变) - 不改变 `StopResumeCallback` 接口(`triggerStopAfterExpiry()` 和 `checkAndTriggerSuspension()` 不受影响) - 不修改 PackageActivationHandler 内部逻辑(另立提案) - 不引入新的外部依赖(Lua 脚本、ZRANGEBYSCORE、ZREM 均为 Redis 原生命令) ## Decisions ### 决策 1:分片队列设计(千万级核心) **问题背景**:单个 Sorted Set 存千万级卡 ID,单节点出队成为瓶颈。 **数学估算**: ``` 目标规模:1000万张卡,4种任务类型 单队列深度:1000万 / 4 = 250万 单次出队:每秒 1 次,每次取 1000条 → 2500秒处理完一轮 ≈ 41分钟 16个分片后:每分片 ~15.6万张卡,每分片每次取 1000条 并行消费:16分片同时 Lua 脚本原子出队,吞吐量提升 16x ``` **Key 命名规范**: ``` 分片队列 Key:polling:shard:{shardID}:queue:{taskType} shardID:0 到 N-1(默认 N=16) taskType:realname | carddata | package | protect 示例:polling:shard:3:queue:carddata 手动触发 Key(List):polling:manual:{taskType} 示例:polling:manual:realname 背压监控 Key(已有):polling:stats:queue_depth:{taskType} ``` **分片路由算法**: ```go // 入队时按 card_id 取模分桶,确保同一张卡始终在同一分片 shardID := cardID % uint(shardCount) key := fmt.Sprintf("polling:shard:%d:queue:%s", shardID, taskType) ``` **背压机制**: ```go // 单分片队列深度超过阈值(默认 500K)时,跳过该分片本轮调度 depth, _ := redis.ZCard(ctx, shardKey).Result() if depth > BackpressureThreshold { continue // 跳过该分片,防止 Asynq 过载 } ``` **初始化阶段兼容**:初始化完成前(initCompleted=false),分片队列保持为空,Scheduler 不出队;初始化通过 CardInitializer.Run() 并行写入各分片。 --- ### 决策 2:Lua 脚本原子出队替代 ZRANGEBYSCORE+ZREMRANGEBYSCORE **问题**:当前两步操作存在两层风险: ``` 风险1(重复处理): Worker A:ZRANGEBYSCORE → 得到 [card1, card2] Worker B:ZRANGEBYSCORE → 得到 [card1, card2](未被移除) 结果:card1 和 card2 被重复入队 Asynq,触发重复停复机 Gateway 调用 风险2(卡丢失,更严重): ZRANGEBYSCORE 有 LIMIT(如 50000),ZREMRANGEBYSCORE 无 LIMIT 当到期卡数 > 50000 时,ZRANGEBYSCORE 只读前 50000 张 ZREMRANGEBYSCORE 删除全部到期卡(含未读取的后 50000 张) 结果:超出批次的卡永久丢失,不再被轮询 ``` **解决方案**:使用 Lua 脚本在 Redis 服务端原子执行 ZRANGEBYSCORE + ZREM: ```lua -- polling_dequeue.lua -- KEYS[1]: 分片队列 Key -- ARGV[1]: 当前时间戳(score 上限,只取到期卡) -- ARGV[2]: 批次大小(LIMIT,Go 层已限制 ≤ 7000,即 DequeueMaxBatchSize) local results = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2])) -- 注意:由于 Go 层已将 batchSize 限制在 7000 以内,results 最多 7000 个。 -- 以下分批 ZREM 循环实际只执行一次,是防御性代码(应对未来放宽 batchSize 上限的情况)。 -- Lua unpack() 在 Lua 5.1(Redis 内置版本)中受 LUAI_MAXCSTACK 约 8000 限制。 for i = 1, #results, 7000 do local j = math.min(i + 6999, #results) redis.call('ZREM', KEYS[1], unpack(results, i, j)) end return results ``` ```go // Go 端封装(redis.NewScript 自动处理 EVALSHA 缓存) // DequeueMaxBatchSize Lua 脚本单次出队上限(受 Lua unpack 栈限制,不超过 7000) // 实际上限制在此值时,Lua 内部的 7000 分批 ZREM 循环只执行一次(防御性代码) const DequeueMaxBatchSize = 7000 var dequeueScript = redis.NewScript(` local results = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2])) for i = 1, #results, 7000 do local j = math.min(i + 6999, #results) redis.call('ZREM', KEYS[1], unpack(results, i, j)) end return results `) func (m *PollingQueueManager) DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error) { // 防御:batchSize 不超过 Lua unpack 栈限制,保证 results ≤ DequeueMaxBatchSize if batchSize > DequeueMaxBatchSize { batchSize = DequeueMaxBatchSize } key := constants.RedisPollingShardQueueKey(shardID, taskType) now := time.Now().Unix() results, err := dequeueScript.Run(ctx, m.redis, []string{key}, now, batchSize).StringSlice() // 解析 results 为 []CardEntry(Redis ZRANGEBYSCORE 返回 string,需 strconv.ParseUint 转换为 uint) } ``` **语义保持说明**: - 保留 ZRANGEBYSCORE 的时间过滤语义:只取 score ≤ now 的到期卡,不触碰未来项 - 保留 LIMIT 语义:每次最多取 batchSize 条,不多删 - 原子性保证:Lua 脚本在 Redis 单线程中执行,ZRANGEBYSCORE 和 ZREM 之间无竞态窗口 - 重入队由 Task Handler 在处理完成后通过 `PollingQueueManager.Requeue()` 负责 **为何不用 ZPOPMIN**: - ZPOPMIN 弹出 score 最小的 N 个成员,**不区分 score 是否 ≤ now** - 卡的 score 是下次检查时间戳,未到期卡(score > now)会被提前取出处理,违反轮询间隔设计 - 初始化阶段大量卡 score 在未来,ZPOPMIN 会将它们全部错误地提前处理 **Lua 脚本性能说明**: - Redis Lua 在服务端单线程执行,与原生命令性能差异在微秒级 - 脚本首次执行后被 Redis 缓存(SHA1),后续通过 EVALSHA 执行,等效于原生命令 - 对于 1 秒间隔的调度循环,此差异无实际影响 --- ### 决策 3:StopResumeService.EvaluateAndAct() 完整伪代码设计 > **签名变更(Mi2 修复)**:删除原设计中的 `carrierType string, carrierID uint` 参数。 > 这两个参数原来"仅用于日志",放在方法签名里会误导实现者认为它们影响业务逻辑。 > 设备/单卡维度的判断通过 `card.DeviceID` 完全可推导,日志上下文通过 `zap.Field` 传递。 ``` EvaluateAndAct(ctx, card): // 停复机维度通过 card.DeviceID 推导: // card.DeviceID != nil → 设备维度(停/复机覆盖设备下所有卡) // card.DeviceID == nil → 单卡维度 if card.NetworkStatus == Online(1): // 卡在线,检查是否需要停机 reasons = checkStopReasons(ctx, card) if len(reasons) > 0: // 取最高优先级原因(no_package > traffic_exhausted > not_realname) primaryReason = reasons[0] if card.DeviceID != nil: // 设备维度:停机覆盖设备下所有在线卡 stopDeviceCards(ctx, card.DeviceID, primaryReason) else: // 单卡维度 stopCardWithRetry(ctx, card, primaryReason) else if card.NetworkStatus == Offline(0): // 卡停机,检查是否可以复机 if card.StopReason NOT IN [traffic_exhausted, no_package, not_realname]: return nil // 手动停机等非轮询原因,不自动复机 if shouldResume(ctx, card): if card.DeviceID != nil: resumeDeviceCards(ctx, card.DeviceID) else: resumeSingleCard(ctx, card) checkStopReasons(ctx, card) → []string: reasons = [] // 条件A:无有效套餐(优先级最高) if !hasValidPackage(ctx, card): reasons += ["no_package"] // 条件B:虚流量耗尽 if isTrafficExhausted(ctx, card): reasons += ["traffic_exhausted"] // 条件C:非行业卡且未实名 if !isRealnameOK(card): reasons += ["not_realname"] return reasons // 已按优先级排序 hasValidPackage(ctx, card) → bool: if card.DeviceID != nil: // 修复Bug1:设备套餐查 device_id count = db.Count(tb_package_usage WHERE device_id=card.DeviceID AND status IN(0,1)) else: count = db.Count(tb_package_usage WHERE iot_card_id=card.ID AND status IN(0,1)) return count > 0 isTrafficExhausted(ctx, card) → bool: // 查询活跃(status=1)或已耗尽(status=2)的套餐 // ORDER BY status ASC: 优先取 status=1(活跃),再取 status=2(耗尽) // 语义:只要存在一个活跃套餐(status=1)且未超限,就不判定为流量耗尽 if card.DeviceID != nil: pkg = db.Get(tb_package_usage WHERE device_id=card.DeviceID AND status IN(1,2) ORDER BY status ASC LIMIT 1) else: pkg = db.Get(tb_package_usage WHERE iot_card_id=card.ID AND status IN(1,2) ORDER BY status ASC LIMIT 1) if pkg == nil: return false // 无活跃或耗尽套餐,不判定为流量耗尽 // status=2 表示系统已标记虚流量耗尽 if pkg.Status == 2: return true // 活跃套餐(status=1):用量 >= 上限(data_limit_mb > 0 防止无限流量卡误判) // 业务约定:data_limit_mb=0 表示无限流量套餐,永远不判定为耗尽 return pkg.DataLimitMB > 0 && pkg.DataUsageMB >= pkg.DataLimitMB isRealnameOK(card) → bool: // 行业卡无需实名 return card.CardCategory == "industry" || card.RealNameStatus == 1 shouldResume(ctx, card) → bool: return hasValidPackage(ctx, card) && !isTrafficExhausted(ctx, card) && isRealnameOK(card) stopDeviceCards(ctx, deviceID, stopReason): cards = db.List(tb_iot_card WHERE device_id=deviceID AND network_status=1) for card in cards: stopCardWithRetry(ctx, card, stopReason) // 3次重试 resumeDeviceCards(ctx, deviceID): cards = db.List(tb_iot_card WHERE device_id=deviceID AND network_status=0 AND stop_reason IN(traffic_exhausted, no_package, not_realname)) for card in cards: if isRealnameOK(card): // 跳过未实名普通卡 resumeSingleCard(ctx, card) ``` --- ### 决策 4:Task Handler 职责边界表 > **S1 修复**:`polling_realname_handler.go` 在检测到实名状态 0→1 时,**必须**调用 `EvaluateAndAct`, > 以确保因 `not_realname` 停机的卡在实名完成后能立即复机,不依赖下一个 carddata/package 轮询周期(可能长达 1 小时)。 > 这不是"一般性停复机决策",而是实名事件驱动的精确复机触发。 | Handler | 调用 Gateway | 调用 Store | 调用 StopResumeService | 禁止 | |---------|-------------|-----------|----------------------|------| | `polling_realname_handler.go` | ✅ QueryRealname | ✅ UpdateRealNameStatus | ⚡ **仅当实名状态 0→1 时**调 `EvaluateAndAct`(触发 not_realname 复机) | 无条件停复机判断 | | `polling_carddata_handler.go` | ✅ QueryDataUsage | ✅ UpdateDataUsage | ✅ EvaluateAndAct | 直接停复机判断 | | `polling_package_handler.go` | ✅ QueryPackageInfo | ✅ UpdatePackageUsage | ✅ EvaluateAndAct | 直接停复机判断 | | `polling_protect_handler.go` | ✅ **保护期内强制停复机**(StopCard/StartCard) | ✅ GetCard | ⚡ **仅保护期结束后**调 `EvaluateAndAct`(重新评估) | 将保护期内操作走 EvaluateAndAct 三条件判断 | | `polling_base.go`(共享基类) | ❌ | ✅ GetCard(缓存) | ❌ | 业务逻辑 | > **protect Handler 双路径说明**: > - 保护期**内**(保护期 Key 存在)+ 状态不一致 → 直接调 Gateway 强制修正(绕过 EvaluateAndAct,保护期期间强制执行,无论套餐/流量/实名状态如何) > - 保护期**结束后**(保护期 Key 不存在) → 调 `EvaluateAndAct` 重新评估正常停复机条件 > - 两种路径不可混淆:保护期内的强制修正是"一致性保障",不能被三条件判断覆盖 **Handler 不再包含的函数**(全部迁移到 StopResumeService): - `checkStopResume`、`shouldStopCard`、`hasAvailablePackage` - `stopCardByUsageExhausted`、`resumeCardByPackageAvailable` - 任何直接调用 `h.db.Model()` 的语句 --- ### 决策 5:PollingQueueManager 接口设计 ```go // PollingQueueManager 统一 Redis 轮询队列操作 // 两个进程(API 进程和 Worker 进程)共享,仅依赖 Redis Client type PollingQueueManager interface { // DequeueReady 原子出队到期卡(Lua 脚本:ZRANGEBYSCORE + ZREM 服务端原子执行) // 只取 score ≤ now 的到期卡,不触碰未来项 // taskType: realname | carddata | package | protect // shardID: 0 到 shardCount-1 // batchSize: 每次出队数量上限 DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error) // Requeue 将卡重新入队指定时间 Requeue(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) error // RemoveFromAllQueues 从所有分片的所有4个队列(含protect)移除 RemoveFromAllQueues(ctx context.Context, cardID uint) error // EnqueueManual 手动触发入队(List RPUSH,调度器优先消费) EnqueueManual(ctx context.Context, cardID uint, taskType string) error // OnCardDeleted 卡删除事件处理(移除队列 + 清理缓存) OnCardDeleted(ctx context.Context, cardID uint) error // GetQueueDepth 获取分片队列深度(用于背压检测和监控统计) GetQueueDepth(ctx context.Context, shardID int, taskType string) (int64, error) } // CardEntry 出队卡信息 type CardEntry struct { CardID uint Score float64 // Unix 时间戳(到期时间) } ``` --- ### 决策 6:Scheduler 精简设计(伪代码) ``` Scheduler: 依赖:PollingQueueManager、PollingConfigManager、Asynq Client、 PackageActivationHandler、DataResetHandler 不再包含:DB查询、配置加载、卡初始化、Gateway调用、Callback注册 Start(ctx): cardInitializer.Run(ctx) // 异步,后台渐进式初始化 configManager.Start(ctx) // 定时刷新配置 go scheduleLoop(ctx) // 调度主循环 scheduleLoop(ctx): ticker = time.NewTicker(1秒) activationTicker = time.NewTicker(10秒) // 套餐过期检测周期 for { select { case <-ticker.C: for shardID in 0..shardCount-1: go processShardSchedule(ctx, shardID) // 并行消费分片 case <-activationTicker.C: // ⚠️ 必须保留:套餐过期检测和流量重置(决策 9) packageActivationHandler.HandlePackageActivationCheck(ctx) dataResetHandler.HandleDataReset(ctx) } } processShardSchedule(ctx, shardID): for taskType in [realname, carddata, package, protect]: // 背压检测 depth = queueMgr.GetQueueDepth(ctx, shardID, taskType) if depth > BackpressureThreshold: continue // 跳过,Asynq 已有大量待处理任务 // 优先消费手动触发队列 processManualQueue(ctx, taskType, MaxManualBatch) // Lua 脚本原子出队到期卡(score ≤ now) entries = queueMgr.DequeueReady(ctx, shardID, taskType, ScheduleBatchSize) enqueueBatch(ctx, entries, taskType) // 批量推入 Asynq enqueueBatch(ctx, entries, taskType): for entry in entries: task = asynq.NewTask(taskType, payload{CardID: entry.CardID}) // ⚠️ MaxRetry(0) 保持不变(决策 12):避免与 Scheduler 出队产生并发双重处理 err = asynqClient.Enqueue(task, asynq.Queue("polling"), asynq.MaxRetry(0)) if err != nil: // 回退:Asynq 入队失败时,将卡重新放回分片队列,防止卡丢失 queueMgr.Requeue(ctx, entry.CardID, taskType, time.Now()) logger.Error("Asynq入队失败,已回退到分片队列", cardID=entry.CardID, error=err) ``` --- ### 决策 7:CardInitializer 分片初始化伪代码 ``` CardInitializer: 依赖:Store(DB查询)、PollingQueueManager、PollingConfigManager 状态:progress(已处理卡数)、total(总卡数)、completed(是否完成) Run(ctx): total = store.CountIotCards(ctx) // 查询总卡数 offset = 0 for offset < total: // 分批加载,每批 10万张 cards = store.ListIotCards(ctx, offset, BatchSize=100000) for card in cards: if !card.EnablePolling: continue // 跳过禁用轮询的卡 cfg = configManager.MatchConfig(card) if cfg == nil: continue // 无匹配配置,跳过 shardID = card.ID % shardCount // 分片路由 for taskType in cfg.EnabledTaskTypes: // ZADD:score = now + initialDelay(随机散列,避免同时触发) initialDelay = rand.Intn(cfg.Interval) queueMgr.Requeue(ctx, card.ID, taskType, now.Add(initialDelay)) progress += len(cards) offset += BatchSize time.Sleep(500ms) // 批次间休眠,减少对 DB 和 Redis 的压力 completed = true GetProgress() → InitProgress: return {Total: total, Processed: progress, Completed: completed} ``` ### 决策 8:Phase 4/5 原子部署(解决迁移断层) **问题**:Phase 4 新 Handler 使用 `PollingBase.requeueCard()` 写入分片键(`polling:shard:N:queue:type`),Phase 5 新 Scheduler 从分片键读取。但若 Phase 4 和 Phase 5 分开部署,中间窗口期旧 Scheduler 仍从非分片键读取 → 新 Handler 重入队到分片键的卡永久消失。 **决定**:Phase 4 和 Phase 5 **必须原子部署**(同一次上线),取消中间 24 小时观察窗口。 **理由**: - 双写方案(同时写新旧两套键)增加代码复杂度且引入清理问题 - Phase 4 和 Phase 5 共同构成"新出队/入队管线",拆开部署无意义 - 原子部署配合 Phase 1-3 的逐步准备,风险可控 **回滚方案**:原子回滚 Phase 4+5 → 恢复旧 `polling_handler.go` + 旧 `scheduler.go` + 旧 `callbacks.go`。 --- ### 决策 9:Scheduler 保留套餐过期检测和流量重置触发 **问题**:当前 `scheduler.go` 的 `processSchedule()` 每 10 秒调用 `PackageActivationHandler.HandlePackageActivationCheck()` 和 `DataResetHandler.HandleDataReset()`。精简 Scheduler 为"纯调度循环"会丢失这两个核心业务触发器。 **决定**:精简后的 Scheduler 保留这两个定时触发调用。 **Scheduler 精简后完整职责**: 1. 启动 CardInitializer.Run(ctx)(异步) 2. 启动 PollingConfigManager.Start(ctx)(定时刷新) 3. 运行 scheduleLoop: - 分片出队 → 推入 Asynq(核心调度) - 每 10 秒调用 `PackageActivationHandler.HandlePackageActivationCheck(ctx)` - 每 10 秒调用 `DataResetHandler.HandleDataReset(ctx)` 4. 背压检测 **目标行数调整**:< 250 行(原 < 200 行,因保留两个触发器)。 --- ### 决策 10:PollingLifecycleService 替代 callbacks.go 生命周期方法 **问题**:`callbacks.go` 实现了 `PollingCallback` 接口,被 `iot_card/service.go` 在卡创建/状态变更/启用/禁用/删除时调用。`PollingQueueManager` 设计为"只依赖 Redis Client,无 DB 依赖",无法承担需要配置匹配和 DB 查询的生命周期方法。 **决定**:新增 `PollingLifecycleService`,封装「配置匹配 + 队列操作」组合逻辑。 ```go // PollingLifecycleService 卡生命周期轮询管理 // 替代 callbacks.go 和 api_callback.go 中的生命周期方法 // 两个进程(API 和 Worker)共享同一实现 type PollingLifecycleService struct { queueMgr *PollingQueueManager configMgr *PollingConfigManager cardStore IotCardStore logger *zap.Logger } // OnCardCreated 新卡创建后初始化轮询 func (s *PollingLifecycleService) OnCardCreated(ctx context.Context, cardID uint) error // OnBatchCardsCreated 批量导入后批量初始化 func (s *PollingLifecycleService) OnBatchCardsCreated(ctx context.Context, cardIDs []uint) error // OnCardStatusChanged 卡状态变化后重新匹配配置 func (s *PollingLifecycleService) OnCardStatusChanged(ctx context.Context, cardID uint) error // OnCardEnabled 卡启用后初始化轮询 func (s *PollingLifecycleService) OnCardEnabled(ctx context.Context, cardID uint) error // OnCardDisabled 卡禁用后移除所有队列 func (s *PollingLifecycleService) OnCardDisabled(ctx context.Context, cardID uint) error // OnCardDeleted 卡删除后移除所有队列并清理缓存 func (s *PollingLifecycleService) OnCardDeleted(ctx context.Context, cardID uint) error ``` **PollingLifecycleService 实现 `PollingCallback` 接口**,`iot_card/service.go` 无需感知替换。 --- ### 决策 11:MonitoringService 适配分片队列 **问题**:`MonitoringService` 直接读取非分片键(`polling:queue:realname` 等)。分片后这些键不再有数据,监控指标全部返回 0。 **决定**: 1. `PollingQueueManager` 新增 `GetTotalQueueDepth(ctx, taskType) (int64, error)` 方法,聚合所有分片的 `ZCard` 2. `MonitoringService` 注入 `PollingQueueManager`,替代直接 Redis 调用 ```go // GetTotalQueueDepth 获取指定任务类型的总队列深度(聚合所有分片) func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error) { var total int64 for i := 0; i < m.shardCount; i++ { depth, err := m.GetQueueDepth(ctx, i, taskType) if err != nil { continue } total += depth } return total, nil } ``` --- ### 决策 12:保持 Asynq MaxRetry(0) **问题**:当前设计使用 `MaxRetry(0)`,失败后通过 `requeueCard` 放回 Redis Sorted Set 延后处理。若改为 `MaxRetry(3)`,Asynq 重试期间同一张卡可能被 Scheduler 从 Redis 队列再次取出,导致并发双重处理。 **决定**:保持 `MaxRetry(0)` 不变。 **理由**: - 当前的"软重试"机制(失败 → requeueCard → 下个轮询周期重新处理)更安全 - 避免 Gateway 重复调用(停机已成功但 DB 更新失败 → 重试再次停机) - 避免 Asynq 重试窗口与 Scheduler 出队的并发冲突 --- ### 决策 13:carddata Handler 必须保留跨月流量边界检测逻辑 **问题**:当前 `HandleCarddataCheck` 包含复杂的跨月检测逻辑(月份切换检测、`current_month_start_date` 比较、上月总量保存到 `last_month_total_mb`、当月计数器重置)。这不是"自然包含"在 Gateway 数据采集步骤中的,需要显式保留。 **决定**:`polling_carddata_handler.go` 的 `processCard` 方法须完整迁移以下逻辑: 1. Gateway 返回月度总流量后,与 `card.CurrentMonthStartDate` 比较检测跨月 2. 跨月时:保存 `card.LastMonthTotalMB`、重置 `card.CurrentMonthUsageMB`、更新 `card.CurrentMonthStartDate` 3. 同月时:计算增量 delta = gateway值 - card.LastMonthTotalMB 4. 记录流量历史到 `data_usage_records` --- ## Risks / Trade-offs | 风险 | 严重度 | 缓解措施 | |------|--------|---------| | 重构范围大(7个文件新建/重写),引入回归 Bug | 高 | 逐 Phase 替换,每个 Phase 独立验证;使用 PostgreSQL MCP 验证关键数据路径 | | polling_handler.go 重写可能遗漏边界逻辑 | 高 | 对照旧代码逐行比对;重点检查 cardCondition、matchPollingConfig、retry 逻辑 | | **新增 `not_realname` 停机条件导致存量卡批量停机(S3)** | **高** | **Phase 2 实施前,用 PostgreSQL MCP 统计「在线+未实名+普通卡」的数量;与业务方确认是否允许部署后批量停机;若影响较大,可通过给不匹配的配置添加豁免期或先灰度单一运营商** | | Lua 脚本出队的消费性语义(卡被移除后须重入队)| 中 | 验证重入队逻辑:Handler 处理完成后必须调 Requeue;异常时由 Asynq 重试覆盖 | | 分片队列需要一次性全量重新初始化 | 中 | 初始化完成前 Scheduler 不出队;提供进度监控接口;初始化幂等(重复加入同一队列只更新 score) | | Scheduler 拆分后依赖注入复杂度增加 | 低 | 在 cmd/worker/main.go 中按固定顺序组装:QueueMgr → ConfigMgr → Initializer → Scheduler | | Lua 原子出队后 Asynq 入队失败,卡可能丢失 | 中 | `enqueueBatch` 入队失败时立即调 `Requeue` 回退到分片队列;极端情况(Redis 连接断开)依赖 Worker 重启后 `CardInitializer` 全量重建 | | 现有 36 个 HTTP 接口兼容性 | 低 | 32个接口只依赖 Redis + Store,零改动;4个监控接口需适配分片队列(内部聚合 ZCard,对外响应格式不变) | **Trade-off 说明**: - 分片引入后,`RemoveFromAllQueues` 需要遍历所有 N 个分片的所有 4 个队列 = 4N 次 Redis 操作。16分片 = 64次操作,对于删除卡这种低频操作可以接受。 - Lua 脚本原子出队的消费性语义要求每个 Task Handler 都必须在完成后调用 `Requeue`,否则卡将永久从队列消失。需在代码 Review 时重点检查。 ## Migration Plan ### Phase 1:基础准备(DB 迁移 + 常量) **变更内容**: - `tb_device` 新增 `enable_polling BOOLEAN NOT NULL DEFAULT TRUE` 列 - `internal/model/device.go` 新增 `EnablePolling bool` 字段 - `pkg/constants/iot.go` 新增 `StopReasonNoPackage`、`StopReasonNotRealname` - `pkg/constants/redis.go` 新增 `RedisPollingShardQueueKey(shardID int, taskType string)` - 执行迁移验证 **可部署状态**:✅ 无功能变化,仅数据库结构和常量变更 **回滚方案**:`ALTER TABLE tb_device DROP COLUMN IF EXISTS enable_polling;` 回滚迁移,删除新增常量和 Redis Key 函数。新增字段有默认值(true),回滚前无代码依赖。 ### Phase 2:StopResumeService 重写(停复机逻辑统一) **变更内容**: - 新增 `hasValidPackage`(修复设备套餐 Bug1) - 新增 `isTrafficExhausted`、`isRealnameOK`、`checkStopReasons` - 新增 `EvaluateAndAct` 统一入口 - 修改 `stopCardWithRetry` 接收 stopReason 参数 - 修改 `resumeSingleCard`、`resumeDeviceCards` 按新复机条件 - 删除旧 `hasAvailablePackage` **可部署状态**:✅ StopResumeService 兼容旧调用,新方法独立,可安全部署 **回滚方案**:删除新增方法(`EvaluateAndAct`、`hasValidPackage` 等),恢复旧 `hasAvailablePackage`。新方法在 Phase 3 之前无调用者,回滚不影响现有功能。 ### Phase 3:PollingQueueManager + PollingConfigManager(基础组件) **变更内容**: - 新建 `queue_manager.go`(含分片 Sorted Set、Lua 脚本原子出队、分批 ZREM) - 新建 `config_manager.go`(DB 加载 → 内存缓存 → Redis 同步 → 5分钟定时刷新) **前置依赖**:Phase 1 常量(`RedisPollingShardQueueKey`) **被依赖方**:Phase 4 的 `PollingBase` 和 Phase 5 的 `Scheduler` 均依赖这两个组件 **可部署状态**:✅ 新增文件,无功能变化(旧 Scheduler 和 callbacks.go 仍在运行),新组件创建但尚未被调用 **回滚方案**:删除新建的 `queue_manager.go` 和 `config_manager.go`。纯新增文件,回滚不影响现有功能。 ### Phase 4+5(原子部署):Task Handler 拆分 + Scheduler 精简 + CardInitializer + 删除旧文件 > ⚠️ **Phase 4 和 Phase 5 必须原子部署**:新 Handler 的 `PollingBase.requeueCard()` 写入分片键,新 Scheduler 从分片键读取。若分开部署,中间窗口期旧 Scheduler 仍读非分片键 → 卡永久丢失轮询。详见决策 8。 **Phase 4 变更内容**: - 新建 `polling_realname/carddata/package/protect_handler.go` - 新建 `polling_base.go`(共享基类,依赖 Phase 3 的 QueueManager 和 ConfigManager) - `polling_carddata_handler.go` 须完整迁移跨月流量边界检测逻辑(详见决策 13) - 所有直接 DB 操作替换为 Store 方法调用 - 所有停复机决策改为调用 `StopResumeService.EvaluateAndAct()` - 注册 4 个新 Handler 到 Asynq(`MaxRetry(0)` 不变,详见决策 12) - 删除旧 `polling_handler.go` **Phase 5 变更内容**: - 新建 `initializer.go`(分片渐进式初始化) - 新建 `lifecycle_service.go`(替代 callbacks.go 的卡生命周期方法,详见决策 10) - 精简 `scheduler.go`(< 250行,保留调度循环 + 套餐过期检测触发 + 流量重置触发,详见决策 9) - 更新 `MonitoringService`(适配分片队列,详见决策 11) - 删除 `callbacks.go`、`api_callback.go`(队列操作由 PollingQueueManager 替代,生命周期方法由 PollingLifecycleService 替代) - 更新 `cmd/worker/main.go` 启动流程 - 更新所有 `PollingCallback` 引用 → `PollingLifecycleService` **前置依赖**:Phase 2(StopResumeService)+ Phase 3(QueueManager、ConfigManager) **可部署状态**:✅ Asynq 任务类型常量不变;32个HTTP接口零改动,4个监控接口适配分片 **回滚方案**:原子回滚 Phase 4+5 → 恢复旧 `polling_handler.go` + 旧 `scheduler.go` + 旧 `callbacks.go` + 旧 `api_callback.go`,回退 `cmd/worker/main.go` 和 `MonitoringService`。**此为最高风险节点**,涉及出队机制变更和队列数据结构变更。建议:① 部署前清空旧队列(初始化器会重建);② 灰度观察 48 小时。 ### Phase 6:轮询管控 API(enable_polling 接口) **变更内容**: - DTO:`UpdateAssetPollingStatusRequest/Response` - Store:`device_store.UpdatePollingStatus` - Service:`AssetPollingService.UpdatePollingStatus` - Handler:`asset.UpdatePollingStatus` - Route:`PATCH /api/admin/assets/:asset_type/:id/polling-status` - Docs:更新 docs.go 和 gendocs/main.go **可部署状态**:✅ 新增接口,不影响现有功能 **回滚方案**:删除新增路由、Handler、Service、Store 方法和 DTO。纯新增接口,回滚不影响现有功能。 ### Phase 7:全面验证 - DB 验证(PostgreSQL MCP):设备套餐停复机、行业卡实名豁免 - Redis 验证:Lua 脚本原子性、protect 队列清理 - 接口验证:enable_polling 接口、分片队列监控 - 兼容性验证:36个已有接口全量回归 --- ### 决策 14(新增):设备维度停复机幂等锁(防止多卡并发重复调 Gateway) **问题背景**:设备下的多张卡分布在不同分片,可能在同一调度周期内同时被 Scheduler 出队并触发 `EvaluateAndAct`: ``` card-A(shard 3)→ EvaluateAndAct → stopDeviceCards(deviceID=10) ← 同时 card-B(shard 7)→ EvaluateAndAct → stopDeviceCards(deviceID=10) ← 同时 card-C(shard 2)→ EvaluateAndAct → stopDeviceCards(deviceID=10) ← 同时 ``` 三次 `stopDeviceCards` 均遍历设备下所有在线卡,对每张卡调 Gateway 停机 → **每张卡被停机 3 次**,大量重复 Gateway 调用。 **决定**:在 `stopDeviceCards` 和 `resumeDeviceCards` 执行前,使用 Redis 分布式锁(`SetNX`)确保设备维度操作的幂等性。 **实现方案**: ```go // RedisPollingDeviceOpLockKey 设备维度停复机操作锁 // TTL 建议 30 秒(覆盖 stopDeviceCards 最长执行时间) func RedisPollingDeviceOpLockKey(deviceID uint) string { return fmt.Sprintf("polling:device:op_lock:%d", deviceID) } // stopDeviceCards 中加锁 func (s *Service) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) { lockKey := constants.RedisPollingDeviceOpLockKey(deviceID) locked, _ := s.redis.SetNX(ctx, lockKey, time.Now().String(), 30*time.Second).Result() if !locked { s.logger.Debug("设备停复机操作已在进行中,跳过重复调用", zap.Uint("device_id", deviceID)) return // 其他协程正在处理,本次跳过 } defer s.redis.Del(ctx, lockKey) // ... 执行停机逻辑 } ``` 同样适用于 `resumeDeviceCards`,使用同一把锁(`op_lock` 覆盖停机和复机,防止同一设备同时触发相反操作)。 **需要新增的常量**(`pkg/constants/redis.go`): ```go // RedisPollingDeviceOpLockKey 设备维度停复机操作锁 Key // TTL 30 秒,防止设备下多张卡并发触发重复 Gateway 调用 func RedisPollingDeviceOpLockKey(deviceID uint) string { return fmt.Sprintf("polling:device:op_lock:%d", deviceID) } ``` **需要新增的任务**(在 tasks.md Phase 2.5 和 2.8 中分别加入加锁逻辑,并在 Phase 1 的 redis.go 中新增 Key 函数)。 --- ### 决策 15:修复 `getCardCondition` 中 `suspended` 永远不返回的问题(M1) **问题背景**: 现有 `getCardCondition` 逻辑如下: ```go if card.RealNameStatus != RealNameStatusVerified { return "not_real_name" } if card.NetworkStatus == 1 { return "activated" } return "real_name" // 停机+已实名卡落到这里 ``` - 停机且已实名的卡返回 `"real_name"`,而 `"real_name"` 配置通常 `carddata_check_interval=null`、`package_check_interval=null` - 结果:停机卡不再被 carddata/package 轮询,`EvaluateAndAct` 不会被调用,**无法自动复机** - `card_condition='suspended'` 在 DTO 中作为合法值存在,但 `getCardCondition` 从未返回该值,是死代码 **修复方案**:在 `PollingConfigManager.getCardCondition` 中,**最先**检查网络状态: ```go func getCardCondition(card *model.IotCard) string { // 停机卡(无论实名状态),使用独立的 suspended 配置 // 停机卡需要继续轮询 carddata/package 以便检测复机条件 if card.NetworkStatus == 0 { return "suspended" } // 在线卡按实名状态细分 if card.RealNameStatus != constants.RealNameStatusVerified { return "not_real_name" } return "activated" } ``` **配套数据库配置**:需为 `suspended` 条件添加对应的 PollingConfig,包含 carddata/package 检查间隔(建议与 `activated` 相同或更低频率),确保停机卡能持续被轮询以便自动复机。示例: ```sql INSERT INTO tb_polling_config (config_name, card_condition, priority, carddata_check_interval, package_check_interval, status) VALUES ('停机卡轮询', 'suspended', 25, 3600, 3600, 1); ``` **注意**:`"real_name"` 条件从 `getCardCondition` 的返回值中删除(仅保留 not_real_name / activated / suspended)。现有配置中 `card_condition='real_name'` 的条目不再被匹配,可按需清理或转换。 --- ## Open Questions 1. **分片数量配置化**:shardCount 是否需要可配置(当前设计为常量16)? 建议:Phase 4 实现时作为 `PollingConfig.ShardCount` 写入 Redis,`PollingConfigManager` 读取,修改无需重启。 2. `polling_handler.go` 中 `HandleCarddataCheck` 调用的 `usageService.DeductDataUsage()`,在拆分后应归属于 `carddata_handler` 还是 `StopResumeService`? → **决定**:保留在 `carddata_handler`,仅为数据计算,不是停复机决策。 3. `PackageActivationHandler` 的直接 DB 操作(4处)是否在本次重构范围内? → **不在本次范围**,计划在单独提案中处理,避免本次范围蔓延。 4. ~~初始化期间(initCompleted=false),新注册卡如何处理?~~ **已解决**(决策 10) 通过 `PollingLifecycleService.OnCardCreated()` 处理:匹配配置 → 调用 `PollingQueueManager.Requeue()` 写入分片队列。与初始化并发进行,ZADD 的幂等性保证不重复入队。`PollingLifecycleService` 实现 `PollingCallback` 接口,`iot_card/service.go` 无需感知替换。