usage_billing_quantize_test.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. package service
  2. import (
  3. "math"
  4. "testing"
  5. "github.com/shopspring/decimal"
  6. "github.com/stretchr/testify/require"
  7. )
  8. // decimalPlaces 返回 float64 最短十进制表示的小数位数,
  9. // 即 PostgreSQL 把 float8 参数转成 numeric 时看到的刻度。
  10. func decimalPlaces(v float64) int32 {
  11. return -decimal.NewFromFloat(v).Exponent()
  12. }
  13. // 复现 #5229:同一笔 ActualCost 分别流向
  14. //
  15. // balance = balance - $1
  16. // quota_used = quota_used + $1
  17. //
  18. // 两列都是 NUMERIC(20,8),PostgreSQL 按 half-away-from-zero 舍入「运算结果」。
  19. // 金额在第 9 位落在 half 边界时,减法与加法会朝相反方向舍入,
  20. // 导致余额 delta 与 Key 配额 delta 相差 1e-8,永远无法精确对账。
  21. //
  22. // 修复后命令里的金额已经落在 8 位刻度上,存储阶段不再舍入。
  23. func TestUsageBillingCommandQuantizesBalanceAndQuotaIdentically(t *testing.T) {
  24. // 10 input × 0.00000125 + 5 output × 0.00001000 = 0.0000625
  25. // 0.0000625 × 1.25(分组倍率) = 0.000078125
  26. const actualCost = 0.000078125
  27. cmd := &UsageBillingCommand{
  28. RequestID: "req-5229",
  29. UserID: 1,
  30. APIKeyID: 2,
  31. AccountID: 3,
  32. BalanceCost: actualCost,
  33. APIKeyQuotaCost: actualCost,
  34. }
  35. cmd.Normalize()
  36. require.Equal(t, cmd.BalanceCost, cmd.APIKeyQuotaCost,
  37. "余额扣减与 API Key 配额累加必须使用同一个规范金额")
  38. require.LessOrEqual(t, decimalPlaces(cmd.BalanceCost), int32(UsageBillingMonetaryScale),
  39. "金额超过 NUMERIC(20,8) 刻度时 PostgreSQL 仍会在存储阶段舍入")
  40. }
  41. // 第 9 位 half 边界的表驱动覆盖。
  42. func TestQuantizeUsageBillingAmountBoundaries(t *testing.T) {
  43. cases := []struct {
  44. name string
  45. in float64
  46. }{
  47. {"below_half", 0.000078120},
  48. {"just_below_half", 0.000078124},
  49. {"exact_half", 0.000078125},
  50. {"just_above_half", 0.000078126},
  51. {"above_half", 0.000078130},
  52. {"long_tail", 0.0000781234567},
  53. {"already_quantized", 0.00007813},
  54. }
  55. for _, tc := range cases {
  56. t.Run(tc.name, func(t *testing.T) {
  57. got := QuantizeUsageBillingAmount(tc.in)
  58. require.LessOrEqual(t, decimalPlaces(got), int32(UsageBillingMonetaryScale))
  59. want, _ := decimal.NewFromFloat(tc.in).Round(UsageBillingMonetaryScale).Float64()
  60. require.Equal(t, want, got)
  61. // 量化误差不得超过半个刻度(5e-9)。
  62. require.LessOrEqual(t, math.Abs(got-tc.in), 5e-9)
  63. })
  64. }
  65. }
  66. // 累积对账:重复应用同一笔金额,余额侧与配额侧的总量必须精确相等,
  67. // 不允许依赖 epsilon 比较。
  68. func TestQuantizedAmountsReconcileExactlyOverManyApplications(t *testing.T) {
  69. const actualCost = 0.000078125
  70. cmd := &UsageBillingCommand{
  71. RequestID: "req-5229-bulk",
  72. UserID: 1,
  73. APIKeyID: 2,
  74. AccountID: 3,
  75. BalanceCost: actualCost,
  76. SubscriptionCost: 0,
  77. APIKeyQuotaCost: actualCost,
  78. APIKeyRateLimitCost: actualCost,
  79. }
  80. cmd.Normalize()
  81. unit := decimal.NewFromFloat(cmd.BalanceCost)
  82. for _, n := range []int64{1, 10, 100, 1000} {
  83. total := unit.Mul(decimal.NewFromInt(n))
  84. balance := decimal.NewFromInt(10000).Sub(total)
  85. quotaUsed := total
  86. // NUMERIC(20,8) 存储不改变任何一侧的值。
  87. require.True(t, balance.Equal(balance.Round(UsageBillingMonetaryScale)),
  88. "n=%d 余额结果超出 NUMERIC(20,8) 刻度", n)
  89. require.True(t, quotaUsed.Equal(quotaUsed.Round(UsageBillingMonetaryScale)),
  90. "n=%d 配额结果超出 NUMERIC(20,8) 刻度", n)
  91. balanceDelta := decimal.NewFromInt(10000).Sub(balance)
  92. require.True(t, balanceDelta.Equal(quotaUsed),
  93. "n=%d 余额 delta 与配额 delta 不相等: %s vs %s", n, balanceDelta, quotaUsed)
  94. }
  95. }
  96. // 所有金额字段都要量化,避免订阅用量 / 账号配额留在未规范化的刻度上。
  97. func TestNormalizeQuantizesEveryMonetaryField(t *testing.T) {
  98. const raw = 0.0000781234567
  99. cmd := &UsageBillingCommand{
  100. RequestID: "req-5229-fields",
  101. UserID: 1,
  102. APIKeyID: 2,
  103. AccountID: 3,
  104. BalanceCost: raw,
  105. SubscriptionCost: raw,
  106. APIKeyQuotaCost: raw,
  107. APIKeyRateLimitCost: raw,
  108. AccountQuotaCost: raw,
  109. }
  110. cmd.Normalize()
  111. for name, got := range map[string]float64{
  112. "BalanceCost": cmd.BalanceCost,
  113. "SubscriptionCost": cmd.SubscriptionCost,
  114. "APIKeyQuotaCost": cmd.APIKeyQuotaCost,
  115. "APIKeyRateLimitCost": cmd.APIKeyRateLimitCost,
  116. "AccountQuotaCost": cmd.AccountQuotaCost,
  117. } {
  118. require.LessOrEqual(t, decimalPlaces(got), int32(UsageBillingMonetaryScale), name)
  119. }
  120. }
  121. // 指纹是请求幂等键,必须仍由原始金额派生:
  122. // 若量化发生在指纹之前,升级前后同一 request_id 的重试会算出不同指纹,
  123. // 被误判为 fingerprint conflict。
  124. func TestNormalizeKeepsFingerprintDerivedFromRawAmounts(t *testing.T) {
  125. const raw = 0.000078125
  126. newCmd := func() *UsageBillingCommand {
  127. return &UsageBillingCommand{
  128. RequestID: "req-5229-fp",
  129. UserID: 1,
  130. APIKeyID: 2,
  131. AccountID: 3,
  132. BalanceCost: raw,
  133. APIKeyQuotaCost: raw,
  134. }
  135. }
  136. cmd := newCmd()
  137. expected := buildUsageBillingFingerprint(newCmd())
  138. cmd.Normalize()
  139. require.Equal(t, expected, cmd.RequestFingerprint)
  140. }
  141. // 显式设置的指纹不被覆盖,且金额仍会被量化。
  142. func TestNormalizePreservesExplicitFingerprint(t *testing.T) {
  143. cmd := &UsageBillingCommand{
  144. RequestID: "req-5229-explicit",
  145. RequestFingerprint: "preset-fingerprint",
  146. BalanceCost: 0.0000781234567,
  147. }
  148. cmd.Normalize()
  149. require.Equal(t, "preset-fingerprint", cmd.RequestFingerprint)
  150. require.LessOrEqual(t, decimalPlaces(cmd.BalanceCost), int32(UsageBillingMonetaryScale))
  151. }
  152. func TestQuantizeUsageBillingAmountPassesThroughNonFinite(t *testing.T) {
  153. require.Equal(t, 0.0, QuantizeUsageBillingAmount(0))
  154. require.True(t, math.IsNaN(QuantizeUsageBillingAmount(math.NaN())))
  155. require.True(t, math.IsInf(QuantizeUsageBillingAmount(math.Inf(1)), 1))
  156. require.True(t, math.IsInf(QuantizeUsageBillingAmount(math.Inf(-1)), -1))
  157. }
  158. // 退款/负向金额同样按 half-away-from-zero 对称处理。
  159. func TestQuantizeUsageBillingAmountHandlesNegativeAmounts(t *testing.T) {
  160. got := QuantizeUsageBillingAmount(-0.000078125)
  161. want, _ := decimal.NewFromFloat(-0.000078125).Round(UsageBillingMonetaryScale).Float64()
  162. require.Equal(t, want, got)
  163. require.Equal(t, -QuantizeUsageBillingAmount(0.000078125), got,
  164. "正负金额必须对称量化")
  165. }