Files
junhong_cmp_fiber/internal/application/cardobservation/traffic_queue_settings.go
邹进 9c3c258f16
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
feat: 卡流量读数先过采集队列再入账,扣减按采集时刻确定套餐
- 每卡 Redis 采集队列(默认 N=3、跨天清空、回退容差 1 MB),攒满且不下降才写最后一个点
- 测试环境全量切换日快照计量,下线灰度名单、切换水位与卡表基线扣费;全部卡置待建起点
- 读数区分没给值与 0;有套餐的卡读数 0 也写快照与日用量 0 行;重置日比较基准为 0 照常防旧值
- 新增采集记录(按月分区保留 90 天)、人工冲正流水与后台接口、采集告警
- 扣减按采集时刻有效套餐查找,只对生效中套餐做状态迁移,查不到套餐按成功跳过
- 换货新卡(新设备下的卡)打待建起点;采集间隔上限只校验启用中的轮询配置

Change: add-card-traffic-queue-and-observed-deduction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:18:47 +09:00

194 lines
7.8 KiB
Go
Raw Permalink 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 cardobservation
import (
"context"
stderrors "errors"
"math"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// trafficQueueSettingsTTL 是队列配置在进程内的缓存时长;后台修改数秒内生效。
const trafficQueueSettingsTTL = 5 * time.Second
// TrafficQueueSettings 是卡流量采集队列的运行参数。
type TrafficQueueSettings struct {
Size int
ClearOnNewDay bool
MaxPointAgeHours int
DropToleranceMB float64
}
// DefaultTrafficQueueSettings 返回代码默认值。
func DefaultTrafficQueueSettings() TrafficQueueSettings {
return TrafficQueueSettings{
Size: constants.TrafficQueueDefaultSize, ClearOnNewDay: true,
MaxPointAgeHours: constants.TrafficQueueDefaultMaxPointAgeHours, DropToleranceMB: constants.TrafficQueueDefaultDropToleranceMB,
}
}
// TrafficIntervalChecker 校验队列配置变更后启用中轮询配置的流量采集间隔是否仍在上限内。
type TrafficIntervalChecker func(settings TrafficQueueSettings) error
// RegisterTrafficQueueConfigDefinitions 注册采集队列系统配置。checker 非空时,队列长度、跨天清空、最长点龄
// 修改前用它校验启用中的轮询配置(读取其余三项的当前值组合成修改后的配置)。
func RegisterTrafficQueueConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger, current func() TrafficQueueSettings, checker TrafficIntervalChecker) {
if registry == nil {
return
}
minSize, maxSize := int64(constants.TrafficQueueMinSize), int64(constants.TrafficQueueMaxSize)
minAge, maxAge := int64(0), int64(24*30)
withCheck := func(apply func(*TrafficQueueSettings, string) error) func(string) error {
return func(value string) error {
next := DefaultTrafficQueueSettings()
if current != nil {
next = current()
}
if err := apply(&next, value); err != nil {
return err
}
if checker == nil {
return nil
}
return checker(next)
}
}
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigTrafficQueueSize, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultSize), Min: &minSize, Max: &maxSize,
Description: "卡流量采集队列长度 N:攒满 N 个不下降的读数才写最后一个点",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
n, err := strconv.Atoi(v)
s.Size = n
return err
})},
{Key: constants.SystemConfigTrafficQueueClearOnNewDay, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeBool,
DefaultValue: "true", Description: "卡流量采集队列跨天清空:新点与队列中的点不在同一上海日期时先清空",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
s.ClearOnNewDay = v == "true"
return nil
})},
{Key: constants.SystemConfigTrafficQueueMaxPointAgeHours, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultMaxPointAgeHours), Min: &minAge, Max: &maxAge,
Description: "卡流量采集队列最长点龄(小时):入队时丢弃与新点相差超过该时长的点,0 表示不限",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
n, err := strconv.Atoi(v)
s.MaxPointAgeHours = n
return err
})},
{Key: constants.SystemConfigTrafficQueueDropToleranceMB, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeString,
DefaultValue: "1", Description: "卡流量采集队列回退容差(MB):相邻两点回退不超过该值算持平,0 表示不允许回退",
Validator: func(v string) error {
_, err := parseDropTolerance(v)
return err
}},
}
for _, definition := range definitions {
if _, exists := registry.Get(definition.Key); exists {
continue
}
if err := registry.Register(definition); err != nil && logger != nil {
logger.Error("注册卡流量采集队列系统配置失败,将使用代码默认值", zap.String("config_key", definition.Key), zap.Error(err))
}
}
}
func parseDropTolerance(value string) (float64, error) {
tolerance, err := strconv.ParseFloat(value, 64)
if err != nil || math.IsNaN(tolerance) || math.IsInf(tolerance, 0) || tolerance < 0 || tolerance > 1024 {
return 0, stderrors.New("回退容差必须是 0~1024 之间的数字(MB)")
}
return tolerance, nil
}
// trafficConfigReader 读取系统配置的最小接缝。
type trafficConfigReader interface {
Get(ctx context.Context, key string) (string, error)
}
// TrafficQueueSettingsProvider 读取采集队列配置,带短缓存;读取失败时使用代码默认值。
type TrafficQueueSettingsProvider struct {
reader trafficConfigReader
logger *zap.Logger
mu sync.Mutex
cached TrafficQueueSettings
expiresAt time.Time
}
// NewTrafficQueueSettingsProvider 创建采集队列配置读取器。
func NewTrafficQueueSettingsProvider(reader trafficConfigReader, logger *zap.Logger) *TrafficQueueSettingsProvider {
if logger == nil {
logger = zap.NewNop()
}
return &TrafficQueueSettingsProvider{reader: reader, logger: logger}
}
// NewDefaultTrafficQueueSettingsProvider 用独立注册表与读取器创建采集队列配置读取器(API 与 Worker 共用同一份数据)。
func NewDefaultTrafficQueueSettingsProvider(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *TrafficQueueSettingsProvider {
registry := systemconfig.NewRegistry()
RegisterTrafficQueueConfigDefinitions(registry, logger, nil, nil)
var cache systemconfig.Cache
if redisClient != nil {
cache = systemconfig.NewRedisCache(redisClient)
}
reader := systemconfig.NewReader(db, registry, cache, systemconfig.NewLogAlertSink(logger))
return NewTrafficQueueSettingsProvider(reader, logger)
}
// Current 返回当前采集队列配置。
func (p *TrafficQueueSettingsProvider) Current(ctx context.Context) TrafficQueueSettings {
if p == nil || p.reader == nil {
return DefaultTrafficQueueSettings()
}
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
if now.Before(p.expiresAt) {
return p.cached
}
settings, err := p.load(ctx)
if err != nil {
p.logger.Warn("读取卡流量采集队列配置失败,使用代码默认值", zap.Error(err))
settings = DefaultTrafficQueueSettings()
}
p.cached, p.expiresAt = settings, now.Add(trafficQueueSettingsTTL)
return settings
}
func (p *TrafficQueueSettingsProvider) load(ctx context.Context) (TrafficQueueSettings, error) {
settings := DefaultTrafficQueueSettings()
raw, err := p.reader.Get(ctx, constants.SystemConfigTrafficQueueSize)
if err != nil {
return settings, err
}
if settings.Size, err = strconv.Atoi(raw); err != nil || settings.Size < constants.TrafficQueueMinSize {
return DefaultTrafficQueueSettings(), stderrors.New("采集队列长度配置非法")
}
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueClearOnNewDay); err != nil {
return DefaultTrafficQueueSettings(), err
}
settings.ClearOnNewDay = raw == "true"
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueMaxPointAgeHours); err != nil {
return DefaultTrafficQueueSettings(), err
}
if settings.MaxPointAgeHours, err = strconv.Atoi(raw); err != nil || settings.MaxPointAgeHours < 0 {
return DefaultTrafficQueueSettings(), stderrors.New("采集队列最长点龄配置非法")
}
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueDropToleranceMB); err != nil {
return DefaultTrafficQueueSettings(), err
}
if settings.DropToleranceMB, err = parseDropTolerance(raw); err != nil {
return DefaultTrafficQueueSettings(), err
}
return settings, nil
}