Files

499 lines
20 KiB
Go
Raw Permalink 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/errors/gerror"
"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
}
// 特殊计费方案 fail-closed:定价引用了未注册的方案(方案代码缺失/被回滚)时
// 引擎无从得知正确价格,静默回落 generic 会错价,拒绝任务提交与「未配价模型
// 不得放行」的同步路径口径一致
if pricing.Scheme != "" && !SchemeRegistered(pricing.Scheme) {
return Zero, gerror.Newf("billing scheme %q not registered (model=%s)", pricing.Scheme, modelName)
}
// special 必须与方案配对(写侧已双向校验,此处兜底防手工改库):裸 special 会落
// generic 分发走 default 分支,计费口径不可预期,拒绝任务提交
if pricing.BillingMode == BillingModeSpecial && pricing.Scheme == "" {
return Zero, gerror.Newf("billing_mode special requires scheme (model=%s)", modelName)
}
// 参数倍率求值:命中注入 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, taskBody), nil
}
// imagePlaceholderPreDeduct 图片等按次任务**未配置按次单价**时的占位预扣(USD)。
// 图片提交时拿不到 token 用量、无法精确估价,故先按此小额占位冻结,结算阶段再按上游
// 返回的真实 token 用量多退少补。仅用于「无有效定价」的兜底,不覆盖已配置的真实按次单价。
const imagePlaceholderPreDeduct = 0.1
// estimateTaskCost 纯函数:根据定价与计费上下文估算任务预扣费用,不依赖数据库/缓存,便于单测。
// 骨架只做两件事:按 pricing.Scheme 分发到计费方案实现(未声明走 generic 通用引擎),
// 再统一应用附加比率与 minCost 钳制——方案实现只负责组件费用(含租户/时段乘数),
// 附加乘数、钳制、舍入全站收口于此,保证各方案结算口径一致。
func estimateTaskCost(pricing *PricingResult, ratios map[string]any, taskBody []byte) decimal.Decimal {
if pricing == nil {
return NewFromFloat(0.01)
}
costD := LookupScheme(pricing.Scheme).EstimateTaskCost(pricing, ratios, taskBody)
return wrapSchemeCost(pricing, ratios, 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 / special(特殊方案)模式:无 token 语义,费用整体记 BaseCost(折扣前成本),
// 与 per_request 快照口径一致。命中档的每秒价在预扣时已消费,此处不重复记录
// (CostBreakdown 无单价字段先例);special 若漏此分支会落 token 路径,生成
// 「N tokens × $0」的自相矛盾快照行
if pricing.BillingMode == "per_second" || pricing.BillingMode == BillingModeSpecial {
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
}
// RecalculateByMaterials 按上游 usage 的素材计量重算任务费用(素材计费方案的结算依据)。
// 模型未配置素材方案 / 取价失败 / usage 为 nil 时 ok=false,调用方保持既有结算来源。
// billAt 为任务受理时刻(时段定价按该时刻评估,与预扣口径一致)。
func (b *TaskBillingProviderImpl) RecalculateByMaterials(ctx context.Context, tenantID int64, modelName string, ratios map[string]any, usage *common.TaskMaterialUsage, billAt time.Time) (decimal.Decimal, bool, error) {
if usage == nil {
return Zero, false, nil
}
pricing, err := GetModelPriceAt(ctx, tenantID, modelName, billAt)
if err != nil || pricing == nil {
// 取价失败不阻断结算(调用方回落预扣金额),仅返回 ok=false
return Zero, false, err
}
// 仅显式配置了计费方案、或 generic per_second(消费上游实际输出秒数结算)的模型适用:
// 其余 generic 任务保持「预扣即终价」不被重估(素材计费语义由方案声明,未声明素材的
// 模型不应因 usage 附带字段改变结算来源)。per_second 放行是安全的:存量按秒渠道
// (kling/volcengine/sora)适配器均不产出 MaterialUsage,结算链路到不了这里,行为不变
if pricing.Scheme == "" && pricing.BillingMode != "per_second" {
return Zero, false, nil
}
costD := LookupScheme(pricing.Scheme).SettleTaskCost(pricing, ratios, usage)
return wrapSchemeCost(pricing, ratios, costD), true, 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 / special(特殊方案)模式按秒计价,token 数与费用无关:返回 0 让结算
// 保持预扣金额,防止上游附带返回 token 用量时把按秒任务重算成 token 价
// (special 若漏此分支,租户 custom_output_price 覆盖 + 上游返回 token 时会被错误重算)
if pricing.BillingMode == "per_second" || pricing.BillingMode == BillingModeSpecial {
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
}