195 lines
6.3 KiB
Go
195 lines
6.3 KiB
Go
package service
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"math"
|
|||
|
|
"testing"
|
|||
|
|
|
|||
|
|
"github.com/shopspring/decimal"
|
|||
|
|
"github.com/stretchr/testify/require"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// decimalPlaces 返回 float64 最短十进制表示的小数位数,
|
|||
|
|
// 即 PostgreSQL 把 float8 参数转成 numeric 时看到的刻度。
|
|||
|
|
func decimalPlaces(v float64) int32 {
|
|||
|
|
return -decimal.NewFromFloat(v).Exponent()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 复现 #5229:同一笔 ActualCost 分别流向
|
|||
|
|
//
|
|||
|
|
// balance = balance - $1
|
|||
|
|
// quota_used = quota_used + $1
|
|||
|
|
//
|
|||
|
|
// 两列都是 NUMERIC(20,8),PostgreSQL 按 half-away-from-zero 舍入「运算结果」。
|
|||
|
|
// 金额在第 9 位落在 half 边界时,减法与加法会朝相反方向舍入,
|
|||
|
|
// 导致余额 delta 与 Key 配额 delta 相差 1e-8,永远无法精确对账。
|
|||
|
|
//
|
|||
|
|
// 修复后命令里的金额已经落在 8 位刻度上,存储阶段不再舍入。
|
|||
|
|
func TestUsageBillingCommandQuantizesBalanceAndQuotaIdentically(t *testing.T) {
|
|||
|
|
// 10 input × 0.00000125 + 5 output × 0.00001000 = 0.0000625
|
|||
|
|
// 0.0000625 × 1.25(分组倍率) = 0.000078125
|
|||
|
|
const actualCost = 0.000078125
|
|||
|
|
|
|||
|
|
cmd := &UsageBillingCommand{
|
|||
|
|
RequestID: "req-5229",
|
|||
|
|
UserID: 1,
|
|||
|
|
APIKeyID: 2,
|
|||
|
|
AccountID: 3,
|
|||
|
|
BalanceCost: actualCost,
|
|||
|
|
APIKeyQuotaCost: actualCost,
|
|||
|
|
}
|
|||
|
|
cmd.Normalize()
|
|||
|
|
|
|||
|
|
require.Equal(t, cmd.BalanceCost, cmd.APIKeyQuotaCost,
|
|||
|
|
"余额扣减与 API Key 配额累加必须使用同一个规范金额")
|
|||
|
|
require.LessOrEqual(t, decimalPlaces(cmd.BalanceCost), int32(UsageBillingMonetaryScale),
|
|||
|
|
"金额超过 NUMERIC(20,8) 刻度时 PostgreSQL 仍会在存储阶段舍入")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 第 9 位 half 边界的表驱动覆盖。
|
|||
|
|
func TestQuantizeUsageBillingAmountBoundaries(t *testing.T) {
|
|||
|
|
cases := []struct {
|
|||
|
|
name string
|
|||
|
|
in float64
|
|||
|
|
}{
|
|||
|
|
{"below_half", 0.000078120},
|
|||
|
|
{"just_below_half", 0.000078124},
|
|||
|
|
{"exact_half", 0.000078125},
|
|||
|
|
{"just_above_half", 0.000078126},
|
|||
|
|
{"above_half", 0.000078130},
|
|||
|
|
{"long_tail", 0.0000781234567},
|
|||
|
|
{"already_quantized", 0.00007813},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for _, tc := range cases {
|
|||
|
|
t.Run(tc.name, func(t *testing.T) {
|
|||
|
|
got := QuantizeUsageBillingAmount(tc.in)
|
|||
|
|
|
|||
|
|
require.LessOrEqual(t, decimalPlaces(got), int32(UsageBillingMonetaryScale))
|
|||
|
|
|
|||
|
|
want, _ := decimal.NewFromFloat(tc.in).Round(UsageBillingMonetaryScale).Float64()
|
|||
|
|
require.Equal(t, want, got)
|
|||
|
|
|
|||
|
|
// 量化误差不得超过半个刻度(5e-9)。
|
|||
|
|
require.LessOrEqual(t, math.Abs(got-tc.in), 5e-9)
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 累积对账:重复应用同一笔金额,余额侧与配额侧的总量必须精确相等,
|
|||
|
|
// 不允许依赖 epsilon 比较。
|
|||
|
|
func TestQuantizedAmountsReconcileExactlyOverManyApplications(t *testing.T) {
|
|||
|
|
const actualCost = 0.000078125
|
|||
|
|
|
|||
|
|
cmd := &UsageBillingCommand{
|
|||
|
|
RequestID: "req-5229-bulk",
|
|||
|
|
UserID: 1,
|
|||
|
|
APIKeyID: 2,
|
|||
|
|
AccountID: 3,
|
|||
|
|
BalanceCost: actualCost,
|
|||
|
|
SubscriptionCost: 0,
|
|||
|
|
APIKeyQuotaCost: actualCost,
|
|||
|
|
APIKeyRateLimitCost: actualCost,
|
|||
|
|
}
|
|||
|
|
cmd.Normalize()
|
|||
|
|
|
|||
|
|
unit := decimal.NewFromFloat(cmd.BalanceCost)
|
|||
|
|
for _, n := range []int64{1, 10, 100, 1000} {
|
|||
|
|
total := unit.Mul(decimal.NewFromInt(n))
|
|||
|
|
|
|||
|
|
balance := decimal.NewFromInt(10000).Sub(total)
|
|||
|
|
quotaUsed := total
|
|||
|
|
|
|||
|
|
// NUMERIC(20,8) 存储不改变任何一侧的值。
|
|||
|
|
require.True(t, balance.Equal(balance.Round(UsageBillingMonetaryScale)),
|
|||
|
|
"n=%d 余额结果超出 NUMERIC(20,8) 刻度", n)
|
|||
|
|
require.True(t, quotaUsed.Equal(quotaUsed.Round(UsageBillingMonetaryScale)),
|
|||
|
|
"n=%d 配额结果超出 NUMERIC(20,8) 刻度", n)
|
|||
|
|
|
|||
|
|
balanceDelta := decimal.NewFromInt(10000).Sub(balance)
|
|||
|
|
require.True(t, balanceDelta.Equal(quotaUsed),
|
|||
|
|
"n=%d 余额 delta 与配额 delta 不相等: %s vs %s", n, balanceDelta, quotaUsed)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 所有金额字段都要量化,避免订阅用量 / 账号配额留在未规范化的刻度上。
|
|||
|
|
func TestNormalizeQuantizesEveryMonetaryField(t *testing.T) {
|
|||
|
|
const raw = 0.0000781234567
|
|||
|
|
|
|||
|
|
cmd := &UsageBillingCommand{
|
|||
|
|
RequestID: "req-5229-fields",
|
|||
|
|
UserID: 1,
|
|||
|
|
APIKeyID: 2,
|
|||
|
|
AccountID: 3,
|
|||
|
|
BalanceCost: raw,
|
|||
|
|
SubscriptionCost: raw,
|
|||
|
|
APIKeyQuotaCost: raw,
|
|||
|
|
APIKeyRateLimitCost: raw,
|
|||
|
|
AccountQuotaCost: raw,
|
|||
|
|
}
|
|||
|
|
cmd.Normalize()
|
|||
|
|
|
|||
|
|
for name, got := range map[string]float64{
|
|||
|
|
"BalanceCost": cmd.BalanceCost,
|
|||
|
|
"SubscriptionCost": cmd.SubscriptionCost,
|
|||
|
|
"APIKeyQuotaCost": cmd.APIKeyQuotaCost,
|
|||
|
|
"APIKeyRateLimitCost": cmd.APIKeyRateLimitCost,
|
|||
|
|
"AccountQuotaCost": cmd.AccountQuotaCost,
|
|||
|
|
} {
|
|||
|
|
require.LessOrEqual(t, decimalPlaces(got), int32(UsageBillingMonetaryScale), name)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 指纹是请求幂等键,必须仍由原始金额派生:
|
|||
|
|
// 若量化发生在指纹之前,升级前后同一 request_id 的重试会算出不同指纹,
|
|||
|
|
// 被误判为 fingerprint conflict。
|
|||
|
|
func TestNormalizeKeepsFingerprintDerivedFromRawAmounts(t *testing.T) {
|
|||
|
|
const raw = 0.000078125
|
|||
|
|
|
|||
|
|
newCmd := func() *UsageBillingCommand {
|
|||
|
|
return &UsageBillingCommand{
|
|||
|
|
RequestID: "req-5229-fp",
|
|||
|
|
UserID: 1,
|
|||
|
|
APIKeyID: 2,
|
|||
|
|
AccountID: 3,
|
|||
|
|
BalanceCost: raw,
|
|||
|
|
APIKeyQuotaCost: raw,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cmd := newCmd()
|
|||
|
|
expected := buildUsageBillingFingerprint(newCmd())
|
|||
|
|
|
|||
|
|
cmd.Normalize()
|
|||
|
|
require.Equal(t, expected, cmd.RequestFingerprint)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 显式设置的指纹不被覆盖,且金额仍会被量化。
|
|||
|
|
func TestNormalizePreservesExplicitFingerprint(t *testing.T) {
|
|||
|
|
cmd := &UsageBillingCommand{
|
|||
|
|
RequestID: "req-5229-explicit",
|
|||
|
|
RequestFingerprint: "preset-fingerprint",
|
|||
|
|
BalanceCost: 0.0000781234567,
|
|||
|
|
}
|
|||
|
|
cmd.Normalize()
|
|||
|
|
|
|||
|
|
require.Equal(t, "preset-fingerprint", cmd.RequestFingerprint)
|
|||
|
|
require.LessOrEqual(t, decimalPlaces(cmd.BalanceCost), int32(UsageBillingMonetaryScale))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestQuantizeUsageBillingAmountPassesThroughNonFinite(t *testing.T) {
|
|||
|
|
require.Equal(t, 0.0, QuantizeUsageBillingAmount(0))
|
|||
|
|
require.True(t, math.IsNaN(QuantizeUsageBillingAmount(math.NaN())))
|
|||
|
|
require.True(t, math.IsInf(QuantizeUsageBillingAmount(math.Inf(1)), 1))
|
|||
|
|
require.True(t, math.IsInf(QuantizeUsageBillingAmount(math.Inf(-1)), -1))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 退款/负向金额同样按 half-away-from-zero 对称处理。
|
|||
|
|
func TestQuantizeUsageBillingAmountHandlesNegativeAmounts(t *testing.T) {
|
|||
|
|
got := QuantizeUsageBillingAmount(-0.000078125)
|
|||
|
|
want, _ := decimal.NewFromFloat(-0.000078125).Round(UsageBillingMonetaryScale).Float64()
|
|||
|
|
|
|||
|
|
require.Equal(t, want, got)
|
|||
|
|
require.Equal(t, -QuantizeUsageBillingAmount(0.000078125), got,
|
|||
|
|
"正负金额必须对称量化")
|
|||
|
|
}
|