Refactor proxy-core surfaces and transformer boundaries (#163)

* refactor: add canonical request contracts

* test: harden transformer boundary contracts

* refactor: adapt protocol transformers to canonical contract

* refactor: add cli profile registry

* refactor: extract provider profiles

* refactor: add conductor and provider executors

* refactor: add models surface

* refactor: migrate responses surface

* refactor: migrate chat surface

* refactor: migrate gemini surface

* refactor: migrate file surfaces

* test: align runtime executor undici mock

* fix: preserve compact SSE closeout payloads

* feat: support all-api-hub v2 backup imports

* fix: aggregate codex oauth SSE final payloads

* fix: harden responses SSE failure handling
This commit is contained in:
Cita
2026-03-19 16:07:56 +08:00
committed by GitHub
parent c0c993f448
commit 24483c28eb
75 changed files with 8333 additions and 3574 deletions
+225
View File
@@ -0,0 +1,225 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import ImportExport from './ImportExport.js';
const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: {
exportBackup: vi.fn(),
importBackup: vi.fn(),
},
toastMock: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
toast: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/Toast.js', () => ({
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
useToast: () => toastMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
const allApiHubV2Payload = JSON.stringify({
version: '2.0',
timestamp: 1735689600000,
accounts: {
accounts: [
{
id: 'managed-account',
site_url: 'https://newapi.example.com',
site_type: 'new-api',
site_name: 'Managed Site',
authType: 'access_token',
account_info: {
id: 7788,
username: 'managed-user',
access_token: 'managed-session-token',
},
},
{
id: 'direct-account',
site_url: 'https://api.openai.com',
site_type: 'openai',
site_name: 'OpenAI Direct',
authType: 'access_token',
account_info: {
username: 'openai-user',
access_token: 'sk-openai-account',
},
},
],
bookmarks: [
{
id: 'bookmark-1',
name: 'Ignored Bookmark',
url: 'https://bookmark.example.com',
},
],
pinnedAccountIds: ['direct-account'],
orderedAccountIds: ['managed-account', 'direct-account'],
last_updated: 1735689601000,
},
preferences: {
language: 'zh-CN',
},
channelConfigs: {
bySite: {
demo: { enabled: true },
},
},
tagStore: {
version: 1,
tagsById: {},
},
apiCredentialProfiles: {
version: 2,
profiles: [
{
id: 'profile-openai',
name: 'OpenAI Profile',
apiType: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-profile-openai',
tagIds: [],
notes: '',
createdAt: 1735689602000,
updatedAt: 1735689603000,
},
{
id: 'profile-gemini',
name: 'Gemini Profile',
apiType: 'google',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
apiKey: 'gemini-profile-key',
tagIds: [],
notes: '',
createdAt: 1735689604000,
updatedAt: 1735689605000,
},
],
lastUpdated: 1735689606000,
},
});
describe('ImportExport', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('window', {
confirm: vi.fn(() => true),
});
apiMock.importBackup.mockResolvedValue({
allImported: true,
sections: {
accounts: true,
preferences: true,
},
appliedSettings: [],
summary: {
importedSites: 3,
importedAccounts: 2,
importedProfiles: 2,
importedApiKeyConnections: 3,
skippedAccounts: 1,
ignoredSections: ['accounts.bookmarks', 'channelConfigs', 'tagStore'],
},
warnings: ['跳过 ALL-API-Hub 账号 skipped-account:authType=none 不支持离线迁移'],
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('shows ALL-API-Hub V2 preview counts and ignored sections', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
const textarea = root!.root.findByType('textarea');
await act(async () => {
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
});
await flushMicrotasks();
const rendered = collectText(root!.root);
expect(rendered).toContain('ALL-API-Hub V2');
expect(rendered).toContain('统计:账号 2 / 书签 1 / 独立 API 凭据 2');
expect(rendered).toContain('accounts.bookmarks、channelConfigs、tagStore');
} finally {
root?.unmount();
}
});
it('uses backend import summary in the completion toast', async () => {
const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
const textarea = root!.root.findByType('textarea');
await act(async () => {
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
});
await flushMicrotasks();
const importButton = root!.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('导入')
)).at(-1);
expect(importButton).toBeTruthy();
await act(async () => {
importButton!.props.onClick();
});
await flushMicrotasks();
expect(confirmSpy).toHaveBeenCalled();
expect(apiMock.importBackup).toHaveBeenCalledTimes(1);
expect(toastMock.success).toHaveBeenCalledWith(
expect.stringContaining('站点 3 / 账号 2 / API Key 连接 3 / 跳过 1'),
);
expect(toastMock.success).toHaveBeenCalledWith(
expect.stringContaining('accounts.bookmarks、channelConfigs、tagStore'),
);
} finally {
root?.unmount();
}
});
});
+93 -33
View File
@@ -12,12 +12,16 @@ type ParsedSummary = {
hasAccounts: boolean;
hasPreferences: boolean;
hasLegacyData: boolean;
isAllApiHubV2: boolean;
sitesCount: number;
accountsCount: number;
bookmarksCount: number;
profilesCount: number;
tokensCount: number;
routesCount: number;
channelsCount: number;
settingsCount: number;
ignoredSections: string[];
};
function downloadJsonFile(data: unknown, filename: string) {
@@ -35,23 +39,29 @@ function downloadJsonFile(data: unknown, filename: string) {
function parseImportSummary(raw: string): ParsedSummary | null {
if (!raw.trim()) return null;
const invalidSummary = (): ParsedSummary => ({
valid: false,
version: '-',
timestampLabel: '未知',
hasAccounts: false,
hasPreferences: false,
hasLegacyData: false,
isAllApiHubV2: false,
sitesCount: 0,
accountsCount: 0,
bookmarksCount: 0,
profilesCount: 0,
tokensCount: 0,
routesCount: 0,
channelsCount: 0,
settingsCount: 0,
ignoredSections: [],
});
try {
const data = JSON.parse(raw) as any;
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return {
valid: false,
version: '-',
timestampLabel: '未知',
hasAccounts: false,
hasPreferences: false,
hasLegacyData: false,
sitesCount: 0,
accountsCount: 0,
tokensCount: 0,
routesCount: 0,
channelsCount: 0,
settingsCount: 0,
};
return invalidSummary();
}
const accountsSection = (data.accounts && typeof data.accounts === 'object' && !Array.isArray(data.accounts))
@@ -63,6 +73,20 @@ function parseImportSummary(raw: string): ParsedSummary | null {
const legacyAccounts = Boolean(data.data?.accounts || Array.isArray(data.accounts));
const legacyPrefs = Boolean(data.data?.preferences);
const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0;
const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0;
const isAllApiHubV2 = Boolean(
accountsSection
&& Array.isArray(accountsSection.accounts)
&& (
(typeof data.version === 'string' && data.version.startsWith('2'))
|| 'last_updated' in accountsSection
|| Array.isArray(accountsSection.bookmarks)
|| Array.isArray(accountsSection.pinnedAccountIds)
|| Array.isArray(accountsSection.orderedAccountIds)
|| profilesCount > 0
)
);
const hasAccounts = Boolean(
data.type === 'accounts'
@@ -74,6 +98,10 @@ function parseImportSummary(raw: string): ParsedSummary | null {
|| preferencesSection
|| legacyPrefs,
);
const ignoredSections: string[] = [];
if (bookmarksCount > 0) ignoredSections.push('accounts.bookmarks');
if (data.channelConfigs && typeof data.channelConfigs === 'object' && !Array.isArray(data.channelConfigs)) ignoredSections.push('channelConfigs');
if (data.tagStore && typeof data.tagStore === 'object' && !Array.isArray(data.tagStore)) ignoredSections.push('tagStore');
const toCount = (value: unknown): number => (Array.isArray(value) ? value.length : 0);
@@ -89,31 +117,52 @@ function parseImportSummary(raw: string): ParsedSummary | null {
hasAccounts,
hasPreferences,
hasLegacyData: legacyAccounts || legacyPrefs,
isAllApiHubV2,
sitesCount: toCount(accountsSection?.sites),
accountsCount: toCount(accountsSection?.accounts),
bookmarksCount,
profilesCount,
tokensCount: toCount(accountsSection?.accountTokens),
routesCount: toCount(accountsSection?.tokenRoutes),
channelsCount: toCount(accountsSection?.routeChannels),
settingsCount: toCount(preferencesSection?.settings),
ignoredSections,
};
} catch {
return {
valid: false,
version: '-',
timestampLabel: '未知',
hasAccounts: false,
hasPreferences: false,
hasLegacyData: false,
sitesCount: 0,
accountsCount: 0,
tokensCount: 0,
routesCount: 0,
channelsCount: 0,
settingsCount: 0,
};
return invalidSummary();
}
}
function buildImportSuccessMessage(result: any): string {
const sections: string[] = [];
if (result?.sections?.accounts) sections.push('账号与路由');
if (result?.sections?.preferences) sections.push('系统设置');
const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`];
if (result?.summary) {
const summary = result.summary;
parts.push(
[
`站点 ${summary.importedSites ?? 0}`,
`账号 ${summary.importedAccounts ?? 0}`,
`API Key 连接 ${summary.importedApiKeyConnections ?? summary.importedProfiles ?? 0}`,
`跳过 ${summary.skippedAccounts ?? 0}`,
].join(' / '),
);
if (Array.isArray(summary.ignoredSections) && summary.ignoredSections.length > 0) {
parts.push(`未原生导入 ${summary.ignoredSections.join('、')}`);
}
}
if (Array.isArray(result?.warnings) && result.warnings.length > 0) {
const preview = result.warnings.slice(0, 2).join(';');
parts.push(`提示:${preview}${result.warnings.length > 2 ? ` 等 ${result.warnings.length} 项` : ''}`);
}
return parts.join(';');
}
export default function ImportExport() {
const toast = useToast();
const [exportingType, setExportingType] = useState<BackupType | ''>('');
@@ -193,7 +242,10 @@ export default function ImportExport() {
toast.error('当前 JSON 结构无法识别');
return;
}
if (!window.confirm('导入会覆盖账号/路由或系统设置,确认继续?')) {
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
? true
: window.confirm('导入会覆盖账号/路由或系统设置,确认继续?');
if (!confirmed) {
return;
}
@@ -201,10 +253,7 @@ export default function ImportExport() {
try {
const parsed = JSON.parse(importData);
const result = await api.importBackup(parsed);
const sections: string[] = [];
if (result?.sections?.accounts) sections.push('账号与路由');
if (result?.sections?.preferences) sections.push('系统设置');
toast.success(`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`);
toast.success(buildImportSuccessMessage(result));
setImportData('');
setSelectedFileName('');
} catch (err: any) {
@@ -379,6 +428,17 @@ export default function ImportExport() {
<>
<div>结构有效,版本:{summary.version},时间:{summary.timestampLabel}</div>
<div>包含分区:{summary.hasAccounts ? '账号路由' : ''}{summary.hasAccounts && summary.hasPreferences ? ' + ' : ''}{summary.hasPreferences ? '系统设置' : ''}</div>
{summary.isAllApiHubV2 ? (
<>
<div>检测到 ALL-API-Hub V2 兼容备份:将离线迁移可用连接。</div>
<div>
统计:账号 {summary.accountsCount} / 书签 {summary.bookmarksCount} / 独立 API 凭据 {summary.profilesCount}
</div>
{summary.ignoredSections.length ? (
<div>不会原生导入:{summary.ignoredSections.join('、')}</div>
) : null}
</>
) : null}
{(summary.sitesCount || summary.accountsCount || summary.tokensCount || summary.routesCount || summary.channelsCount || summary.settingsCount) ? (
<div>
统计:站点 {summary.sitesCount} / 账号 {summary.accountsCount} / 令牌 {summary.tokensCount} / 路由 {summary.routesCount} / 通道 {summary.channelsCount} / 设置 {summary.settingsCount}