From f4b62b8e11a1e91295e5cf8950c9f0c8cedad7b0 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sat, 22 Aug 2026 17:28:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(models):=20=E6=A8=A1=E5=9E=8B=E8=A7=84?= =?UTF-8?q?=E6=A0=BC=E4=BB=B7=E6=A0=BC=20-=20=E4=BF=AE=E5=A4=8D=E4=B8=BB?= =?UTF-8?q?=E7=BA=BF=E5=85=BC=E5=AE=B9=E4=B8=8E=E5=8F=82=E8=80=83=E5=9B=BE?= =?UTF-8?q?=20SKU=20=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/repository/finance.go | 16 ++++++- backend/internal/service/admin.go | 2 +- backend/internal/service/channel_models.go | 26 ++++++++--- backend/internal/service/model_capability.go | 8 ++++ backend/internal/service/model_router.go | 3 ++ backend/internal/service/model_router_test.go | 21 ++++++++- backend/internal/service/provider.go | 19 +++++--- web/src/components/model-picker.tsx | 18 +++++--- .../components/channel-model-manager.tsx | 45 ++++++++++--------- 9 files changed, 112 insertions(+), 46 deletions(-) diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 2793ff9cc..a6517d88d 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -187,8 +187,10 @@ func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) e ids = append(ids, item.ID) } var tiers []model.ChannelModelPriceTier - if err := r.db.Where("channel_model_id IN ?", ids).Order("selector_key asc, created_at asc").Find(&tiers).Error; err != nil { - return err + if r.db.Migrator().HasTable(&model.ChannelModelPriceTier{}) { + if err := r.db.Where("channel_model_id IN ?", ids).Order("selector_key asc, created_at asc").Find(&tiers).Error; err != nil { + return err + } } for index := range tiers { tiers[index].Selector = model.DecodeSKUSelector(tiers[index].SelectorJSON) @@ -199,6 +201,16 @@ func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) e } for _, item := range items { item.PriceTiers = tiersByModelID[item.ID] + // 兼容尚未执行价格档回填的旧数据库;正式迁移会将同一数据持久化为默认档。 + if len(item.PriceTiers) == 0 && item.PriceConfigured { + item.PriceTiers = []model.ChannelModelPriceTier{{ + ChannelModelID: item.ID, SelectorKey: "{}", SelectorJSON: "{}", Resolution: "*", + ProviderModelKey: item.ProviderModelKey, BillingMode: item.BillingMode, + UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, + OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, + PriceConfigured: item.PriceConfigured, Enabled: item.Enabled, PriceVersion: item.PriceVersion, + }} + } } return nil } diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 2e9504618..e7bf4443c 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -837,7 +837,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model if item.Enabled && item.PriceConfigured { capabilityConfig, decodeErr := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) if decodeErr == nil && capabilityConfig != nil { - if normalized, normalizeErr := NormalizeModelCapabilityConfig(item.Capability, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), channel.APIFormat, capabilityConfig); normalizeErr == nil { + if normalized, normalizeErr := NormalizeModelCapabilityConfig(item.Capability, string(item.Protocol), capabilityConfig); normalizeErr == nil { capabilityConfig = normalized } } diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 42741aedb..6b6345d32 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -36,7 +36,7 @@ type ChannelModelRequest struct { // ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。 // Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。 type ChannelModelPriceTierRequest struct { - // Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds; + // Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount; // operation 可区分文生/图生/视频生,避免同一分辨率下错误复用价格。 Selector map[string]string `json:"selector"` Resolution string `json:"resolution"` @@ -84,7 +84,7 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod if err := s.RequireAdmin(actor); err != nil { return nil, err } - if _, err := s.repo.AdminSystemChannel(channelID); err != nil { + if _, err := s.adminSystemChannel(channelID); err != nil { return nil, err } items, err := s.ensureChannelModels(channelID, true) @@ -99,7 +99,7 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod if decodeErr != nil || config == nil { continue } - normalized, normalizeErr := NormalizeModelCapabilityConfig(items[index].Capability, string(items[index].Protocol), firstNonEmpty(items[index].ProviderModelKey, items[index].ModelKey), channel.APIFormat, config) + normalized, normalizeErr := NormalizeModelCapabilityConfig(items[index].Capability, string(items[index].Protocol), config) if normalizeErr != nil || normalized == nil { continue } @@ -203,7 +203,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id return nil, BadAuthRequest("该渠道已存在模型 " + modelKey + ",请直接编辑已有模型") } if capability == "text" || capability == "image" || capability == "video" { - if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig); err != nil { + if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil { return nil, err } } @@ -233,7 +233,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id item.Protocol = protocol s.applyChannelModelPriceTierSummary(item, tiers) if capability == "text" || capability == "image" || capability == "video" { - capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig) + capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig) if normalizeErr != nil { return nil, normalizeErr } @@ -403,6 +403,15 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric continue } value = strconv.Itoa(seconds) + case "imageCount": + count, err := strconv.Atoi(value) + if err != nil || count < 0 { + return nil, "", 0, BadAuthRequest("参考图片数量必须是非负整数") + } + if count == 0 { + continue + } + value = strconv.Itoa(count) default: return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key) } @@ -433,6 +442,9 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric if _, exists := selector["videoSeconds"]; exists && capability != "video" { return nil, "", 0, BadAuthRequest("只有视频模型可以按时长配置价格档") } + if _, exists := selector["imageCount"]; exists && capability != "video" { + return nil, "", 0, BadAuthRequest("只有视频模型可以按参考图片数量配置价格档") + } resolution := "*" if value := selector["vquality"]; value != "" { resolution = value @@ -522,7 +534,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, return nil, err } if capability == "text" || capability == "image" || capability == "video" { - if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig); err != nil { + if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil { return nil, err } } @@ -552,7 +564,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, imageSize, imageQuality := "", "" var imageProfile *ImageCapabilityConfig if capability == "image" { - profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig) + profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig) if normalizeErr != nil { return nil, normalizeErr } diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 57405b109..578e4b2f7 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -579,6 +579,14 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { return validateVideoTask(profile.Video, taskInput) } +// applyModelSpecificImageCapability is retained as a narrow normalization hook +// for provider-specific image validation. The stored capability profile is +// already normalized when the channel model is saved, so no second override is +// needed here. +func applyModelSpecificImageCapability(profile *ImageCapabilityConfig, _ string, _ string, _ string) *ImageCapabilityConfig { + return profile +} + // applyFixedVideoResolution 让单档位 SKU 的预扣、恢复和上游请求保持同一分辨率。 func applyFixedVideoResolution(input *canvasGenerationInput, profile *VideoCapabilityConfig) { if input == nil || profile == nil || len(profile.Resolutions) != 1 { diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index 2494b42e3..0f815dbda 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -684,6 +684,9 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { } switch normalizeCapability(intent.Capability) { case "video": + if count := intent.Inputs["image"]; count > 0 { + selector["imageCount"] = strconv.Itoa(count) + } if value := normalizeChannelModelTierResolution(fmt.Sprint(intent.Options["vquality"])); value != "*" { selector["vquality"] = value } diff --git a/backend/internal/service/model_router_test.go b/backend/internal/service/model_router_test.go index 66ff643ed..6eb5d5c48 100644 --- a/backend/internal/service/model_router_test.go +++ b/backend/internal/service/model_router_test.go @@ -1,6 +1,10 @@ package service -import "testing" +import ( + "testing" + + "infinite-canvas/backend/internal/model" +) func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) { input := map[string]any{ @@ -12,3 +16,18 @@ func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) { t.Fatalf("vquality = %#v, want 480p", got) } } + +func TestSKUSelectorIncludesVideoReferenceImageCount(t *testing.T) { + selector := skuSelectorForIntent(ModelRequestIntent{Capability: "video", Inputs: map[string]int{"image": 5}, Options: map[string]any{"vquality": "720p"}}) + if selector["imageCount"] != "5" || selector["vquality"] != "720p" { + t.Fatalf("selector = %#v", selector) + } + modelWithTiers := model.ChannelModel{PriceTiers: []model.ChannelModelPriceTier{ + {SelectorJSON: `{"vquality":"720p","imageCount":"5"}`, Enabled: true, PriceConfigured: true}, + {SelectorJSON: `{"vquality":"720p","imageCount":"9"}`, Enabled: true, PriceConfigured: true}, + }} + matched := channelModelPriceTierForIntent(modelWithTiers, ModelRequestIntent{Capability: "video", Inputs: map[string]int{"image": 5}, Options: map[string]any{"vquality": "720p"}}) + if matched == nil || matched.SelectorJSON != `{"vquality":"720p","imageCount":"5"}` { + t.Fatalf("matched tier = %#v", matched) + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index e291022bc..5db0533e2 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -825,14 +825,19 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig, modelKey = requestedModel } if modelKey == "" { - models := channelModelNames(*channel) - if len(models) == 0 { - return providerConfig{}, errors.New("系统渠道未配置可用模型") + channelModels, listErr := s.repo.ChannelModels(channel.ID, false) + if listErr != nil { + return providerConfig{}, listErr + } + if len(channelModels) > 0 { + modelKey = channelModels[0].ModelKey + } else { + models := channelModelNames(*channel) + if len(models) == 0 { + return providerConfig{}, errors.New("系统渠道未配置可用模型") + } + modelKey = models[0] } - modelKey = models[0] - } - if !stringInSlice(modelKey, channelModelNames(*channel)) { - return providerConfig{}, errors.New("当前系统渠道未授权该模型") } if _, err := s.validateChannelOutboundURL(channel.BaseURL, channel.AllowLocalChannel, false); err != nil { return providerConfig{}, err diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 8d950032a..ab709abc7 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -60,7 +60,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, selectionRequirements) || storedCurrent; // 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。 const current = options.includes(resolvedCurrent) ? resolvedCurrent : ""; - const currentPrice = modelMenuPrice(config, current, capability); + const currentPrice = modelMenuPrice(config, current, capability, false, requirements); const quoteRequest = useMemo(() => modelQuoteRequest(config, current, capability, requirements), [capability, config, current, requirements]); const [routeQuote, setRouteQuote] = useState(); const creationVariant = variant === "creation"; @@ -354,7 +354,7 @@ type ModelMenuPrice = | { kind: "estimate" } | { kind: "fixed"; value: number; unit: "次" | "秒" | "百万 Token" }; -function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false): ModelMenuPrice | null | undefined { +function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapability, summary = false, requirements?: ModelRequirements): ModelMenuPrice | null | undefined { if (!model) return undefined; const channel = resolveModelChannel(config, model); const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model)); @@ -362,7 +362,7 @@ function modelMenuPrice(config: AiConfig, model: string, capability?: ModelCapab if (cost.pricePolicy === "channel") { const tiers = cost.logicalPriceTiers || []; if (!tiers.length) return null; - const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config); + const matched = summary ? tiers : priceTiersForCurrentSelection(tiers, capability, config, requirements); return channelTierPriceSummary(matched.length ? matched : tiers, tiers); } if (cost.billingMode === "token") return { kind: "estimate" }; @@ -381,12 +381,15 @@ function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredM function priceTiersForCurrentSelection( tiers: NonNullable[number]["logicalPriceTiers"]>, - capability: ModelCapability | undefined, - config: AiConfig, + capability: ModelCapability | undefined, + config: AiConfig, + requirements?: ModelRequirements, ) { const requested: Record = {}; - if (capability === "video") { - const resolution = normalizeTierResolution(config.vquality); + if (capability === "video") { + const imageCount = (requirements?.input?.imageCount || 0) + (requirements?.input?.characterCount || 0); + if (imageCount > 0) requested.imageCount = String(imageCount); + const resolution = normalizeTierResolution(config.vquality); if (resolution !== "*") requested.vquality = resolution; const seconds = Math.max(0, Math.floor(Number(config.videoSeconds) || 0)); if (seconds > 0) requested.videoSeconds = String(seconds); @@ -471,6 +474,7 @@ function tierSpecificationLabel(tier: NonNullable ({ label: value.toUpperCase(), value }))]} /> ) : null} - {isVideo ? ( - - {durationOptions.length ? ({ label: `${value} 秒`, value }))]} /> : } + ) : null} + {isVideo ? ( + + + + ) : null} {isImage ? (