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

@@ -1,107 +0,0 @@
package polling
import (
"context"
"strconv"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// APICallback API 进程使用的轻量级轮询回调
// 直接操作 Redis 队列,不依赖调度器
type APICallback struct {
redis *redis.Client
logger *zap.Logger
}
// NewAPICallback 创建 API 回调实例
func NewAPICallback(redis *redis.Client, logger *zap.Logger) *APICallback {
return &APICallback{
redis: redis,
logger: logger,
}
}
// OnCardCreated 卡创建时的回调
// 注意:大多数卡创建是通过 Worker 的批量导入完成的,这个方法主要用于单卡创建场景
func (c *APICallback) OnCardCreated(ctx context.Context, card *model.IotCard) {
if card == nil {
return
}
c.logger.Debug("API 回调:卡创建", zap.Uint("card_id", card.ID))
// 卡创建后scheduler 的渐进式初始化会将其加入队列
// 这里不做处理,让 scheduler 处理
}
// OnCardStatusChanged 卡状态变化时的回调
func (c *APICallback) OnCardStatusChanged(ctx context.Context, cardID uint) {
c.logger.Debug("API 回调:卡状态变化", zap.Uint("card_id", cardID))
// 状态变化后scheduler 下次扫描时会更新配置匹配
// 这里不做处理,让 scheduler 处理
}
// OnCardDeleted 卡删除时的回调
// 从所有队列中移除卡
func (c *APICallback) OnCardDeleted(ctx context.Context, cardID uint) {
c.logger.Debug("API 回调:卡删除", zap.Uint("card_id", cardID))
member := strconv.FormatUint(uint64(cardID), 10)
// 从所有轮询队列中移除
queues := []string{
constants.RedisPollingQueueRealnameKey(),
constants.RedisPollingQueueCarddataKey(),
constants.RedisPollingQueuePackageKey(),
}
for _, queueKey := range queues {
if err := c.redis.ZRem(ctx, queueKey, member).Err(); err != nil {
c.logger.Warn("从队列移除卡失败",
zap.String("queue", queueKey),
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
// 删除卡信息缓存
cacheKey := constants.RedisPollingCardInfoKey(cardID)
if err := c.redis.Del(ctx, cacheKey).Err(); err != nil {
c.logger.Warn("删除卡缓存失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
// OnCardEnabled 卡启用轮询时的回调
func (c *APICallback) OnCardEnabled(ctx context.Context, cardID uint) {
c.logger.Debug("API 回调:卡启用轮询", zap.Uint("card_id", cardID))
// 启用后scheduler 下次扫描时会将其加入队列
}
// OnCardDisabled 卡禁用轮询时的回调
// 从所有队列中移除卡
func (c *APICallback) OnCardDisabled(ctx context.Context, cardID uint) {
c.logger.Debug("API 回调:卡禁用轮询", zap.Uint("card_id", cardID))
member := strconv.FormatUint(uint64(cardID), 10)
// 从所有轮询队列中移除
queues := []string{
constants.RedisPollingQueueRealnameKey(),
constants.RedisPollingQueueCarddataKey(),
constants.RedisPollingQueuePackageKey(),
}
for _, queueKey := range queues {
if err := c.redis.ZRem(ctx, queueKey, member).Err(); err != nil {
c.logger.Warn("从队列移除卡失败",
zap.String("queue", queueKey),
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
}

View File

@@ -1,228 +0,0 @@
package polling
import (
"context"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// OnCardCreated 卡创建时的回调
// 将新卡加入轮询队列
func (s *Scheduler) OnCardCreated(ctx context.Context, card *model.IotCard) {
if card == nil {
return
}
s.logger.Debug("卡创建回调", zap.Uint("card_id", card.ID))
if err := s.initCardPolling(ctx, card); err != nil {
s.logger.Error("初始化新卡轮询失败",
zap.Uint("card_id", card.ID),
zap.Error(err))
}
}
// OnBatchCardsCreated 批量卡创建时的回调
func (s *Scheduler) OnBatchCardsCreated(ctx context.Context, cards []*model.IotCard) {
if len(cards) == 0 {
return
}
s.logger.Info("批量卡创建回调", zap.Int("count", len(cards)))
for _, card := range cards {
if err := s.initCardPolling(ctx, card); err != nil {
s.logger.Warn("初始化批量导入卡轮询失败",
zap.Uint("card_id", card.ID),
zap.Error(err))
}
}
}
// OnCardStatusChanged 卡状态变化时的回调
// 重新匹配配置并更新轮询队列
func (s *Scheduler) OnCardStatusChanged(ctx context.Context, cardID uint) {
s.logger.Debug("卡状态变化回调", zap.Uint("card_id", cardID))
// 从数据库重新加载卡信息
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
s.logger.Error("加载卡信息失败",
zap.Uint("card_id", cardID),
zap.Error(err))
return
}
// 先从所有队列中移除
s.removeFromAllQueues(ctx, cardID)
// 重新初始化轮询
if err := s.initCardPolling(ctx, card); err != nil {
s.logger.Error("重新初始化卡轮询失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
// OnCardDeleted 卡删除时的回调
// 从轮询队列中移除
func (s *Scheduler) OnCardDeleted(ctx context.Context, cardID uint) {
s.logger.Debug("卡删除回调", zap.Uint("card_id", cardID))
// 从所有队列中移除
s.removeFromAllQueues(ctx, cardID)
// 删除缓存
key := constants.RedisPollingCardInfoKey(cardID)
if err := s.redis.Del(ctx, key).Err(); err != nil {
s.logger.Warn("删除卡缓存失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
// OnCardEnabled 卡启用轮询时的回调
func (s *Scheduler) OnCardEnabled(ctx context.Context, cardID uint) {
s.logger.Debug("卡启用轮询回调", zap.Uint("card_id", cardID))
// 从数据库加载卡信息
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
s.logger.Error("加载卡信息失败",
zap.Uint("card_id", cardID),
zap.Error(err))
return
}
// 初始化轮询
if err := s.initCardPolling(ctx, card); err != nil {
s.logger.Error("启用卡轮询失败",
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
// OnCardDisabled 卡禁用轮询时的回调
func (s *Scheduler) OnCardDisabled(ctx context.Context, cardID uint) {
s.logger.Debug("卡禁用轮询回调", zap.Uint("card_id", cardID))
// 从所有队列中移除
s.removeFromAllQueues(ctx, cardID)
}
// removeFromAllQueues 从所有轮询队列中移除卡
func (s *Scheduler) removeFromAllQueues(ctx context.Context, cardID uint) {
member := formatUint(cardID)
queues := []string{
constants.RedisPollingQueueRealnameKey(),
constants.RedisPollingQueueCarddataKey(),
constants.RedisPollingQueuePackageKey(),
}
for _, queueKey := range queues {
if err := s.redis.ZRem(ctx, queueKey, member).Err(); err != nil {
s.logger.Warn("从队列移除卡失败",
zap.String("queue", queueKey),
zap.Uint("card_id", cardID),
zap.Error(err))
}
}
}
// RequeueCard 重新将卡加入队列
// 用于任务完成后重新入队
func (s *Scheduler) RequeueCard(ctx context.Context, cardID uint, taskType string) error {
// 从数据库加载卡信息
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return err
}
// 匹配配置
config := s.MatchConfig(card)
if config == nil {
return nil
}
now := time.Now()
var queueKey string
var intervalSeconds int
switch taskType {
case constants.TaskTypePollingRealname:
if config.RealnameCheckInterval == nil {
return nil
}
queueKey = constants.RedisPollingQueueRealnameKey()
intervalSeconds = *config.RealnameCheckInterval
case constants.TaskTypePollingCarddata:
if config.CarddataCheckInterval == nil {
return nil
}
queueKey = constants.RedisPollingQueueCarddataKey()
intervalSeconds = *config.CarddataCheckInterval
case constants.TaskTypePollingPackage:
if config.PackageCheckInterval == nil {
return nil
}
queueKey = constants.RedisPollingQueuePackageKey()
intervalSeconds = *config.PackageCheckInterval
default:
return nil
}
nextCheck := now.Add(time.Duration(intervalSeconds) * time.Second)
return s.addToQueue(ctx, queueKey, cardID, nextCheck)
}
// TriggerManualCheck 触发手动检查
func (s *Scheduler) TriggerManualCheck(ctx context.Context, cardID uint, taskType string) error {
key := constants.RedisPollingManualQueueKey(taskType)
return s.redis.RPush(ctx, key, formatUint(cardID)).Err()
}
// TriggerBatchManualCheck 批量触发手动检查
func (s *Scheduler) TriggerBatchManualCheck(ctx context.Context, cardIDs []uint, taskType string) error {
if len(cardIDs) == 0 {
return nil
}
key := constants.RedisPollingManualQueueKey(taskType)
// 转换为 interface{} 切片
members := make([]interface{}, len(cardIDs))
for i, id := range cardIDs {
members[i] = formatUint(id)
}
return s.redis.RPush(ctx, key, members...).Err()
}
// LazyLoad 懒加载卡信息
// 当卡未初始化但被访问时调用
func (s *Scheduler) LazyLoad(ctx context.Context, cardID uint) error {
// 检查是否已在缓存中
key := constants.RedisPollingCardInfoKey(cardID)
exists, err := s.redis.Exists(ctx, key).Result()
if err != nil {
return err
}
if exists > 0 {
return nil // 已缓存
}
// 从数据库加载
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
return err
}
// 初始化轮询
return s.initCardPolling(ctx, card)
}

View File

@@ -0,0 +1,152 @@
package polling
import (
"context"
"sync"
"time"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PollingConfigManager 轮询配置管理器
// 从 DB 加载 tb_polling_config同步到 Redis Hash内存缓存读写锁
// 5 分钟定时自动刷新;加载失败时保留原缓存不清空
type PollingConfigManager struct {
configStore *postgres.PollingConfigStore
redis *redis.Client
logger *zap.Logger
mu sync.RWMutex
configs []*model.PollingConfig
refreshOnce sync.Once
}
// NewPollingConfigManager 创建配置管理器
func NewPollingConfigManager(configStore *postgres.PollingConfigStore, redisClient *redis.Client, logger *zap.Logger) *PollingConfigManager {
return &PollingConfigManager{
configStore: configStore,
redis: redisClient,
logger: logger,
}
}
// Load 加载配置到内存缓存并同步到 Redis
// 加载失败时保留原缓存不清空,确保可用性
func (m *PollingConfigManager) Load(ctx context.Context) error {
configs, err := m.configStore.ListEnabled(ctx)
if err != nil {
m.logger.Error("加载轮询配置失败", zap.Error(err))
return err
}
m.mu.Lock()
m.configs = configs
m.mu.Unlock()
if syncErr := m.syncToRedis(ctx, configs); syncErr != nil {
m.logger.Warn("同步配置到 Redis 失败", zap.Error(syncErr))
}
m.logger.Info("轮询配置已加载", zap.Int("count", len(configs)))
return nil
}
// Start 启动定时刷新(每 5 分钟自动 Load 一次)
// 使用 sync.Once 确保只启动一个刷新 goroutine
func (m *PollingConfigManager) Start(ctx context.Context) {
m.refreshOnce.Do(func() {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
m.logger.Info("配置管理器已启动5分钟自动刷新")
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := m.Load(ctx); err != nil {
m.logger.Warn("定时刷新轮询配置失败,保留原配置", zap.Error(err))
}
}
}
}()
})
}
// MatchConfig 按优先级匹配第一个符合条件的轮询配置
// 配置按 priority ASC 排序DB 层保证),数字越小优先级越高
func (m *PollingConfigManager) MatchConfig(card *model.IotCard) *model.PollingConfig {
m.mu.RLock()
defer m.mu.RUnlock()
for _, cfg := range m.configs {
if matchConfigConditions(cfg, card) {
return cfg
}
}
return nil
}
// matchConfigConditions 检查卡是否满足配置的匹配条件
func matchConfigConditions(cfg *model.PollingConfig, card *model.IotCard) bool {
if cfg.CardCondition != "" {
if cfg.CardCondition != getCardCondition(card) {
return false
}
}
if cfg.CardCategory != "" {
if cfg.CardCategory != card.CardCategory {
return false
}
}
if cfg.CarrierID != nil {
if *cfg.CarrierID != card.CarrierID {
return false
}
}
return true
}
// getCardCondition 获取卡的状态条件(用于匹配轮询配置)
// ⚠️ 注意判断顺序:停机优先,避免停机卡错误匹配 activated 或 not_real_name 配置
// 停机卡需要继续轮询 carddata/package 以检测复机条件(套餐购买、流量重置、实名完成)
func getCardCondition(card *model.IotCard) string {
if card.NetworkStatus == constants.NetworkStatusOffline {
return "suspended"
}
if card.RealNameStatus != constants.RealNameStatusVerified {
return "not_real_name"
}
return "activated"
}
// syncToRedis 将配置同步到 Redis HashTTL 24h
func (m *PollingConfigManager) syncToRedis(ctx context.Context, configs []*model.PollingConfig) error {
if len(configs) == 0 {
return nil
}
key := constants.RedisPollingConfigsCacheKey()
configData := make([]interface{}, 0, len(configs)*2)
for _, cfg := range configs {
jsonData, err := sonic.Marshal(cfg)
if err != nil {
m.logger.Warn("序列化轮询配置失败", zap.Uint("config_id", cfg.ID), zap.Error(err))
continue
}
configData = append(configData, cfg.ID, string(jsonData))
}
if len(configData) == 0 {
return nil
}
pipe := m.redis.Pipeline()
pipe.HSet(ctx, key, configData...)
pipe.Expire(ctx, key, 24*time.Hour)
_, err := pipe.Exec(ctx)
return err
}

View File

@@ -15,7 +15,6 @@ type DataResetHandler struct {
resetService *packagepkg.ResetService
logger *zap.Logger
// 上次执行时间(用于限流,避免重复执行)
lastDailyReset time.Time
lastMonthlyReset time.Time
lastYearlyReset time.Time
@@ -26,9 +25,13 @@ func NewDataResetHandler(
resetService *packagepkg.ResetService,
logger *zap.Logger,
) *DataResetHandler {
now := time.Now()
return &DataResetHandler{
resetService: resetService,
logger: logger,
resetService: resetService,
logger: logger,
lastDailyReset: now,
lastMonthlyReset: now,
lastYearlyReset: now,
}
}

View File

@@ -0,0 +1,297 @@
package polling
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// initProgress 初始化进度内部状态(字段通过 GetProgress 快照对外,避免暴露锁)
type initProgress struct {
mu sync.RWMutex
totalCards int64
loadedCards int64
startTime time.Time
lastBatchTime time.Time
status string
errorMessage string
}
// InitProgress 初始化进度快照GetProgress 返回的值拷贝,调用方无需加锁)
type InitProgress struct {
TotalCards int64 `json:"total_cards"`
LoadedCards int64 `json:"loaded_cards"`
StartTime time.Time `json:"start_time"`
LastBatchTime time.Time `json:"last_batch_time"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
}
// pipelineFlushSize 单次 Pipeline Exec 最大命令数(每张卡最多 6 条)
// 10000 条约 2MB 内存峰值;千万级卡下约 6000 次 ExecRTT 总开销可忽略
const pipelineFlushSize = 10000
// PollingInitializer 分片渐进式初始化器
// 启动时从 DB 分批加载全量卡数据到分片 Sorted Set
// 使用 card_id % shardCount 分片,每 pipelineFlushSize 条命令 flush 一次 Pipeline
type PollingInitializer struct {
iotCardStore *postgres.IotCardStore
redis *redis.Client
configMgr *PollingConfigManager
queueMgr *PollingQueueManager
logger *zap.Logger
progress initProgress
initCompleted atomic.Bool
stopChan chan struct{}
wg sync.WaitGroup
}
// NewPollingInitializer 创建初始化器
func NewPollingInitializer(
iotCardStore *postgres.IotCardStore,
redisClient *redis.Client,
configMgr *PollingConfigManager,
queueMgr *PollingQueueManager,
logger *zap.Logger,
) *PollingInitializer {
p := &PollingInitializer{
iotCardStore: iotCardStore,
redis: redisClient,
configMgr: configMgr,
queueMgr: queueMgr,
logger: logger,
stopChan: make(chan struct{}),
}
p.progress.status = "pending"
return p
}
// StartBackground 启动后台渐进式初始化(非阻塞)
func (p *PollingInitializer) StartBackground(ctx context.Context) {
p.wg.Add(1)
go p.run(ctx)
}
// Stop 停止初始化
func (p *PollingInitializer) Stop() {
close(p.stopChan)
p.wg.Wait()
}
// IsCompleted 检查初始化是否完成
func (p *PollingInitializer) IsCompleted() bool {
return p.initCompleted.Load()
}
// GetProgress 返回当前初始化进度快照(加锁读取,返回值拷贝)
func (p *PollingInitializer) GetProgress() InitProgress {
p.progress.mu.RLock()
defer p.progress.mu.RUnlock()
return InitProgress{
TotalCards: p.progress.totalCards,
LoadedCards: p.progress.loadedCards,
StartTime: p.progress.startTime,
LastBatchTime: p.progress.lastBatchTime,
Status: p.progress.status,
ErrorMessage: p.progress.errorMessage,
}
}
// run 执行渐进式初始化
func (p *PollingInitializer) run(ctx context.Context) {
defer p.wg.Done()
p.setStatus("running", "")
p.progress.mu.Lock()
p.progress.startTime = time.Now()
p.progress.mu.Unlock()
p.logger.Info("开始分片渐进式初始化...")
totalCards, err := p.iotCardStore.CountForPolling(ctx)
if err != nil {
p.logger.Error("获取卡总数失败", zap.Error(err))
p.setStatus("failed", err.Error())
return
}
p.progress.mu.Lock()
p.progress.totalCards = totalCards
p.progress.mu.Unlock()
p.logger.Info("开始加载卡数据", zap.Int64("total_cards", totalCards))
const batchSize = 100000
const batchSleep = 500 * time.Millisecond
var lastID uint
batchCount := 0
for {
select {
case <-p.stopChan:
p.logger.Info("渐进式初始化被中断")
return
default:
}
cards, fetchErr := p.iotCardStore.ListForPollingBatch(ctx, lastID, batchSize)
if fetchErr != nil {
p.logger.Error("加载卡数据失败", zap.Error(fetchErr))
p.setStatus("failed", fetchErr.Error())
return
}
if len(cards) == 0 {
break
}
if initErr := p.initBatch(ctx, cards); initErr != nil {
p.logger.Warn("批量初始化失败", zap.Error(initErr))
}
lastID = cards[len(cards)-1].ID
batchCount++
p.progress.mu.Lock()
p.progress.loadedCards += int64(len(cards))
p.progress.lastBatchTime = time.Now()
loaded := p.progress.loadedCards
p.progress.mu.Unlock()
if batchCount%10 == 0 || len(cards) < batchSize {
p.logger.Info("初始化进度",
zap.Int("batch", batchCount),
zap.Int64("loaded", loaded),
zap.Int64("total", totalCards))
}
time.Sleep(batchSleep)
}
p.setStatus("completed", "")
p.initCompleted.Store(true)
snapshot := p.GetProgress()
p.logger.Info("分片渐进式初始化完成",
zap.Int64("total_loaded", snapshot.LoadedCards),
zap.Duration("duration", time.Since(snapshot.StartTime)))
}
// initBatch 使用 Pipeline 将一批卡写入分片队列和缓存
// 每 pipelineFlushSize 条命令 Exec 一次,控制内存峰值并降低单次失败损失
func (p *PollingInitializer) initBatch(ctx context.Context, cards []*model.IotCard) error {
if len(cards) == 0 {
return nil
}
now := time.Now()
cardCacheTTL := 7 * 24 * time.Hour
pipe := p.redis.Pipeline()
cmdCount := 0
flushPipe := func() {
if cmdCount == 0 {
return
}
if _, execErr := pipe.Exec(ctx); execErr != nil {
p.logger.Warn("Pipeline flush 失败,继续下一批", zap.Error(execErr))
}
pipe = p.redis.Pipeline()
cmdCount = 0
}
for _, card := range cards {
cfg := p.configMgr.MatchConfig(card)
if cfg == nil {
continue
}
shardID := int(card.ID) % p.queueMgr.shardCount
cardIDStr := fmt.Sprintf("%d", card.ID)
if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 {
nextCheck := calculateNextCheckTime(card.LastRealNameCheckAt, *cfg.RealnameCheckInterval, now)
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingRealname), redis.Z{
Score: float64(nextCheck.Unix()), Member: cardIDStr,
})
cmdCount++
}
if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 {
nextCheck := calculateNextCheckTime(card.LastDataCheckAt, *cfg.CarddataCheckInterval, now)
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingCarddata), redis.Z{
Score: float64(nextCheck.Unix()), Member: cardIDStr,
})
cmdCount++
}
if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 {
nextCheck := calculateNextCheckTime(card.LastDataCheckAt, *cfg.PackageCheckInterval, now)
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingPackage), redis.Z{
Score: float64(nextCheck.Unix()), Member: cardIDStr,
})
cmdCount++
}
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
nextCheck := calculateNextCheckTime(card.LastProtectCheckAt, *cfg.ProtectCheckInterval, now)
pipe.ZAdd(ctx, constants.RedisPollingShardQueueKey(shardID, constants.TaskTypePollingProtect), redis.Z{
Score: float64(nextCheck.Unix()), Member: cardIDStr,
})
cmdCount++
}
cacheKey := constants.RedisPollingCardInfoKey(card.ID)
cacheData := map[string]interface{}{
"id": card.ID, "iccid": card.ICCID,
"card_category": card.CardCategory, "real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus, "carrier_id": card.CarrierID,
"current_month_usage_mb": card.CurrentMonthUsageMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
"data_usage_mb": card.DataUsageMB,
"stop_reason": card.StopReason, "is_standalone": boolToStr(card.IsStandalone),
"cached_at": now.Unix(),
}
if card.CurrentMonthStartDate != nil {
cacheData["current_month_start_date"] = card.CurrentMonthStartDate.Unix()
}
pipe.HSet(ctx, cacheKey, cacheData)
pipe.Expire(ctx, cacheKey, cardCacheTTL)
cmdCount += 2
if cmdCount >= pipelineFlushSize {
flushPipe()
}
}
flushPipe()
return nil
}
// calculateNextCheckTime 计算下次检查时间
func calculateNextCheckTime(lastCheckAt *time.Time, intervalSeconds int, now time.Time) time.Time {
if lastCheckAt == nil {
jitter := time.Duration(now.UnixNano()%int64(intervalSeconds)) * time.Second / 10
return now.Add(jitter)
}
nextCheck := lastCheckAt.Add(time.Duration(intervalSeconds) * time.Second)
if nextCheck.Before(now) {
return now
}
return nextCheck
}
func (p *PollingInitializer) setStatus(status, errMsg string) {
p.progress.mu.Lock()
p.progress.status = status
p.progress.errorMessage = errMsg
p.progress.mu.Unlock()
}

View File

@@ -0,0 +1,195 @@
package polling
import (
"context"
"math/rand"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PollingLifecycleService 卡生命周期轮询管理服务
// 替代 callbacks.go 和 api_callback.go 中的生命周期方法
// 实现 iot_card.PollingCallback 接口两个进程API 和 Worker共享同一实现
// 依赖PollingQueueManager队列操作+ PollingConfigManager配置匹配
type PollingLifecycleService struct {
queueMgr *PollingQueueManager
configMgr *PollingConfigManager
iotCardStore *postgres.IotCardStore
deviceBindingStore *postgres.DeviceSimBindingStore
deviceStore *postgres.DeviceStore
logger *zap.Logger
}
// NewPollingLifecycleService 创建卡生命周期轮询管理服务
func NewPollingLifecycleService(
queueMgr *PollingQueueManager,
configMgr *PollingConfigManager,
iotCardStore *postgres.IotCardStore,
deviceBindingStore *postgres.DeviceSimBindingStore,
deviceStore *postgres.DeviceStore,
logger *zap.Logger,
) *PollingLifecycleService {
return &PollingLifecycleService{
queueMgr: queueMgr,
configMgr: configMgr,
iotCardStore: iotCardStore,
deviceBindingStore: deviceBindingStore,
deviceStore: deviceStore,
logger: logger,
}
}
// OnCardCreated 新卡创建后初始化轮询
func (s *PollingLifecycleService) OnCardCreated(ctx context.Context, card *model.IotCard) {
if card == nil {
return
}
if !s.shouldEnqueue(ctx, card) {
s.logger.Debug("卡禁用轮询,跳过入队", zap.Uint("card_id", card.ID))
return
}
s.enqueueCard(ctx, card)
}
// OnBatchCardsCreated 批量卡创建后批量初始化轮询
func (s *PollingLifecycleService) OnBatchCardsCreated(ctx context.Context, cards []*model.IotCard) {
for _, card := range cards {
s.OnCardCreated(ctx, card)
}
}
// OnCardStatusChanged 卡状态变化后重新匹配配置并更新队列
func (s *PollingLifecycleService) OnCardStatusChanged(ctx context.Context, cardID uint) {
if err := s.queueMgr.RemoveFromAllQueues(ctx, cardID); err != nil {
s.logger.Warn("卡状态变化:从队列移除失败", zap.Uint("card_id", cardID), zap.Error(err))
}
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
s.logger.Error("卡状态变化:加载卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
return
}
if !s.shouldEnqueue(ctx, card) {
return
}
s.enqueueCard(ctx, card)
}
// OnCardDeleted 卡删除后移除所有队列并清理缓存
func (s *PollingLifecycleService) OnCardDeleted(ctx context.Context, cardID uint) {
if err := s.queueMgr.OnCardDeleted(ctx, cardID); err != nil {
s.logger.Warn("卡删除:清理队列和缓存失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}
// OnCardEnabled 卡启用轮询后初始化
func (s *PollingLifecycleService) OnCardEnabled(ctx context.Context, cardID uint) {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
s.logger.Error("卡启用:加载卡信息失败", zap.Uint("card_id", cardID), zap.Error(err))
return
}
if !s.shouldEnqueue(ctx, card) {
return
}
s.enqueueCard(ctx, card)
}
// OnCardDisabled 卡禁用轮询后移除所有队列
func (s *PollingLifecycleService) OnCardDisabled(ctx context.Context, cardID uint) {
if err := s.queueMgr.RemoveFromAllQueues(ctx, cardID); err != nil {
s.logger.Warn("卡禁用:从队列移除失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}
// shouldEnqueue M3 修复:检查卡或其绑定设备是否允许轮询
// 两层检查:先查卡级 enable_polling再查设备级 enable_pollingIsStandalone=false 时)
// 直接查 device.enable_polling不依赖级联同步正确性防止同步失败时生命周期事件绕过设备级禁用
func (s *PollingLifecycleService) shouldEnqueue(ctx context.Context, card *model.IotCard) bool {
if !card.EnablePolling {
return false
}
if !card.IsStandalone && s.deviceBindingStore != nil && s.deviceStore != nil {
binding, err := s.deviceBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if err == nil && binding != nil {
device, devErr := s.deviceStore.GetByID(ctx, binding.DeviceID)
if devErr == nil && !device.EnablePolling {
s.logger.Debug("设备已禁用轮询,跳过卡入队",
zap.Uint("card_id", card.ID), zap.Uint("device_id", binding.DeviceID))
return false
}
}
}
return true
}
// enqueueCard 按匹配配置将卡入队分片队列
func (s *PollingLifecycleService) enqueueCard(ctx context.Context, card *model.IotCard) {
cfg := s.configMgr.MatchConfig(card)
if cfg == nil {
return
}
taskTypes := getEnabledTaskTypes(cfg)
for _, taskType := range taskTypes {
if err := s.queueMgr.Requeue(ctx, card.ID, taskType, calcInitialDelay(card, cfg, taskType)); err != nil {
s.logger.Warn("卡入队失败",
zap.Uint("card_id", card.ID), zap.String("task_type", taskType), zap.Error(err))
}
}
}
// getEnabledTaskTypes 返回配置中启用的任务类型列表
func getEnabledTaskTypes(cfg *model.PollingConfig) []string {
var types []string
if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 {
types = append(types, constants.TaskTypePollingRealname)
}
if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 {
types = append(types, constants.TaskTypePollingCarddata)
}
if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 {
types = append(types, constants.TaskTypePollingPackage)
}
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
types = append(types, constants.TaskTypePollingProtect)
}
return types
}
// calcInitialDelay 计算新入队卡的初始检查时间(加随机抖动避免惊群)
// 抖动范围 [0, max(interval/10, 2)) 秒;下界取 2 保证 rand.Intn 有实际随机性
// rand.Intn(1) 永远返回 0对 interval≤10 的配置会导致零抖动)
func calcInitialDelay(_ *model.IotCard, cfg *model.PollingConfig, taskType string) time.Time {
now := time.Now()
var interval int
switch taskType {
case constants.TaskTypePollingRealname:
if cfg.RealnameCheckInterval != nil {
interval = *cfg.RealnameCheckInterval
}
case constants.TaskTypePollingCarddata:
if cfg.CarddataCheckInterval != nil {
interval = *cfg.CarddataCheckInterval
}
case constants.TaskTypePollingPackage:
if cfg.PackageCheckInterval != nil {
interval = *cfg.PackageCheckInterval
}
case constants.TaskTypePollingProtect:
if cfg.ProtectCheckInterval != nil {
interval = *cfg.ProtectCheckInterval
}
}
if interval <= 0 {
return now
}
jitterMax := interval / 10
if jitterMax < 2 {
jitterMax = 2
}
return now.Add(time.Duration(rand.Intn(jitterMax)) * time.Second)
}

View File

@@ -113,8 +113,11 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
}
// processExpiredPackage 处理单个过期套餐
// triggerStopAfterExpiry 在事务提交后才调用,避免 goroutine 在 TX 提交前读到脏数据
func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error {
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
carrierType, carrierID := h.getCarrierInfo(pkg)
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 任务 19.3: 更新过期主套餐状态为 Expired (status=3)
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
return err
@@ -129,11 +132,9 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
h.logger.Warn("加油包级联失效失败",
zap.Uint("master_usage_id", pkg.ID),
zap.Error(err))
// 不返回错误,继续处理
}
// 任务 19.5: 查询并激活下一个待生效主套餐
carrierType, carrierID := h.getCarrierInfo(pkg)
if carrierType != "" && carrierID > 0 {
if err := h.activateNextPackage(ctx, tx, carrierType, carrierID); err != nil {
h.logger.Warn("激活下一个待生效套餐失败",
@@ -148,13 +149,21 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
zap.Uint("carrier_id", carrierID),
zap.Error(err))
}
// 套餐过期后主动触发停机,消除依赖 carddata 轮询兜底的 60s 延迟窗口
h.triggerStopAfterExpiry(ctx, carrierType, carrierID)
}
return nil
})
if err != nil {
return err
}
// 事务提交后再触发异步停机,确保 CheckAndStopCard 读到最新的套餐状态
if carrierType != "" && carrierID > 0 {
h.triggerStopAfterExpiry(ctx, carrierType, carrierID)
}
return nil
}
// invalidateAddons 任务 19.4: 加油包级联失效
@@ -439,67 +448,43 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont
}
// HandlePackageFirstActivation 处理首次实名激活任务Asynq Handler
// 任务 22: 由 Asynq 调用,执行首次实名后的套餐激活
// 由 polling_realname_handler 在检测到首次实名时触发。
// payload 中仅需 carrier_type + carrier_idActivateByRealname 内部自行查找
// pending_realname_activation=true 的套餐,无需外部指定 PackageUsageID。
func (h *PackageActivationHandler) HandlePackageFirstActivation(ctx context.Context, t *asynq.Task) error {
var payload PackageActivationPayload
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
h.logger.Error("解析首次实名激活任务载荷失败", zap.Error(err))
return nil // 不重试
return nil
}
if payload.CarrierType == "" || payload.CarrierID == 0 {
h.logger.Error("首次实名激活任务 carrier 信息缺失",
zap.String("carrier_type", payload.CarrierType),
zap.Uint("carrier_id", payload.CarrierID))
return nil
}
h.logger.Info("开始执行首次实名激活",
zap.Uint("package_usage_id", payload.PackageUsageID),
zap.String("carrier_type", payload.CarrierType),
zap.Uint("carrier_id", payload.CarrierID))
// 任务 22.4: 幂等性检查
var pkg model.PackageUsage
if err := h.db.First(&pkg, payload.PackageUsageID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
h.logger.Warn("套餐使用记录不存在", zap.Uint("package_usage_id", payload.PackageUsageID))
return nil
}
if h.activationService == nil {
h.logger.Warn("ActivationService 未注入,跳过首次实名激活")
return nil
}
if err := h.activationService.ActivateByRealname(ctx, payload.CarrierType, payload.CarrierID); err != nil {
h.logger.Error("首次实名激活失败",
zap.String("carrier_type", payload.CarrierType),
zap.Uint("carrier_id", payload.CarrierID),
zap.Error(err))
return err
}
// 检查 pending_realname_activation 是否已为 false已处理过
if !pkg.PendingRealnameActivation {
h.logger.Info("套餐已处理过首次实名激活,跳过",
zap.Uint("package_usage_id", payload.PackageUsageID))
return nil
}
// 如果已经是生效状态,跳过
if pkg.Status == constants.PackageUsageStatusActive {
h.logger.Info("套餐已激活,跳过",
zap.Uint("package_usage_id", payload.PackageUsageID))
return nil
}
// 任务 22.3: 调用 ActivationService.ActivateByRealname 激活套餐
if h.activationService != nil {
if err := h.activationService.ActivateByRealname(ctx, payload.CarrierType, payload.CarrierID); err != nil {
h.logger.Error("首次实名激活失败",
zap.Uint("package_usage_id", payload.PackageUsageID),
zap.String("carrier_type", payload.CarrierType),
zap.Uint("carrier_id", payload.CarrierID),
zap.Error(err))
return err
}
} else {
// ActivationService 未注入,直接更新状态(备用逻辑)
now := time.Now()
if err := h.db.Model(&pkg).Updates(map[string]any{
"status": constants.PackageUsageStatusActive,
"activated_at": now,
"pending_realname_activation": false,
}).Error; err != nil {
return err
}
}
h.logger.Info("首次实名激活成功",
zap.Uint("package_usage_id", payload.PackageUsageID))
zap.String("carrier_type", payload.CarrierType),
zap.Uint("carrier_id", payload.CarrierID))
return nil
}

View File

@@ -0,0 +1,159 @@
package polling
import (
"context"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// allTaskTypes 轮询系统的全部任务类型(用于 RemoveFromAllQueues 遍历)
var allTaskTypes = []string{
constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
constants.TaskTypePollingProtect,
}
// dequeueScript Lua 脚本原子出队ZRANGEBYSCORE + ZREM 服务端原子执行)
// 保留时间过滤语义:只取 score ≤ now 的到期卡,不触碰未来项
// 分批 ZREMLua unpack() 受 LUAI_MAXCSTACK 约 8000 限制,按 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
`)
// CardEntry 出队卡信息
type CardEntry struct {
CardID uint
}
// PollingQueueManager 统一 Redis 轮询队列操作
// 两个进程API 进程和 Worker 进程)共享,仅依赖 Redis Client
// 支持分片 Sorted Set实现千万级规模
type PollingQueueManager struct {
redis *redis.Client
shardCount int
logger *zap.Logger
}
// NewPollingQueueManager 创建轮询队列管理器
func NewPollingQueueManager(redisClient *redis.Client, shardCount int, logger *zap.Logger) *PollingQueueManager {
if shardCount <= 0 {
shardCount = constants.PollingShardCount
}
return &PollingQueueManager{
redis: redisClient,
shardCount: shardCount,
logger: logger,
}
}
// DequeueReady 原子出队到期卡Lua 脚本ZRANGEBYSCORE + ZREM 服务端原子执行)
// 只取 score ≤ now 的到期卡,不触碰未来项
// taskType: realname | carddata | package | protect
// shardID: 0 到 shardCount-1
func (m *PollingQueueManager) DequeueReady(ctx context.Context, shardID int, taskType string, batchSize int) ([]CardEntry, error) {
// 防御batchSize 不超过 Lua unpack 栈限制
if batchSize <= 0 || batchSize > constants.PollingDequeueMaxBatchSize {
batchSize = constants.PollingDequeueMaxBatchSize
}
key := constants.RedisPollingShardQueueKey(shardID, taskType)
now := time.Now().Unix()
results, err := dequeueScript.Run(ctx, m.redis, []string{key}, now, batchSize).StringSlice()
if err != nil && err != redis.Nil {
return nil, err
}
entries := make([]CardEntry, 0, len(results))
for _, s := range results {
id, parseErr := strconv.ParseUint(s, 10, 64)
if parseErr != nil {
m.logger.Warn("解析卡ID失败", zap.String("value", s), zap.Error(parseErr))
continue
}
entries = append(entries, CardEntry{CardID: uint(id)})
}
return entries, nil
}
// Requeue 将卡重新入队ZADDscore 为下次检查时间戳)
func (m *PollingQueueManager) Requeue(ctx context.Context, cardID uint, taskType string, nextCheckAt time.Time) error {
shardID := int(cardID) % m.shardCount
key := constants.RedisPollingShardQueueKey(shardID, taskType)
return m.redis.ZAdd(ctx, key, redis.Z{
Score: float64(nextCheckAt.Unix()),
Member: fmt.Sprintf("%d", cardID),
}).Err()
}
// RemoveFromAllQueues 从所有分片的所有4个队列含protect移除指定卡
// 修复 Bug3旧实现漏掉 protect 队列
func (m *PollingQueueManager) RemoveFromAllQueues(ctx context.Context, cardID uint) error {
member := fmt.Sprintf("%d", cardID)
pipe := m.redis.Pipeline()
for i := 0; i < m.shardCount; i++ {
for _, taskType := range allTaskTypes {
key := constants.RedisPollingShardQueueKey(i, taskType)
pipe.ZRem(ctx, key, member)
}
}
_, err := pipe.Exec(ctx)
return err
}
// EnqueueManual 手动触发入队List RPUSH调度器优先消费
func (m *PollingQueueManager) EnqueueManual(ctx context.Context, cardID uint, taskType string) error {
key := constants.RedisPollingManualQueueKey(taskType)
return m.redis.RPush(ctx, key, fmt.Sprintf("%d", cardID)).Err()
}
// OnCardDeleted 卡删除事件处理(移除所有队列 + 清理卡信息缓存)
func (m *PollingQueueManager) OnCardDeleted(ctx context.Context, cardID uint) error {
// 从所有分片队列移除
if err := m.RemoveFromAllQueues(ctx, cardID); err != nil {
return err
}
// 清理轮询卡信息缓存
cacheKey := constants.RedisPollingCardInfoKey(cardID)
return m.redis.Del(ctx, cacheKey).Err()
}
// GetQueueDepth 获取分片队列深度(用于背压检测)
func (m *PollingQueueManager) GetQueueDepth(ctx context.Context, shardID int, taskType string) (int64, error) {
key := constants.RedisPollingShardQueueKey(shardID, taskType)
return m.redis.ZCard(ctx, key).Result()
}
// GetTotalQueueDepth 获取指定任务类型的总队列深度(聚合所有分片)
// 供 MonitoringService 使用,替代直接读取旧的非分片 Redis Key
// 若任意分片查询失败,返回已累计的部分总量和第一个错误,调用方可据此判断数据完整性
func (m *PollingQueueManager) GetTotalQueueDepth(ctx context.Context, taskType string) (int64, error) {
var total int64
var firstErr error
for i := 0; i < m.shardCount; i++ {
depth, err := m.GetQueueDepth(ctx, i, taskType)
if err != nil {
m.logger.Warn("获取分片队列深度失败",
zap.Int("shard_id", i),
zap.String("task_type", taskType),
zap.Error(err))
if firstErr == nil {
firstErr = err
}
continue
}
total += depth
}
return total, firstErr
}

View File

@@ -2,144 +2,81 @@ package polling
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"time"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// InitProgress 已迁移到 initializer.go
// Scheduler 轮询调度器
// 负责管理 IoT 卡的定期检查任务(实名、流量、套餐)
// 职责:读取分片 Sorted Set 中到期的卡,生成 Asynq 任务
// 不再负责:配置加载、卡初始化(分别由 PollingConfigManager、PollingInitializer 负责)
type Scheduler struct {
db *gorm.DB
redis *redis.Client
queueClient *asynq.Client
logger *zap.Logger
configStore *postgres.PollingConfigStore
iotCardStore *postgres.IotCardStore
concurrencyStore *postgres.PollingConcurrencyConfigStore
redis *redis.Client
queueClient *asynq.Client
logger *zap.Logger
queueMgr *PollingQueueManager
configMgr *PollingConfigManager
cfg *SchedulerConfig // 启动时固定,避免每次调度重新创建
// 任务 19: 套餐激活检查处理器
packageActivationHandler *PackageActivationHandler
// 任务 20: 流量重置调度处理器
dataResetHandler *DataResetHandler
dataResetHandler *DataResetHandler
// 配置缓存
configCache []*model.PollingConfig
configCacheLock sync.RWMutex
configCacheTime time.Time
// 初始化状态
initProgress *InitProgress
initCompleted atomic.Bool
// 控制信号
stopChan chan struct{}
wg sync.WaitGroup
}
// InitProgress 初始化进度
type InitProgress struct {
mu sync.RWMutex
TotalCards int64 `json:"total_cards"` // 总卡数
LoadedCards int64 `json:"loaded_cards"` // 已加载卡数
StartTime time.Time `json:"start_time"` // 开始时间
LastBatchTime time.Time `json:"last_batch_time"` // 最后一批处理时间
Status string `json:"status"` // 状态: pending, running, completed, failed
ErrorMessage string `json:"error_message"` // 错误信息
}
// SchedulerConfig 调度器配置
// 设计目标:支持一亿张卡规模
type SchedulerConfig struct {
ScheduleInterval time.Duration // 调度循环间隔(默认 1 秒,支持高吞吐)
InitBatchSize int // 初始化每批加载数量(默认 100000
InitBatchSleepDuration time.Duration // 初始化批次间休眠时间(默认 500ms
ConfigCacheTTL time.Duration // 配置缓存 TTL默认 5 分钟)
CardCacheTTL time.Duration // 卡信息缓存 TTL默认 7 天)
ScheduleBatchSize int // 每次调度取出的卡数(默认 50000
MaxManualBatchSize int // 手动触发每次处理数量(默认 1000
ScheduleInterval time.Duration
MaxManualBatchSize int
ScheduleBatchSize int
}
// DefaultSchedulerConfig 默认调度器配置
// 单 Worker 设计吞吐50000 张/秒,支持多 Worker 水平扩展
func DefaultSchedulerConfig() *SchedulerConfig {
return &SchedulerConfig{
ScheduleInterval: 1 * time.Second, // 1秒调度一次提高响应速度
InitBatchSize: 100000, // 10万张/批初始化
InitBatchSleepDuration: 500 * time.Millisecond, // 500ms 间隔,加快初始化
ConfigCacheTTL: 5 * time.Minute,
CardCacheTTL: 7 * 24 * time.Hour,
ScheduleBatchSize: 50000, // 每次取 5 万张,每秒可调度 5 万张
MaxManualBatchSize: 1000, // 手动触发每次处理 1000 张
ScheduleInterval: 1 * time.Second,
MaxManualBatchSize: 1000,
ScheduleBatchSize: constants.PollingDequeueMaxBatchSize,
}
}
// NewScheduler 创建调度器实例
// NewScheduler 创建调度器
func NewScheduler(
db *gorm.DB,
redisClient *redis.Client,
queueClient *asynq.Client,
queueMgr *PollingQueueManager,
configMgr *PollingConfigManager,
logger *zap.Logger,
packageActivationHandler *PackageActivationHandler,
dataResetHandler *DataResetHandler,
) *Scheduler {
return &Scheduler{
db: db,
redis: redisClient,
queueClient: queueClient,
queueMgr: queueMgr,
configMgr: configMgr,
logger: logger,
configStore: postgres.NewPollingConfigStore(db),
iotCardStore: postgres.NewIotCardStore(db, redisClient),
concurrencyStore: postgres.NewPollingConcurrencyConfigStore(db),
packageActivationHandler: NewPackageActivationHandler(db, redisClient, queueClient, nil, nil, logger),
dataResetHandler: NewDataResetHandler(nil, logger), // ResetService 需要通过 SetResetService 注入
initProgress: &InitProgress{
Status: "pending",
},
stopChan: make(chan struct{}),
cfg: DefaultSchedulerConfig(),
stopChan: make(chan struct{}),
packageActivationHandler: packageActivationHandler,
dataResetHandler: dataResetHandler,
}
}
// Start 启动调度
// 快速启动10秒内完成配置加载和调度器启动
// Start 启动调度循环(快速启动,配置加载和初始化由外部完成)
func (s *Scheduler) Start(ctx context.Context) error {
startTime := time.Now()
s.logger.Info("轮询调度器启动中...")
// 1. 加载轮询配置到缓存
if err := s.loadConfigs(ctx); err != nil {
s.logger.Error("加载轮询配置失败", zap.Error(err))
return err
}
s.logger.Info("轮询配置已加载", zap.Int("config_count", len(s.configCache)))
// 2. 初始化并发控制配置
if err := s.initConcurrencyConfigs(ctx); err != nil {
s.logger.Warn("初始化并发控制配置失败,使用默认值", zap.Error(err))
}
// 3. 启动调度循环(非阻塞)
s.wg.Add(1)
go s.scheduleLoop(ctx)
// 4. 启动后台渐进式初始化(非阻塞)
s.wg.Add(1)
go s.progressiveInit(ctx)
elapsed := time.Since(startTime)
s.logger.Info("轮询调度器已启动",
zap.Duration("startup_time", elapsed),
zap.Bool("fast_start", elapsed < 10*time.Second))
s.logger.Info("轮询调度器已启动")
return nil
}
@@ -151,115 +88,116 @@ func (s *Scheduler) Stop() {
s.logger.Info("轮询调度器已停止")
}
// loadConfigs 加载轮询配置缓存
func (s *Scheduler) loadConfigs(ctx context.Context) error {
configs, err := s.configStore.ListEnabled(ctx)
if err != nil {
return err
// RefreshConfigs 刷新配置缓存
func (s *Scheduler) RefreshConfigs(ctx context.Context) error {
if s.configMgr == nil {
return nil
}
s.configCacheLock.Lock()
s.configCache = configs
s.configCacheTime = time.Now()
s.configCacheLock.Unlock()
// 同步到 Redis 缓存
return s.syncConfigsToRedis(ctx, configs)
return s.configMgr.Load(ctx)
}
// syncConfigsToRedis 同步配置到 Redis
func (s *Scheduler) syncConfigsToRedis(ctx context.Context, configs []*model.PollingConfig) error {
key := constants.RedisPollingConfigsCacheKey()
// 序列化配置列表为 JSON
configData := make([]interface{}, 0, len(configs)*2)
for _, cfg := range configs {
jsonData, err := json.Marshal(cfg)
if err != nil {
s.logger.Warn("序列化轮询配置失败", zap.Uint("config_id", cfg.ID), zap.Error(err))
continue
}
configData = append(configData, cfg.ID, string(jsonData))
// SetStopResumeCallback 注入停复机回调(在 Start 前调用)
func (s *Scheduler) SetStopResumeCallback(callback packagepkg.StopResumeCallback) {
if s.packageActivationHandler != nil {
s.packageActivationHandler.stopResumeCallback = callback
}
if len(configData) > 0 {
pipe := s.redis.Pipeline()
pipe.Del(ctx, key)
// 使用 HSET 存储配置
pipe.HSet(ctx, key, configData...)
pipe.Expire(ctx, key, 24*time.Hour)
_, err := pipe.Exec(ctx)
return err
}
return nil
}
// initConcurrencyConfigs 初始化并发控制配置到 Redis
func (s *Scheduler) initConcurrencyConfigs(ctx context.Context) error {
configs, err := s.concurrencyStore.List(ctx)
if err != nil {
return err
}
for _, cfg := range configs {
key := constants.RedisPollingConcurrencyConfigKey(cfg.TaskType)
if err := s.redis.Set(ctx, key, cfg.MaxConcurrency, 0).Err(); err != nil {
s.logger.Warn("设置并发配置失败",
zap.String("task_type", cfg.TaskType),
zap.Error(err))
}
}
return nil
}
// scheduleLoop 调度循环
// 每 10 秒执行一次,从 Redis Sorted Set 获取到期的卡,生成 Asynq 任务
func (s *Scheduler) scheduleLoop(ctx context.Context) {
defer s.wg.Done()
config := DefaultSchedulerConfig()
ticker := time.NewTicker(config.ScheduleInterval)
ticker := time.NewTicker(s.cfg.ScheduleInterval)
activationTicker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
defer activationTicker.Stop()
s.logger.Info("调度循环已启动", zap.Duration("interval", config.ScheduleInterval))
s.logger.Info("调度循环已启动", zap.Duration("interval", s.cfg.ScheduleInterval))
for {
select {
case <-s.stopChan:
s.logger.Info("调度循环收到停止信号")
return
case <-ctx.Done():
s.logger.Info("调度循环收到 ctx 取消信号")
return
case <-ticker.C:
s.processSchedule(ctx)
s.processShardSchedule(ctx)
case <-activationTicker.C:
s.processActivationTasks(ctx)
}
}
}
// processSchedule 处理一次调度
func (s *Scheduler) processSchedule(ctx context.Context) {
now := time.Now().Unix()
// processShardSchedule 处理手动队列和分片定时队列(每 1 秒触发)
// 使用 90% 的 tick 间隔作为超时,确保单分片 Redis 挂起时不阻塞下一个 tick
func (s *Scheduler) processShardSchedule(ctx context.Context) {
for _, taskType := range allTaskTypes {
s.processManualQueue(ctx, taskType, s.cfg.MaxManualBatchSize)
}
// 1. 优先处理手动触发队列
s.processManualQueue(ctx, constants.TaskTypePollingRealname)
s.processManualQueue(ctx, constants.TaskTypePollingCarddata)
s.processManualQueue(ctx, constants.TaskTypePollingPackage)
s.processManualQueue(ctx, constants.TaskTypePollingProtect)
if s.queueMgr == nil {
return
}
// 2. 处理定时队列
s.processTimedQueue(ctx, constants.RedisPollingQueueRealnameKey(), constants.TaskTypePollingRealname, now)
s.processTimedQueue(ctx, constants.RedisPollingQueueCarddataKey(), constants.TaskTypePollingCarddata, now)
s.processTimedQueue(ctx, constants.RedisPollingQueuePackageKey(), constants.TaskTypePollingPackage, now)
s.processTimedQueue(ctx, constants.RedisPollingQueueProtectKey(), constants.TaskTypePollingProtect, now)
timeout := s.cfg.ScheduleInterval * 9 / 10
tickCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// 任务 19.6: 套餐激活检查(每次调度都执行,内部会限流)
var wg sync.WaitGroup
for shardID := 0; shardID < s.queueMgr.shardCount; shardID++ {
wg.Add(1)
go func(sid int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
s.logger.Error("分片处理 panic已恢复",
zap.Int("shard_id", sid), zap.Any("panic", r))
}
}()
s.processOneShard(tickCtx, sid)
}(shardID)
}
wg.Wait()
}
// processOneShard 处理单个分片的所有任务类型出队并推入 Asynq
func (s *Scheduler) processOneShard(ctx context.Context, shardID int) {
for _, taskType := range allTaskTypes {
depth, err := s.queueMgr.GetQueueDepth(ctx, shardID, taskType)
if err != nil {
s.logger.Warn("获取分片队列深度失败",
zap.Int("shard_id", shardID), zap.String("task_type", taskType), zap.Error(err))
} else if depth > constants.PollingBackpressureThreshold {
s.logger.Debug("背压:分片队列积压过深,跳过本轮出队",
zap.Int("shard_id", shardID), zap.String("task_type", taskType), zap.Int64("depth", depth))
continue
}
entries, err := s.queueMgr.DequeueReady(ctx, shardID, taskType, s.cfg.ScheduleBatchSize)
if err != nil {
s.logger.Error("分片出队失败",
zap.Int("shard_id", shardID), zap.String("task_type", taskType), zap.Error(err))
continue
}
if len(entries) > 0 {
cardIDs := make([]string, len(entries))
for i, e := range entries {
cardIDs[i] = formatUint(e.CardID)
}
s.enqueueBatch(ctx, taskType, cardIDs)
}
}
}
// processActivationTasks 套餐激活检查和流量重置调度(每 10 秒触发)
func (s *Scheduler) processActivationTasks(ctx context.Context) {
if s.packageActivationHandler != nil {
if err := s.packageActivationHandler.HandlePackageActivationCheck(ctx); err != nil {
s.logger.Warn("套餐激活检查失败", zap.Error(err))
}
}
// 任务 20.6: 流量重置调度(每次调度都执行,内部会限流)
if s.dataResetHandler != nil {
if err := s.dataResetHandler.HandleDataReset(ctx); err != nil {
s.logger.Warn("流量重置调度失败", zap.Error(err))
@@ -268,497 +206,43 @@ func (s *Scheduler) processSchedule(ctx context.Context) {
}
// processManualQueue 处理手动触发队列
// 优化:批量读取和提交,提高吞吐
func (s *Scheduler) processManualQueue(ctx context.Context, taskType string) {
config := DefaultSchedulerConfig()
func (s *Scheduler) processManualQueue(ctx context.Context, taskType string, maxBatch int) {
key := constants.RedisPollingManualQueueKey(taskType)
// 批量读取手动触发任务
cardIDs := make([]string, 0, config.MaxManualBatchSize)
for i := 0; i < config.MaxManualBatchSize; i++ {
cardIDStr, err := s.redis.LPop(ctx, key).Result()
if err != nil {
if err != redis.Nil {
s.logger.Error("读取手动触发队列失败",
zap.String("task_type", taskType),
zap.Error(err))
}
break
}
cardIDs = append(cardIDs, cardIDStr)
}
// 批量提交任务
if len(cardIDs) > 0 {
s.enqueueBatch(ctx, taskType, cardIDs, true)
cardIDs, err := s.redis.LPopCount(ctx, key, maxBatch).Result()
if err != nil || len(cardIDs) == 0 {
return
}
s.enqueueBatch(ctx, taskType, cardIDs)
}
// processTimedQueue 处理定时队列
// 优化:支持大批量处理,每次最多取 ScheduleBatchSize 张卡
func (s *Scheduler) processTimedQueue(ctx context.Context, queueKey, taskType string, now int64) {
config := DefaultSchedulerConfig()
// 获取所有到期的卡score <= now
// 使用 ZRANGEBYSCORE 获取,每次最多取 ScheduleBatchSize 张
cardIDs, err := s.redis.ZRangeByScore(ctx, queueKey, &redis.ZRangeBy{
Min: "-inf",
Max: formatInt64(now),
Count: int64(config.ScheduleBatchSize),
}).Result()
if err != nil {
if err != redis.Nil {
s.logger.Error("读取定时队列失败",
zap.String("queue_key", queueKey),
zap.Error(err))
// enqueueBatch 批量提交任务到 Asynq 队列;入队失败时回退至分片队列防止卡永久丢失
func (s *Scheduler) enqueueBatch(ctx context.Context, taskType string, cardIDs []string) {
for _, cardID := range cardIDs {
payload := map[string]interface{}{
"card_id": cardID,
"is_manual": false,
"timestamp": time.Now().Unix(),
}
return
}
if len(cardIDs) == 0 {
return
}
// 只在数量较大时打印日志,避免日志过多
if len(cardIDs) >= 1000 {
s.logger.Info("处理定时队列",
zap.String("task_type", taskType),
zap.Int("card_count", len(cardIDs)))
}
// 移除已取出的卡(使用最后一个卡的 score 作为边界,更精确)
if err := s.redis.ZRemRangeByScore(ctx, queueKey, "-inf", formatInt64(now)).Err(); err != nil {
s.logger.Error("移除已处理的卡失败", zap.Error(err))
}
// 批量提交任务(使用 goroutine 并行提交,提高吞吐)
s.enqueueBatch(ctx, taskType, cardIDs, false)
}
// enqueueBatch 批量提交任务到 Asynq 队列
// 使用多 goroutine 并行提交,提高吞吐量
func (s *Scheduler) enqueueBatch(ctx context.Context, taskType string, cardIDs []string, isManual bool) {
if len(cardIDs) == 0 {
return
}
// 分批并行提交,每批 1000 个,最多 10 个并行
batchSize := 1000
maxParallel := 10
sem := make(chan struct{}, maxParallel)
var wg sync.WaitGroup
for i := 0; i < len(cardIDs); i += batchSize {
end := i + batchSize
if end > len(cardIDs) {
end = len(cardIDs)
payloadBytes, marshalErr := marshalJSON(payload)
if marshalErr != nil {
s.logger.Error("序列化任务载荷失败,跳过该卡",
zap.String("task_type", taskType), zap.String("card_id", cardID), zap.Error(marshalErr))
continue
}
batch := cardIDs[i:end]
wg.Add(1)
sem <- struct{}{} // 获取信号量
go func(batch []string) {
defer wg.Done()
defer func() { <-sem }() // 释放信号量
for _, cardID := range batch {
if err := s.enqueueTask(ctx, taskType, cardID, isManual); err != nil {
s.logger.Warn("提交任务失败",
zap.String("task_type", taskType),
zap.String("card_id", cardID),
zap.Error(err))
task := asynq.NewTask(taskType, payloadBytes,
asynq.MaxRetry(0),
asynq.Timeout(30*time.Second),
asynq.Queue(constants.QueueDefault),
)
if _, err := s.queueClient.Enqueue(task); err != nil {
s.logger.Error("提交任务失败,回退至分片队列防止卡永久丢失",
zap.String("task_type", taskType), zap.String("card_id", cardID), zap.Error(err))
if id, parseErr := parseUint(cardID); parseErr == nil {
if reqErr := s.queueMgr.Requeue(ctx, id, taskType, time.Now()); reqErr != nil {
s.logger.Error("回退入队失败,卡可能永久丢失",
zap.String("card_id", cardID), zap.Error(reqErr))
}
}
}(batch)
}
wg.Wait()
}
// enqueueTask 提交任务到 Asynq 队列
func (s *Scheduler) enqueueTask(ctx context.Context, taskType, cardID string, isManual bool) error {
payload := map[string]interface{}{
"card_id": cardID,
"is_manual": isManual,
"timestamp": time.Now().Unix(),
}
task := asynq.NewTask(taskType, mustMarshal(payload),
asynq.MaxRetry(0), // 不重试,失败后重新入队
asynq.Timeout(30*time.Second), // 30秒超时
asynq.Queue(constants.QueueDefault),
)
_, err := s.queueClient.Enqueue(task)
return err
}
// progressiveInit 渐进式初始化
// 分批加载卡数据到 Redis每批 10 万张sleep 1 秒
func (s *Scheduler) progressiveInit(ctx context.Context) {
defer s.wg.Done()
config := DefaultSchedulerConfig()
s.initProgress.mu.Lock()
s.initProgress.Status = "running"
s.initProgress.StartTime = time.Now()
s.initProgress.mu.Unlock()
s.logger.Info("开始渐进式初始化...")
// 获取总卡数
var totalCards int64
if err := s.db.Model(&model.IotCard{}).Count(&totalCards).Error; err != nil {
s.logger.Error("获取卡总数失败", zap.Error(err))
s.setInitError(err.Error())
return
}
s.initProgress.mu.Lock()
s.initProgress.TotalCards = totalCards
s.initProgress.mu.Unlock()
s.logger.Info("开始加载卡数据", zap.Int64("total_cards", totalCards))
// 使用游标分批加载
var lastID uint = 0
batchCount := 0
for {
select {
case <-s.stopChan:
s.logger.Info("渐进式初始化被中断")
return
default:
}
// 加载一批卡
var cards []*model.IotCard
err := s.db.WithContext(ctx).
Where("id > ?", lastID).
Order("id ASC").
Limit(config.InitBatchSize).
Find(&cards).Error
if err != nil {
s.logger.Error("加载卡数据失败", zap.Error(err))
s.setInitError(err.Error())
return
}
if len(cards) == 0 {
break
}
// 批量处理这批卡(使用 Pipeline 提高性能)
if err := s.initCardsBatch(ctx, cards); err != nil {
s.logger.Warn("批量初始化卡轮询失败", zap.Error(err))
}
lastID = cards[len(cards)-1].ID
batchCount++
s.initProgress.mu.Lock()
s.initProgress.LoadedCards += int64(len(cards))
s.initProgress.LastBatchTime = time.Now()
s.initProgress.mu.Unlock()
s.logger.Info("完成一批卡初始化",
zap.Int("batch", batchCount),
zap.Int("batch_size", len(cards)),
zap.Int64("loaded", s.initProgress.LoadedCards),
zap.Int64("total", totalCards))
// 批次间休眠,避免打爆数据库
time.Sleep(config.InitBatchSleepDuration)
}
s.initProgress.mu.Lock()
s.initProgress.Status = "completed"
s.initProgress.mu.Unlock()
s.initCompleted.Store(true)
s.logger.Info("渐进式初始化完成",
zap.Int64("total_loaded", s.initProgress.LoadedCards),
zap.Duration("duration", time.Since(s.initProgress.StartTime)))
}
// initCardsBatch 批量初始化卡的轮询
// 使用 Redis Pipeline 批量写入,大幅提高初始化性能
// 10万张卡从 ~60秒 优化到 ~5秒
func (s *Scheduler) initCardsBatch(ctx context.Context, cards []*model.IotCard) error {
if len(cards) == 0 {
return nil
}
config := DefaultSchedulerConfig()
now := time.Now()
pipe := s.redis.Pipeline()
for _, card := range cards {
// 匹配配置
cfg := s.MatchConfig(card)
if cfg == nil {
continue // 无匹配配置,不需要轮询
}
// 添加到相应的轮询队列
if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastRealNameCheckAt, *cfg.RealnameCheckInterval)
pipe.ZAdd(ctx, constants.RedisPollingQueueRealnameKey(), redis.Z{
Score: float64(nextCheck.Unix()),
Member: card.ID,
})
}
if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *cfg.CarddataCheckInterval)
pipe.ZAdd(ctx, constants.RedisPollingQueueCarddataKey(), redis.Z{
Score: float64(nextCheck.Unix()),
Member: card.ID,
})
}
if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *cfg.PackageCheckInterval)
pipe.ZAdd(ctx, constants.RedisPollingQueuePackageKey(), redis.Z{
Score: float64(nextCheck.Unix()),
Member: card.ID,
})
}
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastProtectCheckAt, *cfg.ProtectCheckInterval)
pipe.ZAdd(ctx, constants.RedisPollingQueueProtectKey(), redis.Z{
Score: float64(nextCheck.Unix()),
Member: card.ID,
})
}
// 缓存卡信息到 Redis
cacheKey := constants.RedisPollingCardInfoKey(card.ID)
cacheData := map[string]interface{}{
"id": card.ID,
"iccid": card.ICCID,
"card_category": card.CardCategory,
"real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus,
"carrier_id": card.CarrierID,
"stop_reason": card.StopReason,
"cached_at": now.Unix(),
}
pipe.HSet(ctx, cacheKey, cacheData)
pipe.Expire(ctx, cacheKey, config.CardCacheTTL)
}
// 执行 Pipeline
_, err := pipe.Exec(ctx)
return err
}
// initCardPolling 初始化单张卡的轮询(保留用于懒加载场景)
func (s *Scheduler) initCardPolling(ctx context.Context, card *model.IotCard) error {
// 匹配配置
config := s.MatchConfig(card)
if config == nil {
return nil // 无匹配配置,不需要轮询
}
now := time.Now()
// 添加到相应的轮询队列
if config.RealnameCheckInterval != nil && *config.RealnameCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastRealNameCheckAt, *config.RealnameCheckInterval)
if err := s.addToQueue(ctx, constants.RedisPollingQueueRealnameKey(), card.ID, nextCheck); err != nil {
return err
}
}
if config.CarddataCheckInterval != nil && *config.CarddataCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *config.CarddataCheckInterval)
if err := s.addToQueue(ctx, constants.RedisPollingQueueCarddataKey(), card.ID, nextCheck); err != nil {
return err
}
}
if config.PackageCheckInterval != nil && *config.PackageCheckInterval > 0 {
// 套餐检查使用流量检查时间作为参考
nextCheck := s.calculateNextCheckTime(card.LastDataCheckAt, *config.PackageCheckInterval)
if err := s.addToQueue(ctx, constants.RedisPollingQueuePackageKey(), card.ID, nextCheck); err != nil {
return err
}
}
if config.ProtectCheckInterval != nil && *config.ProtectCheckInterval > 0 {
nextCheck := s.calculateNextCheckTime(card.LastProtectCheckAt, *config.ProtectCheckInterval)
if err := s.addToQueue(ctx, constants.RedisPollingQueueProtectKey(), card.ID, nextCheck); err != nil {
return err
}
}
// 缓存卡信息到 Redis
return s.cacheCardInfo(ctx, card, now)
}
// MatchConfig 匹配轮询配置
// 按优先级返回第一个匹配的配置
func (s *Scheduler) MatchConfig(card *model.IotCard) *model.PollingConfig {
s.configCacheLock.RLock()
defer s.configCacheLock.RUnlock()
for _, cfg := range s.configCache {
if s.matchConfigConditions(cfg, card) {
return cfg
}
}
return nil
}
// matchConfigConditions 检查卡是否匹配配置条件
func (s *Scheduler) matchConfigConditions(cfg *model.PollingConfig, card *model.IotCard) bool {
// 检查卡状态条件
if cfg.CardCondition != "" {
cardCondition := s.getCardCondition(card)
if cfg.CardCondition != cardCondition {
return false
}
}
// 检查卡业务类型
if cfg.CardCategory != "" {
if cfg.CardCategory != card.CardCategory {
return false
}
}
// 检查运营商
if cfg.CarrierID != nil {
if *cfg.CarrierID != card.CarrierID {
return false
}
}
return true
}
// getCardCondition 获取卡的状态条件
func (s *Scheduler) getCardCondition(card *model.IotCard) string {
if card.RealNameStatus != constants.RealNameStatusVerified {
return "not_real_name"
}
if card.NetworkStatus == 1 {
return "activated"
}
return "real_name"
}
// calculateNextCheckTime 计算下次检查时间
func (s *Scheduler) calculateNextCheckTime(lastCheckAt *time.Time, intervalSeconds int) time.Time {
now := time.Now()
if lastCheckAt == nil {
// 首次检查,立即执行(加上随机抖动避免集中)
jitter := time.Duration(now.UnixNano()%int64(intervalSeconds)) * time.Second / 10
return now.Add(jitter)
}
// 计算下次检查时间
nextCheck := lastCheckAt.Add(time.Duration(intervalSeconds) * time.Second)
if nextCheck.Before(now) {
// 如果已过期,立即执行
return now
}
return nextCheck
}
// addToQueue 添加卡到轮询队列
func (s *Scheduler) addToQueue(ctx context.Context, queueKey string, cardID uint, nextCheck time.Time) error {
score := float64(nextCheck.Unix())
member := formatUint(cardID)
return s.redis.ZAdd(ctx, queueKey, redis.Z{
Score: score,
Member: member,
}).Err()
}
// cacheCardInfo 缓存卡信息到 Redis
func (s *Scheduler) cacheCardInfo(ctx context.Context, card *model.IotCard, cachedAt time.Time) error {
key := constants.RedisPollingCardInfoKey(card.ID)
config := DefaultSchedulerConfig()
data := map[string]interface{}{
"id": card.ID,
"iccid": card.ICCID,
"card_category": card.CardCategory,
"real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus,
"carrier_id": card.CarrierID,
"stop_reason": card.StopReason,
"cached_at": cachedAt.Unix(),
}
pipe := s.redis.Pipeline()
pipe.HSet(ctx, key, data)
pipe.Expire(ctx, key, config.CardCacheTTL)
_, err := pipe.Exec(ctx)
return err
}
// setInitError 设置初始化错误
func (s *Scheduler) setInitError(msg string) {
s.initProgress.mu.Lock()
s.initProgress.Status = "failed"
s.initProgress.ErrorMessage = msg
s.initProgress.mu.Unlock()
}
// GetInitProgress 获取初始化进度
func (s *Scheduler) GetInitProgress() InitProgress {
s.initProgress.mu.RLock()
defer s.initProgress.mu.RUnlock()
return InitProgress{
TotalCards: s.initProgress.TotalCards,
LoadedCards: s.initProgress.LoadedCards,
StartTime: s.initProgress.StartTime,
LastBatchTime: s.initProgress.LastBatchTime,
Status: s.initProgress.Status,
ErrorMessage: s.initProgress.ErrorMessage,
}
}
// IsInitCompleted 检查初始化是否完成
func (s *Scheduler) IsInitCompleted() bool {
return s.initCompleted.Load()
}
// RefreshConfigs 刷新配置缓存
func (s *Scheduler) RefreshConfigs(ctx context.Context) error {
return s.loadConfigs(ctx)
}
// SetResetService 设置流量重置服务(用于依赖注入)
func (s *Scheduler) SetResetService(resetService interface{}) {
if rs, ok := resetService.(*DataResetHandler); ok {
s.dataResetHandler = rs
}
}
// SetActivationService 设置套餐激活服务(用于依赖注入)
func (s *Scheduler) SetActivationService(activationHandler *PackageActivationHandler) {
s.packageActivationHandler = activationHandler
}
// SetStopResumeCallback 注入停复机回调(用于套餐过期后主动触发停机)
func (s *Scheduler) SetStopResumeCallback(callback packagepkg.StopResumeCallback) {
if s.packageActivationHandler != nil {
s.packageActivationHandler.stopResumeCallback = callback
}
}

View File

@@ -6,23 +6,13 @@ import (
"github.com/bytedance/sonic"
)
// formatInt64 将 int64 转换为字符串
func formatInt64(n int64) string {
return strconv.FormatInt(n, 10)
}
// formatUint 将 uint 转换为字符串
func formatUint(n uint) string {
return strconv.FormatUint(uint64(n), 10)
}
// mustMarshal 序列化为 JSON失败时 panic
func mustMarshal(v interface{}) []byte {
data, err := sonic.Marshal(v)
if err != nil {
panic(err)
}
return data
func marshalJSON(v interface{}) ([]byte, error) {
return sonic.Marshal(v)
}
// parseUint 将字符串解析为 uint
@@ -33,3 +23,11 @@ func parseUint(s string) (uint, error) {
}
return uint(n), nil
}
// boolToStr 布尔值转字符串Redis Hash 存储用)
func boolToStr(b bool) string {
if b {
return "1"
}
return "0"
}