469 lines
17 KiB
Go
469 lines
17 KiB
Go
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)
|
||
}
|
||
}
|