feat(admin): PR #497 - 合并系统渠道复制
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -53,6 +53,10 @@ Skills 的 `tool_completed` / `tool_failed` 事件附带 `skillId`、`skillName`
|
||||
|
||||
后台支付订单列表展示用户身份,点击名称打开用户详情;订单详情提供完整订单号、渠道交易号、用户 ID 和各阶段时间。同步支付状态与关闭订单位于更多菜单,关闭订单仍需确认。
|
||||
|
||||
## 管理员系统渠道
|
||||
|
||||
`POST /api/admin/channels/:id/duplicate` 需要管理员权限,会复制指定系统渠道的连接配置、加密凭证、请求头、模型和价格档,生成新的渠道与模型 ID;复制渠道名称自动追加“ - 副本”。原渠道及其前台供应线路不受影响。
|
||||
|
||||
## 信封
|
||||
|
||||
成功:
|
||||
|
||||
@@ -30,7 +30,7 @@ description: 当前项目已实现的核心工作台能力与使用入口
|
||||
|
||||
## 系统渠道排序与前台别名
|
||||
|
||||
后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。
|
||||
后台系统渠道及渠道模型列表通过“设置排序”打开完整列表,支持拖动、上移和下移,点击“保存排序”统一生效;取消不会修改线上顺序。渠道行的“更多”菜单支持复制渠道,复制会生成独立的渠道、模型和价格档,并保留原连接配置与凭证。界面不显示内部排序数字,排序不受筛选及分页影响;并发修改会提示重新加载,避免覆盖其他管理员的调整。系统渠道模式下,用户端模型选择器沿用同一渠道及模型顺序。模型排序独立保存,不改变价格、能力版本或启用状态。
|
||||
|
||||
系统渠道可设置可选的前台别名;为空时显示渠道名,设置后公开目录和用户会话只返回别名,后台仍保留原名。别名最长 80 字,支持后台搜索。前台模型目录模式保留其独立配置。
|
||||
|
||||
|
||||
@@ -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<ModelChannel | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [duplicatingChannelId, setDuplicatingChannelId] = useState<string | null>(null);
|
||||
const [managingChannel, setManagingChannel] = useState<ModelChannel | null>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const [form] = Form.useForm<ChannelFormValues>();
|
||||
@@ -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: <Pencil className="size-3.5" />, onClick: () => openDrawer(channel) },
|
||||
{ key: "duplicate", label: "复制渠道", icon: <Copy className="size-3.5" />, disabled: Boolean(duplicatingChannelId), onClick: () => duplicateChannel(channel) },
|
||||
{
|
||||
key: "toggle",
|
||||
label: channel.enabled !== false ? "停用渠道" : "启用渠道",
|
||||
|
||||
@@ -479,6 +479,10 @@ export function createAdminChannel(input: Partial<ModelChannel> & { 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<ModelChannel> & { useGlobalConcurrency?: boolean }) {
|
||||
return http.patch<{ channel: ModelChannel }>(`/admin/channels/${encodeURIComponent(id)}`, input);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user