fix(platform): 渠道并发 - 支持后台热配置并等待空闲槽位 (#34)

Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
ddcat666
2026-07-23 18:35:51 +08:00
committed by GitHub
parent f26f9983bc
commit 93316dd3d7
25 changed files with 703 additions and 112 deletions

View File

@@ -1,4 +1,4 @@
import { App, Button, Drawer, Form, Input, Select, Switch, Table, Tag } from "antd";
import { App, Button, Drawer, Form, Input, InputNumber, Select, Switch, Table, Tag } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Pencil, Plus, Power, Search } from "lucide-react";
import { useEffect, useRef, useState } from "react";
@@ -14,7 +14,7 @@ import { AdminPageFrame } from "../components/admin-shell";
import { AdminRowActions, AdminTableEmpty, AdminTableSkeleton, configuredSecretText } from "../components/admin-ui";
import { ChannelModelManager } from "../components/channel-model-manager";
type ChannelFormValues = { name: string; baseUrl: string; apiKey?: string; interfaceType: ChannelInterfaceType; enabled?: boolean };
type ChannelFormValues = { name: string; baseUrl: string; apiKey?: string; interfaceType: ChannelInterfaceType; useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean };
const interfaceTypeOptions = [
{ label: "文本", options: [{ label: "Chat Completions", value: "chat-completion" }, { label: "OpenAI Responses", value: "openai-response" }] },
@@ -41,6 +41,7 @@ export default function ChannelsPage() {
const [managingChannel, setManagingChannel] = useState<ModelChannel | null>(null);
const requestSequence = useRef(0);
const [form] = Form.useForm<ChannelFormValues>();
const useGlobalConcurrency = Form.useWatch("useGlobalConcurrency", form) !== false;
const hasFilters = Boolean(keyword || interfaceType !== "all" || status !== "all");
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
@@ -85,7 +86,7 @@ export default function ChannelsPage() {
const openDrawer = (channel?: ModelChannel) => {
setEditingChannel(channel || null);
form.resetFields();
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, apiKey: "", interfaceType: channel.interfaceType || "newapi", enabled: channel.enabled !== false } : { name: "", baseUrl: "", apiKey: "", interfaceType: "newapi", enabled: true });
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, apiKey: "", interfaceType: channel.interfaceType || "newapi", useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false } : { name: "", baseUrl: "", apiKey: "", interfaceType: "newapi", useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true });
setDrawerOpen(true);
};
@@ -106,7 +107,7 @@ export default function ChannelsPage() {
}
setSaving(true);
try {
const payload = { name: values.name.trim(), baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", interfaceType: values.interfaceType, enabled: values.enabled !== false };
const payload = { name: values.name.trim(), baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", interfaceType: values.interfaceType, useGlobalConcurrency: values.useGlobalConcurrency !== false, concurrencyLimit: values.useGlobalConcurrency === false ? values.concurrencyLimit : undefined, enabled: values.enabled !== false };
await (editingChannel ? updateAdminChannel(editingChannel.id, payload) : createAdminChannel(payload));
await syncChannels();
setDrawerOpen(false);
@@ -136,6 +137,7 @@ export default function ChannelsPage() {
{ title: "渠道", dataIndex: "name", render: (_, channel) => <div><div className="font-medium">{channel.name}</div><div className="max-w-lg truncate text-xs text-foreground/45">{channel.baseUrl}</div></div> },
{ title: "接口类型", dataIndex: "interfaceType", width: 160, render: (value: ChannelInterfaceType) => <Tag bordered={false} color={value === "newapi-channel-1" ? "green" : value === "newapi" ? "orange" : value === "newapi-channel-2" ? "purple" : value === "xai-video" ? "cyan" : "blue"}>{interfaceTypeLabel(value)}</Tag> },
{ title: "模型", dataIndex: "models", width: 100, render: (models: string[]) => `${models?.length || 0} 个` },
{ title: "最大并发", dataIndex: "concurrencyLimit", width: 120, render: (value: number) => value > 0 ? value : <span className="text-foreground/45">跟随系统</span> },
{ title: "密钥", dataIndex: "hasApiKey", width: 100, render: (configured) => <Tag bordered={false} color={configured ? "success" : "default"}>{configured ? "已配置" : "未配置"}</Tag> },
{ title: "状态", dataIndex: "enabled", width: 100, render: (enabled) => <Tag bordered={false} color={enabled !== false ? "success" : "default"}>{enabled !== false ? "已启用" : "已停用"}</Tag> },
{ title: "操作", width: 160, fixed: "right", align: "right", 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) }]} /> },
@@ -153,7 +155,7 @@ export default function ChannelsPage() {
<Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value, page: 1 })} options={[{ label: "全部状态", value: "all" }, { label: "已启用", value: "enabled" }, { label: "已停用", value: "disabled" }]} />
</ListToolbar>
<TableSurface>
{loading && channels.length === 0 ? <AdminTableSkeleton rows={8} columns={6} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={channels} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有系统渠道"} description={hasFilters ? undefined : "创建渠道并配置模型后,普通用户即可使用系统模型。"} action={hasFilters ? undefined : <Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>} /> }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 860 }} />}
{loading && channels.length === 0 ? <AdminTableSkeleton rows={8} columns={7} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={channels} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有系统渠道"} description={hasFilters ? undefined : "创建渠道并配置模型后,普通用户即可使用系统模型。"} action={hasFilters ? undefined : <Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>} /> }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 970 }} />}
</TableSurface>
<Drawer title={editingChannel ? "编辑系统渠道" : "新增系统渠道"} open={drawerOpen} width="min(560px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
<Form form={form} layout="vertical" requiredMark={false}>
@@ -161,6 +163,8 @@ export default function ChannelsPage() {
<Form.Item name="interfaceType" label="接口类型" rules={[{ required: true, message: "请选择接口类型" }]} extra="按生成能力选择实际上游协议;系统渠道统一使用 Bearer 鉴权。"><Select options={interfaceTypeOptions} onChange={(value: ChannelInterfaceType) => { const current = String(form.getFieldValue("baseUrl") || "").trim(); if (!current || current === defaultBaseUrlForChannelInterface()) form.setFieldValue("baseUrl", defaultBaseUrlForChannelInterface(value)); }} /></Form.Item>
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder="填写渠道 Base URL" /></Form.Item>
<Form.Item name="apiKey" label={editingChannel ? `API Key(${configuredSecretText})` : "API Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key" }]}><Input.Password placeholder={editingChannel ? "留空保留原密钥" : "系统渠道密钥"} /></Form.Item>
<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: 100, message: "请输入 1-100 的整数" }]}><InputNumber className="w-full" min={1} max={100} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-100"} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Drawer>

