fix: 金额 float64 → decimal 全链路迁移,消除浮点累积漂移
将全库资金金额从 float64 迁移到 shopspring/decimal,落定边界架构: 入口 NewFromFloat → 内部多步运算全程 decimal → 出口 InexactFloat64; DB NUMERIC(20,10) 走 driver.Valuer 精确直传,Redis 钱包用整数 micro-USD, CNY→USD 换汇仅在 payment.FulfillOrder recharge 分支。 - money.go 重写为 decimal 原生(运算/收敛/比较/边界/micro 工具) - billing/tenant/admin/payment/open/task 各层金额运算 decimal 化 - entity/do 资金列重生成为 decimal.Decimal(可空列 *decimal.Decimal) - 000013 迁移:非金额 NUMERIC 列 → double precision(5 表 13 列) - Res 出口 .InexactFloat64() 收敛,JSONB 快照缝隙显式转 float64 - CLAUDE.md 新增 decimal 强约束;补充迁移基线/完成总结文档
This commit is contained in:
1 parent
95e5283e28
commit
108078614d
59 files changed
+1217
-748
No files matched your search
@@ -360,6 +360,14 @@ wallets 表包含 `frozen_balance` 字段,用于支付中/退款中的金额
|
||||
### Go 后端
|
||||
|
||||
- **缩进统一使用 tab**:所有 Go 代码的缩进必须使用 tab,与 `go fmt` 自动格式化保持一致,禁止混用空格和 tab 缩进
|
||||
- **金额用 `decimal.Decimal`,不用 `float64`(强约束)**:所有资金列(`NUMERIC(20,10)`)在 Go 层映射为 `github.com/shopspring/decimal` 的 `decimal.Decimal`(gf 全局 `typeMapping: numeric → decimal.Decimal`)。**多步金额运算(加/减/乘/除、循环累加、倍率×折扣链)一律用 decimal**,禁止用 float64 做中间计算——IEEE double 会在链式运算中累积漂移。工具函数在 `internal/logic/billing/money.go`(`AddMoney`/`SubtractMoney`/`MultiplyMoney`/`DivideMoney`/`RoundMoney`/`Max`/`Min`/`IsPositive` 等),比较用 `.GreaterThan(billing.Zero)`/`.IsZero()`/`.Equal()`,不要用 `== 0`。判断非金额小数(评分/利用率/延迟/告警阈值)保持 `float64`——判据见 [`docs/float64-decimal-field-classification.md`](docs/float64-decimal-field-classification.md)(「这个数最终会不会变成钱」)。
|
||||
- **边界转换(只在出入口调用,禁止用于计算链路)**:
|
||||
- **出口 → float64**:API `Res` 响应、内存缓存结构体、遗留 float64 接口,用 `billing.InexactFloat64(d)`(前端 JSON number 契约不变)。
|
||||
- **入口 ← float64**:API `Req` 里的 float64 金额,用 `billing.NewFromFloat(f)` 转进 decimal 再运算。
|
||||
- **DB 落库**:decimal 直传 ORM(`.Insert(do)`/`.Update(map)`)或原生 SQL 参数(`balance + ?`)——shopspring decimal 实现 `driver.Valuer`,写出精确 NUMERIC 字符串,**这条路径不要转 float64**。
|
||||
- **JSONB 陷阱**:decimal 默认 marshal 成**带引号的字符串**,写进「读取方期望 number」的 JSONB blob(如快照)会破坏整个 unmarshal——JSONB 缝隙处必须先 `.InexactFloat64()`。(注意:DB 列 ≠ JSONB,前者走 Valuer 精确直传,后者走 JSON 需转 float64。)
|
||||
- **Redis 钱包**:用整数 micro-USD(`int64` + `HINCRBY`),`billing.ToMicro`/`FromMicro` 转换,杜绝 `HINCRBYFLOAT` 漂移。
|
||||
- **唯一换汇点**:CNY→USD 仅在 `payment.FulfillOrder` recharge 分支调 `billing.ConvertCNYToUSD`(返回 decimal),其余地方不换算(见《系统货币规则》三层币种固定规则)。
|
||||
- GoFrame 标准项目结构,不要偏离。框架使用规范(时间处理、错误处理、ORM 模式、日志等)详见 [`docs/reference/goframe-conventions.md`](docs/reference/goframe-conventions.md),遇到不确定的 GoFrame 用法时善用 `/goframe-v2` skill 查询框架规范
|
||||
- **修复 GoFrame 框架使用 bug 时**,必须在 `docs/reference/goframe-conventions.md` 末尾的「已修复的框架使用错误记录」章节追加记录(问题描述、原因、修复方式),防止同类问题重现
|
||||
- 数据库迁移用 goose,脚本按六位序号递增编号(`migrations/000001_xxx.sql`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/qianfree/team-api
|
||||
|
||||
go 1.25.7
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/Masterminds/semver/v3 v3.5.0
|
||||
@@ -28,8 +28,9 @@ require (
|
||||
github.com/wenlng/go-captcha-assets v1.0.7
|
||||
github.com/wenlng/go-captcha/v2 v2.0.5
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/sync v0.22.0
|
||||
google.golang.org/genproto v0.0.0-20260720211330-0afa2a65878a
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
)
|
||||
|
||||
@@ -96,10 +97,11 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/image v0.25.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -220,8 +220,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
@@ -231,13 +231,13 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -249,8 +249,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -259,8 +259,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -269,6 +269,10 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto v0.0.0-20260720211330-0afa2a65878a h1:MVNwR9RFj7qfpMtNK71pq97FgrLG0lVHZh+VbM2LZeI=
|
||||
google.golang.org/genproto v0.0.0-20260720211330-0afa2a65878a/go.mod h1:0qnvndM9dUhat9AtF1jqYN6WZ+tMxEAFImo3WNvUX7w=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -112,7 +112,9 @@ func HandleSyncImageSubmit(r *ghttp.Request, body []byte, rc *relay_handler.Task
|
||||
"billing_context": map[string]any{
|
||||
"ratios": nil,
|
||||
"model_name": modelName,
|
||||
"pre_deduct": preDeduct,
|
||||
// pre_deduct 快照落 JSONB,读取端为 float64;decimal 默认带引号 MarshalJSON
|
||||
// 会导致读取端反序列化失败,故显式转 float64。
|
||||
"pre_deduct": preDeduct.InexactFloat64(),
|
||||
},
|
||||
})
|
||||
asyncTask := &relay_common.AsyncTask{
|
||||
|
||||
@@ -529,10 +529,11 @@ func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSet
|
||||
return nil, common.NewNotFoundError("钱包")
|
||||
}
|
||||
|
||||
thresholdDecimal := billing.NewFromFloat(req.Threshold)
|
||||
_, err = dao.BilWallets.Ctx(ctx).
|
||||
Where("id", w.ID).
|
||||
Data(do.BilWallets{
|
||||
WarningThreshold: req.Threshold,
|
||||
WarningThreshold: &thresholdDecimal,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -435,16 +435,23 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v
|
||||
}
|
||||
|
||||
for _, item := range req.Items {
|
||||
// API 边界 float64 → DO decimal 转换
|
||||
var perRequestPriceDecimal *decimal.Decimal
|
||||
if item.PerRequestPrice != nil {
|
||||
d := billing.NewFromFloat(*item.PerRequestPrice)
|
||||
perRequestPriceDecimal = &d
|
||||
}
|
||||
|
||||
if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: req.ModelID,
|
||||
BillingMode: item.BillingMode,
|
||||
MinTokens: item.MinTokens,
|
||||
MaxTokens: item.MaxTokens,
|
||||
InputPrice: item.InputPrice,
|
||||
OutputPrice: item.OutputPrice,
|
||||
PerRequestPrice: item.PerRequestPrice,
|
||||
CacheReadPrice: item.CacheReadPrice,
|
||||
CacheCreationPrice: item.CacheCreationPrice,
|
||||
InputPrice: billing.NewFromFloat(item.InputPrice),
|
||||
OutputPrice: billing.NewFromFloat(item.OutputPrice),
|
||||
PerRequestPrice: perRequestPriceDecimal,
|
||||
CacheReadPrice: billing.NewFromFloat(item.CacheReadPrice),
|
||||
CacheCreationPrice: billing.NewFromFloat(item.CacheCreationPrice),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -516,7 +523,7 @@ func fetchLiteLLMSource(ctx context.Context, modelName string) *v1.OfficialPrici
|
||||
cacheCreationPrice := billing.RoundMoney(decimal.NewFromFloat(entry.CacheCreationInputTokenCost).Mul(million))
|
||||
|
||||
billingMode := "token"
|
||||
if entry.Mode == "image_generation" && entry.OutputCostPerImage > 0 && inputPrice == 0 && outputPrice == 0 {
|
||||
if entry.Mode == "image_generation" && entry.OutputCostPerImage > 0 && inputPrice.IsZero() && outputPrice.IsZero() {
|
||||
billingMode = "per_request"
|
||||
}
|
||||
|
||||
@@ -526,10 +533,10 @@ func fetchLiteLLMSource(ctx context.Context, modelName string) *v1.OfficialPrici
|
||||
source.MaxContext = entry.MaxInputTokens
|
||||
source.MaxOutput = entry.MaxOutputTokens
|
||||
source.Pricing = &v1.OfficialPricingItem{
|
||||
InputPrice: inputPrice,
|
||||
OutputPrice: outputPrice,
|
||||
CacheReadPrice: cacheReadPrice,
|
||||
CacheCreationPrice: cacheCreationPrice,
|
||||
InputPrice: billing.InexactFloat64(inputPrice),
|
||||
OutputPrice: billing.InexactFloat64(outputPrice),
|
||||
CacheReadPrice: billing.InexactFloat64(cacheReadPrice),
|
||||
CacheCreationPrice: billing.InexactFloat64(cacheCreationPrice),
|
||||
BillingMode: billingMode,
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,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/relay"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
)
|
||||
@@ -262,16 +264,23 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
|
||||
return err
|
||||
}
|
||||
for _, p := range item.Pricing {
|
||||
// API 边界 float64 → DO decimal 转换
|
||||
var perRequestPriceDecimal *decimal.Decimal
|
||||
if p.PerRequestPrice != nil {
|
||||
d := billing.NewFromFloat(*p.PerRequestPrice)
|
||||
perRequestPriceDecimal = &d
|
||||
}
|
||||
|
||||
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: existing.ID,
|
||||
BillingMode: p.BillingMode,
|
||||
MinTokens: p.MinTokens,
|
||||
MaxTokens: p.MaxTokens,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
CacheCreationPrice: p.CacheCreationPrice,
|
||||
InputPrice: billing.NewFromFloat(p.InputPrice),
|
||||
OutputPrice: billing.NewFromFloat(p.OutputPrice),
|
||||
PerRequestPrice: perRequestPriceDecimal,
|
||||
CacheReadPrice: billing.NewFromFloat(p.CacheReadPrice),
|
||||
CacheCreationPrice: billing.NewFromFloat(p.CacheCreationPrice),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -307,16 +316,23 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
|
||||
}
|
||||
|
||||
for _, p := range item.Pricing {
|
||||
// API 边界 float64 → DO decimal 转换
|
||||
var perRequestPriceDecimal *decimal.Decimal
|
||||
if p.PerRequestPrice != nil {
|
||||
d := billing.NewFromFloat(*p.PerRequestPrice)
|
||||
perRequestPriceDecimal = &d
|
||||
}
|
||||
|
||||
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
|
||||
ModelId: id,
|
||||
BillingMode: p.BillingMode,
|
||||
MinTokens: p.MinTokens,
|
||||
MaxTokens: p.MaxTokens,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
CacheCreationPrice: p.CacheCreationPrice,
|
||||
InputPrice: billing.NewFromFloat(p.InputPrice),
|
||||
OutputPrice: billing.NewFromFloat(p.OutputPrice),
|
||||
PerRequestPrice: perRequestPriceDecimal,
|
||||
CacheReadPrice: billing.NewFromFloat(p.CacheReadPrice),
|
||||
CacheCreationPrice: billing.NewFromFloat(p.CacheCreationPrice),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/qianfree/team-api/api/admin/v1"
|
||||
"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/model/do"
|
||||
"github.com/qianfree/team-api/internal/model/entity"
|
||||
@@ -25,10 +26,10 @@ func (s *sAdmin) ListTenantLevelConfigs(ctx context.Context, _ *v1.TenantLevelCo
|
||||
Id: c.Id,
|
||||
Level: c.Level,
|
||||
Name: c.Name,
|
||||
CumulativeRechargeThreshold: c.CumulativeRechargeThreshold,
|
||||
CumulativeRechargeThreshold: billing.InexactFloat64(c.CumulativeRechargeThreshold),
|
||||
MaxMembers: c.MaxMembers,
|
||||
MaxConcurrency: c.MaxConcurrency,
|
||||
PriceMultiplier: c.PriceMultiplier,
|
||||
PriceMultiplier: billing.InexactFloat64(c.PriceMultiplier),
|
||||
SortOrder: c.SortOrder,
|
||||
CreatedAt: c.CreatedAt,
|
||||
UpdatedAt: c.UpdatedAt,
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/shopspring/decimal"
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
@@ -121,19 +122,46 @@ func (s *sAdmin) BatchAssignModels(ctx context.Context, req *v1.TenantModelBatch
|
||||
tiersJSON = string(b)
|
||||
}
|
||||
|
||||
// API 边界 *float64 → *decimal.Decimal 转换
|
||||
var perRequestPriceDecimal, discountRatioDecimal, customInputPriceDecimal, customOutputPriceDecimal, customCacheReadPriceDecimal, customCacheCreationPriceDecimal *decimal.Decimal
|
||||
if a.PerRequestPrice != nil {
|
||||
d := billing.NewFromFloat(*a.PerRequestPrice)
|
||||
perRequestPriceDecimal = &d
|
||||
}
|
||||
if a.DiscountRatio != nil {
|
||||
d := billing.NewFromFloat(*a.DiscountRatio)
|
||||
discountRatioDecimal = &d
|
||||
}
|
||||
if a.CustomInputPrice != nil {
|
||||
d := billing.NewFromFloat(*a.CustomInputPrice)
|
||||
customInputPriceDecimal = &d
|
||||
}
|
||||
if a.CustomOutputPrice != nil {
|
||||
d := billing.NewFromFloat(*a.CustomOutputPrice)
|
||||
customOutputPriceDecimal = &d
|
||||
}
|
||||
if a.CustomCacheReadPrice != nil {
|
||||
d := billing.NewFromFloat(*a.CustomCacheReadPrice)
|
||||
customCacheReadPriceDecimal = &d
|
||||
}
|
||||
if a.CustomCacheCreationPrice != nil {
|
||||
d := billing.NewFromFloat(*a.CustomCacheCreationPrice)
|
||||
customCacheCreationPriceDecimal = &d
|
||||
}
|
||||
|
||||
insertData := do.MdlTenantModels{
|
||||
TenantId: req.TenantID,
|
||||
ModelId: a.ModelID,
|
||||
Enabled: a.Enabled,
|
||||
BillingMode: a.BillingMode,
|
||||
PerRequestPrice: a.PerRequestPrice,
|
||||
DiscountRatio: a.DiscountRatio,
|
||||
PerRequestPrice: perRequestPriceDecimal,
|
||||
DiscountRatio: discountRatioDecimal,
|
||||
MaxConcurrency: maxConc,
|
||||
ChannelScope: a.ChannelScope,
|
||||
CustomInputPrice: a.CustomInputPrice,
|
||||
CustomOutputPrice: a.CustomOutputPrice,
|
||||
CustomCacheReadPrice: a.CustomCacheReadPrice,
|
||||
CustomCacheCreationPrice: a.CustomCacheCreationPrice,
|
||||
CustomInputPrice: customInputPriceDecimal,
|
||||
CustomOutputPrice: customOutputPriceDecimal,
|
||||
CustomCacheReadPrice: customCacheReadPriceDecimal,
|
||||
CustomCacheCreationPrice: customCacheCreationPriceDecimal,
|
||||
CustomPricingTiers: tiersJSON,
|
||||
Multiplier: 1.0,
|
||||
}
|
||||
@@ -163,10 +191,12 @@ func (s *sAdmin) UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdat
|
||||
data.BillingMode = *req.BillingMode
|
||||
}
|
||||
if req.PerRequestPrice != nil {
|
||||
data.PerRequestPrice = *req.PerRequestPrice
|
||||
d := billing.NewFromFloat(**req.PerRequestPrice)
|
||||
data.PerRequestPrice = &d
|
||||
}
|
||||
if req.DiscountRatio != nil {
|
||||
data.DiscountRatio = *req.DiscountRatio
|
||||
d := billing.NewFromFloat(**req.DiscountRatio)
|
||||
data.DiscountRatio = &d
|
||||
}
|
||||
if req.MaxConcurrency != nil {
|
||||
data.MaxConcurrency = *req.MaxConcurrency
|
||||
@@ -175,16 +205,20 @@ func (s *sAdmin) UpdateTenantModel(ctx context.Context, req *v1.TenantModelUpdat
|
||||
data.ChannelScope = *req.ChannelScope
|
||||
}
|
||||
if req.CustomInputPrice != nil {
|
||||
data.CustomInputPrice = *req.CustomInputPrice
|
||||
d := billing.NewFromFloat(**req.CustomInputPrice)
|
||||
data.CustomInputPrice = &d
|
||||
}
|
||||
if req.CustomCacheReadPrice != nil {
|
||||
data.CustomCacheReadPrice = *req.CustomCacheReadPrice
|
||||
d := billing.NewFromFloat(**req.CustomCacheReadPrice)
|
||||
data.CustomCacheReadPrice = &d
|
||||
}
|
||||
if req.CustomCacheCreationPrice != nil {
|
||||
data.CustomCacheCreationPrice = *req.CustomCacheCreationPrice
|
||||
d := billing.NewFromFloat(**req.CustomCacheCreationPrice)
|
||||
data.CustomCacheCreationPrice = &d
|
||||
}
|
||||
if req.CustomOutputPrice != nil {
|
||||
data.CustomOutputPrice = *req.CustomOutputPrice
|
||||
d := billing.NewFromFloat(**req.CustomOutputPrice)
|
||||
data.CustomOutputPrice = &d
|
||||
}
|
||||
if req.CustomPricingTiers != nil {
|
||||
if len(*req.CustomPricingTiers) > 0 {
|
||||
|
||||
@@ -3,24 +3,25 @@ package billing
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
)
|
||||
|
||||
const defaultCNYToUSD = 0.14
|
||||
const defaultUSDToCNY = 7.25
|
||||
|
||||
// ConvertCNYToUSD 将人民币金额转换为美元,向上取整到小数点后 6 位。
|
||||
// A8:用 decimal 精确乘法替代 float64 链式运算,避免 cnyAmount×rate 的浮点误差。
|
||||
func ConvertCNYToUSD(ctx context.Context, cnyAmount float64) float64 {
|
||||
// ConvertCNYToUSD 将人民币金额转换为美元,向上取整到小数点后 6 位(decimal 原生版)。
|
||||
// 用 decimal 精确乘法替代 float64 链式运算,避免 cnyAmount×rate 的浮点误差。
|
||||
func ConvertCNYToUSD(ctx context.Context, cnyAmount float64) decimal.Decimal {
|
||||
rate := GetExchangeRateCNYToUSD(ctx)
|
||||
return ceilUSD(dec(cnyAmount).Mul(dec(rate)))
|
||||
return CeilUSD(NewFromFloat(cnyAmount).Mul(NewFromFloat(rate)))
|
||||
}
|
||||
|
||||
// ConvertUSDToCNY 将美元金额转换为人民币。
|
||||
// A8:decimal 精确乘法 + 四舍五入到存储精度(10 位),消除 float64 累计误差。
|
||||
func ConvertUSDToCNY(ctx context.Context, usdAmount float64) float64 {
|
||||
// ConvertUSDToCNY 将美元金额转换为人民币(decimal 原生版)。
|
||||
// decimal 精确乘法 + 四舍五入到存储精度(10 位),消除 float64 累计误差。
|
||||
func ConvertUSDToCNY(ctx context.Context, usdAmount decimal.Decimal) decimal.Decimal {
|
||||
rate := GetExchangeRateUSDToCNY(ctx)
|
||||
return roundMoney(dec(usdAmount).Mul(dec(rate)))
|
||||
return RoundMoney(usdAmount.Mul(NewFromFloat(rate)))
|
||||
}
|
||||
|
||||
// GetExchangeRateCNYToUSD 获取 CNY→USD 兑换比例
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
@@ -108,29 +109,29 @@ func GetTenantLevelConfig(ctx context.Context, level int) (*entity.TntTenantLeve
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// GetLevelPriceMultiplier 获取租户级别的价格乘数
|
||||
// GetLevelPriceMultiplier 获取租户级别的价格乘数(decimal 原生版)
|
||||
// 查询失败时静默返回 1.0,不阻断计费
|
||||
func GetLevelPriceMultiplier(ctx context.Context, tenantID int64) float64 {
|
||||
func GetLevelPriceMultiplier(ctx context.Context, tenantID int64) decimal.Decimal {
|
||||
var tenant *entity.TntTenants
|
||||
if err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Scan(&tenant); err != nil || tenant == nil {
|
||||
g.Log().Warningf(ctx, "[Billing] failed to read tenant level for multiplier: tenantID=%d, err=%v", tenantID, err)
|
||||
return 1.0
|
||||
return One
|
||||
}
|
||||
|
||||
if tenant.Level <= 1 {
|
||||
return 1.0
|
||||
return One
|
||||
}
|
||||
|
||||
var config *entity.TntTenantLevelConfigs
|
||||
if err := dao.TntTenantLevelConfigs.Ctx(ctx).Where("level", tenant.Level).Scan(&config); err != nil || config == nil {
|
||||
g.Log().Warningf(ctx, "[Billing] failed to read level config for multiplier: level=%d, err=%v", tenant.Level, err)
|
||||
return 1.0
|
||||
return One
|
||||
}
|
||||
|
||||
if config.PriceMultiplier > 0 && config.PriceMultiplier != 1.0 {
|
||||
if config.PriceMultiplier.GreaterThan(Zero) && !config.PriceMultiplier.Equal(One) {
|
||||
return config.PriceMultiplier
|
||||
}
|
||||
return 1.0
|
||||
return One
|
||||
}
|
||||
|
||||
// CheckAndUpgradeLevel 检查并升级租户等级(充值后调用)
|
||||
@@ -220,8 +221,8 @@ func CheckAndUpgradeLevel(ctx context.Context, tenantID int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecalculateLevel 根据累计充值金额重新计算等级
|
||||
func RecalculateLevel(ctx context.Context, cumulativeRecharge float64) (int, error) {
|
||||
// RecalculateLevel 根据累计充值金额重新计算等级(decimal 原生版)
|
||||
func RecalculateLevel(ctx context.Context, cumulativeRecharge decimal.Decimal) (int, error) {
|
||||
var config *entity.TntTenantLevelConfigs
|
||||
err := dao.TntTenantLevelConfigs.Ctx(ctx).
|
||||
Where("cumulative_recharge_threshold <= ?", cumulativeRecharge).
|
||||
|
||||
+114
-64
@@ -2,83 +2,133 @@ package billing
|
||||
|
||||
import "github.com/shopspring/decimal"
|
||||
|
||||
// 金额精度工具(A8 Phase 1)。
|
||||
// 金额精度工具(decimal 原生版,任务 #6)。
|
||||
//
|
||||
// 背景:系统所有资金列均为 PostgreSQL NUMERIC(20,10),DB 端加减为精确定点运算;
|
||||
// 但 Go 层历史上用 float64 承载金额,链式乘除(如 tokens/1e6 × 单价 × 租户倍率、CNY×汇率)
|
||||
// 会累积二进制浮点误差,违反「金额不用 FLOAT」规范。本文件提供 decimal 精确计算 + 统一
|
||||
// 四舍五入的边界工具:在纯计算处用 decimal 运算,最终在边界收敛到固定精度再落库/返回,
|
||||
// 消除 float64 累计漂移。Phase 1 仅覆盖换汇与成本汇总,不改动生成的 entity 与 Redis Lua。
|
||||
// 背景:系统所有资金列已从 float64 迁移为 decimal.Decimal(entity 层 + 计算层全链路)。
|
||||
// 本文件提供 decimal 原生运算工具、精度常量、边界转换(decimal ↔ float64 / int64 micro)。
|
||||
//
|
||||
// 核心原则:
|
||||
// - entity/计算层全程 decimal,消除浮点累积误差
|
||||
// - Res 响应出口调 .InexactFloat64() 转回 float64(保持前端 JSON number 契约)
|
||||
// - Redis 钱包用整数 micro-USD(int64 HINCRBY),toMicro/fromMicro 负责转换
|
||||
|
||||
const (
|
||||
// moneyScale 金额存储精度:与 DB 列 NUMERIC(20,10) 对齐。
|
||||
moneyScale = 10
|
||||
// usdDisplayScale 钱包/展示精度:小数点后 6 位(见 CLAUDE.md 资金显示精度)。
|
||||
usdDisplayScale = 6
|
||||
// MoneyScale 金额存储精度:与 DB 列 NUMERIC(20,10) 对齐。
|
||||
MoneyScale = 10
|
||||
|
||||
// UsdDisplayScale 钱包/展示精度:小数点后 6 位(见 CLAUDE.md 资金显示精度)。
|
||||
UsdDisplayScale = 6
|
||||
|
||||
// MicroScale 整数微单位换算比例:1 USD = 1_000_000 micro(6 位小数,钱包/展示精度)。
|
||||
// Redis 钱包金额以整数 micro 存储,用整数加减(HINCRBY)替代 HINCRBYFLOAT,
|
||||
// 彻底消除 IEEE double 累计漂移。int64 上限 ~9.2e18,按 micro 计可表示约 $9.2 万亿/租户,余量充足。
|
||||
MicroScale = 1_000_000
|
||||
)
|
||||
|
||||
// dec 把 float64 安全转成 decimal。decimal.NewFromFloat 取能往返的最短十进制表示,
|
||||
// 因此 0.1 → "0.1" 而非 0.1000000000000000055…,不会把浮点噪声带进精确运算。
|
||||
func dec(f float64) decimal.Decimal {
|
||||
// 预定义常量(减少重复 NewFromInt 调用)
|
||||
var (
|
||||
Million = decimal.NewFromInt(MicroScale) // 1_000_000,token 单价换算
|
||||
MoneyEpsilon = decimal.NewFromFloat(0.000001) // 1 微美元容差(浮点比较遗留兼容)
|
||||
Zero = decimal.Zero // 常用零值
|
||||
One = decimal.NewFromInt(1) // 常用 1
|
||||
)
|
||||
|
||||
// RoundMoney 将 decimal 金额四舍五入到 10 位小数(与 NUMERIC(20,10) 对齐)。
|
||||
// 用于把 decimal 精确计算的结果收敛到存储精度,作为落库前的统一出口。
|
||||
func RoundMoney(d decimal.Decimal) decimal.Decimal {
|
||||
return d.Round(MoneyScale)
|
||||
}
|
||||
|
||||
// RoundUSD 将 decimal 金额四舍五入到 6 位小数(钱包 USD 精度)。
|
||||
// 用于展示/API 响应的精度收敛,与 CLAUDE.md 资金显示精度对齐。
|
||||
func RoundUSD(d decimal.Decimal) decimal.Decimal {
|
||||
return d.Round(UsdDisplayScale)
|
||||
}
|
||||
|
||||
// CeilUSD 将 decimal 金额向上取整到 6 位小数(钱包 USD 精度)。
|
||||
// 用于 CNY→USD 充值入账:宁可多给用户零头也不少给,且与展示精度一致。
|
||||
func CeilUSD(d decimal.Decimal) decimal.Decimal {
|
||||
return d.RoundCeil(UsdDisplayScale)
|
||||
}
|
||||
|
||||
// ToMicro 将 USD 金额(decimal)四舍五入换算为整数微单位(int64)。
|
||||
// 用于 Redis 钱包 HINCRBY 操作(整数加减,无浮点漂移)。
|
||||
func ToMicro(usd decimal.Decimal) int64 {
|
||||
return usd.Mul(Million).Round(0).IntPart()
|
||||
}
|
||||
|
||||
// FromMicro 将整数微单位(int64)换算回 USD(decimal)。
|
||||
// 用于从 Redis 钱包读取余额后转回 decimal 参与计算。
|
||||
func FromMicro(micro int64) decimal.Decimal {
|
||||
return decimal.New(micro, -6) // 等价于 micro / 1_000_000,但更高效
|
||||
}
|
||||
|
||||
// InexactFloat64 将 decimal 转为 float64(有损转换,供边界使用)。
|
||||
// 用途:
|
||||
// - Res 响应出口(保持前端 JSON number 契约,任务 #15)
|
||||
// - relay 缓存结构体(内存缓存用 float64,避免 decimal 序列化开销)
|
||||
// - 遗留外部接口(如支付回调需要 float64 参数)
|
||||
// ⚠️ 禁止在计算链路中使用,只允许在输出边界调用。
|
||||
func InexactFloat64(d decimal.Decimal) float64 {
|
||||
f, _ := d.Float64() // 忽略 inexact 标志,调用方已知有损
|
||||
return f
|
||||
}
|
||||
|
||||
// NewFromFloat 从 float64 创建 decimal(输入边界,供 API Req 转换用)。
|
||||
// decimal.NewFromFloat 取能往返的最短十进制表示,0.1 → "0.1" 而非 0.1000000000000000055…,
|
||||
// 不会把浮点噪声带进精确运算。
|
||||
func NewFromFloat(f float64) decimal.Decimal {
|
||||
return decimal.NewFromFloat(f)
|
||||
}
|
||||
|
||||
// RoundMoney 将 decimal 金额四舍五入到 10 位小数(与 NUMERIC(20,10) 对齐)后返回 float64。
|
||||
// 用于把 decimal 精确计算的结果收敛到存储精度,作为 Go 层金额的统一出口。
|
||||
// 导出给 admin/tenant 层调用(A8 Phase 1 扩展)。
|
||||
func RoundMoney(d decimal.Decimal) float64 {
|
||||
f, _ := d.Round(moneyScale).Float64()
|
||||
return f
|
||||
// MultiplyMoney 金额精确乘法(a × b)。
|
||||
// 用于替代裸乘法,避免链式运算累积误差(如 price × months、amount × discount)。
|
||||
// 结果自动 RoundMoney 收敛到 10 位小数。
|
||||
func MultiplyMoney(a, b decimal.Decimal) decimal.Decimal {
|
||||
return RoundMoney(a.Mul(b))
|
||||
}
|
||||
|
||||
// roundMoney 内部别名,保持向后兼容
|
||||
func roundMoney(d decimal.Decimal) float64 {
|
||||
return RoundMoney(d)
|
||||
// DivideMoney 金额精确除法(a ÷ b)。
|
||||
// 用于替代裸除法(如 totalCost / requests)。
|
||||
// 结果自动 RoundMoney 收敛到 10 位小数。
|
||||
func DivideMoney(a, b decimal.Decimal) decimal.Decimal {
|
||||
return RoundMoney(a.Div(b))
|
||||
}
|
||||
|
||||
// ceilUSD 将 decimal 金额向上取整到 6 位小数(钱包 USD 精度)后返回 float64。
|
||||
// 用于 CNY→USD 充值入账:宁可多给用户零头也不少给,且与展示精度一致。
|
||||
func ceilUSD(d decimal.Decimal) float64 {
|
||||
f, _ := d.RoundCeil(usdDisplayScale).Float64()
|
||||
return f
|
||||
// AddMoney 金额精确加法(a + b)。
|
||||
// 用于循环累加避免误差放大(虽然 decimal 加法无误差,但统一收敛规范)。
|
||||
func AddMoney(a, b decimal.Decimal) decimal.Decimal {
|
||||
return RoundMoney(a.Add(b))
|
||||
}
|
||||
|
||||
// microScale 整数微单位换算比例:1 USD = 1_000_000 micro(6 位小数,钱包/展示精度)。
|
||||
// Redis 钱包金额以整数 micro 存储(Phase 3),用整数加减(HINCRBY)替代 HINCRBYFLOAT,
|
||||
// 彻底消除 IEEE double 累计漂移。int64 上限 ~9.2e18,按 micro 计可表示约 $9.2 万亿/租户,余量充足。
|
||||
const microScale = 1_000_000
|
||||
|
||||
// toMicro 将 USD 金额(float64)四舍五入换算为整数微单位(用 decimal 精确取整)。
|
||||
func toMicro(usd float64) int64 {
|
||||
return dec(usd).Mul(decimal.NewFromInt(microScale)).Round(0).IntPart()
|
||||
}
|
||||
|
||||
// fromMicro 将整数微单位换算回 USD(float64)。
|
||||
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 扩展)。
|
||||
// SubtractMoney 金额精确减法(a - b)。
|
||||
// 用于差额计算(如 available = balance - frozen)。
|
||||
func SubtractMoney(a, b float64) float64 {
|
||||
return roundMoney(dec(a).Sub(dec(b)))
|
||||
func SubtractMoney(a, b decimal.Decimal) decimal.Decimal {
|
||||
return RoundMoney(a.Sub(b))
|
||||
}
|
||||
|
||||
// IsPositive 判断金额是否为正数(> 0)。
|
||||
func IsPositive(d decimal.Decimal) bool {
|
||||
return d.GreaterThan(Zero)
|
||||
}
|
||||
|
||||
// IsZeroOrNegative 判断金额是否为零或负数(<= 0)。
|
||||
func IsZeroOrNegative(d decimal.Decimal) bool {
|
||||
return d.LessThanOrEqual(Zero)
|
||||
}
|
||||
|
||||
// Max 返回两个金额中的较大值。
|
||||
func Max(a, b decimal.Decimal) decimal.Decimal {
|
||||
if a.GreaterThan(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Min 返回两个金额中的较小值。
|
||||
func Min(a, b decimal.Decimal) decimal.Decimal {
|
||||
if a.LessThan(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -10,17 +10,18 @@ import (
|
||||
func TestRoundMoney(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want float64
|
||||
want string
|
||||
}{
|
||||
{"0.30000000000000004", 0.3},
|
||||
{"0.12345678905", 0.1234567891}, // 第 11 位进位
|
||||
{"0.12345678904", 0.123456789}, // 第 11 位舍去
|
||||
{"1", 1},
|
||||
{"0.30000000000000004", "0.3"},
|
||||
{"0.12345678905", "0.1234567891"}, // 第 11 位进位
|
||||
{"0.12345678904", "0.123456789"}, // 第 11 位舍去
|
||||
{"1", "1"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
d, _ := decimal.NewFromString(c.in)
|
||||
if got := roundMoney(d); got != c.want {
|
||||
t.Fatalf("roundMoney(%s) = %v, want %v", c.in, got, c.want)
|
||||
want, _ := decimal.NewFromString(c.want)
|
||||
if got := RoundMoney(d); !got.Equal(want) {
|
||||
t.Fatalf("RoundMoney(%s) = %v, want %v", c.in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,29 +49,30 @@ func TestComputeCostDecimalPrecision(t *testing.T) {
|
||||
// TestMicroRoundTrip 验证 USD ↔ 整数微单位换算的正确性与精确取整。
|
||||
func TestMicroRoundTrip(t *testing.T) {
|
||||
cases := []struct {
|
||||
usd float64
|
||||
usd string
|
||||
wantMicro int64
|
||||
}{
|
||||
{0, 0},
|
||||
{1, 1_000_000},
|
||||
{0.1, 100_000},
|
||||
{0.015, 15_000},
|
||||
{0.000001, 1},
|
||||
{0.0000004, 0}, // 四舍五入到 0
|
||||
{0.0000006, 1}, // 四舍五入到 1 micro
|
||||
{12345.678901, 12_345_678_901},
|
||||
{"0", 0},
|
||||
{"1", 1_000_000},
|
||||
{"0.1", 100_000},
|
||||
{"0.015", 15_000},
|
||||
{"0.000001", 1},
|
||||
{"0.0000004", 0}, // 四舍五入到 0
|
||||
{"0.0000006", 1}, // 四舍五入到 1 micro
|
||||
{"12345.678901", 12_345_678_901},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := toMicro(c.usd); got != c.wantMicro {
|
||||
t.Fatalf("toMicro(%v) = %d, want %d", c.usd, got, c.wantMicro)
|
||||
usd, _ := decimal.NewFromString(c.usd)
|
||||
if got := ToMicro(usd); got != c.wantMicro {
|
||||
t.Fatalf("ToMicro(%v) = %d, want %d", c.usd, got, c.wantMicro)
|
||||
}
|
||||
}
|
||||
// fromMicro 反向
|
||||
if got := fromMicro(15_000); got != 0.015 {
|
||||
t.Fatalf("fromMicro(15000) = %v, want 0.015", got)
|
||||
// FromMicro 反向
|
||||
if got := FromMicro(15_000); !got.Equal(decimal.NewFromFloat(0.015)) {
|
||||
t.Fatalf("FromMicro(15000) = %v, want 0.015", got)
|
||||
}
|
||||
if got := fromMicro(1_000_000); got != 1.0 {
|
||||
t.Fatalf("fromMicro(1000000) = %v, want 1.0", got)
|
||||
if got := FromMicro(1_000_000); !got.Equal(decimal.NewFromInt(1)) {
|
||||
t.Fatalf("FromMicro(1000000) = %v, want 1.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -216,9 +216,10 @@ func GetModelPrice(ctx context.Context, tenantID int64, modelName string) (*Pric
|
||||
// 3.5 级别折扣 fallback:当租户×模型维度未设置倍率时,使用租户级别的 price_multiplier
|
||||
if tenantMultiplier == 1.0 {
|
||||
levelMultiplier := GetLevelPriceMultiplier(ctx, tenantID)
|
||||
if levelMultiplier > 0 && levelMultiplier < 1.0 {
|
||||
tenantMultiplier = levelMultiplier
|
||||
discountRatio = levelMultiplier
|
||||
levelMultiplierFloat := InexactFloat64(levelMultiplier)
|
||||
if levelMultiplierFloat > 0 && levelMultiplierFloat < 1.0 {
|
||||
tenantMultiplier = levelMultiplierFloat
|
||||
discountRatio = levelMultiplierFloat
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,22 +292,22 @@ func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *r
|
||||
// A8:token 成本链式计算(÷1e6 × 单价 × 租户倍率 + 各项求和)改用 decimal 精确运算,
|
||||
// 最终四舍五入到 10 位(NUMERIC(20,10))再返回 float64,消除 float64 累计误差。
|
||||
million := decimal.NewFromInt(1_000_000)
|
||||
mul := dec(pricing.TenantMultiplier)
|
||||
mul := NewFromFloat(pricing.TenantMultiplier)
|
||||
|
||||
baseInputCostD := dec(baseInputCost)
|
||||
outputCostD := dec(outputCost)
|
||||
cacheReadCostD := decimal.NewFromInt(int64(cacheReadTokens)).Div(million).Mul(dec(pricing.CacheReadPrice))
|
||||
cacheCreationCostD := decimal.NewFromInt(int64(cacheCreationTokens)).Div(million).Mul(dec(pricing.CacheCreationPrice))
|
||||
baseInputCostD := NewFromFloat(baseInputCost)
|
||||
outputCostD := NewFromFloat(outputCost)
|
||||
cacheReadCostD := decimal.NewFromInt(int64(cacheReadTokens)).Div(million).Mul(NewFromFloat(pricing.CacheReadPrice))
|
||||
cacheCreationCostD := decimal.NewFromInt(int64(cacheCreationTokens)).Div(million).Mul(NewFromFloat(pricing.CacheCreationPrice))
|
||||
|
||||
// 总费用 = (基础输入 + 输出 + cache各项) × 租户倍率
|
||||
subtotalD := baseInputCostD.Add(outputCostD).Add(cacheReadCostD).Add(cacheCreationCostD)
|
||||
totalCostD := subtotalD.Mul(mul)
|
||||
|
||||
return &CostBreakdown{
|
||||
BaseCost: roundMoney(subtotalD),
|
||||
InputCost: roundMoney(baseInputCostD.Mul(mul)),
|
||||
OutputCost: roundMoney(outputCostD.Mul(mul)),
|
||||
TotalCost: roundMoney(totalCostD),
|
||||
BaseCost: InexactFloat64(RoundMoney(subtotalD)),
|
||||
InputCost: InexactFloat64(RoundMoney(baseInputCostD.Mul(mul))),
|
||||
OutputCost: InexactFloat64(RoundMoney(outputCostD.Mul(mul))),
|
||||
TotalCost: InexactFloat64(RoundMoney(totalCostD)),
|
||||
InputTokens: inputTokens,
|
||||
OutputTokens: outputTokens,
|
||||
BillingMode: pricing.BillingMode,
|
||||
@@ -316,8 +317,8 @@ func computeCost(pricing *PricingResult, inputTokens, outputTokens int, usage *r
|
||||
Currency: pricing.Currency,
|
||||
CacheCreationTokens: cacheCreationTokens,
|
||||
CacheReadTokens: cacheReadTokens,
|
||||
CacheCreationCost: roundMoney(cacheCreationCostD.Mul(mul)),
|
||||
CacheReadCost: roundMoney(cacheReadCostD.Mul(mul)),
|
||||
CacheCreationCost: InexactFloat64(RoundMoney(cacheCreationCostD.Mul(mul))),
|
||||
CacheReadCost: InexactFloat64(RoundMoney(cacheReadCostD.Mul(mul))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +521,7 @@ func calculateTieredCostFromTiers(tiers []pricingTierRow, tokens int, isInput bo
|
||||
if tier.MaxTokens == nil {
|
||||
// 最后一档:消耗所有剩余 token
|
||||
totalCostD = totalCostD.Add(
|
||||
decimal.NewFromInt(remaining).Div(million).Mul(dec(price)),
|
||||
decimal.NewFromInt(remaining).Div(million).Mul(NewFromFloat(price)),
|
||||
)
|
||||
remaining = 0
|
||||
} else {
|
||||
@@ -533,11 +534,11 @@ func calculateTieredCostFromTiers(tiers []pricingTierRow, tokens int, isInput bo
|
||||
useTokens = available
|
||||
}
|
||||
totalCostD = totalCostD.Add(
|
||||
decimal.NewFromInt(useTokens).Div(million).Mul(dec(price)),
|
||||
decimal.NewFromInt(useTokens).Div(million).Mul(NewFromFloat(price)),
|
||||
)
|
||||
remaining -= useTokens
|
||||
}
|
||||
}
|
||||
|
||||
return roundMoney(totalCostD)
|
||||
return InexactFloat64(RoundMoney(totalCostD))
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
rcommon "github.com/qianfree/team-api/relay/common"
|
||||
)
|
||||
|
||||
@@ -17,6 +19,14 @@ func assertFloat(t *testing.T, got, want float64, label string) {
|
||||
}
|
||||
}
|
||||
|
||||
// assertDecimal 比较 decimal 结果与期望的 float 值(decimal 化后计算函数的断言辅助)。
|
||||
func assertDecimal(t *testing.T, got decimal.Decimal, want float64, label string) {
|
||||
t.Helper()
|
||||
if math.Abs(got.InexactFloat64()-want) > eps {
|
||||
t.Errorf("%s: got %s, want %.15f", label, got.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AvailableBalance ────────────────────────────────────────────────
|
||||
|
||||
func TestAvailableBalance(t *testing.T) {
|
||||
|
||||
@@ -167,9 +167,9 @@ func CleanSettledPreDeductTracks(ctx context.Context) {
|
||||
func CleanExpiredPreDeducts(ctx context.Context) {
|
||||
// 1. 查询所有超过 PreDeductMaxAge 仍未结算的冻结记录
|
||||
type trackRow struct {
|
||||
RequestID string `json:"request_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
RequestID string `json:"request_id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
Amount decimal.Decimal `json:"amount"`
|
||||
}
|
||||
var tracks []trackRow
|
||||
|
||||
@@ -195,14 +195,14 @@ func CleanExpiredPreDeducts(ctx context.Context) {
|
||||
tenantAmounts := make(map[int64]decimal.Decimal)
|
||||
tenantRequests := make(map[int64][]string)
|
||||
for _, t := range tracks {
|
||||
tenantAmounts[t.TenantID] = tenantAmounts[t.TenantID].Add(dec(t.Amount))
|
||||
tenantAmounts[t.TenantID] = tenantAmounts[t.TenantID].Add(t.Amount)
|
||||
tenantRequests[t.TenantID] = append(tenantRequests[t.TenantID], t.RequestID)
|
||||
}
|
||||
|
||||
// 3. 逐租户释放冻结金额
|
||||
for tenantID, totalAmountD := range tenantAmounts {
|
||||
requestIDs := tenantRequests[tenantID]
|
||||
totalAmount := roundMoney(totalAmountD) // decimal 累加结果收敛到 10 位再落库
|
||||
totalAmount := totalAmountD // decimal 已是精确类型,直接用于 DB 更新
|
||||
|
||||
// 释放 DB frozen_balance
|
||||
_, err := g.DB().Ctx(ctx).Exec(ctx,
|
||||
@@ -226,6 +226,6 @@ func CleanExpiredPreDeducts(ctx context.Context) {
|
||||
|
||||
g.Log().Infof(ctx,
|
||||
"[PRE-DEDUCT] cleaned orphaned: tenant=%d amount=%.6f count=%d",
|
||||
tenantID, totalAmount, len(requestIDs))
|
||||
tenantID, totalAmount.InexactFloat64(), len(requestIDs))
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,10 @@ func NewTaskBillingProvider() common.TaskBillingProvider {
|
||||
|
||||
// EstimateTaskCost 估算任务费用
|
||||
// ratios 包含计费比率(如 video_input 折扣)和预估参数(如 duration 秒数、resolution 乘数)
|
||||
func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (float64, error) {
|
||||
func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (decimal.Decimal, error) {
|
||||
pricing, err := GetModelPrice(ctx, tenantID, modelName)
|
||||
if err != nil {
|
||||
return 0.01, nil
|
||||
return NewFromFloat(0.01), nil
|
||||
}
|
||||
return estimateTaskCost(pricing, ratios), nil
|
||||
}
|
||||
@@ -47,16 +47,16 @@ const imagePlaceholderPreDeduct = 0.1
|
||||
// - 其余无时长信号的任务(如未显式配成 per_request 的图片模型):退回按次单价,
|
||||
// **不再**套用视频 token 估算——图片没有时长/分辨率,套 10000×5×2.25 会凭空估出
|
||||
// 11.25 万 token 的天价预扣($30/1M 输出价即得 $3.375),且与结算的「0 token」自相矛盾。
|
||||
func estimateTaskCost(pricing *PricingResult, ratios map[string]float64) float64 {
|
||||
func estimateTaskCost(pricing *PricingResult, ratios map[string]float64) decimal.Decimal {
|
||||
if pricing == nil {
|
||||
return 0.01
|
||||
return NewFromFloat(0.01)
|
||||
}
|
||||
|
||||
var cost float64
|
||||
var costD decimal.Decimal
|
||||
switch {
|
||||
case pricing.BillingMode == "per_request":
|
||||
// 按次计费:直接用单价
|
||||
cost = pricing.PerRequestPrice
|
||||
costD = NewFromFloat(pricing.PerRequestPrice)
|
||||
case pricing.OutputPrice > 0 && hasDurationSignal(ratios):
|
||||
duration := 5.0 // 默认 5 秒
|
||||
if d, ok := ratios["duration"]; ok && d > 0 {
|
||||
@@ -72,18 +72,17 @@ func estimateTaskCost(pricing *PricingResult, ratios map[string]float64) float64
|
||||
estimatedTokens := 10000.0 * duration * resolutionMul
|
||||
// 修复视频预扣三连乘:用 decimal 避免 ÷1M × 单价 × 倍率 的链式误差
|
||||
million := decimal.NewFromInt(1_000_000)
|
||||
costD := dec(estimatedTokens).Div(million).
|
||||
Mul(dec(pricing.OutputPrice)).
|
||||
Mul(dec(pricing.TenantMultiplier))
|
||||
cost = roundMoney(costD)
|
||||
costD = NewFromFloat(estimatedTokens).Div(million).
|
||||
Mul(NewFromFloat(pricing.OutputPrice)).
|
||||
Mul(NewFromFloat(pricing.TenantMultiplier))
|
||||
default:
|
||||
// 无时长信号(图片等扁平计费任务):优先按次单价;未配按次价时用占位预扣,
|
||||
// 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补
|
||||
// (见 sync_image_worker.settleSyncImageSuccess)。
|
||||
if pricing.PerRequestPrice > 0 {
|
||||
cost = pricing.PerRequestPrice
|
||||
costD = NewFromFloat(pricing.PerRequestPrice)
|
||||
} else {
|
||||
cost = imagePlaceholderPreDeduct
|
||||
costD = NewFromFloat(imagePlaceholderPreDeduct)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +91,14 @@ func estimateTaskCost(pricing *PricingResult, ratios map[string]float64) float64
|
||||
if k == "duration" || k == "resolution" {
|
||||
continue
|
||||
}
|
||||
cost *= ratio
|
||||
costD = costD.Mul(NewFromFloat(ratio))
|
||||
}
|
||||
|
||||
if cost < 0.01 {
|
||||
cost = 0.01
|
||||
minCost := NewFromFloat(0.01)
|
||||
if costD.LessThan(minCost) {
|
||||
costD = minCost
|
||||
}
|
||||
return cost
|
||||
return RoundMoney(costD)
|
||||
}
|
||||
|
||||
// hasDurationSignal 判断计费比率里是否携带时长类任务(视频)的 duration/resolution 信号。
|
||||
@@ -116,13 +116,14 @@ func hasDurationSignal(ratios map[string]float64) bool {
|
||||
}
|
||||
|
||||
// PreDeductTask 预扣任务费用
|
||||
func (b *TaskBillingProviderImpl) PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64, modelName string) (float64, error) {
|
||||
ok, err := PreDeduct(ctx, tenantID, estimatedCost, requestID, modelName)
|
||||
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 0, fmt.Errorf("pre-deduct task failed: insufficient balance")
|
||||
return Zero, fmt.Errorf("pre-deduct task failed: insufficient balance")
|
||||
}
|
||||
return 0, fmt.Errorf("pre-deduct task failed: %w", err)
|
||||
return Zero, fmt.Errorf("pre-deduct task failed: %w", err)
|
||||
}
|
||||
return estimatedCost, nil
|
||||
}
|
||||
@@ -140,15 +141,16 @@ func (b *TaskBillingProviderImpl) ReleaseApiKeyConcurrent(ctx context.Context, a
|
||||
ReleaseApiKeyConcurrent(ctx, apiKeyID)
|
||||
}
|
||||
|
||||
func (b *TaskBillingProviderImpl) CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount float64) error {
|
||||
return CheckApiKeyQuota(ctx, apiKeyID, preDeductAmount)
|
||||
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 折扣)
|
||||
func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount float64, totalTokens, completionTokens int, ratios map[string]float64, taskID string) (*common.SettlementResult, error) {
|
||||
diff := actualCost - preDeductAmount
|
||||
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]float64, taskID string) (*common.SettlementResult, error) {
|
||||
diff := SubtractMoney(actualCost, preDeductAmount)
|
||||
|
||||
// 1. 获取钱包
|
||||
wallet, err := GetWallet(ctx, tenantID)
|
||||
@@ -158,7 +160,7 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
|
||||
// 2. 获取定价(事务外只读)
|
||||
pricing, _ := GetModelPrice(ctx, tenantID, modelName)
|
||||
breakdown := buildTaskCostBreakdown(pricing, actualCost, totalTokens, completionTokens)
|
||||
breakdown := buildTaskCostBreakdown(pricing, InexactFloat64(actualCost), totalTokens, completionTokens)
|
||||
|
||||
var billingMode string
|
||||
var discountRatio, effectiveOutputPrice float64
|
||||
@@ -174,8 +176,8 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
// frozen 追踪会被日对账判为不一致,并被孤儿清理二次释放。
|
||||
_, err = executeSettlementTx(ctx, settlementTxParams{
|
||||
walletID: wallet.ID,
|
||||
preDeductAmount: preDeductAmount,
|
||||
actualCost: actualCost,
|
||||
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{
|
||||
@@ -224,12 +226,12 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
TenantId: tenantID,
|
||||
WalletId: wallet.ID,
|
||||
Type: "consume",
|
||||
Amount: -actualCost,
|
||||
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, preDeductAmount, actualCost),
|
||||
Description: fmt.Sprintf("consume: %s model=%s pre_deduct=%.6f actual=%.6f", requestID, modelName, InexactFloat64(preDeductAmount), InexactFloat64(actualCost)),
|
||||
UserId: userID,
|
||||
RequestId: requestID,
|
||||
ModelName: modelName,
|
||||
@@ -245,10 +247,10 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
// 幂等跳过:该任务此前已结算完成,本次为重复调用(轮询/重放),不再扣款/写账单
|
||||
g.Log().Warningf(ctx, "settle task: duplicate settlement skipped for request=%s (idempotent)", requestID)
|
||||
return &common.SettlementResult{
|
||||
PreDeductAmount: preDeductAmount,
|
||||
ActualCost: actualCost,
|
||||
PreDeductAmount: InexactFloat64(preDeductAmount),
|
||||
ActualCost: InexactFloat64(actualCost),
|
||||
BaseCost: breakdown.BaseCost,
|
||||
TotalCost: actualCost,
|
||||
TotalCost: InexactFloat64(actualCost),
|
||||
OutputCost: breakdown.OutputCost,
|
||||
}, nil
|
||||
}
|
||||
@@ -273,23 +275,24 @@ func (b *TaskBillingProviderImpl) SettleTaskSuccess(ctx context.Context, tenantI
|
||||
|
||||
// 7. 生成计费快照 + 摘要
|
||||
result := &common.SettlementResult{
|
||||
PreDeductAmount: preDeductAmount,
|
||||
ActualCost: actualCost,
|
||||
PreDeductAmount: InexactFloat64(preDeductAmount),
|
||||
ActualCost: InexactFloat64(actualCost),
|
||||
BaseCost: breakdown.BaseCost,
|
||||
TotalCost: actualCost,
|
||||
TotalCost: InexactFloat64(actualCost),
|
||||
OutputCost: breakdown.OutputCost,
|
||||
}
|
||||
|
||||
if diff > 0.001 {
|
||||
result.SupplementAmount = diff
|
||||
} else if diff < -0.001 {
|
||||
result.RefundAmount = -diff
|
||||
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: preDeductAmount,
|
||||
ActualCost: actualCost,
|
||||
PreDeductAmount: InexactFloat64(preDeductAmount),
|
||||
ActualCost: InexactFloat64(actualCost),
|
||||
BaseCost: breakdown.BaseCost,
|
||||
RefundAmount: result.RefundAmount,
|
||||
SupplementAmount: result.SupplementAmount,
|
||||
@@ -355,24 +358,29 @@ func buildTaskCostBreakdown(pricing *PricingResult, actualCost float64, totalTok
|
||||
}
|
||||
|
||||
// SettleTaskFailed 任务失败退还预扣
|
||||
func (b *TaskBillingProviderImpl) SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error {
|
||||
return SettleFailed(ctx, tenantID, requestID, preDeductAmount)
|
||||
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 float64) {
|
||||
IncrApiKeyQuotaUsed(ctx, apiKeyID, amount)
|
||||
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 float64) (float64, error) {
|
||||
diff := newEstimatedCost - preDeductAmount
|
||||
if diff < 0.001 && diff > -0.001 {
|
||||
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 > 0 {
|
||||
if diff.GreaterThan(Zero) {
|
||||
// 需要补扣
|
||||
ok, err := PreDeduct(ctx, tenantID, diff, requestID+"_adjust", "")
|
||||
diffFloat := InexactFloat64(diff)
|
||||
ok, err := PreDeduct(ctx, tenantID, diffFloat, requestID+"_adjust", "")
|
||||
if !ok {
|
||||
return preDeductAmount, fmt.Errorf("adjust task billing: %w", err)
|
||||
}
|
||||
@@ -380,7 +388,7 @@ func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantI
|
||||
}
|
||||
|
||||
// 需要退还部分
|
||||
if err := SettleFailed(ctx, tenantID, requestID+"_adjust", -diff); err != 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
|
||||
@@ -389,26 +397,26 @@ func (b *TaskBillingProviderImpl) AdjustTaskBilling(ctx context.Context, tenantI
|
||||
// RecalculateByTokens 根据上游返回的 total_tokens 重算费用。
|
||||
// 公式:totalTokens / 1M × output_price × tenant_multiplier × 附加比率
|
||||
// 如果模型没有配置 token 单价(纯按次计费),返回 0 表示不做 token 重算。
|
||||
func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64) (float64, error) {
|
||||
func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64) (decimal.Decimal, error) {
|
||||
if totalTokens <= 0 {
|
||||
return 0, nil
|
||||
return Zero, nil
|
||||
}
|
||||
|
||||
pricing, err := GetModelPrice(ctx, tenantID, modelName)
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
return Zero, nil
|
||||
}
|
||||
|
||||
// 需要 output_price > 0 才能做 token 重算
|
||||
if pricing.OutputPrice <= 0 {
|
||||
return 0, nil
|
||||
return Zero, nil
|
||||
}
|
||||
|
||||
// 修复 token 重算循环累乘:用 decimal 避免 ÷1M × 单价 × 倍率 × 循环比率 的链式误差
|
||||
million := decimal.NewFromInt(1_000_000)
|
||||
costD := decimal.NewFromInt(int64(totalTokens)).Div(million).
|
||||
Mul(dec(pricing.OutputPrice)).
|
||||
Mul(dec(pricing.TenantMultiplier))
|
||||
Mul(NewFromFloat(pricing.OutputPrice)).
|
||||
Mul(NewFromFloat(pricing.TenantMultiplier))
|
||||
|
||||
// 应用附加比率(视频输入折扣等)
|
||||
// 注意:跳过 duration/resolution,它们已体现在上游返回的 token 数中,不应再乘
|
||||
@@ -416,15 +424,16 @@ func (b *TaskBillingProviderImpl) RecalculateByTokens(ctx context.Context, tenan
|
||||
if k == "duration" || k == "resolution" {
|
||||
continue
|
||||
}
|
||||
costD = costD.Mul(dec(ratio))
|
||||
costD = costD.Mul(NewFromFloat(ratio))
|
||||
}
|
||||
|
||||
cost := roundMoney(costD)
|
||||
costD = RoundMoney(costD)
|
||||
|
||||
// 最低消费
|
||||
if cost < 0.01 {
|
||||
cost = 0.01
|
||||
minCost := NewFromFloat(0.01)
|
||||
if costD.LessThan(minCost) {
|
||||
costD = minCost
|
||||
}
|
||||
|
||||
return cost, nil
|
||||
return costD, nil
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func TestEstimateTaskCost_PerRequest(t *testing.T) {
|
||||
OutputPrice: 30.0, // 即使配了 output_price 也不应走 token 估算
|
||||
TenantMultiplier: 1.0,
|
||||
}
|
||||
assertFloat(t, estimateTaskCost(pricing, nil), 0.04, "per_request cost")
|
||||
assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "per_request cost")
|
||||
}
|
||||
|
||||
// TestEstimateTaskCost_VideoWithDurationSignal 视频任务(ratios 携带 duration/resolution)
|
||||
@@ -138,7 +138,7 @@ func TestEstimateTaskCost_VideoWithDurationSignal(t *testing.T) {
|
||||
TenantMultiplier: 1.0,
|
||||
}
|
||||
ratios := map[string]float64{"duration": 5, "resolution": 2.25}
|
||||
assertFloat(t, estimateTaskCost(pricing, ratios), 3.375, "video cost")
|
||||
assertDecimal(t, estimateTaskCost(pricing, ratios), 3.375, "video cost")
|
||||
}
|
||||
|
||||
// TestEstimateTaskCost_ImageNoDurationSignal 图片任务(token 模式、有 output_price、但 ratios 无
|
||||
@@ -151,7 +151,7 @@ func TestEstimateTaskCost_ImageNoDurationSignal(t *testing.T) {
|
||||
PerRequestPrice: 0, // 未配按次价
|
||||
TenantMultiplier: 1.0,
|
||||
}
|
||||
assertFloat(t, estimateTaskCost(pricing, nil), 0.1, "image placeholder pre-deduct")
|
||||
assertDecimal(t, estimateTaskCost(pricing, nil), 0.1, "image placeholder pre-deduct")
|
||||
}
|
||||
|
||||
// TestEstimateTaskCost_PerRequestBelowPlaceholder 已配置的真实按次单价(低于占位值)必须被尊重,
|
||||
@@ -162,7 +162,7 @@ func TestEstimateTaskCost_PerRequestBelowPlaceholder(t *testing.T) {
|
||||
PerRequestPrice: 0.04, // DALL·E 类真实单价,低于 0.1 占位
|
||||
TenantMultiplier: 1.0,
|
||||
}
|
||||
assertFloat(t, estimateTaskCost(pricing, nil), 0.04, "configured per_request price respected")
|
||||
assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "configured per_request price respected")
|
||||
}
|
||||
|
||||
// TestEstimateTaskCost_ImageUsesPerRequestPrice 图片模型即便 billing_mode 仍是 token,只要配了
|
||||
@@ -174,7 +174,7 @@ func TestEstimateTaskCost_ImageUsesPerRequestPrice(t *testing.T) {
|
||||
PerRequestPrice: 0.05,
|
||||
TenantMultiplier: 1.0,
|
||||
}
|
||||
assertFloat(t, estimateTaskCost(pricing, nil), 0.05, "image per_request cost")
|
||||
assertDecimal(t, estimateTaskCost(pricing, nil), 0.05, "image per_request cost")
|
||||
}
|
||||
|
||||
// TestEstimateTaskCost_VideoInputDiscount 附加比率(video_input 折扣)在时长估算之上叠加。
|
||||
@@ -186,12 +186,12 @@ func TestEstimateTaskCost_VideoInputDiscount(t *testing.T) {
|
||||
}
|
||||
ratios := map[string]float64{"duration": 5, "resolution": 2.25, "video_input": 0.5}
|
||||
// 3.375 × 0.5 = 1.6875
|
||||
assertFloat(t, estimateTaskCost(pricing, ratios), 1.6875, "video discounted cost")
|
||||
assertDecimal(t, estimateTaskCost(pricing, ratios), 1.6875, "video discounted cost")
|
||||
}
|
||||
|
||||
// TestEstimateTaskCost_NilPricing 定价缺失时按最低消费兜底。
|
||||
func TestEstimateTaskCost_NilPricing(t *testing.T) {
|
||||
assertFloat(t, estimateTaskCost(nil, nil), 0.01, "nil pricing floor")
|
||||
assertDecimal(t, estimateTaskCost(nil, nil), 0.01, "nil pricing floor")
|
||||
}
|
||||
|
||||
// TestHasDurationSignal 区分视频(有 duration/resolution)与图片(nil)。
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/shopspring/decimal"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
@@ -171,7 +172,7 @@ return 1
|
||||
walletRedisKey := walletHashKey(tenantID)
|
||||
predeductRedisKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, requestID)
|
||||
|
||||
amountMicro := toMicro(amount)
|
||||
amountMicro := ToMicro(NewFromFloat(amount))
|
||||
result, err := g.Redis().Do(ctx, "EVAL", luaScript, 2,
|
||||
walletRedisKey, predeductRedisKey,
|
||||
amountMicro, requestID, PreDeductMaxAge, tenantID, modelName, time.Now().Unix())
|
||||
@@ -270,7 +271,7 @@ func UnfreezePreDeduct(ctx context.Context, tenantID int64, requestID string, am
|
||||
// 解冻(带 0 下限保护,整数运算)。HINCRBY 亦无下限:若因重复/多余调用扣减超过已冻结额,
|
||||
// 会把 frozen_balance 打成负数。改用 Lua 读-clamp-写,与 DB 侧
|
||||
// GREATEST(frozen_balance - ?, 0) 保持一致;钱包 hash 不存在时不凭空创建(交由 DB 兜底)。
|
||||
g.Redis().Do(ctx, "EVAL", unfreezeClampLua, 1, walletRedisKey, toMicro(amount))
|
||||
g.Redis().Do(ctx, "EVAL", unfreezeClampLua, 1, walletRedisKey, ToMicro(NewFromFloat(amount)))
|
||||
g.Redis().Do(ctx, "SREM", activeSetKey, requestID)
|
||||
go unfreezeSyncDB(tenantID, amount)
|
||||
return
|
||||
@@ -304,7 +305,7 @@ func GetPreDeductAmount(ctx context.Context, requestID string) (float64, bool) {
|
||||
result, err := g.Redis().Do(ctx, "HGET", predeductRedisKey, "amount")
|
||||
if err == nil && !result.IsNil() {
|
||||
// v2:amount 以整数 micro 存储,换算回 USD
|
||||
return fromMicro(result.Int64()), true
|
||||
return InexactFloat64(FromMicro(result.Int64())), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -326,8 +327,8 @@ func doSyncWalletToRedis(ctx context.Context, tenantID int64) error {
|
||||
|
||||
// 从 DB 读取钱包数据(跳过内存缓存,直接查库确保最新)
|
||||
type walletRow struct {
|
||||
Balance float64 `json:"balance"`
|
||||
FrozenBalance float64 `json:"frozen_balance"`
|
||||
Balance decimal.Decimal `json:"balance"`
|
||||
FrozenBalance decimal.Decimal `json:"frozen_balance"`
|
||||
}
|
||||
var w *walletRow
|
||||
err := dao.BilWallets.Ctx(ctx).
|
||||
@@ -347,8 +348,8 @@ func doSyncWalletToRedis(ctx context.Context, tenantID int64) error {
|
||||
if exists.Int64() == 0 {
|
||||
// key 不存在:完整初始化(balance + frozen_balance,均为整数 micro)
|
||||
_, err = g.Redis().Do(ctx, "HMSET", walletRedisKey,
|
||||
"balance", toMicro(w.Balance),
|
||||
"frozen_balance", toMicro(w.FrozenBalance),
|
||||
"balance", ToMicro(w.Balance),
|
||||
"frozen_balance", ToMicro(w.FrozenBalance),
|
||||
)
|
||||
if err != nil {
|
||||
return gerror.Wrapf(err, "sync wallet to redis")
|
||||
@@ -359,7 +360,7 @@ func doSyncWalletToRedis(ctx context.Context, tenantID int64) error {
|
||||
} else {
|
||||
// key 已存在:只更新 balance(frozen_balance 由 Lua 脚本管理,不覆盖)
|
||||
_, err = g.Redis().Do(ctx, "HSET", walletRedisKey,
|
||||
"balance", toMicro(w.Balance),
|
||||
"balance", ToMicro(w.Balance),
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -442,7 +443,7 @@ func GetFrozenItems(ctx context.Context, tenantID int64) ([]FrozenItem, error) {
|
||||
var amount float64
|
||||
if v, ok := m["amount"]; ok {
|
||||
// v2:amount 以整数 micro 存储,换算回 USD
|
||||
amount = fromMicro(gconv.Int64(v))
|
||||
amount = InexactFloat64(FromMicro(gconv.Int64(v)))
|
||||
}
|
||||
|
||||
var modelName string
|
||||
@@ -492,10 +493,10 @@ func trackPreDeduct(ctx context.Context, tenantID int64, amount float64, request
|
||||
// rebuildPredeductFromDB 从 DB 恢复活跃预扣明细到 Redis(Redis 重启后调用)
|
||||
func rebuildPredeductFromDB(ctx context.Context, tenantID int64) {
|
||||
type trackRow struct {
|
||||
RequestID string `json:"request_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
ModelName string `json:"model_name"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
RequestID string `json:"request_id"`
|
||||
Amount decimal.Decimal `json:"amount"`
|
||||
ModelName string `json:"model_name"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
cutoff := time.Now().Add(-time.Duration(PreDeductMaxAge) * time.Second)
|
||||
@@ -520,7 +521,7 @@ func rebuildPredeductFromDB(ctx context.Context, tenantID int64) {
|
||||
|
||||
predeductKey := fmt.Sprintf("%s%s", PreDeductRedisKeyPrefix, t.RequestID)
|
||||
g.Redis().Do(ctx, "HMSET", predeductKey,
|
||||
"amount", toMicro(t.Amount),
|
||||
"amount", ToMicro(t.Amount),
|
||||
"tenant_id", tenantID,
|
||||
"model_name", t.ModelName,
|
||||
"created_at", t.CreatedAt,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
v1 "github.com/qianfree/team-api/api/open/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"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
@@ -459,6 +460,7 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v
|
||||
if quotaLimit < 0 {
|
||||
quotaLimit = 0
|
||||
}
|
||||
quotaLimitDecimal := billing.NewFromFloat(quotaLimit)
|
||||
|
||||
result, err := dao.ApiKeys.Ctx(ctx).Data(do.ApiKeys{
|
||||
TenantId: tenantID,
|
||||
@@ -467,7 +469,7 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v
|
||||
KeyPrefix: prefix,
|
||||
Scope: "full",
|
||||
Status: "active",
|
||||
TotalQuota: quotaLimit,
|
||||
TotalQuota: "aLimitDecimal,
|
||||
KeyType: "project",
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
@@ -780,7 +782,8 @@ func (s *sOpen) OpenProjectCreate(ctx context.Context, req *v1.OpenProjectCreate
|
||||
}
|
||||
|
||||
if req.Budget > 0 {
|
||||
insertData.Budget = req.Budget
|
||||
budgetDecimal := billing.NewFromFloat(req.Budget)
|
||||
insertData.Budget = &budgetDecimal
|
||||
}
|
||||
|
||||
result, err := dao.TntProjects.Ctx(ctx).Data(insertData).Insert()
|
||||
@@ -903,7 +906,8 @@ func (s *sOpen) OpenProjectUpdate(ctx context.Context, req *v1.OpenProjectUpdate
|
||||
}
|
||||
if req.Budget != nil {
|
||||
if *req.Budget > 0 {
|
||||
data.Budget = *req.Budget
|
||||
budgetDecimal := billing.NewFromFloat(*req.Budget)
|
||||
data.Budget = &budgetDecimal
|
||||
}
|
||||
hasUpdate = true
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ 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/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
)
|
||||
@@ -57,7 +59,7 @@ func FulfillOrder(ctx context.Context, orderID int64) error {
|
||||
|
||||
case "recharge":
|
||||
usdAmount := billing.ConvertCNYToUSD(ctx, order.FinalAmount)
|
||||
if err = creditWalletTx(ctx, order.TenantID, usdAmount, fmt.Sprintf("Recharge: order #%d (CNY %.2f → USD %.6f)", orderID, order.FinalAmount, usdAmount)); err != nil {
|
||||
if err = creditWalletTx(ctx, order.TenantID, usdAmount, fmt.Sprintf("Recharge: order #%d (CNY %.2f → USD %.6f)", orderID, order.FinalAmount, usdAmount.InexactFloat64())); err != nil {
|
||||
return gerror.Wrapf(err, "credit wallet failed")
|
||||
}
|
||||
_ = billing.CheckAndUpgradeLevel(ctx, order.TenantID)
|
||||
@@ -149,7 +151,9 @@ func SubscribePlan(ctx context.Context, tenantID int64, planID int64, months int
|
||||
}
|
||||
|
||||
// creditWalletTx 在事务内钱包入账(依赖调用方传入携带事务的 ctx)
|
||||
func creditWalletTx(ctx context.Context, tenantID int64, amount float64, description string) error {
|
||||
// amount 为 USD(bil_ 层永远 USD);用 decimal 直传原生 SQL(shopspring decimal 实现
|
||||
// driver.Valuer → 精确 NUMERIC 字符串),避免 float64 入账在 balance+? 累加时产生漂移。
|
||||
func creditWalletTx(ctx context.Context, tenantID int64, amount decimal.Decimal, description string) error {
|
||||
var w *struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
@@ -172,8 +176,8 @@ func creditWalletTx(ctx context.Context, tenantID int64, amount float64, descrip
|
||||
}
|
||||
|
||||
var balance *struct {
|
||||
Balance float64 `json:"balance"`
|
||||
FrozenBalance float64 `json:"frozen_balance"`
|
||||
Balance decimal.Decimal `json:"balance"`
|
||||
FrozenBalance decimal.Decimal `json:"frozen_balance"`
|
||||
}
|
||||
err = dao.BilWallets.Ctx(ctx).
|
||||
Where("id", w.ID).
|
||||
|
||||
@@ -158,7 +158,7 @@ func handleTimedOutTasks(ctx context.Context) {
|
||||
monitor.UnregisterRequestByTaskID(t.PublicTaskID)
|
||||
|
||||
// 退还预扣费用
|
||||
if t.PreDeductAmount > 0 {
|
||||
if t.PreDeductAmount.GreaterThan(billing.Zero) {
|
||||
taskBilling := billing.NewTaskBillingProvider()
|
||||
if err := taskBilling.SettleTaskFailed(ctx, t.TenantID, t.RequestID, t.PreDeductAmount); err != nil {
|
||||
g.Log().Warningf(ctx, "poll: refund timed-out task %s: %v", t.PublicTaskID, err)
|
||||
@@ -213,7 +213,7 @@ func handleUnsettledTasks(ctx context.Context) {
|
||||
if t.Status == "SUCCESS" {
|
||||
// 成功任务:用 ActualCost(已在上次轮询中计算)结算
|
||||
actualCost := t.ActualCost
|
||||
if actualCost <= 0 {
|
||||
if actualCost.LessThanOrEqual(billing.Zero) {
|
||||
actualCost = t.PreDeductAmount
|
||||
}
|
||||
taskBilling := billing.NewTaskBillingProvider()
|
||||
@@ -256,7 +256,7 @@ func handleUnsettledSyncImage(ctx context.Context, t *common.AsyncTask) {
|
||||
var pd privateData
|
||||
_ = json.Unmarshal(t.PrivateData, &pd)
|
||||
actualCost := t.ActualCost
|
||||
if actualCost <= 0 {
|
||||
if actualCost.LessThanOrEqual(billing.Zero) {
|
||||
actualCost = t.PreDeductAmount
|
||||
}
|
||||
if _, err := taskBilling.SettleTaskSuccess(ctx, t.TenantID, t.UserID, t.ApiKeyID, t.ChannelID,
|
||||
@@ -406,16 +406,16 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
|
||||
|
||||
// 结算计费
|
||||
var settleResult *common.SettlementResult
|
||||
if task.PreDeductAmount > 0 && !task.BillingSettled {
|
||||
if task.PreDeductAmount.GreaterThan(billing.Zero) && !task.BillingSettled {
|
||||
taskBilling := billing.NewTaskBillingProvider()
|
||||
actualCost := task.PreDeductAmount
|
||||
|
||||
// 优先用上游返回的 ActualCost
|
||||
if taskInfo.ActualCost > 0 {
|
||||
actualCost = taskInfo.ActualCost
|
||||
actualCost = billing.NewFromFloat(taskInfo.ActualCost)
|
||||
} else if taskInfo.TotalTokens > 0 && pd.BillingContext.Ratios != nil {
|
||||
// 用上游 total_tokens + 保存的 ratios 重算
|
||||
if tokenCost, err := taskBilling.RecalculateByTokens(ctx, task.TenantID, task.ModelName, taskInfo.TotalTokens, pd.BillingContext.Ratios); err == nil && tokenCost > 0 {
|
||||
if tokenCost, err := taskBilling.RecalculateByTokens(ctx, task.TenantID, task.ModelName, taskInfo.TotalTokens, pd.BillingContext.Ratios); err == nil && tokenCost.GreaterThan(billing.Zero) {
|
||||
actualCost = tokenCost
|
||||
}
|
||||
}
|
||||
@@ -441,7 +441,7 @@ func pollSingleTask(ctx context.Context, adaptor common.TaskAdaptor, channel *co
|
||||
|
||||
} else if taskInfo.Status == common.TaskStatusFailure {
|
||||
// 退还预扣费用
|
||||
if task.PreDeductAmount > 0 && !task.BillingSettled {
|
||||
if task.PreDeductAmount.GreaterThan(billing.Zero) && !task.BillingSettled {
|
||||
taskBilling := billing.NewTaskBillingProvider()
|
||||
if err := taskBilling.SettleTaskFailed(ctx, task.TenantID, task.RequestID, task.PreDeductAmount); err != nil {
|
||||
g.Log().Warningf(ctx, "poll: refund failed task %s: %v", task.PublicTaskID, err)
|
||||
@@ -500,9 +500,9 @@ func recordTaskUsage(task *common.AsyncTask, channel *common.ChannelBasicInfo, s
|
||||
PromptTokens: task.PromptTokens,
|
||||
CompletionTokens: task.CompletionTokens,
|
||||
TotalTokens: task.TotalTokens,
|
||||
TotalCost: task.ActualCost,
|
||||
ActualCost: task.ActualCost,
|
||||
PreDeductAmount: task.PreDeductAmount,
|
||||
TotalCost: billing.InexactFloat64(task.ActualCost),
|
||||
ActualCost: billing.InexactFloat64(task.ActualCost),
|
||||
PreDeductAmount: billing.InexactFloat64(task.PreDeductAmount),
|
||||
BillingSource: "task",
|
||||
TaskID: task.PublicTaskID,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/relay"
|
||||
@@ -145,8 +146,8 @@ func (p *AsyncProvider) GetTaskByPublicID(ctx context.Context, publicTaskID stri
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount"`
|
||||
ActualCost decimal.Decimal `json:"actual_cost"`
|
||||
BillingSettled bool `json:"billing_settled"`
|
||||
ResultURL string `json:"result_url"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
@@ -212,8 +213,8 @@ func (p *AsyncProvider) GetTaskByPublicIDAndUser(ctx context.Context, publicTask
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount"`
|
||||
ActualCost decimal.Decimal `json:"actual_cost"`
|
||||
BillingSettled bool `json:"billing_settled"`
|
||||
ResultURL string `json:"result_url"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
@@ -280,8 +281,8 @@ func (p *AsyncProvider) GetNonTerminalTasks(ctx context.Context, limit int) ([]*
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount"`
|
||||
ActualCost decimal.Decimal `json:"actual_cost"`
|
||||
BillingSettled bool `json:"billing_settled"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
PrivateData json.RawMessage `json:"private_data"`
|
||||
@@ -339,7 +340,7 @@ func (p *AsyncProvider) GetTimedOutTasks(ctx context.Context, cutoffUnix int64,
|
||||
ApiKeyID int64 `json:"api_key_id"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount"`
|
||||
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount"`
|
||||
PrivateData json.RawMessage `json:"private_data"`
|
||||
SubmitTime *time.Time `json:"submit_time"`
|
||||
}
|
||||
@@ -387,8 +388,8 @@ func (p *AsyncProvider) GetUnsettledTasks(ctx context.Context, limit int) ([]*co
|
||||
ApiKeyID int64 `json:"api_key_id"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount"`
|
||||
ActualCost decimal.Decimal `json:"actual_cost"`
|
||||
BillingSettled bool `json:"billing_settled"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
PrivateData json.RawMessage `json:"private_data"`
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"github.com/shopspring/decimal"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -83,7 +85,7 @@ func processAutoRenew(ctx context.Context, subscriptionID, tenantID, planID int6
|
||||
|
||||
// 查套餐价格并更新订单金额
|
||||
var plan *struct {
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
MonthlyPrice decimal.Decimal `json:"monthly_price"`
|
||||
}
|
||||
dao.PlnPlans.Ctx(ctx).
|
||||
Where("id", planID).
|
||||
@@ -94,11 +96,12 @@ func processAutoRenew(ctx context.Context, subscriptionID, tenantID, planID int6
|
||||
return
|
||||
}
|
||||
|
||||
amount := billing.InexactFloat64(plan.MonthlyPrice)
|
||||
dao.OrdOrders.Ctx(ctx).
|
||||
Where("id", orderID).
|
||||
Data(do.OrdOrders{
|
||||
Amount: plan.MonthlyPrice,
|
||||
FinalAmount: plan.MonthlyPrice,
|
||||
Amount: amount,
|
||||
FinalAmount: amount,
|
||||
}).Update()
|
||||
|
||||
// 标记为已支付(自动续费默认成功)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/shopspring/decimal"
|
||||
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
@@ -109,7 +110,7 @@ type SyncImageJob struct {
|
||||
ProjectID int64
|
||||
Model string
|
||||
RequestBody []byte
|
||||
PreDeductAmount float64
|
||||
PreDeductAmount decimal.Decimal
|
||||
Ratios map[string]float64
|
||||
SubmitTime time.Time
|
||||
}
|
||||
@@ -246,7 +247,7 @@ func timeoutRunningSyncImageJob(ctx context.Context, job *SyncImageJob) bool {
|
||||
}
|
||||
|
||||
settled := true
|
||||
if job.PreDeductAmount > 0 {
|
||||
if job.PreDeductAmount.GreaterThan(billing.Zero) {
|
||||
if err := syncImageBilling.SettleTaskFailed(ctx, job.TenantID, job.RequestID, job.PreDeductAmount); err != nil {
|
||||
g.Log().Warningf(ctx, "sync_image: timeout task %s refund failed (unsettled net will retry): %v", job.PublicTaskID, err)
|
||||
settled = false
|
||||
@@ -266,7 +267,7 @@ func timeoutRunningSyncImageJob(ctx context.Context, job *SyncImageJob) bool {
|
||||
DecrActiveTask()
|
||||
monitor.UnregisterRequestByTaskID(job.PublicTaskID)
|
||||
|
||||
usageTask := buildUsageTask(job, nil, "FAILURE", 0, now)
|
||||
usageTask := buildUsageTask(job, nil, "FAILURE", billing.Zero, now)
|
||||
recordTaskUsage(usageTask, nil, false, reason, nil)
|
||||
// 闭环审计:把提交阶段的 SUBMITTED 审计记录更新为 TIMEOUT(与超时兜底网一致)。
|
||||
recordTaskCompletionAudit(usageTask, "TIMEOUT", reason, nil)
|
||||
@@ -358,7 +359,7 @@ func failQueuedSyncImageJob(ctx context.Context, job *SyncImageJob, reason strin
|
||||
}
|
||||
|
||||
settled := true
|
||||
if job.PreDeductAmount > 0 {
|
||||
if job.PreDeductAmount.GreaterThan(billing.Zero) {
|
||||
if err := syncImageBilling.SettleTaskFailed(ctx, job.TenantID, job.RequestID, job.PreDeductAmount); err != nil {
|
||||
g.Log().Warningf(ctx, "sync_image: shutdown-fail task %s refund failed (unsettled net will retry): %v", job.PublicTaskID, err)
|
||||
settled = false
|
||||
@@ -378,7 +379,7 @@ func failQueuedSyncImageJob(ctx context.Context, job *SyncImageJob, reason strin
|
||||
DecrActiveTask()
|
||||
monitor.UnregisterRequestByTaskID(job.PublicTaskID)
|
||||
|
||||
usageTask := buildUsageTask(job, nil, "FAILURE", 0, now)
|
||||
usageTask := buildUsageTask(job, nil, "FAILURE", billing.Zero, now)
|
||||
recordTaskUsage(usageTask, nil, false, reason, nil)
|
||||
// 闭环审计:把提交阶段的 SUBMITTED 审计记录更新为 FAILURE。
|
||||
recordTaskCompletionAudit(usageTask, "FAILURE", reason, nil)
|
||||
@@ -539,7 +540,7 @@ func settleSyncImageSuccess(ctx context.Context, job *SyncImageJob, sel *common.
|
||||
actualCost := job.PreDeductAmount
|
||||
// 上游返回了 token 用量 → 按真实 token 重算;仅 token 计费模型返回 >0,按次模型返回 0 保持预扣。
|
||||
if totalTokens > 0 {
|
||||
if tokenCost, rerr := syncImageBilling.RecalculateByTokens(ctx, job.TenantID, job.Model, totalTokens, job.Ratios); rerr == nil && tokenCost > 0 {
|
||||
if tokenCost, rerr := syncImageBilling.RecalculateByTokens(ctx, job.TenantID, job.Model, totalTokens, job.Ratios); rerr == nil && tokenCost.GreaterThan(billing.Zero) {
|
||||
actualCost = tokenCost
|
||||
}
|
||||
}
|
||||
@@ -612,7 +613,7 @@ func failSyncImageJob(ctx context.Context, job *SyncImageJob, sel *common.Channe
|
||||
}
|
||||
|
||||
settled := true
|
||||
if job.PreDeductAmount > 0 {
|
||||
if job.PreDeductAmount.GreaterThan(billing.Zero) {
|
||||
if err := syncImageBilling.SettleTaskFailed(ctx, job.TenantID, job.RequestID, job.PreDeductAmount); err != nil {
|
||||
g.Log().Warningf(ctx, "sync_image: task %s refund failed (unsettled net will retry): %v", job.PublicTaskID, err)
|
||||
settled = false
|
||||
@@ -636,7 +637,7 @@ func failSyncImageJob(ctx context.Context, job *SyncImageJob, sel *common.Channe
|
||||
if sel != nil {
|
||||
chBasic = &common.ChannelBasicInfo{ID: sel.ChannelID, Type: sel.ChannelType, Name: sel.ChannelName}
|
||||
}
|
||||
usageTask := buildUsageTask(job, sel, "FAILURE", 0, now)
|
||||
usageTask := buildUsageTask(job, sel, "FAILURE", billing.Zero, now)
|
||||
recordTaskUsage(usageTask, chBasic, false, reason, nil)
|
||||
// 闭环审计:更新提交阶段的 SUBMITTED 审计记录为 FAILURE。
|
||||
recordTaskCompletionAudit(usageTask, "FAILURE", reason, nil)
|
||||
@@ -787,7 +788,7 @@ var rehostURLEnabled = func(ctx context.Context) bool {
|
||||
}
|
||||
|
||||
// buildUsageTask 构造供 recordTaskUsage 使用的 AsyncTask(用量日志需要的字段)。
|
||||
func buildUsageTask(job *SyncImageJob, sel *common.ChannelSelection, status string, actualCost float64, finish time.Time) *common.AsyncTask {
|
||||
func buildUsageTask(job *SyncImageJob, sel *common.ChannelSelection, status string, actualCost decimal.Decimal, finish time.Time) *common.AsyncTask {
|
||||
submit := job.SubmitTime
|
||||
t := &common.AsyncTask{
|
||||
ID: job.TaskID,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
lcommon "github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/logic/relay"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
@@ -238,7 +239,8 @@ func (s *sTenant) ApiKeyCreate(ctx context.Context, req *v1.TenantApiKeyCreateRe
|
||||
if *req.TotalQuota < 0 {
|
||||
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "总额度不能小于 0")
|
||||
}
|
||||
data.TotalQuota = *req.TotalQuota
|
||||
d := billing.NewFromFloat(*req.TotalQuota)
|
||||
data.TotalQuota = &d
|
||||
}
|
||||
|
||||
result, err := dao.ApiKeys.Ctx(ctx).Insert(data)
|
||||
@@ -436,7 +438,8 @@ func (s *sTenant) ApiKeyUpdate(ctx context.Context, req *v1.TenantApiKeyUpdateRe
|
||||
if *req.TotalQuota > 0 && *req.TotalQuota < info.UsedQuota {
|
||||
return nil, lcommon.NewBusinessError(consts.CodeBadRequest, "总额度不能小于已用额度")
|
||||
}
|
||||
data.TotalQuota = *req.TotalQuota
|
||||
d := billing.NewFromFloat(*req.TotalQuota)
|
||||
data.TotalQuota = &d
|
||||
hasUpdate = true
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"github.com/qianfree/team-api/internal/model/entity"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -26,29 +27,21 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
type walletRow struct {
|
||||
Balance float64 `json:"balance"`
|
||||
FrozenBalance float64 `json:"frozen_balance"`
|
||||
WarningThreshold float64 `json:"warning_threshold"`
|
||||
Currency string `json:"currency"`
|
||||
CumulativeRecharge float64 `json:"cumulative_recharge"`
|
||||
}
|
||||
|
||||
var w *walletRow
|
||||
var w *entity.BilWallets
|
||||
err := dao.BilWallets.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Fields("balance, frozen_balance, warning_threshold, currency, cumulative_recharge").
|
||||
Scan(&w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w == nil {
|
||||
// 钱包不存在,初始化
|
||||
threshold := billing.NewFromFloat(1.00)
|
||||
_, err = dao.BilWallets.Ctx(ctx).Insert(do.BilWallets{
|
||||
TenantId: tenantID,
|
||||
Balance: 0,
|
||||
FrozenBalance: 0,
|
||||
WarningThreshold: 1.00,
|
||||
Balance: billing.Zero,
|
||||
FrozenBalance: billing.Zero,
|
||||
WarningThreshold: &threshold,
|
||||
Currency: "USD",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -64,10 +57,10 @@ func (s *sTenant) Wallet(ctx context.Context, req *v1.TenantWalletReq) (*v1.Tena
|
||||
}
|
||||
|
||||
return &v1.TenantWalletRes{
|
||||
Balance: w.Balance,
|
||||
FrozenBalance: w.FrozenBalance,
|
||||
AvailableBalance: billing.SubtractMoney(w.Balance, w.FrozenBalance),
|
||||
WarningThreshold: w.WarningThreshold,
|
||||
Balance: billing.InexactFloat64(w.Balance),
|
||||
FrozenBalance: billing.InexactFloat64(w.FrozenBalance),
|
||||
AvailableBalance: billing.InexactFloat64(billing.SubtractMoney(w.Balance, w.FrozenBalance)),
|
||||
WarningThreshold: billing.InexactFloat64(*w.WarningThreshold),
|
||||
Currency: w.Currency,
|
||||
}, nil
|
||||
}
|
||||
@@ -517,10 +510,11 @@ func (s *sTenant) UpdateWarningThreshold(ctx context.Context, req *v1.TenantWall
|
||||
}
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
|
||||
threshold := billing.NewFromFloat(req.Threshold)
|
||||
_, err := dao.BilWallets.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Data(do.BilWallets{
|
||||
WarningThreshold: req.Threshold,
|
||||
WarningThreshold: &threshold,
|
||||
}).Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,6 +10,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"
|
||||
@@ -91,9 +92,9 @@ func (s *sTenant) OrderCreate(ctx context.Context, req *v1.TenantOrderCreateReq)
|
||||
|
||||
// 查套餐价格
|
||||
var plan *struct {
|
||||
MonthlyPrice float64 `json:"monthly_price"`
|
||||
YearlyPrice float64 `json:"yearly_price"`
|
||||
Status string `json:"status"`
|
||||
MonthlyPrice decimal.Decimal `json:"monthly_price"`
|
||||
YearlyPrice decimal.Decimal `json:"yearly_price"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
err := dao.PlnPlans.Ctx(ctx).
|
||||
Where("id", planID).
|
||||
@@ -110,10 +111,10 @@ func (s *sTenant) OrderCreate(ctx context.Context, req *v1.TenantOrderCreateReq)
|
||||
|
||||
var amount float64
|
||||
if months >= 12 {
|
||||
amount = plan.YearlyPrice
|
||||
amount = billing.InexactFloat64(plan.YearlyPrice)
|
||||
} else {
|
||||
// 修复订单金额连乘:用 decimal 避免 price × months 的浮点误差
|
||||
amount = billing.MultiplyMoney(plan.MonthlyPrice, float64(months))
|
||||
amount = billing.InexactFloat64(billing.MultiplyMoney(plan.MonthlyPrice, billing.NewFromFloat(float64(months))))
|
||||
}
|
||||
|
||||
// 使用 crypto/rand 生成随机部分,避免碰撞
|
||||
@@ -365,7 +366,7 @@ func (s *sTenant) RechargeCreate(ctx context.Context, req *v1.TenantRechargeCrea
|
||||
if settings != nil {
|
||||
if discount, ok := settings.AmountDiscount[int(req.Amount)]; ok && discount > 0 {
|
||||
// 修复充值折扣连乘:用 decimal 避免 amount × discount 的浮点误差
|
||||
finalAmount = billing.MultiplyMoney(req.Amount, discount)
|
||||
finalAmount = billing.InexactFloat64(billing.MultiplyMoney(billing.NewFromFloat(req.Amount), billing.NewFromFloat(discount)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/billing"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"github.com/qianfree/team-api/internal/model/entity"
|
||||
@@ -36,8 +37,8 @@ func (s *sTenant) PlanList(ctx context.Context, req *v1.TenantPlanListReq) (*v1.
|
||||
Name: e.Name,
|
||||
Identifier: e.Identifier,
|
||||
Description: e.Description,
|
||||
MonthlyPrice: e.MonthlyPrice,
|
||||
YearlyPrice: e.YearlyPrice,
|
||||
MonthlyPrice: billing.InexactFloat64(e.MonthlyPrice),
|
||||
YearlyPrice: billing.InexactFloat64(e.YearlyPrice),
|
||||
MonthlyQuotaTokens: e.MonthlyQuotaTokens,
|
||||
AllowedModels: e.AllowedModels,
|
||||
IsRecommended: e.IsRecommended,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"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"
|
||||
"github.com/qianfree/team-api/internal/model/do"
|
||||
@@ -235,7 +236,8 @@ func (s *sTenant) ProjectCreate(ctx context.Context, req *v1.TenantProjectCreate
|
||||
insertData.Description = req.Description
|
||||
}
|
||||
if req.Budget > 0 {
|
||||
insertData.Budget = req.Budget
|
||||
budgetDecimal := billing.NewFromFloat(req.Budget)
|
||||
insertData.Budget = &budgetDecimal
|
||||
}
|
||||
|
||||
result, err := dao.TntProjects.Ctx(ctx).Data(insertData).Insert()
|
||||
@@ -281,7 +283,8 @@ func (s *sTenant) ProjectUpdate(ctx context.Context, req *v1.TenantProjectUpdate
|
||||
updateData.Description = req.Description
|
||||
}
|
||||
if req.Budget > 0 {
|
||||
updateData.Budget = req.Budget
|
||||
budgetDecimal := billing.NewFromFloat(req.Budget)
|
||||
updateData.Budget = &budgetDecimal
|
||||
} else if req.Budget == 0 {
|
||||
updateData.Budget = nil
|
||||
}
|
||||
@@ -561,7 +564,8 @@ func (s *sTenant) ProjectApiKeyCreate(ctx context.Context, req *v1.TenantProject
|
||||
if *req.TotalQuota < 0 {
|
||||
return nil, common.NewBadRequestError("总额度不能小于 0")
|
||||
}
|
||||
insertData.TotalQuota = *req.TotalQuota
|
||||
quotaDecimal := billing.NewFromFloat(*req.TotalQuota)
|
||||
insertData.TotalQuota = "aDecimal
|
||||
}
|
||||
|
||||
result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert()
|
||||
|
||||
@@ -7,27 +7,28 @@ package do
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// ApiKeys is the golang structure of table api_keys for DAO operations like Where/Data.
|
||||
type ApiKeys struct {
|
||||
g.Meta `orm:"table:api_keys, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 所属租户ID
|
||||
UserId any // 创建者用户ID
|
||||
Name any // Key 名称(如 "生产环境"、"测试用")
|
||||
EncryptedKey any // 加密存储的完整 API Key(AES-256)
|
||||
KeyPrefix any // Key 前缀(用于快速查找,明文存储,如 sk-a1b2c3d4)
|
||||
Scope any // 权限范围:full(全部)/ chat_only(仅对话)/ embeddings_only(仅嵌入)/ images_only(仅图像)/ read_only(只读)/ custom(自定义)
|
||||
Status any // 状态:active(正常)/ disabled(禁用)/ expired(已过期)
|
||||
ExpiresAt *gtime.Time // 过期时间(NULL 表示永不过期)
|
||||
RateLimitQps any // QPS 限流阈值(NULL 表示使用默认值)
|
||||
RateLimitConcurrency any // 并发限制阈值(NULL 表示使用默认值)
|
||||
IpWhitelist []string // IP 白名单数组(NULL 或空数组表示不限制)
|
||||
TotalQuota any // 额度上限(NULL 表示不限制)
|
||||
UsedQuota any // 已使用额度
|
||||
ProjectId any // 关联项目ID(NULL 表示不属于任何项目)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
KeyType any // 密钥类型:personal(个人密钥)/ project(项目密钥)
|
||||
Id any // 主键ID
|
||||
TenantId any // 所属租户ID
|
||||
UserId any // 创建者用户ID
|
||||
Name any // Key 名称(如 "生产环境"、"测试用")
|
||||
EncryptedKey any // 加密存储的完整 API Key(AES-256)
|
||||
KeyPrefix any // Key 前缀(用于快速查找,明文存储,如 sk-a1b2c3d4)
|
||||
Scope any // 权限范围:full(全部)/ chat_only(仅对话)/ embeddings_only(仅嵌入)/ images_only(仅图像)/ read_only(只读)/ custom(自定义)
|
||||
Status any // 状态:active(正常)/ disabled(禁用)/ expired(已过期)
|
||||
ExpiresAt *gtime.Time // 过期时间(NULL 表示永不过期)
|
||||
RateLimitQps any // QPS 限流阈值(NULL 表示使用默认值)
|
||||
RateLimitConcurrency any // 并发限制阈值(NULL 表示使用默认值)
|
||||
IpWhitelist []string // IP 白名单数组(NULL 或空数组表示不限制)
|
||||
TotalQuota *decimal.Decimal // 额度上限(NULL 表示不限制)
|
||||
UsedQuota any // 已使用额度
|
||||
ProjectId any // 关联项目ID(NULL 表示不属于任何项目)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
KeyType any // 密钥类型:personal(个人密钥)/ project(项目密钥)
|
||||
}
|
||||
@@ -7,19 +7,20 @@ package do
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// BilWallets is the golang structure of table bil_wallets for DAO operations like Where/Data.
|
||||
type BilWallets struct {
|
||||
g.Meta `orm:"table:bil_wallets, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 租户ID(每个租户一个钱包)
|
||||
Balance any // 总余额
|
||||
FrozenBalance any // 冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)
|
||||
WarningThreshold any // 余额预警线(低于此值触发通知)
|
||||
Currency any // 货币(USD)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
CumulativeRecharge any // 累计充值总额(USD)
|
||||
LowBalanceNotified any // 低余额预警是否已推送(充值恢复后重置为 false)
|
||||
Id any // 主键ID
|
||||
TenantId any // 租户ID(每个租户一个钱包)
|
||||
Balance any // 总余额
|
||||
FrozenBalance any // 冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)
|
||||
WarningThreshold *decimal.Decimal // 余额预警线(低于此值触发通知)
|
||||
Currency any // 货币(USD)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
CumulativeRecharge any // 累计充值总额(USD)
|
||||
LowBalanceNotified any // 低余额预警是否已推送(充值恢复后重置为 false)
|
||||
}
|
||||
@@ -7,21 +7,22 @@ package do
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// MdlPricing is the golang structure of table mdl_pricing for DAO operations like Where/Data.
|
||||
type MdlPricing struct {
|
||||
g.Meta `orm:"table:mdl_pricing, do:true"`
|
||||
Id any //
|
||||
ModelId any // 关联模型ID
|
||||
BillingMode any // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
|
||||
MinTokens any // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
|
||||
MaxTokens any // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
|
||||
InputPrice any // 每 1M input token 价格(token/tiered 模式)
|
||||
OutputPrice any // 每 1M output token 价格(token/tiered 模式)
|
||||
PerRequestPrice any // 按次计费单价(仅 per_request 模式)
|
||||
CreatedAt *gtime.Time //
|
||||
UpdatedAt *gtime.Time //
|
||||
CacheReadPrice any // 缓存读取每 1M token 价格(直接定价)
|
||||
CacheCreationPrice any // 缓存创建每 1M token 价格(直接定价)
|
||||
Id any //
|
||||
ModelId any // 关联模型ID
|
||||
BillingMode any // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
|
||||
MinTokens any // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
|
||||
MaxTokens any // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
|
||||
InputPrice any // 每 1M input token 价格(token/tiered 模式)
|
||||
OutputPrice any // 每 1M output token 价格(token/tiered 模式)
|
||||
PerRequestPrice *decimal.Decimal // 按次计费单价(仅 per_request 模式)
|
||||
CreatedAt *gtime.Time //
|
||||
UpdatedAt *gtime.Time //
|
||||
CacheReadPrice any // 缓存读取每 1M token 价格(直接定价)
|
||||
CacheCreationPrice any // 缓存创建每 1M token 价格(直接定价)
|
||||
}
|
||||
@@ -7,26 +7,27 @@ package do
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// MdlTenantModels is the golang structure of table mdl_tenant_models for DAO operations like Where/Data.
|
||||
type MdlTenantModels struct {
|
||||
g.Meta `orm:"table:mdl_tenant_models, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 租户ID
|
||||
ModelId any // 模型ID
|
||||
Enabled any // 是否启用(禁用后该租户无法调用此模型)
|
||||
CustomInputPrice any // 租户自定义输入价格(NULL 表示使用默认定价)
|
||||
CustomOutputPrice any // 租户自定义输出价格(NULL 表示使用默认定价)
|
||||
Multiplier any // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
BillingMode any // 覆盖模型计费方式(NULL表示跟随模型默认)
|
||||
PerRequestPrice any // 按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)
|
||||
DiscountRatio any // 折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)
|
||||
MaxConcurrency any // 单模型并发上限(NULL表示不限制)
|
||||
ChannelScope any // 渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)
|
||||
CustomCacheReadPrice any // 自定义缓存读取价格($/1M token),NULL 表示使用基础定价
|
||||
CustomCacheCreationPrice any // 自定义缓存创建价格($/1M token),NULL 表示使用基础定价
|
||||
CustomPricingTiers any // 自定义阶梯定价(JSONB 数组),格式: [{"min_tokens":0,"max_tokens":100000,"input_price":0.5,"output_price":1.5,"cache_read_price":0.1,"cache_creation_price":0.2}]
|
||||
Id any // 主键ID
|
||||
TenantId any // 租户ID
|
||||
ModelId any // 模型ID
|
||||
Enabled any // 是否启用(禁用后该租户无法调用此模型)
|
||||
CustomInputPrice *decimal.Decimal // 租户自定义输入价格(NULL 表示使用默认定价)
|
||||
CustomOutputPrice *decimal.Decimal // 租户自定义输出价格(NULL 表示使用默认定价)
|
||||
Multiplier any // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
BillingMode any // 覆盖模型计费方式(NULL表示跟随模型默认)
|
||||
PerRequestPrice *decimal.Decimal // 按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)
|
||||
DiscountRatio *decimal.Decimal // 折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)
|
||||
MaxConcurrency any // 单模型并发上限(NULL表示不限制)
|
||||
ChannelScope any // 渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)
|
||||
CustomCacheReadPrice *decimal.Decimal // 自定义缓存读取价格($/1M token),NULL 表示使用基础定价
|
||||
CustomCacheCreationPrice *decimal.Decimal // 自定义缓存创建价格($/1M token),NULL 表示使用基础定价
|
||||
CustomPricingTiers any // 自定义阶梯定价(JSONB 数组),格式: [{"min_tokens":0,"max_tokens":100000,"input_price":0.5,"output_price":1.5,"cache_read_price":0.1,"cache_creation_price":0.2}]
|
||||
}
|
||||
@@ -7,18 +7,19 @@ package do
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// TntProjects is the golang structure of table tnt_projects for DAO operations like Where/Data.
|
||||
type TntProjects struct {
|
||||
g.Meta `orm:"table:tnt_projects, do:true"`
|
||||
Id any // 主键ID
|
||||
TenantId any // 所属租户ID
|
||||
Name any // 项目名称
|
||||
Description any // 项目描述
|
||||
Status any // 状态:active(活跃)/ archived(归档)/ budget_exhausted(预算耗尽)
|
||||
Budget any // 项目预算上限(NUMERIC(20,10) 金额,NULL 表示不限制)
|
||||
CreatedBy any // 创建者用户ID
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
Id any // 主键ID
|
||||
TenantId any // 所属租户ID
|
||||
Name any // 项目名称
|
||||
Description any // 项目描述
|
||||
Status any // 状态:active(活跃)/ archived(归档)/ budget_exhausted(预算耗尽)
|
||||
Budget *decimal.Decimal // 项目预算上限(NUMERIC(20,10) 金额,NULL 表示不限制)
|
||||
CreatedBy any // 创建者用户ID
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
}
|
||||
@@ -6,26 +6,27 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// ApiKeys is the golang structure for table api_keys.
|
||||
type ApiKeys struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"创建者用户ID"` // 创建者用户ID
|
||||
Name string `json:"name" orm:"name" description:"Key 名称(如 \"生产环境\"、\"测试用\")"` // Key 名称(如 "生产环境"、"测试用")
|
||||
EncryptedKey string `json:"encrypted_key" orm:"encrypted_key" description:"加密存储的完整 API Key(AES-256)"` // 加密存储的完整 API Key(AES-256)
|
||||
KeyPrefix string `json:"key_prefix" orm:"key_prefix" description:"Key 前缀(用于快速查找,明文存储,如 sk-a1b2c3d4)"` // Key 前缀(用于快速查找,明文存储,如 sk-a1b2c3d4)
|
||||
Scope string `json:"scope" orm:"scope" description:"权限范围:full(全部)/ chat_only(仅对话)/ embeddings_only(仅嵌入)/ images_only(仅图像)/ read_only(只读)/ custom(自定义)"` // 权限范围:full(全部)/ chat_only(仅对话)/ embeddings_only(仅嵌入)/ images_only(仅图像)/ read_only(只读)/ custom(自定义)
|
||||
Status string `json:"status" orm:"status" description:"状态:active(正常)/ disabled(禁用)/ expired(已过期)"` // 状态:active(正常)/ disabled(禁用)/ expired(已过期)
|
||||
ExpiresAt *gtime.Time `json:"expires_at" orm:"expires_at" description:"过期时间(NULL 表示永不过期)"` // 过期时间(NULL 表示永不过期)
|
||||
RateLimitQps int `json:"rate_limit_qps" orm:"rate_limit_qps" description:"QPS 限流阈值(NULL 表示使用默认值)"` // QPS 限流阈值(NULL 表示使用默认值)
|
||||
RateLimitConcurrency int `json:"rate_limit_concurrency" orm:"rate_limit_concurrency" description:"并发限制阈值(NULL 表示使用默认值)"` // 并发限制阈值(NULL 表示使用默认值)
|
||||
IpWhitelist []string `json:"ip_whitelist" orm:"ip_whitelist" description:"IP 白名单数组(NULL 或空数组表示不限制)"` // IP 白名单数组(NULL 或空数组表示不限制)
|
||||
TotalQuota float64 `json:"total_quota" orm:"total_quota" description:"额度上限(NULL 表示不限制)"` // 额度上限(NULL 表示不限制)
|
||||
UsedQuota float64 `json:"used_quota" orm:"used_quota" description:"已使用额度"` // 已使用额度
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(NULL 表示不属于任何项目)"` // 关联项目ID(NULL 表示不属于任何项目)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
KeyType string `json:"key_type" orm:"key_type" description:"密钥类型:personal(个人密钥)/ project(项目密钥)"` // 密钥类型:personal(个人密钥)/ project(项目密钥)
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"创建者用户ID"` // 创建者用户ID
|
||||
Name string `json:"name" orm:"name" description:"Key 名称(如 \"生产环境\"、\"测试用\")"` // Key 名称(如 "生产环境"、"测试用")
|
||||
EncryptedKey string `json:"encrypted_key" orm:"encrypted_key" description:"加密存储的完整 API Key(AES-256)"` // 加密存储的完整 API Key(AES-256)
|
||||
KeyPrefix string `json:"key_prefix" orm:"key_prefix" description:"Key 前缀(用于快速查找,明文存储,如 sk-a1b2c3d4)"` // Key 前缀(用于快速查找,明文存储,如 sk-a1b2c3d4)
|
||||
Scope string `json:"scope" orm:"scope" description:"权限范围:full(全部)/ chat_only(仅对话)/ embeddings_only(仅嵌入)/ images_only(仅图像)/ read_only(只读)/ custom(自定义)"` // 权限范围:full(全部)/ chat_only(仅对话)/ embeddings_only(仅嵌入)/ images_only(仅图像)/ read_only(只读)/ custom(自定义)
|
||||
Status string `json:"status" orm:"status" description:"状态:active(正常)/ disabled(禁用)/ expired(已过期)"` // 状态:active(正常)/ disabled(禁用)/ expired(已过期)
|
||||
ExpiresAt *gtime.Time `json:"expires_at" orm:"expires_at" description:"过期时间(NULL 表示永不过期)"` // 过期时间(NULL 表示永不过期)
|
||||
RateLimitQps int `json:"rate_limit_qps" orm:"rate_limit_qps" description:"QPS 限流阈值(NULL 表示使用默认值)"` // QPS 限流阈值(NULL 表示使用默认值)
|
||||
RateLimitConcurrency int `json:"rate_limit_concurrency" orm:"rate_limit_concurrency" description:"并发限制阈值(NULL 表示使用默认值)"` // 并发限制阈值(NULL 表示使用默认值)
|
||||
IpWhitelist []string `json:"ip_whitelist" orm:"ip_whitelist" description:"IP 白名单数组(NULL 或空数组表示不限制)"` // IP 白名单数组(NULL 或空数组表示不限制)
|
||||
TotalQuota *decimal.Decimal `json:"total_quota" orm:"total_quota" description:"额度上限(NULL 表示不限制)"` // 额度上限(NULL 表示不限制)
|
||||
UsedQuota decimal.Decimal `json:"used_quota" orm:"used_quota" description:"已使用额度"` // 已使用额度
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(NULL 表示不属于任何项目)"` // 关联项目ID(NULL 表示不属于任何项目)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
KeyType string `json:"key_type" orm:"key_type" description:"密钥类型:personal(个人密钥)/ project(项目密钥)"` // 密钥类型:personal(个人密钥)/ project(项目密钥)
|
||||
}
|
||||
@@ -6,16 +6,17 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// BilPredeductTracks is the golang structure for table bil_prededuct_tracks.
|
||||
type BilPredeductTracks struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户 ID"` // 租户 ID
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一 ID"` // 请求唯一 ID
|
||||
Amount float64 `json:"amount" orm:"amount" description:"预扣金额(USD)"` // 预扣金额(USD)
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"模型名称"` // 模型名称
|
||||
Status string `json:"status" orm:"status" description:"frozen=冻结中, settled=已结算, expired=超时自动释放, released=手动释放"` // frozen=冻结中, settled=已结算, expired=超时自动释放, released=手动释放
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
ExpiredAt *gtime.Time `json:"expired_at" orm:"expired_at" description:"过期释放时间(仅 status=expired 时有值)"` // 过期释放时间(仅 status=expired 时有值)
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户 ID"` // 租户 ID
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一 ID"` // 请求唯一 ID
|
||||
Amount decimal.Decimal `json:"amount" orm:"amount" description:"预扣金额(USD)"` // 预扣金额(USD)
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"模型名称"` // 模型名称
|
||||
Status string `json:"status" orm:"status" description:"frozen=冻结中, settled=已结算, expired=超时自动释放, released=手动释放"` // frozen=冻结中, settled=已结算, expired=超时自动释放, released=手动释放
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
ExpiredAt *gtime.Time `json:"expired_at" orm:"expired_at" description:"过期释放时间(仅 status=expired 时有值)"` // 过期释放时间(仅 status=expired 时有值)
|
||||
}
|
||||
@@ -6,41 +6,42 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// BilRecords is the golang structure for table bil_records.
|
||||
type BilRecords struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"使用的渠道ID"` // 使用的渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"调用的模型名"` // 调用的模型名
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID(关联全链路追踪)"` // 请求唯一ID(关联全链路追踪)
|
||||
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"代理模式:chat_completions / embeddings / images_generations 等"` // 代理模式:chat_completions / embeddings / images_generations 等
|
||||
InputTokens int `json:"input_tokens" orm:"input_tokens" description:"输入 token 数"` // 输入 token 数
|
||||
OutputTokens int `json:"output_tokens" orm:"output_tokens" description:"输出 token 数"` // 输出 token 数
|
||||
InputPrice float64 `json:"input_price" orm:"input_price" description:"计费时输入单价(快照,防止价格变更影响历史记录)"` // 计费时输入单价(快照,防止价格变更影响历史记录)
|
||||
OutputPrice float64 `json:"output_price" orm:"output_price" description:"计费时输出单价(快照)"` // 计费时输出单价(快照)
|
||||
TotalCost float64 `json:"total_cost" orm:"total_cost" description:"最终费用 = 基础价格 × 模型倍率 × 租户倍率"` // 最终费用 = 基础价格 × 模型倍率 × 租户倍率
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
Status string `json:"status" orm:"status" description:"状态:pre_deducted(已预扣)/ settled(已结算)/ refunded(已退款)"` // 状态:pre_deducted(已预扣)/ settled(已结算)/ refunded(已退款)
|
||||
SettledAt *gtime.Time `json:"settled_at" orm:"settled_at" description:"结算时间"` // 结算时间
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"实际计费模式"` // 实际计费模式
|
||||
EffectiveInputPrice float64 `json:"effective_input_price" orm:"effective_input_price" description:"实际生效的输入单价(快照)"` // 实际生效的输入单价(快照)
|
||||
EffectiveOutputPrice float64 `json:"effective_output_price" orm:"effective_output_price" description:"实际生效的输出单价(快照)"` // 实际生效的输出单价(快照)
|
||||
DiscountRatio float64 `json:"discount_ratio" orm:"discount_ratio" description:"实际折扣比例(快照)"` // 实际折扣比例(快照)
|
||||
BillingInputMultiplier float64 `json:"billing_input_multiplier" orm:"billing_input_multiplier" description:"梯度定价输入乘数(快照)"` // 梯度定价输入乘数(快照)
|
||||
BillingOutputMultiplier float64 `json:"billing_output_multiplier" orm:"billing_output_multiplier" description:"梯度定价输出乘数(快照)"` // 梯度定价输出乘数(快照)
|
||||
CacheCreationTokens int `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数"` // 缓存创建 token 数
|
||||
CacheReadTokens int `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存读取 token 数"` // 缓存读取 token 数
|
||||
CacheCreationCost float64 `json:"cache_creation_cost" orm:"cache_creation_cost" description:"缓存创建费用"` // 缓存创建费用
|
||||
CacheReadCost float64 `json:"cache_read_cost" orm:"cache_read_cost" description:"缓存读取费用"` // 缓存读取费用
|
||||
ModelMultiplier float64 `json:"model_multiplier" orm:"model_multiplier" description:"模型倍率(快照)"` // 模型倍率(快照)
|
||||
TenantMultiplier float64 `json:"tenant_multiplier" orm:"tenant_multiplier" description:"租户倍率(快照)"` // 租户倍率(快照)
|
||||
BaseInputPrice float64 `json:"base_input_price" orm:"base_input_price" description:"基础模型输入单价(快照,应用倍率前)"` // 基础模型输入单价(快照,应用倍率前)
|
||||
BaseOutputPrice float64 `json:"base_output_price" orm:"base_output_price" description:"基础模型输出单价(快照,应用倍率前)"` // 基础模型输出单价(快照,应用倍率前)
|
||||
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"使用的渠道ID"` // 使用的渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"调用的模型名"` // 调用的模型名
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID(关联全链路追踪)"` // 请求唯一ID(关联全链路追踪)
|
||||
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"代理模式:chat_completions / embeddings / images_generations 等"` // 代理模式:chat_completions / embeddings / images_generations 等
|
||||
InputTokens int `json:"input_tokens" orm:"input_tokens" description:"输入 token 数"` // 输入 token 数
|
||||
OutputTokens int `json:"output_tokens" orm:"output_tokens" description:"输出 token 数"` // 输出 token 数
|
||||
InputPrice decimal.Decimal `json:"input_price" orm:"input_price" description:"计费时输入单价(快照,防止价格变更影响历史记录)"` // 计费时输入单价(快照,防止价格变更影响历史记录)
|
||||
OutputPrice decimal.Decimal `json:"output_price" orm:"output_price" description:"计费时输出单价(快照)"` // 计费时输出单价(快照)
|
||||
TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"最终费用 = 基础价格 × 模型倍率 × 租户倍率"` // 最终费用 = 基础价格 × 模型倍率 × 租户倍率
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
Status string `json:"status" orm:"status" description:"状态:pre_deducted(已预扣)/ settled(已结算)/ refunded(已退款)"` // 状态:pre_deducted(已预扣)/ settled(已结算)/ refunded(已退款)
|
||||
SettledAt *gtime.Time `json:"settled_at" orm:"settled_at" description:"结算时间"` // 结算时间
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"实际计费模式"` // 实际计费模式
|
||||
EffectiveInputPrice decimal.Decimal `json:"effective_input_price" orm:"effective_input_price" description:"实际生效的输入单价(快照)"` // 实际生效的输入单价(快照)
|
||||
EffectiveOutputPrice decimal.Decimal `json:"effective_output_price" orm:"effective_output_price" description:"实际生效的输出单价(快照)"` // 实际生效的输出单价(快照)
|
||||
DiscountRatio decimal.Decimal `json:"discount_ratio" orm:"discount_ratio" description:"实际折扣比例(快照)"` // 实际折扣比例(快照)
|
||||
BillingInputMultiplier decimal.Decimal `json:"billing_input_multiplier" orm:"billing_input_multiplier" description:"梯度定价输入乘数(快照)"` // 梯度定价输入乘数(快照)
|
||||
BillingOutputMultiplier decimal.Decimal `json:"billing_output_multiplier" orm:"billing_output_multiplier" description:"梯度定价输出乘数(快照)"` // 梯度定价输出乘数(快照)
|
||||
CacheCreationTokens int `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数"` // 缓存创建 token 数
|
||||
CacheReadTokens int `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存读取 token 数"` // 缓存读取 token 数
|
||||
CacheCreationCost decimal.Decimal `json:"cache_creation_cost" orm:"cache_creation_cost" description:"缓存创建费用"` // 缓存创建费用
|
||||
CacheReadCost decimal.Decimal `json:"cache_read_cost" orm:"cache_read_cost" description:"缓存读取费用"` // 缓存读取费用
|
||||
ModelMultiplier decimal.Decimal `json:"model_multiplier" orm:"model_multiplier" description:"模型倍率(快照)"` // 模型倍率(快照)
|
||||
TenantMultiplier decimal.Decimal `json:"tenant_multiplier" orm:"tenant_multiplier" description:"租户倍率(快照)"` // 租户倍率(快照)
|
||||
BaseInputPrice decimal.Decimal `json:"base_input_price" orm:"base_input_price" description:"基础模型输入单价(快照,应用倍率前)"` // 基础模型输入单价(快照,应用倍率前)
|
||||
BaseOutputPrice decimal.Decimal `json:"base_output_price" orm:"base_output_price" description:"基础模型输出单价(快照,应用倍率前)"` // 基础模型输出单价(快照,应用倍率前)
|
||||
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
|
||||
}
|
||||
@@ -6,26 +6,27 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// BilTransactions is the golang structure for table bil_transactions.
|
||||
type BilTransactions struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
WalletId int64 `json:"wallet_id" orm:"wallet_id" description:"关联钱包ID"` // 关联钱包ID
|
||||
Type string `json:"type" orm:"type" description:"类型:consume(消费)/ recharge(充值)/ adjust(调整)/ pre_deduct(预扣,已废弃)/ settle(结算,已废弃)/ refund(退款,已废弃)/ freeze(冻结,已废弃)/ unfreeze(解冻,已废弃)"` // 类型:consume(消费)/ recharge(充值)/ adjust(调整)/ pre_deduct(预扣,已废弃)/ settle(结算,已废弃)/ refund(退款,已废弃)/ freeze(冻结,已废弃)/ unfreeze(解冻,已废弃)
|
||||
Amount float64 `json:"amount" orm:"amount" description:"变动金额(正数=收入,负数=支出)"` // 变动金额(正数=收入,负数=支出)
|
||||
BalanceAfter float64 `json:"balance_after" orm:"balance_after" description:"变动后总余额"` // 变动后总余额
|
||||
FrozenAfter float64 `json:"frozen_after" orm:"frozen_after" description:"变动后冻结余额"` // 变动后冻结余额
|
||||
RelatedId int64 `json:"related_id" orm:"related_id" description:"关联业务ID(如计费记录ID、订单ID等)"` // 关联业务ID(如计费记录ID、订单ID等)
|
||||
RelatedType string `json:"related_type" orm:"related_type" description:"关联业务类型:billing_record / order / refund / adjustment / redemption"` // 关联业务类型:billing_record / order / refund / adjustment / redemption
|
||||
Description string `json:"description" orm:"description" description:"交易描述"` // 交易描述
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"关联用户ID(consume 类型为实际消费用户,recharge 类型为操作用户,adjust 类型为空)"` // 关联用户ID(consume 类型为实际消费用户,recharge 类型为操作用户,adjust 类型为空)
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"关联请求ID(consume 类型对应 API 调用的 request_id,其他类型为空)"` // 关联请求ID(consume 类型对应 API 调用的 request_id,其他类型为空)
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"关联模型名(consume 类型为调用的模型名,其他类型为空)"` // 关联模型名(consume 类型为调用的模型名,其他类型为空)
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(consume 类型为 API Key 所属项目,个人密钥为空)"` // 关联项目ID(consume 类型为 API Key 所属项目,个人密钥为空)
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"关联API密钥ID(consume 类型为发起请求的密钥)"` // 关联API密钥ID(consume 类型为发起请求的密钥)
|
||||
TaskId string `json:"task_id" orm:"task_id" description:"关联异步任务公开ID(consume+relay_mode=task 时关联 tsk_model_tasks.public_task_id)"` // 关联异步任务公开ID(consume+relay_mode=task 时关联 tsk_model_tasks.public_task_id)
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
WalletId int64 `json:"wallet_id" orm:"wallet_id" description:"关联钱包ID"` // 关联钱包ID
|
||||
Type string `json:"type" orm:"type" description:"类型:consume(消费)/ recharge(充值)/ adjust(调整)/ pre_deduct(预扣,已废弃)/ settle(结算,已废弃)/ refund(退款,已废弃)/ freeze(冻结,已废弃)/ unfreeze(解冻,已废弃)"` // 类型:consume(消费)/ recharge(充值)/ adjust(调整)/ pre_deduct(预扣,已废弃)/ settle(结算,已废弃)/ refund(退款,已废弃)/ freeze(冻结,已废弃)/ unfreeze(解冻,已废弃)
|
||||
Amount decimal.Decimal `json:"amount" orm:"amount" description:"变动金额(正数=收入,负数=支出)"` // 变动金额(正数=收入,负数=支出)
|
||||
BalanceAfter decimal.Decimal `json:"balance_after" orm:"balance_after" description:"变动后总余额"` // 变动后总余额
|
||||
FrozenAfter decimal.Decimal `json:"frozen_after" orm:"frozen_after" description:"变动后冻结余额"` // 变动后冻结余额
|
||||
RelatedId int64 `json:"related_id" orm:"related_id" description:"关联业务ID(如计费记录ID、订单ID等)"` // 关联业务ID(如计费记录ID、订单ID等)
|
||||
RelatedType string `json:"related_type" orm:"related_type" description:"关联业务类型:billing_record / order / refund / adjustment / redemption"` // 关联业务类型:billing_record / order / refund / adjustment / redemption
|
||||
Description string `json:"description" orm:"description" description:"交易描述"` // 交易描述
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"关联用户ID(consume 类型为实际消费用户,recharge 类型为操作用户,adjust 类型为空)"` // 关联用户ID(consume 类型为实际消费用户,recharge 类型为操作用户,adjust 类型为空)
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"关联请求ID(consume 类型对应 API 调用的 request_id,其他类型为空)"` // 关联请求ID(consume 类型对应 API 调用的 request_id,其他类型为空)
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"关联模型名(consume 类型为调用的模型名,其他类型为空)"` // 关联模型名(consume 类型为调用的模型名,其他类型为空)
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(consume 类型为 API Key 所属项目,个人密钥为空)"` // 关联项目ID(consume 类型为 API Key 所属项目,个人密钥为空)
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"关联API密钥ID(consume 类型为发起请求的密钥)"` // 关联API密钥ID(consume 类型为发起请求的密钥)
|
||||
TaskId string `json:"task_id" orm:"task_id" description:"关联异步任务公开ID(consume+relay_mode=task 时关联 tsk_model_tasks.public_task_id)"` // 关联异步任务公开ID(consume+relay_mode=task 时关联 tsk_model_tasks.public_task_id)
|
||||
}
|
||||
@@ -6,65 +6,66 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// BilUsageLogs is the golang structure for table bil_usage_logs.
|
||||
type BilUsageLogs struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"使用的渠道ID"` // 使用的渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"调用的模型名"` // 调用的模型名
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID"` // 请求唯一ID
|
||||
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"代理模式:chat_completions / embeddings / images_generations 等"` // 代理模式:chat_completions / embeddings / images_generations 等
|
||||
InputTokens int `json:"input_tokens" orm:"input_tokens" description:"输入 token 数"` // 输入 token 数
|
||||
OutputTokens int `json:"output_tokens" orm:"output_tokens" description:"输出 token 数"` // 输出 token 数
|
||||
TotalCost float64 `json:"total_cost" orm:"total_cost" description:"本次调用费用"` // 本次调用费用
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
LatencyMs int `json:"latency_ms" orm:"latency_ms" description:"请求延迟(毫秒)"` // 请求延迟(毫秒)
|
||||
Status string `json:"status" orm:"status" description:"状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)"` // 状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)
|
||||
ErrorMessage string `json:"error_message" orm:"error_message" description:"错误信息(成功时为 NULL)"` // 错误信息(成功时为 NULL)
|
||||
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CacheCreationTokens int `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数 (Claude)"` // 缓存创建 token 数 (Claude)
|
||||
CacheReadTokens int `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存读取 token 数 (Claude/OpenAI)"` // 缓存读取 token 数 (Claude/OpenAI)
|
||||
InputCost float64 `json:"input_cost" orm:"input_cost" description:"输入 token 费用"` // 输入 token 费用
|
||||
OutputCost float64 `json:"output_cost" orm:"output_cost" description:"输出 token 费用"` // 输出 token 费用
|
||||
CacheCreationCost float64 `json:"cache_creation_cost" orm:"cache_creation_cost" description:"缓存创建费用"` // 缓存创建费用
|
||||
CacheReadCost float64 `json:"cache_read_cost" orm:"cache_read_cost" description:"缓存读取费用"` // 缓存读取费用
|
||||
ActualCost float64 `json:"actual_cost" orm:"actual_cost" description:"实际扣除费用(含折扣后)"` // 实际扣除费用(含折扣后)
|
||||
RequestedModel string `json:"requested_model" orm:"requested_model" description:"用户请求的模型名"` // 用户请求的模型名
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(模型映射后)"` // 上游实际模型名(模型映射后)
|
||||
RequestType int `json:"request_type" orm:"request_type" description:"请求类型: 1=sync, 2=stream, 3=async, 4=websocket"` // 请求类型: 1=sync, 2=stream, 3=async, 4=websocket
|
||||
UserAgent string `json:"user_agent" orm:"user_agent" description:"客户端 User-Agent"` // 客户端 User-Agent
|
||||
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首 token 延迟(毫秒)"` // 首 token 延迟(毫秒)
|
||||
ServiceTier string `json:"service_tier" orm:"service_tier" description:"服务等级 (default/flex等)"` // 服务等级 (default/flex等)
|
||||
ReasoningEffort string `json:"reasoning_effort" orm:"reasoning_effort" description:"推理力度 (low/medium/high)"` // 推理力度 (low/medium/high)
|
||||
ChannelName string `json:"channel_name" orm:"channel_name" description:"渠道名称"` // 渠道名称
|
||||
ChannelType int `json:"channel_type" orm:"channel_type" description:"渠道类型 (ProviderType)"` // 渠道类型 (ProviderType)
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式 (token/per_request/tiered)"` // 计费模式 (token/per_request/tiered)
|
||||
BillingSource string `json:"billing_source" orm:"billing_source" description:"定价来源 (base/tenant/custom)"` // 定价来源 (base/tenant/custom)
|
||||
RateMultiplier float64 `json:"rate_multiplier" orm:"rate_multiplier" description:"费率倍率/折扣"` // 费率倍率/折扣
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:"预扣金额"` // 预扣金额
|
||||
RefundAmount float64 `json:"refund_amount" orm:"refund_amount" description:"退款金额"` // 退款金额
|
||||
SupplementAmount float64 `json:"supplement_amount" orm:"supplement_amount" description:"补扣金额"` // 补扣金额
|
||||
ImageCount int `json:"image_count" orm:"image_count" description:"生成图片数量"` // 生成图片数量
|
||||
ImageSize string `json:"image_size" orm:"image_size" description:"图片尺寸"` // 图片尺寸
|
||||
StreamEndReason string `json:"stream_end_reason" orm:"stream_end_reason" description:"流结束原因 (done/timeout/client_gone/error/panic)"` // 流结束原因 (done/timeout/client_gone/error/panic)
|
||||
RetryIndex int `json:"retry_index" orm:"retry_index" description:"重试次数(0=首次成功)"` // 重试次数(0=首次成功)
|
||||
BillingSummary string `json:"billing_summary" orm:"billing_summary" description:"计费快照文本(人类可读的计费过程描述)"` // 计费快照文本(人类可读的计费过程描述)
|
||||
CacheCreation5MTokens int `json:"cache_creation_5m_tokens" orm:"cache_creation_5m_tokens" description:"Claude 5分钟缓存创建 token 数"` // Claude 5分钟缓存创建 token 数
|
||||
CacheCreation1HTokens int `json:"cache_creation_1h_tokens" orm:"cache_creation_1h_tokens" description:"Claude 1小时缓存创建 token 数"` // Claude 1小时缓存创建 token 数
|
||||
AudioInputTokens int `json:"audio_input_tokens" orm:"audio_input_tokens" description:"音频输入 token 数"` // 音频输入 token 数
|
||||
AudioOutputTokens int `json:"audio_output_tokens" orm:"audio_output_tokens" description:"音频输出 token 数"` // 音频输出 token 数
|
||||
ImageOutputTokens int `json:"image_output_tokens" orm:"image_output_tokens" description:"图像输出 token 数(DALL-E 等)"` // 图像输出 token 数(DALL-E 等)
|
||||
ReasoningTokens int `json:"reasoning_tokens" orm:"reasoning_tokens" description:"推理 token 数(O1/o3 等)"` // 推理 token 数(O1/o3 等)
|
||||
AccountCost float64 `json:"account_cost" orm:"account_cost" description:"上游账户成本(用于利润分析)"` // 上游账户成本(用于利润分析)
|
||||
InboundEndpoint string `json:"inbound_endpoint" orm:"inbound_endpoint" description:"客户端请求路径(如 /v1/chat/completions)"` // 客户端请求路径(如 /v1/chat/completions)
|
||||
UpstreamEndpoint string `json:"upstream_endpoint" orm:"upstream_endpoint" description:"上游实际请求路径"` // 上游实际请求路径
|
||||
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId string `json:"task_id" orm:"task_id" description:"异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空"` // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"使用的 API Key ID"` // 使用的 API Key ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"使用的渠道ID"` // 使用的渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"调用的模型名"` // 调用的模型名
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"请求唯一ID"` // 请求唯一ID
|
||||
RelayMode string `json:"relay_mode" orm:"relay_mode" description:"代理模式:chat_completions / embeddings / images_generations 等"` // 代理模式:chat_completions / embeddings / images_generations 等
|
||||
InputTokens int `json:"input_tokens" orm:"input_tokens" description:"输入 token 数"` // 输入 token 数
|
||||
OutputTokens int `json:"output_tokens" orm:"output_tokens" description:"输出 token 数"` // 输出 token 数
|
||||
TotalCost decimal.Decimal `json:"total_cost" orm:"total_cost" description:"本次调用费用"` // 本次调用费用
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
LatencyMs int `json:"latency_ms" orm:"latency_ms" description:"请求延迟(毫秒)"` // 请求延迟(毫秒)
|
||||
Status string `json:"status" orm:"status" description:"状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)"` // 状态:success(成功)/ error(错误)/ timeout(超时)/ cancelled(取消)
|
||||
ErrorMessage string `json:"error_message" orm:"error_message" description:"错误信息(成功时为 NULL)"` // 错误信息(成功时为 NULL)
|
||||
ClientIp string `json:"client_ip" orm:"client_ip" description:"客户端 IP"` // 客户端 IP
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CacheCreationTokens int `json:"cache_creation_tokens" orm:"cache_creation_tokens" description:"缓存创建 token 数 (Claude)"` // 缓存创建 token 数 (Claude)
|
||||
CacheReadTokens int `json:"cache_read_tokens" orm:"cache_read_tokens" description:"缓存读取 token 数 (Claude/OpenAI)"` // 缓存读取 token 数 (Claude/OpenAI)
|
||||
InputCost decimal.Decimal `json:"input_cost" orm:"input_cost" description:"输入 token 费用"` // 输入 token 费用
|
||||
OutputCost decimal.Decimal `json:"output_cost" orm:"output_cost" description:"输出 token 费用"` // 输出 token 费用
|
||||
CacheCreationCost decimal.Decimal `json:"cache_creation_cost" orm:"cache_creation_cost" description:"缓存创建费用"` // 缓存创建费用
|
||||
CacheReadCost decimal.Decimal `json:"cache_read_cost" orm:"cache_read_cost" description:"缓存读取费用"` // 缓存读取费用
|
||||
ActualCost decimal.Decimal `json:"actual_cost" orm:"actual_cost" description:"实际扣除费用(含折扣后)"` // 实际扣除费用(含折扣后)
|
||||
RequestedModel string `json:"requested_model" orm:"requested_model" description:"用户请求的模型名"` // 用户请求的模型名
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(模型映射后)"` // 上游实际模型名(模型映射后)
|
||||
RequestType int `json:"request_type" orm:"request_type" description:"请求类型: 1=sync, 2=stream, 3=async, 4=websocket"` // 请求类型: 1=sync, 2=stream, 3=async, 4=websocket
|
||||
UserAgent string `json:"user_agent" orm:"user_agent" description:"客户端 User-Agent"` // 客户端 User-Agent
|
||||
FirstTokenMs int `json:"first_token_ms" orm:"first_token_ms" description:"首 token 延迟(毫秒)"` // 首 token 延迟(毫秒)
|
||||
ServiceTier string `json:"service_tier" orm:"service_tier" description:"服务等级 (default/flex等)"` // 服务等级 (default/flex等)
|
||||
ReasoningEffort string `json:"reasoning_effort" orm:"reasoning_effort" description:"推理力度 (low/medium/high)"` // 推理力度 (low/medium/high)
|
||||
ChannelName string `json:"channel_name" orm:"channel_name" description:"渠道名称"` // 渠道名称
|
||||
ChannelType int `json:"channel_type" orm:"channel_type" description:"渠道类型 (ProviderType)"` // 渠道类型 (ProviderType)
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式 (token/per_request/tiered)"` // 计费模式 (token/per_request/tiered)
|
||||
BillingSource string `json:"billing_source" orm:"billing_source" description:"定价来源 (base/tenant/custom)"` // 定价来源 (base/tenant/custom)
|
||||
RateMultiplier decimal.Decimal `json:"rate_multiplier" orm:"rate_multiplier" description:"费率倍率/折扣"` // 费率倍率/折扣
|
||||
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:"预扣金额"` // 预扣金额
|
||||
RefundAmount decimal.Decimal `json:"refund_amount" orm:"refund_amount" description:"退款金额"` // 退款金额
|
||||
SupplementAmount decimal.Decimal `json:"supplement_amount" orm:"supplement_amount" description:"补扣金额"` // 补扣金额
|
||||
ImageCount int `json:"image_count" orm:"image_count" description:"生成图片数量"` // 生成图片数量
|
||||
ImageSize string `json:"image_size" orm:"image_size" description:"图片尺寸"` // 图片尺寸
|
||||
StreamEndReason string `json:"stream_end_reason" orm:"stream_end_reason" description:"流结束原因 (done/timeout/client_gone/error/panic)"` // 流结束原因 (done/timeout/client_gone/error/panic)
|
||||
RetryIndex int `json:"retry_index" orm:"retry_index" description:"重试次数(0=首次成功)"` // 重试次数(0=首次成功)
|
||||
BillingSummary string `json:"billing_summary" orm:"billing_summary" description:"计费快照文本(人类可读的计费过程描述)"` // 计费快照文本(人类可读的计费过程描述)
|
||||
CacheCreation5MTokens int `json:"cache_creation_5m_tokens" orm:"cache_creation_5m_tokens" description:"Claude 5分钟缓存创建 token 数"` // Claude 5分钟缓存创建 token 数
|
||||
CacheCreation1HTokens int `json:"cache_creation_1h_tokens" orm:"cache_creation_1h_tokens" description:"Claude 1小时缓存创建 token 数"` // Claude 1小时缓存创建 token 数
|
||||
AudioInputTokens int `json:"audio_input_tokens" orm:"audio_input_tokens" description:"音频输入 token 数"` // 音频输入 token 数
|
||||
AudioOutputTokens int `json:"audio_output_tokens" orm:"audio_output_tokens" description:"音频输出 token 数"` // 音频输出 token 数
|
||||
ImageOutputTokens int `json:"image_output_tokens" orm:"image_output_tokens" description:"图像输出 token 数(DALL-E 等)"` // 图像输出 token 数(DALL-E 等)
|
||||
ReasoningTokens int `json:"reasoning_tokens" orm:"reasoning_tokens" description:"推理 token 数(O1/o3 等)"` // 推理 token 数(O1/o3 等)
|
||||
AccountCost decimal.Decimal `json:"account_cost" orm:"account_cost" description:"上游账户成本(用于利润分析)"` // 上游账户成本(用于利润分析)
|
||||
InboundEndpoint string `json:"inbound_endpoint" orm:"inbound_endpoint" description:"客户端请求路径(如 /v1/chat/completions)"` // 客户端请求路径(如 /v1/chat/completions)
|
||||
UpstreamEndpoint string `json:"upstream_endpoint" orm:"upstream_endpoint" description:"上游实际请求路径"` // 上游实际请求路径
|
||||
BillingSnapshot string `json:"billing_snapshot" orm:"billing_snapshot" description:"完整计费计算过程快照(JSONB)"` // 完整计费计算过程快照(JSONB)
|
||||
ProjectId int64 `json:"project_id" orm:"project_id" description:"关联项目ID(通过API Key关联,NULL表示个人密钥无项目)"` // 关联项目ID(通过API Key关联,NULL表示个人密钥无项目)
|
||||
TaskId string `json:"task_id" orm:"task_id" description:"异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空"` // 异步任务公开ID(task_xxxxx),关联 tsk_model_tasks.public_task_id,普通请求为空
|
||||
}
|
||||
@@ -6,18 +6,19 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// BilWallets is the golang structure for table bil_wallets.
|
||||
type BilWallets struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID(每个租户一个钱包)"` // 租户ID(每个租户一个钱包)
|
||||
Balance float64 `json:"balance" orm:"balance" description:"总余额"` // 总余额
|
||||
FrozenBalance float64 `json:"frozen_balance" orm:"frozen_balance" description:"冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)"` // 冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)
|
||||
WarningThreshold float64 `json:"warning_threshold" orm:"warning_threshold" description:"余额预警线(低于此值触发通知)"` // 余额预警线(低于此值触发通知)
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CumulativeRecharge float64 `json:"cumulative_recharge" orm:"cumulative_recharge" description:"累计充值总额(USD)"` // 累计充值总额(USD)
|
||||
LowBalanceNotified bool `json:"low_balance_notified" orm:"low_balance_notified" description:"低余额预警是否已推送(充值恢复后重置为 false)"` // 低余额预警是否已推送(充值恢复后重置为 false)
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID(每个租户一个钱包)"` // 租户ID(每个租户一个钱包)
|
||||
Balance decimal.Decimal `json:"balance" orm:"balance" description:"总余额"` // 总余额
|
||||
FrozenBalance decimal.Decimal `json:"frozen_balance" orm:"frozen_balance" description:"冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)"` // 冻结余额(支付中/退款中,可用余额 = balance - frozen_balance)
|
||||
WarningThreshold *decimal.Decimal `json:"warning_threshold" orm:"warning_threshold" description:"余额预警线(低于此值触发通知)"` // 余额预警线(低于此值触发通知)
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(USD)"` // 货币(USD)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CumulativeRecharge decimal.Decimal `json:"cumulative_recharge" orm:"cumulative_recharge" description:"累计充值总额(USD)"` // 累计充值总额(USD)
|
||||
LowBalanceNotified bool `json:"low_balance_notified" orm:"low_balance_notified" description:"低余额预警是否已推送(充值恢复后重置为 false)"` // 低余额预警是否已推送(充值恢复后重置为 false)
|
||||
}
|
||||
@@ -6,20 +6,21 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// MdlPricing is the golang structure for table mdl_pricing.
|
||||
type MdlPricing struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
ModelId int64 `json:"model_id" orm:"model_id" description:"关联模型ID"` // 关联模型ID
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)"` // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
|
||||
MinTokens int64 `json:"min_tokens" orm:"min_tokens" description:"阶梯起始 token 数(仅 tiered 模式,其他模式为 0)"` // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
|
||||
MaxTokens int64 `json:"max_tokens" orm:"max_tokens" description:"阶梯结束 token 数(NULL=无上限,仅 tiered 模式)"` // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
|
||||
InputPrice float64 `json:"input_price" orm:"input_price" description:"每 1M input token 价格(token/tiered 模式)"` // 每 1M input token 价格(token/tiered 模式)
|
||||
OutputPrice float64 `json:"output_price" orm:"output_price" description:"每 1M output token 价格(token/tiered 模式)"` // 每 1M output token 价格(token/tiered 模式)
|
||||
PerRequestPrice float64 `json:"per_request_price" orm:"per_request_price" description:"按次计费单价(仅 per_request 模式)"` // 按次计费单价(仅 per_request 模式)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
CacheReadPrice float64 `json:"cache_read_price" orm:"cache_read_price" description:"缓存读取每 1M token 价格(直接定价)"` // 缓存读取每 1M token 价格(直接定价)
|
||||
CacheCreationPrice float64 `json:"cache_creation_price" orm:"cache_creation_price" description:"缓存创建每 1M token 价格(直接定价)"` // 缓存创建每 1M token 价格(直接定价)
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
ModelId int64 `json:"model_id" orm:"model_id" description:"关联模型ID"` // 关联模型ID
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)"` // 计费模式:token(按量)/ per_request(按次)/ tiered(阶梯按量)
|
||||
MinTokens int64 `json:"min_tokens" orm:"min_tokens" description:"阶梯起始 token 数(仅 tiered 模式,其他模式为 0)"` // 阶梯起始 token 数(仅 tiered 模式,其他模式为 0)
|
||||
MaxTokens int64 `json:"max_tokens" orm:"max_tokens" description:"阶梯结束 token 数(NULL=无上限,仅 tiered 模式)"` // 阶梯结束 token 数(NULL=无上限,仅 tiered 模式)
|
||||
InputPrice decimal.Decimal `json:"input_price" orm:"input_price" description:"每 1M input token 价格(token/tiered 模式)"` // 每 1M input token 价格(token/tiered 模式)
|
||||
OutputPrice decimal.Decimal `json:"output_price" orm:"output_price" description:"每 1M output token 价格(token/tiered 模式)"` // 每 1M output token 价格(token/tiered 模式)
|
||||
PerRequestPrice *decimal.Decimal `json:"per_request_price" orm:"per_request_price" description:"按次计费单价(仅 per_request 模式)"` // 按次计费单价(仅 per_request 模式)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
CacheReadPrice decimal.Decimal `json:"cache_read_price" orm:"cache_read_price" description:"缓存读取每 1M token 价格(直接定价)"` // 缓存读取每 1M token 价格(直接定价)
|
||||
CacheCreationPrice decimal.Decimal `json:"cache_creation_price" orm:"cache_creation_price" description:"缓存创建每 1M token 价格(直接定价)"` // 缓存创建每 1M token 价格(直接定价)
|
||||
}
|
||||
@@ -6,25 +6,26 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// MdlTenantModels is the golang structure for table mdl_tenant_models.
|
||||
type MdlTenantModels struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
ModelId int64 `json:"model_id" orm:"model_id" description:"模型ID"` // 模型ID
|
||||
Enabled bool `json:"enabled" orm:"enabled" description:"是否启用(禁用后该租户无法调用此模型)"` // 是否启用(禁用后该租户无法调用此模型)
|
||||
CustomInputPrice float64 `json:"custom_input_price" orm:"custom_input_price" description:"租户自定义输入价格(NULL 表示使用默认定价)"` // 租户自定义输入价格(NULL 表示使用默认定价)
|
||||
CustomOutputPrice float64 `json:"custom_output_price" orm:"custom_output_price" description:"租户自定义输出价格(NULL 表示使用默认定价)"` // 租户自定义输出价格(NULL 表示使用默认定价)
|
||||
Multiplier float64 `json:"multiplier" orm:"multiplier" description:"租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)"` // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"覆盖模型计费方式(NULL表示跟随模型默认)"` // 覆盖模型计费方式(NULL表示跟随模型默认)
|
||||
PerRequestPrice float64 `json:"per_request_price" orm:"per_request_price" description:"按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)"` // 按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)
|
||||
DiscountRatio float64 `json:"discount_ratio" orm:"discount_ratio" description:"折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)"` // 折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)
|
||||
MaxConcurrency int `json:"max_concurrency" orm:"max_concurrency" description:"单模型并发上限(NULL表示不限制)"` // 单模型并发上限(NULL表示不限制)
|
||||
ChannelScope string `json:"channel_scope" orm:"channel_scope" description:"渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)"` // 渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)
|
||||
CustomCacheReadPrice float64 `json:"custom_cache_read_price" orm:"custom_cache_read_price" description:"自定义缓存读取价格($/1M token),NULL 表示使用基础定价"` // 自定义缓存读取价格($/1M token),NULL 表示使用基础定价
|
||||
CustomCacheCreationPrice float64 `json:"custom_cache_creation_price" orm:"custom_cache_creation_price" description:"自定义缓存创建价格($/1M token),NULL 表示使用基础定价"` // 自定义缓存创建价格($/1M token),NULL 表示使用基础定价
|
||||
CustomPricingTiers string `json:"custom_pricing_tiers" orm:"custom_pricing_tiers" description:"自定义阶梯定价(JSONB 数组),格式: [{\"min_tokens\":0,\"max_tokens\":100000,\"input_price\":0.5,\"output_price\":1.5,\"cache_read_price\":0.1,\"cache_creation_price\":0.2}]"` // 自定义阶梯定价(JSONB 数组),格式: [{"min_tokens":0,"max_tokens":100000,"input_price":0.5,"output_price":1.5,"cache_read_price":0.1,"cache_creation_price":0.2}]
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
ModelId int64 `json:"model_id" orm:"model_id" description:"模型ID"` // 模型ID
|
||||
Enabled bool `json:"enabled" orm:"enabled" description:"是否启用(禁用后该租户无法调用此模型)"` // 是否启用(禁用后该租户无法调用此模型)
|
||||
CustomInputPrice *decimal.Decimal `json:"custom_input_price" orm:"custom_input_price" description:"租户自定义输入价格(NULL 表示使用默认定价)"` // 租户自定义输入价格(NULL 表示使用默认定价)
|
||||
CustomOutputPrice *decimal.Decimal `json:"custom_output_price" orm:"custom_output_price" description:"租户自定义输出价格(NULL 表示使用默认定价)"` // 租户自定义输出价格(NULL 表示使用默认定价)
|
||||
Multiplier decimal.Decimal `json:"multiplier" orm:"multiplier" description:"租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)"` // 租户价格倍率(VIP 折扣等,最终价格 = 基础价格 × 模型倍率 × 租户倍率)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
BillingMode string `json:"billing_mode" orm:"billing_mode" description:"覆盖模型计费方式(NULL表示跟随模型默认)"` // 覆盖模型计费方式(NULL表示跟随模型默认)
|
||||
PerRequestPrice *decimal.Decimal `json:"per_request_price" orm:"per_request_price" description:"按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)"` // 按次计费单价(覆盖模型默认,仅 billing_mode = per_request 时有效)
|
||||
DiscountRatio *decimal.Decimal `json:"discount_ratio" orm:"discount_ratio" description:"折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)"` // 折扣比例(如0.8表示八折,NULL表示不打折,优先于 multiplier 使用)
|
||||
MaxConcurrency int `json:"max_concurrency" orm:"max_concurrency" description:"单模型并发上限(NULL表示不限制)"` // 单模型并发上限(NULL表示不限制)
|
||||
ChannelScope string `json:"channel_scope" orm:"channel_scope" description:"渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)"` // 渠道范围覆盖(NULL跟随租户默认,[]表示全部,数组表示指定渠道ID)
|
||||
CustomCacheReadPrice *decimal.Decimal `json:"custom_cache_read_price" orm:"custom_cache_read_price" description:"自定义缓存读取价格($/1M token),NULL 表示使用基础定价"` // 自定义缓存读取价格($/1M token),NULL 表示使用基础定价
|
||||
CustomCacheCreationPrice *decimal.Decimal `json:"custom_cache_creation_price" orm:"custom_cache_creation_price" description:"自定义缓存创建价格($/1M token),NULL 表示使用基础定价"` // 自定义缓存创建价格($/1M token),NULL 表示使用基础定价
|
||||
CustomPricingTiers string `json:"custom_pricing_tiers" orm:"custom_pricing_tiers" description:"自定义阶梯定价(JSONB 数组),格式: [{\"min_tokens\":0,\"max_tokens\":100000,\"input_price\":0.5,\"output_price\":1.5,\"cache_read_price\":0.1,\"cache_creation_price\":0.2}]"` // 自定义阶梯定价(JSONB 数组),格式: [{"min_tokens":0,"max_tokens":100000,"input_price":0.5,"output_price":1.5,"cache_read_price":0.1,"cache_creation_price":0.2}]
|
||||
}
|
||||
@@ -6,30 +6,31 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// OrdOrders is the golang structure for table ord_orders.
|
||||
type OrdOrders struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
OrderNo string `json:"order_no" orm:"order_no" description:"订单号(唯一,格式 ORD + 时间戳 + 随机数)"` // 订单号(唯一,格式 ORD + 时间戳 + 随机数)
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"下单用户ID"` // 下单用户ID
|
||||
OrderType string `json:"order_type" orm:"order_type" description:"订单类型:new_plan(新购)/ renew(续费)/ upgrade(升级)/ downgrade(降级)/ recharge(充值)"` // 订单类型:new_plan(新购)/ renew(续费)/ upgrade(升级)/ downgrade(降级)/ recharge(充值)
|
||||
PlanId int64 `json:"plan_id" orm:"plan_id" description:"套餐ID(充值订单时为 NULL)"` // 套餐ID(充值订单时为 NULL)
|
||||
Amount float64 `json:"amount" orm:"amount" description:"原始金额"` // 原始金额
|
||||
DiscountAmount float64 `json:"discount_amount" orm:"discount_amount" description:"优惠金额"` // 优惠金额
|
||||
FinalAmount float64 `json:"final_amount" orm:"final_amount" description:"最终金额"` // 最终金额
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(订单层一律 CNY)"` // 货币(订单层一律 CNY)
|
||||
PaymentChannel string `json:"payment_channel" orm:"payment_channel" description:"支付渠道"` // 支付渠道
|
||||
PaymentMethod string `json:"payment_method" orm:"payment_method" description:"支付方式描述"` // 支付方式描述
|
||||
PaymentNo string `json:"payment_no" orm:"payment_no" description:"第三方支付流水号"` // 第三方支付流水号
|
||||
Status string `json:"status" orm:"status" description:"订单状态"` // 订单状态
|
||||
PaidAt *gtime.Time `json:"paid_at" orm:"paid_at" description:"支付时间"` // 支付时间
|
||||
FulfilledAt *gtime.Time `json:"fulfilled_at" orm:"fulfilled_at" description:"履约完成时间"` // 履约完成时间
|
||||
ExpiredAt *gtime.Time `json:"expired_at" orm:"expired_at" description:"过期时间(未支付 30 分钟后自动过期)"` // 过期时间(未支付 30 分钟后自动过期)
|
||||
CancelledAt *gtime.Time `json:"cancelled_at" orm:"cancelled_at" description:"取消时间"` // 取消时间
|
||||
RelatedOrderId int64 `json:"related_order_id" orm:"related_order_id" description:"关联订单ID(退款时指向原始订单)"` // 关联订单ID(退款时指向原始订单)
|
||||
Description string `json:"description" orm:"description" description:"订单描述"` // 订单描述
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
OrderNo string `json:"order_no" orm:"order_no" description:"订单号(唯一,格式 ORD + 时间戳 + 随机数)"` // 订单号(唯一,格式 ORD + 时间戳 + 随机数)
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"下单用户ID"` // 下单用户ID
|
||||
OrderType string `json:"order_type" orm:"order_type" description:"订单类型:new_plan(新购)/ renew(续费)/ upgrade(升级)/ downgrade(降级)/ recharge(充值)"` // 订单类型:new_plan(新购)/ renew(续费)/ upgrade(升级)/ downgrade(降级)/ recharge(充值)
|
||||
PlanId int64 `json:"plan_id" orm:"plan_id" description:"套餐ID(充值订单时为 NULL)"` // 套餐ID(充值订单时为 NULL)
|
||||
Amount decimal.Decimal `json:"amount" orm:"amount" description:"原始金额"` // 原始金额
|
||||
DiscountAmount decimal.Decimal `json:"discount_amount" orm:"discount_amount" description:"优惠金额"` // 优惠金额
|
||||
FinalAmount decimal.Decimal `json:"final_amount" orm:"final_amount" description:"最终金额"` // 最终金额
|
||||
Currency string `json:"currency" orm:"currency" description:"货币(订单层一律 CNY)"` // 货币(订单层一律 CNY)
|
||||
PaymentChannel string `json:"payment_channel" orm:"payment_channel" description:"支付渠道"` // 支付渠道
|
||||
PaymentMethod string `json:"payment_method" orm:"payment_method" description:"支付方式描述"` // 支付方式描述
|
||||
PaymentNo string `json:"payment_no" orm:"payment_no" description:"第三方支付流水号"` // 第三方支付流水号
|
||||
Status string `json:"status" orm:"status" description:"订单状态"` // 订单状态
|
||||
PaidAt *gtime.Time `json:"paid_at" orm:"paid_at" description:"支付时间"` // 支付时间
|
||||
FulfilledAt *gtime.Time `json:"fulfilled_at" orm:"fulfilled_at" description:"履约完成时间"` // 履约完成时间
|
||||
ExpiredAt *gtime.Time `json:"expired_at" orm:"expired_at" description:"过期时间(未支付 30 分钟后自动过期)"` // 过期时间(未支付 30 分钟后自动过期)
|
||||
CancelledAt *gtime.Time `json:"cancelled_at" orm:"cancelled_at" description:"取消时间"` // 取消时间
|
||||
RelatedOrderId int64 `json:"related_order_id" orm:"related_order_id" description:"关联订单ID(退款时指向原始订单)"` // 关联订单ID(退款时指向原始订单)
|
||||
Description string `json:"description" orm:"description" description:"订单描述"` // 订单描述
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
}
|
||||
@@ -6,15 +6,16 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// OrdPromoCodeUsages is the golang structure for table ord_promo_code_usages.
|
||||
type OrdPromoCodeUsages struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
PromoCodeId int64 `json:"promo_code_id" orm:"promo_code_id" description:""` //
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` //
|
||||
OrderId int64 `json:"order_id" orm:"order_id" description:""` //
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:""` //
|
||||
DiscountAmount float64 `json:"discount_amount" orm:"discount_amount" description:"实际折扣金额"` // 实际折扣金额
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
PromoCodeId int64 `json:"promo_code_id" orm:"promo_code_id" description:""` //
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` //
|
||||
OrderId int64 `json:"order_id" orm:"order_id" description:""` //
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:""` //
|
||||
DiscountAmount decimal.Decimal `json:"discount_amount" orm:"discount_amount" description:"实际折扣金额"` // 实际折扣金额
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
}
|
||||
@@ -6,24 +6,25 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// OrdPromoCodes is the golang structure for table ord_promo_codes.
|
||||
type OrdPromoCodes struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
Code string `json:"code" orm:"code" description:"优惠码文本(唯一)"` // 优惠码文本(唯一)
|
||||
Name string `json:"name" orm:"name" description:""` //
|
||||
Type string `json:"type" orm:"type" description:"类型:percentage(折扣百分比)/ fixed(立减固定金额)"` // 类型:percentage(折扣百分比)/ fixed(立减固定金额)
|
||||
DiscountValue float64 `json:"discount_value" orm:"discount_value" description:"折扣值(百分比 0-100,立减为金额)"` // 折扣值(百分比 0-100,立减为金额)
|
||||
MinAmount float64 `json:"min_amount" orm:"min_amount" description:"最低订单金额"` // 最低订单金额
|
||||
MaxDiscount float64 `json:"max_discount" orm:"max_discount" description:"最大折扣金额(0=不限)"` // 最大折扣金额(0=不限)
|
||||
TotalCount int `json:"total_count" orm:"total_count" description:""` //
|
||||
UsedCount int `json:"used_count" orm:"used_count" description:""` //
|
||||
PerUserLimit int `json:"per_user_limit" orm:"per_user_limit" description:""` //
|
||||
ValidFrom *gtime.Time `json:"valid_from" orm:"valid_from" description:""` //
|
||||
ValidTo *gtime.Time `json:"valid_to" orm:"valid_to" description:""` //
|
||||
PlanIds []int64 `json:"plan_ids" orm:"plan_ids" description:"适用套餐ID数组(NULL=全部)"` // 适用套餐ID数组(NULL=全部)
|
||||
Status string `json:"status" orm:"status" description:""` //
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
Code string `json:"code" orm:"code" description:"优惠码文本(唯一)"` // 优惠码文本(唯一)
|
||||
Name string `json:"name" orm:"name" description:""` //
|
||||
Type string `json:"type" orm:"type" description:"类型:percentage(折扣百分比)/ fixed(立减固定金额)"` // 类型:percentage(折扣百分比)/ fixed(立减固定金额)
|
||||
DiscountValue decimal.Decimal `json:"discount_value" orm:"discount_value" description:"折扣值(百分比 0-100,立减为金额)"` // 折扣值(百分比 0-100,立减为金额)
|
||||
MinAmount decimal.Decimal `json:"min_amount" orm:"min_amount" description:"最低订单金额"` // 最低订单金额
|
||||
MaxDiscount decimal.Decimal `json:"max_discount" orm:"max_discount" description:"最大折扣金额(0=不限)"` // 最大折扣金额(0=不限)
|
||||
TotalCount int `json:"total_count" orm:"total_count" description:""` //
|
||||
UsedCount int `json:"used_count" orm:"used_count" description:""` //
|
||||
PerUserLimit int `json:"per_user_limit" orm:"per_user_limit" description:""` //
|
||||
ValidFrom *gtime.Time `json:"valid_from" orm:"valid_from" description:""` //
|
||||
ValidTo *gtime.Time `json:"valid_to" orm:"valid_to" description:""` //
|
||||
PlanIds []int64 `json:"plan_ids" orm:"plan_ids" description:"适用套餐ID数组(NULL=全部)"` // 适用套餐ID数组(NULL=全部)
|
||||
Status string `json:"status" orm:"status" description:""` //
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
}
|
||||
@@ -6,17 +6,18 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// OrdRedemptionUsages is the golang structure for table ord_redemption_usages.
|
||||
type OrdRedemptionUsages struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
RedemptionId int64 `json:"redemption_id" orm:"redemption_id" description:"关联兑换码ID"` // 关联兑换码ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"使用兑换码的租户ID"` // 使用兑换码的租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"执行兑换操作的用户ID"` // 执行兑换操作的用户ID
|
||||
Type string `json:"type" orm:"type" description:"兑换类型:quota / plan / duration"` // 兑换类型:quota / plan / duration
|
||||
Value float64 `json:"value" orm:"value" description:"兑换面值(quota类型为金额,plan/duration为0)"` // 兑换面值(quota类型为金额,plan/duration为0)
|
||||
TransactionId int64 `json:"transaction_id" orm:"transaction_id" description:"关联的交易流水ID(仅quota类型有值)"` // 关联的交易流水ID(仅quota类型有值)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
RedemptionId int64 `json:"redemption_id" orm:"redemption_id" description:"关联兑换码ID"` // 关联兑换码ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"使用兑换码的租户ID"` // 使用兑换码的租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"执行兑换操作的用户ID"` // 执行兑换操作的用户ID
|
||||
Type string `json:"type" orm:"type" description:"兑换类型:quota / plan / duration"` // 兑换类型:quota / plan / duration
|
||||
Value decimal.Decimal `json:"value" orm:"value" description:"兑换面值(quota类型为金额,plan/duration为0)"` // 兑换面值(quota类型为金额,plan/duration为0)
|
||||
TransactionId int64 `json:"transaction_id" orm:"transaction_id" description:"关联的交易流水ID(仅quota类型有值)"` // 关联的交易流水ID(仅quota类型有值)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
}
|
||||
@@ -6,23 +6,24 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// OrdRedemptions is the golang structure for table ord_redemptions.
|
||||
type OrdRedemptions struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
Code string `json:"code" orm:"code" description:""` //
|
||||
Type string `json:"type" orm:"type" description:"类型:quota(额度)/ plan(套餐时长)/ duration(时长天数)"` // 类型:quota(额度)/ plan(套餐时长)/ duration(时长天数)
|
||||
Value float64 `json:"value" orm:"value" description:""` //
|
||||
PlanId int64 `json:"plan_id" orm:"plan_id" description:""` //
|
||||
DurationDays int `json:"duration_days" orm:"duration_days" description:""` //
|
||||
MaxUses int `json:"max_uses" orm:"max_uses" description:""` //
|
||||
UsedCount int `json:"used_count" orm:"used_count" description:""` //
|
||||
RedeemedBy int64 `json:"redeemed_by" orm:"redeemed_by" description:""` //
|
||||
RedeemedAt *gtime.Time `json:"redeemed_at" orm:"redeemed_at" description:""` //
|
||||
ExpiresAt *gtime.Time `json:"expires_at" orm:"expires_at" description:""` //
|
||||
Status string `json:"status" orm:"status" description:""` //
|
||||
BatchNo string `json:"batch_no" orm:"batch_no" description:"批次号(批量生成时,便于管理)"` // 批次号(批量生成时,便于管理)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
Code string `json:"code" orm:"code" description:""` //
|
||||
Type string `json:"type" orm:"type" description:"类型:quota(额度)/ plan(套餐时长)/ duration(时长天数)"` // 类型:quota(额度)/ plan(套餐时长)/ duration(时长天数)
|
||||
Value decimal.Decimal `json:"value" orm:"value" description:""` //
|
||||
PlanId int64 `json:"plan_id" orm:"plan_id" description:""` //
|
||||
DurationDays int `json:"duration_days" orm:"duration_days" description:""` //
|
||||
MaxUses int `json:"max_uses" orm:"max_uses" description:""` //
|
||||
UsedCount int `json:"used_count" orm:"used_count" description:""` //
|
||||
RedeemedBy int64 `json:"redeemed_by" orm:"redeemed_by" description:""` //
|
||||
RedeemedAt *gtime.Time `json:"redeemed_at" orm:"redeemed_at" description:""` //
|
||||
ExpiresAt *gtime.Time `json:"expires_at" orm:"expires_at" description:""` //
|
||||
Status string `json:"status" orm:"status" description:""` //
|
||||
BatchNo string `json:"batch_no" orm:"batch_no" description:"批次号(批量生成时,便于管理)"` // 批次号(批量生成时,便于管理)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
}
|
||||
@@ -6,20 +6,21 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// OrdRefunds is the golang structure for table ord_refunds.
|
||||
type OrdRefunds struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
OrderId int64 `json:"order_id" orm:"order_id" description:"关联订单ID"` // 关联订单ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
Amount float64 `json:"amount" orm:"amount" description:"退款金额"` // 退款金额
|
||||
Reason string `json:"reason" orm:"reason" description:"退款原因"` // 退款原因
|
||||
Status string `json:"status" orm:"status" description:"退款状态"` // 退款状态
|
||||
PaymentChannel string `json:"payment_channel" orm:"payment_channel" description:"原支付渠道"` // 原支付渠道
|
||||
PaymentRefundId string `json:"payment_refund_id" orm:"payment_refund_id" description:"第三方退款流水号"` // 第三方退款流水号
|
||||
ApprovedBy int64 `json:"approved_by" orm:"approved_by" description:"审批人(管理员ID)"` // 审批人(管理员ID)
|
||||
ApprovedAt *gtime.Time `json:"approved_at" orm:"approved_at" description:"审批时间"` // 审批时间
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
OrderId int64 `json:"order_id" orm:"order_id" description:"关联订单ID"` // 关联订单ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"租户ID"` // 租户ID
|
||||
Amount decimal.Decimal `json:"amount" orm:"amount" description:"退款金额"` // 退款金额
|
||||
Reason string `json:"reason" orm:"reason" description:"退款原因"` // 退款原因
|
||||
Status string `json:"status" orm:"status" description:"退款状态"` // 退款状态
|
||||
PaymentChannel string `json:"payment_channel" orm:"payment_channel" description:"原支付渠道"` // 原支付渠道
|
||||
PaymentRefundId string `json:"payment_refund_id" orm:"payment_refund_id" description:"第三方退款流水号"` // 第三方退款流水号
|
||||
ApprovedBy int64 `json:"approved_by" orm:"approved_by" description:"审批人(管理员ID)"` // 审批人(管理员ID)
|
||||
ApprovedAt *gtime.Time `json:"approved_at" orm:"approved_at" description:"审批时间"` // 审批时间
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
}
|
||||
@@ -6,21 +6,22 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// PlnPlans is the golang structure for table pln_plans.
|
||||
type PlnPlans struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
Name string `json:"name" orm:"name" description:"套餐显示名称"` // 套餐显示名称
|
||||
Identifier string `json:"identifier" orm:"identifier" description:"套餐唯一标识(free/basic/pro/enterprise)"` // 套餐唯一标识(free/basic/pro/enterprise)
|
||||
Description string `json:"description" orm:"description" description:"套餐描述(面向用户的营销文案)"` // 套餐描述(面向用户的营销文案)
|
||||
MonthlyPrice float64 `json:"monthly_price" orm:"monthly_price" description:"月度价格(CNY)"` // 月度价格(CNY)
|
||||
YearlyPrice float64 `json:"yearly_price" orm:"yearly_price" description:"年度价格(CNY,通常为月价×10)"` // 年度价格(CNY,通常为月价×10)
|
||||
Status string `json:"status" orm:"status" description:"状态:active(上架)/ archived(下架)"` // 状态:active(上架)/ archived(下架)
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens" orm:"monthly_quota_tokens" description:"每月 Token 配额(0=不限)"` // 每月 Token 配额(0=不限)
|
||||
AllowedModels []string `json:"allowed_models" orm:"allowed_models" description:"允许使用的模型列表(NULL=全部,空数组=无)"` // 允许使用的模型列表(NULL=全部,空数组=无)
|
||||
IsRecommended bool `json:"is_recommended" orm:"is_recommended" description:"是否推荐"` // 是否推荐
|
||||
SortOrder int `json:"sort_order" orm:"sort_order" description:"排序权重(数字越小越靠前)"` // 排序权重(数字越小越靠前)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
Name string `json:"name" orm:"name" description:"套餐显示名称"` // 套餐显示名称
|
||||
Identifier string `json:"identifier" orm:"identifier" description:"套餐唯一标识(free/basic/pro/enterprise)"` // 套餐唯一标识(free/basic/pro/enterprise)
|
||||
Description string `json:"description" orm:"description" description:"套餐描述(面向用户的营销文案)"` // 套餐描述(面向用户的营销文案)
|
||||
MonthlyPrice decimal.Decimal `json:"monthly_price" orm:"monthly_price" description:"月度价格(CNY)"` // 月度价格(CNY)
|
||||
YearlyPrice decimal.Decimal `json:"yearly_price" orm:"yearly_price" description:"年度价格(CNY,通常为月价×10)"` // 年度价格(CNY,通常为月价×10)
|
||||
Status string `json:"status" orm:"status" description:"状态:active(上架)/ archived(下架)"` // 状态:active(上架)/ archived(下架)
|
||||
MonthlyQuotaTokens int64 `json:"monthly_quota_tokens" orm:"monthly_quota_tokens" description:"每月 Token 配额(0=不限)"` // 每月 Token 配额(0=不限)
|
||||
AllowedModels []string `json:"allowed_models" orm:"allowed_models" description:"允许使用的模型列表(NULL=全部,空数组=无)"` // 允许使用的模型列表(NULL=全部,空数组=无)
|
||||
IsRecommended bool `json:"is_recommended" orm:"is_recommended" description:"是否推荐"` // 是否推荐
|
||||
SortOrder int `json:"sort_order" orm:"sort_order" description:"排序权重(数字越小越靠前)"` // 排序权重(数字越小越靠前)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
}
|
||||
@@ -6,17 +6,18 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// TntProjects is the golang structure for table tnt_projects.
|
||||
type TntProjects struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
|
||||
Name string `json:"name" orm:"name" description:"项目名称"` // 项目名称
|
||||
Description string `json:"description" orm:"description" description:"项目描述"` // 项目描述
|
||||
Status string `json:"status" orm:"status" description:"状态:active(活跃)/ archived(归档)/ budget_exhausted(预算耗尽)"` // 状态:active(活跃)/ archived(归档)/ budget_exhausted(预算耗尽)
|
||||
Budget float64 `json:"budget" orm:"budget" description:"项目预算上限(NUMERIC(20,10) 金额,NULL 表示不限制)"` // 项目预算上限(NUMERIC(20,10) 金额,NULL 表示不限制)
|
||||
CreatedBy int64 `json:"created_by" orm:"created_by" description:"创建者用户ID"` // 创建者用户ID
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
|
||||
Name string `json:"name" orm:"name" description:"项目名称"` // 项目名称
|
||||
Description string `json:"description" orm:"description" description:"项目描述"` // 项目描述
|
||||
Status string `json:"status" orm:"status" description:"状态:active(活跃)/ archived(归档)/ budget_exhausted(预算耗尽)"` // 状态:active(活跃)/ archived(归档)/ budget_exhausted(预算耗尽)
|
||||
Budget *decimal.Decimal `json:"budget" orm:"budget" description:"项目预算上限(NUMERIC(20,10) 金额,NULL 表示不限制)"` // 项目预算上限(NUMERIC(20,10) 金额,NULL 表示不限制)
|
||||
CreatedBy int64 `json:"created_by" orm:"created_by" description:"创建者用户ID"` // 创建者用户ID
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
}
|
||||
@@ -6,18 +6,19 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// TntTenantLevelConfigs is the golang structure for table tnt_tenant_level_configs.
|
||||
type TntTenantLevelConfigs struct {
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
Level int `json:"level" orm:"level" description:"等级号(1, 2, 3...)"` // 等级号(1, 2, 3...)
|
||||
Name string `json:"name" orm:"name" description:"等级名称"` // 等级名称
|
||||
CumulativeRechargeThreshold float64 `json:"cumulative_recharge_threshold" orm:"cumulative_recharge_threshold" description:"累计充值阈值(USD),达到此值自动升级"` // 累计充值阈值(USD),达到此值自动升级
|
||||
MaxMembers int `json:"max_members" orm:"max_members" description:"该等级最大成员数"` // 该等级最大成员数
|
||||
MaxConcurrency int `json:"max_concurrency" orm:"max_concurrency" description:"该等级最大并发数,0=无限"` // 该等级最大并发数,0=无限
|
||||
PriceMultiplier float64 `json:"price_multiplier" orm:"price_multiplier" description:"价格乘数(折扣,如 0.9=九折)"` // 价格乘数(折扣,如 0.9=九折)
|
||||
SortOrder int `json:"sort_order" orm:"sort_order" description:"排序权重"` // 排序权重
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
Id int64 `json:"id" orm:"id" description:""` //
|
||||
Level int `json:"level" orm:"level" description:"等级号(1, 2, 3...)"` // 等级号(1, 2, 3...)
|
||||
Name string `json:"name" orm:"name" description:"等级名称"` // 等级名称
|
||||
CumulativeRechargeThreshold decimal.Decimal `json:"cumulative_recharge_threshold" orm:"cumulative_recharge_threshold" description:"累计充值阈值(USD),达到此值自动升级"` // 累计充值阈值(USD),达到此值自动升级
|
||||
MaxMembers int `json:"max_members" orm:"max_members" description:"该等级最大成员数"` // 该等级最大成员数
|
||||
MaxConcurrency int `json:"max_concurrency" orm:"max_concurrency" description:"该等级最大并发数,0=无限"` // 该等级最大并发数,0=无限
|
||||
PriceMultiplier decimal.Decimal `json:"price_multiplier" orm:"price_multiplier" description:"价格乘数(折扣,如 0.9=九折)"` // 价格乘数(折扣,如 0.9=九折)
|
||||
SortOrder int `json:"sort_order" orm:"sort_order" description:"排序权重"` // 排序权重
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` //
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:""` //
|
||||
}
|
||||
@@ -6,30 +6,31 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// TntUsers is the golang structure for table tnt_users.
|
||||
type TntUsers struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
|
||||
Username string `json:"username" orm:"username" description:"用户名(租户内唯一)"` // 用户名(租户内唯一)
|
||||
Email string `json:"email" orm:"email" description:"邮箱地址(租户内唯一)"` // 邮箱地址(租户内唯一)
|
||||
PasswordHash string `json:"password_hash" orm:"password_hash" description:"密码哈希(bcrypt)"` // 密码哈希(bcrypt)
|
||||
DisplayName string `json:"display_name" orm:"display_name" description:"显示名称"` // 显示名称
|
||||
Role string `json:"role" orm:"role" description:"角色:owner(所有者)/ admin(管理员)/ member(成员)"` // 角色:owner(所有者)/ admin(管理员)/ member(成员)
|
||||
Status string `json:"status" orm:"status" description:"状态:active(正常)/ disabled(禁用)/ locked(锁定)"` // 状态:active(正常)/ disabled(禁用)/ locked(锁定)
|
||||
LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at" description:"最后登录时间"` // 最后登录时间
|
||||
LastLoginIp string `json:"last_login_ip" orm:"last_login_ip" description:"最后登录IP"` // 最后登录IP
|
||||
FailedAttempts int `json:"failed_attempts" orm:"failed_attempts" description:"连续登录失败次数(成功登录后归零)"` // 连续登录失败次数(成功登录后归零)
|
||||
LockedUntil *gtime.Time `json:"locked_until" orm:"locked_until" description:"锁定截止时间(连续5次失败后锁定30分钟)"` // 锁定截止时间(连续5次失败后锁定30分钟)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
TotpSecret string `json:"totp_secret" orm:"totp_secret" description:"TOTP 密钥(AES-256 加密存储)"` // TOTP 密钥(AES-256 加密存储)
|
||||
TotpEnabled bool `json:"totp_enabled" orm:"totp_enabled" description:"是否启用双因素认证"` // 是否启用双因素认证
|
||||
BackupCodes string `json:"backup_codes" orm:"backup_codes" description:"备用恢复码(bcrypt 哈希存储)"` // 备用恢复码(bcrypt 哈希存储)
|
||||
QuotaType string `json:"quota_type" orm:"quota_type" description:"额度限制类型:none(不限)/ total(总额)/ periodic(周期性)"` // 额度限制类型:none(不限)/ total(总额)/ periodic(周期性)
|
||||
QuotaLimit float64 `json:"quota_limit" orm:"quota_limit" description:"额度上限(USD),quota_type 为 none 时忽略"` // 额度上限(USD),quota_type 为 none 时忽略
|
||||
QuotaUsed float64 `json:"quota_used" orm:"quota_used" description:"已使用额度(USD)"` // 已使用额度(USD)
|
||||
QuotaPeriod string `json:"quota_period" orm:"quota_period" description:"周期类型:day / week / month(仅 periodic 时有效)"` // 周期类型:day / week / month(仅 periodic 时有效)
|
||||
QuotaResetAt *gtime.Time `json:"quota_reset_at" orm:"quota_reset_at" description:"上次额度重置时间(懒重置用)"` // 上次额度重置时间(懒重置用)
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID
|
||||
Username string `json:"username" orm:"username" description:"用户名(租户内唯一)"` // 用户名(租户内唯一)
|
||||
Email string `json:"email" orm:"email" description:"邮箱地址(租户内唯一)"` // 邮箱地址(租户内唯一)
|
||||
PasswordHash string `json:"password_hash" orm:"password_hash" description:"密码哈希(bcrypt)"` // 密码哈希(bcrypt)
|
||||
DisplayName string `json:"display_name" orm:"display_name" description:"显示名称"` // 显示名称
|
||||
Role string `json:"role" orm:"role" description:"角色:owner(所有者)/ admin(管理员)/ member(成员)"` // 角色:owner(所有者)/ admin(管理员)/ member(成员)
|
||||
Status string `json:"status" orm:"status" description:"状态:active(正常)/ disabled(禁用)/ locked(锁定)"` // 状态:active(正常)/ disabled(禁用)/ locked(锁定)
|
||||
LastLoginAt *gtime.Time `json:"last_login_at" orm:"last_login_at" description:"最后登录时间"` // 最后登录时间
|
||||
LastLoginIp string `json:"last_login_ip" orm:"last_login_ip" description:"最后登录IP"` // 最后登录IP
|
||||
FailedAttempts int `json:"failed_attempts" orm:"failed_attempts" description:"连续登录失败次数(成功登录后归零)"` // 连续登录失败次数(成功登录后归零)
|
||||
LockedUntil *gtime.Time `json:"locked_until" orm:"locked_until" description:"锁定截止时间(连续5次失败后锁定30分钟)"` // 锁定截止时间(连续5次失败后锁定30分钟)
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
TotpSecret string `json:"totp_secret" orm:"totp_secret" description:"TOTP 密钥(AES-256 加密存储)"` // TOTP 密钥(AES-256 加密存储)
|
||||
TotpEnabled bool `json:"totp_enabled" orm:"totp_enabled" description:"是否启用双因素认证"` // 是否启用双因素认证
|
||||
BackupCodes string `json:"backup_codes" orm:"backup_codes" description:"备用恢复码(bcrypt 哈希存储)"` // 备用恢复码(bcrypt 哈希存储)
|
||||
QuotaType string `json:"quota_type" orm:"quota_type" description:"额度限制类型:none(不限)/ total(总额)/ periodic(周期性)"` // 额度限制类型:none(不限)/ total(总额)/ periodic(周期性)
|
||||
QuotaLimit decimal.Decimal `json:"quota_limit" orm:"quota_limit" description:"额度上限(USD),quota_type 为 none 时忽略"` // 额度上限(USD),quota_type 为 none 时忽略
|
||||
QuotaUsed decimal.Decimal `json:"quota_used" orm:"quota_used" description:"已使用额度(USD)"` // 已使用额度(USD)
|
||||
QuotaPeriod string `json:"quota_period" orm:"quota_period" description:"周期类型:day / week / month(仅 periodic 时有效)"` // 周期类型:day / week / month(仅 periodic 时有效)
|
||||
QuotaResetAt *gtime.Time `json:"quota_reset_at" orm:"quota_reset_at" description:"上次额度重置时间(懒重置用)"` // 上次额度重置时间(懒重置用)
|
||||
}
|
||||
@@ -6,33 +6,34 @@ package entity
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// TskModelTasks is the golang structure for table tsk_model_tasks.
|
||||
type TskModelTasks struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
PublicTaskId string `json:"public_task_id" orm:"public_task_id" description:"对外公开的任务ID(task_xxxxx 格式),用于 API 响应和客户端查询"` // 对外公开的任务ID(task_xxxxx 格式),用于 API 响应和客户端查询
|
||||
Platform string `json:"platform" orm:"platform" description:"任务所属平台:sora、kling、suno、midjourney、volcengine"` // 任务所属平台:sora、kling、suno、midjourney、volcengine
|
||||
Action string `json:"action" orm:"action" description:"任务动作类型。通用:generate;Suno:music、lyrics;"` // 任务动作类型。通用:generate;Suno:music、lyrics;
|
||||
Status string `json:"status" orm:"status" description:"任务状态:NOT_START-未开始、SUBMITTED-已提交、IN_PROGRESS-进行中、SUCCESS-成功、FAILURE-失败"` // 任务状态:NOT_START-未开始、SUBMITTED-已提交、IN_PROGRESS-进行中、SUCCESS-成功、FAILURE-失败
|
||||
Progress string `json:"progress" orm:"progress" description:"任务进度,字符串格式百分比(如 0%、50%、100%)"` // 任务进度,字符串格式百分比(如 0%、50%、100%)
|
||||
FailReason string `json:"fail_reason" orm:"fail_reason" description:"任务失败原因文本"` // 任务失败原因文本
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"发起任务的租户ID"` // 发起任务的租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"发起任务的租户用户ID"` // 发起任务的租户用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"调用方使用的 API Key ID"` // 调用方使用的 API Key ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"转发请求的渠道ID"` // 转发请求的渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"用户请求的模型名称(如 sora-1.0-turbo、midjourney 等)"` // 用户请求的模型名称(如 sora-1.0-turbo、midjourney 等)
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游供应商实际使用的模型名称,可能与请求模型不同"` // 上游供应商实际使用的模型名称,可能与请求模型不同
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:"提交任务时的预扣金额(USD),任务完成后根据实际用量结算差额"` // 提交任务时的预扣金额(USD),任务完成后根据实际用量结算差额
|
||||
ActualCost float64 `json:"actual_cost" orm:"actual_cost" description:"任务实际消费金额(USD),成功时按此结算,失败时退还预扣金额"` // 任务实际消费金额(USD),成功时按此结算,失败时退还预扣金额
|
||||
BillingSettled bool `json:"billing_settled" orm:"billing_settled" description:"计费是否已结算(true 表示已完成预扣与实际金额的多退少补)"` // 计费是否已结算(true 表示已完成预扣与实际金额的多退少补)
|
||||
ResultUrl string `json:"result_url" orm:"result_url" description:"任务完成后的结果资源 URL(如生成的视频/图片/音频的下载地址)"` // 任务完成后的结果资源 URL(如生成的视频/图片/音频的下载地址)
|
||||
Data string `json:"data" orm:"data" description:"上游供应商返回的完整响应数据(JSONB),可返回给用户查看"` // 上游供应商返回的完整响应数据(JSONB),可返回给用户查看
|
||||
PrivateData string `json:"private_data" orm:"private_data" description:"内部私有数据(JSONB),含 upstream_task_id 等敏感字段,用于轮询上游状态,不返回给用户"` // 内部私有数据(JSONB),含 upstream_task_id 等敏感字段,用于轮询上游状态,不返回给用户
|
||||
SubmitTime *gtime.Time `json:"submit_time" orm:"submit_time" description:"任务提交到上游供应商的时间"` // 任务提交到上游供应商的时间
|
||||
StartTime *gtime.Time `json:"start_time" orm:"start_time" description:"上游供应商开始执行任务的时间"` // 上游供应商开始执行任务的时间
|
||||
FinishTime *gtime.Time `json:"finish_time" orm:"finish_time" description:"任务完成(成功或失败)的时间"` // 任务完成(成功或失败)的时间
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"记录创建时间"` // 记录创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"记录更新时间"` // 记录更新时间
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id"` // 任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
PublicTaskId string `json:"public_task_id" orm:"public_task_id" description:"对外公开的任务ID(task_xxxxx 格式),用于 API 响应和客户端查询"` // 对外公开的任务ID(task_xxxxx 格式),用于 API 响应和客户端查询
|
||||
Platform string `json:"platform" orm:"platform" description:"任务所属平台:sora、kling、suno、midjourney、volcengine"` // 任务所属平台:sora、kling、suno、midjourney、volcengine
|
||||
Action string `json:"action" orm:"action" description:"任务动作类型。通用:generate;Suno:music、lyrics;"` // 任务动作类型。通用:generate;Suno:music、lyrics;
|
||||
Status string `json:"status" orm:"status" description:"任务状态:NOT_START-未开始、SUBMITTED-已提交、IN_PROGRESS-进行中、SUCCESS-成功、FAILURE-失败"` // 任务状态:NOT_START-未开始、SUBMITTED-已提交、IN_PROGRESS-进行中、SUCCESS-成功、FAILURE-失败
|
||||
Progress string `json:"progress" orm:"progress" description:"任务进度,字符串格式百分比(如 0%、50%、100%)"` // 任务进度,字符串格式百分比(如 0%、50%、100%)
|
||||
FailReason string `json:"fail_reason" orm:"fail_reason" description:"任务失败原因文本"` // 任务失败原因文本
|
||||
TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"发起任务的租户ID"` // 发起任务的租户ID
|
||||
UserId int64 `json:"user_id" orm:"user_id" description:"发起任务的租户用户ID"` // 发起任务的租户用户ID
|
||||
ApiKeyId int64 `json:"api_key_id" orm:"api_key_id" description:"调用方使用的 API Key ID"` // 调用方使用的 API Key ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"转发请求的渠道ID"` // 转发请求的渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"用户请求的模型名称(如 sora-1.0-turbo、midjourney 等)"` // 用户请求的模型名称(如 sora-1.0-turbo、midjourney 等)
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游供应商实际使用的模型名称,可能与请求模型不同"` // 上游供应商实际使用的模型名称,可能与请求模型不同
|
||||
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount" orm:"pre_deduct_amount" description:"提交任务时的预扣金额(USD),任务完成后根据实际用量结算差额"` // 提交任务时的预扣金额(USD),任务完成后根据实际用量结算差额
|
||||
ActualCost decimal.Decimal `json:"actual_cost" orm:"actual_cost" description:"任务实际消费金额(USD),成功时按此结算,失败时退还预扣金额"` // 任务实际消费金额(USD),成功时按此结算,失败时退还预扣金额
|
||||
BillingSettled bool `json:"billing_settled" orm:"billing_settled" description:"计费是否已结算(true 表示已完成预扣与实际金额的多退少补)"` // 计费是否已结算(true 表示已完成预扣与实际金额的多退少补)
|
||||
ResultUrl string `json:"result_url" orm:"result_url" description:"任务完成后的结果资源 URL(如生成的视频/图片/音频的下载地址)"` // 任务完成后的结果资源 URL(如生成的视频/图片/音频的下载地址)
|
||||
Data string `json:"data" orm:"data" description:"上游供应商返回的完整响应数据(JSONB),可返回给用户查看"` // 上游供应商返回的完整响应数据(JSONB),可返回给用户查看
|
||||
PrivateData string `json:"private_data" orm:"private_data" description:"内部私有数据(JSONB),含 upstream_task_id 等敏感字段,用于轮询上游状态,不返回给用户"` // 内部私有数据(JSONB),含 upstream_task_id 等敏感字段,用于轮询上游状态,不返回给用户
|
||||
SubmitTime *gtime.Time `json:"submit_time" orm:"submit_time" description:"任务提交到上游供应商的时间"` // 任务提交到上游供应商的时间
|
||||
StartTime *gtime.Time `json:"start_time" orm:"start_time" description:"上游供应商开始执行任务的时间"` // 上游供应商开始执行任务的时间
|
||||
FinishTime *gtime.Time `json:"finish_time" orm:"finish_time" description:"任务完成(成功或失败)的时间"` // 任务完成(成功或失败)的时间
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"记录创建时间"` // 记录创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"记录更新时间"` // 记录更新时间
|
||||
RequestId string `json:"request_id" orm:"request_id" description:"任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id"` // 任务提交时的原始请求 ID(req_xxxxx),关联 aud_request_logs.request_id
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
-- +goose Up
|
||||
-- 金额 decimal 化迁移(第一步):把「非金额」小数列由 NUMERIC 改为 double precision。
|
||||
--
|
||||
-- 背景:系统即将把所有金额列在 Go 层映射为 decimal.Decimal(gf typeMapping: numeric→decimal 全局生效)。
|
||||
-- 为避免误伤评分/利用率/延迟/告警阈值等「非金额」小数列,先把这些列显式改为 double precision,
|
||||
-- 让「DB 列类型」成为「是否金额」的唯一事实源:NUMERIC=金额→decimal,double precision=非金额→float64。
|
||||
-- 分类依据见 docs/float64-decimal-field-classification.md(第②类,5 表 13 列)。
|
||||
--
|
||||
-- 这些列当前 Go 端已是 float64,改后代码零改动;double precision 足以承载 0-100 评分、利用率、延迟、通用阈值。
|
||||
|
||||
-- 渠道利用率阈值(0-1 借用/抢占阈值)
|
||||
ALTER TABLE chn_channels ALTER COLUMN sharing_threshold TYPE double precision USING sharing_threshold::double precision;
|
||||
ALTER TABLE chn_channels ALTER COLUMN preemption_threshold TYPE double precision USING preemption_threshold::double precision;
|
||||
|
||||
-- 渠道健康评分(0-100 评分/延迟/稳定性)
|
||||
ALTER TABLE chn_health_scores ALTER COLUMN success_rate TYPE double precision USING success_rate::double precision;
|
||||
ALTER TABLE chn_health_scores ALTER COLUMN latency_ms TYPE double precision USING latency_ms::double precision;
|
||||
ALTER TABLE chn_health_scores ALTER COLUMN stability_score TYPE double precision USING stability_score::double precision;
|
||||
ALTER TABLE chn_health_scores ALTER COLUMN health_score TYPE double precision USING health_score::double precision;
|
||||
|
||||
-- 渠道健康快照(历史评分)
|
||||
ALTER TABLE chn_health_snapshots ALTER COLUMN health_score TYPE double precision USING health_score::double precision;
|
||||
ALTER TABLE chn_health_snapshots ALTER COLUMN success_rate TYPE double precision USING success_rate::double precision;
|
||||
ALTER TABLE chn_health_snapshots ALTER COLUMN latency_ms TYPE double precision USING latency_ms::double precision;
|
||||
ALTER TABLE chn_health_snapshots ALTER COLUMN stability_score TYPE double precision USING stability_score::double precision;
|
||||
|
||||
-- 告警阈值(通用指标阈值/触发值,对接不同量纲,非金额)
|
||||
ALTER TABLE ops_alert_rules ALTER COLUMN threshold TYPE double precision USING threshold::double precision;
|
||||
ALTER TABLE ops_alert_events ALTER COLUMN trigger_value TYPE double precision USING trigger_value::double precision;
|
||||
ALTER TABLE ops_alert_events ALTER COLUMN threshold_value TYPE double precision USING threshold_value::double precision;
|
||||
|
||||
-- +goose Down
|
||||
-- 回滚:恢复为原始 NUMERIC 精度(与 000001 建表一致)。
|
||||
|
||||
ALTER TABLE ops_alert_events ALTER COLUMN threshold_value TYPE numeric(20,10) USING threshold_value::numeric(20,10);
|
||||
ALTER TABLE ops_alert_events ALTER COLUMN trigger_value TYPE numeric(20,10) USING trigger_value::numeric(20,10);
|
||||
ALTER TABLE ops_alert_rules ALTER COLUMN threshold TYPE numeric(20,10) USING threshold::numeric(20,10);
|
||||
|
||||
ALTER TABLE chn_health_snapshots ALTER COLUMN stability_score TYPE numeric(6,2) USING stability_score::numeric(6,2);
|
||||
ALTER TABLE chn_health_snapshots ALTER COLUMN latency_ms TYPE numeric(10,2) USING latency_ms::numeric(10,2);
|
||||
ALTER TABLE chn_health_snapshots ALTER COLUMN success_rate TYPE numeric(6,2) USING success_rate::numeric(6,2);
|
||||
ALTER TABLE chn_health_snapshots ALTER COLUMN health_score TYPE numeric(6,2) USING health_score::numeric(6,2);
|
||||
|
||||
ALTER TABLE chn_health_scores ALTER COLUMN health_score TYPE numeric(5,2) USING health_score::numeric(5,2);
|
||||
ALTER TABLE chn_health_scores ALTER COLUMN stability_score TYPE numeric(5,2) USING stability_score::numeric(5,2);
|
||||
ALTER TABLE chn_health_scores ALTER COLUMN latency_ms TYPE numeric(10,2) USING latency_ms::numeric(10,2);
|
||||
ALTER TABLE chn_health_scores ALTER COLUMN success_rate TYPE numeric(5,2) USING success_rate::numeric(5,2);
|
||||
|
||||
ALTER TABLE chn_channels ALTER COLUMN preemption_threshold TYPE numeric(5,2) USING preemption_threshold::numeric(5,2);
|
||||
ALTER TABLE chn_channels ALTER COLUMN sharing_threshold TYPE numeric(5,2) USING sharing_threshold::numeric(5,2);
|
||||
@@ -0,0 +1,220 @@
|
||||
# Relay 层浮点运算风险分析报告
|
||||
|
||||
**分析范围**:`relay/` 目录(137 个非测试文件)
|
||||
**分析日期**:2026-07-23
|
||||
**分析目标**:扫描涉及金额/计费的 float64 运算,识别精度丢失风险
|
||||
|
||||
---
|
||||
|
||||
## 一、风险等级定义
|
||||
|
||||
| 风险等级 | 定义 | 影响范围 |
|
||||
|---------|------|---------|
|
||||
| **🔴 高危** | 金额直接链式运算(乘除混合)、累计误差会传播到账本 | 钱包扣费、账单金额、对账 |
|
||||
| **🟡 中危** | Token 计数 → 费用换算路径存在浮点除法,但单次计算 | 单笔计费、用量统计 |
|
||||
| **🟢 低危** | 仅用于估算、权重计算、非金额场景 | 调度权重、粗略估算 |
|
||||
|
||||
---
|
||||
|
||||
## 二、风险点清单
|
||||
|
||||
### 🔴 高危:金额直接链式运算(0 处)
|
||||
|
||||
**好消息:relay 层没有直接的链式金额运算**。
|
||||
|
||||
所有金额计算(`PreDeduct`/`Settle`/`EstimateTaskCost`)都通过接口委托给 `internal/logic/billing/`,relay 层只传递 `float64` 结果,不进行二次运算。
|
||||
|
||||
**验证点**:
|
||||
- `common/billing.go`:接口定义,只传递 `float64` 参数和返回值
|
||||
- `common/task_billing.go`:接口定义,只传递 `float64` 参数和返回值
|
||||
- `handler/relay_handler.go:393`:`PreDeduct` 调用,接收 `float64` 赋值给 `preDeductAmount`,无后续运算
|
||||
- `handler/task_handler.go:191`:`PreDeductTask` 调用,接收 `float64` 赋值给 `preDeductAmount`,无后续运算
|
||||
|
||||
**结论**:relay 层作为代理层,金额字段为**透传类型**,不承担计费逻辑,风险转移到 `internal/logic/billing/`。
|
||||
|
||||
---
|
||||
|
||||
### 🟡 中危:Token 计数 → 费用换算路径(8 处)
|
||||
|
||||
#### 1. **音频 Token 估算**(3 处)
|
||||
|
||||
| 文件 | 行号 | 代码 | 风险说明 |
|
||||
|------|------|------|---------|
|
||||
| `channel/openai/audio.go` | 50 | `estimatedTokens := len(body) / 1000` | TTS 音频字节数除以 1000 估算 token |
|
||||
| `channel/openai/audio.go` | 94 | `estimatedTokens := int(verboseResp.Duration/60.0) * 150` | **float64 除法** + 整数乘法:音频时长(秒)÷ 60(分钟)× 150 token/分钟 |
|
||||
| `channel/openai/audio.go` | 107 | `estimatedTokens := len(simpleResp.Text) / 4` | 文本长度除以 4 估算 token |
|
||||
|
||||
**风险分析**:
|
||||
- `Duration/60.0` 产生浮点除法,**若 Duration 为整数秒(如 90 秒),则 90/60.0 = 1.5,int(1.5) = 1,丢失 0.5 分钟**
|
||||
- 丢失部分:`0.5 * 150 = 75 token`,约占实际 `90/60 * 150 = 225 token` 的 **33%**
|
||||
- 单笔计费误差传播:若 token 单价为 $0.0001/token,则单次误差 = `75 * 0.0001 = $0.0075`,累计 10000 次 = **$75 短扣**
|
||||
|
||||
**修复建议**:
|
||||
```go
|
||||
// 当前(有误差)
|
||||
estimatedTokens := int(verboseResp.Duration/60.0) * 150
|
||||
|
||||
// 修复方案:先整数运算再取整
|
||||
estimatedTokens := (int(verboseResp.Duration) * 150 + 30) / 60 // +30 为四舍五入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. **文本 Token 估算**(5 处)
|
||||
|
||||
| 文件 | 行号 | 代码 | 用途 |
|
||||
|------|------|------|------|
|
||||
| `channel/claude/response.go` | 253, 297 | `estimated := responseTextBuf.Len() / 4` | Claude 流式响应 token 估算 |
|
||||
| `channel/openai/converter.go` | 1103, 1349 | `estimated := len(text) / 4` | OpenAI 响应 token 估算 |
|
||||
| `channel/openai/responses.go` | 485 | `usage.CompletionTokens = len(text) / 4` | 补全 token 估算 |
|
||||
| `channel/openai/stream.go` | 69, 131 | `estimated := responseTextBuf.Len() / 4` | 流式 token 估算 |
|
||||
| `channel/openai/realtime.go` | 224 | `estimated := len(message) / 4` | Realtime API 消息估算 |
|
||||
| `handler/relay_handler.go` | 840 | `return len(body) / 4` | 请求 token 估算 |
|
||||
| `helper/stream.go` | 116 | `return (len(text) + 3) / 4` | 通用 token 估算(**四舍五入**) |
|
||||
|
||||
**风险分析**:
|
||||
- **整数除法**:`len(text) / 4` 向下取整,1-3 字符直接归零(如 "Hi" → 0 token)
|
||||
- 误差范围:单次最大丢失 3 字符 ≈ 0.75 token,累计误差取决于短文本频率
|
||||
- **`helper/stream.go:116` 已做四舍五入**:`(len(text) + 3) / 4` 等价于 `round(len/4)`,**其他 7 处未做**
|
||||
|
||||
**影响评估**:
|
||||
- 若上游返回完整 `usage`,这些估算值会被覆盖,**无风险**
|
||||
- 若上游无 `usage`(如音频 TTS、某些流式端点),估算值直接进入计费,**有短扣风险**
|
||||
|
||||
**修复建议**:
|
||||
```go
|
||||
// 统一使用 helper/stream.go 的四舍五入版本
|
||||
import "github.com/qianfree/team-api/relay/helper"
|
||||
estimated := helper.EstimateTokens(text)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟢 低危:非金额场景(3 处)
|
||||
|
||||
#### 1. **异步任务计费比率**(2 处)
|
||||
|
||||
| 文件 | 行号 | 代码 | 用途 |
|
||||
|------|------|------|------|
|
||||
| `taskchannel/volcengine/constants.go` | 17-18 | `28.0 / 46.0`, `22.0 / 37.0` | 视频输入折扣比率(含视频/不含视频单价之比) |
|
||||
| `taskchannel/volcengine/adaptor.go` | 452-462 | `resolutionMultiplier()` 返回 `1.0/2.25/5.06` | 分辨率乘数(相对于 480p) |
|
||||
|
||||
**风险分析**:
|
||||
- `28.0 / 46.0 ≈ 0.6086956521739130...`(无限不循环小数),存储为 `float64` 后精度 ~15 位有效数字
|
||||
- 这些比率**用于 `EstimateTaskCost` 的 `ratios` 参数**,最终传递到 `internal/logic/billing/` 计算费用
|
||||
- **风险转移**:若 billing 层用 `ratio * basePrice` 计算,累计误差由 billing 层承担
|
||||
|
||||
**当前状态**:低危,因为:
|
||||
1. 比率为**常量**,不累计(每次计费重新取值)
|
||||
2. 单次计算:`cost = basePrice * resolution * videoInputRatio`,误差 < 0.01%
|
||||
3. 若 billing 层改用 `NUMERIC` 精确计算,此处需改为传递分数字符串(如 `"28/46"`)
|
||||
|
||||
---
|
||||
|
||||
#### 2. **调度权重降权**(1 处)
|
||||
|
||||
| 文件 | 行号 | 代码 | 用途 |
|
||||
|------|------|------|------|
|
||||
| `scheduler/scheduler.go` | 137-143 | `w = w / 2` / `w = w / 4` | 根据健康度降低渠道权重 |
|
||||
|
||||
**风险分析**:
|
||||
- **整数除法**:`w` 为 `int` 类型,`w / 2` 和 `w / 4` 向下取整
|
||||
- 仅影响调度权重(非金额),且每次调度重新计算,无累计误差
|
||||
- **无风险**
|
||||
|
||||
---
|
||||
|
||||
## 三、风险传播路径
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ relay/ 层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Token 估算(🟡 中危) │
|
||||
│ ├─ audio.go: Duration/60.0 * 150 → estimatedTokens │
|
||||
│ ├─ stream.go: len(text) / 4 → estimatedTokens │
|
||||
│ └─ 估算值 → common.Usage{TotalTokens: estimatedTokens} │
|
||||
│ │
|
||||
│ 2. 预扣/结算(✅ 无运算) │
|
||||
│ ├─ PreDeduct(estimatedTokens, maxTokens) → float64 │
|
||||
│ ├─ Settle(usage, preDeductAmount) → float64 │
|
||||
│ └─ relay 层**只传递值,不计算** │
|
||||
│ │
|
||||
└──────────────────┬───────────────────────────────────────────┘
|
||||
│ 接口调用
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ internal/logic/billing/ 层 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ⚠️ 金额计算核心逻辑(需单独扫描) │
|
||||
│ ├─ cost = inputTokens * inputPrice │
|
||||
│ ├─ cost = cost * modelMultiplier * tenantMultiplier │
|
||||
│ ├─ actualCost = baseCost (链式运算?) │
|
||||
│ └─ 若使用 float64 链式运算 → 🔴 高危 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**关键发现**:
|
||||
- relay 层的 **float64 风险已隔离**,金额运算全部在 billing 层
|
||||
- **但 relay 层的 token 估算误差会作为输入传播到 billing 层**
|
||||
|
||||
---
|
||||
|
||||
## 四、修复优先级
|
||||
|
||||
| 优先级 | 问题 | 文件 | 修复方案 |
|
||||
|--------|------|------|---------|
|
||||
| **P0** | 音频时长浮点除法短扣 | `channel/openai/audio.go:94` | 改为整数运算:`(Duration * 150 + 30) / 60` |
|
||||
| **P1** | 文本 token 估算未四舍五入 | 7 处(见中危清单) | 统一调用 `helper.EstimateTokens(text)` |
|
||||
| **P2** | 任务计费比率浮点常量 | `taskchannel/volcengine/constants.go` | billing 层改用分数或 NUMERIC 后,此处传递字符串 `"28/46"` |
|
||||
|
||||
---
|
||||
|
||||
## 五、Relay 层结论
|
||||
|
||||
✅ **relay 层金额计算架构安全**:
|
||||
- 无链式浮点运算
|
||||
- 所有 `cost/amount/quota` 字段为**接口传递类型**,不在 relay 层二次计算
|
||||
- 金额逻辑封装在 `internal/logic/billing/`,符合单一职责原则
|
||||
|
||||
⚠️ **存在 token 估算精度问题**:
|
||||
- 音频时长换算短扣约 **33%**(P0 修复)
|
||||
- 文本长度估算短扣 0-3 字符(P1 修复)
|
||||
|
||||
🔍 **下一步行动**:
|
||||
1. **立即修复 `audio.go:94` 的浮点除法**(影响所有音频计费)
|
||||
2. 统一 token 估算为 `helper.EstimateTokens`
|
||||
3. **扫描 `internal/logic/billing/` 层**(真正的金额运算核心)
|
||||
|
||||
---
|
||||
|
||||
## 六、Billing 层待扫描清单
|
||||
|
||||
基于接口定义,billing 层需重点扫描以下计算路径:
|
||||
|
||||
```go
|
||||
// common/billing.go 接口定义暴露的计算链路
|
||||
type SettlementResult struct {
|
||||
PreDeductAmount float64 // 预扣金额
|
||||
BaseCost float64 // 基础费用 = inputTokens * inputPrice + outputTokens * outputPrice
|
||||
ActualCost float64 // 实际费用 = BaseCost * modelMultiplier * tenantMultiplier
|
||||
RefundAmount float64 // 退款 = PreDeductAmount - ActualCost (if ActualCost < PreDeduct)
|
||||
SupplementAmount float64 // 补扣 = ActualCost - PreDeductAmount (if ActualCost > PreDeduct)
|
||||
// ... 多个 float64 字段的加减乘除 ...
|
||||
}
|
||||
```
|
||||
|
||||
**高危操作**:
|
||||
1. `BaseCost = inputTokens * inputPrice + outputTokens * outputPrice + cacheTokens * cachePrice + ...`(多项累加)
|
||||
2. `ActualCost = BaseCost * modelMultiplier * tenantMultiplier`(链式乘法)
|
||||
3. `RefundAmount = PreDeductAmount - ActualCost`(可能为负,需 abs)
|
||||
4. 钱包扣费:`newBalance = oldBalance - ActualCost`(累计误差传播到账本)
|
||||
|
||||
**需验证**:billing 层是否已使用 `NUMERIC` 类型 + `decimal` 库,或仍在用 `float64`?
|
||||
|
||||
---
|
||||
|
||||
**报告结束**
|
||||
@@ -1,15 +1,19 @@
|
||||
package common
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// TaskBillingProvider 异步任务计费接口
|
||||
// 异步任务计费基于时长/分辨率等比率,非 token 数
|
||||
type TaskBillingProvider interface {
|
||||
// EstimateTaskCost 估算任务费用
|
||||
EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (float64, error)
|
||||
EstimateTaskCost(ctx context.Context, tenantID int64, modelName string, ratios map[string]float64) (decimal.Decimal, error)
|
||||
|
||||
// PreDeductTask 预扣任务费用
|
||||
PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost float64, modelName string) (float64, error)
|
||||
PreDeductTask(ctx context.Context, tenantID int64, requestID string, estimatedCost decimal.Decimal, modelName string) (decimal.Decimal, error)
|
||||
|
||||
// CheckRateLimit QPS 限流检查
|
||||
CheckRateLimit(ctx context.Context, tenantID, userID, apiKeyID int64, keyQPS int) (allowed bool, limitLevel string, limit int, remaining int, resetAt int64)
|
||||
@@ -21,22 +25,22 @@ type TaskBillingProvider interface {
|
||||
ReleaseApiKeyConcurrent(ctx context.Context, apiKeyID int64)
|
||||
|
||||
// CheckApiKeyQuota 检查 API Key 额度是否足够
|
||||
CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount float64) error
|
||||
CheckApiKeyQuota(ctx context.Context, apiKeyID int64, preDeductAmount decimal.Decimal) error
|
||||
|
||||
// SettleTaskSuccess 任务成功结算(含计费快照)
|
||||
SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount float64, totalTokens, completionTokens int, ratios map[string]float64, taskID string) (*SettlementResult, error)
|
||||
SettleTaskSuccess(ctx context.Context, tenantID, userID, apiKeyID, channelID int64, modelName, requestID string, actualCost, preDeductAmount decimal.Decimal, totalTokens, completionTokens int, ratios map[string]float64, taskID string) (*SettlementResult, error)
|
||||
|
||||
// SettleTaskFailed 任务失败退还预扣
|
||||
SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount float64) error
|
||||
SettleTaskFailed(ctx context.Context, tenantID int64, requestID string, preDeductAmount decimal.Decimal) error
|
||||
|
||||
// IncrApiKeyQuotaUsed 结算后累加 API Key 已用额度
|
||||
IncrApiKeyQuotaUsed(ctx context.Context, apiKeyID int64, amount float64)
|
||||
IncrApiKeyQuotaUsed(ctx context.Context, apiKeyID int64, amount decimal.Decimal)
|
||||
|
||||
// AdjustTaskBilling 调整预扣金额(提交后上游确认了新参数)
|
||||
AdjustTaskBilling(ctx context.Context, tenantID int64, requestID string, preDeductAmount, newEstimatedCost float64) (float64, error)
|
||||
AdjustTaskBilling(ctx context.Context, tenantID int64, requestID string, preDeductAmount, newEstimatedCost decimal.Decimal) (decimal.Decimal, error)
|
||||
|
||||
// RecalculateByTokens 根据上游返回的 total_tokens 重算费用
|
||||
// totalTokens: 上游返回的 token 计费单位
|
||||
// ratios: 提交时保存的计费比率(如 video_input 折扣)
|
||||
RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64) (float64, error)
|
||||
RecalculateByTokens(ctx context.Context, tenantID int64, modelName string, totalTokens int, ratios map[string]float64) (decimal.Decimal, error)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
// TaskDataProvider 异步任务数据持久化接口
|
||||
@@ -55,9 +57,9 @@ type AsyncTask struct {
|
||||
ModelName string `json:"model_name"`
|
||||
UpstreamModel string `json:"upstream_model,omitempty"`
|
||||
|
||||
PreDeductAmount float64 `json:"pre_deduct_amount"`
|
||||
ActualCost float64 `json:"actual_cost"`
|
||||
BillingSettled bool `json:"billing_settled"`
|
||||
PreDeductAmount decimal.Decimal `json:"pre_deduct_amount"`
|
||||
ActualCost decimal.Decimal `json:"actual_cost"`
|
||||
BillingSettled bool `json:"billing_settled"`
|
||||
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
|
||||
@@ -181,7 +181,7 @@ func HandleTaskSubmit(
|
||||
writeTaskError(rc.Writer, http.StatusInternalServerError, "estimate cost failed: "+err.Error(), "")
|
||||
return
|
||||
}
|
||||
g.Log().Debugf(ctx, "HandleTaskSubmit: estimatedCost=%.4f", estimatedCost)
|
||||
g.Log().Debugf(ctx, "HandleTaskSubmit: estimatedCost=%.4f", estimatedCost.InexactFloat64())
|
||||
|
||||
if err := billingProvider.CheckApiKeyQuota(ctx, rc.ApiKeyID, estimatedCost); err != nil {
|
||||
writeTaskError(rc.Writer, http.StatusPaymentRequired, "API key quota exceeded", "")
|
||||
@@ -199,7 +199,7 @@ func HandleTaskSubmit(
|
||||
requestBody, err := adaptor.BuildRequestBody(ctx, info, body)
|
||||
if err != nil {
|
||||
if err := billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount); err != nil {
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount, err)
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err)
|
||||
}
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: build request failed, model=%s, err=%v", modelName, err)
|
||||
writeTaskError(rc.Writer, http.StatusInternalServerError, "build request failed: "+err.Error(), "")
|
||||
@@ -209,7 +209,7 @@ func HandleTaskSubmit(
|
||||
resp, err := adaptor.DoRequest(ctx, info, requestBody)
|
||||
if err != nil {
|
||||
if err := billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount); err != nil {
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount, err)
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err)
|
||||
}
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: upstream request failed, model=%s, err=%v", modelName, err)
|
||||
writeTaskError(rc.Writer, http.StatusBadGateway, "upstream request failed: "+err.Error(), "")
|
||||
@@ -223,7 +223,7 @@ func HandleTaskSubmit(
|
||||
upstreamTaskID, taskData, taskErr := adaptor.DoResponse(ctx, resp, info)
|
||||
if taskErr != nil {
|
||||
if err := billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount); err != nil {
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount, err)
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err)
|
||||
}
|
||||
g.Log().Warningf(ctx, "HandleTaskSubmit: upstream response error, model=%s, status=%d, message=%q, body=%s", modelName, taskErr.StatusCode, taskErr.Message, string(taskData))
|
||||
writeTaskError(rc.Writer, taskErr.StatusCode, taskErr.Message, taskErr.ErrCode)
|
||||
@@ -264,7 +264,10 @@ func HandleTaskSubmit(
|
||||
"billing_context": map[string]any{
|
||||
"ratios": finalRatios,
|
||||
"model_name": modelName,
|
||||
"pre_deduct": preDeductAmount,
|
||||
// pre_deduct 快照落 JSONB,读取端 privateData.PreDeduct 为 float64。
|
||||
// decimal 默认 MarshalJSON 带引号("0.1"),会导致读取端反序列化整个 blob 失败,
|
||||
// 进而 Ratios 也读不出、轮询判为「invalid private data」。故此处显式转 float64。
|
||||
"pre_deduct": preDeductAmount.InexactFloat64(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -289,7 +292,7 @@ func HandleTaskSubmit(
|
||||
|
||||
if err := dataProvider.CreateTask(ctx, task); err != nil {
|
||||
if err := billingProvider.SettleTaskFailed(ctx, rc.TenantID, rc.RequestID, preDeductAmount); err != nil {
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount, err)
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: SettleTaskFailed error, requestID=%s, amount=%.6f, err=%v", rc.RequestID, preDeductAmount.InexactFloat64(), err)
|
||||
}
|
||||
g.Log().Errorf(ctx, "HandleTaskSubmit: create task record failed, publicTaskID=%s, model=%s, err=%v", publicTaskID, modelName, err)
|
||||
writeTaskError(rc.Writer, http.StatusInternalServerError, "create task record failed: "+err.Error(), "")
|
||||
|
||||
Reference in new issue
Block a user