From 8360b2c933289fa299e660a37f927a42bf36317b Mon Sep 17 00:00:00 2001 From: Ray <133741134+likeraylee@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:56:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E6=B8=A0=E9=81=93=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E5=8F=AF=E8=A7=86=E5=8C=96=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E4=B8=8E=E5=88=AB=E5=90=8D=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E5=8F=8A=E5=A4=96=E8=A7=82=E6=B8=85=E7=90=86=20(#444)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../database/channel_presentation_test.go | 42 +++ backend/internal/database/migrations.go | 17 +- backend/internal/database/migrations_test.go | 5 +- backend/internal/handler/auth.go | 4 + backend/internal/handler/channel_order.go | 43 +++ backend/internal/handler/finance.go | 17 ++ backend/internal/handler/response.go | 7 + backend/internal/handler/response_test.go | 9 + backend/internal/handler/security.go | 6 +- backend/internal/model/models_channel.go | 14 +- backend/internal/repository/channel_order.go | 62 +++++ .../repository/channel_presentation.go | 45 ++++ backend/internal/repository/finance.go | 4 +- backend/internal/repository/repository.go | 6 +- backend/internal/service/admin.go | 35 ++- backend/internal/service/channel_order.go | 63 +++++ .../internal/service/channel_order_test.go | 62 +++++ .../internal/service/channel_presentation.go | 72 +++++ .../service/channel_presentation_test.go | 155 +++++++++++ backend/internal/service/coordination.go | 24 +- backend/internal/service/email.go | 3 +- backend/internal/service/email_cooldown.go | 9 + backend/internal/service/model_catalog.go | 8 +- .../service/registration_limits_test.go | 49 ++++ .../internal/service/resource_delete_test.go | 29 +++ .../service/resource_deletion_worker.go | 4 + backend/internal/service/runtime_policy.go | 4 +- .../content/docs/backend/backend-database.mdx | 6 +- docs/content/docs/overview/features.mdx | 10 + docs/content/docs/progress/pending-test.mdx | 16 ++ web/src/lib/user-session.ts | 12 +- .../pages/admin/channels/channels-page.tsx | 246 ++++++++++++++++-- .../components/channel-model-manager.tsx | 11 +- .../admin/components/channel-order-dialog.tsx | 135 ++++++++++ .../settings/runtime-policy-settings-page.tsx | 4 +- web/src/pages/auth/register.tsx | 138 ++++++++-- web/src/services/api/channel-order.ts | 5 + web/src/services/api/logical-models.ts | 20 +- web/src/services/api/wallet.ts | 9 +- web/src/stores/use-config-store.ts | 168 +++++++----- web/test/channel-presentation.test.tsx | 55 ++++ 41 files changed, 1485 insertions(+), 148 deletions(-) create mode 100644 backend/internal/database/channel_presentation_test.go create mode 100644 backend/internal/handler/channel_order.go create mode 100644 backend/internal/repository/channel_order.go create mode 100644 backend/internal/repository/channel_presentation.go create mode 100644 backend/internal/service/channel_order.go create mode 100644 backend/internal/service/channel_order_test.go create mode 100644 backend/internal/service/channel_presentation.go create mode 100644 backend/internal/service/channel_presentation_test.go create mode 100644 backend/internal/service/email_cooldown.go create mode 100644 backend/internal/service/registration_limits_test.go create mode 100644 web/src/pages/admin/components/channel-order-dialog.tsx create mode 100644 web/src/services/api/channel-order.ts create mode 100644 web/test/channel-presentation.test.tsx diff --git a/backend/internal/database/channel_presentation_test.go b/backend/internal/database/channel_presentation_test.go new file mode 100644 index 000000000..209d6326b --- /dev/null +++ b/backend/internal/database/channel_presentation_test.go @@ -0,0 +1,42 @@ +package database + +import "testing" + +func TestChannelPresentationMigrationPreservesExistingRows(t *testing.T) { + db, err := Open(Config{Driver: "sqlite", DSN: "file:channel-presentation-migration?mode=memory&cache=shared"}) + if err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + `CREATE TABLE model_channels (id TEXT PRIMARY KEY, name TEXT, api_key TEXT)`, + `CREATE TABLE channel_models (id TEXT PRIMARY KEY, price_version INTEGER, unit_price_microcredits INTEGER)`, + `INSERT INTO model_channels VALUES ('existing', '原渠道', 'synthetic-key')`, + `INSERT INTO channel_models VALUES ('existing', 7, 12345)`, + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + for i := 0; i < 2; i++ { + if err := migrateChannelPresentation(db); err != nil { + t.Fatal(err) + } + } + var channel struct { + Name, APIKey, PublicAlias string + SortOrder int + } + if err := db.Table("model_channels").First(&channel).Error; err != nil { + t.Fatal(err) + } + if channel.Name != "原渠道" || channel.APIKey != "synthetic-key" || channel.PublicAlias != "" || channel.SortOrder != 0 { + t.Fatalf("channel migration: %#v", channel) + } + var cm struct{ PriceVersion, UnitPriceMicrocredits, SortOrder int } + if err := db.Table("channel_models").First(&cm).Error; err != nil { + t.Fatal(err) + } + if cm.PriceVersion != 7 || cm.UnitPriceMicrocredits != 12345 || cm.SortOrder != 0 { + t.Fatalf("model migration: %#v", cm) + } +} diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 09dcace10..b9328539e 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -10,7 +10,7 @@ import ( "gorm.io/gorm" ) -const CurrentSchemaVersion int64 = 8 +const CurrentSchemaVersion int64 = 9 const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830" const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830" @@ -54,6 +54,21 @@ var schemaMigrations = []migration{ {version: 6, name: "resource_playback_variant", checksum: resourcePlaybackChecksum, apply: migrateSchemaV6}, {version: 7, name: "asset_library_folders", checksum: assetLibraryFoldersChecksum, apply: migrateSchemaV7}, {version: 8, name: "logical_model_active_code", checksum: logicalModelActiveCodeChecksum, apply: migrateSchemaV8}, + {version: 9, name: "channel_presentation", checksum: "sha256:channel-presentation-v9-20260908", apply: migrateChannelPresentation}, +} + +func migrateChannelPresentation(tx *gorm.DB) error { + for _, column := range []struct { + model any + field string + }{{&model.ModelChannel{}, "PublicAlias"}, {&model.ModelChannel{}, "SortOrder"}, {&model.ChannelModel{}, "SortOrder"}} { + if !tx.Migrator().HasColumn(column.model, column.field) { + if err := tx.Migrator().AddColumn(column.model, column.field); err != nil { + return err + } + } + } + return nil } func migrationsForDatabase(db *gorm.DB) ([]migration, error) { diff --git a/backend/internal/database/migrations_test.go b/backend/internal/database/migrations_test.go index a47702145..a78c51722 100644 --- a/backend/internal/database/migrations_test.go +++ b/backend/internal/database/migrations_test.go @@ -122,6 +122,9 @@ func TestMigrateSchemaV4AddsResourceUploadKeyToExistingSchema(t *testing.T) { if err := db.Exec(`CREATE TABLE resources (id TEXT PRIMARY KEY, user_id TEXT NOT NULL)`).Error; err != nil { t.Fatal(err) } + if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}); err != nil { + t.Fatal(err) + } if err := db.AutoMigrate(&schemaMigration{}); err != nil { t.Fatal(err) } @@ -163,7 +166,7 @@ func TestMigrateSchemaRepairsLegacyAssetFoldersMigrationOrder(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}); err != nil { + if err := db.AutoMigrate(&model.Resource{}, &model.Asset{}, &model.AssetFolder{}, &model.ModelChannel{}, &model.ChannelModel{}); err != nil { t.Fatal(err) } if err := db.AutoMigrate(&schemaMigration{}); err != nil { diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 390695f61..5d60568c7 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -21,6 +21,7 @@ import ( ) func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { + registerChannelOrderRoutes(r, svc) r.GET("/auth/settings", func(c *gin.Context) { settings, err := svc.PublicAuthSettings() if err != nil { @@ -61,6 +62,9 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { if !available || !enforceRateLimit(c, "email-code:"+c.ClientIP(), policy.Request.EmailCodePerHour, time.Hour) { return } + if !enforceRateLimit(c, "registration-email-account:"+passwordResetRateLimitSubject(req.Email), 10, time.Hour) { + return + } if err := svc.SendRegistrationEmailCode(req.Email); err != nil { failService(c, err) return diff --git a/backend/internal/handler/channel_order.go b/backend/internal/handler/channel_order.go new file mode 100644 index 000000000..347e667e4 --- /dev/null +++ b/backend/internal/handler/channel_order.go @@ -0,0 +1,43 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "infinite-canvas/backend/internal/service" + "net/http" +) + +func registerChannelOrderRoutes(r *gin.RouterGroup, svc *service.Service) { + for _, path := range []string{"/admin/channels/order", "/admin/channels/:id/models/order"} { + r.GET(path, func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + items, err := svc.AdminChannelOrder(user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"items": items}) + }) + r.PUT(path, 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, 1<<20) + var req service.ChannelOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + if err := svc.SaveAdminChannelOrder(user, c.Param("id"), req); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"saved": true}) + }) + } +} diff --git a/backend/internal/handler/finance.go b/backend/internal/handler/finance.go index 4918e2445..ec4ec7441 100644 --- a/backend/internal/handler/finance.go +++ b/backend/internal/handler/finance.go @@ -293,6 +293,23 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) { r.PATCH("/admin/channels/:id/models/:modelId", func(c *gin.Context) { saveChannelModel(c, svc, c.Param("modelId")) }) + r.PATCH("/admin/channels/:id/models/:modelId/sort", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.ChannelModelSortRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + if err := svc.UpdateAdminChannelModelSort(user, c.Param("id"), c.Param("modelId"), req); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"updated": true}) + }) r.DELETE("/admin/channels/:id/models/:modelId", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/handler/response.go b/backend/internal/handler/response.go index 095b54b13..6f0484afc 100644 --- a/backend/internal/handler/response.go +++ b/backend/internal/handler/response.go @@ -4,6 +4,7 @@ import ( "errors" "log" "net/http" + "strconv" "strings" "infinite-canvas/backend/internal/service" @@ -27,6 +28,12 @@ func fail(c *gin.Context, status int, err error) { } func failService(c *gin.Context, err error) { + var cooldown *service.EmailCodeCooldownError + if errors.As(err, &cooldown) { + c.Header("Retry-After", strconv.Itoa(cooldown.Seconds)) + fail(c, http.StatusTooManyRequests, cooldown) + return + } var appErr *service.AppError if errors.As(err, &appErr) && validErrorStatus(appErr.Status) { code := appErr.Code diff --git a/backend/internal/handler/response_test.go b/backend/internal/handler/response_test.go index f81a302af..71df275c7 100644 --- a/backend/internal/handler/response_test.go +++ b/backend/internal/handler/response_test.go @@ -18,6 +18,15 @@ type failureEnvelope struct { Msg string `json:"msg"` } +func TestFailServiceRegistrationCooldown(t *testing.T) { + recorder, context := responseTestContext() + failService(context, &service.EmailCodeCooldownError{Seconds: 47}) + response := decodeFailureEnvelope(t, recorder) + if recorder.Code != http.StatusTooManyRequests || response.Code != 429 || recorder.Header().Get("Retry-After") != "47" || !strings.Contains(response.Msg, "47") { + t.Fatalf("cooldown response: status=%d header=%s body=%#v", recorder.Code, recorder.Header().Get("Retry-After"), response) + } +} + func TestFailServiceProjectsAppError(t *testing.T) { recorder, context := responseTestContext() err := service.NewAppError(http.StatusTooManyRequests, "请求过于频繁,请稍后重试") diff --git a/backend/internal/handler/security.go b/backend/internal/handler/security.go index 84f68859e..dba7fa544 100644 --- a/backend/internal/handler/security.go +++ b/backend/internal/handler/security.go @@ -152,8 +152,10 @@ func enforceRateLimit(c *gin.Context, key string, limit int, window time.Duratio if allowed { return true } - c.Header("Retry-After", "60") - fail(c, http.StatusTooManyRequests, errors.New("请求过于频繁,请稍后再试")) + wait := runtimeService.RequestRetryAfter(c.Request.Context(), key, window) + seconds := max(1, int((wait+time.Second-1)/time.Second)) + c.Header("Retry-After", strconv.Itoa(seconds)) + fail(c, http.StatusTooManyRequests, fmt.Errorf("请求次数已达上限,请在 %d 秒后重试", seconds)) return false } diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index ff15323ce..bb8489179 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -1,8 +1,10 @@ package model import ( - "gorm.io/gorm" + "strings" "time" + + "gorm.io/gorm" ) type ModelChannel struct { @@ -11,6 +13,8 @@ type ModelChannel struct { Scope ChannelScope `json:"scope" gorm:"index;size:24"` Enabled bool `json:"enabled" gorm:"index"` Name string `json:"name" gorm:"size:80"` + PublicAlias string `json:"publicAlias" gorm:"size:80;not null;default:''"` + SortOrder int `json:"sortOrder" gorm:"not null;default:0"` BaseURL string `json:"baseUrl"` AllowLocalChannel bool `json:"allowLocalChannel" gorm:"default:false"` APIKey string `json:"-"` @@ -26,12 +30,20 @@ type ModelChannel struct { DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } +func (channel ModelChannel) PublicName() string { + if alias := strings.TrimSpace(channel.PublicAlias); alias != "" { + return alias + } + return channel.Name +} + type ChannelModel struct { ID string `json:"id" gorm:"primaryKey;size:36"` ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"` ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` ProviderModelKey string `json:"providerModelKey" gorm:"size:120"` DisplayName string `json:"displayName" gorm:"size:160"` + SortOrder int `json:"sortOrder" gorm:"not null;default:0"` Icon string `json:"icon" gorm:"size:80"` Capability string `json:"capability" gorm:"size:32;index"` Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"` diff --git a/backend/internal/repository/channel_order.go b/backend/internal/repository/channel_order.go new file mode 100644 index 000000000..7926d7ccc --- /dev/null +++ b/backend/internal/repository/channel_order.go @@ -0,0 +1,62 @@ +package repository + +import ( + "errors" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" + "slices" + "time" +) + +var ErrChannelOrderChanged = errors.New("列表已发生变化,请重新打开排序后再保存") + +// 全量顺序在事务中保存;拒绝过期快照,避免覆盖另一管理员的调整。 +func (r *Repository) SaveChannelOrder(channelID string, ids, expected []string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var current []string + var target any = &model.ModelChannel{} + query := tx.Model(target).Where("scope = ?", model.ChannelScopeSystem) + if channelID != "" { + if err := r.lockSystemChannelForModelMutation(tx, channelID); err != nil { + return err + } + target = &model.ChannelModel{} + query = tx.Model(target).Where("channel_id = ?", channelID) + } + if tx.Dialector.Name() == "postgres" { + // Lock in a stable order, then read a fresh ordered snapshot after waiting. + // PostgreSQL can otherwise return pre-wait ORDER BY results after another sort commits. + var locked []string + if err := query.Session(&gorm.Session{}).Clauses(clause.Locking{Strength: "UPDATE"}).Order("id asc").Pluck("id", &locked).Error; err != nil { + return err + } + } + if err := query.Order("sort_order asc, created_at asc, id asc").Pluck("id", ¤t).Error; err != nil { + return err + } + if !slices.Equal(current, expected) || len(ids) != len(current) { + return ErrChannelOrderChanged + } + allowed := make(map[string]bool, len(current)) + for _, id := range current { + allowed[id] = true + } + for _, id := range ids { + if !allowed[id] { + return ErrChannelOrderChanged + } + delete(allowed, id) + } + now := time.Now() + for index, id := range ids { + if err := tx.Model(target).Where("id = ?", id).Updates(map[string]any{"sort_order": index, "updated_at": now}).Error; err != nil { + return err + } + } + if channelID != "" { + return refreshChannelModelNames(tx, channelID, now) + } + return nil + }) +} diff --git a/backend/internal/repository/channel_presentation.go b/backend/internal/repository/channel_presentation.go new file mode 100644 index 000000000..957dbe7e4 --- /dev/null +++ b/backend/internal/repository/channel_presentation.go @@ -0,0 +1,45 @@ +package repository + +import ( + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +func (r *Repository) UpdateSystemChannelPresentation(channelID string, alias *string, sortOrder *int, now time.Time) error { + updates := map[string]any{"updated_at": now} + if alias != nil { + updates["public_alias"] = *alias + } + if sortOrder != nil { + updates["sort_order"] = *sortOrder + } + result := r.db.Model(&model.ModelChannel{}).Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem).Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +// 排序是展示配置,不重写价格、能力版本或模型标识。 +func (r *Repository) UpdateChannelModelSort(channelID, modelID string, sortOrder int, now time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := r.lockSystemChannelForModelMutation(tx, channelID); err != nil { + return err + } + result := tx.Model(&model.ChannelModel{}).Where("channel_id = ? AND id = ?", channelID, modelID). + Updates(map[string]any{"sort_order": sortOrder, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return refreshChannelModelNames(tx, channelID, now) + }) +} diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 0d80734ad..f210d2b68 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -70,7 +70,7 @@ type AdminRedeemCodeRow struct { func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]model.ChannelModel, error) { var items []model.ChannelModel - query := r.db.Where("channel_id = ?", channelID).Order("created_at asc") + query := r.db.Where("channel_id = ?", channelID).Order("sort_order asc, created_at asc, id asc") if !includeDisabled { query = query.Where("enabled = ?", true) } @@ -329,7 +329,7 @@ func refreshChannelModelNames(tx *gorm.DB, channelID string, now time.Time) erro var names []string if err := tx.Model(&model.ChannelModel{}). Where("channel_id = ? AND enabled = ?", channelID, true). - Order("created_at asc"). + Order("sort_order asc, created_at asc, id asc"). Pluck("model_key", &names).Error; err != nil { return err } diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 998008a2c..fc7d8b0ab 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -755,7 +755,7 @@ func (r *Repository) TaskLogs(userID string, taskID string) ([]model.TaskLog, er func (r *Repository) SystemChannels(includeDisabled bool) ([]model.ModelChannel, error) { var channels []model.ModelChannel - query := r.db.Order("created_at asc").Where("scope = ?", model.ChannelScopeSystem) + query := r.db.Order("sort_order asc, created_at asc, id asc").Where("scope = ?", model.ChannelScopeSystem) if !includeDisabled { query = query.Where("enabled = ?", true) } @@ -775,7 +775,7 @@ func (r *Repository) AdminSystemChannels(keyword string, status string, limit in query := r.db.Model(&model.ModelChannel{}).Where("scope = ?", model.ChannelScopeSystem) if value := strings.TrimSpace(keyword); value != "" { pattern := "%" + strings.ToLower(value) + "%" - query = query.Where("lower(name) LIKE ? OR lower(base_url) LIKE ?", pattern, pattern) + query = query.Where("lower(name) LIKE ? OR lower(public_alias) LIKE ? OR lower(base_url) LIKE ?", pattern, pattern, pattern) } if status == "enabled" { query = query.Where("enabled = ?", true) @@ -785,7 +785,7 @@ func (r *Repository) AdminSystemChannels(keyword string, status string, limit in if err := query.Count(&total).Error; err != nil { return nil, 0, err } - if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&channels).Error; err != nil { + if err := query.Order("sort_order asc, created_at asc, id asc").Limit(limit).Offset(offset).Find(&channels).Error; err != nil { return nil, 0, err } return channels, total, nil diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 5297fd7e0..df6838a23 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -86,6 +86,8 @@ type AdminReferenceData struct { type ChannelRequest struct { Name string `json:"name"` + PublicAlias *string `json:"publicAlias"` + SortOrder *int `json:"sortOrder"` BaseURL string `json:"baseUrl"` AllowLocalChannel *bool `json:"allowLocalChannel"` APIKey string `json:"apiKey"` @@ -103,6 +105,8 @@ type PublicModelChannel struct { Scope model.ChannelScope `json:"scope"` Enabled bool `json:"enabled"` Name string `json:"name"` + PublicAlias string `json:"publicAlias,omitempty"` + SortOrder int `json:"sortOrder"` BaseURL string `json:"baseUrl"` AllowLocalChannel bool `json:"allowLocalChannel,omitempty"` APIKey string `json:"apiKey"` @@ -539,6 +543,10 @@ func (s *Service) UpdateSystemChannel(actor *model.User, id string, req ChannelR if err := s.RequireAdmin(actor); err != nil { return nil, err } + if req.presentationOnly() { + return s.updateChannelPresentation(id, req) + } + updateModels := req.Models != nil channel, err := s.repo.AdminSystemChannel(id) if err != nil { return nil, err @@ -567,8 +575,10 @@ func (s *Service) UpdateSystemChannel(actor *model.User, id string, req ChannelR if err := s.repo.Save(&next); err != nil { return nil, err } - if err := s.syncInitialChannelModels(&next, req.Models); err != nil { - return nil, err + if updateModels { + if err := s.syncInitialChannelModels(&next, req.Models); err != nil { + return nil, err + } } s.invalidateRouteCatalog() items, err := s.repo.ChannelModels(next.ID, true) @@ -779,6 +789,19 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan return channel, err } channel.Name = name + if req.PublicAlias != nil { + alias := strings.TrimSpace(*req.PublicAlias) + if len([]rune(alias)) > 80 { + return channel, BadAuthRequest("前台显示别名不能超过 80 个字符") + } + channel.PublicAlias = alias + } + if req.SortOrder != nil { + if err := validateChannelSortOrder(*req.SortOrder); err != nil { + return channel, err + } + channel.SortOrder = *req.SortOrder + } channel.BaseURL = strings.TrimRight(baseURL, "/") channel.AllowLocalChannel = requestedAllowLocal if req.APIKey != "" { @@ -862,12 +885,18 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model } else if admin { apiKey = channel.APIKey } + name, alias := channel.PublicName(), "" + if admin { + name, alias = channel.Name, channel.PublicAlias + } return PublicModelChannel{ ID: channel.ID, UserID: channel.UserID, Scope: channel.Scope, Enabled: channel.Enabled, - Name: channel.Name, + Name: name, + PublicAlias: alias, + SortOrder: channel.SortOrder, BaseURL: baseURL, AllowLocalChannel: admin && channel.AllowLocalChannel, APIKey: apiKey, diff --git a/backend/internal/service/channel_order.go b/backend/internal/service/channel_order.go new file mode 100644 index 000000000..56112ef7b --- /dev/null +++ b/backend/internal/service/channel_order.go @@ -0,0 +1,63 @@ +package service + +import ( + "errors" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + "net/http" +) + +type ChannelOrderItem struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` +} +type ChannelOrderRequest struct { + IDs []string `json:"ids"` + ExpectedIDs []string `json:"expectedIds"` +} + +func (s *Service) AdminChannelOrder(actor *model.User, channelID string) ([]ChannelOrderItem, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + result := []ChannelOrderItem{} + if channelID == "" { + rows, err := s.repo.SystemChannels(true) + if err != nil { + return nil, err + } + for _, row := range rows { + result = append(result, ChannelOrderItem{row.ID, row.Name, row.Enabled}) + } + } else { + if _, err := s.repo.AdminSystemChannel(channelID); err != nil { + return nil, err + } + rows, err := s.repo.ChannelModels(channelID, true) + if err != nil { + return nil, err + } + for _, row := range rows { + result = append(result, ChannelOrderItem{row.ID, firstNonEmpty(row.DisplayName, row.ModelKey), row.Enabled}) + } + } + return result, nil +} + +func (s *Service) SaveAdminChannelOrder(actor *model.User, channelID string, req ChannelOrderRequest) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + if req.IDs == nil || req.ExpectedIDs == nil || len(req.IDs) > 10000 { + return BadAuthRequest("请重新加载完整排序列表") + } + err := s.repo.SaveChannelOrder(channelID, req.IDs, req.ExpectedIDs) + if errors.Is(err, repository.ErrChannelOrderChanged) { + return NewAppError(http.StatusConflict, err.Error()) + } + if err == nil { + s.invalidateRouteCatalog() + } + return err +} diff --git a/backend/internal/service/channel_order_test.go b/backend/internal/service/channel_order_test.go new file mode 100644 index 000000000..9826257a0 --- /dev/null +++ b/backend/internal/service/channel_order_test.go @@ -0,0 +1,62 @@ +package service + +import ( + "infinite-canvas/backend/internal/model" + "testing" +) + +func TestVisualChannelOrderAtomicSnapshotAndPermissions(t *testing.T) { + svc, db := newChannelModelTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + for _, id := range []string{"a", "b", "c"} { + if err := db.Create(&model.ModelChannel{ID: id, Name: id, Scope: model.ChannelScopeSystem, Enabled: true}).Error; err != nil { + t.Fatal(err) + } + } + rows, err := svc.AdminChannelOrder(admin, "") + if err != nil { + t.Fatal(err) + } + original := []string{} + for _, row := range rows { + original = append(original, row.ID) + } + req := ChannelOrderRequest{IDs: []string{"c", "a", "b"}, ExpectedIDs: original} + if err := svc.SaveAdminChannelOrder(nil, "", req); err == nil { + t.Fatal("nonadmin accepted") + } + if err := svc.SaveAdminChannelOrder(admin, "", req); err != nil { + t.Fatal(err) + } + if err := svc.SaveAdminChannelOrder(admin, "", req); err == nil { + t.Fatal("stale snapshot accepted") + } + bad := ChannelOrderRequest{IDs: []string{"c", "c", "b"}, ExpectedIDs: []string{"c", "a", "b"}} + if err := svc.SaveAdminChannelOrder(admin, "", bad); err == nil { + t.Fatal("duplicate accepted") + } + after, _ := svc.AdminChannelOrder(admin, "") + if after[0].ID != "c" || after[1].ID != "a" { + t.Fatal("failed save changed order") + } + for _, id := range []string{"m1", "m2"} { + if err := db.Create(&model.ChannelModel{ID: id, ChannelID: "a", ModelKey: id, Enabled: true, PriceVersion: 7, CapabilityVersion: 4, UnitPriceMicrocredits: 123}).Error; err != nil { + t.Fatal(err) + } + } + models, _ := svc.AdminChannelOrder(admin, "a") + expected := []string{models[0].ID, models[1].ID} + if err := svc.SaveAdminChannelOrder(admin, "a", ChannelOrderRequest{IDs: []string{expected[1], expected[0]}, ExpectedIDs: expected}); err != nil { + t.Fatal(err) + } + var stored []model.ChannelModel + db.Find(&stored) + for _, item := range stored { + if item.PriceVersion != 7 || item.CapabilityVersion != 4 || item.UnitPriceMicrocredits != 123 || !item.Enabled { + t.Fatal("model configuration changed") + } + } + if err := svc.SaveAdminChannelOrder(admin, "b", ChannelOrderRequest{IDs: expected, ExpectedIDs: expected}); err == nil { + t.Fatal("cross-channel accepted") + } +} diff --git a/backend/internal/service/channel_presentation.go b/backend/internal/service/channel_presentation.go new file mode 100644 index 000000000..552ee3ba2 --- /dev/null +++ b/backend/internal/service/channel_presentation.go @@ -0,0 +1,72 @@ +package service + +import ( + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +func (req ChannelRequest) presentationOnly() bool { + return (req.PublicAlias != nil || req.SortOrder != nil) && req.Name == "" && req.BaseURL == "" && req.AllowLocalChannel == nil && req.APIKey == "" && req.SecretKey == "" && req.ConcurrencyLimit == nil && req.UseGlobalConcurrency == nil && req.Models == nil && req.Headers == nil && req.Enabled == nil +} + +func (s *Service) updateChannelPresentation(id string, req ChannelRequest) (*PublicModelChannel, error) { + if req.SortOrder != nil { + if err := validateChannelSortOrder(*req.SortOrder); err != nil { + return nil, err + } + } + if req.PublicAlias != nil { + alias := strings.TrimSpace(*req.PublicAlias) + if len([]rune(alias)) > 80 { + return nil, BadAuthRequest("前台别名不能超过 80 字") + } + req.PublicAlias = &alias + } + if err := s.repo.UpdateSystemChannelPresentation(id, req.PublicAlias, req.SortOrder, time.Now()); err != nil { + return nil, err + } + s.invalidateRouteCatalog() + channel, err := s.repo.AdminSystemChannel(id) + if err != nil { + return nil, err + } + items, err := s.repo.ChannelModels(id, true) + if err != nil { + return nil, err + } + public := publicChannel(*channel, true, items) + return &public, nil +} + +type ChannelModelSortRequest struct { + SortOrder *int `json:"sortOrder"` +} + +func validateChannelSortOrder(value int) error { + if value < 0 || value > 999999 { + return BadAuthRequest("排序值必须是 0-999999 的整数,数值越小越靠前") + } + return nil +} + +func (s *Service) UpdateAdminChannelModelSort(actor *model.User, channelID, modelID string, req ChannelModelSortRequest) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + if req.SortOrder == nil { + return BadAuthRequest("请填写排序值") + } + if err := validateChannelSortOrder(*req.SortOrder); err != nil { + return err + } + if _, err := s.repo.AdminSystemChannel(channelID); err != nil { + return err + } + if err := s.repo.UpdateChannelModelSort(channelID, modelID, *req.SortOrder, time.Now()); err != nil { + return err + } + s.invalidateRouteCatalog() + return nil +} diff --git a/backend/internal/service/channel_presentation_test.go b/backend/internal/service/channel_presentation_test.go new file mode 100644 index 000000000..5a3add09c --- /dev/null +++ b/backend/internal/service/channel_presentation_test.go @@ -0,0 +1,155 @@ +package service + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "infinite-canvas/backend/internal/model" +) + +func TestChannelPresentationAliasAndPublicOrder(t *testing.T) { + svc, db := newChannelModelTestService(t) + svc.dataDir = t.TempDir() + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + for i, id := range []string{"first", "second"} { + channel := model.ModelChannel{ID: id, Name: "内部" + id, Scope: model.ChannelScopeSystem, Enabled: true, BaseURL: "https://dead.invalid/v1", ModelsJSON: `[]`, CreatedAt: time.Unix(int64(i+1), 0)} + if err := db.Create(&channel).Error; err != nil { + t.Fatal(err) + } + cm := model.ChannelModel{ID: id + "-model", ChannelID: id, ModelKey: id, Enabled: true, PriceConfigured: true, Capability: "image"} + if err := db.Create(&cm).Error; err != nil { + t.Fatal(err) + } + } + alias := " 创作精选 " + updated, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{PublicAlias: &alias}) + if err != nil { + t.Fatal(err) + } + if updated.Name != "内部second" || updated.PublicAlias != "创作精选" { + t.Fatalf("admin alias: %#v", updated) + } + if _, err := svc.UpdateSystemChannel(admin, "first", ChannelRequest{SortOrder: intPtr(20)}); err != nil { + t.Fatal(err) + } + adminRows, total, err := svc.repo.AdminSystemChannels("", "all", 1, 0) + if err != nil || total != 2 || len(adminRows) != 1 || adminRows[0].ID != "second" { + t.Fatalf("pagination before sort: %v %v %v", adminRows, total, err) + } + search, _, err := svc.repo.AdminSystemChannels("精选", "all", 10, 0) + if err != nil || len(search) != 1 { + t.Fatalf("alias search: %v %v", search, err) + } + catalog, err := svc.publicSystemChannelCatalog(nil) + if err != nil { + t.Fatal(err) + } + if len(catalog) != 2 || catalog[0].ID != "second" || catalog[0].Name != "创作精选" || catalog[0].DisplayName != "创作精选" { + t.Fatalf("public catalog: %#v", catalog) + } + legacy, err := svc.PublicSystemChannels() + if err != nil { + t.Fatal(err) + } + if legacy[0].Name != "创作精选" { + t.Fatalf("public session: %#v", legacy) + } + encoded, _ := json.Marshal([]any{catalog[0], legacy[0]}) + if strings.Contains(string(encoded), "内部second") { + t.Fatal("internal channel name leaked into public payload") + } + if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{SortOrder: intPtr(5)}); err != nil { + t.Fatal(err) + } + stored, _ := svc.repo.AdminSystemChannel("second") + if stored.PublicAlias != "创作精选" { + t.Fatal("partial sort patch cleared alias") + } + alias = " " + if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{PublicAlias: &alias, SortOrder: intPtr(0)}); err != nil { + t.Fatal(err) + } + catalog, err = svc.publicSystemChannelCatalog(nil) + if err != nil || catalog[0].Name != "内部second" { + t.Fatalf("empty alias fallback: %v %v", catalog, err) + } + for _, invalid := range []int{-1, 1000000} { + if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{SortOrder: &invalid}); err == nil { + t.Fatal("invalid sort accepted") + } + } + alias = strings.Repeat("字", 81) + if _, err := svc.UpdateSystemChannel(admin, "second", ChannelRequest{PublicAlias: &alias}); err == nil { + t.Fatal("long alias accepted") + } +} + +func TestChannelModelSortPreservesConfigurationAndOwnership(t *testing.T) { + svc, db := newChannelModelTestService(t) + admin := &model.User{ID: "admin", Role: model.UserRoleAdmin} + for _, id := range []string{"channel", "other"} { + if err := db.Create(&model.ModelChannel{ID: id, Scope: model.ChannelScopeSystem, Enabled: true, Name: id, ModelsJSON: `[]`}).Error; err != nil { + t.Fatal(err) + } + } + for i, id := range []string{"z", "a", "b"} { + cm := model.ChannelModel{ID: id, ChannelID: "channel", ModelKey: id, ProviderModelKey: "provider-" + id, Enabled: true, Capability: "image", PriceConfigured: true, UnitPriceMicrocredits: 123, PriceVersion: 7, CapabilityVersion: 4, CapabilityConfigJSON: `{}`, CreatedAt: time.Unix(int64(i), 0)} + if err := db.Create(&cm).Error; err != nil { + t.Fatal(err) + } + } + var before model.ChannelModel + if err := db.First(&before, "id = ?", "z").Error; err != nil { + t.Fatal(err) + } + if err := svc.UpdateAdminChannelModelSort(admin, "channel", "z", ChannelModelSortRequest{SortOrder: intPtr(10)}); err != nil { + t.Fatal(err) + } + var after model.ChannelModel + if err := db.First(&after, "id = ?", "z").Error; err != nil { + t.Fatal(err) + } + after.SortOrder, after.UpdatedAt = before.SortOrder, before.UpdatedAt + if !reflect.DeepEqual(before, after) { + t.Fatal("sort changed model business configuration") + } + rows, err := svc.repo.ChannelModels("channel", true) + if err != nil || len(rows) != 3 || rows[0].ID != "a" || rows[1].ID != "b" || rows[2].ID != "z" { + t.Fatalf("sort and stable tie: %v %v", rows, err) + } + channel, _ := svc.repo.AdminSystemChannel("channel") + if channel.ModelsJSON != `["a","b","z"]` { + t.Fatalf("legacy models order: %s", channel.ModelsJSON) + } + catalog, err := svc.publicSystemChannelCatalog(nil) + if err != nil || len(catalog) != 1 || catalog[0].Models[2].ModelKey != "z" { + t.Fatalf("public model order: %v %v", catalog, err) + } + for _, tc := range []struct { + actor *model.User + channel, id string + value *int + }{ + {nil, "channel", "z", intPtr(1)}, + {&model.User{ID: "user"}, "channel", "z", intPtr(1)}, + {admin, "other", "z", intPtr(1)}, + {admin, "channel", "missing", intPtr(1)}, + {admin, "channel", "z", nil}, + {admin, "channel", "z", intPtr(-1)}, + {admin, "channel", "z", intPtr(1000000)}, + } { + if err := svc.UpdateAdminChannelModelSort(tc.actor, tc.channel, tc.id, ChannelModelSortRequest{SortOrder: tc.value}); err == nil { + t.Fatalf("invalid update accepted: %#v", tc) + } + } + if err := svc.UpdateAdminChannelModelSort(admin, "channel", "z", ChannelModelSortRequest{SortOrder: intPtr(0)}); err != nil { + t.Fatal(err) + } + rows, _ = svc.repo.ChannelModels("channel", true) + if rows[0].ID != "z" { + t.Fatal("resetting to zero did not restore creation order") + } +} diff --git a/backend/internal/service/coordination.go b/backend/internal/service/coordination.go index f9243b873..169f39279 100644 --- a/backend/internal/service/coordination.go +++ b/backend/internal/service/coordination.go @@ -67,6 +67,8 @@ type runtimeCoordinator struct { } var fixedWindowScript = redis.NewScript(` +local existing = tonumber(redis.call('GET', KEYS[1]) or '0') +if existing >= tonumber(ARGV[2]) then return existing + 1 end local count = redis.call('INCR', KEYS[1]) if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end return count @@ -108,7 +110,7 @@ func newRuntimeCoordinator(dialect string) (*runtimeCoordinator, error) { func (c *runtimeCoordinator) allow(ctx context.Context, key string, limit int, window time.Duration) (bool, error) { if c.redis != nil { - count, err := fixedWindowScript.Run(ctx, c.redis, []string{"canvas:rate:" + key}, window.Milliseconds()).Int64() + count, err := fixedWindowScript.Run(ctx, c.redis, []string{"canvas:rate:" + key}, window.Milliseconds(), limit).Int64() return count <= int64(limit), err } c.localMu.Lock() @@ -422,6 +424,26 @@ func (s *Service) AllowRequest(ctx context.Context, key string, limit int, windo return s.coordinator.allow(ctx, key, limit, window) } +func (s *Service) RequestRetryAfter(ctx context.Context, key string, window time.Duration) time.Duration { + c := s.coordinator + if c == nil { + return window + } + if c.redis != nil { + ttl, err := c.redis.PTTL(ctx, "canvas:rate:"+key).Result() + if err == nil && ttl > 0 { + return ttl + } + return window + } + c.localMu.Lock() + defer c.localMu.Unlock() + if entry, ok := c.localRate[key]; ok { + return max(time.Second, time.Until(entry.started.Add(window))) + } + return time.Second +} + func (s *Service) AcquireCustomRelaySlot(ctx context.Context, userID string, limit int, ttl time.Duration) (func(), bool, error) { if s.coordinator == nil { return nil, false, errors.New("运行时协调器未初始化") diff --git a/backend/internal/service/email.go b/backend/internal/service/email.go index 6b2f97813..6a9a1a4cd 100644 --- a/backend/internal/service/email.go +++ b/backend/internal/service/email.go @@ -178,7 +178,8 @@ func (s *Service) SendRegistrationEmailCode(rawEmail string) error { s.emailCodeMu.Lock() defer s.emailCodeMu.Unlock() if latest, err := s.repo.LatestEmailVerificationCode(email, registrationEmailPurpose); err == nil && time.Since(latest.CreatedAt) < time.Minute { - return BadAuthRequest("验证码发送过于频繁,请稍后再试") + seconds := max(1, int((time.Until(latest.CreatedAt.Add(time.Minute))+time.Second-1)/time.Second)) + return &EmailCodeCooldownError{Seconds: seconds} } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } diff --git a/backend/internal/service/email_cooldown.go b/backend/internal/service/email_cooldown.go new file mode 100644 index 000000000..ccc60da36 --- /dev/null +++ b/backend/internal/service/email_cooldown.go @@ -0,0 +1,9 @@ +package service + +import "fmt" + +type EmailCodeCooldownError struct{ Seconds int } + +func (e *EmailCodeCooldownError) Error() string { + return fmt.Sprintf("验证码已发送,请查看邮箱;%d 秒后可以重新获取", e.Seconds) +} diff --git a/backend/internal/service/model_catalog.go b/backend/internal/service/model_catalog.go index ec915f6df..17d97799c 100644 --- a/backend/internal/service/model_catalog.go +++ b/backend/internal/service/model_catalog.go @@ -27,6 +27,7 @@ type PublicChannelCatalog struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"displayName"` + SortOrder int `json:"sortOrder"` Models []PublicChannelModel `json:"models"` } @@ -35,6 +36,7 @@ type PublicChannelModel struct { ID string `json:"id"` ModelKey string `json:"modelKey"` DisplayName string `json:"displayName"` + SortOrder int `json:"sortOrder"` Icon string `json:"icon"` Capability string `json:"capability"` Protocol model.ChannelInterfaceType `json:"protocol"` @@ -127,8 +129,9 @@ func (s *Service) publicSystemChannelCatalog(intent *ModelRequestIntent) ([]Publ if len(publicModels) > 0 { result = append(result, PublicChannelCatalog{ ID: channel.ID, - Name: channel.Name, - DisplayName: channel.Name, + Name: channel.PublicName(), + DisplayName: channel.PublicName(), + SortOrder: channel.SortOrder, Models: publicModels, }) } @@ -181,6 +184,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) PublicChannelMode ID: cm.ID, ModelKey: cm.ModelKey, DisplayName: cm.DisplayName, + SortOrder: cm.SortOrder, Icon: cm.Icon, Capability: cm.Capability, Protocol: cm.Protocol, diff --git a/backend/internal/service/registration_limits_test.go b/backend/internal/service/registration_limits_test.go new file mode 100644 index 000000000..5e105c65e --- /dev/null +++ b/backend/internal/service/registration_limits_test.go @@ -0,0 +1,49 @@ +package service + +import ( + "context" + "errors" + "infinite-canvas/backend/internal/model" + "testing" + "time" +) + +func TestRegistrationCooldownAndFailedSMTPDoesNotBlockRetry(t *testing.T) { + svc, db := newPasswordResetTestService(t) + db.Create(&model.User{ID: "admin", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}) + db.Create(&model.SystemSetting{Key: registrationSettingKey, ValueJSON: `{"enabled":true}`}) + svc.mailSender = func(emailSettingValue, string, string, string) error { return errors.New("synthetic SMTP failure") } + if err := svc.SendRegistrationEmailCode("new@example.com"); err == nil { + t.Fatal("SMTP error hidden") + } + sent := 0 + svc.mailSender = func(emailSettingValue, string, string, string) error { sent++; return nil } + if err := svc.SendRegistrationEmailCode("new@example.com"); err != nil { + t.Fatal(err) + } + var cooldown *EmailCodeCooldownError + if err := svc.SendRegistrationEmailCode("new@example.com"); !errors.As(err, &cooldown) || cooldown.Seconds < 58 || cooldown.Seconds > 60 { + t.Fatalf("cooldown: %v", err) + } + if sent != 1 { + t.Fatal("duplicate mail sent") + } +} + +func TestRequestRetryAfterMatchesWindow(t *testing.T) { + s := &Service{coordinator: &runtimeCoordinator{localRate: map[string]localRateEntry{}}} + ctx := context.Background() + if ok, err := s.AllowRequest(ctx, "email-code:test", 1, time.Hour); err != nil || !ok { + t.Fatal(err) + } + if ok, _ := s.AllowRequest(ctx, "email-code:test", 1, time.Hour); ok { + t.Fatal("limit bypassed") + } + wait := s.RequestRetryAfter(ctx, "email-code:test", time.Hour) + if wait < 59*time.Minute || wait > time.Hour { + t.Fatalf("wrong wait: %v", wait) + } + if s.coordinator.localRate["email-code:test"].count != 1 { + t.Fatal("rejection consumed quota") + } +} diff --git a/backend/internal/service/resource_delete_test.go b/backend/internal/service/resource_delete_test.go index 7fc837e6c..c12431e63 100644 --- a/backend/internal/service/resource_delete_test.go +++ b/backend/internal/service/resource_delete_test.go @@ -351,6 +351,35 @@ func TestDetachedResourceCleanupRemovesOrphanAndKeepsAssetBackedResource(t *test } } +func TestDetachedResourceCleanupKeepsAppearanceAssets(t *testing.T) { + for _, setting := range []string{ + `{"logoResourceId":"logo","darkLogoResourceId":"dark","authVideoResourceId":"video","authVideoPosterResourceId":"poster"}`, + `{invalid`, + } { + svc, db, _ := newResourceDeletionTestService(t) + if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: setting}).Error; err != nil { + t.Fatal(err) + } + var resources []model.Resource + for _, id := range []string{"logo", "dark", "video", "poster"} { + resource := model.Resource{ID: id, UserID: "admin", Status: model.ResourceStatusReady, Provider: "local", ObjectKey: id, CreatedAt: time.Now().Add(-48 * time.Hour)} + if err := db.Create(&resource).Error; err != nil { + t.Fatal(err) + } + resources = append(resources, resource) + } + if err := svc.cleanupDetachedUserResources("admin", resources); err != nil { + t.Fatal(err) + } + var count, jobs int64 + db.Model(&model.Resource{}).Count(&count) + db.Model(&model.ResourceDeletionJob{}).Count(&jobs) + if count != 4 || jobs != 0 { + t.Fatalf("appearance cleanup: resources=%d jobs=%d", count, jobs) + } + } +} + func TestResourceCleanupCandidatesUseStatusSpecificRetention(t *testing.T) { _, db, _ := newResourceDeletionTestService(t) now := time.Now() diff --git a/backend/internal/service/resource_deletion_worker.go b/backend/internal/service/resource_deletion_worker.go index 9b753795b..50a744542 100644 --- a/backend/internal/service/resource_deletion_worker.go +++ b/backend/internal/service/resource_deletion_worker.go @@ -76,6 +76,10 @@ func (s *Service) cleanupDetachedUserResources(userID string, candidates []model return err } referenced := map[string]struct{}{} + // Site assets are global references and are not part of a user's canvas snapshot. + for resourceID := range s.appearanceResourceReferences(resourceIDs) { + referenced[resourceID] = struct{}{} + } for _, reference := range snapshot.Direct { if _, exists := candidateSet[reference.ResourceID]; exists { referenced[reference.ResourceID] = struct{}{} diff --git a/backend/internal/service/runtime_policy.go b/backend/internal/service/runtime_policy.go index 1ec378def..0e48d1f55 100644 --- a/backend/internal/service/runtime_policy.go +++ b/backend/internal/service/runtime_policy.go @@ -134,8 +134,8 @@ func defaultRuntimePolicy() RuntimePolicySetting { SessionFilePerMinute: 30, AssetWritePerMinute: 120, CanvasWritePerMinute: 120, - RegisterPerHour: 5, - EmailCodePerHour: 10, + RegisterPerHour: 30, + EmailCodePerHour: 60, LoginIPPerTenMinutes: 50, LoginAccountPerTenMinutes: 10, SystemRelayPerMinute: 120, diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 516f35a89..9728e7622 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -15,7 +15,7 @@ description: 影策后端核心持久化表与状态边界 容器内命令 `migrate-schema up` 应用缺失迁移,`migrate-schema status` 输出当前和期望版本,`migrate-schema verify` 只检查兼容性。`CANVAS_AUTO_MIGRATE=true` 仅用于开发和旧部署兼容;受管生产部署设置为 `false`,并由 Compose 的一次性 `migrate` 服务先完成迁移。后续迁移必须新增版本,不得修改已经发布迁移的实现或校验和。 -当前结构版本为 8。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录。 +当前结构版本为 9。版本 2 为 `schema_migrations.applied_at` 增加 `idx_schema_migrations_applied_at` 索引;版本 3 `asset_taxonomy_candidate_identity` 为资产候选增加规范化名称和来源,收敛资产分类,并为待确认候选建立项目内部分唯一索引;版本 4 `resource_upload_key` 为资源上传幂等补充 `resources.upload_key` 列和用户维度唯一索引;版本 5 `payment_topup` 创建积分充值、支付通知和对账所需结构;版本 6 `resource_playback_variant` 增加资源播放副本状态;版本 7 `asset_library_folders` 增加用户素材自定义分类及 `assets.folder_id`;版本 8 `logical_model_active_code` 将前台模型 code 的唯一约束限定为未归档记录;版本 9 `channel_presentation` 为渠道添加 `public_alias`(默认空字符串)与 `sort_order`(默认 0),为渠道模型添加 `sort_order`(默认 0),不重写既有凭据、价格或能力配置。 历史发布曾使用版本 6 `asset_library_folders`,之后合并引入了上述 6/7 顺序冲突。迁移器仅在该历史记录的名称和校验和均精确匹配时选择历史顺序:保留版本 6 的素材目录记录及时间,在版本 7 应用 `resource_playback_variant`,再继续版本 8。新数据库及已采用“6 播放副本、7 素材目录”的数据库保持原顺序;状态检查与启动校验采用同一套识别规则。未知名称、被改动的校验和或冲突的版本 7 记录仍会拒绝迁移,不通过删除、改名或伪造已应用记录绕过检查。 @@ -113,8 +113,8 @@ description: 影策后端核心持久化表与状态边界 | 表 | 用途 | 关键边界 | | --- | --- | --- | -| `model_channels` | 保存系统渠道与用户渠道的连接配置 | 系统渠道密钥不进入公开目录;普通用户只能读取脱敏后的可用模型集合 | -| `channel_models` | 保存系统渠道中的产品模型标识、上游模型 ID、显示名称、模型 Logo、能力、协议和版本状态 | `icon` 只用于模型选择器展示;能力、协议、启用状态和价格仍由 service 强校验 | +| `model_channels` | 保存系统渠道与用户渠道的连接配置,以及系统渠道前台别名和排序 | 系统渠道密钥不进入公开目录;公开名称优先使用 `public_alias`,为空时使用原名;按 `sort_order, created_at, id` 升序展示 | +| `channel_models` | 保存系统渠道中的产品模型标识、上游模型 ID、显示名称、模型 Logo、排序、能力、协议和版本状态 | 模型排序独立保存,不递增价格或能力版本;能力、协议、启用状态和价格仍由 service 强校验 | | `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录 | | `logical_models` | 保存前台模型的公开名称、Logo、能力类型和归档状态 | 前台模型与系统渠道模型分别维护展示信息;供应线路通过 revision 和 route 关联;`code` 仅在 `archived_at IS NULL` 的活动记录中唯一,归档后可用同 code 重建目录项 | diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index 3a8de1601..9a1aa0853 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -5,6 +5,16 @@ description: 当前项目已实现的核心工作台能力与使用入口 # 功能清单 +## 系统渠道排序与前台别名 + +后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。 + +系统渠道可设置可选的前台别名;为空时显示渠道名,设置后公开目录和用户会话只返回别名,后台仍保留原名。别名最长 80 字,支持后台搜索。前台模型目录模式保留其独立配置。 + +注册默认每 IP 每小时允许 30 次注册请求、60 次验证码请求,单邮箱每小时另限 10 次且发送后间隔 60 秒。失败尝试也占请求额度,达到上限后的拒绝不会继续增加计数或延长窗口;限流响应携带真实剩余等待时间,注册页面显示倒计时并防止重复提交。已保存的运行策略仍需管理员按实际共用网络规模调整,不随默认值升级自动覆盖。 + +定时闲置资源清理与后台手动删除共同保护外观配置引用的浅色 Logo、深色 Logo、登录视频及封面;即使资源超过闲置期限也不会删除,外观配置无法读取时停止相关清理。 + ## 章节资产提取与任务浏览 画布视频支持鼠标悬停 350ms 后静音预览前 3 秒,不循环;全局仅一个悬停解码器,离开或操作画布立即停止并恢复封面。手动播放优先,系统开启减弱动态效果时不自动预览。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index 5e5ddcf1b..7a1ab4d27 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -53,6 +53,22 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [ ] 上传、拖入和替换图片,确认原图没有生成框;执行人物质感、局部重绘、裁剪、放大与表情编辑,确认原图保留、结果生成到新节点并有原图连线。 - [ ] 先打开节点编辑框,再打开底部添加/外观菜单,确认后打开的面板在上层;再次点击节点编辑框能置顶,键盘操作、明暗主题和菜单关闭正常。 +## 可视化排序与注册限流 + +- [x] 排序使用完整列表快照与事务保存,回归验证取消前的本地移动、过期快照、重复 ID、跨渠道修改及非管理员拒绝;价格和模型能力保持不变。 +- [x] 验证验证码 SMTP 失败后可重试、同邮箱 60 秒冷却、429 响应的 Retry-After、小时窗口剩余时间与前端目录顺序。 +- [x] 浏览器确认渠道和模型的“设置排序”、上下移动后保存及取消、用户端同时采用渠道与模型顺序;默认选择保留,临时顺序已恢复。拖动手势尚未单独做浏览器自动化验证。 +- [x] 最终部署后日志记录 4 次验证码请求成功、3 次注册成功;自动化使用模拟 SMTP,未代替用户发送测试邮件。 +- [x] 上线验收发现旧定时清理漏掉全局外观引用,启动时误删过期 Logo;已精确恢复原记录和文件,并验证清理保留四类外观资源、配置异常时拒绝清理。 +- [ ] 用户继续验收不同邮箱服务商的收件体验及较多用户共用网络的情况。 + +## 系统渠道排序与前台别名 + +- [x] 系统渠道及模型列表保存排序、刷新后持久化的浏览器验收通过;自动化验证分页前排序、别名筛选及相同值按创建时间和 ID 排列。 +- [x] 用户端模型设置与创作模型选择器沿用目录顺序;浏览器验证渠道换序、模型移至末尾、别名显示和清空回退,临时配置已恢复。 +- [x] 自动化回归验证排序独立保存不改价格、启用状态或能力版本,普通用户无权保存排序,不能跨渠道修改模型;前端会话归一化保留目录顺序与当前所选模型。 +- [x] SQLite 与独立 PostgreSQL 内存盘环境验证版本 8 升级至 9、重复迁移和默认值;生产迁移后业务指纹一致,保留数据库备份及已验证启动的 schema 9 兼容回滚程序。 + ## Tiptap 依赖版本对齐 - Tiptap 核心、React、扩展及间接依赖统一锁定为 `3.28.0`,同步 Bun 与 pnpm 锁文件;显式声明 `pm` 和 `extensions`,避免 pnpm 保留旧 peer 解析。此项不修改编辑器业务代码和 UI 样式。 diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 3915f0074..48ac756aa 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -45,7 +45,14 @@ export async function applyUserSession(payload: AuthSessionPayload) { useUserStore.getState().setRuntimeLimits(payload.runtimeLimits); useUserStore.getState().setDrawingEngine(payload.drawingEngine); useUserStore.getState().setFeatures(payload.features); - await Promise.all([useCanvasStore.persist.rehydrate(), useCanvasHistoryStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate(), useCreationPreferencesStore.persist.rehydrate()]); + await Promise.all([ + useCanvasStore.persist.rehydrate(), + useCanvasHistoryStore.persist.rehydrate(), + useAssetStore.persist.rehydrate(), + useConfigStore.persist.rehydrate(), + usePluginStore.persist.rehydrate(), + useCreationPreferencesStore.persist.rehydrate(), + ]); // Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。 if (!persistedCanvas) useCanvasStore.setState({ projects: [] }); if (!persistedCanvasHistory) useCanvasHistoryStore.setState({ deletedProjects: [] }); @@ -139,12 +146,13 @@ function managedModelChannels(models: PublicLogicalModel[]) { } // 系统渠道模型转换为前端配置格式 -function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] { +export function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChannel[] { return channels.map((channel) => { const availableModels = channel.models.filter((m) => m.available); return { id: channel.id, name: channel.displayName, + sortOrder: channel.sortOrder, // 系统渠道必须走带渠道 ID 的站内代理;/api 只是业务 API 根路径, // 不能作为模型请求的运行时 Base URL 传给 channelRequest。 baseUrl: `/api/${channel.id}`, diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index 9250738d8..fac750192 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -17,8 +17,20 @@ import { useAdminContext } from "../admin-context"; import { AdminPageFrame } from "../components/admin-shell"; import { AdminDataTable, AdminRowActions, AdminStatusBadge, AdminTableEmpty, configuredSecretText } from "../components/admin-ui"; import { ChannelModelManager } from "../components/channel-model-manager"; +import { ChannelOrderDialog } from "../components/channel-order-dialog"; -type ChannelFormValues = { name: string; baseUrl: string; allowLocalChannel?: boolean; apiKey?: string; secretKey?: string; headers?: ChannelHeader[]; useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean }; +type ChannelFormValues = { + name: string; + publicAlias?: string; + baseUrl: string; + allowLocalChannel?: boolean; + apiKey?: string; + secretKey?: string; + headers?: ChannelHeader[]; + useGlobalConcurrency?: boolean; + concurrencyLimit?: number; + enabled?: boolean; +}; export function adminLocalChannelFormOwner(desktopLocalChannelsEnabled: boolean, hostname: string, requestedAllowLocalChannel?: boolean) { const state = desktopLocalChannelFormState(desktopLocalChannelsEnabled, hostname, requestedAllowLocalChannel); @@ -37,7 +49,9 @@ export function AdminLocalChannelSwitch({ visible, checked, onChange }: { visibl export function AdminLocalChannelFields({ visible, checked, form }: { visible: boolean; checked: boolean; form: Pick, "setFieldValue"> }) { return ( <> - + + + form.setFieldValue("allowLocalChannel", value)} /> ); @@ -46,6 +60,7 @@ export function AdminLocalChannelFields({ visible, checked, form }: { visible: b export function adminChannelSavePayload(values: ChannelFormValues, desktopLocalChannelsEnabled: boolean, hostname: string) { return { name: values.name.trim(), + publicAlias: values.publicAlias?.trim() || "", baseUrl: values.baseUrl.trim(), allowLocalChannel: adminLocalChannelFormOwner(desktopLocalChannelsEnabled, hostname, values.allowLocalChannel).payloadValue, apiKey: values.apiKey?.trim() || "", @@ -126,8 +141,23 @@ export default function ChannelsPage() { const openDrawer = (channel?: ModelChannel) => { setEditingChannel(channel || null); form.resetFields(); - form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, allowLocalChannel: adminLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).checked, apiKey: "", secretKey: "", headers: channel.headers || [], useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false } : { name: "", baseUrl: "", allowLocalChannel: false, apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true }); + form.setFieldsValue( + channel + ? { + name: channel.name, + baseUrl: channel.baseUrl, + allowLocalChannel: adminLocalChannelFormOwner(desktopLocalChannelsEnabled, desktopLocalChannelHostname, channel.allowLocalChannel).checked, + apiKey: "", + secretKey: "", + headers: channel.headers || [], + useGlobalConcurrency: !channel.concurrencyLimit, + concurrencyLimit: channel.concurrencyLimit || undefined, + enabled: channel.enabled !== false, + } + : { name: "", baseUrl: "", allowLocalChannel: false, apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true }, + ); setDrawerOpen(true); + form.setFieldsValue({ publicAlias: channel?.publicAlias || "" }); }; const closeDrawer = () => { @@ -189,27 +219,142 @@ export default function ChannelsPage() { }; const columns: ColumnsType = [ - { title: "渠道", dataIndex: "name", render: (_, channel) =>
{channel.name}
{channel.baseUrl}
}, + { + title: "渠道", + dataIndex: "name", + render: (_, channel) => ( +
+
{channel.name}
+
{channel.baseUrl}
+
+ ), + }, + { title: "前台名称", width: 160, render: (_, channel) => {channel.publicAlias || channel.name} }, { title: "模型", dataIndex: "models", width: 100, align: "center", render: (models: string[]) => `${models?.length || 0} 个` }, - { title: "最大并发", dataIndex: "concurrencyLimit", width: 120, align: "center", render: (value: number) => value > 0 ? value : 跟随系统 }, + { title: "最大并发", dataIndex: "concurrencyLimit", width: 120, align: "center", render: (value: number) => (value > 0 ? value : 跟随系统) }, { title: "凭证", width: 130, align: "center", render: (_, channel) => }, { title: "状态", dataIndex: "enabled", width: 100, align: "center", render: (enabled) => }, - { title: "操作", width: 250, align: "center", render: (_, channel) => setManagingChannel(channel) }} actions={[{ key: "edit", label: "编辑", icon: , onClick: () => openDrawer(channel) }, { key: "toggle", label: channel.enabled !== false ? "停用渠道" : "启用渠道", icon: , danger: channel.enabled !== false, confirm: { title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?", description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。", okText: channel.enabled !== false ? "确认停用" : "确认启用" }, onClick: () => toggleChannel(channel) }, { key: "delete", label: "删除渠道", icon: , danger: true, confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" }, onClick: () => removeChannel(channel) }]} /> }, + { + title: "操作", + width: 250, + align: "center", + render: (_, channel) => ( + setManagingChannel(channel) }} + actions={[ + { key: "edit", label: "编辑", icon: , onClick: () => openDrawer(channel) }, + { + key: "toggle", + label: channel.enabled !== false ? "停用渠道" : "启用渠道", + icon: , + danger: channel.enabled !== false, + confirm: { + title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?", + description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。", + okText: channel.enabled !== false ? "确认停用" : "确认启用", + }, + onClick: () => toggleChannel(channel), + }, + { + key: "delete", + label: "删除渠道", + icon: , + danger: true, + confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" }, + onClick: () => removeChannel(channel), + }, + ]} + /> + ), + }, ]; if (managingChannel) { - return setManagingChannel(null)} onChanged={async () => { await syncChannels(); await reload(); }} />; + return ( + setManagingChannel(null)} + onChanged={async () => { + await syncChannels(); + await reload(); + }} + /> + ); } return ( - } onClick={() => openDrawer()}>新增系统渠道}> + + { + await syncChannels(); + await reload(); + }} + /> + + + } + > } value={keyword} placeholder="搜索渠道名称或地址" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} />} - toolbarFilters={} + value={keyword} + placeholder="搜索渠道名称、别名或地址" + onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} + /> + } + toolbarFilters={ + + + + + + + - - -
- - - + + + + + + +
+ + + +
+ + + + + + + + +
); } -function positiveInt(value: string | null, fallback: number) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } -function normalizePageSize(value: string | null) { const parsed = positiveInt(value, 20); return [20, 50, 100].includes(parsed) ? parsed : 20; } -function normalizeStatus(value: string | null): "all" | "enabled" | "disabled" { return value === "enabled" || value === "disabled" ? value : "all"; } -function channelSecretText(channel: ModelChannel) { return channel.hasSecretKey ? "已配置,留空不修改" : "未配置"; } +function positiveInt(value: string | null, fallback: number) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} +function normalizePageSize(value: string | null) { + const parsed = positiveInt(value, 20); + return [20, 50, 100].includes(parsed) ? parsed : 20; +} +function normalizeStatus(value: string | null): "all" | "enabled" | "disabled" { + return value === "enabled" || value === "disabled" ? value : "all"; +} +function channelSecretText(channel: ModelChannel) { + return channel.hasSecretKey ? "已配置,留空不修改" : "未配置"; +} diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 31fda071d..e78d71e32 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -12,6 +12,7 @@ import type { ModelChannel } from "@/stores/use-config-store"; import { ChannelModelEditor } from "./channel-model-editor"; import { AdminPageFrame } from "./admin-shell"; import { AdminBatchBar, AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui"; +import { ChannelOrderDialog } from "./channel-order-dialog"; export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise }) { const { message, modal } = App.useApp(); @@ -252,9 +253,17 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: return ( + { + await reload(); + await onChanged(); + }} + /> @@ -372,7 +381,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: columns, dataSource: pagedItems, pagination: false, - scroll: { x: 990 }, + scroll: { x: 1150 }, }} footer={ (items: T[], id: string, target: number): T[] { + const from = items.findIndex((item) => item.id === id); + if (from < 0 || target < 0 || target >= items.length || from === target) return items; + const next = [...items]; + const [item] = next.splice(from, 1); + next.splice(target, 0, item!); + return next; +} + +export function ChannelOrderDialog({ channelId, onSaved }: { channelId?: string; onSaved: () => Promise }) { + const { message } = App.useApp(); + const [open, setOpen] = useState(false), + [loading, setLoading] = useState(false), + [saving, setSaving] = useState(false); + const [items, setItems] = useState([]), + [original, setOriginal] = useState([]); + const dragged = useRef(null), + pending = useRef(false); + const changed = items.some((item, index) => item.id !== original[index]); + const start = async () => { + setOpen(true); + setLoading(true); + setItems([]); + setOriginal([]); + try { + const result = await getChannelOrder(channelId); + setItems(result.items); + setOriginal(result.items.map((item) => item.id)); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取排序失败"); + setOpen(false); + } finally { + setLoading(false); + } + }; + const save = async () => { + if (pending.current || !changed) return; + pending.current = true; + setSaving(true); + try { + await saveChannelOrder( + channelId, + items.map((item) => item.id), + original, + ); + setOpen(false); + await onSaved(); + message.success("排序已保存,用户端按此顺序展示"); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存排序失败"); + } finally { + pending.current = false; + setSaving(false); + } + }; + return ( + <> + + setOpen(false)} + closable={!saving && !loading} + mask={{ closable: !saving && !loading }} + keyboard={!saving && !loading} + width={600} + footer={ +
+ + +
+ } + > +

拖动条目或点击上下箭头调整顺序,保存后用户端按此顺序展示。这里包含全部条目,不受列表筛选和分页影响。

+ +
+ {!loading && !items.length ? ( + + ) : ( + items.map((item, index) => ( +
{ + event.dataTransfer.setData("text/plain", item.id); + dragged.current = item.id; + }} + onDragEnd={() => { + dragged.current = null; + }} + onDragOver={(event) => { + if (!saving) event.preventDefault(); + }} + onDrop={(event) => { + event.preventDefault(); + const source = dragged.current; + if (!saving && source) setItems((current) => moveOrderItem(current, source, index)); + dragged.current = null; + }} + className="flex items-center gap-3 rounded-lg border border-border bg-background p-3" + > + + + {item.name} + {!item.enabled && 已停用} + +
+ )) + )} +
+
+
+ + ); +} diff --git a/web/src/pages/admin/settings/runtime-policy-settings-page.tsx b/web/src/pages/admin/settings/runtime-policy-settings-page.tsx index 532006d69..e5cae15af 100644 --- a/web/src/pages/admin/settings/runtime-policy-settings-page.tsx +++ b/web/src/pages/admin/settings/runtime-policy-settings-page.tsx @@ -61,8 +61,8 @@ const rateFields: PolicyField[] = [ { group: "request", name: "sessionFilePerMinute", label: "会话附件", extra: "每账号每分钟上传会话附件的次数。", unit: "次/分钟", max: 999_999 }, { group: "request", name: "assetWritePerMinute", label: "素材写入", extra: "每账号每分钟写入素材的次数。", unit: "次/分钟", max: 999_999 }, { group: "request", name: "canvasWritePerMinute", label: "画布写入", extra: "每账号每分钟写入画布的次数。", unit: "次/分钟", max: 999_999 }, - { group: "request", name: "registerPerHour", label: "账号注册", extra: "每 IP 每小时允许注册的次数。", unit: "次/小时", max: 999_999 }, - { group: "request", name: "emailCodePerHour", label: "邮箱验证码", extra: "每 IP 每小时允许请求验证码的次数。", unit: "次/小时", max: 999_999 }, + { group: "request", name: "registerPerHour", label: "账号注册", extra: "每 IP 每小时允许的注册请求次数,包含失败尝试;多人共用网络时建议至少 30 次。", unit: "次/小时", max: 999_999 }, + { group: "request", name: "emailCodePerHour", label: "邮箱验证码", extra: "每 IP 每小时的验证码请求次数,建议至少 60 次;单邮箱另限每小时 10 次,发送后需间隔 60 秒。", unit: "次/小时", max: 999_999 }, { group: "request", name: "loginIPPerTenMinutes", label: "登录 IP", extra: "每 IP 每 10 分钟允许登录的次数。", unit: "次/10分钟", max: 999_999 }, { group: "request", name: "loginAccountPerTenMinutes", label: "登录账号组合", extra: "同一 IP 与账号组合每 10 分钟的登录次数。", unit: "次/10分钟", max: 999_999 }, { group: "request", name: "systemRelayPerMinute", label: "系统渠道中转", extra: "每账号每分钟使用系统渠道的请求数。", unit: "次/分钟", max: 999_999 }, diff --git a/web/src/pages/auth/register.tsx b/web/src/pages/auth/register.tsx index 50a86310b..d10097f82 100644 --- a/web/src/pages/auth/register.tsx +++ b/web/src/pages/auth/register.tsx @@ -1,10 +1,11 @@ -import { type FormEvent, useEffect, useState, type ReactNode } from "react"; +import { type FormEvent, useEffect, useRef, useState, type ReactNode } from "react"; import { App, Button, Divider, Input } from "antd"; import { ArrowRight, Info, LockKeyhole, Mail, ShieldCheck, TriangleAlert, UserRound } from "lucide-react"; import { useNavigate, useSearchParams } from "react-router"; import { getAuthSession, getAuthSettings, linuxDOLoginURL, register, sendRegistrationEmailCode } from "@/services/api/auth"; import { LinuxDOIcon } from "./auth-scene"; +import { ApiError } from "@/services/api/request"; type AuthSettings = Awaited>; @@ -22,12 +23,19 @@ export default function RegisterPage() { const [submitting, setSubmitting] = useState(false); const [sendingCode, setSendingCode] = useState(false); const [countdown, setCountdown] = useState(0); + const [registerCountdown, setRegisterCountdown] = useState(0); + const sending = useRef(false), + registering = useRef(false); const next = safeNext(params.get("next")); useEffect(() => { let cancelled = false; - void getAuthSettings().then((value) => !cancelled && setSettings(value)).catch((error) => !cancelled && message.error(error instanceof Error ? error.message : "读取注册设置失败")); - return () => { cancelled = true; }; + void getAuthSettings() + .then((value) => !cancelled && setSettings(value)) + .catch((error) => !cancelled && message.error(error instanceof Error ? error.message : "读取注册设置失败")); + return () => { + cancelled = true; + }; }, [message]); useEffect(() => { @@ -37,28 +45,34 @@ export default function RegisterPage() { }, [countdown]); const sendCode = async () => { + if (sending.current || countdown > 0) return; if (!email.trim()) { message.warning("请先输入邮箱"); return; } + sending.current = true; setSendingCode(true); try { await sendRegistrationEmailCode(email.trim()); setCountdown(60); message.success("验证码已发送,请检查邮箱"); } catch (error) { + if (error instanceof ApiError && error.status === 429) setCountdown(Math.max(1, Math.ceil((error.retryAfterMs ?? 60000) / 1000))); message.error(error instanceof Error ? error.message : "发送验证码失败"); } finally { + sending.current = false; setSendingCode(false); } }; const submit = async (event: FormEvent) => { event.preventDefault(); + if (registering.current || registerCountdown > 0) return; if (password !== confirmPassword) { message.error("两次输入的密码不一致"); return; } + registering.current = true; setSubmitting(true); try { await register({ username, email, emailCode, displayName, password }); @@ -68,12 +82,20 @@ export default function RegisterPage() { message.success(settings?.firstUser ? "管理员账号已创建" : "注册成功"); navigate(next, { replace: true }); } catch (error) { + if (error instanceof ApiError && error.status === 429) setRegisterCountdown(Math.max(1, Math.ceil((error.retryAfterMs ?? 60000) / 1000))); message.error(error instanceof Error ? error.message : "注册失败"); } finally { + registering.current = false; setSubmitting(false); } }; + useEffect(() => { + if (registerCountdown <= 0) return; + const timer = window.setInterval(() => setRegisterCountdown((value) => Math.max(0, value - 1)), 1000); + return () => window.clearInterval(timer); + }, [registerCountdown]); + const registrationClosed = settings?.registrationEnabled === false; const mailUnavailable = Boolean(settings && !settings.firstUser && settings.emailCodeRequired && !settings.emailEnabled); const disabled = registrationClosed || mailUnavailable; @@ -81,43 +103,125 @@ export default function RegisterPage() { return (
- {settings?.firstUser ? } tone="blue">首个账号自动成为管理员,邮箱验证码暂不要求。 : null} - {registrationClosed ? } tone="amber">当前已关闭普通注册,请联系管理员创建账号。 : null} - {mailUnavailable ? } tone="amber">管理员尚未配置注册邮件,普通邮箱注册暂不可用。 : null} + {settings?.firstUser ? ( + } tone="blue"> + 首个账号自动成为管理员,邮箱验证码暂不要求。 + + ) : null} + {registrationClosed ? ( + } tone="amber"> + 当前已关闭普通注册,请联系管理员创建账号。 + + ) : null} + {mailUnavailable ? ( + } tone="amber"> + 管理员尚未配置注册邮件,普通邮箱注册暂不可用。 + + ) : null}
- } value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字符" autoComplete="username" required disabled={disabled} /> - setDisplayName(event.target.value)} placeholder="不填则使用用户名" disabled={disabled} /> + + } value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字符" autoComplete="username" required disabled={disabled} /> + + + setDisplayName(event.target.value)} placeholder="不填则使用用户名" disabled={disabled} /> +
- } value={email} onChange={(event) => setEmail(event.target.value)} placeholder="用于登录与安全验证" autoComplete="email" required={!settings?.firstUser} disabled={disabled} /> + + } + value={email} + onChange={(event) => setEmail(event.target.value)} + placeholder="用于登录与安全验证" + autoComplete="email" + required={!settings?.firstUser} + disabled={disabled} + /> + {requireCode ? (
- } value={emailCode} onChange={(event) => setEmailCode(event.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="6 位验证码" inputMode="numeric" autoComplete="one-time-code" required disabled={disabled} /> - + } + value={emailCode} + onChange={(event) => setEmailCode(event.target.value.replace(/\D/g, "").slice(0, 6))} + placeholder="6 位验证码" + inputMode="numeric" + autoComplete="one-time-code" + required + disabled={disabled} + /> +
) : null}
- } value={password} onChange={(event) => setPassword(event.target.value)} placeholder="至少 8 位" autoComplete="new-password" required disabled={disabled} /> - } value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} placeholder="再次输入密码" autoComplete="new-password" required disabled={disabled} /> + + } + value={password} + onChange={(event) => setPassword(event.target.value)} + placeholder="至少 8 位" + autoComplete="new-password" + required + disabled={disabled} + /> + + + } + value={confirmPassword} + onChange={(event) => setConfirmPassword(event.target.value)} + placeholder="再次输入密码" + autoComplete="new-password" + required + disabled={disabled} + /> +
- - {settings?.linuxdoEnabled ? <>或 : null} + + {settings?.linuxdoEnabled ? ( + <> + + 或 + + + + ) : null}
); } function AuthField({ label, children }: { label: string; children: ReactNode }) { - return ; + return ( + + ); } function Notice({ icon, tone, children }: { icon: ReactNode; tone: "blue" | "amber"; children: ReactNode }) { - return
{icon}{children}
; + return ( +
+ {icon} + {children} +
+ ); } function safeNext(value: string | null) { diff --git a/web/src/services/api/channel-order.ts b/web/src/services/api/channel-order.ts new file mode 100644 index 000000000..8a3822c5a --- /dev/null +++ b/web/src/services/api/channel-order.ts @@ -0,0 +1,5 @@ +import { apiClient, request } from "./request"; +export type ChannelOrderItem = { id: string; name: string; enabled: boolean }; +const pathFor = (channelId?: string) => (channelId ? `/admin/channels/${encodeURIComponent(channelId)}/models/order` : "/admin/channels/order"); +export const getChannelOrder = (channelId?: string) => request<{ items: ChannelOrderItem[] }>(apiClient.get(pathFor(channelId))); +export const saveChannelOrder = (channelId: string | undefined, ids: string[], expectedIds: string[]) => request<{ saved: boolean }>(apiClient.put(pathFor(channelId), { ids, expectedIds })); diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index 477449bc4..fde906f9d 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -31,7 +31,7 @@ export type PublicLogicalModel = { inputPriceMicrocredits: number; outputPriceMicrocredits: number; cachedPriceMicrocredits: number; - priceTiers: PublicLogicalModelPriceTier[]; + priceTiers: PublicLogicalModelPriceTier[]; legacyModelIds: string[]; capabilitySpec: CapabilitySpec; capabilityProfiles: CapabilitySpec[]; @@ -40,14 +40,14 @@ export type PublicLogicalModel = { }; export type PublicLogicalModelPriceTier = { - selector: Record; - resolution: string; - videoSeconds: number; - billingMode: "fixed_request" | "per_second" | "token"; - unitPriceMicrocredits: number; - inputTokenPriceMicrocredits: number; - outputTokenPriceMicrocredits: number; - cachedTokenPriceMicrocredits: number; + selector: Record; + resolution: string; + videoSeconds: number; + billingMode: "fixed_request" | "per_second" | "token"; + unitPriceMicrocredits: number; + inputTokenPriceMicrocredits: number; + outputTokenPriceMicrocredits: number; + cachedTokenPriceMicrocredits: number; }; export type AdminLogicalRoute = { @@ -127,6 +127,7 @@ export type PublicChannelCatalog = { id: string; name: string; displayName: string; + sortOrder?: number; models: PublicChannelModel[]; }; @@ -134,6 +135,7 @@ export type PublicChannelModel = { id: string; modelKey: string; displayName: string; + sortOrder?: number; icon: string; capability: string; protocol?: string; diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index 509f1bd29..2d424c2ed 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -53,6 +53,7 @@ export type ChannelModel = { modelKey: string; providerModelKey: string; displayName: string; + sortOrder?: number; icon: string; capability: "text" | "image" | "video" | "audio" | ""; protocol?: import("@/lib/model-protocols").ModelProtocol; @@ -272,11 +273,11 @@ export function listAdminChannelModels(channelId: string) { // 管理员从上游读取模型目录;确认导入后才会写入渠道模型,价格和启用仍需人工确认。 export function fetchAdminChannelModels(channelId: string) { - return request<{ models: string[] }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)); + return request<{ models: string[] }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)); } export function importAdminChannelModels(channelId: string, models: string[]) { - return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })); + return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })); } export function testAdminChannelModel(channelId: string, input: Pick & { capabilityConfig?: ChannelModel["capabilityConfig"] }) { @@ -291,6 +292,10 @@ export function updateAdminChannelModel(channelId: string, id: string, input: Ch return request<{ model: ChannelModel }>(api.patch(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`, input)); } +export function updateAdminChannelModelSort(channelId: string, id: string, sortOrder: number) { + return request<{ updated: boolean }>(api.patch(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}/sort`, { sortOrder })); +} + export function deleteAdminChannelModel(channelId: string, id: string) { return request<{ ok: boolean }>(api.delete(`/admin/channels/${encodeURIComponent(channelId)}/models/${encodeURIComponent(id)}`)); } diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 75d27b90f..f0b971bcf 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -60,16 +60,47 @@ function normalizeWorkflowFieldSourceName(value: unknown, capability?: RunningHu const source = String(value || "").trim(); const normalized = source.toLowerCase().replace(/[\s_-]/g, ""); const aliases: Record = { - text: "prompt", positive: "prompt", positiveprompt: "prompt", - image: "referenceImage", referenceimage: "referenceImage", referenceimages: "referenceImage", - video: "referenceVideo", referencevideo: "referenceVideo", referencevideos: "referenceVideo", - audio: "referenceAudio", referenceaudio: "referenceAudio", referenceaudios: "referenceAudio", - sizewidth: "width", imagewidth: "width", videowidth: "width", sizeheight: "height", imageheight: "height", videoheight: "height", - ratio: "aspectRatio", aspectratio: "aspectRatio", imageaspectratio: "aspectRatio", imageratio: "aspectRatio", videoaspectratio: "aspectRatio", videoratio: "aspectRatio", - videoresolution: "vquality", batch: "count", batchsize: "count", duration: "videoSeconds", videoseconds: "videoSeconds", videoquality: "vquality", - generateaudio: "videoGenerateAudio", videogenerateaudio: "videoGenerateAudio", watermark: "videoWatermark", videowatermark: "videoWatermark", voice: "audioVoice", - systemprompt: "systemPrompt", transparentbackground: "transparentBackground", audiovoice: "audioVoice", - audioformat: "audioFormat", audiospeed: "audioSpeed", audioinstructions: "audioInstructions", + text: "prompt", + positive: "prompt", + positiveprompt: "prompt", + image: "referenceImage", + referenceimage: "referenceImage", + referenceimages: "referenceImage", + video: "referenceVideo", + referencevideo: "referenceVideo", + referencevideos: "referenceVideo", + audio: "referenceAudio", + referenceaudio: "referenceAudio", + referenceaudios: "referenceAudio", + sizewidth: "width", + imagewidth: "width", + videowidth: "width", + sizeheight: "height", + imageheight: "height", + videoheight: "height", + ratio: "aspectRatio", + aspectratio: "aspectRatio", + imageaspectratio: "aspectRatio", + imageratio: "aspectRatio", + videoaspectratio: "aspectRatio", + videoratio: "aspectRatio", + videoresolution: "vquality", + batch: "count", + batchsize: "count", + duration: "videoSeconds", + videoseconds: "videoSeconds", + videoquality: "vquality", + generateaudio: "videoGenerateAudio", + videogenerateaudio: "videoGenerateAudio", + watermark: "videoWatermark", + videowatermark: "videoWatermark", + voice: "audioVoice", + systemprompt: "systemPrompt", + transparentbackground: "transparentBackground", + audiovoice: "audioVoice", + audioformat: "audioFormat", + audiospeed: "audioSpeed", + audioinstructions: "audioInstructions", }; if (normalized === "resolution") return capability === "video" ? "vquality" : "size"; // 工作流的 quality 可能是连续数值(例如 0.1-3),不能按视频分辨率处理。 @@ -135,7 +166,7 @@ export function normalizeWorkflowFieldMappings(value: unknown, capability?: Runn // source 明确存在但为空,表示用户要求保留工作流默认值;只有旧数据完全缺少来源字段时才自动补绑定。 const sourceKey = ["source", "bind", "from"].find((key) => Object.prototype.hasOwnProperty.call(raw, key)); const sourceConfigured = Boolean(sourceKey); - const configuredSource = sourceKey ? raw[sourceKey] : (raw.bindPrompt === true || raw.bind_prompt === true ? "prompt" : ""); + const configuredSource = sourceKey ? raw[sourceKey] : raw.bindPrompt === true || raw.bind_prompt === true ? "prompt" : ""; let source = normalizeWorkflowFieldSourceName(configuredSource, capability); const rawSourceAutomatic = raw.sourceAutomatic ?? raw.source_automatic; let sourceAutomatic = typeof rawSourceAutomatic === "boolean" ? rawSourceAutomatic : !sourceConfigured && Boolean(source); @@ -164,8 +195,8 @@ export function normalizeWorkflowFieldMappings(value: unknown, capability?: Runn const options = Array.isArray(rawOptions) ? rawOptions : rawOptions && typeof rawOptions === "object" - ? ((rawOptions as Record).choices ?? (rawOptions as Record).options ?? (rawOptions as Record).values) - : undefined; + ? ((rawOptions as Record).choices ?? (rawOptions as Record).options ?? (rawOptions as Record).values) + : undefined; const range = workflowFieldRange(rawOptions); const min = raw.min ?? raw.minValue ?? raw.min_value ?? range.min; const max = raw.max ?? raw.maxValue ?? raw.max_value ?? range.max; @@ -196,8 +227,10 @@ export function normalizeWorkflowFieldMappings(value: unknown, capability?: Runn const role = workflowFieldRole({ ...candidate, role: String(raw.role || "") }); const configuredEnabled = typeof raw.enabled === "boolean" ? raw.enabled : role !== "internal"; const optionsSource = ["workflow", "manual", "preset"].includes(String(raw.optionsSource || raw.options_source || "")) - ? String(raw.optionsSource || raw.options_source) as WorkflowFieldMapping["optionsSource"] - : Array.isArray(options) ? "workflow" : undefined; + ? (String(raw.optionsSource || raw.options_source) as WorkflowFieldMapping["optionsSource"]) + : Array.isArray(options) + ? "workflow" + : undefined; return [{ ...candidate, role, safeToOverride, enabled: safeToOverride && configuredEnabled, ...(optionsSource ? { optionsSource } : {}) }]; }); let imageOrder = 0; @@ -231,11 +264,7 @@ export function mergeWorkflowFieldMappings(current: unknown, incoming: unknown, const previousFields = normalizeWorkflowFieldMappings(current, capability); const nextFields = normalizeWorkflowFieldMappings(incoming, capability); const previousByKey = new Map(previousFields.map((field) => [`${field.nodeId}::${field.fieldName}`, field])); - const policyKeys: Array = [ - "label", "enabled", - "randomEnabled", "source", "sourceAutomatic", "sourceFromUpstream", "sourceIndex", - "imageOrder", "required", "bindPrompt", - ]; + const policyKeys: Array = ["label", "enabled", "randomEnabled", "source", "sourceAutomatic", "sourceFromUpstream", "sourceIndex", "imageOrder", "required", "bindPrompt"]; return nextFields.map((field) => { const previous = previousByKey.get(`${field.nodeId}::${field.fieldName}`); if (!previous) return field; @@ -263,9 +292,7 @@ export function mergeWorkflowFieldMappings(current: unknown, incoming: unknown, function normalizeSavedWorkflowFields(workflow: { fields?: unknown; workflowJson?: Record }, capability: RunningHubCapability) { const schemaFields = workflowVideoFieldsFromJson(workflow.workflowJson); - return schemaFields.length - ? mergeWorkflowFieldMappings(workflow.fields, schemaFields, capability) - : normalizeWorkflowFieldMappings(workflow.fields, capability); + return schemaFields.length ? mergeWorkflowFieldMappings(workflow.fields, schemaFields, capability) : normalizeWorkflowFieldMappings(workflow.fields, capability); } function workflowFieldRange(value: unknown) { @@ -339,6 +366,8 @@ export const PUBLIC_MODEL_CATALOG_ID = "managed"; export type ModelChannel = { id: string; name: string; + publicAlias?: string; + sortOrder?: number; baseUrl: string; allowLocalChannel?: boolean; apiKey: string; @@ -371,7 +400,7 @@ export type ModelChannel = { logicalModelId?: string; logicalCapabilitySpec?: CapabilitySpec; logicalCapabilityProfiles?: CapabilitySpec[]; - logicalPriceTiers?: PublicLogicalModelPriceTier[]; + logicalPriceTiers?: PublicLogicalModelPriceTier[]; defaultOptions?: Record; }>; transport?: "backend-channel" | "local-runtime"; @@ -642,36 +671,34 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefine const runningHubCapability = normalizeRunningHubCapability(persistedRunningHub?.capability, defaultConfig.runningHub.capability); const runningHubWorkflows = Array.isArray(persistedRunningHub?.workflows) ? persistedRunningHub.workflows - .filter((item): item is RunningHubWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim())) - .map((item) => { - const capability = normalizeRunningHubCapability(item.capability, runningHubCapability); - return { - ...item, - kind: normalizeRunningHubWorkflowKind(item.kind), - workflowId: String(item.workflowId || "").trim(), - capability, - fields: normalizeSavedWorkflowFields(item, capability), - }; - }) + .filter((item): item is RunningHubWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim())) + .map((item) => { + const capability = normalizeRunningHubCapability(item.capability, runningHubCapability); + return { + ...item, + kind: normalizeRunningHubWorkflowKind(item.kind), + workflowId: String(item.workflowId || "").trim(), + capability, + fields: normalizeSavedWorkflowFields(item, capability), + }; + }) : []; const runningHubWorkflowID = String(persistedRunningHub?.workflowId || "").trim(); - const runningHubSelectedKind = persistedRunningHub?.selectedKind - ? normalizeRunningHubWorkflowKind(persistedRunningHub.selectedKind) - : normalizeRunningHubWorkflowKind(runningHubWorkflows.find((item) => item.workflowId === runningHubWorkflowID)?.kind); + const runningHubSelectedKind = persistedRunningHub?.selectedKind ? normalizeRunningHubWorkflowKind(persistedRunningHub.selectedKind) : normalizeRunningHubWorkflowKind(runningHubWorkflows.find((item) => item.workflowId === runningHubWorkflowID)?.kind); const persistedComfyBridge = persistedConfig.comfyBridge; const comfyBridgeCapability = normalizeRunningHubCapability(persistedComfyBridge?.capability, defaultConfig.comfyBridge.capability); const comfyBridgeWorkflows = Array.isArray(persistedComfyBridge?.workflows) ? persistedComfyBridge.workflows - .filter((item): item is ComfyBridgeWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim())) - .map((item) => { - const capability = normalizeRunningHubCapability(item.capability, comfyBridgeCapability); - return { - ...item, - workflowId: String(item.workflowId || "").trim(), - capability, - fields: normalizeSavedWorkflowFields(item, capability), - }; - }) + .filter((item): item is ComfyBridgeWorkflow => Boolean(item && typeof item === "object" && String(item.workflowId || "").trim())) + .map((item) => { + const capability = normalizeRunningHubCapability(item.capability, comfyBridgeCapability); + return { + ...item, + workflowId: String(item.workflowId || "").trim(), + capability, + fields: normalizeSavedWorkflowFields(item, capability), + }; + }) : []; const config = { ...defaultConfig, @@ -789,6 +816,7 @@ export function createModelChannel(channel?: Partial): ModelChanne return { id: channel?.id?.trim() || nanoid(), name: channel?.name?.trim() || "新渠道", + sortOrder: channel?.sortOrder ?? 0, baseUrl: providedBaseUrl || (interfaceType ? defaultBaseUrlForChannelInterface(interfaceType) : defaultBaseUrlForApiFormat(apiFormat)), allowLocalChannel: channel?.allowLocalChannel === true, apiKey: channel?.apiKey || "", @@ -864,19 +892,19 @@ export function hasSystemModelPrice(channel: ModelChannel, model: string) { if (channel.scope !== "system") return true; // 价格字段已由后端按“非负数”校验;0 表示免费模型,不能在目录重建时被过滤。 const configured = (value: number | undefined) => typeof value === "number" && Number.isFinite(value) && value >= 0; - return channel.modelCosts?.some((item) => { - if (item.model !== model) return false; - const tiers = item.logicalPriceTiers || []; - if (tiers.length) { - return tiers.some((tier) => tier.billingMode === "token" - ? [tier.inputTokenPriceMicrocredits, tier.outputTokenPriceMicrocredits, tier.cachedTokenPriceMicrocredits].every(configured) - : configured(tier.unitPriceMicrocredits)); - } - if (item.billingMode === "token") { - return [item.inputTokenPriceMicrocredits, item.outputTokenPriceMicrocredits, item.cachedTokenPriceMicrocredits].every(configured); - } - return configured(item.unitPriceMicrocredits); - }) === true; + return ( + channel.modelCosts?.some((item) => { + if (item.model !== model) return false; + const tiers = item.logicalPriceTiers || []; + if (tiers.length) { + return tiers.some((tier) => (tier.billingMode === "token" ? [tier.inputTokenPriceMicrocredits, tier.outputTokenPriceMicrocredits, tier.cachedTokenPriceMicrocredits].every(configured) : configured(tier.unitPriceMicrocredits))); + } + if (item.billingMode === "token") { + return [item.inputTokenPriceMicrocredits, item.outputTokenPriceMicrocredits, item.cachedTokenPriceMicrocredits].every(configured); + } + return configured(item.unitPriceMicrocredits); + }) === true + ); } export function normalizeModelOptionValue(value: unknown, channels: ModelChannel[]) { @@ -1020,14 +1048,16 @@ export function buildApiUrl(baseUrl: string, path: string) { if (isSystemProxyBaseUrl(normalizedBaseUrl)) return `${normalizedBaseUrl}${requestPath}`; const knownPrefixes = ["/api/plan/v3", "/api/v3", "/v1beta", "/v1", "/v2", "/v3"]; - const requestPrefixFor = (value: string) => knownPrefixes.find((prefix) => { - const lower = value.toLowerCase(); - return lower === prefix || lower.startsWith(`${prefix}/`) || lower.startsWith(`${prefix}?`) || lower.startsWith(`${prefix}#`); - }) || ""; - const basePrefixFor = (value: string) => knownPrefixes.find((prefix) => { - const lower = value.toLowerCase(); - return lower.endsWith(prefix) || lower.includes(`${prefix}/`) || lower.includes(`${prefix}?`) || lower.includes(`${prefix}#`); - }) || ""; + const requestPrefixFor = (value: string) => + knownPrefixes.find((prefix) => { + const lower = value.toLowerCase(); + return lower === prefix || lower.startsWith(`${prefix}/`) || lower.startsWith(`${prefix}?`) || lower.startsWith(`${prefix}#`); + }) || ""; + const basePrefixFor = (value: string) => + knownPrefixes.find((prefix) => { + const lower = value.toLowerCase(); + return lower.endsWith(prefix) || lower.includes(`${prefix}/`) || lower.includes(`${prefix}?`) || lower.includes(`${prefix}#`); + }) || ""; const basePrefix = basePrefixFor(normalizedBaseUrl); const requestPrefix = requestPrefixFor(requestPath); if (requestPrefix) { diff --git a/web/test/channel-presentation.test.tsx b/web/test/channel-presentation.test.tsx new file mode 100644 index 000000000..725885941 --- /dev/null +++ b/web/test/channel-presentation.test.tsx @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { App } from "antd"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { systemChannelModelChannels } from "../src/lib/user-session"; +import { ChannelOrderDialog, moveOrderItem } from "../src/pages/admin/components/channel-order-dialog"; +import type { PublicChannelCatalog, PublicChannelModel } from "../src/services/api/logical-models"; +import { defaultConfig, normalizeConfigSnapshot, selectableModelsByCapability } from "../src/stores/use-config-store"; + +test("public channel order, alias and model order survive session normalization and capability filtering", () => { + const model = (key: string, available = true): PublicChannelModel => ({ + id: key, + modelKey: key, + displayName: key, + icon: "", + capability: "image", + protocol: "openai-image", + available, + priceTiers: [], + pricingMode: "fixed", + displayPrice: 1, + priceLabel: "1", + }); + const catalog: PublicChannelCatalog[] = [ + { id: "z-channel", name: "前台精选", displayName: "前台精选", sortOrder: 1, models: [model("z-model"), model("hidden", false), model("a-model")] }, + { id: "a-channel", name: "默认名称", displayName: "默认名称", sortOrder: 20, models: [model("b-model")] }, + ]; + const config = normalizeConfigSnapshot({ config: { ...defaultConfig, channels: systemChannelModelChannels(catalog), model: "z-channel::a-model", imageModels: undefined } }).config; + expect(config.channels.map((channel) => [channel.id, channel.name, channel.sortOrder])).toEqual([ + ["z-channel", "前台精选", 1], + ["a-channel", "默认名称", 20], + ]); + expect(config.channels[0]!.models).toEqual(["z-model", "a-model"]); + expect(selectableModelsByCapability(config, "image")).toEqual(["z-channel::z-model", "z-channel::a-model", "a-channel::b-model"]); + expect(config.model).toBe("z-channel::a-model"); +}); + +test("sorting exposes a simple settings entry and moves items without changing their data", () => { + const html = renderToStaticMarkup( + + {}} /> + , + ); + expect(html).toContain("设置排序"); + expect(html).not.toContain("spinbutton"); + const rows = [ + { id: "a", name: "A" }, + { id: "b", name: "B" }, + { id: "c", name: "C" }, + ]; + expect(moveOrderItem(rows, "c", 0).map((item) => item.id)).toEqual(["c", "a", "b"]); + expect(moveOrderItem(rows, "a", 2).map((item) => item.id)).toEqual(["b", "c", "a"]); + expect(moveOrderItem(rows, "a", -1)).toBe(rows); + expect(rows.map((item) => item.id)).toEqual(["a", "b", "c"]); +});