feat(channels): 渠道管理 - 可视化排序与别名并修复注册及外观清理 (#444)

This commit is contained in:
Ray
2026-09-08 20:56:42 +08:00
committed by GitHub
parent 381edf37e3
commit 8360b2c933
41 changed files with 1485 additions and 148 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, "请求过于频繁,请稍后重试")

View File

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

View File

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

View File

@@ -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", &current).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
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("运行时协调器未初始化")

View File

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

View File

@@ -0,0 +1,9 @@
package service
import "fmt"
type EmailCodeCooldownError struct{ Seconds int }
func (e *EmailCodeCooldownError) Error() string {
return fmt.Sprintf("验证码已发送,请查看邮箱;%d 秒后可以重新获取", e.Seconds)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 重建目录项 |

View File

@@ -5,6 +5,16 @@ description: 当前项目已实现的核心工作台能力与使用入口
# 功能清单
## 系统渠道排序与前台别名
后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。
系统渠道可设置可选的前台别名;为空时显示渠道名,设置后公开目录和用户会话只返回别名,后台仍保留原名。别名最长 80 字,支持后台搜索。前台模型目录模式保留其独立配置。
注册默认每 IP 每小时允许 30 次注册请求、60 次验证码请求,单邮箱每小时另限 10 次且发送后间隔 60 秒。失败尝试也占请求额度,达到上限后的拒绝不会继续增加计数或延长窗口;限流响应携带真实剩余等待时间,注册页面显示倒计时并防止重复提交。已保存的运行策略仍需管理员按实际共用网络规模调整,不随默认值升级自动覆盖。
定时闲置资源清理与后台手动删除共同保护外观配置引用的浅色 Logo、深色 Logo、登录视频及封面;即使资源超过闲置期限也不会删除,外观配置无法读取时停止相关清理。
## 章节资产提取与任务浏览
画布视频支持鼠标悬停 350ms 后静音预览前 3 秒,不循环;全局仅一个悬停解码器,离开或操作画布立即停止并恢复封面。手动播放优先,系统开启减弱动态效果时不自动预览。

View File

@@ -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 样式。

View File

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

View File

@@ -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<FormInstance<ChannelFormValues>, "setFieldValue"> }) {
return (
<>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder={checked ? DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL : "填写渠道 Base URL"} /></Form.Item>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}>
<Input placeholder={checked ? DESKTOP_LOCAL_CHANNEL_EXAMPLE_BASE_URL : "填写渠道 Base URL"} />
</Form.Item>
<AdminLocalChannelSwitch visible={visible} checked={checked} onChange={(value) => 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<ModelChannel> = [
{ title: "渠道", dataIndex: "name", render: (_, channel) => <div><div className="font-medium">{channel.name}</div><div className="admin-monospace max-w-lg truncate text-foreground/45">{channel.baseUrl}</div></div> },
{
title: "渠道",
dataIndex: "name",
render: (_, channel) => (
<div>
<div className="font-medium">{channel.name}</div>
<div className="admin-monospace max-w-lg truncate text-foreground/45">{channel.baseUrl}</div>
</div>
),
},
{ title: "前台名称", width: 160, render: (_, channel) => <span className={channel.publicAlias ? "" : "text-foreground/45"}>{channel.publicAlias || channel.name}</span> },
{ 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 : <span className="text-foreground/45">跟随系统</span> },
{ title: "最大并发", dataIndex: "concurrencyLimit", width: 120, align: "center", render: (value: number) => (value > 0 ? value : <span className="text-foreground/45">跟随系统</span>) },
{ title: "凭证", width: 130, align: "center", render: (_, channel) => <AdminStatusBadge label={channel.hasApiKey ? (channel.hasSecretKey ? "AK/SK 已配置" : "API Key 已配置") : "未配置"} tone={channel.hasApiKey ? "success" : "neutral"} /> },
{ title: "状态", dataIndex: "enabled", width: 100, align: "center", render: (enabled) => <AdminStatusBadge label={enabled !== false ? "已启用" : "已停用"} tone={enabled !== false ? "success" : "neutral"} /> },
{ title: "操作", width: 250, align: "center", render: (_, channel) => <AdminRowActions primary={{ label: "模型管理", onClick: () => setManagingChannel(channel) }} actions={[{ key: "edit", label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(channel) }, { key: "toggle", label: channel.enabled !== false ? "停用渠道" : "启用渠道", icon: <Power className="size-3.5" />, danger: channel.enabled !== false, confirm: { title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?", description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。", okText: channel.enabled !== false ? "确认停用" : "确认启用" }, onClick: () => toggleChannel(channel) }, { key: "delete", label: "删除渠道", icon: <Trash2 className="size-3.5" />, danger: true, confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" }, onClick: () => removeChannel(channel) }]} /> },
{
title: "操作",
width: 250,
align: "center",
render: (_, channel) => (
<AdminRowActions
primary={{ label: "模型管理", onClick: () => setManagingChannel(channel) }}
actions={[
{ key: "edit", label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(channel) },
{
key: "toggle",
label: channel.enabled !== false ? "停用渠道" : "启用渠道",
icon: <Power className="size-3.5" />,
danger: channel.enabled !== false,
confirm: {
title: channel.enabled !== false ? "停用这个系统渠道?" : "启用这个系统渠道?",
description: channel.enabled !== false ? "停用后新任务不会再使用该渠道,但仍会保留在列表中,可随时重新启用。" : "启用后,配置完整的模型会重新进入系统可用模型集合。",
okText: channel.enabled !== false ? "确认停用" : "确认启用",
},
onClick: () => toggleChannel(channel),
},
{
key: "delete",
label: "删除渠道",
icon: <Trash2 className="size-3.5" />,
danger: true,
confirm: { title: "删除这个系统渠道?", description: "删除后渠道及所属模型将不再显示,API Key 会被清除,历史账单和调用记录继续保留。该操作不能在页面恢复。", okText: "确认删除" },
onClick: () => removeChannel(channel),
},
]}
/>
),
},
];
if (managingChannel) {
return <ChannelModelManager channel={managingChannel} onClose={() => setManagingChannel(null)} onChanged={async () => { await syncChannels(); await reload(); }} />;
return (
<ChannelModelManager
channel={managingChannel}
onClose={() => setManagingChannel(null)}
onChanged={async () => {
await syncChannels();
await reload();
}}
/>
);
}
return (
<AdminPageFrame title="系统渠道" description="渠道、模型与售价" actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>}>
<AdminPageFrame
title="系统渠道"
description="列表顺序就是用户端展示顺序,点击“设置排序”即可调整。"
actions={
<div className="flex gap-2">
<ChannelOrderDialog
onSaved={async () => {
await syncChannels();
await reload();
}}
/>
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>
新增系统渠道
</Button>
</div>
}
>
<AdminDataTable
toolbar={<Input id="admin-channel-search" aria-label="搜索系统渠道" autoComplete="off" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索渠道名称或地址" onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)} />}
toolbarFilters={<Select aria-label="筛选渠道状态" className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={[{ label: "全部状态", value: "all" }, { label: "已启用", value: "enabled" }, { label: "已停用", value: "disabled" }]} />}
toolbar={
<Input
id="admin-channel-search"
aria-label="搜索系统渠道"
autoComplete="off"
allowClear
className="app-list-search"
prefix={<Search className="size-4 text-foreground/40" />}
value={keyword}
placeholder="搜索渠道名称、别名或地址"
onChange={(event) => updateUrl({ filter: event.target.value, page: 1 }, true)}
/>
}
toolbarFilters={
<Select
aria-label="筛选渠道状态"
className="w-32"
value={status}
onChange={(value) => updateUrl({ status: value, page: 1 })}
options={[
{ label: "全部状态", value: "all" },
{ label: "已启用", value: "enabled" },
{ label: "已停用", value: "disabled" },
]}
/>
}
toolbarActive={hasFilters}
onReset={() => updateUrl({ filter: "", status: "all", page: 1 })}
skeletonColumns={6}
table={{ className: "app-data-table", size: "small", rowKey: "id", loading, columns, dataSource: channels, locale: { emptyText: <AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有系统渠道"} action={hasFilters ? undefined : <Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>} /> }, pagination: false, scroll: { x: 820 } }}
skeletonColumns={7}
table={{
className: "app-data-table",
size: "small",
rowKey: "id",
loading,
columns,
dataSource: channels,
locale: {
emptyText: (
<AdminTableEmpty
filtered={hasFilters}
title={hasFilters ? undefined : "还没有系统渠道"}
action={
hasFilters ? undefined : (
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>
新增系统渠道
</Button>
)
}
/>
),
},
pagination: false,
scroll: { x: 1150 },
}}
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize })} />}
/>
<Modal
@@ -221,24 +366,77 @@ export default function ChannelsPage() {
onCancel={closeDrawer}
mask={{ closable: !saving }}
destroyOnHidden
footer={<div className="flex justify-end gap-2"><Button onClick={closeDrawer}>取消</Button><Button type="primary" loading={saving} onClick={() => void save()}>保存</Button></div>}
footer={
<div className="flex justify-end gap-2">
<Button onClick={closeDrawer}>取消</Button>
<Button type="primary" loading={saving} onClick={() => void save()}>
保存
</Button>
</div>
}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请填写渠道名称" }]}><Input placeholder="例如:OpenAI 官方渠道" /></Form.Item>
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请填写渠道名称" }]}>
<Input placeholder="例如:OpenAI 官方渠道" />
</Form.Item>
<Form.Item name="publicAlias" label="前台显示别名" extra="留空时显示渠道名称;填写后用户端只显示此别名,后台仍保留原渠道名称。" rules={[{ max: 80, message: "别名不能超过 80 个字符" }]}>
<Input maxLength={80} placeholder="可选,例如:精选图片" />
</Form.Item>
<AdminLocalChannelFields visible={showDesktopLocalChannelControl} checked={allowLocalChannel} form={form} />
<Form.Item name="apiKey" label={editingChannel ? `API Key / Access Key(${configuredSecretText})` : "API Key / Access Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key 或 Access Key" }]} extra="OpenAI 兼容协议填写 API Key;即梦官方协议填写 IAM Access Key。"><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原凭证" : "API Key 或 Access Key"} /></Form.Item>
<Form.Item name="secretKey" label={editingChannel ? `Secret Key(${channelSecretText(editingChannel)})` : "Secret Key(可选)"} extra="仅即梦官方等 AK/SK 签名协议需要;其他渠道留空。"><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原 Secret Key" : "IAM Secret Key"} /></Form.Item>
<div className="mb-6"><Form.Item name="headers" noStyle><ChannelHeadersEditor /></Form.Item></div>
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="concurrencyLimit" label="渠道最大并发数" extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。" rules={useGlobalConcurrency ? [] : [{ required: true, message: "请填写渠道最大并发数" }, { type: "number", min: 1, max: 999, message: "请输入 1-999 的整数" }]}><InputNumber className="w-full" min={1} max={999} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-999"} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
<Form.Item
name="apiKey"
label={editingChannel ? `API Key / Access Key(${configuredSecretText})` : "API Key / Access Key"}
rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key 或 Access Key" }]}
extra="OpenAI 兼容协议填写 API Key;即梦官方协议填写 IAM Access Key。"
>
<Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原凭证" : "API Key 或 Access Key"} />
</Form.Item>
<Form.Item name="secretKey" label={editingChannel ? `Secret Key(${channelSecretText(editingChannel)})` : "Secret Key(可选)"} extra="仅即梦官方等 AK/SK 签名协议需要;其他渠道留空。">
<Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原 Secret Key" : "IAM Secret Key"} />
</Form.Item>
<div className="mb-6">
<Form.Item name="headers" noStyle>
<ChannelHeadersEditor />
</Form.Item>
</div>
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name="concurrencyLimit"
label="渠道最大并发数"
extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。"
rules={
useGlobalConcurrency
? []
: [
{ required: true, message: "请填写渠道最大并发数" },
{ type: "number", min: 1, max: 999, message: "请输入 1-999 的整数" },
]
}
>
<InputNumber className="w-full" min={1} max={999} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-999"} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</AdminPageFrame>
);
}
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 ? "已配置,留空不修改" : "未配置";
}

View File

@@ -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<void> }) {
const { message, modal } = App.useApp();
@@ -252,9 +253,17 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
return (
<AdminPageFrame
title={`${channel.name} / 模型管理`}
description="模型按用户端展示顺序排列,点击“设置排序”即可调整。"
back={{ label: "返回系统渠道", onClick: onClose }}
actions={
<Space wrap>
<ChannelOrderDialog
channelId={channel.id}
onSaved={async () => {
await reload();
await onChanged();
}}
/>
<Button loading={fetching} icon={<RefreshCw className="size-4" />} onClick={() => void fetchModels()}>
拉取模型
</Button>
@@ -372,7 +381,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
columns,
dataSource: pagedItems,
pagination: false,
scroll: { x: 990 },
scroll: { x: 1150 },
}}
footer={
<PaginationBar

View File

@@ -0,0 +1,135 @@
import { App, Button, Empty, Modal, Spin } from "antd";
import { ArrowDown, ArrowUp, GripVertical, ListOrdered } from "lucide-react";
import { useRef, useState } from "react";
import { getChannelOrder, saveChannelOrder, type ChannelOrderItem } from "@/services/api/channel-order";
export function moveOrderItem<T extends { id: string }>(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<void> }) {
const { message } = App.useApp();
const [open, setOpen] = useState(false),
[loading, setLoading] = useState(false),
[saving, setSaving] = useState(false);
const [items, setItems] = useState<ChannelOrderItem[]>([]),
[original, setOriginal] = useState<string[]>([]);
const dragged = useRef<string | null>(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 (
<>
<Button icon={<ListOrdered className="size-4" />} onClick={() => void start()}>
设置排序
</Button>
<Modal
title={channelId ? "调整模型顺序" : "调整渠道顺序"}
open={open}
onCancel={() => setOpen(false)}
closable={!saving && !loading}
mask={{ closable: !saving && !loading }}
keyboard={!saving && !loading}
width={600}
footer={
<div className="flex justify-end gap-2">
<Button disabled={saving || loading} onClick={() => setOpen(false)}>
取消
</Button>
<Button type="primary" loading={saving} disabled={!changed || loading} onClick={() => void save()}>
保存排序
</Button>
</div>
}
>
<p className="mb-4 text-sm text-foreground/60">拖动条目或点击上下箭头调整顺序,保存后用户端按此顺序展示。这里包含全部条目,不受列表筛选和分页影响。</p>
<Spin spinning={loading}>
<div className="max-h-[55vh] overflow-y-auto space-y-2" role="list" aria-label="展示顺序">
{!loading && !items.length ? (
<Empty description="暂无可排序条目" />
) : (
items.map((item, index) => (
<div
role="listitem"
key={item.id}
draggable={!saving}
onDragStart={(event) => {
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"
>
<GripVertical aria-hidden className="size-4 shrink-0 cursor-grab text-foreground/40" />
<span className="min-w-0 flex-1 break-words">
{item.name}
{!item.enabled && <span className="ml-2 text-xs text-foreground/45">已停用</span>}
</span>
<Button aria-label={`上移${item.name}`} title="上移" icon={<ArrowUp className="size-4" />} disabled={saving || index === 0} onClick={() => setItems((current) => moveOrderItem(current, item.id, index - 1))} />
<Button
aria-label={`下移${item.name}`}
title="下移"
icon={<ArrowDown className="size-4" />}
disabled={saving || index === items.length - 1}
onClick={() => setItems((current) => moveOrderItem(current, item.id, index + 1))}
/>
</div>
))
)}
</div>
</Spin>
</Modal>
</>
);
}

View File

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

View File

@@ -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<ReturnType<typeof getAuthSettings>>;
@@ -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<HTMLFormElement>) => {
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 (
<form onSubmit={submit} className="space-y-4">
{settings?.firstUser ? <Notice icon={<Info className="size-3.5" />} tone="blue">首个账号自动成为管理员,邮箱验证码暂不要求。</Notice> : null}
{registrationClosed ? <Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">当前已关闭普通注册,请联系管理员创建账号。</Notice> : null}
{mailUnavailable ? <Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">管理员尚未配置注册邮件,普通邮箱注册暂不可用。</Notice> : null}
{settings?.firstUser ? (
<Notice icon={<Info className="size-3.5" />} tone="blue">
首个账号自动成为管理员,邮箱验证码暂不要求。
</Notice>
) : null}
{registrationClosed ? (
<Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">
当前已关闭普通注册,请联系管理员创建账号。
</Notice>
) : null}
{mailUnavailable ? (
<Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">
管理员尚未配置注册邮件,普通邮箱注册暂不可用。
</Notice>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<AuthField label="用户名"><Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字符" autoComplete="username" required disabled={disabled} /></AuthField>
<AuthField label="显示名称"><Input size="large" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="不填则使用用户名" disabled={disabled} /></AuthField>
<AuthField label="用户名">
<Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字符" autoComplete="username" required disabled={disabled} />
</AuthField>
<AuthField label="显示名称">
<Input size="large" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="不填则使用用户名" disabled={disabled} />
</AuthField>
</div>
<AuthField label="邮箱"><Input size="large" prefix={<Mail className="size-4 text-white/35" />} value={email} onChange={(event) => setEmail(event.target.value)} placeholder="用于登录与安全验证" autoComplete="email" required={!settings?.firstUser} disabled={disabled} /></AuthField>
<AuthField label="邮箱">
<Input
size="large"
prefix={<Mail className="size-4 text-white/35" />}
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="用于登录与安全验证"
autoComplete="email"
required={!settings?.firstUser}
disabled={disabled}
/>
</AuthField>
{requireCode ? (
<AuthField label="邮箱验证码">
<div className="grid grid-cols-[minmax(0,1fr)_116px] gap-2">
<Input size="large" prefix={<ShieldCheck className="size-4 text-white/35" />} 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} />
<Button size="large" loading={sendingCode} disabled={disabled || countdown > 0} onClick={() => void sendCode()}>{countdown > 0 ? `${countdown}s` : "获取验证码"}</Button>
<Input
size="large"
prefix={<ShieldCheck className="size-4 text-white/35" />}
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}
/>
<Button size="large" loading={sendingCode} disabled={disabled || countdown > 0} onClick={() => void sendCode()}>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</Button>
</div>
</AuthField>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<AuthField label="密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={password} onChange={(event) => setPassword(event.target.value)} placeholder="至少 8 位" autoComplete="new-password" required disabled={disabled} /></AuthField>
<AuthField label="确认密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} placeholder="再次输入密码" autoComplete="new-password" required disabled={disabled} /></AuthField>
<AuthField label="密码">
<Input.Password
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 8 位"
autoComplete="new-password"
required
disabled={disabled}
/>
</AuthField>
<AuthField label="确认密码">
<Input.Password
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="再次输入密码"
autoComplete="new-password"
required
disabled={disabled}
/>
</AuthField>
</div>
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled} icon={<ArrowRight className="size-4" />} iconPlacement="end">创建账号</Button>
{settings?.linuxdoEnabled ? <><Divider plain className="!border-white/10 !text-white/30">或</Divider><Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>使用 Linux.do 注册 / 登录</Button></> : null}
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled || registerCountdown > 0} icon={<ArrowRight className="size-4" />} iconPlacement="end">
{registerCountdown > 0 ? `${registerCountdown} 秒后可重试` : "创建账号"}
</Button>
{settings?.linuxdoEnabled ? (
<>
<Divider plain className="!border-white/10 !text-white/30">
或
</Divider>
<Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>
使用 Linux.do 注册 / 登录
</Button>
</>
) : null}
</form>
);
}
function AuthField({ label, children }: { label: string; children: ReactNode }) {
return <label className="block space-y-2"><span className="text-xs font-medium text-white/62">{label}</span>{children}</label>;
return (
<label className="block space-y-2">
<span className="text-xs font-medium text-white/62">{label}</span>
{children}
</label>
);
}
function Notice({ icon, tone, children }: { icon: ReactNode; tone: "blue" | "amber"; children: ReactNode }) {
return <div className={`flex items-start gap-2 rounded-lg border px-3 py-2.5 text-xs leading-5 ${tone === "blue" ? "border-blue-300/15 bg-blue-300/[0.06] text-blue-100/78" : "border-amber-300/15 bg-amber-300/[0.06] text-amber-100/78"}`}><span className="mt-0.5 shrink-0">{icon}</span>{children}</div>;
return (
<div className={`flex items-start gap-2 rounded-lg border px-3 py-2.5 text-xs leading-5 ${tone === "blue" ? "border-blue-300/15 bg-blue-300/[0.06] text-blue-100/78" : "border-amber-300/15 bg-amber-300/[0.06] text-amber-100/78"}`}>
<span className="mt-0.5 shrink-0">{icon}</span>
{children}
</div>
);
}
function safeNext(value: string | null) {

View File

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

View File

@@ -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<string, string>;
resolution: string;
videoSeconds: number;
billingMode: "fixed_request" | "per_second" | "token";
unitPriceMicrocredits: number;
inputTokenPriceMicrocredits: number;
outputTokenPriceMicrocredits: number;
cachedTokenPriceMicrocredits: number;
selector: Record<string, string>;
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;

View File

@@ -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<ChannelModel, "modelKey" | "providerModelKey" | "capability" | "protocol"> & { 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)}`));
}

View File

@@ -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<string, string> = {
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<string, unknown>).choices ?? (rawOptions as Record<string, unknown>).options ?? (rawOptions as Record<string, unknown>).values)
: undefined;
? ((rawOptions as Record<string, unknown>).choices ?? (rawOptions as Record<string, unknown>).options ?? (rawOptions as Record<string, unknown>).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<keyof WorkflowFieldMapping> = [
"label", "enabled",
"randomEnabled", "source", "sourceAutomatic", "sourceFromUpstream", "sourceIndex",
"imageOrder", "required", "bindPrompt",
];
const policyKeys: Array<keyof WorkflowFieldMapping> = ["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<string, unknown> }, 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<string, unknown>;
}>;
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<ModelChannel>): 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) {

View File

@@ -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(
<App>
<ChannelOrderDialog onSaved={async () => {}} />
</App>,
);
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"]);
});