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.
This commit is contained in:
Hureru
2026-03-11 01:14:31 +08:00
parent 2343383729
commit bb7ea90f0b
2 changed files with 175 additions and 27 deletions
+161 -17
View File
@@ -378,24 +378,9 @@ function parseBatchRouteWideDecisionRouteIds(
};
}
export async function tokensRoutes(app: FastifyInstance) {
// List routes with basic info only (lightweight for selectors)
app.get('/api/routes/lite', async () => {
return await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
displayName: schema.tokenRoutes.displayName,
displayIcon: schema.tokenRoutes.displayIcon,
enabled: schema.tokenRoutes.enabled,
}).from(schema.tokenRoutes).all();
});
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
if (routeIds.length === 0) return new Map();
// 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))
@@ -424,6 +409,165 @@ export async function tokensRoutes(app: FastifyInstance) {
});
}
return channelsByRoute;
}
export async function tokensRoutes(app: FastifyInstance) {
// List routes with basic info only (lightweight for selectors)
app.get('/api/routes/lite', async () => {
return await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
displayName: schema.tokenRoutes.displayName,
displayIcon: schema.tokenRoutes.displayIcon,
enabled: schema.tokenRoutes.enabled,
}).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<number, { channelCount: number; enabledChannelCount: number; siteNames: Set<string> }>();
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<string>(
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 channelsByRoute = await fetchChannelsForRoutes(routeIds);
return routes.map((route) => ({
...route,
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
+4
View File
@@ -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' }),