676 lines
25 KiB
Go
676 lines
25 KiB
Go
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
|
||
}
|