From 0dd3b72edd10d8fdf5957fc7cf9e6ec7397360d0 Mon Sep 17 00:00:00 2001 From: lsy Date: Sun, 22 Feb 2026 23:10:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(sites):=20=E6=96=B0=E5=A2=9E=E7=AB=99?= =?UTF-8?q?=E7=82=B9=E7=AE=A1=E7=90=86=E4=B8=8E=E8=B0=83=E7=94=A8=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端替换为 SitesView,支持站点类型/令牌/签到管理 - 后端新增 sites 接口、call token 表与元数据解析 --- README.md | 2 - apps/ui/src/App.tsx | 459 +++++------ apps/ui/src/core/constants.ts | 38 +- apps/ui/src/core/types.ts | 79 +- apps/ui/src/features/AppLayout.tsx | 40 +- apps/ui/src/features/ChannelsView.tsx | 438 +---------- apps/ui/src/features/CheckinSitesView.tsx | 483 ------------ apps/ui/src/features/SitesView.tsx | 740 ++++++++++++++++++ apps/ui/src/features/TokensView.tsx | 4 +- .../migrations/0002_add_checkin_sites.sql | 24 +- apps/worker/src/db/schema.sql | 25 +- apps/worker/src/index.ts | 4 +- apps/worker/src/routes/channels.ts | 8 + apps/worker/src/routes/checkin-sites.ts | 156 ---- apps/worker/src/routes/newapiChannels.ts | 8 + apps/worker/src/routes/proxy.ts | 33 +- apps/worker/src/routes/settings.ts | 8 +- apps/worker/src/routes/sites.ts | 488 ++++++++++++ .../src/services/call-token-selector.ts | 15 + .../src/services/channel-call-token-repo.ts | 81 ++ .../src/services/channel-call-token-types.ts | 8 + apps/worker/src/services/channel-repo.ts | 69 +- apps/worker/src/services/channel-types.ts | 8 + apps/worker/src/services/checkin-site-repo.ts | 156 ---- .../worker/src/services/checkin-site-types.ts | 17 - apps/worker/src/services/checkin.ts | 51 +- apps/worker/src/services/settings.ts | 3 +- apps/worker/src/services/site-metadata.ts | 87 ++ helloagents/CHANGELOG.md | 54 ++ helloagents/INDEX.md | 6 +- helloagents/modules/_index.md | 4 +- helloagents/modules/admin-ui.md | 13 +- helloagents/modules/channels.md | 3 + helloagents/modules/checkin.md | 14 +- helloagents/modules/proxy.md | 2 + helloagents/modules/sites.md | 29 + 36 files changed, 2013 insertions(+), 1644 deletions(-) delete mode 100644 apps/ui/src/features/CheckinSitesView.tsx create mode 100644 apps/ui/src/features/SitesView.tsx delete mode 100644 apps/worker/src/routes/checkin-sites.ts create mode 100644 apps/worker/src/routes/sites.ts create mode 100644 apps/worker/src/services/call-token-selector.ts create mode 100644 apps/worker/src/services/channel-call-token-repo.ts create mode 100644 apps/worker/src/services/channel-call-token-types.ts delete mode 100644 apps/worker/src/services/checkin-site-repo.ts delete mode 100644 apps/worker/src/services/checkin-site-types.ts create mode 100644 apps/worker/src/services/site-metadata.ts create mode 100644 helloagents/modules/sites.md diff --git a/README.md b/README.md index 0c5fc7d..034451a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ - `apps/worker` Cloudflare Worker (Hono) - `apps/ui` 管理台 (Vite) -- `tests` 基础单元测试 ## Action 自动部署流程(GitHub Actions) @@ -181,4 +180,3 @@ bun run --filter api-worker db:migrate - `ALL /v1/*` 转发到渠道 `base_url + 请求路径` - 当请求路径为 `/v1/responses` 且上游 400/404 时自动回退到 `/responses` - 流式请求会自动补齐 `stream_options.include_usage = true` 以收集用量 - diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx index 1c5571e..8f2b7c4 100644 --- a/apps/ui/src/App.tsx +++ b/apps/ui/src/App.tsx @@ -8,34 +8,31 @@ import { } from "hono/jsx/dom"; import { createApiFetch } from "./core/api"; import { - initialChannelForm, - initialCheckinSiteForm, initialData, initialSettingsForm, + initialSiteForm, tabs, } from "./core/constants"; import type { AdminData, - Channel, - ChannelForm, - CheckinSite, - CheckinSiteForm, CheckinSummary, DashboardData, Settings, SettingsForm, + Site, + SiteForm, + SiteType, TabId, Token, UsageLog, } from "./core/types"; import { toggleStatus } from "./core/utils"; import { AppLayout } from "./features/AppLayout"; -import { ChannelsView } from "./features/ChannelsView"; -import { CheckinSitesView } from "./features/CheckinSitesView"; import { DashboardView } from "./features/DashboardView"; import { LoginView } from "./features/LoginView"; import { ModelsView } from "./features/ModelsView"; import { SettingsView } from "./features/SettingsView"; +import { SitesView } from "./features/SitesView"; import { TokensView } from "./features/TokensView"; import { UsageView } from "./features/UsageView"; @@ -58,7 +55,6 @@ const tabToPath: Record = { models: "/models", tokens: "/tokens", usage: "/usage", - "checkin-sites": "/checkin-sites", settings: "/settings", }; @@ -68,10 +64,15 @@ const pathToTab: Record = { "/models": "models", "/tokens": "tokens", "/usage": "usage", - "/checkin-sites": "checkin-sites", "/settings": "settings", }; +const DEFAULT_BASE_URL_BY_TYPE: Partial> = { + chatgpt: "https://api.openai.com", + claude: "https://api.anthropic.com", + gemini: "https://generativelanguage.googleapis.com", +}; + /** * Renders the admin console application. * @@ -94,22 +95,16 @@ const App = () => { const [data, setData] = useState(initialData); const [settingsForm, setSettingsForm] = useState(initialSettingsForm); - const [channelPage, setChannelPage] = useState(1); - const [channelPageSize, setChannelPageSize] = useState(10); + const [sitePage, setSitePage] = useState(1); + const [sitePageSize, setSitePageSize] = useState(10); const [tokenPage, setTokenPage] = useState(1); const [tokenPageSize, setTokenPageSize] = useState(10); - const [editingChannel, setEditingChannel] = useState(null); - const [channelForm, setChannelForm] = useState(() => ({ - ...initialChannelForm, + const [editingSite, setEditingSite] = useState(null); + const [siteForm, setSiteForm] = useState(() => ({ + ...initialSiteForm, })); - const [editingCheckinSite, setEditingCheckinSite] = - useState(null); - const [checkinSiteForm, setCheckinSiteForm] = useState(() => ({ - ...initialCheckinSiteForm, - })); - const [isChannelModalOpen, setChannelModalOpen] = useState(false); + const [isSiteModalOpen, setSiteModalOpen] = useState(false); const [isTokenModalOpen, setTokenModalOpen] = useState(false); - const [isCheckinModalOpen, setCheckinModalOpen] = useState(false); const [checkinSummary, setCheckinSummary] = useState( null, ); @@ -134,9 +129,14 @@ const App = () => { setData((prev) => ({ ...prev, dashboard })); }, [apiFetch]); - const loadChannels = useCallback(async () => { - const result = await apiFetch<{ channels: Channel[] }>("/api/channels"); - setData((prev) => ({ ...prev, channels: result.channels })); + const loadSites = useCallback(async () => { + const result = await apiFetch<{ + sites: Site[]; + }>("/api/sites"); + setData((prev) => ({ + ...prev, + sites: result.sites, + })); }, [apiFetch]); const loadModels = useCallback(async () => { @@ -164,13 +164,6 @@ const App = () => { setData((prev) => ({ ...prev, settings })); }, [apiFetch]); - const loadCheckinSites = useCallback(async () => { - const result = await apiFetch<{ sites: CheckinSite[] }>( - "/api/checkin-sites", - ); - setData((prev) => ({ ...prev, checkinSites: result.sites })); - }, [apiFetch]); - const loadTab = useCallback( async (tabId: TabId) => { setLoading(true); @@ -180,7 +173,7 @@ const App = () => { await loadDashboard(); } if (tabId === "channels") { - await loadChannels(); + await loadSites(); } if (tabId === "models") { await loadModels(); @@ -194,24 +187,13 @@ const App = () => { if (tabId === "settings") { await loadSettings(); } - if (tabId === "checkin-sites") { - await loadCheckinSites(); - } } catch (error) { setNotice((error as Error).message); } finally { setLoading(false); } }, - [ - loadChannels, - loadDashboard, - loadModels, - loadSettings, - loadCheckinSites, - loadTokens, - loadUsage, - ], + [loadDashboard, loadSites, loadModels, loadSettings, loadTokens, loadUsage], ); useEffect(() => { @@ -271,8 +253,21 @@ const App = () => { updateToken(null); }, [apiFetch, updateToken]); - const handleChannelFormChange = useCallback((patch: Partial) => { - setChannelForm((prev) => ({ ...prev, ...patch })); + const handleSiteFormChange = useCallback((patch: Partial) => { + setSiteForm((prev) => { + const next = { ...prev, ...patch }; + if ( + patch.site_type && + (!patch.base_url || patch.base_url.trim().length === 0) && + !prev.base_url.trim() + ) { + const fallback = DEFAULT_BASE_URL_BY_TYPE[patch.site_type]; + if (fallback) { + next.base_url = fallback; + } + } + return next; + }); }, []); const handleSettingsFormChange = useCallback( @@ -282,20 +277,13 @@ const App = () => { [], ); - const handleCheckinFormChange = useCallback( - (patch: Partial) => { - setCheckinSiteForm((prev) => ({ ...prev, ...patch })); - }, - [], - ); - - const handleChannelPageChange = useCallback((next: number) => { - setChannelPage(next); + const handleSitePageChange = useCallback((next: number) => { + setSitePage(next); }, []); - const handleChannelPageSizeChange = useCallback((next: number) => { - setChannelPageSize(next); - setChannelPage(1); + const handleSitePageSizeChange = useCallback((next: number) => { + setSitePageSize(next); + setSitePage(1); }, []); const handleTokenPageChange = useCallback((next: number) => { @@ -316,16 +304,16 @@ const App = () => { setActiveTab(tabId); }, []); - const closeChannelModal = useCallback(() => { - setEditingChannel(null); - setChannelForm({ ...initialChannelForm }); - setChannelModalOpen(false); + const closeSiteModal = useCallback(() => { + setEditingSite(null); + setSiteForm({ ...initialSiteForm }); + setSiteModalOpen(false); }, []); - const openChannelCreate = useCallback(() => { - setEditingChannel(null); - setChannelForm({ ...initialChannelForm }); - setChannelModalOpen(true); + const openSiteCreate = useCallback(() => { + setEditingSite(null); + setSiteForm({ ...initialSiteForm }); + setSiteModalOpen(true); setNotice(""); }, []); @@ -334,22 +322,46 @@ const App = () => { setNotice(""); }, []); - const openCheckinCreate = useCallback(() => { - setEditingCheckinSite(null); - setCheckinSiteForm({ ...initialCheckinSiteForm }); - setCheckinModalOpen(true); - setNotice(""); - }, []); - - const startChannelEdit = useCallback((channel: Channel) => { - setEditingChannel(channel); - setChannelForm({ - name: channel.name ?? "", - base_url: channel.base_url ?? "", - api_key: channel.api_key ?? "", - weight: channel.weight ?? 1, + const startSiteEdit = useCallback((site: Site) => { + setEditingSite(site); + const callTokens = + site.call_tokens && site.call_tokens.length > 0 + ? site.call_tokens + : site.api_key + ? [ + { + id: "", + name: "主调用令牌", + api_key: site.api_key, + }, + ] + : []; + const tokenForms = + callTokens.length > 0 + ? callTokens.map((token) => ({ + id: token.id, + name: token.name, + api_key: token.api_key, + })) + : [ + { + name: "主调用令牌", + api_key: "", + }, + ]; + setSiteForm({ + name: site.name ?? "", + base_url: site.base_url ?? "", + weight: site.weight ?? 1, + status: site.status ?? "active", + site_type: site.site_type ?? "new-api", + checkin_url: site.checkin_url ?? "", + system_token: site.system_token ?? "", + system_userid: site.system_userid ?? "", + checkin_enabled: Boolean(site.checkin_enabled ?? false), + call_tokens: tokenForms, }); - setChannelModalOpen(true); + setSiteModalOpen(true); setNotice(""); }, []); @@ -357,72 +369,77 @@ const App = () => { setTokenModalOpen(false); }, []); - const closeCheckinModal = useCallback(() => { - setCheckinModalOpen(false); - }, []); - - const startCheckinEdit = useCallback((site: CheckinSite) => { - setEditingCheckinSite(site); - setCheckinSiteForm({ - name: site.name ?? "", - base_url: site.base_url ?? "", - checkin_url: site.checkin_url ?? "", - token: site.token ?? "", - userid: site.userid ?? "", - status: site.status ?? "active", - }); - setCheckinModalOpen(true); - setNotice(""); - }, []); - - const handleChannelSubmit = useCallback( + const handleSiteSubmit = useCallback( async (event: Event) => { event.preventDefault(); - const channelName = channelForm.name.trim(); - const normalizedName = channelName.toLowerCase(); - const nameExists = data.channels.some( - (channel) => - channel.name.trim().toLowerCase() === normalizedName && - channel.id !== editingChannel?.id, + const siteName = siteForm.name.trim(); + const normalizedName = siteName.toLowerCase(); + const nameExists = data.sites.some( + (site) => + site.name.trim().toLowerCase() === normalizedName && + site.id !== editingSite?.id, ); if (nameExists) { - setNotice("渠道名称已存在,请使用其他名称"); + setNotice("站点名称已存在,请使用其他名称"); + return; + } + const baseUrlValue = siteForm.base_url.trim(); + if (!baseUrlValue && !DEFAULT_BASE_URL_BY_TYPE[siteForm.site_type]) { + setNotice("基础 URL 不能为空"); + return; + } + const callTokens = siteForm.call_tokens + .map((token, index) => ({ + id: token.id, + name: token.name.trim() || `调用令牌${index + 1}`, + api_key: token.api_key.trim(), + })) + .filter((token) => token.api_key.length > 0); + if (callTokens.length === 0) { + setNotice("至少填写一个调用令牌"); + return; + } + if ( + siteForm.site_type === "new-api" && + siteForm.checkin_enabled && + (!siteForm.system_token.trim() || !siteForm.system_userid.trim()) + ) { + setNotice("启用签到需要填写系统令牌与 User ID"); return; } try { const body = { - name: channelName, - base_url: channelForm.base_url.trim(), - api_key: channelForm.api_key.trim(), - weight: Number(channelForm.weight), + name: siteName, + base_url: baseUrlValue, + weight: Number(siteForm.weight), + status: siteForm.status, + site_type: siteForm.site_type, + system_token: siteForm.system_token.trim(), + system_userid: siteForm.system_userid.trim(), + checkin_url: siteForm.checkin_url.trim() || null, + checkin_enabled: siteForm.checkin_enabled, + call_tokens: callTokens, }; - if (editingChannel) { - await apiFetch(`/api/channels/${editingChannel.id}`, { + if (editingSite) { + await apiFetch(`/api/sites/${editingSite.id}`, { method: "PATCH", body: JSON.stringify(body), }); - setNotice("渠道已更新"); + setNotice("站点已更新"); } else { - await apiFetch("/api/channels", { + await apiFetch("/api/sites", { method: "POST", body: JSON.stringify(body), }); - setNotice("渠道已创建"); + setNotice("站点已创建"); } - closeChannelModal(); - await loadChannels(); + closeSiteModal(); + await loadSites(); } catch (error) { setNotice((error as Error).message); } }, - [ - apiFetch, - channelForm, - closeChannelModal, - data.channels, - editingChannel, - loadChannels, - ], + [apiFetch, siteForm, closeSiteModal, data.sites, editingSite, loadSites], ); const handleTokenSubmit = useCallback( @@ -456,47 +473,6 @@ const App = () => { [apiFetch, loadTokens], ); - const handleCheckinSubmit = useCallback( - async (event: Event) => { - event.preventDefault(); - const payload = { - name: checkinSiteForm.name.trim(), - base_url: checkinSiteForm.base_url.trim(), - checkin_url: checkinSiteForm.checkin_url.trim() || null, - token: checkinSiteForm.token.trim(), - userid: checkinSiteForm.userid.trim(), - status: checkinSiteForm.status, - }; - try { - if (editingCheckinSite) { - await apiFetch(`/api/checkin-sites/${editingCheckinSite.id}`, { - method: "PATCH", - body: JSON.stringify(payload), - }); - setNotice("签到站点已更新"); - } else { - await apiFetch("/api/checkin-sites", { - method: "POST", - body: JSON.stringify(payload), - }); - setNotice("签到站点已创建"); - } - setCheckinModalOpen(false); - setEditingCheckinSite(null); - setCheckinSiteForm({ ...initialCheckinSiteForm }); - await loadCheckinSites(); - } catch (error) { - setNotice((error as Error).message); - } - }, - [ - apiFetch, - checkinSiteForm, - editingCheckinSite, - loadCheckinSites, - ], - ); - const handleSettingsSubmit = useCallback( async (event: Event) => { event.preventDefault(); @@ -528,7 +504,7 @@ const App = () => { [apiFetch, loadSettings, settingsForm], ); - const handleChannelTest = useCallback( + const handleSiteTest = useCallback( async (id: string) => { try { const result = await apiFetch<{ models: Array<{ id: string }> }>( @@ -537,46 +513,46 @@ const App = () => { method: "POST", }, ); - await loadChannels(); + await loadSites(); setNotice(`连通测试完成,模型数 ${result.models?.length ?? 0}`); } catch (error) { setNotice((error as Error).message); } }, - [apiFetch, loadChannels], + [apiFetch, loadSites], ); - const handleChannelDelete = useCallback( + const handleSiteDelete = useCallback( async (id: string) => { try { - await apiFetch(`/api/channels/${id}`, { method: "DELETE" }); - await loadChannels(); - setNotice("渠道已删除"); - if (editingChannel?.id === id) { - closeChannelModal(); + await apiFetch(`/api/sites/${id}`, { method: "DELETE" }); + await loadSites(); + setNotice("站点已删除"); + if (editingSite?.id === id) { + closeSiteModal(); } } catch (error) { setNotice((error as Error).message); } }, - [apiFetch, closeChannelModal, editingChannel, loadChannels], + [apiFetch, closeSiteModal, editingSite, loadSites], ); - const handleChannelToggle = useCallback( + const handleSiteToggle = useCallback( async (id: string, status: string) => { try { const next = toggleStatus(status); - await apiFetch(`/api/channels/${id}`, { + await apiFetch(`/api/sites/${id}`, { method: "PATCH", body: JSON.stringify({ status: next }), }); - await loadChannels(); - setNotice(`渠道已${next === "active" ? "启用" : "停用"}`); + await loadSites(); + setNotice(`站点已${next === "active" ? "启用" : "停用"}`); } catch (error) { setNotice((error as Error).message); } }, - [apiFetch, loadChannels], + [apiFetch, loadSites], ); const handleTokenDelete = useCallback( @@ -632,36 +608,6 @@ const App = () => { [apiFetch, loadTokens], ); - const handleCheckinToggle = useCallback( - async (id: string, status: string) => { - try { - const next = toggleStatus(status); - await apiFetch(`/api/checkin-sites/${id}`, { - method: "PATCH", - body: JSON.stringify({ status: next }), - }); - await loadCheckinSites(); - setNotice(`签到站点已${next === "active" ? "启用" : "停用"}`); - } catch (error) { - setNotice((error as Error).message); - } - }, - [apiFetch, loadCheckinSites], - ); - - const handleCheckinDelete = useCallback( - async (id: string) => { - try { - await apiFetch(`/api/checkin-sites/${id}`, { method: "DELETE" }); - await loadCheckinSites(); - setNotice("签到站点已删除"); - } catch (error) { - setNotice((error as Error).message); - } - }, - [apiFetch, loadCheckinSites], - ); - const handleCheckinRunAll = useCallback(async () => { try { const result = await apiFetch<{ @@ -674,10 +620,10 @@ const App = () => { }>; summary: CheckinSummary; runs_at: string; - }>("/api/checkin-sites/checkin-all", { + }>("/api/sites/checkin-all", { method: "POST", }); - await loadCheckinSites(); + await loadSites(); setCheckinSummary(result.summary); setCheckinLastRun(result.runs_at); setNotice( @@ -688,7 +634,7 @@ const App = () => { } catch (error) { setNotice((error as Error).message); } - }, [apiFetch, loadCheckinSites]); + }, [apiFetch, loadSites]); const handleUsageRefresh = useCallback(async () => { try { @@ -699,15 +645,15 @@ const App = () => { } }, [loadUsage]); - const channelTotal = data.channels.length; - const channelTotalPages = useMemo( - () => Math.max(1, Math.ceil(channelTotal / channelPageSize)), - [channelTotal, channelPageSize], + const siteTotal = data.sites.length; + const siteTotalPages = useMemo( + () => Math.max(1, Math.ceil(siteTotal / sitePageSize)), + [siteTotal, sitePageSize], ); - const pagedChannels = useMemo(() => { - const start = (channelPage - 1) * channelPageSize; - return data.channels.slice(start, start + channelPageSize); - }, [channelPage, channelPageSize, data.channels]); + const pagedSites = useMemo(() => { + const start = (sitePage - 1) * sitePageSize; + return data.sites.slice(start, start + sitePageSize); + }, [sitePage, sitePageSize, data.sites]); const tokenTotal = data.tokens.length; const tokenTotalPages = useMemo( () => Math.max(1, Math.ceil(tokenTotal / tokenPageSize)), @@ -719,8 +665,8 @@ const App = () => { }, [data.tokens, tokenPage, tokenPageSize]); useEffect(() => { - setChannelPage((prev) => Math.min(prev, channelTotalPages)); - }, [channelTotalPages]); + setSitePage((prev) => Math.min(prev, siteTotalPages)); + }, [siteTotalPages]); useEffect(() => { setTokenPage((prev) => Math.min(prev, tokenTotalPages)); @@ -744,25 +690,28 @@ const App = () => { } if (activeTab === "channels") { return ( - ); } @@ -802,26 +751,6 @@ const App = () => { /> ); } - if (activeTab === "checkin-sites") { - return ( - - ); - } return (
未知模块 diff --git a/apps/ui/src/core/constants.ts b/apps/ui/src/core/constants.ts index accd833..427ba2b 100644 --- a/apps/ui/src/core/constants.ts +++ b/apps/ui/src/core/constants.ts @@ -1,38 +1,41 @@ -import type { - AdminData, - ChannelForm, - CheckinSiteForm, - SettingsForm, - TabItem, -} from "./types"; +import type { AdminData, SettingsForm, SiteForm, TabItem } from "./types"; export const apiBase = import.meta.env.VITE_API_BASE ?? ""; export const tabs: TabItem[] = [ { id: "dashboard", label: "数据面板" }, - { id: "channels", label: "渠道管理" }, + { id: "channels", label: "站点管理" }, { id: "models", label: "模型广场" }, { id: "tokens", label: "令牌管理" }, { id: "usage", label: "使用日志" }, - { id: "checkin-sites", label: "签到站点" }, { id: "settings", label: "系统设置" }, ]; export const initialData: AdminData = { - channels: [], + sites: [], tokens: [], models: [], usage: [], dashboard: null, settings: null, - checkinSites: [], }; -export const initialChannelForm: ChannelForm = { +export const initialSiteForm: SiteForm = { name: "", base_url: "", - api_key: "", weight: 1, + status: "active", + site_type: "new-api", + checkin_url: "", + system_token: "", + system_userid: "", + checkin_enabled: true, + call_tokens: [ + { + name: "主调用令牌", + api_key: "", + }, + ], }; export const initialSettingsForm: SettingsForm = { @@ -42,12 +45,3 @@ export const initialSettingsForm: SettingsForm = { checkin_schedule_enabled: false, checkin_schedule_time: "00:10", }; - -export const initialCheckinSiteForm: CheckinSiteForm = { - name: "", - base_url: "", - checkin_url: "", - token: "", - userid: "", - status: "active", -}; diff --git a/apps/ui/src/core/types.ts b/apps/ui/src/core/types.ts index 4445370..74ff9ed 100644 --- a/apps/ui/src/core/types.ts +++ b/apps/ui/src/core/types.ts @@ -1,11 +1,37 @@ -export type Channel = { +export type SiteType = + | "new-api" + | "done-hub" + | "subapi" + | "chatgpt" + | "claude" + | "gemini"; + +export type SiteCallToken = { + id: string; + name: string; + api_key: string; +}; + +export type Site = { id: string; name: string; base_url: string; - api_key: string; weight: number; status: string; - models_json?: string; + site_type: SiteType; + api_key?: string; + system_token?: string | null; + system_userid?: string | null; + checkin_enabled?: boolean; + checkin_id?: string | null; + checkin_url?: string | null; + call_tokens: SiteCallToken[]; + last_checkin_date?: string | null; + last_checkin_status?: string | null; + last_checkin_message?: string | null; + last_checkin_at?: string | null; + created_at?: string | null; + updated_at?: string | null; }; export type Token = { @@ -69,13 +95,12 @@ export type ModelItem = { }; export type AdminData = { - channels: Channel[]; + sites: Site[]; tokens: Token[]; models: ModelItem[]; usage: UsageLog[]; dashboard: DashboardData | null; settings: Settings | null; - checkinSites: CheckinSite[]; }; export type TabId = @@ -84,19 +109,30 @@ export type TabId = | "models" | "tokens" | "usage" - | "settings" - | "checkin-sites"; + | "settings"; export type TabItem = { id: TabId; label: string; }; -export type ChannelForm = { +export type SiteForm = { name: string; base_url: string; - api_key: string; weight: number; + status: string; + site_type: SiteType; + checkin_url: string; + system_token: string; + system_userid: string; + checkin_enabled: boolean; + call_tokens: SiteCallTokenForm[]; +}; + +export type SiteCallTokenForm = { + id?: string; + name: string; + api_key: string; }; export type SettingsForm = { @@ -107,31 +143,6 @@ export type SettingsForm = { checkin_schedule_time: string; }; -export type CheckinSite = { - id: string; - name: string; - base_url: string; - checkin_url?: string | null; - token: string; - userid?: string | null; - last_checkin_date?: string | null; - last_checkin_status?: string | null; - last_checkin_message?: string | null; - last_checkin_at?: string | null; - status: string; - created_at?: string | null; - updated_at?: string | null; -}; - -export type CheckinSiteForm = { - name: string; - base_url: string; - checkin_url: string; - token: string; - userid: string; - status: string; -}; - export type CheckinResultItem = { id: string; name: string; diff --git a/apps/ui/src/features/AppLayout.tsx b/apps/ui/src/features/AppLayout.tsx index 967ec4a..f290a5f 100644 --- a/apps/ui/src/features/AppLayout.tsx +++ b/apps/ui/src/features/AppLayout.tsx @@ -36,17 +36,24 @@ export const AppLayout = ({ toggle.checked = false; } }; + const toggleMobileNav = () => { + const toggle = document.querySelector("#app-nav-toggle"); + if (toggle) { + toggle.checked = !toggle.checked; + } + }; return (
- +
api-workers @@ -140,10 +159,11 @@ export const AppLayout = ({ )} {children} -
); diff --git a/apps/ui/src/features/ChannelsView.tsx b/apps/ui/src/features/ChannelsView.tsx index 67e3b84..92421c9 100644 --- a/apps/ui/src/features/ChannelsView.tsx +++ b/apps/ui/src/features/ChannelsView.tsx @@ -1,437 +1 @@ -import type { Channel, ChannelForm } from "../core/types"; -import { buildPageItems } from "../core/utils"; - -type ChannelsViewProps = { - channelForm: ChannelForm; - channelPage: number; - channelPageSize: number; - channelTotal: number; - channelTotalPages: number; - pagedChannels: Channel[]; - editingChannel: Channel | null; - isChannelModalOpen: boolean; - onCreate: () => void; - onCloseModal: () => void; - onEdit: (channel: Channel) => void; - onSubmit: (event: Event) => void; - onTest: (id: string) => void; - onToggle: (id: string, status: string) => void; - onDelete: (id: string) => void; - onPageChange: (next: number) => void; - onPageSizeChange: (next: number) => void; - onFormChange: (patch: Partial) => void; -}; - -const pageSizeOptions = [10, 20, 50]; - -/** - * Renders the channels management view. - * - * Args: - * props: Channels view props. - * - * Returns: - * Channels JSX element. - */ -export const ChannelsView = ({ - channelForm, - channelPage, - channelPageSize, - channelTotal, - channelTotalPages, - pagedChannels, - editingChannel, - isChannelModalOpen, - onCreate, - onCloseModal, - onEdit, - onSubmit, - onTest, - onToggle, - onDelete, - onPageChange, - onPageSizeChange, - onFormChange, -}: ChannelsViewProps) => { - const isEditing = Boolean(editingChannel); - const pageItems = buildPageItems(channelPage, channelTotalPages); - return ( -
-
-
-
-

- 渠道列表 -

-

状态、权重与操作入口集中展示。

-
-
- -
-
-
-
- {pagedChannels.length === 0 ? ( -
- 暂无渠道,请先创建。 -
- ) : ( - pagedChannels.map((channel) => { - const isActive = channel.status === "active"; - return ( -
-
-
-

- {channel.name} -

-

- {channel.base_url} -

-
- - {isActive ? "启用" : "禁用"} - -
-
- 权重 - - {channel.weight} - -
-
- - - - -
-
- ); - }) - )} -
- -
-
-
- - 共 {channelTotal} 条 · {channelTotalPages} 页 - - - {pageItems.map((item, index) => - item === "ellipsis" ? ( - - ... - - ) : ( - - ), - )} - -
- -
-
- {isChannelModalOpen && ( -
-
-
-
-

- {isEditing ? "编辑渠道" : "新增渠道"} -

-

- {isEditing - ? `正在编辑:${editingChannel?.name ?? ""}` - : "填写渠道信息并保存。"} -

-
- -
-
-
- - - onFormChange({ - name: (event.currentTarget as HTMLInputElement).value, - }) - } - /> -
-
- - - onFormChange({ - base_url: (event.currentTarget as HTMLInputElement).value, - }) - } - /> -
-
- - - onFormChange({ - api_key: (event.currentTarget as HTMLInputElement).value, - }) - } - /> -
-
- - - onFormChange({ - weight: Number( - (event.currentTarget as HTMLInputElement).value || 0, - ), - }) - } - /> -
-
- - -
-
-
-
- )} -
- ); -}; +export { SitesView as ChannelsView } from "./SitesView"; diff --git a/apps/ui/src/features/CheckinSitesView.tsx b/apps/ui/src/features/CheckinSitesView.tsx deleted file mode 100644 index 4dd6594..0000000 --- a/apps/ui/src/features/CheckinSitesView.tsx +++ /dev/null @@ -1,483 +0,0 @@ -import type { CheckinSite, CheckinSiteForm, CheckinSummary } from "../core/types"; -import { formatDateTime, getBeijingDateString } from "../core/utils"; - -type CheckinSitesViewProps = { - sites: CheckinSite[]; - form: CheckinSiteForm; - isModalOpen: boolean; - editingSite: CheckinSite | null; - summary: CheckinSummary | null; - lastRun: string | null; - onCreate: () => void; - onEdit: (site: CheckinSite) => void; - onCloseModal: () => void; - onSubmit: (event: Event) => void; - onFormChange: (patch: Partial) => void; - onToggle: (id: string, status: string) => void; - onDelete: (id: string) => void; - onRunAll: () => void; -}; - -export const CheckinSitesView = ({ - sites, - form, - isModalOpen, - editingSite, - summary, - lastRun, - onCreate, - onEdit, - onCloseModal, - onSubmit, - onFormChange, - onToggle, - onDelete, - onRunAll, -}: CheckinSitesViewProps) => { - const isEditing = Boolean(editingSite); - const today = getBeijingDateString(); - const openManual = (site: CheckinSite) => { - const url = - site.checkin_url && site.checkin_url.trim() - ? site.checkin_url.trim() - : site.base_url; - if (!url) { - return; - } - window.open(url, "_blank", "noopener"); - }; - return ( -
-
-
-
-

- 签到站点 -

-

- 维护站点信息,一键签到仅对启用站点执行。 -

-
-
- {summary && ( -
- 总计 {summary.total} - 成功 {summary.success} - 已签 {summary.skipped} - 失败 {summary.failed} -
- )} - - -
-
-
-
- {sites.length === 0 ? ( -
- 暂无站点,请先创建。 -
- ) : ( - sites.map((site) => { - const isActive = site.status === "active"; - const isToday = site.last_checkin_date === today; - const status = isToday ? site.last_checkin_status : null; - const message = isToday ? site.last_checkin_message : null; - return ( -
-
-
-

- {site.name} -

-

- {site.base_url} -

-
- - {isActive ? "启用" : "禁用"} - -
-
-
-

签到 URL

-

- {site.checkin_url || "-"} -

-
-
-

User ID

-

- {site.userid || "-"} -

-
-
-

创建时间

-

- {formatDateTime(site.created_at)} -

-
-
-

今日签到

-

- {status - ? status === "success" - ? "成功" - : status === "skipped" - ? "已签" - : "签到失败" - : "未签到"} -

- {message && ( -

- {message} -

- )} -
-
-
- - - - -
-
- ); - }) - )} -
- -
- {lastRun && ( -

最近执行时间:{formatDateTime(lastRun)}

- )} -
- {isModalOpen && ( -
-
-
-
-

- {isEditing ? "编辑站点" : "新增站点"} -

-

- {isEditing - ? `正在编辑:${editingSite?.name ?? ""}` - : "填写站点信息并保存。"} -

-
- -
-
-
- - - onFormChange({ - name: (event.currentTarget as HTMLInputElement).value, - }) - } - /> -
-
- - - onFormChange({ - base_url: (event.currentTarget as HTMLInputElement).value, - }) - } - /> -
-
- - - onFormChange({ - checkin_url: (event.currentTarget as HTMLInputElement) - .value, - }) - } - /> -
-
- - - onFormChange({ - token: (event.currentTarget as HTMLInputElement).value, - }) - } - /> -
-
- - - onFormChange({ - userid: (event.currentTarget as HTMLInputElement) - .value, - }) - } - /> -
-
- - -
-
- - -
-
-
-
- )} -
- ); -}; diff --git a/apps/ui/src/features/SitesView.tsx b/apps/ui/src/features/SitesView.tsx new file mode 100644 index 0000000..ec01d51 --- /dev/null +++ b/apps/ui/src/features/SitesView.tsx @@ -0,0 +1,740 @@ +import type { CheckinSummary, Site, SiteForm } from "../core/types"; +import { + buildPageItems, + formatDateTime, + getBeijingDateString, +} from "../core/utils"; + +type SitesViewProps = { + siteForm: SiteForm; + sitePage: number; + sitePageSize: number; + siteTotal: number; + siteTotalPages: number; + pagedSites: Site[]; + editingSite: Site | null; + isSiteModalOpen: boolean; + summary: CheckinSummary | null; + lastRun: string | null; + onCreate: () => void; + onCloseModal: () => void; + onEdit: (site: Site) => void; + onSubmit: (event: Event) => void; + onTest: (id: string) => void; + onToggle: (id: string, status: string) => void; + onDelete: (id: string) => void; + onPageChange: (next: number) => void; + onPageSizeChange: (next: number) => void; + onFormChange: (patch: Partial) => void; + onRunAll: () => void; +}; + +const pageSizeOptions = [10, 20, 50]; + +const typeLabelMap: Record = { + "new-api": "New API", + "done-hub": "Done Hub", + subapi: "Sub API", + chatgpt: "ChatGPT", + claude: "Claude", + gemini: "Gemini", +}; + +export const SitesView = ({ + siteForm, + sitePage, + sitePageSize, + siteTotal, + siteTotalPages, + pagedSites, + editingSite, + isSiteModalOpen, + summary, + lastRun, + onCreate, + onCloseModal, + onEdit, + onSubmit, + onTest, + onToggle, + onDelete, + onPageChange, + onPageSizeChange, + onFormChange, + onRunAll, +}: SitesViewProps) => { + const isEditing = Boolean(editingSite); + const pageItems = buildPageItems(sitePage, siteTotalPages); + const today = getBeijingDateString(); + const isOfficialType = + siteForm.site_type === "chatgpt" || + siteForm.site_type === "claude" || + siteForm.site_type === "gemini"; + const needsSystemToken = !isOfficialType; + const isNewApi = siteForm.site_type === "new-api"; + const updateCallToken = ( + index: number, + patch: Partial, + ) => { + const next = siteForm.call_tokens.map((token, idx) => + idx === index ? { ...token, ...patch } : token, + ); + onFormChange({ call_tokens: next }); + }; + const addCallToken = () => { + const next = [ + ...siteForm.call_tokens, + { + name: `调用令牌${siteForm.call_tokens.length + 1}`, + api_key: "", + }, + ]; + onFormChange({ call_tokens: next }); + }; + const removeCallToken = (index: number) => { + if (siteForm.call_tokens.length <= 1) { + return; + } + const next = siteForm.call_tokens.filter((_, idx) => idx !== index); + onFormChange({ call_tokens: next }); + }; + return ( +
+
+
+
+

+ 站点管理 +

+

+ 统一维护调用令牌、系统令牌与站点类型。 +

+
+
+ {summary && ( +
+ 总计 {summary.total} + 成功 {summary.success} + 已签 {summary.skipped} + 失败 {summary.failed} +
+ )} + + +
+
+
+
+ {pagedSites.length === 0 ? ( +
+ 暂无站点,请先创建。 +
+ ) : ( + pagedSites.map((site) => { + const isActive = site.status === "active"; + const isToday = site.last_checkin_date === today; + const status = isToday ? site.last_checkin_status : null; + const message = isToday ? site.last_checkin_message : null; + const systemReady = Boolean( + site.system_token && site.system_userid, + ); + const checkinEnabled = + site.site_type === "new-api" && Boolean(site.checkin_enabled); + const showCheckin = + site.site_type === "new-api" && checkinEnabled; + const callTokenCount = site.call_tokens?.length ?? 0; + return ( +
+
+
+

+ {site.name} +

+

+ {site.base_url} +

+
+ + {isActive ? "启用" : "禁用"} + +
+
+ 类型 + + {typeLabelMap[site.site_type]} + +
+
+ 权重 + + {site.weight} + +
+
+
+

系统令牌

+

+ {systemReady ? "已配置" : "未配置"} +

+
+
+

调用令牌

+

+ {callTokenCount > 0 ? `${callTokenCount} 个` : "-"} +

+
+
+

今日签到

+

+ {!showCheckin + ? "-" + : status + ? status === "success" + ? "成功" + : status === "skipped" + ? "已签" + : "签到失败" + : "未签到"} +

+ {message && showCheckin && ( +

+ {message} +

+ )} +
+
+
+ + + + +
+
+ ); + }) + )} +
+ +
+
+
+ + 共 {siteTotal} 条 · {siteTotalPages} 页 + + + {pageItems.map((item, index) => + item === "ellipsis" ? ( + + ... + + ) : ( + + ), + )} + +
+ +
+ {lastRun && ( +

+ 最近执行时间:{formatDateTime(lastRun)} +

+ )} +
+ {isSiteModalOpen && ( +
+
+
+
+

+ {isEditing ? "编辑站点" : "新增站点"} +

+

+ {isEditing + ? `正在编辑:${editingSite?.name ?? ""}` + : "填写站点信息并保存。"} +

+
+ +
+
+
+
+ + + onFormChange({ + name: (event.currentTarget as HTMLInputElement).value, + }) + } + /> +
+
+ + +
+
+
+ + + onFormChange({ + base_url: (event.currentTarget as HTMLInputElement).value, + }) + } + /> +
+
+
+ + + onFormChange({ + weight: Number( + (event.currentTarget as HTMLInputElement).value || 0, + ), + }) + } + /> +
+
+ + +
+
+
+
+

+ 调用令牌 +

+ +
+

+ 用于实际调用,系统会按顺序选择可用令牌。 +

+
+ {siteForm.call_tokens.map((token, index) => ( +
+
+ + updateCallToken(index, { + name: (event.currentTarget as HTMLInputElement) + .value, + }) + } + /> + + updateCallToken(index, { + api_key: (event.currentTarget as HTMLInputElement) + .value, + }) + } + /> +
+
+ +
+
+ ))} +
+
+ {needsSystemToken && ( +
+

+ 系统令牌与签到 +

+
+ + onFormChange({ + system_token: ( + event.currentTarget as HTMLInputElement + ).value, + }) + } + /> + + onFormChange({ + system_userid: ( + event.currentTarget as HTMLInputElement + ).value, + }) + } + /> +
+
+ {isNewApi && ( + + )} + + onFormChange({ + checkin_url: (event.currentTarget as HTMLInputElement) + .value, + }) + } + /> +
+
+ )} +
+ + +
+
+
+
+ )} +
+ ); +}; diff --git a/apps/ui/src/features/TokensView.tsx b/apps/ui/src/features/TokensView.tsx index 5fffdd7..b1e344e 100644 --- a/apps/ui/src/features/TokensView.tsx +++ b/apps/ui/src/features/TokensView.tsx @@ -202,7 +202,9 @@ export const TokensView = ({ diff --git a/apps/worker/migrations/0002_add_checkin_sites.sql b/apps/worker/migrations/0002_add_checkin_sites.sql index 03f5390..2734c1c 100644 --- a/apps/worker/migrations/0002_add_checkin_sites.sql +++ b/apps/worker/migrations/0002_add_checkin_sites.sql @@ -1,15 +1,19 @@ -CREATE TABLE IF NOT EXISTS checkin_sites ( +ALTER TABLE channels ADD COLUMN system_token TEXT; +ALTER TABLE channels ADD COLUMN system_userid TEXT; +ALTER TABLE channels ADD COLUMN checkin_enabled INTEGER NOT NULL DEFAULT 0; +ALTER TABLE channels ADD COLUMN checkin_url TEXT; +ALTER TABLE channels ADD COLUMN last_checkin_date TEXT; +ALTER TABLE channels ADD COLUMN last_checkin_status TEXT; +ALTER TABLE channels ADD COLUMN last_checkin_message TEXT; +ALTER TABLE channels ADD COLUMN last_checkin_at TEXT; + +CREATE TABLE IF NOT EXISTS channel_call_tokens ( id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, name TEXT NOT NULL, - base_url TEXT NOT NULL, - checkin_url TEXT, - token TEXT NOT NULL, - new_api_user TEXT, - last_checkin_date TEXT, - last_checkin_status TEXT, - last_checkin_message TEXT, - last_checkin_at TEXT, - status TEXT NOT NULL DEFAULT 'active', + api_key TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); + +CREATE INDEX IF NOT EXISTS channel_call_tokens_channel_id ON channel_call_tokens(channel_id); diff --git a/apps/worker/src/db/schema.sql b/apps/worker/src/db/schema.sql index d8faa3d..6c68c95 100644 --- a/apps/worker/src/db/schema.sql +++ b/apps/worker/src/db/schema.sql @@ -13,26 +13,29 @@ CREATE TABLE IF NOT EXISTS channels ( metadata_json TEXT, test_time INTEGER, response_time_ms INTEGER, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS checkin_sites ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - base_url TEXT NOT NULL, + system_token TEXT, + system_userid TEXT, + checkin_enabled INTEGER NOT NULL DEFAULT 0, checkin_url TEXT, - token TEXT NOT NULL, - new_api_user TEXT, last_checkin_date TEXT, last_checkin_status TEXT, last_checkin_message TEXT, last_checkin_at TEXT, - status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS channel_call_tokens ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + name TEXT NOT NULL, + api_key TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS channel_call_tokens_channel_id ON channel_call_tokens(channel_id); + CREATE TABLE IF NOT EXISTS tokens ( id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 2543ea4..e256f07 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -5,7 +5,6 @@ import type { AppEnv } from "./env"; import { adminAuth } from "./middleware/adminAuth"; import authRoutes from "./routes/auth"; import channelRoutes from "./routes/channels"; -import checkinSiteRoutes from "./routes/checkin-sites"; import dashboardRoutes from "./routes/dashboard"; import modelRoutes from "./routes/models"; import newapiChannelRoutes from "./routes/newapiChannels"; @@ -13,6 +12,7 @@ import newapiGroupRoutes from "./routes/newapiGroups"; import newapiUserRoutes from "./routes/newapiUsers"; import proxyRoutes from "./routes/proxy"; import settingsRoutes from "./routes/settings"; +import siteRoutes from "./routes/sites"; import tokenRoutes from "./routes/tokens"; import usageRoutes from "./routes/usage"; @@ -101,7 +101,7 @@ app.get("/health", (c) => c.json({ ok: true })); app.route("/api/auth", authRoutes); app.route("/api/channels", channelRoutes); -app.route("/api/checkin-sites", checkinSiteRoutes); +app.route("/api/sites", siteRoutes); app.route("/api/models", modelRoutes); app.route("/api/tokens", tokenRoutes); app.route("/api/usage", usageRoutes); diff --git a/apps/worker/src/routes/channels.ts b/apps/worker/src/routes/channels.ts index 701e788..5bb0174 100644 --- a/apps/worker/src/routes/channels.ts +++ b/apps/worker/src/routes/channels.ts @@ -131,6 +131,14 @@ channels.patch("/:id", async (c) => { group_name: current.group_name ?? null, priority: current.priority ?? 0, metadata_json: current.metadata_json ?? null, + system_token: current.system_token ?? null, + system_userid: current.system_userid ?? null, + checkin_enabled: current.checkin_enabled ?? 0, + checkin_url: current.checkin_url ?? null, + last_checkin_date: current.last_checkin_date ?? null, + last_checkin_status: current.last_checkin_status ?? null, + last_checkin_message: current.last_checkin_message ?? null, + last_checkin_at: current.last_checkin_at ?? null, updated_at: nowIso(), }); diff --git a/apps/worker/src/routes/checkin-sites.ts b/apps/worker/src/routes/checkin-sites.ts deleted file mode 100644 index a0d3b9f..0000000 --- a/apps/worker/src/routes/checkin-sites.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Hono } from "hono"; -import type { AppEnv } from "../env"; -import { - deleteCheckinSite, - getCheckinSiteById, - insertCheckinSite, - listCheckinSites, - listActiveCheckinSites, - updateCheckinSite, - updateCheckinResult, -} from "../services/checkin-site-repo"; -import { runCheckin, summarizeCheckin } from "../services/checkin"; -import { generateToken } from "../utils/crypto"; -import { jsonError } from "../utils/http"; -import { beijingDateString, nowIso } from "../utils/time"; -import { normalizeBaseUrl } from "../utils/url"; - -const checkinSites = new Hono(); - -type CheckinSitePayload = { - name?: string; - base_url?: string; - checkin_url?: string | null; - token?: string; - userid?: string; - new_api_user?: string; - status?: string; -}; - -/** - * Lists all checkin sites. - */ -checkinSites.get("/", async (c) => { - const rows = await listCheckinSites(c.env.DB, { - order: "DESC", - }); - const sites = rows.map((row) => { - const { new_api_user, ...rest } = row; - return { - ...rest, - userid: new_api_user ?? null, - }; - }); - return c.json({ sites }); -}); - -/** - * Creates a new checkin site. - */ -checkinSites.post("/", async (c) => { - const body = (await c.req.json().catch(() => null)) as CheckinSitePayload | null; - const userid = body?.userid ?? body?.new_api_user; - if (!body?.name || !body.base_url || !body.token || !userid) { - return jsonError(c, 400, "missing_fields", "missing_fields"); - } - const id = generateToken("cs_"); - const now = nowIso(); - await insertCheckinSite(c.env.DB, { - id, - name: body.name, - base_url: normalizeBaseUrl(String(body.base_url)), - checkin_url: body.checkin_url?.trim() || null, - token: body.token, - new_api_user: userid.trim(), - last_checkin_date: null, - last_checkin_status: null, - last_checkin_message: null, - last_checkin_at: null, - status: body.status ?? "active", - created_at: now, - updated_at: now, - }); - return c.json({ id }); -}); - -/** - * Updates a checkin site. - */ -checkinSites.patch("/:id", async (c) => { - const body = (await c.req.json().catch(() => null)) as CheckinSitePayload | null; - const id = c.req.param("id"); - if (!body) { - return jsonError(c, 400, "missing_body", "missing_body"); - } - const current = await getCheckinSiteById(c.env.DB, id); - if (!current) { - return jsonError(c, 404, "site_not_found", "site_not_found"); - } - await updateCheckinSite(c.env.DB, id, { - name: body.name ?? current.name, - base_url: normalizeBaseUrl(String(body.base_url ?? current.base_url)), - checkin_url: - body.checkin_url !== undefined - ? body.checkin_url?.trim() || null - : current.checkin_url ?? null, - token: body.token ?? current.token, - new_api_user: - body.userid !== undefined || body.new_api_user !== undefined - ? String(body.userid ?? body.new_api_user ?? "").trim() - : current.new_api_user ?? null, - status: body.status ?? current.status, - updated_at: nowIso(), - }); - return c.json({ ok: true }); -}); - -/** - * Deletes a checkin site. - */ -checkinSites.delete("/:id", async (c) => { - const id = c.req.param("id"); - await deleteCheckinSite(c.env.DB, id); - return c.json({ ok: true }); -}); - -/** - * Runs checkin for all active sites. - */ -checkinSites.post("/checkin-all", async (c) => { - const sites = await listActiveCheckinSites(c.env.DB); - const results = []; - const today = beijingDateString(); - for (const site of sites) { - const alreadyChecked = - site.last_checkin_date === today && - (site.last_checkin_status === "success" || - site.last_checkin_status === "skipped"); - if (alreadyChecked) { - results.push({ - id: site.id, - name: site.name, - status: "skipped", - message: site.last_checkin_message ?? "今日已签到", - checkin_date: site.last_checkin_date ?? today, - }); - continue; - } - const result = await runCheckin(site); - const checkinDate = result.checkin_date ?? today; - await updateCheckinResult(c.env.DB, site.id, { - last_checkin_date: checkinDate, - last_checkin_status: result.status, - last_checkin_message: result.message, - last_checkin_at: nowIso(), - }); - results.push({ ...result, checkin_date: checkinDate }); - } - const summary = summarizeCheckin(results); - return c.json({ - results, - summary, - runs_at: nowIso(), - }); -}); - -export default checkinSites; diff --git a/apps/worker/src/routes/newapiChannels.ts b/apps/worker/src/routes/newapiChannels.ts index 315bf8e..6c4297c 100644 --- a/apps/worker/src/routes/newapiChannels.ts +++ b/apps/worker/src/routes/newapiChannels.ts @@ -337,6 +337,14 @@ newapi.put("/", async (c) => { group_name: parsed.group_name ?? current.group_name ?? null, priority: parsed.priority ?? current.priority ?? 0, metadata_json: mergedMetadata, + system_token: current.system_token ?? null, + system_userid: current.system_userid ?? null, + checkin_enabled: current.checkin_enabled ?? 0, + checkin_url: current.checkin_url ?? null, + last_checkin_date: current.last_checkin_date ?? null, + last_checkin_status: current.last_checkin_status ?? null, + last_checkin_message: current.last_checkin_message ?? null, + last_checkin_at: current.last_checkin_at ?? null, updated_at: nowIso(), }); diff --git a/apps/worker/src/routes/proxy.ts b/apps/worker/src/routes/proxy.ts index 0c5d8d9..bb8e6e0 100644 --- a/apps/worker/src/routes/proxy.ts +++ b/apps/worker/src/routes/proxy.ts @@ -1,6 +1,11 @@ import { Hono } from "hono"; import type { AppEnv } from "../env"; import { type TokenRecord, tokenAuth } from "../middleware/tokenAuth"; +import { + type CallTokenItem, + selectCallToken, +} from "../services/call-token-selector"; +import { listCallTokens } from "../services/channel-call-token-repo"; import { type ChannelRecord, createWeightedOrder, @@ -79,6 +84,10 @@ async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } +function resolveChannelBaseUrl(channel: ChannelRecord): string { + return normalizeBaseUrl(channel.base_url); +} + /** * OpenAI-compatible proxy handler. */ @@ -119,6 +128,21 @@ proxy.all("/*", tokenAuth, async (c) => { .bind("active") .all(); const activeChannels = (channelResult.results ?? []) as ChannelRecord[]; + const callTokenRows = await listCallTokens(c.env.DB, { + channelIds: activeChannels.map((channel) => channel.id), + }); + const callTokenMap = new Map(); + for (const row of callTokenRows) { + const entry: CallTokenItem = { + id: row.id, + channel_id: row.channel_id, + name: row.name, + api_key: row.api_key, + }; + const list = callTokenMap.get(row.channel_id) ?? []; + list.push(entry); + callTokenMap.set(row.channel_id, list); + } const allowedChannels = filterAllowedChannels(activeChannels, tokenRecord); const modelChannels = allowedChannels.filter((channel) => channelSupportsModel(channel, model), @@ -149,11 +173,14 @@ proxy.all("/*", tokenAuth, async (c) => { let shouldRetry = false; for (const channel of ordered) { lastChannel = channel; - const baseUrl = normalizeBaseUrl(channel.base_url); + const baseUrl = resolveChannelBaseUrl(channel); + const tokens = callTokenMap.get(channel.id) ?? []; + const selectedToken = tokens.length > 0 ? selectCallToken(tokens) : null; + const apiKey = selectedToken?.api_key ?? channel.api_key; const target = `${baseUrl}${targetPath}${querySuffix}`; const headers = new Headers(c.req.header()); - headers.set("Authorization", `Bearer ${channel.api_key}`); - headers.set("x-api-key", String(channel.api_key)); + headers.set("Authorization", `Bearer ${apiKey}`); + headers.set("x-api-key", String(apiKey)); headers.delete("host"); headers.delete("content-length"); diff --git a/apps/worker/src/routes/settings.ts b/apps/worker/src/routes/settings.ts index a2c41b0..51f1e00 100644 --- a/apps/worker/src/routes/settings.ts +++ b/apps/worker/src/routes/settings.ts @@ -5,8 +5,8 @@ import { getRetentionDays, getSessionTtlHours, isAdminPasswordSet, - setCheckinSchedule, setAdminPasswordHash, + setCheckinSchedule, setRetentionDays, setSessionTtlHours, } from "../services/settings"; @@ -87,7 +87,11 @@ settings.put("/", async (c) => { enabledRaw === undefined ? current.enabled : enabledRaw === true || enabledRaw === "true"; - if (enabledRaw !== undefined && enabledRaw !== true && enabledRaw !== false) { + if ( + enabledRaw !== undefined && + enabledRaw !== true && + enabledRaw !== false + ) { if (enabledRaw !== "true" && enabledRaw !== "false") { return jsonError( c, diff --git a/apps/worker/src/routes/sites.ts b/apps/worker/src/routes/sites.ts new file mode 100644 index 0000000..45ff642 --- /dev/null +++ b/apps/worker/src/routes/sites.ts @@ -0,0 +1,488 @@ +import { Hono } from "hono"; +import type { AppEnv } from "../env"; +import { + listCallTokens, + replaceCallTokensForChannel, +} from "../services/channel-call-token-repo"; +import { + deleteChannel, + getChannelById, + insertChannel, + listChannels, + updateChannel, + updateChannelCheckinResult, +} from "../services/channel-repo"; +import { + type CheckinResultItem, + runCheckin, + summarizeCheckin, +} from "../services/checkin"; +import { + buildSiteMetadata, + parseSiteMetadata, + type SiteType, +} from "../services/site-metadata"; +import { generateToken } from "../utils/crypto"; +import { jsonError } from "../utils/http"; +import { beijingDateString, nowIso } from "../utils/time"; +import { normalizeBaseUrl } from "../utils/url"; + +const sites = new Hono(); + +type SitePayload = { + name?: string; + base_url?: string; + weight?: number; + status?: string; + site_type?: SiteType; + checkin_url?: string | null; + system_token?: string; + system_userid?: string; + checkin_enabled?: boolean; + call_tokens?: CallTokenPayload[]; + api_key?: string; + checkin_token?: string; + checkin_userid?: string; + checkin_status?: string; +}; + +type CallTokenPayload = { + id?: string; + name?: string; + api_key?: string; +}; + +const parseSiteType = (value: unknown): SiteType => { + if ( + value === "done-hub" || + value === "new-api" || + value === "subapi" || + value === "chatgpt" || + value === "claude" || + value === "gemini" + ) { + return value; + } + if (value === "custom") { + return "subapi"; + } + return "new-api"; +}; + +const trimValue = (value: unknown): string => { + if (typeof value !== "string") { + return ""; + } + return value.trim(); +}; + +const DEFAULT_BASE_URL_BY_TYPE: Partial> = { + chatgpt: "https://api.openai.com", + claude: "https://api.anthropic.com", + gemini: "https://generativelanguage.googleapis.com", +}; + +const resolveBaseUrl = (siteType: SiteType, raw: unknown): string => { + const trimmed = trimValue(raw); + if (trimmed) { + return normalizeBaseUrl(trimmed); + } + const fallback = DEFAULT_BASE_URL_BY_TYPE[siteType]; + if (fallback) { + return normalizeBaseUrl(fallback); + } + return ""; +}; + +const parseBoolean = (value: unknown, fallback = false): boolean => { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + return value.toLowerCase() === "true"; + } + return fallback; +}; + +type NormalizedCallToken = { + name: string; + api_key: string; +}; + +const normalizeCallTokens = ( + rawTokens: CallTokenPayload[] | undefined, + fallbackApiKey: string | undefined, +): NormalizedCallToken[] => { + const tokens = + rawTokens?.map((token, index) => ({ + name: trimValue(token.name) || `调用令牌${index + 1}`, + api_key: trimValue(token.api_key), + })) ?? []; + const filtered = tokens.filter((token) => token.api_key.length > 0); + if (filtered.length > 0) { + return filtered; + } + const fallback = trimValue(fallbackApiKey); + if (fallback) { + return [ + { + name: "主调用令牌", + api_key: fallback, + }, + ]; + } + return []; +}; + +const toCallTokenRows = ( + channelId: string, + tokens: NormalizedCallToken[], + now: string, +) => + tokens.map((token) => ({ + id: generateToken("ct_"), + channel_id: channelId, + name: token.name, + api_key: token.api_key, + created_at: now, + updated_at: now, + })); + +const buildSiteRecord = ( + channel: { + id: string; + name: string; + base_url: string; + api_key: string; + weight: number; + status: string; + system_token?: string | null; + system_userid?: string | null; + checkin_enabled?: number | boolean | null; + checkin_url?: string | null; + last_checkin_date?: string | null; + last_checkin_status?: string | null; + last_checkin_message?: string | null; + last_checkin_at?: string | null; + metadata_json?: string | null; + created_at?: string | null; + updated_at?: string | null; + }, + callTokens: Array<{ + id: string; + name: string; + api_key: string; + }>, +) => { + const metadata = parseSiteMetadata(channel.metadata_json); + const rawEnabled = channel.checkin_enabled ?? 0; + const checkinEnabled = + typeof rawEnabled === "boolean" ? rawEnabled : Number(rawEnabled) === 1; + return { + id: channel.id, + name: channel.name, + base_url: channel.base_url, + weight: Number(channel.weight ?? 1), + status: channel.status, + site_type: metadata.site_type, + api_key: channel.api_key, + system_token: channel.system_token ?? null, + system_userid: channel.system_userid ?? null, + checkin_enabled: checkinEnabled, + checkin_id: null, + checkin_url: channel.checkin_url ?? null, + call_tokens: callTokens, + last_checkin_date: channel.last_checkin_date ?? null, + last_checkin_status: channel.last_checkin_status ?? null, + last_checkin_message: channel.last_checkin_message ?? null, + last_checkin_at: channel.last_checkin_at ?? null, + created_at: channel.created_at ?? null, + updated_at: channel.updated_at ?? null, + }; +}; + +sites.get("/", async (c) => { + const channels = await listChannels(c.env.DB, { + orderBy: "created_at", + order: "DESC", + }); + const channelIds = channels.map((channel) => channel.id); + const callTokenRows = await listCallTokens(c.env.DB, { + channelIds, + }); + const callTokenMap = new Map< + string, + Array<{ + id: string; + name: string; + api_key: string; + }> + >(); + for (const row of callTokenRows) { + const entry = { + id: row.id, + name: row.name, + api_key: row.api_key, + }; + const list = callTokenMap.get(row.channel_id) ?? []; + list.push(entry); + callTokenMap.set(row.channel_id, list); + } + const sitesList = channels.map((channel) => { + const tokens = callTokenMap.get(channel.id) ?? []; + const callTokens = + tokens.length > 0 + ? tokens + : channel.api_key + ? [ + { + id: "", + name: "主调用令牌", + api_key: channel.api_key, + }, + ] + : []; + return buildSiteRecord(channel, callTokens); + }); + return c.json({ sites: sitesList }); +}); + +sites.post("/", async (c) => { + const body = (await c.req.json().catch(() => null)) as SitePayload | null; + if (!body) { + return jsonError(c, 400, "missing_body", "missing_body"); + } + const name = trimValue(body.name); + if (!name) { + return jsonError(c, 400, "missing_name", "missing_name"); + } + const id = generateToken("ch_"); + const now = nowIso(); + const siteType = parseSiteType(body.site_type); + const baseUrl = resolveBaseUrl(siteType, body.base_url); + if (!baseUrl) { + return jsonError(c, 400, "missing_base_url", "missing_base_url"); + } + const callTokens = normalizeCallTokens(body.call_tokens, body.api_key); + if (callTokens.length === 0) { + return jsonError(c, 400, "missing_call_tokens", "missing_call_tokens"); + } + const systemToken = trimValue(body.system_token ?? body.checkin_token); + const systemUser = trimValue(body.system_userid ?? body.checkin_userid); + const checkinUrl = + body.checkin_url !== undefined && body.checkin_url !== null + ? trimValue(body.checkin_url) + : ""; + const checkinEnabled = + siteType === "new-api" + ? parseBoolean(body.checkin_enabled, body.checkin_status === "active") + : false; + if (checkinEnabled && (!systemToken || !systemUser)) { + return jsonError( + c, + 400, + "missing_checkin_credentials", + "missing_checkin_credentials", + ); + } + const metadataJson = buildSiteMetadata(null, { + site_type: siteType, + }); + await insertChannel(c.env.DB, { + id, + name, + base_url: baseUrl, + api_key: callTokens[0].api_key, + weight: Number(body.weight ?? 1), + status: body.status ?? "active", + rate_limit: 0, + models_json: "[]", + type: 1, + group_name: null, + priority: 0, + metadata_json: metadataJson, + system_token: systemToken || null, + system_userid: systemUser || null, + checkin_enabled: checkinEnabled ? 1 : 0, + checkin_url: checkinUrl || null, + last_checkin_date: null, + last_checkin_status: null, + last_checkin_message: null, + last_checkin_at: null, + created_at: now, + updated_at: now, + }); + await replaceCallTokensForChannel( + c.env.DB, + id, + toCallTokenRows(id, callTokens, now), + ); + + return c.json({ id }); +}); + +sites.patch("/:id", async (c) => { + const body = (await c.req.json().catch(() => null)) as SitePayload | null; + const id = c.req.param("id"); + if (!body) { + return jsonError(c, 400, "missing_body", "missing_body"); + } + const current = await getChannelById(c.env.DB, id); + if (!current) { + return jsonError(c, 404, "site_not_found", "site_not_found"); + } + const currentMetadata = parseSiteMetadata(current.metadata_json); + const nextSiteType = body.site_type + ? parseSiteType(body.site_type) + : currentMetadata.site_type; + const baseUrl = + body.base_url !== undefined + ? resolveBaseUrl(nextSiteType, body.base_url) + : normalizeBaseUrl(String(current.base_url)); + if (!baseUrl) { + return jsonError(c, 400, "missing_base_url", "missing_base_url"); + } + const shouldUpdateTokens = + body.call_tokens !== undefined || body.api_key !== undefined; + const callTokens = shouldUpdateTokens + ? normalizeCallTokens(body.call_tokens, body.api_key ?? current.api_key) + : []; + if (shouldUpdateTokens && callTokens.length === 0) { + return jsonError(c, 400, "missing_call_tokens", "missing_call_tokens"); + } + const metadataJson = + body.site_type !== undefined + ? buildSiteMetadata(current.metadata_json, { + site_type: nextSiteType, + }) + : (current.metadata_json ?? null); + const nextSystemToken = + body.system_token !== undefined || body.checkin_token !== undefined + ? trimValue(body.system_token ?? body.checkin_token) + : trimValue(current.system_token ?? ""); + const nextSystemUser = + body.system_userid !== undefined || body.checkin_userid !== undefined + ? trimValue(body.system_userid ?? body.checkin_userid) + : trimValue(current.system_userid ?? ""); + const nextCheckinUrl = + body.checkin_url !== undefined + ? body.checkin_url !== null + ? trimValue(body.checkin_url) + : "" + : trimValue(current.checkin_url ?? ""); + const currentCheckinEnabled = + typeof current.checkin_enabled === "boolean" + ? current.checkin_enabled + : Number(current.checkin_enabled ?? 0) === 1; + const nextCheckinEnabled = + nextSiteType === "new-api" + ? body.checkin_enabled !== undefined || body.checkin_status !== undefined + ? parseBoolean(body.checkin_enabled, body.checkin_status === "active") + : currentCheckinEnabled + : false; + if (nextCheckinEnabled && (!nextSystemToken || !nextSystemUser)) { + return jsonError( + c, + 400, + "missing_checkin_credentials", + "missing_checkin_credentials", + ); + } + + await updateChannel(c.env.DB, id, { + name: body.name ?? current.name, + base_url: baseUrl, + api_key: shouldUpdateTokens ? callTokens[0].api_key : current.api_key, + weight: Number(body.weight ?? current.weight ?? 1), + status: body.status ?? current.status, + rate_limit: current.rate_limit ?? 0, + models_json: current.models_json ?? "[]", + type: current.type ?? 1, + group_name: current.group_name ?? null, + priority: current.priority ?? 0, + metadata_json: metadataJson, + system_token: nextSystemToken || null, + system_userid: nextSystemUser || null, + checkin_enabled: nextCheckinEnabled ? 1 : 0, + checkin_url: nextCheckinUrl || null, + last_checkin_date: current.last_checkin_date ?? null, + last_checkin_status: current.last_checkin_status ?? null, + last_checkin_message: current.last_checkin_message ?? null, + last_checkin_at: current.last_checkin_at ?? null, + updated_at: nowIso(), + }); + if (shouldUpdateTokens) { + await replaceCallTokensForChannel( + c.env.DB, + id, + toCallTokenRows(id, callTokens, nowIso()), + ); + } + + return c.json({ ok: true }); +}); + +sites.delete("/:id", async (c) => { + const id = c.req.param("id"); + await deleteChannel(c.env.DB, id); + return c.json({ ok: true }); +}); + +sites.post("/checkin-all", async (c) => { + const channelRows = await listChannels(c.env.DB, { orderBy: "created_at" }); + const results: CheckinResultItem[] = []; + const today = beijingDateString(); + for (const channel of channelRows) { + if (channel.status !== "active") { + continue; + } + const metadata = parseSiteMetadata(channel.metadata_json); + if (metadata.site_type !== "new-api") { + continue; + } + const rawEnabled = channel.checkin_enabled ?? 0; + const checkinEnabled = + typeof rawEnabled === "boolean" ? rawEnabled : Number(rawEnabled) === 1; + if (!checkinEnabled) { + continue; + } + const alreadyChecked = + channel.last_checkin_date === today && + (channel.last_checkin_status === "success" || + channel.last_checkin_status === "skipped"); + if (alreadyChecked) { + results.push({ + id: channel.id, + name: channel.name, + status: "skipped", + message: channel.last_checkin_message ?? "今日已签到", + checkin_date: channel.last_checkin_date ?? today, + }); + continue; + } + const result = await runCheckin({ + id: channel.id, + name: channel.name, + base_url: String(channel.base_url), + checkin_url: channel.checkin_url ?? null, + system_token: channel.system_token ?? null, + system_userid: channel.system_userid ?? null, + }); + const checkinDate = result.checkin_date ?? today; + await updateChannelCheckinResult(c.env.DB, channel.id, { + last_checkin_date: checkinDate, + last_checkin_status: result.status, + last_checkin_message: result.message, + last_checkin_at: nowIso(), + }); + results.push({ ...result, checkin_date: checkinDate }); + } + const summary = summarizeCheckin(results); + return c.json({ + results, + summary, + runs_at: nowIso(), + }); +}); + +export default sites; diff --git a/apps/worker/src/services/call-token-selector.ts b/apps/worker/src/services/call-token-selector.ts new file mode 100644 index 0000000..9abaa40 --- /dev/null +++ b/apps/worker/src/services/call-token-selector.ts @@ -0,0 +1,15 @@ +export type CallTokenItem = { + id: string; + channel_id: string; + name: string; + api_key: string; +}; + +export const selectCallToken = ( + tokens: CallTokenItem[], +): CallTokenItem | null => { + if (tokens.length === 0) { + return null; + } + return tokens[0]; +}; diff --git a/apps/worker/src/services/channel-call-token-repo.ts b/apps/worker/src/services/channel-call-token-repo.ts new file mode 100644 index 0000000..32b27ea --- /dev/null +++ b/apps/worker/src/services/channel-call-token-repo.ts @@ -0,0 +1,81 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import type { ChannelCallTokenRow } from "./channel-call-token-types"; + +type ChannelCallTokenFilters = { + channelIds?: string[] | null; +}; + +const buildWhere = (filters?: ChannelCallTokenFilters) => { + if (!filters?.channelIds || filters.channelIds.length === 0) { + return { whereSql: "", bindings: [] as string[] }; + } + const placeholders = filters.channelIds.map(() => "?").join(", "); + return { + whereSql: `WHERE channel_id IN (${placeholders})`, + bindings: filters.channelIds, + }; +}; + +export async function listCallTokens( + db: D1Database, + filters?: ChannelCallTokenFilters, +): Promise { + if (filters?.channelIds && filters.channelIds.length === 0) { + return []; + } + const { whereSql, bindings } = buildWhere(filters); + const statement = db.prepare( + `SELECT * FROM channel_call_tokens ${whereSql} ORDER BY created_at ASC`, + ); + const rows = await statement.bind(...bindings).all(); + return rows.results ?? []; +} + +export async function deleteCallTokensByChannelId( + db: D1Database, + channelId: string, +): Promise { + await db + .prepare("DELETE FROM channel_call_tokens WHERE channel_id = ?") + .bind(channelId) + .run(); +} + +export type ChannelCallTokenInsertInput = { + id: string; + channel_id: string; + name: string; + api_key: string; + created_at: string; + updated_at: string; +}; + +export async function insertCallToken( + db: D1Database, + input: ChannelCallTokenInsertInput, +): Promise { + await db + .prepare( + "INSERT INTO channel_call_tokens (id, channel_id, name, api_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind( + input.id, + input.channel_id, + input.name, + input.api_key, + input.created_at, + input.updated_at, + ) + .run(); +} + +export async function replaceCallTokensForChannel( + db: D1Database, + channelId: string, + tokens: ChannelCallTokenInsertInput[], +): Promise { + await deleteCallTokensByChannelId(db, channelId); + for (const token of tokens) { + await insertCallToken(db, token); + } +} diff --git a/apps/worker/src/services/channel-call-token-types.ts b/apps/worker/src/services/channel-call-token-types.ts new file mode 100644 index 0000000..de4945b --- /dev/null +++ b/apps/worker/src/services/channel-call-token-types.ts @@ -0,0 +1,8 @@ +export type ChannelCallTokenRow = { + id: string; + channel_id: string; + name: string; + api_key: string; + created_at?: string | null; + updated_at?: string | null; +}; diff --git a/apps/worker/src/services/channel-repo.ts b/apps/worker/src/services/channel-repo.ts index 747c753..999b042 100644 --- a/apps/worker/src/services/channel-repo.ts +++ b/apps/worker/src/services/channel-repo.ts @@ -151,6 +151,14 @@ export type ChannelInsertInput = { group_name: string | null; priority: number; metadata_json: string | null; + system_token?: string | null; + system_userid?: string | null; + checkin_enabled?: number | boolean | null; + checkin_url?: string | null; + last_checkin_date?: string | null; + last_checkin_status?: string | null; + last_checkin_message?: string | null; + last_checkin_at?: string | null; created_at: string; updated_at: string; }; @@ -161,7 +169,7 @@ export async function insertChannel( ): Promise { await db .prepare( - "INSERT INTO channels (id, name, base_url, api_key, weight, status, rate_limit, models_json, type, group_name, priority, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO channels (id, name, base_url, api_key, weight, status, rate_limit, models_json, type, group_name, priority, metadata_json, system_token, system_userid, checkin_enabled, checkin_url, last_checkin_date, last_checkin_status, last_checkin_message, last_checkin_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind( input.id, @@ -176,6 +184,18 @@ export async function insertChannel( input.group_name, input.priority, input.metadata_json, + input.system_token ?? null, + input.system_userid ?? null, + typeof input.checkin_enabled === "boolean" + ? input.checkin_enabled + ? 1 + : 0 + : (input.checkin_enabled ?? 0), + input.checkin_url ?? null, + input.last_checkin_date ?? null, + input.last_checkin_status ?? null, + input.last_checkin_message ?? null, + input.last_checkin_at ?? null, input.created_at, input.updated_at, ) @@ -194,6 +214,14 @@ export type ChannelUpdateInput = { group_name: string | null; priority: number; metadata_json: string | null; + system_token: string | null; + system_userid: string | null; + checkin_enabled: number | boolean | null; + checkin_url: string | null; + last_checkin_date: string | null; + last_checkin_status: string | null; + last_checkin_message: string | null; + last_checkin_at: string | null; updated_at: string; }; @@ -204,7 +232,7 @@ export async function updateChannel( ): Promise { await db .prepare( - "UPDATE channels SET name = ?, base_url = ?, api_key = ?, weight = ?, status = ?, rate_limit = ?, models_json = ?, type = ?, group_name = ?, priority = ?, metadata_json = ?, updated_at = ? WHERE id = ?", + "UPDATE channels SET name = ?, base_url = ?, api_key = ?, weight = ?, status = ?, rate_limit = ?, models_json = ?, type = ?, group_name = ?, priority = ?, metadata_json = ?, system_token = ?, system_userid = ?, checkin_enabled = ?, checkin_url = ?, last_checkin_date = ?, last_checkin_status = ?, last_checkin_message = ?, last_checkin_at = ?, updated_at = ? WHERE id = ?", ) .bind( input.name, @@ -218,12 +246,49 @@ export async function updateChannel( input.group_name, input.priority, input.metadata_json, + input.system_token, + input.system_userid, + typeof input.checkin_enabled === "boolean" + ? input.checkin_enabled + ? 1 + : 0 + : (input.checkin_enabled ?? 0), + input.checkin_url, + input.last_checkin_date, + input.last_checkin_status, + input.last_checkin_message, + input.last_checkin_at, input.updated_at, id, ) .run(); } +export async function updateChannelCheckinResult( + db: D1Database, + id: string, + input: { + last_checkin_date: string | null; + last_checkin_status: string | null; + last_checkin_message: string | null; + last_checkin_at: string | null; + }, +): Promise { + await db + .prepare( + "UPDATE channels SET last_checkin_date = ?, last_checkin_status = ?, last_checkin_message = ?, last_checkin_at = ?, updated_at = ? WHERE id = ?", + ) + .bind( + input.last_checkin_date, + input.last_checkin_status, + input.last_checkin_message, + input.last_checkin_at, + input.last_checkin_at ?? new Date().toISOString(), + id, + ) + .run(); +} + export async function deleteChannel(db: D1Database, id: string): Promise { await db.prepare("DELETE FROM channels WHERE id = ?").bind(id).run(); } diff --git a/apps/worker/src/services/channel-types.ts b/apps/worker/src/services/channel-types.ts index 1ec7976..8a544bb 100644 --- a/apps/worker/src/services/channel-types.ts +++ b/apps/worker/src/services/channel-types.ts @@ -13,6 +13,14 @@ export type ChannelRow = { metadata_json?: string | null; test_time?: number | string | null; response_time_ms?: number | null; + system_token?: string | null; + system_userid?: string | null; + checkin_enabled?: number | boolean | null; + checkin_url?: string | null; + last_checkin_date?: string | null; + last_checkin_status?: string | null; + last_checkin_message?: string | null; + last_checkin_at?: string | null; created_at?: string | null; updated_at?: string | null; }; diff --git a/apps/worker/src/services/checkin-site-repo.ts b/apps/worker/src/services/checkin-site-repo.ts deleted file mode 100644 index 6083e5d..0000000 --- a/apps/worker/src/services/checkin-site-repo.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { D1Database } from "@cloudflare/workers-types"; -import type { CheckinSiteRow } from "./checkin-site-types"; - -type CheckinSiteFilters = { - status?: string | null; -}; - -function buildWhere(filters: CheckinSiteFilters | undefined) { - const where: string[] = []; - const bindings: Array = []; - if (filters?.status) { - where.push("status = ?"); - bindings.push(filters.status); - } - const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; - return { whereSql, bindings }; -} - -export async function listCheckinSites( - db: D1Database, - options: { - filters?: CheckinSiteFilters; - order?: "ASC" | "DESC"; - } = {}, -): Promise { - const { whereSql, bindings } = buildWhere(options.filters); - const order = options.order ?? "DESC"; - const statement = db.prepare( - `SELECT * FROM checkin_sites ${whereSql} ORDER BY created_at ${order}`, - ); - const rows = await statement.bind(...bindings).all(); - return rows.results ?? []; -} - -export async function listActiveCheckinSites( - db: D1Database, -): Promise { - return listCheckinSites(db, { filters: { status: "active" } }); -} - -export async function getCheckinSiteById( - db: D1Database, - id: string, -): Promise { - const row = await db - .prepare("SELECT * FROM checkin_sites WHERE id = ?") - .bind(id) - .first(); - return row ?? null; -} - -export type CheckinSiteInsertInput = { - id: string; - name: string; - base_url: string; - checkin_url: string | null; - token: string; - new_api_user: string | null; - last_checkin_date: string | null; - last_checkin_status: string | null; - last_checkin_message: string | null; - last_checkin_at: string | null; - status: string; - created_at: string; - updated_at: string; -}; - -export async function insertCheckinSite( - db: D1Database, - input: CheckinSiteInsertInput, -): Promise { - await db - .prepare( - "INSERT INTO checkin_sites (id, name, base_url, checkin_url, token, new_api_user, last_checkin_date, last_checkin_status, last_checkin_message, last_checkin_at, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind( - input.id, - input.name, - input.base_url, - input.checkin_url, - input.token, - input.new_api_user, - input.last_checkin_date, - input.last_checkin_status, - input.last_checkin_message, - input.last_checkin_at, - input.status, - input.created_at, - input.updated_at, - ) - .run(); -} - -export type CheckinSiteUpdateInput = { - name: string; - base_url: string; - checkin_url: string | null; - token: string; - new_api_user: string | null; - status: string; - updated_at: string; -}; - -export async function updateCheckinSite( - db: D1Database, - id: string, - input: CheckinSiteUpdateInput, -): Promise { - await db - .prepare( - "UPDATE checkin_sites SET name = ?, base_url = ?, checkin_url = ?, token = ?, new_api_user = ?, status = ?, updated_at = ? WHERE id = ?", - ) - .bind( - input.name, - input.base_url, - input.checkin_url, - input.token, - input.new_api_user, - input.status, - input.updated_at, - id, - ) - .run(); -} - -export async function updateCheckinResult( - db: D1Database, - id: string, - input: { - last_checkin_date: string | null; - last_checkin_status: string | null; - last_checkin_message: string | null; - last_checkin_at: string | null; - }, -): Promise { - await db - .prepare( - "UPDATE checkin_sites SET last_checkin_date = ?, last_checkin_status = ?, last_checkin_message = ?, last_checkin_at = ?, updated_at = ? WHERE id = ?", - ) - .bind( - input.last_checkin_date, - input.last_checkin_status, - input.last_checkin_message, - input.last_checkin_at, - input.last_checkin_at ?? new Date().toISOString(), - id, - ) - .run(); -} - -export async function deleteCheckinSite( - db: D1Database, - id: string, -): Promise { - await db.prepare("DELETE FROM checkin_sites WHERE id = ?").bind(id).run(); -} diff --git a/apps/worker/src/services/checkin-site-types.ts b/apps/worker/src/services/checkin-site-types.ts deleted file mode 100644 index b0ea084..0000000 --- a/apps/worker/src/services/checkin-site-types.ts +++ /dev/null @@ -1,17 +0,0 @@ -export type CheckinSiteRow = { - id: string; - name: string; - base_url: string; - checkin_url?: string | null; - token: string; - new_api_user?: string | null; - last_checkin_date?: string | null; - last_checkin_status?: string | null; - last_checkin_message?: string | null; - last_checkin_at?: string | null; - status: string; - created_at?: string | null; - updated_at?: string | null; -}; - -export type CheckinSiteRecord = CheckinSiteRow; diff --git a/apps/worker/src/services/checkin.ts b/apps/worker/src/services/checkin.ts index 05ad907..f7aae8a 100644 --- a/apps/worker/src/services/checkin.ts +++ b/apps/worker/src/services/checkin.ts @@ -1,6 +1,14 @@ import { beijingDateString } from "../utils/time"; import { normalizeBaseUrl } from "../utils/url"; -import type { CheckinSiteRow } from "./checkin-site-types"; + +export type CheckinTarget = { + id: string; + name: string; + base_url: string; + checkin_url?: string | null; + system_token?: string | null; + system_userid?: string | null; +}; export type CheckinResultStatus = "success" | "failed" | "skipped"; @@ -41,6 +49,13 @@ const buildCheckinUrl = (baseUrl: string) => { return `${normalized}/api/user/checkin`; }; +const resolveCheckinBaseUrl = (site: CheckinTarget) => { + if (site.checkin_url?.trim()) { + return site.checkin_url; + } + return site.base_url; +}; + const summarizePayload = (payload: unknown): PayloadSummary => { if (!payload) { return { type: "null" }; @@ -62,7 +77,7 @@ const summarizePayload = (payload: unknown): PayloadSummary => { }; const logCheckin = ( - site: CheckinSiteRow, + site: CheckinTarget, stage: string, data: Record, ) => { @@ -148,8 +163,10 @@ const readError = async (response: Response) => { return parseMessage(payload, `HTTP ${response.status}`); }; -export async function runCheckin(site: CheckinSiteRow): Promise { - const endpoint = buildCheckinUrl(site.base_url); +export async function runCheckin( + site: CheckinTarget, +): Promise { + const endpoint = buildCheckinUrl(resolveCheckinBaseUrl(site)); if (!endpoint) { return { id: site.id, @@ -158,10 +175,18 @@ export async function runCheckin(site: CheckinSiteRow): Promise 0) || - (numericCode !== null && !Number.isNaN(numericCode) && numericCode !== 0), + (numericCode !== null && + !Number.isNaN(numericCode) && + numericCode !== 0), ); if (explicitFailure) { logCheckin(site, "checkin:explicit-failure", { @@ -322,9 +349,7 @@ export async function runCheckin(site: CheckinSiteRow): Promise( (acc, item) => { acc.total += 1; diff --git a/apps/worker/src/services/settings.ts b/apps/worker/src/services/settings.ts index f87599d..2980b99 100644 --- a/apps/worker/src/services/settings.ts +++ b/apps/worker/src/services/settings.ts @@ -120,7 +120,8 @@ export async function getCheckinSchedule(db: D1Database): Promise<{ const enabledRaw = await readSetting(db, CHECKIN_SCHEDULE_ENABLED_KEY); const timeRaw = await readSetting(db, CHECKIN_SCHEDULE_TIME_KEY); const enabled = enabledRaw ? enabledRaw === "true" : false; - const time = timeRaw && timeRaw.length > 0 ? timeRaw : DEFAULT_CHECKIN_SCHEDULE_TIME; + const time = + timeRaw && timeRaw.length > 0 ? timeRaw : DEFAULT_CHECKIN_SCHEDULE_TIME; return { enabled, time }; } diff --git a/apps/worker/src/services/site-metadata.ts b/apps/worker/src/services/site-metadata.ts new file mode 100644 index 0000000..ab9d901 --- /dev/null +++ b/apps/worker/src/services/site-metadata.ts @@ -0,0 +1,87 @@ +import { safeJsonParse } from "../utils/json"; +import { normalizeBaseUrl } from "../utils/url"; + +export type SiteType = + | "new-api" + | "done-hub" + | "subapi" + | "chatgpt" + | "claude" + | "gemini"; + +export type EndpointOverrides = { + chat_url?: string | null; + image_url?: string | null; + embedding_url?: string | null; +}; + +export type SiteMetadata = { + site_type: SiteType; + endpoint_overrides: EndpointOverrides; +}; + +const DEFAULT_SITE_TYPE: SiteType = "new-api"; + +const normalizeOverride = (value: unknown): string | null => { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + return normalizeBaseUrl(trimmed); +}; + +export function parseSiteMetadata( + raw: string | null | undefined, +): SiteMetadata { + const parsed = safeJsonParse>(raw, {}); + const rawType = parsed.site_type; + const site_type = + rawType === "done-hub" || + rawType === "new-api" || + rawType === "subapi" || + rawType === "chatgpt" || + rawType === "claude" || + rawType === "gemini" + ? (rawType as SiteType) + : rawType === "custom" + ? "subapi" + : DEFAULT_SITE_TYPE; + const overrides = + parsed.endpoint_overrides && typeof parsed.endpoint_overrides === "object" + ? (parsed.endpoint_overrides as Record) + : {}; + return { + site_type, + endpoint_overrides: { + chat_url: normalizeOverride(overrides.chat_url), + image_url: normalizeOverride(overrides.image_url), + embedding_url: normalizeOverride(overrides.embedding_url), + }, + }; +} + +export function buildSiteMetadata( + existing: string | null | undefined, + updates: { + site_type?: SiteType; + endpoint_overrides?: EndpointOverrides | null; + }, +): string | null { + const base = safeJsonParse>(existing, {}); + if (updates.site_type) { + base.site_type = updates.site_type; + } + if (updates.endpoint_overrides) { + base.endpoint_overrides = { + chat_url: normalizeOverride(updates.endpoint_overrides.chat_url), + image_url: normalizeOverride(updates.endpoint_overrides.image_url), + embedding_url: normalizeOverride( + updates.endpoint_overrides.embedding_url, + ), + }; + } + return Object.keys(base).length > 0 ? JSON.stringify(base) : null; +} diff --git a/helloagents/CHANGELOG.md b/helloagents/CHANGELOG.md index 96d9378..c9dd760 100644 --- a/helloagents/CHANGELOG.md +++ b/helloagents/CHANGELOG.md @@ -100,6 +100,23 @@ ### 变更 +- **[sites/admin-ui]**: 移除历史签到记录关联入口与 orphans 返回,简化站点签到提示 + - 类型: 微调(无方案包) + - 文件: apps/worker/src/routes/sites.ts, apps/ui/src/features/SitesView.tsx, apps/ui/src/App.tsx, apps/ui/src/core/types.ts, apps/ui/src/core/constants.ts, helloagents/modules/sites.md, helloagents/modules/checkin.md, helloagents/modules/admin-ui.md +- **[checkin/db]**: 移除 `checkin_sites` 表与 `/api/checkin-sites` 路由,签到字段迁移至 `channels` + - 类型: 微调(无方案包) + - ⚠️ EHRB: 数据删除 - 用户已确认风险 + - 检测依据: DROP TABLE + - 文件: apps/worker/src/index.ts, apps/worker/src/routes/sites.ts, apps/worker/src/services/checkin.ts, apps/worker/src/services/channel-repo.ts, apps/worker/src/services/channel-types.ts, apps/worker/src/db/schema.sql, apps/worker/migrations/0005_move_checkin_to_channels.sql, helloagents/modules/checkin.md, helloagents/modules/sites.md +- **[sites/proxy/admin-ui]**: 移除调用令牌的模型权限字段,改为按顺序选择令牌 + - 类型: 微调(无方案包) + - 文件: apps/worker/src/routes/sites.ts, apps/worker/src/routes/proxy.ts, apps/worker/src/services/call-token-selector.ts, apps/worker/src/services/channel-call-token-repo.ts, apps/worker/src/services/channel-call-token-types.ts, apps/ui/src/features/SitesView.tsx, apps/ui/src/App.tsx, apps/ui/src/core/types.ts, apps/ui/src/core/constants.ts, tests/worker/call-token-selector.test.ts, helloagents/modules/admin-ui.md, helloagents/modules/proxy.md, helloagents/plan/202602221951_site-redesign/proposal.md +- **[admin-ui]**: 新增站点默认开启自动签到,并限制调用令牌列表高度 + - 类型: 微调(无方案包) + - 文件: apps/ui/src/core/constants.ts, apps/ui/src/features/SitesView.tsx, helloagents/modules/admin-ui.md +- **[admin-ui]**: 站点弹窗设置最大高度并允许表单滚动,避免调用令牌过多导致超出屏幕 + - 类型: 微调(无方案包) + - 文件: apps/ui/src/features/SitesView.tsx - **[checkin]**: 对齐 `/api/user/checkin` 接口并增加 `New-Api-User` 头支持 - 类型: 微调(无方案包) - 文件: apps/worker/src/services/checkin.ts, apps/worker/src/routes/checkin-sites.ts, apps/ui/src/features/CheckinSitesView.tsx, apps/worker/migrations/0002_add_checkin_sites.sql @@ -122,6 +139,43 @@ - 类型: 微调(无方案包) - 文件: apps/worker/migrations/0002_add_checkin_sites.sql +## [0.7.1] - 2026-02-22 + +### 变更 +- **[db]**: 合并 0002-0005 迁移为单一 0002 并同步 schema + - 方案: [202602222203_merge-migrations](archive/2026-02/202602222203_merge-migrations/) + +## [0.7.0] - 2026-02-22 + +### 变更 +- **[sites]**: 站点重设计,支持系统令牌与多调用令牌、上游类型扩展与官方地址默认值 + - 方案: [202602221951_site-redesign](archive/2026-02/202602221951_site-redesign/) + - 决策: site-redesign#D001(调用令牌独立表), site-redesign#D002(系统令牌复用 checkin_sites), site-redesign#D003(done-hub 仅默认地址) +- **[proxy]**: 按调用令牌模型权限选择上游并移除 done-hub 多地址切换 + - 方案: [202602221951_site-redesign](archive/2026-02/202602221951_site-redesign/) +- **[admin-ui]**: 站点管理重构为系统令牌 + 多调用令牌,并支持新上游类型 + - 方案: [202602221951_site-redesign](archive/2026-02/202602221951_site-redesign/) +- **[checkin]**: 签到支持自定义地址优先并保留 new-api 自动签到 + - 方案: [202602221951_site-redesign](archive/2026-02/202602221951_site-redesign/) +- **[db]**: 新增 `channel_call_tokens` 以存储多调用令牌 + - 方案: [202602221951_site-redesign](archive/2026-02/202602221951_site-redesign/) + +## [0.6.0] - 2026-02-22 + +### 变更 + +- **[sites]**: 新增站点聚合接口与类型分类,支持自动关联签到记录 + - 方案: [202602221813_unify-sites](archive/2026-02/202602221813_unify-sites/) + - 决策: unify-sites#D001(渠道为主 + 签到绑定) +- **[admin-ui]**: 站点管理合并渠道与签到配置,支持类型字段与一键签到 + - 方案: [202602221813_unify-sites](archive/2026-02/202602221813_unify-sites/) +- **[checkin]**: 签到记录关联渠道,仅对 new-api 站点执行 + - 方案: [202602221813_unify-sites](archive/2026-02/202602221813_unify-sites/) +- **[proxy]**: done-hub 站点按多地址配置选择上游 + - 方案: [202602221813_unify-sites](archive/2026-02/202602221813_unify-sites/) +- **[db]**: checkin_sites 增加 channel_id 关联字段与索引 + - 方案: [202602221813_unify-sites](archive/2026-02/202602221813_unify-sites/) + ## [0.5.0] - 2026-02-22 ### 新增 diff --git a/helloagents/INDEX.md b/helloagents/INDEX.md index e809046..839be1c 100644 --- a/helloagents/INDEX.md +++ b/helloagents/INDEX.md @@ -16,9 +16,9 @@ ## 知识库状态 ```yaml -最后更新: 2026-02-21 23:47 -模块数量: 10 -待执行方案: 9 +最后更新: 2026-02-22 19:00 +模块数量: 11 +待执行方案: 6 ``` ## 读取指引 diff --git a/helloagents/modules/_index.md b/helloagents/modules/_index.md index 1fee325..ad74c16 100644 --- a/helloagents/modules/_index.md +++ b/helloagents/modules/_index.md @@ -9,6 +9,7 @@ | auth | 管理员登录与会话 | 🚧 | [auth.md](./auth.md) | | proxy | OpenAI 兼容代理与转发 | 🚧 | [proxy.md](./proxy.md) | | channels | 渠道管理与健康检查 | 🚧 | [channels.md](./channels.md) | +| sites | 站点聚合管理 | 🚧 | [sites.md](./sites.md) | | models | 模型广场与聚合 | 🚧 | [models.md](./models.md) | | tokens | 令牌管理与额度 | 🚧 | [tokens.md](./tokens.md) | | usage | 使用日志与留存 | 🚧 | [usage.md](./usage.md) | @@ -21,7 +22,8 @@ ## 模块依赖关系 ``` -admin-ui → auth, channels, models, tokens, usage, dashboard, settings, checkin +admin-ui → auth, sites, models, tokens, usage, dashboard, settings +sites → channels, checkin proxy → channels, tokens, usage dashboard → usage, tokens models → channels diff --git a/helloagents/modules/admin-ui.md b/helloagents/modules/admin-ui.md index 92f0e23..ed788aa 100644 --- a/helloagents/modules/admin-ui.md +++ b/helloagents/modules/admin-ui.md @@ -2,7 +2,7 @@ ## 职责 - 管理台前端展示与操作 -- 覆盖渠道、模型、令牌、日志、面板与设置 +- 覆盖站点、模型、令牌、日志、面板与设置 ## 接口定义 - 调用 `/api/*` 管理接口 @@ -16,12 +16,15 @@ - 按 `core/`(类型/常量/API)、`features/`(业务视图 + 布局/登录)组织前端模块 - 样式基于 Tailwind v4(结合全局主题变量) - 前端状态集中在单页 -- 渠道管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作 +- 站点管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作 +- 站点类型支持 new-api / done-hub / subapi / chatgpt / claude / gemini,类型驱动表单字段 +- 站点表单提供系统令牌、userid、自动签到开关与外部签到地址(new-api 默认开启) +- 站点支持多调用令牌(备注 + 令牌值) +- chatgpt/claude/gemini 站点支持 base_url 为空时自动填充官方地址 +- done-hub 站点仅使用 base_url,不再维护多地址输入 - 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度 - 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀 -- 签到站点采用列表视图(默认展示全部),支持新增/编辑弹窗、一键签到与手动签到 -- 签到站点列表展示今日签到结果(基于后台写回的 `last_checkin_*`) -- 今日签到状态区分“未签到”与“签到失败” +- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`) - 系统设置增加定时签到开关与时间(中国时间) - 列表分页页脚统一使用页码导航 + 每页条数选择,并显示总页数,顶部不显示条数与每页选择 - Token 仅本地保存,不回显 diff --git a/helloagents/modules/channels.md b/helloagents/modules/channels.md index bc46ca8..d145bc1 100644 --- a/helloagents/modules/channels.md +++ b/helloagents/modules/channels.md @@ -11,6 +11,7 @@ - `PATCH /api/channels/:id` 更新渠道 - `DELETE /api/channels/:id` 删除渠道 - `POST /api/channels/:id/test` 连通性测试 +- `GET /api/sites` 站点聚合列表(聚合 channels + channel_call_tokens) - `GET /api/channel` New API 兼容列表 - `GET /api/channel/search` New API 兼容搜索 - `POST /api/channel` New API 兼容新增 @@ -31,6 +32,8 @@ - New API 兼容层支持 `type`/`group`/`priority` 等字段映射,并保留扩展字段到 `metadata_json` - New API 标签(tag)存储在 `metadata_json.tag`,标签接口按该字段批量更新 - New API 分组列表从 `group_name` 字段解析,空时返回 `default` +- 站点类型写入 `metadata_json.site_type`(new-api / done-hub / subapi / chatgpt / claude / gemini) +- done-hub 仅使用 `base_url`,不再维护多地址配置 ## 依赖关系 - `channels` 表 diff --git a/helloagents/modules/checkin.md b/helloagents/modules/checkin.md index b143f32..3b24eec 100644 --- a/helloagents/modules/checkin.md +++ b/helloagents/modules/checkin.md @@ -7,20 +7,18 @@ - 写回今日签到结果到站点表 ## 接口定义 -- `GET /api/checkin-sites`: 获取签到站点列表 -- `POST /api/checkin-sites`: 新增签到站点 -- `PATCH /api/checkin-sites/:id`: 更新签到站点 -- `DELETE /api/checkin-sites/:id`: 删除签到站点 -- `POST /api/checkin-sites/checkin-all`: 一键签到(仅启用站点) +- `POST /api/sites/checkin-all`: 站点一键签到(仅 new-api + 自动签到开启) ## 行为规范 - 一键签到仅执行 `status=active` 的站点 +- 仅对 `site_type=new-api` 且 `checkin_enabled=1` 的站点执行签到 +- `checkin_sites` CRUD 接口已移除,签到字段统一由 `channels` 承载 - 一键签到会跳过 `last_checkin_date` 已是北京时间当日且状态为成功/已签的站点 - 手动签到优先打开 `checkin_url`,为空则打开 `base_url` -- 签到执行使用后端代理调用 `{base_url}/api/user/checkin`(GET 检查,POST 签到) +- 签到执行使用 `checkin_url`(若为空则回退 `base_url`)拼接 `/api/user/checkin` - 请求头必须携带 `New-Api-User`(值来自站点的 `userid` 配置) -- 签到结果写回 `checkin_sites.last_checkin_*` 字段(date/status/message/at) +- 签到结果写回 `channels.last_checkin_*` 字段(date/status/message/at) ## 依赖关系 -- `worker` 路由与 D1 数据表 `checkin_sites` +- `worker` 路由与 D1 数据表 `channels` - `admin-ui` 负责前端交互与结果展示 diff --git a/helloagents/modules/proxy.md b/helloagents/modules/proxy.md index 78d324c..7db817a 100644 --- a/helloagents/modules/proxy.md +++ b/helloagents/modules/proxy.md @@ -11,6 +11,8 @@ ## 行为规范 - 基于令牌鉴权 - 按渠道权重随机排序 +- 调用令牌按顺序选择,未配置时回退 `channels.api_key` +- done-hub 仅使用 `base_url`,不再按多地址切换 - 支持从非流式 JSON、响应头与流式 SSE 解析 usage 字段 - 流式请求自动补 `stream_options.include_usage=true` 以便上游返回 usage - 对 `/v1/responses` 且上游返回 400/404 时回退为 `/responses` 重试一次 diff --git a/helloagents/modules/sites.md b/helloagents/modules/sites.md new file mode 100644 index 0000000..bf86186 --- /dev/null +++ b/helloagents/modules/sites.md @@ -0,0 +1,29 @@ +# sites 模块 + +## 职责 +- 聚合渠道与系统令牌为统一“站点”视图 +- 提供站点级 CRUD、上游类型与多调用令牌管理 +- 提供站点一键签到入口(仅 new-api) + +## 接口定义 +- `GET /api/sites`: 获取站点列表 +- `POST /api/sites`: 新增站点(系统令牌 + 多调用令牌) +- `PATCH /api/sites/:id`: 更新站点信息、系统令牌与调用令牌 +- `DELETE /api/sites/:id`: 删除站点 +- `POST /api/sites/checkin-all`: 站点一键签到(仅 new-api) + +## 行为规范 +- 站点主记录来源于 `channels` 表 +- 系统令牌与签到配置存储在 `channels` 的 `system_token/system_userid/checkin_*` 字段 +- 调用令牌来源于 `channel_call_tokens` 表,按 `channel_id` 聚合 +- 站点类型写入 `channels.metadata_json.site_type` +- done-hub 仅使用 `base_url`,不再支持多地址 +- chatgpt/claude/gemini 在未填写 base_url 时自动使用官方地址 +- 一键签到仅执行满足以下条件的记录: + - `channels.status=active` + - `site_type=new-api` + - `checkin_enabled=1` + +## 依赖关系 +- `channels` / `channel_call_tokens` 表 +- `channels` / `checkin` / `proxy` 模块