refactor: remove deprecated apiKey from site management and update related functionality

- Removed apiKey from SiteRow type and related forms in Sites component.
- Updated site saving logic to handle site creation without apiKey.
- Adjusted tests to ensure apiKey is not included in site editor state.
- Refactored Tokens component to support embedded mode and redirect logic.
- Added migration tests for site apiKey to account migration service.
- Implemented siteApiKeyMigrationService to handle migration of apiKey to account tokens.
- Enhanced tests for segmented connections in Accounts and redirect logic for Tokens.
This commit is contained in:
cita
2026-03-07 15:03:30 +08:00
parent a98a6f2c18
commit c31bbcbb51
30 changed files with 1960 additions and 592 deletions
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { and, eq, sql } from 'drizzle-orm';
import { mergeAccountExtraConfig } from '../../services/accountExtraConfig.js';
const getApiTokensMock = vi.fn();
const getApiTokenMock = vi.fn();
@@ -137,6 +138,80 @@ describe('account tokens sync routes with site status', () => {
expect(tokenRows.length).toBe(0);
});
it('rejects sync and token management for apikey connections', async () => {
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
await db.update(schema.accounts)
.set({
apiToken: 'sk-proxy-only',
checkinEnabled: false,
extraConfig: mergeAccountExtraConfig(null, { credentialMode: 'apikey' }),
})
.where(eq(schema.accounts.id, account.id))
.run();
const syncResponse = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(syncResponse.statusCode).toBe(400);
expect(syncResponse.json()).toMatchObject({
success: false,
message: 'API Key 连接不支持同步账号令牌',
});
const createResponse = await app.inject({
method: 'POST',
url: '/api/account-tokens',
payload: {
accountId: account.id,
name: 'should-fail',
},
});
expect(createResponse.statusCode).toBe(400);
expect(createResponse.json()).toMatchObject({
success: false,
message: 'API Key 连接不支持创建账号令牌',
});
const groupsResponse = await app.inject({
method: 'GET',
url: `/api/account-tokens/groups/${account.id}`,
});
expect(groupsResponse.statusCode).toBe(400);
expect(groupsResponse.json()).toMatchObject({
success: false,
message: 'API Key 连接不支持拉取账号令牌分组',
});
});
it('hides legacy mirrored tokens for apikey connections from list API', async () => {
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
await db.update(schema.accounts)
.set({
apiToken: 'sk-hidden-legacy',
checkinEnabled: false,
extraConfig: mergeAccountExtraConfig(null, { credentialMode: 'apikey' }),
})
.where(eq(schema.accounts.id, account.id))
.run();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-hidden-legacy',
enabled: true,
isDefault: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/account-tokens',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual([]);
});
it('sync-all skips disabled-site accounts and syncs active-site accounts', async () => {
const disabled = await seedAccount({ siteStatus: 'disabled' });
const active = await seedAccount({ siteStatus: 'active' });
+61 -2
View File
@@ -11,7 +11,7 @@ import {
syncTokensFromUpstream,
} from '../../services/accountTokenService.js';
import { getAdapter } from '../../services/platforms/index.js';
import { resolvePlatformUserId } from '../../services/accountExtraConfig.js';
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
type AccountWithSiteRow = {
@@ -82,6 +82,12 @@ function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled';
}
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
@@ -169,6 +175,20 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
};
}
if (isApiKeyConnection(row.accounts)) {
return {
...base,
status: 'skipped',
reason: 'apikey_connection',
message: 'apikey connection does not support account tokens',
synced: false,
created: 0,
updated: 0,
total: 0,
defaultTokenId: null,
};
}
if (!row.accounts.accessToken) {
if (row.accounts.apiToken) {
ensureDefaultTokenForAccount(accountId, row.accounts.apiToken, { name: 'default', source: 'legacy' });
@@ -341,6 +361,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
if (isApiKeyConnection(row.accounts)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持创建账号令牌' });
}
const tokenValue = (body.token || '').trim();
if (tokenValue) {
const now = new Date().toISOString();
@@ -481,6 +505,14 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
}
const owner = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.accountId)).get();
if (!owner) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
if (isApiKeyConnection(owner)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
const body = request.body;
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
@@ -525,7 +557,18 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (Number.isNaN(tokenId)) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
}
const success = setDefaultToken(tokenId);
const tokenRow = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
if (!tokenRow) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
}
const owner = await db.select().from(schema.accounts).where(eq(schema.accounts.id, tokenRow.accountId)).get();
if (!owner) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
if (isApiKeyConnection(owner)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
const success = await setDefaultToken(tokenId);
if (!success) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
}
@@ -548,6 +591,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
}
if (isApiKeyConnection(row.accounts)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
const tokenValue = normalizeTokenForDisplay(row.account_tokens.token, row.sites.platform);
return {
success: true,
@@ -573,6 +620,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
if (isApiKeyConnection(row.accounts)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持拉取账号令牌分组' });
}
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
@@ -612,6 +663,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
}
if (isApiKeyConnection(row.accounts)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
const existing = row.account_tokens;
const account = row.accounts;
const site = row.sites;
@@ -657,6 +712,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const result = await executeAccountTokenSync(row);
appendTokenSyncEvent(result);
if (result.status === 'skipped' && result.reason === 'apikey_connection') {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持同步账号令牌' });
}
if (result.status === 'failed' && result.reason === 'unsupported_platform') {
return reply.code(400).send({ success: false, message: result.message });
}
@@ -727,6 +785,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
success: true,
token: row
? (() => {
if (isApiKeyConnection(row.accounts)) return null;
const { token: rawToken, ...meta } = row.account_tokens;
return { ...meta, tokenMasked: maskToken(rawToken, row.sites.platform) };
})()
@@ -122,6 +122,9 @@ describe('accounts credential mode', () => {
expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
expect(accounts[0]?.checkinEnabled).toBe(false);
const accountTokens = await db.select().from(schema.accountTokens).all();
expect(accountTokens).toHaveLength(0);
const parsedExtra = JSON.parse(accounts[0]?.extraConfig || '{}') as { credentialMode?: string };
expect(parsedExtra.credentialMode).toBe('apikey');
});
+10 -3
View File
@@ -1058,7 +1058,7 @@ export async function accountsRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '创建账号失败' });
}
if (apiToken) {
if (tokenType === 'session' && apiToken) {
try {
await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
} catch { }
@@ -1161,7 +1161,15 @@ export async function accountsRoutes(app: FastifyInstance) {
updates.updatedAt = new Date().toISOString();
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
const nextAccessToken = typeof updates.accessToken === 'string' ? updates.accessToken : account.accessToken;
const nextExtraConfig = typeof updates.extraConfig === 'string' ? updates.extraConfig : account.extraConfig;
const explicitNextMode = getCredentialModeFromExtraConfig(nextExtraConfig);
const nextCredentialMode =
explicitNextMode && explicitNextMode !== 'auto'
? explicitNextMode
: (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey');
if (nextCredentialMode !== 'apikey' && typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
try {
await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
} catch { }
@@ -1253,4 +1261,3 @@ export async function accountsRoutes(app: FastifyInstance) {
}
});
}
+1 -5
View File
@@ -96,7 +96,6 @@ export async function sitesRoutes(app: FastifyInstance) {
name: string;
url: string;
platform?: string;
apiKey?: string;
proxyUrl?: string | null;
externalCheckinUrl?: string | null;
status?: string;
@@ -104,7 +103,7 @@ export async function sitesRoutes(app: FastifyInstance) {
sortOrder?: number;
globalWeight?: number;
} }>('/api/sites', async (request, reply) => {
const { name, url, platform, apiKey, proxyUrl, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
const { name, url, platform, proxyUrl, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
@@ -145,7 +144,6 @@ export async function sitesRoutes(app: FastifyInstance) {
name,
url: url.replace(/\/+$/, ''),
platform: detectedPlatform,
apiKey,
proxyUrl: parsedProxyUrl.proxyUrl,
externalCheckinUrl: normalizedExternalCheckinUrl.url,
status: normalizedStatus ?? 'active',
@@ -170,7 +168,6 @@ export async function sitesRoutes(app: FastifyInstance) {
name?: string;
url?: string;
platform?: string;
apiKey?: string;
proxyUrl?: string | null;
externalCheckinUrl?: string | null;
status?: string;
@@ -218,7 +215,6 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform;
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
if (parsedProxyUrl.present) updates.proxyUrl = parsedProxyUrl.proxyUrl;
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
if (body.status !== undefined) updates.status = normalizedStatus;
@@ -363,4 +363,81 @@ describe('/api/models/token-candidates', () => {
},
]);
});
it('does not pass deprecated site apiKey into pricing catalog lookup', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-e',
url: 'https://site-e.example.com',
platform: 'new-api',
status: 'active',
apiKey: 'sk-site-fallback',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'erin',
accessToken: '',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-default-e',
tokenGroup: 'default',
enabled: true,
isDefault: true,
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'claude-opus-4-6',
available: true,
}).run();
await db.insert(schema.tokenModelAvailability).values({
tokenId: token.id,
modelName: 'claude-opus-4-6',
available: true,
}).run();
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
{
modelName: 'claude-opus-4-6',
quotaType: 0,
modelDescription: null,
tags: [],
supportedEndpointTypes: [],
ownerBy: null,
enableGroups: ['default', 'vip'],
groupPricing: {},
},
],
groupRatio: { default: 1, vip: 2 },
});
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
expect(fetchModelPricingCatalogMock).toHaveBeenCalled();
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]).toMatchObject({
site: {
id: site.id,
url: site.url,
platform: site.platform,
},
account: {
id: account.id,
accessToken: '',
apiToken: null,
},
});
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]?.site?.apiKey).toBeUndefined();
});
});
-2
View File
@@ -332,7 +332,6 @@ export async function statsRoutes(app: FastifyInstance) {
id: row.sites.id,
url: row.sites.url,
platform: row.sites.platform,
apiKey: row.sites.apiKey,
},
account: {
id: row.accounts.id,
@@ -663,7 +662,6 @@ export async function statsRoutes(app: FastifyInstance) {
id: row.sites.id,
url: row.sites.url,
platform: row.sites.platform,
apiKey: row.sites.apiKey,
},
account: {
id: row.accounts.id,
@@ -955,7 +955,6 @@ export async function resolveUpstreamEndpointCandidates(
id: context.site.id,
url: context.site.url,
platform: context.site.platform,
apiKey: context.site.apiKey ?? null,
},
account: {
id: context.account.id,