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:
116
openspec/specs/polling-config-manager/spec.md
Normal file
116
openspec/specs/polling-config-manager/spec.md
Normal file
@@ -0,0 +1,116 @@
|
||||
### Requirement: 独立配置管理模块(PollingConfigManager)
|
||||
|
||||
新建 `internal/polling/config_manager.go`,提供 `PollingConfigManager` 类型,从 `scheduler.go` 中拆分配置相关职责。
|
||||
|
||||
**文件目标**:< 150 行,职责:DB 加载 → 内存缓存 → Redis 同步 → 定时刷新。
|
||||
|
||||
**从 Scheduler 提取的方法**:
|
||||
- `loadConfigs(ctx)`:从 DB 加载所有启用的 `tb_polling_config`
|
||||
- `syncConfigsToRedis(configs)`:写入 Redis Hash(TTL 24小时)
|
||||
- `MatchConfig(card)`:按优先级顺序返回第一个匹配的配置
|
||||
- `matchConfigConditions(config, card)`:判断卡是否满足配置条件
|
||||
- `getCardCondition(card)`:获取卡的匹配条件(carrier、simtype 等)
|
||||
|
||||
**Scheduler 修改后**:不包含任何 DB 查询或配置加载逻辑,通过 `configManager.MatchConfig(card)` 获取配置。
|
||||
|
||||
#### Scenario: 启动时加载配置并同步 Redis
|
||||
- **GIVEN** Worker 进程启动,DB 中有 5 条启用的 PollingConfig
|
||||
- **WHEN** `PollingConfigManager.Load(ctx)` 被调用
|
||||
- **THEN** 从 DB 加载 5 条配置,写入内存缓存(sync.RWMutex 保护),同步到 Redis Hash(TTL 24小时);加载完成日志记录「已加载 5 条轮询配置」
|
||||
|
||||
#### Scenario: 配置按优先级匹配
|
||||
- **GIVEN** 内存中有 3 条配置,优先级分别为 1、5、10(数字越小优先级越高)
|
||||
- **WHEN** 调用 `MatchConfig(card)` 匹配某张卡
|
||||
- **THEN** 按优先级升序遍历,返回第一个满足所有条件的配置;无匹配时返回 nil,该卡不加入轮询队列
|
||||
|
||||
#### Scenario: 配置匹配使用读锁不阻塞
|
||||
- **GIVEN** Scheduler 高频调用 `MatchConfig(card)`(每秒可能数千次)
|
||||
- **WHEN** 同时有定时刷新正在写入新配置(写锁)
|
||||
- **THEN** 读取操作等待写锁释放后继续,不发生数据竞争;写锁持有时间极短(内存赋值),不影响 Scheduler 调度性能
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 定时刷新配置(5分钟周期)
|
||||
|
||||
`PollingConfigManager.Start(ctx)` 启动后台 goroutine,每 5 分钟自动重新加载配置。
|
||||
|
||||
#### Scenario: 定时刷新不重启 Worker
|
||||
- **GIVEN** Worker 进程已运行 10 分钟,管理员在控制台修改了某条 PollingConfig 的轮询间隔
|
||||
- **WHEN** 距上次加载超过 5 分钟,定时器触发
|
||||
- **THEN** 自动重新从 DB 加载配置,更新内存缓存,新配置在下一轮调度中生效;无需重启 Worker 进程
|
||||
|
||||
#### Scenario: 刷新失败不影响当前配置
|
||||
- **GIVEN** 定时刷新时 DB 连接超时
|
||||
- **WHEN** `Load(ctx)` 返回 error
|
||||
- **THEN** 内存缓存保持原有配置不变(不清空),记录 Error 日志「配置刷新失败: xxx」;下一轮(5分钟后)重试
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 独立卡初始化模块(CardInitializer)
|
||||
|
||||
新建 `internal/polling/initializer.go`,提供 `CardInitializer` 类型,从 `scheduler.go` 中拆分渐进式初始化职责。
|
||||
|
||||
**文件目标**:< 250 行,职责:分批 DB 查询 → 分片路由入队 → 进度追踪 → enable_polling 过滤。
|
||||
|
||||
**从 Scheduler 提取的方法**:
|
||||
- `progressiveInit(ctx)`:分批加载卡并入队
|
||||
- `initCardsBatch(ctx, offset, limit)`:加载单批次卡
|
||||
- `initCardPolling(ctx, card)`:为单张卡匹配配置并入队
|
||||
|
||||
**Scheduler 修改后**:不包含任何初始化逻辑,通过 `cardInitializer.Run(ctx)` 异步启动,通过 `cardInitializer.GetProgress()` 查询进度。
|
||||
|
||||
#### Scenario: 渐进式分批初始化避免 DB 过载
|
||||
- **GIVEN** DB 中有 500 万张启用轮询的卡
|
||||
- **WHEN** `CardInitializer.Run(ctx)` 被调用
|
||||
- **THEN** 分批加载(每批 10 万张),批次间 Sleep 500ms;约 500 批次,每批处理后记录进度日志「初始化进度: 100000/5000000」;全部完成后 `initCompleted=true`,Scheduler 开始正常出队
|
||||
|
||||
#### Scenario: 跳过 enable_polling=false 的卡
|
||||
- **GIVEN** 10 万张卡中,5000 张卡的 `enable_polling=false`
|
||||
- **WHEN** 批次处理时遇到这 5000 张卡
|
||||
- **THEN** 这 5000 张卡不加入任何轮询队列(ZADD 跳过);其余 95000 张卡按正常流程入队
|
||||
|
||||
#### Scenario: 初始化幂等(重启不重复)
|
||||
- **GIVEN** Worker 重启,部分卡已在分片队列中(上次初始化留下)
|
||||
- **WHEN** 重新执行 `CardInitializer.Run(ctx)`
|
||||
- **THEN** ZADD 操作幂等:若卡已在队列中,只更新 score 为新的初始延迟时间(不添加重复条目);Redis Sorted Set 中不出现重复成员
|
||||
|
||||
#### Scenario: Scheduler 只在初始化完成后出队
|
||||
- **GIVEN** Worker 刚启动,初始化尚未完成(`initCompleted=false`)
|
||||
- **WHEN** Scheduler 的 `scheduleLoop` 执行
|
||||
- **THEN** 检查 `cardInitializer.GetProgress().Completed`,若为 false 则跳过本轮所有分片的出队;避免初始化中途 Scheduler 取到不完整队列
|
||||
|
||||
#### Scenario: 进度暴露给监控接口
|
||||
- **GIVEN** 管理员调用轮询状态监控接口
|
||||
- **WHEN** `MonitoringService` 调用 `cardInitializer.GetProgress()`
|
||||
- **THEN** 返回 `{Total: 5000000, Processed: 1500000, Completed: false}`;前端可展示初始化进度百分比
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Scheduler 精简到 < 250 行(Mi1 修复)
|
||||
|
||||
> **注意**:本 spec 原写 `< 200 行`,已与 `design.md` 决策 9 和 `tasks.md` 统一更正为 **`< 250 行`**。
|
||||
> 原因:精简后的 Scheduler 保留了套餐过期检测和流量重置两个触发器(见决策 9),额外占用约 50 行。
|
||||
|
||||
`internal/polling/scheduler.go` 精简后只保留纯调度循环逻辑,所有子职责委托给新建的三个模块。
|
||||
|
||||
**精简后 Scheduler 的完整职责**:
|
||||
1. 启动 `CardInitializer.Run(ctx)`(异步)
|
||||
2. 启动 `PollingConfigManager.Start(ctx)`(定时刷新)
|
||||
3. 运行 `scheduleLoop`:每秒 × N分片 × 4任务类型 的出队循环
|
||||
4. 背压检测:`GetQueueDepth > 阈值` 时跳过该分片
|
||||
|
||||
**禁止出现在精简后 Scheduler 中的内容**:
|
||||
- 任何 `db.Find()`、`db.Where()` 等 DB 操作
|
||||
- 配置加载逻辑(`loadConfigs`、`syncConfigsToRedis`)
|
||||
- 卡初始化逻辑(`progressiveInit`、`initCardsBatch`)
|
||||
- 任何 Callback 注册(`callbacks.go` 已删除)
|
||||
|
||||
#### Scenario: Scheduler 保留套餐过期检测和流量重置触发
|
||||
- **GIVEN** Phase 4+5 原子部署完成,精简后的 Scheduler 运行中
|
||||
- **WHEN** 每 10 秒定时器触发
|
||||
- **THEN** 调用 `PackageActivationHandler.HandlePackageActivationCheck(ctx)` 检查过期套餐;调用 `DataResetHandler.HandleDataReset(ctx)` 检查流量重置;这两个触发器在精简后 Scheduler 中**必须保留**(决策 9)
|
||||
|
||||
#### Scenario: Scheduler 精简后行数验证
|
||||
- **GIVEN** Phase 4+5 重构完成
|
||||
- **WHEN** 统计 `internal/polling/scheduler.go` 的代码行数
|
||||
- **THEN** 文件行数(含注释)**< 250 行**(因保留套餐过期/流量重置触发器,此处统一以 design.md 决策 9 和 tasks.md 的 <250 为准);文件中不出现 `db.Find`、`db.Where`、`loadConfig` 等字样;文件中出现 `HandlePackageActivationCheck` 和 `HandleDataReset`
|
||||
171
openspec/specs/polling-queue-manager/spec.md
Normal file
171
openspec/specs/polling-queue-manager/spec.md
Normal file
@@ -0,0 +1,171 @@
|
||||
### 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 将卡重新入队(ZADD,score=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 Set,Key 格式为 `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** 返回 600000;Scheduler 跳过该分片本轮出队,等待 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 均执行 ZREM;Redis 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_polling(M3)
|
||||
|
||||
`PollingLifecycleService` 在 `OnCardCreated`、`OnCardEnabled`、`OnCardStatusChanged` 触发重新入队前,**必须**检查资产的 enable_polling 状态,防止禁用轮询的设置因生命周期事件被意外绕过。
|
||||
|
||||
#### Scenario: 设备禁用轮询后状态变更不重新入队
|
||||
- **GIVEN** deviceID=10 已被设置 `enable_polling=false`,设备下 card-A(cardID=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 队列深度查询失败」
|
||||
162
openspec/specs/polling-stop-resume-logic/spec.md
Normal file
162
openspec/specs/polling-stop-resume-logic/spec.md
Normal file
@@ -0,0 +1,162 @@
|
||||
### Requirement: EvaluateAndAct——停复机统一入口
|
||||
|
||||
`StopResumeService` 新增 `EvaluateAndAct(ctx context.Context, card *model.IotCard) error` 方法,封装完整的停复机判断和执行逻辑。
|
||||
|
||||
> **签名说明**:删除原设计的 `carrierType string, carrierID uint` 参数(仅用于日志,放入签名会误导实现者)。
|
||||
> 设备/单卡维度通过 `card.DeviceID` 推导,日志上下文通过函数内部的 `zap.Field` 记录。
|
||||
|
||||
**删除** `internal/task/polling_handler.go` 中的以下函数(迁移到 StopResumeService):
|
||||
- `checkStopResume`
|
||||
- `shouldStopCard`
|
||||
- `hasAvailablePackage`(旧版,被新 `hasValidPackage` 替代)
|
||||
- `stopCardByUsageExhausted`
|
||||
- `resumeCardByPackageAvailable`
|
||||
|
||||
所有 Task Handler(carddata、package、protect)在需要停复机决策时,统一调用 `stopResumeService.EvaluateAndAct()`。
|
||||
|
||||
#### Scenario: Task Handler 调用统一停复机入口
|
||||
- **GIVEN** `carddata_handler.go` 完成流量数据采集和 DB 写入
|
||||
- **WHEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card)`
|
||||
- **THEN** StopResumeService 完整执行停复机判断逻辑;Handler 不包含任何无条件停复机相关代码(`shouldStop`、`hasPackage` 等函数不出现在 Handler 文件中)
|
||||
|
||||
#### Scenario: 停机原因按优先级记录
|
||||
- **GIVEN** 卡在线,同时满足「无套餐」和「流量耗尽」和「未实名」三个条件
|
||||
- **WHEN** `EvaluateAndAct` 执行 `checkStopReasons`
|
||||
- **THEN** 按优先级取最高:`no_package > traffic_exhausted > not_realname`;`stop_reason` 字段记录 `no_package`;停机后 DB 中该卡 `stop_reason='no_package'`
|
||||
|
||||
#### Scenario: EvaluateAndAct 幂等
|
||||
- **GIVEN** 卡已停机,`stop_reason='no_package'`,无套餐状态未变化
|
||||
- **WHEN** 下次轮询再次调用 `EvaluateAndAct`
|
||||
- **THEN** 检测到卡已停机(`network_status=0`),进入复机判断分支;复机条件不满足(仍无套餐),不发起 Gateway 调用,返回 nil;DB 状态不变
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 停机条件——三种场景全面覆盖
|
||||
|
||||
卡在线(`network_status=1`)时,满足以下**任一**条件触发停机:
|
||||
|
||||
**条件 A(no_package)**:无有效套餐
|
||||
- 独立卡:`tb_package_usage` 中无 `iot_card_id=卡ID AND status IN (0,1)` 的记录
|
||||
- 绑定设备的卡:`tb_package_usage` 中无 `device_id=设备ID AND status IN (0,1)` 的记录
|
||||
- status=0(待激活)和 status=1(激活中)均视为有效套餐
|
||||
|
||||
**条件 B(traffic_exhausted)**:虚流量耗尽
|
||||
- 活跃套餐 `status=2`(系统标记为虚流量耗尽),或
|
||||
- 活跃套餐 `data_usage_mb >= data_limit_mb`(且 `data_limit_mb > 0`,防止无限流量卡误判)
|
||||
|
||||
**条件 C(not_realname)**:非行业卡且未实名
|
||||
- `card_category != 'industry'` 且 `real_name_status = 0`
|
||||
|
||||
#### Scenario: 无套餐触发停机(独立卡)
|
||||
- **GIVEN** cardID=100 的独立卡(无 device_id),`network_status=1`
|
||||
- **WHEN** `tb_package_usage` 中无任何 `iot_card_id=100 AND status IN(0,1)` 的记录
|
||||
- **THEN** `checkStopReasons` 返回 `["no_package"]`;发起 Gateway 停机调用(3次重试);DB 更新 `network_status=0, stop_reason='no_package'`
|
||||
|
||||
#### Scenario: 无套餐触发停机(设备卡,修复Bug1)
|
||||
- **GIVEN** cardID=200 的卡绑定 deviceID=50,`network_status=1`
|
||||
- **WHEN** `tb_package_usage` 中无 `iot_card_id=200` 的记录,但有 `device_id=50 AND status=1` 的记录(购买了设备套餐)
|
||||
- **THEN** `hasValidPackage` 检测到设备套餐存在,返回 true;不触发停机;卡保持在线状态(修复之前只查 iot_card_id 导致误停机的 Bug)
|
||||
|
||||
#### Scenario: 流量耗尽触发停机
|
||||
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=1000, data_usage_mb=1001`
|
||||
- **WHEN** `isTrafficExhausted` 检测
|
||||
- **THEN** `data_usage_mb(1001) >= data_limit_mb(1000)` 条件满足;返回 true;触发停机,`stop_reason='traffic_exhausted'`
|
||||
|
||||
#### Scenario: 套餐 status=2 触发停机
|
||||
- **GIVEN** 卡在线,活跃套餐 `status=2`(系统已标记虚流量耗尽)
|
||||
- **WHEN** `isTrafficExhausted` 检测
|
||||
- **THEN** 检测到 `status=2`;返回 true;触发停机,`stop_reason='traffic_exhausted'`
|
||||
|
||||
#### Scenario: 无限流量套餐不误判流量耗尽
|
||||
- **GIVEN** 卡在线,活跃套餐 `data_limit_mb=0`(无限流量),`data_usage_mb=9999`
|
||||
- **WHEN** `isTrafficExhausted` 检测
|
||||
- **THEN** `data_limit_mb=0` 时跳过用量比较(防止无限流量卡被误停机);返回 false;不触发停机
|
||||
|
||||
#### Scenario: 未实名普通卡触发停机
|
||||
- **GIVEN** 卡在线,`card_category='normal'`,`real_name_status=0`,有有效套餐且流量未耗尽
|
||||
- **WHEN** `checkStopReasons` 检测条件 C
|
||||
- **THEN** `!isRealnameOK(card)` 为 true;触发停机,`stop_reason='not_realname'`
|
||||
|
||||
#### Scenario: 行业卡无需实名不停机
|
||||
- **GIVEN** 卡在线,`card_category='industry'`,`real_name_status=0`,有有效套餐
|
||||
- **WHEN** `checkStopReasons` 检测
|
||||
- **THEN** `isRealnameOK(card)` 返回 true(行业卡豁免实名要求);不触发停机;卡保持在线
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 复机条件——全部满足才复机
|
||||
|
||||
卡停机(`network_status=0`)时,以下**全部**条件满足才触发自动复机:
|
||||
|
||||
1. `stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')`(排除手动停机、运营商停机等其他原因)
|
||||
2. 有有效套餐且流量未耗尽(`hasValidPackage=true` 且 `isTrafficExhausted=false`)
|
||||
3. 行业卡 OR 已实名(`card_category='industry'` OR `real_name_status=1`)
|
||||
|
||||
#### Scenario: 购买套餐后自动复机
|
||||
- **GIVEN** cardID=300 因 `no_package` 停机,`stop_reason='no_package'`,现在购买了套餐(status=1)
|
||||
- **WHEN** 下次轮询执行 `EvaluateAndAct`
|
||||
- **THEN** 三个复机条件全部满足(stop_reason合规 + 有套餐 + 已实名或行业卡);发起 Gateway 复机调用(3次重试);DB 更新 `network_status=1, stop_reason=''`
|
||||
|
||||
#### Scenario: 手动停机不自动复机
|
||||
- **GIVEN** 卡 `stop_reason='manual'`(管理员手动停机)
|
||||
- **WHEN** 该卡购买了套餐,完成实名认证,轮询执行 `EvaluateAndAct`
|
||||
- **THEN** 条件1不满足(`'manual'` 不在 IN 列表中);不触发自动复机;卡保持停机状态;需管理员手动复机
|
||||
|
||||
#### Scenario: 流量耗尽停机后购买新套餐复机
|
||||
- **GIVEN** 卡因 `traffic_exhausted` 停机,`stop_reason='traffic_exhausted'`;旧套餐已过期(status=3),新购套餐 status=1,usage=0
|
||||
- **WHEN** 轮询执行 `EvaluateAndAct`
|
||||
- **THEN** 条件1满足(traffic_exhausted);条件2满足(新套餐有效,usage<limit);条件3满足(已实名);触发复机
|
||||
|
||||
#### Scenario: 未实名卡实名后复机
|
||||
- **GIVEN** 普通卡因 `not_realname` 停机,用户完成实名认证(`real_name_status=1`)
|
||||
- **WHEN** 实名检查轮询执行,更新 DB 后调用 `EvaluateAndAct`
|
||||
- **THEN** 条件1满足;条件2满足(有套餐且未耗尽);条件3满足(`real_name_status=1`);触发复机
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备维度停复机——覆盖所有绑定卡
|
||||
|
||||
**停机**操作覆盖设备下所有在线卡(`network_status=1`);**复机**操作对满足条件的卡执行,跳过未满足 `isRealnameOK` 的普通卡。
|
||||
|
||||
#### Scenario: 设备套餐耗尽停所有绑定卡
|
||||
- **GIVEN** deviceID=10 下有 3 张在线卡(cardID=1,2,3),设备套餐 `data_usage_mb >= data_limit_mb`
|
||||
- **WHEN** 任一绑定卡触发 `EvaluateAndAct`,检测到设备套餐流量耗尽
|
||||
- **THEN** 调用 `stopDeviceCards(ctx, deviceID=10, "traffic_exhausted")`;3 张卡均发起 Gateway 停机调用;3 张卡均更新 `stop_reason='traffic_exhausted'`
|
||||
|
||||
#### Scenario: 设备停机调用 Gateway 带3次重试
|
||||
- **GIVEN** 设备下有 3 张卡需要停机,Gateway 第一次调用返回超时
|
||||
- **WHEN** `stopCardWithRetry` 执行
|
||||
- **THEN** 自动重试至多 3 次;至少 1 次成功则记录成功,最终失败则记录 Error 日志「卡停机失败: cardID=xxx, error=xxx」
|
||||
|
||||
#### Scenario: 购买设备套餐后全卡复机
|
||||
- **GIVEN** deviceID=10 下 3 张卡均因 `traffic_exhausted` 停机,购买新套餐后 status=1
|
||||
- **WHEN** 轮询执行 `EvaluateAndAct`,`shouldResume` 返回 true
|
||||
- **THEN** `resumeDeviceCards(ctx, deviceID=10)` 被调用;检查所有 `stop_reason IN(...)` 的停机卡;3 张卡均满足 `isRealnameOK`(均已实名);3 张卡均触发复机
|
||||
|
||||
#### Scenario: 设备复机跳过未实名普通卡
|
||||
- **GIVEN** deviceID=20 下有 3 张卡:card-A(已实名)、card-B(已实名)、card-C(`card_category='normal'`, `real_name_status=0`)
|
||||
- **WHEN** 设备复机条件满足,执行 `resumeDeviceCards`
|
||||
- **THEN** card-A 和 card-B 触发复机(各执行 Gateway 复机调用);card-C 因 `isRealnameOK=false` 被跳过(保持停机);card-C 的 `stop_reason` 自动更新为 `not_realname`
|
||||
|
||||
#### Scenario: 设备复机中单卡 Gateway 失败不阻止其他卡
|
||||
- **GIVEN** 设备下 3 张卡需要复机,card-B 的 Gateway 复机调用失败(含重试)
|
||||
- **WHEN** `resumeDeviceCards` 遍历执行
|
||||
- **THEN** card-A 和 card-C 正常复机;card-B 记录 Error 日志并跳过,不影响其他卡的复机;`resumeDeviceCards` 整体不返回 error(尽力复机语义)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备维度操作幂等锁——防止并发重复 Gateway 调用
|
||||
|
||||
设备下多张卡分布在不同分片队列,同一调度周期内可能同时触发 `EvaluateAndAct`,进而并发调用 `stopDeviceCards` / `resumeDeviceCards`,导致同一张卡被 Gateway 重复停/复机。
|
||||
|
||||
`stopDeviceCards` 和 `resumeDeviceCards` 均在执行前通过 Redis `SetNX` 获取设备操作锁(`polling:device:op_lock:{deviceID}`,TTL 30 秒)。获取失败时视为"其他协程正在处理",直接跳过。
|
||||
|
||||
#### Scenario: 多卡并发触发不重复调 Gateway
|
||||
- **GIVEN** deviceID=10 下有 card-A(shard 3)和 card-B(shard 7),同一调度周期被并发出队
|
||||
- **WHEN** card-A 和 card-B 同时触发 `EvaluateAndAct` → 均尝试调用 `stopDeviceCards(deviceID=10)`
|
||||
- **THEN** 第一个调用获取设备锁成功,执行完整的停机流程;第二个调用 `SetNX` 返回 false,记录 Debug 日志「设备停机操作已在进行中,跳过: deviceID=10」,直接返回;设备下各卡只被 Gateway 停机一次
|
||||
|
||||
#### Scenario: 设备锁 TTL 超时后可重新获取
|
||||
- **GIVEN** 设备操作锁已过期(上次操作完成后 `Del` 释放,或 TTL 30 秒到期)
|
||||
- **WHEN** 下一轮轮询再次触发 `stopDeviceCards`
|
||||
- **THEN** `SetNX` 成功获取锁,正常执行停机流程
|
||||
267
openspec/specs/polling-task-handlers/spec.md
Normal file
267
openspec/specs/polling-task-handlers/spec.md
Normal file
@@ -0,0 +1,267 @@
|
||||
### Requirement: polling_handler.go 拆分为 4 个专注 Handler
|
||||
|
||||
将 `internal/task/polling_handler.go`(1360行)拆分为 4 个职责单一的文件,每个文件只负责一种任务类型的数据采集,停复机决策统一委托给 `StopResumeService.EvaluateAndAct()`。
|
||||
|
||||
**文件目标行数**:
|
||||
| 文件 | 职责 | 目标行数 |
|
||||
|------|------|---------|
|
||||
| `polling_base.go` | 共享基类(并发/缓存/重入队) | < 150行 |
|
||||
| `polling_realname_handler.go` | 实名状态采集 | < 200行 |
|
||||
| `polling_carddata_handler.go` | 流量数据采集 | < 300行 |
|
||||
| `polling_package_handler.go` | 套餐数据采集 | < 200行 |
|
||||
| `polling_protect_handler.go` | 保护期一致性检查 | < 150行 |
|
||||
|
||||
**删除**:拆分完成后,`internal/task/polling_handler.go` 整体删除。
|
||||
|
||||
**Handler 边界原则**:
|
||||
- ✅ 允许:调 Gateway 采集数据、写 Store(DB更新)、调 StopResumeService.EvaluateAndAct()
|
||||
- ❌ 禁止:直接 `h.db.Model()`(绕过 Store 层)、停复机判断逻辑、直接调 Gateway 停复机接口
|
||||
|
||||
#### Scenario: Asynq 任务类型常量不变(向后兼容)
|
||||
- **GIVEN** 现有 Asynq Worker 已注册 4 种任务类型常量
|
||||
- **WHEN** 拆分后注册 4 个新 Handler
|
||||
- **THEN** 任务类型常量名称完全不变(`TaskTypeRealnameCheck`、`TaskTypeCarddataCheck` 等);已在 Asynq 队列中的待处理任务无需清空,新 Handler 直接接管处理
|
||||
|
||||
#### Scenario: 4 个 Handler 并行处理不干扰
|
||||
- **GIVEN** 同时有 realname、carddata、package、protect 四种任务在处理
|
||||
- **WHEN** 各 Handler 独立执行
|
||||
- **THEN** 各 Handler 使用独立的并发锁(`PollingBase.acquireConcurrency` 按任务类型隔离);realname 任务的并发数不占用 carddata 任务的并发配额
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 共享基类 PollingBase
|
||||
|
||||
新建 `internal/task/polling_base.go`,提供 `PollingBase` 结构体,供 4 个 Handler 组合使用。
|
||||
|
||||
**提取的公共方法**(原来在 `polling_handler.go` 中重复出现):
|
||||
- `acquireConcurrency(taskType) bool`:获取并发控制信号量
|
||||
- `releaseConcurrency(taskType)`:释放并发控制信号量
|
||||
- `getCardWithCache(ctx, cardID) (*model.IotCard, error)`:带 Redis 缓存的卡查询
|
||||
- `updateCardCache(ctx, card)`:更新 Redis 卡信息缓存
|
||||
- `requeueCard(ctx, cardID, taskType, interval)`:按间隔重新入队(调 `PollingQueueManager.Requeue`)
|
||||
- `getMatchedPollingInterval(card) time.Duration`:获取该卡匹配的轮询间隔
|
||||
|
||||
#### Scenario: 缓存命中减少 DB 压力
|
||||
- **GIVEN** cardID=100 的卡信息已缓存在 Redis(TTL 5分钟)
|
||||
- **WHEN** `polling_realname_handler.go` 调用 `base.getCardWithCache(ctx, 100)`
|
||||
- **THEN** 直接从 Redis 读取,不查询 DB;缓存命中后更新 TTL(滑动窗口)
|
||||
|
||||
#### Scenario: 缓存未命中回源 DB
|
||||
- **GIVEN** cardID=200 的卡信息不在 Redis 缓存中
|
||||
- **WHEN** 任一 Handler 调用 `base.getCardWithCache(ctx, 200)`
|
||||
- **THEN** 查询 DB,将结果写入 Redis(TTL 5分钟);返回卡信息
|
||||
|
||||
#### Scenario: 并发控制防止过载——并发满时必须 requeue
|
||||
- **GIVEN** `carddata` 任务最大并发数配置为 50
|
||||
- **WHEN** 同时有 60 个 carddata 任务尝试执行
|
||||
- **THEN** 前 50 个获取到信号量正常执行;后 10 个 `acquireConcurrency` 返回 false;**后 10 个任务调 `requeueCard(ctx, cardID, taskType, time.Now())` 立即重入队**,记录 Debug 日志「并发数已满,已重新入队: cardID=xxx」,返回 nil(Asynq 不报错,不重试)
|
||||
|
||||
> **⚠️ 正确性约束**:Lua 脚本原子出队时卡已从 Redis Sorted Set 中删除。若并发满时直接 return nil 而不 requeue,该卡将永久消失(不再被轮询)。所有 Handler 必须在 `acquireConcurrency` 返回 false 时先调 `requeueCard` 再返回。
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_realname_handler.go——实名数据采集
|
||||
|
||||
**文件**:`internal/task/polling_realname_handler.go`(< 200行)
|
||||
|
||||
**构造函数依赖**(通过构造函数注入,禁止全局变量):
|
||||
```go
|
||||
func NewPollingRealnameHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient,
|
||||
iotCardStore IotCardStore,
|
||||
queueClient QueueClient, // 用于触发首次实名激活任务
|
||||
) *PollingRealnameHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
|
||||
**处理流程**:
|
||||
1. 调 `base.acquireConcurrency("realname")`,获取失败则跳过
|
||||
2. 调 `base.getCardWithCache(ctx, cardID)` 获取卡信息
|
||||
3. 调 Gateway 查询实名状态
|
||||
4. 写 Store(更新 `real_name_status`)
|
||||
5. 若实名状态变为已实名(0→1):
|
||||
a. 入队首次实名激活 Asynq 任务(`triggerFirstRealnameActivation`)
|
||||
b. **调 `stopResumeService.EvaluateAndAct(ctx, card)` 触发复机判断**(卡可能因 `not_realname` 停机,需立即复机)
|
||||
6. 调 `base.requeueCard(ctx, cardID, "realname", interval)` 重新入队
|
||||
7. 调 `base.releaseConcurrency("realname")`
|
||||
|
||||
#### Scenario: 实名状态由未实名变为已实名触发激活和复机
|
||||
- **GIVEN** cardID=100,`real_name_status=0`(未实名),且卡因 `not_realname` 处于停机状态(`network_status=0, stop_reason='not_realname'`)
|
||||
- **WHEN** Gateway 返回实名已完成,`HandleRealnameCheck` 执行
|
||||
- **THEN** Store 更新 `real_name_status=1`;入队首次实名激活 Asynq 任务;**立即调用 `EvaluateAndAct` 检测复机条件**;若有有效套餐且流量未耗尽,发起 Gateway 复机调用,DB 更新 `network_status=1, stop_reason=''`;记录日志「卡实名状态变更: cardID=100, 0→1,触发激活任务和复机评估」
|
||||
|
||||
#### Scenario: 实名状态未变化不触发激活
|
||||
- **GIVEN** cardID=200,`real_name_status=1`(已实名),Gateway 返回仍已实名
|
||||
- **WHEN** `HandleRealnameCheck` 执行
|
||||
- **THEN** Store 不执行更新(无变化);不入队激活任务;调 `requeueCard` 按正常间隔重新入队
|
||||
|
||||
#### Scenario: realname Handler 仅在实名 0→1 时调用 EvaluateAndAct(S1 修复)
|
||||
- **GIVEN** `polling_realname_handler.go` 代码 Review
|
||||
- **WHEN** 检查文件内容
|
||||
- **THEN** 文件中**不出现**无条件的 `stopCard`、`resumeCard` 等停复机操作;但**允许且必须**在实名状态由 0→1 时调用 `stopResumeService.EvaluateAndAct(ctx, card)` 触发复机判断;原因:若卡因 `not_realname` 停机,实名完成后应立即复机,不能等下一个 carddata/package 轮询周期(可能长达 1 小时)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_carddata_handler.go——流量数据采集
|
||||
|
||||
**文件**:`internal/task/polling_carddata_handler.go`(< 300行)
|
||||
|
||||
**构造函数依赖**:
|
||||
```go
|
||||
func NewPollingCarddataHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient,
|
||||
iotCardStore IotCardStore,
|
||||
packageStore PackageUsageStore,
|
||||
usageService UsageService, // DeductDataUsage
|
||||
stopResumeService StopResumeServiceInterface,
|
||||
) *PollingCarddataHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
- `collectUsageData(ctx, card) (*UsageData, error)`:调 Gateway 获取流量增量
|
||||
|
||||
**处理流程**:
|
||||
1. 获取并发控制
|
||||
2. 调 Gateway 查询流量增量
|
||||
3. 写 Store(更新 `data_usage_mb`)
|
||||
4. 调 `usageService.DeductDataUsage()`(套餐流量扣减计算)
|
||||
5. 调 `stopResumeService.EvaluateAndAct(ctx, card, ...)`(停复机决策)
|
||||
6. 重新入队,释放并发控制
|
||||
|
||||
#### Scenario: 流量更新后委托停复机决策
|
||||
- **GIVEN** cardID=100,流量更新后 `data_usage_mb` 达到 `data_limit_mb`
|
||||
- **WHEN** `HandleCarddataCheck` 执行完流量写入
|
||||
- **THEN** 调用 `stopResumeService.EvaluateAndAct(ctx, card, "iot_card", 100)`;由 StopResumeService 决定是否停机;Handler 不包含 `if usage >= limit { stop() }` 这类判断代码
|
||||
|
||||
#### Scenario: 消除直接 DB 操作
|
||||
- **GIVEN** 原 `polling_handler.go` 中有 13 处 `h.db.Model()` 直接 DB 操作
|
||||
- **WHEN** 拆分后的 `polling_carddata_handler.go` 代码 Review
|
||||
- **THEN** 文件中不出现 `h.db.`、`db.Model()`、`db.Where()` 等直接 GORM 调用;所有 DB 操作通过 Store 接口(`iotCardStore.UpdateXxx()`、`packageStore.UpdateXxx()`)
|
||||
|
||||
#### Scenario: 跨月流量边界检测(必须保留)
|
||||
- **GIVEN** cardID=100,`current_month_start_date=2026-03-01`,当前日期为 `2026-04-02`
|
||||
- **WHEN** Gateway 返回月度总流量 `500MB`,`processCard` 检测到跨月(当前月份首日 != current_month_start_date)
|
||||
- **THEN** 保存 `last_month_total_mb = 旧 current_month_total`;重置 `current_month_usage_mb = 500`(以 Gateway 新月份值为准);更新 `current_month_start_date = 2026-04-01`;记录流量历史到 `data_usage_records`
|
||||
|
||||
#### Scenario: 同月流量增量计算
|
||||
- **GIVEN** cardID=200,`current_month_start_date=2026-04-01`,`last_month_total_mb=100`,当前日期为 `2026-04-02`
|
||||
- **WHEN** Gateway 返回月度总流量 `150MB`
|
||||
- **THEN** 计算增量 `delta = 150 - 100 = 50MB`;更新 `current_month_usage_mb += 50`;更新 `last_month_total_mb = 150`
|
||||
|
||||
#### Scenario: Gateway 调用失败不丢失数据
|
||||
- **GIVEN** cardID=300,Gateway 返回网络超时
|
||||
- **WHEN** `collectUsageData` 调用 Gateway 失败
|
||||
- **THEN** 不更新 DB(不写入错误数据);记录 Warn 日志「流量查询失败: cardID=300, error=xxx」;调 `requeueCard` 按较短间隔(重试间隔)重新入队;Asynq 层面不触发重试(Handler 返回 nil,MaxRetry=0)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_package_handler.go——套餐数据采集
|
||||
|
||||
**文件**:`internal/task/polling_package_handler.go`(< 200行)
|
||||
|
||||
**构造函数依赖**:
|
||||
```go
|
||||
func NewPollingPackageHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient,
|
||||
packageStore PackageUsageStore,
|
||||
stopResumeService StopResumeServiceInterface,
|
||||
) *PollingPackageHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
|
||||
**处理流程**:
|
||||
1. 获取并发控制
|
||||
2. 调 Gateway 查询套餐信息(剩余流量、状态)
|
||||
3. 写 Store(更新 `tb_package_usage` 套餐状态/用量)
|
||||
4. 调 `stopResumeService.EvaluateAndAct(ctx, card, ...)`(停复机决策)
|
||||
5. 重新入队,释放并发控制
|
||||
|
||||
#### Scenario: 套餐到期后触发复机判断
|
||||
- **GIVEN** 卡因 `traffic_exhausted` 停机,旧套餐已过期(status=3),Gateway 返回新套餐已激活
|
||||
- **WHEN** `HandlePackageCheck` 执行,更新套餐状态后
|
||||
- **THEN** 调 `EvaluateAndAct`;StopResumeService 检测到新套餐有效、流量未耗尽;触发复机;Handler 不直接调用 resumeCard
|
||||
|
||||
#### Scenario: package Handler 不做停复机判断
|
||||
- **GIVEN** `polling_package_handler.go` 代码 Review
|
||||
- **WHEN** 检查文件内容
|
||||
- **THEN** 文件中不出现 `shouldStopCard`、`hasAvailablePackage`、`stopCard`、`resumeCard` 等函数;套餐处理逻辑仅限于数据采集和 Store 写入
|
||||
|
||||
---
|
||||
|
||||
### Requirement: polling_protect_handler.go——保护期一致性检查
|
||||
|
||||
**文件**:`internal/task/polling_protect_handler.go`(< 200行)
|
||||
|
||||
> **业务语义说明**:本 Handler 的核心目的是"确保保护期内卡状态与保护期方向一致,防止状态漂移"。
|
||||
> 保护期**内**:强制修正状态(直接调 Gateway,不走 EvaluateAndAct 三条件判断)。
|
||||
> 保护期**结束后**:调 EvaluateAndAct 重新评估(按正常停复机逻辑处理)。
|
||||
|
||||
**构造函数依赖**:
|
||||
```go
|
||||
func NewPollingProtectHandler(
|
||||
base *PollingBase,
|
||||
gateway GatewayClient, // 保护期内强制停复机需要直接调 Gateway
|
||||
iotCardStore IotCardStore,
|
||||
stopResumeService StopResumeServiceInterface,
|
||||
) *PollingProtectHandler
|
||||
```
|
||||
|
||||
**方法列表**:
|
||||
- `Handle(ctx, task) error`:Asynq 任务入口
|
||||
- `processCard(ctx, cardID) error`:核心处理逻辑
|
||||
|
||||
**处理流程**:
|
||||
1. 获取并发控制(并发满时 **requeue 后返回**,不丢弃)
|
||||
2. 查 Store 获取卡信息
|
||||
3. 前置跳过检查:
|
||||
- 卡未实名(`real_name_status=0`)→ requeue,直接返回
|
||||
- 卡未绑定设备(`is_standalone=true`)→ requeue,直接返回
|
||||
4. 读取设备保护期 Redis Key(`stop` 保护期 Key + `start` 保护期 Key)
|
||||
5. 根据保护期状态执行对应逻辑:
|
||||
- **有 stop 保护期 + 卡在线(network_status=1)**:直接调 `gateway.StopCard`(强制修正),写 DB `stop_reason='protect'`
|
||||
- **有 start 保护期 + 卡停机(network_status=0)**:直接调 `gateway.StartCard`(强制修正),清空 `stop_reason`
|
||||
- **有保护期 + 状态已一致**:跳过(不调 Gateway,不调 EvaluateAndAct)
|
||||
- **无保护期(保护期已结束)**:调 `stopResumeService.EvaluateAndAct(ctx, card)` 重新评估
|
||||
6. 调 `base.requeueCard` 按间隔重新入队
|
||||
7. 释放并发控制
|
||||
|
||||
#### Scenario: stop 保护期内卡在线——强制停机
|
||||
- **GIVEN** 卡已实名且绑定设备,设备有 stop 保护期(`polling:protect:stop:{deviceID}` Key 存在),但卡当前 `network_status=1`(在线,与保护期方向不一致)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到 stop 保护期存在且状态不一致;直接调 `gateway.StopCard`(不走 EvaluateAndAct,强制修正);DB 更新 `network_status=0, stop_reason='protect'`;记录 Info 日志「保护期强制停机: cardID=xxx, deviceID=xxx」
|
||||
|
||||
#### Scenario: start 保护期内卡停机——强制复机
|
||||
- **GIVEN** 卡已实名且绑定设备,设备有 start 保护期(`polling:protect:start:{deviceID}` Key 存在),但卡当前 `network_status=0`(停机,与保护期方向不一致)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到 start 保护期存在且状态不一致;直接调 `gateway.StartCard`(不走 EvaluateAndAct,强制修正);DB 更新 `network_status=1, stop_reason=''`;记录 Info 日志「保护期强制复机: cardID=xxx, deviceID=xxx」
|
||||
|
||||
#### Scenario: 保护期内状态已一致——跳过
|
||||
- **GIVEN** 设备有 stop 保护期,卡已是停机状态(`network_status=0`)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到保护期存在且状态已一致;跳过,不调 Gateway,不调 EvaluateAndAct;直接 requeue
|
||||
|
||||
#### Scenario: 保护期已结束——调 EvaluateAndAct 重新评估
|
||||
- **GIVEN** 卡在保护期内已停机(`stop_reason='protect'`),保护期 Key 已过期(TTL = 0)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行,读取保护期 Key 不存在
|
||||
- **THEN** 调 `stopResumeService.EvaluateAndAct(ctx, card)` 重新评估;若有有效套餐且已实名,触发复机;否则保持停机(`stop_reason` 更新为实际原因)
|
||||
|
||||
#### Scenario: 未实名卡跳过保护期逻辑
|
||||
- **GIVEN** 卡 `real_name_status=0`(未实名)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到未实名,直接 requeue(不检查保护期,不调 Gateway)
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)跳过
|
||||
- **GIVEN** 卡 `is_standalone=true`(未绑定设备)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 执行
|
||||
- **THEN** 检测到独立卡,直接 requeue(设备保护期与独立卡无关)
|
||||
Reference in New Issue
Block a user