All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 16m2s
- 轮询步骤异步模式下只调网关并发布 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>
379 lines
12 KiB
Go
379 lines
12 KiB
Go
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
|
||
}
|