Files
sub2api/backend/internal/service/usage_billing_quantize_test.go
T
li e2652eb853 fix(billing): quantize usage billing amounts to the NUMERIC(20,8) scale
同一笔 ActualCost 会被分别写入两条方向相反的 SQL:

    balance    = balance - $1      -- 存剩余额度,舍入的是"减法结果"
    quota_used = quota_used + $1   -- 存累计用量,舍入的是"加法结果"

两列都是 NUMERIC(20,8),PostgreSQL 按 half-away-from-zero 舍入运算结果。
金额在第 9 位落到 half 边界时,两侧朝相反方向舍入:

    10 输入 token × 0.00000125 + 5 输出 token × 0.00001000 = 0.0000625
    × 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

余额少扣、API Key 配额多记,单次相差 1e-8 且随请求量线性累积(1000 次 ≈ 1e-5 USD),
余额、Key 配额与用量记录无法精确对账,只能靠 epsilon 比较勉强吻合。

修复:在参数进入 SQL 之前,把命令中的全部金额统一量化到 8 位小数
(half-away-from-zero,与 PostgreSQL NUMERIC 一致)。两条语句拿到的是
同一个已落在 8 位刻度上的金额,存储阶段不再发生舍入,delta 精确相等。

- 走 shopspring/decimal 而非 math.Round(v*1e8)/1e8:后者在乘除中引入额外
  二进制误差,边界值可能被推到错误的一侧
- 量化排在指纹计算之后:指纹是请求幂等键,保持由原始金额派生,
  避免升级前后同一 request_id 的重试算出不同指纹而被判为 fingerprint conflict

Fixes #5229
2026-08-03 20:00:32 +08:00

195 lines
6.3 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 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,
"正负金额必须对称量化")
}