usage_billing.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. package service
  2. import (
  3. "context"
  4. "crypto/sha256"
  5. "encoding/hex"
  6. "errors"
  7. "fmt"
  8. "math"
  9. "strings"
  10. "github.com/shopspring/decimal"
  11. )
  12. var ErrUsageBillingRequestIDRequired = errors.New("usage billing request_id is required")
  13. var ErrUsageBillingRequestConflict = errors.New("usage billing request fingerprint conflict")
  14. // UsageBillingCommand describes one billable request that must be applied at most once.
  15. type UsageBillingCommand struct {
  16. RequestID string
  17. APIKeyID int64
  18. RequestFingerprint string
  19. RequestPayloadHash string
  20. UserID int64
  21. AccountID int64
  22. SubscriptionID *int64
  23. AccountType string
  24. Model string
  25. ServiceTier string
  26. ReasoningEffort string
  27. BillingType int8
  28. InputTokens int
  29. OutputTokens int
  30. CacheCreationTokens int
  31. CacheReadTokens int
  32. ImageCount int
  33. MediaType string
  34. BalanceCost float64
  35. SubscriptionCost float64
  36. APIKeyQuotaCost float64
  37. APIKeyRateLimitCost float64
  38. AccountQuotaCost float64
  39. }
  40. func (c *UsageBillingCommand) Normalize() {
  41. if c == nil {
  42. return
  43. }
  44. c.RequestID = strings.TrimSpace(c.RequestID)
  45. if strings.TrimSpace(c.RequestFingerprint) == "" {
  46. c.RequestFingerprint = buildUsageBillingFingerprint(c)
  47. }
  48. // 量化必须在指纹计算之后:指纹是请求幂等键,保持由原始金额派生可以避免
  49. // 升级前后同一 request_id 的重试算出不同指纹而被判为 fingerprint conflict。
  50. c.quantizeMonetaryFields()
  51. }
  52. // UsageBillingMonetaryScale 是所有计费金额的规范小数位数,
  53. // 对齐 users.balance / api_keys.quota_used 的 NUMERIC(20,8)。
  54. const UsageBillingMonetaryScale = 8
  55. // quantizeMonetaryFields 把命令中的金额统一量化到 NUMERIC(20,8)。
  56. //
  57. // 不量化时,同一笔 ActualCost 会在两条方向相反的 SQL 上被 PostgreSQL 分别舍入:
  58. //
  59. // balance = balance - $1 // 存剩余额度,舍入的是「减法结果」
  60. // quota_used = quota_used + $1 // 存累计用量,舍入的是「加法结果」
  61. //
  62. // PostgreSQL 对 NUMERIC 采用 half-away-from-zero。当金额在第 9 位出现 half 边界
  63. // (例:10 输入 token × 0.00000125 + 5 输出 token × 0.00001000,再乘分组倍率
  64. // 1.25 = 0.000078125)时:
  65. //
  66. // balance: 10000 - 0.000078125 = 9999.999921875 → 9999.99992188(delta 0.00007812)
  67. // quota_used: 0 + 0.000078125 = 0.000078125 → 0.00007813(delta 0.00007813)
  68. //
  69. // 两个 delta 相差 1e-8,且方向相反——余额少扣、Key 配额多记,随请求量线性累积,
  70. // 使余额、API Key 配额与用量记录无法精确对账(需要 epsilon 比较才能勉强吻合)。
  71. //
  72. // 在参数进入 SQL 之前量化一次,两条语句就都拿到已经落在 8 位刻度上的同一个金额,
  73. // 存储阶段不再发生任何舍入,delta 精确相等。
  74. func (c *UsageBillingCommand) quantizeMonetaryFields() {
  75. c.BalanceCost = QuantizeUsageBillingAmount(c.BalanceCost)
  76. c.SubscriptionCost = QuantizeUsageBillingAmount(c.SubscriptionCost)
  77. c.APIKeyQuotaCost = QuantizeUsageBillingAmount(c.APIKeyQuotaCost)
  78. c.APIKeyRateLimitCost = QuantizeUsageBillingAmount(c.APIKeyRateLimitCost)
  79. c.AccountQuotaCost = QuantizeUsageBillingAmount(c.AccountQuotaCost)
  80. }
  81. // QuantizeUsageBillingAmount 把金额舍入到 UsageBillingMonetaryScale 位小数,
  82. // 采用与 PostgreSQL NUMERIC 一致的 half-away-from-zero 规则。
  83. //
  84. // 走 decimal 而不是 math.Round(v*1e8)/1e8:后者在乘除过程中会引入额外的二进制
  85. // 误差,边界值可能被推到错误的一侧。decimal.NewFromFloat 取 float64 的最短十进制
  86. // 表示,正是 PostgreSQL 把 float8 参数转成 numeric 时所用的表示。
  87. func QuantizeUsageBillingAmount(v float64) float64 {
  88. if v == 0 || math.IsNaN(v) || math.IsInf(v, 0) {
  89. return v
  90. }
  91. quantized, _ := decimal.NewFromFloat(v).Round(UsageBillingMonetaryScale).Float64()
  92. return quantized
  93. }
  94. func buildUsageBillingFingerprint(c *UsageBillingCommand) string {
  95. if c == nil {
  96. return ""
  97. }
  98. raw := fmt.Sprintf(
  99. "%d|%d|%d|%s|%s|%s|%s|%d|%d|%d|%d|%d|%d|%s|%d|%0.10f|%0.10f|%0.10f|%0.10f|%0.10f",
  100. c.UserID,
  101. c.AccountID,
  102. c.APIKeyID,
  103. strings.TrimSpace(c.AccountType),
  104. strings.TrimSpace(c.Model),
  105. strings.TrimSpace(c.ServiceTier),
  106. strings.TrimSpace(c.ReasoningEffort),
  107. c.BillingType,
  108. c.InputTokens,
  109. c.OutputTokens,
  110. c.CacheCreationTokens,
  111. c.CacheReadTokens,
  112. c.ImageCount,
  113. strings.TrimSpace(c.MediaType),
  114. valueOrZero(c.SubscriptionID),
  115. c.BalanceCost,
  116. c.SubscriptionCost,
  117. c.APIKeyQuotaCost,
  118. c.APIKeyRateLimitCost,
  119. c.AccountQuotaCost,
  120. )
  121. if payloadHash := strings.TrimSpace(c.RequestPayloadHash); payloadHash != "" {
  122. raw += "|" + payloadHash
  123. }
  124. sum := sha256.Sum256([]byte(raw))
  125. return hex.EncodeToString(sum[:])
  126. }
  127. func HashUsageRequestPayload(payload []byte) string {
  128. if len(payload) == 0 {
  129. return ""
  130. }
  131. sum := sha256.Sum256(payload)
  132. return hex.EncodeToString(sum[:])
  133. }
  134. func valueOrZero(v *int64) int64 {
  135. if v == nil {
  136. return 0
  137. }
  138. return *v
  139. }
  140. // AccountQuotaState holds the post-increment quota state returned by the DB transaction.
  141. // All values are post-update (i.e., already include the increment).
  142. type AccountQuotaState struct {
  143. TotalUsed float64
  144. TotalLimit float64
  145. DailyUsed float64
  146. DailyLimit float64
  147. WeeklyUsed float64
  148. WeeklyLimit float64
  149. }
  150. type UsageBillingApplyResult struct {
  151. Applied bool
  152. APIKeyQuotaExhausted bool
  153. NewBalance *float64 // post-deduction balance (nil = no balance deduction)
  154. BalanceOverdrafted bool // true when the sufficient-balance guard missed and debt was still recorded
  155. QuotaState *AccountQuotaState // post-increment quota state (nil = no quota increment)
  156. }
  157. // BatchImageBalanceHoldCommand describes an idempotent balance hold operation.
  158. type BatchImageBalanceHoldCommand struct {
  159. RequestID string
  160. APIKeyID int64
  161. RequestFingerprint string
  162. RequestPayloadHash string
  163. UserID int64
  164. BatchID string
  165. HoldAmount float64
  166. ActualAmount float64
  167. }
  168. func (c *BatchImageBalanceHoldCommand) Normalize() {
  169. if c == nil {
  170. return
  171. }
  172. c.RequestID = strings.TrimSpace(c.RequestID)
  173. c.BatchID = strings.TrimSpace(c.BatchID)
  174. if strings.TrimSpace(c.RequestFingerprint) == "" {
  175. c.RequestFingerprint = buildBatchImageBalanceHoldFingerprint(c)
  176. }
  177. }
  178. func buildBatchImageBalanceHoldFingerprint(c *BatchImageBalanceHoldCommand) string {
  179. if c == nil {
  180. return ""
  181. }
  182. raw := fmt.Sprintf(
  183. "%d|%d|%s|%0.10f|%0.10f",
  184. c.UserID,
  185. c.APIKeyID,
  186. strings.TrimSpace(c.BatchID),
  187. c.HoldAmount,
  188. c.ActualAmount,
  189. )
  190. if payloadHash := strings.TrimSpace(c.RequestPayloadHash); payloadHash != "" {
  191. raw += "|" + payloadHash
  192. }
  193. sum := sha256.Sum256([]byte(raw))
  194. return hex.EncodeToString(sum[:])
  195. }
  196. type BatchImageBalanceHoldResult struct {
  197. Applied bool
  198. NewBalance *float64
  199. FrozenBalance *float64
  200. }
  201. type UsageBillingRepository interface {
  202. Apply(ctx context.Context, cmd *UsageBillingCommand) (*UsageBillingApplyResult, error)
  203. ReserveBatchImageBalance(ctx context.Context, cmd *BatchImageBalanceHoldCommand) (*BatchImageBalanceHoldResult, error)
  204. CaptureBatchImageBalance(ctx context.Context, cmd *BatchImageBalanceHoldCommand) (*BatchImageBalanceHoldResult, error)
  205. ReleaseBatchImageBalance(ctx context.Context, cmd *BatchImageBalanceHoldCommand) (*BatchImageBalanceHoldResult, error)
  206. }