diff --git a/backend/internal/database/schema.go b/backend/internal/database/schema.go index 36324ee4d..2c30584f0 100644 --- a/backend/internal/database/schema.go +++ b/backend/internal/database/schema.go @@ -23,7 +23,6 @@ func Models() []any { &model.IDSequence{}, &model.LogicalModel{}, &model.LogicalModelRevision{}, - &model.PhysicalCapabilityVariant{}, &model.LogicalModelRoute{}, &model.RouteAttempt{}, &model.ApiCallLog{}, @@ -84,9 +83,15 @@ func MigrateSchema(db *gorm.DB) error { if err := widenPostgresAssetIDColumns(db); err != nil { return err } + if err := migrateLogicalRoutesToChannelModels(db); err != nil { + return err + } if err := db.AutoMigrate(Models()...); err != nil { return err } + if err := dropLegacyPhysicalVariants(db); err != nil { + return err + } // 为升级前已存在的逻辑模型回填版本序列,避免首次保存时从 0 重新分配。 if err := db.Exec(`UPDATE logical_models SET revision_sequence = COALESCE((SELECT MAX(version) FROM logical_model_revisions WHERE logical_model_id = logical_models.id), 0) WHERE revision_sequence = 0`).Error; err != nil { return err @@ -104,6 +109,197 @@ func MigrateSchema(db *gorm.DB) error { return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_nonempty ON users(lower(email)) WHERE email <> ''").Error } +// migrateLogicalRoutesToChannelModels 在模型结构切换前把历史 variant 外键转换为渠道模型外键。 +// 写路径不能接受无法映射的历史引用,否则任务恢复和计费会失去明确执行目标。 +func migrateLogicalRoutesToChannelModels(db *gorm.DB) error { + legacyTargets := []any{ + &model.LogicalModelRoute{}, + &model.Task{}, + &model.RouteAttempt{}, + } + legacyReferenceFound := false + for _, target := range legacyTargets { + if db.Migrator().HasTable(target) && db.Migrator().HasColumn(target, "physical_variant_id") { + legacyReferenceFound = true + break + } + } + if !db.Migrator().HasTable("physical_capability_variants") { + if legacyReferenceFound { + return fmt.Errorf("发现历史可用配置引用,但 physical_capability_variants 表不存在,拒绝继续迁移") + } + return nil + } + for _, target := range []struct { + table any + field string + }{ + {table: &model.LogicalModelRoute{}, field: "ChannelModelID"}, + {table: &model.RouteAttempt{}, field: "ChannelModelID"}, + {table: &model.Task{}, field: "ChannelModelID"}, + } { + if db.Migrator().HasTable(target.table) && !db.Migrator().HasColumn(target.table, target.field) { + if err := db.Migrator().AddColumn(target.table, target.field); err != nil { + return fmt.Errorf("新增渠道模型迁移列 %T.%s:%w", target.table, target.field, err) + } + } + } + + if db.Migrator().HasTable(&model.LogicalModelRoute{}) && db.Migrator().HasColumn(&model.LogicalModelRoute{}, "physical_variant_id") { + if err := db.Exec(`UPDATE logical_model_routes + SET channel_model_id = (SELECT channel_model_id FROM physical_capability_variants WHERE id = logical_model_routes.physical_variant_id) + WHERE COALESCE(channel_model_id, '') = '' AND COALESCE(physical_variant_id, '') <> ''`).Error; err != nil { + return fmt.Errorf("回填供应线路渠道模型:%w", err) + } + if err := ensureLegacyReferencesMapped(db, "logical_model_routes", "physical_variant_id", "channel_model_id"); err != nil { + return err + } + } + + if db.Migrator().HasTable(&model.Task{}) && db.Migrator().HasColumn(&model.Task{}, "physical_variant_id") { + if err := db.Exec(`UPDATE tasks + SET channel_model_id = (SELECT channel_model_id FROM physical_capability_variants WHERE id = tasks.physical_variant_id) + WHERE COALESCE(channel_model_id, '') = '' AND COALESCE(physical_variant_id, '') <> ''`).Error; err != nil { + return fmt.Errorf("回填任务渠道模型:%w", err) + } + if err := ensureLegacyReferencesMapped(db, "tasks", "physical_variant_id", "channel_model_id"); err != nil { + return err + } + } + + if db.Migrator().HasTable(&model.RouteAttempt{}) && db.Migrator().HasColumn(&model.RouteAttempt{}, "physical_variant_id") { + if err := db.Exec(`UPDATE route_attempts + SET channel_model_id = (SELECT channel_model_id FROM physical_capability_variants WHERE id = route_attempts.physical_variant_id) + WHERE COALESCE(channel_model_id, '') = '' AND COALESCE(physical_variant_id, '') <> ''`).Error; err != nil { + return fmt.Errorf("回填路由尝试渠道模型:%w", err) + } + if err := ensureLegacyReferencesMapped(db, "route_attempts", "physical_variant_id", "channel_model_id"); err != nil { + return err + } + } + for _, target := range []struct { + table any + name string + column string + }{ + {table: &model.LogicalModelRoute{}, name: "logical_model_routes", column: "channel_model_id"}, + {table: &model.Task{}, name: "tasks", column: "channel_model_id"}, + {table: &model.RouteAttempt{}, name: "route_attempts", column: "channel_model_id"}, + } { + if db.Migrator().HasTable(target.table) && db.Migrator().HasColumn(target.table, target.column) { + if err := ensureChannelModelReferencesExist(db, target.name, target.column); err != nil { + return err + } + } + } + if db.Migrator().HasTable(&model.LogicalModelRoute{}) && db.Migrator().HasColumn(&model.LogicalModelRoute{}, "channel_model_id") { + if err := ensureLogicalRouteMembersUnique(db); err != nil { + return err + } + } + if db.Migrator().HasTable(&model.LogicalModelRoute{}) && db.Migrator().HasColumn(&model.LogicalModelRoute{}, "physical_variant_id") { + if err := db.Exec("DROP INDEX IF EXISTS idx_logical_route_member").Error; err != nil { + return fmt.Errorf("删除旧供应线路唯一索引:%w", err) + } + } + return nil +} + +func ensureLegacyReferencesMapped(db *gorm.DB, table string, legacyColumn string, targetColumn string) error { + var count int64 + query := fmt.Sprintf( + `SELECT COUNT(*) FROM %s WHERE COALESCE(%s, '') <> '' AND COALESCE(%s, '') = ''`, + table, + legacyColumn, + targetColumn, + ) + if err := db.Raw(query).Scan(&count).Error; err != nil { + return fmt.Errorf("检查 %s 历史渠道模型映射:%w", table, err) + } + if count > 0 { + return fmt.Errorf("%s 存在 %d 条无法映射到渠道模型的历史引用", table, count) + } + query = fmt.Sprintf( + `SELECT COUNT(*) FROM %s AS source + LEFT JOIN physical_capability_variants AS legacy ON legacy.id = source.%s + WHERE COALESCE(source.%s, '') <> '' AND (legacy.id IS NULL OR source.%s <> legacy.channel_model_id)`, + table, + legacyColumn, + legacyColumn, + targetColumn, + ) + if err := db.Raw(query).Scan(&count).Error; err != nil { + return fmt.Errorf("核对 %s 历史渠道模型映射:%w", table, err) + } + if count > 0 { + return fmt.Errorf("%s 存在与历史可用配置不一致的渠道模型引用", table) + } + return nil +} + +func ensureChannelModelReferencesExist(db *gorm.DB, table string, targetColumn string) error { + var count int64 + query := fmt.Sprintf( + `SELECT COUNT(*) FROM %s AS source + LEFT JOIN channel_models AS channel_model ON channel_model.id = source.%s + WHERE COALESCE(source.%s, '') <> '' AND channel_model.id IS NULL`, + table, + targetColumn, + targetColumn, + ) + if err := db.Raw(query).Scan(&count).Error; err != nil { + return fmt.Errorf("检查 %s 渠道模型引用:%w", table, err) + } + if count > 0 { + return fmt.Errorf("%s 存在 %d 条指向不存在渠道模型的历史引用", table, count) + } + return nil +} + +func ensureLogicalRouteMembersUnique(db *gorm.DB) error { + var count int64 + if err := db.Raw(`SELECT COUNT(*) FROM ( + SELECT logical_model_revision_id, channel_model_id + FROM logical_model_routes + WHERE COALESCE(channel_model_id, '') <> '' + GROUP BY logical_model_revision_id, channel_model_id + HAVING COUNT(*) > 1 + ) AS duplicate_routes`).Scan(&count).Error; err != nil { + return fmt.Errorf("检查供应线路重复渠道模型:%w", err) + } + if count > 0 { + return fmt.Errorf("历史供应线路存在 %d 组同一版本重复引用同一渠道模型的数据,请先清理后再迁移", count) + } + return nil +} + +func dropLegacyPhysicalVariants(db *gorm.DB) error { + if db.Migrator().HasColumn(&model.LogicalModelRoute{}, "physical_variant_id") { + if err := db.Exec("DROP INDEX IF EXISTS idx_logical_route_member").Error; err != nil { + return fmt.Errorf("删除供应线路迁移索引:%w", err) + } + if err := db.Migrator().DropColumn(&model.LogicalModelRoute{}, "physical_variant_id"); err != nil { + return fmt.Errorf("删除供应线路可用配置列:%w", err) + } + } + if db.Migrator().HasColumn(&model.Task{}, "physical_variant_id") { + if err := db.Migrator().DropColumn(&model.Task{}, "physical_variant_id"); err != nil { + return fmt.Errorf("删除任务可用配置列:%w", err) + } + } + if db.Migrator().HasColumn(&model.RouteAttempt{}, "physical_variant_id") { + if err := db.Migrator().DropColumn(&model.RouteAttempt{}, "physical_variant_id"); err != nil { + return fmt.Errorf("删除路由尝试可用配置列:%w", err) + } + } + if db.Migrator().HasTable("physical_capability_variants") { + if err := db.Migrator().DropTable("physical_capability_variants"); err != nil { + return fmt.Errorf("删除可用配置表:%w", err) + } + } + return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_logical_route_member ON logical_model_routes(logical_model_revision_id, channel_model_id)`).Error +} + type varcharColumnMigration struct { table string column string diff --git a/backend/internal/handler/logical_models.go b/backend/internal/handler/logical_models.go index e76a42dcc..e7a177d08 100644 --- a/backend/internal/handler/logical_models.go +++ b/backend/internal/handler/logical_models.go @@ -58,25 +58,6 @@ func RegisterLogicalModelRoutes(r *gin.RouterGroup, svc *service.Service) { r.PATCH("/admin/logical-models/:id", func(c *gin.Context) { saveAdminLogicalModel(c, svc, c.Param("id")) }) - r.GET("/admin/logical-models/physical-variants", func(c *gin.Context) { - actor, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - items, err := svc.AdminPhysicalVariantsForChannelModel(actor, c.Query("channelModelId")) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"variants": items}) - }) - r.POST("/admin/logical-models/physical-variants", func(c *gin.Context) { - saveAdminPhysicalVariant(c, svc, "") - }) - r.PATCH("/admin/logical-models/physical-variants/:id", func(c *gin.Context) { - saveAdminPhysicalVariant(c, svc, c.Param("id")) - }) r.POST("/admin/logical-models/:id/simulate", func(c *gin.Context) { actor, err := currentUser(c, svc) if err != nil { @@ -115,22 +96,3 @@ func saveAdminLogicalModel(c *gin.Context, svc *service.Service, id string) { } ok(c, gin.H{"model": item}) } - -func saveAdminPhysicalVariant(c *gin.Context, svc *service.Service, id string) { - actor, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - var req service.PhysicalVariantRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, errors.New("可用配置参数格式错误")) - return - } - item, err := svc.SaveAdminPhysicalVariant(actor, id, req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"variant": item}) -} diff --git a/backend/internal/model/models_logical_model.go b/backend/internal/model/models_logical_model.go index 6fad0af87..7adf9b209 100644 --- a/backend/internal/model/models_logical_model.go +++ b/backend/internal/model/models_logical_model.go @@ -43,21 +43,10 @@ type LogicalModelRevision struct { CreatedAt time.Time `json:"createdAt"` } -// PhysicalCapabilityVariant 描述一个真实模型可同时满足的一组能力。variant 内为 AND,多个 variant 之间为 OR。 -type PhysicalCapabilityVariant struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ChannelModelID string `json:"channelModelId" gorm:"size:36;index"` - Name string `json:"name" gorm:"size:120"` - Enabled bool `json:"enabled" gorm:"index"` - CapabilitySpecJSON string `json:"-" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - type LogicalModelRoute struct { ID string `json:"id" gorm:"primaryKey;size:36"` LogicalModelRevisionID string `json:"logicalModelRevisionId" gorm:"size:36;index;uniqueIndex:idx_logical_route_member,priority:1"` - PhysicalVariantID string `json:"physicalVariantId" gorm:"size:36;index;uniqueIndex:idx_logical_route_member,priority:2"` + ChannelModelID string `json:"channelModelId" gorm:"size:36;index;uniqueIndex:idx_logical_route_member,priority:2"` Enabled bool `json:"enabled" gorm:"index"` Priority int `json:"priority" gorm:"index"` Weight int `json:"weight"` @@ -73,7 +62,6 @@ type RouteAttempt struct { LogicalModelID string `json:"logicalModelId" gorm:"size:36;index"` LogicalModelRevisionID string `json:"logicalModelRevisionId" gorm:"size:36;index"` RouteID string `json:"routeId" gorm:"size:36;index"` - PhysicalVariantID string `json:"physicalVariantId" gorm:"size:36;index"` ChannelModelID string `json:"channelModelId" gorm:"size:36;index"` ChannelID string `json:"channelId" gorm:"size:36;index"` Status string `json:"status" gorm:"size:32;index"` diff --git a/backend/internal/model/models_task.go b/backend/internal/model/models_task.go index aadf55480..00020c889 100644 --- a/backend/internal/model/models_task.go +++ b/backend/internal/model/models_task.go @@ -18,7 +18,7 @@ type Task struct { LogicalModelID string `json:"logicalModelId,omitempty" gorm:"size:36;index"` LogicalModelRevisionID string `json:"logicalModelRevisionId,omitempty" gorm:"size:36;index"` RouteID string `json:"routeId,omitempty" gorm:"size:36;index"` - PhysicalVariantID string `json:"physicalVariantId,omitempty" gorm:"size:36;index"` + ChannelModelID string `json:"channelModelId,omitempty" gorm:"size:36;index"` // RouteRun 只在用户主动重试时递增;worker 租约恢复不应创建新的路由选择世代。 RouteRun int `json:"-" gorm:"index"` BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 85c0652d6..6436a3869 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -21,6 +21,7 @@ var ( ErrBillingStateConflict = errors.New("billing state conflict") ErrBillingUsageUnavailable = errors.New("billing usage unavailable") ErrBillingUnderreserved = errors.New("billing amount exceeds reservation") + ErrChannelModelInUse = errors.New("channel model is in use") ) // 先抢占唯一业务键再更新账户,确保注册和签到奖励在多实例并发下只入账一次。 @@ -105,6 +106,24 @@ func (r *Repository) SaveChannelModel(item *model.ChannelModel) error { func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON string, now time.Time) error { return r.db.Transaction(func(tx *gorm.DB) error { + var activeReferences int64 + if err := tx.Table("logical_model_routes AS route"). + Joins("JOIN logical_models AS logical_model ON logical_model.active_revision_id = route.logical_model_revision_id"). + Where("route.channel_model_id = ?", id). + Count(&activeReferences).Error; err != nil { + return err + } + if activeReferences > 0 { + return ErrChannelModelInUse + } + if err := tx.Model(&model.Task{}). + Where("channel_model_id = ? AND status IN ?", id, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Count(&activeReferences).Error; err != nil { + return err + } + if activeReferences > 0 { + return ErrChannelModelInUse + } result := tx.Model(&model.ChannelModel{}). Where("id = ? AND channel_id = ?", id, channelID). Updates(map[string]any{"enabled": false, "price_version": gorm.Expr("price_version + 1"), "updated_at": now}) @@ -231,7 +250,7 @@ func (r *Repository) RetryTaskWithBilling(userID string, prepared *model.Task, o "provider_cancel_requested_at": nil, "provider_cancelled_at": nil, "provider_cancel_next_check_at": nil, "route_run": gorm.Expr("route_run + ?", 1), "logical_model_revision_id": prepared.LogicalModelRevisionID, "route_id": prepared.RouteID, - "physical_variant_id": prepared.PhysicalVariantID, "input_json": prepared.InputJSON, + "channel_model_id": prepared.ChannelModelID, "input_json": prepared.InputJSON, "model": prepared.Model, "provider": prepared.Provider, "lease_owner": "", "lease_expires_at": nil, "updated_at": time.Now(), } diff --git a/backend/internal/repository/logical_models.go b/backend/internal/repository/logical_models.go index 9f6a69863..e8e7bc797 100644 --- a/backend/internal/repository/logical_models.go +++ b/backend/internal/repository/logical_models.go @@ -13,7 +13,6 @@ type LogicalModelGraph struct { Model model.LogicalModel Revision *model.LogicalModelRevision Routes []model.LogicalModelRoute - Variants []model.PhysicalCapabilityVariant ChannelModels []model.ChannelModel } @@ -64,58 +63,6 @@ func (r *Repository) LogicalModelRoute(id string) (*model.LogicalModelRoute, err return &item, nil } -func (r *Repository) PhysicalVariants(channelModelIDs []string, includeDisabled bool) ([]model.PhysicalCapabilityVariant, error) { - var items []model.PhysicalCapabilityVariant - if len(channelModelIDs) == 0 { - return items, nil - } - query := r.db.Where("channel_model_id IN ?", channelModelIDs).Order("created_at asc") - if !includeDisabled { - query = query.Where("enabled = ?", true) - } - return items, query.Find(&items).Error -} - -func (r *Repository) PhysicalVariant(id string) (*model.PhysicalCapabilityVariant, error) { - var item model.PhysicalCapabilityVariant - if err := r.db.First(&item, "id = ?", id).Error; err != nil { - return nil, err - } - return &item, nil -} - -func (r *Repository) AllPhysicalVariants(includeDisabled bool) ([]model.PhysicalCapabilityVariant, error) { - var items []model.PhysicalCapabilityVariant - query := r.db.Order("created_at asc") - if !includeDisabled { - query = query.Where("enabled = ?", true) - } - return items, query.Find(&items).Error -} - -func (r *Repository) PhysicalVariantRouteCounts(ids []string) (map[string]int64, error) { - counts := make(map[string]int64, len(ids)) - if len(ids) == 0 { - return counts, nil - } - type row struct { - PhysicalVariantID string `gorm:"column:physical_variant_id"` - Count int64 `gorm:"column:count"` - } - var rows []row - if err := r.db.Model(&model.LogicalModelRoute{}). - Select("physical_variant_id, COUNT(*) AS count"). - Where("physical_variant_id IN ?", uniqueStrings(ids)). - Group("physical_variant_id"). - Scan(&rows).Error; err != nil { - return nil, err - } - for _, item := range rows { - counts[item.PhysicalVariantID] = item.Count - } - return counts, nil -} - func (r *Repository) ChannelModelsByIDs(ids []string) ([]model.ChannelModel, error) { var items []model.ChannelModel if len(ids) == 0 { @@ -159,7 +106,7 @@ func (r *Repository) LogicalModelGraph(id string, includeDisabled bool) (*Logica return graphs[item.ID], nil } -// LogicalModelGraphs 批量加载前台模型的当前 revision、供应线路、可用配置和渠道模型。 +// LogicalModelGraphs 批量加载前台模型的当前 revision、供应线路和渠道模型。 // 管理列表与路由目录都需要同一张关系图,集中按 IN 查询避免逐模型 N+1。 func (r *Repository) LogicalModelGraphs(items []model.LogicalModel, includeDisabled bool) (map[string]*LogicalModelGraph, error) { graphs := make(map[string]*LogicalModelGraph, len(items)) @@ -210,22 +157,12 @@ func (r *Repository) LogicalModelGraphs(items []model.LogicalModel, includeDisab } } routesByRevision := make(map[string][]model.LogicalModelRoute, len(routeRevisionIDs)) - variantIDs := make([]string, 0, len(routes)) + channelModelIDs := make([]string, 0, len(routes)) for _, route := range routes { routesByRevision[route.LogicalModelRevisionID] = append(routesByRevision[route.LogicalModelRevisionID], route) - variantIDs = append(variantIDs, route.PhysicalVariantID) + channelModelIDs = append(channelModelIDs, route.ChannelModelID) } - variants, err := r.PhysicalVariantsByIDs(uniqueStrings(variantIDs), includeDisabled) - if err != nil { - return nil, err - } - variantByID := make(map[string]model.PhysicalCapabilityVariant, len(variants)) - channelModelIDs := make([]string, 0, len(variants)) - for _, variant := range variants { - variantByID[variant.ID] = variant - channelModelIDs = append(channelModelIDs, variant.ChannelModelID) - } channelModels, err := r.ChannelModelsByIDs(uniqueStrings(channelModelIDs)) if err != nil { return nil, err @@ -241,20 +178,10 @@ func (r *Repository) LogicalModelGraphs(items []model.LogicalModel, includeDisab continue } graph.Routes = routesByRevision[graph.Revision.ID] - graph.Variants = make([]model.PhysicalCapabilityVariant, 0, len(graph.Routes)) graph.ChannelModels = make([]model.ChannelModel, 0, len(graph.Routes)) - seenVariants := make(map[string]bool, len(graph.Routes)) seenChannelModels := make(map[string]bool, len(graph.Routes)) for _, route := range graph.Routes { - variant, variantOK := variantByID[route.PhysicalVariantID] - if !variantOK { - continue - } - if !seenVariants[variant.ID] { - seenVariants[variant.ID] = true - graph.Variants = append(graph.Variants, variant) - } - if channelModel, channelOK := channelModelByID[variant.ChannelModelID]; channelOK && !seenChannelModels[channelModel.ID] { + if channelModel, channelOK := channelModelByID[route.ChannelModelID]; channelOK && !seenChannelModels[channelModel.ID] { seenChannelModels[channelModel.ID] = true graph.ChannelModels = append(graph.ChannelModels, channelModel) } @@ -263,18 +190,6 @@ func (r *Repository) LogicalModelGraphs(items []model.LogicalModel, includeDisab return graphs, nil } -func (r *Repository) PhysicalVariantsByIDs(ids []string, includeDisabled bool) ([]model.PhysicalCapabilityVariant, error) { - var items []model.PhysicalCapabilityVariant - if len(ids) == 0 { - return items, nil - } - query := r.db.Where("id IN ?", ids).Order("created_at asc") - if !includeDisabled { - query = query.Where("enabled = ?", true) - } - return items, query.Find(&items).Error -} - func uniqueStrings(values []string) []string { result := make([]string, 0, len(values)) seen := make(map[string]bool, len(values)) @@ -288,22 +203,6 @@ func uniqueStrings(values []string) []string { return result } -func (r *Repository) PhysicalVariantsForRoutes(routes []model.LogicalModelRoute, includeDisabled bool) ([]model.PhysicalCapabilityVariant, error) { - ids := make([]string, 0, len(routes)) - for _, route := range routes { - ids = append(ids, route.PhysicalVariantID) - } - var items []model.PhysicalCapabilityVariant - if len(ids) == 0 { - return items, nil - } - query := r.db.Where("id IN ?", ids) - if !includeDisabled { - query = query.Where("enabled = ?", true) - } - return items, query.Find(&items).Error -} - func (r *Repository) SaveLogicalModel(item *model.LogicalModel) error { return r.db.Save(item).Error } func (r *Repository) CreateLogicalModel(item *model.LogicalModel) error { return r.db.Create(item).Error @@ -314,12 +213,6 @@ func (r *Repository) CreateLogicalModelRevision(item *model.LogicalModelRevision func (r *Repository) SaveLogicalModelRevision(item *model.LogicalModelRevision) error { return r.db.Save(item).Error } -func (r *Repository) CreatePhysicalVariant(item *model.PhysicalCapabilityVariant) error { - return r.db.Create(item).Error -} -func (r *Repository) SavePhysicalVariant(item *model.PhysicalCapabilityVariant) error { - return r.db.Save(item).Error -} func (r *Repository) CreateLogicalModelRoute(item *model.LogicalModelRoute) error { return r.db.Create(item).Error } @@ -339,12 +232,6 @@ func (r *Repository) LatestRouteAttempt(taskID string) (*model.RouteAttempt, err return &item, nil } -func (r *Repository) PhysicalVariantRouteCount(id string) (int64, error) { - var count int64 - err := r.db.Model(&model.LogicalModelRoute{}).Where("physical_variant_id = ?", id).Count(&count).Error - return count, err -} - func (r *Repository) RouteAttempts(taskID string, routeRun int) ([]model.RouteAttempt, error) { var items []model.RouteAttempt query := r.db.Where("task_id = ? AND route_run = ?", taskID, routeRun) @@ -353,11 +240,11 @@ func (r *Repository) RouteAttempts(taskID string, routeRun int) ([]model.RouteAt // SwitchTaskLogicalRoute 同时更新任务执行目标和账单。跟随供应价格时会原子调整预留积分, // 保证明确未创建上游任务后的故障切线不会沿用上一条线路的价格快照。 -func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID string, routeID string, variantID string, inputJSON string, billingOrderID string, channelID string, channelModelID string, replacement *model.BillingOrder) error { +func (r *Repository) SwitchTaskLogicalRoute(taskID string, expectedRouteID string, routeID string, inputJSON string, billingOrderID string, channelID string, channelModelID string, replacement *model.BillingOrder) error { return r.db.Transaction(func(tx *gorm.DB) error { updated := tx.Model(&model.Task{}). Where("id = ? AND status = ? AND route_id = ?", taskID, model.TaskStatusRunning, expectedRouteID). - Updates(map[string]any{"route_id": routeID, "physical_variant_id": variantID, "input_json": inputJSON, "updated_at": time.Now()}) + Updates(map[string]any{"route_id": routeID, "channel_model_id": channelModelID, "input_json": inputJSON, "updated_at": time.Now()}) if updated.Error != nil { return updated.Error } diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 14da78c7e..adacbc78d 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -9,6 +9,7 @@ import ( "time" "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" "gorm.io/gorm" ) @@ -228,8 +229,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id if req.Enabled != nil { item.Enabled = *req.Enabled } - // 可用配置不再保存独立能力范围;路由能力会在读取时从当前渠道模型能力实时投影。 - // 因此修改尺寸、比例或数量时无需拿旧快照做冲突校验,也不会留下第二个事实来源。 + // 供应线路直接引用渠道模型,修改能力参数后会从这一唯一事实来源实时生成线路能力。 if err := s.repo.SaveChannelModel(item); err != nil { return nil, err } @@ -423,6 +423,9 @@ func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, i } // 删除模型与渠道的兼容模型清单必须同事务提交,避免接口报错但列表已部分变化。 err = s.repo.DeleteChannelModel(channelID, id, string(encoded), time.Now()) + if errors.Is(err, repository.ErrChannelModelInUse) { + return BadAuthRequest("渠道模型仍被前台模型供应线路或进行中任务使用,请先移除线路并等待任务结束") + } if errors.Is(err, gorm.ErrRecordNotFound) { return BadAuthRequest("渠道模型不存在或已删除") } diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index ea2beb88d..59d1138f5 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -460,13 +460,13 @@ func (s *Service) newLogicalModelBillingOrder(userID string, task *model.Task, i if err != nil || !logicalModel.Enabled || logicalModel.ActiveRevisionID != task.LogicalModelRevisionID { return nil, BadAuthRequest("所选模型计费配置已失效,请重新选择") } - variant, err := s.repo.PhysicalVariant(task.PhysicalVariantID) - if err != nil { - return nil, BadAuthRequest("所选模型配置已更新,请重新选择") + route, err := s.repo.LogicalModelRoute(task.RouteID) + if err != nil || route.LogicalModelRevisionID != task.LogicalModelRevisionID || route.ChannelModelID != task.ChannelModelID { + return nil, BadAuthRequest("所选模型供应线路已更新,请重新选择") } - channelModel, err := s.repo.ChannelModel(variant.ChannelModelID) + channelModel, err := s.repo.ChannelModel(task.ChannelModelID) if err != nil { - return nil, BadAuthRequest("所选模型配置已更新,请重新选择") + return nil, BadAuthRequest("所选模型供应线路已更新,请重新选择") } config, _ := input["config"].(map[string]any) capability := normalizeCapability(fmt.Sprint(input["mode"])) diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index 1d0237f01..39ccc9270 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -37,17 +37,10 @@ type LogicalModelRequest struct { } type LogicalRouteRequest struct { - PhysicalVariantID string `json:"physicalVariantId"` - Enabled bool `json:"enabled"` - Priority int `json:"priority"` - Weight int `json:"weight"` -} - -type PhysicalVariantRequest struct { - ChannelModelID string `json:"channelModelId"` - Name string `json:"name"` - Enabled bool `json:"enabled"` - CapabilitySpec CapabilitySpec `json:"capabilitySpec"` + ChannelModelID string `json:"channelModelId"` + Enabled bool `json:"enabled"` + Priority int `json:"priority"` + Weight int `json:"weight"` } type PublicLogicalModel struct { @@ -73,12 +66,10 @@ type PublicLogicalModel struct { type AdminLogicalRoute struct { ID string `json:"id"` - PhysicalVariantID string `json:"physicalVariantId"` - PhysicalVariantName string `json:"physicalVariantName"` ChannelModelID string `json:"channelModelId"` ChannelID string `json:"channelId"` - PhysicalModelKey string `json:"physicalModelKey"` - PhysicalModelName string `json:"physicalModelName"` + ChannelModelKey string `json:"channelModelKey"` + ChannelModelName string `json:"channelModelName"` Enabled bool `json:"enabled"` Priority int `json:"priority"` Weight int `json:"weight"` @@ -97,32 +88,18 @@ type AdminLogicalModel struct { Routes []AdminLogicalRoute `json:"routes"` } -type AdminPhysicalVariant struct { - ID string `json:"id"` - ChannelModelID string `json:"channelModelId"` - ChannelID string `json:"channelId"` - ModelKey string `json:"modelKey"` - ModelName string `json:"modelName"` - Name string `json:"name"` - Capability string `json:"capability"` - Protocol string `json:"protocol"` - Enabled bool `json:"enabled"` - ModelEnabled bool `json:"modelEnabled"` - UsageCount int64 `json:"usageCount"` - CapabilitySpec CapabilitySpec `json:"capabilitySpec"` -} - type RouteSimulationCandidate struct { - RouteID string `json:"routeId"` - VariantID string `json:"variantId"` - ChannelModelID string `json:"channelModelId"` - Priority int `json:"priority"` - Weight int `json:"weight"` - Enabled bool `json:"enabled"` - Matched bool `json:"matched"` - Blocked bool `json:"blocked"` - InPool bool `json:"inPool"` - Reasons []string `json:"reasons,omitempty"` + RouteID string `json:"routeId"` + ChannelModelID string `json:"channelModelId"` + ChannelModelKey string `json:"channelModelKey"` + ChannelModelName string `json:"channelModelName"` + Priority int `json:"priority"` + Weight int `json:"weight"` + Enabled bool `json:"enabled"` + Matched bool `json:"matched"` + Blocked bool `json:"blocked"` + InPool bool `json:"inPool"` + Reasons []string `json:"reasons,omitempty"` } type RouteSimulationResult struct { @@ -151,7 +128,7 @@ func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogic available = false if coverageValid { for _, route := range cached.Routes { - if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.VariantSpec, resolvedIntent).Matched { + if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.CapabilitySpec, resolvedIntent).Matched { available = true break } @@ -167,22 +144,22 @@ func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogical item := cached.Model routeSpecs := make([]CapabilitySpec, 0, len(cached.Routes)) for _, route := range cached.Routes { - routeSpecs = append(routeSpecs, route.VariantSpec) + routeSpecs = append(routeSpecs, route.CapabilitySpec) } productSpec := capabilitySpecWithRoutePresets(cached.ProductSpec, routeSpecs) - variants := make([]CapabilitySpec, 0, len(cached.Routes)) + profiles := make([]CapabilitySpec, 0, len(cached.Routes)) seen := make(map[string]bool, len(cached.Routes)) for _, route := range cached.Routes { if !route.Route.Enabled || route.Route.Weight <= 0 { continue } - key := capabilityFingerprint(route.VariantSpec) + key := capabilityFingerprint(route.CapabilitySpec) if !seen[key] { seen[key] = true - variants = append(variants, route.VariantSpec) + profiles = append(profiles, route.CapabilitySpec) } } - return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, CapabilitySpec: productSpec, CapabilityProfiles: variants, DefaultOptions: cached.Defaults, Available: available} + return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, CapabilitySpec: productSpec, CapabilityProfiles: profiles, DefaultOptions: cached.Defaults, Available: available} } // capabilitySpecWithRoutePresets repairs old front-model snapshots that stored @@ -273,7 +250,7 @@ func (s *Service) AdminLogicalModels(actor *model.User) ([]AdminLogicalModel, er } admin, buildErr := s.buildAdminLogicalModel(item, graph, systemChannelByID) if buildErr != nil { - continue + return nil, buildErr } result = append(result, *admin) } @@ -285,32 +262,24 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit if err != nil { return nil, err } - variantByID := make(map[string]model.PhysicalCapabilityVariant, len(graph.Variants)) - for _, variant := range graph.Variants { - variantByID[variant.ID] = variant - } channelModelByID := make(map[string]model.ChannelModel, len(graph.ChannelModels)) for _, channelModel := range graph.ChannelModels { channelModelByID[channelModel.ID] = channelModel } admin := AdminLogicalModel{PublicLogicalModel: publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: map[string]any{}}, false), Enabled: item.Enabled, ActiveRevisionID: graph.Revision.ID, RevisionVersion: graph.Revision.Version, Routes: []AdminLogicalRoute{}} for _, route := range graph.Routes { - variant, variantOK := variantByID[route.PhysicalVariantID] - if !variantOK { - continue - } - channelModel, channelOK := channelModelByID[variant.ChannelModelID] + channelModel, channelOK := channelModelByID[route.ChannelModelID] if !channelOK { - continue + return nil, errors.New("供应线路引用的渠道模型不存在") } - variantSpec, specErr := effectivePhysicalVariantSpec(variant, channelModel) + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) if specErr != nil { - continue + return nil, specErr } _, channelOK = systemChannelByID[channelModel.ChannelID] - structurallyAvailable := route.Enabled && route.Weight > 0 && variant.Enabled && channelModel.Enabled && channelOK + structurallyAvailable := route.Enabled && route.Weight > 0 && channelModel.Enabled && channelOK available := structurallyAvailable && (item.PricePolicy != "channel" || channelModel.PriceConfigured) - admin.Routes = append(admin.Routes, AdminLogicalRoute{ID: route.ID, PhysicalVariantID: variant.ID, PhysicalVariantName: variant.Name, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, PhysicalModelKey: channelModel.ModelKey, PhysicalModelName: channelModel.DisplayName, Enabled: route.Enabled, Priority: route.Priority, Weight: route.Weight, Available: available, structurallyAvailable: structurallyAvailable, CapabilitySpec: variantSpec}) + admin.Routes = append(admin.Routes, AdminLogicalRoute{ID: route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, ChannelModelKey: channelModel.ModelKey, ChannelModelName: channelModel.DisplayName, Enabled: route.Enabled, Priority: route.Priority, Weight: route.Weight, Available: available, structurallyAvailable: structurallyAvailable, CapabilitySpec: capabilitySpec}) } routeSpecs := make([]CapabilitySpec, 0, len(admin.Routes)) for _, route := range admin.Routes { @@ -368,7 +337,7 @@ func availableCachedRouteSpecs(routes []cachedLogicalRoute) []CapabilitySpec { result := make([]CapabilitySpec, 0, len(routes)) for _, route := range routes { if route.Route.Enabled && route.Route.Weight > 0 { - result = append(result, route.VariantSpec) + result = append(result, route.CapabilitySpec) } } return result @@ -508,25 +477,24 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo defaultsJSON, _ := json.Marshal(defaultMap(req.DefaultOptions)) revision := &model.LogicalModelRevision{ID: revisionID, LogicalModelID: item.ID, CapabilitySpecJSON: string(specJSON), DefaultOptionsJSON: string(defaultsJSON), CreatedBy: actor.ID, CreatedAt: time.Now()} routes := make([]model.LogicalModelRoute, 0, len(req.Routes)) - seenVariants := make(map[string]bool, len(req.Routes)) + seenChannelModels := make(map[string]bool, len(req.Routes)) structuralRouteSpecs := make([]CapabilitySpec, 0, len(req.Routes)) settlementRouteSpecs := make([]CapabilitySpec, 0, len(req.Routes)) for _, input := range req.Routes { - variantID := strings.TrimSpace(input.PhysicalVariantID) - if variantID == "" || seenVariants[variantID] { - return nil, nil, nil, false, BadAuthRequest("供应线路必须选择不重复的可用配置") + channelModelID := strings.TrimSpace(input.ChannelModelID) + if channelModelID == "" || seenChannelModels[channelModelID] { + return nil, nil, nil, false, BadAuthRequest("供应线路必须选择不重复的渠道模型") } - seenVariants[variantID] = true - variant, variantErr := s.repo.PhysicalVariant(variantID) - if variantErr != nil { - return nil, nil, nil, false, BadAuthRequest("供应线路引用的可用配置不存在") - } - channelModel, modelErr := s.repo.ChannelModel(variant.ChannelModelID) + seenChannelModels[channelModelID] = true + channelModel, modelErr := s.repo.ChannelModel(channelModelID) if modelErr != nil { - return nil, nil, nil, false, BadAuthRequest("可用配置引用的渠道模型不存在") + return nil, nil, nil, false, BadAuthRequest("供应线路引用的渠道模型不存在") } - variantSpec, specErr := effectivePhysicalVariantSpec(*variant, *channelModel) - if specErr != nil || normalizeCapability(variantSpec.Capability) != capability { + capabilitySpec, specErr := channelModelCapabilitySpec(*channelModel) + if specErr != nil { + return nil, nil, nil, false, specErr + } + if normalizeCapability(capabilitySpec.Capability) != capability { return nil, nil, nil, false, BadAuthRequest("供应线路能力类型与前台模型不一致") } if req.Enabled && input.Enabled && input.Weight <= 0 { @@ -535,26 +503,22 @@ func (s *Service) logicalModelBundle(actor *model.User, id string, req LogicalMo if input.Weight < 0 { return nil, nil, nil, false, BadAuthRequest("供应线路的同级权重不能为负数") } - if req.Enabled && input.Enabled && variant.Enabled { - if err := validateVariantWithinChannelModel(*channelModel, variantSpec); err != nil { - return nil, nil, nil, false, err - } - if channelModel.Enabled { - if _, channelErr := s.repo.SystemChannel(channelModel.ChannelID); channelErr == nil { - structuralRouteSpecs = append(structuralRouteSpecs, variantSpec) - if pricePolicy != "channel" || channelModel.PriceConfigured { - settlementRouteSpecs = append(settlementRouteSpecs, variantSpec) - } - } + if _, channelErr := s.repo.SystemChannel(channelModel.ChannelID); channelErr != nil { + return nil, nil, nil, false, BadAuthRequest("供应线路只能选择系统渠道模型") + } + if req.Enabled && input.Enabled && channelModel.Enabled { + structuralRouteSpecs = append(structuralRouteSpecs, capabilitySpec) + if pricePolicy != "channel" || channelModel.PriceConfigured { + settlementRouteSpecs = append(settlementRouteSpecs, capabilitySpec) } } routeID, idErr := s.repo.NextPrefixedID("ROUTE") if idErr != nil { return nil, nil, nil, false, idErr } - routes = append(routes, model.LogicalModelRoute{ID: routeID, PhysicalVariantID: variant.ID, Enabled: input.Enabled, Priority: input.Priority, Weight: input.Weight, CreatedAt: time.Now(), UpdatedAt: time.Now()}) + routes = append(routes, model.LogicalModelRoute{ID: routeID, ChannelModelID: channelModel.ID, Enabled: input.Enabled, Priority: input.Priority, Weight: input.Weight, CreatedAt: time.Now(), UpdatedAt: time.Now()}) } - // 停用必须始终可执行,便于管理员立即阻止失效配置继续对外服务;重新启用时再强校验结构能力和计费可用性。 + // 停用必须始终可执行,便于管理员立即阻止失效线路继续对外服务;重新启用时再强校验结构能力和计费可用性。 if req.Enabled { if len(structuralRouteSpecs) == 0 { return nil, nil, nil, false, BadAuthRequest("启用前台模型前至少需要一条已启用的供应线路") @@ -601,149 +565,12 @@ func defaultMap(value map[string]any) map[string]any { return value } -func (s *Service) AdminPhysicalVariants(actor *model.User) ([]AdminPhysicalVariant, error) { - return s.adminPhysicalVariants(actor, "") -} - -func (s *Service) AdminPhysicalVariantsForChannelModel(actor *model.User, channelModelID string) ([]AdminPhysicalVariant, error) { - return s.adminPhysicalVariants(actor, strings.TrimSpace(channelModelID)) -} - -func (s *Service) adminPhysicalVariants(actor *model.User, channelModelID string) ([]AdminPhysicalVariant, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - var variants []model.PhysicalCapabilityVariant - var err error - if channelModelID != "" { - if _, err := s.repo.ChannelModel(channelModelID); err != nil { - return nil, BadAuthRequest("渠道模型不存在") - } - variants, err = s.repo.PhysicalVariants([]string{channelModelID}, true) - } else { - variants, err = s.repo.AllPhysicalVariants(true) - } +func channelModelCapabilitySpec(channelModel model.ChannelModel) (CapabilitySpec, error) { + config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON) if err != nil { - return nil, err + return CapabilitySpec{}, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型") } - variantIDs := make([]string, 0, len(variants)) - for _, variant := range variants { - variantIDs = append(variantIDs, variant.ID) - } - usageCounts, err := s.repo.PhysicalVariantRouteCounts(variantIDs) - if err != nil { - return nil, err - } - result := make([]AdminPhysicalVariant, 0, len(variants)) - for _, variant := range variants { - channelModel, modelErr := s.repo.ChannelModel(variant.ChannelModelID) - if modelErr != nil { - continue - } - spec, specErr := effectivePhysicalVariantSpec(variant, *channelModel) - if specErr != nil { - continue - } - result = append(result, AdminPhysicalVariant{ID: variant.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, ModelKey: channelModel.ModelKey, ModelName: channelModel.DisplayName, Name: variant.Name, Capability: channelModel.Capability, Protocol: string(channelModel.Protocol), Enabled: variant.Enabled, ModelEnabled: channelModel.Enabled, UsageCount: usageCounts[variant.ID], CapabilitySpec: spec}) - } - return result, nil -} - -func effectivePhysicalVariantSpec(variant model.PhysicalCapabilityVariant, channelModel model.ChannelModel) (CapabilitySpec, error) { - config, configErr := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON) - if configErr == nil && config != nil { - if spec, err := CapabilitySpecFromModelCapabilityConfig(config, normalizeCapability(channelModel.Capability)); err == nil { - return spec, nil - } - } - // 音频模型以及历史数据可能没有新的能力配置,保留旧快照作为只读降级,避免读路径直接丢失线路。 - return DecodeCapabilitySpec(variant.CapabilitySpecJSON) -} - -func (s *Service) SaveAdminPhysicalVariant(actor *model.User, id string, req PhysicalVariantRequest) (*AdminPhysicalVariant, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - channelModel, err := s.repo.ChannelModel(strings.TrimSpace(req.ChannelModelID)) - if err != nil { - return nil, BadAuthRequest("请选择有效的渠道模型") - } - name := strings.TrimSpace(req.Name) - if name == "" || len([]rune(name)) > 120 { - return nil, BadAuthRequest("请填写 1-120 个字符的可用配置名称") - } - creating := strings.TrimSpace(id) == "" - var item *model.PhysicalCapabilityVariant - if creating { - existing, existingErr := s.repo.PhysicalVariants([]string{channelModel.ID}, true) - if existingErr != nil { - return nil, existingErr - } - if len(existing) > 0 { - return nil, BadAuthRequest("一个渠道模型只能配置一个可用配置,请编辑现有配置") - } - id, err = s.repo.NextPrefixedID("VARIANT") - if err != nil { - return nil, err - } - item = &model.PhysicalCapabilityVariant{ID: id, CreatedAt: time.Now()} - } else { - item, err = s.repo.PhysicalVariant(id) - if err != nil { - return nil, err - } - if item.ChannelModelID != channelModel.ID { - return nil, BadAuthRequest("已创建的可用配置不能换绑渠道模型,请新建可用配置") - } - } - // variant 不再承载第二份可编辑能力配置,能力快照始终从渠道模型生成。 - // 读取旧请求中的 capabilitySpec 仅为兼容旧客户端,不再作为事实来源。 - // 停用是强制止损操作:即使渠道模型能力 JSON 已损坏,也必须允许管理员先停用; - // 只有启用路径才要求重新生成并校验能力快照。 - capabilityConfig, configErr := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON) - derivedSpec, deriveErr := CapabilitySpecFromModelCapabilityConfig(capabilityConfig, normalizeCapability(channelModel.Capability)) - if req.Enabled { - if configErr != nil { - return nil, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型") - } - if deriveErr != nil { - return nil, deriveErr - } - if err := validateVariantWithinChannelModel(*channelModel, derivedSpec); err != nil { - return nil, err - } - } - spec := derivedSpec - if deriveErr != nil { - // 禁用时保留已有快照,避免坏配置导致管理列表丢失;新建记录使用最小可读快照。 - if existingSpec, existingErr := DecodeCapabilitySpec(item.CapabilitySpecJSON); existingErr == nil { - spec = existingSpec - } else { - spec = CapabilitySpec{Version: 1, Capability: normalizeCapability(channelModel.Capability)} - } - } - specJSON, _ := json.Marshal(spec) - item.ChannelModelID, item.Name, item.Enabled, item.CapabilitySpecJSON, item.UpdatedAt = channelModel.ID, name, req.Enabled, string(specJSON), time.Now() - if creating { - err = s.repo.CreatePhysicalVariant(item) - } else { - err = s.repo.SavePhysicalVariant(item) - } - if err != nil { - return nil, err - } - s.invalidateRouteCatalog() - _ = s.appendAdminAudit(actor, map[bool]string{true: "physical_variant.create", false: "physical_variant.update"}[creating], "physical_variant", item.ID, "保存可用配置", map[string]any{"channelModelId": channelModel.ID}) - variants, err := s.AdminPhysicalVariants(actor) - if err != nil { - return nil, err - } - for index := range variants { - if variants[index].ID == item.ID { - return &variants[index], nil - } - } - return nil, gorm.ErrRecordNotFound + return CapabilitySpecFromModelCapabilityConfig(config, normalizeCapability(channelModel.Capability)) } func (s *Service) SimulateLogicalModelRoute(actor *model.User, id string, intent ModelRequestIntent) (*RouteSimulationResult, error) { @@ -764,100 +591,6 @@ func (s *Service) SimulateLogicalModelRoute(actor *model.User, id string, intent func logicalModelNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } -// 可用配置只能收窄渠道模型能力,不能声明协议适配器实际无法发送的范围。 -func validateVariantWithinChannelModel(channelModel model.ChannelModel, spec CapabilitySpec) error { - profile, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON) - if err != nil { - return BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型") - } - switch normalizeCapability(spec.Capability) { - case "text": - if profile == nil || profile.Text == nil { - return BadAuthRequest("渠道文本模型尚未配置能力参数") - } - limits := map[string]int{"image": profile.Text.References.MaxImages, "video": profile.Text.References.MaxVideos} - for kind, constraint := range spec.Inputs { - limit, known := limits[kind] - if !known && constraint.Max > 0 || known && constraint.Max > limit { - return BadAuthRequest("可用配置的 " + kind + " 输入上限超过渠道模型配置") - } - } - return validateKnownVariantOptions(spec.Options, map[string]OptionConstraint{}) - case "image": - if profile == nil || profile.Image == nil { - return BadAuthRequest("渠道图片模型尚未配置能力参数") - } - image := profile.Image - for kind, constraint := range spec.Inputs { - switch kind { - case "image": - if constraint.Max > image.References.MaxImages { - return BadAuthRequest("可用配置的图片输入上限超过渠道模型配置") - } - case "mask": - if constraint.Max > 1 || constraint.Max > 0 && !image.References.MaskSupported { - return BadAuthRequest("可用配置声明了渠道模型不支持的蒙版输入") - } - default: - if constraint.Max > 0 { - return BadAuthRequest("图片协议尚未接入 " + kind + " 输入") - } - } - } - physical := map[string]OptionConstraint{ - "size": imageSizeOptionConstraint(image.Size), - "quality": anyValues(image.Quality.Values), - "transparentBackground": boolValues(image.TransparentBackground.Supported), - "count": numericRange(1, float64(image.MaxOutputs), 1), - } - return validateKnownVariantOptions(spec.Options, physical) - case "video": - if profile == nil || profile.Video == nil { - return BadAuthRequest("渠道视频模型尚未配置能力参数") - } - video := profile.Video - limits := map[string]int{"image": video.References.MaxImages, "video": video.References.MaxVideos, "audio": video.References.MaxAudios} - for kind, constraint := range spec.Inputs { - limit, known := limits[kind] - if !known && constraint.Max > 0 || known && constraint.Max > limit { - return BadAuthRequest("可用配置的 " + kind + " 输入上限超过渠道模型配置") - } - } - for _, operation := range spec.Operations { - if !containsCapabilityString(video.Operations, operation) { - return BadAuthRequest("可用配置包含渠道模型不支持的操作:" + operation) - } - } - duration := OptionConstraint{} - if video.Duration.Selection == "enum" { - values := make([]any, 0, len(video.Duration.Values)) - for _, value := range video.Duration.Values { - values = append(values, value) - } - duration.Values = values - } else { - duration = numericRange(float64(video.Duration.Min), float64(video.Duration.Max), float64(video.Duration.Step)) - } - physical := map[string]OptionConstraint{ - "videoSeconds": duration, - "size": anyValues(video.Ratios), - "vquality": anyValues(video.Resolutions), - "videoGenerateAudio": boolValues(video.GenerateAudio.Supported), - "videoWatermark": boolValues(video.Watermark.Supported), - } - return validateKnownVariantOptions(spec.Options, physical) - case "audio": - for kind, constraint := range spec.Inputs { - if constraint.Max > 0 { - return BadAuthRequest("音频协议尚未接入 " + kind + " 输入") - } - } - wildcard := OptionConstraint{Values: []any{"*"}} - return validateKnownVariantOptions(spec.Options, map[string]OptionConstraint{"audioVoice": wildcard, "audioFormat": wildcard, "audioSpeed": wildcard, "audioInstructions": wildcard}) - } - return BadAuthRequest("可用配置类型暂不支持") -} - // 前台模型只声明供应线路真实提供的总目录;组合是否可承接仍由匿名 capabilityProfiles 按 OR 语义判断。 func validateProductSpecWithinRoutes(product CapabilitySpec, routeSpecs []CapabilitySpec) error { for _, routeSpec := range routeSpecs { @@ -1030,23 +763,6 @@ func continuousOptionRangeCovered(minimum float64, maximum float64, constraints return true } -func validateKnownVariantOptions(options map[string]OptionConstraint, physical map[string]OptionConstraint) error { - for name, constraint := range options { - limit, known := physical[name] - if !known { - return BadAuthRequest("参数 " + name + " 尚未接入该类型的供应商协议映射") - } - if !optionConstraintWithin(constraint, limit) { - return BadAuthRequest("参数 " + name + " 超过渠道模型能力范围") - } - } - return nil -} - -func optionConstraintWithin(candidate OptionConstraint, limit OptionConstraint) bool { - return optionConstraintCovered(candidate, "value", []CapabilitySpec{{Options: map[string]OptionConstraint{"value": limit}}}) -} - func anyValues(values []string) OptionConstraint { result := make([]any, 0, len(values)) for _, value := range values { diff --git a/backend/internal/service/logical_models_test.go b/backend/internal/service/logical_models_test.go index 864a46873..748a22ee6 100644 --- a/backend/internal/service/logical_models_test.go +++ b/backend/internal/service/logical_models_test.go @@ -150,23 +150,11 @@ func TestLogicalModelAvailabilityErrorRequiresSettlementReadyCoverage(t *testing } } -func TestOptionConstraintWithinRejectsFinerStepThanRealModel(t *testing.T) { - physical := numericRange(6, 18, 6) - tooBroad := numericRange(6, 18, 3) - if optionConstraintWithin(tooBroad, physical) { - t.Fatal("a route step finer than the channel model step was accepted") - } - if !optionConstraintWithin(numericRange(6, 18, 6), physical) { - t.Fatal("the channel model step was rejected") - } -} - -func TestValidateVariantWithinChannelModelRequiresExplicitImageCapability(t *testing.T) { +func TestChannelModelCapabilitySpecRequiresExplicitImageCapability(t *testing.T) { channelModel := model.ChannelModel{Capability: "image", CapabilityConfigJSON: ""} - spec := CapabilitySpec{Version: 1, Capability: "image"} - err := validateVariantWithinChannelModel(channelModel, spec) + _, err := channelModelCapabilitySpec(channelModel) if err == nil || !strings.Contains(err.Error(), "渠道图片模型尚未配置能力参数") { - t.Fatalf("validateVariantWithinChannelModel() error = %v", err) + t.Fatalf("channelModelCapabilitySpec() error = %v", err) } } diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index e36ce8bd2..ccc76f59a 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -277,9 +277,8 @@ func NormalizeModelCapabilityConfig(capability string, _ string, input *ModelCap return value, nil } -// CapabilitySpecFromModelCapabilityConfig 将渠道模型的真实供应能力投影为路由能力快照。 -// 这是唯一的转换入口:渠道模型能力参数负责定义事实,variant 只保存兼容路由表的投影, -// 不再允许管理员在 variant 上重复维护尺寸、比例、数量等字段。 +// CapabilitySpecFromModelCapabilityConfig 将渠道模型的真实供应能力投影为路由能力规格。 +// 渠道模型能力参数是唯一事实来源,前台模型供应线路直接引用该规格。 func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capability string) (CapabilitySpec, error) { spec := CapabilitySpec{Version: 1, Capability: capability, Inputs: map[string]InputConstraint{}, Options: map[string]OptionConstraint{}} // 音频模型当前没有可编辑的渠道能力 JSON,使用空能力规格表示“无额外路由约束”。 diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index c1f003ea3..9ed0b7e33 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -97,10 +97,9 @@ type cachedLogicalModel struct { } type cachedLogicalRoute struct { - Route model.LogicalModelRoute - Variant model.PhysicalCapabilityVariant - VariantSpec CapabilitySpec - ChannelModel model.ChannelModel + Route model.LogicalModelRoute + CapabilitySpec CapabilitySpec + ChannelModel model.ChannelModel } type routeCatalogSnapshot struct { @@ -114,7 +113,6 @@ type RoutedModel struct { LogicalModel model.LogicalModel Revision model.LogicalModelRevision Route model.LogicalModelRoute - Variant model.PhysicalCapabilityVariant ChannelModel model.ChannelModel Defaults map[string]any } @@ -529,37 +527,29 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) { log.Printf("logical model omitted from route catalog id=%s: invalid product capability: %v", item.ID, decodeErr) continue } - variantByID := make(map[string]model.PhysicalCapabilityVariant, len(graph.Variants)) - for _, variant := range graph.Variants { - variantByID[variant.ID] = variant - } channelModelByID := make(map[string]model.ChannelModel, len(graph.ChannelModels)) for _, channelModel := range graph.ChannelModels { channelModelByID[channelModel.ID] = channelModel } cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: map[string]any{}} for _, route := range graph.Routes { - variant, ok := variantByID[route.PhysicalVariantID] - if !ok || !variant.Enabled { - continue - } - channelModel, ok := channelModelByID[variant.ChannelModelID] + channelModel, ok := channelModelByID[route.ChannelModelID] if !ok || !channelModel.Enabled || !enabledSystemChannels[channelModel.ChannelID] { continue } if item.PricePolicy == "channel" && !channelModel.PriceConfigured { continue } - variantSpec, specErr := effectivePhysicalVariantSpec(variant, channelModel) + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) if specErr != nil { - log.Printf("logical route omitted from catalog route_id=%s variant_id=%s: invalid capability: %v", route.ID, variant.ID, specErr) + log.Printf("logical route omitted from catalog route_id=%s channel_model_id=%s: invalid capability: %v", route.ID, channelModel.ID, specErr) continue } - cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, Variant: variant, VariantSpec: variantSpec, ChannelModel: channelModel}) + cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) } routeSpecs := make([]CapabilitySpec, 0, len(cached.Routes)) for _, route := range cached.Routes { - routeSpecs = append(routeSpecs, route.VariantSpec) + routeSpecs = append(routeSpecs, route.CapabilitySpec) } productSpec = capabilitySpecWithRoutePresets(productSpec, routeSpecs) defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec) @@ -593,7 +583,7 @@ func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequest return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") } selected := weightedRoute(eligible) - return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, Variant: selected.Variant, ChannelModel: selected.ChannelModel, Defaults: cached.Defaults}, nil + return &RoutedModel{LogicalModel: cached.Model, Revision: cached.Revision, Route: selected.Route, ChannelModel: selected.ChannelModel, Defaults: cached.Defaults}, nil } func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent ModelRequestIntent, tried map[string]bool) []cachedLogicalRoute { @@ -603,7 +593,7 @@ func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent Mode if !route.Route.Enabled || route.Route.Weight <= 0 || tried[route.Route.ID] || s.logicalRouteBlocked(route) { continue } - if match := MatchCapability(route.VariantSpec, intent); !match.Matched { + if match := MatchCapability(route.CapabilitySpec, intent); !match.Matched { continue } if route.Route.Priority > maxPriority { @@ -619,7 +609,7 @@ func (s *Service) eligibleLogicalRoutes(routes []cachedLogicalRoute, intent Mode func (s *Service) logicalRouteBlocked(route cachedLogicalRoute) bool { now := time.Now() - keys := []string{"channel:" + route.ChannelModel.ChannelID, "variant:" + route.Variant.ID, "route:" + route.Route.ID} + keys := []string{"channel:" + route.ChannelModel.ChannelID, "channel-model:" + route.ChannelModel.ID, "route:" + route.Route.ID} // 这里会删除过期项,必须使用写锁;不要改成 RLock。 s.routeHealthMu.Lock() for _, key := range keys { @@ -704,12 +694,12 @@ func (s *Service) sortedRouteDiagnostics(routes []cachedLogicalRoute, intent Mod result := make([]RouteSimulationCandidate, 0, len(routes)) poolPriority := math.MinInt for _, route := range routes { - match := MatchCapability(route.VariantSpec, intent) + match := MatchCapability(route.CapabilitySpec, intent) blocked := s.logicalRouteBlocked(route) if route.Route.Enabled && route.Route.Weight > 0 && match.Matched && !blocked && route.Route.Priority > poolPriority { poolPriority = route.Route.Priority } - result = append(result, RouteSimulationCandidate{RouteID: route.Route.ID, VariantID: route.Variant.ID, ChannelModelID: route.ChannelModel.ID, Priority: route.Route.Priority, Weight: route.Route.Weight, Enabled: route.Route.Enabled, Matched: match.Matched, Blocked: blocked, Reasons: match.Reasons}) + result = append(result, RouteSimulationCandidate{RouteID: route.Route.ID, ChannelModelID: route.ChannelModel.ID, ChannelModelKey: route.ChannelModel.ModelKey, ChannelModelName: route.ChannelModel.DisplayName, Priority: route.Route.Priority, Weight: route.Route.Weight, Enabled: route.Route.Enabled, Matched: match.Matched, Blocked: blocked, Reasons: match.Reasons}) } for index := range result { result[index].InPool = result[index].Enabled && result[index].Weight > 0 && result[index].Matched && !result[index].Blocked && result[index].Priority == poolPriority @@ -727,7 +717,7 @@ func (s *Service) createRouteAttempt(task *model.Task, routed *RoutedModel, atte if err != nil { return nil, err } - attempt := &model.RouteAttempt{ID: id, TaskID: task.ID, RouteRun: task.RouteRun, AttemptNumber: attemptNumber, LogicalModelID: routed.LogicalModel.ID, LogicalModelRevisionID: routed.Revision.ID, RouteID: routed.Route.ID, PhysicalVariantID: routed.Variant.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, Status: "selected", DispatchState: "not_sent", StartedAt: time.Now()} + attempt := &model.RouteAttempt{ID: id, TaskID: task.ID, RouteRun: task.RouteRun, AttemptNumber: attemptNumber, LogicalModelID: routed.LogicalModel.ID, LogicalModelRevisionID: routed.Revision.ID, RouteID: routed.Route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, Status: "selected", DispatchState: "not_sent", StartedAt: time.Now()} if err := s.repo.CreateRouteAttempt(attempt); err != nil { return nil, err } @@ -811,14 +801,10 @@ func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, e if !route.Enabled || route.Weight <= 0 || route.LogicalModelRevisionID != task.LogicalModelRevisionID { return nil, errors.New("任务使用的模型服务配置已失效") } - variant, err := s.repo.PhysicalVariant(task.PhysicalVariantID) - if err != nil { - return nil, err - } - if !variant.Enabled || route.PhysicalVariantID != variant.ID { + if route.ChannelModelID != task.ChannelModelID { return nil, errors.New("任务使用的模型服务配置已失效") } - channelModel, err := s.repo.ChannelModel(variant.ChannelModelID) + channelModel, err := s.repo.ChannelModel(task.ChannelModelID) if err != nil { return nil, err } @@ -847,11 +833,14 @@ func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, e if err != nil { return nil, err } - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, Variant: *variant, ChannelModel: *channelModel, Defaults: defaults} - spec, err := effectivePhysicalVariantSpec(*variant, *channelModel) - if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, Variant: *variant, VariantSpec: spec, ChannelModel: *channelModel}) { + capabilitySpec, err := channelModelCapabilitySpec(*channelModel) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { return nil, errors.New("当前模型服务暂不可用") } + if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { + return nil, errors.New("任务使用的模型服务价格配置已失效") + } + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults} return routed, nil } @@ -889,41 +878,43 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route if err != nil { return nil, err } - variants, err := s.repo.PhysicalVariantsForRoutes(routes, false) - if err != nil { - return nil, err - } - variantByID := make(map[string]model.PhysicalCapabilityVariant, len(variants)) - channelModelIDs := make([]string, 0, len(variants)) - for _, variant := range variants { - variantByID[variant.ID] = variant - channelModelIDs = append(channelModelIDs, variant.ChannelModelID) + channelModelIDs := make([]string, 0, len(routes)) + for _, route := range routes { + channelModelIDs = append(channelModelIDs, route.ChannelModelID) } channelModels, err := s.repo.ChannelModelsByIDs(channelModelIDs) if err != nil { return nil, err } + channelIDs := make([]string, 0, len(channelModels)) + for _, channelModel := range channelModels { + channelIDs = append(channelIDs, channelModel.ChannelID) + } + systemChannels, err := s.repo.SystemChannelsByIDs(channelIDs, false) + if err != nil { + return nil, err + } + enabledSystemChannels := make(map[string]bool, len(systemChannels)) + for _, channel := range systemChannels { + enabledSystemChannels[channel.ID] = true + } channelModelByID := make(map[string]model.ChannelModel, len(channelModels)) for _, channelModel := range channelModels { - if !channelModel.Enabled { - continue - } - if _, channelErr := s.repo.SystemChannel(channelModel.ChannelID); channelErr == nil { + if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModel.PriceConfigured) { channelModelByID[channelModel.ID] = channelModel } } candidates := make([]cachedLogicalRoute, 0, len(routes)) for _, route := range routes { - variant, variantOK := variantByID[route.PhysicalVariantID] - channelModel, channelOK := channelModelByID[variant.ChannelModelID] - if !variantOK || !channelOK { + channelModel, channelOK := channelModelByID[route.ChannelModelID] + if !channelOK { continue } - variantSpec, specErr := effectivePhysicalVariantSpec(variant, channelModel) + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) if specErr != nil { continue } - candidates = append(candidates, cachedLogicalRoute{Route: route, Variant: variant, VariantSpec: variantSpec, ChannelModel: channelModel}) + candidates = append(candidates, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) } tried := make(map[string]bool, len(attempts)) for _, attempt := range attempts { @@ -934,7 +925,7 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") } selected := weightedRoute(eligible) - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, Variant: selected.Variant, ChannelModel: selected.ChannelModel, Defaults: defaults} + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, Defaults: defaults} nextInput := applyRoutedProviderSelection(input, routed) if err := s.ValidateTaskCapability(nextInput); err != nil { return nil, err @@ -960,14 +951,14 @@ func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.Route replacement.Model = logicalModel.Code } previousRouteID := task.RouteID - if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, selected.Variant.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement); err != nil { + if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement); err != nil { if errors.Is(err, repository.ErrInsufficientCredits) { return nil, BadAuthRequest("模型服务价格发生变化,当前积分余额不足") } return nil, err } task.RouteID = selected.Route.ID - task.PhysicalVariantID = selected.Variant.ID + task.ChannelModelID = selected.ChannelModel.ID task.InputJSON = string(encoded) task.ProviderRequestID = "" return s.createRouteAttempt(task, routed, len(attempts)+1) @@ -997,7 +988,7 @@ func (s *Service) blockLogicalRouteForFailure(attempt *model.RouteAttempt, taskE if attempt.FailureCode == "upstream_401" || attempt.FailureCode == "upstream_403" { key, duration = "channel:"+attempt.ChannelID, 10*time.Minute } else if attempt.FailureCode == "upstream_404" { - key, duration = "variant:"+attempt.PhysicalVariantID, 10*time.Minute + key, duration = "channel-model:"+attempt.ChannelModelID, 10*time.Minute } else if attempt.FailureCode == "upstream_429" { key, duration = "channel:"+attempt.ChannelID, 30*time.Second var upstream providerHTTPError @@ -1044,7 +1035,7 @@ func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any } task.LogicalModelRevisionID = routed.Revision.ID task.RouteID = routed.Route.ID - task.PhysicalVariantID = routed.Variant.ID + task.ChannelModelID = routed.ChannelModel.ID task.Model = routed.LogicalModel.Code task.Provider = "managed" task.InputJSON = string(encoded) diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 6c54afd5a..b14b20c43 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -376,7 +376,7 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task, task.LogicalModelID = routed.LogicalModel.ID task.LogicalModelRevisionID = routed.Revision.ID task.RouteID = routed.Route.ID - task.PhysicalVariantID = routed.Variant.ID + task.ChannelModelID = routed.ChannelModel.ID task.RouteRun = 1 task.Model = routed.LogicalModel.Code task.Provider = "managed" @@ -891,10 +891,10 @@ func truncateRunes(value string, limit int) string { func taskForOutput(task model.Task) *model.Task { task.InputJSON = publicTaskInputJSON(task.InputJSON) - // 普通任务接口只暴露前台模型身份;可用配置和供应线路属于管理员内部信息。 + // 普通任务接口只暴露前台模型身份;渠道模型和供应线路属于管理员内部信息。 task.LogicalModelRevisionID = "" task.RouteID = "" - task.PhysicalVariantID = "" + task.ChannelModelID = "" return &task } diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 176209a52..34c0c53db 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -22,7 +22,6 @@ type CanvasNodeProps = { isRelated: boolean; isFocusRelated: boolean; isConnectionTarget: boolean; - isConnecting: boolean; forceInputVisible?: boolean; showImageInfo: boolean; reduceMediaEffects?: boolean; @@ -364,9 +363,9 @@ export const CanvasNode = React.memo(function CanvasNode({ /> - {hasReplaceAction ? ( + {hasReplaceAction && (!hasMediaContent || isSelected || hovered) ? (
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > @@ -436,7 +435,7 @@ export const CanvasNode = React.memo(function CanvasNode({
) : null} - {!readOnly && !data.metadata?.locked ? <> + {!readOnly && !data.metadata?.locked && (isSelected || hovered) ? <> @@ -444,12 +443,61 @@ export const CanvasNode = React.memo(function CanvasNode({ : null} - {!readOnly && data.type !== CanvasNodeType.Script ? onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null} - {!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null} + {!readOnly && data.type !== CanvasNodeType.Script && (hovered || forceInputVisible) ? onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null} + {!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && hovered ? onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null} ); -}); +}, areCanvasNodePropsEqual); + +function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProps) { + return ( + previous.data === next.data && + previous.dragOffset?.x === next.dragOffset?.x && + previous.dragOffset?.y === next.dragOffset?.y && + previous.scale === next.scale && + previous.isSelected === next.isSelected && + previous.isRelated === next.isRelated && + previous.isFocusRelated === next.isFocusRelated && + previous.isConnectionTarget === next.isConnectionTarget && + previous.forceInputVisible === next.forceInputVisible && + previous.showImageInfo === next.showImageInfo && + previous.reduceMediaEffects === next.reduceMediaEffects && + previous.readOnly === next.readOnly && + previous.resourceLabel === next.resourceLabel && + previous.mentionReferences === next.mentionReferences && + previous.renderNodeContent === next.renderNodeContent && + previous.drawingProjectId === next.drawingProjectId && + previous.batchCount === next.batchCount && + previous.batchExpanded === next.batchExpanded && + previous.batchClosing === next.batchClosing && + previous.batchOpening === next.batchOpening && + previous.batchRecovering === next.batchRecovering && + previous.batchPrimary === next.batchPrimary && + previous.batchMotion?.x === next.batchMotion?.x && + previous.batchMotion?.y === next.batchMotion?.y && + previous.batchMotion?.index === next.batchMotion?.index && + previous.onMouseDown === next.onMouseDown && + previous.onHoverStart === next.onHoverStart && + previous.onHoverEnd === next.onHoverEnd && + previous.onConnectStart === next.onConnectStart && + previous.onResize === next.onResize && + previous.onTitleChange === next.onTitleChange && + previous.onContentChange === next.onContentChange && + previous.onToggleBatch === next.onToggleBatch && + previous.onSetBatchPrimary === next.onSetBatchPrimary && + previous.onRetry === next.onRetry && + previous.onCancelTask === next.onCancelTask && + previous.onOpenTaskDetails === next.onOpenTaskDetails && + previous.onOpenVersions === next.onOpenVersions && + previous.onViewImage === next.onViewImage && + previous.onReplaceMedia === next.onReplaceMedia && + previous.onOpenTextEditor === next.onOpenTextEditor && + previous.onOpenDirector === next.onOpenDirector && + previous.onOpenDrawing === next.onOpenDrawing && + previous.onContextMenu === next.onContextMenu + ); +} function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) { return ( @@ -624,7 +672,7 @@ function NodeStatusBadge({ status }: { status: "loading" | "success" | "error" } ); } -function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { side: "left" | "right"; scale: number; visible: boolean; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) { +function ConnectionSideRail({ side, scale, theme, onPointerDown }: { side: "left" | "right"; scale: number; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) { const handleRef = useRef(null); const anchorRatioRef = useRef(0.5); const inverseScale = 1 / Math.max(scale, 0.05); @@ -634,10 +682,6 @@ function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { si if (handleRef.current) handleRef.current.style.top = "50%"; }, []); - useEffect(() => { - if (!visible) resetAnchor(); - }, [resetAnchor, visible]); - const updateAnchor = (event: React.PointerEvent) => { const railBounds = event.currentTarget.getBoundingClientRect(); const nodeBounds = event.currentTarget.parentElement?.getBoundingClientRect() || railBounds; @@ -651,7 +695,7 @@ function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { si return ( - void remove(item)}> + void remove(item)}> - - )} - -

计费

@@ -558,32 +446,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
- !configSaving && setConfigDrawerOpen(false)} - rootClassName="admin-secondary-drawer" - footer={
} - > -
-
-
-
- 唯一可用配置 - -
- {currentConfig?.usageCount ? `引用 ${currentConfig.usageCount}` : "未引用"} -
-
- - -
-
继承的模型能力
- {editingCapabilitySpec ? : 请先在“能力与协议”中完成模型能力参数。} -
-
-
); } diff --git a/web/src/pages/admin/logical-models/logical-models-page.tsx b/web/src/pages/admin/logical-models/logical-models-page.tsx index e319b00b0..98b039933 100644 --- a/web/src/pages/admin/logical-models/logical-models-page.tsx +++ b/web/src/pages/admin/logical-models/logical-models-page.tsx @@ -6,18 +6,18 @@ import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from " import { PaginationBar } from "@/components/layout/workspace-page"; import { ModelIconPicker, ModelLogo } from "@/components/model-logo"; +import { CapabilityCardPicker } from "@/components/model-protocol-picker"; import { formatCredits } from "@/constant/credits"; import { AdminPageFrame } from "@/pages/admin/components/admin-shell"; import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "@/pages/admin/components/admin-ui"; import { listAdminChannels } from "@/services/api/auth"; +import { listAdminChannelModels, type ChannelModel } from "@/services/api/wallet"; import { createAdminLogicalModel, listAdminLogicalModels, - listAdminPhysicalVariants, simulateAdminLogicalModel, updateAdminLogicalModel, type AdminLogicalModel, - type AdminPhysicalVariant, type CapabilitySpec, type LogicalModelMutation, type ModelRequestIntent, @@ -29,6 +29,7 @@ import { CapabilitySummary, DefaultOptionsEditor, capabilityLabel, + capabilitySpecFromChannelModel, capabilitySourceError, emptyCapabilitySpec, mergeCapabilitySpecs, @@ -38,7 +39,7 @@ import { type CapabilityKind, } from "./model-routing-capabilities"; -type RouteRuleRow = { physicalVariantId: string; enabled: boolean; priority: number; weight: number }; +type RouteRuleRow = { channelModelId: string; enabled: boolean; priority: number; weight: number }; type LogicalModelFormValues = { code: string; name: string; @@ -57,13 +58,6 @@ type LogicalModelFormValues = { defaultOptions: Record; routes: RouteRuleRow[]; }; -const capabilityOptions = [ - { label: "文本", value: "text" }, - { label: "图片", value: "image" }, - { label: "视频", value: "video" }, - { label: "音频", value: "audio" }, -]; - export default function LogicalModelsPage() { const { message } = App.useApp(); const [keyword, setKeyword] = useState(""); @@ -72,7 +66,7 @@ export default function LogicalModelsPage() { const deferredKeyword = useDeferredValue(keyword.trim().toLowerCase()); const [loading, setLoading] = useState(true); const [models, setModels] = useState([]); - const [variants, setVariants] = useState([]); + const [channelModels, setChannelModels] = useState([]); const [channelNames, setChannelNames] = useState>({}); const [channelEnabled, setChannelEnabled] = useState>({}); const [editingModel, setEditingModel] = useState(); @@ -89,11 +83,16 @@ export default function LogicalModelsPage() { const reload = async () => { setLoading(true); try { - const [modelResult, variantResult, channelResult] = await Promise.all([listAdminLogicalModels(), listAdminPhysicalVariants(), listAdminChannels({ page: 1, limit: 100 })]); + const [modelResult, firstChannelPage] = await Promise.all([listAdminLogicalModels(), listAdminChannels({ page: 1, limit: 100 })]); + const remainingChannelPages = await Promise.all( + Array.from({ length: Math.max(0, Math.ceil(firstChannelPage.total / firstChannelPage.limit) - 1) }, (_, index) => listAdminChannels({ page: index + 2, limit: firstChannelPage.limit })), + ); + const channels = [firstChannelPage, ...remainingChannelPages].flatMap((result) => result.channels); + const channelModelResults = await Promise.all(channels.map((channel) => listAdminChannelModels(channel.id))); setModels(modelResult.models); - setVariants(variantResult.variants); - setChannelNames(Object.fromEntries(channelResult.channels.map((channel) => [channel.id, channel.name]))); - setChannelEnabled(Object.fromEntries(channelResult.channels.map((channel) => [channel.id, channel.enabled !== false]))); + setChannelModels(channelModelResults.flatMap((result) => result.models)); + setChannelNames(Object.fromEntries(channels.map((channel) => [channel.id, channel.name]))); + setChannelEnabled(Object.fromEntries(channels.map((channel) => [channel.id, channel.enabled !== false]))); } catch (error) { message.error(error instanceof Error ? error.message : "读取前台模型配置失败"); } finally { @@ -107,14 +106,15 @@ export default function LogicalModelsPage() { const filteredModels = useMemo(() => models.filter((item) => !deferredKeyword || [item.name, item.code, item.capability].some((value) => value.toLowerCase().includes(deferredKeyword))), [models, deferredKeyword]); const paginatedModels = useMemo(() => filteredModels.slice((page - 1) * pageSize, page * pageSize), [filteredModels, page, pageSize]); - const modelVariants = useMemo(() => variants.filter((item) => item.capability === modelCapability), [variants, modelCapability]); + const modelChannelModels = useMemo(() => channelModels.filter((item) => item.capability === modelCapability), [channelModels, modelCapability]); const modelSourceSpecs = useMemo( () => modelRoutes .filter((route) => route.enabled && route.weight > 0) - .map((route) => variants.find((item) => item.id === route.physicalVariantId && item.enabled && item.modelEnabled && channelEnabled[item.channelId] !== false)?.capabilitySpec) + .map((route) => channelModels.find((item) => item.id === route.channelModelId && item.enabled && channelEnabled[item.channelId] !== false)) + .map((item) => (item ? capabilitySpecFromChannelModel(item) : undefined)) .filter((item): item is CapabilitySpec => Boolean(item)), - [channelEnabled, modelRoutes, variants], + [channelEnabled, channelModels, modelRoutes], ); const openModel = (item?: AdminLogicalModel) => { @@ -148,7 +148,7 @@ export default function LogicalModelsPage() { const saveModel = async () => { const values = await modelForm.validateFields(); if (values.enabled && !values.routes.length) { - message.error("请至少添加一条供应配置"); + message.error("请至少添加一条供应线路"); return; } const sourceError = capabilitySourceError(values.capability, modelSourceSpecs, values.capabilitySpec); @@ -218,9 +218,9 @@ export default function LogicalModelsPage() { ), }, { title: "类型", dataIndex: "capability", width: 90, render: (value: CapabilityKind) => capabilityLabel(value) }, - { title: "创作端能力", render: (_, item) => }, + { title: "创作端能力", width: 360, render: (_, item) => }, { - title: "供应配置", + title: "供应线路", width: 110, render: (_, item) => (
@@ -239,7 +239,7 @@ export default function LogicalModelsPage() { , onClick: () => openModel(item) }} actions={[ - { key: "simulate", label: "模拟供应配置匹配", icon: , onClick: () => openSimulation(item) }, + { key: "simulate", label: "模拟供应线路匹配", icon: , onClick: () => openSimulation(item) }, { key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) }, ]} /> @@ -276,13 +276,28 @@ export default function LogicalModelsPage() { rootClassName="admin-drawer" footer={
} > -
+ ) => { + const capability = changedValues.capability; + if (!capability) return; + modelForm.setFieldsValue({ + routes: [], + capabilitySpec: emptyCapabilitySpec(capability), + defaultOptions: {}, + billingMode: capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request", + }); + }} + > {editingModel?.configurationError || editingModel?.availabilityError ? ( ) : null} @@ -301,15 +316,10 @@ export default function LogicalModelsPage() { -
- - { + onChange={(channelModelId) => { const nextRoutes = [...(form.getFieldValue("routes") || [])]; - nextRoutes[field.name] = { ...nextRoutes[field.name], physicalVariantId }; + nextRoutes[field.name] = { ...nextRoutes[field.name], channelModelId }; const specs = nextRoutes .filter((route) => route.enabled && route.weight > 0) - .map((route) => variants.find((item) => item.id === route.physicalVariantId && item.enabled && item.modelEnabled && channelEnabled[item.channelId] !== false)?.capabilitySpec) + .map((route) => channelModels.find((item) => item.id === route.channelModelId && item.enabled && channelEnabled[item.channelId] !== false)) + .map((item) => (item ? capabilitySpecFromChannelModel(item) : undefined)) .filter((item): item is CapabilitySpec => Boolean(item)); form.setFieldValue("routes", nextRoutes); if (!hasCapabilityRules(form.getFieldValue("capabilitySpec"))) form.setFieldValue("capabilitySpec", mergeCapabilitySpecs(capability, specs)); @@ -498,7 +509,7 @@ function RouteFields({ })}
) : null} - {!fields.length ?
尚未添加供应配置
: null} + {!fields.length ?
尚未添加供应线路
: null}
); }} @@ -569,7 +580,7 @@ function PricingFields() { ) : null} ) : ( -
用户费用按实际命中的供应配置价格计算。故障切换到更高价格配置时会重新校验余额。
+
用户费用按实际命中的供应线路价格计算。故障切换到更高价格线路时会重新校验余额。
)} ); @@ -613,7 +624,7 @@ function logicalModelToForm(item: AdminLogicalModel): LogicalModelFormValues { cachedPriceMicrocredits: item.cachedPriceMicrocredits, capabilitySpec: item.capabilitySpec, defaultOptions: item.defaultOptions, - routes: item.routes.map((route) => ({ physicalVariantId: route.physicalVariantId, enabled: route.enabled, priority: route.priority, weight: route.weight })), + routes: item.routes.map((route) => ({ channelModelId: route.channelModelId, enabled: route.enabled, priority: route.priority, weight: route.weight })), }; } @@ -643,9 +654,12 @@ function hasCapabilityRules(spec?: CapabilitySpec) { return Boolean(spec && ((spec.operations?.length || 0) > 0 || Object.keys(spec.inputs || {}).length > 0 || Object.keys(spec.options || {}).length > 0)); } -function simulationColumns(model: AdminLogicalModel): ColumnsType { +function simulationColumns(): ColumnsType { return [ - { title: "供应配置", render: (_, candidate) => model.routes.find((route) => route.id === candidate.routeId)?.physicalVariantName || "未知配置" }, + { + title: "供应线路", + render: (_, candidate) => `${candidate.channelModelName}(${candidate.channelModelKey})`, + }, { title: "优先级", dataIndex: "priority", width: 80 }, { title: "权重", dataIndex: "weight", width: 70 }, { diff --git a/web/src/pages/admin/logical-models/model-routing-capabilities.tsx b/web/src/pages/admin/logical-models/model-routing-capabilities.tsx index 147793386..45667d11d 100644 --- a/web/src/pages/admin/logical-models/model-routing-capabilities.tsx +++ b/web/src/pages/admin/logical-models/model-routing-capabilities.tsx @@ -66,7 +66,7 @@ export function emptyCapabilitySpec(capability: CapabilityKind): CapabilitySpec } function imageSizeOptionValues(values: string[], allowCustom: boolean) { - const concreteValues = Array.from(new Set(values.map((value) => value.trim()).filter((value) => value && value !== "*"))); + const concreteValues = Array.from(new Set(values.map((value) => String(normalizeScalar(value)).trim()).filter((value) => value && value !== "*"))); const result = concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES]; if (allowCustom) result.push("*"); return result; @@ -437,11 +437,13 @@ export function CapabilitySummary({ spec }: { spec: CapabilitySpec }) { if (spec.operations?.length) labels.unshift(spec.operations.map(operationLabel).join("/")); if (!labels.length) return 基础能力; return ( -
+
{labels.slice(0, 4).map((label) => ( - {label} + + {label} + ))} - {labels.length > 4 ? +{labels.length - 4} : null} + {labels.length > 4 ? +{labels.length - 4} : null}
); } diff --git a/web/src/pages/admin/settings/drawing-engine-settings-page.tsx b/web/src/pages/admin/settings/drawing-engine-settings-page.tsx index 9f31fce9b..9e5a8d6af 100644 --- a/web/src/pages/admin/settings/drawing-engine-settings-page.tsx +++ b/web/src/pages/admin/settings/drawing-engine-settings-page.tsx @@ -54,7 +54,7 @@ export default function DrawingEngineSettingsPage() { const dirty = engine !== current.defaultEngine || tldrawLicenseKey !== (current.tldrawLicenseKey || ""); return ( - +
} diff --git a/web/src/pages/admin/settings/libtv-settings-page.tsx b/web/src/pages/admin/settings/libtv-settings-page.tsx index 404d2aead..46d7b239e 100644 --- a/web/src/pages/admin/settings/libtv-settings-page.tsx +++ b/web/src/pages/admin/settings/libtv-settings-page.tsx @@ -83,7 +83,7 @@ export default function LibTVSettingsPage() { }; return ( - +
} diff --git a/web/src/pages/admin/settings/response-interception-settings-page.tsx b/web/src/pages/admin/settings/response-interception-settings-page.tsx index 39c919588..576c512e2 100644 --- a/web/src/pages/admin/settings/response-interception-settings-page.tsx +++ b/web/src/pages/admin/settings/response-interception-settings-page.tsx @@ -59,7 +59,7 @@ export default function ResponseInterceptionSettingsPage() { }; return ( - +
} > setDirty(true)}> diff --git a/web/src/pages/admin/settings/storage-settings-page.tsx b/web/src/pages/admin/settings/storage-settings-page.tsx index c8a132777..5b8f94f13 100644 --- a/web/src/pages/admin/settings/storage-settings-page.tsx +++ b/web/src/pages/admin/settings/storage-settings-page.tsx @@ -83,7 +83,7 @@ export default function StorageSettingsPage() { }; return ( - +
- + , label: "导入 LibTV 画布", onClick: onImportLibTV }, + { key: "tapnow", icon: , label: "导入 TapNow 画布", onClick: onImportTapNow }, + ], + }} + > + + @@ -126,7 +126,6 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) { isRelated={props.relatedNodeIds.has(node.id)} isFocusRelated={props.activeNodeId === node.id} isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id} - isConnecting={Boolean(props.connectingParams || props.batchConnectionPreview)} forceInputVisible={Boolean(props.batchConnectionPreview)} batchCount={props.batchChildCountById.get(node.id) || 0} batchExpanded={Boolean(node.metadata?.imageBatchExpanded)} @@ -136,7 +135,7 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) { batchPrimary={Boolean(node.metadata?.batchRootId && props.nodeById.get(node.metadata.batchRootId)?.metadata?.primaryImageId === node.id)} batchMotion={props.batchMotionById.get(node.id)} showImageInfo={props.showImageInfo} - reduceMediaEffects={props.reduceMediaEffects || props.isNodeDragging || props.mediaEffectsDisabledNodeId === node.id} + reduceMediaEffects={props.reduceMediaEffects || props.mediaEffectsDisabledNodeId === node.id} resourceLabel={props.resourceReferenceByNodeId.get(node.id)} mentionReferences={props.mentionReferencesByNodeId.get(node.id) || EMPTY_RESOURCE_REFERENCES} renderNodeContent={props.renderCanvasNodeContent} @@ -187,7 +186,7 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) { ) : null} ); -} +}); function BatchConnectionHandle({ scale, count, active, onPointerDown }: { scale: number; count: number; active: boolean; onPointerDown: (event: ReactPointerEvent) => void }) { const inverseScale = 1 / Math.max(scale, 0.05); diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 9c9fc2236..0c13e72d6 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -560,6 +560,7 @@ function InfiniteCanvasPage() { setContextMenu, setDialogNodeId, }); + const replaceCanvasNodeMedia = useCallback((node: CanvasNodeData) => handleUploadRequest(node.id), [handleUploadRequest]); useEffect(() => { if (!projectLoaded || !assetsHydrated || searchParams.get("mode") !== "handoff") return; @@ -1840,7 +1841,7 @@ function InfiniteCanvasPage() { onOpenTaskDetails={openCanvasNodeTaskDetails} onOpenVersions={openCanvasNodeVersions} onViewImage={viewCanvasNodeImage} - onReplaceMedia={(node) => handleUploadRequest(node.id)} + onReplaceMedia={replaceCanvasNodeMedia} onOpenTextEditor={openTextNodeEditor} onOpenDirector={editCanvasDirector} onOpenDrawing={openDrawingNode} diff --git a/web/src/pages/canvas/shared.tsx b/web/src/pages/canvas/shared.tsx index ca3aef68f..1f279128c 100644 --- a/web/src/pages/canvas/shared.tsx +++ b/web/src/pages/canvas/shared.tsx @@ -244,7 +244,7 @@ export default function SharedCanvasPage() { const dragged = [node, ...(frameChildrenById.get(nodeId) || [])]; dragRef.current = { primaryId: nodeId, nodeIds: dragged.map((item) => item.id), startX: event.clientX, startY: event.clientY, origins: new Map(dragged.map((item) => [item.id, item.position])), moved: false }; document.body.style.cursor = "grabbing"; - }} onResize={() => undefined} onToggleCollapsed={toggleFrame} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : { + }} onResize={() => undefined} onToggleCollapsed={toggleFrame} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : { event.stopPropagation(); if (event.button !== 0) return; const target = nodes.find((item) => item.id === nodeId); diff --git a/web/src/pages/canvas/use-canvas-render-model.ts b/web/src/pages/canvas/use-canvas-render-model.ts index bd8842928..3eebf6cab 100644 --- a/web/src/pages/canvas/use-canvas-render-model.ts +++ b/web/src/pages/canvas/use-canvas-render-model.ts @@ -110,7 +110,7 @@ export function useCanvasRenderModel({ return { left, top, width: Math.max(2, right - left), height: Math.max(2, bottom - top) }; }, [nodes]); const renderBounds = useMemo(() => { - const padding = (reduceMediaEffects ? Math.max(240, Math.max(viewportSize.width, viewportSize.height) * 0.4) : Math.max(800, Math.max(viewportSize.width, viewportSize.height) * 1.5)) / viewport.k; + const padding = (reduceMediaEffects ? 240 : 360) / viewport.k; const viewLeft = -viewport.x / viewport.k - padding; const viewTop = -viewport.y / viewport.k - padding; const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2; @@ -121,11 +121,13 @@ export function useCanvasRenderModel({ const frames: CanvasNodeData[] = []; const regular: CanvasNodeData[] = []; nodes.forEach((node) => { - if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom) return; + if (renderHiddenNodeIds.has(node.id)) return; + const retained = selectedNodeIds.has(node.id) || Boolean(dragPreview?.nodeIds.has(node.id)); + if (!retained && (node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom)) return; (isFrameNode(node) ? frames : regular).push(node); }); return [...frames, ...regular]; - }, [nodes, renderBounds, renderHiddenNodeIds]); + }, [dragPreview, nodes, renderBounds, renderHiddenNodeIds, selectedNodeIds]); const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]); const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]); diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index 79ee8fe35..9c2813c33 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -39,12 +39,10 @@ export type PublicLogicalModel = { export type AdminLogicalRoute = { id: string; - physicalVariantId: string; - physicalVariantName: string; channelModelId: string; channelId: string; - physicalModelKey: string; - physicalModelName: string; + channelModelKey: string; + channelModelName: string; enabled: boolean; priority: number; weight: number; @@ -61,29 +59,6 @@ export type AdminLogicalModel = PublicLogicalModel & { routes: AdminLogicalRoute[]; }; -export type AdminPhysicalVariant = { - id: string; - channelModelId: string; - channelId: string; - modelKey: string; - modelName: string; - name: string; - capability: CapabilitySpec["capability"]; - protocol: string; - enabled: boolean; - modelEnabled: boolean; - usageCount: number; - capabilitySpec: CapabilitySpec; -}; - -export type PhysicalVariantMutation = { - channelModelId: string; - name: string; - enabled: boolean; - /** 能力范围由渠道模型能力参数自动生成;保留可选字段兼容旧调用方。 */ - capabilitySpec?: CapabilitySpec; -}; - export type LogicalModelMutation = { code: string; name: string; @@ -100,12 +75,12 @@ export type LogicalModelMutation = { cachedPriceMicrocredits: number; capabilitySpec: CapabilitySpec; defaultOptions: Record; - routes: Array<{ physicalVariantId: string; enabled: boolean; priority: number; weight: number }>; + routes: Array<{ channelModelId: string; enabled: boolean; priority: number; weight: number }>; }; export type RouteSimulationResult = { productMatch: { matched: boolean; reasons?: string[] }; - candidates: Array<{ routeId: string; variantId: string; channelModelId: string; priority: number; weight: number; enabled: boolean; matched: boolean; blocked: boolean; inPool: boolean; reasons?: string[] }>; + candidates: Array<{ routeId: string; channelModelId: string; channelModelKey: string; channelModelName: string; priority: number; weight: number; enabled: boolean; matched: boolean; blocked: boolean; inPool: boolean; reasons?: string[] }>; }; export function listLogicalModels() { @@ -128,18 +103,6 @@ export function updateAdminLogicalModel(id: string, input: LogicalModelMutation) return request<{ model: AdminLogicalModel }>(apiClient.patch(`/admin/logical-models/${encodeURIComponent(id)}`, input)); } -export function listAdminPhysicalVariants(channelModelId?: string) { - return request<{ variants: AdminPhysicalVariant[] }>(apiClient.get("/admin/logical-models/physical-variants", { params: channelModelId ? { channelModelId } : undefined })); -} - -export function createAdminPhysicalVariant(input: PhysicalVariantMutation) { - return request<{ variant: AdminPhysicalVariant }>(apiClient.post("/admin/logical-models/physical-variants", input)); -} - -export function updateAdminPhysicalVariant(id: string, input: PhysicalVariantMutation) { - return request<{ variant: AdminPhysicalVariant }>(apiClient.patch(`/admin/logical-models/physical-variants/${encodeURIComponent(id)}`, input)); -} - export function simulateAdminLogicalModel(id: string, intent: ModelRequestIntent) { return request(apiClient.post(`/admin/logical-models/${encodeURIComponent(id)}/simulate`, intent)); } diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index ae1ff7736..9756d5d1d 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -1390,7 +1390,8 @@ .app-workspace-shell.is-spatial .app-workspace-rail .app-workspace-rail-tile { width: 40px !important; - height: 40px !important; + height: auto !important; + min-height: 40px; border-radius: var(--r-md); margin-bottom: 0 !important; } @@ -7725,6 +7726,11 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: height: 100%; } +.admin-page-root.admin-page-root-scrollable > div { + height: auto; + min-height: 100%; +} + .admin-page-frame { display: flex; height: 100%; @@ -7733,6 +7739,11 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: padding: 12px 16px 0; } +.admin-page-frame-scrollable { + height: auto; + min-height: 100%; +} + .admin-page-header { min-height: 44px; }