View File

@@ -1,5 +1,5 @@
import { Tooltip } from "antd";
import { BarChart3, BellRing, Coins, FileClock, HardDrive, Home, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
import { BarChart3, BellRing, Coins, FileClock, Gauge, HardDrive, Home, Mail, MessageSquareText, PanelLeftClose, PanelLeftOpen, RadioTower, ShieldCheck, TicketCheck, UsersRound } from "lucide-react";
import { useState, type ReactNode } from "react";
import { NavLink, Outlet } from "react-router";
@@ -40,6 +40,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{
label: "系统配置",
items: [
{ path: "/admin/settings/concurrency", label: "任务并发", description: "Worker 与渠道请求调度", icon: <Gauge className="size-4" />, secondary: true },
{ path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: <ShieldCheck className="size-4" />, secondary: true },
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" />, secondary: true },
{ path: "/admin/settings/storage", label: "存储服务", description: "OSS 与资源存储", icon: <HardDrive className="size-4" />, secondary: true },

View File

@@ -33,6 +33,7 @@ export function ApiLogDetailDrawer({ logId, onClose }: { logId: string | null; o
["方法与路径", `${log.method} ${log.path}`],
["HTTP 状态", String(log.statusCode || "--")],
["耗时", `${log.durationMs} ms`],
["渠道并发上限", log.concurrencyLimit ? String(log.concurrencyLimit) : "--"],
["Token", log.usageAvailable ? `${log.inputTokens} 输入 / ${log.outputTokens} 输出 / ${log.cachedTokens} 缓存` : "未返回"],
["错误码", log.errorCode || "--"],
["错误详情", log.error || "--"],

View File

@@ -0,0 +1,73 @@
import { App, Button, Form, InputNumber, Tag } from "antd";
import { Gauge, RadioTower, Workflow } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { getAdminRuntimeConcurrencySetting, updateAdminRuntimeConcurrencySetting, type RuntimeConcurrencySetting } from "@/services/api/auth";
import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { SettingsSectionCard } from "../components/admin-ui";
type ConcurrencyFormValues = Pick<RuntimeConcurrencySetting, "workerConcurrency" | "channelConcurrency">;
export default function ConcurrencySettingsPage() {
const { message } = App.useApp();
const { references } = useAdminContext();
const [setting, setSetting] = useState<RuntimeConcurrencySetting | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<ConcurrencyFormValues>();
const userNameById = useMemo(() => new Map(references.users.map((user) => [user.id, user.displayName || user.username])), [references.users]);
useEffect(() => {
void getAdminRuntimeConcurrencySetting()
.then(({ setting: value }) => {
setSetting(value);
form.setFieldsValue(value);
})
.catch((error) => message.error(error instanceof Error ? error.message : "读取任务并发配置失败"))
.finally(() => setLoading(false));
}, [form, message]);
const save = async () => {
const values = await form.validateFields();
setSaving(true);
try {
const result = await updateAdminRuntimeConcurrencySetting(values);
setSetting(result.setting);
form.setFieldsValue(result.setting);
message.success("任务并发配置已生效");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存任务并发配置失败");
} finally {
setSaving(false);
}
};
return (
<AdminPageFrame title="任务并发" description="Worker 与渠道请求调度">
<div className="mx-auto max-w-5xl space-y-5">
<SettingsSectionCard
icon={<Gauge className="size-4" />}
title="运行时并发"
description="控制任务消费速度和未单独配置渠道的默认请求并发。"
status={<Tag bordered={false} color="blue">热更新</Tag>}
footer={<><div className="text-xs text-foreground/45">{setting?.updatedAt ? `上次更新:${formatTime(setting.updatedAt)}${setting.updatedBy ? ` · ${userNameById.get(setting.updatedBy) || setting.updatedBy}` : ""}` : "当前使用环境变量默认值"}</div><Button type="primary" loading={saving} onClick={() => void save()}>保存并发配置</Button></>}
>
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
<div className="grid grid-cols-1 gap-x-5 px-5 pt-5 md:grid-cols-2">
<Form.Item name="workerConcurrency" label="Worker 并发数" extra="控制集群同时执行的后台任务数。" rules={concurrencyRules("Worker 并发数")}><InputNumber className="w-full" min={1} max={100} precision={0} prefix={<Workflow className="size-3.5 text-foreground/40" />} /></Form.Item>
<Form.Item name="channelConcurrency" label="全局渠道并发数" extra="渠道选择“跟随系统”时使用;自定义渠道也使用该值。" rules={concurrencyRules("全局渠道并发数")}><InputNumber className="w-full" min={1} max={100} precision={0} prefix={<RadioTower className="size-3.5 text-foreground/40" />} /></Form.Item>
</div>
</Form>
</SettingsSectionCard>
<div className="rounded-lg border border-border bg-muted/25 px-4 py-3 text-xs leading-6 text-foreground/55">任务可以继续进入队列;达到渠道上限时会等待空闲槽位,不会因暂时满载直接失败。单独配置的渠道上限优先于这里的全局值。</div>
</div>
</AdminPageFrame>
);
}
function concurrencyRules(label: string) {
return [{ required: true, message: `请填写${label}` }, { type: "number" as const, min: 1, max: 100, message: `${label}必须是 1-100 的整数` }];
}
function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }

View File

@@ -7,6 +7,7 @@ import { AccessSettingsPage, AnalyticsPage, AnnouncementsPage, CreditOperationsP
import ChannelsPage from "@/pages/admin/channels/channels-page";
import LogsPage from "@/pages/admin/logs/logs-page";
import RedemptionCodesPage from "@/pages/admin/redemption-codes/redemption-codes-page";
import ConcurrencySettingsPage from "@/pages/admin/settings/concurrency-settings-page";
import StorageSettingsPage from "@/pages/admin/settings/storage-settings-page";
import StoryboardPromptsPage from "@/pages/admin/storyboard-prompts/storyboard-prompts-page";
import UsersPage from "@/pages/admin/users/users-page";
@@ -61,7 +62,8 @@ export const router = createBrowserRouter([
{ path: "credit-operations", element: <CreditOperationsPage /> },
{ path: "redemption-codes", element: <RedemptionCodesPage /> },
{ path: "logs", element: <LogsPage /> },
{ path: "settings", element: <Navigate to="access" replace /> },
{ path: "settings", element: <Navigate to="concurrency" replace /> },
{ path: "settings/concurrency", element: <ConcurrencySettingsPage /> },
{ path: "settings/access", element: <AccessSettingsPage /> },
{ path: "settings/email", element: <EmailSettingsPage /> },
{ path: "settings/storage", element: <StorageSettingsPage /> },

View File

@@ -61,6 +61,7 @@ export type ApiCallLog = {
currency?: string;
errorCode?: string;
error?: string;
concurrencyLimit: number;
upstreamUrl: string;
createdAt: string;
};
@@ -196,6 +197,14 @@ export type AdminOSSSetting = {
updatedAt?: string;
};
export type RuntimeConcurrencySetting = {
workerConcurrency: number;
channelConcurrency: number;
updatedBy?: string;
createdAt?: string;
updatedAt?: string;
};
type BackendEnvelope<T> = { code: number; data: T; msg: string };
async function request<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
@@ -284,11 +293,11 @@ export function listAdminChannels(params: AdminListParams = {}) {
return request<{ channels: ModelChannel[]; total: number; page: number; limit: number }>(api.get("/admin/channels", { params }));
}
export function createAdminChannel(input: Partial<ModelChannel>) {
export function createAdminChannel(input: Partial<ModelChannel> & { useGlobalConcurrency?: boolean }) {
return request<{ channel: ModelChannel }>(api.post("/admin/channels", input));
}
export function updateAdminChannel(id: string, input: Partial<ModelChannel>) {
export function updateAdminChannel(id: string, input: Partial<ModelChannel> & { useGlobalConcurrency?: boolean }) {
return request<{ channel: ModelChannel }>(api.patch(`/admin/channels/${encodeURIComponent(id)}`, input));
}
@@ -320,6 +329,14 @@ export function updateAdminOSSSetting(input: Partial<AdminOSSSetting>) {
return request<{ setting: AdminOSSSetting }>(api.patch("/admin/settings/oss", input));
}
export function getAdminRuntimeConcurrencySetting() {
return request<{ setting: RuntimeConcurrencySetting }>(api.get("/admin/settings/concurrency"));
}
export function updateAdminRuntimeConcurrencySetting(input: Pick<RuntimeConcurrencySetting, "workerConcurrency" | "channelConcurrency">) {
return request<{ setting: RuntimeConcurrencySetting }>(api.patch("/admin/settings/concurrency", input));
}
export function listAdminApiLogs(params: AdminListParams = {}) {
return request<{ logs: ApiCallLog[]; total: number; page: number; limit: number }>(api.get("/admin/api-logs", { params }));
}

View File

@@ -20,6 +20,7 @@ export type ModelChannel = {
scope?: "system" | "user";
enabled?: boolean;
hasApiKey?: boolean;
concurrencyLimit?: number;
modelCosts?: Array<{
model: string;
capability: ModelCapability;