From bb7ea90f0b67efbc6919eac0173ad22a6ad4519d Mon Sep 17 00:00:00 2001 From: Hureru <3507039083@qq.com> Date: Wed, 11 Mar 2026 01:14:31 +0800 Subject: [PATCH] feat(api): add layered route loading endpoints Add GET /api/routes/summary for lightweight first-screen data, GET /api/routes/:id/channels for on-demand channel loading, and POST /api/routes/:id/channels/batch for bulk channel creation. Extract fetchChannelsForRoutes() as reusable helper. --- src/server/routes/api/tokens.ts | 198 +++++++++++++++++++++++++++----- src/web/api.ts | 4 + 2 files changed, 175 insertions(+), 27 deletions(-) diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index a86b5a8..9a45549 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -378,6 +378,40 @@ function parseBatchRouteWideDecisionRouteIds( }; } +async function fetchChannelsForRoutes(routeIds: number[]): Promise> { + if (routeIds.length === 0) return new Map(); + + 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)) + .where(inArray(schema.routeChannels.routeId, routeIds)) + .all(); + + const channelsByRoute = new Map(); + + for (const row of channelRows) { + const routeId = row.route_channels.routeId; + if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []); + channelsByRoute.get(routeId)!.push({ + ...row.route_channels, + account: row.accounts, + site: row.sites, + token: row.account_tokens + ? { + id: row.account_tokens.id, + name: row.account_tokens.name, + accountId: row.account_tokens.accountId, + enabled: row.account_tokens.enabled, + isDefault: row.account_tokens.isDefault, + } + : null, + }); + } + + return channelsByRoute; +} + export async function tokensRoutes(app: FastifyInstance) { // List routes with basic info only (lightweight for selectors) app.get('/api/routes/lite', async () => { @@ -390,39 +424,149 @@ export async function tokensRoutes(app: FastifyInstance) { }).from(schema.tokenRoutes).all(); }); + // Route summary (no channel details) for first-screen rendering + app.get('/api/routes/summary', async () => { + const routes = await db.select().from(schema.tokenRoutes).all(); + if (routes.length === 0) return []; + + const routeIds = routes.map((route) => route.id); + + // Aggregate channel counts and site names per route + const channelRows = await db.select({ + routeId: schema.routeChannels.routeId, + enabled: schema.routeChannels.enabled, + siteName: schema.sites.name, + }).from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(inArray(schema.routeChannels.routeId, routeIds)) + .all(); + + const aggByRoute = new Map }>(); + for (const row of channelRows) { + let agg = aggByRoute.get(row.routeId); + if (!agg) { + agg = { channelCount: 0, enabledChannelCount: 0, siteNames: new Set() }; + aggByRoute.set(row.routeId, agg); + } + agg.channelCount += 1; + if (row.enabled) agg.enabledChannelCount += 1; + if (row.siteName) agg.siteNames.add(row.siteName); + } + + return routes.map((route) => { + const agg = aggByRoute.get(route.id); + return { + id: route.id, + modelPattern: route.modelPattern, + displayName: route.displayName ?? null, + displayIcon: route.displayIcon ?? null, + modelMapping: route.modelMapping ?? null, + enabled: route.enabled, + channelCount: agg?.channelCount ?? 0, + enabledChannelCount: agg?.enabledChannelCount ?? 0, + siteNames: agg ? Array.from(agg.siteNames) : [], + decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), + decisionRefreshedAt: route.decisionRefreshedAt ?? null, + }; + }); + }); + + // Get channels for a single route (on-demand loading) + app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + 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: '路由不存在' }); + } + const channelsByRoute = await fetchChannelsForRoutes([routeId]); + return channelsByRoute.get(routeId) || []; + }); + + // Batch add channels to a route + app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + const body = request.body; + + 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: '路由不存在' }); + } + + if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) { + return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' }); + } + + const existingChannels = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, routeId)) + .all(); + const existingPairs = new Set( + existingChannels.map((channel) => { + const tokenId = typeof channel.tokenId === 'number' && Number.isFinite(channel.tokenId) ? channel.tokenId : 0; + const sourceModel = (channel.sourceModel || '').trim().toLowerCase(); + return `${channel.accountId}::${tokenId}::${sourceModel}`; + }), + ); + + let created = 0; + let skipped = 0; + const errors: string[] = []; + + for (const item of body.channels) { + if (!item?.accountId || typeof item.accountId !== 'number') { + errors.push('无效的 accountId'); + continue; + } + + const sourceModel = typeof item.sourceModel === 'string' + ? item.sourceModel.trim() + : (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : ''); + const effectiveTokenId = item.tokenId ?? await getDefaultTokenId(item.accountId); + + if (item.tokenId && !await checkTokenBelongsToAccount(item.tokenId, item.accountId)) { + errors.push(`令牌 ${item.tokenId} 不属于账号 ${item.accountId}`); + continue; + } + + const tokenIdForKey = typeof effectiveTokenId === 'number' && Number.isFinite(effectiveTokenId) ? effectiveTokenId : 0; + const pairKey = `${item.accountId}::${tokenIdForKey}::${sourceModel.toLowerCase()}`; + if (existingPairs.has(pairKey)) { + skipped += 1; + continue; + } + + try { + await db.insert(schema.routeChannels).values({ + routeId, + accountId: item.accountId, + tokenId: item.tokenId, + sourceModel: sourceModel || null, + priority: 0, + weight: 10, + manualOverride: true, + }).run(); + existingPairs.add(pairKey); + created += 1; + } catch (e: any) { + errors.push(e.message || `添加通道失败: accountId=${item.accountId}`); + } + } + + if (created > 0) { + await clearRouteDecisionSnapshot(routeId); + invalidateTokenRouterCache(); + } + + return { success: true, created, skipped, errors }; + }); + // List all routes app.get('/api/routes', async () => { const routes = await db.select().from(schema.tokenRoutes).all(); if (routes.length === 0) return []; const routeIds = routes.map((route) => route.id); - 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)) - .where(inArray(schema.routeChannels.routeId, routeIds)) - .all(); - - const channelsByRoute = new Map(); - - for (const row of channelRows) { - const routeId = row.route_channels.routeId; - if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []); - channelsByRoute.get(routeId)!.push({ - ...row.route_channels, - account: row.accounts, - site: row.sites, - token: row.account_tokens - ? { - id: row.account_tokens.id, - name: row.account_tokens.name, - accountId: row.account_tokens.accountId, - enabled: row.account_tokens.enabled, - isDefault: row.account_tokens.isDefault, - } - : null, - }); - } + const channelsByRoute = await fetchChannelsForRoutes(routeIds); return routes.map((route) => ({ ...route, diff --git a/src/web/api.ts b/src/web/api.ts index 86e9627..6e97cb3 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -274,6 +274,10 @@ export const api = { // Routes getRoutes: () => request('/api/routes'), getRoutesLite: () => request('/api/routes/lite'), + getRoutesSummary: () => request('/api/routes/summary'), + getRouteChannels: (routeId: number) => request(`/api/routes/${routeId}/channels`), + batchAddChannels: (routeId: number, channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }>) => + request(`/api/routes/${routeId}/channels/batch`, { method: 'POST', body: JSON.stringify({ channels }) }), addRoute: (data: any) => request('/api/routes', { method: 'POST', body: JSON.stringify(data) }), updateRoute: (id: number, data: any) => request(`/api/routes/${id}`, { method: 'PUT', body: JSON.stringify(data) }), deleteRoute: (id: number) => request(`/api/routes/${id}`, { method: 'DELETE' }),