feat: 轮询系统重构(分片队列 + 停复机统一 + Handler 拆分)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m46s
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:
@@ -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_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` 无需感知替换。
|
||||
Reference in New Issue
Block a user