feat: enhance API key management and fix routing logic

* ```
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): 修复批量创建回归并下沉创建流程
This commit is contained in:
puyujian
2026-04-07 14:28:25 +08:00
committed by GitHub
parent 5cab6c83f1
commit 569a15e14c
15 changed files with 655 additions and 282 deletions
@@ -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']);
});
});
+93 -219
View File
@@ -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<ReturnType<typeof getAdapter>>;
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<Record<string, unknown>> = [];
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<string, unknown> = { 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<typeof schema.accounts.$inferSelect>({
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
@@ -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',
},
});
});