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 tier *TierResolver } // SetTierResolver 注入分层事实解析器;未注入时要求套餐状态的配置不匹配。 func (m *PollingConfigManager) SetTierResolver(tier *TierResolver) { m.tier = tier } // TierResolver 返回已注入的分层事实解析器。 func (m *PollingConfigManager) TierResolver() *TierResolver { return m.tier } // TaskTypeInterval 某任务类型的轮询间隔信息 type TaskTypeInterval struct { Interval int // 秒 Priority int // 来源配置的 priority ConfigID uint // 来源配置 ID } // 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 { return m.load(ctx, true) } // LoadForInspection 加载配置到内存缓存但不同步 Redis。 // 用于迁移 dry-run 等只读检查场景,避免检查动作产生外部副作用。 func (m *PollingConfigManager) LoadForInspection(ctx context.Context) error { return m.load(ctx, false) } func (m *PollingConfigManager) load(ctx context.Context, syncRedis bool) 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 syncRedis && m.redis != nil { if syncErr := m.syncToRedis(ctx, configs); syncErr != nil { m.logger.Warn("同步配置到 Redis 失败", zap.Error(syncErr)) } } else if syncRedis { m.logger.Warn("Redis 客户端为空,跳过轮询配置同步") } m.logger.Info("轮询配置已加载", zap.Int("count", len(configs))) return nil } // WatchChanges 订阅 Redis 配置变更频道,收到通知后立即刷新内存缓存 // onRefreshed 回调参数:(变更前是否有配置, 变更后是否有配置) // 当从无配置变为有配置时,调用方可据此触发 Initializer 重启 func (m *PollingConfigManager) WatchChanges(ctx context.Context, onRefreshed func(hadConfigs, hasConfigs bool)) { go func() { pubsub := m.redis.Subscribe(ctx, constants.RedisPollingConfigChangedChannel()) defer pubsub.Close() ch := pubsub.Channel() for { select { case <-ctx.Done(): return case msg, ok := <-ch: if !ok { return } m.mu.RLock() hadConfigs := len(m.configs) > 0 m.mu.RUnlock() m.logger.Info("收到配置变更通知,立即刷新内存缓存", zap.String("event", msg.Payload)) if err := m.Load(ctx); err != nil { m.logger.Warn("配置变更后刷新缓存失败,等待下次定时刷新", zap.Error(err)) continue } m.mu.RLock() hasConfigs := len(m.configs) > 0 m.mu.RUnlock() if onRefreshed != nil { onRefreshed(hadConfigs, hasConfigs) } } } }() } // 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 层保证),数字越小优先级越高 // MatchConfigs()[0] 的前向兼容包装 func (m *PollingConfigManager) MatchConfig(card *model.IotCard) *model.PollingConfig { configs := m.MatchConfigs(card) if len(configs) == 0 { return nil } return configs[0] } // MatchConfigs 按 priority、id 升序遍历所有启用的配置,返回满足匹配条件且至少配置了一种检查间隔(含 0 不检查)的配置。 // 无匹配时返回空切片。 func (m *PollingConfigManager) MatchConfigs(card *model.IotCard) []*model.PollingConfig { m.mu.RLock() configs := m.configs m.mu.RUnlock() return m.matchAgainst(configs, card) } func (m *PollingConfigManager) matchAgainst(configs []*model.PollingConfig, card *model.IotCard) []*model.PollingConfig { facts := m.lazyTierFacts(card) var result []*model.PollingConfig for _, cfg := range configs { if matchConfigConditions(cfg, card, facts) && hasAnyConfiguredInterval(cfg) { result = append(result, cfg) } } return result } // lazyTierFacts 只在某个配置要求套餐状态时才解析卡的分层事实,避免无分层配置时额外查库。 func (m *PollingConfigManager) lazyTierFacts(card *model.IotCard) func() TierFacts { var once sync.Once var facts TierFacts return func() TierFacts { once.Do(func() { if m.tier != nil { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() facts = m.tier.Resolve(ctx, card) } }) return facts } } // hasAnyConfiguredInterval 检查配置是否至少配置了一种检查间隔(正数或 0)。 func hasAnyConfiguredInterval(cfg *model.PollingConfig) bool { return cfg.RealnameCheckInterval != nil || cfg.CarddataCheckInterval != nil || cfg.PackageCheckInterval != nil || cfg.CardStatusCheckInterval != nil } // IntervalSource 是某检查类型最终采用的间隔及其来源,供合并结果与匹配预览共用。 type IntervalSource struct { TaskType string Interval int // 秒;Disabled 为 true 时为 0 Disabled bool // 命中取值 0(不检查) ConfigID uint // 来源配置;未匹配时为 0 Priority int Config *model.PollingConfig } // taskIntervalFields 返回配置中各检查类型的间隔字段。 func taskIntervalFields(cfg *model.PollingConfig) []struct { taskType string value *int } { return []struct { taskType string value *int }{ {constants.TaskTypePollingRealname, cfg.RealnameCheckInterval}, {constants.TaskTypePollingCarddata, cfg.CarddataCheckInterval}, {constants.TaskTypePollingPackage, cfg.PackageCheckInterval}, {constants.TaskTypePollingCardStatus, cfg.CardStatusCheckInterval}, } } // ResolveIntervalSources 按三态规则合并匹配配置:每种检查类型取第一个非空取值,正数按间隔、0 不检查且停止向下查找、空继承。 func ResolveIntervalSources(configs []*model.PollingConfig) map[string]IntervalSource { result := make(map[string]IntervalSource) for _, cfg := range configs { for _, field := range taskIntervalFields(cfg) { if field.value == nil { continue } if _, exists := result[field.taskType]; exists { continue } source := IntervalSource{TaskType: field.taskType, ConfigID: cfg.ID, Priority: cfg.Priority, Config: cfg} if *field.value <= 0 { source.Disabled = true } else { source.Interval = *field.value } result[field.taskType] = source } } return result } // MergedTaskIntervals 返回卡各检查类型最终采用的正数间隔;取值 0(不检查)的类型不出现在结果中。 // 返回 map: taskType → TaskTypeInterval{Interval, Priority} func (m *PollingConfigManager) MergedTaskIntervals(card *model.IotCard) map[string]TaskTypeInterval { sources := ResolveIntervalSources(m.MatchConfigs(card)) merged := make(map[string]TaskTypeInterval, len(sources)) for taskType, source := range sources { if source.Disabled { continue } merged[taskType] = TaskTypeInterval{Interval: source.Interval, Priority: source.Priority, ConfigID: source.ConfigID} } if len(sources) == 0 { m.warnUnmatchedFallback(card) } return merged } // PreviewIntervalSources 用给定配置集合(可含未保存修改)计算卡的匹配配置与最终间隔,只读。 func (m *PollingConfigManager) PreviewIntervalSources(configs []*model.PollingConfig, card *model.IotCard) ([]*model.PollingConfig, map[string]IntervalSource) { matched := m.matchAgainst(configs, card) return matched, ResolveIntervalSources(matched) } // EnabledConfigs 返回当前内存中的启用配置快照(已按 priority、id 排序)。 func (m *PollingConfigManager) EnabledConfigs() []*model.PollingConfig { m.mu.RLock() defer m.mu.RUnlock() out := make([]*model.PollingConfig, len(m.configs)) copy(out, m.configs) return out } // TierFactsOf 返回卡的分层事实(未配置解析器时 Known 为 false)。 func (m *PollingConfigManager) TierFactsOf(card *model.IotCard) TierFacts { return m.lazyTierFacts(card)() } // warnUnmatchedFallback 已销售无套餐、在库的卡未匹配任何配置时告警:这两类卡依赖卡状态检查兜底开机即停。 func (m *PollingConfigManager) warnUnmatchedFallback(card *model.IotCard) { if card == nil || m.logger == nil { return } if card.AssetStatus == constants.AssetStatusInStock { m.logger.Warn("在库卡未匹配任何轮询配置,将不入队", zap.Uint("card_id", card.ID), zap.String("metric", "polling.tier.unmatched_in_stock")) return } if card.AssetStatus == constants.AssetStatusSold { facts := m.TierFactsOf(card) if facts.Known && facts.PackageState == constants.PollingPackageStateNone { m.logger.Warn("已销售无套餐卡未匹配任何轮询配置,将不入队", zap.Uint("card_id", card.ID), zap.String("metric", "polling.tier.unmatched_sold_no_package")) } } } // matchConfigConditions 检查卡是否满足配置的匹配条件;套餐状态仅在配置要求时解析,解析失败视为不匹配。 func matchConfigConditions(cfg *model.PollingConfig, card *model.IotCard, facts func() TierFacts) 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 } } if cfg.AssetStatus != nil && *cfg.AssetStatus != card.AssetStatus { return false } if cfg.PackageState != nil && *cfg.PackageState != "" { f := facts() if !f.Known || f.PackageState != *cfg.PackageState { return false } } return true } // CardConditionOf 返回卡用于匹配轮询配置的状态条件(not_real_name / activated / suspended)。 func CardConditionOf(card *model.IotCard) string { return getCardCondition(card) } // 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 Hash(TTL 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 }