| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226 |
- package service
- import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "math"
- "strings"
- "github.com/shopspring/decimal"
- )
- var ErrUsageBillingRequestIDRequired = errors.New("usage billing request_id is required")
- var ErrUsageBillingRequestConflict = errors.New("usage billing request fingerprint conflict")
- // UsageBillingCommand describes one billable request that must be applied at most once.
- type UsageBillingCommand struct {
- RequestID string
- APIKeyID int64
- RequestFingerprint string
- RequestPayloadHash string
- UserID int64
- AccountID int64
- SubscriptionID *int64
- AccountType string
- Model string
- ServiceTier string
- ReasoningEffort string
- BillingType int8
- InputTokens int
- OutputTokens int
- CacheCreationTokens int
- CacheReadTokens int
- ImageCount int
- MediaType string
- BalanceCost float64
- SubscriptionCost float64
- APIKeyQuotaCost float64
- APIKeyRateLimitCost float64
- AccountQuotaCost float64
- }
- func (c *UsageBillingCommand) Normalize() {
- if c == nil {
- return
- }
- c.RequestID = strings.TrimSpace(c.RequestID)
- 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 {
- if c == nil {
- return ""
- }
- raw := fmt.Sprintf(
- "%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",
- c.UserID,
- c.AccountID,
- c.APIKeyID,
- strings.TrimSpace(c.AccountType),
- strings.TrimSpace(c.Model),
- strings.TrimSpace(c.ServiceTier),
- strings.TrimSpace(c.ReasoningEffort),
- c.BillingType,
- c.InputTokens,
- c.OutputTokens,
- c.CacheCreationTokens,
- c.CacheReadTokens,
- c.ImageCount,
- strings.TrimSpace(c.MediaType),
- valueOrZero(c.SubscriptionID),
- c.BalanceCost,
- c.SubscriptionCost,
- c.APIKeyQuotaCost,
- c.APIKeyRateLimitCost,
- c.AccountQuotaCost,
- )
- if payloadHash := strings.TrimSpace(c.RequestPayloadHash); payloadHash != "" {
- raw += "|" + payloadHash
- }
- sum := sha256.Sum256([]byte(raw))
- return hex.EncodeToString(sum[:])
- }
- func HashUsageRequestPayload(payload []byte) string {
- if len(payload) == 0 {
- return ""
- }
- sum := sha256.Sum256(payload)
- return hex.EncodeToString(sum[:])
- }
- func valueOrZero(v *int64) int64 {
- if v == nil {
- return 0
- }
- return *v
- }
- // AccountQuotaState holds the post-increment quota state returned by the DB transaction.
- // All values are post-update (i.e., already include the increment).
- type AccountQuotaState struct {
- TotalUsed float64
- TotalLimit float64
- DailyUsed float64
- DailyLimit float64
- WeeklyUsed float64
- WeeklyLimit float64
- }
- type UsageBillingApplyResult struct {
- Applied bool
- APIKeyQuotaExhausted bool
- NewBalance *float64 // post-deduction balance (nil = no balance deduction)
- BalanceOverdrafted bool // true when the sufficient-balance guard missed and debt was still recorded
- QuotaState *AccountQuotaState // post-increment quota state (nil = no quota increment)
- }
- // BatchImageBalanceHoldCommand describes an idempotent balance hold operation.
- type BatchImageBalanceHoldCommand struct {
- RequestID string
- APIKeyID int64
- RequestFingerprint string
- RequestPayloadHash string
- UserID int64
- BatchID string
- HoldAmount float64
- ActualAmount float64
- }
- func (c *BatchImageBalanceHoldCommand) Normalize() {
- if c == nil {
- return
- }
- c.RequestID = strings.TrimSpace(c.RequestID)
- c.BatchID = strings.TrimSpace(c.BatchID)
- if strings.TrimSpace(c.RequestFingerprint) == "" {
- c.RequestFingerprint = buildBatchImageBalanceHoldFingerprint(c)
- }
- }
- func buildBatchImageBalanceHoldFingerprint(c *BatchImageBalanceHoldCommand) string {
- if c == nil {
- return ""
- }
- raw := fmt.Sprintf(
- "%d|%d|%s|%0.10f|%0.10f",
- c.UserID,
- c.APIKeyID,
- strings.TrimSpace(c.BatchID),
- c.HoldAmount,
- c.ActualAmount,
- )
- if payloadHash := strings.TrimSpace(c.RequestPayloadHash); payloadHash != "" {
- raw += "|" + payloadHash
- }
- sum := sha256.Sum256([]byte(raw))
- return hex.EncodeToString(sum[:])
- }
- type BatchImageBalanceHoldResult struct {
- Applied bool
- NewBalance *float64
- FrozenBalance *float64
- }
- type UsageBillingRepository interface {
- Apply(ctx context.Context, cmd *UsageBillingCommand) (*UsageBillingApplyResult, error)
- ReserveBatchImageBalance(ctx context.Context, cmd *BatchImageBalanceHoldCommand) (*BatchImageBalanceHoldResult, error)
- CaptureBatchImageBalance(ctx context.Context, cmd *BatchImageBalanceHoldCommand) (*BatchImageBalanceHoldResult, error)
- ReleaseBatchImageBalance(ctx context.Context, cmd *BatchImageBalanceHoldCommand) (*BatchImageBalanceHoldResult, error)
- }
|