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:
chengqian committed 2026-07-23 22:08:54 +08:00
1 parent 95e5283e28
commit 108078614d
59 files changed
+1217 -748

No files matched your search

+8
View File
@@ -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`)
+8 -6
View File
@@ -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
)
+14 -10
View File
@@ -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=
+3 -1
View File
@@ -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{
+2 -1
View File
@@ -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
+17 -10
View File
@@ -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,
}
+26 -10
View File
@@ -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
+3 -2
View File
@@ -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,
+46 -12
View File
@@ -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 {
+9 -8
View File
@@ -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 兑换比例
+10 -9
View File
@@ -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
View File
@@ -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
}
+25 -23
View File
@@ -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)
}
}
+18 -17
View File
@@ -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))
}
+10
View File
@@ -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) {
+6 -6
View File
@@ -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))
}
}
+71 -62
View File
@@ -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
}
+7 -7
View File
@@ -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)。
+15 -14
View File
@@ -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,
+7 -3
View File
@@ -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: &quotaLimitDecimal,
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
}
+8 -4
View File
@@ -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).
+10 -10
View File
@@ -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,
}
+10 -9
View File
@@ -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"`
+6 -3
View File
@@ -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()
// 标记为已支付(自动续费默认成功)
+10 -9
View File
@@ -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,
+5 -2
View File
@@ -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
}
+12 -18
View File
@@ -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
+7 -6
View File
@@ -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)))
}
}
+3 -2
View File
@@ -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,
+7 -3
View File
@@ -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 = &quotaDecimal
}
result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert()
+19 -18
View File
@@ -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(项目密钥)
}
+11 -10
View File
@@ -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)
}
+13 -12
View File
@@ -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 价格(直接定价)
}
+18 -17
View File
@@ -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}]
}
+10 -9
View File
@@ -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 // 更新时间
}
+19 -18
View File
@@ -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 时有值)
}
+34 -33
View File
@@ -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)
}
+19 -18
View File
@@ -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)
}
+58 -57
View File
@@ -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,普通请求为空
}
+11 -10
View File
@@ -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)
}
+13 -12
View File
@@ -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 价格(直接定价)
}
+18 -17
View File
@@ -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}]
}
+23 -22
View File
@@ -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:""` //
}
+17 -16
View File
@@ -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:""` //
}
+10 -9
View File
@@ -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:"更新时间"` // 更新时间
}
+16 -15
View File
@@ -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:""` //
}
+13 -12
View File
@@ -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:"更新时间"` // 更新时间
}
+14 -13
View File
@@ -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:"更新时间"` // 更新时间
}
+10 -9
View File
@@ -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:""` //
}
+23 -22
View File
@@ -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:"上次额度重置时间(懒重置用)"` // 上次额度重置时间(懒重置用)
}
+26 -25
View File
@@ -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);
+220
View File
@@ -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`?
---
**报告结束**
+13 -9
View File
@@ -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)
}
+5 -3
View File
@@ -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"`
+9 -6
View File
@@ -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(), "")