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
227 lines
7.3 KiB
Go
227 lines
7.3 KiB
Go
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)
|
||
}
|