feat: add masked token placeholders and desktop icons

This commit is contained in:
cita
2026-03-16 21:36:23 +08:00
parent 2409d9f184
commit 0a06f15e13
37 changed files with 2954 additions and 136 deletions
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
const { deleteApiTokenMock } = vi.hoisted(() => ({
deleteApiTokenMock: vi.fn(),
@@ -105,6 +106,43 @@ describe('account token batch routes', () => {
expect(rows.every((row) => row.enabled === true)).toBe(true);
});
it('rejects enabling masked_pending placeholders until they are completed', async () => {
await db.update(schema.accountTokens)
.set({
enabled: false,
valueStatus: 'masked_pending' as any,
token: 'sk-mask***tail',
})
.where(eq(schema.accountTokens.id, 1))
.run();
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/batch',
payload: {
ids: [1, 2],
action: 'enable',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
successIds?: number[];
failedItems?: Array<{ id: number; message: string }>;
};
expect(body.successIds).toEqual([2]);
expect(body.failedItems).toEqual([
expect.objectContaining({
id: 1,
message: expect.stringContaining('待补全令牌'),
}),
]);
const rows = await db.select().from(schema.accountTokens).all();
expect(rows.find((row) => row.id === 1)?.enabled).toBe(false);
expect(rows.find((row) => row.id === 2)?.enabled).toBe(true);
});
it('deletes selected account tokens through the upstream adapter', async () => {
const response = await app.inject({
method: 'POST',
@@ -138,7 +138,7 @@ describe('account tokens sync routes with site status', () => {
expect(tokenRows.length).toBe(0);
});
it('skips masked upstream token values and does not store them', async () => {
it('stores masked upstream token values as masked_pending placeholders', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: 'sk-abc***xyz', enabled: true },
@@ -153,12 +153,13 @@ describe('account tokens sync routes with site status', () => {
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: false,
status: 'skipped',
synced: true,
status: 'synced',
reason: 'upstream_masked_tokens',
maskedSkipped: 1,
total: 0,
created: 0,
maskedPending: 1,
pendingTokenIds: [expect.any(Number)],
total: 1,
created: 1,
updated: 0,
});
@@ -166,7 +167,33 @@ describe('account tokens sync routes with site status', () => {
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows.length).toBe(0);
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: 'sk-abc***xyz',
source: 'sync',
enabled: false,
isDefault: false,
});
expect((tokenRows[0] as any).valueStatus).toBe('masked_pending');
const owner = await db.select()
.from(schema.accounts)
.where(eq(schema.accounts.id, account.id))
.get();
expect(owner?.apiToken ?? null).toBeNull();
const listResponse = await app.inject({
method: 'GET',
url: '/api/account-tokens',
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toMatchObject([
expect.objectContaining({
id: tokenRows[0].id,
valueStatus: 'masked_pending',
}),
]);
});
it('rejects sync and token management for apikey connections', async () => {
@@ -491,6 +518,7 @@ describe('account tokens sync routes with site status', () => {
source: 'sync',
enabled: true,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
@@ -503,4 +531,139 @@ describe('account tokens sync routes with site status', () => {
success: false,
});
});
it('upgrades an existing masked_pending placeholder when upstream later returns the full token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: 'sk-abc***xyz',
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
valueStatus: 'masked_pending' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: 'sk-real-token-1234', enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
total: 1,
created: 0,
updated: 1,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: 'sk-real-token-1234',
enabled: true,
});
expect((tokenRows[0] as any).valueStatus).toBe('ready');
});
it('does not allow setting a masked_pending placeholder as default', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/${token.id}/default`,
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
message: expect.stringContaining('待补全令牌'),
});
});
it('promotes a masked_pending placeholder to ready when a full token is saved', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'PUT',
url: `/api/account-tokens/${token.id}`,
payload: {
token: 'sk-real-token-updated',
enabled: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
token: expect.objectContaining({
id: token.id,
enabled: true,
valueStatus: 'ready',
}),
});
const latest = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.id, token.id))
.get();
expect(latest).toMatchObject({
token: 'sk-real-token-updated',
enabled: true,
});
expect((latest as any)?.valueStatus).toBe('ready');
});
it('deletes masked_pending placeholders locally without calling upstream delete', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'DELETE',
url: `/api/account-tokens/${token.id}`,
});
expect(response.statusCode).toBe(200);
expect(deleteApiTokenMock).not.toHaveBeenCalled();
const removed = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
expect(removed).toBeUndefined();
});
});
+53 -17
View File
@@ -2,12 +2,17 @@
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING,
ACCOUNT_TOKEN_VALUE_STATUS_READY,
ensureDefaultTokenForAccount,
isMaskedPendingAccountToken,
isMaskedTokenValue,
isUsableAccountToken,
listTokensWithRelations,
normalizeTokenForDisplay,
maskToken,
repairDefaultToken,
resolveAccountTokenValueStatus,
setDefaultToken,
syncTokensFromUpstream,
} from '../../services/accountTokenService.js';
@@ -38,7 +43,8 @@ type SyncExecutionResult = {
synced: boolean;
created: number;
updated: number;
maskedSkipped?: number;
maskedPending?: number;
pendingTokenIds?: number[];
total: number;
defaultTokenId?: number | null;
};
@@ -287,13 +293,13 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
}
const synced = await syncTokensFromUpstream(accountId, tokens);
if ((synced.created + synced.updated) === 0 && synced.maskedSkipped > 0) {
if ((synced.maskedPending || 0) > 0) {
return {
...base,
status: 'skipped',
status: 'synced',
reason: 'upstream_masked_tokens',
message: `上游仅返回脱敏令牌(含*),跳过 ${synced.maskedSkipped} 条,无法同步明文令牌`,
synced: false,
message: `上游返回 ${synced.maskedPending} 条脱敏令牌,已保存为待补全记录,请手动补全明文 token。`,
synced: true,
...synced,
};
}
@@ -326,7 +332,7 @@ async function appendTokenSyncEvent(result: SyncExecutionResult) {
? 'info'
: (result.status === 'skipped' ? 'warning' : 'error');
const detail = result.status === 'synced'
? `新增 ${result.created},更新 ${result.updated},总数 ${result.total}`
? `新增 ${result.created},更新 ${result.updated},待补全 ${result.maskedPending || 0},总数 ${result.total}`
: (result.message || result.reason || 'sync skipped');
try {
@@ -485,15 +491,25 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const existing = await db.select().from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, body.accountId))
.all();
const valueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
const enabled = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY
? (body.enabled ?? true)
: false;
const isDefault = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY
? (body.isDefault ?? false)
: false;
const inserted = await db.insert(schema.accountTokens).values({
accountId: body.accountId,
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
token: tokenValue,
tokenGroup: (body.group || '').trim() || null,
valueStatus,
source: body.source || 'manual',
enabled: body.enabled ?? true,
isDefault: body.isDefault ?? false,
enabled,
isDefault,
createdAt: now,
updatedAt: now,
}).run();
@@ -506,9 +522,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '创建令牌失败' });
}
if (body.isDefault || (existing.length === 0 && (body.enabled ?? true))) {
if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) {
await setDefaultToken(created.id);
} else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) {
} else if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && existing.every((token) => !token.isDefault) && enabled) {
await setDefaultToken(created.id);
}
const coverageRefresh = await refreshCoverageForAccounts([body.accountId]);
@@ -629,7 +645,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter;
const shouldDeleteUpstream = !isMaskedPendingAccountToken(existing)
&& !isSiteDisabled(site.status)
&& !!account.accessToken?.trim()
&& !!adapter;
if (shouldDeleteUpstream) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
@@ -690,6 +709,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
continue;
}
} else {
if (isMaskedPendingAccountToken(existing)) {
failedItems.push({ id, message: '待补全令牌不能修改启用状态,请先补全明文 token' });
continue;
}
await db.update(schema.accountTokens)
.set({
enabled: action === 'enable',
@@ -736,6 +759,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const body = request.body;
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
let nextValueStatus = resolveAccountTokenValueStatus(existing);
if (body.name !== undefined) {
updates.name = (body.name || '').trim() || existing.name;
@@ -747,15 +771,24 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '令牌不能为空' });
}
updates.token = tokenValue;
nextValueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
updates.valueStatus = nextValueStatus;
}
if (body.group !== undefined) {
updates.tokenGroup = (body.group || '').trim() || null;
}
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
updates.enabled = false;
updates.isDefault = false;
} else {
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
}
if (body.source !== undefined) updates.source = body.source;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run();
@@ -764,11 +797,11 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '更新失败' });
}
if (body.isDefault === true) {
if (body.isDefault === true && isUsableAccountToken(latest)) {
setDefaultToken(tokenId);
} else if (latest.isDefault && latest.enabled) {
} else if (latest.isDefault && isUsableAccountToken(latest)) {
setDefaultToken(tokenId);
} else if (existing.isDefault && !latest.enabled) {
} else if (existing.isDefault && !isUsableAccountToken(latest)) {
repairDefaultToken(existing.accountId);
} else if (body.isDefault === false && existing.isDefault) {
repairDefaultToken(existing.accountId);
@@ -793,6 +826,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (isApiKeyConnection(owner)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
if (isMaskedPendingAccountToken(tokenRow)) {
return reply.code(400).send({ success: false, message: '待补全令牌不能设为默认,请先补全明文 token' });
}
const success = await setDefaultToken(tokenId);
if (!success) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
@@ -820,7 +856,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
if (isMaskedTokenValue(row.account_tokens.token)) {
if (isMaskedPendingAccountToken(row.account_tokens) || isMaskedTokenValue(row.account_tokens.token)) {
return reply.code(409).send({
success: false,
message: '当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。',
+2
View File
@@ -3,6 +3,7 @@ import { db, schema } from '../../db/index.js';
import { and, like, desc, eq, or } from 'drizzle-orm';
import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
function hasSessionTokenValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
@@ -134,6 +135,7 @@ export async function searchRoutes(app: FastifyInstance) {
like(schema.tokenModelAvailability.modelName, q),
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
),
)
+11
View File
@@ -18,6 +18,7 @@ import {
withProxyLogSelectFields,
} from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
import {
formatLocalDateTime,
formatUtcSqlDateTime,
@@ -724,6 +725,15 @@ export async function statsRoutes(app: FastifyInstance) {
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const accountAvailability = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -989,6 +999,7 @@ export async function statsRoutes(app: FastifyInstance) {
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
+38 -27
View File
@@ -1,10 +1,14 @@
import { FastifyInstance } from 'fastify';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
isUsableAccountToken,
} from '../../services/accountTokenService.js';
import { normalizeRouteRoutingStrategy } from '../../services/routeRoutingStrategy.js';
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import {
clearRouteDecisionSnapshot,
clearRouteDecisionSnapshots,
@@ -19,12 +23,17 @@ function isExactModelPattern(modelPattern: string): boolean {
return !/[\*\?\[]/.test(normalized);
}
async function getDefaultTokenId(accountId: number): Promise<number | null> {
const token = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true), eq(schema.accountTokens.isDefault, true)))
.get();
return token?.id ?? null;
}
async function getDefaultTokenId(accountId: number): Promise<number | null> {
const token = await db.select().from(schema.accountTokens)
.where(and(
eq(schema.accountTokens.accountId, accountId),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.isDefault, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.get();
return isUsableAccountToken(token ?? null) ? token!.id : null;
}
function canonicalModelAlias(modelName: string): string {
const normalized = modelName.trim().toLowerCase();
@@ -58,12 +67,12 @@ async function tokenSupportsModel(tokenId: number, modelName: string): Promise<b
});
}
async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise<boolean> {
const row = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId)))
.get();
return !!row;
}
async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise<boolean> {
const row = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId)))
.get();
return isUsableAccountToken(row ?? null);
}
async function getPatternTokenCandidates(modelPattern: string): Promise<Array<{ tokenId: number; accountId: number; sourceModel: string }>> {
const rows = await db.select().from(schema.tokenModelAvailability)
@@ -72,18 +81,20 @@ async function getPatternTokenCandidates(modelPattern: string): Promise<Array<{
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = [];
for (const row of rows) {
const modelName = row.token_model_availability.modelName?.trim();
if (!modelName) continue;
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = [];
for (const row of rows) {
if (!isUsableAccountToken(row.account_tokens)) continue;
const modelName = row.token_model_availability.modelName?.trim();
if (!modelName) continue;
if (!matchesModelPattern(modelName, modelPattern)) continue;
result.push({
tokenId: row.account_tokens.id,