From 569a15e14c5e3b472497e95022d0acd2ec4b9ea2 Mon Sep 17 00:00:00 2001 From: puyujian <46592377+puyujian@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:28:25 +0800 Subject: [PATCH] feat: enhance API key management and fix routing logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ``` feat(accounts): 支持批量添加API密钥连接 - 添加accessTokens字段到账户创建载荷模式,允许传递多个API密钥 - 实现批量API密钥解析功能,支持换行符、空格和逗号分隔 - 重构账户创建逻辑以支持单个和批量API密钥处理 - 在前端界面中添加批量API密钥输入检测和处理 - 添加批量创建结果返回和错误处理 - 更新验证流程以支持批量模式下的逐条验证 - 添加详细的批量API密钥测试用例 该更改允许用户一次提交多个API密钥,系统将为每个密钥创建独立的连接并参与负载均衡。 ``` * ``` fix(tokenRouter): 调整路由匹配优先级以优先匹配显示名称别名 当精确路由和显式组显示名称冲突时,现在优先选择显式组显示名称 而不是精确路由模式。这修正了路由决策逻辑,使显式组路由能够正 确覆盖精确模型匹配。 相关的测试用例已更新以反映新的匹配行为,确保显式组路由在冲 突情况下获得更高优先级。 ``` * ``` fix(accounts): 修复API密钥验证错误消息显示逻辑 当凭证模式不为apikey时显示会话令牌提示信息,否则显示API密钥验证失败信息 feat(accounts): 更新API密钥输入界面提示文本 将批量添加说明从输入框占位符移至独立提示文本, 使界面更清晰易懂 ``` * ``` feat(workflow): 在CI/CD工作流中使用动态Docker Hub镜像名称 - 在ci.yml和release.yml工作流中添加DOCKERHUB_IMAGE环境变量 - 将硬编码的Docker Hub用户名替换为从secrets.DOCKERHUB_USERNAME动态获取 - 更新docker.metadata-action配置以使用环境变量中的镜像名称 - 添加测试用例验证Docker Hub镜像名称的正确配置 ``` * fix(accounts): 修复批量创建回归并下沉创建流程 --- .github/workflows/ci.yml | 8 +- .github/workflows/release.yml | 76 +++-- .gitignore | 1 + scripts/dev/docker.workflow.test.ts | 11 + src/server/contracts/accountsRoutePayloads.ts | 6 +- .../routes/api/accounts.apiSite.test.ts | 93 ++++++ src/server/routes/api/accounts.ts | 312 ++++++------------ .../api/tokens.route-update-rebuild.test.ts | 7 +- src/server/services/apiKeyBatch.ts | 4 + .../services/manualAccountCreationService.ts | 305 +++++++++++++++++ src/server/services/shared/apiKeyBatchCore.ts | 26 ++ .../services/tokenRouter.patterns.test.ts | 16 +- src/server/services/tokenRouter.ts | 12 +- src/shared/apiKeyBatch.ts | 4 + src/web/pages/Accounts.tsx | 56 +++- 15 files changed, 655 insertions(+), 282 deletions(-) create mode 100644 src/server/services/apiKeyBatch.ts create mode 100644 src/server/services/manualAccountCreationService.ts create mode 100644 src/server/services/shared/apiKeyBatchCore.ts create mode 100644 src/shared/apiKeyBatch.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d4b3b5..9064c4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,6 +342,8 @@ jobs: if: github.event_name == 'push' runs-on: ${{ matrix.runner }} timeout-minutes: 45 + env: + DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi strategy: fail-fast: false matrix: @@ -384,7 +386,7 @@ jobs: id: meta uses: docker/metadata-action@v6 with: - images: 1467078763/metapi + images: ${{ env.DOCKERHUB_IMAGE }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=ref,event=branch @@ -424,6 +426,8 @@ jobs: runs-on: ubuntu-latest needs: publish-docker-arch timeout-minutes: 15 + env: + DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi steps: - name: Checkout @@ -449,7 +453,7 @@ jobs: id: meta uses: docker/metadata-action@v6 with: - images: 1467078763/metapi + images: ${{ env.DOCKERHUB_IMAGE }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=ref,event=branch diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7d63bc..e9bcc9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -283,14 +283,16 @@ jobs: generate_release_notes: true files: release-assets/* - publish-docker-arch: - name: Publish Docker Image (${{ matrix.arch }}) - needs: build-packages - runs-on: ${{ matrix.runner }} - timeout-minutes: 45 - if: startsWith(github.ref, 'refs/tags/') - strategy: - fail-fast: false + publish-docker-arch: + name: Publish Docker Image (${{ matrix.arch }}) + needs: build-packages + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + if: startsWith(github.ref, 'refs/tags/') + env: + DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi + strategy: + fail-fast: false matrix: include: - arch: amd64 @@ -334,16 +336,16 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Docker metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: | - 1467078763/metapi - ghcr.io/${{ github.repository }} - tags: | - type=ref,event=tag - type=raw,value=latest + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.DOCKERHUB_IMAGE }} + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest - name: Add architecture suffix to tags id: arch-tags @@ -373,14 +375,16 @@ jobs: cache-from: type=gha,scope=release-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=release-${{ matrix.arch }} - publish-docker: - name: Publish Docker Manifests - needs: publish-docker-arch - runs-on: ubuntu-latest - timeout-minutes: 15 - if: startsWith(github.ref, 'refs/tags/') - - steps: + publish-docker: + name: Publish Docker Manifests + needs: publish-docker-arch + runs-on: ubuntu-latest + timeout-minutes: 15 + if: startsWith(github.ref, 'refs/tags/') + env: + DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi + + steps: - name: Checkout uses: actions/checkout@v6 @@ -407,16 +411,16 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Docker metadata - id: meta - uses: docker/metadata-action@v6 - with: - images: | - 1467078763/metapi - ghcr.io/${{ github.repository }} - tags: | - type=ref,event=tag - type=raw,value=latest + - name: Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: | + ${{ env.DOCKERHUB_IMAGE }} + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=tag + type=raw,value=latest - name: Create multi-arch manifests shell: bash diff --git a/.gitignore b/.gitignore index d789c71..54ca230 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ docker/.env # Local planning docs docs/plan/ docs/plans/ +.ace-tool/ diff --git a/scripts/dev/docker.workflow.test.ts b/scripts/dev/docker.workflow.test.ts index 2242fb5..8805c8d 100644 --- a/scripts/dev/docker.workflow.test.ts +++ b/scripts/dev/docker.workflow.test.ts @@ -16,6 +16,17 @@ describe('docker workflows', () => { expect(releaseWorkflow).toContain('"${tag}-armv7"'); }); + it('derives Docker Hub image names from the configured username secret', () => { + const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8'); + const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8'); + + expect(ciWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi'); + expect(ciWorkflow).not.toContain('images: 1467078763/metapi'); + + expect(releaseWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi'); + expect(releaseWorkflow).not.toContain('1467078763/metapi'); + }); + it('uses an armv7-capable node base image in the Dockerfile', () => { const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8'); diff --git a/src/server/contracts/accountsRoutePayloads.ts b/src/server/contracts/accountsRoutePayloads.ts index 76132f6..6d76961 100644 --- a/src/server/contracts/accountsRoutePayloads.ts +++ b/src/server/contracts/accountsRoutePayloads.ts @@ -5,7 +5,8 @@ const accountCredentialModeSchema = z.enum(['auto', 'session', 'apikey']); const accountCreatePayloadSchema = z.object({ siteId: z.number().int().positive(), username: z.string().optional(), - accessToken: z.string(), + accessToken: z.string().optional(), + accessTokens: z.array(z.string()).optional(), apiToken: z.string().optional(), platformUserId: z.number().int().positive().optional(), checkinEnabled: z.boolean().optional(), @@ -95,6 +96,9 @@ function formatAccountsPayloadError(error: z.ZodError): string { if (firstPath === 'apiToken') { return 'Invalid apiToken. Expected string or null.'; } + if (firstPath === 'accessTokens') { + return 'Invalid accessTokens. Expected string[].'; + } if (firstPath === 'checkinEnabled') { return 'Invalid checkinEnabled. Expected boolean.'; } diff --git a/src/server/routes/api/accounts.apiSite.test.ts b/src/server/routes/api/accounts.apiSite.test.ts index de5775f..0da81f6 100644 --- a/src/server/routes/api/accounts.apiSite.test.ts +++ b/src/server/routes/api/accounts.apiSite.test.ts @@ -268,4 +268,97 @@ describe('accounts api endpoint host selection', { timeout: 15_000 }, () => { expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api-create-a.example.com', 'sk-nihao-create-rotate', undefined); expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api-create-b.example.com', 'sk-nihao-create-rotate', undefined); }); + + it('supports batch creating multiple API key connections for one site', async () => { + getModelsMock + .mockResolvedValueOnce(['gpt-4o-mini']) + .mockResolvedValueOnce(['gpt-4.1-mini']); + + const site = await db.insert(schema.sites).values({ + name: 'Nihao Batch Pool', + url: 'https://console.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + await db.insert(schema.siteApiEndpoints).values({ + siteId: site.id, + url: 'https://api.example.com', + enabled: true, + sortOrder: 0, + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + username: 'batch-key', + accessToken: 'sk-batch-a\nsk-batch-b', + credentialMode: 'apikey', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + batch: true, + totalCount: 2, + createdCount: 2, + failedCount: 0, + }); + expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api.example.com', 'sk-batch-a', undefined); + expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api.example.com', 'sk-batch-b', undefined); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(2); + expect(accounts.map((item) => item.apiToken)).toEqual(['sk-batch-a', 'sk-batch-b']); + expect(accounts.map((item) => item.username)).toEqual(['batch-key #1', 'batch-key #2']); + }); + + it('treats accessTokens payloads as batch API key creation even without credentialMode', async () => { + getModelsMock + .mockResolvedValueOnce(['gpt-4o-mini']) + .mockResolvedValueOnce(['gpt-4.1-mini']); + + const site = await db.insert(schema.sites).values({ + name: 'Nihao Batch Array', + url: 'https://console.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + await db.insert(schema.siteApiEndpoints).values({ + siteId: site.id, + url: 'https://api.example.com', + enabled: true, + sortOrder: 0, + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + username: 'array-key', + accessTokens: ['sk-array-a', 'sk-array-b'], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + batch: true, + totalCount: 2, + createdCount: 2, + failedCount: 0, + }); + expect(getModelsMock).toHaveBeenNthCalledWith(1, 'https://api.example.com', 'sk-array-a', undefined); + expect(getModelsMock).toHaveBeenNthCalledWith(2, 'https://api.example.com', 'sk-array-b', undefined); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(2); + expect(accounts.map((item) => item.apiToken)).toEqual(['sk-array-a', 'sk-array-b']); + expect(accounts.map((item) => item.username)).toEqual(['array-key #1', 'array-key #2']); + }); }); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index eafc1ce..eeda591 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; import { db, schema, runtimeDbDialect } from '../../db/index.js'; -import { getInsertedRowId, insertAndGetById } from '../../db/insertHelpers.js'; +import { insertAndGetById } from '../../db/insertHelpers.js'; import { and, eq, gte, lt, sql } from 'drizzle-orm'; import { refreshBalance } from '../../services/balanceService.js'; import { getAdapter } from '../../services/platforms/index.js'; @@ -34,6 +34,7 @@ import { appendSessionTokenRebindHint } from '../../services/alertRules.js'; import { parseSiteProxyUrlInput, withAccountProxyOverride, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; import { + type AccountCreatePayload, parseAccountBatchPayload, parseAccountCreatePayload, parseAccountHealthRefreshPayload, @@ -44,6 +45,8 @@ import { parseAccountVerifyTokenPayload, } from '../../contracts/accountsRoutePayloads.js'; import { requireSiteApiBaseUrl, runWithSiteApiEndpointPool } from '../../services/siteApiEndpointService.js'; +import { buildBatchApiKeyConnectionName, parseBatchApiKeys } from '../../services/apiKeyBatch.js'; +import { createManualAccount } from '../../services/manualAccountCreationService.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -65,17 +68,6 @@ type AccountCapabilities = { proxyOnly: boolean; }; -type AccountInitializationParams = { - accountId: number; - site: typeof schema.sites.$inferSelect; - adapter: NonNullable>; - tokenType: 'session' | 'apikey' | 'unknown'; - accessToken: string; - apiToken: string; - platformUserId?: number; - skipModelFetch?: boolean; -}; - type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null; const limitAccountLogin = createRateLimitGuard({ @@ -150,65 +142,18 @@ function normalizePinnedFlag(input: unknown): boolean | null { return null; } -async function initializeAccountInBackground({ - accountId, - site, - adapter, - tokenType, - accessToken, - apiToken, - platformUserId, - skipModelFetch, -}: AccountInitializationParams) { - const summary = { - accountId, - syncedTokenCount: 0, - refreshedBalance: false, - refreshedModels: false, - rebuiltRoutes: false, - }; - - let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = []; - if (tokenType === 'session' && accessToken) { - try { - const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId); - summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0; - fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : []; - } catch {} +function resolveRequestedCreateTokens( + body: AccountCreatePayload, + credentialMode: AccountCredentialMode, +): string[] { + if (credentialMode !== 'apikey') { + const single = String(body.accessToken || '').trim(); + return single ? [single] : []; } - const convergence = await convergeAccountMutation({ - accountId, - preferredApiToken: tokenType === 'session' ? apiToken : null, - defaultTokenSource: 'manual', - ensurePreferredTokenBeforeSync: tokenType === 'session', - upstreamTokens: fetchedUpstreamTokens, - refreshBalance: tokenType === 'session', - refreshModels: skipModelFetch !== true, - rebuildRoutes: skipModelFetch !== true, - continueOnError: true, - }); - summary.refreshedBalance = convergence.refreshedBalance; - summary.refreshedModels = convergence.refreshedModels; - summary.rebuiltRoutes = convergence.rebuiltRoutes; - - return summary; -} - -function buildQueuedAccountInitializationMessage( - tokenType: 'session' | 'apikey' | 'unknown', - skipModelFetch?: boolean, -) { - if (tokenType === 'session' && skipModelFetch === true) { - return '账号已添加,后台正在同步令牌和余额信息。'; - } - if (tokenType === 'session') { - return '账号已添加,后台正在同步令牌、余额和模型信息。'; - } - if (skipModelFetch === true) { - return '已添加为 API Key 账号(可用于代理转发)。'; - } - return '已添加为 API Key 账号,后台正在同步模型和路由信息。'; + const batchTokens = parseBatchApiKeys(body.accessTokens); + if (batchTokens.length > 0) return batchTokens; + return parseBatchApiKeys(body.accessToken); } function normalizeSortOrder(input: unknown): number | null { @@ -1147,174 +1092,103 @@ export async function accountsRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` }); } - const credentialMode = resolveRequestedCredentialMode(body.credentialMode); - const rawAccessToken = (body.accessToken || '').trim(); - if (!rawAccessToken) { + const explicitBatchTokens = parseBatchApiKeys(body.accessTokens); + const credentialMode = explicitBatchTokens.length > 0 + ? 'apikey' + : resolveRequestedCredentialMode(body.credentialMode); + const requestedTokens = explicitBatchTokens.length > 0 + ? explicitBatchTokens + : resolveRequestedCreateTokens(body, credentialMode); + if (requestedTokens.length === 0) { return reply.code(400).send({ success: false, message: '请填写 Token' }); } - let username = (body.username || '').trim(); - let accessToken = rawAccessToken; - let apiToken = (body.apiToken || '').trim(); - let tokenType: 'session' | 'apikey' | 'unknown' = 'unknown'; - let verifiedModels: string[] = []; + if (credentialMode === 'apikey' && requestedTokens.length > 1) { + const items: Array> = []; + let createdCount = 0; - if (credentialMode === 'apikey') { - if (body.skipModelFetch === true) { - tokenType = 'apikey'; - accessToken = ''; - if (!apiToken) apiToken = rawAccessToken; - } else { + for (const [index, token] of requestedTokens.entries()) { try { - const models = await getModelsWithSiteApiEndpointPool( + const created = await createManualAccount({ + body, site, adapter, - rawAccessToken, - body.platformUserId, - ); - verifiedModels = Array.isArray(models) - ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) - : []; - } catch (err: any) { - return reply.code(400).send({ - success: false, - message: err?.message || 'API Key 验证失败', + credentialMode, + rawAccessToken: token, + usernameOverride: buildBatchApiKeyConnectionName(body.username, index, requestedTokens.length) || undefined, + }); + createdCount += 1; + items.push({ + index, + status: 'created', + id: created.account.id, + username: created.account.username || null, + queued: created.queued === true, + message: created.message || null, + modelCount: created.modelCount || 0, + }); + } catch (error: any) { + items.push({ + index, + status: 'failed', + message: error?.message || '创建失败', + requiresVerification: error?.requiresVerification === true, }); } - - if (verifiedModels.length === 0) { - return reply.code(400).send({ - success: false, - requiresVerification: true, - message: 'API Key 验证失败:未获取到可用模型', - }); - } - - tokenType = 'apikey'; - accessToken = ''; - if (!apiToken) apiToken = rawAccessToken; } - } else { - let verifyResult: any; - try { - verifyResult = await adapter.verifyToken(site.url, rawAccessToken, body.platformUserId); - } catch (err: any) { + + if (createdCount === 0) { return reply.code(400).send({ success: false, - message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'), + batch: true, + totalCount: requestedTokens.length, + createdCount: 0, + failedCount: requestedTokens.length, + message: `批量添加失败(0/${requestedTokens.length})`, + items, }); } - tokenType = verifyResult.tokenType; - if (tokenType === 'unknown') { - return reply.code(400).send({ - success: false, - requiresVerification: true, - message: 'Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号', - }); - } - - if (credentialMode === 'session' && tokenType !== 'session') { - return reply.code(400).send({ - success: false, - message: '当前凭证是 API Key,请切换到 API Key 模式,或改用 Session Token', - }); - } - - if (tokenType === 'session') { - if (!username && verifyResult.userInfo?.username) username = String(verifyResult.userInfo.username).trim(); - if (!apiToken && verifyResult.apiToken) apiToken = String(verifyResult.apiToken).trim(); - } else if (tokenType === 'apikey') { - accessToken = ''; - if (!apiToken) apiToken = rawAccessToken; - verifiedModels = Array.isArray(verifyResult.models) - ? verifyResult.models.filter((item: unknown) => typeof item === 'string' && item.trim().length > 0) - : []; - } + return { + success: true, + batch: true, + totalCount: requestedTokens.length, + createdCount, + failedCount: requestedTokens.length - createdCount, + message: `批量添加完成:成功 ${createdCount},失败 ${requestedTokens.length - createdCount}`, + items, + }; } - // Store platformUserId and credential mode in extraConfig. - const resolvedPlatformUserId = - body.platformUserId || guessPlatformUserIdFromUsername(username) || undefined; - const resolvedCredentialMode: AccountCredentialMode = tokenType === 'apikey' ? 'apikey' : 'session'; - const extraConfigPatch: Record = { credentialMode: resolvedCredentialMode }; - if (resolvedPlatformUserId) { - extraConfigPatch.platformUserId = resolvedPlatformUserId; + try { + const created = await createManualAccount({ + body, + site, + adapter, + credentialMode, + rawAccessToken: requestedTokens[0]!, + }); + return { + ...created.account, + tokenType: created.tokenType, + credentialMode: resolveStoredCredentialMode(created.account), + capabilities: buildCapabilitiesForAccount(created.account), + modelCount: created.modelCount, + apiTokenFound: created.apiTokenFound, + usernameDetected: created.usernameDetected, + queued: created.queued, + jobId: created.jobId, + message: created.message, + }; + } catch (err: any) { + return reply.code(400).send({ + success: false, + requiresVerification: err?.requiresVerification === true, + message: credentialMode !== 'apikey' + ? appendSessionTokenRebindHint(err?.message || 'Token 验证失败') + : (err?.message || 'API Key 验证失败'), + }); } - if ((site.platform || '').toLowerCase() === 'sub2api') { - const managedRefreshToken = normalizeManagedRefreshToken(body.refreshToken); - const managedTokenExpiresAt = normalizeManagedTokenExpiresAt(body.tokenExpiresAt); - if (managedRefreshToken) { - extraConfigPatch.sub2apiAuth = managedTokenExpiresAt - ? { refreshToken: managedRefreshToken, tokenExpiresAt: managedTokenExpiresAt } - : { refreshToken: managedRefreshToken }; - } - } - const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch); - - const result = await insertAndGetById({ - table: schema.accounts, - idColumn: schema.accounts.id, - values: { - siteId: body.siteId, - username: username || undefined, - accessToken, - apiToken: apiToken || undefined, - checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false, - extraConfig, - isPinned: false, - sortOrder: await getNextAccountSortOrder(), - }, - insertErrorMessage: '创建账号失败', - loadErrorMessage: '创建账号失败', - }); - - const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true; - let queuedTaskId: string | undefined; - let queuedMessage: string | undefined; - if (shouldQueueInitialization) { - const taskTitle = `初始化连接 #${result.id}`; - const { task } = startBackgroundTask( - { - type: 'account-init', - title: taskTitle, - dedupeKey: `account-init-${result.id}`, - notifyOnFailure: true, - successMessage: () => `${taskTitle}已完成`, - failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`, - }, - async () => initializeAccountInBackground({ - accountId: result.id, - site, - adapter, - tokenType, - accessToken, - apiToken, - platformUserId: resolvedPlatformUserId, - skipModelFetch: body.skipModelFetch, - }), - ); - queuedTaskId = task.id; - queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch); - } - - const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get(); - const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode; - const capabilities = account - ? buildCapabilitiesForAccount(account) - : buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null); - return { - ...account, - tokenType, - credentialMode: finalCredentialMode, - capabilities, - modelCount: verifiedModels.length, - apiTokenFound: !!apiToken, - usernameDetected: !!(!body.username && username), - queued: !!queuedTaskId, - jobId: queuedTaskId, - message: queuedMessage, - }; }); // Update an account diff --git a/src/server/routes/api/tokens.route-update-rebuild.test.ts b/src/server/routes/api/tokens.route-update-rebuild.test.ts index 66d2025..3a0d3a9 100644 --- a/src/server/routes/api/tokens.route-update-rebuild.test.ts +++ b/src/server/routes/api/tokens.route-update-rebuild.test.ts @@ -658,7 +658,7 @@ describe('PUT /api/routes/:id route rebuild', () => { expect(updated?.tokenId).toBe(seeded.token.id); }); - it('prefers an exact route over a colliding explicit-group display name', async () => { + it('prefers an explicit-group display name over a colliding exact route', async () => { const exactCandidate = await seedAccountWithToken('claude-opus-4-6'); const groupedCandidate = await seedAccountWithToken('claude-opus-4-5'); @@ -716,9 +716,8 @@ describe('PUT /api/routes/:id route rebuild', () => { success: true, decision: { matched: true, - routeId: exactRoute.id, - modelPattern: 'claude-opus-4-6', - actualModel: 'claude-opus-4-6', + routeId: groupResponse.json().id, + actualModel: 'claude-opus-4-5', }, }); }); diff --git a/src/server/services/apiKeyBatch.ts b/src/server/services/apiKeyBatch.ts new file mode 100644 index 0000000..8cfa30e --- /dev/null +++ b/src/server/services/apiKeyBatch.ts @@ -0,0 +1,4 @@ +export { + buildBatchApiKeyConnectionName, + parseBatchApiKeys, +} from './shared/apiKeyBatchCore.js'; diff --git a/src/server/services/manualAccountCreationService.ts b/src/server/services/manualAccountCreationService.ts new file mode 100644 index 0000000..f1b3f82 --- /dev/null +++ b/src/server/services/manualAccountCreationService.ts @@ -0,0 +1,305 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../db/index.js'; +import { insertAndGetById } from '../db/insertHelpers.js'; +import { startBackgroundTask } from './backgroundTaskService.js'; +import { getAdapter } from './platforms/index.js'; +import { + guessPlatformUserIdFromUsername, + mergeAccountExtraConfig, + type AccountCredentialMode, +} from './accountExtraConfig.js'; +import { runWithSiteApiEndpointPool } from './siteApiEndpointService.js'; +import { type AccountCreatePayload } from '../contracts/accountsRoutePayloads.js'; +import { convergeAccountMutation } from './accountMutationWorkflow.js'; + +const ACCOUNT_VERIFY_TIMEOUT_MS = 10_000; + +type AccountInitializationParams = { + accountId: number; + site: typeof schema.sites.$inferSelect; + adapter: NonNullable>; + tokenType: 'session' | 'apikey' | 'unknown'; + accessToken: string; + apiToken: string; + platformUserId?: number; + skipModelFetch?: boolean; +}; + +export type CreateManualAccountParams = { + body: AccountCreatePayload; + site: typeof schema.sites.$inferSelect; + adapter: NonNullable>; + credentialMode: AccountCredentialMode; + rawAccessToken: string; + usernameOverride?: string; +}; + +export type CreateManualAccountResult = { + account: typeof schema.accounts.$inferSelect; + tokenType: 'session' | 'apikey' | 'unknown'; + modelCount: number; + apiTokenFound: boolean; + usernameDetected: boolean; + queued: boolean; + jobId?: string; + message?: string; +}; + +async function withTimeout(fn: () => Promise, timeoutMs: number, timeoutMessage: string): Promise { + let timer: ReturnType | null = null; + try { + return await Promise.race([ + fn(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function buildAccountVerifyTimeoutMessage(): string { + return `Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`; +} + +async function getNextAccountSortOrder(): Promise { + const rows = await db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all(); + const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1); + return max + 1; +} + +async function getModelsWithSiteApiEndpointPool( + site: typeof schema.sites.$inferSelect, + adapter: NonNullable>, + accessToken: string, + platformUserId?: number, +): Promise { + const timeoutMessage = buildAccountVerifyTimeoutMessage(); + const deadline = Date.now() + ACCOUNT_VERIFY_TIMEOUT_MS; + return runWithSiteApiEndpointPool(site, (target) => { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw new Error(timeoutMessage); + } + return withTimeout( + () => adapter.getModels(target.baseUrl, accessToken, platformUserId), + remainingMs, + timeoutMessage, + ); + }); +} + +async function initializeAccountInBackground({ + accountId, + site, + adapter, + tokenType, + accessToken, + apiToken, + platformUserId, + skipModelFetch, +}: AccountInitializationParams) { + const summary = { + accountId, + syncedTokenCount: 0, + refreshedBalance: false, + refreshedModels: false, + rebuiltRoutes: false, + }; + + let fetchedUpstreamTokens: Array<{ name?: string | null; key?: string | null; enabled?: boolean | null; tokenGroup?: string | null }> = []; + if (tokenType === 'session' && accessToken) { + try { + const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId); + summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0; + fetchedUpstreamTokens = Array.isArray(syncedTokens) ? syncedTokens : []; + } catch {} + } + + const convergence = await convergeAccountMutation({ + accountId, + preferredApiToken: tokenType === 'session' ? apiToken : null, + defaultTokenSource: 'manual', + ensurePreferredTokenBeforeSync: tokenType === 'session', + upstreamTokens: fetchedUpstreamTokens, + refreshBalance: tokenType === 'session', + refreshModels: skipModelFetch !== true, + rebuildRoutes: skipModelFetch !== true, + continueOnError: true, + }); + summary.refreshedBalance = convergence.refreshedBalance; + summary.refreshedModels = convergence.refreshedModels; + summary.rebuiltRoutes = convergence.rebuiltRoutes; + + return summary; +} + +function buildQueuedAccountInitializationMessage( + tokenType: 'session' | 'apikey' | 'unknown', + skipModelFetch?: boolean, +) { + if (tokenType === 'session' && skipModelFetch === true) { + return '账号已添加,后台正在同步令牌和余额信息。'; + } + if (tokenType === 'session') { + return '账号已添加,后台正在同步令牌、余额和模型信息。'; + } + if (skipModelFetch === true) { + return '已添加为 API Key 账号(可用于代理转发)。'; + } + return '已添加为 API Key 账号,后台正在同步模型和路由信息。'; +} + +export async function createManualAccount({ + body, + site, + adapter, + credentialMode, + rawAccessToken, + usernameOverride, +}: CreateManualAccountParams): Promise { + let username = typeof usernameOverride === 'string' + ? usernameOverride.trim() + : (body.username || '').trim(); + let accessToken = rawAccessToken; + let apiToken = (body.apiToken || '').trim(); + let tokenType: 'session' | 'apikey' | 'unknown' = 'unknown'; + let verifiedModels: string[] = []; + + if (credentialMode === 'apikey') { + if (body.skipModelFetch === true) { + tokenType = 'apikey'; + accessToken = ''; + if (!apiToken) apiToken = rawAccessToken; + } else { + const models = await getModelsWithSiteApiEndpointPool( + site, + adapter, + rawAccessToken, + body.platformUserId, + ); + verifiedModels = Array.isArray(models) + ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) + : []; + if (verifiedModels.length === 0) { + const error = new Error('API Key 验证失败:未获取到可用模型'); + (error as Error & { requiresVerification?: boolean }).requiresVerification = true; + throw error; + } + + tokenType = 'apikey'; + accessToken = ''; + if (!apiToken) apiToken = rawAccessToken; + } + } else { + const verifyResult = await withTimeout( + () => adapter.verifyToken(site.url, rawAccessToken, body.platformUserId), + ACCOUNT_VERIFY_TIMEOUT_MS, + buildAccountVerifyTimeoutMessage(), + ); + tokenType = verifyResult.tokenType; + if (tokenType === 'unknown') { + const error = new Error('Token 验证失败,请先点击“验证 Token”,验证成功后再绑定账号'); + (error as Error & { requiresVerification?: boolean }).requiresVerification = true; + throw error; + } + + if (credentialMode === 'session' && tokenType !== 'session') { + throw new Error('当前凭证是 API Key,请切换到 API Key 模式,或改用 Session Token'); + } + + if (tokenType === 'session') { + if (!username && verifyResult.userInfo?.username) username = String(verifyResult.userInfo.username).trim(); + if (!apiToken && verifyResult.apiToken) apiToken = String(verifyResult.apiToken).trim(); + } else if (tokenType === 'apikey') { + accessToken = ''; + if (!apiToken) apiToken = rawAccessToken; + verifiedModels = Array.isArray(verifyResult.models) + ? verifyResult.models.filter((item: unknown) => typeof item === 'string' && item.trim().length > 0) + : []; + } + } + + const resolvedPlatformUserId = + body.platformUserId || guessPlatformUserIdFromUsername(username) || undefined; + const resolvedCredentialMode: AccountCredentialMode = tokenType === 'apikey' ? 'apikey' : 'session'; + const extraConfigPatch: Record = { credentialMode: resolvedCredentialMode }; + if (resolvedPlatformUserId) { + extraConfigPatch.platformUserId = resolvedPlatformUserId; + } + if ((site.platform || '').toLowerCase() === 'sub2api') { + const managedRefreshToken = typeof body.refreshToken === 'string' ? body.refreshToken.trim() : ''; + const managedTokenExpiresAt = typeof body.tokenExpiresAt === 'number' + ? Math.trunc(body.tokenExpiresAt) + : (typeof body.tokenExpiresAt === 'string' ? Number.parseInt(body.tokenExpiresAt.trim(), 10) : undefined); + if (managedRefreshToken) { + extraConfigPatch.sub2apiAuth = managedTokenExpiresAt && Number.isFinite(managedTokenExpiresAt) && managedTokenExpiresAt > 0 + ? { refreshToken: managedRefreshToken, tokenExpiresAt: managedTokenExpiresAt } + : { refreshToken: managedRefreshToken }; + } + } + const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch); + + const result = await insertAndGetById({ + table: schema.accounts, + idColumn: schema.accounts.id, + values: { + siteId: body.siteId, + username: username || undefined, + accessToken, + apiToken: apiToken || undefined, + checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false, + extraConfig, + isPinned: false, + sortOrder: await getNextAccountSortOrder(), + }, + insertErrorMessage: '创建账号失败', + loadErrorMessage: '创建账号失败', + }); + + const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true; + let queuedTaskId: string | undefined; + let queuedMessage: string | undefined; + if (shouldQueueInitialization) { + const taskTitle = `初始化连接 #${result.id}`; + const { task } = startBackgroundTask( + { + type: 'account-init', + title: taskTitle, + dedupeKey: `account-init-${result.id}`, + notifyOnFailure: true, + successMessage: () => `${taskTitle}已完成`, + failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`, + }, + async () => initializeAccountInBackground({ + accountId: result.id, + site, + adapter, + tokenType, + accessToken, + apiToken, + platformUserId: resolvedPlatformUserId, + skipModelFetch: body.skipModelFetch, + }), + ); + queuedTaskId = task.id; + queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch); + } + + const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get(); + if (!account) { + throw new Error('创建账号失败'); + } + + return { + account, + tokenType, + modelCount: verifiedModels.length, + apiTokenFound: !!apiToken, + usernameDetected: !!(!body.username && username), + queued: !!queuedTaskId, + jobId: queuedTaskId, + message: queuedMessage, + }; +} diff --git a/src/server/services/shared/apiKeyBatchCore.ts b/src/server/services/shared/apiKeyBatchCore.ts new file mode 100644 index 0000000..9ca236c --- /dev/null +++ b/src/server/services/shared/apiKeyBatchCore.ts @@ -0,0 +1,26 @@ +export function parseBatchApiKeys(input: unknown): string[] { + if (Array.isArray(input)) { + return Array.from(new Set( + input + .map((item) => String(item || '').trim()) + .filter((item) => item.length > 0), + )); + } + + const raw = String(input || '').trim(); + if (!raw) return []; + + return Array.from(new Set( + raw + .split(/[\r\n,,;;\s]+/) + .map((item) => item.trim()) + .filter((item) => item.length > 0), + )); +} + +export function buildBatchApiKeyConnectionName(baseName: string | null | undefined, index: number, total: number): string { + const normalized = String(baseName || '').trim(); + if (!normalized) return ''; + if (total <= 1) return normalized; + return `${normalized} #${index + 1}`; +} diff --git a/src/server/services/tokenRouter.patterns.test.ts b/src/server/services/tokenRouter.patterns.test.ts index f764b24..23ec683 100644 --- a/src/server/services/tokenRouter.patterns.test.ts +++ b/src/server/services/tokenRouter.patterns.test.ts @@ -189,12 +189,11 @@ describe('TokenRouter patterns and model mapping', () => { expect(exposedModels).toContain('claude-opus-4-6'); }); - it('prefers an exact route over a colliding group display-name alias', async () => { - await createRouteWithSingleChannel( - 're:^claude-(opus|sonnet)-4-5$', + it('prefers a group display-name alias over a colliding exact route', async () => { + const source = await createRouteWithSingleChannel( + 'claude-opus-4-5', undefined, { - displayName: 'claude-opus-4-6', sourceModel: 'claude-opus-4-5', }, ); @@ -205,15 +204,18 @@ describe('TokenRouter patterns and model mapping', () => { sourceModel: 'claude-opus-4-6', }, ); + const grouped = await createExplicitGroupRoute('claude-opus-4-6', [source.route.id]); const router = new TokenRouter(); const selected = await router.selectChannel('claude-opus-4-6'); const decision = await router.explainSelection('claude-opus-4-6'); expect(selected).toBeTruthy(); - expect(selected?.channel.id).toBe(exact.channel.id); - expect(selected?.actualModel).toBe('claude-opus-4-6'); - expect(decision.actualModel).toBe('claude-opus-4-6'); + expect(selected?.channel.routeId).toBe(source.route.id); + expect(selected?.channel.id).not.toBe(exact.channel.id); + expect(selected?.actualModel).toBe('claude-opus-4-5'); + expect(decision.routeId).toBe(grouped.id); + expect(decision.actualModel).toBe('claude-opus-4-5'); }); it('falls back to the source exact-route model when explicit-group channels omit sourceModel', async () => { diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 24301c0..a9bbd27 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -3041,12 +3041,12 @@ export class TokenRouter { routes = routes.filter((route) => allowSet.has(route.id)); } - const matchedRoute = routes.find((route) => ( - !isExplicitGroupRoute(route) - && isExactRouteModelPattern(route.modelPattern) - && (route.modelPattern || '').trim() === model - )) - || routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) + const matchedRoute = routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) + || routes.find((route) => ( + !isExplicitGroupRoute(route) + && isExactRouteModelPattern(route.modelPattern) + && (route.modelPattern || '').trim() === model + )) || routes.find((route) => !isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) || routes.find((route) => !isExplicitGroupRoute(route) && matchesModelPattern(model, route.modelPattern)); diff --git a/src/shared/apiKeyBatch.ts b/src/shared/apiKeyBatch.ts new file mode 100644 index 0000000..12b7a6f --- /dev/null +++ b/src/shared/apiKeyBatch.ts @@ -0,0 +1,4 @@ +export { + buildBatchApiKeyConnectionName, + parseBatchApiKeys, +} from '../server/services/shared/apiKeyBatchCore.js'; diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 2341226..119dd6d 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -29,6 +29,7 @@ import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from '. import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js'; import { SITE_DOCS_URL } from '../docsLink.js'; import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js'; +import { parseBatchApiKeys } from '../../shared/apiKeyBatch.js'; type ConnectionsSegment = 'session' | 'apikey' | 'tokens'; @@ -177,6 +178,11 @@ export default function Accounts() { () => sites.find((item) => item.id === tokenForm.siteId) || null, [sites, tokenForm.siteId], ); + const parsedApiKeys = useMemo( + () => activeSegment === 'apikey' ? parseBatchApiKeys(tokenForm.accessToken) : [], + [activeSegment, tokenForm.accessToken], + ); + const isBatchApiKeyInput = activeSegment === 'apikey' && parsedApiKeys.length > 1; const siteSelectOptions = useMemo( () => [ { value: '0', label: '选择站点' }, @@ -322,6 +328,10 @@ export default function Accounts() { const handleVerifyToken = async () => { if (!tokenForm.siteId || !tokenForm.accessToken) return; + if (isBatchApiKeyInput) { + toast.info(`检测到 ${parsedApiKeys.length} 个 API Key,批量模式会在添加时逐条校验`); + return; + } const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session'; setVerifying(true); setVerifyResult(null); @@ -352,7 +362,7 @@ export default function Accounts() { const handleTokenAdd = async () => { if (!tokenForm.siteId || !tokenForm.accessToken) return; - if (!verifyResult?.success && !tokenForm.skipModelFetch) { + if (!isBatchApiKeyInput && !verifyResult?.success && !tokenForm.skipModelFetch) { toast.error('请先验证 Token 成功后再添加账号'); return; } @@ -364,6 +374,7 @@ export default function Accounts() { siteId: tokenForm.siteId, username: tokenForm.username.trim() || undefined, accessToken: tokenForm.accessToken, + accessTokens: isBatchApiKeyInput ? parsedApiKeys : undefined, platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined, refreshToken: isSub2ApiSelected && tokenForm.refreshToken.trim() ? tokenForm.refreshToken.trim() @@ -374,6 +385,23 @@ export default function Accounts() { credentialMode, skipModelFetch: tokenForm.skipModelFetch, }); + if (result?.batch) { + closeAddPanel(); + const createdCount = Number(result.createdCount) || 0; + const failedCount = Number(result.failedCount) || 0; + if (createdCount > 0) { + toast.success(`批量添加完成:成功 ${createdCount},失败 ${failedCount}`); + } + const failedItems = Array.isArray(result.items) + ? result.items.filter((item: any) => item?.status === 'failed') + : []; + if (failedItems.length > 0) { + const firstMessage = failedItems[0]?.message || '创建失败'; + toast.error(`失败 ${failedItems.length} 条:${firstMessage}`); + } + load(); + return; + } let seededRecommendedModels = false; const recommendedModels = initializationPreset?.recommendedModels || []; const createdAccountId = Number(result?.id) || 0; @@ -951,6 +979,9 @@ export default function Accounts() { || (activeSegment === 'session' && verifyResult.tokenType === 'session') ), ); + const canSubmitApiKeyConnection = activeSegment === 'apikey' + ? (isBatchApiKeyInput || canAddVerifiedConnection || !!tokenForm.skipModelFetch) + : canAddVerifiedConnection; return (
@@ -1421,9 +1452,18 @@ export default function Accounts() {