Files
junhong_cmp_fiber/internal/polling/config_manager.go
邹进 1214181e78
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 16m2s
feat: 轮询观测入库异步化,调网关与入库分离,采集记录经消息攒批写入
- 轮询步骤异步模式下只调网关并发布 card.ingest 入库消息,入库消费者固定并发 30、按采集 ID 去重、可重试错误不限次数退避重投
- 采集记录经 card.collection 攒批写入,失败整批重投,新增 (observation_id, observed_at) 唯一索引
- 网络、实名观测增加乱序保护;轮询读查询走缓存,套餐状态查询各自计时且失败不缓存
- 入库积压告警与反压;系统配置 polling.ingest.mode 默认 sync 可回退;测试环境 nsqd 消息全部落盘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:33:29 +09:00

379 lines
12 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
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 {
// 每次查询在解析器内单独计时,避免一张绑定设备的卡连查多次时共用一个超时。
facts = m.tier.Resolve(context.Background(), 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
}