From 5ee493778f6ac11e96c9598c55c58e4b10369348 Mon Sep 17 00:00:00 2001 From: Brucents Date: Fri, 20 Mar 2026 20:41:32 +0800 Subject: [PATCH] fix(routes): dedupe direct credentials and clarify binding labels --- src/server/db/index.ts | 2 + src/server/index.ts | 2 + .../services/modelService.discovery.test.ts | 49 ++++++++ src/server/services/modelService.test.ts | 61 ++++++++++ src/server/services/modelService.ts | 34 +++--- src/web/pages/Accounts.tsx | 17 ++- .../pages/token-routes/SortableChannelRow.tsx | 17 ++- .../tokenBindingPresentation.test.ts | 71 ++++++++++++ .../token-routes/tokenBindingPresentation.ts | 105 +++++++++++++++++- src/web/pages/token-routes/types.ts | 3 + 10 files changed, 331 insertions(+), 30 deletions(-) diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 900833f..ab951ff 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -182,6 +182,8 @@ function ensureTokenManagementSchema() { WHERE a.api_token IS NOT NULL AND trim(a.api_token) <> '' + AND a.access_token IS NOT NULL + AND trim(a.access_token) <> '' AND NOT EXISTS ( SELECT 1 FROM account_tokens AS t WHERE t.account_id = a.id diff --git a/src/server/index.ts b/src/server/index.ts index 8aa3dc0..c6b388d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,6 +22,7 @@ import { oauthRoutes } from './routes/api/oauth.js'; import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; +import { rebuildTokenRoutesFromAvailability } from './services/modelService.js'; import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; @@ -329,6 +330,7 @@ try { await repairStoredCreatedAtValues(); await migrateSiteApiKeysToAccounts(); await ensureDefaultSitesSeeded(); + await rebuildTokenRoutesFromAvailability(); console.log('Loaded runtime settings overrides'); } catch (error) { diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 904126c..9de6827 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -248,6 +248,55 @@ describe('refreshModelsForAccount credential discovery', () => { expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/); }); + it('does not scan hidden managed tokens for direct apikey connections', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( + token === 'sk-direct-credential' ? ['gpt-4.1'] : ['legacy-should-not-be-used'] + )); + + const site = await db.insert(schema.sites).values({ + name: 'apikey-direct-site', + url: 'https://apikey-direct.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'apikey-direct-user', + accessToken: '', + apiToken: 'sk-direct-credential', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + const hiddenToken = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'legacy-hidden', + token: 'sk-legacy-hidden', + source: 'legacy', + enabled: true, + isDefault: true, + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + modelCount: 1, + modelsPreview: ['gpt-4.1'], + tokenScanned: 0, + discoveredByCredential: true, + }); + + const tokenRows = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, hiddenToken.id)) + .all(); + expect(tokenRows).toHaveLength(0); + }); + it('returns structured result when account missing', async () => { const result = await refreshModelsForAccount(9999); diff --git a/src/server/services/modelService.test.ts b/src/server/services/modelService.test.ts index 00b6e7d..e88d15e 100644 --- a/src/server/services/modelService.test.ts +++ b/src/server/services/modelService.test.ts @@ -85,6 +85,67 @@ describe('rebuildTokenRoutesFromAvailability', () => { expect(channels[0]?.manualOverride).toBe(false); }); + it('ignores hidden account_tokens for direct apikey connections when rebuilding routes', async () => { + const site = await db.insert(schema.sites).values({ + name: 'apikey-legacy-site', + url: 'https://apikey-legacy.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'apikey-legacy-user', + accessToken: '', + apiToken: 'sk-direct-credential', + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + }).returning().get(); + + const hiddenToken = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'legacy-hidden', + token: 'sk-hidden-legacy-token', + source: 'legacy', + enabled: true, + isDefault: true, + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-4.1', + available: true, + latencyMs: 200, + checkedAt: '2026-03-20T08:00:00.000Z', + }).run(); + + await db.insert(schema.tokenModelAvailability).values({ + tokenId: hiddenToken.id, + modelName: 'gpt-4.1', + available: true, + latencyMs: 180, + checkedAt: '2026-03-20T08:00:00.000Z', + }).run(); + + const rebuild = await rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(1); + + const route = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.modelPattern, 'gpt-4.1')) + .get(); + expect(route).toBeDefined(); + + const channels = await db.select().from(schema.routeChannels) + .where(and( + eq(schema.routeChannels.routeId, route!.id), + eq(schema.routeChannels.accountId, account.id), + )) + .all(); + + expect(channels).toHaveLength(1); + expect(channels[0]?.tokenId ?? null).toBeNull(); + }); + it('creates an exact route with an account-direct channel for oauth model availability', async () => { const site = await db.insert(schema.sites).values({ name: 'codex-site', diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index d47ad94..321b171 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -10,8 +10,8 @@ import { isUsableAccountToken, } from './accountTokenService.js'; import { - getCredentialModeFromExtraConfig, mergeAccountExtraConfig, + requiresManagedAccountTokens, resolvePlatformUserId, supportsDirectAccountRoutingConnection, } from './accountExtraConfig.js'; @@ -141,12 +141,6 @@ 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 normalizeModels(models: string[]): string[] { const normalizedModels: string[] = []; const seen = new Set(); @@ -821,18 +815,21 @@ export async function refreshModelsForAccount(accountId: number): Promise isUsableAccountToken(row.account_tokens)); + const usableTokenRows = tokenRows.filter((row) => ( + isUsableAccountToken(row.account_tokens) + && requiresManagedAccountTokens(row.accounts) + )); const accountRows = await db.select().from(schema.modelAvailability) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 7bb9375..be27bce 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -1164,12 +1164,17 @@ export default function Accounts() { {isSub2ApiSelected && ( <> - setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))} - style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} - /> +
+ setTokenForm((f) => ({ ...f, refreshToken: e.target.value.trim() }))} + style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} + /> +
+ 可在浏览器控制台执行 localStorage.getItem('refresh_token') 获取。 +
+
@@ -113,10 +122,10 @@ export function SortableChannelRow({ className="badge" style={{ fontSize: 10, - background: tokenBinding.isFollowingAccountDefault + background: tokenBinding.badgeTone === 'info' ? 'color-mix(in srgb, var(--color-info) 15%, transparent)' : 'color-mix(in srgb, var(--color-warning) 15%, transparent)', - color: tokenBinding.isFollowingAccountDefault ? 'var(--color-info)' : 'var(--color-warning)', + color: tokenBinding.badgeTone === 'info' ? 'var(--color-info)' : 'var(--color-warning)', }} > {tokenBinding.bindingModeLabel} @@ -211,7 +220,7 @@ export function SortableChannelRow({ options={[ { value: '0', - label: '跟随账号默认', + label: tokenBinding.followOptionLabel, description: tokenBinding.followOptionDescription, }, ...tokenOptions.map((token) => ({ diff --git a/src/web/pages/token-routes/tokenBindingPresentation.test.ts b/src/web/pages/token-routes/tokenBindingPresentation.test.ts index d8d3ecc..5468f4c 100644 --- a/src/web/pages/token-routes/tokenBindingPresentation.test.ts +++ b/src/web/pages/token-routes/tokenBindingPresentation.test.ts @@ -3,6 +3,7 @@ import { buildFixedTokenOptionDescription, buildFixedTokenOptionLabel, describeTokenBinding, + resolveTokenBindingConnectionMode, } from './tokenBindingPresentation.js'; describe('tokenBindingPresentation', () => { @@ -13,8 +14,10 @@ describe('tokenBindingPresentation', () => { ], 0); expect(result.bindingModeLabel).toBe('跟随账号默认'); + expect(result.badgeTone).toBe('info'); expect(result.effectiveTokenName).toBe('token-a'); expect(result.helperText).toContain('以后账号默认变化时会自动切换'); + expect(result.followOptionLabel).toBe('跟随账号默认'); expect(result.followOptionDescription).toContain('当前生效:token-a'); }); @@ -25,6 +28,7 @@ describe('tokenBindingPresentation', () => { ], 1); expect(result.bindingModeLabel).toBe('固定令牌'); + expect(result.badgeTone).toBe('warning'); expect(result.effectiveTokenName).toBe('default'); expect(result.helperText).toContain('它目前也是账号默认'); expect(result.helperText).toContain('不会跟着变'); @@ -37,8 +41,10 @@ describe('tokenBindingPresentation', () => { ], 2); expect(result.bindingModeLabel).toBe('固定令牌'); + expect(result.badgeTone).toBe('warning'); expect(result.effectiveTokenName).toBe('backup'); expect(result.helperText).toContain('不会随账号默认变化'); + expect(result.followOptionLabel).toBe('跟随账号默认'); }); it('describes follow-account-default mode when no default token exists yet', () => { @@ -48,11 +54,76 @@ describe('tokenBindingPresentation', () => { ], 0); expect(result.bindingModeLabel).toBe('跟随账号默认'); + expect(result.badgeTone).toBe('info'); expect(result.effectiveTokenName).toBe('未设置默认令牌'); expect(result.helperText).toContain('当前账号还没有默认令牌'); + expect(result.followOptionLabel).toBe('跟随账号默认'); expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换'); }); + it('describes follow-api-setting mode for direct apikey connections', () => { + const result = describeTokenBinding([], 0, null, { + connectionMode: 'apikey', + accountName: 'elysiver_api', + }); + + expect(result.bindingModeLabel).toBe('API令牌'); + expect(result.badgeTone).toBe('warning'); + expect(result.isFollowingAccountDefault).toBe(false); + expect(result.effectiveTokenName).toBe('elysiver_api'); + expect(result.helperText).toContain('连接「elysiver_api」保存的 API Key'); + expect(result.followOptionLabel).toBe('固定使用:elysiver_api(跟随 API Key 设置)'); + expect(result.followOptionDescription).toContain('API Key'); + }); + + it('describes oauth direct binding without default-token wording', () => { + const result = describeTokenBinding([], 0, null, { + connectionMode: 'oauth', + accountName: 'mail@urlk.cn', + }); + + expect(result.bindingModeLabel).toBe('OAuth授权'); + expect(result.badgeTone).toBe('warning'); + expect(result.isFollowingAccountDefault).toBe(false); + expect(result.effectiveTokenName).toBe('mail@urlk.cn'); + expect(result.helperText).toContain('OAuth 授权'); + expect(result.helperText).not.toContain('默认令牌'); + expect(result.followOptionLabel).toBe('固定使用:mail@urlk.cn(OAuth 授权)'); + expect(result.followOptionDescription).toContain('OAuth 授权'); + }); + + it('uses oauth fallback copy when a fixed token route is switched back to oauth direct binding', () => { + const result = describeTokenBinding([ + { id: 1, name: 'default', isDefault: true }, + { id: 2, name: 'backup', isDefault: false }, + ], 2, null, { + connectionMode: 'oauth', + accountName: 'mail@urlk.cn', + }); + + expect(result.bindingModeLabel).toBe('固定令牌'); + expect(result.followOptionLabel).toBe('OAuth授权'); + expect(result.followOptionDescription).toContain('mail@urlk.cn'); + expect(result.followOptionDescription).not.toContain('当前生效:default'); + }); + + it('resolves token binding connection mode from stored account fields', () => { + expect(resolveTokenBindingConnectionMode({ + accessToken: '', + extraConfig: JSON.stringify({ credentialMode: 'apikey' }), + })).toBe('apikey'); + + expect(resolveTokenBindingConnectionMode({ + accessToken: 'session-token', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + })).toBe('session'); + + expect(resolveTokenBindingConnectionMode({ + accessToken: 'oauth-access-token', + extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }), + })).toBe('oauth'); + }); + it('formats fixed token options with clearer labels and descriptions', () => { const token = { id: 3, diff --git a/src/web/pages/token-routes/tokenBindingPresentation.ts b/src/web/pages/token-routes/tokenBindingPresentation.ts index 306db3c..a2e2f57 100644 --- a/src/web/pages/token-routes/tokenBindingPresentation.ts +++ b/src/web/pages/token-routes/tokenBindingPresentation.ts @@ -8,11 +8,93 @@ export type TokenBindingOption = { export type TokenBindingPresentation = { isFollowingAccountDefault: boolean; bindingModeLabel: string; + badgeTone: 'info' | 'warning'; effectiveTokenName: string; helperText: string; + followOptionLabel: string; followOptionDescription: string; }; +type TokenBindingConnectionMode = 'session' | 'apikey' | 'oauth'; + +type TokenBindingContext = { + connectionMode?: TokenBindingConnectionMode; + accountName?: string | null; +}; + +function parseExtraConfigHints(extraConfig?: string | null): { + credentialMode: Extract | null; + hasOauthProvider: boolean; +} { + if (typeof extraConfig !== 'string' || !extraConfig.trim()) { + return { + credentialMode: null, + hasOauthProvider: false, + }; + } + try { + const parsed = JSON.parse(extraConfig) as { + credentialMode?: unknown; + oauth?: { provider?: unknown } | null; + }; + const raw = String(parsed?.credentialMode || '').trim().toLowerCase(); + return { + credentialMode: raw === 'session' || raw === 'apikey' ? raw : null, + hasOauthProvider: typeof parsed?.oauth?.provider === 'string' && parsed.oauth.provider.trim().length > 0, + }; + } catch {} + return { + credentialMode: null, + hasOauthProvider: false, + }; +} + +function buildDirectBindingPresentation( + connectionMode: Extract, + accountName: string, +): TokenBindingPresentation { + if (connectionMode === 'oauth') { + return { + isFollowingAccountDefault: false, + bindingModeLabel: 'OAuth授权', + badgeTone: 'warning', + effectiveTokenName: accountName, + helperText: `当前直接使用连接「${accountName}」的 OAuth 授权,不依赖账号令牌。`, + followOptionLabel: `固定使用:${accountName}(OAuth 授权)`, + followOptionDescription: `直接使用连接「${accountName}」的 OAuth 授权`, + }; + } + + return { + isFollowingAccountDefault: false, + bindingModeLabel: 'API令牌', + badgeTone: 'warning', + effectiveTokenName: accountName, + helperText: `当前直接使用连接「${accountName}」保存的 API Key,不依赖账号令牌。`, + followOptionLabel: `固定使用:${accountName}(跟随 API Key 设置)`, + followOptionDescription: `直接使用连接「${accountName}」保存的 API Key`, + }; +} + +export function resolveTokenBindingConnectionMode(account?: { + accessToken?: string | null; + extraConfig?: string | null; + credentialMode?: string | null; +} | null): TokenBindingConnectionMode { + const parsedHints = parseExtraConfigHints(account?.extraConfig); + if (parsedHints.hasOauthProvider) return 'oauth'; + + const rawMode = String(account?.credentialMode || '').trim().toLowerCase(); + if (rawMode === 'session') return 'session'; + if (rawMode === 'apikey') return 'apikey'; + + if (parsedHints.credentialMode) return parsedHints.credentialMode; + + return typeof account?.accessToken === 'string' && account.accessToken.trim() + ? 'session' + : 'apikey'; +} + export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null { return options.find((option) => option.isDefault) || null; } @@ -21,21 +103,30 @@ export function describeTokenBinding( options: TokenBindingOption[], activeTokenId: number, fallbackTokenName?: string | null, + context: TokenBindingContext = {}, ): TokenBindingPresentation { const defaultToken = getDefaultTokenOption(options); const selectedToken = activeTokenId ? options.find((option) => option.id === activeTokenId) || null : null; + const connectionMode = context.connectionMode || 'session'; + const accountName = String(context.accountName || '').trim() || '当前连接'; if (!activeTokenId) { + if (connectionMode === 'apikey' || connectionMode === 'oauth') { + return buildDirectBindingPresentation(connectionMode, accountName); + } + const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌'; return { isFollowingAccountDefault: true, bindingModeLabel: '跟随账号默认', + badgeTone: 'info', effectiveTokenName, helperText: defaultToken ? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。` : '跟随账号默认。当前账号还没有默认令牌。', + followOptionLabel: '跟随账号默认', followOptionDescription: defaultToken ? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换` : '以后账号默认变化时会自动切换', @@ -46,13 +137,21 @@ export function describeTokenBinding( return { isFollowingAccountDefault: false, bindingModeLabel: '固定令牌', + badgeTone: 'warning', effectiveTokenName, helperText: selectedToken?.isDefault ? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。` : `已固定到「${effectiveTokenName}」,不会随账号默认变化。`, - followOptionDescription: defaultToken - ? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换` - : '以后账号默认变化时会自动切换', + followOptionLabel: connectionMode === 'oauth' + ? 'OAuth授权' + : (connectionMode === 'apikey' ? 'API 设置' : '跟随账号默认'), + followOptionDescription: connectionMode === 'oauth' + ? `直接使用连接「${accountName}」的 OAuth 授权` + : (connectionMode === 'apikey' + ? `直接使用连接「${accountName}」保存的 API Key` + : (defaultToken + ? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换` + : '以后账号默认变化时会自动切换')), }; } diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index 9c8ad5b..b6cb1ba 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -28,6 +28,9 @@ export type RouteChannel = { cooldownUntil?: string | null; account?: { username: string | null; + accessToken?: string | null; + extraConfig?: string | null; + credentialMode?: string | null; }; site?: { id: number;