forked from boos/BoosAPI
[codex] Split site API endpoint pool from panel host (#373)
* feat: add site api endpoint schema scaffolding * feat: wire site api endpoints into backup migration * feat: add site api endpoint selection service * feat: add site api endpoint contracts * feat: route discovery through site api endpoints * feat: route proxy traffic through site api endpoints * feat: add site api endpoint pool editor * fix: keep proxy video tasks on site endpoint pool * fix: restore core test compatibility for site endpoint pool * fix: address endpoint pool review findings
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildSiteSaveAction,
|
||||
emptySiteApiEndpoint,
|
||||
emptySiteCustomHeader,
|
||||
emptySiteForm,
|
||||
serializeSiteApiEndpoints,
|
||||
serializeSiteCustomHeaders,
|
||||
siteFormFromSite,
|
||||
} from './sitesEditor.js';
|
||||
@@ -17,6 +19,10 @@ describe('buildSiteSaveAction', () => {
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||
apiEndpoints: [
|
||||
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
|
||||
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
|
||||
],
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
useSystemProxy: false,
|
||||
globalWeight: 1.2,
|
||||
@@ -31,6 +37,10 @@ describe('buildSiteSaveAction', () => {
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||
apiEndpoints: [
|
||||
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
|
||||
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
|
||||
],
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
useSystemProxy: false,
|
||||
globalWeight: 1.2,
|
||||
@@ -48,6 +58,7 @@ describe('buildSiteSaveAction', () => {
|
||||
platform: 'one-api',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: true,
|
||||
apiEndpoints: [],
|
||||
customHeaders: '',
|
||||
globalWeight: 0.8,
|
||||
},
|
||||
@@ -63,6 +74,7 @@ describe('buildSiteSaveAction', () => {
|
||||
platform: 'one-api',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: true,
|
||||
apiEndpoints: [],
|
||||
customHeaders: '',
|
||||
globalWeight: 0.8,
|
||||
},
|
||||
@@ -80,6 +92,7 @@ describe('buildSiteSaveAction', () => {
|
||||
platform: '',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: false,
|
||||
apiEndpoints: [],
|
||||
customHeaders: '',
|
||||
globalWeight: 1,
|
||||
},
|
||||
@@ -94,6 +107,14 @@ describe('buildSiteSaveAction', () => {
|
||||
externalCheckinUrl: null,
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'http://127.0.0.1:8080',
|
||||
apiEndpoints: [
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
enabled: false,
|
||||
cooldownUntil: '2026-04-01T00:05:00.000Z',
|
||||
lastFailureReason: 'HTTP 502',
|
||||
},
|
||||
],
|
||||
customHeaders: '{"x-site-token":"alpha"}',
|
||||
globalWeight: 1,
|
||||
apiKey: 'sk-legacy-site-key',
|
||||
@@ -101,11 +122,20 @@ describe('buildSiteSaveAction', () => {
|
||||
|
||||
expect(emptySiteForm()).not.toHaveProperty('apiKey');
|
||||
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
|
||||
expect(emptySiteForm().apiEndpoints).toEqual([emptySiteApiEndpoint()]);
|
||||
expect(emptySiteForm().proxyUrl).toBe('');
|
||||
expect(siteFormFromSite(legacySite)).not.toHaveProperty('apiKey');
|
||||
expect(siteFormFromSite({
|
||||
proxyUrl: 'http://127.0.0.1:8080',
|
||||
}).proxyUrl).toBe('http://127.0.0.1:8080');
|
||||
expect(siteFormFromSite(legacySite).apiEndpoints).toEqual([
|
||||
{
|
||||
url: 'https://api.example.com',
|
||||
enabled: false,
|
||||
cooldownUntil: '2026-04-01T00:05:00.000Z',
|
||||
lastFailureReason: 'HTTP 502',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses custom headers json into key value rows', () => {
|
||||
@@ -139,4 +169,29 @@ describe('buildSiteSaveAction', () => {
|
||||
error: '请求头 "authorization" 重复了',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes api endpoint rows into ordered payloads', () => {
|
||||
expect(serializeSiteApiEndpoints([
|
||||
{ url: 'https://api-a.example.com/', enabled: true },
|
||||
{ url: 'https://api-b.example.com', enabled: false },
|
||||
emptySiteApiEndpoint(),
|
||||
])).toEqual({
|
||||
valid: true,
|
||||
apiEndpoints: [
|
||||
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
|
||||
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate api endpoints after normalization', () => {
|
||||
expect(serializeSiteApiEndpoints([
|
||||
{ url: 'https://api.example.com/', enabled: true },
|
||||
{ url: 'https://api.example.com', enabled: true },
|
||||
])).toEqual({
|
||||
valid: false,
|
||||
apiEndpoints: [],
|
||||
error: 'AI 请求地址 "https://api.example.com" 重复了',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,14 @@ export type SiteCustomHeaderField = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type SiteApiEndpointField = {
|
||||
draftId?: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
cooldownUntil?: string | null;
|
||||
lastFailureReason?: string | null;
|
||||
};
|
||||
|
||||
export type SiteForm = {
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -10,6 +18,7 @@ export type SiteForm = {
|
||||
platform: string;
|
||||
proxyUrl: string;
|
||||
useSystemProxy: boolean;
|
||||
apiEndpoints: SiteApiEndpointField[];
|
||||
customHeaders: SiteCustomHeaderField[];
|
||||
globalWeight: string;
|
||||
};
|
||||
@@ -26,6 +35,11 @@ export type SiteSavePayload = {
|
||||
initializationPresetId?: string | null;
|
||||
proxyUrl: string;
|
||||
useSystemProxy: boolean;
|
||||
apiEndpoints: Array<{
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
customHeaders: string;
|
||||
globalWeight: number;
|
||||
};
|
||||
@@ -38,6 +52,15 @@ export function emptySiteCustomHeader(): SiteCustomHeaderField {
|
||||
return { key: '', value: '' };
|
||||
}
|
||||
|
||||
export function emptySiteApiEndpoint(): SiteApiEndpointField {
|
||||
return {
|
||||
url: '',
|
||||
enabled: true,
|
||||
cooldownUntil: null,
|
||||
lastFailureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureSiteCustomHeaderRows(rows: SiteCustomHeaderField[]): SiteCustomHeaderField[] {
|
||||
return rows.length > 0 ? rows : [emptySiteCustomHeader()];
|
||||
}
|
||||
@@ -50,11 +73,16 @@ export function emptySiteForm(): SiteForm {
|
||||
platform: '',
|
||||
proxyUrl: '',
|
||||
useSystemProxy: false,
|
||||
apiEndpoints: [emptySiteApiEndpoint()],
|
||||
customHeaders: [emptySiteCustomHeader()],
|
||||
globalWeight: '1',
|
||||
};
|
||||
}
|
||||
|
||||
function ensureSiteApiEndpointRows(rows: SiteApiEndpointField[]): SiteApiEndpointField[] {
|
||||
return rows.length > 0 ? rows : [emptySiteApiEndpoint()];
|
||||
}
|
||||
|
||||
function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
|
||||
if (typeof raw !== 'string') {
|
||||
return ensureSiteCustomHeaderRows([]);
|
||||
@@ -80,10 +108,35 @@ function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
|
||||
function parseApiEndpointsForEditor(raw: unknown): SiteApiEndpointField[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return ensureSiteApiEndpointRows([]);
|
||||
}
|
||||
|
||||
const rows: SiteApiEndpointField[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
|
||||
const row = item as Record<string, unknown>;
|
||||
rows.push({
|
||||
url: typeof row.url === 'string' ? row.url : '',
|
||||
enabled: row.enabled !== false,
|
||||
cooldownUntil: typeof row.cooldownUntil === 'string' ? row.cooldownUntil : null,
|
||||
lastFailureReason: typeof row.lastFailureReason === 'string' ? row.lastFailureReason : null,
|
||||
});
|
||||
}
|
||||
return ensureSiteApiEndpointRows(rows);
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'apiEndpoints' | 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
|
||||
externalCheckinUrl?: string | null;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
apiEndpoints?: Array<{
|
||||
url?: string | null;
|
||||
enabled?: boolean | null;
|
||||
cooldownUntil?: string | null;
|
||||
lastFailureReason?: string | null;
|
||||
}> | null;
|
||||
customHeaders?: string | null;
|
||||
globalWeight?: number | string | null;
|
||||
}): SiteForm {
|
||||
@@ -96,11 +149,27 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' |
|
||||
platform: site.platform ?? '',
|
||||
proxyUrl: site.proxyUrl ?? '',
|
||||
useSystemProxy: !!site.useSystemProxy,
|
||||
apiEndpoints: parseApiEndpointsForEditor(site.apiEndpoints),
|
||||
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
|
||||
globalWeight,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep this in sync with normalizeSiteApiEndpointBaseUrl in
|
||||
// src/server/services/siteApiEndpointService.ts.
|
||||
function normalizeSiteApiEndpointUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
|
||||
valid: boolean;
|
||||
customHeaders: string;
|
||||
@@ -131,6 +200,48 @@ export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeSiteApiEndpoints(fields: SiteApiEndpointField[]): {
|
||||
valid: boolean;
|
||||
apiEndpoints: Array<{
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}>;
|
||||
error?: string;
|
||||
} {
|
||||
const apiEndpoints: Array<{
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const field of fields) {
|
||||
const rawUrl = field.url.trim();
|
||||
if (!rawUrl) continue;
|
||||
const normalizedUrl = normalizeSiteApiEndpointUrl(rawUrl);
|
||||
if (!normalizedUrl) continue;
|
||||
if (seen.has(normalizedUrl)) {
|
||||
return {
|
||||
valid: false,
|
||||
apiEndpoints: [],
|
||||
error: `AI 请求地址 "${normalizedUrl}" 重复了`,
|
||||
};
|
||||
}
|
||||
seen.add(normalizedUrl);
|
||||
apiEndpoints.push({
|
||||
url: normalizedUrl || rawUrl,
|
||||
enabled: field.enabled !== false,
|
||||
sortOrder: apiEndpoints.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
apiEndpoints,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteSavePayload): SiteSaveAction {
|
||||
if (editor.mode === 'edit') {
|
||||
if (!Number.isFinite(editor.editingSiteId)) {
|
||||
|
||||
Reference in New Issue
Block a user