fix:修复float金额带来的误差

This commit is contained in:
chengqian committed 2026-07-23 17:36:38 +08:00
1 parent 4b13477a83
commit 95e5283e28
8 files changed
+81 -26

No files matched your search

+12 -8
View File
@@ -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,
}
+32 -2
View File
@@ -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)))
}
+11 -4
View File
@@ -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)
}
+15 -7
View File
@@ -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
+1 -1
View File
@@ -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
+5 -2
View File
@@ -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)
}
}
+3 -1
View File
@@ -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
}
+2 -1
View File
@@ -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
}