feat(billing):计费功能基础抽象

This commit is contained in:
qian committed 2026-09-13 22:14:21 +08:00
1 parent 3fae790362
commit 31ecd9e143
10 files changed
+452 -103

No files matched your search

+14 -1
View File
@@ -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{}
+26 -3
View File
@@ -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
}
+2 -2
View File
@@ -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)
}
@@ -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")
}
+19
View File
@@ -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 阶梯数组中是否存在任一档正价(输入或输出)。
+180
View File
@@ -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
}
+128
View File
@@ -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{})
}
+13 -77
View File
@@ -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.*(规格事实值非乘数)
+16 -16
View File
@@ -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。
+52 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { ref, reactive, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconCloudDownload, IconRefresh } from '@arco-design/web-vue/es/icon'
@@ -11,6 +12,7 @@ import PricingTimeSegmentsEditor, {
timeSegmentPayloadFrom,
} from './PricingTimeSegmentsEditor.vue'
import PricingParamRulesEditor, { type ParamRuleRow, paramRuleRowsFromAPI, paramRulePayloadFrom } from './PricingParamRulesEditor.vue'
import { resolvePricingSchemeEditor } from './pricingSchemeEditors'
// 本位币符号:定价输入控件后缀跟随本位币,输入值仍为 bil 层存储原值不折算
@@ -33,6 +35,12 @@ const editorSaving = ref(false)
const editorBillingMode = ref('token')
const editorItems = reactive<any[]>([])
// 特殊计费方案分发:命中注册编辑器时内容区整体交给方案组件(加载/校验/保存自治),
// 内置通用表单不参与;契约见 pricingSchemeEditors.ts
const schemeEditorComp = ref<Component | null>(null)
const schemeConfigData = ref<any>(null)
const schemeEditorRef = ref<any>()
// 展示字段(挂锚点行:内部备注 + 对外营销文案)
const editorPriceNote = ref('')
const editorDiscountLabel = ref('')
@@ -742,6 +750,9 @@ const paramEditorRef = ref<InstanceType<typeof PricingParamRulesEditor>>()
// ============================================================
async function loadPricing() {
if (!props.modelId) return
// 模型切换时先清除上一模型的方案分发状态
schemeEditorComp.value = null
schemeConfigData.value = null
editorItems.length = 0
editorBillingMode.value = 'token'
paramRules.length = 0
@@ -750,6 +761,13 @@ async function loadPricing() {
try {
const res: any = await request.get(`/admin/models/${props.modelId}/pricing`)
// 特殊计费方案:命中注册编辑器则整体交给方案组件,通用表单不初始化
// (未登记的方案名同样回落通用表单——后端查询已透出 scheme,前端映射缺失时兜底)
schemeEditorComp.value = resolvePricingSchemeEditor(res.data?.data?.scheme)
if (schemeEditorComp.value) {
schemeConfigData.value = res.data?.data?.scheme_config ?? null
return
}
const list: any[] = res.data?.data?.list || []
editorPriceNote.value = res.data?.data?.price_note || ''
editorDiscountLabel.value = res.data?.data?.discount_label || ''
@@ -938,6 +956,28 @@ async function savePricing() {
}
}
// 保存统一入口:特殊计费方案交给方案编辑器(自治校验与保存,失败自行提示),
// 成功提示与关弹窗由本外壳统一处理;否则走通用表单保存
async function onSave() {
if (schemeEditorComp.value) {
editorSaving.value = true
try {
const ok = await schemeEditorRef.value?.save()
if (ok) {
Message.success('定价已保存')
emit('update:visible', false)
emit('saved')
}
} catch {
// 方案编辑器内部已提示,此处不重复弹错
} finally {
editorSaving.value = false
}
return
}
await savePricing()
}
// ============================================================
// Watchers
// ============================================================
@@ -951,6 +991,8 @@ watch(() => props.visible, (val) => {
// 关闭时重置状态
watch(() => props.visible, (val) => {
if (!val) {
schemeEditorComp.value = null
schemeConfigData.value = null
resetEditorDefaults()
resetOfficialData()
}
@@ -970,10 +1012,18 @@ watch(() => props.visible, (val) => {
>
<template #footer>
<AButton @click="emit('update:visible', false)">取消</AButton>
<AButton type="primary" :loading="editorSaving" @click="savePricing">保存定价</AButton>
<AButton type="primary" :loading="editorSaving" @click="onSave">保存定价</AButton>
</template>
<ASpin :loading="editorLoading" style="width: 100%">
<AForm :model="{}" layout="vertical">
<!-- 特殊计费方案:内容区整体交给方案专属编辑器(契约见 pricingSchemeEditors.ts) -->
<component
:is="schemeEditorComp"
v-if="schemeEditorComp"
ref="schemeEditorRef"
:model-id="props.modelId"
:scheme-config="schemeConfigData"
/>
<AForm v-else :model="{}" layout="vertical">
<div class="pricing-editor">
<!-- Billing Mode -->
<div class="editor-section">