diff --git a/backend/internal/service/usage_billing.go b/backend/internal/service/usage_billing.go index 8d52c92d26..e058ecb1b0 100644 --- a/backend/internal/service/usage_billing.go +++ b/backend/internal/service/usage_billing.go @@ -6,7 +6,10 @@ import ( "encoding/hex" "errors" "fmt" + "math" "strings" + + "github.com/shopspring/decimal" ) var ErrUsageBillingRequestIDRequired = errors.New("usage billing request_id is required") @@ -49,6 +52,54 @@ func (c *UsageBillingCommand) Normalize() { if strings.TrimSpace(c.RequestFingerprint) == "" { c.RequestFingerprint = buildUsageBillingFingerprint(c) } + // 量化必须在指纹计算之后:指纹是请求幂等键,保持由原始金额派生可以避免 + // 升级前后同一 request_id 的重试算出不同指纹而被判为 fingerprint conflict。 + c.quantizeMonetaryFields() +} + +// UsageBillingMonetaryScale 是所有计费金额的规范小数位数, +// 对齐 users.balance / api_keys.quota_used 的 NUMERIC(20,8)。 +const UsageBillingMonetaryScale = 8 + +// quantizeMonetaryFields 把命令中的金额统一量化到 NUMERIC(20,8)。 +// +// 不量化时,同一笔 ActualCost 会在两条方向相反的 SQL 上被 PostgreSQL 分别舍入: +// +// balance = balance - $1 // 存剩余额度,舍入的是「减法结果」 +// quota_used = quota_used + $1 // 存累计用量,舍入的是「加法结果」 +// +// PostgreSQL 对 NUMERIC 采用 half-away-from-zero。当金额在第 9 位出现 half 边界 +// (例:10 输入 token × 0.00000125 + 5 输出 token × 0.00001000,再乘分组倍率 +// 1.25 = 0.000078125)时: +// +// balance: 10000 - 0.000078125 = 9999.999921875 → 9999.99992188(delta 0.00007812) +// quota_used: 0 + 0.000078125 = 0.000078125 → 0.00007813(delta 0.00007813) +// +// 两个 delta 相差 1e-8,且方向相反——余额少扣、Key 配额多记,随请求量线性累积, +// 使余额、API Key 配额与用量记录无法精确对账(需要 epsilon 比较才能勉强吻合)。 +// +// 在参数进入 SQL 之前量化一次,两条语句就都拿到已经落在 8 位刻度上的同一个金额, +// 存储阶段不再发生任何舍入,delta 精确相等。 +func (c *UsageBillingCommand) quantizeMonetaryFields() { + c.BalanceCost = QuantizeUsageBillingAmount(c.BalanceCost) + c.SubscriptionCost = QuantizeUsageBillingAmount(c.SubscriptionCost) + c.APIKeyQuotaCost = QuantizeUsageBillingAmount(c.APIKeyQuotaCost) + c.APIKeyRateLimitCost = QuantizeUsageBillingAmount(c.APIKeyRateLimitCost) + c.AccountQuotaCost = QuantizeUsageBillingAmount(c.AccountQuotaCost) +} + +// QuantizeUsageBillingAmount 把金额舍入到 UsageBillingMonetaryScale 位小数, +// 采用与 PostgreSQL NUMERIC 一致的 half-away-from-zero 规则。 +// +// 走 decimal 而不是 math.Round(v*1e8)/1e8:后者在乘除过程中会引入额外的二进制 +// 误差,边界值可能被推到错误的一侧。decimal.NewFromFloat 取 float64 的最短十进制 +// 表示,正是 PostgreSQL 把 float8 参数转成 numeric 时所用的表示。 +func QuantizeUsageBillingAmount(v float64) float64 { + if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) { + return v + } + quantized, _ := decimal.NewFromFloat(v).Round(UsageBillingMonetaryScale).Float64() + return quantized } func buildUsageBillingFingerprint(c *UsageBillingCommand) string { diff --git a/backend/internal/service/usage_billing_quantize_test.go b/backend/internal/service/usage_billing_quantize_test.go new file mode 100644 index 0000000000..5f9491a7a2 --- /dev/null +++ b/backend/internal/service/usage_billing_quantize_test.go @@ -0,0 +1,194 @@ +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, + "正负金额必须对称量化") +}