diff --git a/backend/cmd/migrate-channel-model-price-tiers/main.go b/backend/cmd/migrate-channel-model-price-tiers/main.go new file mode 100644 index 000000000..e915d33d6 --- /dev/null +++ b/backend/cmd/migrate-channel-model-price-tiers/main.go @@ -0,0 +1,675 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "infinite-canvas/backend/internal/service" + + "gorm.io/gorm" +) + +// 该迁移把上一轮“每个分辨率一个渠道模型”的视频 SKU 收敛为一个渠道模型的多个价格档。 +// 已被家族模型替代的渠道 SKU 会软删除,旧前台 SKU 会归档;任务、路由尝试与账单订单不改写。 +var familyCodes = []string{ + "seedance-2-5", "seedance-2-0", "seedance-2-0-mini", "seedance-2-0-fast", + "grok-video-1-5", "artdance-2-0", "artdance-2-5", "artdance-2-mini", "artdance-fast", +} + +type familyPlan struct { + logicalModel model.LogicalModel + channelModel model.ChannelModel + priceTiers []model.ChannelModelPriceTier + legacyLogicalModelIDs []string + legacyChannelModelIDs []string + alreadySynced bool + creatingLogical bool +} + +func main() { + apply := flag.Bool("apply", false, "创建系统模型价格档并切换当前前台目录版本") + flag.Parse() + db, err := database.Open(database.Config{Driver: "postgres", DSN: os.Getenv("DATABASE_URL"), DataDir: os.Getenv("CANVAS_BACKEND_DATA_DIR")}) + if err != nil { + log.Fatal(err) + } + if db.Dialector.Name() != "postgres" { + log.Fatal("价格档迁移只允许连接 PostgreSQL") + } + if err := database.ConfigurePool(db); err != nil { + log.Fatal(err) + } + if err := database.MigrateSchema(db); err != nil { + log.Fatal(err) + } + repo := repository.New(db) + plans, err := buildPlans(repo) + if err != nil { + log.Fatal(err) + } + for _, plan := range plans { + tiers := make([]string, 0, len(plan.priceTiers)) + for _, tier := range plan.priceTiers { + tiers = append(tiers, fmt.Sprintf("%s=>%s", tier.SelectorKey, tier.ProviderModelKey)) + } + log.Printf("%s: 系统模型=%s 价格档=[%s] 已同步=%t,待归档旧前台=%d,待删除旧渠道模型=%d", plan.logicalModel.Code, plan.channelModel.ModelKey, strings.Join(tiers, ", "), plan.alreadySynced, len(plan.legacyLogicalModelIDs), len(plan.legacyChannelModelIDs)) + } + if !*apply { + log.Print("dry-run 完成;确认后使用 --apply 写入。旧 SKU 会从配置目录删除,历史任务与账务快照不会改写") + return + } + actor, err := migrationAdmin(db) + if err != nil { + log.Fatal(err) + } + svc := service.New(repo, os.Getenv("CANVAS_BACKEND_DATA_DIR")) + for _, plan := range plans { + // 已规范化且没有遗留 SKU 的家族只参与 dry-run 报告,不能在每次迁移时重发 revision, + // 否则会无意义地改动已经稳定的前台能力和路由快照。 + if plan.alreadySynced && !plan.creatingLogical && len(plan.legacyLogicalModelIDs) == 0 && len(plan.legacyChannelModelIDs) == 0 { + log.Printf("跳过已规范化模型家族 %s", plan.logicalModel.Code) + continue + } + if !plan.alreadySynced { + if err := repo.SaveChannelModelWithPriceTiers(&plan.channelModel, plan.priceTiers); err != nil { + log.Fatalf("保存 %s 价格档失败:%v", plan.logicalModel.Code, err) + } + } + logicalModelID := plan.logicalModel.ID + if plan.creatingLogical { + logicalModelID = "" + } + if !plan.alreadySynced || plan.creatingLogical { + if _, err := svc.SaveAdminLogicalModel(actor, logicalModelID, service.LogicalModelRequest{ + Code: plan.logicalModel.Code, Name: plan.logicalModel.Name, Icon: plan.logicalModel.Icon, Description: plan.logicalModel.Description, + Capability: plan.channelModel.Capability, Enabled: plan.logicalModel.Enabled, SortOrder: plan.logicalModel.SortOrder, + LegacyModelIDs: append(decodeLegacyModelIDs(plan.logicalModel.LegacyModelIDsJSON), plan.legacyLogicalModelIDs...), SourceChannelModelID: plan.channelModel.ID, + }); err != nil { + log.Fatalf("同步前台模型 %s 失败:%v", plan.logicalModel.Code, err) + } + } + if err := deleteLegacySKURecords(db, plan); err != nil { + log.Fatalf("删除 %s 的旧 SKU 失败:%v", plan.logicalModel.Code, err) + } + log.Printf("已同步 %s 到系统模型 %s", plan.logicalModel.Code, plan.channelModel.ModelKey) + } + log.Print("迁移完成:新任务将按系统模型规格价格档结算;旧 SKU 已从配置目录删除,历史任务与账务快照保持不变") +} + +func buildPlans(repo *repository.Repository) ([]familyPlan, error) { + plans := make([]familyPlan, 0, len(familyCodes)) + for _, code := range familyCodes { + items, err := repo.LogicalModels(true) + if err != nil { + return nil, err + } + var logicalModel *model.LogicalModel + for index := range items { + if items[index].Code == code { + logicalModel = &items[index] + break + } + } + if logicalModel == nil { + return nil, fmt.Errorf("缺少前台模型家族 %s", code) + } + graph, err := repo.LogicalModelGraph(logicalModel.ID, true) + if err != nil { + return nil, fmt.Errorf("读取前台模型 %s:%w", code, err) + } + if graph.Revision == nil || len(graph.Routes) == 0 || len(graph.ChannelModels) == 0 { + return nil, fmt.Errorf("前台模型 %s 没有当前供应线路", code) + } + if logicalModel.SourceChannelModelID != "" { + source, sourceErr := repo.ChannelModel(logicalModel.SourceChannelModelID) + if sourceErr != nil { + return nil, fmt.Errorf("读取已同步模型 %s 的系统源:%w", code, sourceErr) + } + legacyIDs, legacyErr := legacyChannelModelIDsForFamily(repo, *source, logicalModel.Code) + if legacyErr != nil { + return nil, fmt.Errorf("识别 %s 的旧渠道 SKU:%w", logicalModel.Code, legacyErr) + } + plans = append(plans, familyPlan{logicalModel: *logicalModel, channelModel: *source, priceTiers: source.PriceTiers, legacyChannelModelIDs: legacyIDs, alreadySynced: true}) + continue + } + plan, planErr := buildFamilyPlan(repo, *logicalModel, graph) + if planErr != nil { + return nil, planErr + } + plans = append(plans, plan) + } + imagePlan, imageErr := buildImage2Plan(repo) + if imageErr != nil { + return nil, imageErr + } + if imagePlan != nil { + plans = append(plans, *imagePlan) + } + return plans, nil +} + +// buildImage2Plan merges the three historical 1K/2K/4K records into one system +// model. The exact upstream key remains tier-local so existing channel behaviour +// is retained while the selected quality becomes a request parameter. +func buildImage2Plan(repo *repository.Repository) (*familyPlan, error) { + all, err := repo.LogicalModels(true) + if err != nil { + return nil, err + } + byCode := make(map[string]model.LogicalModel, len(all)) + for _, item := range all { + byCode[item.Code] = item + } + var existing *model.LogicalModel + if item, found := byCode["image-2"]; found { + existing = &item + } + var canonical *model.ChannelModel + if existing != nil && existing.SourceChannelModelID != "" { + source, sourceErr := repo.ChannelModel(existing.SourceChannelModelID) + if sourceErr != nil { + return nil, sourceErr + } + canonical = source + } + legacyCodes := []string{"image-2-1k", "image-2-2k", "image-2-4k"} + legacyModels := make([]model.LogicalModel, 0, len(legacyCodes)) + legacyChannelModels := make([]model.ChannelModel, 0, len(legacyCodes)) + for _, code := range legacyCodes { + logicalModel, found := byCode[code] + if !found { + continue + } + graph, graphErr := repo.LogicalModelGraph(logicalModel.ID, true) + if graphErr != nil || graph.Revision == nil || len(graph.Routes) != 1 || len(graph.ChannelModels) != 1 { + return nil, fmt.Errorf("读取 GPT Image 2 旧模型 %s 的供应线路失败", code) + } + legacyModels = append(legacyModels, logicalModel) + legacyChannelModels = append(legacyChannelModels, graph.ChannelModels[0]) + } + if len(legacyModels) == 0 { + if existing == nil || canonical == nil { + return nil, errors.New("缺少 GPT Image 2 的系统模型和旧 SKU,无法判断迁移状态") + } + return &familyPlan{logicalModel: *existing, channelModel: *canonical, priceTiers: canonical.PriceTiers, alreadySynced: true}, nil + } + if len(legacyModels) != len(legacyCodes) { + return nil, errors.New("GPT Image 2 的旧 SKU 记录不完整,拒绝自动删除") + } + channelID := legacyChannelModels[0].ChannelID + for _, item := range legacyChannelModels { + if item.ChannelID != channelID || item.Capability != "image" || item.Protocol != legacyChannelModels[0].Protocol || item.CapabilityConfigJSON != legacyChannelModels[0].CapabilityConfigJSON { + return nil, errors.New("GPT Image 2 的旧 SKU 渠道、协议或能力配置不一致,拒绝自动合并") + } + } + if canonical == nil { + found, canonicalErr := repo.ChannelModelByKeyIncludingDisabled(channelID, "gpt-image-2") + if canonicalErr != nil && !errors.Is(canonicalErr, gorm.ErrRecordNotFound) { + return nil, canonicalErr + } + canonical = found + } else if canonical.ChannelID != channelID { + return nil, errors.New("GPT Image 2 的新旧模型不属于同一系统渠道,拒绝自动删除") + } + channelModel := model.ChannelModel{} + alreadySynced := canonical != nil && len(canonical.PriceTiers) > 0 + var tiers []model.ChannelModelPriceTier + if canonical != nil { + channelModel = *canonical + tiers = canonical.PriceTiers + } else { + var tierErr error + tiers, tierErr = image2PriceTiersFromLegacy(repo, legacyChannelModels) + if tierErr != nil { + return nil, tierErr + } + modelID, idErr := repo.NextPrefixedID("MODEL") + if idErr != nil { + return nil, idErr + } + channelModel = model.ChannelModel{ + ID: modelID, ChannelID: channelID, ModelKey: "gpt-image-2", ProviderModelKey: "gpt-image-2", DisplayName: "GPT Image 2", + Capability: "image", Protocol: legacyChannelModels[0].Protocol, Enabled: true, PriceConfigured: true, PriceVersion: 1, + CapabilityConfigJSON: legacyChannelModels[0].CapabilityConfigJSON, CapabilityVersion: 1, + } + } + if len(tiers) == 0 { + var tierErr error + tiers, tierErr = image2PriceTiersFromLegacy(repo, legacyChannelModels) + if tierErr != nil { + return nil, tierErr + } + } + channelModel.PriceTiers = tiers + applyPriceSummary(&channelModel, tiers) + logicalModel := model.LogicalModel{Code: "image-2", Name: "GPT Image 2", Description: "GPT Image 2", Capability: "image", Enabled: true, SortOrder: legacyModels[0].SortOrder} + creatingLogical := true + if existing != nil { + logicalModel = *existing + creatingLogical = false + } + plan := &familyPlan{logicalModel: logicalModel, channelModel: channelModel, priceTiers: tiers, alreadySynced: alreadySynced, creatingLogical: creatingLogical} + for index := range legacyModels { + plan.legacyLogicalModelIDs = append(plan.legacyLogicalModelIDs, legacyModels[index].ID) + plan.legacyChannelModelIDs = append(plan.legacyChannelModelIDs, legacyChannelModels[index].ID) + } + return plan, nil +} + +func image2PriceTiersFromLegacy(repo *repository.Repository, items []model.ChannelModel) ([]model.ChannelModelPriceTier, error) { + result := make([]model.ChannelModelPriceTier, 0, len(items)) + seen := make(map[string]bool, len(items)) + for _, item := range items { + quality := "" + for _, candidate := range []string{"1k", "2k", "4k"} { + if strings.HasSuffix(strings.ToLower(item.ModelKey), "-"+candidate) { + quality = candidate + } + } + if quality == "" || seen[quality] { + return nil, fmt.Errorf("无法从旧渠道模型 %s 推导唯一的 GPT Image 2 质量档", item.ModelKey) + } + seen[quality] = true + tier := firstActiveTier(item) + id, err := repo.NextPrefixedID("PTIER") + if err != nil { + return nil, err + } + selector, key, err := model.CanonicalSKUSelector(map[string]string{"quality": quality}) + if err != nil { + return nil, err + } + tier.ID, tier.ChannelModelID, tier.Selector, tier.SelectorKey, tier.SelectorJSON = id, "", selector, key, key + tier.Resolution, tier.VideoSeconds = "*", 0 + tier.ProviderModelKey = firstNonEmpty(tier.ProviderModelKey, item.ProviderModelKey, "gpt-image-2") + tier.PriceVersion = 1 + result = append(result, tier) + } + if len(result) != 3 { + return nil, errors.New("GPT Image 2 缺少 1K、2K 或 4K 价格档") + } + return result, nil +} + +func deleteLegacySKURecords(db *gorm.DB, plan familyPlan) error { + if len(plan.legacyChannelModelIDs) == 0 && len(plan.legacyLogicalModelIDs) == 0 { + return nil + } + return db.Transaction(func(tx *gorm.DB) error { + var activeChannelTasks int64 + if len(plan.legacyChannelModelIDs) > 0 { + if err := tx.Model(&model.Task{}).Where("channel_model_id IN ? AND status IN ?", plan.legacyChannelModelIDs, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).Count(&activeChannelTasks).Error; err != nil { + return err + } + } + var activeLogicalTasks int64 + if len(plan.legacyLogicalModelIDs) > 0 { + if err := tx.Model(&model.Task{}).Where("logical_model_id IN ? AND status IN ?", plan.legacyLogicalModelIDs, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).Count(&activeLogicalTasks).Error; err != nil { + return err + } + } + active := activeChannelTasks + activeLogicalTasks + if active > 0 { + return fmt.Errorf("仍有 %d 个排队或运行中的任务引用旧 SKU,稍后重试迁移", active) + } + + now := time.Now() + if len(plan.legacyLogicalModelIDs) > 0 { + if err := tx.Model(&model.LogicalModel{}).Where("id IN ? AND archived_at IS NULL", plan.legacyLogicalModelIDs).Updates(map[string]any{"enabled": false, "archived_at": now, "updated_at": now}).Error; err != nil { + return err + } + } + if len(plan.legacyChannelModelIDs) == 0 { + return nil + } + + var legacyModels []model.ChannelModel + if err := tx.Where("id IN ?", plan.legacyChannelModelIDs).Find(&legacyModels).Error; err != nil { + return err + } + var channelIDs []string + if err := tx.Model(&model.ChannelModel{}).Distinct("channel_id").Where("id IN ?", plan.legacyChannelModelIDs).Pluck("channel_id", &channelIDs).Error; err != nil { + return err + } + if err := tx.Where("channel_model_id IN ?", plan.legacyChannelModelIDs).Delete(&model.ChannelModelPriceTier{}).Error; err != nil { + return err + } + if err := tx.Where("id IN ?", plan.legacyChannelModelIDs).Delete(&model.ChannelModel{}).Error; err != nil { + return err + } + for _, channelID := range channelIDs { + var channel model.ModelChannel + if err := tx.First(&channel, "id = ?", channelID).Error; err != nil { + return err + } + var activeModels []model.ChannelModel + if err := tx.Where("channel_id = ? AND enabled = ?", channelID, true).Order("created_at asc").Find(&activeModels).Error; err != nil { + return err + } + modelKeys := make([]string, 0, len(activeModels)) + for _, item := range activeModels { + modelKeys = append(modelKeys, item.ModelKey) + } + encoded, err := json.Marshal(modelKeys) + if err != nil { + return err + } + retiredKeys := decodeRetiredModelKeys(channel.RetiredModelsJSON) + for _, legacy := range legacyModels { + if legacy.ChannelID == channelID { + retiredKeys[channelModelCatalogKey(legacy.ModelKey)] = legacy.ModelKey + } + } + retiredModels := make([]string, 0, len(retiredKeys)) + for _, modelKey := range retiredKeys { + retiredModels = append(retiredModels, modelKey) + } + sort.Strings(retiredModels) + retiredJSON, err := json.Marshal(retiredModels) + if err != nil { + return err + } + if err := tx.Model(&model.ModelChannel{}).Where("id = ?", channelID).Updates(map[string]any{"models_json": string(encoded), "retired_models_json": string(retiredJSON), "updated_at": now}).Error; err != nil { + return err + } + } + return nil + }) +} + +func buildFamilyPlan(repo *repository.Repository, logicalModel model.LogicalModel, graph *repository.LogicalModelGraph) (familyPlan, error) { + active := make([]model.ChannelModel, 0, len(graph.Routes)) + byID := make(map[string]model.ChannelModel, len(graph.ChannelModels)) + for _, item := range graph.ChannelModels { + byID[item.ID] = item + } + for _, route := range graph.Routes { + if route.Enabled && route.Weight > 0 { + if item, found := byID[route.ChannelModelID]; found { + active = append(active, item) + } + } + } + if len(active) == 0 { + return familyPlan{}, fmt.Errorf("前台模型 %s 没有启用供应线路", logicalModel.Code) + } + channelID := active[0].ChannelID + for _, item := range active { + if item.ChannelID != channelID || item.Capability != "video" { + return familyPlan{}, fmt.Errorf("前台模型 %s 的供应线路不属于同一视频系统渠道,需人工处理", logicalModel.Code) + } + } + canonical, err := repo.ChannelModelByKeyIncludingDisabled(channelID, logicalModel.Code) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return familyPlan{}, err + } + if canonical != nil { + legacyIDs, legacyErr := legacyChannelModelIDsForFamily(repo, *canonical, logicalModel.Code) + if legacyErr != nil { + return familyPlan{}, legacyErr + } + return familyPlan{logicalModel: logicalModel, channelModel: *canonical, priceTiers: canonical.PriceTiers, legacyChannelModelIDs: legacyIDs, alreadySynced: true}, nil + } + configJSON, err := mergedVideoCapabilityConfig(active) + if err != nil { + return familyPlan{}, fmt.Errorf("合并 %s 视频能力:%w", logicalModel.Code, err) + } + modelID, err := repo.NextPrefixedID("MODEL") + if err != nil { + return familyPlan{}, err + } + tiers, err := priceTiersFromLegacy(repo, active) + if err != nil { + return familyPlan{}, fmt.Errorf("读取 %s 旧 SKU 价格:%w", logicalModel.Code, err) + } + channelModel := model.ChannelModel{ + ID: modelID, ChannelID: channelID, ModelKey: logicalModel.Code, ProviderModelKey: active[0].ProviderModelKey, + DisplayName: logicalModel.Name, Capability: "video", Protocol: active[0].Protocol, Enabled: true, + PriceConfigured: len(tiers) > 0, PriceVersion: 1, CapabilityConfigJSON: configJSON, CapabilityVersion: 1, + } + applyPriceSummary(&channelModel, tiers) + return familyPlan{logicalModel: logicalModel, channelModel: channelModel, priceTiers: tiers}, nil +} + +// legacyChannelModelIDsForFamily 只识别与已规范模型同渠道、同能力、同协议的历史 SKU。 +// 这里宁可漏掉无法确认归属的模型,也不能删除独立产品模型。 +func legacyChannelModelIDsForFamily(repo *repository.Repository, canonical model.ChannelModel, familyCode string) ([]string, error) { + items, err := repo.ChannelModels(canonical.ChannelID, true) + if err != nil { + return nil, err + } + aliases := familyModelKeyAliases(familyCode) + ids := make([]string, 0) + for _, item := range items { + if item.ID == canonical.ID || item.Capability != canonical.Capability || item.Protocol != canonical.Protocol { + continue + } + key := normalizedModelKey(item.ModelKey) + for _, alias := range aliases { + if key == alias || (strings.HasPrefix(key, alias+"-") && isLegacySKUVariant(strings.TrimPrefix(key, alias+"-"))) { + ids = append(ids, item.ID) + break + } + } + } + sort.Strings(ids) + return ids, nil +} + +func isLegacySKUVariant(value string) bool { + for _, resolution := range []string{"480p", "720p", "1080p", "1440p", "2160p"} { + if value == resolution { + return true + } + } + return false +} + +func familyModelKeyAliases(familyCode string) []string { + canonical := normalizedModelKey(familyCode) + aliases := []string{canonical, "doubao-" + canonical} + if familyCode == "grok-video-1-5" { + aliases = append(aliases, "grok-imagine-video-1-5") + } + return aliases +} + +func normalizedModelKey(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var builder strings.Builder + lastDash := false + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') { + builder.WriteRune(char) + lastDash = false + continue + } + if !lastDash { + builder.WriteByte('-') + lastDash = true + } + } + return strings.Trim(builder.String(), "-") +} + +func channelModelCatalogKey(value string) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(value), "models/")) +} + +func decodeRetiredModelKeys(raw string) map[string]string { + var values []string + _ = json.Unmarshal([]byte(raw), &values) + result := make(map[string]string, len(values)) + for _, value := range values { + if key := channelModelCatalogKey(value); key != "" { + result[key] = value + } + } + return result +} + +func mergedVideoCapabilityConfig(items []model.ChannelModel) (string, error) { + config, err := service.DecodeModelCapabilityConfig(items[0].CapabilityConfigJSON) + if err != nil || config == nil || config.Video == nil { + return "", errors.New("首个旧 SKU 缺少视频能力配置") + } + resolutions := make(map[string]bool) + durations := make(map[int]bool) + for _, item := range items { + candidate, decodeErr := service.DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + if decodeErr != nil || candidate == nil || candidate.Video == nil { + return "", fmt.Errorf("渠道模型 %s 的视频能力无效", item.ModelKey) + } + for _, value := range candidate.Video.Resolutions { + resolutions[value] = true + } + for _, value := range candidate.Video.Duration.Values { + durations[value] = true + } + } + config.Video.Resolutions = sortedStrings(resolutions) + if len(durations) > 0 { + config.Video.Duration.Selection = "enum" + config.Video.Duration.Values = sortedInts(durations) + config.Video.Duration.Default = config.Video.Duration.Values[0] + } + encoded, err := json.Marshal(config) + if err != nil { + return "", err + } + return string(encoded), nil +} + +func priceTiersFromLegacy(repo *repository.Repository, items []model.ChannelModel) ([]model.ChannelModelPriceTier, error) { + result := make([]model.ChannelModelPriceTier, 0, len(items)) + seen := make(map[string]bool, len(items)) + for _, item := range items { + resolution, seconds := legacyTierDimensions(item) + key := resolution + fmt.Sprintf(":%d", seconds) + if seen[key] { + return nil, fmt.Errorf("旧 SKU 在规格 %s 上重复,无法决定唯一价格", key) + } + seen[key] = true + tier := firstActiveTier(item) + id, err := repo.NextPrefixedID("PTIER") + if err != nil { + return nil, err + } + selectorInput := map[string]string{} + if resolution != "*" { + selectorInput["vquality"] = resolution + } + if seconds > 0 { + selectorInput["videoSeconds"] = strconv.Itoa(seconds) + } + selector, selectorKey, selectorErr := model.CanonicalSKUSelector(selectorInput) + if selectorErr != nil { + return nil, selectorErr + } + tier.ID, tier.ChannelModelID, tier.Selector, tier.SelectorKey, tier.SelectorJSON, tier.Resolution, tier.VideoSeconds = id, "", selector, selectorKey, selectorKey, resolution, seconds + tier.ProviderModelKey = firstNonEmpty(tier.ProviderModelKey, item.ProviderModelKey, item.ModelKey) + tier.PriceVersion = 1 + result = append(result, tier) + } + return result, nil +} + +func legacyTierDimensions(item model.ChannelModel) (string, int) { + resolution := "*" + seconds := 0 + config, _ := service.DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + if config != nil && config.Video != nil { + if len(config.Video.Resolutions) == 1 { + resolution = strings.ToLower(config.Video.Resolutions[0]) + } + if len(config.Video.Duration.Values) == 1 { + seconds = config.Video.Duration.Values[0] + } + } + for _, candidate := range []string{"480p", "720p", "1080p", "1440p", "2160p"} { + if strings.Contains(strings.ToLower(item.ModelKey), candidate) { + resolution = candidate + } + } + return resolution, seconds +} + +func firstActiveTier(item model.ChannelModel) model.ChannelModelPriceTier { + for _, tier := range item.PriceTiers { + if tier.Enabled && tier.PriceConfigured { + return tier + } + } + return model.ChannelModelPriceTier{ProviderModelKey: item.ProviderModelKey, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, PriceConfigured: item.PriceConfigured, Enabled: item.Enabled} +} + +func applyPriceSummary(channelModel *model.ChannelModel, tiers []model.ChannelModelPriceTier) { + if len(tiers) == 0 { + return + } + tier := tiers[0] + channelModel.BillingMode = tier.BillingMode + channelModel.UnitPriceMicrocredits = tier.UnitPriceMicrocredits + channelModel.InputTokenPriceMicrocredits = tier.InputTokenPriceMicrocredits + channelModel.OutputTokenPriceMicrocredits = tier.OutputTokenPriceMicrocredits + channelModel.CachedTokenPriceMicrocredits = tier.CachedTokenPriceMicrocredits + channelModel.PriceConfigured = false + for _, candidate := range tiers { + if candidate.Enabled && candidate.PriceConfigured { + channelModel.PriceConfigured = true + break + } + } +} + +func migrationAdmin(db *gorm.DB) (*model.User, error) { + var actor model.User + if err := db.Where("role = ?", model.UserRoleAdmin).Order("created_at asc").First(&actor).Error; err != nil { + return nil, fmt.Errorf("需要现有管理员作为迁移审计主体:%w", err) + } + return &actor, nil +} + +func decodeLegacyModelIDs(raw string) []string { + var ids []string + _ = json.Unmarshal([]byte(raw), &ids) + return ids +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func sortedStrings(values map[string]bool) []string { + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Strings(result) + return result +} + +func sortedInts(values map[int]bool) []int { + result := make([]int, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Ints(result) + return result +} diff --git a/backend/cmd/migrate-logical-model-families/main.go b/backend/cmd/migrate-logical-model-families/main.go new file mode 100644 index 000000000..b27a4e1a2 --- /dev/null +++ b/backend/cmd/migrate-logical-model-families/main.go @@ -0,0 +1,468 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "math" + "os" + "sort" + "strconv" + "strings" + + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "infinite-canvas/backend/internal/service" + + "gorm.io/gorm" +) + +// 迁移只重建创作端目录。旧模型、revision、route 以及全部任务和账务快照都保留, +// 这样历史任务恢复或重试时仍会使用它创建时绑定的供应线路。 +type familyDefinition struct { + Code string + Name string + Description string + OldCodes []string +} + +type familyRoute struct { + channelModel model.ChannelModel + spec service.CapabilitySpec +} + +type familyPlan struct { + definition familyDefinition + oldModels []model.LogicalModel + routes []familyRoute + request service.LogicalModelRequest + exists bool +} + +var familyDefinitions = []familyDefinition{ + {Code: "seedance-2-5", Name: "Seedance 2.5", Description: "Seedance 2.5 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"doubao-seedance-2-5-480p", "doubao-seedance-2-5", "doubao-seedance-2-5-1080p"}}, + {Code: "seedance-2-0", Name: "Seedance 2.0", Description: "Seedance 2.0 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"doubao-seedance-2-0-480p", "doubao-seedance-2-0", "doubao-seedance-2-0-1080p"}}, + {Code: "seedance-2-0-mini", Name: "Seedance 2.0 Mini", Description: "Seedance 2.0 Mini 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"doubao-seedance-2-0-mini-480p", "doubao-seedance-2-0-mini"}}, + {Code: "seedance-2-0-fast", Name: "Seedance 2.0 Fast", Description: "Seedance 2.0 Fast 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"doubao-seedance-2-0-fast-480p", "doubao-seedance-2-0-fast"}}, + {Code: "grok-video-1-5", Name: "Grok Imagine Video 1.5", Description: "Grok Imagine Video 1.5,按所选分辨率自动路由并结算", OldCodes: []string{"grok-video-1.5-480p", "grok-video-1.5-720p", "grok-imagine-video-1.5-1080p"}}, + {Code: "artdance-2-0", Name: "Artdance 2.0", Description: "Artdance 2.0 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"artdance-2-0-480p", "artdance-2-0-720p", "artdance-2-0-1080p"}}, + {Code: "artdance-2-5", Name: "Artdance 2.5", Description: "Artdance 2.5 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"artdance-2-5-480p", "artdance-2-5-720p"}}, + {Code: "artdance-2-mini", Name: "Artdance 2 Mini", Description: "Artdance 2 Mini 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"artdance-2-mini-480p", "artdance-2-mini-720p"}}, + {Code: "artdance-fast", Name: "Artdance Fast", Description: "Artdance Fast 视频生成,按所选分辨率自动路由并结算", OldCodes: []string{"artdance-fast-480p", "artdance-fast-720p"}}, +} + +func main() { + apply := flag.Bool("apply", false, "写入模型家族并归档旧 SKU 目录项") + flag.Parse() + + db, err := database.Open(database.Config{ + Driver: "postgres", + DSN: os.Getenv("DATABASE_URL"), + DataDir: os.Getenv("CANVAS_BACKEND_DATA_DIR"), + }) + if err != nil { + log.Fatal(err) + } + if db.Dialector.Name() != "postgres" { + log.Fatal("模型家族迁移只允许连接 PostgreSQL") + } + if err := database.ConfigurePool(db); err != nil { + log.Fatal(err) + } + if err := database.MigrateSchema(db); err != nil { + log.Fatal(err) + } + + repo := repository.New(db) + plans, err := buildPlans(repo, db) + if err != nil { + log.Fatal(err) + } + printPlans(plans) + if err := ensureNoActiveTasks(db, plans); err != nil { + log.Fatal(err) + } + if !*apply { + log.Print("dry-run 完成;确认无误后传入 --apply 执行写入") + return + } + + actor, err := migrationAdmin(db) + if err != nil { + log.Fatal(err) + } + svc := service.New(repo, os.Getenv("CANVAS_BACKEND_DATA_DIR")) + for _, plan := range plans { + if plan.exists { + log.Printf("模型家族已存在,跳过创建:%s", plan.definition.Code) + continue + } + if _, err := svc.SaveAdminLogicalModel(actor, "", plan.request); err != nil { + log.Fatalf("创建模型家族 %s 失败:%v", plan.definition.Code, err) + } + log.Printf("已创建模型家族:%s", plan.definition.Code) + } + if err := verifyPlans(db, svc, plans); err != nil { + log.Fatal(err) + } + for _, plan := range plans { + for _, old := range plan.oldModels { + if old.ArchivedAt != nil { + continue + } + if err := svc.DeleteAdminLogicalModel(actor, old.ID); err != nil { + log.Fatalf("归档旧模型 %s 失败:%v", old.Code, err) + } + log.Printf("已归档旧 SKU 目录项:%s", old.Code) + } + } + log.Print("模型家族迁移完成;历史任务、账务和路由快照均未改写") +} + +func buildPlans(repo *repository.Repository, db *gorm.DB) ([]familyPlan, error) { + var models []model.LogicalModel + if err := db.Order("sort_order asc, created_at asc").Find(&models).Error; err != nil { + return nil, err + } + graphs, err := repo.LogicalModelGraphs(models, true) + if err != nil { + return nil, err + } + modelByCode := make(map[string]model.LogicalModel, len(models)) + for _, item := range models { + modelByCode[item.Code] = item + } + plans := make([]familyPlan, 0, len(familyDefinitions)) + for _, definition := range familyDefinitions { + plan, planErr := buildFamilyPlan(definition, modelByCode, graphs) + if planErr != nil { + return nil, planErr + } + var existing model.LogicalModel + err := db.Where("code = ? AND archived_at IS NULL", definition.Code).First(&existing).Error + if err == nil { + plan.exists = true + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + plans = append(plans, plan) + } + return plans, nil +} + +func buildFamilyPlan(definition familyDefinition, modelByCode map[string]model.LogicalModel, graphs map[string]*repository.LogicalModelGraph) (familyPlan, error) { + plan := familyPlan{definition: definition} + routeSpecs := make([]service.CapabilitySpec, 0, len(definition.OldCodes)) + defaults := make([]map[string]any, 0, len(definition.OldCodes)) + legacyIDs := make([]string, 0, len(definition.OldCodes)) + sortOrder := math.MaxInt + icon := "" + for _, code := range definition.OldCodes { + item, found := modelByCode[code] + if !found { + return plan, fmt.Errorf("模型家族 %s 缺少旧 SKU 模型:%s", definition.Code, code) + } + graph := graphs[item.ID] + if graph == nil || graph.Revision == nil { + return plan, fmt.Errorf("旧 SKU 模型 %s 缺少当前 revision", code) + } + route, channelModel, err := onlyEnabledRoute(graph) + if err != nil { + return plan, fmt.Errorf("旧 SKU 模型 %s:%w", code, err) + } + if route.ChannelModelID != channelModel.ID { + return plan, fmt.Errorf("旧 SKU 模型 %s 的供应线路快照不一致", code) + } + capabilityConfig, err := service.DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON) + if err != nil { + return plan, fmt.Errorf("读取渠道模型 %s 能力失败:%w", channelModel.ModelKey, err) + } + spec, err := service.CapabilitySpecFromModelCapabilityConfig(capabilityConfig, channelModel.Capability) + if err != nil { + return plan, err + } + if spec.Capability != "video" || channelModel.Capability != "video" { + return plan, fmt.Errorf("旧 SKU 模型 %s 不是视频模型", code) + } + oldDefaults := map[string]any{} + if err := json.Unmarshal([]byte(graph.Revision.DefaultOptionsJSON), &oldDefaults); err != nil { + return plan, fmt.Errorf("读取旧 SKU 模型 %s 默认参数失败:%w", code, err) + } + plan.oldModels = append(plan.oldModels, item) + plan.routes = append(plan.routes, familyRoute{channelModel: channelModel, spec: spec}) + routeSpecs = append(routeSpecs, spec) + defaults = append(defaults, oldDefaults) + legacyIDs = append(legacyIDs, item.ID) + if item.SortOrder < sortOrder { + sortOrder = item.SortOrder + } + if icon == "" { + icon = item.Icon + } + } + productSpec, err := unionCapabilitySpecs(routeSpecs) + if err != nil { + return plan, fmt.Errorf("合并模型家族 %s 能力失败:%w", definition.Code, err) + } + if sortOrder == math.MaxInt { + sortOrder = 0 + } + plan.request = service.LogicalModelRequest{ + Code: definition.Code, + Name: definition.Name, + Icon: icon, + Description: definition.Description, + Capability: "video", + Enabled: true, + SortOrder: sortOrder, + PricePolicy: "channel", + BillingMode: "fixed_request", + LegacyModelIDs: legacyIDs, + CapabilitySpec: productSpec, + DefaultOptions: familyDefaultOptions(productSpec, defaults), + Routes: make([]service.LogicalRouteRequest, 0, len(plan.routes)), + } + for _, route := range plan.routes { + plan.request.Routes = append(plan.request.Routes, service.LogicalRouteRequest{ChannelModelID: route.channelModel.ID, Enabled: true, Weight: 1}) + } + return plan, nil +} + +func onlyEnabledRoute(graph *repository.LogicalModelGraph) (model.LogicalModelRoute, model.ChannelModel, error) { + routes := make([]model.LogicalModelRoute, 0, len(graph.Routes)) + for _, route := range graph.Routes { + if route.Enabled && route.Weight > 0 { + routes = append(routes, route) + } + } + if len(routes) != 1 { + return model.LogicalModelRoute{}, model.ChannelModel{}, fmt.Errorf("需要恰好一条启用供应线路,当前为 %d 条", len(routes)) + } + for _, channelModel := range graph.ChannelModels { + if channelModel.ID == routes[0].ChannelModelID { + return routes[0], channelModel, nil + } + } + return model.LogicalModelRoute{}, model.ChannelModel{}, errors.New("启用供应线路引用的渠道模型不存在") +} + +func unionCapabilitySpecs(specs []service.CapabilitySpec) (service.CapabilitySpec, error) { + if len(specs) == 0 { + return service.CapabilitySpec{}, errors.New("没有可合并的供应线路能力") + } + result := service.CapabilitySpec{Version: 1, Capability: specs[0].Capability, Inputs: map[string]service.InputConstraint{}, Options: map[string]service.OptionConstraint{}} + operations := make(map[string]bool) + for _, spec := range specs { + if spec.Version != 1 || spec.Capability != result.Capability { + return result, errors.New("供应线路能力类型不一致") + } + for _, operation := range spec.Operations { + operations[operation] = true + } + for name, constraint := range spec.Inputs { + current, exists := result.Inputs[name] + if !exists { + result.Inputs[name] = constraint + continue + } + result.Inputs[name] = service.InputConstraint{Min: min(current.Min, constraint.Min), Max: max(current.Max, constraint.Max)} + } + for name, constraint := range spec.Options { + values, valuesErr := optionValues(constraint) + if valuesErr != nil { + return result, fmt.Errorf("参数 %s:%w", name, valuesErr) + } + current := result.Options[name] + current.Values = appendUniqueValues(current.Values, values...) + current.Min, current.Max, current.Step = nil, nil, nil + result.Options[name] = current + } + } + for operation := range operations { + result.Operations = append(result.Operations, operation) + } + sort.Strings(result.Operations) + for name, constraint := range result.Options { + sort.SliceStable(constraint.Values, func(i, j int) bool { + return optionValueSortKey(constraint.Values[i]) < optionValueSortKey(constraint.Values[j]) + }) + result.Options[name] = constraint + } + return service.NormalizeCapabilitySpec(result) +} + +func optionValues(constraint service.OptionConstraint) ([]any, error) { + if len(constraint.Values) > 0 { + return append([]any(nil), constraint.Values...), nil + } + if constraint.Min == nil || constraint.Max == nil || constraint.Step == nil || *constraint.Step <= 0 { + return nil, errors.New("无法展开数值范围") + } + count := int(math.Floor((*constraint.Max-*constraint.Min)/(*constraint.Step)+1e-9)) + 1 + if count < 1 || count > 1000 { + return nil, errors.New("数值范围过大,拒绝自动合并") + } + values := make([]any, 0, count) + for index := 0; index < count; index++ { + values = append(values, *constraint.Min+float64(index)*(*constraint.Step)) + } + return values, nil +} + +func appendUniqueValues(values []any, additions ...any) []any { + seen := make(map[string]bool, len(values)+len(additions)) + for _, value := range values { + seen[optionValueSortKey(value)] = true + } + for _, value := range additions { + key := optionValueSortKey(value) + if seen[key] { + continue + } + seen[key] = true + values = append(values, value) + } + return values +} + +func optionValueSortKey(value any) string { + return fmt.Sprintf("%T:%v", value, value) +} + +func familyDefaultOptions(spec service.CapabilitySpec, previous []map[string]any) map[string]any { + defaults := make(map[string]any, len(spec.Options)) + for name, constraint := range spec.Options { + if name == "vquality" { + if value, found := optionValueByText(constraint.Values, "720p"); found { + defaults[name] = value + continue + } + } + for _, candidate := range previous { + if value, found := candidate[name]; found && optionContains(constraint, value) { + defaults[name] = value + break + } + } + if _, found := defaults[name]; found { + continue + } + if len(constraint.Values) > 0 { + defaults[name] = constraint.Values[0] + } + } + return defaults +} + +func optionContains(constraint service.OptionConstraint, value any) bool { + for _, candidate := range constraint.Values { + if strings.EqualFold(fmt.Sprint(candidate), fmt.Sprint(value)) { + return true + } + candidateNumber, candidateErr := strconv.ParseFloat(fmt.Sprint(candidate), 64) + valueNumber, valueErr := strconv.ParseFloat(fmt.Sprint(value), 64) + if candidateErr == nil && valueErr == nil && math.Abs(candidateNumber-valueNumber) < 1e-9 { + return true + } + } + return false +} + +func optionValueByText(values []any, target string) (any, bool) { + for _, value := range values { + if strings.EqualFold(fmt.Sprint(value), target) { + return value, true + } + } + return nil, false +} + +func ensureNoActiveTasks(db *gorm.DB, plans []familyPlan) error { + ids := make([]string, 0) + for _, plan := range plans { + for _, old := range plan.oldModels { + ids = append(ids, old.ID) + } + } + var active []struct { + LogicalModelID string + Status string + Count int64 + } + if err := db.Model(&model.Task{}). + Select("logical_model_id, status, count(*) AS count"). + Where("logical_model_id IN ? AND status IN ?", ids, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Group("logical_model_id, status"). + Scan(&active).Error; err != nil { + return err + } + if len(active) == 0 { + return nil + } + parts := make([]string, 0, len(active)) + for _, item := range active { + parts = append(parts, fmt.Sprintf("%s/%s=%d", item.LogicalModelID, item.Status, item.Count)) + } + return fmt.Errorf("存在 queued 或 running 旧 SKU 任务,拒绝归档:%s", strings.Join(parts, ", ")) +} + +func migrationAdmin(db *gorm.DB) (*model.User, error) { + var actor model.User + if err := db.Where("role = ?", model.UserRoleAdmin).Order("created_at asc").First(&actor).Error; err != nil { + return nil, fmt.Errorf("需要现有管理员作为迁移审计主体:%w", err) + } + return &actor, nil +} + +func verifyPlans(db *gorm.DB, svc *service.Service, plans []familyPlan) error { + for _, plan := range plans { + var family model.LogicalModel + if err := db.Where("code = ? AND archived_at IS NULL", plan.definition.Code).First(&family).Error; err != nil { + return fmt.Errorf("读取模型家族 %s 失败:%w", plan.definition.Code, err) + } + for _, route := range plan.routes { + intent := routeIntent(route.spec) + resolved, err := svc.ResolveLogicalModel(family.ID, intent) + if err != nil { + return fmt.Errorf("验证模型家族 %s 的渠道 SKU %s 失败:%w", plan.definition.Code, route.channelModel.ModelKey, err) + } + if resolved.ChannelModel.ID != route.channelModel.ID { + return fmt.Errorf("模型家族 %s 的参数没有命中预期 SKU:期望 %s,实际 %s", plan.definition.Code, route.channelModel.ModelKey, resolved.ChannelModel.ModelKey) + } + } + } + return nil +} + +func routeIntent(spec service.CapabilitySpec) service.ModelRequestIntent { + intent := service.ModelRequestIntent{Capability: spec.Capability, Inputs: map[string]int{}, Options: map[string]any{}} + if len(spec.Operations) > 0 { + intent.Operation = spec.Operations[0] + } + for name, constraint := range spec.Inputs { + intent.Inputs[name] = constraint.Min + } + for name, constraint := range spec.Options { + if len(constraint.Values) > 0 { + intent.Options[name] = constraint.Values[0] + } + } + return intent +} + +func printPlans(plans []familyPlan) { + for _, plan := range plans { + oldCodes := make([]string, 0, len(plan.oldModels)) + channelModels := make([]string, 0, len(plan.routes)) + for _, old := range plan.oldModels { + oldCodes = append(oldCodes, old.Code) + } + for _, route := range plan.routes { + channelModels = append(channelModels, route.channelModel.ModelKey) + } + log.Printf("家族 %s (%s): 旧 SKU=[%s] 路由=[%s] 默认=%v 已存在=%t", plan.definition.Code, plan.definition.Name, strings.Join(oldCodes, ", "), strings.Join(channelModels, ", "), plan.request.DefaultOptions, plan.exists) + } +} diff --git a/backend/cmd/reseed-logical-model-sources/main.go b/backend/cmd/reseed-logical-model-sources/main.go new file mode 100644 index 000000000..9071abf60 --- /dev/null +++ b/backend/cmd/reseed-logical-model-sources/main.go @@ -0,0 +1,165 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log" + "os" + "sort" + "strings" + + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "infinite-canvas/backend/internal/service" + + "gorm.io/gorm" +) + +// 此命令把“单条系统渠道线路驱动、但尚未绑定来源”的前台模型规范为系统渠道投影。 +// 保留产品名称、排序、说明和旧 ID 映射;能力、默认参数、路由和价格统一从系统模型生成。 +type sourcePlan struct { + logicalModel model.LogicalModel + source model.ChannelModel +} + +func main() { + apply := flag.Bool("apply", false, "将符合条件的前台模型改为系统渠道模型驱动") + flag.Parse() + + db, err := database.Open(database.Config{Driver: "postgres", DSN: os.Getenv("DATABASE_URL"), DataDir: os.Getenv("CANVAS_BACKEND_DATA_DIR")}) + if err != nil { + log.Fatal(err) + } + if db.Dialector.Name() != "postgres" { + log.Fatal("前台模型目录刷新只允许连接 PostgreSQL") + } + if err := database.ConfigurePool(db); err != nil { + log.Fatal(err) + } + if err := database.MigrateSchema(db); err != nil { + log.Fatal(err) + } + + repo := repository.New(db) + plans, err := buildPlans(repo) + if err != nil { + log.Fatal(err) + } + for _, plan := range plans { + log.Printf("%s: 当前前台模型=%s,系统模型=%s,协议=%s", plan.logicalModel.Code, plan.logicalModel.ID, plan.source.ModelKey, plan.source.Protocol) + } + if !*apply { + log.Print("dry-run 完成;确认后使用 --apply 写入。未匹配单条系统线路的前台模型不会被修改") + return + } + if err := ensureNoActiveTasks(db, plans); err != nil { + log.Fatal(err) + } + actor, err := migrationAdmin(db) + if err != nil { + log.Fatal(err) + } + svc := service.New(repo, os.Getenv("CANVAS_BACKEND_DATA_DIR")) + for _, plan := range plans { + if _, err := svc.SaveAdminLogicalModel(actor, plan.logicalModel.ID, service.LogicalModelRequest{ + Code: plan.logicalModel.Code, + Name: plan.logicalModel.Name, + Icon: plan.logicalModel.Icon, + Description: plan.logicalModel.Description, + Capability: plan.source.Capability, + Enabled: plan.logicalModel.Enabled, + SortOrder: plan.logicalModel.SortOrder, + LegacyModelIDs: decodeLegacyModelIDs(plan.logicalModel.LegacyModelIDsJSON), + SourceChannelModelID: plan.source.ID, + }); err != nil { + log.Fatalf("刷新前台模型 %s 失败:%v", plan.logicalModel.Code, err) + } + log.Printf("已刷新前台模型:%s", plan.logicalModel.Code) + } + log.Print("前台模型目录刷新完成:能力、默认参数、路线和价格均以系统渠道模型为准") +} + +func buildPlans(repo *repository.Repository) ([]sourcePlan, error) { + items, err := repo.LogicalModels(false) + if err != nil { + return nil, err + } + plans := make([]sourcePlan, 0) + for _, item := range items { + if strings.TrimSpace(item.SourceChannelModelID) != "" { + continue + } + graph, graphErr := repo.LogicalModelGraph(item.ID, false) + if graphErr != nil { + return nil, fmt.Errorf("读取前台模型 %s 的当前线路:%w", item.Code, graphErr) + } + if graph.Revision == nil || len(graph.Routes) != 1 || len(graph.ChannelModels) != 1 { + return nil, fmt.Errorf("前台模型 %s 不是单条有效系统线路,拒绝自动改写", item.Code) + } + route := graph.Routes[0] + source := graph.ChannelModels[0] + if route.ChannelModelID != source.ID { + return nil, fmt.Errorf("前台模型 %s 的当前线路与系统模型不一致", item.Code) + } + channel, channelErr := repo.AdminSystemChannel(source.ChannelID) + if channelErr != nil { + return nil, fmt.Errorf("前台模型 %s 的线路不是系统渠道:%w", item.Code, channelErr) + } + if !channel.Enabled || !source.Enabled || !source.PriceConfigured { + return nil, fmt.Errorf("前台模型 %s 的系统模型未启用或未配置价格", item.Code) + } + plans = append(plans, sourcePlan{logicalModel: item, source: source}) + } + sort.Slice(plans, func(i, j int) bool { + return plans[i].logicalModel.Code < plans[j].logicalModel.Code + }) + return plans, nil +} + +func ensureNoActiveTasks(db *gorm.DB, plans []sourcePlan) error { + ids := make([]string, 0, len(plans)) + for _, plan := range plans { + ids = append(ids, plan.logicalModel.ID) + } + if len(ids) == 0 { + return nil + } + var active []struct { + LogicalModelID string + Status string + Count int64 + } + if err := db.Model(&model.Task{}). + Select("logical_model_id, status, count(*) AS count"). + Where("logical_model_id IN ? AND status IN ?", ids, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Group("logical_model_id, status"). + Scan(&active).Error; err != nil { + return err + } + if len(active) == 0 { + return nil + } + parts := make([]string, 0, len(active)) + for _, item := range active { + parts = append(parts, fmt.Sprintf("%s/%s=%d", item.LogicalModelID, item.Status, item.Count)) + } + return fmt.Errorf("存在 queued 或 running 前台模型任务,拒绝刷新:%s", strings.Join(parts, ", ")) +} + +func migrationAdmin(db *gorm.DB) (*model.User, error) { + var actor model.User + if err := db.Where("role = ?", model.UserRoleAdmin).Order("created_at asc").First(&actor).Error; err != nil { + return nil, fmt.Errorf("需要现有管理员作为迁移审计主体:%w", err) + } + return &actor, nil +} + +func decodeLegacyModelIDs(raw string) []string { + var ids []string + if err := json.Unmarshal([]byte(raw), &ids); err != nil { + return []string{} + } + return ids +} diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index c8a823be5..6e1d85031 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -1,9 +1,12 @@ package database import ( + "crypto/sha256" "database/sql" "errors" "fmt" + "strconv" + "strings" "infinite-canvas/backend/internal/model" @@ -20,6 +23,7 @@ func Models() []any { &model.EmailVerificationCode{}, &model.ModelChannel{}, &model.ChannelModel{}, + &model.ChannelModelPriceTier{}, &model.IDSequence{}, &model.LogicalModel{}, &model.LogicalModelRevision{}, @@ -90,6 +94,15 @@ func MigrateSchema(db *gorm.DB) error { if err := db.AutoMigrate(Models()...); err != nil { return err } + if err := migrateChannelModelPriceTierSelectors(db); err != nil { + return err + } + if err := backfillChannelModelPriceTiers(db); err != nil { + return err + } + if err := migrateChannelModelPriceTierSelectors(db); err != nil { + return err + } if err := dropLegacyPhysicalVariants(db); err != nil { return err } @@ -107,9 +120,113 @@ func MigrateSchema(db *gorm.DB) error { if err := db.Exec("DROP INDEX IF EXISTS idx_route_attempt_task_number").Error; err != nil { return err } + if err := db.Exec("DROP INDEX IF EXISTS idx_logical_model_source_active").Error; err != nil { + return err + } + if err := db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_logical_model_source_active ON logical_models(source_channel_model_id) WHERE source_channel_model_id <> '' AND archived_at IS NULL").Error; err != nil { + return err + } return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ON users(lower(email)) WHERE email <> ''").Error } +// migrateChannelModelPriceTierSelectors upgrades the old video-only unique key to +// a canonical SKU selector key. It never changes task, route-attempt, or billing +// foreign keys, so completed work remains auditable after a product SKU merge. +func migrateChannelModelPriceTierSelectors(db *gorm.DB) error { + if !db.Migrator().HasTable(&model.ChannelModelPriceTier{}) { + return nil + } + var tiers []model.ChannelModelPriceTier + if err := db.Unscoped().Find(&tiers).Error; err != nil { + return fmt.Errorf("读取规格价格档:%w", err) + } + for _, tier := range tiers { + selector := model.DecodeSKUSelector(tier.SelectorJSON) + if len(selector) == 0 { + selector = map[string]string{} + if resolution := strings.TrimSpace(tier.Resolution); resolution != "" && resolution != "*" { + selector["vquality"] = strings.ToLower(resolution) + } + if tier.VideoSeconds > 0 { + selector["videoSeconds"] = strconv.Itoa(tier.VideoSeconds) + } + } + _, key, err := model.CanonicalSKUSelector(selector) + if err != nil { + return fmt.Errorf("规范化规格价格档 %s:%w", tier.ID, err) + } + if tier.SelectorKey == key && tier.SelectorJSON == key { + continue + } + if err := db.Unscoped().Model(&model.ChannelModelPriceTier{}).Where("id = ?", tier.ID).Updates(map[string]any{"selector_key": key, "selector_json": key}).Error; err != nil { + return fmt.Errorf("更新规格价格档 %s:%w", tier.ID, err) + } + } + var duplicate struct { + ChannelModelID string + SelectorKey string + Count int64 + } + err := db.Table("channel_model_price_tiers"). + Select("channel_model_id, selector_key, COUNT(*) AS count"). + Where("deleted_at IS NULL"). + Group("channel_model_id, selector_key"). + Having("COUNT(*) > 1"). + First(&duplicate).Error + if err == nil { + return fmt.Errorf("渠道模型 %s 存在重复 SKU 选择器 %s,拒绝建立唯一索引", duplicate.ChannelModelID, duplicate.SelectorKey) + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("检查重复 SKU 选择器:%w", err) + } + if err := db.Exec("DROP INDEX IF EXISTS idx_channel_model_price_tier_active").Error; err != nil { + return err + } + return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_channel_model_price_tier_active ON channel_model_price_tiers(channel_model_id, selector_key) WHERE deleted_at IS NULL").Error +} + +// backfillChannelModelPriceTiers 将旧的单价模型无损映射为默认价格档。历史订单保存的是 +// 金额快照,不能也不需要回写;这里仅保证升级后现有渠道模型仍能按原价格继续结算。 +func backfillChannelModelPriceTiers(db *gorm.DB) error { + var channelModels []model.ChannelModel + if err := db.Find(&channelModels).Error; err != nil { + return fmt.Errorf("读取渠道模型价格档回填数据:%w", err) + } + for _, channelModel := range channelModels { + var count int64 + if err := db.Model(&model.ChannelModelPriceTier{}).Where("channel_model_id = ?", channelModel.ID).Count(&count).Error; err != nil { + return fmt.Errorf("检查渠道模型 %s 价格档:%w", channelModel.ID, err) + } + if count > 0 { + continue + } + priceVersion := channelModel.PriceVersion + if priceVersion < 1 { + priceVersion = 1 + } + digest := sha256.Sum256([]byte(channelModel.ID)) + tier := model.ChannelModelPriceTier{ + ID: fmt.Sprintf("PTIER-%x", digest[:15]), + ChannelModelID: channelModel.ID, + Resolution: "*", + VideoSeconds: 0, + ProviderModelKey: channelModel.ProviderModelKey, + BillingMode: channelModel.BillingMode, + UnitPriceMicrocredits: channelModel.UnitPriceMicrocredits, + InputTokenPriceMicrocredits: channelModel.InputTokenPriceMicrocredits, + OutputTokenPriceMicrocredits: channelModel.OutputTokenPriceMicrocredits, + CachedTokenPriceMicrocredits: channelModel.CachedTokenPriceMicrocredits, + PriceConfigured: channelModel.PriceConfigured, + Enabled: channelModel.Enabled, + PriceVersion: priceVersion, + } + if err := db.Create(&tier).Error; err != nil { + return fmt.Errorf("回填渠道模型 %s 默认价格档:%w", channelModel.ID, err) + } + } + return nil +} + // migrateLogicalRoutesToChannelModels 在模型结构切换前把历史 variant 外键转换为渠道模型外键。 // 写路径不能接受无法映射的历史引用,否则任务恢复和计费会失去明确执行目标。 func migrateLogicalRoutesToChannelModels(db *gorm.DB) error { diff --git a/backend/internal/handler/logical_models.go b/backend/internal/handler/logical_models.go index 6a5c26454..420c6f899 100644 --- a/backend/internal/handler/logical_models.go +++ b/backend/internal/handler/logical_models.go @@ -39,6 +39,23 @@ func RegisterLogicalModelRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"models": models}) }) + r.POST("/models/:id/quote", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + var intent service.ModelRequestIntent + if err := c.ShouldBindJSON(&intent); err != nil { + fail(c, http.StatusBadRequest, errors.New("模型报价请求格式错误")) + return + } + quote, err := svc.QuoteLogicalModel(c.Param("id"), intent) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"quote": quote}) + }) r.GET("/admin/logical-models", func(c *gin.Context) { actor, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/model/model_sku.go b/backend/internal/model/model_sku.go new file mode 100644 index 000000000..157cc97a6 --- /dev/null +++ b/backend/internal/model/model_sku.go @@ -0,0 +1,34 @@ +package model + +import ( + "encoding/json" + "strings" +) + +// CanonicalSKUSelector stores selector values as strings so an API number and its +// textual equivalent cannot create two price rows for the same purchasable SKU. +func CanonicalSKUSelector(raw map[string]string) (map[string]string, string, error) { + selector := make(map[string]string, len(raw)) + for rawKey, rawValue := range raw { + key := strings.TrimSpace(rawKey) + value := strings.TrimSpace(rawValue) + if key == "" || value == "" { + continue + } + selector[key] = value + } + encoded, err := json.Marshal(selector) + if err != nil { + return nil, "", err + } + return selector, string(encoded), nil +} + +func DecodeSKUSelector(raw string) map[string]string { + selector := map[string]string{} + if strings.TrimSpace(raw) == "" { + return selector + } + _ = json.Unmarshal([]byte(raw), &selector) + return selector +} diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index d8d02cb19..a86a0bf1f 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -18,6 +18,8 @@ type ModelChannel struct { APIFormat string `json:"apiFormat" gorm:"size:24"` ConcurrencyLimit int `json:"concurrencyLimit"` ModelsJSON string `json:"modelsJson" gorm:"type:text"` + // RetiredModelsJSON 记录已被一个模型家族吸收的上游 SKU,防止目录拉取时重新创建重复记录。 + RetiredModelsJSON string `json:"-" gorm:"type:text"` HeadersJSON string `json:"-" gorm:"type:text"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -28,6 +30,7 @@ type ChannelModel struct { ID string `json:"id" gorm:"primaryKey;size:36"` ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"` ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` + ProviderModelKey string `json:"providerModelKey" gorm:"size:120"` DisplayName string `json:"displayName" gorm:"size:160"` Capability string `json:"capability" gorm:"size:32;index"` Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"` @@ -42,9 +45,39 @@ type ChannelModel struct { CapabilityConfigJSON string `json:"-" gorm:"type:text"` CapabilityVersion int64 `json:"capabilityVersion"` CapabilityConfig map[string]any `json:"capabilityConfig,omitempty" gorm:"-"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + // PriceTiers 是系统渠道模型的价格真相。标量价格仅为旧调用与历史数据兼容, + // 新的创作端报价必须按所选规格命中一个价格档。 + PriceTiers []ChannelModelPriceTier `json:"priceTiers" gorm:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// ChannelModelPriceTier 表示渠道模型在一个规格组合下的可执行上游 SKU 和用户价格。 +// SelectorJSON 是规格选择器真相,例如 {"operation":"image_to_video","vquality":"720p", +// "videoSeconds":"10"} 或 {"operation":"text_to_image","quality":"2k"}。 +// SelectorKey 是规范化 JSON,用于 PostgreSQL 的活动规格唯一约束。旧的 Resolution / VideoSeconds +// 仅保留给历史 API 与升级回填,不能再作为 SKU 唯一性依据。 +type ChannelModelPriceTier struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ChannelModelID string `json:"channelModelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_price_tier_active,priority:1,where:deleted_at IS NULL"` + SelectorKey string `json:"selectorKey" gorm:"size:500;not null;default:{};uniqueIndex:idx_channel_model_price_tier_active,priority:2,where:deleted_at IS NULL"` + SelectorJSON string `json:"-" gorm:"type:text;not null;default:{}"` + Selector map[string]string `json:"selector,omitempty" gorm:"-"` + Resolution string `json:"resolution" gorm:"size:24;not null;default:*"` + VideoSeconds int `json:"videoSeconds" gorm:"not null;default:0"` + ProviderModelKey string `json:"providerModelKey" gorm:"size:120"` + BillingMode string `json:"billingMode" gorm:"size:32"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + PriceConfigured bool `json:"priceConfigured" gorm:"index"` + Enabled bool `json:"enabled" gorm:"index"` + PriceVersion int64 `json:"priceVersion"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } type ApiCallLog struct { diff --git a/backend/internal/model/models_finance.go b/backend/internal/model/models_finance.go index 54db099c6..ec8c09949 100644 --- a/backend/internal/model/models_finance.go +++ b/backend/internal/model/models_finance.go @@ -38,6 +38,10 @@ type BillingOrder struct { TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` ChannelID string `json:"channelId" gorm:"index;size:36"` ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` + // PriceTierID/Version 记录任务实际命中的规格档;金额字段仍是不可变结算快照。 + PriceTierID string `json:"priceTierId,omitempty" gorm:"index;size:36"` + PriceTierVersion int64 `json:"priceTierVersion"` + PriceSelectorJSON string `json:"-" gorm:"type:text"` Model string `json:"model" gorm:"index;size:120"` Capability string `json:"capability" gorm:"index;size:32"` Scene string `json:"scene" gorm:"index;size:80"` diff --git a/backend/internal/model/models_logical_model.go b/backend/internal/model/models_logical_model.go index 762f7c1c5..415cfcd4e 100644 --- a/backend/internal/model/models_logical_model.go +++ b/backend/internal/model/models_logical_model.go @@ -20,14 +20,20 @@ type LogicalModel struct { Enabled bool `json:"enabled" gorm:"index"` SortOrder int `json:"sortOrder" gorm:"index"` // RevisionSequence 记录已经分配的 revision 版本号,由数据库原子递增。 - RevisionSequence int `json:"-" gorm:"not null;default:0"` - ActiveRevisionID string `json:"activeRevisionId" gorm:"size:36;index"` + RevisionSequence int `json:"-" gorm:"not null;default:0"` + ActiveRevisionID string `json:"activeRevisionId" gorm:"size:36;index"` + // SourceChannelModelID 标识这个前台目录项由哪个系统渠道模型投影而来。 + // 管理员不再重复维护能力和价格,渠道模型保存时会创建新的前台 revision。 + SourceChannelModelID string `json:"sourceChannelModelId,omitempty" gorm:"size:36;index"` PricePolicy string `json:"pricePolicy" gorm:"size:24;default:unified"` BillingMode string `json:"billingMode" gorm:"size:32"` UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"` OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"` CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"` + // LegacyModelIDsJSON 用于创作端把已保存的旧 SKU 选择无缝映射到新的模型家族。 + // 这只是目录兼容信息,任务、路由尝试和账单仍固定引用其创建时的 ID 快照。 + LegacyModelIDsJSON string `json:"-" gorm:"type:text"` // ArchivedAt 仅从可选目录隐藏模型;历史任务、计费和审计仍需读取主体及不可变 revision。 ArchivedAt *time.Time `json:"-" gorm:"index"` CreatedAt time.Time `json:"createdAt"` diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 337bff5ea..2793ff9cc 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/hex" "errors" + "strconv" "strings" "time" @@ -73,7 +74,14 @@ func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]mo if !includeDisabled { query = query.Where("enabled = ?", true) } - return items, query.Find(&items).Error + if err := query.Find(&items).Error; err != nil { + return nil, err + } + pointers := make([]*model.ChannelModel, len(items)) + for index := range items { + pointers[index] = &items[index] + } + return items, r.attachChannelModelPriceTiers(pointers) } func (r *Repository) ChannelModelByID(channelID string, id string) (*model.ChannelModel, error) { @@ -81,6 +89,9 @@ func (r *Repository) ChannelModelByID(channelID string, id string) (*model.Chann if err := r.db.First(&item, "id = ? AND channel_id = ?", id, channelID).Error; err != nil { return nil, err } + if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil { + return nil, err + } return &item, nil } @@ -89,6 +100,9 @@ func (r *Repository) ChannelModelByKey(channelID string, modelKey string) (*mode if err := r.db.First(&item, "channel_id = ? AND model_key = ? AND enabled = ?", channelID, modelKey, true).Error; err != nil { return nil, err } + if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil { + return nil, err + } return &item, nil } @@ -97,6 +111,9 @@ func (r *Repository) ChannelModelByKeyIncludingDisabled(channelID string, modelK if err := r.db.First(&item, "channel_id = ? AND model_key = ?", channelID, modelKey).Error; err != nil { return nil, err } + if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil { + return nil, err + } return &item, nil } @@ -104,6 +121,104 @@ func (r *Repository) SaveChannelModel(item *model.ChannelModel) error { return r.db.Save(item).Error } +// SaveChannelModelWithPriceTiers 原子保存系统模型与其活动价格档。移除价格档采用软删除, +// 让已结算订单的 PriceTierID 仍能回溯到原始配置版本。 +func (r *Repository) SaveChannelModelWithPriceTiers(item *model.ChannelModel, tiers []model.ChannelModelPriceTier) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var existing []model.ChannelModelPriceTier + if err := tx.Where("channel_model_id = ?", item.ID).Find(&existing).Error; err != nil { + return err + } + existingByKey := make(map[string]model.ChannelModelPriceTier, len(existing)) + for _, tier := range existing { + existingByKey[channelModelPriceTierKey(tier)] = tier + } + selected := make(map[string]bool, len(tiers)) + for index := range tiers { + tier := &tiers[index] + tier.ChannelModelID = item.ID + key := channelModelPriceTierKey(*tier) + if existingTier, exists := existingByKey[key]; exists { + tier.ID = existingTier.ID + tier.PriceVersion = existingTier.PriceVersion + 1 + if err := tx.Save(tier).Error; err != nil { + return err + } + } else if err := tx.Create(tier).Error; err != nil { + return err + } + selected[tier.ID] = true + } + for _, tier := range existing { + if selected[tier.ID] { + continue + } + if err := tx.Delete(&tier).Error; err != nil { + return err + } + } + if err := tx.Save(item).Error; err != nil { + return err + } + return nil + }) +} + +func channelModelPriceTierKey(tier model.ChannelModelPriceTier) string { + if strings.TrimSpace(tier.SelectorKey) != "" { + return tier.SelectorKey + } + _, key, err := model.CanonicalSKUSelector(map[string]string{ + "vquality": strings.TrimSpace(tier.Resolution), + "videoSeconds": strconv.Itoa(tier.VideoSeconds), + }) + if err != nil { + return "{}" + } + return key +} + +func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) error { + if len(items) == 0 { + return nil + } + ids := make([]string, 0, len(items)) + for _, item := range items { + 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 + } + for index := range tiers { + tiers[index].Selector = model.DecodeSKUSelector(tiers[index].SelectorJSON) + } + tiersByModelID := make(map[string][]model.ChannelModelPriceTier, len(items)) + for _, tier := range tiers { + tiersByModelID[tier.ChannelModelID] = append(tiersByModelID[tier.ChannelModelID], tier) + } + for _, item := range items { + item.PriceTiers = tiersByModelID[item.ID] + } + return nil +} + +// PopulateChannelModelPriceTiers 将价格档附着到已经查询出的渠道模型,供路由关系图批量加载使用。 +func (r *Repository) PopulateChannelModelPriceTiers(items []model.ChannelModel) error { + pointers := make([]*model.ChannelModel, len(items)) + for index := range items { + pointers[index] = &items[index] + } + return r.attachChannelModelPriceTiers(pointers) +} + +func (r *Repository) PopulateChannelModelPriceTier(item *model.ChannelModel) error { + if item == nil { + return nil + } + return r.attachChannelModelPriceTiers([]*model.ChannelModel{item}) +} + func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON string, now time.Time) error { return r.db.Transaction(func(tx *gorm.DB) error { var activeReferences int64 diff --git a/backend/internal/repository/logical_models.go b/backend/internal/repository/logical_models.go index 34c6fc09a..522e98458 100644 --- a/backend/internal/repository/logical_models.go +++ b/backend/internal/repository/logical_models.go @@ -37,6 +37,22 @@ func (r *Repository) LogicalModel(id string) (*model.LogicalModel, error) { return &item, nil } +func (r *Repository) LogicalModelsBySourceChannelModelID(channelModelID string) ([]model.LogicalModel, error) { + var items []model.LogicalModel + return items, r.db.Where("source_channel_model_id = ? AND archived_at IS NULL", channelModelID).Order("sort_order asc, created_at asc").Find(&items).Error +} + +func (r *Repository) LogicalModelsByOnlyActiveChannelModelID(channelModelID string) ([]model.LogicalModel, error) { + var items []model.LogicalModel + query := r.db.Model(&model.LogicalModel{}). + Joins("JOIN logical_model_routes AS route ON route.logical_model_revision_id = logical_models.active_revision_id"). + Where("logical_models.archived_at IS NULL"). + Group("logical_models.id"). + Having("COUNT(route.id) = 1 AND MAX(route.channel_model_id) = ?", channelModelID). + Order("logical_models.sort_order asc, logical_models.created_at asc") + return items, query.Find(&items).Error +} + func (r *Repository) LogicalModelRevision(id string) (*model.LogicalModelRevision, error) { var item model.LogicalModelRevision if err := r.db.First(&item, "id = ?", id).Error; err != nil { @@ -72,7 +88,10 @@ func (r *Repository) ChannelModelsByIDs(ids []string) ([]model.ChannelModel, err if len(ids) == 0 { return items, nil } - return items, r.db.Where("id IN ?", ids).Find(&items).Error + if err := r.db.Where("id IN ?", ids).Find(&items).Error; err != nil { + return nil, err + } + return items, r.PopulateChannelModelPriceTiers(items) } func (r *Repository) SystemChannelsByIDs(ids []string, includeDisabled bool) ([]model.ModelChannel, error) { @@ -92,6 +111,9 @@ func (r *Repository) ChannelModel(id string) (*model.ChannelModel, error) { if err := r.db.First(&item, "id = ?", id).Error; err != nil { return nil, err } + if err := r.PopulateChannelModelPriceTier(&item); err != nil { + return nil, err + } return &item, nil } @@ -316,6 +338,8 @@ func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID strin } updates["billing_mode"] = replacement.BillingMode updates["price_version"] = replacement.PriceVersion + updates["price_tier_id"] = replacement.PriceTierID + updates["price_tier_version"] = replacement.PriceTierVersion updates["unit_price_microcredits"] = replacement.UnitPriceMicrocredits updates["multiplier_basis_points"] = replacement.MultiplierBasisPoints updates["quantity"] = replacement.Quantity @@ -353,12 +377,14 @@ func (r *Repository) SaveLogicalModelBundle(item *model.LogicalModel, revision * "capability": item.Capability, "enabled": item.Enabled, "sort_order": item.SortOrder, + "source_channel_model_id": item.SourceChannelModelID, "price_policy": item.PricePolicy, "billing_mode": item.BillingMode, "unit_price_microcredits": item.UnitPriceMicrocredits, "input_price_microcredits": item.InputPriceMicrocredits, "output_price_microcredits": item.OutputPriceMicrocredits, "cached_price_microcredits": item.CachedPriceMicrocredits, + "legacy_model_ids_json": item.LegacyModelIDsJSON, "updated_at": item.UpdatedAt, }) if result.Error != nil { diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 0aa86f86c..2e9504618 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -835,7 +835,12 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model } models = append(models, item.ModelKey) if item.Enabled && item.PriceConfigured { - capabilityConfig, _ := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + 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 { + capabilityConfig = normalized + } + } modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig}) } } diff --git a/backend/internal/service/admin_channel_test.go b/backend/internal/service/admin_channel_test.go index 0d8b6b458..ac26f277d 100644 --- a/backend/internal/service/admin_channel_test.go +++ b/backend/internal/service/admin_channel_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "net/http" "net/http/httptest" @@ -174,6 +175,69 @@ func TestSaveAdminChannelModelRejectsActiveDuplicateKey(t *testing.T) { } } +func TestResolveProviderConfigMapsSKUToProviderModel(t *testing.T) { + svc, db := newChannelModelTestService(t) + svc.dataDir = t.TempDir() + channel := model.ModelChannel{ + ID: "channel-1", Scope: model.ChannelScopeSystem, Enabled: true, Name: "Seedance", + BaseURL: "https://ark.cn-beijing.volces.com/api/v3", APIKey: "test-key", APIFormat: "openai", ModelsJSON: `["seedance-2-5-480p"]`, + } + if err := svc.encryptSystemChannelSecrets(&channel); err != nil { + t.Fatal(err) + } + item := model.ChannelModel{ + ID: "model-1", ChannelID: channel.ID, ModelKey: "seedance-2-5-480p", ProviderModelKey: "doubao-seedance-2-5", + Capability: "video", Protocol: model.ChannelInterfaceVolcengineArkVideo, Enabled: true, + } + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + + config, err := svc.resolveProviderConfig(providerConfig{ChannelID: channel.ID, Model: item.ModelKey}) + if err != nil { + t.Fatal(err) + } + if config.ChannelModelKey != item.ModelKey || config.Model != item.ProviderModelKey { + t.Fatalf("resolved config = %#v", config) + } +} + +func TestValidateTaskCapabilityFixesSingleResolutionSKU(t *testing.T) { + svc, db := newChannelModelTestService(t) + video := DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceVolcengineArkVideo), "doubao-seedance-2-5").Video + video.References.MaxVideos = 0 + video.Resolutions = []string{"480p"} + video.DefaultResolution = "480p" + encoded, err := json.Marshal(&ModelCapabilityConfig{Version: 1, Video: video}) + if err != nil { + t.Fatal(err) + } + channelModel := model.ChannelModel{ + ID: "model-480p", ChannelID: "channel-1", ModelKey: "doubao-seedance-2-5-480p", ProviderModelKey: "doubao-seedance-2-5", + Capability: "video", Protocol: model.ChannelInterfaceVolcengineArkVideo, Enabled: true, CapabilityConfigJSON: string(encoded), + } + if err := db.Create(&channelModel).Error; err != nil { + t.Fatal(err) + } + input := map[string]any{ + "mode": "video", + "config": map[string]any{ + "channelId": "channel-1", "model": channelModel.ModelKey, "vquality": "auto", "videoSeconds": "6", "size": "16:9", + "videoGenerateAudio": "true", "videoWatermark": "false", + }, + } + if err := svc.ValidateTaskCapability(input); err != nil { + t.Fatal(err) + } + config := input["config"].(map[string]any) + if got := config["vquality"]; got != "480p" { + t.Fatalf("vquality = %#v, want 480p", got) + } +} + func newChannelModelTestService(t *testing.T) (*Service, *gorm.DB) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 13e462c95..42741aedb 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" + "strconv" "strings" "time" @@ -15,18 +17,38 @@ import ( ) type ChannelModelRequest struct { - ModelKey string `json:"modelKey"` - DisplayName string `json:"displayName"` - Capability string `json:"capability"` - Protocol string `json:"protocol"` - BillingMode string `json:"billingMode"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` - OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` - CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` - PriceConfigured bool `json:"priceConfigured"` - Enabled *bool `json:"enabled"` - CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"` + ModelKey string `json:"modelKey"` + ProviderModelKey string `json:"providerModelKey"` + DisplayName string `json:"displayName"` + Capability string `json:"capability"` + Protocol string `json:"protocol"` + BillingMode string `json:"billingMode"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + PriceConfigured bool `json:"priceConfigured"` + Enabled *bool `json:"enabled"` + CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig"` + PriceTiers []ChannelModelPriceTierRequest `json:"priceTiers"` +} + +// ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。 +// Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。 +type ChannelModelPriceTierRequest struct { + // Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds; + // operation 可区分文生/图生/视频生,避免同一分辨率下错误复用价格。 + Selector map[string]string `json:"selector"` + Resolution string `json:"resolution"` + VideoSeconds int `json:"videoSeconds"` + ProviderModelKey string `json:"providerModelKey"` + BillingMode string `json:"billingMode"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + PriceConfigured bool `json:"priceConfigured"` + Enabled *bool `json:"enabled"` } // AdminChannelModelFetchResult 是管理员从上游拉目录后的汇总:models 为去重后的标识,added 为本次新建条数。 @@ -73,9 +95,18 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod if strings.TrimSpace(items[index].CapabilityConfigJSON) == "" { continue } - var config map[string]any - if json.Unmarshal([]byte(items[index].CapabilityConfigJSON), &config) == nil { - items[index].CapabilityConfig = config + config, decodeErr := DecodeModelCapabilityConfig(items[index].CapabilityConfigJSON) + 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) + if normalizeErr != nil || normalized == nil { + continue + } + encoded, encodeErr := json.Marshal(normalized) + var value map[string]any + if encodeErr == nil && json.Unmarshal(encoded, &value) == nil { + items[index].CapabilityConfig = value } } return items, nil @@ -117,18 +148,21 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User if err != nil { return nil, err } - // 只按当前未删除记录去重;重新拉取已删除模型时应生成新的待配置记录。 + // 只按当前未删除记录去重;普通手动删除的模型仍可重新拉取,已合并进模型家族的 SKU 除外。 existing, err := s.repo.ChannelModels(channelID, true) if err != nil { return nil, err } known := make(map[string]struct{}, len(existing)) for _, item := range existing { - known[item.ModelKey] = struct{}{} + known[channelModelCatalogKey(item.ModelKey)] = struct{}{} } + retired := retiredChannelModelKeys(channel.RetiredModelsJSON) missing := make([]model.ChannelModel, 0, len(models)) for _, name := range models { - if _, ok := known[name]; ok { + name = strings.TrimPrefix(strings.TrimSpace(name), "models/") + key := channelModelCatalogKey(name) + if _, ok := known[key]; ok || retired[key] { continue } // 自动发现不能绕过定价边界;新模型由管理员定价后再手动启用。 @@ -156,7 +190,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if err != nil { return nil, err } - modelKey, capability, protocol, err := normalizeChannelModelContract(channel, req) + modelKey, providerModelKey, capability, protocol, err := normalizeChannelModelContract(channel, req) if err != nil { return nil, err } @@ -169,35 +203,13 @@ 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), req.CapabilityConfig); err != nil { + if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig); err != nil { return nil, err } } - billingMode := strings.TrimSpace(req.BillingMode) - if billingMode == "" { - billingMode = "fixed_request" - } - if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" { - return nil, BadAuthRequest("模型计费方式仅支持按次、按秒或 Token") - } - if billingMode == "per_second" && capability != "video" { - return nil, BadAuthRequest("只有视频模型可以按秒计费") - } - if billingMode == "token" && !supportsTokenBilling(capability, protocol) { - return nil, BadAuthRequest("Token 计费仅支持文本模型和火山方舟视频协议") - } - if req.UnitPriceMicrocredits < 0 || req.InputTokenPriceMicrocredits < 0 || req.OutputTokenPriceMicrocredits < 0 || req.CachedTokenPriceMicrocredits < 0 { - return nil, BadAuthRequest("模型积分价格不能小于 0") - } - if billingMode == "token" && req.InputTokenPriceMicrocredits == 0 && req.OutputTokenPriceMicrocredits == 0 && req.CachedTokenPriceMicrocredits == 0 { - return nil, BadAuthRequest("Token 计费至少需要配置一项价格") - } - if billingMode == "token" && capability == "video" && req.OutputTokenPriceMicrocredits == 0 { - return nil, BadAuthRequest("火山方舟视频 Token 计费需要配置每百万视频 Token 价格") - } - const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale - if req.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || req.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits { - return nil, BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分") + tiers, err := s.normalizeChannelModelPriceTiers(req, capability, protocol, providerModelKey) + if err != nil { + return nil, err } modelID, err := s.repo.NextPrefixedID("MODEL") if err != nil { @@ -212,20 +224,16 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id item.PriceVersion++ } item.ModelKey = modelKey + item.ProviderModelKey = providerModelKey item.DisplayName = strings.TrimSpace(req.DisplayName) if item.DisplayName == "" { item.DisplayName = modelKey } item.Capability = capability item.Protocol = protocol - item.BillingMode = billingMode - item.UnitPriceMicrocredits = req.UnitPriceMicrocredits - item.InputTokenPriceMicrocredits = req.InputTokenPriceMicrocredits - item.OutputTokenPriceMicrocredits = req.OutputTokenPriceMicrocredits - item.CachedTokenPriceMicrocredits = req.CachedTokenPriceMicrocredits - item.PriceConfigured = req.PriceConfigured + s.applyChannelModelPriceTierSummary(item, tiers) if capability == "text" || capability == "image" || capability == "video" { - capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig) + capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig) if normalizeErr != nil { return nil, normalizeErr } @@ -244,21 +252,263 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if req.Enabled != nil { item.Enabled = *req.Enabled } - // 供应线路直接引用渠道模型,修改能力参数后会从这一唯一事实来源实时生成线路能力。 - if err := s.repo.SaveChannelModel(item); err != nil { + if err := validateChannelModelTierCapabilities(tiers, item.CapabilityConfigJSON, capability); err != nil { return nil, err } + // 渠道模型及所有价格档必须同时落库,不能出现“能力已开放但规格价格尚未更新”的窗口。 + if err := s.repo.SaveChannelModelWithPriceTiers(item, tiers); err != nil { + return nil, err + } + item.PriceTiers = tiers s.invalidateRouteCatalog() if err := s.syncChannelModelNames(channel); err != nil { return nil, err } + if err := s.syncLogicalModelsFromChannelModel(actor, item); err != nil { + return nil, err + } return item, nil } +func validateChannelModelTierCapabilities(tiers []model.ChannelModelPriceTier, rawCapabilityConfig string, capability string) error { + if capability != "video" { + return nil + } + config, err := DecodeModelCapabilityConfig(rawCapabilityConfig) + if err != nil || config == nil || config.Video == nil { + return BadAuthRequest("视频模型能力配置无效,无法校验价格档规格") + } + resolutionSupported := make(map[string]bool, len(config.Video.Resolutions)) + for _, resolution := range config.Video.Resolutions { + resolutionSupported[normalizeChannelModelTierResolution(resolution)] = true + } + durationSupported := make(map[int]bool, len(config.Video.Duration.Values)) + for _, seconds := range config.Video.Duration.Values { + durationSupported[seconds] = true + } + for _, tier := range tiers { + if tier.Resolution != "*" && !resolutionSupported[normalizeChannelModelTierResolution(tier.Resolution)] { + return BadAuthRequest("价格档分辨率不在该视频模型支持范围内:" + tier.Resolution) + } + if tier.VideoSeconds == 0 { + continue + } + if config.Video.Duration.Selection == "enum" && !durationSupported[tier.VideoSeconds] { + return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds)) + } + if config.Video.Duration.Selection == "range" && (tier.VideoSeconds < config.Video.Duration.Min || tier.VideoSeconds > config.Video.Duration.Max || (config.Video.Duration.Step > 0 && (tier.VideoSeconds-config.Video.Duration.Min)%config.Video.Duration.Step != 0)) { + return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds)) + } + } + return nil +} + +// syncLogicalModelsFromChannelModel 只失效路由目录。系统渠道 SKU 与前台模型目录 +// 分别维护:保存渠道模型绝不能自动创建、覆盖或删除前台模型及其线路配置。 +func (s *Service) syncLogicalModelsFromChannelModel(actor *model.User, channelModel *model.ChannelModel) error { + _ = actor + _ = channelModel + s.invalidateRouteCatalog() + return nil +} + func supportsTokenBilling(capability string, protocol model.ChannelInterfaceType) bool { return capability == "text" || (capability == "video" && protocol == model.ChannelInterfaceVolcengineArkVideo) } +func (s *Service) normalizeChannelModelPriceTiers(req ChannelModelRequest, capability string, protocol model.ChannelInterfaceType, fallbackProviderModelKey string) ([]model.ChannelModelPriceTier, error) { + inputs := req.PriceTiers + // 兼容旧管理 API:没有 priceTiers 的请求等价于一个默认价格档。 + if len(inputs) == 0 { + enabled := true + inputs = []ChannelModelPriceTierRequest{{ + Resolution: "*", VideoSeconds: 0, ProviderModelKey: fallbackProviderModelKey, + BillingMode: req.BillingMode, UnitPriceMicrocredits: req.UnitPriceMicrocredits, + InputTokenPriceMicrocredits: req.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: req.OutputTokenPriceMicrocredits, + CachedTokenPriceMicrocredits: req.CachedTokenPriceMicrocredits, PriceConfigured: req.PriceConfigured, Enabled: &enabled, + }} + } + result := make([]model.ChannelModelPriceTier, 0, len(inputs)) + seen := make(map[string]bool, len(inputs)) + for _, input := range inputs { + selector, resolution, videoSeconds, selectorErr := normalizeChannelModelTierSelector(capability, input) + if selectorErr != nil { + return nil, selectorErr + } + _, key, keyErr := model.CanonicalSKUSelector(selector) + if keyErr != nil { + return nil, keyErr + } + if seen[key] { + return nil, BadAuthRequest("同一个操作和规格组合只能配置一个价格档") + } + seen[key] = true + billingMode := strings.TrimSpace(input.BillingMode) + if billingMode == "" { + billingMode = "fixed_request" + } + if err := validateChannelModelTierPricing(capability, protocol, billingMode, input); err != nil { + return nil, err + } + id, idErr := s.repo.NextPrefixedID("PTIER") + if idErr != nil { + return nil, idErr + } + enabled := input.Enabled == nil || *input.Enabled + result = append(result, model.ChannelModelPriceTier{ + ID: id, + SelectorKey: key, + SelectorJSON: key, + Selector: selector, + Resolution: resolution, + VideoSeconds: videoSeconds, + ProviderModelKey: strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(input.ProviderModelKey, fallbackProviderModelKey)), "models/"), + BillingMode: billingMode, + UnitPriceMicrocredits: input.UnitPriceMicrocredits, + InputTokenPriceMicrocredits: input.InputTokenPriceMicrocredits, + OutputTokenPriceMicrocredits: input.OutputTokenPriceMicrocredits, + CachedTokenPriceMicrocredits: input.CachedTokenPriceMicrocredits, + PriceConfigured: input.PriceConfigured, + Enabled: enabled, + PriceVersion: 1, + }) + } + return result, nil +} + +func normalizeChannelModelTierSelector(capability string, input ChannelModelPriceTierRequest) (map[string]string, string, int, error) { + selector := make(map[string]string, len(input.Selector)+3) + for rawKey, rawValue := range input.Selector { + key := strings.TrimSpace(rawKey) + value := strings.TrimSpace(rawValue) + if key == "" || value == "" { + continue + } + switch key { + case "operation": + value = strings.ToLower(value) + case "quality", "size": + value = strings.ToLower(value) + if value == "auto" || value == "any" { + value = "*" + } + case "vquality": + value = normalizeChannelModelTierResolution(value) + case "videoSeconds": + seconds, err := strconv.Atoi(value) + if err != nil || seconds < 0 { + return nil, "", 0, BadAuthRequest("视频价格档时长必须是非负整数") + } + if seconds == 0 { + continue + } + value = strconv.Itoa(seconds) + default: + return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key) + } + selector[key] = value + } + if capability == "video" { + if _, exists := selector["vquality"]; !exists { + if resolution := normalizeChannelModelTierResolution(input.Resolution); resolution != "*" { + selector["vquality"] = resolution + } + } + if _, exists := selector["videoSeconds"]; !exists && input.VideoSeconds > 0 { + selector["videoSeconds"] = strconv.Itoa(input.VideoSeconds) + } + } else if input.Resolution != "" && normalizeChannelModelTierResolution(input.Resolution) != "*" { + return nil, "", 0, BadAuthRequest("非视频模型不能使用视频分辨率价格档") + } else if input.VideoSeconds != 0 { + return nil, "", 0, BadAuthRequest("非视频模型不能使用视频时长价格档") + } + for _, key := range []string{"quality", "size"} { + if _, exists := selector[key]; exists && capability != "image" { + return nil, "", 0, BadAuthRequest("只有图片模型可以按 " + key + " 配置价格档") + } + } + if _, exists := selector["vquality"]; exists && capability != "video" { + return nil, "", 0, BadAuthRequest("只有视频模型可以按分辨率配置价格档") + } + if _, exists := selector["videoSeconds"]; exists && capability != "video" { + return nil, "", 0, BadAuthRequest("只有视频模型可以按时长配置价格档") + } + resolution := "*" + if value := selector["vquality"]; value != "" { + resolution = value + } + videoSeconds := 0 + if value := selector["videoSeconds"]; value != "" { + videoSeconds, _ = strconv.Atoi(value) + } + return selector, resolution, videoSeconds, nil +} + +func validateChannelModelTierPricing(capability string, protocol model.ChannelInterfaceType, billingMode string, input ChannelModelPriceTierRequest) error { + if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" { + return BadAuthRequest("模型计费方式仅支持按次、按秒或 Token") + } + if billingMode == "per_second" && capability != "video" { + return BadAuthRequest("只有视频模型可以按秒计费") + } + if billingMode == "token" && !supportsTokenBilling(capability, protocol) { + return BadAuthRequest("Token 计费仅支持文本模型和火山方舟视频协议") + } + if input.UnitPriceMicrocredits < 0 || input.InputTokenPriceMicrocredits < 0 || input.OutputTokenPriceMicrocredits < 0 || input.CachedTokenPriceMicrocredits < 0 { + return BadAuthRequest("模型积分价格不能小于 0") + } + if !input.PriceConfigured { + return nil + } + if billingMode == "token" && input.InputTokenPriceMicrocredits == 0 && input.OutputTokenPriceMicrocredits == 0 && input.CachedTokenPriceMicrocredits == 0 { + return BadAuthRequest("Token 计费至少需要配置一项价格") + } + if billingMode == "token" && capability == "video" && input.OutputTokenPriceMicrocredits == 0 { + return BadAuthRequest("火山方舟视频 Token 计费需要配置每百万视频 Token 价格") + } + const maxTokenPriceMicrocredits = int64(1_000_000) * CreditScale + if input.InputTokenPriceMicrocredits > maxTokenPriceMicrocredits || input.OutputTokenPriceMicrocredits > maxTokenPriceMicrocredits || input.CachedTokenPriceMicrocredits > maxTokenPriceMicrocredits { + return BadAuthRequest("Token 每百万用量价格不能超过 1,000,000 积分") + } + return nil +} + +func normalizeChannelModelTierResolution(raw string) string { + value := strings.TrimSpace(raw) + if value == "" || value == "*" || strings.EqualFold(value, "any") { + return "*" + } + normalized := normalizeModelRequestOption("vquality", value) + return strings.ToLower(strings.TrimSpace(fmt.Sprint(normalized))) +} + +func (s *Service) applyChannelModelPriceTierSummary(item *model.ChannelModel, tiers []model.ChannelModelPriceTier) { + item.PriceConfigured = false + item.BillingMode = "fixed_request" + item.UnitPriceMicrocredits = 0 + item.InputTokenPriceMicrocredits = 0 + item.OutputTokenPriceMicrocredits = 0 + item.CachedTokenPriceMicrocredits = 0 + var summary *model.ChannelModelPriceTier + for index := range tiers { + tier := &tiers[index] + if tier.Enabled && tier.PriceConfigured { + item.PriceConfigured = true + } + if summary == nil || (tier.Resolution == "*" && tier.VideoSeconds == 0) { + summary = tier + } + } + if summary == nil { + return + } + item.BillingMode = summary.BillingMode + item.UnitPriceMicrocredits = summary.UnitPriceMicrocredits + item.InputTokenPriceMicrocredits = summary.InputTokenPriceMicrocredits + item.OutputTokenPriceMicrocredits = summary.OutputTokenPriceMicrocredits + item.CachedTokenPriceMicrocredits = summary.CachedTokenPriceMicrocredits +} + func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, channelID string, req ChannelModelRequest) (*AdminChannelModelTestResult, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err @@ -267,12 +517,12 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, if err != nil { return nil, err } - modelKey, capability, protocol, err := normalizeChannelModelContract(channel, req) + modelKey, providerModelKey, capability, protocol, err := normalizeChannelModelContract(channel, req) if err != nil { return nil, err } if capability == "text" || capability == "image" || capability == "video" { - if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil { + if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig); err != nil { return nil, err } } @@ -302,7 +552,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), req.CapabilityConfig) + profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig) if normalizeErr != nil { return nil, normalizeErr } @@ -321,7 +571,8 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, APIKey: channel.APIKey, SecretKey: channel.SecretKey, Headers: headers, - Model: modelKey, + Model: providerModelKey, + ChannelModelKey: modelKey, Size: map[string]string{"image": imageSize, "video": "16:9"}[capability], Quality: imageQuality, Count: "1", @@ -384,26 +635,30 @@ func imageTestDefaults(profile *ImageCapabilityConfig) (string, string) { return size, quality } -func normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, model.ChannelInterfaceType, error) { +func normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) { modelKey := strings.TrimPrefix(strings.TrimSpace(req.ModelKey), "models/") if modelKey == "" { - return "", "", "", BadAuthRequest("请填写模型标识") + return "", "", "", "", BadAuthRequest("请填写模型标识") + } + providerModelKey := strings.TrimPrefix(strings.TrimSpace(req.ProviderModelKey), "models/") + if providerModelKey == "" { + providerModelKey = modelKey } capability := normalizeCapability(req.Capability) if capability == "" { - return "", "", "", BadAuthRequest("请选择模型能力") + return "", "", "", "", BadAuthRequest("请选择模型能力") } protocol := model.ChannelInterfaceType(strings.TrimSpace(req.Protocol)) if !validChannelInterfaceType(protocol) { - return "", "", "", BadAuthRequest("请选择有效的模型请求协议") + return "", "", "", "", BadAuthRequest("请选择有效的模型请求协议") } if expected := capabilityForProtocol(protocol); expected != "" && expected != capability { - return "", "", "", BadAuthRequest("模型能力与请求协议不匹配") + return "", "", "", "", BadAuthRequest("模型能力与请求协议不匹配") } if (protocol == model.ChannelInterfaceVolcengineJiMengImage || protocol == model.ChannelInterfaceVolcengineJiMengVideo) && (strings.TrimSpace(channel.APIKey) == "" || strings.TrimSpace(channel.SecretKey) == "") { - return "", "", "", BadAuthRequest("即梦官方协议需要先在渠道中配置 Access Key 和 Secret Key") + return "", "", "", "", BadAuthRequest("即梦官方协议需要先在渠道中配置 Access Key 和 Secret Key") } - return modelKey, capability, protocol, nil + return modelKey, providerModelKey, capability, protocol, nil } func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, id string) error { @@ -460,8 +715,12 @@ func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names [] byKey[existing[index].ModelKey] = &existing[index] } desired := make(map[string]bool, len(names)) + retired := retiredChannelModelKeys(channel.RetiredModelsJSON) for _, name := range uniqueNonEmpty(names) { name = strings.TrimPrefix(name, "models/") + if retired[channelModelCatalogKey(name)] { + continue + } desired[name] = true if item := byKey[name]; item != nil { if !item.Enabled { @@ -494,6 +753,22 @@ func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names [] return nil } +func retiredChannelModelKeys(raw string) map[string]bool { + var values []string + _ = json.Unmarshal([]byte(raw), &values) + result := make(map[string]bool, len(values)) + for _, value := range values { + if key := channelModelCatalogKey(value); key != "" { + result[key] = true + } + } + return result +} + +func channelModelCatalogKey(value string) string { + return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(value), "models/")) +} + func (s *Service) ensureChannelModels(channelID string, includeDisabled bool) ([]model.ChannelModel, error) { items, err := s.repo.ChannelModels(channelID, includeDisabled) if err != nil || len(items) > 0 { diff --git a/backend/internal/service/channel_models_test.go b/backend/internal/service/channel_models_test.go index f50eedaf4..729c46fd7 100644 --- a/backend/internal/service/channel_models_test.go +++ b/backend/internal/service/channel_models_test.go @@ -8,20 +8,33 @@ import ( func TestNormalizeChannelModelContract(t *testing.T) { channel := &model.ModelChannel{APIKey: "test-key"} - modelKey, capability, protocol, err := normalizeChannelModelContract(channel, ChannelModelRequest{ + modelKey, providerModelKey, capability, protocol, err := normalizeChannelModelContract(channel, ChannelModelRequest{ ModelKey: "models/gpt-test", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion), }) if err != nil { t.Fatalf("normalizeChannelModelContract() error = %v", err) } - if modelKey != "gpt-test" || capability != "text" || protocol != model.ChannelInterfaceChatCompletion { - t.Fatalf("contract = %q, %q, %q", modelKey, capability, protocol) + if modelKey != "gpt-test" || providerModelKey != "gpt-test" || capability != "text" || protocol != model.ChannelInterfaceChatCompletion { + t.Fatalf("contract = %q, %q, %q, %q", modelKey, providerModelKey, capability, protocol) + } +} + +func TestNormalizeChannelModelContractPreservesProviderModelKey(t *testing.T) { + channel := &model.ModelChannel{APIKey: "test-key"} + modelKey, providerModelKey, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{ + ModelKey: "seedance-2-5-480p", ProviderModelKey: "models/doubao-seedance-2-5", Capability: "video", Protocol: string(model.ChannelInterfaceVolcengineArkVideo), + }) + if err != nil { + t.Fatalf("normalizeChannelModelContract() error = %v", err) + } + if modelKey != "seedance-2-5-480p" || providerModelKey != "doubao-seedance-2-5" { + t.Fatalf("contract = %q, %q", modelKey, providerModelKey) } } func TestNormalizeChannelModelContractRejectsCapabilityMismatch(t *testing.T) { channel := &model.ModelChannel{APIKey: "test-key"} - _, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{ + _, _, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{ ModelKey: "image-test", Capability: "text", Protocol: string(model.ChannelInterfaceOpenAIImage), }) if err == nil { @@ -31,7 +44,7 @@ func TestNormalizeChannelModelContractRejectsCapabilityMismatch(t *testing.T) { func TestNormalizeChannelModelContractRequiresJiMengSecret(t *testing.T) { channel := &model.ModelChannel{APIKey: "access-key"} - _, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{ + _, _, _, _, err := normalizeChannelModelContract(channel, ChannelModelRequest{ ModelKey: "jimeng-test", Capability: "image", Protocol: string(model.ChannelInterfaceVolcengineJiMengImage), }) if err == nil { diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index 6607f8a9d..7aa4cb6ae 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -474,7 +474,7 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i capability = capabilityFromTaskType(task.Type) } if logicalModel.PricePolicy == "channel" { - order, priceErr := s.newBillingOrder(userID, task.ID, "task:"+task.ID+":"+newID(), channelModel.ChannelID, channelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability)) + order, priceErr := s.newBillingOrderWithPriceTier(userID, task.ID, "task:"+task.ID+":"+newID(), channelModel.ChannelID, channelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability), strings.TrimSpace(fmt.Sprint(config["priceTierId"]))) if priceErr != nil { return nil, priceErr } @@ -557,6 +557,10 @@ func (s *Service) ReserveProxyBillingWithBody(userID string, channelID string, m } func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate) (*model.BillingOrder, error) { + return s.newBillingOrderWithPriceTier(userID, taskID, idempotencyKey, channelID, modelKey, capability, scene, requestedQuantity, tokenEstimate, "") +} + +func (s *Service) newBillingOrderWithPriceTier(userID string, taskID string, idempotencyKey string, channelID string, modelKey string, capability string, scene string, requestedQuantity int64, tokenEstimate tokenBillingEstimate, priceTierID string) (*model.BillingOrder, error) { item, err := s.repo.ChannelModelByKey(channelID, modelKey) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, BadAuthRequest("当前模型暂时不可用,请重新选择") @@ -564,12 +568,13 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s if err != nil { return nil, err } - if !item.PriceConfigured { - return nil, BadAuthRequest("当前模型尚未配置用户积分价格") + tier := channelModelPriceTierForBilling(*item, priceTierID, capability) + if tier == nil { + return nil, BadAuthRequest("当前模型尚未配置所选规格的用户积分价格") } quantity := int64(1) amount := int64(0) - switch item.BillingMode { + switch tier.BillingMode { case "fixed_request": case "per_second": if item.Capability != "video" || capability != "video" { @@ -601,25 +606,38 @@ func (s *Service) newBillingOrder(userID string, taskID string, idempotencyKey s if configured := policy.ModelMultiplierBPS[modelKey]; configured > 0 { multiplierBPS = configured } - if item.BillingMode == "token" { - amount, err = tokenEstimateAmount(item, tokenEstimate, multiplierBPS) + if tier.BillingMode == "token" { + amount, err = tokenEstimateAmount(&model.ChannelModel{InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits}, tokenEstimate, multiplierBPS) } else { - amount, err = creditAmount(item.UnitPriceMicrocredits, quantity, multiplierBPS) + amount, err = creditAmount(tier.UnitPriceMicrocredits, quantity, multiplierBPS) } if err != nil { return nil, err } return &model.BillingOrder{ ID: newID(), UserID: userID, IdempotencyKey: idempotencyKey, TaskID: taskID, - ChannelID: channelID, ChannelModelID: item.ID, Model: modelKey, Capability: capability, - Scene: truncateRunes(scene, 80), BillingMode: item.BillingMode, PriceVersion: item.PriceVersion, - UnitPriceMicrocredits: item.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: quantity, AmountMicrocredits: amount, - ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, - OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, + ChannelID: channelID, ChannelModelID: item.ID, PriceTierID: tier.ID, PriceTierVersion: tier.PriceVersion, PriceSelectorJSON: tier.SelectorJSON, Model: modelKey, Capability: capability, + Scene: truncateRunes(scene, 80), BillingMode: tier.BillingMode, PriceVersion: item.PriceVersion, + UnitPriceMicrocredits: tier.UnitPriceMicrocredits, MultiplierBasisPoints: multiplierBPS, Quantity: quantity, AmountMicrocredits: amount, + ReservedAmountMicrocredits: amount, InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, + OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits, Status: model.BillingStatusReserved, }, nil } +func channelModelPriceTierForBilling(channelModel model.ChannelModel, priceTierID string, capability string) *model.ChannelModelPriceTier { + if priceTierID != "" { + for index := range channelModel.PriceTiers { + tier := &channelModel.PriceTiers[index] + if tier.ID == priceTierID && tier.Enabled && tier.PriceConfigured { + return tier + } + } + return nil + } + return channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: capability, Options: map[string]any{}}) +} + func estimateTaskTokens(input map[string]any) tokenBillingEstimate { encoded, _ := json.Marshal(input) return tokenBillingEstimate{InputTokens: estimatedTokens(encoded), OutputTokens: maxOutputTokens(input)} diff --git a/backend/internal/service/logical_model_quote.go b/backend/internal/service/logical_model_quote.go new file mode 100644 index 000000000..0fdf64197 --- /dev/null +++ b/backend/internal/service/logical_model_quote.go @@ -0,0 +1,113 @@ +package service + +import "infinite-canvas/backend/internal/model" + +// LogicalModelQuote 是创作端当前参数命中的实际供应线路报价。 +// Token 视频在上游返回真实 usage 前只能预估,创建任务仍以账务预留逻辑为准。 +type LogicalModelQuote struct { + LogicalModelID string `json:"logicalModelId"` + BillingMode string `json:"billingMode"` + Quantity int64 `json:"quantity"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + Estimated bool `json:"estimated"` +} + +func (s *Service) QuoteLogicalModel(logicalModelID string, intent ModelRequestIntent) (*LogicalModelQuote, error) { + routed, err := s.ResolveLogicalModel(logicalModelID, intent) + if err != nil { + return nil, err + } + capability := normalizeCapability(intent.Capability) + if capability == "" { + return nil, BadAuthRequest("报价请求缺少模型能力类型") + } + resolvedIntent := intent + resolvedIntent.Options = mergeIntentDefaults(intent.Options, routed.Defaults) + input := quoteInput(resolvedIntent, routed.ChannelModel.ModelKey) + quantity := billingQuantity(capability, inputConfigValue(input, "videoSeconds")) + if capability != "video" { + quantity = 1 + } + tokenEstimate := estimateTaskBillingTokens(input, capability) + + if routed.LogicalModel.PricePolicy == "channel" { + priceTierID := "" + if routed.PriceTier != nil { + priceTierID = routed.PriceTier.ID + } + order, billingErr := s.newBillingOrderWithPriceTier("", "", "quote", routed.ChannelModel.ChannelID, routed.ChannelModel.ModelKey, capability, "model_quote", quantity, tokenEstimate, priceTierID) + if billingErr != nil { + return nil, billingErr + } + return &LogicalModelQuote{ + LogicalModelID: routed.LogicalModel.ID, + BillingMode: order.BillingMode, + Quantity: order.Quantity, + AmountMicrocredits: order.AmountMicrocredits, + Estimated: order.BillingMode == "token", + }, nil + } + + if routed.LogicalModel.PricePolicy != "unified" { + return nil, BadAuthRequest("当前模型价格策略无效") + } + amount := int64(0) + switch routed.LogicalModel.BillingMode { + case "fixed_request": + quantity = 1 + amount = routed.LogicalModel.UnitPriceMicrocredits + case "per_second": + if capability != "video" || quantity <= 0 { + return nil, BadAuthRequest("当前模型按时长计费,但请求未提供有效时长") + } + amount, err = creditAmount(routed.LogicalModel.UnitPriceMicrocredits, quantity, 10_000) + case "token": + if routed.ChannelModel.Capability != capability || !supportsTokenBilling(capability, routed.ChannelModel.Protocol) { + return nil, BadAuthRequest("当前供应线路不支持前台模型的 Token 计费方式") + } + pricing := &model.ChannelModel{ + InputTokenPriceMicrocredits: routed.LogicalModel.InputPriceMicrocredits, + OutputTokenPriceMicrocredits: routed.LogicalModel.OutputPriceMicrocredits, + CachedTokenPriceMicrocredits: routed.LogicalModel.CachedPriceMicrocredits, + } + amount, err = tokenEstimateAmount(pricing, tokenEstimate, 10_000) + quantity = tokenEstimate.InputTokens + tokenEstimate.OutputTokens + default: + return nil, BadAuthRequest("当前模型计费方式暂不支持") + } + if err != nil { + return nil, err + } + if amount <= 0 { + return nil, BadAuthRequest("当前模型尚未配置有效的用户价格") + } + return &LogicalModelQuote{ + LogicalModelID: routed.LogicalModel.ID, + BillingMode: routed.LogicalModel.BillingMode, + Quantity: quantity, + AmountMicrocredits: amount, + Estimated: routed.LogicalModel.BillingMode == "token", + }, nil +} + +func quoteInput(intent ModelRequestIntent, modelKey string) map[string]any { + config := make(map[string]any, len(intent.Options)+1) + for key, value := range intent.Options { + config[key] = value + } + config["model"] = modelKey + input := map[string]any{"mode": intent.Capability, "config": config} + if count := intent.Inputs["video"]; count > 0 { + references := make([]any, count) + input["referenceVideos"] = references + } + return input +} + +func inputConfigValue(input map[string]any, key string) any { + config, _ := input["config"].(map[string]any) + if config == nil { + return nil + } + return config[key] +} diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index 6fd85ffd4..d96b9e5ac 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -3,6 +3,7 @@ package service import ( "encoding/json" "errors" + "fmt" "math" "regexp" "sort" @@ -18,22 +19,27 @@ import ( var logicalModelCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,79}$`) type LogicalModelRequest struct { - Code string `json:"code"` - Name string `json:"name"` - Icon string `json:"icon"` - Description string `json:"description"` - Capability string `json:"capability"` - Enabled bool `json:"enabled"` - SortOrder int `json:"sortOrder"` - PricePolicy string `json:"pricePolicy"` - BillingMode string `json:"billingMode"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"` - OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"` - CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"` - CapabilitySpec CapabilitySpec `json:"capabilitySpec"` - DefaultOptions map[string]any `json:"defaultOptions"` - Routes []LogicalRouteRequest `json:"routes"` + Code string `json:"code"` + Name string `json:"name"` + Icon string `json:"icon"` + Description string `json:"description"` + Capability string `json:"capability"` + Enabled bool `json:"enabled"` + SortOrder int `json:"sortOrder"` + PricePolicy string `json:"pricePolicy"` + BillingMode string `json:"billingMode"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"` + OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"` + CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"` + // LegacyModelIDs 只用于将用户本地保存的旧目录选择迁移到当前模型家族, + // 不能用它重写任务、账单或路由尝试中的不可变快照。 + LegacyModelIDs []string `json:"legacyModelIds"` + CapabilitySpec CapabilitySpec `json:"capabilitySpec"` + DefaultOptions map[string]any `json:"defaultOptions"` + Routes []LogicalRouteRequest `json:"routes"` + // SourceChannelModelID 仅供系统渠道同步流程使用,前台模型不再拥有独立的能力和价格真相。 + SourceChannelModelID string `json:"-"` } type LogicalRouteRequest struct { @@ -44,26 +50,41 @@ type LogicalRouteRequest struct { } type PublicLogicalModel struct { - ID string `json:"id"` - Code string `json:"code"` - Name string `json:"name"` - Icon string `json:"icon"` - Description string `json:"description"` - Capability string `json:"capability"` - SortOrder int `json:"sortOrder"` - PricePolicy string `json:"pricePolicy"` - BillingMode string `json:"billingMode"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"` - OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"` - CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"` - CapabilitySpec CapabilitySpec `json:"capabilitySpec"` + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Icon string `json:"icon"` + Description string `json:"description"` + Capability string `json:"capability"` + SortOrder int `json:"sortOrder"` + PricePolicy string `json:"pricePolicy"` + BillingMode string `json:"billingMode"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputPriceMicrocredits int64 `json:"inputPriceMicrocredits"` + OutputPriceMicrocredits int64 `json:"outputPriceMicrocredits"` + CachedPriceMicrocredits int64 `json:"cachedPriceMicrocredits"` + PriceTiers []PublicLogicalModelPriceTier `json:"priceTiers"` + LegacyModelIDs []string `json:"legacyModelIds"` + CapabilitySpec CapabilitySpec `json:"capabilitySpec"` // CapabilityProfiles 是创作端可见的匿名能力组合,不暴露其背后的供应线路关系。 CapabilityProfiles []CapabilitySpec `json:"capabilityProfiles"` DefaultOptions map[string]any `json:"defaultOptions"` Available bool `json:"available"` } +// PublicLogicalModelPriceTier 是创作端用于约束规格选择和展示当前报价的安全投影, +// 不暴露供应渠道、上游模型 ID 或内部路由信息。 +type PublicLogicalModelPriceTier struct { + Selector map[string]string `json:"selector"` + Resolution string `json:"resolution"` + VideoSeconds int `json:"videoSeconds"` + BillingMode string `json:"billingMode"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` +} + type AdminLogicalRoute struct { ID string `json:"id"` ChannelModelID string `json:"channelModelId"` @@ -128,7 +149,7 @@ func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogic available = false if coverageValid { for _, route := range cached.Routes { - if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.CapabilitySpec, resolvedIntent).Matched { + if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.CapabilitySpec, resolvedIntent).Matched && (cached.Model.PricePolicy != "channel" || channelModelPriceTierForIntent(route.ChannelModel, resolvedIntent) != nil) { available = true break } @@ -159,7 +180,59 @@ func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogical profiles = append(profiles, route.CapabilitySpec) } } - return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, CapabilitySpec: productSpec, CapabilityProfiles: profiles, DefaultOptions: cached.Defaults, Available: available} + return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, PriceTiers: publicLogicalModelPriceTiers(cached), LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON), CapabilitySpec: productSpec, CapabilityProfiles: profiles, DefaultOptions: cached.Defaults, Available: available} +} + +func publicLogicalModelPriceTiers(cached cachedLogicalModel) []PublicLogicalModelPriceTier { + if cached.Model.PricePolicy != "channel" { + return []PublicLogicalModelPriceTier{} + } + result := make([]PublicLogicalModelPriceTier, 0) + seen := make(map[string]bool) + for _, route := range cached.Routes { + if !route.Route.Enabled || route.Route.Weight <= 0 { + continue + } + for _, tier := range route.ChannelModel.PriceTiers { + if !tier.Enabled || !tier.PriceConfigured { + continue + } + selector := skuSelectorForTier(tier) + _, selectorKey, selectorErr := model.CanonicalSKUSelector(selector) + if selectorErr != nil { + continue + } + key := fmt.Sprintf("%s:%s:%d:%d:%d:%d", selectorKey, tier.BillingMode, tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits) + if seen[key] { + continue + } + seen[key] = true + result = append(result, PublicLogicalModelPriceTier{Selector: selector, Resolution: normalizeChannelModelTierResolution(tier.Resolution), VideoSeconds: tier.VideoSeconds, BillingMode: tier.BillingMode, UnitPriceMicrocredits: tier.UnitPriceMicrocredits, InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits}) + } + } + return result +} + +func decodeLegacyModelIDs(raw string) []string { + var values []string + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return []string{} + } + return normalizeLegacyModelIDs(values) +} + +func normalizeLegacyModelIDs(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, raw := range values { + value := strings.TrimSpace(raw) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result } // capabilitySpecWithRoutePresets repairs old front-model snapshots that stored @@ -423,6 +496,9 @@ func (s *Service) DeleteAdminLogicalModel(actor *model.User, id string) error { if item.ArchivedAt != nil { return BadAuthRequest("前台模型不存在或已删除") } + if item.SourceChannelModelID != "" { + return BadAuthRequest("该前台模型由系统渠道自动同步,请在系统渠道模型中停用") + } audit, err := newAdminAuditEvent(actor, "logical_model.archive", "logical_model", item.ID, "归档前台模型", map[string]any{"code": item.Code, "name": item.Name}) if err != nil { return err @@ -450,6 +526,37 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo if name == "" || len([]rune(name)) > 120 { return nil, nil, nil, false, BadAuthRequest("请填写 1-120 个字符的模型名称") } + sourceChannelModelID := strings.TrimSpace(req.SourceChannelModelID) + if sourceChannelModelID != "" { + source, sourceErr := s.repo.ChannelModel(sourceChannelModelID) + if sourceErr != nil { + return nil, nil, nil, false, BadAuthRequest("系统渠道模型不存在") + } + if _, channelErr := s.repo.AdminSystemChannel(source.ChannelID); channelErr != nil { + return nil, nil, nil, false, BadAuthRequest("前台模型只能同步系统渠道模型") + } + capability = normalizeCapability(source.Capability) + derivedSpec, specErr := channelModelCapabilitySpec(*source) + if specErr != nil { + return nil, nil, nil, false, specErr + } + derivedDefaults, defaultsErr := channelModelDefaultOptions(*source, derivedSpec) + if defaultsErr != nil { + return nil, nil, nil, false, defaultsErr + } + if len(req.Routes) == 0 { + req.CapabilitySpec = derivedSpec + req.DefaultOptions = derivedDefaults + req.Routes = []LogicalRouteRequest{{ChannelModelID: source.ID, Enabled: true, Priority: 100, Weight: 100}} + } + req.PricePolicy = "channel" + req.BillingMode = "fixed_request" + req.UnitPriceMicrocredits, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits = 0, 0, 0, 0 + // 未完成定价的系统模型仅在后台目录保留同步记录,不能暴露到创作端。 + if strings.TrimSpace(id) == "" { + req.Enabled = source.Enabled && channelModelHasActivePriceTier(*source) + } + } normalizedSpec, err := NormalizeCapabilitySpec(req.CapabilitySpec) if err != nil { return nil, nil, nil, false, err @@ -498,8 +605,18 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo } } item.Code, item.Name, item.Icon, item.Description, item.Capability = code, name, strings.TrimSpace(req.Icon), strings.TrimSpace(req.Description), capability + if sourceChannelModelID != "" { + item.SourceChannelModelID = sourceChannelModelID + } item.Enabled, item.SortOrder, item.PricePolicy, item.BillingMode = req.Enabled, req.SortOrder, pricePolicy, billingMode item.UnitPriceMicrocredits, item.InputPriceMicrocredits, item.OutputPriceMicrocredits, item.CachedPriceMicrocredits = req.UnitPriceMicrocredits, req.InputPriceMicrocredits, req.OutputPriceMicrocredits, req.CachedPriceMicrocredits + if req.LegacyModelIDs != nil { + legacyJSON, marshalErr := json.Marshal(normalizeLegacyModelIDs(req.LegacyModelIDs)) + if marshalErr != nil { + return nil, nil, nil, false, marshalErr + } + item.LegacyModelIDsJSON = string(legacyJSON) + } item.UpdatedAt = time.Now() revisionID, err := s.repo.NextPrefixedID("REVISION") if err != nil { @@ -629,7 +746,109 @@ func channelModelCapabilitySpec(channelModel model.ChannelModel) (CapabilitySpec if err != nil { return CapabilitySpec{}, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型") } - return CapabilitySpecFromModelCapabilityConfig(config, normalizeCapability(channelModel.Capability)) + spec, err := CapabilitySpecFromModelCapabilityConfig(config, normalizeCapability(channelModel.Capability)) + if err != nil { + return CapabilitySpec{}, err + } + return capabilitySpecWithPriceTiers(spec, channelModel), nil +} + +// capabilitySpecWithPriceTiers 只让创作端选择已经启用且可结算的规格。规格组合最终仍由 +// 路由时的精确价格档匹配保证,避免独立枚举无法表达“分辨率 × 时长”非笛卡尔组合的问题。 +func capabilitySpecWithPriceTiers(spec CapabilitySpec, channelModel model.ChannelModel) CapabilitySpec { + if normalizeCapability(spec.Capability) != "video" || len(channelModel.PriceTiers) == 0 { + return spec + } + tiers := make([]model.ChannelModelPriceTier, 0, len(channelModel.PriceTiers)) + for _, tier := range channelModel.PriceTiers { + if tier.Enabled && tier.PriceConfigured { + tiers = append(tiers, tier) + } + } + if len(tiers) == 0 { + return spec + } + result := spec + result.Options = make(map[string]OptionConstraint, len(spec.Options)) + for name, option := range spec.Options { + result.Options[name] = option + } + hasResolutionWildcard, hasDurationWildcard := false, false + resolutions := make([]any, 0, len(tiers)) + durations := make([]any, 0, len(tiers)) + seenResolutions := make(map[string]bool, len(tiers)) + seenDurations := make(map[int]bool, len(tiers)) + for _, tier := range tiers { + if normalizeChannelModelTierResolution(tier.Resolution) == "*" { + hasResolutionWildcard = true + } else if value := normalizeChannelModelTierResolution(tier.Resolution); !seenResolutions[value] { + seenResolutions[value] = true + resolutions = append(resolutions, value) + } + if tier.VideoSeconds == 0 { + hasDurationWildcard = true + } else if !seenDurations[tier.VideoSeconds] { + seenDurations[tier.VideoSeconds] = true + durations = append(durations, tier.VideoSeconds) + } + } + if !hasResolutionWildcard && len(resolutions) > 0 { + result.Options["vquality"] = OptionConstraint{Values: resolutions} + } + if !hasDurationWildcard && len(durations) > 0 { + result.Options["videoSeconds"] = OptionConstraint{Values: durations} + } + return result +} + +func channelModelDefaultOptions(channelModel model.ChannelModel, spec CapabilitySpec) (map[string]any, error) { + config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON) + if err != nil { + return nil, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型") + } + defaults := make(map[string]any) + if config != nil { + switch normalizeCapability(channelModel.Capability) { + case "image": + if config.Image != nil { + defaults["size"] = config.Image.Size.Default + if config.Image.Quality.Supported { + defaults["quality"] = config.Image.Quality.Default + } + if config.Image.TransparentBackground.Supported { + defaults["transparentBackground"] = config.Image.TransparentBackground.Default + } + } + case "video": + if config.Video != nil { + defaults["videoSeconds"] = config.Video.Duration.Default + defaults["vquality"] = normalizeChannelModelTierResolution(config.Video.DefaultResolution) + defaults["size"] = config.Video.DefaultRatio + if config.Video.GenerateAudio.Supported { + defaults["videoGenerateAudio"] = config.Video.GenerateAudio.Default + } + if config.Video.Watermark.Supported { + defaults["videoWatermark"] = config.Video.Watermark.Default + } + } + } + } + // 当渠道默认规格没有价格档时,优先选择第一个可结算档,确保初始选择可直接生成。 + if normalizeCapability(channelModel.Capability) == "video" && channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: "video", Options: defaults}) == nil { + for _, tier := range channelModel.PriceTiers { + if !tier.Enabled || !tier.PriceConfigured { + continue + } + if tier.Resolution != "*" { + defaults["vquality"] = normalizeChannelModelTierResolution(tier.Resolution) + } + if tier.VideoSeconds > 0 { + defaults["videoSeconds"] = tier.VideoSeconds + } + break + } + } + return normalizeLogicalDefaults(spec, defaults) } func (s *Service) SimulateLogicalModelRoute(actor *model.User, id string, intent ModelRequestIntent) (*RouteSimulationResult, error) { diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 8a052a933..57405b109 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -554,7 +554,7 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { } return validateVideoTask(taskInput.Config.CapabilityConfig.Video, taskInput) } - item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(taskInput.Config.Model), "models/")) + item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(taskInput.Config)) if err != nil { return BadAuthRequest("当前系统渠道模型未配置或已停用") } @@ -563,18 +563,32 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error { if err != nil { return BadAuthRequest("当前图片模型能力参数无效") } - imageProfile := DefaultImageCapabilityConfig(string(item.Protocol), item.ModelKey) + imageProfile := DefaultImageCapabilityConfig(string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey)) if profile != nil && profile.Image != nil { imageProfile = profile.Image } - return validateImageTask(imageProfile, taskInput) + return validateImageTask(applyModelSpecificImageCapability(imageProfile, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), taskInput.Config.APIFormat), taskInput) } if err != nil || profile == nil || profile.Video == nil { return BadAuthRequest("当前视频模型尚未配置能力参数") } + applyFixedVideoResolution(&taskInput, profile.Video) + if config, ok := input["config"].(map[string]any); ok { + config["vquality"] = taskInput.Config.VQuality + } return validateVideoTask(profile.Video, taskInput) } +// applyFixedVideoResolution 让单档位 SKU 的预扣、恢复和上游请求保持同一分辨率。 +func applyFixedVideoResolution(input *canvasGenerationInput, profile *VideoCapabilityConfig) { + if input == nil || profile == nil || len(profile.Resolutions) != 1 { + return + } + if resolution := videoResolutionNameRequest(profile, profile.Resolutions[0]); resolution != "" { + input.Config.VQuality = resolution + } +} + func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInput) error { if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios { return BadAuthRequest("参考素材数量超过当前模型限制") diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index e0e3db43f..2494b42e3 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -64,7 +64,8 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper if options, ok := input["capabilityOptions"].(map[string]any); ok { explicitOptions = true for key, value := range options { - intent.Options[canonicalCapabilityOptionName(key)] = value + name := canonicalCapabilityOptionName(key) + intent.Options[name] = normalizeModelRequestOption(name, value) } } if config, ok := input["config"].(map[string]any); ok && !explicitOptions { @@ -75,7 +76,7 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper default: canonical := canonicalCapabilityOptionName(key) if isCapabilityOptionFor(capability, canonical) && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { - intent.Options[canonical] = value + intent.Options[canonical] = normalizeModelRequestOption(canonical, value) } } } @@ -83,6 +84,30 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper return intent } +func normalizeModelRequestOption(name string, value any) any { + if canonicalCapabilityOptionName(name) != "vquality" { + return value + } + resolution, ok := value.(string) + if !ok { + return value + } + switch strings.ToLower(strings.TrimSpace(resolution)) { + case "low", "480", "480p": + return "480p" + case "720", "720p": + return "720p" + case "1080", "1080p": + return "1080p" + case "2k", "1440", "1440p": + return "1440p" + case "4k", "2160", "2160p": + return "2160p" + default: + return value + } +} + type CapabilityMatch struct { Matched bool `json:"matched"` Reasons []string `json:"reasons,omitempty"` @@ -114,6 +139,7 @@ type RoutedModel struct { Revision model.LogicalModelRevision Route model.LogicalModelRoute ChannelModel model.ChannelModel + PriceTier *model.ChannelModelPriceTier Defaults map[string]any } @@ -540,7 +566,7 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) { if item.PricePolicy == "unified" && item.BillingMode == "token" && !supportsTokenBilling(item.Capability, channelModel.Protocol) { continue } - if item.PricePolicy == "channel" && !channelModel.PriceConfigured { + if item.PricePolicy == "channel" && !channelModelHasActivePriceTier(channelModel) { continue } capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) @@ -581,15 +607,22 @@ func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequest if match := MatchCapability(cached.ProductSpec, intent); !match.Matched { return nil, BadAuthRequest("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";")) } - eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil) + eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil, cached.Model.PricePolicy == "channel") if len(eligible) == 0 { return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") } selected := weightedRoute(eligible) - return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, Defaults: cached.Defaults}, nil + var priceTier *model.ChannelModelPriceTier + if cached.Model.PricePolicy == "channel" { + priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) + if priceTier == nil { + return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") + } + } + return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: cached.Defaults}, nil } -func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool) []cachedLogicalRoute { +func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool, requirePriceTier bool) []cachedLogicalRoute { eligible := make([]cachedLogicalRoute, 0, len(routes)) maxPriority := math.MinInt for _, route := range routes { @@ -599,6 +632,9 @@ func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent Mode if match := MatchCapability(route.CapabilitySpec, intent); !match.Matched { continue } + if requirePriceTier && channelModelPriceTierForIntent(route.ChannelModel, intent) == nil { + continue + } if route.Route.Priority > maxPriority { eligible = eligible[:0] maxPriority = route.Route.Priority @@ -610,6 +646,88 @@ func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent Mode return eligible } +func channelModelHasActivePriceTier(channelModel model.ChannelModel) bool { + for _, tier := range channelModel.PriceTiers { + if tier.Enabled && tier.PriceConfigured { + return true + } + } + return false +} + +// channelModelPriceTierForIntent 使用“精确规格优先、通配规格兜底”的规则。SKU 选择器与 +// 运行意图使用同一组规范键,因而图片质量/画幅、视频分辨率/时长和生成操作都能独立定价。 +func channelModelPriceTierForIntent(channelModel model.ChannelModel, intent ModelRequestIntent) *model.ChannelModelPriceTier { + selector := skuSelectorForIntent(intent) + bestScore := -1 + var best *model.ChannelModelPriceTier + for index := range channelModel.PriceTiers { + tier := &channelModel.PriceTiers[index] + if !tier.Enabled || !tier.PriceConfigured { + continue + } + matched, score := matchSKUSelector(skuSelectorForTier(*tier), selector) + if !matched { + continue + } + if score > bestScore { + best, bestScore = tier, score + } + } + return best +} + +func skuSelectorForIntent(intent ModelRequestIntent) map[string]string { + selector := map[string]string{} + if operation := strings.ToLower(strings.TrimSpace(intent.Operation)); operation != "" { + selector["operation"] = operation + } + switch normalizeCapability(intent.Capability) { + case "video": + if value := normalizeChannelModelTierResolution(fmt.Sprint(intent.Options["vquality"])); value != "*" { + selector["vquality"] = value + } + if seconds, err := strconv.Atoi(strings.TrimSpace(fmt.Sprint(intent.Options["videoSeconds"]))); err == nil && seconds > 0 { + selector["videoSeconds"] = strconv.Itoa(seconds) + } + case "image": + for _, key := range []string{"quality", "size"} { + if value := strings.ToLower(strings.TrimSpace(fmt.Sprint(intent.Options[key]))); value != "" && value != "auto" && value != "any" { + selector[key] = value + } + } + } + return selector +} + +func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string { + selector := model.DecodeSKUSelector(tier.SelectorJSON) + if len(selector) == 0 { + if resolution := normalizeChannelModelTierResolution(tier.Resolution); resolution != "*" { + selector["vquality"] = resolution + } + if tier.VideoSeconds > 0 { + selector["videoSeconds"] = strconv.Itoa(tier.VideoSeconds) + } + } + return selector +} + +func matchSKUSelector(tier map[string]string, requested map[string]string) (bool, int) { + score := 0 + for key, expected := range tier { + expected = strings.TrimSpace(expected) + if expected == "" || expected == "*" { + continue + } + if requested[key] != expected { + return false, 0 + } + score++ + } + return true, score +} + func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool { now := time.Now() keys := []string{"channel:" + route.ChannelModel.ChannelID, "channel-model:" + route.ChannelModel.ID, "route:" + route.Route.ID} @@ -906,7 +1024,7 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route } channelModelByID := make(map[string]model.ChannelModel, len(channelModels)) for _, channelModel := range channelModels { - if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModel.PriceConfigured) { + if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModelHasActivePriceTier(channelModel)) { channelModelByID[channelModel.ID] = channelModel } } @@ -926,12 +1044,19 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route for _, attempt := range attempts { tried[attempt.RouteID] = true } - eligible := s.eligibleLogicalRoutes(candidates, intent, tried) + eligible := s.eligibleLogicalRoutes(candidates, intent, tried, logicalModel.PricePolicy == "channel") if len(eligible) == 0 { return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") } selected := weightedRoute(eligible) - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, Defaults: defaults} + var priceTier *model.ChannelModelPriceTier + if logicalModel.PricePolicy == "channel" { + priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) + if priceTier == nil { + return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") + } + } + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: defaults} nextInput := applyRoutedProviderSelection(input, routed) if err := s.ValidateTaskCapability(nextInput); err != nil { return nil, err @@ -945,12 +1070,12 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route } var replacement *model.BillingOrder if logicalModel.PricePolicy == "channel" && task.BillingOrderID != "" { - config, _ := input["config"].(map[string]any) - capability := normalizeCapability(fmt.Sprint(input["mode"])) + config, _ := nextInput["config"].(map[string]any) + capability := normalizeCapability(fmt.Sprint(nextInput["mode"])) if capability == "" { capability = capabilityFromTaskType(task.Type) } - replacement, err = s.newBillingOrder(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(input, capability)) + replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(fmt.Sprint(config["priceTierId"]))) if err != nil { return nil, err } diff --git a/backend/internal/service/model_router_test.go b/backend/internal/service/model_router_test.go new file mode 100644 index 000000000..66ff643ed --- /dev/null +++ b/backend/internal/service/model_router_test.go @@ -0,0 +1,14 @@ +package service + +import "testing" + +func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) { + input := map[string]any{ + "mode": "video", + "config": map[string]any{"vquality": "480", "videoSeconds": "6", "size": "16:9"}, + } + intent := ModelRequestIntentFromTaskInput(input, "video_generate", "text_to_video") + if got := intent.Options["vquality"]; got != "480p" { + t.Fatalf("vquality = %#v, want 480p", got) + } +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index b8adc5d30..e291022bc 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -54,6 +54,9 @@ type providerTextMessage struct { type providerConfig struct { ChannelID string `json:"channelId"` + ChannelModelKey string `json:"channelModelKey,omitempty"` + PriceTierID string `json:"priceTierId,omitempty"` + ProviderModelKey string `json:"providerModelKey,omitempty"` APIFormat string `json:"apiFormat"` InterfaceType string `json:"interfaceType"` BaseURL string `json:"baseUrl"` @@ -167,7 +170,7 @@ func withProviderAnalytics(ctx context.Context, service *Service, task model.Tas } if json.Unmarshal([]byte(task.InputJSON), &input) == nil { metadata.ChannelID = firstNonEmpty(input.Config.ChannelID, systemChannelIDFromBaseURL(input.Config.BaseURL)) - metadata.Model = firstNonEmpty(input.Config.Model, metadata.Model) + metadata.Model = firstNonEmpty(input.Config.ChannelModelKey, input.Config.Model, metadata.Model) metadata.VideoSeconds, _ = strconv.Atoi(input.Config.VideoSeconds) if normalized := normalizeCapability(input.Mode); normalized != "" { metadata.Capability = normalized @@ -660,7 +663,7 @@ func (s *Service) validateResolvedVideoCapability(input *canvasGenerationInput) input.VideoCapability = input.Config.CapabilityConfig.Video return validateVideoTask(input.VideoCapability, *input) } - item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(input.Config.Model), "models/")) + item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(input.Config)) if err != nil { return errors.New("当前系统渠道模型未配置或已停用") } @@ -669,6 +672,7 @@ func (s *Service) validateResolvedVideoCapability(input *canvasGenerationInput) return errors.New("当前视频模型尚未配置能力参数") } input.VideoCapability = profile.Video + applyFixedVideoResolution(input, profile.Video) return validateVideoTask(profile.Video, *input) } @@ -683,7 +687,7 @@ func (s *Service) validateResolvedImageCapability(input *canvasGenerationInput) } return validateImageTask(input.ImageCapability, *input) } - item, err := s.repo.ChannelModelByKey(channelID, strings.TrimPrefix(strings.TrimSpace(input.Config.Model), "models/")) + item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(input.Config)) if err != nil { return errors.New("当前系统渠道模型未配置或已停用") } @@ -815,15 +819,19 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig, if err != nil { return providerConfig{}, errors.New("系统渠道不存在或已停用") } - modelName := strings.TrimSpace(config.Model) - if modelName == "" { + modelKey := strings.TrimPrefix(strings.TrimSpace(config.ChannelModelKey), "models/") + requestedModel := strings.TrimPrefix(strings.TrimSpace(config.Model), "models/") + if modelKey == "" { + modelKey = requestedModel + } + if modelKey == "" { models := channelModelNames(*channel) if len(models) == 0 { return providerConfig{}, errors.New("系统渠道未配置可用模型") } - modelName = models[0] + modelKey = models[0] } - if !stringInSlice(modelName, channelModelNames(*channel)) { + if !stringInSlice(modelKey, channelModelNames(*channel)) { return providerConfig{}, errors.New("当前系统渠道未授权该模型") } if _, err := s.validateChannelOutboundURL(channel.BaseURL, channel.AllowLocalChannel, false); err != nil { @@ -831,10 +839,31 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig, } config.ChannelID = channel.ID config.APIFormat = channel.APIFormat - channelModel, modelErr := s.repo.ChannelModelByKey(channel.ID, modelName) - if modelErr != nil || channelModel.Protocol == "" { + channelModel, modelErr := s.repo.ChannelModelByKey(channel.ID, modelKey) + if modelErr != nil { + // ModelsJSON 只是旧渠道表上的目录缓存。SKU 合并后它不能代表可执行模型, + // 唯一授权来源必须是已启用的 channel_models 记录。 + return providerConfig{}, errors.New("当前系统渠道未授权该模型") + } + if channelModel.Protocol == "" { return providerConfig{}, errors.New("当前模型尚未配置请求协议") } + providerModelKey := strings.TrimPrefix(strings.TrimSpace(config.ProviderModelKey), "models/") + if config.PriceTierID != "" { + matched := false + for _, tier := range channelModel.PriceTiers { + if tier.ID == config.PriceTierID && tier.Enabled && tier.PriceConfigured { + providerModelKey = firstNonEmpty(providerModelKey, tier.ProviderModelKey) + matched = true + break + } + } + if !matched { + return providerConfig{}, errors.New("当前模型规格价格档已更新,请重新创建任务") + } + } else if modelKey != "" && requestedModel != "" && modelKey != requestedModel { + return providerConfig{}, errors.New("系统渠道模型标识不一致") + } config.InterfaceType = string(channelModel.Protocol) // 模型协议是实际请求契约;混合渠道中鉴权格式也必须随模型协议切换。 if config.InterfaceType == string(model.ChannelInterfaceGeminiVeo) || config.InterfaceType == string(model.ChannelInterfaceGeminiImage) { @@ -850,18 +879,14 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig, if err != nil { return providerConfig{}, err } - config.Model = modelName + config.ChannelModelKey = modelKey + config.ProviderModelKey = providerModelKey + config.Model = firstNonEmpty(providerModelKey, channelModel.ProviderModelKey, modelKey) return config, nil } -func stringInSlice(value string, values []string) bool { - value = strings.TrimPrefix(strings.TrimSpace(value), "models/") - for _, candidate := range values { - if strings.TrimPrefix(strings.TrimSpace(candidate), "models/") == value { - return true - } - } - return false +func providerChannelModelKey(config providerConfig) string { + return strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(config.ChannelModelKey, config.Model)), "models/") } func systemChannelIDFromBaseURL(baseURL string) string { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 0100a3623..69dcf7250 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -187,8 +187,8 @@ func TestRunAgentToolTaskFallsBackToolChoice(t *testing.T) { })) defer server.Close() - config := providerConfig{BaseURL: server.URL, APIKey: "key", Model: "thinking-model", AllowLocalChannel: true} - result, err := runAgentToolTask(withProviderOutboundPolicy(context.Background(), config), canvasGenerationInput{ + config := providerConfig{BaseURL: server.URL, APIKey: "key", Model: "thinking-model"} + result, err := runAgentToolTask(context.Background(), canvasGenerationInput{ Config: config, AgentRequests: &agentToolRequests{ChatCompletion: map[string]interface{}{"messages": []interface{}{}, "tool_choice": "required"}}, }) diff --git a/backend/internal/service/task_creation.go b/backend/internal/service/task_creation.go index a61af5061..447fae39a 100644 --- a/backend/internal/service/task_creation.go +++ b/backend/internal/service/task_creation.go @@ -116,7 +116,7 @@ func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map nextConfig := make(map[string]any, len(config)+2) for key, value := range config { switch key { - case "channelId", "apiFormat", "interfaceType", "baseUrl", "allowLocalChannel", "apiKey", "secretKey", "headers", "model", "capabilityConfig": + case "channelId", "channelModelKey", "priceTierId", "providerModelKey", "apiFormat", "interfaceType", "baseUrl", "allowLocalChannel", "apiKey", "secretKey", "headers", "model", "capabilityConfig": continue default: nextConfig[key] = value @@ -139,6 +139,11 @@ func applyRoutedProviderSelection(input map[string]any, routed *RoutedModel) map } nextConfig["channelId"] = routed.ChannelModel.ChannelID nextConfig["model"] = routed.ChannelModel.ModelKey + nextConfig["channelModelKey"] = routed.ChannelModel.ModelKey + if routed.PriceTier != nil { + nextConfig["priceTierId"] = routed.PriceTier.ID + nextConfig["providerModelKey"] = routed.PriceTier.ProviderModelKey + } input["config"] = nextConfig return input } diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index 58aee479d..d48021bd6 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -3,9 +3,9 @@ import { ConfigProvider, Switch } from "antd"; import { type CanvasTheme } from "@/lib/canvas-theme"; import { buildImageResolutionOptions, formatImageResolutionSize, imageRatioForSize, imageResolutionChoices, imageResolutionOption, imageSizeForResolution, supportsImageResolutionPresets, type ImageResolutionChoice } from "@/lib/image-resolution-tiers"; -import { normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities"; +import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities"; import { mergedImageCapabilityConfig } from "@/lib/model-selection"; -import { type AiConfig } from "@/stores/use-config-store"; +import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store"; const qualityOptions = [ { value: "auto", label: "自动" }, @@ -74,7 +74,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = : imageAspectOptions(profile); const selectedAspect = availableAspects.find((item) => imageOptionValue(profile, item) === activeSize || item.value === activeSize) || availableAspects.find((item) => item.label === activeRatio); const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]); - const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value }); + const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value }); + const priceTiers = imageModelPriceTiers(config); const selectAspect = (value: string) => { const option = availableAspects.find((item) => item.value === value); onConfigChange("size", option ? imageOptionValue(profile, option) : "auto"); @@ -110,8 +111,8 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle = {profile.quality.supported ?