【核心变更】
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>
10 KiB
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):
checkStopResumeshouldStopCardhasAvailablePackage(旧版,被新hasValidPackage替代)stopCardByUsageExhaustedresumeCardByPackageAvailable
所有 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)时,以下全部条件满足才触发自动复机:
stop_reason IN ('traffic_exhausted', 'no_package', 'not_realname')(排除手动停机、运营商停机等其他原因)- 有有效套餐且流量未耗尽(
hasValidPackage=true且isTrafficExhausted=false) - 行业卡 OR 已实名(
card_category='industry'ORreal_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成功获取锁,正常执行停机流程