499 lines
20 KiB
Go
499 lines
20 KiB
Go
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
|
||
}
|