Files
junhong_cmp_fiber/internal/domain/cardflow/rules.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

129 lines
4.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 cardflow
import (
"math"
"strings"
"time"
)
// 计量规则常量。
const (
// StaleGuardRatio 是重置日防旧值阈值:重置日读数超过比较基准的该倍数时按 0 计。
StaleGuardRatio = 0.9
// MinResetDay 与 MaxResetDay 是通道上游流量重置日的合法范围。
MinResetDay = 1
MaxResetDay = 28
// FallbackResetDayCUCC 是联通卡重置日查询失败时的兜底值。
FallbackResetDayCUCC = 27
// FallbackResetDayDefault 是非联通卡重置日查询失败时的兜底值。
FallbackResetDayDefault = 1
// carrierTypeCUCC 是联通运营商类型编码。
carrierTypeCUCC = "CUCC"
)
// shanghaiLocation 是快照日期与周期边界的固定时区。
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// ShanghaiDate 返回观测时刻对应的上海日期(当天 0 点)。
func ShanghaiDate(t time.Time) time.Time {
local := t.In(shanghaiLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
}
// MonthStart 返回日期所在自然月 1 号(上海时区)。
func MonthStart(date time.Time) time.Time {
local := date.In(shanghaiLocation)
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, shanghaiLocation)
}
// ResolveResetDay 返回可用的重置日;查询失败或取值非法时按运营商类型兜底,fallback 为 true 表示使用了兜底值。
func ResolveResetDay(queried int, queryOK bool, carrierType string) (resetDay int, fallback bool) {
if queryOK && queried >= MinResetDay && queried <= MaxResetDay {
return queried, false
}
if strings.EqualFold(strings.TrimSpace(carrierType), carrierTypeCUCC) {
return FallbackResetDayCUCC, true
}
return FallbackResetDayDefault, true
}
// MonthContext 是换算与防旧值需要的已有快照取值,均为自然月累计(MB)。
type MonthContext struct {
// ThisMonthBeforeResetMax 是本月 1 号~重置日前一天的最大快照。
ThisMonthBeforeResetMax float64
// PrevMonthMax 是上月最大快照。
PrevMonthMax float64
// PrevMonthBeforeResetMax 是上月 1 号~重置日前一天的最大快照。
PrevMonthBeforeResetMax float64
}
// Conversion 是一次读数换算为自然月累计的结果。
type Conversion struct {
// MonthUsedMB 是换算后的本月自然月累计。
MonthUsedMB float64
// StaleGuarded 为 true 表示本次读数在重置日触发了防旧值、按 0 参与换算。
StaleGuarded bool
}
// Convert 把运营商周期读数换算为自然月累计,并在重置日当天应用防旧值。
//
// 重置日为 1 时运营商周期即自然月;重置日大于 1 时:日号 ≥ 重置日累加本月重置日前的最大快照,
// 日号 < 重置日扣除上月重置日及以后的用量(上月最大 − 上月重置日前最大),结果小于 0 按 0。
func Convert(readingMB float64, day int, resetDay int, ctx MonthContext) Conversion {
reading := readingMB
guarded := isStale(readingMB, day, resetDay, ctx)
if guarded {
reading = 0
}
var month float64
switch {
case resetDay <= 1:
month = reading
case day >= resetDay:
month = reading + ctx.ThisMonthBeforeResetMax
default:
month = reading - (ctx.PrevMonthMax - ctx.PrevMonthBeforeResetMax)
}
return Conversion{MonthUsedMB: nonNegative(round3(month)), StaleGuarded: guarded}
}
// isStale 判断重置日当天的读数是否仍接近上周期总量;只看配置的重置日,当天全天判断。
// 比较基准为 0(没有快照可比)时,不论重置日是几号,当天任何正读数都按旧值处理,次日如实写入。
func isStale(readingMB float64, day int, resetDay int, ctx MonthContext) bool {
if day != resetDay || readingMB <= 0 {
return false
}
if resetDay <= 1 {
return readingMB > ctx.PrevMonthMax*StaleGuardRatio
}
return readingMB > ctx.ThisMonthBeforeResetMax*StaleGuardRatio
}
// MonthlyDelta 返回按月对账的待结算增量:本月最大快照 − 本月已结算合计,不大于 0 时返回 0。
func MonthlyDelta(monthMaxMB float64, settledSumMB float64) float64 {
return nonNegative(round3(monthMaxMB - settledSumMB))
}
// DailyUsage 返回某天用量:当天最大快照 − 本月此前最大快照,小于 0 按 0;本月第一条快照时 priorMax 传 0。
func DailyUsage(dayMaxMB float64, priorMaxMB float64) float64 {
return nonNegative(round3(dayMaxMB - priorMaxMB))
}
// ValidReading 判断运营商读数是否有效:必须为有限且不小于 0 的数。
// 读数为 0 写不写快照由卡在采集时刻有没有套餐决定,不在这里拦。
func ValidReading(readingMB float64) bool {
return !math.IsNaN(readingMB) && !math.IsInf(readingMB, 0) && readingMB >= 0
}
func nonNegative(v float64) float64 {
if v < 0 {
return 0
}
return v
}
// round3 按快照列精度(三位小数)取整,避免浮点误差产生极小的虚假增量。
func round3(v float64) float64 {
return math.Round(v*1000) / 1000
}