From 8ec110a885bb7bdeba986e09b340fcb0cd9d07a2 Mon Sep 17 00:00:00 2001 From: qian Date: Sun, 13 Sep 2026 00:25:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(model):=E4=BC=98=E5=8C=96=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=BB=B7=E6=A0=BC=E8=AE=BE=E7=BD=AE=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/model.go | 10 + internal/logic/admin/model.go | 276 ++- internal/logic/billing/pricing.go | 24 +- internal/logic/billing/pricing_blob_test.go | 47 + internal/logic/billing/task_billing.go | 15 +- internal/logic/billing/task_billing_test.go | 32 + .../src/components/ModelPricingModal.vue | 1909 +++++++++-------- .../components/PricingParamRulesEditor.vue | 563 +++++ .../components/PricingTimeSegmentsEditor.vue | 412 ++++ 9 files changed, 2304 insertions(+), 984 deletions(-) create mode 100644 web/admin/src/components/PricingParamRulesEditor.vue create mode 100644 web/admin/src/components/PricingTimeSegmentsEditor.vue diff --git a/api/admin/v1/model.go b/api/admin/v1/model.go index 036fdda..f69a7dd 100644 --- a/api/admin/v1/model.go +++ b/api/admin/v1/model.go @@ -147,6 +147,11 @@ type PricingGetRes struct { List []PricingItem `json:"list"` TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价列表(pricing JSONB 顶层)"` ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(pricing JSONB 顶层,横切所有计费模式)"` + // 官方参考定价(official_pricing JSONB 还原,结构与计费定价完全一致;OfficialItems nil=未配置)。 + // 非计费依据,管理端按官方价 × 折扣快速定价 / 计算现价几折以此为准 + OfficialItems []PricingItem `json:"official_items" dc:"官方参考定价项(tiered 展开为多行;nil=未配置)"` + OfficialTimeSegments []TimeSegmentItem `json:"official_time_segments" dc:"官方参考-时段定价"` + OfficialParamMultipliers []ParamMultiplierItem `json:"official_param_multipliers" dc:"官方参考-参数倍率规则"` // 以下为定价行展示字段 PriceNote string `json:"price_note" dc:"价格说明(仅管理后台可见的内部备注)"` DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示,如 7折起)"` @@ -160,6 +165,11 @@ type PricingSetReq struct { Items []PricingItem `json:"items" v:"required" dc:"定价列表"` TimeSegments []TimeSegmentItem `json:"time_segments" dc:"时段定价(可选,全量替换;空数组清除时段配置)"` ParamMultipliers []ParamMultiplierItem `json:"param_multipliers" dc:"参数倍率规则(可选,全量替换;空数组清除)"` + // 官方参考定价(可选,结构与计费定价完全一致,支持全部四种计费模式 + 时段/倍率): + // OfficialItems nil=本次不动库内官方定价;非 nil=全量替换(三个数组全空=清除)。金额本位币,非计费依据 + OfficialItems []PricingItem `json:"official_items" dc:"官方参考定价项(nil=不修改;非 nil=全量替换,与另两个官方数组全空=清除)"` + OfficialTimeSegments []TimeSegmentItem `json:"official_time_segments" dc:"官方参考-时段定价(随 official_items 一并全量替换)"` + OfficialParamMultipliers []ParamMultiplierItem `json:"official_param_multipliers" dc:"官方参考-参数倍率规则(随 official_items 一并全量替换)"` // 以下为定价行展示字段(全量替换语义:空=清除;price_note 不透出到租户端) PriceNote string `json:"price_note" dc:"价格说明(仅内部可见)"` DiscountLabel string `json:"discount_label" dc:"折扣标签(对外展示)"` diff --git a/internal/logic/admin/model.go b/internal/logic/admin/model.go index b7336b1..e7e2fec 100644 --- a/internal/logic/admin/model.go +++ b/internal/logic/admin/model.go @@ -463,6 +463,7 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v var row *struct { BillingMode string `json:"billing_mode" orm:"billing_mode"` Pricing string `json:"pricing" orm:"pricing"` + OfficialPricing string `json:"official_pricing" orm:"official_pricing"` PriceNote *string `json:"price_note" orm:"price_note"` DiscountLabel *string `json:"discount_label" orm:"discount_label"` PriceChangeNote *string `json:"price_change_note" orm:"price_change_note"` @@ -475,68 +476,99 @@ func (s *sAdmin) GetModelPricing(ctx context.Context, req *v1.PricingGetReq) (*v return nil, err } - result := make([]v1.PricingItem, 0, 1) - var timeSegments []billing.TimeSegment - var paramRules []billing.ParamRule + var result []v1.PricingItem + var timeSegments []v1.TimeSegmentItem + var paramRules []v1.ParamMultiplierItem + var officialItems []v1.PricingItem + var officialTimeSegments []v1.TimeSegmentItem + var officialParamRules []v1.ParamMultiplierItem var priceNote, discountLabel, priceChangeNote string if row != nil { - mode := row.BillingMode - blob := billing.ParsePricingBlob(row.Pricing) - if blob != nil { - paramRules = blob.ParamMultipliers - } - switch { - case blob != nil && len(blob.Tiers) > 0: - // tiered:tiers 数组还原为多行 PricingItem(与旧档位行结构一致) - for _, tier := range blob.Tiers { - result = append(result, v1.PricingItem{ - BillingMode: mode, - MinTokens: tier.MinTokens, - MaxTokens: tier.MaxTokens, - InputPrice: tier.InputPrice, - OutputPrice: tier.OutputPrice, - }) - } - default: - item := v1.PricingItem{BillingMode: mode} - if blob != nil { - if blob.InputPrice != nil { - item.InputPrice = *blob.InputPrice - } - if blob.OutputPrice != nil { - item.OutputPrice = *blob.OutputPrice - } - if blob.CacheReadPrice != nil { - item.CacheReadPrice = *blob.CacheReadPrice - } - if blob.CacheCreationPrice != nil { - item.CacheCreationPrice = *blob.CacheCreationPrice - } - if blob.Price != nil { - p := *blob.Price - item.PerRequestPrice = &p - } - if len(blob.Prices) > 0 { - item.PerSecondPrices = blob.Prices - } - timeSegments = blob.TimeSegments - } - result = append(result, item) + // 主定价:pricing JSONB → items + 时段 + 倍率(与官方参考定价共用展开逻辑) + result, timeSegments, paramRules = expandPricingBlob(row.BillingMode, billing.ParsePricingBlob(row.Pricing)) + + // 官方参考定价:official_pricing JSONB 同构展开;未配置(NULL/解析失败)时三个字段保持 nil + offMode, offBlob := officialBlobFromJSON(row.OfficialPricing) + if offBlob != nil { + officialItems, officialTimeSegments, officialParamRules = expandPricingBlob(offMode, offBlob) } + priceNote = derefStr(row.PriceNote) discountLabel = derefStr(row.DiscountLabel) priceChangeNote = derefStr(row.PriceChangeNote) } return &v1.PricingGetRes{ - List: result, - TimeSegments: timeSegmentsToAPI(timeSegments), - ParamMultipliers: paramMultipliersToAPI(paramRules), - PriceNote: priceNote, - DiscountLabel: discountLabel, - PriceChangeNote: priceChangeNote, + List: result, + TimeSegments: timeSegments, + ParamMultipliers: paramRules, + OfficialItems: officialItems, + OfficialTimeSegments: officialTimeSegments, + OfficialParamMultipliers: officialParamRules, + PriceNote: priceNote, + DiscountLabel: discountLabel, + PriceChangeNote: priceChangeNote, }, nil } +// expandPricingBlob pricing JSONB 内存形态 → API 响应结构(items 行 + 时段 + 参数倍率)。 +// tiered 的 tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。blob 为 nil 时 +// 仍返回单行空 item(mode 随入参),保持「无定价」的既有响应形态。主定价与官方参考定价共用。 +func expandPricingBlob(mode string, blob *billing.PricingBlob) ([]v1.PricingItem, []v1.TimeSegmentItem, []v1.ParamMultiplierItem) { + if blob == nil { + blob = &billing.PricingBlob{} + } + items := make([]v1.PricingItem, 0, 1) + switch { + case len(blob.Tiers) > 0: + // tiered:tiers 数组还原为多行 PricingItem(与旧档位行结构一致)。 + // 逐档缓存价随行还原;首档无档内值时回退 blob 顶层锚点缓存价(存量数据兼容) + for i, tier := range blob.Tiers { + item := v1.PricingItem{ + BillingMode: mode, + MinTokens: tier.MinTokens, + MaxTokens: tier.MaxTokens, + InputPrice: tier.InputPrice, + OutputPrice: tier.OutputPrice, + } + if tier.CacheReadPrice != nil { + item.CacheReadPrice = *tier.CacheReadPrice + } else if i == 0 && blob.CacheReadPrice != nil { + item.CacheReadPrice = *blob.CacheReadPrice + } + if tier.CacheCreationPrice != nil { + item.CacheCreationPrice = *tier.CacheCreationPrice + } else if i == 0 && blob.CacheCreationPrice != nil { + item.CacheCreationPrice = *blob.CacheCreationPrice + } + items = append(items, item) + } + default: + item := v1.PricingItem{BillingMode: mode} + if blob.InputPrice != nil { + item.InputPrice = *blob.InputPrice + } + if blob.OutputPrice != nil { + item.OutputPrice = *blob.OutputPrice + } + if blob.CacheReadPrice != nil { + item.CacheReadPrice = *blob.CacheReadPrice + } + if blob.CacheCreationPrice != nil { + item.CacheCreationPrice = *blob.CacheCreationPrice + } + if blob.Price != nil { + p := *blob.Price + item.PerRequestPrice = &p + } + if len(blob.Prices) > 0 { + item.PerSecondPrices = blob.Prices + } + items = append(items, item) + } + // 时段与倍率横切所有计费模式(含 tiered),随 blob 顶层还原 + return items, timeSegmentsToAPI(blob.TimeSegments), paramMultipliersToAPI(blob.ParamMultipliers) +} + // derefStr 字符串指针安全解引用(NULL → 空串) func derefStr(s *string) string { if s == nil { @@ -671,20 +703,125 @@ func writePricingForModel(ctx context.Context, modelDBID int64, items []v1.Prici return err } - // 全量替换语义:删旧行插新行(保留展示字段列随 insert 重置为默认,随后由调用方回写) - if _, err := dao.MdlPricing.Ctx(ctx).Where("model_id", modelDBID).Delete(); err != nil { - return err - } - if _, err := dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{ - ModelId: modelDBID, - BillingMode: items[0].BillingMode, - Pricing: string(blobJSON), - }); err != nil { + // 全量替换语义改为 upsert:ON CONFLICT (model_id) 命中时只覆盖 billing_mode / pricing, + // official_pricing 与展示字段列未涉及时保留原值——模型导入共用本函数,不得误清人工 + // 核对落库的官方定价;展示字段随后仍由调用方按「空=清除」全量回写,行为与删行重插等价。 + // 列名直写而非 do 字段,与 gf gen dao 生成物解耦(同 writePricingDisplayFieldsForModel)。 + if _, err := dao.MdlPricing.Ctx(ctx). + Data(g.Map{ + "model_id": modelDBID, + "billing_mode": items[0].BillingMode, + "pricing": string(blobJSON), + }). + OnConflict("model_id"). + OnDuplicate(g.Map{ + "billing_mode": gdb.Raw("excluded.billing_mode"), + "pricing": gdb.Raw("excluded.pricing"), + }). + Save(); err != nil { return err } return nil } +// officialPricingBlob official_pricing JSONB 的内存形态:billing_mode 键 + PricingBlob 字段平铺 +// (encoding/json 对嵌入结构体平铺序列化),与计费定价 pricing JSONB 完全同构, +// 仅多 billing_mode 键——官方定价无独立模式列,模式随 JSONB 存储。 +type officialPricingBlob struct { + BillingMode string `json:"billing_mode"` + billing.PricingBlob +} + +// buildOfficialBlob 官方定价项 + 时段/倍率 → PricingBlob(与计费定价同构,复用同一构建管线)。 +// 全空(价格维度 + tiers + 时段 + 倍率均无)返回 nil,调用方按清除处理(写 NULL)。 +// 官方价是折扣换算的参照基准而非计费依据,是可选配置:per_second 空/全零矩阵属「未配置」 +// 形态,不走计费定价的矩阵强校验(否则未配置形态会报错,连带阻断主定价保存), +// 直接落成空 blob 交给下方全空判断;其余模式沿用 BuildPricingBlob 结构校验与时段/倍率 FromAPI 校验。 +func buildOfficialBlob(items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) (*billing.PricingBlob, error) { + segs, err := timeSegmentsFromAPI(segments) + if err != nil { + return nil, err + } + paramRules, err := paramMultipliersFromAPI(rules) + if err != nil { + return nil, err + } + input := pricingItemsToInput(items) + var blob *billing.PricingBlob + if len(input) > 0 && input[0].BillingMode == "per_second" && !perSecondMatrixHasPositivePrice(input[0].PerSecondPrices) { + blob = &billing.PricingBlob{} + } else { + blob, err = billing.BuildPricingBlob(input) + if err != nil { + return nil, err + } + } + blob.TimeSegments = segs + blob.ParamMultipliers = paramRules + empty := blob.InputPrice == nil && blob.OutputPrice == nil && blob.CacheReadPrice == nil && + blob.CacheCreationPrice == nil && len(blob.Tiers) == 0 && blob.Price == nil && + len(blob.Prices) == 0 && len(blob.TimeSegments) == 0 && len(blob.ParamMultipliers) == 0 + if empty { + return nil, nil + } + return blob, nil +} + +// perSecondMatrixHasPositivePrice 按秒矩阵是否存在任一正价(空矩阵/全零价=未配置形态) +func perSecondMatrixHasPositivePrice(prices map[string]float64) bool { + for _, p := range prices { + if p > 0 { + return true + } + } + return false +} + +// officialBlobFromJSON 解析 official_pricing JSONB → (billing_mode, PricingBlob)。 +// 空串/null/解析失败返回 ("", nil),调用方按未配置处理(参照字段,解析失败不阻断定价编辑)。 +func officialBlobFromJSON(raw string) (string, *billing.PricingBlob) { + if raw == "" || raw == "null" { + return "", nil + } + wrapped := officialPricingBlob{} + if err := json.Unmarshal([]byte(raw), &wrapped); err != nil { + return "", nil + } + mode := wrapped.BillingMode + if mode == "" { + mode = "token" + } + return mode, &wrapped.PricingBlob +} + +// writeOfficialPricingForModel 官方参考定价写入模型定价行(official_pricing 列整体替换, +// 全空=清除写 NULL)。与 pricing 列同构:BuildPricingBlob 构建 + 时段/倍率并入 blob 顶层, +// 支持全部四种计费模式。列名直写而非 do 字段,与 gf gen dao 生成物解耦 +// (同 writePricingDisplayFieldsForModel)。需在定价行存在之后执行。调用方需保证事务 ctx 传播。 +func writeOfficialPricingForModel(ctx context.Context, modelDBID int64, items []v1.PricingItem, segments []v1.TimeSegmentItem, rules []v1.ParamMultiplierItem) error { + blob, err := buildOfficialBlob(items, segments, rules) + if err != nil { + return err + } + data := any(gdb.Raw("NULL")) + if blob != nil { + mode := "token" + if len(items) > 0 { + mode = items[0].BillingMode + } + raw, mErr := json.Marshal(officialPricingBlob{BillingMode: mode, PricingBlob: *blob}) + if mErr != nil { + return mErr + } + data = string(raw) + } + _, err = dao.MdlPricing.Ctx(ctx). + Where("model_id", modelDBID). + Data(g.Map{"official_pricing": data}). + Update() + return err +} + // writeTimeSegmentsForModel 把时段定价并入模型定价行的 pricing JSONB 顶层(time_segments 键), // 空列表=清除(全量替换语义,写 "[]" 而非删除键:nil 切片会序列化成 "null",语义不明确)。 // 定价保存与模型导入共用。jsonb_set 只更新目标键,不覆盖 JSON 内其他计费字段。 @@ -748,6 +885,16 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v return nil, err } + // 官方参考定价的时段/倍率预校验(与主定价同一套校验;OfficialItems nil=本次不动官方定价) + if req.OfficialItems != nil { + if _, err := timeSegmentsFromAPI(req.OfficialTimeSegments); err != nil { + return nil, gerror.Wrap(err, "官方参考定价") + } + if _, err := paramMultipliersFromAPI(req.OfficialParamMultipliers); err != nil { + return nil, gerror.Wrap(err, "官方参考定价") + } + } + // 先查模型编码,供事务提交后按模型清除所有租户的价格缓存 var model struct { ModelId string `json:"model_id"` @@ -765,6 +912,15 @@ func (s *sAdmin) SetModelPricing(ctx context.Context, req *v1.PricingSetReq) (*v return err } + // 官方参考定价(OfficialItems nil=本次不动库内值;三个官方数组全空=清除)。 + // 与 pricing 列同构,支持全部四种计费模式 + 时段/倍率。官方价非计费依据, + // 不触发价格缓存失效 + if req.OfficialItems != nil { + if err := writeOfficialPricingForModel(ctx, req.ModelID, req.OfficialItems, req.OfficialTimeSegments, req.OfficialParamMultipliers); err != nil { + return gerror.Wrap(err, "官方参考定价") + } + } + // 时段定价并入 JSON 顶层(空=清除,全量替换语义;需在定价行插入之后执行) if err := writeTimeSegmentsForModel(ctx, req.ModelID, req.TimeSegments); err != nil { return err diff --git a/internal/logic/billing/pricing.go b/internal/logic/billing/pricing.go index 8ca65e4..dd47179 100644 --- a/internal/logic/billing/pricing.go +++ b/internal/logic/billing/pricing.go @@ -702,10 +702,12 @@ func BuildPricingBlob(items []PricingItemInput) (*PricingBlob, error) { } for _, item := range items { blob.Tiers = append(blob.Tiers, pricingTierRow{ - MinTokens: item.MinTokens, - MaxTokens: item.MaxTokens, - InputPrice: item.InputPrice, - OutputPrice: item.OutputPrice, + MinTokens: item.MinTokens, + MaxTokens: item.MaxTokens, + InputPrice: item.InputPrice, + OutputPrice: item.OutputPrice, + CacheReadPrice: positivePtr(item.CacheReadPrice), + CacheCreationPrice: positivePtr(item.CacheCreationPrice), }) } return blob, nil @@ -776,12 +778,16 @@ func tiersHavePrice(tiers []pricingTierRow) bool { return false } -// pricingTierRow 定价阶梯行(绝对价格) +// pricingTierRow 定价阶梯行(绝对价格)。 +// 逐档缓存价为可选键(omitempty,旧数据缺键 unmarshal 为 nil,无需迁移): +// 编辑器逐档缓存价落库于此;计费引擎的缓存 token 计费仍取 blob 顶层锚点缓存价,不读档内字段 type pricingTierRow struct { - MinTokens int64 `json:"min_tokens"` - MaxTokens *int64 `json:"max_tokens"` - InputPrice float64 `json:"input_price"` - OutputPrice float64 `json:"output_price"` + MinTokens int64 `json:"min_tokens"` + MaxTokens *int64 `json:"max_tokens"` + InputPrice float64 `json:"input_price"` + OutputPrice float64 `json:"output_price"` + CacheReadPrice *float64 `json:"cache_read_price,omitempty"` + CacheCreationPrice *float64 `json:"cache_creation_price,omitempty"` } // effectiveTimeMultiplier 时段乘数归一化:0/负值视为 1.0。 diff --git a/internal/logic/billing/pricing_blob_test.go b/internal/logic/billing/pricing_blob_test.go index 35fda75..51dad0e 100644 --- a/internal/logic/billing/pricing_blob_test.go +++ b/internal/logic/billing/pricing_blob_test.go @@ -101,3 +101,50 @@ func TestBuildPricingBlob_ZeroPricesOmitted(t *testing.T) { t.Fatalf("expected empty blob json, got %s", raw) } } + +// TestBuildPricingBlob_TieredPerTierCachePrices 阶梯逐档缓存价:档内键随行落库, +// 顶层锚点缓存价照旧写入(计费引擎的缓存 token 计费读顶层,不读档内字段)。 +// JSONB 往返后两处都完整保留;旧格式(档内无缓存键)unmarshal 为 nil 不报错。 +func TestBuildPricingBlob_TieredPerTierCachePrices(t *testing.T) { + max := int64(100000) + blob, err := BuildPricingBlob([]PricingItemInput{ + {BillingMode: "tiered", MinTokens: 0, MaxTokens: &max, InputPrice: 15, OutputPrice: 75, CacheReadPrice: 1.5, CacheCreationPrice: 18.75}, + {BillingMode: "tiered", MinTokens: 100000, InputPrice: 12, OutputPrice: 60, CacheReadPrice: 1.2, CacheCreationPrice: 15}, + }) + if err != nil { + t.Fatalf("build: %v", err) + } + // 顶层锚点缓存价保留(计费引擎读取口径) + if blob.CacheReadPrice == nil || *blob.CacheReadPrice != 1.5 || blob.CacheCreationPrice == nil || *blob.CacheCreationPrice != 18.75 { + t.Fatalf("anchor cache prices lost: %+v", blob) + } + // 逐档缓存价落行 + if blob.Tiers[0].CacheReadPrice == nil || *blob.Tiers[0].CacheReadPrice != 1.5 { + t.Fatalf("tier0 cache_read_price lost: %+v", blob.Tiers[0]) + } + if blob.Tiers[1].CacheReadPrice == nil || *blob.Tiers[1].CacheReadPrice != 1.2 { + t.Fatalf("tier1 cache_read_price lost: %+v", blob.Tiers[1]) + } + + // JSONB 往返(mdl_pricing.pricing 的真实存取路径) + raw, err := json.Marshal(blob) + if err != nil { + t.Fatalf("marshal: %v", err) + } + parsed := ParsePricingBlob(string(raw)) + if parsed == nil { + t.Fatal("parse failed") + } + if parsed.CacheReadPrice == nil || *parsed.CacheReadPrice != 1.5 { + t.Fatalf("roundtrip anchor cache lost: %+v", parsed) + } + if parsed.Tiers[1].CacheCreationPrice == nil || *parsed.Tiers[1].CacheCreationPrice != 15 { + t.Fatalf("roundtrip tier1 cache_creation lost: %+v", parsed.Tiers[1]) + } + + // 旧格式(档内无缓存键、顶层有锚点):unmarshal 不报错,档内为 nil + legacy := ParsePricingBlob(`{"tiers":[{"min_tokens":0,"input_price":15,"output_price":75}],"cache_read_price":1.5}`) + if legacy == nil || legacy.Tiers[0].CacheReadPrice != nil || legacy.CacheReadPrice == nil || *legacy.CacheReadPrice != 1.5 { + t.Fatalf("legacy blob parse broken: %+v", legacy) + } +} diff --git a/internal/logic/billing/task_billing.go b/internal/logic/billing/task_billing.go index 7a86b30..0ca22ac 100644 --- a/internal/logic/billing/task_billing.go +++ b/internal/logic/billing/task_billing.go @@ -87,8 +87,12 @@ func estimateTaskCost(pricing *PricingResult, ratios map[string]any) decimal.Dec Mul(NewFromFloat(pricing.TenantMultiplier)). Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) case pricing.BillingMode == "per_request": - // 按次计费:直接用单价 - costD = NewFromFloat(pricing.PerRequestPrice) + // 按次计费:单价 × 租户乘数 × 时段乘数。与 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 { @@ -112,11 +116,14 @@ func estimateTaskCost(pricing *PricingResult, ratios map[string]any) decimal.Dec 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) + costD = NewFromFloat(pricing.PerRequestPrice). + Mul(NewFromFloat(pricing.TenantMultiplier)). + Mul(NewFromFloat(effectiveTimeMultiplier(pricing))) } else { costD = NewFromFloat(imagePlaceholderPreDeduct) } diff --git a/internal/logic/billing/task_billing_test.go b/internal/logic/billing/task_billing_test.go index 757d8aa..5bf17d6 100644 --- a/internal/logic/billing/task_billing_test.go +++ b/internal/logic/billing/task_billing_test.go @@ -168,6 +168,38 @@ func TestEstimateTaskCost_PerRequest(t *testing.T) { assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "per_request cost") } +// TestEstimateTaskCost_PerRequestTenantTimeDiscount 按次任务预扣即终价(结算无 token 重算 +// 信号),租户乘数与时段乘数必须在预扣阶段就生效,与 chat 路径 computeCost 的按次口径一致。 +func TestEstimateTaskCost_PerRequestTenantTimeDiscount(t *testing.T) { + pricing := &PricingResult{ + BillingMode: "per_request", + PerRequestPrice: 0.10, + TenantMultiplier: 0.5, + TimeMultiplier: 0.8, + } + // 0.10 × 0.5 × 0.8 = 0.04 + assertDecimal(t, estimateTaskCost(pricing, nil), 0.04, "per_request tenant × time discount") + + // token 模式但仅配按次价的图片任务(default 分支回退)同样生效 + p2 := &PricingResult{ + BillingMode: "token", + PerRequestPrice: 0.10, + OutputPrice: 30.0, + TenantMultiplier: 0.5, + TimeMultiplier: 0.8, + } + assertDecimal(t, estimateTaskCost(p2, nil), 0.04, "image per-request fallback discount") + + // 时段乘数零值(旧缓存条目缺字段)兜底 1.0,不得把预扣清零 + p3 := &PricingResult{ + BillingMode: "per_request", + PerRequestPrice: 0.10, + TenantMultiplier: 0.5, + TimeMultiplier: 0, + } + assertDecimal(t, estimateTaskCost(p3, nil), 0.05, "zero time multiplier fallback") +} + // TestEstimateTaskCost_VideoWithDurationSignal 视频任务(ratios 携带 duration/resolution) // 才走 10000×duration×resolution 的 token 估算:默认 5s×720p(2.25)×$30/1M = $3.375。 func TestEstimateTaskCost_VideoWithDurationSignal(t *testing.T) { diff --git a/web/admin/src/components/ModelPricingModal.vue b/web/admin/src/components/ModelPricingModal.vue index 81b09c4..6ce2896 100644 --- a/web/admin/src/components/ModelPricingModal.vue +++ b/web/admin/src/components/ModelPricingModal.vue @@ -1,9 +1,16 @@ + + + + + + diff --git a/web/admin/src/components/PricingTimeSegmentsEditor.vue b/web/admin/src/components/PricingTimeSegmentsEditor.vue new file mode 100644 index 0000000..77f8496 --- /dev/null +++ b/web/admin/src/components/PricingTimeSegmentsEditor.vue @@ -0,0 +1,412 @@ + + + + + + +