test:Add database migration service with normalization and masking functions

- Implemented `normalizeMigrationInput` to standardize database migration input for sqlite, mysql, and postgres.
- Added `maskConnectionString` to obscure sensitive credentials in connection strings.
- Created comprehensive tests for migration input normalization and connection string masking.
- Established database schema creation logic for multiple dialects (sqlite, mysql, postgres).
- Developed functions for managing database transactions, including begin, commit, and rollback.
- Implemented data insertion logic from backup snapshots into the target database.
This commit is contained in:
cita
2026-03-05 17:38:52 +08:00
parent 0168592c95
commit 283c052fa5
79 changed files with 3410 additions and 1111 deletions
@@ -35,18 +35,18 @@ describe('account tokens sync routes with site status', () => {
return seedId;
};
const seedAccount = (input: { siteStatus?: 'active' | 'disabled'; accountStatus?: string; accessToken?: string | null }) => {
const seedAccount = async (input: { siteStatus?: 'active' | 'disabled'; accountStatus?: string; accessToken?: string | null }) => {
const id = nextSeed();
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: `site-${id}`,
url: `https://site-${id}.example.com`,
platform: 'new-api',
}).returning().get();
if (input.siteStatus === 'disabled') {
db.run(sql`update sites set status = 'disabled' where id = ${site.id}`);
await db.run(sql`update sites set status = 'disabled' where id = ${site.id}`);
}
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: `user-${id}`,
accessToken: input.accessToken ?? `access-token-${id}`,
@@ -70,7 +70,7 @@ describe('account tokens sync routes with site status', () => {
await app.register(routesModule.accountTokensRoutes);
});
beforeEach(() => {
beforeEach(async () => {
getApiTokensMock.mockReset();
getApiTokenMock.mockReset();
createApiTokenMock.mockReset();
@@ -78,14 +78,14 @@ describe('account tokens sync routes with site status', () => {
deleteApiTokenMock.mockReset();
seedId = 0;
db.delete(schema.accountTokens).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -94,7 +94,7 @@ describe('account tokens sync routes with site status', () => {
});
it('returns skipped for single-account sync when site is disabled', async () => {
const { account } = seedAccount({ siteStatus: 'disabled' });
const { account } = await seedAccount({ siteStatus: 'disabled' });
const response = await app.inject({
method: 'POST',
@@ -113,7 +113,7 @@ describe('account tokens sync routes with site status', () => {
});
it('returns skipped when upstream has no api tokens', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
const { account } = await seedAccount({ siteStatus: 'active' });
getApiTokensMock.mockResolvedValue([]);
getApiTokenMock.mockResolvedValue(null);
@@ -130,7 +130,7 @@ describe('account tokens sync routes with site status', () => {
reason: 'no_upstream_tokens',
});
const tokenRows = db.select()
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
@@ -138,8 +138,8 @@ describe('account tokens sync routes with site status', () => {
});
it('sync-all skips disabled-site accounts and syncs active-site accounts', async () => {
const disabled = seedAccount({ siteStatus: 'disabled' });
const active = seedAccount({ siteStatus: 'active' });
const disabled = await seedAccount({ siteStatus: 'disabled' });
const active = await seedAccount({ siteStatus: 'active' });
getApiTokensMock.mockResolvedValue([
{ name: 'default', key: 'sk-synced-token', enabled: true },
@@ -186,7 +186,7 @@ describe('account tokens sync routes with site status', () => {
synced: true,
});
const syncedDefaultToken = db.select()
const syncedDefaultToken = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, active.account.id), eq(schema.accountTokens.isDefault, true)))
.get();
@@ -194,7 +194,7 @@ describe('account tokens sync routes with site status', () => {
});
it('creates token via upstream api and syncs into local store when manual token is omitted', async () => {
const { account, site } = seedAccount({ siteStatus: 'active' });
const { account, site } = await seedAccount({ siteStatus: 'active' });
createApiTokenMock.mockResolvedValue(true);
getApiTokensMock.mockResolvedValue([
{ name: 'created-from-upstream', key: 'sk-created-upstream-token', enabled: true },
@@ -220,7 +220,7 @@ describe('account tokens sync routes with site status', () => {
expect(createApiTokenMock.mock.calls[0][0]).toBe(site.url);
expect(createApiTokenMock.mock.calls[0][1]).toBe(account.accessToken);
const tokenRows = db.select()
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
@@ -232,7 +232,7 @@ describe('account tokens sync routes with site status', () => {
});
it('passes token creation options to upstream adapter', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
const { account } = await seedAccount({ siteStatus: 'active' });
createApiTokenMock.mockResolvedValue(true);
getApiTokensMock.mockResolvedValue([
{ name: 'custom-token', key: 'sk-created-upstream-token', enabled: true },
@@ -265,7 +265,7 @@ describe('account tokens sync routes with site status', () => {
});
it('returns 400 when limited token misses remainQuota', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
const { account } = await seedAccount({ siteStatus: 'active' });
const response = await app.inject({
method: 'POST',
@@ -286,7 +286,7 @@ describe('account tokens sync routes with site status', () => {
});
it('returns 502 when upstream token creation fails', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
const { account } = await seedAccount({ siteStatus: 'active' });
createApiTokenMock.mockResolvedValue(false);
const response = await app.inject({
@@ -306,7 +306,7 @@ describe('account tokens sync routes with site status', () => {
});
it('fetches account token groups from upstream', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
const { account } = await seedAccount({ siteStatus: 'active' });
getUserGroupsMock.mockResolvedValue(['default', 'vip']);
const response = await app.inject({
@@ -323,8 +323,8 @@ describe('account tokens sync routes with site status', () => {
});
it('deletes upstream token before removing local token', async () => {
const { account, site } = seedAccount({ siteStatus: 'active' });
const token = db.insert(schema.accountTokens).values({
const { account, site } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'upstream-token',
token: 'sk-upstream-token',
@@ -345,13 +345,13 @@ describe('account tokens sync routes with site status', () => {
expect(deleteApiTokenMock.mock.calls[0][1]).toBe(account.accessToken);
expect(deleteApiTokenMock.mock.calls[0][2]).toBe('sk-upstream-token');
const removed = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
const removed = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
expect(removed).toBeUndefined();
});
it('keeps local token when upstream deletion fails', async () => {
const { account } = seedAccount({ siteStatus: 'active' });
const token = db.insert(schema.accountTokens).values({
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'upstream-token',
token: 'sk-upstream-token',
@@ -372,7 +372,7 @@ describe('account tokens sync routes with site status', () => {
message: '站点删除令牌失败,本地未删除',
});
const existing = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
const existing = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
expect(existing).toBeDefined();
});
});
+30 -22
View File
@@ -233,7 +233,7 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
};
}
const synced = syncTokensFromUpstream(accountId, tokens);
const synced = await syncTokensFromUpstream(accountId, tokens);
return {
...base,
status: 'synced',
@@ -255,7 +255,7 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
}
}
function appendTokenSyncEvent(result: SyncExecutionResult) {
async function appendTokenSyncEvent(result: SyncExecutionResult) {
const title = result.status === 'synced'
? '令牌同步成功'
: (result.status === 'skipped' ? '令牌同步跳过' : '令牌同步失败');
@@ -267,7 +267,7 @@ function appendTokenSyncEvent(result: SyncExecutionResult) {
: (result.message || result.reason || 'sync skipped');
try {
db.insert(schema.events).values({
await db.insert(schema.events).values({
type: 'token',
title,
message: `${result.accountName} @ ${result.siteName}: ${detail}`,
@@ -279,7 +279,7 @@ function appendTokenSyncEvent(result: SyncExecutionResult) {
}
async function executeSyncAllAccountTokens() {
const rows = db.select().from(schema.accounts)
const rows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.status, 'active'))
.all();
@@ -331,7 +331,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
modelLimits?: string;
} }>('/api/account-tokens', async (request, reply) => {
const body = request.body;
const row = db.select()
const row = await db.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, body.accountId))
@@ -343,11 +343,11 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const tokenValue = (body.token || '').trim();
if (tokenValue) {
const now = new Date().toISOString();
const existing = db.select().from(schema.accountTokens)
const existing = await db.select().from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, body.accountId))
.all();
const created = db.insert(schema.accountTokens).values({
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,
@@ -357,12 +357,20 @@ export async function accountTokensRoutes(app: FastifyInstance) {
isDefault: body.isDefault ?? false,
createdAt: now,
updatedAt: now,
}).returning().get();
}).run();
const createdId = Number(inserted.lastInsertRowid || 0);
if (createdId <= 0) {
return reply.code(500).send({ success: false, message: '创建令牌失败' });
}
const created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, createdId)).get();
if (!created) {
return reply.code(500).send({ success: false, message: '创建令牌失败' });
}
if (body.isDefault || (existing.length === 0 && (body.enabled ?? true))) {
setDefaultToken(created.id);
await setDefaultToken(created.id);
} else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) {
setDefaultToken(created.id);
await setDefaultToken(created.id);
}
return { success: true, token: created };
@@ -445,12 +453,12 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(502).send({ success: false, message: syncResult.message || '站点未返回可用令牌' });
}
const preferred = db.select().from(schema.accountTokens)
const preferred = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.isDefault, true)))
.get();
const token = preferred || db.select().from(schema.accountTokens)
const token = preferred || (await db.select().from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all()
.all())
.slice(-1)[0] || null;
return {
@@ -467,7 +475,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
}
const existing = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
const existing = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
if (!existing) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
}
@@ -491,9 +499,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (body.source !== undefined) updates.source = body.source;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run();
await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run();
const latest = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
const latest = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
if (!latest) {
return reply.code(500).send({ success: false, message: '更新失败' });
}
@@ -529,7 +537,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
}
const row = db.select()
const row = await db.select()
.from(schema.accountTokens)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
@@ -555,7 +563,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
const row = db.select()
const row = await db.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
@@ -593,7 +601,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
}
const row = db.select()
const row = await db.select()
.from(schema.accountTokens)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
@@ -622,7 +630,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
}
}
db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).run();
await db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).run();
if (existing.isDefault) {
repairDefaultToken(existing.accountId);
@@ -637,7 +645,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
const row = db.select().from(schema.accounts)
const row = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
@@ -707,7 +715,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
const row = db.select()
const row = await db.select()
.from(schema.accountTokens)
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
@@ -36,20 +36,20 @@ describe('accounts add requires token verification success', () => {
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
beforeEach(async () => {
verifyTokenMock.mockReset();
getApiTokensMock.mockReset();
getApiTokensMock.mockResolvedValue([]);
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -60,7 +60,7 @@ describe('accounts add requires token verification success', () => {
it('rejects binding when token verification is not successful', async () => {
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Verify Site',
url: 'https://verify.example.com',
platform: 'new-api',
@@ -79,13 +79,13 @@ describe('accounts add requires token verification success', () => {
expect(response.json()).toMatchObject({
success: false,
});
expect(db.select().from(schema.accounts).all()).toHaveLength(0);
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
});
it('returns rebind hint when token verify reports invalid access token', async () => {
verifyTokenMock.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Verify Site',
url: 'https://verify.example.com',
platform: 'new-api',
@@ -105,7 +105,7 @@ describe('accounts add requires token verification success', () => {
success: false,
message: '无权进行此操作,access token 无效,请在中转站重新生成系统访问令牌后重新绑定账号',
});
expect(db.select().from(schema.accounts).all()).toHaveLength(0);
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
});
it('allows binding when token verification succeeds as api key', async () => {
@@ -114,7 +114,7 @@ describe('accounts add requires token verification success', () => {
models: ['gpt-4o-mini'],
});
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'API Key Site',
url: 'https://apikey.example.com',
platform: 'new-api',
@@ -134,7 +134,7 @@ describe('accounts add requires token verification success', () => {
expect(body.tokenType).toBe('apikey');
expect(body.apiTokenFound).toBe(true);
const accounts = db.select().from(schema.accounts).all();
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(1);
expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
});
@@ -39,21 +39,21 @@ describe('accounts credential mode', () => {
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
beforeEach(async () => {
verifyTokenMock.mockReset();
getModelsMock.mockReset();
getApiTokensMock.mockReset();
getApiTokensMock.mockResolvedValue([]);
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -65,7 +65,7 @@ describe('accounts credential mode', () => {
verifyTokenMock.mockRejectedValueOnce(new Error('verifyToken should not be called'));
getModelsMock.mockResolvedValueOnce(['gpt-5-mini', 'gpt-4o-mini']);
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Fast Verify Site',
url: 'https://fast-verify.example.com',
platform: 'new-api',
@@ -95,7 +95,7 @@ describe('accounts credential mode', () => {
verifyTokenMock.mockRejectedValueOnce(new Error('verifyToken should not be called'));
getModelsMock.mockResolvedValueOnce(['gpt-4o-mini']);
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Proxy Only Site',
url: 'https://proxy-only.example.com',
platform: 'new-api',
@@ -116,7 +116,7 @@ describe('accounts credential mode', () => {
expect(body.tokenType).toBe('apikey');
expect(body.capabilities?.proxyOnly).toBe(true);
const accounts = db.select().from(schema.accounts).all();
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toHaveLength(1);
expect(accounts[0]?.accessToken || '').toBe('');
expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
@@ -132,7 +132,7 @@ describe('accounts credential mode', () => {
userInfo: { username: 'sub2-user' },
});
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Sub2 Site',
url: 'https://sub2.example.com',
platform: 'sub2api',
@@ -150,7 +150,7 @@ describe('accounts credential mode', () => {
});
expect(response.statusCode).toBe(200);
const created = db.select().from(schema.accounts).all()[0];
const created = (await db.select().from(schema.accounts).all())[0];
const parsedExtra = JSON.parse(created?.extraConfig || '{}') as {
credentialMode?: string;
sub2apiAuth?: {
@@ -164,12 +164,12 @@ describe('accounts credential mode', () => {
});
it('updates and clears managed refresh token via account update API', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Sub2 Site',
url: 'https://sub2.example.com',
platform: 'sub2api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'sub2-user',
accessToken: 'access-token',
@@ -192,7 +192,7 @@ describe('accounts credential mode', () => {
});
expect(updateResponse.statusCode).toBe(200);
const updated = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const updated = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const parsedUpdated = JSON.parse(updated?.extraConfig || '{}') as {
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
};
@@ -208,7 +208,7 @@ describe('accounts credential mode', () => {
});
expect(clearResponse.statusCode).toBe(200);
const cleared = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const cleared = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const parsedCleared = JSON.parse(cleared?.extraConfig || '{}') as {
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
};
@@ -32,17 +32,17 @@ describe('accounts health refresh runtime state', () => {
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
beforeEach(async () => {
refreshBalanceMock.mockReset();
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -51,13 +51,13 @@ describe('accounts health refresh runtime state', () => {
});
it('keeps degraded runtime state for unsupported checkin after health refresh', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Wind Hub',
url: 'https://windhub.cc',
platform: 'done-hub',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'ld6jl3djexjf',
accessToken: 'token',
@@ -34,18 +34,18 @@ describe('accounts login shield detection', () => {
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
beforeEach(async () => {
loginMock.mockReset();
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -59,7 +59,7 @@ describe('accounts login shield detection', () => {
message: "Unexpected token '<', \"<html><scr\"... is not valid JSON",
});
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'AnyRouter',
url: 'https://anyrouter.example.com',
platform: 'new-api',
@@ -35,18 +35,18 @@ describe('accounts rebind-session api', () => {
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
beforeEach(async () => {
verifyTokenMock.mockReset();
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -57,13 +57,13 @@ describe('accounts rebind-session api', () => {
it('rejects rebinding when token is not verified as session', async () => {
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Rebind Site',
url: 'https://rebind.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'linuxdo_1001',
accessToken: 'old-access-token',
@@ -83,7 +83,7 @@ describe('accounts rebind-session api', () => {
success: false,
});
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(latest?.accessToken).toBe('old-access-token');
expect(latest?.status).toBe('expired');
});
@@ -91,13 +91,13 @@ describe('accounts rebind-session api', () => {
it('returns rebind hint when verify reports invalid access token', async () => {
verifyTokenMock.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Rebind Site',
url: 'https://rebind.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'linuxdo_1001',
accessToken: 'old-access-token',
@@ -126,13 +126,13 @@ describe('accounts rebind-session api', () => {
apiToken: 'sk-rebound-token',
});
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Rebind Site',
url: 'https://rebind.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'linuxdo_1001',
accessToken: 'old-access-token',
@@ -153,7 +153,7 @@ describe('accounts rebind-session api', () => {
expect(body.success).toBe(true);
expect(body.apiTokenFound).toBe(true);
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(latest?.accessToken).toBe('new-session-token');
expect(latest?.apiToken).toBe('sk-rebound-token');
expect(latest?.username).toBe('linuxdo_1002');
@@ -166,13 +166,13 @@ describe('accounts rebind-session api', () => {
userInfo: { username: 'sub2_user' },
});
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Sub2 Rebind Site',
url: 'https://sub2.example.com',
platform: 'sub2api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'sub2_user',
accessToken: 'old-access-token',
@@ -197,7 +197,7 @@ describe('accounts rebind-session api', () => {
expect(response.statusCode).toBe(200);
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const parsedExtra = JSON.parse(String(latest?.extraConfig || '{}')) as {
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
};
@@ -211,13 +211,13 @@ describe('accounts rebind-session api', () => {
userInfo: { username: 'sub2_user' },
});
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Sub2 Rebind Site',
url: 'https://sub2.example.com',
platform: 'sub2api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'sub2_user',
accessToken: 'old-access-token',
@@ -242,7 +242,7 @@ describe('accounts rebind-session api', () => {
expect(response.statusCode).toBe(200);
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const parsedExtra = JSON.parse(String(latest?.extraConfig || '{}')) as {
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
};
@@ -256,13 +256,13 @@ describe('accounts rebind-session api', () => {
userInfo: { username: 'linuxdo_1003' },
});
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Rebind Site',
url: 'https://rebind.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'linuxdo_1001',
accessToken: 'old-access-token',
@@ -282,7 +282,7 @@ describe('accounts rebind-session api', () => {
expect(response.statusCode).toBe(200);
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const parsedExtra = JSON.parse(String(latest?.extraConfig || '{}')) as {
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
platformUserId?: number;
@@ -32,16 +32,16 @@ describe('accounts api today reward fallback', () => {
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -51,12 +51,12 @@ describe('accounts api today reward fallback', () => {
it('uses today income value when checkin reward is missing', async () => {
const today = formatLocalDate(new Date());
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'reward-site',
url: 'https://reward-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'reward-user',
accessToken: 'token',
@@ -71,7 +71,7 @@ describe('accounts api today reward fallback', () => {
}),
}).returning().get();
db.insert(schema.checkinLogs).values({
await db.insert(schema.checkinLogs).values({
accountId: account.id,
status: 'success',
message: 'checked in',
@@ -92,12 +92,12 @@ describe('accounts api today reward fallback', () => {
it('prefers parsed checkin reward when available', async () => {
const today = formatLocalDate(new Date());
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'reward-site',
url: 'https://reward-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'reward-user',
accessToken: 'token',
@@ -112,7 +112,7 @@ describe('accounts api today reward fallback', () => {
}),
}).returning().get();
db.insert(schema.checkinLogs).values({
await db.insert(schema.checkinLogs).values({
accountId: account.id,
status: 'success',
message: 'checkin success',
@@ -132,12 +132,12 @@ describe('accounts api today reward fallback', () => {
});
it('counts today spend only inside local-day range', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'spend-site',
url: 'https://spend-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'spend-user',
accessToken: 'token',
@@ -151,7 +151,7 @@ describe('accounts api today reward fallback', () => {
const inRange = formatUtcSqlDateTime(new Date(startDate.getTime() + 60_000));
const afterEnd = formatUtcSqlDateTime(new Date(endDate.getTime() + 60_000));
db.insert(schema.proxyLogs).values([
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
status: 'success',
+49 -40
View File
@@ -114,8 +114,8 @@ function normalizeManagedTokenExpiresAt(input: unknown): number | undefined {
return undefined;
}
function getNextAccountSortOrder(): number {
const rows = db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
async function getNextAccountSortOrder(): Promise<number> {
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;
}
@@ -191,7 +191,7 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
try {
await refreshBalance(accountId);
const refreshedAccount = db.select().from(schema.accounts)
const refreshedAccount = await db.select().from(schema.accounts)
.where(eq(schema.accounts.id, accountId))
.get();
const runtimeHealth = buildRuntimeHealthForAccount({
@@ -227,7 +227,7 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
}
async function executeRefreshAccountRuntimeHealth(accountId?: number) {
const rows = db.select().from(schema.accounts)
const rows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.all();
@@ -249,13 +249,13 @@ async function executeRefreshAccountRuntimeHealth(accountId?: number) {
export async function accountsRoutes(app: FastifyInstance) {
// List all accounts (with site info)
app.get('/api/accounts', async () => {
const rows = db.select().from(schema.accounts)
const rows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)).all();
const { localDay, startUtc, endUtc } = getLocalDayRangeUtc();
// Aggregate today's spend per account from proxy logs
const todaySpendRows = db.select({
const todaySpendRows = await db.select({
accountId: schema.proxyLogs.accountId,
totalSpend: sql<number>`coalesce(sum(${schema.proxyLogs.estimatedCost}), 0)`,
}).from(schema.proxyLogs)
@@ -269,7 +269,7 @@ export async function accountsRoutes(app: FastifyInstance) {
}
// Aggregate today's checkin rewards per account
const todayCheckins = db.select({
const todayCheckins = await db.select({
accountId: schema.checkinLogs.accountId,
reward: schema.checkinLogs.reward,
message: schema.checkinLogs.message,
@@ -323,7 +323,7 @@ export async function accountsRoutes(app: FastifyInstance) {
const { siteId, username, password } = request.body;
// Get site info
const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!site) return { success: false, message: 'site not found' };
// Get platform adapter
@@ -354,7 +354,7 @@ export async function accountsRoutes(app: FastifyInstance) {
} catch { }
const preferredApiToken = apiTokens.find((token) => token.enabled !== false && token.key)?.key || apiToken || null;
const existing = db.select().from(schema.accounts)
const existing = await db.select().from(schema.accounts)
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.username, username)))
.get();
@@ -374,7 +374,7 @@ export async function accountsRoutes(app: FastifyInstance) {
// Create or update account
let accountId = existing?.id;
if (existing) {
db.update(schema.accounts).set({
await db.update(schema.accounts).set({
accessToken: loginResult.accessToken,
apiToken: preferredApiToken || undefined,
checkinEnabled: true,
@@ -383,7 +383,7 @@ export async function accountsRoutes(app: FastifyInstance) {
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, existing.id)).run();
} else {
const created = db.insert(schema.accounts).values({
const inserted = await db.insert(schema.accounts).values({
siteId,
username,
accessToken: loginResult.accessToken,
@@ -391,23 +391,24 @@ export async function accountsRoutes(app: FastifyInstance) {
checkinEnabled: true,
extraConfig,
isPinned: false,
sortOrder: getNextAccountSortOrder(),
}).returning().get();
accountId = created.id;
sortOrder: await getNextAccountSortOrder(),
}).run();
const insertedId = Number(inserted.lastInsertRowid || 0);
accountId = insertedId > 0 ? insertedId : undefined;
}
const result = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
if (!result) {
return { success: false, message: 'account create failed' };
}
if (apiTokens.length > 0) {
try {
syncTokensFromUpstream(result.id, apiTokens);
await syncTokensFromUpstream(result.id, apiTokens);
} catch { }
} else if (preferredApiToken) {
try {
ensureDefaultTokenForAccount(result.id, preferredApiToken, { name: 'default', source: 'sync' });
await ensureDefaultTokenForAccount(result.id, preferredApiToken, { name: 'default', source: 'sync' });
} catch { }
}
@@ -415,10 +416,10 @@ export async function accountsRoutes(app: FastifyInstance) {
try { await refreshBalance(result.id); } catch { }
try {
await refreshModelsForAccount(result.id);
rebuildTokenRoutesFromAvailability();
await rebuildTokenRoutesFromAvailability();
} catch { }
const account = db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
return {
success: true,
account,
@@ -433,7 +434,7 @@ export async function accountsRoutes(app: FastifyInstance) {
const { siteId, platformUserId } = request.body;
const accessToken = (request.body.accessToken || '').trim();
const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode);
const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!site) return { success: false, message: 'site not found' };
if (!accessToken) {
@@ -607,7 +608,7 @@ export async function accountsRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '请提供新的 Session Token' });
}
const row = db.select()
const row = await db.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
@@ -686,11 +687,11 @@ export async function accountsRoutes(app: FastifyInstance) {
}
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, extraConfigPatch);
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
if (nextApiToken) {
try {
ensureDefaultTokenForAccount(accountId, nextApiToken, { name: 'default', source: 'sync' });
await ensureDefaultTokenForAccount(accountId, nextApiToken, { name: 'default', source: 'sync' });
} catch {}
}
@@ -699,10 +700,10 @@ export async function accountsRoutes(app: FastifyInstance) {
} catch {}
try {
await refreshModelsForAccount(accountId);
rebuildTokenRoutesFromAvailability();
await rebuildTokenRoutesFromAvailability();
} catch {}
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
return {
success: true,
account: latest,
@@ -717,7 +718,7 @@ export async function accountsRoutes(app: FastifyInstance) {
// Add an account (manual credential input)
app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean; credentialMode?: AccountCredentialMode; refreshToken?: string; tokenExpiresAt?: number | string } }>('/api/accounts', async (request, reply) => {
const body = request.body;
const site = db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
if (!site) {
return reply.code(400).send({ success: false, message: 'site not found' });
}
@@ -821,7 +822,7 @@ export async function accountsRoutes(app: FastifyInstance) {
}
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
const result = db.insert(schema.accounts).values({
const inserted = await db.insert(schema.accounts).values({
siteId: body.siteId,
username: username || undefined,
accessToken,
@@ -829,12 +830,20 @@ export async function accountsRoutes(app: FastifyInstance) {
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
extraConfig,
isPinned: false,
sortOrder: getNextAccountSortOrder(),
}).returning().get();
sortOrder: await getNextAccountSortOrder(),
}).run();
const insertedId = Number(inserted.lastInsertRowid || 0);
if (insertedId <= 0) {
return reply.code(500).send({ success: false, message: '创建账号失败' });
}
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, insertedId)).get();
if (!result) {
return reply.code(500).send({ success: false, message: '创建账号失败' });
}
if (apiToken) {
try {
ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
} catch { }
}
@@ -842,7 +851,7 @@ export async function accountsRoutes(app: FastifyInstance) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId);
if (syncedTokens.length > 0) {
syncTokensFromUpstream(result.id, syncedTokens);
await syncTokensFromUpstream(result.id, syncedTokens);
}
} catch { }
}
@@ -853,10 +862,10 @@ export async function accountsRoutes(app: FastifyInstance) {
}
try {
await refreshModelsForAccount(result.id);
rebuildTokenRoutesFromAvailability();
await rebuildTokenRoutesFromAvailability();
} catch { }
const account = db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
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)
@@ -876,7 +885,7 @@ export async function accountsRoutes(app: FastifyInstance) {
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request, reply) => {
const id = parseInt(request.params.id);
const body = request.body as Record<string, unknown>;
const row = db.select()
const row = await db.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, id))
@@ -933,28 +942,28 @@ export async function accountsRoutes(app: FastifyInstance) {
}
updates.updatedAt = new Date().toISOString();
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
try {
ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
} catch { }
}
try {
await refreshModelsForAccount(id);
rebuildTokenRoutesFromAvailability();
await rebuildTokenRoutesFromAvailability();
} catch { }
return db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
});
// Delete an account
app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => {
const id = parseInt(request.params.id);
db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
try {
rebuildTokenRoutesFromAvailability();
await rebuildTokenRoutesFromAvailability();
} catch { }
return { success: true };
});
@@ -41,20 +41,20 @@ describe('accounts verify-token shield detection', () => {
await app.register(routesModule.accountsRoutes);
});
beforeEach(() => {
beforeEach(async () => {
verifyTokenMock.mockReset();
undiciFetchMock.mockReset();
adapterPlatformName = 'new-api';
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -65,7 +65,7 @@ describe('accounts verify-token shield detection', () => {
it('returns rebind hint when verify-token reports invalid access token', async () => {
verifyTokenMock.mockRejectedValueOnce(new Error('invalid access token'));
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'AnyRouter',
url: 'https://anyrouter.example.com',
platform: 'new-api',
@@ -96,7 +96,7 @@ describe('accounts verify-token shield detection', () => {
},
});
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'AnyRouter',
url: 'https://anyrouter.example.com',
platform: 'new-api',
@@ -129,7 +129,7 @@ describe('accounts verify-token shield detection', () => {
},
});
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'AnyRouter Alias',
url: 'https://anyrouter-alias.example.com',
platform: 'newapi',
@@ -165,7 +165,7 @@ describe('accounts verify-token shield detection', () => {
},
});
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'Legacy Shielded',
url: 'https://legacy-shield.example.com',
platform: 'one-api',
+4 -4
View File
@@ -21,18 +21,18 @@ export async function authRoutes(app: FastifyInstance) {
}
// Save to settings table
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, 'auth_token')).get();
const existing = await db.select().from(schema.settings).where(eq(schema.settings.key, 'auth_token')).get();
if (existing) {
db.update(schema.settings).set({ value: JSON.stringify(newToken) }).where(eq(schema.settings.key, 'auth_token')).run();
await db.update(schema.settings).set({ value: JSON.stringify(newToken) }).where(eq(schema.settings.key, 'auth_token')).run();
} else {
db.insert(schema.settings).values({ key: 'auth_token', value: JSON.stringify(newToken) }).run();
await db.insert(schema.settings).values({ key: 'auth_token', value: JSON.stringify(newToken) }).run();
}
// Update runtime config
config.authToken = newToken;
try {
db.insert(schema.events).values({
await db.insert(schema.events).values({
type: 'token',
title: '管理员登录令牌已更新',
message: '管理员登录 Token 已被修改,请使用新 Token 登录。',
+2 -2
View File
@@ -126,7 +126,7 @@ export async function checkinRoutes(app: FastifyInstance) {
query = query.where(eq(schema.checkinLogs.accountId, parseInt(request.query.accountId, 10))) as any;
}
const rows = query.all();
const rows = await query.all();
return rows.map((row: any) => {
const source = row?.checkin_logs || row;
const failureReason = classifyFailureReason({
@@ -144,7 +144,7 @@ export async function checkinRoutes(app: FastifyInstance) {
app.put<{ Body: { cron: string } }>('/api/checkin/schedule', async (request) => {
try {
updateCheckinCron(request.body.cron);
db.insert(schema.settings).values({ key: 'checkin_cron', value: JSON.stringify(request.body.cron) })
await db.insert(schema.settings).values({ key: 'checkin_cron', value: JSON.stringify(request.body.cron) })
.onConflictDoUpdate({ target: schema.settings.key, set: { value: JSON.stringify(request.body.cron) } }).run();
return { success: true, cron: request.body.cron };
} catch (err: any) {
+17 -7
View File
@@ -28,7 +28,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
app.get('/api/downstream-keys', async () => {
return {
success: true,
items: listDownstreamApiKeys(),
items: await listDownstreamApiKeys(),
};
});
@@ -66,7 +66,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
const nowIso = new Date().toISOString();
try {
const inserted = db.insert(schema.downstreamApiKeys).values({
const insertedResult = await db.insert(schema.downstreamApiKeys).values({
name: normalized.name,
key: normalized.key,
description: normalized.description,
@@ -81,7 +81,17 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
createdAt: nowIso,
updatedAt: nowIso,
}).returning().get();
}).run();
const insertedId = Number(insertedResult.lastInsertRowid || 0);
if (insertedId <= 0) {
return reply.code(500).send({ success: false, message: '创建失败' });
}
const inserted = await db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, insertedId))
.get();
if (!inserted) {
return reply.code(500).send({ success: false, message: '创建失败' });
}
return {
success: true,
@@ -115,7 +125,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const existing = db.select().from(schema.downstreamApiKeys)
const existing = await db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, id))
.get();
@@ -154,7 +164,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
const nowIso = new Date().toISOString();
try {
db.update(schema.downstreamApiKeys).set({
await db.update(schema.downstreamApiKeys).set({
name: normalized.name,
key: normalized.key,
description: normalized.description,
@@ -192,7 +202,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
db.update(schema.downstreamApiKeys).set({
await db.update(schema.downstreamApiKeys).set({
usedCost: 0,
usedRequests: 0,
updatedAt: new Date().toISOString(),
@@ -215,7 +225,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
db.delete(schema.downstreamApiKeys)
await db.delete(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, id))
.run();
+7 -7
View File
@@ -10,14 +10,14 @@ export async function eventsRoutes(app: FastifyInstance) {
const type = request.query.type;
const readQuery = request.query.read;
const filters = [];
const filters: any[] = [];
if (type) filters.push(eq(schema.events.type, type));
if (readQuery === 'true') filters.push(eq(schema.events.read, true));
if (readQuery === 'false') filters.push(eq(schema.events.read, false));
const base = db.select().from(schema.events);
if (filters.length > 0) {
return base
return await base
.where(and(...filters))
.orderBy(desc(schema.events.createdAt))
.limit(limit)
@@ -25,7 +25,7 @@ export async function eventsRoutes(app: FastifyInstance) {
.all();
}
return base
return await base
.orderBy(desc(schema.events.createdAt))
.limit(limit)
.offset(offset)
@@ -34,7 +34,7 @@ export async function eventsRoutes(app: FastifyInstance) {
// Unread count
app.get('/api/events/count', async () => {
const result = db.select({ count: sql<number>`count(*)` }).from(schema.events)
const result = await db.select({ count: sql<number>`count(*)` }).from(schema.events)
.where(eq(schema.events.read, false)).get();
return { count: result?.count || 0 };
});
@@ -42,19 +42,19 @@ export async function eventsRoutes(app: FastifyInstance) {
// Mark one as read
app.post<{ Params: { id: string } }>('/api/events/:id/read', async (request) => {
const id = parseInt(request.params.id);
db.update(schema.events).set({ read: true }).where(eq(schema.events.id, id)).run();
await db.update(schema.events).set({ read: true }).where(eq(schema.events.id, id)).run();
return { success: true };
});
// Mark all as read
app.post('/api/events/read-all', async () => {
db.update(schema.events).set({ read: true }).where(eq(schema.events.read, false)).run();
await db.update(schema.events).set({ read: true }).where(eq(schema.events.read, false)).run();
return { success: true };
});
// Clear all events
app.delete('/api/events', async () => {
db.delete(schema.events).run();
await db.delete(schema.events).run();
return { success: true };
});
}
+8 -8
View File
@@ -7,8 +7,8 @@ const MONITOR_AUTH_COOKIE = 'meta_monitor_auth';
const LDOH_BASE_URL = 'https://ldoh.105117.xyz';
const LDOH_COOKIE_SETTING_KEY = 'monitor_ldoh_cookie';
function upsertSetting(key: string, value: unknown) {
db.insert(schema.settings)
async function upsertSetting(key: string, value: unknown) {
await db.insert(schema.settings)
.values({ key, value: JSON.stringify(value) })
.onConflictDoUpdate({
target: schema.settings.key,
@@ -17,8 +17,8 @@ function upsertSetting(key: string, value: unknown) {
.run();
}
function getSettingString(key: string): string {
const row = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
async function getSettingString(key: string): Promise<string> {
const row = await db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
if (!row?.value) return '';
try {
const parsed = JSON.parse(row.value);
@@ -111,7 +111,7 @@ function resolveLdohProxyPath(request: FastifyRequest): string {
export async function monitorRoutes(app: FastifyInstance) {
app.get('/api/monitor/config', async () => {
const ldohCookie = getSettingString(LDOH_COOKIE_SETTING_KEY);
const ldohCookie = await getSettingString(LDOH_COOKIE_SETTING_KEY);
return {
ldohCookieConfigured: !!ldohCookie,
ldohCookieMasked: ldohCookie ? maskCookieValue(ldohCookie) : '',
@@ -121,7 +121,7 @@ export async function monitorRoutes(app: FastifyInstance) {
app.put<{ Body: { ldohCookie?: string | null } }>('/api/monitor/config', async (request, reply) => {
const raw = String(request.body?.ldohCookie || '').trim();
if (!raw) {
upsertSetting(LDOH_COOKIE_SETTING_KEY, '');
await upsertSetting(LDOH_COOKIE_SETTING_KEY, '');
return { success: true, message: 'LDOH Cookie 已清空', ldohCookieConfigured: false };
}
@@ -130,7 +130,7 @@ export async function monitorRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'Cookie 格式无效,请填写 ld_auth_session 或其值' });
}
upsertSetting(LDOH_COOKIE_SETTING_KEY, normalized);
await upsertSetting(LDOH_COOKIE_SETTING_KEY, normalized);
return {
success: true,
message: 'LDOH Cookie 已保存',
@@ -151,7 +151,7 @@ export async function monitorRoutes(app: FastifyInstance) {
const handleLdohProxy = async (request: FastifyRequest, reply: FastifyReply) => {
if (!ensureMonitorAuth(request, reply)) return;
const storedCookie = getSettingString(LDOH_COOKIE_SETTING_KEY);
const storedCookie = await getSettingString(LDOH_COOKIE_SETTING_KEY);
if (!storedCookie) {
return reply.code(400).send('LDOH cookie not configured');
}
+8 -8
View File
@@ -13,11 +13,11 @@ export async function searchRoutes(app: FastifyInstance) {
const perCategory = Math.min(Math.ceil(limit / 5), 10);
// Search sites
const sites = db.select().from(schema.sites)
const sites = (await db.select().from(schema.sites)
.where(like(schema.sites.name, q))
.limit(perCategory).all()
.limit(perCategory).all())
.concat(
db.select().from(schema.sites)
await db.select().from(schema.sites)
.where(like(schema.sites.url, q))
.limit(perCategory).all()
);
@@ -25,28 +25,28 @@ export async function searchRoutes(app: FastifyInstance) {
const uniqueSites = [...new Map(sites.map(s => [s.id, s])).values()].slice(0, perCategory);
// Search accounts (join with sites for site name)
const accountResults = db.select().from(schema.accounts)
const accountResults = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(like(schema.accounts.username, q))
.limit(perCategory).all();
const accounts = accountResults.map(r => ({ ...r.accounts, site: r.sites }));
// Search checkin logs (by message)
const checkinLogs = db.select().from(schema.checkinLogs)
const checkinLogs = (await db.select().from(schema.checkinLogs)
.innerJoin(schema.accounts, eq(schema.checkinLogs.accountId, schema.accounts.id))
.where(like(schema.checkinLogs.message, q))
.orderBy(desc(schema.checkinLogs.createdAt))
.limit(perCategory).all()
.limit(perCategory).all())
.map(r => ({ ...r.checkin_logs, account: r.accounts }));
// Search proxy logs (by model name)
const proxyLogs = db.select().from(schema.proxyLogs)
const proxyLogs = await db.select().from(schema.proxyLogs)
.where(like(schema.proxyLogs.modelRequested, q))
.orderBy(desc(schema.proxyLogs.createdAt))
.limit(perCategory).all();
// Search models (only keep routable items)
const modelRows = db.select({
const modelRows = await db.select({
modelName: schema.tokenModelAvailability.modelName,
tokenId: schema.accountTokens.id,
accountId: schema.accounts.id,
@@ -0,0 +1,142 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import Database from 'better-sqlite3';
type DbModule = typeof import('../../db/index.js');
describe('settings database migration api', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-db-migration-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const settingsRoutesModule = await import('./settings.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
});
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
await db.delete(schema.settings).run();
await db.delete(schema.events).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('tests sqlite target connection from settings api', async () => {
const targetPath = join(dataDir, 'target-connect.db');
const response = await app.inject({
method: 'POST',
url: '/api/settings/database/test-connection',
payload: {
dialect: 'sqlite',
connectionString: targetPath,
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as { success?: boolean; dialect?: string };
expect(body.success).toBe(true);
expect(body.dialect).toBe('sqlite');
});
it('migrates current sqlite data to another sqlite file via settings api', async () => {
const site = await db.insert(schema.sites).values({
name: 'Target Site',
url: 'https://example.com',
platform: 'new-api',
status: 'active',
proxyUrl: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'u1',
accessToken: 'token-1',
apiToken: null,
status: 'active',
checkinEnabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}).returning().get();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-test',
source: 'manual',
enabled: true,
isDefault: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}).run();
await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
modelMapping: null,
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}).run();
await db.insert(schema.settings).values({
key: 'routing_fallback_unit_cost',
value: JSON.stringify(0.25),
}).run();
const targetPath = join(dataDir, 'target-migrate.db');
const response = await app.inject({
method: 'POST',
url: '/api/settings/database/migrate',
payload: {
dialect: 'sqlite',
connectionString: targetPath,
overwrite: true,
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as { success?: boolean; rows?: Record<string, number> };
expect(body.success).toBe(true);
expect(body.rows?.sites).toBe(1);
expect(body.rows?.accounts).toBe(1);
expect(body.rows?.accountTokens).toBe(1);
expect(body.rows?.settings).toBe(1);
const targetDb = new Database(targetPath);
try {
const targetSites = targetDb.prepare('SELECT COUNT(*) AS cnt FROM sites').get() as { cnt: number };
const targetAccounts = targetDb.prepare('SELECT COUNT(*) AS cnt FROM accounts').get() as { cnt: number };
const targetSettings = targetDb.prepare('SELECT COUNT(*) AS cnt FROM settings').get() as { cnt: number };
expect(targetSites.cnt).toBe(1);
expect(targetAccounts.cnt).toBe(1);
expect(targetSettings.cnt).toBe(1);
} finally {
targetDb.close();
}
});
});
@@ -34,9 +34,9 @@ describe('settings and auth events', () => {
await app.register(authRoutesModule.authRoutes);
});
beforeEach(() => {
db.delete(schema.events).run();
db.delete(schema.settings).run();
beforeEach(async () => {
await db.delete(schema.events).run();
await db.delete(schema.settings).run();
config.authToken = 'old-admin-token-123';
config.proxyToken = 'sk-old-proxy-token-123';
@@ -62,7 +62,7 @@ describe('settings and auth events', () => {
expect(response.statusCode).toBe(200);
const events = db.select().from(schema.events).all();
const events = await db.select().from(schema.events).all();
expect(events.length).toBe(1);
expect(events[0]).toMatchObject({
type: 'status',
@@ -178,7 +178,7 @@ describe('settings and auth events', () => {
expect(updated.routingFallbackUnitCost).toBe(0.25);
expect(config.routingFallbackUnitCost).toBe(0.25);
const saved = db.select().from(schema.settings).where(eq(schema.settings.key, 'routing_fallback_unit_cost')).get();
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'routing_fallback_unit_cost')).get();
expect(saved).toBeTruthy();
expect(saved?.value).toBe(JSON.stringify(0.25));
@@ -206,7 +206,7 @@ describe('settings and auth events', () => {
expect(body.message).toContain('白名单');
expect(body.message).toContain('198.51.100.10');
const saved = db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get();
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get();
expect(saved).toBeFalsy();
});
@@ -224,7 +224,7 @@ describe('settings and auth events', () => {
const body = response.json() as { adminIpAllowlist?: string[] };
expect(body.adminIpAllowlist).toEqual(['198.51.100.10', '198.51.100.11']);
const saved = db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get();
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get();
expect(saved?.value).toBe(JSON.stringify(['198.51.100.10', '198.51.100.11']));
});
@@ -240,7 +240,7 @@ describe('settings and auth events', () => {
expect(response.statusCode).toBe(200);
const events = db.select().from(schema.events).all();
const events = await db.select().from(schema.events).all();
expect(events.length).toBe(1);
expect(events[0]).toMatchObject({
type: 'token',
+59 -14
View File
@@ -7,6 +7,7 @@ import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/chec
import { sendNotification } from '../../services/notifyService.js';
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { migrateCurrentDatabase, testDatabaseConnection } from '../../services/databaseMigrationService.js';
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
type RoutingWeights = typeof config.routingWeights;
@@ -38,6 +39,12 @@ interface RuntimeSettingsBody {
routingWeights?: Partial<RoutingWeights>;
}
interface DatabaseMigrationBody {
dialect?: unknown;
connectionString?: unknown;
overwrite?: unknown;
}
const PROXY_TOKEN_PREFIX = 'sk-';
function isValidProxyToken(value: string): boolean {
@@ -50,8 +57,8 @@ function maskSecret(value: string): string {
return `${value.slice(0, 4)}****${value.slice(-4)}`;
}
function upsertSetting(key: string, value: unknown) {
db.insert(schema.settings)
async function upsertSetting(key: string, value: unknown) {
await db.insert(schema.settings)
.values({ key, value: JSON.stringify(value) })
.onConflictDoUpdate({
target: schema.settings.key,
@@ -60,14 +67,14 @@ function upsertSetting(key: string, value: unknown) {
.run();
}
function appendSettingsEvent(input: {
async function appendSettingsEvent(input: {
type: 'checkin' | 'balance' | 'proxy' | 'status' | 'token';
title: string;
message: string;
level?: 'info' | 'warning' | 'error';
}) {
try {
db.insert(schema.events).values({
await db.insert(schema.events).values({
type: input.type,
title: input.title,
message: input.message,
@@ -275,7 +282,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
}
export async function settingsRoutes(app: FastifyInstance) {
app.get('/api/settings/runtime', async (request) => {
await app.get('/api/settings/runtime', async (request) => {
const currentAdminIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
return getRuntimeSettingsResponse(currentAdminIp);
});
@@ -595,13 +602,50 @@ export async function settingsRoutes(app: FastifyInstance) {
};
});
app.get<{ Querystring: { type?: string } }>('/api/settings/backup/export', async (request, reply) => {
app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/test-connection', async (request, reply) => {
try {
const result = await testDatabaseConnection(request.body || {});
return {
success: true,
message: '目标数据库连接成功',
...result,
};
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '数据库连接失败',
});
}
});
app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/migrate', async (request, reply) => {
try {
const result = await migrateCurrentDatabase(request.body || {});
appendSettingsEvent({
type: 'status',
title: '数据库迁移已完成',
message: `目标 ${result.dialect},已迁移站点 ${result.rows.sites}、账号 ${result.rows.accounts}、令牌 ${result.rows.accountTokens}、路由 ${result.rows.tokenRoutes}、通道 ${result.rows.routeChannels}、设置 ${result.rows.settings}`,
});
return {
success: true,
message: '数据库迁移完成',
...result,
};
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '数据库迁移失败',
});
}
});
await app.get<{ Querystring: { type?: string } }>('/api/settings/backup/export', async (request, reply) => {
const rawType = String(request.query.type || 'all').trim().toLowerCase();
const type: BackupExportType = rawType === 'accounts' || rawType === 'preferences' ? rawType : 'all';
if (rawType && !['all', 'accounts', 'preferences'].includes(rawType)) {
return reply.code(400).send({ success: false, message: '导出类型无效,仅支持 all/accounts/preferences' });
}
return exportBackup(type);
return await exportBackup(type);
});
app.post<{ Body: { data?: Record<string, unknown> } }>('/api/settings/backup/import', async (request, reply) => {
@@ -611,7 +655,7 @@ export async function settingsRoutes(app: FastifyInstance) {
}
try {
const result = importBackup(payload);
const result = await importBackup(payload);
for (const item of result.appliedSettings) {
applyImportedSettingToRuntime(item.key, item.value);
}
@@ -653,9 +697,9 @@ export async function settingsRoutes(app: FastifyInstance) {
});
app.post('/api/settings/maintenance/clear-cache', async (_, reply) => {
const deletedModelAvailability = db.delete(schema.modelAvailability).run().changes;
const deletedRouteChannels = db.delete(schema.routeChannels).run().changes;
const deletedTokenRoutes = db.delete(schema.tokenRoutes).run().changes;
const deletedModelAvailability = (await db.delete(schema.modelAvailability).run()).changes;
const deletedRouteChannels = (await db.delete(schema.routeChannels).run()).changes;
const deletedTokenRoutes = (await db.delete(schema.tokenRoutes).run()).changes;
const { task, reused } = startBackgroundTask(
{
@@ -686,9 +730,9 @@ export async function settingsRoutes(app: FastifyInstance) {
});
app.post('/api/settings/maintenance/clear-usage', async () => {
const deletedProxyLogs = db.delete(schema.proxyLogs).run().changes;
const deletedProxyLogs = (await db.delete(schema.proxyLogs).run()).changes;
db.update(schema.routeChannels).set({
await db.update(schema.routeChannels).set({
successCount: 0,
failCount: 0,
totalLatencyMs: 0,
@@ -698,7 +742,7 @@ export async function settingsRoutes(app: FastifyInstance) {
cooldownUntil: null,
}).run();
db.update(schema.accounts).set({
await db.update(schema.accounts).set({
balanceUsed: 0,
updatedAt: new Date().toISOString(),
}).run();
@@ -717,3 +761,4 @@ export async function settingsRoutes(app: FastifyInstance) {
};
});
}
+3 -3
View File
@@ -26,9 +26,9 @@ describe('sites proxy url settings', () => {
await app.register(routesModule.sitesRoutes);
});
beforeEach(() => {
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
beforeEach(async () => {
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -27,9 +27,9 @@ describe('sites status cascade', () => {
await app.register(routesModule.sitesRoutes);
});
beforeEach(() => {
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
beforeEach(async () => {
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -38,13 +38,13 @@ describe('sites status cascade', () => {
});
it('disables and re-enables related accounts with site status', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'status-site',
url: 'https://status-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'status-user',
accessToken: 'access-token',
@@ -58,7 +58,7 @@ describe('sites status cascade', () => {
});
expect(disableResp.statusCode).toBe(200);
const disabledAccount = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const disabledAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(disabledAccount?.status).toBe('disabled');
const enableResp = await app.inject({
@@ -68,7 +68,7 @@ describe('sites status cascade', () => {
});
expect(enableResp.statusCode).toBe(200);
const enabledAccount = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
const enabledAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
expect(enabledAccount?.status).toBe('active');
});
});
+21 -13
View File
@@ -71,8 +71,8 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
export async function sitesRoutes(app: FastifyInstance) {
// List all sites
app.get('/api/sites', async () => {
const siteRows = db.select().from(schema.sites).all();
const accountBalanceRows = db.select({
const siteRows = await db.select().from(schema.sites).all();
const accountBalanceRows = await db.select({
siteId: schema.accounts.siteId,
totalBalance: sql<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
}).from(schema.accounts)
@@ -129,7 +129,7 @@ export async function sitesRoutes(app: FastifyInstance) {
return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' });
}
const existingSites = db.select().from(schema.sites).all();
const existingSites = await db.select().from(schema.sites).all();
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
let detectedPlatform = platform;
@@ -140,7 +140,7 @@ export async function sitesRoutes(app: FastifyInstance) {
if (!detectedPlatform) {
return { error: 'Could not detect platform. Please specify manually.' };
}
const result = db.insert(schema.sites).values({
const inserted = await db.insert(schema.sites).values({
name,
url: url.replace(/\/+$/, ''),
platform: detectedPlatform,
@@ -151,7 +151,15 @@ export async function sitesRoutes(app: FastifyInstance) {
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
globalWeight: normalizedGlobalWeight ?? 1,
}).returning().get();
}).run();
const siteId = Number(inserted.lastInsertRowid || 0);
if (siteId <= 0) {
return reply.code(500).send({ error: 'Create site failed' });
}
const result = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
if (!result) {
return reply.code(500).send({ error: 'Create site failed' });
}
invalidateSiteProxyCache();
return result;
});
@@ -174,7 +182,7 @@ export async function sitesRoutes(app: FastifyInstance) {
return reply.code(400).send({ error: 'Invalid site id' });
}
const existingSite = db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
if (!existingSite) {
return reply.code(404).send({ error: 'Site not found' });
}
@@ -217,19 +225,19 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
updates.updatedAt = new Date().toISOString();
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
invalidateSiteProxyCache();
if (body.status !== undefined && normalizedStatus) {
const now = new Date().toISOString();
if (normalizedStatus === 'disabled') {
db.update(schema.accounts)
await db.update(schema.accounts)
.set({ status: 'disabled', updatedAt: now })
.where(eq(schema.accounts.siteId, id))
.run();
try {
db.insert(schema.events).values({
await db.insert(schema.events).values({
type: 'status',
title: '站点已禁用',
message: `${existingSite.name} 已禁用,关联账号已全部置为禁用`,
@@ -239,13 +247,13 @@ export async function sitesRoutes(app: FastifyInstance) {
}).run();
} catch {}
} else {
db.update(schema.accounts)
await db.update(schema.accounts)
.set({ status: 'active', updatedAt: now })
.where(and(eq(schema.accounts.siteId, id), eq(schema.accounts.status, 'disabled')))
.run();
try {
db.insert(schema.events).values({
await db.insert(schema.events).values({
type: 'status',
title: '站点已启用',
message: `${existingSite.name} 已启用,关联禁用账号已恢复为活跃`,
@@ -257,13 +265,13 @@ export async function sitesRoutes(app: FastifyInstance) {
}
}
return db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
return await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
});
// Delete a site
app.delete<{ Params: { id: string } }>('/api/sites/:id', async (request) => {
const id = parseInt(request.params.id);
db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
await db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
invalidateSiteProxyCache();
return { success: true };
});
+14 -14
View File
@@ -28,16 +28,16 @@ describe('/api/models/marketplace', () => {
await app.register(routesModule.statsRoutes);
});
beforeEach(() => {
db.delete(schema.proxyLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -46,14 +46,14 @@ describe('/api/models/marketplace', () => {
});
it('returns account-level discovered models even when account has no managed tokens', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'site-no-token',
url: 'https://site-no-token.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'alice',
accessToken: 'session-token',
@@ -61,14 +61,14 @@ describe('/api/models/marketplace', () => {
balance: 12.5,
}).returning().get();
db.insert(schema.modelAvailability).values({
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'claude-sonnet-4-5-20250929',
available: true,
latencyMs: 233,
}).run();
const visibleRows = db.select().from(schema.modelAvailability)
const visibleRows = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
+18 -18
View File
@@ -28,16 +28,16 @@ describe('stats dashboard filters disabled sites', () => {
await app.register(routesModule.statsRoutes);
});
beforeEach(() => {
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -46,21 +46,21 @@ describe('stats dashboard filters disabled sites', () => {
});
it('excludes disabled-site balances from dashboard totals', async () => {
const activeSite = db.insert(schema.sites).values({
const activeSite = await db.insert(schema.sites).values({
name: 'active-site',
url: 'https://active-site.example.com',
platform: 'new-api',
}).returning().get();
const disabledSite = db.insert(schema.sites).values({
const disabledSite = await db.insert(schema.sites).values({
name: 'disabled-site',
url: 'https://disabled-site.example.com',
platform: 'new-api',
}).returning().get();
db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`);
await db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`);
db.insert(schema.accounts).values({
await db.insert(schema.accounts).values({
siteId: activeSite.id,
username: 'active-user',
accessToken: 'active-token',
@@ -68,7 +68,7 @@ describe('stats dashboard filters disabled sites', () => {
status: 'active',
}).run();
db.insert(schema.accounts).values({
await db.insert(schema.accounts).values({
siteId: disabledSite.id,
username: 'disabled-user',
accessToken: 'disabled-token',
@@ -95,13 +95,13 @@ describe('stats dashboard filters disabled sites', () => {
it('treats skipped checkins as successful in dashboard stats', async () => {
const today = formatLocalDate(new Date());
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'checkin-site',
url: 'https://checkin-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'checkin-user',
accessToken: 'token',
@@ -109,7 +109,7 @@ describe('stats dashboard filters disabled sites', () => {
status: 'active',
}).returning().get();
db.insert(schema.checkinLogs).values([
await db.insert(schema.checkinLogs).values([
{
accountId: account.id,
status: 'success',
@@ -32,16 +32,16 @@ describe('stats dashboard today reward fallback', () => {
await app.register(routesModule.statsRoutes);
});
beforeEach(() => {
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -51,12 +51,12 @@ describe('stats dashboard today reward fallback', () => {
it('uses today income value for dashboard todayReward when reward text is empty', async () => {
const today = formatLocalDate(new Date());
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'stats-site',
url: 'https://stats-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'stats-user',
accessToken: 'token',
@@ -71,7 +71,7 @@ describe('stats dashboard today reward fallback', () => {
}),
}).returning().get();
db.insert(schema.checkinLogs).values({
await db.insert(schema.checkinLogs).values({
accountId: account.id,
status: 'success',
message: 'checked in',
@@ -90,12 +90,12 @@ describe('stats dashboard today reward fallback', () => {
});
it('counts dashboard today spend only inside local-day range', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'stats-spend-site',
url: 'https://stats-spend.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'stats-spend-user',
accessToken: 'token',
@@ -109,7 +109,7 @@ describe('stats dashboard today reward fallback', () => {
const inRange = formatUtcSqlDateTime(new Date(startDate.getTime() + 60_000));
const afterEnd = formatUtcSqlDateTime(new Date(endDate.getTime() + 60_000));
db.insert(schema.proxyLogs).values([
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
status: 'success',
@@ -35,18 +35,18 @@ describe('/api/models/token-candidates', () => {
await app.register(routesModule.statsRoutes);
});
beforeEach(() => {
beforeEach(async () => {
fetchModelPricingCatalogMock.mockReset();
fetchModelPricingCatalogMock.mockResolvedValue(null);
db.delete(schema.proxyLogs).run();
db.delete(schema.checkinLogs).run();
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.tokenModelAvailability).run();
db.delete(schema.modelAvailability).run();
db.delete(schema.accountTokens).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -55,21 +55,21 @@ describe('/api/models/token-candidates', () => {
});
it('returns modelsWithoutToken for models available in account but not covered by enabled tokens', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'alice',
accessToken: 'acc-token',
status: 'active',
}).returning().get();
const token = db.insert(schema.accountTokens).values({
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'tk-default',
@@ -77,7 +77,7 @@ describe('/api/models/token-candidates', () => {
isDefault: true,
}).returning().get();
db.insert(schema.modelAvailability).values([
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'claude-haiku-4-5-20251001',
@@ -90,7 +90,7 @@ describe('/api/models/token-candidates', () => {
},
]).run();
db.insert(schema.tokenModelAvailability).values({
await db.insert(schema.tokenModelAvailability).values({
tokenId: token.id,
modelName: 'claude-haiku-4-5-20251001',
available: true,
@@ -120,21 +120,21 @@ describe('/api/models/token-candidates', () => {
});
it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'site-b',
url: 'https://site-b.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'bob',
accessToken: 'acc-token-b',
status: 'active',
}).returning().get();
const defaultToken = db.insert(schema.accountTokens).values({
const defaultToken = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default-token',
token: 'sk-default',
@@ -143,13 +143,13 @@ describe('/api/models/token-candidates', () => {
isDefault: true,
}).returning().get();
db.insert(schema.modelAvailability).values({
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'claude-opus-4-6',
available: true,
}).run();
db.insert(schema.tokenModelAvailability).values({
await db.insert(schema.tokenModelAvailability).values({
tokenId: defaultToken.id,
modelName: 'claude-opus-4-6',
available: true,
@@ -202,21 +202,21 @@ describe('/api/models/token-candidates', () => {
});
it('infers default group from token name when token_group is empty', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'site-c',
url: 'https://site-c.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'charlie',
accessToken: 'acc-token-c',
status: 'active',
}).returning().get();
const token = db.insert(schema.accountTokens).values({
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'sk-default-c',
@@ -225,13 +225,13 @@ describe('/api/models/token-candidates', () => {
isDefault: true,
}).returning().get();
db.insert(schema.modelAvailability).values({
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'claude-sonnet-4-5-20250929',
available: true,
}).run();
db.insert(schema.tokenModelAvailability).values({
await db.insert(schema.tokenModelAvailability).values({
tokenId: token.id,
modelName: 'claude-sonnet-4-5-20250929',
available: true,
@@ -283,21 +283,21 @@ describe('/api/models/token-candidates', () => {
});
it('marks coverage as uncertain when token group cannot be inferred', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'site-d',
url: 'https://site-d.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'david',
accessToken: 'acc-token-d',
status: 'active',
}).returning().get();
const token = db.insert(schema.accountTokens).values({
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'token-1',
token: 'sk-token-1',
@@ -306,13 +306,13 @@ describe('/api/models/token-candidates', () => {
isDefault: true,
}).returning().get();
db.insert(schema.modelAvailability).values({
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'claude-opus-4-5-20251101',
available: true,
}).run();
db.insert(schema.tokenModelAvailability).values({
await db.insert(schema.tokenModelAvailability).values({
tokenId: token.id,
modelName: 'claude-opus-4-5-20251101',
available: true,
+20 -20
View File
@@ -71,7 +71,7 @@ function proxyCostSqlExpression() {
export async function statsRoutes(app: FastifyInstance) {
// Dashboard summary
app.get('/api/stats/dashboard', async () => {
const accountRows = db.select().from(schema.accounts)
const accountRows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.sites.status, 'active'))
.all();
@@ -80,7 +80,7 @@ export async function statsRoutes(app: FastifyInstance) {
const activeCount = accounts.filter((a) => a.status === 'active').length;
const { localDay: today, startUtc: todayStartUtc, endUtc: todayEndUtc } = getLocalDayRangeUtc();
const todayCheckinRows = db.select().from(schema.checkinLogs)
const todayCheckinRows = await db.select().from(schema.checkinLogs)
.innerJoin(schema.accounts, eq(schema.checkinLogs.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(
@@ -109,13 +109,13 @@ export async function statsRoutes(app: FastifyInstance) {
const nowTs = Date.now();
const last24hDate = formatUtcSqlDateTime(new Date(nowTs - 86400000));
const last7dDate = getLocalRangeStartUtc(7);
const recentProxyLogs = db.select().from(schema.proxyLogs)
const recentProxyLogs = (await db.select().from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(gte(schema.proxyLogs.createdAt, last7dDate), eq(schema.sites.status, 'active')))
.all()
.all())
.map((row) => row.proxy_logs);
const totalUsedRow = db.select({
const totalUsedRow = await db.select({
totalUsed: sql<number>`coalesce(sum(${proxyCostSqlExpression()}), 0)`,
})
.from(schema.proxyLogs)
@@ -123,7 +123,7 @@ export async function statsRoutes(app: FastifyInstance) {
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.sites.status, 'active'))
.get();
const proxy24hRow = db.select({
const proxy24hRow = await db.select({
total: sql<number>`count(*)`,
success: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failed: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'failed' then 1 else 0 end), 0)`,
@@ -134,7 +134,7 @@ export async function statsRoutes(app: FastifyInstance) {
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(gte(schema.proxyLogs.createdAt, last24hDate), eq(schema.sites.status, 'active')))
.get();
const todaySpendRow = db.select({
const todaySpendRow = await db.select({
todaySpend: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
@@ -179,7 +179,7 @@ export async function statsRoutes(app: FastifyInstance) {
app.get<{ Querystring: { limit?: string; offset?: string } }>('/api/stats/proxy-logs', async (request) => {
const limit = parseInt(request.query.limit || '50', 10);
const offset = parseInt(request.query.offset || '0', 10);
const rows = db.select().from(schema.proxyLogs)
const rows = await db.select().from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.orderBy(desc(schema.proxyLogs.createdAt))
@@ -244,12 +244,12 @@ export async function statsRoutes(app: FastifyInstance) {
}
}
const availability = db.select().from(schema.tokenModelAvailability)
const availability = await db.select().from(schema.tokenModelAvailability)
.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))
.all();
const accountAvailability = db.select().from(schema.modelAvailability)
const accountAvailability = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
@@ -262,7 +262,7 @@ export async function statsRoutes(app: FastifyInstance) {
.all();
const last7d = getLocalRangeStartUtc(7);
const recentLogs = db.select().from(schema.proxyLogs)
const recentLogs = await db.select().from(schema.proxyLogs)
.where(gte(schema.proxyLogs.createdAt, last7d))
.all();
@@ -299,7 +299,7 @@ export async function statsRoutes(app: FastifyInstance) {
const modelMetadataMap = new Map<string, ModelMetadataAggregate>();
if (includePricing) {
const activeAccountRows = db.select().from(schema.accounts)
const activeAccountRows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(eq(schema.accounts.status, 'active'), eq(schema.sites.status, 'active')))
.all();
@@ -506,7 +506,7 @@ export async function statsRoutes(app: FastifyInstance) {
return name;
};
const rows = db.select().from(schema.tokenModelAvailability)
const rows = await db.select().from(schema.tokenModelAvailability)
.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))
@@ -519,7 +519,7 @@ export async function statsRoutes(app: FastifyInstance) {
),
)
.all();
const availableModelRows = db.select({
const availableModelRows = await db.select({
modelName: schema.modelAvailability.modelName,
accountId: schema.accounts.id,
username: schema.accounts.username,
@@ -621,7 +621,7 @@ export async function statsRoutes(app: FastifyInstance) {
const hasPotentialGroupHints = hasAnyTokenGroupSignals || unknownGroupCoverageByAccountModel.size > 0;
if (hasPotentialGroupHints && accountIdsForGroupHints.size > 0) {
const accountRows = db.select().from(schema.accounts)
const accountRows = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
@@ -749,7 +749,7 @@ export async function statsRoutes(app: FastifyInstance) {
// Site distribution – per-site aggregate data
app.get('/api/stats/site-distribution', async () => {
const accountRows = db.select({
const accountRows = await db.select({
siteId: schema.sites.id,
siteName: schema.sites.name,
platform: schema.sites.platform,
@@ -762,7 +762,7 @@ export async function statsRoutes(app: FastifyInstance) {
.groupBy(schema.sites.id, schema.sites.name, schema.sites.platform)
.all();
const spendRows = db.select({
const spendRows = await db.select({
siteId: schema.sites.id,
totalSpend: sql<number>`coalesce(sum(${proxyCostSqlExpression()}), 0)`,
})
@@ -796,7 +796,7 @@ export async function statsRoutes(app: FastifyInstance) {
const days = Math.max(1, parseInt(request.query.days || '7', 10));
const sinceDate = getLocalRangeStartUtc(days);
const rows = db.select().from(schema.proxyLogs)
const rows = await db.select().from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(gte(schema.proxyLogs.createdAt, sinceDate), eq(schema.sites.status, 'active')))
@@ -847,12 +847,12 @@ export async function statsRoutes(app: FastifyInstance) {
// Get account IDs belonging to the site (if filtered)
let accountIds: Set<number> | null = null;
if (siteId != null && !Number.isNaN(siteId)) {
const siteAccounts = db.select().from(schema.accounts)
const siteAccounts = await db.select().from(schema.accounts)
.where(eq(schema.accounts.siteId, siteId)).all();
accountIds = new Set(siteAccounts.map((a) => a.id));
}
const rows = db.select().from(schema.proxyLogs)
const rows = await db.select().from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(and(gte(schema.proxyLogs.createdAt, sinceDate), eq(schema.sites.status, 'active')))
+15 -15
View File
@@ -19,24 +19,24 @@ describe('PUT /api/channels/batch', () => {
return seedId;
};
const seedChannel = (options: { priority: number; weight: number; manualOverride?: boolean }) => {
const seedChannel = async (options: { priority: number; weight: number; manualOverride?: boolean }) => {
const id = nextId();
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: `site-${id}`,
url: `https://example.com/${id}`,
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: `access-token-${id}`,
apiToken: `api-token-${id}`,
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: `gpt-4o-${id}`,
enabled: true,
}).returning().get();
return db.insert(schema.routeChannels).values({
return await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
priority: options.priority,
@@ -59,12 +59,12 @@ describe('PUT /api/channels/batch', () => {
await app.register(routesModule.tokensRoutes);
});
beforeEach(() => {
db.delete(schema.routeChannels).run();
db.delete(schema.accountTokens).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
@@ -112,8 +112,8 @@ describe('PUT /api/channels/batch', () => {
});
it('updates priorities in batch, sets manualOverride, and keeps weight unchanged', async () => {
const channelA = seedChannel({ priority: 9, weight: 17, manualOverride: false });
const channelB = seedChannel({ priority: 8, weight: 23, manualOverride: false });
const channelA = await seedChannel({ priority: 9, weight: 17, manualOverride: false });
const channelB = await seedChannel({ priority: 8, weight: 23, manualOverride: false });
const res = await app.inject({
method: 'PUT',
@@ -145,8 +145,8 @@ describe('PUT /api/channels/batch', () => {
expect(returnedA?.manualOverride).toBe(true);
expect(returnedB?.manualOverride).toBe(true);
const dbA = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelA.id)).get();
const dbB = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelB.id)).get();
const dbA = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelA.id)).get();
const dbB = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelB.id)).get();
expect(dbA?.priority).toBe(3);
expect(dbB?.priority).toBe(0);
expect(dbA?.weight).toBe(17);
@@ -20,15 +20,15 @@ describe('POST /api/routes/decision/batch', () => {
return seedId;
};
const seedRoutableChannel = () => {
const seedRoutableChannel = async () => {
const id = nextId();
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: `site-${id}`,
url: `https://site-${id}.example.com`,
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: `user-${id}`,
accessToken: `access-token-${id}`,
@@ -36,12 +36,12 @@ describe('POST /api/routes/decision/batch', () => {
status: 'active',
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
@@ -67,12 +67,12 @@ describe('POST /api/routes/decision/batch', () => {
await app.register(routesModule.tokensRoutes);
});
beforeEach(() => {
beforeEach(async () => {
seedId = 0;
db.delete(schema.routeChannels).run();
db.delete(schema.tokenRoutes).run();
db.delete(schema.accounts).run();
db.delete(schema.sites).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
});
@@ -107,13 +107,13 @@ describe('POST /api/routes/decision/batch', () => {
});
it('returns decisions scoped by route id to avoid wildcard channel mismatch', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'wildcard-site',
url: 'https://wildcard-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'wildcard-user',
accessToken: 'wildcard-access',
@@ -121,12 +121,12 @@ describe('POST /api/routes/decision/batch', () => {
status: 'active',
}).returning().get();
const exactRoute = db.insert(schema.tokenRoutes).values({
const exactRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-6',
enabled: true,
}).returning().get();
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId: exactRoute.id,
accountId: account.id,
tokenId: null,
@@ -136,12 +136,12 @@ describe('POST /api/routes/decision/batch', () => {
enabled: true,
}).run();
const wildcardRoute = db.insert(schema.tokenRoutes).values({
const wildcardRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
enabled: true,
}).returning().get();
const wildcardChannel = db.insert(schema.routeChannels).values({
const wildcardChannel = await db.insert(schema.routeChannels).values({
routeId: wildcardRoute.id,
accountId: account.id,
tokenId: null,
@@ -173,13 +173,13 @@ describe('POST /api/routes/decision/batch', () => {
});
it('returns route-wide wildcard probabilities normalized to 100 across all channels', async () => {
const site = db.insert(schema.sites).values({
const site = await db.insert(schema.sites).values({
name: 'route-wide-site',
url: 'https://route-wide-site.example.com',
platform: 'new-api',
}).returning().get();
const account = db.insert(schema.accounts).values({
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'route-wide-user',
accessToken: 'route-wide-access',
@@ -187,12 +187,12 @@ describe('POST /api/routes/decision/batch', () => {
status: 'active',
}).returning().get();
const route = db.insert(schema.tokenRoutes).values({
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
enabled: true,
}).returning().get();
const channelA = db.insert(schema.routeChannels).values({
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
@@ -202,7 +202,7 @@ describe('POST /api/routes/decision/batch', () => {
enabled: true,
}).returning().get();
const channelB = db.insert(schema.routeChannels).values({
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: null,
+89 -73
View File
@@ -12,8 +12,8 @@ function isExactModelPattern(modelPattern: string): boolean {
return !/[\*\?\[]/.test(normalized);
}
function getDefaultTokenId(accountId: number): number | null {
const token = db.select().from(schema.accountTokens)
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;
@@ -35,8 +35,8 @@ function isModelAliasEquivalent(left: string, right: string): boolean {
return !!a && !!b && a === b;
}
function tokenSupportsModel(tokenId: number, modelName: string): boolean {
const rows = db.select().from(schema.tokenModelAvailability)
async function tokenSupportsModel(tokenId: number, modelName: string): Promise<boolean> {
const rows = await db.select().from(schema.tokenModelAvailability)
.where(
and(
eq(schema.tokenModelAvailability.tokenId, tokenId),
@@ -51,15 +51,15 @@ function tokenSupportsModel(tokenId: number, modelName: string): boolean {
});
}
function checkTokenBelongsToAccount(tokenId: number, accountId: number): boolean {
const row = db.select().from(schema.accountTokens)
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;
}
function getPatternTokenCandidates(modelPattern: string): Array<{ tokenId: number; accountId: number; sourceModel: string }> {
const rows = db.select().from(schema.tokenModelAvailability)
async function getPatternTokenCandidates(modelPattern: string): Promise<Array<{ tokenId: number; accountId: number; sourceModel: string }>> {
const rows = await db.select().from(schema.tokenModelAvailability)
.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))
@@ -88,7 +88,7 @@ function getPatternTokenCandidates(modelPattern: string): Array<{ tokenId: numbe
return result;
}
function getMatchedExactRouteChannelCandidates(modelPattern: string): Array<{
async function getMatchedExactRouteChannelCandidates(modelPattern: string): Promise<Array<{
tokenId: number | null;
accountId: number;
sourceModel: string;
@@ -96,17 +96,17 @@ function getMatchedExactRouteChannelCandidates(modelPattern: string): Array<{
weight: number;
enabled: boolean;
manualOverride: boolean;
}> {
const matchedRoutes = db.select().from(schema.tokenRoutes)
}>> {
const matchedRoutes = (await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all()
.all())
.filter((route) => isExactModelPattern(route.modelPattern) && matchesModelPattern(route.modelPattern, modelPattern));
if (matchedRoutes.length === 0) return [];
const routeMap = new Map<number, typeof matchedRoutes[number]>();
for (const route of matchedRoutes) routeMap.set(route.id, route);
const channels = db.select().from(schema.routeChannels)
const channels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.routeId, matchedRoutes.map((route) => route.id)))
.all();
@@ -121,9 +121,9 @@ function getMatchedExactRouteChannelCandidates(modelPattern: string): Array<{
})).filter((candidate) => candidate.sourceModel.length > 0);
}
function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): number {
const routeCandidates = getMatchedExactRouteChannelCandidates(modelPattern);
const availabilityCandidates = getPatternTokenCandidates(modelPattern).map((candidate) => ({
async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<number> {
const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern);
const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({
tokenId: candidate.tokenId,
accountId: candidate.accountId,
sourceModel: candidate.sourceModel,
@@ -135,7 +135,7 @@ function populateRouteChannelsByModelPattern(routeId: number, modelPattern: stri
const candidates = [...routeCandidates, ...availabilityCandidates];
if (candidates.length === 0) return 0;
const existingChannels = db.select().from(schema.routeChannels)
const existingChannels = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, routeId))
.all();
const existingPairs = new Set<string>(
@@ -152,7 +152,7 @@ function populateRouteChannelsByModelPattern(routeId: number, modelPattern: stri
const tokenId = typeof candidate.tokenId === 'number' && Number.isFinite(candidate.tokenId) ? candidate.tokenId : 0;
const pairKey = `${candidate.accountId}::${tokenId}::${candidate.sourceModel.trim().toLowerCase()}`;
if (existingPairs.has(pairKey)) continue;
db.insert(schema.routeChannels).values({
await db.insert(schema.routeChannels).values({
routeId,
accountId: candidate.accountId,
tokenId: candidate.tokenId,
@@ -328,11 +328,11 @@ function parseBatchRouteWideDecisionRouteIds(
export async function tokensRoutes(app: FastifyInstance) {
// List all routes
app.get('/api/routes', async () => {
const routes = db.select().from(schema.tokenRoutes).all();
const routes = await db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id);
const channelRows = db.select().from(schema.routeChannels)
const channelRows = await db.select().from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
@@ -372,7 +372,7 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'model 不能为空' });
}
const decision = tokenRouter.explainSelection(model);
const decision = await tokenRouter.explainSelection(model);
return { success: true, decision };
});
@@ -382,10 +382,10 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: parsed.message });
}
const decisions: Record<string, ReturnType<typeof tokenRouter.explainSelection>> = {};
for (const model of parsed.models) {
decisions[model] = tokenRouter.explainSelection(model);
}
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelection>>> = {};
for (const model of parsed.models) {
decisions[model] = await tokenRouter.explainSelection(model);
}
return { success: true, decisions };
});
@@ -396,12 +396,12 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: parsed.message });
}
const decisions: Record<string, Record<string, ReturnType<typeof tokenRouter.explainSelection>>> = {};
for (const item of parsed.items) {
const routeKey = String(item.routeId);
if (!decisions[routeKey]) decisions[routeKey] = {};
decisions[routeKey][item.model] = tokenRouter.explainSelectionForRoute(item.routeId, item.model);
}
const decisions: Record<string, Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionForRoute>>>> = {};
for (const item of parsed.items) {
const routeKey = String(item.routeId);
if (!decisions[routeKey]) decisions[routeKey] = {};
decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model);
}
return { success: true, decisions };
});
@@ -412,10 +412,10 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: parsed.message });
}
const decisions: Record<string, ReturnType<typeof tokenRouter.explainSelection>> = {};
for (const routeId of parsed.routeIds) {
decisions[String(routeId)] = tokenRouter.explainSelectionRouteWide(routeId);
}
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionRouteWide>>> = {};
for (const routeId of parsed.routeIds) {
decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId);
}
return { success: true, decisions };
});
@@ -423,15 +423,23 @@ export async function tokensRoutes(app: FastifyInstance) {
// Create a route
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
const body = request.body;
const route = db.insert(schema.tokenRoutes).values({
modelPattern: body.modelPattern,
displayName: body.displayName,
displayIcon: body.displayIcon,
modelMapping: body.modelMapping,
enabled: body.enabled ?? true,
}).returning().get();
const insertedRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: body.modelPattern,
displayName: body.displayName,
displayIcon: body.displayIcon,
modelMapping: body.modelMapping,
enabled: body.enabled ?? true,
}).run();
const routeId = Number(insertedRoute.lastInsertRowid || 0);
if (routeId <= 0) {
return { success: false, message: '创建路由失败' };
}
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return { success: false, message: '创建路由失败' };
}
populateRouteChannelsByModelPattern(route.id, body.modelPattern);
await populateRouteChannelsByModelPattern(route.id, body.modelPattern);
invalidateTokenRouterCache();
return route;
});
@@ -449,15 +457,15 @@ export async function tokensRoutes(app: FastifyInstance) {
if (body.enabled !== undefined) updates.enabled = body.enabled;
updates.updatedAt = new Date().toISOString();
db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
invalidateTokenRouterCache();
return db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
});
// Delete a route
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
const id = parseInt(request.params.id, 10);
db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
invalidateTokenRouterCache();
return { success: true };
});
@@ -467,7 +475,7 @@ export async function tokensRoutes(app: FastifyInstance) {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
@@ -475,19 +483,19 @@ export async function tokensRoutes(app: FastifyInstance) {
const sourceModel = typeof body.sourceModel === 'string'
? body.sourceModel.trim()
: (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : '');
const effectiveTokenId = body.tokenId ?? getDefaultTokenId(body.accountId);
const effectiveTokenId = body.tokenId ?? await getDefaultTokenId(body.accountId);
if (body.tokenId && !checkTokenBelongsToAccount(body.tokenId, body.accountId)) {
if (body.tokenId && !await checkTokenBelongsToAccount(body.tokenId, body.accountId)) {
return reply.code(400).send({ success: false, message: '令牌不存在或不属于当前账号' });
}
if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !tokenSupportsModel(effectiveTokenId, route.modelPattern)) {
if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !await tokenSupportsModel(effectiveTokenId, route.modelPattern)) {
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
}
const duplicate = db.select().from(schema.routeChannels)
const duplicate = (await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, routeId))
.all()
.all())
.some((channel) =>
channel.accountId === body.accountId
&& (channel.tokenId ?? null) === (body.tokenId ?? null)
@@ -497,14 +505,22 @@ export async function tokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' });
}
const created = db.insert(schema.routeChannels).values({
routeId,
accountId: body.accountId,
tokenId: body.tokenId,
sourceModel: sourceModel || null,
priority: body.priority ?? 0,
weight: body.weight ?? 10,
}).returning().get();
const insertedChannel = await db.insert(schema.routeChannels).values({
routeId,
accountId: body.accountId,
tokenId: body.tokenId,
sourceModel: sourceModel || null,
priority: body.priority ?? 0,
weight: body.weight ?? 10,
}).run();
const channelId = Number(insertedChannel.lastInsertRowid || 0);
if (channelId <= 0) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
}
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!created) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
}
invalidateTokenRouterCache();
return created;
});
@@ -517,7 +533,7 @@ export async function tokensRoutes(app: FastifyInstance) {
}
const channelIds = Array.from(new Set(parsed.updates.map((update) => update.id)));
const existingChannels = db.select().from(schema.routeChannels)
const existingChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds))
.all();
if (existingChannels.length !== channelIds.length) {
@@ -527,13 +543,13 @@ export async function tokensRoutes(app: FastifyInstance) {
}
for (const update of parsed.updates) {
db.update(schema.routeChannels).set({
await db.update(schema.routeChannels).set({
priority: update.priority,
manualOverride: true,
}).where(eq(schema.routeChannels.id, update.id)).run();
}
const updatedChannels = db.select().from(schema.routeChannels)
const updatedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds))
.all();
invalidateTokenRouterCache();
@@ -545,28 +561,28 @@ export async function tokensRoutes(app: FastifyInstance) {
const channelId = parseInt(request.params.channelId, 10);
const body = request.body as Record<string, unknown>;
const channel = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!channel) {
return reply.code(404).send({ success: false, message: '通道不存在' });
}
const route = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, channel.routeId)).get();
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, channel.routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
if (body.tokenId !== undefined && body.tokenId !== null) {
const tokenId = Number(body.tokenId);
if (!Number.isFinite(tokenId) || !checkTokenBelongsToAccount(tokenId, channel.accountId)) {
if (!Number.isFinite(tokenId) || !await checkTokenBelongsToAccount(tokenId, channel.accountId)) {
return reply.code(400).send({ success: false, message: '令牌不存在或不属于通道账号' });
}
}
const nextTokenId = body.tokenId === undefined
? (channel.tokenId ?? getDefaultTokenId(channel.accountId))
: (body.tokenId === null ? getDefaultTokenId(channel.accountId) : Number(body.tokenId));
const nextTokenId = body.tokenId === undefined
? (channel.tokenId ?? await getDefaultTokenId(channel.accountId))
: (body.tokenId === null ? await getDefaultTokenId(channel.accountId) : Number(body.tokenId));
if (isExactModelPattern(route.modelPattern) && nextTokenId && !tokenSupportsModel(nextTokenId, route.modelPattern)) {
if (isExactModelPattern(route.modelPattern) && nextTokenId && !await tokenSupportsModel(nextTokenId, route.modelPattern)) {
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
}
@@ -580,15 +596,15 @@ export async function tokensRoutes(app: FastifyInstance) {
if (body[key] !== undefined) updates[key] = body[key];
}
db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
invalidateTokenRouterCache();
return db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
});
// Delete a channel
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
const channelId = parseInt(request.params.channelId, 10);
db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
invalidateTokenRouterCache();
return { success: true };
});
+6 -6
View File
@@ -139,7 +139,7 @@ async function handleChatProxyRequest(
const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!;
const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions';
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const excludeChannelIds: number[] = [];
@@ -147,12 +147,12 @@ async function handleChatProxyRequest(
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
@@ -703,7 +703,7 @@ async function handleChatProxyRequest(
}
}
function logProxy(
async function logProxy(
selected: any,
modelRequested: string,
status: string,
@@ -724,7 +724,7 @@ function logProxy(
upstreamPath,
errorMessage,
});
db.insert(schema.proxyLogs).values({
await db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
+6 -6
View File
@@ -22,7 +22,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const isStream = body.stream === true;
@@ -31,12 +31,12 @@ export async function completionsProxyRoute(app: FastifyInstance) {
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
@@ -228,7 +228,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
});
}
function logProxy(
async function logProxy(
selected: any,
modelRequested: string,
status: string,
@@ -246,7 +246,7 @@ function logProxy(
downstreamPath: '/v1/completions',
errorMessage,
});
db.insert(schema.proxyLogs).values({
await db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
+4 -4
View File
@@ -7,15 +7,15 @@ export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamR
return getProxyAuthContext(request)?.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY;
}
export function ensureModelAllowedForDownstreamKey(
export async function ensureModelAllowedForDownstreamKey(
request: FastifyRequest,
reply: FastifyReply,
requestedModel: string,
): boolean {
): Promise<boolean> {
const authContext = getProxyAuthContext(request);
if (!authContext) return true;
if (isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) {
if (await isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) {
return true;
}
@@ -31,5 +31,5 @@ export function ensureModelAllowedForDownstreamKey(
export function recordDownstreamCostUsage(request: FastifyRequest, estimatedCost: number): void {
const authContext = getProxyAuthContext(request);
if (!authContext || authContext.keyId === null) return;
recordManagedKeyCostUsage(authContext.keyId, estimatedCost);
void recordManagedKeyCostUsage(authContext.keyId, estimatedCost);
}
+6 -6
View File
@@ -22,7 +22,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const excludeChannelIds: number[] = [];
@@ -30,12 +30,12 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
@@ -143,7 +143,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
});
}
function logProxy(
async function logProxy(
selected: any,
modelRequested: string,
status: string,
@@ -161,7 +161,7 @@ function logProxy(
downstreamPath: '/v1/embeddings',
errorMessage,
});
db.insert(schema.proxyLogs).values({
await db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
+6 -6
View File
@@ -17,19 +17,19 @@ export async function imagesProxyRoute(app: FastifyInstance) {
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
const requestedModel = body?.model || 'gpt-image-1';
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const excludeChannelIds: number[] = [];
let retryCount = 0;
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
@@ -116,7 +116,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
});
}
function logProxy(
async function logProxy(
selected: any,
modelRequested: string,
status: string,
@@ -131,7 +131,7 @@ function logProxy(
downstreamPath: '/v1/images/generations',
errorMessage,
});
db.insert(schema.proxyLogs).values({
await db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
+13 -7
View File
@@ -9,8 +9,8 @@ export async function modelsProxyRoute(app: FastifyInstance) {
app.get('/v1/models', async (request) => {
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const readModels = () => {
const rows = db.select({ modelName: schema.modelAvailability.modelName })
const readModels = async () => {
const rows = await db.select({ modelName: schema.modelAvailability.modelName })
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
@@ -22,23 +22,29 @@ export async function modelsProxyRoute(app: FastifyInstance) {
),
)
.all();
const routeAliases = db.select({ displayName: schema.tokenRoutes.displayName })
const routeAliases = (await db.select({ displayName: schema.tokenRoutes.displayName })
.from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all()
.all())
.map((row) => (row.displayName || '').trim())
.filter((name) => name.length > 0);
const deduped = Array.from(new Set([
...rows.map((r) => r.modelName),
...routeAliases,
])).sort();
return deduped.filter((modelName) => isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy));
const allowed: string[] = [];
for (const modelName of deduped) {
if (await isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy)) {
allowed.push(modelName);
}
}
return allowed;
};
let models = readModels();
let models = await readModels();
if (models.length === 0) {
await refreshModelsAndRebuildRoutes();
models = readModels();
models = await readModels();
}
const wantsClaudeFormat = typeof request.headers['anthropic-version'] === 'string'
+6 -6
View File
@@ -1147,7 +1147,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const isStream = body.stream === true;
@@ -1156,12 +1156,12 @@ export async function responsesProxyRoute(app: FastifyInstance) {
while (retryCount <= MAX_RETRIES) {
let selected = retryCount === 0
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
if (!selected && retryCount === 0) {
await refreshModelsAndRebuildRoutes();
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
}
if (!selected) {
@@ -1561,7 +1561,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
});
}
function logProxy(
async function logProxy(
selected: any,
modelRequested: string,
status: string,
@@ -1582,7 +1582,7 @@ function logProxy(
upstreamPath,
errorMessage,
});
db.insert(schema.proxyLogs).values({
await db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,