feat: add masked token placeholders and desktop icons
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: '当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。',
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user