541 lines
21 KiB
Go
541 lines
21 KiB
Go
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
|
||
}
|