From 561104ae72811398e5c2c238bdf86d2ea1826421 Mon Sep 17 00:00:00 2001 From: esncy <60813831+esncy@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:31:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=B3=BB=E7=BB=9F=E6=B8=A0?= =?UTF-8?q?=E9=81=93=20-=20=E5=A2=9E=E5=8A=A0=E5=A4=8D=E5=88=B6=E6=B8=A0?= =?UTF-8?q?=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/app/admin.go | 87 ++++++++++++++++++ backend/internal/app/admin_channel_test.go | 89 +++++++++++++++++++ backend/internal/handler/auth.go | 13 +++ backend/internal/handler/openapi.yaml | 19 ++++ backend/internal/repository/finance.go | 19 ++++ docs/content/docs/backend/http-api.mdx | 4 + docs/content/docs/overview/features.mdx | 2 +- .../pages/admin/channels/channels-page.tsx | 20 ++++- web/src/services/api/auth.ts | 4 + 9 files changed, 254 insertions(+), 3 deletions(-) diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index 27696ca6b..3aad5ab6d 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -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 diff --git a/backend/internal/app/admin_channel_test.go b/backend/internal/app/admin_channel_test.go index 0d930c50a..a50650943 100644 --- a/backend/internal/app/admin_channel_test.go +++ b/backend/internal/app/admin_channel_test.go @@ -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{}) diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 3a0a384f7..3459bc712 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -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 { diff --git a/backend/internal/handler/openapi.yaml b/backend/internal/handler/openapi.yaml index efef2da17..820df3151 100644 --- a/backend/internal/handler/openapi.yaml +++ b/backend/internal/handler/openapi.yaml @@ -22,6 +22,7 @@ tags: - name: tasks - name: assets - name: agent + - name: admin paths: /agent/capabilities: get: @@ -185,6 +186,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] diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 6621d3584..24f10ddc5 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -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 { diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 63f333cad..098c6bf8d 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -49,6 +49,10 @@ Skills 的 `tool_completed` / `tool_failed` 事件附带 `skillId`、`skillName` 后台支付订单列表展示用户身份,点击名称打开用户详情;订单详情提供完整订单号、渠道交易号、用户 ID 和各阶段时间。同步支付状态与关闭订单位于更多菜单,关闭订单仍需确认。 +## 管理员系统渠道 + +`POST /api/admin/channels/:id/duplicate` 需要管理员权限,会复制指定系统渠道的连接配置、加密凭证、请求头、模型和价格档,生成新的渠道与模型 ID;复制渠道名称自动追加“ - 副本”。原渠道及其前台供应线路不受影响。 + ## 信封 成功: diff --git a/docs/content/docs/overview/features.mdx b/docs/content/docs/overview/features.mdx index da54f60c1..894219311 100644 --- a/docs/content/docs/overview/features.mdx +++ b/docs/content/docs/overview/features.mdx @@ -30,7 +30,7 @@ description: 当前项目已实现的核心工作台能力与使用入口 ## 系统渠道排序与前台别名 -后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。 +后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。 系统渠道可设置可选的前台别名;为空时显示渠道名,设置后公开目录和用户会话只返回别名,后台仍保留原名。别名最长 80 字,支持后台搜索。前台模型目录模式保留其独立配置。 diff --git a/web/src/pages/admin/channels/channels-page.tsx b/web/src/pages/admin/channels/channels-page.tsx index 9c2acf678..d263d5b4c 100644 --- a/web/src/pages/admin/channels/channels-page.tsx +++ b/web/src/pages/admin/channels/channels-page.tsx @@ -1,7 +1,7 @@ import { App, Button, Form, Input, InputNumber, Modal, Select, Switch } from "antd"; import type { FormInstance } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { Pencil, Plus, Power, Search, Trash2 } from "lucide-react"; +import { Copy, Pencil, Plus, Power, Search, Trash2 } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router"; @@ -9,7 +9,7 @@ import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/chann import { PaginationBar } from "@/components/layout/workspace-page"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { refreshSystemChannels } from "@/lib/user-session"; -import { createAdminChannel, deleteAdminChannel, listAdminChannels, updateAdminChannel } from "@/services/api/auth"; +import { createAdminChannel, deleteAdminChannel, duplicateAdminChannel, listAdminChannels, updateAdminChannel } from "@/services/api/auth"; import { type ChannelHeader, type ModelChannel } from "@/stores/use-config-store"; import { useAdminContext } from "../admin-context"; import { AdminPageFrame } from "../components/admin-shell"; @@ -58,6 +58,7 @@ export default function ChannelsPage() { const [drawerOpen, setDrawerOpen] = useState(false); const [editingChannel, setEditingChannel] = useState(null); const [saving, setSaving] = useState(false); + const [duplicatingChannelId, setDuplicatingChannelId] = useState(null); const [managingChannel, setManagingChannel] = useState(null); const requestSequence = useRef(0); const [form] = Form.useForm(); @@ -171,6 +172,20 @@ export default function ChannelsPage() { } }; + const duplicateChannel = async (channel: ModelChannel) => { + setDuplicatingChannelId(channel.id); + try { + await duplicateAdminChannel(channel.id); + await syncChannels(); + await reload(); + message.success("系统渠道已复制"); + } catch (error) { + message.error(error instanceof Error ? error.message : "复制系统渠道失败"); + } finally { + setDuplicatingChannelId(null); + } + }; + const removeChannel = async (channel: ModelChannel) => { try { await deleteAdminChannel(channel.id); @@ -207,6 +222,7 @@ export default function ChannelsPage() { primary={{ label: "模型管理", onClick: () => setManagingChannel(channel) }} actions={[ { key: "edit", label: "编辑", icon: , onClick: () => openDrawer(channel) }, + { key: "duplicate", label: "复制渠道", icon: , disabled: Boolean(duplicatingChannelId), onClick: () => duplicateChannel(channel) }, { key: "toggle", label: channel.enabled !== false ? "停用渠道" : "启用渠道", diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index dcf1d4cf1..72913f531 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -479,6 +479,10 @@ export function createAdminChannel(input: Partial & { useGlobalCon return http.post<{ channel: ModelChannel }>("/admin/channels", input); } +export function duplicateAdminChannel(id: string) { + return http.post<{ channel: ModelChannel }>(`/admin/channels/${encodeURIComponent(id)}/duplicate`); +} + export function updateAdminChannel(id: string, input: Partial & { useGlobalConcurrency?: boolean }) { return http.patch<{ channel: ModelChannel }>(`/admin/channels/${encodeURIComponent(id)}`, input); }