refactor(core): 模型路由与画布 - 收敛渠道模型映射并优化交互

This commit is contained in:
ddCat
2026-08-19 20:27:26 +08:00
parent 52424543ea
commit d47975f9bc
34 changed files with 601 additions and 901 deletions

View File

@@ -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

View File

@@ -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})
}

View File

@@ -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"`

View File

@@ -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"`

View File

@@ -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(),
}

View File

@@ -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
}

View File

@@ -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("渠道模型不存在或已删除")
}

View File

@@ -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"]))

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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,使用空能力规格表示“无额外路由约束”。

View File

@@ -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)

View File

@@ -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
}