From 31ecd9e1436ffa6163add63e99eefdb959ba71c8 Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 13 Sep 2026 12:17:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(billing):=E8=AE=A1=E8=B4=B9=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=9F=BA=E7=A1=80=E6=8A=BD=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/model.go | 15 +- internal/logic/admin/model.go | 29 ++- internal/logic/admin/model_import_export.go | 4 +- .../logic/billing/param_multiplier_test.go | 4 +- internal/logic/billing/pricing.go | 19 ++ internal/logic/billing/scheme.go | 180 ++++++++++++++++++ internal/logic/billing/scheme_test.go | 128 +++++++++++++ internal/logic/billing/task_billing.go | 90 ++------- internal/logic/billing/task_billing_test.go | 32 ++-- .../src/components/ModelPricingModal.vue | 54 +++++- 10 files changed, 452 insertions(+), 103 deletions(-) create mode 100644 internal/logic/billing/scheme.go create mode 100644 internal/logic/billing/scheme_test.go diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index de52cdf..9c6dd4f 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -1,6 +1,10 @@ package v1 -import "github.com/gogf/gf/v2/frame/g" +import ( + "encoding/json" + + "github.com/gogf/gf/v2/frame/g" +) // ModelListReq 模型列表请求 type ModelListReq struct { @@ -161,6 +165,10 @@ type PricingGetRes struct { PriceNote string `json:"price_note" dc:"价格说明(仅管理后台可见的内部备注)"` DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示,如 7折起)"` PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示,提示价格有变动)"` + // 计费方案:空 = 通用引擎(前端渲染内置通用表单);非空时前端分发到方案专属编辑器 + Scheme string `json:"scheme" dc:"计费方案名(空=通用引擎)"` + // 方案私有配置(不透明容器):schema 由方案自定义,通用表单不理解其内容 + SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"计费方案私有配置(随方案编辑器解析)"` } // PricingSetReq 设置模型定价(全量替换) @@ -179,6 +187,11 @@ type PricingSetReq struct { PriceNote string `json:"price_note" dc:"价格说明(仅内部可见)"` DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示)"` PriceChangeNote string `json:"price_change_note" dc:"价格调整说明(对外展示)"` + // 计费方案(全量替换语义:空串=回落通用引擎并清除 scheme_config)。 + // 必须是已注册方案(后端校验),自定义方案建议 custom: 前缀命名 + Scheme string `json:"scheme" dc:"计费方案名(空=通用引擎并清除私有配置)"` + // 方案私有配置:仅 scheme 非空时允许携带,由方案实现的校验规则把关 + SchemeConfig json.RawMessage `json:"scheme_config,omitempty" dc:"计费方案私有配置(方案自定义 schema)"` } type PricingSetRes struct{} diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index 1350ff7..5eefa00 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -513,9 +513,16 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v var officialTimeSegments []v1.TimeSegmentItem var officialParamRules []v1.ParamMultiplierItem var priceNote, discountLabel, priceChangeNote string + var scheme string + var schemeConfig json.RawMessage if row != nil { // 主定价:pricing JSONB → items + 时段 + 倍率(与官方参考定价共用展开逻辑) - result, timeSegments, paramRules = expandPricingBlob(row.BillingMode, billing.ParsePricingBlob(row.Pricing)) + blob := billing.ParsePricingBlob(row.Pricing) + result, timeSegments, paramRules = expandPricingBlob(row.BillingMode, blob) + if blob != nil { + scheme = blob.Scheme + schemeConfig = blob.SchemeConfig + } // 官方参考定价:official_pricing JSONB 同构展开;未配置(NULL/解析失败)时三个字段保持 nil offMode, offBlob := officialBlobFromJSON(row.OfficialPricing) @@ -537,6 +544,8 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v PriceNote: priceNote, DiscountLabel: discountLabel, PriceChangeNote: priceChangeNote, + Scheme: scheme, + SchemeConfig: schemeConfig, }, nil } @@ -721,13 +730,17 @@ func pricingItemsToInput(items []v1.PricingItem) []billing.PricingItemInput { // writePricingForModel 全量替换模型定价:单行写 billing_mode + pricing JSONB(每模型一行, // uk_mdl_pricing_model 唯一)。SetModelPricing 与模型导入共用;时段定价随后经 // writeTimeSegmentsForModel 的 jsonb_set 并入 JSON 顶层;参数倍率规则随 blob 顶层写入。 +// scheme/schemeConfig 为计费方案声明与私有配置(导入路径恒传空——导出格式不含 +// 方案字段,特殊方案定价需在目标环境用方案编辑器手工配置)。 // 调用方需保证事务 ctx 传播。 -func writePricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, paramRules []billing.ParamRule) error { +func writePricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, paramRules []billing.ParamRule, scheme string, schemeConfig json.RawMessage) error { blob, err := billing.BuildPricingBlob(pricingItemsToInput(items)) if err != nil { return err } blob.ParamMultipliers = paramRules + blob.Scheme = scheme + blob.SchemeConfig = schemeConfig blobJSON, err := json.Marshal(blob) if err != nil { return err @@ -925,6 +938,16 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v } } + // 计费方案校验:未注册的方案名不得写入(运行期引擎无从得知正确价格); + // scheme_config 由方案实现自校验(generic 不接受非空配置,拦截「空方案带配置」的错位数据) + req.Scheme = strings.TrimSpace(req.Scheme) + if req.Scheme != "" && !billing.SchemeRegistered(req.Scheme) { + return nil, gerror.Newf("计费方案 %q 未注册", req.Scheme) + } + if err := billing.LookupScheme(req.Scheme).ValidateSchemeConfig(req.SchemeConfig); err != nil { + return nil, err + } + // 先查模型编码,供事务提交后按模型清除所有租户的价格缓存 var model struct { ModelId string `json:"model_id"` @@ -938,7 +961,7 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v // 全量替换:单行写 pricing JSONB(计费详情 + 参数倍率随 blob 顶层),同一事务。 // 否则中途失败会残留「无定价」状态,导致计费失败或回退到默认价。 err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - if err := writePricingForModel(ctx, req.ModelID, req.Items, paramRules); err != nil { + if err := writePricingForModel(ctx, req.ModelID, req.Items, paramRules, req.Scheme, req.SchemeConfig); err != nil { return err } diff --git a/internal/logic/admin/model_import_export.go b/internal/logic/admin/model_import_export.go index 886d308..26d4d15 100644 --- a/internal/logic/admin/model_import_export.go +++ b/internal/logic/admin/model_import_export.go @@ -313,7 +313,7 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. if pErr != nil { return gerror.Wrapf(pErr, "模型 %s 参数倍率", item.ModelId) } - if err := writePricingForModel(ctx, existing.ID, item.Pricing, paramRules); err != nil { + if err := writePricingForModel(ctx, existing.ID, item.Pricing, paramRules, "", nil); err != nil { return gerror.Wrapf(err, "模型 %s 定价", item.ModelId) } @@ -357,7 +357,7 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1. if pErr != nil { return gerror.Wrapf(pErr, "模型 %s 参数倍率", item.ModelId) } - if err := writePricingForModel(ctx, id, item.Pricing, paramRules); err != nil { + if err := writePricingForModel(ctx, id, item.Pricing, paramRules, "", nil); err != nil { return gerror.Wrapf(err, "模型 %s 定价", item.ModelId) } diff --git a/internal/logic/billing/param_multiplier_test.go b/internal/logic/billing/param_multiplier_test.go index e4d5443..6bacf06 100644 --- a/internal/logic/billing/param_multiplier_test.go +++ b/internal/logic/billing/param_multiplier_test.go @@ -296,9 +296,9 @@ func TestEstimateTaskCost_ParamMultiplierInjection(t *testing.T) { ratios[ratioKeyParamMatched] = matched[0] } // 注入后费用 = 0.5 × 12 × 1.5 × 2.0 = 18 - assertDecimal(t, estimateTaskCost(pricing, ratios), 18.0, "param multiplier cost") + assertDecimal(t, estimateTaskCost(pricing, ratios, nil), 18.0, "param multiplier cost") // 未命中的任务体不注入(保持 0.5 × 5 缺省 = 2.5) ratios2 := map[string]any{} - assertDecimal(t, estimateTaskCost(pricing, ratios2), 2.5, "no param match cost") + assertDecimal(t, estimateTaskCost(pricing, ratios2, nil), 2.5, "no param match cost") } diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index dd47179..b77c354 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -76,6 +76,13 @@ type PricingResult struct { // 按归一化任务体参数匹配,命中规则连乘(EvalParamMultipliers)。 // 旧缓存条目无此字段(zero value),不受影响。 ParamMultipliers []ParamRule `json:"param_multipliers,omitempty"` + + // 计费方案(pricing JSONB 顶层 scheme):空 = generic 通用引擎; + // 特殊方案的估算分发见 estimateTaskCost / LookupScheme。 + // 旧缓存条目无此字段(空串),不受影响 + Scheme string `json:"scheme,omitempty"` + // 方案私有配置(不透明容器,方案自行解析)。旧缓存条目无此字段(nil) + SchemeConfig json.RawMessage `json:"scheme_config,omitempty"` } // perSecondWildcard 矩阵兜底价键:规格未命中时使用 @@ -207,6 +214,8 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill var perSecondPrices map[string]float64 var timeSegments []TimeSegment var paramMultipliers []ParamRule + var schemeName string + var schemeConfig json.RawMessage if pricing != nil { if pricing.BillingMode != "" { @@ -239,6 +248,8 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill perSecondPrices = blob.Prices timeSegments = blob.TimeSegments paramMultipliers = blob.ParamMultipliers + schemeName = blob.Scheme + schemeConfig = blob.SchemeConfig } } } @@ -365,6 +376,8 @@ func GetModelPriceAt(ctx context.Context, tenantID int64, modelName string, bill TimeSegments: timeSegments, PerSecondPrices: perSecondPrices, ParamMultipliers: paramMultipliers, + Scheme: schemeName, + SchemeConfig: schemeConfig, } // 时段乘数按定价时刻评估后随缓存存储(缓存命中路径会按 billAt 重评估,存储值仅参考) @@ -765,6 +778,12 @@ type PricingBlob struct { TimeSegments []TimeSegment `json:"time_segments,omitempty"` // 横切:参数倍率(按归一化任务体参数匹配,命中规则连乘;所有计费模式共享) ParamMultipliers []ParamRule `json:"param_multipliers,omitempty"` + // 计费方案:特殊计费模型声明引用的方案名(空 = generic 通用引擎), + // 前端按此字段分发定价编辑器。导出格式不含该字段(特殊方案定价不随导入导出迁移) + Scheme string `json:"scheme,omitempty"` + // 方案私有配置(不透明容器):schema 由方案实现自定义并自行解析校验, + // 引擎与通用编辑器不理解其内容,防止主干结构随方案膨胀 + SchemeConfig json.RawMessage `json:"scheme_config,omitempty"` } // tiersHavePrice 阶梯数组中是否存在任一档正价(输入或输出)。 diff --git a/internal/logic/billing/scheme.go b/internal/logic/billing/scheme.go new file mode 100644 index 0000000..1f77601 --- /dev/null +++ b/internal/logic/billing/scheme.go @@ -0,0 +1,180 @@ +package billing + +import ( + "encoding/json" + "sync" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/shopspring/decimal" +) + +// ============================================================ +// 计费方案(BillingScheme)扩展点 +// ============================================================ +// 背景:部分模型的计费规则无法归约为「单价 × 数量」的结构化配置(条件切价、 +// 组合计费、依赖上游私有字段等)。为此把「任务预扣估算」的定价解释权抽象为 +// 可注册的方案接口:模型在 pricing JSONB 顶层 "scheme" 键声明引用,未声明走 +// generic(现有通用引擎),计费行为与抽象前完全一致。 +// +// 设计约束(新增方案必须遵守,否则账目口径漂移): +// 1. 方案只负责组件费用计算:EstimateTaskCost 返回已乘租户乘数 × 时段乘数的 +// 金额;ratios 附加乘数(param_multiplier 等)、minCost 钳制与 RoundMoney +// 由通用层统一应用——全站结算口径、快照与对账不因方案而分叉; +// 2. 方案私有配置存 pricing JSONB 顶层 "scheme_config"(不透明容器),由方案 +// 自行解析校验,不得向 PricingBlob 增加方案私有字段(主干文件不随方案膨胀, +// 二开新增方案不改任何既有文件); +// 3. 优先消费归一化计费上下文(ratios,adaptor 经 EstimateBilling 上报); +// taskBody 为归一化任务体,仅确实需要原始请求体的方案使用(nil = 不可用); +// 4. 自定义方案建议 "custom:" 前缀命名,避免与内置方案撞名。 +// +// 注册模式与 relay/channel 的供应商适配器一致:方案实现放独立文件/目录, +// init() 中 RegisterScheme 自注册,新增方案不改本文件与 task_billing.go。 + +// SchemeGeneric 通用计费方案名(pricing JSONB 未声明 scheme 时的默认值) +const SchemeGeneric = "generic" + +// BillingScheme 计费方案接口:特殊计费模型的任务预扣估算扩展点。 +// 接口签名视为公共契约(主干与二开方案的边界),保持稳定;修改需评估所有已注册方案。 +type BillingScheme interface { + // Name 方案名(唯一,与 pricing JSONB scheme 键对应) + Name() string + + // EstimateTaskCost 任务预扣估算(纯函数,不触 DB/缓存,便于单测)。 + // 返回已含租户乘数 × 时段乘数、未应用 ratios 附加乘数的费用; + // 附加乘数、minCost 钳制与 RoundMoney 由 estimateTaskCost 骨架统一应用。 + EstimateTaskCost(pricing *PricingResult, ratios map[string]any, taskBody []byte) decimal.Decimal + + // ValidateSchemeConfig 校验方案私有配置(pricing JSONB scheme_config), + // 管理端保存定价时 fail-fast 调用;generic 方案不接受非空配置。 + ValidateSchemeConfig(cfg json.RawMessage) error +} + +// schemeRegistry 方案注册表(init 自注册,同 relay/channel 适配器模式) +var schemeRegistry sync.Map // name → BillingScheme + +// RegisterScheme 注册计费方案(方案实现的 init 中调用)。 +// 空名/重名直接 panic:注册发生在进程启动期,快死优于带病运行。 +func RegisterScheme(s BillingScheme) { + if s == nil || s.Name() == "" { + panic("billing: register nil or empty-name scheme") + } + if _, loaded := schemeRegistry.LoadOrStore(s.Name(), s); loaded { + panic("billing: duplicate scheme registration: " + s.Name()) + } +} + +// LookupScheme 按方案名查注册表:空名/未注册一律回落 generic。 +// 未注册兜底是纯函数层的防御(写路径已由 SchemeRegistered 拦截、预扣入口 +// fail-closed 拒绝,正常流量到不了这里),保证估算函数不因配置异常而 panic。 +func LookupScheme(name string) BillingScheme { + if name != "" { + if s, ok := schemeRegistry.Load(name); ok { + return s.(BillingScheme) + } + } + if s, ok := schemeRegistry.Load(SchemeGeneric); ok { + return s.(BillingScheme) + } + return GenericScheme{} +} + +// SchemeRegistered 方案是否已注册。管理端保存定价前校验:未注册的方案名 +// 不得写入配置(防止配置引用不存在的实现,运行期才暴露成计费异常)。 +func SchemeRegistered(name string) bool { + _, ok := schemeRegistry.Load(name) + return ok +} + +func init() { + RegisterScheme(GenericScheme{}) +} + +// GenericScheme 通用计费方案:token / per_request / per_second / tiered 的 +// 现有通用引擎(自 estimateTaskCost 分支逻辑原样搬迁,行为不变)。 +type GenericScheme struct{} + +func (GenericScheme) Name() string { return SchemeGeneric } + +func (GenericScheme) ValidateSchemeConfig(cfg json.RawMessage) error { + if len(cfg) > 0 && string(cfg) != "null" { + return gerror.New("通用计费方案不接受 scheme_config(仅特殊方案支持私有配置)") + } + return nil +} + +// EstimateTaskCost 通用引擎的任务预扣估算。计费口径按任务类型分流: +// - per_second(按秒计费,视频生成):矩阵查价(spec.resolution 未命中回退 "*")× +// spec.duration 秒 × 租户乘数 × 时段乘数; +// - per_request(按次计费,图片/音乐等):直接取按次单价; +// - 时长类任务(视频生成,ratios 携带 duration/resolution 信号):按 +// 10000 tokens/s × duration × resolution 预估 token 再乘输出单价(存量 token 伪装路径); +// - 其余无时长信号的任务(如未显式配成 per_request 的图片模型):退回按次单价, +// 不再套用视频 token 估算——图片没有时长/分辨率,套 10000×5×2.25 会凭空估出 +// 11.25 万 token 的天价预扣($30/1M 输出价即得 $3.375),且与结算的「0 token」自相矛盾。 +func (GenericScheme) EstimateTaskCost(pricing *PricingResult, ratios map[string]any, _ []byte) decimal.Decimal { + var costD decimal.Decimal + switch { + case pricing.BillingMode == "per_second": + // 按秒计费:矩阵查价 × 时长 × 租户乘数 × 时段乘数 + duration := defaultTaskDurationSeconds + if d, ok := ratioFloat(ratios, "spec.duration"); ok && d > 0 { + duration = d + } + // 时长来自用户请求,钳制上限防天价预扣(正常视频模型远低于该上限) + if duration > maxTaskDurationSeconds { + duration = maxTaskDurationSeconds + } + spec, _ := ratioString(ratios, "spec.resolution") + price := LookupPerSecondPrice(pricing.PerSecondPrices, spec) + if price <= 0 { + // 矩阵全零/为空:按未配价占位预扣,结算多退少补 + return NewFromFloat(imagePlaceholderPreDeduct) + } + costD = NewFromFloat(price). + Mul(NewFromFloat(duration)). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) + case pricing.BillingMode == "per_request": + // 按次计费:单价 × 租户乘数 × 时段乘数。与 computeCost / EstimatePreDeductAmount 的 + // 按次口径对齐——按次任务结算无 token 重算信号,预扣即终价, + // 预扣漏乘租户/时段折扣会让折扣租户按原价多扣 + costD = NewFromFloat(pricing.PerRequestPrice). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) + case pricing.OutputPrice > 0 && hasDurationSignal(ratios): + duration := 5.0 // 默认 5 秒 + if d, ok := ratioFloat(ratios, "duration"); ok && d > 0 { + duration = d + } + resolutionMul := 2.25 // 默认 720p + if r, ok := ratioFloat(ratios, "resolution"); ok && r > 0 { + resolutionMul = r + } + + // 预估 tokens ≈ base_tokens_per_second × duration × resolution_multiplier + // 火山方舟视频生成约 10000 tokens/s (480p 基准),用于预扣估算 + // 视频预扣三连乘全程 decimal 避免链式误差 + baseTokensPerSec := decimal.NewFromInt(10000) + durationD := NewFromFloat(duration) + resolutionMulD := NewFromFloat(resolutionMul) + million := decimal.NewFromInt(1_000_000) + + costD = baseTokensPerSec.Mul(durationD).Mul(resolutionMulD). + Div(million). + Mul(NewFromFloat(pricing.OutputPrice)). + Mul(NewFromFloat(pricing.TenantMultiplier)) + default: + // 无时长信号(图片等扁平计费任务):优先按次单价(同样乘租户/时段乘数, + // 预扣即终价的口径与 per_request 分支一致);未配按次价时用占位预扣, + // 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补 + // (见 sync_image_worker.settleSyncImageSuccess)。 + if pricing.PerRequestPrice > 0 { + costD = NewFromFloat(pricing.PerRequestPrice). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) + } else { + costD = NewFromFloat(imagePlaceholderPreDeduct) + } + } + return costD +} diff --git a/internal/logic/billing/scheme_test.go b/internal/logic/billing/scheme_test.go new file mode 100644 index 0000000..0c007a6 --- /dev/null +++ b/internal/logic/billing/scheme_test.go @@ -0,0 +1,128 @@ +package billing + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/shopspring/decimal" +) + +// testConfigScheme 测试用方案:演示方案实现的正确形态——注册实例无状态单例, +// 费用参数从 pricing.SchemeConfig(不透明容器)读取,同名注册仅一次。 +type testConfigScheme struct{} + +func (testConfigScheme) Name() string { return "test:config-scheme" } + +func (testConfigScheme) ValidateSchemeConfig(cfg json.RawMessage) error { + var c struct { + Base float64 `json:"base"` + } + if err := json.Unmarshal(cfg, &c); err != nil { + return gerror.Wrap(err, "scheme_config 解析失败") + } + if c.Base < 0 { + return gerror.New("base 不能为负") + } + return nil +} + +func (testConfigScheme) EstimateTaskCost(pricing *PricingResult, _ map[string]any, _ []byte) decimal.Decimal { + var c struct { + Base float64 `json:"base"` + } + _ = json.Unmarshal(pricing.SchemeConfig, &c) + return NewFromFloat(c.Base) +} + +// TestSchemeRegistry 注册表基础行为:generic 默认注册、空名/未注册回落、SchemeRegistered 判定。 +func TestSchemeRegistry(t *testing.T) { + if !SchemeRegistered(SchemeGeneric) { + t.Fatal("generic scheme should be pre-registered") + } + if SchemeRegistered("test:not-registered") { + t.Fatal("unregistered name should report false") + } + + // 空名与未注册名都必须回落 generic(纯函数层防御) + if got := LookupScheme(""); got.Name() != SchemeGeneric { + t.Errorf("empty name should fall back to generic, got %s", got.Name()) + } + if got := LookupScheme("no:such-scheme"); got.Name() != SchemeGeneric { + t.Errorf("unknown name should fall back to generic, got %s", got.Name()) + } +} + +// TestGenericSchemeConfigValidation generic 方案不接受非空 scheme_config +// (拦截「空方案带配置」的错位数据;null 视同未配置)。 +func TestGenericSchemeConfigValidation(t *testing.T) { + g := GenericScheme{} + if err := g.ValidateSchemeConfig(nil); err != nil { + t.Errorf("nil config should pass, got %v", err) + } + if err := g.ValidateSchemeConfig(json.RawMessage("null")); err != nil { + t.Errorf("null config should pass, got %v", err) + } + if err := g.ValidateSchemeConfig(json.RawMessage(`{"x":1}`)); err == nil { + t.Error("non-empty config should be rejected for generic scheme") + } +} + +// TestSchemeDispatch 特殊方案分发:pricing.Scheme 命中注册方案后估算走方案实现, +// 且通用层统一应用附加乘数、minCost 钳制(方案实现不得自行处理,全站口径收口)。 +func TestSchemeDispatch(t *testing.T) { + RegisterScheme(testConfigScheme{}) + + pricing := &PricingResult{ + BillingMode: "per_second", // billing_mode 对特殊方案只是参考字段,分发只看 Scheme + TenantMultiplier: 1.0, + Scheme: "test:config-scheme", + SchemeConfig: json.RawMessage(`{"base":2.0}`), + } + + // 分发命中:方案读 scheme_config 返回 2.0,无附加乘数时原样(高于 minCost 不触发钳制) + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 2.0, "scheme dispatch base") + + // 通用层应用附加乘数:2.0 × 0.5 = 1.0(方案实现未乘,证明 applyRatioMultipliers 收口在骨架) + assertDecimal(t, estimateTaskCost(pricing, map[string]any{"video_input": 0.5}, nil), 1.0, "scheme with ratio multiplier") + + // spec.* 事实键不参与乘法(通用层跳过) + assertDecimal(t, estimateTaskCost(pricing, map[string]any{"spec.resolution": "720p"}, nil), 2.0, "spec facts not multiplied") + + // 通用层 minCost 钳制:方案低返回值(0.001)被钳到 0.01 + lowPricing := &PricingResult{ + TenantMultiplier: 1.0, + Scheme: "test:config-scheme", + SchemeConfig: json.RawMessage(`{"base":0.001}`), + } + assertDecimal(t, estimateTaskCost(lowPricing, nil, nil), 0.01, "scheme result clamped by minCost") +} + +// TestSchemeDispatchGenericEquivalence 未声明 scheme 的定价走 generic 引擎, +// 结果与 billing_mode 直接计算一致(重构等价性的分发层验证,具体口径见 task_billing_test)。 +func TestSchemeDispatchGenericEquivalence(t *testing.T) { + pricing := &PricingResult{ + BillingMode: "per_request", + PerRequestPrice: 0.05, + TenantMultiplier: 1.0, + } + if got := LookupScheme(pricing.Scheme).Name(); got != SchemeGeneric { + t.Errorf("empty scheme should resolve to generic, got %s", got) + } + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.05, "generic equivalence per_request") +} + +// TestSchemeRegisteredNameUniqueness 重名注册 panic(注册期快死优于带病运行)。 +func TestSchemeRegisteredNameUniqueness(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Error("duplicate registration should panic") + } else if !strings.Contains(fmt.Sprint(r), "duplicate scheme") { + t.Errorf("panic message should mention duplicate scheme, got %v", r) + } + }() + RegisterScheme(GenericScheme{}) +} diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 0ca22ac..f4c9e13 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "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" @@ -32,6 +33,12 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID if err != nil { return NewFromFloat(0.01), nil } + // 特殊计费方案 fail-closed:定价引用了未注册的方案(方案代码缺失/被回滚)时 + // 引擎无从得知正确价格,静默回落 generic 会错价,拒绝任务提交与「未配价模型 + // 不得放行」的同步路径口径一致 + if pricing.Scheme != "" && !SchemeRegistered(pricing.Scheme) { + return Zero, gerror.Newf("billing scheme %q not registered (model=%s)", pricing.Scheme, modelName) + } // 参数倍率求值:命中注入 ratios(预扣、per_second 结算、token 重算经 applyRatioMultipliers 自动同口径) if len(pricing.ParamMultipliers) > 0 && len(taskBody) > 0 && ratios != nil { if m, matched := EvalParamMultipliers(pricing.ParamMultipliers, taskBody); len(matched) > 0 && m > 0 { @@ -39,7 +46,7 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID ratios[ratioKeyParamMatched] = strings.Join(matched, "|") } } - return estimateTaskCost(pricing, ratios), nil + return estimateTaskCost(pricing, ratios, taskBody), nil } // imagePlaceholderPreDeduct 图片等按次任务**未配置按次单价**时的占位预扣(USD)。 @@ -48,86 +55,15 @@ func (b *TaskBillingProviderImpl) EstimateTaskCost(ctx context.Context, tenantID const imagePlaceholderPreDeduct = 0.1 // estimateTaskCost 纯函数:根据定价与计费上下文估算任务预扣费用,不依赖数据库/缓存,便于单测。 -// -// 计费口径按任务类型分流: -// - per_second(按秒计费,视频生成):矩阵查价(spec.resolution 未命中回退 "*")× -// spec.duration 秒 × 租户乘数 × 时段乘数 × 附加乘数(video_input 折扣等); -// - per_request(按次计费,图片/音乐等):直接取按次单价; -// - 时长类任务(视频生成,ratios 携带 duration/resolution 信号):按 -// 10000 tokens/s × duration × resolution 预估 token 再乘输出单价(存量 token 伪装路径); -// - 其余无时长信号的任务(如未显式配成 per_request 的图片模型):退回按次单价, -// **不再**套用视频 token 估算——图片没有时长/分辨率,套 10000×5×2.25 会凭空估出 -// 11.25 万 token 的天价预扣($30/1M 输出价即得 $3.375),且与结算的「0 token」自相矛盾。 -func estimateTaskCost(pricing *PricingResult, ratios map[string]any) decimal.Decimal { +// 骨架只做三件事:按 pricing.Scheme 分发到计费方案实现(未声明走 generic 通用引擎), +// 再统一应用附加比率与 minCost 钳制——方案实现只负责组件费用(含租户/时段乘数), +// 附加乘数、钳制、舍入全站收口于此,保证各方案结算口径一致。 +func estimateTaskCost(pricing *PricingResult, ratios map[string]any, taskBody []byte) decimal.Decimal { if pricing == nil { return NewFromFloat(0.01) } - var costD decimal.Decimal - switch { - case pricing.BillingMode == "per_second": - // 按秒计费:矩阵查价 × 时长 × 租户乘数 × 时段乘数 - duration := defaultTaskDurationSeconds - if d, ok := ratioFloat(ratios, "spec.duration"); ok && d > 0 { - duration = d - } - // 时长来自用户请求,钳制上限防天价预扣(正常视频模型远低于该上限) - if duration > maxTaskDurationSeconds { - duration = maxTaskDurationSeconds - } - spec, _ := ratioString(ratios, "spec.resolution") - price := LookupPerSecondPrice(pricing.PerSecondPrices, spec) - if price <= 0 { - // 矩阵全零/为空:按未配价占位预扣,结算多退少补 - costD = NewFromFloat(imagePlaceholderPreDeduct) - break - } - costD = NewFromFloat(price). - Mul(NewFromFloat(duration)). - Mul(NewFromFloat(pricing.TenantMultiplier)). - Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) - case pricing.BillingMode == "per_request": - // 按次计费:单价 × 租户乘数 × 时段乘数。与 computeCost / EstimatePreDeductAmount 的 - // 按次口径对齐——按次任务结算无 token 重算信号,预扣即终价, - // 预扣漏乘租户/时段折扣会让折扣租户按原价多扣 - costD = NewFromFloat(pricing.PerRequestPrice). - Mul(NewFromFloat(pricing.TenantMultiplier)). - Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) - case pricing.OutputPrice > 0 && hasDurationSignal(ratios): - duration := 5.0 // 默认 5 秒 - if d, ok := ratioFloat(ratios, "duration"); ok && d > 0 { - duration = d - } - resolutionMul := 2.25 // 默认 720p - if r, ok := ratioFloat(ratios, "resolution"); ok && r > 0 { - resolutionMul = r - } - - // 预估 tokens ≈ base_tokens_per_second × duration × resolution_multiplier - // 火山方舟视频生成约 10000 tokens/s (480p 基准),用于预扣估算 - // 修复视频预扣三连乘:全程 decimal 避免链式误差 - baseTokensPerSec := decimal.NewFromInt(10000) - durationD := NewFromFloat(duration) - resolutionMulD := NewFromFloat(resolutionMul) - million := decimal.NewFromInt(1_000_000) - - costD = baseTokensPerSec.Mul(durationD).Mul(resolutionMulD). - Div(million). - Mul(NewFromFloat(pricing.OutputPrice)). - Mul(NewFromFloat(pricing.TenantMultiplier)) - default: - // 无时长信号(图片等扁平计费任务):优先按次单价(同样乘租户/时段乘数, - // 预扣即终价的口径与 per_request 分支一致);未配按次价时用占位预扣, - // 绝不走视频 token 估算。结算阶段再按上游真实 token 用量多退少补 - // (见 sync_image_worker.settleSyncImageSuccess)。 - if pricing.PerRequestPrice > 0 { - costD = NewFromFloat(pricing.PerRequestPrice). - Mul(NewFromFloat(pricing.TenantMultiplier)). - Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) - } else { - costD = NewFromFloat(imagePlaceholderPreDeduct) - } - } + costD := LookupScheme(pricing.Scheme).EstimateTaskCost(pricing, ratios, taskBody) // 应用附加比率(video_input 折扣等):只乘 float 乘数值, // 跳过 duration/resolution(已在时长类分支消费)与 spec.*(规格事实值非乘数) diff --git a/internal/logic/billing/task_billing_test.go b/internal/logic/billing/task_billing_test.go index 5bf17d6..b8ce809 100644 --- a/internal/logic/billing/task_billing_test.go +++ b/internal/logic/billing/task_billing_test.go @@ -165,7 +165,7 @@ func TestEstimateTaskCost_PerRequest(t *testing.T) { OutputPrice: 30.0, // 即使配了 output_price 也不应走 token 估算 TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "per_request cost") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.04, "per_request cost") } // TestEstimateTaskCost_PerRequestTenantTimeDiscount 按次任务预扣即终价(结算无 token 重算 @@ -178,7 +178,7 @@ func TestEstimateTaskCost_PerRequestTenantTimeDiscount(t *testing.T) { TimeMultiplier: 0.8, } // 0.10 × 0.5 × 0.8 = 0.04 - assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "per_request tenant × time discount") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.04, "per_request tenant × time discount") // token 模式但仅配按次价的图片任务(default 分支回退)同样生效 p2 := &PricingResult{ @@ -188,7 +188,7 @@ func TestEstimateTaskCost_PerRequestTenantTimeDiscount(t *testing.T) { TenantMultiplier: 0.5, TimeMultiplier: 0.8, } - assertDecimal(t, estimateTaskCost(p2, nil), 0.04, "image per-request fallback discount") + assertDecimal(t, estimateTaskCost(p2, nil, nil), 0.04, "image per-request fallback discount") // 时段乘数零值(旧缓存条目缺字段)兜底 1.0,不得把预扣清零 p3 := &PricingResult{ @@ -197,7 +197,7 @@ func TestEstimateTaskCost_PerRequestTenantTimeDiscount(t *testing.T) { TenantMultiplier: 0.5, TimeMultiplier: 0, } - assertDecimal(t, estimateTaskCost(p3, nil), 0.05, "zero time multiplier fallback") + assertDecimal(t, estimateTaskCost(p3, nil, nil), 0.05, "zero time multiplier fallback") } // TestEstimateTaskCost_VideoWithDurationSignal 视频任务(ratios 携带 duration/resolution) @@ -209,7 +209,7 @@ func TestEstimateTaskCost_VideoWithDurationSignal(t *testing.T) { TenantMultiplier: 1.0, } ratios := map[string]any{"duration": 5.0, "resolution": 2.25} - assertDecimal(t, estimateTaskCost(pricing, ratios), 3.375, "video cost") + assertDecimal(t, estimateTaskCost(pricing, ratios, nil), 3.375, "video cost") } // TestEstimateTaskCost_ImageNoDurationSignal 图片任务(token 模式、有 output_price、但 ratios 无 @@ -222,7 +222,7 @@ func TestEstimateTaskCost_ImageNoDurationSignal(t *testing.T) { PerRequestPrice: 0, // 未配按次价 TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.1, "image placeholder pre-deduct") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.1, "image placeholder pre-deduct") } // TestEstimateTaskCost_PerRequestBelowPlaceholder 已配置的真实按次单价(低于占位值)必须被尊重, @@ -233,7 +233,7 @@ func TestEstimateTaskCost_PerRequestBelowPlaceholder(t *testing.T) { PerRequestPrice: 0.04, // DALL·E 类真实单价,低于 0.1 占位 TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "configured per_request price respected") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.04, "configured per_request price respected") } // TestEstimateTaskCost_ImageUsesPerRequestPrice 图片模型即便 billing_mode 仍是 token,只要配了 @@ -245,7 +245,7 @@ func TestEstimateTaskCost_ImageUsesPerRequestPrice(t *testing.T) { PerRequestPrice: 0.05, TenantMultiplier: 1.0, } - assertDecimal(t, estimateTaskCost(pricing, nil), 0.05, "image per_request cost") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 0.05, "image per_request cost") } // TestEstimateTaskCost_VideoInputDiscount 附加比率(video_input 折扣)在时长估算之上叠加。 @@ -257,12 +257,12 @@ func TestEstimateTaskCost_VideoInputDiscount(t *testing.T) { } ratios := map[string]any{"duration": 5.0, "resolution": 2.25, "video_input": 0.5} // 3.375 × 0.5 = 1.6875 - assertDecimal(t, estimateTaskCost(pricing, ratios), 1.6875, "video discounted cost") + assertDecimal(t, estimateTaskCost(pricing, ratios, nil), 1.6875, "video discounted cost") } // TestEstimateTaskCost_NilPricing 定价缺失时按最低消费兜底。 func TestEstimateTaskCost_NilPricing(t *testing.T) { - assertDecimal(t, estimateTaskCost(nil, nil), 0.01, "nil pricing floor") + assertDecimal(t, estimateTaskCost(nil, nil, nil), 0.01, "nil pricing floor") } // TestHasDurationSignal 区分视频(有 duration/resolution)与图片(nil)。 @@ -293,15 +293,15 @@ func TestEstimateTaskCost_PerSecond(t *testing.T) { // 720p × 8s × 1.0 = 4.0 assertDecimal(t, estimateTaskCost(pricing, map[string]any{ "spec.duration": 8.0, "spec.resolution": "720p", - }), 4.0, "per_second 720p") + }, nil), 4.0, "per_second 720p") // 未配规格(2k)→ "*" 兜底 0.5 × 8s = 4.0 assertDecimal(t, estimateTaskCost(pricing, map[string]any{ "spec.duration": 8.0, "spec.resolution": "2k", - }), 4.0, "per_second wildcard fallback") + }, nil), 4.0, "per_second wildcard fallback") // 无时长信号 → 缺省 5s × "*" 0.5 = 2.5 - assertDecimal(t, estimateTaskCost(pricing, nil), 2.5, "per_second default duration") + assertDecimal(t, estimateTaskCost(pricing, nil, nil), 2.5, "per_second default duration") // 租户乘数与附加折扣叠加:1080p × 10s × 0.8(租户) × 0.6(video_input) = 4.8 p2 := &PricingResult{ @@ -311,7 +311,7 @@ func TestEstimateTaskCost_PerSecond(t *testing.T) { } assertDecimal(t, estimateTaskCost(p2, map[string]any{ "spec.duration": 10.0, "spec.resolution": "1080p", "video_input": 0.6, - }), 4.8, "per_second with multipliers") + }, nil), 4.8, "per_second with multipliers") } // TestEstimateTaskCost_PerSecondDurationClamp 天价时长钳制到上限,防 metadata 伪造刷预扣漏洞。 @@ -324,13 +324,13 @@ func TestEstimateTaskCost_PerSecondDurationClamp(t *testing.T) { // 999999s 钳到 120s × 0.5 = 60 assertDecimal(t, estimateTaskCost(pricing, map[string]any{ "spec.duration": 999999.0, - }), 60.0, "per_second duration clamp") + }, nil), 60.0, "per_second duration clamp") } // TestEstimateTaskCost_PerSecondEmptyMatrix 矩阵为空/全零:占位预扣 $0.1 兜底(结算多退少补)。 func TestEstimateTaskCost_PerSecondEmptyMatrix(t *testing.T) { pricing := &PricingResult{BillingMode: "per_second", TenantMultiplier: 1.0} - assertDecimal(t, estimateTaskCost(pricing, map[string]any{"spec.duration": 8.0}), 0.1, "per_second empty matrix placeholder") + assertDecimal(t, estimateTaskCost(pricing, map[string]any{"spec.duration": 8.0}, nil), 0.1, "per_second empty matrix placeholder") } // TestLookupPerSecondPrice 矩阵查价回退链:命中档 → "*" → 矩阵最低价 → 0。 diff --git a/web/admin/src/components/ModelPricingModal.vue b/web/admin/src/components/ModelPricingModal.vue index 6ce2896..57f08d3 100644 --- a/web/admin/src/components/ModelPricingModal.vue +++ b/web/admin/src/components/ModelPricingModal.vue @@ -1,4 +1,5 @@