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

【核心变更】

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,141 @@
## Why已合并 polling-logic-redesign可废弃该提案
轮询系统经过多次迭代后出现了四类问题,须同步解决:
### 一、结构性问题(影响长期可维护性)
**问题 1`polling_handler.go`1360行职责爆炸**
Asynq Task Handler 本应只做「数据采集」,但它同时承担:停复机决策(与 stop_resume_service.go 重复)、直接 DB 操作15处绕过 Store 层)、直接 Gateway 调用7处无重试、缓存管理、队列管理。
**问题 2停复机逻辑分裂可靠性不一致**
`polling_handler.go``stop_resume_service.go` 各有一套停复机实现,`hasAvailablePackage` 重复定义;前者 Gateway 调用无重试,后者有 3 次重试,同一业务,不同可靠性。
**问题 3Scheduler764行身兼六职**
调度循环 + 配置管理 + 卡初始化 + 生命周期回调 + 套餐激活触发 + 流量重置触发。`callbacks.go``api_callback.go` 大量重复代码(两套相同的 Redis 队列操作逻辑)。
### 二、线上 Bug需立即修复
**Bug 1停复机判断错误设备套餐被忽略 → 误停机**
`PackageUsage` 设备套餐存 `device_id`,但 `shouldStopCard` / `hasAvailablePackage` 只查 `iot_card_id`,购买设备套餐后卡仍被误停机。
**Bug 2停复机条件不完整停机条件缺失**
当前停机条件仅为「无有效套餐」,未判断虚流量是否耗尽,未区分行业卡/非行业卡的实名要求。
**Bug 3保护期队列遗漏protect 队列残留脏数据**
`removeFromAllQueues` 只清 3 个队列realname、carddata、package漏掉 `protect` 队列,删除卡后 protect 队列残留。
**Bug 4出队竞态ZRANGEBYSCORE + ZREMRANGEBYSCORE 非原子**
两层风险:① 高并发下多 Worker 读取同一批卡,导致重复停机/复机 Gateway 调用;② `ZRANGEBYSCORE` 有 LIMIT 但 `ZREMRANGEBYSCORE` 无 LIMIT当到期卡数超过批次大小时超出部分被删除但从未被读取——**卡永久丢失,不再被轮询**。
### 三、规模挑战(千万级支撑设计)
当前设计假设百万级100K 初始化批次、50K 调度批次、50 并发)。千万级规模下,单个 Sorted Set 深度达千万条,需引入:**分片队列**Sharded Sorted Set、**多 Worker 分片消费**(无协调开销)、**分片并行初始化**。
### 四、管理接口兼容性确认
现有 36 个轮询管理 HTTP 接口(配置/手动触发/告警/监控/并发/清理)经全量检查,**绝大部分 Service 只依赖 Redis Client 和 Store不依赖 Scheduler 对象**。其中 **32 个接口全部兼容,零改动****4 个监控统计接口需适配分片队列**`MonitoringService` 读取队列深度的 Key 从 `polling:queue:{type}` 变更为聚合所有分片 `polling:shard:{N}:queue:{type}``ZCard` 之和)。
## What Changes
**功能修复(来自 polling-logic-redesign合并入本提案**
- 修复 `hasAvailablePackage` / `shouldStopCard`:根据卡绑定关系动态切换 `iot_card_id` / `device_id` 查询
- 重写停复机判断:新增三条件停机(无套餐/流量耗尽/未实名),区分行业卡
- 新增停机原因精细化常量:`no_package``not_realname`
- 新增 `Device.enable_polling` 字段
- 新增 HTTP 接口:`PATCH /api/admin/assets/:asset_type/:id/polling-status`
**架构重构:**
- 拆分 `polling_handler.go`1360行→ 4 个专注 Handler实名/流量/套餐/保护期)+ 共享基类,每个 < 300行
- 新增 `PollingQueueManager`:统一 Redis 队列操作Lua 脚本原子出队ZRANGEBYSCORE + ZREM 原子执行),修复 protect 遗漏 Bug
- 新增 `PollingConfigManager`:独立配置管理,支持 5 分钟定时刷新
- 新增 `CardInitializer`:独立初始化模块,支持分片并行
- 精简 `Scheduler`(目标 < 250行保留调度循环 + 套餐过期检测触发 + 流量重置触发(`PackageActivationHandler.HandlePackageActivationCheck``DataResetHandler.HandleDataReset` 必须保留在调度主循环中)
- 新增 `PollingLifecycleService`:替代 `callbacks.go` + `api_callback.go` 中卡生命周期方法(`OnCardCreated`/`OnCardStatusChanged`/`OnCardEnabled`/`OnCardDisabled`/`OnCardDeleted`),依赖 `PollingQueueManager` + `PollingConfigManager`,封装「匹配配置 + 入队」组合逻辑
- 删除 `callbacks.go` + `api_callback.go`(队列操作由 PollingQueueManager 替代,生命周期方法由 PollingLifecycleService 替代)
- `MonitoringService` 适配分片队列:队列深度查询改为聚合所有分片的 `ZCard` 之和
**千万级规模设计:**
- 分片 Sorted Set`polling:shard:{0..N-1}:queue:{taskType}`,默认 16 分片
- `CardInitializer``card_id % shard_count` 分桶入队
- Scheduler 并行消费 N 个分片(每个分片独立 Lua 脚本原子出队)
- 背压机制:单分片队列深度超阈值时,跳过该分片本轮调度
**重要设计约束:**
- Asynq 任务提交保持 `MaxRetry(0)`(与当前设计一致),失败后通过 `requeueCard` 放回 Redis Sorted Set 延后处理,避免 Asynq 重试与 Scheduler 出队产生并发双重处理
- `StopResumeCallback` 接口不在本次变更范围内(仅替换 `PollingCallback``triggerStopAfterExpiry()``checkAndTriggerSuspension()` 不受影响
- Phase 4Task Handler 拆分)和 Phase 5Scheduler 精简)必须**原子部署**,不可分开上线(详见迁移计划)
## Capabilities
### New Capabilities
- `polling-queue-manager`: 统一 Redis 队列操作封装——Lua 脚本原子出队ZRANGEBYSCORE + ZREM 服务端原子执行,保留时间过滤语义)、分片 Sorted Set支持千万级、修复 protect 遗漏 Bug、入队/重入队/手动触发/删除的统一接口;替代 callbacks.go、api_callback.go、polling_handler.go 中所有分散的队列操作
- `polling-config-manager`: 独立配置管理模块——从 DB 加载 `tb_polling_config`、同步到 Redis HashTTL 24h、内存缓存读写锁、5 分钟定时自动刷新、`MatchConfig(card)` 按优先级返回第一个匹配配置
- `card-initializer`: 独立卡初始化模块——渐进式分批100K/批)、按分片入队(`card_id % shard_count`)、`enable_polling=false` 过滤、进度状态暴露给 MonitoringService
- `polling-lifecycle-service`: 卡生命周期轮询管理——替代 `callbacks.go``api_callback.go` 中的 `OnCardCreated`/`OnBatchCardsCreated`/`OnCardStatusChanged`/`OnCardEnabled`/`OnCardDisabled`/`OnCardDeleted`,依赖 `PollingQueueManager`(队列操作)+ `PollingConfigManager`(配置匹配),封装「匹配配置 → 分片入队」组合逻辑两个进程API 和 Worker共享
- `asset-polling-control`: 资产轮询管控 HTTP 接口——`PATCH /api/admin/assets/:asset_type/:id/polling-status`,支持 card/device 两种资产类型,启用/禁用轮询
### Modified Capabilities
- `polling-stop-resume-logic`: 停复机逻辑统一到 `StopResumeService`——新增 `EvaluateAndAct()` 统一入口、修复设备套餐查询 Bug`device_id` vs `iot_card_id`)、实现三条件停机判断(无套餐/流量耗尽/未实名)、完整复机条件(含行业卡豁免)、停机原因精细化
- `polling-task-handlers`: `polling_handler.go` 拆分为 4 个专注文件——每个 Handler 只做数据采集,停复机通过 `StopResumeService.EvaluateAndAct()` 完成,消除所有直接 DB 操作和无重试 Gateway 调用;`polling_carddata_handler.go` 须完整保留当前 `HandleCarddataCheck` 中的跨月流量边界检测逻辑(月份切换检测、上月总量保存、当月计数器重置)
- `polling-monitoring-service`: `MonitoringService` 适配分片队列——队列深度查询改为调用 `PollingQueueManager.GetTotalQueueDepth(taskType)` 聚合所有分片,替代直接读取旧的非分片 Redis Key
## Impact
### 新建文件
| 文件 | 职责 | 目标行数 |
|------|------|---------|
| `internal/task/polling_realname_handler.go` | 实名检查 Task Handler | < 200行 |
| `internal/task/polling_carddata_handler.go` | 流量检查 Task Handler | < 300行 |
| `internal/task/polling_package_handler.go` | 套餐检查 Task Handler | < 200行 |
| `internal/task/polling_protect_handler.go` | 保护期一致性 Task Handler | < 200行 |
| `internal/task/polling_base.go` | 共享基类(并发控制/缓存/重入队) | < 150行 |
| `internal/polling/queue_manager.go` | 统一 Redis 队列操作 | < 200行 |
| `internal/polling/config_manager.go` | 配置加载管理 | < 150行 |
| `internal/polling/initializer.go` | 分片渐进式初始化 | < 250行 |
| `internal/polling/lifecycle_service.go` | 卡生命周期轮询管理(替代 callbacks.go | < 200行 |
### 修改文件
| 文件 | 变更类型 | 变更内容 |
|------|---------|---------|
| `internal/polling/scheduler.go` | 精简重写 | 保留调度循环 + 套餐过期/流量重置触发,< 250行 |
| `internal/service/iot_card/stop_resume_service.go` | 扩展+修复 | 新增 EvaluateAndAct + 修复设备套餐 Bug + 三条件停机 |
| `internal/model/device.go` | 字段新增 | `EnablePolling bool` |
| `internal/handler/admin/asset.go` | 方法新增 | `UpdatePollingStatus` handler |
| `internal/routes/asset.go` | 路由新增 | `PATCH /assets/:asset_type/:id/polling-status` |
| `internal/store/postgres/device_store.go` | 方法新增 | `UpdatePollingStatus(ctx, id, enabled)` |
| `pkg/constants/iot.go` | 常量新增 | `StopReasonNoPackage``StopReasonNotRealname` |
| `pkg/constants/redis.go` | 函数新增 | `RedisPollingShardQueueKey(shard, taskType)` |
| `internal/service/polling/monitoring_service.go` | 适配更新 | 队列深度查询改为聚合分片 ZCard |
| `cmd/worker/main.go` | 启动流程更新 | 使用新组件,注册 4 个 Handler |
| `pkg/queue/handler.go` | Handler 注册更新 | 注册 4 个新 Task Handler |
| `cmd/api/docs.go` + `cmd/gendocs/main.go` | 文档更新 | 注册新 AssetHandler 方法 |
### 删除文件
| 文件 | 删除原因 |
|------|---------|
| `internal/task/polling_handler.go` | 拆分为 4 个专注文件后废弃 |
| `internal/polling/callbacks.go` | 职责转移到 PollingQueueManager |
| `internal/polling/api_callback.go` | 职责转移到 PollingQueueManager |
### 数据库迁移
| 表 | 变更 |
|----|------|
| `tb_device` | 新增 `enable_polling BOOLEAN NOT NULL DEFAULT TRUE` |
### 管理接口兼容性(零改动)
| 模块 | 接口数 | 兼容性 |
|------|--------|--------|
| 轮询配置管理polling-configs | 7个 | ✅ 完全兼容 |
| 手动触发polling-manual-trigger | 6个 | ✅ 完全兼容 |
| 告警管理polling-alert-rules | 6个 | ✅ 完全兼容 |
| 监控统计polling-stats | 4个 | ⚠️ 需适配分片队列(聚合 ZCard |
| 并发控制polling-concurrency | 4个 | ✅ 完全兼容 |
| 数据清理data-cleanup | 9个 | ✅ 完全兼容 |
| **合计** | **36个** | **32个零改动 + 4个监控接口需适配** |