Files
junhong_cmp_fiber/internal/polling/config_manager.go
Break 0d96a94e5f
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m25s
关于上游流量同步覆盖修复,以及新增redis同步迁移
2026-05-27 10:59:19 +08:00

275 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
// TaskTypeInterval 某任务类型的轮询间隔信息
type TaskTypeInterval struct {
Interval int // 秒
Priority int // 来源配置的 priority
}
// 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 ASC 遍历所有启用的配置,返回所有满足匹配条件且至少有一个非 NULL interval 的配置
// 无匹配时返回空切片
func (m *PollingConfigManager) MatchConfigs(card *model.IotCard) []*model.PollingConfig {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*model.PollingConfig
for _, cfg := range m.configs {
if matchConfigConditions(cfg, card) && hasAnyEnabledInterval(cfg) {
result = append(result, cfg)
}
}
return result
}
// hasAnyEnabledInterval 检查配置是否有至少一个非 NULL 且大于 0 的轮询间隔
func hasAnyEnabledInterval(cfg *model.PollingConfig) bool {
return (cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0) ||
(cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0) ||
(cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0) ||
(cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0) ||
(cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0)
}
// MergedTaskIntervals 对所有匹配配置按 priority 遍历,对每种 task type 选取最高优先级的非 nil interval
// 返回 map: taskType → TaskTypeInterval{Interval, Priority}
func (m *PollingConfigManager) MergedTaskIntervals(card *model.IotCard) map[string]TaskTypeInterval {
configs := m.MatchConfigs(card)
merged := make(map[string]TaskTypeInterval)
for _, cfg := range configs {
priority := int(cfg.Priority)
if cfg.RealnameCheckInterval != nil && *cfg.RealnameCheckInterval > 0 {
taskType := constants.TaskTypePollingRealname
if _, exists := merged[taskType]; !exists {
merged[taskType] = TaskTypeInterval{Interval: *cfg.RealnameCheckInterval, Priority: priority}
}
}
if cfg.CarddataCheckInterval != nil && *cfg.CarddataCheckInterval > 0 {
taskType := constants.TaskTypePollingCarddata
if _, exists := merged[taskType]; !exists {
merged[taskType] = TaskTypeInterval{Interval: *cfg.CarddataCheckInterval, Priority: priority}
}
}
if cfg.PackageCheckInterval != nil && *cfg.PackageCheckInterval > 0 {
taskType := constants.TaskTypePollingPackage
if _, exists := merged[taskType]; !exists {
merged[taskType] = TaskTypeInterval{Interval: *cfg.PackageCheckInterval, Priority: priority}
}
}
if cfg.ProtectCheckInterval != nil && *cfg.ProtectCheckInterval > 0 {
taskType := constants.TaskTypePollingProtect
if _, exists := merged[taskType]; !exists {
merged[taskType] = TaskTypeInterval{Interval: *cfg.ProtectCheckInterval, Priority: priority}
}
}
if cfg.CardStatusCheckInterval != nil && *cfg.CardStatusCheckInterval > 0 {
taskType := constants.TaskTypePollingCardStatus
if _, exists := merged[taskType]; !exists {
merged[taskType] = TaskTypeInterval{Interval: *cfg.CardStatusCheckInterval, Priority: priority}
}
}
}
return merged
}
// 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
}