feat(models): 统一模型规格价格与路由

This commit is contained in:
kinoko committed 2026-08-22 17:00:08 +08:00
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
}