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:
+171
-27
@@ -378,6 +378,40 @@ function parseBatchRouteWideDecisionRouteIds(
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
|
||||
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<number, any[]>();
|
||||
|
||||
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<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 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<number, any[]>();
|
||||
|
||||
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,
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
Reference in New Issue
Block a user