diff --git a/.gitignore b/.gitignore index ffb4b0447..fb56951e4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ docs/content/docs/plugins/* !docs/content/docs/backend/ docs/content/docs/backend/* !docs/content/docs/backend/task-runtime-stability.mdx +!docs/content/docs/backend/newapi-channel-2.mdx .env* !.env.example data/ diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ff44fde3c..d99a0f399 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -120,6 +120,7 @@ func run(ctx context.Context) error { handler.RegisterAdminAnalyticsRoutes(api, svc) handler.RegisterAdminStorageRoutes(api, svc) handler.RegisterAdminUpdateRoutes(api, svc) + handler.RegisterAdminSystemPerformanceRoutes(api, svc) handler.RegisterAnnouncementRoutes(api, svc) handler.RegisterFinanceRoutes(api, svc) handler.RegisterPaymentRoutes(api, svc) diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 785d94f62..09dcace10 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -178,6 +178,13 @@ func migrateSchemaV6(tx *gorm.DB) error { return nil } +func migrateSchemaV7(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.Asset{}, &model.AssetFolder{}); err != nil { + return fmt.Errorf("创建个人素材分类并扩展素材目录字段:%w", err) + } + return nil +} + func migrateSchemaV8(tx *gorm.DB) error { if !tx.Migrator().HasTable(&model.LogicalModel{}) { return nil @@ -191,12 +198,6 @@ func migrateSchemaV8(tx *gorm.DB) error { return nil } -func migrateSchemaV7(tx *gorm.DB) error { - if err := tx.AutoMigrate(&model.Asset{}, &model.AssetFolder{}); err != nil { - return fmt.Errorf("创建个人素材分类并扩展素材目录字段:%w", err) - } - return nil -} func MigrateSchema(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { if tx.Dialector.Name() == "postgres" { diff --git a/backend/internal/handler/admin_system_performance.go b/backend/internal/handler/admin_system_performance.go new file mode 100644 index 000000000..869ab6c6c --- /dev/null +++ b/backend/internal/handler/admin_system_performance.go @@ -0,0 +1,45 @@ +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterAdminSystemPerformanceRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/system-performance", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + result, err := svc.AdminSystemPerformance(c.Request.Context(), actor) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + + r.POST("/admin/system-performance/cache/clear", func(c *gin.Context) { + actor, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4<<10) + var request service.AdminCacheClearRequest + if err := c.ShouldBindJSON(&request); err != nil { + failService(c, service.BadAuthRequest("缓存清理请求无效")) + return + } + result, err := svc.ClearAdminRuntimeCache(c.Request.Context(), actor, request) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) +} diff --git a/backend/internal/handler/admin_system_performance_test.go b/backend/internal/handler/admin_system_performance_test.go new file mode 100644 index 000000000..fca44ed82 --- /dev/null +++ b/backend/internal/handler/admin_system_performance_test.go @@ -0,0 +1,30 @@ +package handler + +import ( + "testing" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func TestAdminSystemPerformanceRoutesAreRegistered(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + RegisterAdminSystemPerformanceRoutes(router.Group("/api"), &service.Service{}) + wanted := map[string]bool{ + "GET /api/admin/system-performance": false, + "POST /api/admin/system-performance/cache/clear": false, + } + for _, route := range router.Routes() { + key := route.Method + " " + route.Path + if _, exists := wanted[key]; exists { + wanted[key] = true + } + } + for route, found := range wanted { + if !found { + t.Errorf("route %s is not registered", route) + } + } +} diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index e62653806..5557b2d79 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -375,18 +375,6 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"ok": true}) }) - r.DELETE("/admin/users/:id/permanent", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.PurgeUser(user, c.Param("id")); err != nil { - failService(c, err) - return - } - ok(c, gin.H{"ok": true}) - }) r.GET("/admin/channels", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index e68a9156b..4918e2445 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -269,6 +269,27 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) { r.POST("/admin/channels/:id/models", func(c *gin.Context) { saveChannelModel(c, svc, "") }) + r.POST("/admin/channels/:id/models/batch-delete", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10) + var req struct { + ModelIDs []string `json:"modelIds"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + deleted, err := svc.DeleteAdminChannelModels(user, c.Param("id"), req.ModelIDs) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"deleted": deleted}) + }) r.PATCH("/admin/channels/:id/models/:modelId", func(c *gin.Context) { saveChannelModel(c, svc, c.Param("modelId")) }) diff --git a/backend/internal/handler/finance_routes_test.go b/backend/internal/handler/finance_routes_test.go new file mode 100644 index 000000000..9cc6fe4ab --- /dev/null +++ b/backend/internal/handler/finance_routes_test.go @@ -0,0 +1,23 @@ +package handler + +import ( + "testing" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func TestFinanceRoutesExposeChannelModelBatchDelete(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + RegisterFinanceRoutes(router.Group("/api"), &service.Service{}) + + const wanted = "POST /api/admin/channels/:id/models/batch-delete" + for _, route := range router.Routes() { + if route.Method+" "+route.Path == wanted { + return + } + } + t.Fatalf("route %s is not registered", wanted) +} diff --git a/backend/internal/protocol/official_catalog_test.go b/backend/internal/protocol/official_catalog_test.go index 2927db4a9..2c7df04ee 100644 --- a/backend/internal/protocol/official_catalog_test.go +++ b/backend/internal/protocol/official_catalog_test.go @@ -377,6 +377,55 @@ func TestOfficialRollDekWanVideoUsesVideosLifecycle(t *testing.T) { } } +func TestNewAPIVideoGenerationsParsesNestedTaskIDs(t *testing.T) { + adapter := officialPackageAdapter(t, "newapi-video-generations-v1.yingce-plugin", "newapi-channel-2") + tests := []struct { + name string + payload string + wantID string + }{ + {name: "snake case", payload: `{"data":{"task_id":"task-snake","status":"queued"}}`, wantID: "task-snake"}, + {name: "camel case", payload: `{"data":{"taskId":"task-camel","status":"queued"}}`, wantID: "task-camel"}, + {name: "nested upstream id wins over wrapper id", payload: `{"id":"49137","data":{"task_id":"task-upstream","status":"queued"}}`, wantID: "task-upstream"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + created, err := adapter.ParseCreate(context.Background(), []byte(test.payload)) + if err != nil { + t.Fatal(err) + } + if created.TaskID != test.wantID || created.Status != StatusPending { + t.Fatalf("created = %#v, want task ID %q and pending status", created, test.wantID) + } + }) + } +} + +func TestNewAPIVideoGenerationsParsesNestedVideoResults(t *testing.T) { + adapter := officialPackageAdapter(t, "newapi-video-generations-v1.yingce-plugin", "newapi-channel-2") + tests := []struct { + name string + payload string + }{ + {name: "channel result URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","result_url":"https://cdn.example/channel-result.mp4"}}`}, + {name: "provider nested video URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","data":{"status":"completed","video_url":"https://cdn.example/provider-result.mp4"}}}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + state, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "task-upstream"}, []byte(test.payload)) + if err != nil { + t.Fatal(err) + } + if state.Status != StatusSucceeded || state.Result == nil || len(state.Result.Videos) != 1 { + t.Fatalf("state = %#v, want one completed video", state) + } + if state.Result.Videos[0].URL == "" { + t.Fatalf("video = %#v, want a result URL", state.Result.Videos[0]) + } + }) + } +} + func TestOfficialOpenAIVideosDeclaresAuthenticatedResultDownload(t *testing.T) { adapter := officialPackageAdapter(t, "openai-videos.yingce-plugin", "newapi") capability, ok := adapter.(ResultCapability) diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 820b39795..0d80734ad 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -3,6 +3,7 @@ package repository import ( "crypto/rand" "encoding/hex" + "encoding/json" "errors" "strconv" "strings" @@ -230,12 +231,43 @@ func (r *Repository) PopulateChannelModelPriceTier(item *model.ChannelModel) err return r.attachChannelModelPriceTiers([]*model.ChannelModel{item}) } -func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON string, now time.Time) error { - return r.db.Transaction(func(tx *gorm.DB) error { +func (r *Repository) DeleteChannelModel(channelID string, id string, now time.Time) error { + deleted, err := r.DeleteChannelModels(channelID, []string{id}, now) + if err != nil { + return err + } + if deleted != 1 { + return gorm.ErrRecordNotFound + } + return nil +} + +// DeleteChannelModels atomically removes a selection and refreshes the channel's +// compatibility model list. Any active route or task reference aborts the whole +// transaction so a bulk action cannot leave the administrator with a partial result. +func (r *Repository) DeleteChannelModels(channelID string, ids []string, now time.Time) (int64, error) { + ids = uniqueStrings(ids) + if len(ids) == 0 { + return 0, gorm.ErrRecordNotFound + } + var deleted int64 + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := r.lockSystemChannelForModelMutation(tx, channelID); err != nil { + return err + } + var existing int64 + if err := tx.Model(&model.ChannelModel{}). + Where("channel_id = ? AND id IN ?", channelID, ids). + Count(&existing).Error; err != nil { + return err + } + if existing != int64(len(ids)) { + return gorm.ErrRecordNotFound + } 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). + Where("route.channel_model_id IN ?", ids). Count(&activeReferences).Error; err != nil { return err } @@ -243,7 +275,7 @@ func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON return ErrChannelModelInUse } if err := tx.Model(&model.Task{}). - Where("channel_model_id = ? AND status IN ?", id, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Where("channel_model_id IN ? AND status IN ?", ids, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). Count(&activeReferences).Error; err != nil { return err } @@ -251,28 +283,63 @@ func (r *Repository) DeleteChannelModel(channelID string, id string, modelsJSON return ErrChannelModelInUse } result := tx.Model(&model.ChannelModel{}). - Where("id = ? AND channel_id = ?", id, channelID). + Where("id IN ? AND channel_id = ?", ids, channelID). Updates(map[string]any{"enabled": false, "price_version": gorm.Expr("price_version + 1"), "updated_at": now}) if result.Error != nil { return result.Error } - if result.RowsAffected != 1 { + if result.RowsAffected != int64(len(ids)) { return gorm.ErrRecordNotFound } - if err := tx.Where("id = ? AND channel_id = ?", id, channelID).Delete(&model.ChannelModel{}).Error; err != nil { + if err := tx.Where("id IN ? AND channel_id = ?", ids, channelID).Delete(&model.ChannelModel{}).Error; err != nil { return err } - channelResult := tx.Model(&model.ModelChannel{}). - Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem). - Updates(map[string]any{"models_json": modelsJSON, "updated_at": now}) - if channelResult.Error != nil { - return channelResult.Error - } - if channelResult.RowsAffected != 1 { - return gorm.ErrRecordNotFound + if err := refreshChannelModelNames(tx, channelID, now); err != nil { + return err } + deleted = result.RowsAffected return nil }) + return deleted, err +} + +// SyncChannelModelNames serializes compatibility-list refreshes for a system +// channel and derives the list from the committed channel_models rows. Callers +// must not pass a previously-read snapshot because concurrent administrator +// writes could otherwise overwrite a newer catalog. +func (r *Repository) SyncChannelModelNames(channelID string, now time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := r.lockSystemChannelForModelMutation(tx, channelID); err != nil { + return err + } + return refreshChannelModelNames(tx, channelID, now) + }) +} + +func (r *Repository) lockSystemChannelForModelMutation(tx *gorm.DB, channelID string) error { + query := tx.Select("id").Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var channel model.ModelChannel + return query.First(&channel).Error +} + +func refreshChannelModelNames(tx *gorm.DB, channelID string, now time.Time) error { + var names []string + if err := tx.Model(&model.ChannelModel{}). + Where("channel_id = ? AND enabled = ?", channelID, true). + Order("created_at asc"). + Pluck("model_key", &names).Error; err != nil { + return err + } + encoded, err := json.Marshal(names) + if err != nil { + return err + } + return tx.Model(&model.ModelChannel{}). + Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem). + Updates(map[string]any{"models_json": string(encoded), "updated_at": now}).Error } func (r *Repository) CreateMissingChannelModels(items []model.ChannelModel) (int64, error) { diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 53f057e6c..998008a2c 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -285,20 +285,6 @@ func (r *Repository) DeleteUserAuthSessions(userID string) error { return r.db.Delete(&model.AuthSession{}, "user_id = ?", userID).Error } -// CountUserRowsByTable 统计目标用户在给定表(均须含 user_id 列)中的行数,供删除前守卫使用。 -func (r *Repository) CountUserRowsByTable(userID string, tables []string) (map[string]int64, error) { - counts := make(map[string]int64, len(tables)) - for _, table := range tables { - var n int64 - if err := r.db.Table(table).Where("user_id = ?", userID).Count(&n).Error; err != nil { - return nil, err - } - if n > 0 { - counts[table] = n - } - } - return counts, nil -} func (r *Repository) LatestEmailVerificationCode(email string, purpose string) (*model.EmailVerificationCode, error) { var code model.EmailVerificationCode if err := r.db.Where("email = ? AND purpose = ? AND used_at IS NULL", email, purpose).Order("created_at desc").First(&code).Error; err != nil { diff --git a/backend/internal/repository/system_performance.go b/backend/internal/repository/system_performance.go new file mode 100644 index 000000000..c013fc26b --- /dev/null +++ b/backend/internal/repository/system_performance.go @@ -0,0 +1,107 @@ +package repository + +import ( + "context" + "database/sql" + "strconv" + "time" + + "infinite-canvas/backend/internal/database" +) + +type DatabasePoolStats struct { + MaxOpenConnections int `json:"maxOpenConnections"` + OpenConnections int `json:"openConnections"` + InUse int `json:"inUse"` + Idle int `json:"idle"` + WaitCount int64 `json:"waitCount"` + WaitDuration time.Duration `json:"-"` + WaitDurationMs int64 `json:"waitDurationMs"` + MaxIdleClosed int64 `json:"maxIdleClosed"` + MaxLifetimeClosed int64 `json:"maxLifetimeClosed"` +} + +type PostgresRuntimeStats struct { + ServerVersion string `json:"serverVersion,omitempty"` + DatabaseBytes int64 `json:"databaseBytes,omitempty"` + Connections int64 `json:"connections,omitempty"` + MaxConnections int64 `json:"maxConnections,omitempty"` + Transactions int64 `json:"transactions,omitempty"` + Rollbacks int64 `json:"rollbacks,omitempty"` + CacheHitRate float64 `json:"cacheHitRate,omitempty"` + TempFiles int64 `json:"tempFiles,omitempty"` + TempBytes int64 `json:"tempBytes,omitempty"` + Deadlocks int64 `json:"deadlocks,omitempty"` +} + +type DatabaseRuntimeStats struct { + Driver string `json:"driver"` + Connected bool `json:"connected"` + LatencyMs int64 `json:"latencyMs"` + DatabaseBytes int64 `json:"databaseBytes,omitempty"` + Schema database.SchemaStatus `json:"schema"` + Pool DatabasePoolStats `json:"pool"` + Postgres *PostgresRuntimeStats `json:"postgres,omitempty"` +} + +func (r *Repository) DatabaseRuntimeStats(ctx context.Context) (DatabaseRuntimeStats, error) { + result := DatabaseRuntimeStats{Driver: r.Dialect(), Schema: database.SchemaStatus{Expected: database.CurrentSchemaVersion}} + sqlDB, err := r.db.DB() + if err != nil { + return result, err + } + started := time.Now() + if err := sqlDB.PingContext(ctx); err != nil { + return result, err + } + result.Connected = true + result.LatencyMs = max(1, time.Since(started).Milliseconds()) + result.Pool = databasePoolStats(sqlDB.Stats()) + if schema, schemaErr := database.ReadSchemaStatus(r.db.WithContext(ctx)); schemaErr == nil { + result.Schema = schema + } + if result.Driver == "postgres" { + result.Postgres, err = r.postgresRuntimeStats(ctx) + if result.Postgres != nil { + result.DatabaseBytes = result.Postgres.DatabaseBytes + } + return result, err + } + if result.Driver == "sqlite" { + var pageCount, pageSize int64 + if r.db.WithContext(ctx).Raw("PRAGMA page_count").Scan(&pageCount).Error == nil && r.db.WithContext(ctx).Raw("PRAGMA page_size").Scan(&pageSize).Error == nil { + result.DatabaseBytes = pageCount * pageSize + } + } + return result, nil +} + +func databasePoolStats(stats sql.DBStats) DatabasePoolStats { + return DatabasePoolStats{ + MaxOpenConnections: stats.MaxOpenConnections, OpenConnections: stats.OpenConnections, InUse: stats.InUse, Idle: stats.Idle, + WaitCount: stats.WaitCount, WaitDuration: stats.WaitDuration, WaitDurationMs: stats.WaitDuration.Milliseconds(), + MaxIdleClosed: stats.MaxIdleClosed, MaxLifetimeClosed: stats.MaxLifetimeClosed, + } +} + +func (r *Repository) postgresRuntimeStats(ctx context.Context) (*PostgresRuntimeStats, error) { + stats := &PostgresRuntimeStats{} + if err := r.db.WithContext(ctx).Raw("SHOW server_version").Scan(&stats.ServerVersion).Error; err != nil { + return stats, err + } + var maxConnections string + if err := r.db.WithContext(ctx).Raw("SHOW max_connections").Scan(&maxConnections).Error; err == nil { + stats.MaxConnections, _ = strconv.ParseInt(maxConnections, 10, 64) + } + var blocksRead, blocksHit int64 + err := r.db.WithContext(ctx).Raw(` + SELECT pg_database_size(current_database()) AS database_bytes, + numbackends AS connections, xact_commit AS transactions, xact_rollback AS rollbacks, + blks_read, blks_hit, temp_files, temp_bytes, deadlocks + FROM pg_stat_database WHERE datname = current_database() + `).Row().Scan(&stats.DatabaseBytes, &stats.Connections, &stats.Transactions, &stats.Rollbacks, &blocksRead, &blocksHit, &stats.TempFiles, &stats.TempBytes, &stats.Deadlocks) + if blocksRead+blocksHit > 0 { + stats.CacheHitRate = float64(blocksHit) / float64(blocksRead+blocksHit) * 100 + } + return stats, err +} diff --git a/backend/internal/repository/user_purge.go b/backend/internal/repository/user_purge.go deleted file mode 100644 index 3d0471451..000000000 --- a/backend/internal/repository/user_purge.go +++ /dev/null @@ -1,213 +0,0 @@ -package repository - -import ( - "fmt" - "strings" - - "infinite-canvas/backend/internal/database" - "infinite-canvas/backend/internal/model" - - "gorm.io/gorm" -) - -// UserPurgeResult 汇总彻底删除用户时清掉的行数(按表)。 -type UserPurgeResult struct { - DeletedByTable map[string]int64 `json:"deletedByTable"` - DeletedRowsTotal int64 `json:"deletedRowsTotal"` -} - -type purgeSchema struct { - model any - table string - dbNameOfGo map[string]string // Go 字段名 → 真实列名(尊重 gorm tag / 命名策略) -} - -func (p purgeSchema) column(goName string) string { - return p.dbNameOfGo[goName] -} - -// purgeFKTemplates 描述“指向用户子数据的列”如何回溯到用户。 -// 统一以 user_id = ? 收口;模板中 %s 为列名占位,? 为用户 ID 参数。 -// 由于所有模板都回溯到“带 user_id 的根表”,而根表行只在第二阶段删除, -// 第一阶段内部任意表间顺序都不会破坏子查询可见性。 -var purgeFKTemplates = map[string]string{ - "TaskID": "%s IN (SELECT id FROM tasks WHERE user_id = ?)", - "SessionID": "%s IN (SELECT id FROM sessions WHERE user_id = ?)", - "ProjectID": "%s IN (SELECT id FROM projects WHERE user_id = ?)", - "AssetID": "%s IN (SELECT id FROM assets WHERE user_id = ?)", - "CanvasID": "%s IN (SELECT id FROM canvas_projects WHERE user_id = ?)", - "ResourceID": "%s IN (SELECT id FROM resources WHERE user_id = ?)", - "StorageSettingID": "%s IN (SELECT id FROM user_oss_settings WHERE user_id = ?)", - "BillingOrderID": "%s IN (SELECT id FROM billing_orders WHERE user_id = ?)", - "PaymentOrderID": "%s IN (SELECT id FROM payment_orders WHERE user_id = ?)", - "AssetVersionID": "%s IN (SELECT id FROM asset_versions WHERE asset_id IN (SELECT id FROM assets WHERE user_id = ?))", - "ShotID": "%s IN (SELECT id FROM shots WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))", - "UnitID": "%s IN (SELECT id FROM project_units WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))", - "WorkflowInstanceID": "%s IN (SELECT id FROM workflow_instances WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))", - "WorkflowStepID": "%s IN (SELECT id FROM workflow_step_instances WHERE workflow_instance_id IN (SELECT id FROM workflow_instances WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?)))", - "RevisionID": "%s IN (SELECT id FROM shot_revisions WHERE shot_id IN (SELECT id FROM shots WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?)))", - "FolderID": "%s IN (SELECT id FROM project_asset_folders WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))", - "VoiceProfileID": "%s IN (SELECT id FROM voice_profiles WHERE user_id = ?)", -} - -// purgeOwnColumns 第二阶段直接归属用户的字段(Go 字段名)。 -var purgeOwnColumns = []string{"UserID", "OwnerID", "RedeemedBy"} - -// PurgeUserData 在单个事务内彻底删除一个用户及其名下业务数据,并(可选) -// 在事务尾部原子写入一条管理员审计记录(如 user.purge)。 -// 阶段一:删除所有能通过 FK 回溯到该用户的子数据(根表行仍在,子查询始终可见); -// 阶段二:删除直接归属该用户的行(user_id / owner_id / redeemed_by); -// 阶段三:审计记录(保留本次传入的审计)、邮箱验证码与用户主行。 -// 用户主行最后删,保证事务内全程可回溯。 -func (r *Repository) PurgeUserData(user *model.User, keepAudit *model.AdminAuditEvent) (*UserPurgeResult, error) { - userID := strings.TrimSpace(user.ID) - if userID == "" { - return nil, fmt.Errorf("缺少用户 ID") - } - email := strings.TrimSpace(user.Email) - - result := &UserPurgeResult{DeletedByTable: map[string]int64{}} - keepAction := "" - if keepAudit != nil { - keepAction = strings.TrimSpace(keepAudit.Action) - } - err := r.db.Transaction(func(tx *gorm.DB) error { - schemas, err := parsePurgeSchemas(tx) - if err != nil { - return err - } - - // 阶段一:子表级联(不含用户主行,也不含 user_id 直属行) - for _, schema := range schemas { - if _, isUser := schema.model.(*model.User); isUser { - continue - } - conds, args := buildFKPurgeConds(schema, userID) - if len(conds) == 0 { - continue - } - if err := deletePurgedRows(tx, schema, conds, args, result); err != nil { - return err - } - } - // 阶段二:直属行 - for _, schema := range schemas { - if _, isUser := schema.model.(*model.User); isUser { - continue - } - conds := make([]string, 0, 1) - args := make([]any, 0, 1) - for _, goName := range purgeOwnColumns { - if column := schema.column(goName); column != "" { - conds = append(conds, column+" = ?") - args = append(args, userID) - } - } - if len(conds) == 0 { - continue - } - if err := deletePurgedRows(tx, schema, conds, args, result); err != nil { - return err - } - } - // 阶段三:审计(该用户作操作者/被操作对象)、邮箱验证码、用户主行 - for _, schema := range schemas { - if _, ok := schema.model.(*model.AdminAuditEvent); ok { - if err := deleteAdminAuditRows(tx, schema, userID, keepAction, result); err != nil { - return err - } - } - if _, ok := schema.model.(*model.EmailVerificationCode); ok && email != "" { - if column := schema.column("Email"); column != "" { - if err := deletePurgedWhere(tx, schema, column+" = ?", []any{email}, result); err != nil { - return err - } - } - } - } - for _, schema := range schemas { - if _, isUser := schema.model.(*model.User); isUser { - if err := deletePurgedWhere(tx, schema, "id = ?", []any{userID}, result); err != nil { - return err - } - } - } - // 本次删除动作的审计记录在用户主行删除后写入,避免被上面的清理误删。 - if keepAudit != nil { - if err := tx.Create(keepAudit).Error; err != nil { - return fmt.Errorf("写入管理员审计失败:%w", err) - } - } - return nil - }) - if err != nil { - return nil, err - } - return result, nil -} - -func parsePurgeSchemas(db *gorm.DB) ([]purgeSchema, error) { - models := database.Models() - schemas := make([]purgeSchema, 0, len(models)) - for _, dst := range models { - stmt := &gorm.Statement{DB: db, Dest: dst} - if err := stmt.Parse(dst); err != nil { - return nil, fmt.Errorf("解析表结构失败(%T):%w", dst, err) - } - names := make(map[string]string, len(stmt.Schema.Fields)) - for _, field := range stmt.Schema.Fields { - names[field.Name] = field.DBName - } - schemas = append(schemas, purgeSchema{model: dst, table: stmt.Schema.Table, dbNameOfGo: names}) - } - return schemas, nil -} - -// buildFKPurgeConds 汇总一张表中命中“用户子数据 FK”的删除条件。 -func buildFKPurgeConds(schema purgeSchema, userID string) ([]string, []any) { - conds := make([]string, 0, 2) - args := make([]any, 0, 2) - for goName, template := range purgeFKTemplates { - column := schema.column(goName) - if column == "" { - continue - } - conds = append(conds, fmt.Sprintf(template, column)) - args = append(args, userID) - } - return conds, args -} - -func deletePurgedRows(tx *gorm.DB, schema purgeSchema, conds []string, args []any, result *UserPurgeResult) error { - combined := "(" + strings.Join(conds, " OR ") + ")" - return deletePurgedWhere(tx, schema, combined, args, result) -} - -func deletePurgedWhere(tx *gorm.DB, schema purgeSchema, where string, args []any, result *UserPurgeResult) error { - db := tx.Table(schema.table).Where(where, args...).Delete(schema.model) - if db.Error != nil { - return fmt.Errorf("清理 %s 失败:%w", schema.table, db.Error) - } - if db.RowsAffected > 0 { - result.DeletedByTable[schema.table] += db.RowsAffected - result.DeletedRowsTotal += db.RowsAffected - } - return nil -} - -func deleteAdminAuditRows(tx *gorm.DB, schema purgeSchema, userID string, keepAction string, result *UserPurgeResult) error { - actorColumn := schema.column("ActorUserID") - targetTypeColumn := schema.column("TargetType") - targetIDColumn := schema.column("TargetID") - actionColumn := schema.column("Action") - if actorColumn == "" || targetTypeColumn == "" || targetIDColumn == "" || actionColumn == "" { - return nil - } - where := "(" + actorColumn + " = ? OR (" + targetTypeColumn + " = 'user' AND " + targetIDColumn + " = ?))" - args := []any{userID, userID} - if keepAction != "" { - where += " AND " + actionColumn + " <> ?" - args = append(args, keepAction) - } - return deletePurgedWhere(tx, schema, where, args, result) -} diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index b5ef5f39a..5297fd7e0 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -389,47 +389,6 @@ func (s *Service) DeleteUser(actor *model.User, userID string) error { return s.appendAdminAudit(actor, "user.disable", "user", user.ID, "停用用户并清除登录态", nil) } -// purgeFinanceTables 存在即拒绝彻底删除的账务数据:真实资金流水/订单必须长期保留。 -var purgeFinanceTables = []string{"billing_orders", "payment_orders"} - -// PurgeUser 彻底删除(硬删)用户:账号与全部关联数据在单个事务内级联物理移除,不可恢复。 -// 守卫:仅管理员、不能删自己、管理员需保底一个;存在资金流水/账单等需保留的账务数据时拒绝,提示改用停用。 -// 业务内容(任务/项目/素材等)由 repo.PurgeUserData 按 FK 链在事务内级联删除,删除动作以 user.purge 审计落库。 -func (s *Service) PurgeUser(actor *model.User, userID string) error { - if err := s.RequireAdmin(actor); err != nil { - return err - } - if actor.ID == userID { - return BadAuthRequest("不能删除当前登录的管理员账号") - } - user, err := s.repo.User(userID) - if err != nil { - return err - } - if user.Role == model.UserRoleAdmin { - count, err := s.repo.ActiveAdminCountExcluding(user.ID) - if err != nil { - return err - } - if count == 0 { - return BadAuthRequest("至少需要保留一个管理员") - } - } - financeCounts, err := s.repo.CountUserRowsByTable(user.ID, purgeFinanceTables) - if err != nil { - return err - } - if len(financeCounts) > 0 { - return BadAuthRequest("该用户存在资金流水/账单数据,无法彻底删除,请改用停用") - } - audit, err := newAdminAuditEvent(actor, "user.purge", "user", user.ID, "彻底删除用户账号及全部关联数据", nil) - if err != nil { - return err - } - _, err = s.repo.PurgeUserData(user, audit) - return err -} - func (s *Service) BulkDisableUsers(actor *model.User, req BulkDisableUsersRequest) (*BulkDisableUsersResult, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err diff --git a/backend/internal/service/admin_channel_test.go b/backend/internal/service/admin_channel_test.go index eeec40aaa..0f0da4b7c 100644 --- a/backend/internal/service/admin_channel_test.go +++ b/backend/internal/service/admin_channel_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strconv" "testing" "infinite-canvas/backend/internal/model" @@ -275,6 +276,109 @@ func TestSaveAdminChannelModelRejectsActiveDuplicateKey(t *testing.T) { } } +func TestDeleteAdminChannelModelsDeletesSelectionAtomically(t *testing.T) { + svc, db := newChannelModelTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: "https://example.com/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `["model-a","model-b","model-c"]`} + items := []model.ChannelModel{ + {ID: "model-a", ChannelID: channel.ID, ModelKey: "model-a", DisplayName: "Model A", Enabled: true, PriceVersion: 1}, + {ID: "model-b", ChannelID: channel.ID, ModelKey: "model-b", DisplayName: "Model B", Enabled: true, PriceVersion: 1}, + {ID: "model-c", ChannelID: channel.ID, ModelKey: "model-c", DisplayName: "Model C", Enabled: true, PriceVersion: 1}, + } + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&items).Error; err != nil { + t.Fatal(err) + } + + deleted, err := svc.DeleteAdminChannelModels(admin, channel.ID, []string{" model-a ", "model-b", "model-a"}) + if err != nil { + t.Fatal(err) + } + if deleted != 2 { + t.Fatalf("deleted = %d, want 2", deleted) + } + var storedChannel model.ModelChannel + if err := db.First(&storedChannel, "id = ?", channel.ID).Error; err != nil { + t.Fatal(err) + } + if storedChannel.ModelsJSON != `["model-c"]` { + t.Fatalf("ModelsJSON = %s, want model-c only", storedChannel.ModelsJSON) + } + var active []model.ChannelModel + if err := db.Where("channel_id = ?", channel.ID).Find(&active).Error; err != nil { + t.Fatal(err) + } + if len(active) != 1 || active[0].ID != "model-c" { + t.Fatalf("active models = %#v, want model-c", active) + } + var removed []model.ChannelModel + if err := db.Unscoped().Where("channel_id = ? AND id IN ?", channel.ID, []string{"model-a", "model-b"}).Find(&removed).Error; err != nil { + t.Fatal(err) + } + if len(removed) != 2 { + t.Fatalf("removed models = %#v, want two", removed) + } + for _, item := range removed { + if item.Enabled || item.PriceVersion != 2 || !item.DeletedAt.Valid { + t.Fatalf("removed model state = %#v", item) + } + } +} + +func TestDeleteAdminChannelModelsRejectsWholeSelectionWhenOneModelIsInUse(t *testing.T) { + svc, db := newChannelModelTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: "https://example.com/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `["model-a","model-b"]`} + items := []model.ChannelModel{ + {ID: "model-a", ChannelID: channel.ID, ModelKey: "model-a", DisplayName: "Model A", Enabled: true, PriceVersion: 1}, + {ID: "model-b", ChannelID: channel.ID, ModelKey: "model-b", DisplayName: "Model B", Enabled: true, PriceVersion: 1}, + } + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&items).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&model.Task{ID: "task-running", ChannelModelID: "model-b", Status: model.TaskStatusRunning}).Error; err != nil { + t.Fatal(err) + } + + deleted, err := svc.DeleteAdminChannelModels(admin, channel.ID, []string{"model-a", "model-b"}) + var authErr *AuthError + if !errors.As(err, &authErr) || authErr.Message != "所选渠道模型中有模型仍被前台模型供应线路或进行中任务使用,本次未删除任何模型" { + t.Fatalf("DeleteAdminChannelModels() deleted = %d, error = %#v", deleted, err) + } + var active int64 + if err := db.Model(&model.ChannelModel{}).Where("channel_id = ?", channel.ID).Count(&active).Error; err != nil { + t.Fatal(err) + } + if active != 2 { + t.Fatalf("active models = %d, want 2 after atomic rejection", active) + } + var storedChannel model.ModelChannel + if err := db.First(&storedChannel, "id = ?", channel.ID).Error; err != nil { + t.Fatal(err) + } + if storedChannel.ModelsJSON != channel.ModelsJSON { + t.Fatalf("ModelsJSON changed after rejected batch: %s", storedChannel.ModelsJSON) + } +} + +func TestNormalizeAdminChannelModelDeleteIDsRequiresBoundedSelection(t *testing.T) { + if _, err := normalizeAdminChannelModelDeleteIDs([]string{"", " "}); err == nil { + t.Fatal("empty selection should be rejected") + } + values := make([]string, 101) + for index := range values { + values[index] = "model-" + strconv.Itoa(index) + } + if _, err := normalizeAdminChannelModelDeleteIDs(values); err == nil { + t.Fatal("selection above 100 models should be rejected") + } +} + func TestResolveProviderConfigMapsSKUToProviderModel(t *testing.T) { svc, db := newChannelModelTestService(t) svc.dataDir = t.TempDir() @@ -344,7 +448,7 @@ func newChannelModelTestService(t *testing.T) (*Service, *gorm.DB) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.IDSequence{}); err != nil { + if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.LogicalModel{}, &model.LogicalModelRevision{}, &model.LogicalModelRoute{}, &model.Task{}, &model.IDSequence{}); err != nil { t.Fatal(err) } return &Service{repo: repository.New(db)}, db diff --git a/backend/internal/service/admin_system_performance.go b/backend/internal/service/admin_system_performance.go new file mode 100644 index 000000000..2839a112b --- /dev/null +++ b/backend/internal/service/admin_system_performance.go @@ -0,0 +1,346 @@ +package service + +import ( + "context" + "errors" + "os" + "runtime" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/buildinfo" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "github.com/redis/go-redis/v9" +) + +var systemProcessStartedAt = time.Now() + +const systemPerformanceTimeout = 3 * time.Second + +type AdminSystemPerformance struct { + CollectedAt time.Time `json:"collectedAt"` + Status string `json:"status"` + Host SystemPerformanceHost `json:"host"` + Memory SystemPerformanceMemory `json:"memory"` + Disk SystemPerformanceDisk `json:"disk"` + Database repository.DatabaseRuntimeStats `json:"database"` + Redis SystemPerformanceRedis `json:"redis"` + Build buildinfo.Info `json:"build"` +} + +type SystemPerformanceHost struct { + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + CPUCores int `json:"cpuCores"` + GOMAXPROCS int `json:"gomaxprocs"` + ProcessID int `json:"processId"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Goroutines int `json:"goroutines"` + ActiveWorkerTasks int64 `json:"activeWorkerTasks"` + LoadAverage [3]float64 `json:"loadAverage"` + LoadAverageAvailable bool `json:"loadAverageAvailable"` +} + +type SystemPerformanceMemory struct { + SystemAvailable bool `json:"systemAvailable"` + TotalBytes uint64 `json:"totalBytes"` + UsedBytes uint64 `json:"usedBytes"` + AvailableBytes uint64 `json:"availableBytes"` + UsagePercent float64 `json:"usagePercent"` + HeapAllocBytes uint64 `json:"heapAllocBytes"` + HeapSysBytes uint64 `json:"heapSysBytes"` + SysBytes uint64 `json:"sysBytes"` + HeapObjects uint64 `json:"heapObjects"` + GCCount uint32 `json:"gcCount"` + LastGCAt *time.Time `json:"lastGCAt,omitempty"` +} + +type SystemPerformanceDisk struct { + Available bool `json:"available"` + Writable bool `json:"writable"` + TotalBytes uint64 `json:"totalBytes"` + UsedBytes uint64 `json:"usedBytes"` + FreeBytes uint64 `json:"freeBytes"` + UsagePercent float64 `json:"usagePercent"` +} + +type SystemPerformanceRedisPool struct { + Hits uint32 `json:"hits"` + Misses uint32 `json:"misses"` + Timeouts uint32 `json:"timeouts"` + TotalConnections uint32 `json:"totalConnections"` + IdleConnections uint32 `json:"idleConnections"` + StaleConnections uint32 `json:"staleConnections"` + PendingRequests uint32 `json:"pendingRequests"` +} + +type SystemPerformanceCacheGroup struct { + ID string `json:"id"` + Label string `json:"label"` + Keys int64 `json:"keys"` + Clearable bool `json:"clearable"` +} + +type SystemPerformanceRedis struct { + Configured bool `json:"configured"` + Connected bool `json:"connected"` + Mode string `json:"mode"` + LatencyMs int64 `json:"latencyMs,omitempty"` + Version string `json:"version,omitempty"` + UptimeSeconds int64 `json:"uptimeSeconds,omitempty"` + UsedMemoryBytes int64 `json:"usedMemoryBytes,omitempty"` + PeakMemoryBytes int64 `json:"peakMemoryBytes,omitempty"` + MaxMemoryBytes int64 `json:"maxMemoryBytes,omitempty"` + MaxMemoryPolicy string `json:"maxMemoryPolicy,omitempty"` + Keys int64 `json:"keys,omitempty"` + Clients int64 `json:"clients,omitempty"` + BlockedClients int64 `json:"blockedClients,omitempty"` + OpsPerSecond int64 `json:"opsPerSecond,omitempty"` + HitRate float64 `json:"hitRate,omitempty"` + ExpiredKeys int64 `json:"expiredKeys,omitempty"` + EvictedKeys int64 `json:"evictedKeys,omitempty"` + Pool SystemPerformanceRedisPool `json:"pool"` + CacheGroups []SystemPerformanceCacheGroup `json:"cacheGroups"` + StatusMessage string `json:"statusMessage,omitempty"` +} + +type AdminCacheClearRequest struct { + Scope string `json:"scope"` +} + +type AdminCacheClearGroupResult struct { + ID string `json:"id"` + DeletedKeys int64 `json:"deletedKeys"` +} + +type AdminCacheClearResult struct { + Scope string `json:"scope"` + DeletedKeys int64 `json:"deletedKeys"` + MemoryEntries int `json:"memoryEntries"` + Groups []AdminCacheClearGroupResult `json:"groups"` + ClearedAt time.Time `json:"clearedAt"` +} + +type runtimeCacheDefinition struct { + id, label, pattern string +} + +var runtimeCacheDefinitions = []runtimeCacheDefinition{ + {id: "rateLimits", label: "请求频控", pattern: "canvas:rate:*"}, + {id: "circuitFailures", label: "渠道失败计数", pattern: "canvas:circuit:failures:*"}, + {id: "circuitOpen", label: "渠道熔断状态", pattern: "canvas:circuit:open:*"}, + {id: "routeHealth", label: "线路临时禁用", pattern: "canvas:logical-route-health:*"}, +} + +func (s *Service) AdminSystemPerformance(ctx context.Context, actor *model.User) (*AdminSystemPerformance, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, systemPerformanceTimeout) + defer cancel() + + hostname, _ := os.Hostname() + load, loadAvailable := platformLoadAverage() + memory := collectSystemMemory() + disk := collectSystemDisk(s.dataDir) + databaseStats, databaseErr := s.repo.DatabaseRuntimeStats(ctx) + redisStats := s.collectRedisPerformance(ctx) + status := "healthy" + if databaseErr != nil || !databaseStats.Connected || !disk.Available || !disk.Writable || s.ValidateRuntime() != nil || (redisStats.Configured && !redisStats.Connected) { + status = "degraded" + } + return &AdminSystemPerformance{ + CollectedAt: time.Now(), Status: status, + Host: SystemPerformanceHost{Hostname: hostname, OS: runtime.GOOS, Arch: runtime.GOARCH, CPUCores: runtime.NumCPU(), GOMAXPROCS: runtime.GOMAXPROCS(0), ProcessID: os.Getpid(), UptimeSeconds: int64(time.Since(systemProcessStartedAt).Seconds()), Goroutines: runtime.NumGoroutine(), ActiveWorkerTasks: s.ActiveWorkerTasks(), LoadAverage: load, LoadAverageAvailable: loadAvailable}, + Memory: memory, Disk: disk, Database: databaseStats, Redis: redisStats, Build: buildinfo.Current(), + }, nil +} + +func collectSystemMemory() SystemPerformanceMemory { + var stats runtime.MemStats + runtime.ReadMemStats(&stats) + total, available, ok := platformMemory() + used := uint64(0) + percentage := float64(0) + if ok && total >= available { + used = total - available + if total > 0 { + percentage = float64(used) / float64(total) * 100 + } + } + result := SystemPerformanceMemory{SystemAvailable: ok, TotalBytes: total, UsedBytes: used, AvailableBytes: available, UsagePercent: percentage, HeapAllocBytes: stats.HeapAlloc, HeapSysBytes: stats.HeapSys, SysBytes: stats.Sys, HeapObjects: stats.HeapObjects, GCCount: stats.NumGC} + if stats.LastGC > 0 { + last := time.Unix(0, int64(stats.LastGC)) + result.LastGCAt = &last + } + return result +} + +func (s *Service) collectRedisPerformance(ctx context.Context) SystemPerformanceRedis { + result := SystemPerformanceRedis{Mode: "local", StatusMessage: "未配置 Redis,当前使用单实例本地协调", CacheGroups: make([]SystemPerformanceCacheGroup, 0, len(runtimeCacheDefinitions))} + if s.coordinator == nil { + result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: "rateLimits", Label: "本地请求频控", Clearable: true}) + return result + } + if s.coordinator.redis == nil { + s.coordinator.localMu.Lock() + localRateKeys := int64(len(s.coordinator.localRate)) + s.coordinator.localMu.Unlock() + result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: "rateLimits", Label: "本地请求频控", Keys: localRateKeys, Clearable: true}) + return result + } + result.Configured, result.Mode = true, "redis" + started := time.Now() + if err := s.coordinator.redis.Ping(ctx).Err(); err != nil { + result.StatusMessage = "Redis 暂时不可用" + return result + } + result.Connected = true + result.LatencyMs = max(1, time.Since(started).Milliseconds()) + result.StatusMessage = "Redis 连接正常" + if size, err := s.coordinator.redis.DBSize(ctx).Result(); err == nil { + result.Keys = size + } + if info, err := s.coordinator.redis.Info(ctx, "server", "clients", "memory", "stats").Result(); err == nil { + values := parseRedisInfo(info) + result.Version = values["redis_version"] + result.UptimeSeconds = redisInfoInt(values, "uptime_in_seconds") + result.UsedMemoryBytes = redisInfoInt(values, "used_memory") + result.PeakMemoryBytes = redisInfoInt(values, "used_memory_peak") + result.MaxMemoryBytes = redisInfoInt(values, "maxmemory") + result.MaxMemoryPolicy = values["maxmemory_policy"] + result.Clients = redisInfoInt(values, "connected_clients") + result.BlockedClients = redisInfoInt(values, "blocked_clients") + result.OpsPerSecond = redisInfoInt(values, "instantaneous_ops_per_sec") + result.ExpiredKeys = redisInfoInt(values, "expired_keys") + result.EvictedKeys = redisInfoInt(values, "evicted_keys") + hits, misses := redisInfoInt(values, "keyspace_hits"), redisInfoInt(values, "keyspace_misses") + if hits+misses > 0 { + result.HitRate = float64(hits) / float64(hits+misses) * 100 + } + } + pool := s.coordinator.redis.PoolStats() + result.Pool = SystemPerformanceRedisPool{Hits: pool.Hits, Misses: pool.Misses, Timeouts: pool.Timeouts, TotalConnections: pool.TotalConns, IdleConnections: pool.IdleConns, StaleConnections: pool.StaleConns, PendingRequests: pool.PendingRequests} + for _, definition := range runtimeCacheDefinitions { + count, _ := scanRedisKeyCount(ctx, s.coordinator.redis, definition.pattern) + result.CacheGroups = append(result.CacheGroups, SystemPerformanceCacheGroup{ID: definition.id, Label: definition.label, Keys: count, Clearable: true}) + } + return result +} + +func (s *Service) ClearAdminRuntimeCache(ctx context.Context, actor *model.User, request AdminCacheClearRequest) (*AdminCacheClearResult, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + if strings.TrimSpace(request.Scope) != "runtime" { + return nil, BadAuthRequest("仅支持清理运行时缓存") + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + result := &AdminCacheClearResult{Scope: "runtime", ClearedAt: time.Now(), Groups: make([]AdminCacheClearGroupResult, 0, len(runtimeCacheDefinitions))} + if s.coordinator != nil { + if s.coordinator.redis != nil { + for _, definition := range runtimeCacheDefinitions { + deleted, err := deleteRedisKeysByPattern(ctx, s.coordinator.redis, definition.pattern) + if err != nil { + return nil, errors.New("清理 Redis 运行时缓存失败") + } + result.Groups = append(result.Groups, AdminCacheClearGroupResult{ID: definition.id, DeletedKeys: deleted}) + result.DeletedKeys += deleted + } + } else { + s.coordinator.localMu.Lock() + count := len(s.coordinator.localRate) + s.coordinator.localRate = map[string]localRateEntry{} + s.coordinator.localMu.Unlock() + result.DeletedKeys += int64(count) + result.Groups = append(result.Groups, AdminCacheClearGroupResult{ID: "rateLimits", DeletedKeys: int64(count)}) + } + } + s.initReadCaches() + result.MemoryEntries += s.concurrencyReadCache.clearAndCount() + result.MemoryEntries += s.textReplayReadCache.clearAndCount() + result.MemoryEntries += s.routeVersionReadCache.clearAndCount() + s.routeHealthMu.Lock() + result.MemoryEntries += len(s.routeHealthBlocked) + s.routeHealthBlocked = make(map[string]time.Time) + s.routeHealthMu.Unlock() + // 与路由目录刷新保持同一加锁顺序,避免清理动作和回源刷新并发写入重试状态。 + s.routeCatalogRefreshMu.Lock() + s.routeCatalogMu.Lock() + if s.routeCatalog != nil { + result.MemoryEntries++ + } + s.routeCatalog = nil + s.routeCatalogVersion = 0 + s.routeCatalogRetryAt = time.Time{} + s.routeCatalogRefreshError = nil + s.routeCatalogMu.Unlock() + s.routeCatalogRefreshMu.Unlock() + if err := s.appendAdminAudit(actor, "system.runtime_cache.clear", "system", "runtime-cache", "清理安全运行时缓存", map[string]any{"deletedKeys": result.DeletedKeys, "memoryEntries": result.MemoryEntries}); err != nil { + return nil, err + } + return result, nil +} + +func parseRedisInfo(info string) map[string]string { + values := make(map[string]string) + for _, line := range strings.Split(info, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, found := strings.Cut(line, ":") + if found { + values[key] = strings.TrimSpace(value) + } + } + return values +} + +func redisInfoInt(values map[string]string, key string) int64 { + value, _ := strconv.ParseInt(values[key], 10, 64) + return value +} + +func scanRedisKeyCount(ctx context.Context, client *redis.Client, pattern string) (int64, error) { + var cursor uint64 + var total int64 + for { + keys, next, err := client.Scan(ctx, cursor, pattern, 200).Result() + if err != nil { + return total, err + } + total += int64(len(keys)) + cursor = next + if cursor == 0 { + return total, nil + } + } +} + +func deleteRedisKeysByPattern(ctx context.Context, client *redis.Client, pattern string) (int64, error) { + var cursor uint64 + var deleted int64 + for { + keys, next, err := client.Scan(ctx, cursor, pattern, 200).Result() + if err != nil { + return deleted, err + } + if len(keys) > 0 { + count, err := client.Unlink(ctx, keys...).Result() + if err != nil { + return deleted, err + } + deleted += count + } + cursor = next + if cursor == 0 { + return deleted, nil + } + } +} diff --git a/backend/internal/service/admin_system_performance_test.go b/backend/internal/service/admin_system_performance_test.go new file mode 100644 index 000000000..d5d075e5e --- /dev/null +++ b/backend/internal/service/admin_system_performance_test.go @@ -0,0 +1,238 @@ +package service + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "github.com/redis/go-redis/v9" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestAdminSystemPerformanceRequiresAdmin(t *testing.T) { + svc := &Service{} + user := &model.User{ID: "user-1", Role: model.UserRoleUser} + + if _, err := svc.AdminSystemPerformance(context.Background(), user); authStatus(err) != 403 { + t.Fatalf("AdminSystemPerformance() error = %#v, want 403", err) + } + if _, err := svc.ClearAdminRuntimeCache(context.Background(), user, AdminCacheClearRequest{Scope: "runtime"}); authStatus(err) != 403 { + t.Fatalf("ClearAdminRuntimeCache() error = %#v, want 403", err) + } + if _, err := svc.AdminSystemPerformance(context.Background(), nil); authStatus(err) != 401 { + t.Fatalf("AdminSystemPerformance(nil) error = %#v, want 401", err) + } +} + +func TestAdminSystemPerformanceReportsSQLiteWithoutRedis(t *testing.T) { + svc, db := newSystemPerformanceTestService(t) + admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin} + + result, err := svc.AdminSystemPerformance(context.Background(), admin) + if err != nil { + t.Fatal(err) + } + if result.Status != "healthy" { + t.Fatalf("status = %q, want healthy", result.Status) + } + if result.Database.Driver != "sqlite" || !result.Database.Connected || result.Database.Postgres != nil { + t.Fatalf("database = %#v", result.Database) + } + if result.Database.DatabaseBytes <= 0 { + t.Fatalf("database bytes = %d, want > 0", result.Database.DatabaseBytes) + } + if result.Database.Schema.Expected != database.CurrentSchemaVersion { + t.Fatalf("schema = %#v", result.Database.Schema) + } + if result.Redis.Configured || result.Redis.Connected || result.Redis.Mode != "local" { + t.Fatalf("redis = %#v", result.Redis) + } + if len(result.Redis.CacheGroups) != 1 || result.Redis.CacheGroups[0].ID != "rateLimits" { + t.Fatalf("cache groups = %#v", result.Redis.CacheGroups) + } + if !result.Disk.Available || !result.Disk.Writable { + t.Fatalf("disk = %#v", result.Disk) + } + if result.Host.CPUCores < 1 || result.Host.GOMAXPROCS < 1 || result.Host.ProcessID < 1 { + t.Fatalf("host = %#v", result.Host) + } + + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + if stats := sqlDB.Stats(); result.Database.Pool.OpenConnections != stats.OpenConnections { + t.Fatalf("pool open connections = %d, want %d", result.Database.Pool.OpenConnections, stats.OpenConnections) + } +} + +func TestClearAdminRuntimeCacheRejectsUnknownScopeAndAuditsLocalClear(t *testing.T) { + svc, db := newSystemPerformanceTestService(t) + admin := &model.User{ID: "admin-1", Role: model.UserRoleAdmin} + svc.coordinator.localRate["user-1"] = localRateEntry{started: time.Now(), count: 2} + + if _, err := svc.ClearAdminRuntimeCache(context.Background(), admin, AdminCacheClearRequest{Scope: "all"}); authStatus(err) != 400 { + t.Fatalf("unknown scope error = %#v, want 400", err) + } + if len(svc.coordinator.localRate) != 1 { + t.Fatal("invalid scope changed local runtime cache") + } + + svc.initReadCaches() + if _, err := svc.routeVersionReadCache.get(context.Background(), "route-version", func(context.Context) (int64, int, error) { + return 7, 256, nil + }); err != nil { + t.Fatal(err) + } + svc.routeHealthBlocked["route-1"] = time.Now().Add(time.Minute) + svc.routeCatalog = &routeCatalogSnapshot{} + svc.routeCatalogRetryAt = time.Now().Add(time.Minute) + svc.routeCatalogRefreshError = errors.New("cached refresh failure") + + result, err := svc.ClearAdminRuntimeCache(context.Background(), admin, AdminCacheClearRequest{Scope: "runtime"}) + if err != nil { + t.Fatal(err) + } + if result.DeletedKeys != 1 || len(svc.coordinator.localRate) != 0 { + t.Fatalf("local clear result = %#v, remaining = %d", result, len(svc.coordinator.localRate)) + } + if result.MemoryEntries != 3 { + t.Fatalf("memory entries = %d, want 3", result.MemoryEntries) + } + if svc.routeCatalog != nil || !svc.routeCatalogRetryAt.IsZero() || svc.routeCatalogRefreshError != nil || len(svc.routeHealthBlocked) != 0 { + t.Fatalf("route caches were not reset") + } + var audit model.AdminAuditEvent + if err := db.Where("action = ?", "system.runtime_cache.clear").First(&audit).Error; err != nil { + t.Fatal(err) + } + if audit.ActorUserID != admin.ID || audit.TargetID != "runtime-cache" { + t.Fatalf("audit = %#v", audit) + } +} + +func TestClearAdminRuntimeCacheUsesRedisWhitelist(t *testing.T) { + client := startSystemPerformanceRedis(t) + svc, db := newSystemPerformanceTestService(t) + svc.coordinator = &runtimeCoordinator{redis: client, instanceID: "system-performance-test", localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}} + admin := &model.User{ID: "admin-redis", Role: model.UserRoleAdmin} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + clearable := []string{ + "canvas:rate:user-1", + "canvas:circuit:failures:channel-1", + "canvas:circuit:open:channel-1", + "canvas:logical-route-health:route-1", + } + preserved := []string{ + "canvas:slots:channel:channel-1", + "canvas:logical-model-route-catalog:version", + "canvas:task:task-1", + } + for _, key := range append(append([]string{}, clearable...), preserved...) { + if err := client.Set(ctx, key, "1", 0).Err(); err != nil { + t.Fatal(err) + } + } + + before := svc.collectRedisPerformance(ctx) + if !before.Configured || !before.Connected || before.Mode != "redis" || before.Keys != int64(len(clearable)+len(preserved)) { + t.Fatalf("redis performance = %#v", before) + } + + result, err := svc.ClearAdminRuntimeCache(ctx, admin, AdminCacheClearRequest{Scope: "runtime"}) + if err != nil { + t.Fatal(err) + } + if result.DeletedKeys != int64(len(clearable)) || len(result.Groups) != len(runtimeCacheDefinitions) { + t.Fatalf("clear result = %#v", result) + } + if remaining, err := client.Exists(ctx, clearable...).Result(); err != nil || remaining != 0 { + t.Fatalf("clearable keys remaining = %d, err = %v", remaining, err) + } + if remaining, err := client.Exists(ctx, preserved...).Result(); err != nil || remaining != int64(len(preserved)) { + t.Fatalf("preserved keys remaining = %d, err = %v", remaining, err) + } + var auditCount int64 + if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "system.runtime_cache.clear").Count(&auditCount).Error; err != nil { + t.Fatal(err) + } + if auditCount != 1 { + t.Fatalf("audit count = %d, want 1", auditCount) + } +} + +func newSystemPerformanceTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + dataDir := t.TempDir() + db, err := gorm.Open(sqlite.Open(filepath.Join(dataDir, "system-performance.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.AdminAuditEvent{}); err != nil { + t.Fatal(err) + } + if sqlDB, err := db.DB(); err == nil { + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + } + return &Service{ + repo: repository.New(db), + dataDir: dataDir, + coordinator: &runtimeCoordinator{instanceID: "local-test", localRate: map[string]localRateEntry{}, localSlots: map[string]map[string]time.Time{}}, + routeCatalogTTL: 30 * time.Second, + routeCatalogMaxStale: 5 * time.Minute, + routeHealthBlocked: map[string]time.Time{}, + }, db +} + +func startSystemPerformanceRedis(t *testing.T) *redis.Client { + t.Helper() + binary, err := exec.LookPath("redis-server") + if err != nil { + t.Skip("redis-server unavailable; Redis cache whitelist integration not run") + } + dir, err := os.MkdirTemp("", "canvas-performance-redis-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + socket := filepath.Join(dir, "r.sock") + cmd := exec.Command(binary, "--port", "0", "--unixsocket", socket, "--unixsocketperm", "700", "--save", "", "--appendonly", "no", "--dir", dir) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }) + client := redis.NewClient(&redis.Options{Network: "unix", Addr: socket, MaxRetries: -1, ContextTimeoutEnabled: true}) + t.Cleanup(func() { _ = client.Close() }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for client.Ping(ctx).Err() != nil { + if ctx.Err() != nil { + t.Fatal("temporary Redis did not start") + } + time.Sleep(20 * time.Millisecond) + } + return client +} + +func authStatus(err error) int { + var authErr *AuthError + if errors.As(err, &authErr) { + return authErr.Status + } + return 0 +} diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go index 6fff6f9a8..6f685997d 100644 --- a/backend/internal/service/analytics.go +++ b/backend/internal/service/analytics.go @@ -1000,7 +1000,11 @@ func (s *Service) enrichAPICallLogFailureSummary(log *model.ApiCallLog, response } func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[string]any) { + nestedTaskID := "" if data, ok := payload["data"].(map[string]any); ok { + if log.Capability == "video" && strings.Contains(log.Path, "/v1/video/generations") { + nestedTaskID = firstNonEmpty(stringField(data, "task_id"), stringField(data, "taskId")) + } for key, value := range data { if _, exists := payload[key]; !exists { payload[key] = value @@ -1064,7 +1068,7 @@ func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[str } log.CachedTokens = firstInt64(usageMetadata, "cachedContentTokenCount") } - log.ProviderRequestID = firstNonEmpty(stringField(payload, "task_id"), stringField(payload, "id"), stringField(payload, "request_id"), stringField(payload, "name"), log.ProviderRequestID) + log.ProviderRequestID = firstNonEmpty(nestedTaskID, stringField(payload, "task_id"), stringField(payload, "id"), stringField(payload, "request_id"), stringField(payload, "name"), log.ProviderRequestID) log.ProviderStatus = strings.ToLower(firstNonEmpty(stringField(payload, "status"), log.ProviderStatus)) if log.ProviderStatus == "failed" || log.ProviderStatus == "cancelled" || log.ProviderStatus == "expired" { log.Status = model.ApiCallStatusFailed diff --git a/backend/internal/service/appearance.go b/backend/internal/service/appearance.go index a2a0db5db..ced7049a3 100644 --- a/backend/internal/service/appearance.go +++ b/backend/internal/service/appearance.go @@ -8,6 +8,8 @@ import ( "mime/multipart" "net/http" "net/url" + "os" + "path/filepath" "strconv" "strings" "time" @@ -29,7 +31,7 @@ const ( ) const ( - appearanceSchemaVersion = 6 + appearanceSchemaVersion = 7 appearanceLogoMaxBytes int64 = 5 << 20 appearancePosterMaxBytes int64 = 10 << 20 appearanceVideoMaxBytes int64 = 256 << 20 @@ -56,6 +58,7 @@ type AppearanceSetting struct { LogoFrameEnabled bool `json:"logoFrameEnabled"` AuthVideoResourceID string `json:"authVideoResourceId"` AuthVideoPosterResourceID string `json:"authVideoPosterResourceId"` + AuthVideoAutoplay bool `json:"authVideoAutoplay"` SkinID string `json:"skinId"` SkinThemes []AppearanceSkinTheme `json:"skinThemes"` SEOTitle string `json:"seoTitle"` @@ -77,6 +80,7 @@ type PublicAppearanceSetting struct { LogoFrameEnabled bool `json:"logoFrameEnabled"` AuthVideoURL string `json:"authVideoUrl"` AuthVideoPosterURL string `json:"authVideoPosterUrl"` + AuthVideoAutoplay bool `json:"authVideoAutoplay"` SkinID string `json:"skinId"` ActiveSkin AppearanceSkinTheme `json:"activeSkin"` SEOTitle string `json:"seoTitle"` @@ -105,13 +109,14 @@ type AdminAppearanceSetting struct { func defaultAppearanceSetting() AppearanceSetting { return AppearanceSetting{ - SchemaVersion: appearanceSchemaVersion, - BrandName: defaultAppearanceBrandName, - BrandSlug: defaultAppearanceBrandSlug, - AuthHeroTitle: defaultAppearanceHeroTitle, - LogoFrameEnabled: true, - SkinID: defaultAppearanceSkinID, - SkinThemes: defaultAppearanceSkinThemes(), + SchemaVersion: appearanceSchemaVersion, + BrandName: defaultAppearanceBrandName, + BrandSlug: defaultAppearanceBrandSlug, + AuthHeroTitle: defaultAppearanceHeroTitle, + AuthVideoAutoplay: true, + LogoFrameEnabled: true, + SkinID: defaultAppearanceSkinID, + SkinThemes: defaultAppearanceSkinThemes(), } } @@ -133,6 +138,7 @@ func (s *Service) Appearance() (*PublicAppearanceSetting, error) { if err != nil { return nil, err } + value = s.resolveAvailableAppearanceAssets(value) return publicAppearanceSetting(setting, value), nil } @@ -144,6 +150,7 @@ func (s *Service) AdminAppearance(actor *model.User) (*AdminAppearanceSetting, e if err != nil { return nil, err } + value = s.resolveAvailableAppearanceAssets(value) result := &AdminAppearanceSetting{ AppearanceSetting: value, Public: *publicAppearanceSetting(setting, value), @@ -257,7 +264,15 @@ func (s *Service) UploadAppearanceAsset(actor *model.User, slot string, header * if slot == AppearanceAssetVideo { kind = "video" } - resource, err := s.UploadResource(actor.ID, header, kind, 0, 0, 0) + var resource *model.Resource + // Logos are tiny, installation-owned assets. Keep them in the server's + // persistent resource directory so their availability and cost do not + // depend on the administrator's currently selected object storage. + if slot == AppearanceAssetLogo || slot == AppearanceAssetDarkLogo { + resource, err = s.uploadLocalResource(actor.ID, header, kind, 0, 0, 0) + } else { + resource, err = s.UploadResource(actor.ID, header, kind, 0, 0, 0) + } if err != nil { return nil, err } @@ -364,6 +379,42 @@ func (s *Service) readAppearance() (*model.SystemSetting, AppearanceSetting, err return setting, value, nil } +// resolveAvailableAppearanceAssets prevents stale resource references from +// being advertised to anonymous clients or echoed back into the admin editor. +// Remote objects are not probed on every appearance request; the frontend has +// its own load-error fallback for objects that disappear outside the system. +func (s *Service) resolveAvailableAppearanceAssets(value AppearanceSetting) AppearanceSetting { + for _, slot := range []string{AppearanceAssetLogo, AppearanceAssetDarkLogo, AppearanceAssetVideo, AppearanceAssetPoster} { + resourceID := appearanceResourceID(value, slot) + if resourceID == "" || s.appearanceAssetAvailable(slot, resourceID) { + continue + } + switch slot { + case AppearanceAssetLogo: + value.LogoResourceID = "" + case AppearanceAssetDarkLogo: + value.DarkLogoResourceID = "" + case AppearanceAssetVideo: + value.AuthVideoResourceID = "" + case AppearanceAssetPoster: + value.AuthVideoPosterResourceID = "" + } + } + return value +} + +func (s *Service) appearanceAssetAvailable(slot string, resourceID string) bool { + resource, err := s.repo.Resource(resourceID) + if err != nil || validateAppearanceResourceType(slot, resource) != nil { + return false + } + if resource.Provider != "local" { + return true + } + info, err := os.Stat(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey))) + return err == nil && !info.IsDir() +} + func validateAppearanceSetting(value AppearanceSetting) error { if value.BrandName == "" || utf8.RuneCountInString(value.BrandName) > 40 { return BadAuthRequest("品牌名称必须为 1 到 40 个字符") @@ -571,6 +622,7 @@ func publicAppearanceSetting(setting *model.SystemSetting, value AppearanceSetti LogoFrameEnabled: value.LogoFrameEnabled, AuthVideoURL: defaultAppearanceVideoURL, AuthVideoPosterURL: defaultAppearancePosterURL, + AuthVideoAutoplay: value.AuthVideoAutoplay, SkinID: value.SkinID, ActiveSkin: activeAppearanceSkin(value.SkinThemes, value.SkinID), SEOTitle: effectiveAppearanceSEOTitle(value), diff --git a/backend/internal/service/appearance_test.go b/backend/internal/service/appearance_test.go index 31f08bf80..900dde917 100644 --- a/backend/internal/service/appearance_test.go +++ b/backend/internal/service/appearance_test.go @@ -3,12 +3,14 @@ package service import ( "bytes" "encoding/json" + "math" "mime/multipart" "net/http" "net/http/httptest" "net/textproto" "os" "path/filepath" + "strconv" "strings" "testing" @@ -27,7 +29,7 @@ func TestAppearanceDefaultsPreserveBuiltInBrand(t *testing.T) { if err != nil { t.Fatal(err) } - if appearance.Configured || appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != defaultAppearanceBrandName || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.AuthVideoURL != defaultAppearanceVideoURL || appearance.AuthVideoPosterURL != defaultAppearancePosterURL || appearance.SkinID != defaultAppearanceSkinID || appearance.SEOTitle != defaultAppearanceBrandName || !strings.Contains(appearance.SEODescription, defaultAppearanceBrandName) || !strings.Contains(appearance.FooterCopyright, defaultAppearanceBrandName) || appearance.ICPFilingEnabled { + if appearance.Configured || appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != defaultAppearanceBrandName || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.AuthVideoURL != defaultAppearanceVideoURL || appearance.AuthVideoPosterURL != defaultAppearancePosterURL || !appearance.AuthVideoAutoplay || appearance.SkinID != defaultAppearanceSkinID || appearance.SEOTitle != defaultAppearanceBrandName || !strings.Contains(appearance.SEODescription, defaultAppearanceBrandName) || !strings.Contains(appearance.FooterCopyright, defaultAppearanceBrandName) || appearance.ICPFilingEnabled { t.Fatalf("Appearance() = %#v", appearance) } if appearance.LogoConfigured || appearance.DarkLogoConfigured || appearance.AuthVideoConfigured || appearance.AuthVideoPosterConfigured || appearance.Revision != "builtin" { @@ -48,7 +50,53 @@ func TestAppearanceDefaultsPreserveBuiltInBrand(t *testing.T) { } } -func TestAppearanceBackfillsVersionSixFieldsForExistingSetting(t *testing.T) { +func TestBuiltInAppearanceSkinTooltipPairsMeetContrast(t *testing.T) { + for _, skin := range defaultAppearanceSkinThemes() { + modes := []struct { + name string + tokens AppearanceSkinModeTokens + }{ + {name: "light", tokens: skin.Tokens.Light}, + {name: "dark", tokens: skin.Tokens.Dark}, + } + for _, mode := range modes { + ratio := appearanceColorContrastRatio(t, mode.tokens.Overlay, mode.tokens.Text) + if ratio < 4.5 { + t.Errorf("skin %s %s tooltip contrast = %.2f, want at least 4.5", skin.ID, mode.name, ratio) + } + } + } +} + +func appearanceColorContrastRatio(t *testing.T, foreground, background string) float64 { + t.Helper() + contrastLuminance := func(color string) float64 { + if len(color) != 7 || color[0] != '#' { + t.Fatalf("unsupported test color %q", color) + } + channels := make([]float64, 3) + for index := range channels { + value, err := strconv.ParseUint(color[1+index*2:3+index*2], 16, 8) + if err != nil { + t.Fatalf("parse test color %q: %v", color, err) + } + srgb := float64(value) / 255 + if srgb <= 0.04045 { + channels[index] = srgb / 12.92 + } else { + channels[index] = math.Pow((srgb+0.055)/1.055, 2.4) + } + } + return 0.2126*channels[0] + 0.7152*channels[1] + 0.0722*channels[2] + } + foregroundLuminance := contrastLuminance(foreground) + backgroundLuminance := contrastLuminance(background) + lighter := math.Max(foregroundLuminance, backgroundLuminance) + darker := math.Min(foregroundLuminance, backgroundLuminance) + return (lighter + 0.05) / (darker + 0.05) +} + +func TestAppearanceBackfillsVersionSevenFieldsForExistingSetting(t *testing.T) { svc, db, _, _ := newAppearanceTestService(t) legacy := model.SystemSetting{Key: appearanceSettingKey, ValueJSON: `{"schemaVersion":1,"brandName":"旧品牌","skinId":"classic"}`} if err := db.Create(&legacy).Error; err != nil { @@ -59,13 +107,13 @@ func TestAppearanceBackfillsVersionSixFieldsForExistingSetting(t *testing.T) { if err != nil { t.Fatal(err) } - if appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != "旧品牌" || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || appearance.SEOTitle != "旧品牌" || !strings.Contains(appearance.SEODescription, "旧品牌") || !strings.Contains(appearance.FooterCopyright, "旧品牌") || appearance.ActiveSkin.ID != "classic" { + if appearance.SchemaVersion != appearanceSchemaVersion || appearance.BrandName != "旧品牌" || appearance.BrandSlug != defaultAppearanceBrandSlug || appearance.AuthHeroTitle != defaultAppearanceHeroTitle || appearance.AuthHeroDescription != "" || appearance.DarkLogoURL != defaultAppearanceLogoURL || !appearance.LogoFrameEnabled || !appearance.AuthVideoAutoplay || appearance.SEOTitle != "旧品牌" || !strings.Contains(appearance.SEODescription, "旧品牌") || !strings.Contains(appearance.FooterCopyright, "旧品牌") || appearance.ActiveSkin.ID != "classic" { t.Fatalf("legacy appearance = %#v", appearance) } } func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T) { - svc, db, _, admin := newAppearanceTestService(t) + svc, db, dataDir, admin := newAppearanceTestService(t) resources := []model.Resource{ {ID: "brand-logo", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo.png", MimeType: "image/png"}, {ID: "brand-logo-dark", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/logo-dark.png", MimeType: "image/png"}, @@ -75,6 +123,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T if err := db.Create(&resources).Error; err != nil { t.Fatal(err) } + writeAppearanceResourceFixtures(t, dataDir, resources) updated, err := svc.UpdateAppearance(admin, AppearanceSetting{ BrandName: "HIMA Studio", @@ -86,6 +135,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T LogoFrameEnabled: false, AuthVideoResourceID: "brand-video", AuthVideoPosterResourceID: "brand-poster", + AuthVideoAutoplay: false, SkinID: "studio-indigo", SEOTitle: "HIMA Studio - AI 影视工作台", SEODescription: "面向 Agent、图片、视频、画布与短剧生产的一体化 AI 创作工作台。", @@ -97,7 +147,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T if err != nil { t.Fatal(err) } - if !updated.Configured || updated.BrandName != "HIMA Studio" || updated.BrandSlug != "hima-studio" || updated.Public.BrandSlug != "hima-studio" || updated.AuthHeroTitle != "把灵感,\n变成可见的故事。" || updated.Public.AuthHeroDescription != "从画布开始,持续推进你的创作。" || !updated.Public.LogoConfigured || !updated.Public.DarkLogoConfigured || updated.Public.LogoFrameEnabled || !updated.Public.AuthVideoConfigured || !updated.Public.AuthVideoPosterConfigured || updated.Public.SkinID != "studio-indigo" || updated.Public.SEOTitle != "HIMA Studio - AI 影视工作台" || updated.Public.SEOKeywords != "AI 影视,短剧,画布" || !updated.Public.ICPFilingEnabled || updated.Public.ICPFilingNumber != "蜀ICP备2026000000号-1" { + if !updated.Configured || updated.BrandName != "HIMA Studio" || updated.BrandSlug != "hima-studio" || updated.Public.BrandSlug != "hima-studio" || updated.AuthHeroTitle != "把灵感,\n变成可见的故事。" || updated.Public.AuthHeroDescription != "从画布开始,持续推进你的创作。" || !updated.Public.LogoConfigured || !updated.Public.DarkLogoConfigured || updated.Public.LogoFrameEnabled || !updated.Public.AuthVideoConfigured || !updated.Public.AuthVideoPosterConfigured || updated.Public.AuthVideoAutoplay || updated.Public.SkinID != "studio-indigo" || updated.Public.SEOTitle != "HIMA Studio - AI 影视工作台" || updated.Public.SEOKeywords != "AI 影视,短剧,画布" || !updated.Public.ICPFilingEnabled || updated.Public.ICPFilingNumber != "蜀ICP备2026000000号-1" { t.Fatalf("UpdateAppearance() = %#v", updated) } for _, assetURL := range []string{updated.Public.LogoURL, updated.Public.DarkLogoURL, updated.Public.AuthVideoURL, updated.Public.AuthVideoPosterURL} { @@ -125,7 +175,7 @@ func TestUpdateAppearancePersistsAuditsAndUsesVersionedPublicAssets(t *testing.T } func TestAppearanceReusesSingleLogoAcrossThemes(t *testing.T) { - svc, db, _, admin := newAppearanceTestService(t) + svc, db, dataDir, admin := newAppearanceTestService(t) resources := []model.Resource{ {ID: "only-light", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/light.png", MimeType: "image/png"}, {ID: "only-dark", UserID: admin.ID, Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/dark.png", MimeType: "image/png"}, @@ -133,6 +183,7 @@ func TestAppearanceReusesSingleLogoAcrossThemes(t *testing.T) { if err := db.Create(&resources).Error; err != nil { t.Fatal(err) } + writeAppearanceResourceFixtures(t, dataDir, resources) lightOnly, err := svc.UpdateAppearance(admin, AppearanceSetting{BrandName: "HIMA Studio", BrandSlug: "hima-studio", AuthHeroTitle: defaultAppearanceHeroTitle, LogoResourceID: "only-light", LogoFrameEnabled: true, SkinID: defaultAppearanceSkinID}) if err != nil { @@ -233,6 +284,89 @@ func TestOpenAppearanceAssetOnlyServesConfiguredSlot(t *testing.T) { } } +func TestAppearanceFallsBackWhenConfiguredLogoResourceIsMissing(t *testing.T) { + svc, db, _, admin := newAppearanceTestService(t) + value := defaultAppearanceSetting() + value.BrandName = "HIMA Studio" + value.BrandSlug = "hima-studio" + value.LogoResourceID = "missing-logo" + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil { + t.Fatal(err) + } + + appearance, err := svc.Appearance() + if err != nil { + t.Fatal(err) + } + if appearance.LogoConfigured || appearance.LogoURL != defaultAppearanceLogoURL || appearance.DarkLogoURL != defaultAppearanceLogoURL { + t.Fatalf("missing logo public appearance = %#v", appearance) + } + adminAppearance, err := svc.AdminAppearance(admin) + if err != nil { + t.Fatal(err) + } + if adminAppearance.LogoResourceID != "" || adminAppearance.Public.LogoConfigured { + t.Fatalf("missing logo admin appearance = %#v", adminAppearance) + } +} + +func TestAppearanceFallsBackWhenLocalLogoFileIsMissing(t *testing.T) { + svc, db, _, _ := newAppearanceTestService(t) + resource := model.Resource{ID: "missing-local-logo", UserID: "appearance-admin", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "brand/missing.png", MimeType: "image/png"} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + value := defaultAppearanceSetting() + value.LogoResourceID = resource.ID + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil { + t.Fatal(err) + } + + appearance, err := svc.Appearance() + if err != nil { + t.Fatal(err) + } + if appearance.LogoConfigured || appearance.LogoURL != defaultAppearanceLogoURL { + t.Fatalf("missing local logo public appearance = %#v", appearance) + } +} + +func TestUploadAppearanceLogoAlwaysUsesServerLocalStorage(t *testing.T) { + svc, db, dataDir, admin := newAppearanceTestService(t) + requests := make(chan struct{}, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests <- struct{}{} + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + seedOSSEnabled(t, db, admin.ID, server.URL) + pngBytes := append([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}, bytes.Repeat([]byte{0}, 32)...) + + for _, slot := range []string{AppearanceAssetLogo, AppearanceAssetDarkLogo} { + resource, err := svc.UploadAppearanceAsset(admin, slot, multipartFileHeader(t, slot+".png", "image/png", pngBytes)) + if err != nil { + t.Fatalf("UploadAppearanceAsset(%s): %v", slot, err) + } + if resource.Provider != "local" || resource.Endpoint != "" || resource.Bucket != "" || resource.StorageSettingID != "" { + t.Fatalf("UploadAppearanceAsset(%s) storage = %#v", slot, resource) + } + if _, err := os.Stat(filepath.Join(dataDir, "resources", filepath.FromSlash(resource.ObjectKey))); err != nil { + t.Fatalf("UploadAppearanceAsset(%s) local file: %v", slot, err) + } + } + if len(requests) != 0 { + t.Fatalf("logo upload unexpectedly contacted object storage %d time(s)", len(requests)) + } +} + func TestUpdateAppearanceValidatesLoginCopy(t *testing.T) { svc, _, _, admin := newAppearanceTestService(t) @@ -367,6 +501,22 @@ func multipartFileHeader(t *testing.T, name string, contentType string, data []b return req.MultipartForm.File["file"][0] } +func writeAppearanceResourceFixtures(t *testing.T, dataDir string, resources []model.Resource) { + t.Helper() + for _, resource := range resources { + if resource.Provider != "local" { + continue + } + filePath := filepath.Join(dataDir, "resources", filepath.FromSlash(resource.ObjectKey)) + if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filePath, []byte(resource.ID), 0o640); err != nil { + t.Fatal(err) + } + } +} + func newAppearanceTestService(t *testing.T) (*Service, *gorm.DB, string, *model.User) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index f6019bad3..02cdc4a1b 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -35,6 +35,8 @@ type ChannelModelRequest struct { PriceTiers []ChannelModelPriceTierRequest `json:"priceTiers"` } +const maxAdminChannelModelBatchDeleteCount = 100 + // ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。 // Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。 type ChannelModelPriceTierRequest struct { @@ -826,47 +828,76 @@ func normalizeChannelModelContractWithRegistry(registry *protocol.Registry, chan } func (s *Service) DeleteAdminChannelModel(actor *model.User, channelID string, id string) error { + _, err := s.DeleteAdminChannelModels(actor, channelID, []string{id}) + return err +} + +// DeleteAdminChannelModels validates the complete selection before asking the +// repository to remove it atomically. This deliberately rejects partial success: +// administrators can safely correct an in-use model and retry the same selection. +func (s *Service) DeleteAdminChannelModels(actor *model.User, channelID string, ids []string) (int64, error) { if err := s.RequireAdmin(actor); err != nil { - return err + return 0, err } if _, err := s.repo.AdminSystemChannel(channelID); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return BadAuthRequest("系统渠道不存在或已删除") + return 0, BadAuthRequest("系统渠道不存在或已删除") } - return err + return 0, err } - if _, err := s.repo.ChannelModelByID(channelID, id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return BadAuthRequest("渠道模型不存在或已删除") - } - return err - } - items, err := s.repo.ChannelModels(channelID, false) + modelIDs, err := normalizeAdminChannelModelDeleteIDs(ids) if err != nil { - return err + return 0, err } - names := make([]string, 0, len(items)) + items, err := s.repo.ChannelModels(channelID, true) + if err != nil { + return 0, err + } + selected := make(map[string]bool, len(modelIDs)) + for _, id := range modelIDs { + selected[id] = true + } + found := 0 for _, item := range items { - if item.ID != id { - names = append(names, item.ModelKey) + if selected[item.ID] { + found++ } } - encoded, err := json.Marshal(names) - if err != nil { - return err + if found != len(modelIDs) { + return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试") } // 删除模型与渠道的兼容模型清单必须同事务提交,避免接口报错但列表已部分变化。 - err = s.repo.DeleteChannelModel(channelID, id, string(encoded), time.Now()) + deleted, err := s.repo.DeleteChannelModels(channelID, modelIDs, time.Now()) if errors.Is(err, repository.ErrChannelModelInUse) { - return BadAuthRequest("渠道模型仍被前台模型供应线路或进行中任务使用,请先移除线路并等待任务结束") + return 0, BadAuthRequest("所选渠道模型中有模型仍被前台模型供应线路或进行中任务使用,本次未删除任何模型") } if errors.Is(err, gorm.ErrRecordNotFound) { - return BadAuthRequest("渠道模型不存在或已删除") + return 0, BadAuthRequest("所选渠道模型中存在已删除或不属于当前渠道的记录,请刷新后重试") } if err == nil { s.invalidateRouteCatalog() } - return err + return deleted, err +} + +func normalizeAdminChannelModelDeleteIDs(values []string) ([]string, error) { + result := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, value := range values { + id := strings.TrimSpace(value) + if id == "" || seen[id] { + continue + } + seen[id] = true + result = append(result, id) + } + if len(result) == 0 { + return nil, BadAuthRequest("请至少选择一个要删除的渠道模型") + } + if len(result) > maxAdminChannelModelBatchDeleteCount { + return nil, BadAuthRequest("单次最多删除 100 个渠道模型") + } + return result, nil } func (s *Service) syncInitialChannelModels(channel *model.ModelChannel, names []string) error { @@ -945,20 +976,7 @@ func (s *Service) ensureChannelModels(channelID string, includeDisabled bool) ([ } func (s *Service) syncChannelModelNames(channel *model.ModelChannel) error { - items, err := s.repo.ChannelModels(channel.ID, false) - if err != nil { - return err - } - names := make([]string, 0, len(items)) - for _, item := range items { - names = append(names, item.ModelKey) - } - encoded, err := json.Marshal(names) - if err != nil { - return err - } - channel.ModelsJSON = string(encoded) - return s.repo.Save(channel) + return s.repo.SyncChannelModelNames(channel.ID, time.Now()) } func (s *Service) capabilityForProtocol(protocol model.ChannelInterfaceType) string { diff --git a/backend/internal/service/finance_token_test.go b/backend/internal/service/finance_token_test.go index 02b18a070..638646c41 100644 --- a/backend/internal/service/finance_token_test.go +++ b/backend/internal/service/finance_token_test.go @@ -86,6 +86,25 @@ func TestEnrichAPICallLogReadsArkVideoUsage(t *testing.T) { } } +func TestEnrichAPICallLogPrefersNewAPIChannel2NestedTaskID(t *testing.T) { + tests := []struct { + name string + payload string + }{ + {name: "snake case", payload: `{"id":"wrapper-id","data":{"task_id":"task-snake"}}`}, + {name: "camel case", payload: `{"id":"wrapper-id","data":{"taskId":"task-camel"}}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + log := &model.ApiCallLog{Capability: "video", Path: "/v1/video/generations"} + (&Service{}).EnrichAPICallLog(log, []byte(test.payload)) + if !strings.HasPrefix(log.ProviderRequestID, "task-") { + t.Fatalf("ProviderRequestID = %q, want nested upstream task ID", log.ProviderRequestID) + } + }) + } +} + func TestEnrichAPICallLogKeepsUserFacingAndUpstreamFailureDetails(t *testing.T) { log := &model.ApiCallLog{ Status: model.ApiCallStatusFailed, diff --git a/backend/internal/service/image_size_presets_test.go b/backend/internal/service/image_size_presets_test.go new file mode 100644 index 000000000..624516998 --- /dev/null +++ b/backend/internal/service/image_size_presets_test.go @@ -0,0 +1,85 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestImageSizePresetsValidationAndRoundTrip(t *testing.T) { + profile := DefaultImageCapabilityConfig("openai-image", "test") + profile.Size = ImageSizeConfig{Parameter: "size", Values: []string{"1824x1024", "1920x1080"}, Default: "1824x1024", Presets: []ImageSizePreset{ + {Tier: "1k", Ratio: "16:9", Size: "1824x1024", Width: 1824, Height: 1024}, + {Tier: "2k", Ratio: "16:9", Size: "1920x1080", Width: 1920, Height: 1080}, + }} + if err := validateImageCapabilityConfig(profile); err != nil { + t.Fatal(err) + } + data, err := json.Marshal(profile) + if err != nil { + t.Fatal(err) + } + var restored ImageCapabilityConfig + if err := json.Unmarshal(data, &restored); err != nil || len(restored.Size.Presets) != 2 || restored.Size.Presets[1].Size != "1920x1080" { + t.Fatalf("presets did not survive JSON round trip: %v", err) + } + + for _, tc := range []struct { + name string + edit func(*ImageCapabilityConfig) + want string + }{ + {"invalid tier", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Tier = "8k" }, "档位"}, + {"zero ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Ratio = "0:9" }, "比例"}, + {"mismatched ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Ratio = "9:16" }, "不一致"}, + {"tiny size", func(p *ImageCapabilityConfig) { + p.Size.Presets[0].Width = 16 + p.Size.Presets[0].Height = 16 + p.Size.Presets[0].Size = "16x16" + }, "像素"}, + {"size mismatch", func(p *ImageCapabilityConfig) { p.Size.Presets[0].Size = "1024x1024" }, "像素"}, + {"not supported", func(p *ImageCapabilityConfig) { p.Size.Values = []string{"1824x1024"} }, "支持值"}, + {"duplicate equivalent ratio", func(p *ImageCapabilityConfig) { p.Size.Presets[1].Tier = "1k"; p.Size.Presets[1].Ratio = "32:18" }, "重复"}, + } { + t.Run(tc.name, func(t *testing.T) { + var p ImageCapabilityConfig + if err := json.Unmarshal(data, &p); err != nil { + t.Fatal(err) + } + tc.edit(&p) + if err := validateImageCapabilityConfig(&p); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("expected %s, got %v", tc.want, err) + } + }) + } +} + +func TestImageSizePresetsEnforceResolutionRatioPair(t *testing.T) { + profile := DefaultImageCapabilityConfig("grok-image", "grok-imagine-image") + profile.Quality.Default = "1k" + profile.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "16:9"}, Default: "1:1", Presets: []ImageSizePreset{ + {Tier: "1k", Ratio: "1:1", Size: "1024x1024", Width: 1024, Height: 1024}, + {Tier: "2k", Ratio: "16:9", Size: "2752x1536", Width: 2752, Height: 1536}, + }} + if err := validateImageCapabilityConfig(profile); err != nil { + t.Fatal(err) + } + profile.Quality.Default = "2k" + if err := validateImageCapabilityConfig(profile); err == nil || !strings.Contains(err.Error(), "默认图片分辨率") { + t.Fatalf("expected invalid default pair error, got %v", err) + } + profile.Quality.Default = "1k" + input := canvasGenerationInput{Config: providerConfig{Size: "1:1", Quality: "2k"}} + if err := validateImageTask(profile, input); err == nil || !strings.Contains(err.Error(), "当前分辨率") { + t.Fatalf("expected pair validation error, got %v", err) + } + input.Config.Size = "16:9" + if err := validateImageTask(profile, input); err != nil { + t.Fatal(err) + } + profile.Size.AllowCustom = true + input.Config.Size = "1:1" + if err := validateImageTask(profile, input); err != nil { + t.Fatal(err) + } +} diff --git a/backend/internal/service/logical_models_test.go b/backend/internal/service/logical_models_test.go index 5af096129..46cf15bb0 100644 --- a/backend/internal/service/logical_models_test.go +++ b/backend/internal/service/logical_models_test.go @@ -65,6 +65,41 @@ func TestMatchCapabilityTreatsVideoResolutionSuffixAsEquivalent(t *testing.T) { } } +func TestVideoResolutionAliasesAcrossProductAndPricedRoutes(t *testing.T) { + product := CapabilitySpec{ + Version: 1, Capability: "video", + Options: map[string]OptionConstraint{"vquality": {Values: []any{"768P", "2K"}}}, + } + route := capabilitySpecWithPriceTiers(product, model.ChannelModel{ + PriceTiers: []model.ChannelModelPriceTier{ + {Resolution: "768P", Enabled: true, PriceConfigured: true}, + {Resolution: "2K", Enabled: true, PriceConfigured: true}, + }, + }) + if err := validateProductSpecWithinRoutes(product, []CapabilitySpec{route}); err != nil { + t.Fatalf("2K product rejected against priced route: %v", err) + } + for _, resolution := range []string{"2K", "2k", "1440P", "1440"} { + intent := ModelRequestIntent{Capability: "video", Options: map[string]any{ + "vquality": normalizeModelRequestOption("vquality", resolution), + }} + for name, spec := range map[string]CapabilitySpec{"product": product, "route": route} { + if match := MatchCapability(spec, intent); !match.Matched { + t.Errorf("%s rejected %s: %#v", name, resolution, match) + } + } + } + unsupported := CapabilitySpec{Version: 1, Capability: "video", Options: map[string]OptionConstraint{ + "vquality": {Values: []any{"4K"}}, + }} + if err := validateProductSpecWithinRoutes(unsupported, []CapabilitySpec{route}); err == nil { + t.Fatal("2K route accepted unsupported 4K product") + } + if capabilityOptionValuesEqual("quality", "2K", "1440p") { + t.Fatal("video aliases must not affect image quality") + } +} + func TestValidateProductSpecWithinRoutesRejectsUnsupportedCapabilityValue(t *testing.T) { routes := []CapabilitySpec{ { diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 2af034c2a..8c8457876 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -50,10 +50,19 @@ type ImageReferenceConfig struct { } type ImageSizeConfig struct { - Parameter string `json:"parameter"` - Values []string `json:"values"` - Default string `json:"default"` - AllowCustom bool `json:"allowCustom"` + Parameter string `json:"parameter"` + Values []string `json:"values"` + Default string `json:"default"` + AllowCustom bool `json:"allowCustom"` + Presets []ImageSizePreset `json:"presets,omitempty"` +} + +type ImageSizePreset struct { + Tier string `json:"tier"` + Ratio string `json:"ratio"` + Size string `json:"size"` + Width int `json:"width"` + Height int `json:"height"` } type ImageQualityConfig struct { @@ -494,6 +503,7 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { switch value.Size.Parameter { case "none": value.Size.Values = []string{} + value.Size.Presets = nil value.Size.Default = "auto" value.Size.AllowCustom = false case "size", "aspect_ratio": @@ -506,6 +516,48 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { default: return BadAuthRequest("尺寸参数仅支持不发送、size 或 aspect_ratio") } + seenPresets := make(map[string]bool) + for _, preset := range value.Size.Presets { + if preset.Tier != "1k" && preset.Tier != "2k" && preset.Tier != "4k" { + return BadAuthRequest("图片分辨率档位仅支持 1K、2K、4K") + } + parts := strings.Split(preset.Ratio, ":") + if len(parts) != 2 { + return BadAuthRequest("图片预设比例格式无效") + } + w, ew := strconv.Atoi(parts[0]) + h, eh := strconv.Atoi(parts[1]) + if ew != nil || eh != nil || w <= 0 || h <= 0 || w > 100000 || h > 100000 || max(w, h) > min(w, h)*3 { + return BadAuthRequest("图片预设比例无效") + } + if preset.Width <= 0 || preset.Height <= 0 || max(preset.Width, preset.Height) > 3840 || max(preset.Width, preset.Height) > min(preset.Width, preset.Height)*3 || preset.Width*preset.Height < 655360 || preset.Width*preset.Height > 8294400 || preset.Size != fmt.Sprintf("%dx%d", preset.Width, preset.Height) { + return BadAuthRequest("图片预设像素尺寸无效") + } + // 容许像素取整误差,但不能将横屏尺寸标记成竖屏或其他比例。 + difference := preset.Width*h - preset.Height*w + if difference < 0 { + difference = -difference + } + if difference*1000 > preset.Height*w*25 { + return BadAuthRequest("图片预设像素尺寸与宽高比不一致") + } + a, b := w, h + for b != 0 { + a, b = b, a%b + } + key := fmt.Sprintf("%s:%d:%d", preset.Tier, w/a, h/a) + if seenPresets[key] { + return BadAuthRequest("图片尺寸预设重复") + } + seenPresets[key] = true + requestValue := preset.Size + if value.Size.Parameter == "aspect_ratio" { + requestValue = preset.Ratio + } + if !containsCapabilityString(value.Size.Values, requestValue) { + return BadAuthRequest("图片预设必须包含在尺寸支持值中") + } + } if value.Quality.Supported { if len(value.Quality.Values) == 0 || strings.TrimSpace(value.Quality.Default) == "" || !containsCapabilityString(value.Quality.Values, value.Quality.Default) { return BadAuthRequest("请配置图片质量支持值和默认值") @@ -514,6 +566,9 @@ func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { value.Quality.Values = []string{} value.Quality.Default = "auto" } + if err := validateImagePresetSelection(value, value.Quality.Default, value.Size.Default); err != nil { + return BadAuthRequest("默认图片分辨率与宽高比不在已配置的组合中") + } if !value.TransparentBackground.Supported { value.TransparentBackground.Default = false } @@ -770,6 +825,9 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp if profile.Quality.Supported && strings.TrimSpace(input.Config.Quality) != "" && !containsCapabilityString(profile.Quality.Values, input.Config.Quality) { return BadAuthRequest("图片质量不在当前模型支持范围内") } + if err := validateImagePresetSelection(profile, firstNonEmpty(input.Config.Quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil { + return err + } count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)) if err == nil && count > profile.MaxOutputs { return BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs)) @@ -777,6 +835,35 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp return nil } +func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error { + if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" { + return nil + } + tier := imageResolutionTier(quality) + if tier == "" { + return nil + } + for _, preset := range profile.Size.Presets { + if preset.Tier == tier && preset.Ratio == ratio { + return nil + } + } + return BadAuthRequest("当前分辨率不支持所选图片宽高比") +} + +func imageResolutionTier(quality string) string { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "1k", "low": + return "1k" + case "2k", "medium": + return "2k" + case "4k", "high": + return "4k" + default: + return "" + } +} + func validateWorkflowProviderPromptLength(input canvasGenerationInput) error { profile := input.Config.CapabilityConfig if input.Mode != "video" || profile == nil || profile.Video == nil { diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index d294d909f..f37e82b2a 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -358,8 +358,9 @@ func capabilityOptionValuesEqual(name string, candidate any, value any) bool { left := normalizedScalar(candidate) right := normalizedScalar(value) if canonicalCapabilityOptionName(name) == "vquality" { - left = strings.TrimSuffix(left, "p") - right = strings.TrimSuffix(right, "p") + // Compare using the same aliases as request intents and price tiers. + left = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, left)), "p") + right = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, right)), "p") } return left == right } diff --git a/backend/internal/service/protocol_runtime_test.go b/backend/internal/service/protocol_runtime_test.go index 195d87a89..4eb960dcf 100644 --- a/backend/internal/service/protocol_runtime_test.go +++ b/backend/internal/service/protocol_runtime_test.go @@ -5,12 +5,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" "infinite-canvas/backend/internal/protocol" ) @@ -342,6 +344,129 @@ func TestDeclarativeProtocolRuntimeExecutesCreatePollAndDownload(t *testing.T) { } } +func TestDeclarativeNewAPIChannel2TaskNotExistRetry(t *testing.T) { + adapter := newDeclarativeNewAPIChannel2TestAdapter(t) + createCalls := 0 + pollCalls := 0 + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/video/generations": + createCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"provider-task-1","status":"pending"}`)) + case "/v1/video/generations/provider-task-1": + pollCalls++ + w.Header().Set("Content-Type", "application/json") + if pollCalls <= 2 { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":"task_not_exist","data":null,"message":"task_not_exist"}`)) + return + } + _, _ = w.Write([]byte(`{"id":"provider-task-1","status":"succeeded","video_url":"` + server.URL + `/media.mp4"}`)) + case "/media.mp4": + w.Header().Set("Content-Type", "video/mp4") + _, _ = w.Write([]byte("video")) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + config := providerConfig{BaseURL: server.URL + "/v1", APIKey: "key", Model: "video-model", InterfaceType: "newapi-channel-2", AllowLocalChannel: true} + ctx := withProviderOutboundPolicy(context.Background(), config) + result, err := runProtocolAdapterTaskWithTiming(ctx, canvasGenerationInput{Mode: "video", Prompt: "a clip", Config: config}, adapter, protocolPollTiming{ + InitialDelay: time.Millisecond, + PollInterval: time.Millisecond, + TaskNotExistWindow: time.Second, + TaskNotExistRetryDelays: []time.Duration{time.Millisecond, time.Millisecond, time.Millisecond, time.Millisecond}, + }) + if err != nil { + t.Fatal(err) + } + if result["mode"] != "video" || createCalls != 1 || pollCalls != 3 { + t.Fatalf("result = %#v, create calls = %d, poll calls = %d", result, createCalls, pollCalls) + } +} + +func TestDeclarativeNewAPIChannel2TaskNotExistExhaustion(t *testing.T) { + adapter := newDeclarativeNewAPIChannel2TestAdapter(t) + createCalls := 0 + pollCalls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/video/generations": + createCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"provider-task-1","status":"pending"}`)) + case "/v1/video/generations/provider-task-1": + pollCalls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"code":"task_not_exist","message":"task_not_exist"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + config := providerConfig{BaseURL: server.URL + "/v1", APIKey: "key", Model: "video-model", InterfaceType: "newapi-channel-2", AllowLocalChannel: true} + ctx := withProviderOutboundPolicy(context.Background(), config) + _, err := runProtocolAdapterTaskWithTiming(ctx, canvasGenerationInput{Mode: "video", Prompt: "a clip", Config: config}, adapter, protocolPollTiming{ + InitialDelay: time.Millisecond, + PollInterval: time.Millisecond, + TaskNotExistWindow: time.Second, + TaskNotExistRetryDelays: []time.Duration{time.Millisecond, time.Millisecond, time.Millisecond, time.Millisecond}, + }) + var pendingErr providerStatePendingError + var httpErr providerHTTPError + if !errors.As(err, &pendingErr) || pendingErr.TaskID != "provider-task-1" || !errors.As(err, &httpErr) { + t.Fatalf("error = %#v, want typed provider pending error wrapping HTTP error", err) + } + if createCalls != 1 || pollCalls != 5 { + t.Fatalf("create calls = %d, poll calls = %d, want 1 and 5", createCalls, pollCalls) + } +} + +func TestDeclarativeNewAPIChannel2TaskNotExistStrictClassification(t *testing.T) { + tests := []struct { + name string + interfaceType string + taskID string + err error + want bool + }{ + {name: "error code", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}, want: true}, + {name: "error message", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: `{"message":"task_not_exist"}`}, want: true}, + {name: "other interface", interfaceType: "newapi-channel-1", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}}, + {name: "missing task id", interfaceType: "newapi-channel-2", err: providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}}, + {name: "other status", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 404, Body: `{"code":"task_not_exist"}`}}, + {name: "unstructured body", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: "task_not_exist"}}, + {name: "partial match", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist_later"}`}}, + {name: "other provider error", interfaceType: "newapi-channel-2", taskID: "task-1", err: providerHTTPError{StatusCode: 400, Body: `{"code":"invalid_parameter"}`}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isNewAPIChannel2TaskNotReady(test.interfaceType, test.taskID, test.err); got != test.want { + t.Fatalf("isNewAPIChannel2TaskNotReady() = %v, want %v", got, test.want) + } + }) + } +} + +func newDeclarativeNewAPIChannel2TestAdapter(t *testing.T) protocol.Adapter { + t.Helper() + adapter, err := protocol.LoadManifest([]byte(`{ + "apiVersion":"yingce.plugin/v1", + "id":"newapi-channel-2","version":"1.0.0","name":"NewAPI Channel 2","author":"Test","documentation":"# Test", + "contributes":{"providers":[{"id":"newapi-channel-2","label":"NewAPI Channel 2","capabilities":["video"],"scopes":["canvas"],"create":{"method":"POST","path":"/video/generations","fields":{"model":"request.model"}},"poll":{"method":"GET","path":"/video/generations/{{taskId}}"},"response":{"taskIdPaths":["id"],"statusPaths":["status"],"resultPaths":["video_url"],"resultKind":"video"}}]} + }`)) + if err != nil { + t.Fatal(err) + } + return adapter +} + func TestDeclarativeProtocolRecoveryQueriesExistingTaskWithoutCreating(t *testing.T) { manifest := []byte(`{ "apiVersion":"yingce.plugin/v2", diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 8a8556e76..33e5ad633 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -156,6 +156,17 @@ type providerHTTPError struct { RetryAfter time.Duration } +type providerStatePendingError struct { + TaskID string + Cause error +} + +func (e providerStatePendingError) Error() string { + return fmt.Sprintf("上游任务状态尚未同步,将继续查询原任务(任务 %s)", e.TaskID) +} + +func (e providerStatePendingError) Unwrap() error { return e.Cause } + type providerAnalyticsKey struct{} type providerOutboundPolicyKey struct{} @@ -368,7 +379,7 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } // 工作流参数由工作流字段定义校验,普通模型能力配置不能覆盖它们。 if resumedProviderRequestID(ctx) == "" { - if err := s.hydrateGenerationMedia(userID, &input, false); err != nil { + if err := s.hydrateGenerationMedia(userID, &input, providerMediaHydrationPolicy{}); err != nil { return nil, err } } @@ -405,11 +416,8 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } } if resumedProviderRequestID(ctx) == "" { - requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2" || input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) || input.Config.InterfaceType == string(model.ChannelInterfaceMiniMaxVideo) - if adapter, ok := protocolAdapterForContext(ctx, input.Config.InterfaceType); ok { - requirePublicURL = requirePublicURL || adapter.Metadata().RequiresPublicMediaURLs - } - if err := s.hydrateGenerationMedia(userID, &input, requirePublicURL); err != nil { + mediaPolicy := providerMediaHydrationPolicyFor(ctx, input) + if err := s.hydrateGenerationMedia(userID, &input, mediaPolicy); err != nil { return nil, err } if err := s.prepareArkPrivateAssetReferences(ctx, userID, &input); err != nil { @@ -442,6 +450,51 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string } } +type providerMediaHydrationPolicy struct { + requireURL bool + preferURL bool +} + +func providerMediaHydrationPolicyFor(ctx context.Context, input canvasGenerationInput) providerMediaHydrationPolicy { + policy := providerMediaHydrationPolicy{preferURL: providerPrefersMediaURLs(input.Config.InterfaceType, input)} + switch strings.TrimSpace(input.Config.InterfaceType) { + case string(model.ChannelInterfaceNewAPIVideo), string(model.ChannelInterfaceNewAPIChannel1), string(model.ChannelInterfaceNewAPIChannel2), string(model.ChannelInterfaceVolcengineArkVideo), string(model.ChannelInterfaceMiniMaxVideo): + policy.requireURL = true + policy.preferURL = true + } + if adapter, ok := protocolAdapterForContext(ctx, input.Config.InterfaceType); ok && adapter.Metadata().RequiresPublicMediaURLs { + policy.requireURL = true + policy.preferURL = true + } + if input.Mask != nil { + policy.requireURL = false + policy.preferURL = false + } + return policy +} + +// providerPrefersMediaURLs lists protocols whose media fields accept a remote +// URL. Byte-oriented protocols deliberately remain on the existing data path. +func providerPrefersMediaURLs(interfaceType string, input canvasGenerationInput) bool { + if input.Mask != nil { + // OpenAI image edits and similar multipart requests require file bytes. + return false + } + switch strings.TrimSpace(interfaceType) { + case string(model.ChannelInterfaceChatCompletion), string(model.ChannelInterfaceOpenAIResponse), string(model.ChannelInterfaceClaudeAPI), + string(model.ChannelInterfaceGrokImage), string(model.ChannelInterfaceVolcengineArkImage), + string(model.ChannelInterfaceXAIVideo), string(model.ChannelInterfaceNovitaVideo), + string(model.ChannelInterfaceMiniMaxVideo), string(model.ChannelInterfaceNewAPIVideo), + string(model.ChannelInterfaceNewAPIChannel1), string(model.ChannelInterfaceNewAPIChannel2), + string(model.ChannelInterfaceVolcengineArkVideo): + return true + } + if isGrokVideoConfig(input.Config) || isSeedanceVideoConfig(input.Config) || isArkPlanVideoConfig(input.Config) { + return true + } + return false +} + func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { if adapter, ok := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok { return runDeclarativeAgentTask(ctx, input, adapter) @@ -1317,40 +1370,41 @@ func metadataStringValues(value any) map[string]string { return values } -func (s *Service) hydrateGenerationMedia(userID string, input *canvasGenerationInput, requirePublicURL bool) error { +func (s *Service) hydrateGenerationMedia(userID string, input *canvasGenerationInput, policy providerMediaHydrationPolicy) error { groups := [][]providerMedia{input.ReferenceImages, input.ReferenceVideos, input.ReferenceAudios} for _, group := range groups { for index := range group { - if err := s.hydrateProviderMedia(userID, &group[index], requirePublicURL); err != nil { + if err := s.hydrateProviderMedia(userID, &group[index], policy); err != nil { return err } } } if input.Mask != nil { - return s.hydrateProviderMedia(userID, input.Mask, requirePublicURL) + return s.hydrateProviderMedia(userID, input.Mask, policy) } return nil } -func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requirePublicURL bool) error { +func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, policy providerMediaHydrationPolicy) error { if !strings.HasPrefix(media.StorageKey, "resource:") { - if requirePublicURL && strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") { + if policy.requireURL && strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") { return errors.New("当前 JSON 视频协议的参考素材不能使用内嵌数据,请先上传到对象存储或提供公网素材地址") } return nil } resourceID := strings.TrimPrefix(media.StorageKey, "resource:") - if requirePublicURL { - resource, err := s.repo.ResourceForUser(userID, resourceID) - if err != nil { - return fmt.Errorf("读取任务参考资源失败:%w", err) - } - if resource.Status != "ready" { - return errors.New("任务参考资源尚未上传完成") - } + resource, err := s.repo.ResourceForUser(userID, resourceID) + if err != nil { + return fmt.Errorf("读取任务参考资源失败:%w", err) + } + if resource.Status != "ready" { + return errors.New("任务参考资源尚未上传完成") + } + useObjectURL := policy.requireURL || (policy.preferURL && resourceUsesObjectStorage(resource)) + if useObjectURL { signedURL, err := s.directResourceURL(resource, time.Now().Add(providerResourceURLTTL)) if err != nil { - return fmt.Errorf("生成 JSON 视频协议参考素材地址失败:%w", err) + return fmt.Errorf("生成参考素材地址失败:%w", err) } media.URL = signedURL media.DataURL = "" @@ -1369,17 +1423,17 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requ return fmt.Errorf("读取任务参考资源失败:%w", err) } defer body.Close() - policy, err := s.RuntimePolicy() + runtimePolicy, err := s.RuntimePolicy() if err != nil { return err } - resourceLimit := megabytes(policy.Resource.ResourceUploadMB) + resourceLimit := megabytes(runtimePolicy.Resource.ResourceUploadMB) data, err := io.ReadAll(io.LimitReader(body, resourceLimit+1)) if err != nil { return err } if int64(len(data)) > resourceLimit { - return fmt.Errorf("任务参考资源超过 %dMB", policy.Resource.ResourceUploadMB) + return fmt.Errorf("任务参考资源超过 %dMB", runtimePolicy.Resource.ResourceUploadMB) } mimeType := normalizedMediaMimeType(firstNonEmpty(media.MimeType, resource.MimeType), data) media.DataURL = dataURL(mimeType, data) @@ -1391,6 +1445,14 @@ func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, requ return nil } +func resourceUsesObjectStorage(resource *model.Resource) bool { + if resource == nil { + return false + } + provider := strings.ToLower(strings.TrimSpace(resource.Provider)) + return provider != "" && provider != "local" +} + func normalizedMediaMimeType(declared string, data []byte) string { declared = strings.TrimSpace(strings.Split(declared, ";")[0]) if declared != "" && declared != "application/octet-stream" { @@ -2356,10 +2418,32 @@ func runDeclarativeProtocolTask(ctx context.Context, input canvasGenerationInput return runProtocolAdapterTask(ctx, input, adapter) } +type protocolPollTiming struct { + InitialDelay time.Duration + PollInterval time.Duration + TaskNotExistWindow time.Duration + TaskNotExistMaxMisses int + TaskNotExistRetryDelays []time.Duration +} + +var defaultProtocolPollTiming = protocolPollTiming{ + InitialDelay: 2 * time.Second, + PollInterval: 2500 * time.Millisecond, + TaskNotExistWindow: 15 * time.Second, + TaskNotExistMaxMisses: 5, + TaskNotExistRetryDelays: []time.Duration{2 * time.Second, 3 * time.Second, 5 * time.Second, 5 * time.Second}, +} + func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter) (map[string]interface{}, error) { + return runProtocolAdapterTaskWithTiming(ctx, input, adapter, defaultProtocolPollTiming) +} + +func runProtocolAdapterTaskWithTiming(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter, timing protocolPollTiming) (map[string]interface{}, error) { request := protocolRequestFromInput(input) taskID := resumedProviderRequestID(ctx) var created protocol.CreateResult + createdProviderTask := false + syncWindowStartedAt := time.Now() if taskID == "" { spec, err := adapter.BuildCreate(ctx, protocol.RequestContext{BaseURL: input.Config.BaseURL, Request: request}) if err != nil { @@ -2383,8 +2467,16 @@ func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, ad if taskID == "" { return nil, errors.New("声明式协议创建请求没有返回任务 ID") } + createdProviderTask = true + syncWindowStartedAt = time.Now() + } + if createdProviderTask && input.Config.InterfaceType == string(model.ChannelInterfaceNewAPIChannel2) { + if err := sleepContext(ctx, timing.InitialDelay); err != nil { + return nil, err + } } + taskNotExistMisses := 0 for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); { spec, err := adapter.BuildPoll(ctx, protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID}) if err != nil { @@ -2392,8 +2484,26 @@ func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, ad } body, err := executeProtocolRequest(withProviderRequestKind(ctx, "poll"), input.Config, spec) if err != nil { + if isNewAPIChannel2TaskNotReady(input.Config.InterfaceType, taskID, err) { + taskNotExistMisses++ + maxMisses := timing.TaskNotExistMaxMisses + if maxMisses <= 0 { + maxMisses = len(timing.TaskNotExistRetryDelays) + 1 + } + if time.Since(syncWindowStartedAt) >= timing.TaskNotExistWindow || taskNotExistMisses >= maxMisses { + return nil, providerStatePendingError{TaskID: taskID, Cause: err} + } + delayIndex := min(taskNotExistMisses-1, len(timing.TaskNotExistRetryDelays)-1) + if delayIndex >= 0 { + if sleepErr := sleepContext(ctx, timing.TaskNotExistRetryDelays[delayIndex]); sleepErr != nil { + return nil, sleepErr + } + } + continue + } return nil, err } + taskNotExistMisses = 0 state, err := adapter.ParsePoll(ctx, protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID}, body) if err != nil { return nil, err @@ -2407,13 +2517,29 @@ func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, ad case protocol.StatusFailed, protocol.StatusCancelled: return nil, protocolResultError(state.Message, taskID) } - if err := sleepContext(ctx, 2500*time.Millisecond); err != nil { + if err := sleepContext(ctx, timing.PollInterval); err != nil { return nil, err } } return nil, fmt.Errorf("声明式协议任务超时(任务 %s)", taskID) } +func isNewAPIChannel2TaskNotReady(interfaceType string, taskID string, err error) bool { + if strings.TrimSpace(interfaceType) != string(model.ChannelInterfaceNewAPIChannel2) || strings.TrimSpace(taskID) == "" { + return false + } + var httpErr providerHTTPError + if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusBadRequest { + return false + } + var payload map[string]any + if json.Unmarshal([]byte(httpErr.Body), &payload) != nil { + return false + } + code, message := providerFailureDetails(payload) + return strings.EqualFold(strings.TrimSpace(code), "task_not_exist") || strings.EqualFold(strings.TrimSpace(message), "task_not_exist") +} + // queryProtocolAdapterVideoTask performs exactly one read of an existing // declarative provider task. Manual recovery uses this path so it can never // create a second billable generation while checking a failed local task. diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index 7ac7dc782..5b2223178 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -23,6 +23,34 @@ import ( const testReferenceImageDataURL = "data:image/png;base64,aGVsbG8=" const testGeminiReferenceImageDataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +func TestProviderMediaHydrationPolicyPrefersObjectURLs(t *testing.T) { + prefer := providerMediaHydrationPolicyFor(context.Background(), canvasGenerationInput{Config: providerConfig{InterfaceType: string(model.ChannelInterfaceGrokImage)}}) + if prefer.requireURL || !prefer.preferURL { + t.Fatalf("grok image policy = %#v", prefer) + } + required := providerMediaHydrationPolicyFor(context.Background(), canvasGenerationInput{Config: providerConfig{InterfaceType: string(model.ChannelInterfaceMiniMaxVideo)}}) + if !required.requireURL || !required.preferURL { + t.Fatalf("minimax policy = %#v", required) + } + bytesOnly := providerMediaHydrationPolicyFor(context.Background(), canvasGenerationInput{Config: providerConfig{InterfaceType: string(model.ChannelInterfaceGeminiImage)}}) + if bytesOnly.requireURL || bytesOnly.preferURL { + t.Fatalf("gemini image policy = %#v", bytesOnly) + } + masked := providerMediaHydrationPolicyFor(context.Background(), canvasGenerationInput{Config: providerConfig{InterfaceType: string(model.ChannelInterfaceOpenAIImage)}, Mask: &providerMedia{ID: "mask"}}) + if masked.requireURL || masked.preferURL { + t.Fatalf("openai mask policy = %#v", masked) + } + if resourceUsesObjectStorage(&model.Resource{Provider: "aliyun"}) != true { + t.Fatal("aliyun should use object storage URLs") + } + if resourceUsesObjectStorage(&model.Resource{Provider: "tencent"}) != true || resourceUsesObjectStorage(&model.Resource{Provider: "qiniu"}) != true || resourceUsesObjectStorage(&model.Resource{Provider: "s3"}) != true { + t.Fatal("cos/kodo/s3 should use object storage URLs") + } + if resourceUsesObjectStorage(&model.Resource{Provider: "local"}) { + t.Fatal("local resources should fall back to protocol-compatible bytes") + } +} + func TestProviderRequestErrorDetails(t *testing.T) { tests := []struct { name string @@ -2714,6 +2742,39 @@ func TestRunMiniMaxVideoTaskCreatesPollsAndDownloads(t *testing.T) { } } +func TestRunMiniMaxVideoTaskSendsOfficial2KResolution(t *testing.T) { + t.Setenv("CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS", "127.0.0.1") + for _, quality := range []string{"2K", "1440p"} { + t.Run(quality, func(t *testing.T) { + requested := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v2/video_generation" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + requested = true + var body miniMaxVideoRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + } + if body.Resolution != "2K" { + t.Errorf("resolution = %q, want official API value 2K", body.Resolution) + } + // End at submission: this test inspects the wire payload without polling. + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + _, err := runMiniMaxVideoTask(context.Background(), canvasGenerationInput{ + Mode: "video", Prompt: "make it move", + Config: providerConfig{BaseURL: server.URL, APIKey: "test-key", Model: "MiniMax-H3", VQuality: quality}, + }) + if !requested || err == nil || !strings.Contains(err.Error(), "没有返回任务 ID") { + t.Fatalf("expected submission without task ID, requested=%v err=%v", requested, err) + } + }) + } +} + func TestRunMiniMaxVideoTaskUsesExplicitReferenceRoles(t *testing.T) { t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true") var server *httptest.Server diff --git a/backend/internal/service/read_cache.go b/backend/internal/service/read_cache.go index 33531cafa..79e0dcc5d 100644 --- a/backend/internal/service/read_cache.go +++ b/backend/internal/service/read_cache.go @@ -139,9 +139,15 @@ func (c *boundedReadCache[K, V]) remove(e *readCacheEntry[K, V]) { } func (c *boundedReadCache[K, V]) clear() { + c.clearAndCount() +} + +func (c *boundedReadCache[K, V]) clearAndCount() int { c.mu.Lock() defer c.mu.Unlock() + count := len(c.entries) c.entries = make(map[K]*readCacheEntry[K, V]) c.lru.Init() c.bytes = 0 + return count } diff --git a/backend/internal/service/resource.go b/backend/internal/service/resource.go index 4678da70f..6f9bb7235 100644 --- a/backend/internal/service/resource.go +++ b/backend/internal/service/resource.go @@ -269,6 +269,17 @@ func validatePublicResourceBaseURL(raw string) (*url.URL, error) { } func (s *Service) UploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) { + return s.uploadResource(userID, header, kind, width, height, durationMs, false, uploadIdentity...) +} + +// uploadLocalResource keeps small system-owned assets on the server even when +// the uploader or platform has enabled object storage. The resource still uses +// the normal database record and persistent data directory lifecycle. +func (s *Service) uploadLocalResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) { + return s.uploadResource(userID, header, kind, width, height, durationMs, true, uploadIdentity...) +} + +func (s *Service) uploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, forceLocal bool, uploadIdentity ...string) (*model.Resource, error) { if header == nil { return nil, BadAuthRequest("请选择要上传的文件") } @@ -298,7 +309,7 @@ func (s *Service) UploadResource(userID string, header *multipart.FileHeader, ki if err != nil { return nil, err } - resource, stored, err := s.storeResource(userID, kind, header.Filename, mimeType, header.Size, width, height, durationMs, file, uploadKey) + resource, stored, err := s.storeResource(userID, kind, header.Filename, mimeType, header.Size, width, height, durationMs, file, uploadKey, forceLocal) if err != nil { s.releaseUserUploadQuota(userID, day, header.Size) } else if stored { @@ -334,7 +345,7 @@ func (s *Service) UploadResourceFile(userID string, fileName string, size int64, if err != nil { return nil, err } - resource, stored, err := s.storeResource(userID, kind, fileName, mimeType, size, width, height, durationMs, file, uploadKey) + resource, stored, err := s.storeResource(userID, kind, fileName, mimeType, size, width, height, durationMs, file, uploadKey, false) if err != nil { s.releaseUserUploadQuota(userID, day, size) } else if stored { @@ -397,7 +408,7 @@ func (s *Service) ImportResourceURL(userID string, rawURL string, kind string, w if err != nil { return nil, err } - resource, stored, err := s.storeResource(userID, kind, payload.fileName, payload.mimeType, size, width, height, durationMs, bytes.NewReader(payload.data), uploadKey) + resource, stored, err := s.storeResource(userID, kind, payload.fileName, payload.mimeType, size, width, height, durationMs, bytes.NewReader(payload.data), uploadKey, false) if err != nil { s.releaseUserUploadQuota(userID, day, size) } else if stored { @@ -492,7 +503,7 @@ func (s *Service) openResourceRange(userID string, resource *model.Resource, ran return &ResourceStream{Resource: resource, Body: stream.body, StatusCode: stream.statusCode, ContentLength: stream.contentLength, ContentRange: stream.contentRange, AcceptRanges: stream.acceptRanges}, nil } -func (s *Service) storeResource(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string) (*model.Resource, bool, error) { +func (s *Service) storeResource(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool) (*model.Resource, bool, error) { if existing, err := s.resourceForUploadKey(userID, uploadKey); err != nil { return nil, false, err } else if existing != nil { @@ -503,9 +514,15 @@ func (s *Service) storeResource(userID string, kind string, fileName string, mim } now := time.Now() kind = normalizeResourceKind(kind, mimeType) - setting, storageSettingID, useOSS, err := s.activeResourceOSSSetting(userID) - if err != nil { - return nil, false, err + var setting ossSettingValue + var storageSettingID string + var useOSS bool + var err error + if !forceLocal { + setting, storageSettingID, useOSS, err = s.activeResourceOSSSetting(userID) + if err != nil { + return nil, false, err + } } provider := "local" objectKey := localObjectKey(userID, kind, fileName, mimeType, now) @@ -752,7 +769,7 @@ func (s *Service) persistGeneratedMediaValueMode(userID string, value interface{ return nil, err } } - resource, _, err := s.storeResource(userID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, int64(len(data)), width, height, int64(intValue(item["durationMs"])), bytes.NewReader(data), nil) + resource, _, err := s.storeResource(userID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, int64(len(data)), width, height, int64(intValue(item["durationMs"])), bytes.NewReader(data), nil, false) if err != nil { if enforceQuota { s.releaseUserUploadQuota(userID, quotaDay, int64(len(data))) diff --git a/backend/internal/service/resource_oss_fallback_test.go b/backend/internal/service/resource_oss_fallback_test.go index dae0364f8..461d255bb 100644 --- a/backend/internal/service/resource_oss_fallback_test.go +++ b/backend/internal/service/resource_oss_fallback_test.go @@ -40,7 +40,7 @@ func TestStoreResourceDegradesToLocalWhenOSSUnavailable(t *testing.T) { resource, created, err := service.storeResource( "user-1", "video", "intro.mp4", "video/mp4", 1024, - 1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil, + 1920, 1080, 0, bytes.NewReader([]byte("fake-mp4-bytes")), nil, false, ) if err != nil { t.Fatalf("storeResource: %v", err) @@ -74,7 +74,7 @@ func TestStoreResourceLocalPathUnaffectedByOSS(t *testing.T) { resource, created, err := service.storeResource( "user-2", "video", "local.mp4", "video/mp4", 512, - 1280, 720, 0, bytes.NewReader([]byte("local-bytes")), nil, + 1280, 720, 0, bytes.NewReader([]byte("local-bytes")), nil, false, ) if err != nil { t.Fatalf("storeResource: %v", err) diff --git a/backend/internal/service/resource_test.go b/backend/internal/service/resource_test.go index ea1c9c02c..529fb4421 100644 --- a/backend/internal/service/resource_test.go +++ b/backend/internal/service/resource_test.go @@ -11,6 +11,7 @@ import ( "net/url" "os" + "path/filepath" "strconv" "strings" "testing" @@ -735,13 +736,13 @@ func TestHydrateNewAPIChannel1ResourceUsesSignedOSSURL(t *testing.T) { t.Fatal(err) } media := providerMedia{StorageKey: "resource:resource-1", DataURL: "data:image/png;base64,old"} - if err := svc.hydrateProviderMedia("user-1", &media, true); err != nil { + if err := svc.hydrateProviderMedia("user-1", &media, providerMediaHydrationPolicy{requireURL: true}); err != nil { t.Fatalf("hydrateProviderMedia() error = %v", err) } if !strings.HasPrefix(media.URL, "https://private-bucket.oss-cn-test.aliyuncs.com/") || media.DataURL != "" || !strings.Contains(media.URL, "Signature=") { t.Fatalf("media = %#v", media) } - if err := svc.hydrateProviderMedia("other-user", &providerMedia{StorageKey: "resource:resource-1"}, true); err == nil { + if err := svc.hydrateProviderMedia("other-user", &providerMedia{StorageKey: "resource:resource-1"}, providerMediaHydrationPolicy{requireURL: true}); err == nil { t.Fatal("hydrateProviderMedia() allowed another user's resource") } } @@ -760,7 +761,7 @@ func TestHydrateNewAPIChannel1ResourceUsesSignedLocalURL(t *testing.T) { t.Fatal(err) } media := providerMedia{StorageKey: "resource:resource-local"} - if err := svc.hydrateProviderMedia("user-1", &media, true); err != nil { + if err := svc.hydrateProviderMedia("user-1", &media, providerMediaHydrationPolicy{requireURL: true}); err != nil { t.Fatalf("hydrateProviderMedia() error = %v", err) } if !strings.HasPrefix(media.URL, server.URL+"/api/public/resources/resource-local/file/resource-local.png?") || !strings.Contains(media.URL, "signature=") || media.DataURL != "" { @@ -772,6 +773,51 @@ func TestHydrateNewAPIChannel1ResourceUsesSignedLocalURL(t *testing.T) { } } +func TestHydratePreferredURLUsesObjectStorageAndFallsBackLocal(t *testing.T) { + svc := newResourceTestService(t) + settingJSON, _ := json.Marshal(ossSettingValue{ + Enabled: true, Provider: "aliyun", Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket", + AccessKeyID: "access-id", AccessKeySecret: "secret-value", + }) + if err := svc.repo.SaveSystemSetting(&model.SystemSetting{Key: ossSettingKey, ValueJSON: string(settingJSON)}); err != nil { + t.Fatal(err) + } + objectResource := model.Resource{ + ID: "resource-oss", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, + Provider: "aliyun", Endpoint: "https://oss-cn-test.aliyuncs.com", Bucket: "private-bucket", + ObjectKey: "users/user-1/image/prefer.png", MimeType: "image/png", + } + if err := svc.repo.CreateResource(&objectResource); err != nil { + t.Fatal(err) + } + media := providerMedia{StorageKey: "resource:resource-oss", DataURL: "data:image/png;base64,old"} + if err := svc.hydrateProviderMedia("user-1", &media, providerMediaHydrationPolicy{preferURL: true}); err != nil { + t.Fatalf("hydrateProviderMedia(prefer object) error = %v", err) + } + if !strings.HasPrefix(media.URL, "https://private-bucket.oss-cn-test.aliyuncs.com/") || media.DataURL != "" || !strings.Contains(media.URL, "Signature=") { + t.Fatalf("object media = %#v", media) + } + + localDir := filepath.Join(svc.dataDir, "resources", "users", "user-1", "image") + if err := os.MkdirAll(localDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(localDir, "local.png"), []byte("png-bytes"), 0o644); err != nil { + t.Fatal(err) + } + localResource := model.Resource{ID: "resource-local-bytes", UserID: "user-1", Kind: "image", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: "users/user-1/image/local.png", MimeType: "image/png"} + if err := svc.repo.CreateResource(&localResource); err != nil { + t.Fatal(err) + } + localMedia := providerMedia{StorageKey: "resource:resource-local-bytes"} + if err := svc.hydrateProviderMedia("user-1", &localMedia, providerMediaHydrationPolicy{preferURL: true}); err != nil { + t.Fatalf("hydrateProviderMedia(prefer local) error = %v", err) + } + if localMedia.URL != "" || !strings.HasPrefix(localMedia.DataURL, "data:image/png;base64,") { + t.Fatalf("local media = %#v", localMedia) + } +} + func TestPublicResourceSignatureRejectsExpiredAndAlteredLinks(t *testing.T) { svc := newResourceTestService(t) expires := strconv.FormatInt(time.Now().Add(time.Minute).Unix(), 10) @@ -879,14 +925,14 @@ func newResourceTestService(t *testing.T) *Service { func TestStoreResourceReusesReadyUploadIdentity(t *testing.T) { svc := newResourceTestService(t) uploadKey := normalizedResourceUploadKey([]string{"image:user-1:logical-upload"}) - first, stored, err := svc.storeResource("user-1", "image", "first.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey) + first, stored, err := svc.storeResource("user-1", "image", "first.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey, false) if err != nil { t.Fatal(err) } if !stored { t.Fatal("first upload was not stored") } - second, stored, err := svc.storeResource("user-1", "image", "second.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey) + second, stored, err := svc.storeResource("user-1", "image", "second.png", "image/png", 7, 1, 1, 0, bytes.NewReader([]byte("payload")), uploadKey, false) if err != nil { t.Fatal(err) } diff --git a/backend/internal/service/runtime_policy_test.go b/backend/internal/service/runtime_policy_test.go index d0c8141a1..d918039e8 100644 --- a/backend/internal/service/runtime_policy_test.go +++ b/backend/internal/service/runtime_policy_test.go @@ -31,16 +31,38 @@ func TestOnlyResumableNewAPIChannel2VideoDeadlinesStayRunning(t *testing.T) { if err != nil { t.Fatal(err) } - base := model.Task{ID: "task-1", Type: "canvas_video", ProviderRequestID: "provider-task-1"} + startedAt := time.Now() + base := model.Task{ID: "task-1", Type: "canvas_video", ProviderRequestID: "provider-task-1", StartedAt: &startedAt} if !svc.shouldDeferVideoProviderTask(base, string(input), context.DeadlineExceeded) { t.Fatal("resumable NewAPI Channel 2 deadline should remain running") } + pendingErr := providerStatePendingError{TaskID: base.ProviderRequestID, Cause: providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}} + if !svc.shouldDeferVideoProviderTask(base, string(input), pendingErr) { + t.Fatal("resumable NewAPI Channel 2 provider sync delay should remain running") + } + expiredStartedAt := startedAt.Add(-newAPIChannel2TaskSyncMaxAge - time.Second) + expiredTask := model.Task{ID: base.ID, Type: base.Type, ProviderRequestID: base.ProviderRequestID, StartedAt: &expiredStartedAt} + if svc.shouldDeferVideoProviderTask(expiredTask, string(input), pendingErr) { + t.Fatal("provider sync delay must stop after the bounded synchronization window") + } + if !svc.shouldDeferVideoProviderTask(expiredTask, string(input), context.DeadlineExceeded) { + t.Fatal("a normal long-running provider task must remain resumable after the synchronization window") + } + if svc.shouldDeferVideoProviderTask(model.Task{ID: base.ID, Type: base.Type, ProviderRequestID: base.ProviderRequestID}, string(input), pendingErr) { + t.Fatal("provider sync delay without a persisted start time must fail closed") + } if svc.shouldDeferVideoProviderTask(model.Task{ID: "task-2", Type: "canvas_video"}, string(input), context.DeadlineExceeded) { t.Fatal("missing provider task id must not be deferred") } + if svc.shouldDeferVideoProviderTask(model.Task{ID: "task-2", Type: "canvas_video"}, string(input), pendingErr) { + t.Fatal("provider sync delay without persisted provider task id must not be deferred") + } if svc.shouldDeferVideoProviderTask(base, string(input), context.Canceled) { t.Fatal("explicit cancellation must not be deferred") } + if svc.shouldDeferVideoProviderTask(base, string(input), providerHTTPError{StatusCode: 400, Body: `{"code":"task_not_exist"}`}) { + t.Fatal("untyped provider error must not be deferred") + } other, err := json.Marshal(canvasGenerationInput{Mode: "video", Config: providerConfig{BaseURL: "https://example.com", InterfaceType: string(model.ChannelInterfaceNewAPIVideo)}}) if err != nil { t.Fatal(err) diff --git a/backend/internal/service/system_metrics_darwin.go b/backend/internal/service/system_metrics_darwin.go new file mode 100644 index 000000000..14b2c53c2 --- /dev/null +++ b/backend/internal/service/system_metrics_darwin.go @@ -0,0 +1,19 @@ +//go:build darwin + +package service + +import "golang.org/x/sys/unix" + +func platformMemory() (uint64, uint64, bool) { + total, err := unix.SysctlUint64("hw.memsize") + if err != nil || total == 0 { + return 0, 0, false + } + // hw.memsize 只能稳定提供物理内存总量;没有可靠可用量时不要把 available=0 + // 误算成 100% 占用,页面会明确降级展示 Go 进程堆内存。 + return total, 0, false +} + +func platformLoadAverage() ([3]float64, bool) { + return [3]float64{}, false +} diff --git a/backend/internal/service/system_metrics_linux.go b/backend/internal/service/system_metrics_linux.go new file mode 100644 index 000000000..2ce36b840 --- /dev/null +++ b/backend/internal/service/system_metrics_linux.go @@ -0,0 +1,43 @@ +//go:build linux + +package service + +import ( + "os" + "strconv" + "strings" +) + +func platformMemory() (uint64, uint64, bool) { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0, 0, false + } + values := map[string]uint64{} + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + value, _ := strconv.ParseUint(fields[1], 10, 64) + values[strings.TrimSuffix(fields[0], ":")] = value * 1024 + } + total, available := values["MemTotal"], values["MemAvailable"] + return total, available, total > 0 +} + +func platformLoadAverage() ([3]float64, bool) { + var result [3]float64 + data, err := os.ReadFile("/proc/loadavg") + if err != nil { + return result, false + } + fields := strings.Fields(string(data)) + if len(fields) < 3 { + return result, false + } + for index := range 3 { + result[index], _ = strconv.ParseFloat(fields[index], 64) + } + return result, true +} diff --git a/backend/internal/service/system_metrics_other.go b/backend/internal/service/system_metrics_other.go new file mode 100644 index 000000000..5b109cede --- /dev/null +++ b/backend/internal/service/system_metrics_other.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package service + +func platformMemory() (uint64, uint64, bool) { return 0, 0, false } +func platformLoadAverage() ([3]float64, bool) { return [3]float64{}, false } +func collectSystemDisk(string) SystemPerformanceDisk { return SystemPerformanceDisk{} } diff --git a/backend/internal/service/system_metrics_unix.go b/backend/internal/service/system_metrics_unix.go new file mode 100644 index 000000000..0d7018863 --- /dev/null +++ b/backend/internal/service/system_metrics_unix.go @@ -0,0 +1,36 @@ +//go:build linux || darwin + +package service + +import ( + "os" + "path/filepath" + "syscall" +) + +func collectSystemDisk(path string) SystemPerformanceDisk { + if path == "" { + path = "." + } + path, _ = filepath.Abs(path) + var stats syscall.Statfs_t + if err := syscall.Statfs(path, &stats); err != nil { + return SystemPerformanceDisk{} + } + blockSize := uint64(stats.Bsize) + total := stats.Blocks * blockSize + free := stats.Bavail * blockSize + used := total - stats.Bfree*blockSize + usage := float64(0) + if total > 0 { + usage = float64(used) / float64(total) * 100 + } + writable := false + if file, err := os.CreateTemp(path, ".system-performance-write-check-"); err == nil { + writable = true + name := file.Name() + _ = file.Close() + _ = os.Remove(name) + } + return SystemPerformanceDisk{Available: true, Writable: writable, TotalBytes: total, UsedBytes: used, FreeBytes: free, UsagePercent: usage} +} diff --git a/backend/internal/service/task_render.go b/backend/internal/service/task_render.go index 9c497abb9..f02defab6 100644 --- a/backend/internal/service/task_render.go +++ b/backend/internal/service/task_render.go @@ -88,7 +88,7 @@ func (w *taskWorkerCoordinator) processTimelineRender(task *model.Task, ctx cont } durationMs := planDurationMs(plan) fileName := fmt.Sprintf("timeline-render-%s.mp4", time.Now().Format("20060102-150405")) - resource, _, err := s.storeResource(task.UserID, "media", fileName, "video/mp4", stat.Size(), renderWidth, renderHeight, durationMs, file, nil) + resource, _, err := s.storeResource(task.UserID, "media", fileName, "video/mp4", stat.Size(), renderWidth, renderHeight, durationMs, file, nil, false) if err != nil || resource == nil { return w.failTimelineTask(task, "渲染失败", "保存渲染产物失败") } diff --git a/backend/internal/service/task_worker.go b/backend/internal/service/task_worker.go index e3614f86a..13d8f0b12 100644 --- a/backend/internal/service/task_worker.go +++ b/backend/internal/service/task_worker.go @@ -12,6 +12,8 @@ import ( "infinite-canvas/backend/internal/model" ) +const newAPIChannel2TaskSyncMaxAge = 5 * time.Minute + // taskWorkerCoordinator 收敛任务领取、租约维护和执行结果落库,避免 Service 同时承担 worker 生命周期与业务命令。 type taskWorkerCoordinator struct { service *Service @@ -211,15 +213,25 @@ func (w *taskWorkerCoordinator) processClaimedTask(task *model.Task, globalSlot return leaseErr default: } - if errors.Is(err, context.DeadlineExceeded) { - decryptedInput, decryptErr := s.decryptTaskInputJSON(task.InputJSON) - if decryptErr == nil && s.shouldDeferVideoProviderTask(*task, decryptedInput, err) { - if deferErr := s.repo.DeferRunningTaskForProviderPoll(task.ID, task.LeaseOwner, "后台仍在生成", 15*time.Second); deferErr != nil { - return deferErr - } - _ = s.log(task.UserID, task.ID, "info", "前台等待结束,上游视频仍在生成,将继续回查原任务", task.PollStage) - return nil + decryptedInput, decryptErr := s.decryptTaskInputJSON(task.InputJSON) + if decryptErr == nil && s.shouldDeferVideoProviderTask(*task, decryptedInput, err) { + stage := "后台仍在生成" + message := "前台等待结束,上游视频仍在生成,将继续回查原任务" + var pendingErr providerStatePendingError + if errors.As(err, &pendingErr) { + stage = "等待上游任务同步" + message = "上游任务状态暂未同步,将继续回查原任务" } + if deferErr := s.repo.DeferRunningTaskForProviderPoll(task.ID, task.LeaseOwner, stage, 15*time.Second); deferErr != nil { + return deferErr + } + _ = s.log(task.UserID, task.ID, "info", message, task.PollStage) + return nil + } + if newAPIChannel2TaskSyncExpired(*task, err, time.Now()) { + err = errors.New("上游任务长时间未同步,已停止自动查询,请确认渠道任务状态后重试。") + } + if errors.Is(err, context.DeadlineExceeded) { err = errors.New(taskTimeoutMessage(task.Type)) } return terminal.handleExecutionFailure(task, err, providerSucceeded, channelSlotFailedBeforeRequest) @@ -283,7 +295,16 @@ func taskExecutionTimeoutWithPolicy(taskType string, policy RuntimeTaskPolicy) t } func (s *Service) shouldDeferVideoProviderTask(task model.Task, decryptedInput string, err error) bool { - if !errors.Is(err, context.DeadlineExceeded) || strings.TrimSpace(task.ProviderRequestID) == "" || (!strings.HasPrefix(task.Type, "canvas_video") && !strings.HasPrefix(task.Type, "video_")) { + providerRequestID := strings.TrimSpace(task.ProviderRequestID) + if providerRequestID == "" || (!strings.HasPrefix(task.Type, "canvas_video") && !strings.HasPrefix(task.Type, "video_")) { + return false + } + deferSignal := errors.Is(err, context.DeadlineExceeded) + var pendingErr providerStatePendingError + if errors.As(err, &pendingErr) { + deferSignal = strings.TrimSpace(pendingErr.TaskID) == providerRequestID && !newAPIChannel2TaskSyncExpired(task, err, time.Now()) + } + if !deferSignal { return false } var input canvasGenerationInput @@ -294,6 +315,17 @@ func (s *Service) shouldDeferVideoProviderTask(task model.Task, decryptedInput s return resolveErr == nil && resolved.InterfaceType == string(model.ChannelInterfaceNewAPIChannel2) } +func newAPIChannel2TaskSyncExpired(task model.Task, err error, now time.Time) bool { + var pendingErr providerStatePendingError + if !errors.As(err, &pendingErr) || strings.TrimSpace(pendingErr.TaskID) == "" || strings.TrimSpace(pendingErr.TaskID) != strings.TrimSpace(task.ProviderRequestID) { + return false + } + if task.StartedAt == nil { + return true + } + return !now.Before(task.StartedAt.Add(newAPIChannel2TaskSyncMaxAge)) +} + func taskTimeoutMessage(taskType string) string { if strings.HasPrefix(taskType, "canvas_video") || strings.HasPrefix(taskType, "video_") { return "视频生成等待超时,请稍后到任务中心查看或重试。" diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 68feb432e..516f35a89 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -118,6 +118,14 @@ description: 影策后端核心持久化表与状态边界 | `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录 | | `logical_models` | 保存前台模型的公开名称、Logo、能力类型和归档状态 | 前台模型与系统渠道模型分别维护展示信息;供应线路通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一,归档后可用同 code 重建目录项 | +图片分档尺寸保存在现有模型能力 JSON 的 `image.size.presets` 中,不新增表、列或 SQL 迁移。每项结构为 `{ tier, ratio, size, width, height }`,例如 `{ "tier": "1k", "ratio": "16:9", "size": "1824x1024", "width": 1824, "height": 1024 }`。 + +- `tier` 仅允许 `1k`、`2k`、`4k`,管理员分别配置各档支持的比例;新增比例自动计算像素,原有精确尺寸不被重算。 +- 同档等价比例不能重复。比例须为正整数比且不超过 3:1;像素最长边不超过 3840,总像素为 655360–8294400,并与比例及 `size` 一致(允许 2.5% 比例取整误差)。这些档位是工作台预设,不保证上游返回完全相同的像素。 +- `parameter=size` 的支持值与请求使用精确像素;`parameter=aspect_ratio` 使用比例,独立分辨率由协议质量值映射。未允许自定义且质量值对应明确档位时,保存默认配置和创建任务均校验已声明的“档位 × 比例”组合,不能借用其他档位的比例。 +- `allowCustom` 只开放支持值之外的输入,不新增上游分辨率能力;比例协议未配置对应质量值时不开放该档位。模型声明的精确像素可不满足 16px 对齐;自定义像素仍须满足请求端的 16px 对齐及尺寸限制。 +- 未声明 `presets` 时,界面从已有支持值推导候选;禁用尺寸参数时清除分档配置。发布与回滚均无需数据库迁移,但旧程序不识别分档组合约束,回滚前应核对相关模型配置。 + ## 插件管理 | 表 | 用途 | 关键边界 | diff --git a/docs/content/docs/backend/meta.json b/docs/content/docs/backend/meta.json index da217b31d..4685f512d 100644 --- a/docs/content/docs/backend/meta.json +++ b/docs/content/docs/backend/meta.json @@ -7,6 +7,7 @@ "code-map", "backend-database", "task-runtime-stability", + "newapi-channel-2", "system-update", "protocol-plugins", "canvas-data-structure" diff --git a/docs/content/docs/backend/newapi-channel-2.mdx b/docs/content/docs/backend/newapi-channel-2.mdx new file mode 100644 index 000000000..ff3eb2f2c --- /dev/null +++ b/docs/content/docs/backend/newapi-channel-2.mdx @@ -0,0 +1,51 @@ +--- +title: NewAPI Video Generations Channel 2 +description: Channel 2 视频任务创建、轮询与上游任务同步延迟处理 +--- + +# NewAPI Video Generations Channel 2 + +## 请求流程 + +声明式插件使用以下接口: + +- 创建:`POST /v1/video/generations` +- 查询:`GET /v1/video/generations/{taskId}` + +创建响应支持顶层 `id`、`task_id`、`taskId`,以及 `data.id`、`data.task_id`、`data.taskId`。当中转包装 ID 与真实上游任务 ID 同时存在时,按 `data.task_id`、`data.taskId`、顶层 `task_id`、顶层 `taskId`、`data.id`、顶层 `id` 的顺序解析,避免把中转内部记录 ID 用作上游查询 ID。宿主把解析出的值保存为 `provider_request_id`,后续查询始终使用该值,并对路径参数进行 URL 编码。 + +查询响应进入成功状态后,插件会从 `data.result_url`、`data.video_url`、`data.output_url`、`data.url`、`data.metadata.url` 以及对应的 `data.data.*` 嵌套字段读取视频地址,再回退到兼容的顶层结果字段。只返回成功状态而没有任何结果地址时,宿主仍按协议异常处理,不会创建空资源。 + +## 上游同步延迟 + +创建接口返回任务 ID 后,Channel 2 可能在数百毫秒到数秒内仍对查询接口返回结构化的 `400`: + +```json +{"code":"task_not_exist","message":"task_not_exist"} +``` + +宿主只在同时满足以下条件时把该响应视为“任务状态尚未同步”: + +1. 渠道接口类型精确为 `newapi-channel-2`; +2. 已经保存非空 Provider Task ID; +3. HTTP 状态为 `400`; +4. JSON 的 `code` 或 `message` 精确等于 `task_not_exist`。 + +不符合条件的 HTTP 错误、非 JSON 正文或其他错误码仍立即按失败语义处理。 + +## 轮询与延期 + +新任务首次查询前等待 `2` 秒;同步错误在同一次执行中按 `2` 秒、`3` 秒、`5` 秒、`5` 秒退避,最多短期查询 5 次。短期窗口耗尽后,Worker 不发送新的创建请求,也不切换渠道或退款,而是: + +- 保持本地任务状态为 `running`; +- 保留原 `provider_request_id` 和计费状态; +- 释放当前租约并把 `next_poll_at` 延后 15 秒; +- 下一轮从已保存的 Provider Task ID 恢复,只发送查询请求。 + +`task_not_exist` 的跨轮次同步等待从本地任务首次开始执行起最多持续 5 分钟。超过该上限仍未得到有效任务状态时,Worker 将任务置为失败并释放活跃任务与计费预留,避免无效 Provider Task ID 永久占用用户任务名额。该限制只针对持续的 `task_not_exist`;查询接口已经返回正常 pending/running 状态后的长视频生成仍按视频任务超时和延期策略恢复。 + +因此,上游生成耗时 500 至 600 秒时,只要查询接口返回有效的处理中状态,本地会持续以低频查询等待完成,不会把正常排队误报为“模型服务拒绝了请求”。视频真正返回失败状态时,仍按供应商失败处理。 + +## 兼容边界 + +该容错只适用于 `newapi-channel-2` 视频任务。其他 NewAPI 接口、其他 Provider、缺少 Provider Task ID 的任务,以及真实参数错误不进入延期路径。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 310e55140..77e915ddc 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,83 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 创作页面附件缩略图 + +- 聊天消息与分镜简报附件的放大图标样式排除 `.cached-resource-image-shell`,避免缓存图片容器被误设为透明和绝对定位;悬停与键盘聚焦仍显示放大图标。 +- [ ] 在真实创作会话中确认远端图片加载后无需悬停即可显示,移开鼠标后保持可见,点击可放大;同时确认分镜简报附件、本地图片和视频预览正常。 + +## MiniMax H3 分辨率能力与价格档 + +- 视频能力比较复用请求意图和价格档的分辨率别名归一规则,修复 MiniMax H3 供应线路配置 2K 价格档后,前台模型声明 2K 时误报「创作端参数超出供应线路能力:vquality」。内部价格档仍使用归一值,MiniMax 请求保持官方参数 `resolution: "2K"`。 +- [x] 回归测试在修复前复现相同保存校验错误,修复后通过;相关能力匹配、线路覆盖和 MiniMax 专项测试通过。模拟 HTTP 请求验证 2K 与内部 1440p 均发送 2K,未声明的 4K 仍被拒绝,图片 quality 参数不受视频别名影响。 +- [ ] 在实际 `/admin/channels` 配置 768P / 2K 价格档后,于 `/admin/models` 保存包含 2K 的模型能力,确认创作端可选并正确报价;未调用真实收费生成接口或部署服务。 + +## 对象存储优先发送媒体 URL + +- 开启对象存储后,参考图、视频、音频发给上游时:协议支持 URL 就优先发对象存储签名链接;协议必须传文件字节时仍走 Base64 或 multipart。不会在上游失败后再自动重发一次生成。 +- 强制 URL:NewAPI 系列、火山方舟视频、MiniMax 视频,以及声明需要公网媒体 URL 的插件协议。优先 URL:chat-completion、openai-response、claude-api、Grok 图片、火山方舟图片、xAI/Novita/Grok/Seedance 视频等。Gemini 图片、Gemini Veo、带蒙版的 OpenAI 图片编辑继续走字节。 +- 本地资源在“优先 URL”协议下仍读成 Base64;对象存储资源签发失败、资源不存在或未就绪会直接报错,不会伪装成“不支持 URL”。存储桶不必公开读,使用短期签名链接即可。 +- [x] 后端专项测试覆盖协议策略、OSS 强制 URL、本地强制 URL,以及 preferURL 时对象存储走签名 URL、本地回退 Base64。 +- [ ] 用户浏览器验收:开启 OSS/COS/七牛/S3 后,图生图或视频参考素材应发对象存储 URL;Gemini 或蒙版编辑仍发文件字节。签名失败不能变成静默回退 Base64。 + +## 管理后台系统性能 + +- 管理后台「系统配置」新增独立「系统性能」页面,集中展示主机与 Go 进程、数据目录所在磁盘、系统/进程内存、数据库连接池与 Schema、PostgreSQL 统计、Redis 运行状态和运行时缓存 key 数量;连接地址、账号、密码、DSN 与 Redis URL 不返回前端。 +- 页面提供 15 秒自动刷新、手动刷新、降级提示和紧凑 KPI。SQLite 环境明确隐藏 PostgreSQL 专属统计,Redis 未配置时显示单实例本地协调状态;macOS 无可靠系统可用内存时降级展示 Go 堆内存,避免把未知可用量误报为 100% 占用。 +- 缓存维护仅接受 `runtime` scope,通过 Redis `SCAN + UNLINK` 清理请求频控、渠道失败/熔断和线路临时禁用四类白名单;保留活动并发租约、跨实例目录版本以及用户、任务、素材、模型配置和数据库数据,同时清理可重建的进程内读缓存并写入管理员审计。 +- [x] 后端专项测试覆盖管理员权限、SQLite/无 Redis 状态、非法 scope 拒绝、本地缓存清理和审计写入;专项竞态测试、handler 路由注册、repository/service 和前端生产构建通过。Redis 白名单删除与保留边界的集成用例已实现,但本机无 `redis-server`,本轮自动跳过。 +- [x] 浏览器 mock 已验证 PostgreSQL + Redis 完整态、SQLite + 本地协调降级态、桌面浅色与 390px 深色布局、无横向溢出,以及缓存清理二次确认文案;未调用真实清理接口。 +- [ ] 在真实部署的 `/admin/settings/system-performance` 验证 PostgreSQL 统计、Redis 指标、自动刷新和 Redis 断连降级;仅在测试环境执行缓存清理,确认流量高峰期间不影响活动并发租约和业务数据。 + +## 图片组预览、连线菜单与尺寸设置 + +- 折叠图片组背板读取真实子图,排除当前主图、最多显示五张,并复用资源缓存与视口懒加载;未完成的子图保留占位背板。 +- 连线创建菜单的说明与禁用原因可换行,菜单限制在视口宽高内并允许纵向滚动。隔离组件页已通过浏览器确认真实图片背板与长说明显示。 +- 管理员固定展示 1K / 2K / 4K 三组,可选择或手动输入每档比例,等价比例归一去重;1K + 16:9 自动换算为 1824x1024,已有精确像素保持原值。未配置分档时,从已有尺寸按总像素推导档位(例如 1920x1080 为 2K)。 +- 图片协议的尺寸参数独占首行,输出数量与可选参数移到下方;每个分辨率档位用横向比例卡片直接点选,额外比例通过输入框添加,默认输出也改为直接点选,不再使用比例下拉框。窄容器自动改为单列,视频协议布局保持不变。 +- 后台未保存的新增模型表单已验证:比例选中/取消、32:18 等价去重、17:11 添加与像素展示、0:9 错误提示、默认输出切换、Enter 添加而非提交表单;1440px 桌面尺寸卡片独占整行,390px 明暗主题和长比例标签无横向溢出,焦点轮廓可见。验证后已取消表单、放弃测试修改并恢复原主题。截图工具未成功,本轮布局依据浏览器 DOM 几何测量与交互结果,视觉效果仍待人工确认。 +- 画布、创作和短剧生产接入同一“分辨率 → 宽高比 → 像素展示”组件。用户端仅显示协议支持且已配置比例的档位:例如 2K 比例为空时隐藏 2K,允许自定义也不额外显示空档位;剩余按钮等宽填充。管理员仍保留三档配置入口。允许自定义时开放比例或像素输入,切档尽量保持比例;重新打开保留真实自定义尺寸展示,不将已隐藏档位的旧尺寸伪装成其他档位选中,也不自动改写尺寸。 +- 空档位隐藏已在隔离组件页验证:仅配置 1K/4K 时无 2K 按钮,选择 4K 返回 3840x2160;仅一档时按钮铺满可用宽度,全空时隐藏分辨率标题和按钮,保留自动尺寸与自定义入口。未保存模型或发起生成,真实路由仍待人工复验。 +- 比例请求配置 aspect_ratio;精确像素配置 size。独立分辨率字段由对应模型协议适配,仅比例不能保证输出像素。模型声明的精确像素预设不受自定义尺寸的 16 倍数对齐限制,保持原值发送,其余尺寸边界继续校验。 +- 浏览器隔离组件页已确认:旧比例配置切 4K 实际交给请求转换函数的是 3840x2160;自定义 2:1 后切 2K 保持比例;0:9 显式报错且不改已选值;管理员输入 32:18 去重、17:11 自动计算并同步用户选项;Grok 禁用 4K。390px 视口内的 264px 面板在明暗主题下测得无横向溢出,含长比例标签。以上不等同于真实上游生成或三个路由的完整验收。 +- 前端 29 项尺寸、协议及能力专项测试和生产构建通过,包含空档位隐藏、比例无下拉框、禁用状态与图片/视频布局隔离回归;此前后端尺寸预设、能力规格转换及图片任务相关测试通过,覆盖默认组合拒绝、跨档位比例拒绝及 JSON 往返,本轮纯 UI 改造未重跑后端。构建仍有既有的大分包与无效动态导入警告。 +- [ ] 复验画布、创作、短剧真实路由中的模型切换、保存后重开、默认档位与比例组合、明暗主题和键盘交互;管理员真实保存由用户确认后执行。 +- [ ] 用真实模型确认新增尺寸是否受上游支持、实际输出与计费档位是否一致;本次未修改已保存模型配置或执行收费生成。 + +## Issue #432 多角度编辑器 + +- 多角度编辑器改为居中 Modal,复用遮罩点击、Escape 和关闭按钮的取消行为,不再定位到节点底部或随节点拖拽卸载。 +- #428、#429、#432 的定向源码回归检查与前端生产构建通过;浏览器访问现有服务停在登录页,未完成真实登录态交互验收。构建保留现有大分包和混合静动态导入警告。 +- [ ] 人工复验不同缩放和明暗主题下的位置、遮罩与 Escape 关闭、重新打开重置参数、预设与角度拖动;本轮不执行收费生成。 + +## Issue #429 文本节点选中 + +- 普通文本和富文本预览恢复鼠标事件冒泡,单击进入节点选中和编辑面板流程;编辑中的输入框仍隔离拖拽和滚轮。 +- [ ] 人工复验正文单击、标题拖拽、富文本、组合键多选、双击编辑和正文滚动。 + +## Issue #428 插件协议分类 + +- 分类筛选同时限制展示分区,避免音视频协议在音频筛选结果中重复展示;全部分类仍展示各自支持的能力分区。 +- [ ] 人工复验 AutoDL 在音频、视频和全部分类中的展示,以及搜索、状态筛选和详情入口。 + +## 画布多图分组与展开动画 + +- 新生成的多图组默认收起;复制图片或替换节点生成多图时,结果也保留分组归属,可展开、收起和设置主图。 +- 子图入场动画仅在主动展开图片组时触发;普通选择、拖动或重新进入视口不播放入场动画,快速切换展开与收起时清理上一轮动画状态。 +- 22 项相关测试及前端生产构建通过,包含子图普通挂载、选中与展开/收起的组件渲染回归;浏览器已确认本地画布可加载,尚未复验截图中的多图场景。 +- [ ] 人工确认多图生成后的堆叠预览、数量按钮展开/收起、子图点击与拖动、主图切换,以及保存后重新打开画布。已有画布保留用户保存的展开状态。 + + +## PR #426 本地整合验收 + +- 保留主分支图片尺寸选择器、后台尺寸预设布局、插件分类筛选、文本节点单击编辑、多角度居中弹窗、附件缩略图、站点外观与系统性能页面;接入 PR 的 UI 组件族和创作会话交互。 +- 创作结果添加到画布前继续校验全部结果均有归属明确的素材记录,避免部分结果尚未落库时只转交部分素材;测试同步到消息级结果组件。 +- 本次不接入 PR 新增的用户彻底删除功能,移除其按钮、客户端调用、路由和级联实现,原有停用/启用及素材回收站功能保持不变。临时 SQLite 回归已复现原实现成功返回后残留镜头修订和素材展示记录;其资源物理清理、账务保留与事务边界需单独完善后再接入。 +- 无新增数据库迁移,不运行开发服务、不操作真实用户数据、不推送远程或部署。 +- [x] 前端生产构建、前端主测试及主分支交互/外观和剪辑专项测试通过;后端 `go test ./...` 全量通过。 +- [ ] 浏览器验收:明暗主题下图片分辨率/比例、自定义尺寸、后台模型能力预设、插件分类、文本节点编辑、多角度弹窗、附件常显、创作历史与完整结果转交画布。 +- [ ] 确认后台用户管理保留查看/编辑/启用/停用入口,不展示未完成的用户彻底删除功能;实际生成与剪辑链路由用户验证后再决定是否推送。 + ## UI 组件同角色迁移(P0,2026-09-06) 依据 `docs/plans/ui-kit-consumption-audit.md` 实施:主界面/后台同角色控件统一消费 `components/ui`,消除 antd 与 ui 双观感。变更范围:Tooltip 29 处、Switch 10 处、朴素单选 Select 12 处、Checkbox.Group 1 处迁移至 ui 族;同步清理 globals.css 中 3 段 antd Checkbox 死选择器。antd `Switch`/`Checkbox`/`Tooltip` 全库归零;Empty/Alert/Badge/状态 Tag 维持零。 @@ -130,6 +207,15 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [x] PR #423 合并验证:前端完整测试脚本 1139 项通过、生产构建通过;后端邮件、任务摘要和图片计价专项通过。后端全量仍有 4 项既有失败(私网上游校验 2 项、视频恢复策略 2 项),已在未合并的 `main` 基线上复现;最后一轮另有资源清理测试 SQLite 表锁失败,该项在合并分支与基线单独复跑均通过。未为通过测试而放宽安全或恢复策略。 - [ ] 真实 SMTP 投递、浏览器刷新恢复和收费生成仍待人工验证。本次不触发外部邮件或收费请求。 +## NewAPI Video Generations Channel 2 轮询恢复 + +- [x] 官方插件兼容从 `data.task_id` 和 `data.taskId` 读取上游任务 ID;Channel 2 创建成功后首次查询等待 2 秒,仅将结构化 `400 task_not_exist` 识别为上游同步延迟,并按 2 秒、3 秒、5 秒、5 秒退避复查同一任务。 +- [x] 短期重试耗尽后,Worker 保留本地任务 `running`、原 `provider_request_id` 和计费状态,延后 15 秒继续查询;恢复过程不重复发送创建请求、不切换渠道,也不立即失败或退款。其他接口类型、HTTP 状态和错误码仍按原失败语义处理。 +- [x] 合并补修为跨轮次 `task_not_exist` 增加 5 分钟总同步上限;超过上限进入明确失败态并释放任务与计费资源,正常 pending/running 长视频仍可跨执行窗口恢复。 +- [x] Channel 2 终态响应兼容 `data.result_url`、`data.video_url`、`data.output_url`、`data.url`、`data.metadata.url` 及对应的 `data.data.*` 嵌套路径;API 调用日志在该接口下优先记录嵌套真实任务 ID。协议与日志富化专项回归测试通过。 +- [ ] 使用真实 Channel 2 分别验证上游任务 ID 短暂不可见和生成耗时 500 至 600 秒的任务:本地不再提示“模型服务拒绝了请求”,创建请求只出现一次,后续始终查询同一 Provider Task ID,最终视频正常回写;真实参数错误仍应及时失败。 +- [ ] 使用返回 `SUCCESS` 且视频地址位于 `data.result_url` 或 `data.data.video_url` 的真实任务,确认不再提示“声明式协议已完成但没有返回结果”,并在当前 OSS 配置下成功下载并持久化为本地资源。 + ## 编辑器(M2 命令状态机) - [x] 命令协议黄金文件:`editor-commands` 8 个黄金 op(addClip/moveClip/trimClip/splitClip/removeClip/setClipProperty/addSubtitle/removeSubtitle)全量快照比对通过;未知 op、非法 payload 一律抛错(fail-closed)且输入不可变。 @@ -496,6 +582,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [ ] 在画布中分别选择按次图片模型、按秒视频模型和前台逻辑模型,确认修改数量、时长、分辨率和参考素材后价格立即更新且与实际账单一致。 - [ ] 在浅色/深色主题及生成中、禁用状态下确认胶囊价格可读、圆形操作区清晰且不挤压模型与参数控件。 +## 系统渠道模型批量删除 + +- [x] 管理员可勾选最多 100 个渠道模型并一次删除;任一模型仍被活动线路或进行中任务引用时整批拒绝,不产生部分删除。 +- [x] 合并补修将兼容 `models_json` 的重建收进删除事务,并由 repository 在取得渠道写锁后读取当前启用模型;模型保存后的目录同步也复用同一锁定入口,避免并发管理员操作用事务外旧快照覆盖较新的模型目录。 +- [x] 后端批量删除与引用拒绝专项测试、前端管理页回归测试和格式检查通过。 +- [ ] 在 PostgreSQL 测试环境由两个管理员并发执行模型新增、启停和批量删除,确认 `channel_models` 与渠道 `models_json` 最终一致,删除失败时列表不发生部分变化。 + ## 系统渠道默认积分价格 - [x] 新建渠道模型默认使用“所有规格统一价格”,管理员只需填写一次积分;1K/2K/4K、画幅和生成方式差异定价收进可选的“按规格定价”。 @@ -1144,7 +1237,3 @@ v2(用户反馈首版难看后重做):菜单改用 antd Dropdown(placeme - [ ] 让 AI 回复含多行代码(如 markdown 围栏 ```ts```):代码块顶部有语言标签 + 复制按钮,点复制后变「已复制」,粘贴内容正确(不含语言标记行)。 - [ ] 代码块圆角/边框/底色在明暗主题均可辨;长代码横向滚动不破版;流式输出中代码块容器稳定不跳动、复制按钮可用。 - [ ] 消息中的行内代码(如 `const x`)样式不受影响;画布 Agent 消息里的代码块同样获得新外壳。 - - - - diff --git a/docs/design/plugin-platform-and-marketplace-research.md b/docs/design/plugin-platform-and-marketplace-research.md new file mode 100644 index 000000000..b387b5e25 --- /dev/null +++ b/docs/design/plugin-platform-and-marketplace-research.md @@ -0,0 +1,314 @@ +# 插件机制现状、对外回应与插件市场演进调研 + +> 状态:调研与建议,未实施;不是现有能力承诺。 +> 基线:2026-09-06,本地工作区 HEAD `df94c3bd`。未修改运行时代码、部署、数据库或现有用户文件。 +> 范围:影策内部的 Provider、应用/UI、编辑器与支付插件;不将 `plugins/yingce/` 的 Codex App 连接插件与影策自身扩展系统混为一谈。 + +## 1. 决策摘要 + +用户反馈**对应用/UI 插件大体成立,但不能泛化为所有插件都只是开关**。现在是三种成熟度并存: + +1. **声明式 Provider 扩展已经成立**:在现有请求、响应与表达式能力内,新协议可以通过包安装,不必修改宿主源码。 +2. **应用/UI 扩展主要仍是内置模块化**:清单、注册表与开关存在,但大量实现、路由、设置和节点渲染仍随主应用编译。外部 Web 包的校验合同不等于执行运行时。 +3. **支付存在真正的包内程序执行**:RPC 加载链路已存在;但它是专用能力,不是通用后端扩展平台,其现有隔离方式也不足以直接承接开放市场的不可信程序。 + +因此,当前准确定位是:**“已有声明式插件能力和部分独立运行时,应用插件正在从内置模块化演进为外部可安装扩展。”** + +建议优先投入:**外部应用插件最小闭环 → 外部作者试用 → 受控分发目录 → 开放市场 → 商业化**。不是先建设市场 UI,也不是把所有内置组件搬进名为 plugins 的目录。 + +### 真正插件化的判断标准 + +关键不是“不写代码”,而是**扩展代码由谁编写、在哪发布、是否必须改宿主**。 + +- 插件作者可以写代码;声明式配置也可以是真插件,不必刻意引入可执行代码。 +- 针对宿主已经开放的能力,作者不应修改宿主源码、不应依赖私有 store/service、不应等待宿主重新编译发布。 +- 宿主仍然需要维护稳定的扩展点与公共 API。全新系统能力、权限类型和核心数据语义可能需要宿主升级,不承诺“任意未来功能永远不改宿主”。 +- “内部模块化”“独立发布安装”“安全隔离”“市场运营”是不同维度;安全隔离不足不等于没有扩展能力,但不能因此宣称可安全开放市场。 + +## 2. 仓库审计:哪些已经有,哪些没有 + +以下是本次读取或执行得到的事实;“未找到”只限定为本次检查的工作区与路径,不代表对其他分支、线上版本作结论。 + +| 面向用户的能力 | 当前证据 | 判断 | +| --- | --- | --- | +| 安装新模型协议 | `.yingce-plugin` 经校验、存储、加载为 `manifestAdapter`,进入运行时 registry | 现有 DSL 范围内是真扩展,不只是开关 | +| 工作流声明 | 统一 `contributes.workflows` 与 Provider 引用合同;另有 RunningHub/ComfyUI 专用状态及配置流程 | 有可扩展基础,但不能从清单推导所有工作流集成都已通用化 | +| 新应用/UI | builtin 静态导入;上传记录转为 `{ manifest, source }`,未构造外部代码实例 | 主要仍是宿主内置能力管理 | +| 新画布节点 | 节点注册表可接声明;通用 declarative 分支按 schema 展示数据 | 有基础声明式显示,不是任意交互组件运行时 | +| sandbox 节点 | 直接渲染“插件节点等待隔离运行时” | 已声明、未打通执行 | +| 编辑器插件 | UI 插槽注册存在;`editor-shell` 静态导入八个组件,以同一插件 ID 注册 | 是内部插槽化,不能据此声称八个独立外部插件已落地 | +| 应用启停与配置 | 后端按官方应用 ID 策略表分类;前端仍有 Eagle 等专用配置分支 | 用户“新增插件仍要改代码”的直接来源 | +| 支付 RPC | 从包提取 `backendEntry`,创建 `payment.NewRPCProvider` 并执行包内程序 | 已实现专用动态后端执行 | +| WASM | 清单允许声明;支付加载时明确返回“wasm 支付运行时尚未启用” | 合同预留,不是已支持运行时 | +| 完整市场 | 检查到本地包、安装接口、已安装 Provider catalog;未找到完整发布者提交/审核/发现/签名分发链路 | 插件中心不等于插件市场 | + +### 2.1 包产物清点 + +只读取 ZIP 中的清单,未运行包内程序: + +- `plugin-packages/*.yingce-plugin` 共 **80** 个文件,均可读取 `manifest.json`。 +- **78** 个包含 Provider 贡献,未显式声明 backend/web runtime;现有加载路径按声明式处理。 +- **2** 个包含支付贡献,声明 `runtime.backend=rpc`。 +- **0** 个声明 Web `entry`。 +- **1** 个包同时包含 workflow 贡献;贡献数量可重叠,不能与包数量相加。 + +这说明当前随仓库分发的样本集主要验证协议包与支付包,不能作为外部应用插件已可用的证据。数量仅是本地快照,不是线上安装量、用户数或市场规模。 + +### 2.2 关键证据定位 + +| 编号 | 文件 / 符号 | 支持的结论 | +| --- | --- | --- | +| E1 | `web/src/lib/plugins/builtin/index.ts:1–6` | Eagle、提示词优化、工作流、肖像排查、审美批改与 editor-shell 均静态导入 | +| E2 | `web/src/components/canvas/canvas-node-content.tsx:63–97`,`CanvasNodeContent` / `PluginCanvasNodeContent` | 肖像排查与审美批改专用渲染分支;sandbox 是占位提示;declarative 是基础数据展示 | +| E3 | `web/src/lib/plugins/builtin/editor/editor-shell.tsx:1–100` | 八个组件通过静态 import,在模块顶层注册到同一插件的插槽 | +| E4 | `web/src/pages/plugins/index.tsx:515` 附近及 `toRegisteredPlugin` | Eagle 专用设置;后端记录只转换为 manifest/source | +| E5 | `web/src/router.tsx:45,121` | Eagle 页面仍是宿主专用 import 和路由 | +| E6 | `backend/internal/service/plugin_management.go`,`officialApplicationPolicies` / `pluginManagement` | 应用类别与管理 scope 依赖宿主 ID 表;上传来源固定按系统级协议管理,未形成通用第三方应用分类 | +| E7 | `backend/internal/service/protocol_plugins.go`,`install` / `reload`;`backend/internal/protocol/manifest.go`,`LoadInstalledProviders` | 包安装与声明式 Provider 动态加载已存在 | +| E8 | `backend/internal/protocol/package.go`,`validatePluginPackageRuntime`;相邻 `package_test.go` | Web 入口存在性与路径校验已实现;相关测试只验证包合同 | +| E9 | `backend/internal/service/protocol_plugins.go:380–445`;`backend/internal/payment/rpc.go:75–145` | 支付专用程序加载;WASM 未启用;RPC 通过宿主子进程执行 | +| E10 | `web/src/lib/plugins/plugin-types.ts`,`RegisteredPlugin`;`web/src/services/plugin-host.ts`,`createPluginHostContext` | 声明了 activate/deactivate;当前 host context 主要封装 ai.text;未检索到通用生命周期调用调度器 | +| E11 | `backend/internal/service/protocol_registry.go`,`InstallPluginForAdmin` | 上传入口要求管理员、保护保留 ID 并写审计;不是任意用户当前均可上传程序 | +| E12 | `backend/internal/service/payment_plugin_lifecycle_test.go` | 已有未完成订单阻止升级等守卫,后续平台化不能丢掉这些业务约束 | +| E13 | `docs/adr/0005-editor-preset-plugin-architecture.md` | 既有 ADR 明确把外部安装/市场/沙箱放在后续,预设插件本身是原阶段边界 | + +### 2.3 不应误判的地方 + +- `backend/internal/protocol/builtin.go` 确实还包含静态适配器,但**不能仅因文件存在,就断言已安装包在生产执行时全部委托给它**。本次看到 `reload` 使用 `LoadInstalledProviders(raw, nil)`;无 resolver 的 `host:` 会被拒绝,上传也禁止 `host:`。现有安装链路本身有真实声明式执行。 +- 支付 RPC 已设超时、输出大小限制、精简环境变量、入口/平台校验和包摘要校验。这些是有效措施,但不是文件系统、网络、进程与租户资源隔离。 +- 未运行真实第三方支付网关或模型上游;测试通过不代表所有随包协议都已做供应商联调。 + +## 3. 如何对用户回应 + +建议直接回应,不争论“插件”一词,也不把责任推给用户不会开发: + +> 这条反馈我们认可,尤其是应用和界面类插件。目前部分插件仍是主程序内置能力的模块化管理,启停独立了,但新增能力仍需要修改主程序;我们之前没有把这个边界说明得足够清楚。 +> +> 同时,模型协议类插件已经可以在现有声明式能力范围内通过安装包扩展,支付也有独立运行时,所以并不是所有插件都只是开关。 +> +> 下一步我们会先补齐外部应用插件的 SDK、隔离运行环境和通用扩展入口。验收标准不是多几个插件卡片,而是第三方开发者不修改影策源码,也能开发、安装、运行和升级插件。这个闭环经过外部开发者验证后,再开放插件市场。 +> +> 在这之前,我们会明确标注哪些是内置扩展、哪些支持外部安装、哪些运行时尚未支持,不把规划能力写成已实现能力。 + +不要承诺未经验证的交付日期、任意插件零成本运行,或“以后完全不用改宿主”。对外更有说服力的证据是**第三方独立开发与安装演示、可下载样例包、SDK 文档和兼容性测试**。 + +## 4. 外部体系对照:借鉴机制,不照搬技术名词 + +来源均为官方开发文档/官方仓库,见 §10。下表的“借鉴”是本报告建议,不是这些产品对影策的推荐。 + +| 体系 | 官方资料确认的机制 | 对影策的借鉴 / 限制 | +| --- | --- | --- | +| VS Code | Manifest contribution points 与扩展宿主分离;Node 和浏览器 Worker 有不同入口。[S1–S3] | 定义贡献点、版本化 API、按环境声明入口;不要让插件直接绑定主应用内部结构 | +| VS Code 安全与市场 | 桌面扩展宿主拥有与 VS Code 相同权限;市场另做扫描、签名验证和封禁。[S4] | **独立进程不自动等于安全沙箱**;市场信任与运行时隔离都要单独设计 | +| Figma | 插件逻辑运行于有限 JS 环境,UI 在 iframe;文档访问与浏览器 UI 通过消息连接。[S5] | 适合画布产品的“受控文档 API + 隔离 UI + 消息调用”模式,而非注入主 React 树 | +| Dify | 插件按能力扩展;daemon 管生命周期和不同运行环境;有 CLI 与包发布流程。[S6–S7] | 将模型、工具、工作流的插件执行与业务 API 进程解耦;复用影策已有任务/配额体系,不复制整套 Dify | +| Dify Marketplace | 发布流程含安装/打包验证、元数据与隐私材料检查、自动检查和人工审核。[S8] | 先建立发布和安装质量门槛,再谈搜索、推荐与规模增长 | + +特别排除两个误区: + +1. **Module Federation 或远程 import 是代码装载方式,不是安全边界。** 本报告不建议将未经审查的市场代码直接加载进主应用 React 上下文;可信内置模块可以保留高性能直连,但必须通过同样的公开业务合同,而不是继续增加私有捷径。 +2. **Web Worker 不是“自动禁网的沙箱”。** Worker 可以发网络请求,且脚本响应的 CSP 有自己的边界;iframe 也不能简单加一个 sandbox 属性就视为完成隔离。[S9–S10] + +## 5. 目标结构:一个包合同,多类贡献,分级运行 + +```text +本地包 / 官方预置包 / 受控目录 / 未来市场 + │ + 安装校验与不可变版本仓库 + │ + Contribution Registry + 生命周期管理 + │ + 权限代理(用户/项目/插件/版本/操作) + ┌─────────┼───────────┐ + 声明式执行器 隔离 Web UI 专用后端插件运行服务 + Provider/表单 iframe/RPC 受限任务执行,后续完善 + └─────────┼───────────┘ + 稳定 Host API / 命令协议 + │ + 画布、任务、资源、模型、权限、账务 +``` + +统一的是**包身份、版本、贡献声明、权限授予与安装记录**,不是强迫所有插件使用一种语言或运行环境。一个包可以同时提供 Provider、面板和命令,不能重新拆成互不相通的多套市场。 + +### 5.1 宿主必须保留的责任 + +保留登录和归属校验、项目与画布存储、任务队列、资源访问、模型中转、配额和账务、撤销重做、基础 UI 插槽、权限代理、生命周期与审计。插件不得直连数据库、写内部 Zustand 状态、直接修改订单状态,或绕过任务/计费协议请求模型。 + +插件负责其业务算法、供应商适配、自己的 UI、配置 schema 与版本迁移。新增同类型插件不应再改路由表、供应商 switch、平台插件 ID 白名单或专用设置页。 + +### 5.2 首批公共 API:小而可用 + +以下是建议接口,不是现有可调用 API: + +| 能力 | 第一批 API 方向 | 约束 | +| --- | --- | --- | +| 命令与界面 | 注册命令、打开插件面板、通用 `/plugins/:pluginId/*` 宿主页 | 仅支持已声明的贡献;快捷键冲突可见,路由不由任意字符串注入 | +| 画布 | 读选区/选中节点、提交受校验的命令批次 | 写入走撤销栈与 revision 校验;没有整份内部 store 暴露 | +| 模型与任务 | 调用宿主已配置的文本/生成能力、订阅进度、取消 | 按请求实时校验权限、归属、配额;插件不接收原始模型密钥 | +| 资源 | 读取获授权的资源引用、上传/导入结果 | 默认不是全素材库权限;大文件走受控资源通道,不反复拷贝 base64 | +| 配置与存储 | schema 设置表单、插件私有 KV、宿主保管的秘密引用 | 用户/工作区/插件作用域隔离;配置声明不是权限授予 | + +先支持命令、面板和基础设置;素材源、复杂节点渲染、时间线细粒度工具在首个闭环后扩展。高频拖拽/播放不逐帧跨 iframe RPC:由宿主渲染基本节点/时间线,通过批量命令和事件快照交换数据。 + +### 5.3 Manifest 与 SDK + +- 在现有 v1/v2 基础上梳理合同,不为宣传而先发 v3;仅在真实破坏性变更时升级主版本。 +- 区分包版本、Manifest 格式版本、Host API 版本、插件数据 schema 版本。 +- 增加宿主 API 兼容范围、目标运行环境、按需激活事件、网络域名及权限需求等合同;无法支持的必要 runtime 必须明确拒绝激活。 +- 插件来源与信任由安装渠道和管理员策略确定;不能信任清单里作者自填的 `trusted`、`official` 或“自动授予权限”。 +- 用共享 JSON Schema/协议定义生成或对照校验 TS 与 Go 合同,避免前端枚举不含 rpc、后端已经执行 rpc 这类漂移。 +- SDK、打包工具与开发宿主必须能脱离主仓库使用;首期可放同一 monorepo,验收必须从独立目录完成,不能用目录位置冒充独立性。 + +建议工具目标:初始化模板、校验包、开发连接、合同测试、打包、发布。**本报告没有创建这些 CLI 或命令。** + +### 5.4 生命周期必须是真的,而不是 toggle 字段 + +建议状态:`installed → validated → awaiting-grant → disabled/activating → active → stopping → disabled`,另有 `failed / quarantined / incompatible`。 + +- `activate(context)` 注册贡献并返回可释放句柄;失败时回滚本次所有注册,不能半激活。 +- `deactivate()` 释放事件、定时器、面板、Worker、任务订阅;宿主在超时后强制终止实例。 +- “安装成功”“用户已启用”“runtime 就绪”“某贡献可用”分开呈现,不能用统一 enabled 掩盖未支持 runtime。 +- 升级先验证目标版本和新增权限,再切换;失败恢复旧版本引用。 +- 运行中任务绑定插件版本与包摘要,升级不让旧任务半途换适配器。支付可先继续采用已有未完成订单阻止升级的保守策略。 +- 卸载要做引用检查。画布中已有插件节点保留数据与不可用占位,不静默删除;代码回滚不意味着数据迁移自动可逆。 +- 卸载与清除用户数据分开操作,后者明确授权。 +- 多副本部署时,安装/升级要有共享的版本与授权真相源、并发互斥及实例间失效通知;不能只依赖当前进程锁和本地 JSON registry。小规模自部署可保留文件存储实现,但共享部署必须显式选择持久化与一致性方案;实施数据库变更时同步数据库专题与迁移方案。 + +### 5.5 安全边界:公开市场的发布前置条件 + +**Web 插件建议采用单独源的受限 iframe 与宿主消息桥。** 优先避免共享主站 Cookie 的部署方式;不向插件注入主站 token、内部 API 客户端或 store。[S5、S9] + +- sandbox/CSP 组合限制脚本、外连、图片/媒体外传、嵌套框架、表单、导航、弹窗与动态代码装载。具体策略必须通过 PoC 验证,不能只设置 `connect-src` 就声称禁止外传。 +- 对不透明 origin 的 frame,不能只按 origin 字符串辨识调用者;由宿主绑定 `event.source`、一次性握手及 MessagePort,再关联安装记录、用户、项目和版本。 +- RPC 校验消息 schema、方法白名单、实例状态、实际授予权限、归属、revision、频率和大小;不能相信插件消息自填的 pluginId/userId。 +- Worker 若用于计算,要独立约束网络、存储与 CPU/内存风险;不能将主站同源 Worker 直接等同于隔离。[S10] + +**后端代码插件不能直接复制当前支付子进程模式后全面开放。** 建议首期仅批准受控作者和能力;后续建设独立低权限 runner,配置只读包、独立临时目录、资源/进程限制、按插件区分网络出口、超时杀进程组、无数据库与主服务秘密挂载。具体容器/WASM 隔离方案应以威胁建模和压测决定,不因更换语言就宣称安全。 + +API Key 由宿主保管。凡需代签名或供应商请求的能力,应通过受控凭据代理处理;无法避免向代码提供凭据的插件必须单独提高信任等级并明示风险。支付插件先维持严格审核,不作为开放市场试点。 + +## 6. 下一步迁移顺序与验收 + +下面是按依赖安排的阶段,**不是已确认排期**。前期由前端宿主与后端协议能力各一名工程师推进,配合测试/安全评审;先通过垂直验证再估全量工期。 + +| 阶段 | 交付内容 | 退出标准 | +| --- | --- | --- | +| P0:纠正边界 | 能力支持矩阵、runtime 不可用原因、贡献点与权限合同草案、更新既有预设插件 ADR 的演进边界 | 产品说明不把已上传等同于可执行;目标结构获确认 | +| P1:外部应用闭环 | 隔离 UI + RPC、通用命令/面板/设置、SDK 模板、真正生命周期、一个独立示例插件 | 未包含该插件的宿主构建可安装运行;宿主源码不变;卸载无残留 | +| P2:以迁移验证抽象 | 将提示词优化器迁出专用入口,随后验证一个陌生作者应用与声明式 Provider;逐步去掉 ID 专用设置和节点分支 | 每新增一个同类插件,宿主无专用改动;现有业务语义与权限不退化 | +| P3:受控目录 | 不可变包版本、发布者身份、签名验证、审核、兼容矩阵、升级回滚、封禁、诊断日志 | 至少有外部作者独立发布/维护的包,完整经历一次失败升级与恢复演练 | +| P4:公开市场 | 开发者提交与支持流程、搜索与分类、质量指标、分批自动更新、滥用举报与撤销 | 不可信插件隔离通过评审;安装、升级、卸载、事故处置形成闭环 | +| P5:商业化 | 再评估付费授权、订阅与用量定价、作者结算、退款/争议流程 | 先有真实使用和维护成本数据;模型调用费用与插件费用清晰分离 | + +### P1 推荐样例:外部“选中文本优化”插件 + +使用新的测试插件 ID,在独立目录开发: + +1. 安装包贡献一个命令和面板。 +2. 用户主动打开,读取被授权的画布选区。 +3. 通过宿主调用文本模型,接收流式结果并可取消。 +4. 预览后提交画布命令,支持撤销。 +5. 升级插件 UI/算法但不重新构建宿主。 +6. 停用或卸载后,面板、命令和订阅全部释放,画布数据仍保留。 + +用测试宿主模型替身做 CI 合同测试;真实模型联调另外执行并记录,不能把模拟结果当作真实上游已通过。先不替换现有提示词优化器,待闭环通过再迁移,降低回归范围。 + +### 防止“再次做成配置开关”的验收门禁 + +- **宿主冻结**:记录一个不包含样例插件的前后端构建摘要;后续只安装新包,宿主摘要保持不变。 +- **独立作者**:作者仅拿 SDK、文档和已构建宿主,不能引用 `web/src` 或后端内部 package。 +- **无专用分支**:在宿主源码中搜索新插件 ID,无专用路由、配置 switch、节点分支;安装记录和通用测试数据不算硬编码。 +- **陌生能力实现**:在已开放 API 范围内实现一项未预置的逻辑,而不是只换标题、图标或开关。 +- **权限拒绝**:未授权、已撤权、跨用户、跨项目、伪造 pluginId、超额调用均失败,写路径不能静默降级。 +- **失败隔离**:崩溃、死循环、超大消息和外连违规不破坏其他用户数据;资源耗尽测试纳入专门环境,不在生产试验。 +- **完整生命周期**:安装、激活失败回滚、停用清理、升级失败恢复、卸载引用保留都有自动化测试。 + +## 7. 插件市场:先分发闭环,再市场规模 + +### 7.1 市场和宿主必须分工 + +市场负责发现、发布者身份、版本元数据、包分发、签名与撤销信息;影策实例负责信任策略、授权、安装、执行、业务数据和付费任务。用户的项目、素材、密钥不应为了浏览市场自动上传。 + +现有 `/plugins/catalog` 是可用 Provider 目录,不应原地承担所有市场责任。可以复用安装器和包格式,但发布/审核/版本仓库应有明确边界。 + +建议概念模型(不是本次数据库迁移): + +- Publisher:身份、命名空间、维护者与账号保护状态。 +- Plugin / Release:包身份、不可变版本、摘要、目标平台、Host API 范围、权限、变更说明。 +- Review / Attestation / Revocation:检查结果、人工审批、签名证明、撤销原因。 +- Installation / Grant / RuntimeInstance:部署范围、用户启用、实际授权、运行实例;与发布者元数据分开。 +- Entitlement:未来付费权益,不与版本签名、用户权限或执行凭据混为一谈。 + +### 7.2 发布到安装的最小流程 + +```text +作者提交包和材料 + → 路径/大小/依赖/秘密/兼容性检查 + → 隔离环境安装与合同测试 + → 按风险人工审核 + → 固化版本并签名 + → 进入目录 + → 实例管理员选择版本和授权范围 + → 下载并验证摘要、签名与撤销状态 + → 激活与健康检查 +``` + +- 摘要只证明字节一致,签名只证明来源/完整性,两者都不能证明代码无恶意。 +- 新增权限、网络域名、维护者重大变更或高风险能力的更新应重新审批,不能随自动更新静默提权。 +- 同一发布版本不可覆盖;依赖锁定并校验供应链,首期限制插件互相依赖的复杂度。 +- 市场下架、阻止新安装、禁止激活、撤销运行实例、删除用户数据是不同动作;不能用一个“删除”按钮混在一起。 +- 市场暂时不可用时,已安装、校验通过的普通插件原则上仍可运行;撤销缓存和高风险插件的离线策略应明确,不把市场服务变成所有创作任务的单点故障。 +- 自部署用户保留手工安装受信包/私有目录能力;用安全策略约束,而不是强制连接公共市场。 + +### 7.3 冷启动与商业化 + +先邀请少量外部作者,优先已有用户需求的提示词/模板、批量处理、素材导入和模型适配。目录初期采用人工审核即可,不急着建设推荐算法与复杂分成。 + +衡量“是否形成生态”的指标应是:**独立作者成功发布率、无需宿主改动的插件比例、安装激活成功率、故障与回滚率、用户持续使用率、作者维护响应**,不是插件卡片数量。门槛目标在试点后设定,不拿本次 80 个包当生态成绩。 + +付费市场至少区分插件授权费、模型上游费用、宿主资源成本、第三方服务订阅。先以使用数据验证价值和成本,再设计定价与结算;具体税务、支付合规和法律条款另行专业评估,本报告不作结论。 + +## 8. 风险与明确不做的事 + +- 不推倒现有 Provider DSL;先收敛通用协议,复杂转换有证据后再引入代码运行时,避免 DSL 无限膨胀成难维护的语言。 +- 不把“八个内部组件”作为“八个第三方可装插件”的交付。 +- 不立即把支付、权限、数据库或账务写接口开放给公共市场插件。 +- 不使用 `eval`、主页面远程脚本注入或仅改名为 sandbox 的执行器来补 Web runtime。 +- 不将模型协议、UI 插件、市场各做一套互不兼容的身份和生命周期。 +- 不一次迁移全部内置功能;先用一个真实外部插件证明边界,再逐个清除专用依赖。 +- 不用更多 helper、更多 Manifest 字段和更多启停按钮替代运行时、SDK 与治理能力。 + +## 9. 本次验证记录与边界 + +环境:Go `1.25.6 darwin/arm64`;仓库要求 Go `1.25.0`。未启动开发服务器,未调用真实模型/支付上游,未安装外部依赖。 + +在 `backend/` 执行的最小测试均通过: + +```sh +go test ./internal/protocol -run 'Test(ParsePluginPackage|DeclarativeManifest|ManifestAgentMapsRequestAndResponse|ManifestMapsSynchronousTextResponse)' -count=1 +go test ./internal/payment -run 'TestRPCProvider' -count=1 +go test ./internal/service -run 'Test(ApplicationPluginUsesUserStateUnderPlatformAvailability|UploadedManifestCannotClaimUserActivationScope|ArtCritiqueIsUserToggleableApplication|PaymentPluginUpgradeBlockedByActiveOrders|PaymentPluginRemovalAllowedAfterOrdersFinish)$' -count=1 +``` + +分别验证已有包合同/声明式映射、支付 RPC 合同/错误处理、应用启用策略/支付生命周期守卫。服务测试使用测试内存数据库或临时目录,没有迁移用户数据库。 + +这些结果**不覆盖**:外部 Web 插件浏览器运行、公开市场安全、全部供应商联调、真实支付成功率、线上安装状态、多实例并发安装一致性。没有把静态检索写成浏览器验证,也没有执行与本次调研无关的全量构建。 + +## 10. 外部一手资料 + +本次联网搜索工具没有返回可用正文,随后直接获取以下官方页面/仓库内容(HTTP 200)并核对相关段落;不是基于搜索摘要或第三方转载。失效页面与只有空壳内容的页面未作为证据。以下来源的总结限于本报告引用的机制,不代表对产品整体安全性的评级。 + +| 来源 | 标题 | 官方地址 | +| --- | --- | --- | +| S1 | VS Code — Extension Host | `https://code.visualstudio.com/api/advanced-topics/extension-host` | +| S2 | VS Code — Contribution Points | `https://code.visualstudio.com/api/references/contribution-points` | +| S3 | VS Code — Web Extensions | `https://code.visualstudio.com/api/extension-guides/web-extensions` | +| S4 | VS Code — Extension Runtime Security | `https://code.visualstudio.com/docs/configure/extensions/extension-runtime-security` | +| S5 | Figma — How Plugins Run | `https://developers.figma.com/docs/plugins/how-plugins-run/` | +| S6 | Dify — Dify Plugin | `https://docs.dify.ai/en/develop-plugin/getting-started/getting-started-dify-plugin` | +| S7 | Dify Plugin Daemon — README | `https://raw.githubusercontent.com/langgenius/dify-plugin-daemon/main/README.md` | +| S8 | Dify — Publish to Dify Marketplace | `https://docs.dify.ai/en/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace` | +| S9 | MDN — iframe element | `https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe` | +| S10 | MDN — Using Web Workers | `https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers` | + +**最终建议:批准 P0/P1 的范围与验收标准,先交付一个主程序完全不认识、但能通过公共 SDK 正常运行的外部应用插件。用这个事实回应用户,再建设市场。** diff --git a/docs/index.md b/docs/index.md index 8518bc6c2..8624b5893 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ ## 设计沉淀 +- [插件平台与市场演进调研](design/plugin-platform-and-marketplace-research.md):插件机制代码审计、对外回应、外部 SDK 与隔离运行时、独立插件验收、受控目录到公开市场的分阶段方案(调研建议,未实施)。 + - [站点及外观与皮肤主题设计合同](design/site-appearance-and-skins.mdx):品牌一致性、登录页与邮件、SEO/备案、三层皮肤令牌、无闪屏启动顺序和验收边界。 - [工作区外壳设计沉淀](design/workspace-shell-design.mdx):侧栏(260px 可折叠导航 + 分组折叠)、主区卡片、顶部栏(账户/公告/主题)的设计决策与样式约束。 diff --git a/plugin-packages/generate-catalog.mjs b/plugin-packages/generate-catalog.mjs index 896f12aee..5383f2513 100644 --- a/plugin-packages/generate-catalog.mjs +++ b/plugin-packages/generate-catalog.mjs @@ -357,7 +357,15 @@ add({ video_urls: omit(map({ $sortByOrder: ref("request.videos") }, "media", ref("media.value"))), audio_urls: omit(map({ $sortByOrder: ref("request.audios") }, "media", ref("media.value"))) }), - poll: { method: "GET", path: "/v1/video/generations/{{taskId}}" }, response: asyncResponse("video") + poll: { method: "GET", path: "/v1/video/generations/{{taskId}}" }, + response: asyncResponse("video", { + taskId: coalesce(ref("response.data.task_id"), ref("response.data.taskId"), ref("response.task_id"), ref("response.taskId"), ref("response.data.id"), ref("response.id"), ref("taskId")), + videos: coalesce( + ref("response.data.result_url"), ref("response.data.video_url"), ref("response.data.output_url"), ref("response.data.url"), ref("response.data.metadata.url"), + ref("response.data.data.video_url"), ref("response.data.data.output_url"), ref("response.data.data.result_url"), ref("response.data.data.url"), ref("response.data.data.metadata.url"), + ref("response.video_url"), ref("response.videoUrl"), ref("response.result_url"), ref("response.output_url"), ref("response.url"), ref("response.metadata.url"), ref("response.output.url") + ) + }) }); add({ diff --git a/plugin-packages/newapi-video-generations-v1.yingce-plugin b/plugin-packages/newapi-video-generations-v1.yingce-plugin index 9398b99ca..42f634d4a 100644 Binary files a/plugin-packages/newapi-video-generations-v1.yingce-plugin and b/plugin-packages/newapi-video-generations-v1.yingce-plugin differ diff --git a/web/index.html b/web/index.html index b065229fb..ab775c955 100644 --- a/web/index.html +++ b/web/index.html @@ -19,13 +19,13 @@ position: fixed; left: 50%; top: 50%; - width: 42px; - height: 42px; - margin: -21px 0 0 -21px; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; background: rgba(255, 255, 255, 0.84); - mask: url('/logo.svg') center / contain no-repeat; - -webkit-mask: url('/logo.svg') center / contain no-repeat; - animation: appearance-bootstrap-logo 1.8s ease-in-out infinite; + border-radius: 999px; + box-shadow: 0 0 0 7px rgba(255, 255, 255, 0.08); + animation: appearance-bootstrap-pulse 1.8s ease-in-out infinite; } #root:empty::after { content: ""; @@ -40,7 +40,7 @@ border-radius: 999px; animation: appearance-bootstrap-orbit 2.8s linear infinite; } - @keyframes appearance-bootstrap-logo { 0%, 100% { opacity: .62; transform: scale(.94); } 50% { opacity: 1; transform: scale(1.04); } } + @keyframes appearance-bootstrap-pulse { 0%, 100% { opacity: .62; transform: scale(.82); } 50% { opacity: 1; transform: scale(1.08); } } @keyframes appearance-bootstrap-orbit { to { transform: rotate(360deg); } } @media (prefers-reduced-motion: reduce) { #root:empty::before, diff --git a/web/src/components/brand/brand-logo.tsx b/web/src/components/brand/brand-logo.tsx index c88cee3e6..b21d48b7d 100644 --- a/web/src/components/brand/brand-logo.tsx +++ b/web/src/components/brand/brand-logo.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, ReactNode } from "react"; +import { useState, type CSSProperties, type ReactNode } from "react"; import { cn } from "@/lib/utils"; import { appearanceLogoURL, useAppearanceStore } from "@/stores/use-appearance-store"; @@ -14,8 +14,24 @@ type BrandLogoProps = { export function BrandLogo({ className, fallback, alt = "", theme = "auto" }: BrandLogoProps) { const appearance = useAppearanceStore((state) => state.appearance); const currentTheme = useThemeStore((state) => state.theme); + const source = appearanceLogoURL(appearance, theme === "auto" ? currentTheme : theme); + const [failedSource, setFailedSource] = useState(null); if (!appearance.logoConfigured) return <>{fallback}; - return {alt}; + // A configured custom logo must never fall through to the built-in brand + // when its file becomes unavailable. Keep its footprint neutral instead. + if (failedSource === source) return