fix:修复float金额带来的误差
This commit is contained in:
1 parent
4b13477a83
commit
95e5283e28
8 files changed
+81
-26
No files matched your search
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user