feat(channels): 渠道管理 - 可视化排序与别名并修复注册及外观清理 (#444)
This commit is contained in:
42
backend/internal/database/channel_presentation_test.go
Normal file
42
backend/internal/database/channel_presentation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
43
backend/internal/handler/channel_order.go
Normal file
43
backend/internal/handler/channel_order.go
Normal 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})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "请求过于频繁,请稍后重试")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
62
backend/internal/repository/channel_order.go
Normal file
62
backend/internal/repository/channel_order.go
Normal 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", ¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !slices.Equal(current, expected) || len(ids) != len(current) {
|
||||
return ErrChannelOrderChanged
|
||||
}
|
||||
allowed := make(map[string]bool, len(current))
|
||||
for _, id := range current {
|
||||
allowed[id] = true
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !allowed[id] {
|
||||
return ErrChannelOrderChanged
|
||||
}
|
||||
delete(allowed, id)
|
||||
}
|
||||
now := time.Now()
|
||||
for index, id := range ids {
|
||||
if err := tx.Model(target).Where("id = ?", id).Updates(map[string]any{"sort_order": index, "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if channelID != "" {
|
||||
return refreshChannelModelNames(tx, channelID, now)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
45
backend/internal/repository/channel_presentation.go
Normal file
45
backend/internal/repository/channel_presentation.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
backend/internal/service/channel_order.go
Normal file
63
backend/internal/service/channel_order.go
Normal 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
|
||||
}
|
||||
62
backend/internal/service/channel_order_test.go
Normal file
62
backend/internal/service/channel_order_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
72
backend/internal/service/channel_presentation.go
Normal file
72
backend/internal/service/channel_presentation.go
Normal 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
|
||||
}
|
||||
155
backend/internal/service/channel_presentation_test.go
Normal file
155
backend/internal/service/channel_presentation_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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("运行时协调器未初始化")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
9
backend/internal/service/email_cooldown.go
Normal file
9
backend/internal/service/email_cooldown.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
49
backend/internal/service/registration_limits_test.go
Normal file
49
backend/internal/service/registration_limits_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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{}{}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 重建目录项 |
|
||||
|
||||
|
||||
@@ -5,6 +5,16 @@ description: 当前项目已实现的核心工作台能力与使用入口
|
||||
|
||||
# 功能清单
|
||||
|
||||
## 系统渠道排序与前台别名
|
||||
|
||||
后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。
|
||||
|
||||
系统渠道可设置可选的前台别名;为空时显示渠道名,设置后公开目录和用户会话只返回别名,后台仍保留原名。别名最长 80 字,支持后台搜索。前台模型目录模式保留其独立配置。
|
||||
|
||||
注册默认每 IP 每小时允许 30 次注册请求、60 次验证码请求,单邮箱每小时另限 10 次且发送后间隔 60 秒。失败尝试也占请求额度,达到上限后的拒绝不会继续增加计数或延长窗口;限流响应携带真实剩余等待时间,注册页面显示倒计时并防止重复提交。已保存的运行策略仍需管理员按实际共用网络规模调整,不随默认值升级自动覆盖。
|
||||
|
||||
定时闲置资源清理与后台手动删除共同保护外观配置引用的浅色 Logo、深色 Logo、登录视频及封面;即使资源超过闲置期限也不会删除,外观配置无法读取时停止相关清理。
|
||||
|
||||
## 章节资产提取与任务浏览
|
||||
|
||||
画布视频支持鼠标悬停 350ms 后静音预览前 3 秒,不循环;全局仅一个悬停解码器,离开或操作画布立即停止并恢复封面。手动播放优先,系统开启减弱动态效果时不自动预览。
|
||||
|
||||
@@ -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 样式。
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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 ? "已配置,留空不修改" : "未配置";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
135
web/src/pages/admin/components/channel-order-dialog.tsx
Normal file
135
web/src/pages/admin/components/channel-order-dialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
5
web/src/services/api/channel-order.ts
Normal file
5
web/src/services/api/channel-order.ts
Normal 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 }));
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
55
web/test/channel-presentation.test.tsx
Normal file
55
web/test/channel-presentation.test.tsx
Normal 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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user