Files
team-api/internal/logic/billing/task_billing.go
T

541 lines
21 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 billing
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/shopspring/decimal"
"github.com/qianfree/team-api/internal/dao"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/qianfree/team-api/relay/common"
)
// TaskBillingProviderImpl 异步任务计费实现
type TaskBillingProviderImpl struct{}
// NewTaskBillingProvider 创建 TaskBillingProvider 实例
func NewTaskBillingProvider() common.TaskBillingProvider {
return &TaskBillingProviderImpl{}
}
// EstimateTaskCost 估算任务费用
// ratios 包含计费上下文(float64 乘数如 video_input 折扣 + spec.* 规格事实值,见 TaskAdaptor.EstimateBilling);
// taskBody 为归一化任务体,参数倍率(param_multipliers)命中时就地注入 ratios 随任务持久化
func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, taskBody []byte) (decimal.Decimal, error) {
pricing, err := GetModelPrice(ctx, tenantID, modelName)
if err != nil {
return NewFromFloat(0.01), nil
}
// 参数倍率求值:命中注入 ratios(预扣、per_second 结算、token 重算经 applyRatioMultipliers 自动同口径)
if len(pricing.ParamMultipliers) > 0 && len(taskBody) > 0 && ratios != nil {
if m, matched := EvalParamMultipliers(pricing.ParamMultipliers, taskBody); len(matched) > 0 && m > 0 {
ratios[ratioKeyParamMultiplier] = m
ratios[ratioKeyParamMatched] = strings.Join(matched, "|")
}
}
return estimateTaskCost(pricing, ratios), nil
}
// imagePlaceholderPreDeduct 图片等按次任务**未配置按次单价**时的占位预扣(USD)。
// 图片提交时拿不到 token 用量、无法精确估价,故先按此小额占位冻结,结算阶段再按上游
// 返回的真实 token 用量多退少补。仅用于「无有效定价」的兜底,不覆盖已配置的真实按次单价。
const imagePlaceholderPreDeduct = 0.1
// estimateTaskCost 纯函数:根据定价与计费上下文估算任务预扣费用,不依赖数据库/缓存,便于单测。
//
// 计费口径按任务类型分流:
// - per_second(按秒计费,视频生成):矩阵查价(spec.resolution 未命中回退 "*")×
// spec.duration 秒 × 租户乘数 × 时段乘数 × 附加乘数(video_input 折扣等);
// - per_request(按次计费,图片/音乐等):直接取按次单价;
// - 时长类任务(视频生成,ratios 携带 duration/resolution 信号):按
// 10000 tokens/s × duration × resolution 预估 token 再乘输出单价(存量 token 伪装路径);
// - 其余无时长信号的任务(如未显式配成 per_request 的图片模型):退回按次单价,
// **不再**套用视频 token 估算——图片没有时长/分辨率,套 10000×5×2.25 会凭空估出
// 11.25 万 token 的天价预扣($30/1M 输出价即得 $3.375),且与结算的「0 token」自相矛盾。
func estimateTaskCost(pricing *PricingResult, ratios map[string]any) decimal.Decimal {
if pricing == nil {
return NewFromFloat(0.01)
}
var costD decimal.Decimal
switch {
case pricing.BillingMode == "per_second":
// 按秒计费:矩阵查价 × 时长 × 租户乘数 × 时段乘数
duration := defaultTaskDurationSeconds
if d, ok := ratioFloat(ratios, "spec.duration"); ok && d > 0 {
duration = d
}
// 时长来自用户请求,钳制上限防天价预扣(正常视频模型远低于该上限)
if duration > maxTaskDurationSeconds {
duration = maxTaskDurationSeconds
}
spec, _ := ratioString(ratios, "spec.resolution")
price := LookupPerSecondPrice(pricing.PerSecondPrices, spec)
if price <= 0 {
// 矩阵全零/为空:按未配价占位预扣,结算多退少补
costD = NewFromFloat(imagePlaceholderPreDeduct)
break
}
costD = NewFromFloat(price).
Mul(NewFromFloat(duration)).
Mul(NewFromFloat(pricing.TenantMultiplier)).
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
case pricing.BillingMode == "per_request":
// 按次计费:单价 × 租户乘数 × 时段乘数。与 computeCost / EstimatePreDeductAmount 的
// 按次口径对齐——按次任务结算无 token 重算信号,预扣即终价,
// 预扣漏乘租户/时段折扣会让折扣租户按原价多扣
costD = NewFromFloat(pricing.PerRequestPrice).
Mul(NewFromFloat(pricing.TenantMultiplier)).
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
case pricing.OutputPrice > 0 && hasDurationSignal(ratios):
duration := 5.0 // 默认 5 秒
if d, ok := ratioFloat(ratios, "duration"); ok && d > 0 {
duration = d
}
resolutionMul := 2.25 // 默认 720p
if r, ok := ratioFloat(ratios, "resolution"); ok && r > 0 {
resolutionMul = r
}
// 预估 tokens ≈ base_tokens_per_second × duration × resolution_multiplier
// 火山方舟视频生成约 10000 tokens/s (480p 基准),用于预扣估算
// 修复视频预扣三连乘:全程 decimal 避免链式误差
baseTokensPerSec := decimal.NewFromInt(10000)
durationD := NewFromFloat(duration)
resolutionMulD := NewFromFloat(resolutionMul)
million := decimal.NewFromInt(1_000_000)
costD = baseTokensPerSec.Mul(durationD).Mul(resolutionMulD).
Div(million).
Mul(NewFromFloat(pricing.OutputPrice)).
Mul(NewFromFloat(pricing.TenantMultiplier))
default:
// 无时长信号(图片等扁平计费任务):优先按次单价(同样乘租户/时段乘数,
// 预扣即终价的口径与 per_request 分支一致);未配按次价时用占位预扣,
// 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补
// (见 sync_image_worker.settleSyncImageSuccess)。
if pricing.PerRequestPrice > 0 {
costD = NewFromFloat(pricing.PerRequestPrice).
Mul(NewFromFloat(pricing.TenantMultiplier)).
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
} else {
costD = NewFromFloat(imagePlaceholderPreDeduct)
}
}
// 应用附加比率(video_input 折扣等):只乘 float 乘数值,
// 跳过 duration/resolution(已在时长类分支消费)与 spec.*(规格事实值非乘数)
costD = applyRatioMultipliers(costD, ratios, "duration", "resolution")
minCost := NewFromFloat(0.01)
if costD.LessThan(minCost) {
costD = minCost
}
return RoundMoney(costD)
}
// ratioFloat 取计费上下文中的 float 值(JSONB 往返后数字均为 float64)
func ratioFloat(ratios map[string]any, key string) (float64, bool) {
v, ok := ratios[key]
if !ok {
return 0, false
}
f, ok := v.(float64)
return f, ok
}
// ratioString 取计费上下文中的 string 值(spec.* 规格原值)
func ratioString(ratios map[string]any, key string) (string, bool) {
v, ok := ratios[key]
if !ok {
return "", false
}
s, ok := v.(string)
return s, ok
}
// applyRatioMultipliers 把计费上下文中的 float 乘数按序连乘到 cost 上。
// spec.*(规格事实值)与 string 值自动跳过;skip 显式追加需跳过的键。
func applyRatioMultipliers(cost decimal.Decimal, ratios map[string]any, skip ...string) decimal.Decimal {
if len(ratios) == 0 {
return cost
}
for k, v := range ratios {
if strings.HasPrefix(k, "spec.") {
continue
}
f, ok := v.(float64)
if !ok || f <= 0 {
continue
}
skipped := false
for _, s := range skip {
if k == s {
skipped = true
break
}
}
if skipped {
continue
}
cost = cost.Mul(NewFromFloat(f))
}
return cost
}
// hasDurationSignal 判断计费上下文里是否携带时长类任务(视频)的 duration/resolution 信号。
// 视频提交管线(VolcengineVideoAdaptor.EstimateBilling)必然写入这两个键;图片等同步/异步
// 任务传 nil ratios,据此区分「该走视频 token 估算」还是「按次计费」。
func hasDurationSignal(ratios map[string]any) bool {
if ratios == nil {
return false
}
if _, ok := ratios["duration"]; ok {
return true
}
_, ok := ratios["resolution"]
return ok
}
// PreDeductTask 预扣任务费用
func (b *TaskBillingProviderImpl) PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost decimal.Decimal, modelName string) (decimal.Decimal, error) {
estimatedFloat := InexactFloat64(estimatedCost)
ok, err := PreDeduct(ctx, tenantID, estimatedFloat, requestID, modelName)
if !ok {
if err == nil {
return Zero, fmt.Errorf("pre-deduct task failed: insufficient balance")
}
return Zero, fmt.Errorf("pre-deduct task failed: %w", err)
}
return estimatedCost, nil
}
func (b *TaskBillingProviderImpl) CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64, keyQPS int) (bool, string, int, int, int64) {
result := CheckRateLimitWithKeyLimit(ctx, LoadRateLimitConfig(ctx), tenantID, userID, apiKeyID, keyQPS)
return result.Allowed, result.LimitLevel, result.Limit, result.Remaining, result.ResetAt
}
func (b *TaskBillingProviderImpl) AcquireApiKeyConcurrent(ctx context.Context, apiKeyID int64, limit int) bool {
return AcquireApiKeyConcurrent(ctx, apiKeyID, limit)
}
func (b *TaskBillingProviderImpl) ReleaseApiKeyConcurrent(ctx context.Context, apiKeyID int64) {
ReleaseApiKeyConcurrent(ctx, apiKeyID)
}
func (b *TaskBillingProviderImpl) CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount decimal.Decimal) error {
preDeductFloat := InexactFloat64(preDeductAmount)
return CheckApiKeyQuota(ctx, apiKeyID, preDeductFloat)
}
// SettleTaskSuccess 任务成功结算(含计费快照)
// totalTokens/completionTokens: 上游返回的 token 用量
// ratios: 提交时保存的计费上下文(如 video_input 折扣)
// billAt: 任务受理时刻(时段定价按该时刻评估;零值按当前时刻兜底)
func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]any, taskID string, billAt time.Time) (*common.SettlementResult, error) {
diff := SubtractMoney(actualCost, preDeductAmount)
// 1. 获取钱包
walletID, err := GetWalletID(ctx, tenantID)
if err != nil {
return nil, fmt.Errorf("settle task: get wallet: %w", err)
}
// 2. 获取定价(事务外只读,按任务受理时刻评估时段乘数)
pricing, _ := GetModelPriceAt(ctx, tenantID, modelName, billAt)
breakdown := buildTaskCostBreakdown(ctx, pricing, InexactFloat64(actualCost), totalTokens, completionTokens)
var billingMode string
var discountRatio, effectiveOutputPrice float64
if pricing != nil {
billingMode = pricing.BillingMode
discountRatio = pricing.DiscountRatio
effectiveOutputPrice = pricing.OutputPrice
}
// 3. 执行结算(幂等闸门 → Redis 认领扣款 → 流水)
// 预扣认领同时覆盖 requestID 与 requestID+"_adjust"(AdjustTaskBilling 补扣产生):
// 骨架按认领模式只释放实际认领到的预扣 hash 金额,
// _adjust 已被解冻/过期处理过时自动少释放,不会二次释放。
_, err = executeSettlement(ctx, settlementTxParams{
tenantID: tenantID,
walletID: walletID,
preDeductAmount: InexactFloat64(preDeductAmount),
actualCost: InexactFloat64(actualCost),
logPrefix: "settle task",
createBillingRecord: func(ctx context.Context) (int64, error) {
billingResult, err := dao.BilRecords.Ctx(ctx).Data(do.BilRecords{
TenantId: tenantID,
UserId: userID,
ApiKeyId: apiKeyID,
ChannelId: channelID,
ModelName: modelName,
RequestId: requestID,
RelayMode: "task",
InputTokens: 0,
OutputTokens: totalTokens,
InputPrice: 0,
OutputPrice: effectiveOutputPrice,
TotalCost: actualCost,
Currency: Currency(ctx),
Status: "settled",
SettledAt: gtime.NewFromTime(time.Now()),
BillingMode: billingMode,
DiscountRatio: func() float64 {
if discountRatio > 0 {
return discountRatio
}
return 0
}(),
EffectiveInputPrice: 0,
EffectiveOutputPrice: effectiveOutputPrice,
BillingInputMultiplier: 0,
BillingOutputMultiplier: 0,
CacheCreationTokens: 0,
CacheReadTokens: 0,
CacheCreationCost: 0,
CacheReadCost: 0,
}).Insert()
if err != nil {
return 0, err
}
var id int64
if billingResult != nil {
id, _ = billingResult.LastInsertId()
}
return id, nil
},
buildTransaction: func(billingID int64, balanceAfter, frozenAfter float64) do.BilTransactions {
return do.BilTransactions{
TenantId: tenantID,
WalletId: walletID,
Type: "consume",
Amount: SubtractMoney(Zero, actualCost),
BalanceAfter: balanceAfter,
FrozenAfter: frozenAfter,
RelatedId: billingID,
RelatedType: "billing_record",
Description: fmt.Sprintf("consume: %s model=%s pre_deduct=%.6f actual=%.6f", requestID, modelName, InexactFloat64(preDeductAmount), InexactFloat64(actualCost)),
UserId: userID,
RequestId: requestID,
ModelName: modelName,
ProjectId: 0,
ApiKeyId: apiKeyID,
TaskId: taskID,
}
},
predeductRequestIDs: []string{requestID, requestID + "_adjust"},
})
if err != nil {
if errors.Is(err, errAlreadySettled) {
// 幂等跳过:该任务此前已结算完成,本次为重复调用(轮询/重放),不再扣款/写账单
g.Log().Warningf(ctx, "settle task: duplicate settlement skipped for request=%s (idempotent)", requestID)
return &common.SettlementResult{
PreDeductAmount: InexactFloat64(preDeductAmount),
ActualCost: InexactFloat64(actualCost),
BaseCost: breakdown.BaseCost,
TotalCost: InexactFloat64(actualCost),
OutputCost: breakdown.OutputCost,
DuplicateSkip: true,
}, nil
}
return nil, err
}
// 4. 异步检查余额预警
go CheckBalanceWarning(context.Background(), tenantID)
// 5. 差额已在步骤 3 一次性结清,此处不得再做任何解冻/退款:
// SettleClaim 已 frozen -= 全部预扣认领金额(释放冻结)、balance -= actualCost
// (只扣真实成本),无论 actualCost 大于还是小于预扣,可用余额都已精确调整到位
// (available 变化量恰为 preDeductAmount - actualCost)。
// 切勿再对 requestID+"_adjust" 调 UnfreezePreDeduct/SettleFailed——那会在步骤 3 之外
// 二次释放从未单独冻结过的金额,导致 frozen_balance 被过度释放。
// 6. 生成计费快照 + 摘要
result := &common.SettlementResult{
PreDeductAmount: InexactFloat64(preDeductAmount),
ActualCost: InexactFloat64(actualCost),
BaseCost: breakdown.BaseCost,
TotalCost: InexactFloat64(actualCost),
OutputCost: breakdown.OutputCost,
}
threshold := NewFromFloat(0.001)
if diff.GreaterThan(threshold) {
result.SupplementAmount = InexactFloat64(diff)
} else if diff.LessThan(threshold.Neg()) {
result.RefundAmount = InexactFloat64(diff.Neg())
}
if pricing != nil {
internalSettlement := &SettlementResult{
PreDeductAmount: InexactFloat64(preDeductAmount),
ActualCost: InexactFloat64(actualCost),
BaseCost: breakdown.BaseCost,
RefundAmount: result.RefundAmount,
SupplementAmount: result.SupplementAmount,
}
snapshot := GenerateBillingSnapshot(pricing, breakdown, nil, internalSettlement, nil)
snapshot.RequestMeta.RequestedModel = modelName
result.BillingSnapshot = SnapshotToJSON(snapshot)
result.BillingSummary = GenerateBillingSummary(ctx, snapshot)
result.BillingMode = pricing.BillingMode
result.BillingSource = pricing.BillingSource
result.RateMultiplier = pricing.DiscountRatio
}
return result, nil
}
// buildTaskCostBreakdown 构建任务计费的 CostBreakdown
func buildTaskCostBreakdown(ctx context.Context, pricing *PricingResult, actualCost float64, totalTokens, _ int) *CostBreakdown {
if pricing == nil {
return &CostBreakdown{
TotalCost: actualCost,
BaseCost: actualCost,
Currency: Currency(ctx),
}
}
bd := &CostBreakdown{
BillingMode: pricing.BillingMode,
PerRequestPrice: pricing.PerRequestPrice,
DiscountRatio: pricing.DiscountRatio,
TenantMultiplier: pricing.TenantMultiplier,
Currency: pricing.Currency,
}
if pricing.BillingMode == "per_request" {
bd.BaseCost = pricing.PerRequestPrice
bd.TotalCost = actualCost
return bd
}
// per_second 模式:无 token 语义,费用整体记 BaseCost(折扣前成本),与 per_request 快照口径一致。
// 命中档的每秒价在预扣时已消费,此处不重复记录(CostBreakdown 无单价字段先例)
if pricing.BillingMode == "per_second" {
bd.BaseCost = preMultiplierCost(actualCost, pricing)
bd.TotalCost = actualCost
return bd
}
// token 模式但没有真实 token 用量(图片等扁平计费任务):把费用整体记为 BaseCost,
// 不摊进 OutputCost。否则快照会生成「0 token 却有 output 费用」的自相矛盾行
// (如 0 tokens × $30/1M = $3.375)。真实费用仍由结算的 actual_cost 体现。
if totalTokens <= 0 {
bd.BaseCost = preMultiplierCost(actualCost, pricing)
bd.TotalCost = actualCost
return bd
}
// token 模式
bd.OutputTokens = totalTokens
bd.OutputCost = actualCost
bd.TotalCost = actualCost
bd.BaseCost = preMultiplierCost(actualCost, pricing)
return bd
}
// preMultiplierCost 把已含「租户乘数 × 时段乘数」的实际费用还原为折扣前成本(BaseCost 语义)。
// actualCost 来自 RecalculateByTokens(已乘全部乘数)或预扣额(EstimatePreDeductAmount 同样含乘数),
// 还原时两个乘数都要除,与 computeCost 的 BaseCost = 折扣前小计语义对齐
func preMultiplierCost(actualCost float64, pricing *PricingResult) float64 {
mul := pricing.TenantMultiplier * effectiveTimeMultiplier(pricing)
if mul <= 0 {
return actualCost
}
return actualCost / mul
}
// SettleTaskFailed 任务失败退还预扣
func (b *TaskBillingProviderImpl) SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount decimal.Decimal) error {
preDeductFloat := InexactFloat64(preDeductAmount)
return SettleFailed(ctx, tenantID, requestID, preDeductFloat)
}
func (b *TaskBillingProviderImpl) IncrApiKeyQuotaUsed(ctx context.Context, apiKeyID int64, amount decimal.Decimal) {
amountFloat := InexactFloat64(amount)
IncrApiKeyQuotaUsed(ctx, apiKeyID, amountFloat)
}
// AdjustTaskBilling 调整预扣金额
func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantID int64, requestID string, preDeductAmount, newEstimatedCost decimal.Decimal) (decimal.Decimal, error) {
diff := SubtractMoney(newEstimatedCost, preDeductAmount)
threshold := NewFromFloat(0.001)
negThreshold := threshold.Neg()
if diff.LessThan(threshold) && diff.GreaterThan(negThreshold) {
return preDeductAmount, nil
}
if diff.GreaterThan(Zero) {
// 需要补扣
diffFloat := InexactFloat64(diff)
ok, err := PreDeduct(ctx, tenantID, diffFloat, requestID+"_adjust", "")
if !ok {
return preDeductAmount, fmt.Errorf("adjust task billing: %w", err)
}
return newEstimatedCost, nil
}
// 需要退还部分
if err := SettleFailed(ctx, tenantID, requestID+"_adjust", InexactFloat64(diff.Neg())); err != nil {
return preDeductAmount, fmt.Errorf("adjust task billing refund: %w", err)
}
return newEstimatedCost, nil
}
// RecalculateByTokens 根据上游返回的 total_tokens 重算费用。
// 公式:totalTokens / 1M × output_price × tenant_multiplier × 时段乘数 × 附加比率
// billAt 为任务受理时刻(时段定价按该时刻评估);如果模型没有配置 token 单价(纯按次计费),
// 返回 0 表示不做 token 重算。
func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]any, billAt time.Time) (decimal.Decimal, error) {
if totalTokens <= 0 {
return Zero, nil
}
pricing, err := GetModelPriceAt(ctx, tenantID, modelName, billAt)
if err != nil {
return Zero, nil
}
// per_second 模式按秒计价,token 数与费用无关:返回 0 让结算保持预扣金额,
// 防止上游附带返回 token 用量时把按秒任务重算成 token 价
if pricing.BillingMode == "per_second" {
return Zero, nil
}
// 需要 output_price > 0 才能做 token 重算
if pricing.OutputPrice <= 0 {
return Zero, nil
}
// 修复 token 重算循环累乘:用 decimal 避免 ÷1M × 单价 × 倍率 × 循环比率 的链式误差
million := decimal.NewFromInt(1_000_000)
costD := decimal.NewFromInt(int64(totalTokens)).Div(million).
Mul(NewFromFloat(pricing.OutputPrice)).
Mul(NewFromFloat(pricing.TenantMultiplier)).
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
// 应用附加比率(视频输入折扣等)
// 注意:跳过 duration/resolution 与 spec.*——时长/规格已体现在上游返回的 token 数中,不应再乘
costD = applyRatioMultipliers(costD, ratios, "duration", "resolution")
costD = RoundMoney(costD)
// 最低消费
minCost := NewFromFloat(0.01)
if costD.LessThan(minCost) {
costD = minCost
}
return costD, nil
}