Files
sub2api/backend/internal/service/usage_billing_quantize_test.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +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,
"正负金额必须对称量化")
}