feat(admin): PR #497 - 合并系统渠道复制

This commit is contained in:
ddCat
2026-09-14 19:47:03 +08:00
9 changed files with 254 additions and 3 deletions

View File

@@ -539,6 +539,93 @@ func (s *Service) CreateSystemChannel(actor *model.User, req ChannelRequest) (*P
return &public, nil
}
func (s *Service) DuplicateSystemChannel(actor *model.User, id string) (*PublicModelChannel, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err
}
source, err := s.adminSystemChannel(id)
if err != nil {
return nil, err
}
sourceModels, err := s.repo.ChannelModels(source.ID, true)
if err != nil {
return nil, err
}
if len(sourceModels) == 0 {
for _, name := range channelModelNames(*source) {
sourceModels = append(sourceModels, model.ChannelModel{ModelKey: name, ProviderModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1})
}
}
channelID, err := s.repo.NextPrefixedID("CHANNEL")
if err != nil {
return nil, err
}
channel := *source
channel.ID = channelID
channel.UserID = actor.ID
channel.Scope = model.ChannelScopeSystem
channel.Name = duplicateChannelName(source.Name)
channel.CreatedAt = time.Time{}
channel.UpdatedAt = time.Time{}
channel.DeletedAt = gorm.DeletedAt{}
if err := s.encryptSystemChannelSecrets(&channel); err != nil {
return nil, err
}
channelModels := make([]model.ChannelModel, 0, len(sourceModels))
priceTiers := make([]model.ChannelModelPriceTier, 0)
for _, sourceModel := range sourceModels {
modelID, idErr := s.repo.NextPrefixedID("MODEL")
if idErr != nil {
return nil, idErr
}
channelModel := sourceModel
channelModel.ID = modelID
channelModel.ChannelID = channel.ID
channelModel.CreatedAt = time.Time{}
channelModel.UpdatedAt = time.Time{}
channelModel.DeletedAt = gorm.DeletedAt{}
channelModel.PriceTiers = nil
channelModels = append(channelModels, channelModel)
for _, sourceTier := range sourceModel.PriceTiers {
tierID, tierErr := s.repo.NextPrefixedID("PTIER")
if tierErr != nil {
return nil, tierErr
}
priceTier := sourceTier
priceTier.ID = tierID
priceTier.ChannelModelID = channelModel.ID
priceTier.Selector = nil
priceTier.CreatedAt = time.Time{}
priceTier.UpdatedAt = time.Time{}
priceTier.DeletedAt = gorm.DeletedAt{}
priceTiers = append(priceTiers, priceTier)
}
}
if err := s.repo.CreateDuplicatedSystemChannel(&channel, channelModels, priceTiers); err != nil {
return nil, err
}
s.invalidateRouteCatalog()
items, err := s.repo.ChannelModels(channel.ID, true)
if err != nil {
return nil, err
}
public := publicChannel(channel, true, items)
return &public, nil
}
func duplicateChannelName(name string) string {
const suffix = " - 副本"
base := []rune(strings.TrimSpace(name))
if len(base) == 0 {
base = []rune("系统渠道")
}
if len(base)+len([]rune(suffix)) > 80 {
base = base[:80-len([]rune(suffix))]
}
return string(base) + suffix
}
func (s *Service) UpdateSystemChannel(actor *model.User, id string, req ChannelRequest) (*PublicModelChannel, error) {
if err := s.RequireAdmin(actor); err != nil {
return nil, err

View File

@@ -78,6 +78,95 @@ func TestUpdateSystemChannelEnabledOnlySkipsOutboundResolution(t *testing.T) {
}
}
func TestDuplicateSystemChannelCopiesSecretsModelsAndPriceTiers(t *testing.T) {
svc, db := newChannelModelTestService(t)
svc.dataDir = t.TempDir()
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
source := model.ModelChannel{
ID: "channel-source",
UserID: admin.ID,
Scope: model.ChannelScopeSystem,
Enabled: false,
Name: "方舟视频",
PublicAlias: "视频主链路",
BaseURL: "https://example.com/v1",
APIKey: "source-key",
SecretKey: "source-secret",
APIFormat: "openai",
ConcurrencyLimit: 6,
ModelsJSON: `["seedance-2"]`,
HeadersJSON: `[{"name":"X-Tenant","value":"tenant-a"}]`,
}
if err := svc.encryptSystemChannelSecrets(&source); err != nil {
t.Fatal(err)
}
if err := db.Create(&source).Error; err != nil {
t.Fatal(err)
}
sourceModel := model.ChannelModel{
ID: "model-source",
ChannelID: source.ID,
ModelKey: "seedance-2",
ProviderModelKey: "seedance-2",
DisplayName: "Seedance 2",
SortOrder: 3,
Capability: "video",
Protocol: model.ChannelInterfaceVolcengineArkVideo,
BillingMode: "fixed_request",
UnitPriceMicrocredits: 120,
PriceConfigured: true,
Enabled: true,
PriceVersion: 4,
}
if err := db.Create(&sourceModel).Error; err != nil {
t.Fatal(err)
}
sourceTier := model.ChannelModelPriceTier{
ID: "tier-source",
ChannelModelID: sourceModel.ID,
SelectorKey: `{}`,
SelectorJSON: `{}`,
Resolution: "720p",
ProviderModelKey: "seedance-2",
BillingMode: "fixed_request",
UnitPriceMicrocredits: 120,
PriceConfigured: true,
Enabled: true,
}
if err := db.Create(&sourceTier).Error; err != nil {
t.Fatal(err)
}
copied, err := svc.DuplicateSystemChannel(admin, source.ID)
if err != nil {
t.Fatalf("DuplicateSystemChannel() error = %v", err)
}
if copied.Name != "方舟视频 - 副本" || copied.PublicAlias != source.PublicAlias || copied.Enabled != source.Enabled {
t.Fatalf("copied channel summary = %#v", copied)
}
var stored model.ModelChannel
if err := db.First(&stored, "id = ?", copied.ID).Error; err != nil {
t.Fatal(err)
}
if err := svc.decryptSystemChannelSecrets(&stored); err != nil {
t.Fatal(err)
}
if stored.APIKey != "source-key" || stored.SecretKey != "source-secret" || stored.HeadersJSON != source.HeadersJSON {
t.Fatalf("copied channel connection = %#v", stored)
}
models, err := svc.repo.ChannelModels(copied.ID, true)
if err != nil {
t.Fatal(err)
}
if len(models) != 1 || models[0].ID == sourceModel.ID || models[0].ModelKey != sourceModel.ModelKey || models[0].SortOrder != sourceModel.SortOrder {
t.Fatalf("copied models = %#v", models)
}
if len(models[0].PriceTiers) != 1 || models[0].PriceTiers[0].ID == sourceTier.ID || models[0].PriceTiers[0].UnitPriceMicrocredits != sourceTier.UnitPriceMicrocredits {
t.Fatalf("copied price tiers = %#v", models[0].PriceTiers)
}
}
func TestChannelFromRequestStoresAndClearsHeaders(t *testing.T) {
request := ChannelRequest{Name: "Headers", BaseURL: "https://example.com/v1", Headers: []OutboundHeader{{Name: "User-Agent", Value: "Custom Agent"}}}
channel, err := channelFromRequest(request, model.ModelChannel{})

View File

@@ -427,6 +427,19 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
}
ok(c, gin.H{"channel": channel})
})
r.POST("/admin/channels/:id/duplicate", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
channel, err := svc.DuplicateSystemChannel(user, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"channel": channel})
})
r.PATCH("/admin/channels/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {

View File

@@ -22,6 +22,7 @@ tags:
- name: tasks
- name: assets
- name: agent
- name: admin
paths:
/agent/capabilities:
get:
@@ -222,6 +223,24 @@ paths:
application/yaml:
schema:
type: string
/admin/channels/{id}/duplicate:
post:
tags: [admin]
summary: 复制系统渠道
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
"200":
$ref: "#/components/responses/Envelope"
"401":
$ref: "#/components/responses/Error"
"403":
$ref: "#/components/responses/Error"
"404":
$ref: "#/components/responses/Error"
/auth/session:
get:
tags: [auth]

View File

@@ -84,6 +84,25 @@ func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]mo
return items, r.attachChannelModelPriceTiers(pointers)
}
func (r *Repository) CreateDuplicatedSystemChannel(channel *model.ModelChannel, channelModels []model.ChannelModel, priceTiers []model.ChannelModelPriceTier) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(channel).Error; err != nil {
return err
}
if len(channelModels) > 0 {
if err := tx.Create(&channelModels).Error; err != nil {
return err
}
}
if len(priceTiers) > 0 {
if err := tx.Create(&priceTiers).Error; err != nil {
return err
}
}
return nil
})
}
func (r *Repository) ChannelModelByID(channelID string, id string) (*model.ChannelModel, error) {
var item model.ChannelModel
if err := r.db.First(&item, "id = ? AND channel_id = ?", id, channelID).Error; err != nil {