feat: 卡流量读数先过采集队列再入账,扣减按采集时刻确定套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s

- 每卡 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>
This commit is contained in:
邹进
2026-10-29 12:18:47 +09:00
parent c33cc6f6c3
commit 9c3c258f16
55 changed files with 3142 additions and 881 deletions

View File

@@ -32,28 +32,6 @@ type Config struct {
Worker WorkerConfig `mapstructure:"worker"`
WeCom WeComConfig `mapstructure:"wecom"`
Approval ApprovalConfig `mapstructure:"approval"`
TrafficCalc TrafficCalcConfig `mapstructure:"traffic_calc"`
}
// 卡流量计量模式取值。
const (
TrafficCalcModeOff = "off"
TrafficCalcModeGray = "gray"
TrafficCalcModeFull = "full"
)
// TrafficCalcConfig 卡流量计量(日快照算法)切换配置。
// API 与全部 Worker 进程必须使用一致的取值,改动后先停掉全部进程再一起启动。
type TrafficCalcConfig struct {
// Mode 计量模式:off 全部走卡表基线算法;gray 只有名单内的卡走日快照算法;full 全部卡走日快照算法。
// 环境变量:JUNHONG_TRAFFIC_CALC_MODE
Mode string `mapstructure:"mode"`
// Cards 灰度名单,逗号分隔的扣减载体:device:<设备ID> 或 iot_card:<卡ID>。
// 环境变量:JUNHONG_TRAFFIC_CALC_CARDS
Cards string `mapstructure:"cards"`
// FlowSnapshotEnabled 未切换卡的日快照写入总开关,关闭后未切换卡停止写快照。
// 环境变量:JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED
FlowSnapshotEnabled bool `mapstructure:"flow_snapshot_enabled"`
}
// ServerConfig HTTP 服务器配置
@@ -304,11 +282,6 @@ func (c *Config) ValidateRequired() error {
}
func (c *Config) Validate() error {
switch c.TrafficCalc.Mode {
case TrafficCalcModeOff, TrafficCalcModeGray, TrafficCalcModeFull:
default:
return fmt.Errorf("invalid configuration: traffic_calc.mode: invalid mode (current value: %s, expected: off, gray, full)", c.TrafficCalc.Mode)
}
// 服务器验证
if c.Server.Address == "" {
return fmt.Errorf("invalid configuration: server.address: must be non-empty (current value: empty)")

View File

@@ -168,12 +168,6 @@ approval:
legacy_offline_recharge_pay_enabled: true
# 卡流量计量(日快照算法)切换配置;API 与全部 Worker 必须一致,改动后先停全部进程再一起启动
traffic_calc:
mode: "off" # off / gray / full(环境变量:JUNHONG_TRAFFIC_CALC_MODE)
cards: "" # 灰度名单,如 device:15276,iot_card:103154(环境变量:JUNHONG_TRAFFIC_CALC_CARDS)
flow_snapshot_enabled: true # 未切换卡写日快照(环境变量:JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED)
# 企业微信 Adapter 配置
wecom:
base_url: "https://qyapi.weixin.qq.com"

View File

@@ -160,13 +160,9 @@ func bindEnvVariables(v *viper.Viper) {
"wechat.payment.notify_url",
"wechat.payment.http_debug",
"wechat.payment.timeout",
"traffic_calc.mode",
"traffic_calc.cards",
}
for _, key := range bindings {
_ = v.BindEnv(key)
}
// 快照写入开关沿用方案约定的环境变量名,与配置键的默认映射不同,需要显式绑定。
_ = v.BindEnv("traffic_calc.flow_snapshot_enabled", "JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED")
}

View File

@@ -0,0 +1,56 @@
package constants
import "fmt"
// 卡流量采集队列系统配置(add-card-traffic-queue-and-observed-deduction)。
const (
// SystemConfigModuleTraffic 是卡流量采集配置模块。
SystemConfigModuleTraffic = "traffic"
// SystemConfigTrafficQueueSize 是采集队列长度 N。
SystemConfigTrafficQueueSize = "traffic.queue.size"
// SystemConfigTrafficQueueClearOnNewDay 是跨天是否清空队列。
SystemConfigTrafficQueueClearOnNewDay = "traffic.queue.clear_on_new_day"
// SystemConfigTrafficQueueMaxPointAgeHours 是队列里点的最长保留时长(小时),0 表示不限。
SystemConfigTrafficQueueMaxPointAgeHours = "traffic.queue.max_point_age_hours"
// SystemConfigTrafficQueueDropToleranceMB 是相邻两点回退不超过该值算持平(MB)。
SystemConfigTrafficQueueDropToleranceMB = "traffic.queue.drop_tolerance_mb"
// TrafficQueueDefaultSize 等是采集队列配置默认值。
TrafficQueueDefaultSize = 3
TrafficQueueMinSize = 2
TrafficQueueMaxSize = 10
TrafficQueueDefaultMaxPointAgeHours = 72
TrafficQueueDefaultDropToleranceMB = 1.0
// TrafficQueueIntervalSpare 是采集间隔上限留的冗余次数:间隔 ≤ 窗口 ÷ (N + 冗余)。
TrafficQueueIntervalSpare = 2
// TrafficQueueUnlimitedTTLHours 是最长点龄为 0(不限)且跨天清空关闭时队列 key 的过期时长。
TrafficQueueUnlimitedTTLHours = 24 * 7
)
// 采集记录的读数结果。
const (
TrafficCollectionResultOK = "ok"
TrafficCollectionResultFailed = "failed"
TrafficCollectionResultRateLimited = "rate_limited"
TrafficCollectionResultInvalidPayload = "invalid_payload"
TrafficCollectionResultNoReading = "no_reading"
TrafficCollectionResultInvalidReading = "invalid_reading"
)
// 采集记录的写入判断。
const (
TrafficDecisionWritten = "written"
TrafficDecisionBaseline = "baseline"
TrafficDecisionQueueNotFull = "queue_not_full"
TrafficDecisionSlopeNegative = "slope_negative"
TrafficDecisionOutOfOrder = "out_of_order"
TrafficDecisionSettledExisting = "settled_existing"
)
// TrafficCollectionRetentionDays 是采集记录保留天数,过期按整月分区删除。
const TrafficCollectionRetentionDays = 90
// RedisCardTrafficQueueKey 返回卡流量采集队列 key。
func RedisCardTrafficQueueKey(cardID uint) string {
return fmt.Sprintf("traffic:queue:%d", cardID)
}