fix(model):修复minimax-h3模型计费问题
This commit is contained in:
1 parent
417c97f94d
commit
2de068633c
3 files changed
+132
-95
No files matched your search
@@ -18,7 +18,11 @@ import (
|
||||
// 总费用 = 输出生成费 + 输入图片费 + 输入视频费(输入音频免费,不配置即不计费)
|
||||
// 输出生成费 = 输出秒数 × pricing JSONB per_second 矩阵[生成分辨率]
|
||||
// 输入图片费 = max(0, 图片张数 - free_count) × price_per_unit
|
||||
// 输入视频费 = 输入视频秒数 × price_per_second(官方 usage 无输入视频分辨率,按秒单一单价)
|
||||
// 输入视频费 = 输入视频秒数 × per_second 矩阵[生成分辨率]
|
||||
//
|
||||
// 输入视频不单独定价:官方规则为「输入视频按时长、以输出分辨率对应单价计费」
|
||||
// (与生成视频同价),单价复用输出生成矩阵按生成分辨率查取,scheme_config 无
|
||||
// input_video 节(存量配置残留该节在保存时被校验拒绝,提示移除)。
|
||||
//
|
||||
// 预扣 vs 结算:
|
||||
// - 图片张数提交时可知(adaptor 上报 ratios 的 spec.input_image_count)→ 预扣即精确;
|
||||
@@ -28,11 +32,11 @@ import (
|
||||
// 结算时 usage 某字段缺失(上游未返回)则回退 ratios 中提交时刻的对应 spec.* 事实值,
|
||||
// 官方完全不返回素材计量时整体回退预扣口径(多退少补兜底)。
|
||||
//
|
||||
// scheme_config(pricing JSONB 不透明容器,金额本位币):
|
||||
// scheme_config(pricing JSONB 不透明容器,金额本位币;可不配置 = 图片免费,
|
||||
// 输出与输入视频均按 per_second 矩阵计费):
|
||||
//
|
||||
// {
|
||||
// "image": {"free_count": 5, "price_per_unit": 0.20},
|
||||
// "input_video": {"price_per_second": 0.50}
|
||||
// "image": {"free_count": 5, "price_per_unit": 0.20}
|
||||
// }
|
||||
|
||||
// SchemeMiniMaxMaterial MiniMax 素材计费方案名(模型 pricing JSONB scheme 键引用)
|
||||
@@ -47,10 +51,9 @@ const inputVideoMaxSeconds = 600.0
|
||||
// 避免 URL 素材时长未知导致预扣少冻结。
|
||||
const inputVideoEstimateSeconds = 15.0
|
||||
|
||||
// minimaxMaterialConfig 方案私有配置
|
||||
// minimaxMaterialConfig 方案私有配置(输入视频按生成分辨率单价计费,无独立配置)
|
||||
type minimaxMaterialConfig struct {
|
||||
Image *minimaxImagePrice `json:"image,omitempty"`
|
||||
InputVideo *minimaxInputVideoPrice `json:"input_video,omitempty"`
|
||||
Image *minimaxImagePrice `json:"image,omitempty"`
|
||||
}
|
||||
|
||||
// minimaxImagePrice 输入图片单价:请求级免费额度 + 超出部分单价
|
||||
@@ -59,12 +62,6 @@ type minimaxImagePrice struct {
|
||||
PricePerUnit float64 `json:"price_per_unit"` // 超出部分单价(本位币/张)
|
||||
}
|
||||
|
||||
// minimaxInputVideoPrice 输入视频按秒单一单价:官方 usage 只有 input_seconds
|
||||
// (无输入视频分辨率信息),不做规格区分
|
||||
type minimaxInputVideoPrice struct {
|
||||
PricePerSecond float64 `json:"price_per_second"`
|
||||
}
|
||||
|
||||
// minimaxMaterialScheme MiniMax 素材计费方案(无状态单例,配置从 pricing.SchemeConfig 读取)
|
||||
type minimaxMaterialScheme struct{}
|
||||
|
||||
@@ -87,16 +84,24 @@ func (minimaxMaterialScheme) parseConfig(pricing *PricingResult) *minimaxMateria
|
||||
}
|
||||
|
||||
// ValidateSchemeConfig 校验 scheme_config(管理端保存 fail-fast):
|
||||
// 单价非负、免费额度非负、输入视频矩阵至少一档正价且规格键非空、至少配置一个计费节。
|
||||
// 配置可空(图片免费、仅按秒矩阵计费输出与输入视频的合法形态);配置时校验图片
|
||||
// 单价与免费额度非负;input_video 节已废弃(输入视频改按生成分辨率单价计费),
|
||||
// 残留即拒绝,提示管理员移除后重存,避免旧单价静默失效造成计费口径突变。
|
||||
func (minimaxMaterialScheme) ValidateSchemeConfig(cfg json.RawMessage) error {
|
||||
if len(cfg) == 0 || string(cfg) == "null" {
|
||||
return gerror.New("素材计费方案必须配置 scheme_config(图片单价 / 输入视频单价至少一项)")
|
||||
return nil
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(cfg, &raw); err != nil {
|
||||
return gerror.Wrap(err, "scheme_config 解析失败")
|
||||
}
|
||||
if _, ok := raw["input_video"]; ok {
|
||||
return gerror.New("输入视频不再单独定价(按生成分辨率单价计费),请移除 scheme_config 中的 input_video 配置")
|
||||
}
|
||||
var c minimaxMaterialConfig
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return gerror.Wrap(err, "scheme_config 解析失败")
|
||||
}
|
||||
hasPricedSection := false
|
||||
if c.Image != nil {
|
||||
if c.Image.PricePerUnit < 0 {
|
||||
return gerror.New("输入图片单价不能为负")
|
||||
@@ -104,32 +109,18 @@ func (minimaxMaterialScheme) ValidateSchemeConfig(cfg json.RawMessage) error {
|
||||
if c.Image.FreeCount < 0 {
|
||||
return gerror.New("输入图片免费张数不能为负")
|
||||
}
|
||||
if c.Image.PricePerUnit > 0 {
|
||||
hasPricedSection = true
|
||||
}
|
||||
}
|
||||
if c.InputVideo != nil {
|
||||
if c.InputVideo.PricePerSecond < 0 {
|
||||
return gerror.New("输入视频每秒单价不能为负")
|
||||
}
|
||||
if c.InputVideo.PricePerSecond == 0 {
|
||||
return gerror.New("输入视频计费必须配置正的每秒单价(音频免费无需配置,视频不免费请填正数)")
|
||||
}
|
||||
hasPricedSection = true
|
||||
}
|
||||
if !hasPricedSection {
|
||||
return gerror.New("素材计费方案至少配置图片单价或输入视频单价之一")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateTaskCost 预扣估算:输出组件 + 提交可知的素材组件(图片张数精确、输入视频按 0 估)。
|
||||
// EstimateTaskCost 预扣估算:按秒组件(输出生成 + 输入视频,共用生成分辨率单价)+
|
||||
// 提交可知的图片组件(张数精确)。
|
||||
func (s minimaxMaterialScheme) EstimateTaskCost(pricing *PricingResult, ratios map[string]any, _ []byte) decimal.Decimal {
|
||||
cfg := s.parseConfig(pricing)
|
||||
spec, _ := ratioString(ratios, "spec.resolution")
|
||||
total := decimal.Zero
|
||||
|
||||
// 输出生成组件:per_second 矩阵(与通用按秒引擎共用查价与兜底链),矩阵未配置时为 0
|
||||
// 按秒组件(与通用按秒引擎共用查价与兜底链),矩阵未配置时输出与输入视频均不计费
|
||||
if price := LookupPerSecondPrice(pricing.PerSecondPrices, spec); price > 0 {
|
||||
duration := defaultTaskDurationSeconds
|
||||
if d, ok := ratioFloat(ratios, "spec.duration"); ok && d > 0 {
|
||||
@@ -138,15 +129,16 @@ func (s minimaxMaterialScheme) EstimateTaskCost(pricing *PricingResult, ratios m
|
||||
if duration > maxTaskDurationSeconds {
|
||||
duration = maxTaskDurationSeconds
|
||||
}
|
||||
// 输出生成:时长 × 单价
|
||||
total = total.Add(NewFromFloat(price).Mul(NewFromFloat(duration)))
|
||||
// 输入视频:估算时长(确知时长 > 含视频按生成长度上限 15s 估 > 0)× 同一单价,
|
||||
// 结算以官方 usage 补差
|
||||
total = total.Add(inputVideoCost(price, estimateInputVideoSeconds(ratios)))
|
||||
}
|
||||
|
||||
// 输入图片:请求级免费额度扣减
|
||||
total = total.Add(s.imageCost(cfg, ratioFloatOr(ratios, "spec.input_image_count", 0)))
|
||||
|
||||
// 输入视频:时长 × 按秒单价(提交含输入视频按生成长度上限 15s 冻结,结算以官方 usage 补扣)
|
||||
total = total.Add(s.inputVideoCost(cfg, estimateInputVideoSeconds(ratios)))
|
||||
|
||||
return total.Mul(NewFromFloat(pricing.TenantMultiplier)).
|
||||
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
|
||||
}
|
||||
@@ -162,8 +154,11 @@ func (s minimaxMaterialScheme) SettleTaskCost(pricing *PricingResult, ratios map
|
||||
spec, _ := ratioString(ratios, "spec.resolution")
|
||||
total := decimal.Zero
|
||||
|
||||
// 按秒组件单价:生成分辨率矩阵查价(输出与输入视频同价),矩阵未配置时为 0
|
||||
price := LookupPerSecondPrice(pricing.PerSecondPrices, spec)
|
||||
|
||||
// 输出生成组件:优先官方 output_seconds,缺失回退请求时长信号(与预扣同源)
|
||||
if price := LookupPerSecondPrice(pricing.PerSecondPrices, spec); price > 0 {
|
||||
if price > 0 {
|
||||
outputSecs := usage.OutputSeconds
|
||||
if outputSecs <= 0 {
|
||||
outputSecs = ratioFloatOr(ratios, "spec.duration", defaultTaskDurationSeconds)
|
||||
@@ -186,7 +181,7 @@ func (s minimaxMaterialScheme) SettleTaskCost(pricing *PricingResult, ratios map
|
||||
if inputSecs <= 0 {
|
||||
inputSecs = estimateInputVideoSeconds(ratios)
|
||||
}
|
||||
total = total.Add(s.inputVideoCost(cfg, inputSecs))
|
||||
total = total.Add(inputVideoCost(price, inputSecs))
|
||||
|
||||
return total.Mul(NewFromFloat(pricing.TenantMultiplier)).
|
||||
Mul(NewFromFloat(effectiveTimeMultiplier(pricing)))
|
||||
@@ -201,16 +196,17 @@ func (minimaxMaterialScheme) imageCost(cfg *minimaxMaterialConfig, count float64
|
||||
return NewFromFloat(charged).Mul(NewFromFloat(cfg.Image.PricePerUnit))
|
||||
}
|
||||
|
||||
// inputVideoCost 输入视频费用:时长(钳制上限)× 按秒单一单价
|
||||
// (官方 usage 无输入视频分辨率信息,不做规格区分)
|
||||
func (minimaxMaterialScheme) inputVideoCost(cfg *minimaxMaterialConfig, seconds float64) decimal.Decimal {
|
||||
if cfg == nil || cfg.InputVideo == nil || cfg.InputVideo.PricePerSecond <= 0 || seconds <= 0 {
|
||||
// inputVideoCost 输入视频费用:时长(钳制上限)× 生成分辨率单价。
|
||||
// 官方规则:输入视频按时长、以输出分辨率对应单价计费(与生成视频同价),
|
||||
// 不单独定价、不区分输入视频自身分辨率(官方 usage 无该信息)。
|
||||
func inputVideoCost(price, seconds float64) decimal.Decimal {
|
||||
if price <= 0 || seconds <= 0 {
|
||||
return decimal.Zero
|
||||
}
|
||||
if seconds > inputVideoMaxSeconds {
|
||||
seconds = inputVideoMaxSeconds
|
||||
}
|
||||
return NewFromFloat(seconds).Mul(NewFromFloat(cfg.InputVideo.PricePerSecond))
|
||||
return NewFromFloat(seconds).Mul(NewFromFloat(price))
|
||||
}
|
||||
|
||||
// estimateInputVideoSeconds 输入视频预扣估算时长(秒):
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// mmTestPricing 构造 MiniMax 素材方案测试定价:
|
||||
// 输出矩阵 768P=0.35 / 2K=0.6;图片 5 张免费、超出 0.20/张;输入视频按秒 0.50。
|
||||
// 按秒矩阵 768P=0.35 / 2K=0.6(输出生成与输入视频共用);图片 5 张免费、超出 0.20/张。
|
||||
// 乘数恒 1(租户/时段折扣单测用例另行覆盖)。
|
||||
// billing_mode 用 special(方案模型的标准配对形态);分发只看 Scheme,
|
||||
// per_second+scheme 的存量组合由 scheme_test 覆盖。
|
||||
@@ -20,13 +20,12 @@ func mmTestPricing(t *testing.T, tenantMul float64) *PricingResult {
|
||||
PerSecondPrices: map[string]float64{"768P": 0.35, "2K": 0.6},
|
||||
Scheme: SchemeMiniMaxMaterial,
|
||||
SchemeConfig: json.RawMessage(`{
|
||||
"image": {"free_count": 5, "price_per_unit": 0.20},
|
||||
"input_video": {"price_per_second": 0.50}
|
||||
"image": {"free_count": 5, "price_per_unit": 0.20}
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinimaxMaterial_Estimate 预扣口径:输出组件 + 提交可知的素材组件
|
||||
// TestMinimaxMaterial_Estimate 预扣口径:按秒组件(输出 + 输入视频同价)+ 提交可知的图片组件
|
||||
// (图片精确、含输入视频按生成长度上限 15s 冻结)。
|
||||
func TestMinimaxMaterial_Estimate(t *testing.T) {
|
||||
pricing := mmTestPricing(t, 1.0)
|
||||
@@ -51,20 +50,28 @@ func TestMinimaxMaterial_Estimate(t *testing.T) {
|
||||
"spec.duration": 6.0, "spec.resolution": "2K", "spec.input_image_count": 8.0,
|
||||
}, nil), 6*0.6+3*0.20, "no input video at estimate")
|
||||
|
||||
// 提交含输入视频(时长未知):按生成长度上限 15s 冻结 → 2.1 + 15×0.50
|
||||
// 提交含输入视频(时长未知):按生成长度上限 15s × 生成分辨率单价冻结 → 2.1 + 15×0.35
|
||||
assertDecimal(t, estimateTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": true,
|
||||
}, nil), 2.1+15*0.50, "input video frozen at generation cap")
|
||||
}, nil), 2.1+15*0.35, "input video frozen at generation cap")
|
||||
|
||||
// has_input_video=false 显式无视频:不含输入视频费
|
||||
assertDecimal(t, estimateTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": false,
|
||||
}, nil), 2.1, "explicit no input video")
|
||||
|
||||
// adaptor 确知输入时长时(spec.input_video_seconds):按确知时长优先于 15s 上限
|
||||
// adaptor 确知输入时长时(spec.input_video_seconds):按确知时长优先于 15s 上限,
|
||||
// 单价同生成分辨率(2K)
|
||||
assertDecimal(t, estimateTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 6.0, "spec.resolution": "2K", "spec.has_input_video": true, "spec.input_video_seconds": 8.0,
|
||||
}, nil), 6*0.6+8*0.50, "input video estimated via ratios")
|
||||
}, nil), 6*0.6+8*0.6, "input video estimated via ratios")
|
||||
|
||||
// 矩阵未配置(查价为 0):输出与输入视频均不计费,仅图片组件
|
||||
noMatrix := mmTestPricing(t, 1.0)
|
||||
noMatrix.PerSecondPrices = nil
|
||||
assertDecimal(t, estimateTaskCost(noMatrix, map[string]any{
|
||||
"spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.has_input_video": true,
|
||||
}, nil), 3*0.20, "empty matrix: no per-second components")
|
||||
}
|
||||
|
||||
// TestMinimaxMaterial_Settle 结算口径:官方 usage 为最终计费依据。
|
||||
@@ -72,17 +79,17 @@ func TestMinimaxMaterial_Settle(t *testing.T) {
|
||||
pricing := mmTestPricing(t, 1.0)
|
||||
scheme := minimaxMaterialScheme{}
|
||||
|
||||
// usage 全量(768P):输出 6×0.35 + 图 (8-5)×0.20 + 输入视频 8×0.50 = 6.7
|
||||
// usage 全量(768P):输出 6×0.35 + 图 (8-5)×0.20 + 输入视频 8×0.35(与输出同价)
|
||||
cost := scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0,
|
||||
}, &rcommon.TaskMaterialUsage{InputVideoSeconds: 8, InputImageCount: 8, OutputSeconds: 6})
|
||||
assertDecimal(t, cost, 6.7, "settle full usage 768P")
|
||||
assertDecimal(t, cost, 6*0.35+3*0.20+8*0.35, "settle full usage 768P")
|
||||
|
||||
// 2K 档:输出 6×0.6 + 输入视频 10×0.50 = 8.6(输入视频按秒单一单价,与生成分辨率无关)
|
||||
// 2K 档:输出与输入视频均按生成分辨率单价 → (6+10)×0.6
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 6.0, "spec.resolution": "2K",
|
||||
}, &rcommon.TaskMaterialUsage{InputVideoSeconds: 10, OutputSeconds: 6})
|
||||
assertDecimal(t, cost, 8.6, "settle 2K with input video")
|
||||
assertDecimal(t, cost, 16*0.6, "settle 2K with input video")
|
||||
|
||||
// usage 字段部分缺失:图片回退 ratios 提交时事实值,输出回退 spec.duration;
|
||||
// 输入视频缺失且提交不含视频 → 0
|
||||
@@ -91,11 +98,11 @@ func TestMinimaxMaterial_Settle(t *testing.T) {
|
||||
}, &rcommon.TaskMaterialUsage{OutputSeconds: 5})
|
||||
assertDecimal(t, cost, 5*0.35+3*0.20, "settle partial usage falls back to ratios")
|
||||
|
||||
// 输入视频字段缺失但提交含输入视频:回退预扣口径(15s 上限)
|
||||
// 输入视频字段缺失但提交含输入视频:回退预扣口径(15s 上限 × 生成分辨率单价)
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.has_input_video": true,
|
||||
}, &rcommon.TaskMaterialUsage{OutputSeconds: 5})
|
||||
assertDecimal(t, cost, 5*0.35+3*0.20+15*0.50, "settle missing input seconds falls back to cap estimate")
|
||||
assertDecimal(t, cost, 5*0.35+3*0.20+15*0.35, "settle missing input seconds falls back to cap estimate")
|
||||
|
||||
// usage 为 nil:整体回退预扣口径(含输入视频时同样含 15s 冻结)
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
@@ -106,13 +113,13 @@ func TestMinimaxMaterial_Settle(t *testing.T) {
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 6.0, "spec.resolution": "768P", "spec.has_input_video": true,
|
||||
}, nil)
|
||||
assertDecimal(t, cost, 6*0.35+15*0.50, "settle nil usage with input video falls back to cap estimate")
|
||||
assertDecimal(t, cost, (6+15)*0.35, "settle nil usage with input video falls back to cap estimate")
|
||||
|
||||
// 输入视频异常大值钳制到 600s(防 usage 异常刷扣)
|
||||
cost = scheme.SettleTaskCost(pricing, map[string]any{
|
||||
"spec.resolution": "768P",
|
||||
}, &rcommon.TaskMaterialUsage{InputVideoSeconds: 99999, OutputSeconds: 1})
|
||||
assertDecimal(t, cost, 1*0.35+600*0.50, "settle clamps input video seconds")
|
||||
assertDecimal(t, cost, 1*0.35+600*0.35, "settle clamps input video seconds")
|
||||
}
|
||||
|
||||
// TestMinimaxMaterial_Multipliers 租户折扣作用于全部组件(输出 + 素材)。
|
||||
@@ -121,7 +128,7 @@ func TestMinimaxMaterial_Multipliers(t *testing.T) {
|
||||
cost := estimateTaskCost(pricing, map[string]any{
|
||||
"spec.duration": 6.0, "spec.resolution": "768P", "spec.input_image_count": 8.0, "spec.input_video_seconds": 4.0,
|
||||
}, nil)
|
||||
assertDecimal(t, cost, (6*0.35+3*0.20+4*0.50)*0.5, "tenant multiplier applies to all components")
|
||||
assertDecimal(t, cost, (6*0.35+3*0.20+4*0.35)*0.5, "tenant multiplier applies to all components")
|
||||
}
|
||||
|
||||
// TestMinimaxMaterial_ValidateSchemeConfig 方案私有配置校验。
|
||||
@@ -132,10 +139,10 @@ func TestMinimaxMaterial_ValidateSchemeConfig(t *testing.T) {
|
||||
if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"image":{"free_count":5,"price_per_unit":0.2}}`)); err != nil {
|
||||
t.Errorf("valid image config rejected: %v", err)
|
||||
}
|
||||
// 空 / null / 全空 → 拒绝
|
||||
// 空 / null / 空对象 → 合法(图片免费,输出与输入视频均按 per_second 矩阵计费)
|
||||
for _, cfg := range []json.RawMessage{nil, json.RawMessage("null"), json.RawMessage(`{}`)} {
|
||||
if err := scheme.ValidateSchemeConfig(cfg); err == nil {
|
||||
t.Errorf("empty config should be rejected: %s", cfg)
|
||||
if err := scheme.ValidateSchemeConfig(cfg); err != nil {
|
||||
t.Errorf("empty config should be accepted: %s (%v)", cfg, err)
|
||||
}
|
||||
}
|
||||
// 负单价 / 负免费额度
|
||||
@@ -145,15 +152,9 @@ func TestMinimaxMaterial_ValidateSchemeConfig(t *testing.T) {
|
||||
if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"image":{"free_count":-1,"price_per_unit":0.2}}`)); err == nil {
|
||||
t.Error("negative free count should be rejected")
|
||||
}
|
||||
// 输入视频按秒单价:零价(配置了节但不收费)与负价拒绝
|
||||
if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":0}}`)); err == nil {
|
||||
t.Error("zero input video price should be rejected")
|
||||
}
|
||||
if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":-0.5}}`)); err == nil {
|
||||
t.Error("negative input video price should be rejected")
|
||||
}
|
||||
if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":0.5}}`)); err != nil {
|
||||
t.Errorf("valid input video price rejected: %v", err)
|
||||
// input_video 节已废弃(改按生成分辨率单价计费):残留即拒绝,防旧配置静默失效
|
||||
if err := scheme.ValidateSchemeConfig(json.RawMessage(`{"input_video":{"price_per_second":0.5}}`)); err == nil {
|
||||
t.Error("deprecated input_video section should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { currencySymbol } from '@/composables/useCurrency'
|
||||
|
||||
// MiniMax 素材计费方案编辑器(scheme = custom:minimax-material,契约见 pricingSchemeEditors.ts):
|
||||
// 费用 = 输出生成(per_second 矩阵,存 pricing JSONB prices)
|
||||
// + 输入视频(按输入时长 × 生成分辨率单价,复用输出生成矩阵,无需单独配置)
|
||||
// + 输入图片(免费额度 + 单价,存 scheme_config.image)
|
||||
// + 输入视频(按秒单一单价,存 scheme_config.input_video.price_per_second)
|
||||
// 输入音频免费(无需配置)。时段定价 / 参数倍率不适用于素材计费,保存时恒清除。
|
||||
// 展示字段(折扣标签 / 价格调整说明 / 价格说明)与通用表单同源,随保存全量替换提交
|
||||
// (后端空串=清除,缺省不传同样清空,故必须显式回传)。
|
||||
// 金额单位均为本位币(bil 层存储原值,不折算)。
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -26,8 +28,10 @@ const outputRows = reactive<PriceRow[]>([])
|
||||
// 输入图片素材
|
||||
const imageFreeCount = ref<number>(5)
|
||||
const imagePrice = ref<number>(0)
|
||||
// 输入视频按秒单一单价(官方 usage 无输入视频分辨率信息,不做规格区分)
|
||||
const inputVideoPrice = ref<number>(0)
|
||||
// 展示字段(挂锚点行:对外营销文案 + 内部备注,与通用定价表单同源)
|
||||
const discountLabel = ref('')
|
||||
const priceChangeNote = ref('')
|
||||
const priceNote = ref('')
|
||||
|
||||
const outputSpecPresets = ['480P', '768P', '2K', '*']
|
||||
|
||||
@@ -78,21 +82,19 @@ onMounted(async () => {
|
||||
const cfg = data?.scheme_config || props.schemeConfig || {}
|
||||
imageFreeCount.value = cfg.image?.free_count ?? 5
|
||||
imagePrice.value = Number(cfg.image?.price_per_unit) || 0
|
||||
inputVideoPrice.value = Number(cfg.input_video?.price_per_second) || 0
|
||||
// 展示字段(锚点行直存列,与通用表单同源)
|
||||
discountLabel.value = data?.discount_label || ''
|
||||
priceChangeNote.value = data?.price_change_note || ''
|
||||
priceNote.value = data?.price_note || ''
|
||||
} catch {
|
||||
// error handled by interceptor;回显留空可手工填写
|
||||
}
|
||||
})
|
||||
|
||||
async function save(): Promise<boolean> {
|
||||
// 输出生成矩阵:至少一档正价
|
||||
// 输出生成矩阵:至少一档正价(该矩阵同时用于输出生成与输入视频按秒计费)
|
||||
const outputMap = buildMatrix(outputRows, true)
|
||||
if (!outputMap) return false
|
||||
// 输入视频按秒单价:可选配置(0 = 不对输入视频计费)
|
||||
if (inputVideoPrice.value < 0) {
|
||||
Message.warning('输入视频单价不能为负')
|
||||
return false
|
||||
}
|
||||
// 图片参数
|
||||
if (imageFreeCount.value < 0) {
|
||||
Message.warning('输入图片免费张数不能为负')
|
||||
@@ -102,20 +104,13 @@ async function save(): Promise<boolean> {
|
||||
Message.warning('输入图片单价不能为负')
|
||||
return false
|
||||
}
|
||||
// 素材配置至少一项(与后端 scheme_config 校验一致)
|
||||
if (imagePrice.value <= 0 && inputVideoPrice.value <= 0) {
|
||||
Message.warning('至少配置输入图片单价或输入视频单价之一(输入音频免费无需配置)')
|
||||
return false
|
||||
}
|
||||
// 时段 / 参数倍率不适用于素材计费,恒传空数组清除(全量替换语义)
|
||||
|
||||
// scheme_config 仅含图片配置(0 = 图片免费;输入视频按输出生成矩阵计费,无需配置)
|
||||
const schemeConfig: any = {}
|
||||
if (imagePrice.value > 0) {
|
||||
schemeConfig.image = { free_count: imageFreeCount.value, price_per_unit: imagePrice.value }
|
||||
}
|
||||
if (inputVideoPrice.value > 0) {
|
||||
schemeConfig.input_video = { price_per_second: inputVideoPrice.value }
|
||||
}
|
||||
|
||||
await request.put(`/admin/models/${props.modelId}/pricing`, {
|
||||
items: [
|
||||
@@ -137,6 +132,10 @@ async function save(): Promise<boolean> {
|
||||
scheme_config: schemeConfig,
|
||||
time_segments: [],
|
||||
param_multipliers: [],
|
||||
// 展示字段全量替换提交(后端空串=清除;缺省不传同样清空,必须显式回传)
|
||||
price_note: priceNote.value.trim(),
|
||||
discount_label: discountLabel.value.trim(),
|
||||
price_change_note: priceChangeNote.value.trim(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
@@ -175,7 +174,7 @@ defineExpose({ save })
|
||||
<div class="editor-section">
|
||||
<div class="section-header">
|
||||
<h3>输入素材计费</h3>
|
||||
<span class="section-hint">音频免费;图片按张(含免费额度);输入视频按其时长 × 生成分辨率单价(结算以官方 usage 为准)</span>
|
||||
<span class="section-hint">音频免费;图片按张(含免费额度);输入视频自动按输出生成矩阵单价 × 输入时长计费(结算以官方 usage 为准,无需单独配置)</span>
|
||||
</div>
|
||||
<div class="image-config">
|
||||
<div class="field-item">
|
||||
@@ -190,11 +189,32 @@ defineExpose({ save })
|
||||
<template #suffix>{{ currencySymbol }} / 张</template>
|
||||
</AInputNumber>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-section">
|
||||
<div class="section-header">
|
||||
<h3>展示信息</h3>
|
||||
<span class="section-hint">折扣标签与调整说明会展示给租户;价格说明仅管理后台可见</span>
|
||||
</div>
|
||||
<div class="display-config">
|
||||
<div class="field-item">
|
||||
<span class="field-label">输入视频单价(按秒)</span>
|
||||
<AInputNumber v-model="inputVideoPrice" :min="0" :step="0.01" size="small" class="field-input">
|
||||
<template #suffix>{{ currencySymbol }} / 秒</template>
|
||||
</AInputNumber>
|
||||
<span class="field-label">折扣标签(对外展示)</span>
|
||||
<AInput v-model="discountLabel" :maxlength="50" placeholder="如:7折起、限时5折" allow-clear size="small" class="field-input-flex" />
|
||||
</div>
|
||||
<div class="field-item">
|
||||
<span class="field-label">价格调整说明(对外展示)</span>
|
||||
<AInput v-model="priceChangeNote" :maxlength="200" placeholder="如:9月1日起价格下调 20%" allow-clear size="small" class="field-input-flex" />
|
||||
</div>
|
||||
<div class="field-item field-item-top">
|
||||
<span class="field-label">价格说明(仅内部可见)</span>
|
||||
<ATextarea
|
||||
v-model="priceNote"
|
||||
:max-length="500"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
placeholder="调价背景、渠道成本等内部备注,不对外展示"
|
||||
class="field-input-flex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,6 +291,26 @@ defineExpose({ save })
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.display-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-input-flex {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 多行文本行:标签顶对齐,避免 textarea 垂直居中错位 */
|
||||
.field-item-top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.field-item-top .field-label {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.field-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in new issue
Block a user