feat(models): 统一模型规格价格与路由
This commit is contained in:
1 parent
d268a938e5
commit
236b850177
36 files changed
+3404
-421
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user