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

172 lines
9.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
### 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 队列深度查询失败」