Files
ttstd-infinite-canvas/backend/cmd/migrate-logical-model-families/main.go

469 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}