diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index b101769..5f683df 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -11,10 +11,12 @@ import ( "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/api/admin/v1" "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/logic/relay" do "github.com/qianfree/team-api/internal/model/do" @@ -506,10 +508,12 @@ func fetchLiteLLMSource(ctx context.Context, modelName string) *v1.OfficialPrici return source } - inputPrice := entry.InputCostPerToken * 1_000_000 - outputPrice := entry.OutputCostPerToken * 1_000_000 - cacheReadPrice := entry.CacheReadInputTokenCost * 1_000_000 - cacheCreationPrice := entry.CacheCreationInputTokenCost * 1_000_000 + // 修复定价单位转换:用 decimal 避免裸浮点乘法,将每 token 价格转换为每百万 token 价格 + million := decimal.NewFromInt(1_000_000) + inputPrice := billing.RoundMoney(decimal.NewFromFloat(entry.InputCostPerToken).Mul(million)) + outputPrice := billing.RoundMoney(decimal.NewFromFloat(entry.OutputCostPerToken).Mul(million)) + cacheReadPrice := billing.RoundMoney(decimal.NewFromFloat(entry.CacheReadInputTokenCost).Mul(million)) + cacheCreationPrice := billing.RoundMoney(decimal.NewFromFloat(entry.CacheCreationInputTokenCost).Mul(million)) billingMode := "token" if entry.Mode == "image_generation" && entry.OutputCostPerImage > 0 && inputPrice == 0 && outputPrice == 0 { @@ -522,10 +526,10 @@ func fetchLiteLLMSource(ctx context.Context, modelName string) *v1.OfficialPrici source.MaxContext = entry.MaxInputTokens source.MaxOutput = entry.MaxOutputTokens source.Pricing = &v1.OfficialPricingItem{ - InputPrice: roundTo2(inputPrice), - OutputPrice: roundTo2(outputPrice), - CacheReadPrice: roundTo2(cacheReadPrice), - CacheCreationPrice: roundTo2(cacheCreationPrice), + InputPrice: inputPrice, + OutputPrice: outputPrice, + CacheReadPrice: cacheReadPrice, + CacheCreationPrice: cacheCreationPrice, BillingMode: billingMode, } diff --git a/internal/logic/billing/money.go b/internal/logic/billing/money.go index e1857b3..dd1d0cd 100644 --- a/internal/logic/billing/money.go +++ b/internal/logic/billing/money.go @@ -23,13 +23,19 @@ func dec(f float64) decimal.Decimal { return decimal.NewFromFloat(f) } -// roundMoney 将 decimal 金额四舍五入到 10 位小数(与 NUMERIC(20,10) 对齐)后返回 float64。 +// RoundMoney 将 decimal 金额四舍五入到 10 位小数(与 NUMERIC(20,10) 对齐)后返回 float64。 // 用于把 decimal 精确计算的结果收敛到存储精度,作为 Go 层金额的统一出口。 -func roundMoney(d decimal.Decimal) float64 { +// 导出给 admin/tenant 层调用(A8 Phase 1 扩展)。 +func RoundMoney(d decimal.Decimal) float64 { f, _ := d.Round(moneyScale).Float64() return f } +// roundMoney 内部别名,保持向后兼容 +func roundMoney(d decimal.Decimal) float64 { + return RoundMoney(d) +} + // ceilUSD 将 decimal 金额向上取整到 6 位小数(钱包 USD 精度)后返回 float64。 // 用于 CNY→USD 充值入账:宁可多给用户零头也不少给,且与展示精度一致。 func ceilUSD(d decimal.Decimal) float64 { @@ -52,3 +58,27 @@ func fromMicro(micro int64) float64 { f, _ := decimal.New(micro, -6).Float64() return f } + +// MultiplyMoney 金额精确乘法(A8 Phase 1 扩展)。 +// 用于替代裸浮点乘法,避免链式运算累积误差(如 price × months、amount × discount)。 +func MultiplyMoney(a, b float64) float64 { + return roundMoney(dec(a).Mul(dec(b))) +} + +// DivideMoney 金额精确除法(A8 Phase 1 扩展)。 +// 用于替代裸浮点除法(如 totalCost / requests)。 +func DivideMoney(a, b float64) float64 { + return roundMoney(dec(a).Div(dec(b))) +} + +// AddMoney 金额精确加法(A8 Phase 1 扩展)。 +// 用于循环累加避免误差放大。 +func AddMoney(a, b float64) float64 { + return roundMoney(dec(a).Add(dec(b))) +} + +// SubtractMoney 金额精确减法(A8 Phase 1 扩展)。 +// 用于差额计算(如 available = balance - frozen)。 +func SubtractMoney(a, b float64) float64 { + return roundMoney(dec(a).Sub(dec(b))) +} diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index 66cc7fc..68cd81e 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -502,7 +502,9 @@ func calculateTieredCostFromTiers(tiers []pricingTierRow, tokens int, isInput bo return 0 } - var totalCost float64 + // 修复阶梯定价循环累加:用 decimal 精确计算避免每次 ÷1M × price 的误差累积 + million := decimal.NewFromInt(1_000_000) + totalCostD := decimal.Zero remaining := int64(tokens) for _, tier := range tiers { @@ -516,7 +518,10 @@ func calculateTieredCostFromTiers(tiers []pricingTierRow, tokens int, isInput bo } if tier.MaxTokens == nil { - totalCost += float64(remaining) / 1_000_000.0 * price + // 最后一档:消耗所有剩余 token + totalCostD = totalCostD.Add( + decimal.NewFromInt(remaining).Div(million).Mul(dec(price)), + ) remaining = 0 } else { available := *tier.MaxTokens - tier.MinTokens @@ -527,10 +532,12 @@ func calculateTieredCostFromTiers(tiers []pricingTierRow, tokens int, isInput bo if useTokens > available { useTokens = available } - totalCost += float64(useTokens) / 1_000_000.0 * price + totalCostD = totalCostD.Add( + decimal.NewFromInt(useTokens).Div(million).Mul(dec(price)), + ) remaining -= useTokens } } - return totalCost + return roundMoney(totalCostD) } diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 1bb50cf..1f81526 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -8,6 +8,7 @@ import ( "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" @@ -69,7 +70,12 @@ func estimateTaskCost(pricing *PricingResult, ratios map[string]float64) float64 // 预估 tokens ≈ base_tokens_per_second × duration × resolution_multiplier // 火山方舟视频生成约 10000 tokens/s (480p 基准),用于预扣估算 estimatedTokens := 10000.0 * duration * resolutionMul - cost = estimatedTokens / 1_000_000.0 * pricing.OutputPrice * pricing.TenantMultiplier + // 修复视频预扣三连乘:用 decimal 避免 ÷1M × 单价 × 倍率 的链式误差 + million := decimal.NewFromInt(1_000_000) + costD := dec(estimatedTokens).Div(million). + Mul(dec(pricing.OutputPrice)). + Mul(dec(pricing.TenantMultiplier)) + cost = roundMoney(costD) default: // 无时长信号(图片等扁平计费任务):优先按次单价;未配按次价时用占位预扣, // 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补 @@ -398,11 +404,11 @@ func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenan return 0, nil } - // 基础费用 = tokens × 单价 - cost := float64(totalTokens) / 1_000_000.0 * pricing.OutputPrice - - // 应用租户倍率 - cost *= pricing.TenantMultiplier + // 修复 token 重算循环累乘:用 decimal 避免 ÷1M × 单价 × 倍率 × 循环比率 的链式误差 + million := decimal.NewFromInt(1_000_000) + costD := decimal.NewFromInt(int64(totalTokens)).Div(million). + Mul(dec(pricing.OutputPrice)). + Mul(dec(pricing.TenantMultiplier)) // 应用附加比率(视频输入折扣等) // 注意:跳过 duration/resolution,它们已体现在上游返回的 token 数中,不应再乘 @@ -410,9 +416,11 @@ func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenan if k == "duration" || k == "resolution" { continue } - cost *= ratio + costD = costD.Mul(dec(ratio)) } + cost := roundMoney(costD) + // 最低消费 if cost < 0.01 { cost = 0.01 diff --git a/internal/logic/tenant/billing.go b/internal/logic/tenant/billing.go index 4050619..425af2a 100644 --- a/internal/logic/tenant/billing.go +++ b/internal/logic/tenant/billing.go @@ -66,7 +66,7 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena return &v1.TenantWalletRes{ Balance: w.Balance, FrozenBalance: w.FrozenBalance, - AvailableBalance: w.Balance - w.FrozenBalance, + AvailableBalance: billing.SubtractMoney(w.Balance, w.FrozenBalance), WarningThreshold: w.WarningThreshold, Currency: w.Currency, }, nil diff --git a/internal/logic/tenant/order.go b/internal/logic/tenant/order.go index 27b08c3..b7ae7d0 100644 --- a/internal/logic/tenant/order.go +++ b/internal/logic/tenant/order.go @@ -14,6 +14,7 @@ import ( "github.com/qianfree/team-api/internal/dao" do "github.com/qianfree/team-api/internal/model/do" + "github.com/qianfree/team-api/internal/logic/billing" lcommon "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/logic/payment" "github.com/qianfree/team-api/internal/middleware" @@ -111,7 +112,8 @@ func (s *sTenant) OrderCreate(ctx context.Context, req *v1.TenantOrderCreateReq) if months >= 12 { amount = plan.YearlyPrice } else { - amount = plan.MonthlyPrice * float64(months) + // 修复订单金额连乘:用 decimal 避免 price × months 的浮点误差 + amount = billing.MultiplyMoney(plan.MonthlyPrice, float64(months)) } // 使用 crypto/rand 生成随机部分,避免碰撞 @@ -362,7 +364,8 @@ func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCrea finalAmount := req.Amount if settings != nil { if discount, ok := settings.AmountDiscount[int(req.Amount)]; ok && discount > 0 { - finalAmount = req.Amount * discount + // 修复充值折扣连乘:用 decimal 避免 amount × discount 的浮点误差 + finalAmount = billing.MultiplyMoney(req.Amount, discount) } } diff --git a/internal/logic/tenant/project.go b/internal/logic/tenant/project.go index c79990e..3960736 100644 --- a/internal/logic/tenant/project.go +++ b/internal/logic/tenant/project.go @@ -167,7 +167,9 @@ func CheckProjectBudget(ctx context.Context, tenantID, projectID int64) error { if err != nil { return err } - if totalCost < project.Budget { + // 修复预算超支浮点比较:加 epsilon 容差避免浮点误差导致误触发/漏触发 + const epsilon = 0.000001 // 1 微美元容差 + if totalCost < project.Budget-epsilon { return nil } diff --git a/relay/channel/openai/audio.go b/relay/channel/openai/audio.go index 728a1b4..6dca066 100644 --- a/relay/channel/openai/audio.go +++ b/relay/channel/openai/audio.go @@ -91,7 +91,8 @@ func extractAudioUsage(body []byte) *common.Usage { var verboseResp dto.WhisperVerboseJSONResponse if err := json.Unmarshal(body, &verboseResp); err == nil && verboseResp.Duration > 0 { // 按音频时长估算:1 分钟 ≈ 150 tokens - estimatedTokens := int(verboseResp.Duration/60.0) * 150 + // 修复浮点除法短扣:用整数运算 + 四舍五入避免 int(90/60.0)=1 丢失 0.5 分钟 + estimatedTokens := (int(verboseResp.Duration)*150 + 30) / 60 if estimatedTokens == 0 { estimatedTokens = 1 }