diff --git a/CLAUDE.md b/CLAUDE.md index 06a4252..d239ad4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) diff --git a/go.mod b/go.mod index 9670cca..8ec9f50 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index b92283a..ec71310 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/handler/relay/sync_image.go b/internal/handler/relay/sync_image.go index bde66eb..64c93df 100644 --- a/internal/handler/relay/sync_image.go +++ b/internal/handler/relay/sync_image.go @@ -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{ diff --git a/internal/logic/admin/dashboard.go b/internal/logic/admin/dashboard.go index 2f99713..e78656e 100644 --- a/internal/logic/admin/dashboard.go +++ b/internal/logic/admin/dashboard.go @@ -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 diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index 5f683df..60880d3 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -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, } diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index 716f030..03e6f3f 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -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 diff --git a/internal/logic/admin/tenant_level.go b/internal/logic/admin/tenant_level.go index 3972bd4..cde7579 100644 --- a/internal/logic/admin/tenant_level.go +++ b/internal/logic/admin/tenant_level.go @@ -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, diff --git a/internal/logic/admin/tenant_model.go b/internal/logic/admin/tenant_model.go index 3976067..db1c1d6 100644 --- a/internal/logic/admin/tenant_model.go +++ b/internal/logic/admin/tenant_model.go @@ -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 { diff --git a/internal/logic/billing/currency.go b/internal/logic/billing/currency.go index d0537ed..da5e593 100644 --- a/internal/logic/billing/currency.go +++ b/internal/logic/billing/currency.go @@ -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 兑换比例 diff --git a/internal/logic/billing/level.go b/internal/logic/billing/level.go index ceedb47..01747fc 100644 --- a/internal/logic/billing/level.go +++ b/internal/logic/billing/level.go @@ -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). diff --git a/internal/logic/billing/money.go b/internal/logic/billing/money.go index dd1d0cd..fe8fda0 100644 --- a/internal/logic/billing/money.go +++ b/internal/logic/billing/money.go @@ -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 } diff --git a/internal/logic/billing/money_test.go b/internal/logic/billing/money_test.go index 4cf6147..72a8d0b 100644 --- a/internal/logic/billing/money_test.go +++ b/internal/logic/billing/money_test.go @@ -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) } } diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index 68cd81e..be71619 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -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)) } diff --git a/internal/logic/billing/pricing_test.go b/internal/logic/billing/pricing_test.go index f6dc62d..313920a 100644 --- a/internal/logic/billing/pricing_test.go +++ b/internal/logic/billing/pricing_test.go @@ -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) { diff --git a/internal/logic/billing/reconciliation.go b/internal/logic/billing/reconciliation.go index 8d68fcb..cee4308 100644 --- a/internal/logic/billing/reconciliation.go +++ b/internal/logic/billing/reconciliation.go @@ -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)) } } diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 1f81526..5be0efe 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -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 } diff --git a/internal/logic/billing/task_billing_test.go b/internal/logic/billing/task_billing_test.go index 7ebed4a..2c3e3dd 100644 --- a/internal/logic/billing/task_billing_test.go +++ b/internal/logic/billing/task_billing_test.go @@ -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)。 diff --git a/internal/logic/billing/wallet.go b/internal/logic/billing/wallet.go index 7f40c0d..2f84d1f 100644 --- a/internal/logic/billing/wallet.go +++ b/internal/logic/billing/wallet.go @@ -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, diff --git a/internal/logic/open/open_api.go b/internal/logic/open/open_api.go index 7c5887b..6741e82 100644 --- a/internal/logic/open/open_api.go +++ b/internal/logic/open/open_api.go @@ -15,6 +15,7 @@ import ( v1 "github.com/qianfree/team-api/api/open/v1" "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/logic/relay" "github.com/qianfree/team-api/internal/middleware" @@ -459,6 +460,7 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v if quotaLimit < 0 { quotaLimit = 0 } + quotaLimitDecimal := billing.NewFromFloat(quotaLimit) result, err := dao.ApiKeys.Ctx(ctx).Data(do.ApiKeys{ TenantId: tenantID, @@ -467,7 +469,7 @@ func (s *sOpen) OpenKeyCreate(ctx context.Context, req *v1.OpenKeyCreateReq) (*v KeyPrefix: prefix, Scope: "full", Status: "active", - TotalQuota: quotaLimit, + TotalQuota: "aLimitDecimal, KeyType: "project", }).Insert() if err != nil { @@ -780,7 +782,8 @@ func (s *sOpen) OpenProjectCreate(ctx context.Context, req *v1.OpenProjectCreate } if req.Budget > 0 { - insertData.Budget = req.Budget + budgetDecimal := billing.NewFromFloat(req.Budget) + insertData.Budget = &budgetDecimal } result, err := dao.TntProjects.Ctx(ctx).Data(insertData).Insert() @@ -903,7 +906,8 @@ func (s *sOpen) OpenProjectUpdate(ctx context.Context, req *v1.OpenProjectUpdate } if req.Budget != nil { if *req.Budget > 0 { - data.Budget = *req.Budget + budgetDecimal := billing.NewFromFloat(*req.Budget) + data.Budget = &budgetDecimal } hasUpdate = true } diff --git a/internal/logic/payment/fulfill.go b/internal/logic/payment/fulfill.go index e54e852..231e55a 100644 --- a/internal/logic/payment/fulfill.go +++ b/internal/logic/payment/fulfill.go @@ -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). diff --git a/internal/logic/task/async_polling.go b/internal/logic/task/async_polling.go index 0d9a9ce..35db34a 100644 --- a/internal/logic/task/async_polling.go +++ b/internal/logic/task/async_polling.go @@ -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, } diff --git a/internal/logic/task/async_provider.go b/internal/logic/task/async_provider.go index 6fdea5a..6d21923 100644 --- a/internal/logic/task/async_provider.go +++ b/internal/logic/task/async_provider.go @@ -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"` diff --git a/internal/logic/task/auto_renew.go b/internal/logic/task/auto_renew.go index 8d7be80..777ea2f 100644 --- a/internal/logic/task/auto_renew.go +++ b/internal/logic/task/auto_renew.go @@ -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() // 标记为已支付(自动续费默认成功) diff --git a/internal/logic/task/sync_image_worker.go b/internal/logic/task/sync_image_worker.go index 0643bca..4a46d47 100644 --- a/internal/logic/task/sync_image_worker.go +++ b/internal/logic/task/sync_image_worker.go @@ -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, diff --git a/internal/logic/tenant/api_key.go b/internal/logic/tenant/api_key.go index 9cd5af4..679687a 100644 --- a/internal/logic/tenant/api_key.go +++ b/internal/logic/tenant/api_key.go @@ -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 } diff --git a/internal/logic/tenant/billing.go b/internal/logic/tenant/billing.go index 425af2a..d743087 100644 --- a/internal/logic/tenant/billing.go +++ b/internal/logic/tenant/billing.go @@ -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 diff --git a/internal/logic/tenant/order.go b/internal/logic/tenant/order.go index b7ae7d0..028123d 100644 --- a/internal/logic/tenant/order.go +++ b/internal/logic/tenant/order.go @@ -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))) } } diff --git a/internal/logic/tenant/plan.go b/internal/logic/tenant/plan.go index efe1b4b..3bef63e 100644 --- a/internal/logic/tenant/plan.go +++ b/internal/logic/tenant/plan.go @@ -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, diff --git a/internal/logic/tenant/project.go b/internal/logic/tenant/project.go index 3960736..4f08f18 100644 --- a/internal/logic/tenant/project.go +++ b/internal/logic/tenant/project.go @@ -10,6 +10,7 @@ import ( "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" "github.com/qianfree/team-api/internal/logic/relay" "github.com/qianfree/team-api/internal/model/do" @@ -235,7 +236,8 @@ func (s *sTenant) ProjectCreate(ctx context.Context, req *v1.TenantProjectCreate insertData.Description = req.Description } if req.Budget > 0 { - insertData.Budget = req.Budget + budgetDecimal := billing.NewFromFloat(req.Budget) + insertData.Budget = &budgetDecimal } result, err := dao.TntProjects.Ctx(ctx).Data(insertData).Insert() @@ -281,7 +283,8 @@ func (s *sTenant) ProjectUpdate(ctx context.Context, req *v1.TenantProjectUpdate updateData.Description = req.Description } if req.Budget > 0 { - updateData.Budget = req.Budget + budgetDecimal := billing.NewFromFloat(req.Budget) + updateData.Budget = &budgetDecimal } else if req.Budget == 0 { updateData.Budget = nil } @@ -561,7 +564,8 @@ func (s *sTenant) ProjectApiKeyCreate(ctx context.Context, req *v1.TenantProject if *req.TotalQuota < 0 { return nil, common.NewBadRequestError("总额度不能小于 0") } - insertData.TotalQuota = *req.TotalQuota + quotaDecimal := billing.NewFromFloat(*req.TotalQuota) + insertData.TotalQuota = "aDecimal } result, err := dao.ApiKeys.Ctx(ctx).Data(insertData).Insert() diff --git a/internal/model/do/api_keys.go b/internal/model/do/api_keys.go index d7efd08..2ab8409 100644 --- a/internal/model/do/api_keys.go +++ b/internal/model/do/api_keys.go @@ -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(项目密钥) } diff --git a/internal/model/do/bil_wallets.go b/internal/model/do/bil_wallets.go index 0c752e2..67fdda5 100644 --- a/internal/model/do/bil_wallets.go +++ b/internal/model/do/bil_wallets.go @@ -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) } diff --git a/internal/model/do/mdl_pricing.go b/internal/model/do/mdl_pricing.go index 0e264f9..5e2c67e 100644 --- a/internal/model/do/mdl_pricing.go +++ b/internal/model/do/mdl_pricing.go @@ -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 价格(直接定价) } diff --git a/internal/model/do/mdl_tenant_models.go b/internal/model/do/mdl_tenant_models.go index ec5d7db..c849def 100644 --- a/internal/model/do/mdl_tenant_models.go +++ b/internal/model/do/mdl_tenant_models.go @@ -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}] } diff --git a/internal/model/do/tnt_projects.go b/internal/model/do/tnt_projects.go index daf11aa..420e7e0 100644 --- a/internal/model/do/tnt_projects.go +++ b/internal/model/do/tnt_projects.go @@ -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 // 更新时间 } diff --git a/internal/model/entity/api_keys.go b/internal/model/entity/api_keys.go index 747a0a6..058a25b 100644 --- a/internal/model/entity/api_keys.go +++ b/internal/model/entity/api_keys.go @@ -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(项目密钥) } diff --git a/internal/model/entity/bil_prededuct_tracks.go b/internal/model/entity/bil_prededuct_tracks.go index 5c60a90..2bf79e3 100644 --- a/internal/model/entity/bil_prededuct_tracks.go +++ b/internal/model/entity/bil_prededuct_tracks.go @@ -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 时有值) } diff --git a/internal/model/entity/bil_records.go b/internal/model/entity/bil_records.go index 7275875..51498c9 100644 --- a/internal/model/entity/bil_records.go +++ b/internal/model/entity/bil_records.go @@ -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) } diff --git a/internal/model/entity/bil_transactions.go b/internal/model/entity/bil_transactions.go index 49edeab..85a03a5 100644 --- a/internal/model/entity/bil_transactions.go +++ b/internal/model/entity/bil_transactions.go @@ -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) } diff --git a/internal/model/entity/bil_usage_logs.go b/internal/model/entity/bil_usage_logs.go index 1852fbc..a84e811 100644 --- a/internal/model/entity/bil_usage_logs.go +++ b/internal/model/entity/bil_usage_logs.go @@ -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,普通请求为空 } diff --git a/internal/model/entity/bil_wallets.go b/internal/model/entity/bil_wallets.go index b21b6d8..9b68c57 100644 --- a/internal/model/entity/bil_wallets.go +++ b/internal/model/entity/bil_wallets.go @@ -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) } diff --git a/internal/model/entity/mdl_pricing.go b/internal/model/entity/mdl_pricing.go index 79ed155..8d662d8 100644 --- a/internal/model/entity/mdl_pricing.go +++ b/internal/model/entity/mdl_pricing.go @@ -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 价格(直接定价) } diff --git a/internal/model/entity/mdl_tenant_models.go b/internal/model/entity/mdl_tenant_models.go index 3e69ddd..ca7b682 100644 --- a/internal/model/entity/mdl_tenant_models.go +++ b/internal/model/entity/mdl_tenant_models.go @@ -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}] } diff --git a/internal/model/entity/ord_orders.go b/internal/model/entity/ord_orders.go index 5dae326..ea8ea44 100644 --- a/internal/model/entity/ord_orders.go +++ b/internal/model/entity/ord_orders.go @@ -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:"更新时间"` // 更新时间 } diff --git a/internal/model/entity/ord_promo_code_usages.go b/internal/model/entity/ord_promo_code_usages.go index ff946d9..5d387f6 100644 --- a/internal/model/entity/ord_promo_code_usages.go +++ b/internal/model/entity/ord_promo_code_usages.go @@ -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:""` // } diff --git a/internal/model/entity/ord_promo_codes.go b/internal/model/entity/ord_promo_codes.go index d648f50..e5751d8 100644 --- a/internal/model/entity/ord_promo_codes.go +++ b/internal/model/entity/ord_promo_codes.go @@ -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:""` // } diff --git a/internal/model/entity/ord_redemption_usages.go b/internal/model/entity/ord_redemption_usages.go index 27508ae..6678513 100644 --- a/internal/model/entity/ord_redemption_usages.go +++ b/internal/model/entity/ord_redemption_usages.go @@ -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:"更新时间"` // 更新时间 } diff --git a/internal/model/entity/ord_redemptions.go b/internal/model/entity/ord_redemptions.go index 0daae78..5c5628e 100644 --- a/internal/model/entity/ord_redemptions.go +++ b/internal/model/entity/ord_redemptions.go @@ -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:""` // } diff --git a/internal/model/entity/ord_refunds.go b/internal/model/entity/ord_refunds.go index da3cf2a..7242af6 100644 --- a/internal/model/entity/ord_refunds.go +++ b/internal/model/entity/ord_refunds.go @@ -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:"更新时间"` // 更新时间 } diff --git a/internal/model/entity/pln_plans.go b/internal/model/entity/pln_plans.go index 5a710a2..972d328 100644 --- a/internal/model/entity/pln_plans.go +++ b/internal/model/entity/pln_plans.go @@ -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:"更新时间"` // 更新时间 } diff --git a/internal/model/entity/tnt_projects.go b/internal/model/entity/tnt_projects.go index 9068931..90e9c8a 100644 --- a/internal/model/entity/tnt_projects.go +++ b/internal/model/entity/tnt_projects.go @@ -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:"更新时间"` // 更新时间 } diff --git a/internal/model/entity/tnt_tenant_level_configs.go b/internal/model/entity/tnt_tenant_level_configs.go index b3983c7..06523a4 100644 --- a/internal/model/entity/tnt_tenant_level_configs.go +++ b/internal/model/entity/tnt_tenant_level_configs.go @@ -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:""` // } diff --git a/internal/model/entity/tnt_users.go b/internal/model/entity/tnt_users.go index 325d15b..ab153f7 100644 --- a/internal/model/entity/tnt_users.go +++ b/internal/model/entity/tnt_users.go @@ -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:"上次额度重置时间(懒重置用)"` // 上次额度重置时间(懒重置用) } diff --git a/internal/model/entity/tsk_model_tasks.go b/internal/model/entity/tsk_model_tasks.go index 31ad672..2a51b6d 100644 --- a/internal/model/entity/tsk_model_tasks.go +++ b/internal/model/entity/tsk_model_tasks.go @@ -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 } diff --git a/migrations/000013_alter_non_money_numeric_to_double.sql b/migrations/000013_alter_non_money_numeric_to_double.sql new file mode 100644 index 0000000..4dbe54a --- /dev/null +++ b/migrations/000013_alter_non_money_numeric_to_double.sql @@ -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); diff --git a/relay/FLOAT64_RISK_ANALYSIS.md b/relay/FLOAT64_RISK_ANALYSIS.md new file mode 100644 index 0000000..85528fb --- /dev/null +++ b/relay/FLOAT64_RISK_ANALYSIS.md @@ -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`? + +--- + +**报告结束** diff --git a/relay/common/task_billing.go b/relay/common/task_billing.go index 6cef9d0..d10f407 100644 --- a/relay/common/task_billing.go +++ b/relay/common/task_billing.go @@ -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) } diff --git a/relay/common/task_provider.go b/relay/common/task_provider.go index 3e61def..29c7770 100644 --- a/relay/common/task_provider.go +++ b/relay/common/task_provider.go @@ -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"` diff --git a/relay/handler/task_handler.go b/relay/handler/task_handler.go index 9517a99..2919d3f 100644 --- a/relay/handler/task_handler.go +++ b/relay/handler/task_handler.go @@ -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(), "")