Files
sub2api/backend/internal/service/usage_billing.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

227 lines
7.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 (
"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.99992188delta 0.00007812
// quota_used: 0 + 0.000078125 = 0.000078125 → 0.00007813delta 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)
}