From 3bf3cd162236a7d9de0dcc30e2efea35624d9aa5 Mon Sep 17 00:00:00 2001 From: Sean Lee <276371998@qq.com> Date: Sat, 28 Mar 2026 16:54:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E7=A6=81=E7=94=A8/=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20(#295)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Preserve manual models during discovery and refresh, and add Claude configuration files and project documentation.feat: Preserve manual models during discovery and refresh, and add Claude configuration and project documentation files. * fix: fix Feishu and Larksuite webhook support to the notification service with updated UI and tests. * feat: 新增路由批量禁用/启用功能 - 后端新增 POST /api/routes/batch 接口,支持批量 enable/disable - 前端新增批量操作模式,支持 checkbox 选择路由 - 浮动操作栏显示选中数量,支持全选/取消全选 - 批量操作完成后自动退出选择模式并刷新列表 * fix: complete route batch actions on mobile --------- Co-authored-by: Xiang Li Co-authored-by: cita-777 --- src/server/routes/api/tokens.batch.test.ts | 25 + src/server/routes/api/tokens.ts | 1389 ++++++++++---------- src/web/api.ts | 2 + src/web/pages/TokenRoutes.tsx | 169 ++- src/web/pages/tokenRoutes.mobile.test.tsx | 58 + 5 files changed, 966 insertions(+), 677 deletions(-) diff --git a/src/server/routes/api/tokens.batch.test.ts b/src/server/routes/api/tokens.batch.test.ts index cf9f070..60a8647 100644 --- a/src/server/routes/api/tokens.batch.test.ts +++ b/src/server/routes/api/tokens.batch.test.ts @@ -154,4 +154,29 @@ describe('PUT /api/channels/batch', () => { expect(dbA?.manualOverride).toBe(true); expect(dbB?.manualOverride).toBe(true); }); + + it('reports the number of routes actually updated in route batch operations', async () => { + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-4o-mini', + enabled: true, + }).returning().get(); + + const res = await app.inject({ + method: 'POST', + url: '/api/routes/batch', + payload: { + ids: [route.id, route.id + 999], + action: 'disable', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ + success: true, + updatedCount: 1, + }); + + const updatedRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get(); + expect(updatedRoute?.enabled).toBe(false); + }); }); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index d28c940..5ab39a3 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -1,4 +1,4 @@ -import { FastifyInstance } from 'fastify'; +import { FastifyInstance } from 'fastify'; import { and, eq, inArray } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js'; @@ -20,6 +20,7 @@ import { saveRouteDecisionSnapshots, } from '../../services/routeDecisionSnapshotStore.js'; import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js'; + function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); if (!normalized) return false; @@ -219,7 +220,7 @@ async function clearDependentExplicitGroupSnapshotsBySourceRouteIds(sourceRouteI if (dependentRouteIds.length === 0) return; await clearRouteDecisionSnapshots(dependentRouteIds); } - + async function getDefaultTokenId(accountId: number): Promise { const token = await db.select().from(schema.accountTokens) .where(and( @@ -231,53 +232,53 @@ async function getDefaultTokenId(accountId: number): Promise { .get(); return isUsableAccountToken(token ?? null) ? token!.id : null; } - -function canonicalModelAlias(modelName: string): string { - const normalized = modelName.trim().toLowerCase(); - if (!normalized) return ''; - const slashIndex = normalized.lastIndexOf('/'); - if (slashIndex >= 0 && slashIndex < normalized.length - 1) { - return normalized.slice(slashIndex + 1); - } - return normalized; -} - -function isModelAliasEquivalent(left: string, right: string): boolean { - const a = canonicalModelAlias(left); - const b = canonicalModelAlias(right); - return !!a && !!b && a === b; -} - -async function tokenSupportsModel(tokenId: number, modelName: string): Promise { - const rows = await db.select().from(schema.tokenModelAvailability) - .where( - and( - eq(schema.tokenModelAvailability.tokenId, tokenId), - eq(schema.tokenModelAvailability.available, true), - ), - ) - .all(); - return rows.some((row) => { - const availableModelName = row.modelName?.trim(); - if (!availableModelName) return false; - return availableModelName === modelName || isModelAliasEquivalent(availableModelName, modelName); - }); -} - + +function canonicalModelAlias(modelName: string): string { + const normalized = modelName.trim().toLowerCase(); + if (!normalized) return ''; + const slashIndex = normalized.lastIndexOf('/'); + if (slashIndex >= 0 && slashIndex < normalized.length - 1) { + return normalized.slice(slashIndex + 1); + } + return normalized; +} + +function isModelAliasEquivalent(left: string, right: string): boolean { + const a = canonicalModelAlias(left); + const b = canonicalModelAlias(right); + return !!a && !!b && a === b; +} + +async function tokenSupportsModel(tokenId: number, modelName: string): Promise { + const rows = await db.select().from(schema.tokenModelAvailability) + .where( + and( + eq(schema.tokenModelAvailability.tokenId, tokenId), + eq(schema.tokenModelAvailability.available, true), + ), + ) + .all(); + return rows.some((row) => { + const availableModelName = row.modelName?.trim(); + if (!availableModelName) return false; + return availableModelName === modelName || isModelAliasEquivalent(availableModelName, modelName); + }); +} + async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise { const row = await db.select().from(schema.accountTokens) .where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId))) .get(); return isUsableAccountToken(row ?? null); } - -async function getPatternTokenCandidates(modelPattern: string): Promise> { - 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)) - .where( - and( + +async function getPatternTokenCandidates(modelPattern: string): Promise> { + 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)) + .where( + and( eq(schema.tokenModelAvailability.available, true), eq(schema.accountTokens.enabled, true), eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), @@ -292,301 +293,301 @@ async function getPatternTokenCandidates(modelPattern: string): Promise> { - const matchedRoutes = (await db.select().from(schema.tokenRoutes) - .where(eq(schema.tokenRoutes.enabled, true)) - .all()) - .filter((route) => isExactModelPattern(route.modelPattern) && matchesModelPattern(route.modelPattern, modelPattern)); - - if (matchedRoutes.length === 0) return []; - const routeMap = new Map(); - for (const route of matchedRoutes) routeMap.set(route.id, route); - - const channels = await db.select().from(schema.routeChannels) - .where(inArray(schema.routeChannels.routeId, matchedRoutes.map((route) => route.id))) - .all(); - - return channels.map((channel) => ({ - tokenId: channel.tokenId ?? null, - accountId: channel.accountId, - sourceModel: (channel.sourceModel || routeMap.get(channel.routeId)?.modelPattern || '').trim(), - priority: channel.priority ?? 0, - weight: channel.weight ?? 10, - enabled: !!channel.enabled, - manualOverride: !!channel.manualOverride, - })).filter((candidate) => candidate.sourceModel.length > 0); -} - -async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise { - const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern); - const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({ - tokenId: candidate.tokenId, - accountId: candidate.accountId, - sourceModel: candidate.sourceModel, - priority: 0, - weight: 10, - enabled: true, - manualOverride: false, - })); - const candidates = [...routeCandidates, ...availabilityCandidates]; - if (candidates.length === 0) return 0; - - 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; - for (const candidate of candidates) { - 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; - await db.insert(schema.routeChannels).values({ - routeId, - accountId: candidate.accountId, - tokenId: candidate.tokenId, - sourceModel: candidate.sourceModel, - priority: candidate.priority, - weight: candidate.weight, - enabled: candidate.enabled, - manualOverride: candidate.manualOverride, - }).run(); - existingPairs.add(pairKey); - created += 1; - } - - return created; -} - -async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{ - removedChannels: number; - createdChannels: number; -}> { - const removableChannels = await db.select().from(schema.routeChannels) - .where( - and( - eq(schema.routeChannels.routeId, routeId), - eq(schema.routeChannels.manualOverride, false), - ), - ) - .all(); - - for (const channel of removableChannels) { - await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); - } - - const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern); - return { - removedChannels: removableChannels.length, - createdChannels, - }; -} - -type BatchChannelPriorityUpdate = { - id: number; - priority: number; -}; - -type BatchRouteDecisionModels = { - models: string[]; - refreshPricingCatalog?: boolean; - persistSnapshots?: boolean; -}; - -type BatchRouteDecisionRouteModels = { - items: Array<{ - routeId: number; - model: string; - }>; - refreshPricingCatalog?: boolean; - persistSnapshots?: boolean; -}; - -type BatchRouteWideDecisionRouteIds = { - routeIds: number[]; - refreshPricingCatalog?: boolean; - persistSnapshots?: boolean; -}; - -function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } { - if (!input || typeof input !== 'object') { - return { ok: false, message: '请求体必须是对象' }; - } - - const updates = (input as { updates?: unknown }).updates; - if (!Array.isArray(updates) || updates.length === 0) { - return { ok: false, message: 'updates 必须是非空数组' }; - } - - const normalized: BatchChannelPriorityUpdate[] = []; - for (let index = 0; index < updates.length; index += 1) { - const item = updates[index]; - if (!item || typeof item !== 'object') { - return { ok: false, message: `updates[${index}] 必须是对象` }; - } - - const { id, priority } = item as { id?: unknown; priority?: unknown }; - if (typeof id !== 'number' || !Number.isFinite(id)) { - return { ok: false, message: `updates[${index}].id 必须是有限数字` }; - } - if (typeof priority !== 'number' || !Number.isFinite(priority)) { - return { ok: false, message: `updates[${index}].priority 必须是有限数字` }; - } - - const normalizedId = Math.trunc(id); - if (normalizedId <= 0) { - return { ok: false, message: `updates[${index}].id 必须大于 0` }; - } - - normalized.push({ - id: normalizedId, - priority: Math.max(0, Math.trunc(priority)), - }); - } - - return { ok: true, updates: normalized }; -} - -function parseBatchRouteDecisionModels( - input: unknown, -): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { - if (!input || typeof input !== 'object') { - return { ok: false, message: '请求体必须是对象' }; - } - - const models = (input as BatchRouteDecisionModels).models; - if (!Array.isArray(models) || models.length === 0) { - return { ok: false, message: 'models 必须是非空数组' }; - } - - const dedupe = new Set(); - const normalized: string[] = []; - for (const raw of models) { - if (typeof raw !== 'string') continue; - const trimmed = raw.trim(); - if (!trimmed || dedupe.has(trimmed)) continue; - dedupe.add(trimmed); - normalized.push(trimmed); - if (normalized.length >= 500) break; - } - - if (normalized.length === 0) { - return { ok: false, message: 'models 中没有有效模型名称' }; - } - - return { - ok: true, - models: normalized, - refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, - persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, - }; -} - -function parseBatchRouteDecisionRouteModels( - input: unknown, -): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { - if (!input || typeof input !== 'object') { - return { ok: false, message: '请求体必须是对象' }; - } - - const items = (input as BatchRouteDecisionRouteModels).items; - if (!Array.isArray(items) || items.length === 0) { - return { ok: false, message: 'items 必须是非空数组' }; - } - - const dedupe = new Set(); - const normalized: Array<{ routeId: number; model: string }> = []; - for (const item of items) { - if (!item || typeof item !== 'object') continue; - const routeIdRaw = (item as { routeId?: unknown }).routeId; - const modelRaw = (item as { model?: unknown }).model; - if (typeof routeIdRaw !== 'number' || !Number.isFinite(routeIdRaw)) continue; - if (typeof modelRaw !== 'string') continue; - - const routeId = Math.trunc(routeIdRaw); - const model = modelRaw.trim(); - if (routeId <= 0 || !model) continue; - - const key = `${routeId}::${model}`; - if (dedupe.has(key)) continue; - dedupe.add(key); - normalized.push({ routeId, model }); - if (normalized.length >= 500) break; - } - - if (normalized.length === 0) { - return { ok: false, message: 'items 中没有有效 routeId/model' }; - } - - return { - ok: true, - items: normalized, - refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, - persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, - }; -} - -function parseBatchRouteWideDecisionRouteIds( - input: unknown, -): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { - if (!input || typeof input !== 'object') { - return { ok: false, message: '请求体必须是对象' }; - } - - const routeIds = (input as BatchRouteWideDecisionRouteIds).routeIds; - if (!Array.isArray(routeIds) || routeIds.length === 0) { - return { ok: false, message: 'routeIds 必须是非空数组' }; - } - - const dedupe = new Set(); - const normalized: number[] = []; - for (const raw of routeIds) { - if (typeof raw !== 'number' || !Number.isFinite(raw)) continue; - const routeId = Math.trunc(raw); - if (routeId <= 0 || dedupe.has(routeId)) continue; - dedupe.add(routeId); - normalized.push(routeId); - if (normalized.length >= 500) break; - } - - if (normalized.length === 0) { - return { ok: false, message: 'routeIds 中没有有效 routeId' }; - } - - return { - ok: true, - routeIds: normalized, - refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, - persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, - }; -} - + if (!matchesModelPattern(modelName, modelPattern)) continue; + result.push({ + tokenId: row.account_tokens.id, + accountId: row.accounts.id, + sourceModel: modelName, + }); + } + + return result; +} + +async function getMatchedExactRouteChannelCandidates(modelPattern: string): Promise> { + const matchedRoutes = (await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.enabled, true)) + .all()) + .filter((route) => isExactModelPattern(route.modelPattern) && matchesModelPattern(route.modelPattern, modelPattern)); + + if (matchedRoutes.length === 0) return []; + const routeMap = new Map(); + for (const route of matchedRoutes) routeMap.set(route.id, route); + + const channels = await db.select().from(schema.routeChannels) + .where(inArray(schema.routeChannels.routeId, matchedRoutes.map((route) => route.id))) + .all(); + + return channels.map((channel) => ({ + tokenId: channel.tokenId ?? null, + accountId: channel.accountId, + sourceModel: (channel.sourceModel || routeMap.get(channel.routeId)?.modelPattern || '').trim(), + priority: channel.priority ?? 0, + weight: channel.weight ?? 10, + enabled: !!channel.enabled, + manualOverride: !!channel.manualOverride, + })).filter((candidate) => candidate.sourceModel.length > 0); +} + +async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise { + const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern); + const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({ + tokenId: candidate.tokenId, + accountId: candidate.accountId, + sourceModel: candidate.sourceModel, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + })); + const candidates = [...routeCandidates, ...availabilityCandidates]; + if (candidates.length === 0) return 0; + + 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; + for (const candidate of candidates) { + 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; + await db.insert(schema.routeChannels).values({ + routeId, + accountId: candidate.accountId, + tokenId: candidate.tokenId, + sourceModel: candidate.sourceModel, + priority: candidate.priority, + weight: candidate.weight, + enabled: candidate.enabled, + manualOverride: candidate.manualOverride, + }).run(); + existingPairs.add(pairKey); + created += 1; + } + + return created; +} + +async function rebuildAutomaticRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<{ + removedChannels: number; + createdChannels: number; +}> { + const removableChannels = await db.select().from(schema.routeChannels) + .where( + and( + eq(schema.routeChannels.routeId, routeId), + eq(schema.routeChannels.manualOverride, false), + ), + ) + .all(); + + for (const channel of removableChannels) { + await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run(); + } + + const createdChannels = await populateRouteChannelsByModelPattern(routeId, modelPattern); + return { + removedChannels: removableChannels.length, + createdChannels, + }; +} + +type BatchChannelPriorityUpdate = { + id: number; + priority: number; +}; + +type BatchRouteDecisionModels = { + models: string[]; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; + +type BatchRouteDecisionRouteModels = { + items: Array<{ + routeId: number; + model: string; + }>; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; + +type BatchRouteWideDecisionRouteIds = { + routeIds: number[]; + refreshPricingCatalog?: boolean; + persistSnapshots?: boolean; +}; + +function parseBatchChannelUpdates(input: unknown): { ok: true; updates: BatchChannelPriorityUpdate[] } | { ok: false; message: string } { + if (!input || typeof input !== 'object') { + return { ok: false, message: '请求体必须是对象' }; + } + + const updates = (input as { updates?: unknown }).updates; + if (!Array.isArray(updates) || updates.length === 0) { + return { ok: false, message: 'updates 必须是非空数组' }; + } + + const normalized: BatchChannelPriorityUpdate[] = []; + for (let index = 0; index < updates.length; index += 1) { + const item = updates[index]; + if (!item || typeof item !== 'object') { + return { ok: false, message: `updates[${index}] 必须是对象` }; + } + + const { id, priority } = item as { id?: unknown; priority?: unknown }; + if (typeof id !== 'number' || !Number.isFinite(id)) { + return { ok: false, message: `updates[${index}].id 必须是有限数字` }; + } + if (typeof priority !== 'number' || !Number.isFinite(priority)) { + return { ok: false, message: `updates[${index}].priority 必须是有限数字` }; + } + + const normalizedId = Math.trunc(id); + if (normalizedId <= 0) { + return { ok: false, message: `updates[${index}].id 必须大于 0` }; + } + + normalized.push({ + id: normalizedId, + priority: Math.max(0, Math.trunc(priority)), + }); + } + + return { ok: true, updates: normalized }; +} + +function parseBatchRouteDecisionModels( + input: unknown, +): { ok: true; models: string[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { + if (!input || typeof input !== 'object') { + return { ok: false, message: '请求体必须是对象' }; + } + + const models = (input as BatchRouteDecisionModels).models; + if (!Array.isArray(models) || models.length === 0) { + return { ok: false, message: 'models 必须是非空数组' }; + } + + const dedupe = new Set(); + const normalized: string[] = []; + for (const raw of models) { + if (typeof raw !== 'string') continue; + const trimmed = raw.trim(); + if (!trimmed || dedupe.has(trimmed)) continue; + dedupe.add(trimmed); + normalized.push(trimmed); + if (normalized.length >= 500) break; + } + + if (normalized.length === 0) { + return { ok: false, message: 'models 中没有有效模型名称' }; + } + + return { + ok: true, + models: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + +function parseBatchRouteDecisionRouteModels( + input: unknown, +): { ok: true; items: Array<{ routeId: number; model: string }>; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { + if (!input || typeof input !== 'object') { + return { ok: false, message: '请求体必须是对象' }; + } + + const items = (input as BatchRouteDecisionRouteModels).items; + if (!Array.isArray(items) || items.length === 0) { + return { ok: false, message: 'items 必须是非空数组' }; + } + + const dedupe = new Set(); + const normalized: Array<{ routeId: number; model: string }> = []; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const routeIdRaw = (item as { routeId?: unknown }).routeId; + const modelRaw = (item as { model?: unknown }).model; + if (typeof routeIdRaw !== 'number' || !Number.isFinite(routeIdRaw)) continue; + if (typeof modelRaw !== 'string') continue; + + const routeId = Math.trunc(routeIdRaw); + const model = modelRaw.trim(); + if (routeId <= 0 || !model) continue; + + const key = `${routeId}::${model}`; + if (dedupe.has(key)) continue; + dedupe.add(key); + normalized.push({ routeId, model }); + if (normalized.length >= 500) break; + } + + if (normalized.length === 0) { + return { ok: false, message: 'items 中没有有效 routeId/model' }; + } + + return { + ok: true, + items: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + +function parseBatchRouteWideDecisionRouteIds( + input: unknown, +): { ok: true; routeIds: number[]; refreshPricingCatalog: boolean; persistSnapshots: boolean } | { ok: false; message: string } { + if (!input || typeof input !== 'object') { + return { ok: false, message: '请求体必须是对象' }; + } + + const routeIds = (input as BatchRouteWideDecisionRouteIds).routeIds; + if (!Array.isArray(routeIds) || routeIds.length === 0) { + return { ok: false, message: 'routeIds 必须是非空数组' }; + } + + const dedupe = new Set(); + const normalized: number[] = []; + for (const raw of routeIds) { + if (typeof raw !== 'number' || !Number.isFinite(raw)) continue; + const routeId = Math.trunc(raw); + if (routeId <= 0 || dedupe.has(routeId)) continue; + dedupe.add(routeId); + normalized.push(routeId); + if (normalized.length >= 500) break; + } + + if (normalized.length === 0) { + return { ok: false, message: 'routeIds 中没有有效 routeId' }; + } + + return { + ok: true, + routeIds: normalized, + refreshPricingCatalog: (input as { refreshPricingCatalog?: unknown }).refreshPricingCatalog === true, + persistSnapshots: (input as { persistSnapshots?: unknown }).persistSnapshots === true, + }; +} + type RouteChannelSummary = { channelCount: number; enabledChannelCount: number; @@ -652,12 +653,12 @@ async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise { @@ -739,14 +740,14 @@ export async function tokensRoutes(app: FastifyInstance) { routingStrategy: route.routingStrategy ?? 'weighted', 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, - }; - }); - }); - + 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); @@ -757,8 +758,8 @@ export async function tokensRoutes(app: FastifyInstance) { const channelsByRoute = await fetchChannelsForRouteRows([route]); return channelsByRoute.get(routeId) || []; }); - - // Batch add channels to a route + + // 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; @@ -770,75 +771,75 @@ export async function tokensRoutes(app: FastifyInstance) { if (isExplicitGroupRoute(route)) { return reply.code(400).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: effectiveTokenId, - 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 (!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: effectiveTokenId, + 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); await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]); invalidateTokenRouterCache(); } - - return { success: true, created, skipped, errors }; - }); - + + return { success: true, created, skipped, errors }; + }); + // List all routes app.get('/api/routes', async () => { const routes = await listRoutesWithSources(); @@ -848,111 +849,111 @@ export async function tokensRoutes(app: FastifyInstance) { return routes.map((route) => ({ ...route, - decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), - decisionRefreshedAt: route.decisionRefreshedAt ?? null, - channels: channelsByRoute.get(route.id) || [], - })); - }); - - app.get<{ Querystring: { model?: string } }>('/api/routes/decision', async (request, reply) => { - const model = (request.query.model || '').trim(); - if (!model) { - return reply.code(400).send({ success: false, message: 'model 不能为空' }); - } - - const decision = await tokenRouter.explainSelection(model); - return { success: true, decision }; - }); - - app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => { - const parsed = parseBatchRouteDecisionModels(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } - - const decisions: Record>> = {}; - const routes = parsed.persistSnapshots - ? await db.select({ - id: schema.tokenRoutes.id, - modelPattern: schema.tokenRoutes.modelPattern, - }).from(schema.tokenRoutes).all() - : []; - const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; - for (const model of parsed.models) { - if (parsed.refreshPricingCatalog) { - await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys }); - } - decisions[model] = await tokenRouter.explainSelection(model); - } - - if (parsed.persistSnapshots) { - const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = []; - for (const model of parsed.models) { - const decision = decisions[model]; - for (const route of routes) { - if (!isExactModelPattern(route.modelPattern)) continue; - if (!matchesModelPattern(model, route.modelPattern)) continue; - snapshotWrites.push({ routeId: route.id, snapshot: decision }); - } - } - await saveRouteDecisionSnapshots(snapshotWrites); - } - - return { success: true, decisions }; - }); - - app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => { - const parsed = parseBatchRouteDecisionRouteModels(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } - - const decisions: Record>>> = {}; - const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; - for (const item of parsed.items) { - const routeKey = String(item.routeId); - if (!decisions[routeKey]) decisions[routeKey] = {}; - if (parsed.refreshPricingCatalog) { - await tokenRouter.refreshPricingReferenceCostsForRoute(item.routeId, item.model, { refreshedKeys }); - } - decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model); - } - - if (parsed.persistSnapshots) { - await saveRouteDecisionSnapshots(parsed.items.map((item) => ({ - routeId: item.routeId, - snapshot: decisions[String(item.routeId)]?.[item.model] ?? null, - }))); - } - - return { success: true, decisions }; - }); - - app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => { - const parsed = parseBatchRouteWideDecisionRouteIds(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } - - const decisions: Record>> = {}; - const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; - for (const routeId of parsed.routeIds) { - if (parsed.refreshPricingCatalog) { - await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys }); - } - decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId); - } - - if (parsed.persistSnapshots) { - await saveRouteDecisionSnapshots(parsed.routeIds.map((routeId) => ({ - routeId, - snapshot: decisions[String(routeId)] ?? null, - }))); - } - - return { success: true, decisions }; - }); - + decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), + decisionRefreshedAt: route.decisionRefreshedAt ?? null, + channels: channelsByRoute.get(route.id) || [], + })); + }); + + app.get<{ Querystring: { model?: string } }>('/api/routes/decision', async (request, reply) => { + const model = (request.query.model || '').trim(); + if (!model) { + return reply.code(400).send({ success: false, message: 'model 不能为空' }); + } + + const decision = await tokenRouter.explainSelection(model); + return { success: true, decision }; + }); + + app.post<{ Body: BatchRouteDecisionModels }>('/api/routes/decision/batch', async (request, reply) => { + const parsed = parseBatchRouteDecisionModels(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } + + const decisions: Record>> = {}; + const routes = parsed.persistSnapshots + ? await db.select({ + id: schema.tokenRoutes.id, + modelPattern: schema.tokenRoutes.modelPattern, + }).from(schema.tokenRoutes).all() + : []; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; + for (const model of parsed.models) { + if (parsed.refreshPricingCatalog) { + await tokenRouter.refreshPricingReferenceCosts(model, { refreshedKeys }); + } + decisions[model] = await tokenRouter.explainSelection(model); + } + + if (parsed.persistSnapshots) { + const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = []; + for (const model of parsed.models) { + const decision = decisions[model]; + for (const route of routes) { + if (!isExactModelPattern(route.modelPattern)) continue; + if (!matchesModelPattern(model, route.modelPattern)) continue; + snapshotWrites.push({ routeId: route.id, snapshot: decision }); + } + } + await saveRouteDecisionSnapshots(snapshotWrites); + } + + return { success: true, decisions }; + }); + + app.post<{ Body: BatchRouteDecisionRouteModels }>('/api/routes/decision/by-route/batch', async (request, reply) => { + const parsed = parseBatchRouteDecisionRouteModels(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } + + const decisions: Record>>> = {}; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; + for (const item of parsed.items) { + const routeKey = String(item.routeId); + if (!decisions[routeKey]) decisions[routeKey] = {}; + if (parsed.refreshPricingCatalog) { + await tokenRouter.refreshPricingReferenceCostsForRoute(item.routeId, item.model, { refreshedKeys }); + } + decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model); + } + + if (parsed.persistSnapshots) { + await saveRouteDecisionSnapshots(parsed.items.map((item) => ({ + routeId: item.routeId, + snapshot: decisions[String(item.routeId)]?.[item.model] ?? null, + }))); + } + + return { success: true, decisions }; + }); + + app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => { + const parsed = parseBatchRouteWideDecisionRouteIds(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } + + const decisions: Record>> = {}; + const refreshedKeys = parsed.refreshPricingCatalog ? new Set() : undefined; + for (const routeId of parsed.routeIds) { + if (parsed.refreshPricingCatalog) { + await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys }); + } + decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId); + } + + if (parsed.persistSnapshots) { + await saveRouteDecisionSnapshots(parsed.routeIds.map((routeId) => ({ + routeId, + snapshot: decisions[String(routeId)] ?? null, + }))); + } + + return { success: true, decisions }; + }); + // Create a route app.post<{ Body: { routeMode?: string; modelPattern?: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean; sourceRouteIds?: number[] } }>('/api/routes', async (request, reply) => { const body = request.body; @@ -1100,8 +1101,8 @@ export async function tokensRoutes(app: FastifyInstance) { invalidateTokenRouterCache(); return await getRouteWithSources(id); }); - - // Delete a route + + // Delete a route app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => { const id = parseInt(request.params.id, 10); await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]); @@ -1109,8 +1110,50 @@ export async function tokensRoutes(app: FastifyInstance) { invalidateTokenRouterCache(); return { success: true }; }); + - // Add a channel to a route + // Batch update routes (enable/disable) + app.post<{ Body: { ids: number[]; action: 'enable' | 'disable' } }>('/api/routes/batch', async (request, reply) => { + const body = request.body; + if (!body || typeof body !== 'object') { + return reply.code(400).send({ success: false, message: '请求体必须是对象' }); + } + const action = body.action; + if (action !== 'enable' && action !== 'disable') { + return reply.code(400).send({ success: false, message: 'action 必须是 enable 或 disable' }); + } + const rawIds = body.ids; + if (!Array.isArray(rawIds) || rawIds.length === 0) { + return reply.code(400).send({ success: false, message: 'ids 必须是非空数组' }); + } + const dedupe = new Set(); + const ids: number[] = []; + for (const raw of rawIds) { + if (typeof raw !== 'number' || !Number.isFinite(raw)) continue; + const id = Math.trunc(raw); + if (id <= 0 || dedupe.has(id)) continue; + dedupe.add(id); + ids.push(id); + if (ids.length >= 500) break; + } + if (ids.length === 0) { + return reply.code(400).send({ success: false, message: 'ids 中没有有效的路由 ID' }); + } + + const enabled = action === 'enable'; + const now = new Date().toISOString(); + const updateResult = await db.update(schema.tokenRoutes) + .set({ enabled, updatedAt: now }) + .where(inArray(schema.tokenRoutes.id, ids)) + .run(); + + await clearRouteDecisionSnapshots(ids); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds(ids); + invalidateTokenRouterCache(); + + return { success: true, updatedCount: Number(updateResult?.changes || 0) }; + }); + // Add a channel to a route app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => { const routeId = parseInt(request.params.id, 10); const body = request.body; @@ -1122,78 +1165,78 @@ export async function tokensRoutes(app: FastifyInstance) { if (isExplicitGroupRoute(route)) { return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' }); } - - const sourceModel = typeof body.sourceModel === 'string' - ? body.sourceModel.trim() - : (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : ''); - const effectiveTokenId = body.tokenId ?? await getDefaultTokenId(body.accountId); - - if (body.tokenId && !await checkTokenBelongsToAccount(body.tokenId, body.accountId)) { - return reply.code(400).send({ success: false, message: '令牌不存在或不属于当前账号' }); - } - - if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !await tokenSupportsModel(effectiveTokenId, route.modelPattern)) { - return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' }); - } - - const duplicate = (await db.select().from(schema.routeChannels) - .where(eq(schema.routeChannels.routeId, routeId)) - .all()) - .some((channel) => - channel.accountId === body.accountId - && (channel.tokenId ?? null) === (body.tokenId ?? null) - && (channel.sourceModel || '').trim().toLowerCase() === sourceModel.toLowerCase(), - ); - if (duplicate) { - return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' }); - } - - 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: '创建通道失败' }); + + const sourceModel = typeof body.sourceModel === 'string' + ? body.sourceModel.trim() + : (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : ''); + const effectiveTokenId = body.tokenId ?? await getDefaultTokenId(body.accountId); + + if (body.tokenId && !await checkTokenBelongsToAccount(body.tokenId, body.accountId)) { + return reply.code(400).send({ success: false, message: '令牌不存在或不属于当前账号' }); + } + + if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !await tokenSupportsModel(effectiveTokenId, route.modelPattern)) { + return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' }); + } + + const duplicate = (await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, routeId)) + .all()) + .some((channel) => + channel.accountId === body.accountId + && (channel.tokenId ?? null) === (body.tokenId ?? null) + && (channel.sourceModel || '').trim().toLowerCase() === sourceModel.toLowerCase(), + ); + if (duplicate) { + return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' }); + } + + 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: '创建通道失败' }); } await clearRouteDecisionSnapshot(routeId); await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]); invalidateTokenRouterCache(); return created; }); - - // Batch update channel priorities - app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => { - const parsed = parseBatchChannelUpdates(request.body); - if (!parsed.ok) { - return reply.code(400).send({ success: false, message: parsed.message }); - } - - const channelIds = Array.from(new Set(parsed.updates.map((update) => update.id))); - const existingChannels = await db.select().from(schema.routeChannels) - .where(inArray(schema.routeChannels.id, channelIds)) - .all(); - if (existingChannels.length !== channelIds.length) { - const existingIds = new Set(existingChannels.map((channel) => channel.id)); - const missingId = channelIds.find((id) => !existingIds.has(id)); - return reply.code(404).send({ success: false, message: `通道不存在: ${missingId}` }); - } - - for (const update of parsed.updates) { - await db.update(schema.routeChannels).set({ - priority: update.priority, - manualOverride: true, - }).where(eq(schema.routeChannels.id, update.id)).run(); - } - + + // Batch update channel priorities + app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => { + const parsed = parseBatchChannelUpdates(request.body); + if (!parsed.ok) { + return reply.code(400).send({ success: false, message: parsed.message }); + } + + const channelIds = Array.from(new Set(parsed.updates.map((update) => update.id))); + const existingChannels = await db.select().from(schema.routeChannels) + .where(inArray(schema.routeChannels.id, channelIds)) + .all(); + if (existingChannels.length !== channelIds.length) { + const existingIds = new Set(existingChannels.map((channel) => channel.id)); + const missingId = channelIds.find((id) => !existingIds.has(id)); + return reply.code(404).send({ success: false, message: `通道不存在: ${missingId}` }); + } + + for (const update of parsed.updates) { + await db.update(schema.routeChannels).set({ + priority: update.priority, + manualOverride: true, + }).where(eq(schema.routeChannels.id, update.id)).run(); + } + const updatedChannels = await db.select().from(schema.routeChannels) .where(inArray(schema.routeChannels.id, channelIds)) .all(); @@ -1202,58 +1245,58 @@ export async function tokensRoutes(app: FastifyInstance) { invalidateTokenRouterCache(); return { success: true, channels: updatedChannels }; }); - - // Update a channel - app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => { - const channelId = parseInt(request.params.channelId, 10); - const body = request.body as Record; - - 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 = 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) || !await checkTokenBelongsToAccount(tokenId, channel.accountId)) { - return reply.code(400).send({ success: false, message: '令牌不存在或不属于通道账号' }); - } - } - - 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 && !await tokenSupportsModel(nextTokenId, route.modelPattern)) { - return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' }); - } - - const updates: Record = { manualOverride: true }; - if (body.sourceModel !== undefined) { - if (body.sourceModel === null) updates.sourceModel = null; - else updates.sourceModel = String(body.sourceModel).trim() || null; - } - - for (const key of ['priority', 'weight', 'enabled', 'tokenId']) { - if (body[key] !== undefined) updates[key] = body[key]; - } - + + // Update a channel + app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => { + const channelId = parseInt(request.params.channelId, 10); + const body = request.body as Record; + + 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 = 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) || !await checkTokenBelongsToAccount(tokenId, channel.accountId)) { + return reply.code(400).send({ success: false, message: '令牌不存在或不属于通道账号' }); + } + } + + 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 && !await tokenSupportsModel(nextTokenId, route.modelPattern)) { + return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' }); + } + + const updates: Record = { manualOverride: true }; + if (body.sourceModel !== undefined) { + if (body.sourceModel === null) updates.sourceModel = null; + else updates.sourceModel = String(body.sourceModel).trim() || null; + } + + for (const key of ['priority', 'weight', 'enabled', 'tokenId']) { + if (body[key] !== undefined) updates[key] = body[key]; + } + await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); await clearRouteDecisionSnapshot(channel.routeId); await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]); invalidateTokenRouterCache(); 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); - const channel = 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); + const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run(); if (channel) { await clearRouteDecisionSnapshot(channel.routeId); @@ -1262,44 +1305,46 @@ export async function tokensRoutes(app: FastifyInstance) { invalidateTokenRouterCache(); return { success: true }; }); - // Rebuild routes/channels from model availability. - app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { + + // Rebuild routes/channels from model availability. + app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { const body = (request.body || {}) as { refreshModels?: boolean }; if (body.refreshModels === false) { const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly(); return { success: true, rebuild }; } - - if ((request.body as { wait?: boolean } | undefined)?.wait) { + + if ((request.body as { wait?: boolean } | undefined)?.wait) { const result = await routeRefreshWorkflow.refreshModelsAndRebuildRoutes(); - return { success: true, ...result }; - } - - const { task, reused } = startBackgroundTask( - { - type: 'route', - title: '刷新模型并重建路由', - dedupeKey: 'refresh-models-and-rebuild-routes', - notifyOnFailure: true, - successMessage: (currentTask) => { - const rebuild = (currentTask.result as any)?.rebuild; - if (!rebuild) return '刷新模型并重建路由已完成'; - return `刷新模型并重建路由完成:新增路由 ${rebuild.createdRoutes},移除旧路由 ${rebuild.removedRoutes ?? 0},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`; - }, - failureMessage: (currentTask) => `刷新模型并重建路由失败:${currentTask.error || 'unknown error'}`, - }, + return { success: true, ...result }; + } + + const { task, reused } = startBackgroundTask( + { + type: 'route', + title: '刷新模型并重建路由', + dedupeKey: 'refresh-models-and-rebuild-routes', + notifyOnFailure: true, + successMessage: (currentTask) => { + const rebuild = (currentTask.result as any)?.rebuild; + if (!rebuild) return '刷新模型并重建路由已完成'; + return `刷新模型并重建路由完成:新增路由 ${rebuild.createdRoutes},移除旧路由 ${rebuild.removedRoutes ?? 0},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}`; + }, + failureMessage: (currentTask) => `刷新模型并重建路由失败:${currentTask.error || 'unknown error'}`, + }, async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(), ); - - return reply.code(202).send({ - success: true, - queued: true, - reused, - jobId: task.id, - status: task.status, - message: reused - ? '路由重建任务执行中,请稍后查看程序日志' - : '已开始路由重建,请稍后查看程序日志', - }); - }); -} + + return reply.code(202).send({ + success: true, + queued: true, + reused, + jobId: task.id, + status: task.status, + message: reused + ? '路由重建任务执行中,请稍后查看程序日志' + : '已开始路由重建,请稍后查看程序日志', + }); + }); +} + diff --git a/src/web/api.ts b/src/web/api.ts index dbbd034..3403cad 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -504,6 +504,8 @@ export const api = { 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' }), + batchUpdateRoutes: (data: { ids: number[]; action: 'enable' | 'disable' }) => + request('/api/routes/batch', { method: 'POST', body: JSON.stringify(data) }), addChannel: (routeId: number, data: any) => request(`/api/routes/${routeId}/channels`, { method: 'POST', body: JSON.stringify(data) }), updateChannel: (id: number, data: any) => request(`/api/channels/${id}`, { method: 'PUT', body: JSON.stringify(data) }), batchUpdateChannels: (updates: Array<{ id: number; priority: number }>) => diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 5b1fd4e..5929af1 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -128,6 +128,9 @@ export default function TokenRoutes() { const [editingRouteId, setEditingRouteId] = useState(null); const [saving, setSaving] = useState(false); const [rebuilding, setRebuilding] = useState(false); + const [batchUpdatingRoutes, setBatchUpdatingRoutes] = useState(false); + const [batchSelectMode, setBatchSelectMode] = useState(false); + const [selectedRouteIds, setSelectedRouteIds] = useState>(new Set()); const [channelTokenDraft, setChannelTokenDraft] = useState>({}); const [updatingChannel, setUpdatingChannel] = useState>({}); @@ -751,6 +754,62 @@ export default function TokenRoutes() { }); }, [baseFilteredRoutes, enabledFilter]); + const selectableRouteIds = useMemo(() => { + return new Set( + filteredRoutes + .filter((route) => route.kind !== 'zero_channel' && route.readOnly !== true && route.isVirtual !== true) + .map((route) => route.id), + ); + }, [filteredRoutes]); + + const toggleBatchSelectMode = () => { + setBatchSelectMode((prev) => { + if (prev) setSelectedRouteIds(new Set()); + return !prev; + }); + }; + + const toggleRouteSelection = (routeId: number) => { + setSelectedRouteIds((prev) => { + const next = new Set(prev); + if (next.has(routeId)) next.delete(routeId); + else next.add(routeId); + return next; + }); + }; + + const selectAllRoutes = () => { + setSelectedRouteIds(new Set(selectableRouteIds)); + }; + + const deselectAllRoutes = () => { + setSelectedRouteIds(new Set()); + }; + + const handleBatchUpdateRoutes = async (action: 'enable' | 'disable') => { + const ids = Array.from(selectedRouteIds).filter((id) => selectableRouteIds.has(id)); + if (ids.length === 0) { + toast.info('请先选择要操作的路由'); + return; + } + const actionLabel = action === 'disable' ? '禁用' : '启用'; + const confirmed = window.confirm(`确认批量${actionLabel} ${ids.length} 条路由?`); + if (!confirmed) return; + + setBatchUpdatingRoutes(true); + try { + await api.batchUpdateRoutes({ ids, action }); + toast.success(`已批量${actionLabel} ${ids.length} 条路由`); + setSelectedRouteIds(new Set()); + setBatchSelectMode(false); + await load(); + } catch (e: any) { + toast.error(e.message || `批量${actionLabel}路由失败`); + } finally { + setBatchUpdatingRoutes(false); + } + }; + useEffect(() => { setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK)); }, [filteredRoutes.length]); @@ -1298,6 +1357,14 @@ export default function TokenRoutes() { {tr('新建群组')} + + + +
+ + +
+ + )} +
{visibleRoutes.map((route) => { const isExpanded = expandedRouteIds.includes(route.id); @@ -1414,16 +1518,37 @@ export default function TokenRoutes() { const exactRoute = isRouteExactModel(route); const explicitGroupRoute = isExplicitGroupRoute(route); const channelManagementDisabled = explicitGroupRoute; + const routeTitle = resolveRouteTitle(route); + + const isSelectable = selectableRouteIds.has(route.id); + const isSelected = selectedRouteIds.has(route.id); if (isMobile) { return (
- {isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} - +
+ {batchSelectMode && isSelectable && ( + + )} + + {isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} + +
)} footerActions={( <> @@ -1513,7 +1638,7 @@ export default function TokenRoutes() { ); } - return ( + const routeCard = ( ); + + if (batchSelectMode && isSelectable) { + return ( +
+
toggleRouteSelection(route.id)} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'center', + width: 36, minHeight: '100%', cursor: 'pointer', + borderRadius: '8px 0 0 8px', + background: isSelected ? 'var(--color-primary, #4f46e5)' : 'var(--color-bg-card, #fff)', + border: '1px solid var(--color-border)', + borderRight: 'none', + transition: 'background 0.15s', + }} + > + toggleRouteSelection(route.id)} + onClick={(e) => e.stopPropagation()} + style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary, #4f46e5)' }} + /> +
+
+ {routeCard} +
+
+ ); + } + + return routeCard; })}
diff --git a/src/web/pages/tokenRoutes.mobile.test.tsx b/src/web/pages/tokenRoutes.mobile.test.tsx index 1273be5..cf6d98c 100644 --- a/src/web/pages/tokenRoutes.mobile.test.tsx +++ b/src/web/pages/tokenRoutes.mobile.test.tsx @@ -11,6 +11,7 @@ const { apiMock, getBrandMock } = vi.hoisted(() => ({ getModelTokenCandidates: vi.fn(), getRouteDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(), + batchUpdateRoutes: vi.fn(), updateRoute: vi.fn(), addRoute: vi.fn(), }, @@ -67,6 +68,7 @@ async function flushMicrotasks() { describe('TokenRoutes mobile actions', () => { const originalIntersectionObserver = globalThis.IntersectionObserver; + const originalWindow = (globalThis as { window?: { confirm?: (message?: string) => boolean } }).window; beforeEach(() => { vi.clearAllMocks(); @@ -115,13 +117,23 @@ describe('TokenRoutes mobile actions', () => { apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); + apiMock.batchUpdateRoutes.mockResolvedValue({ success: true, updatedCount: 1 }); apiMock.updateRoute.mockResolvedValue({}); apiMock.addRoute.mockResolvedValue({}); + (globalThis as { window?: { confirm?: (message?: string) => boolean } }).window = { + ...(originalWindow || {}), + confirm: vi.fn(() => true), + }; }); afterEach(() => { vi.clearAllMocks(); globalThis.IntersectionObserver = originalIntersectionObserver; + if (originalWindow) { + (globalThis as { window?: { confirm?: (message?: string) => boolean } }).window = originalWindow; + } else { + delete (globalThis as { window?: { confirm?: (message?: string) => boolean } }).window; + } }); it('shows mobile detail expansion and direct management actions', async () => { @@ -166,4 +178,50 @@ describe('TokenRoutes mobile actions', () => { root?.unmount(); } }); + + it('lets mobile users select a route and batch disable it', async () => { + let root!: WebTestRenderer; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const batchModeButton = findButtonByText(root!.root, '批量操作'); + await act(async () => { + batchModeButton.props.onClick(); + }); + await flushMicrotasks(); + + const routeCheckbox = root!.root.find((node) => ( + node.type === 'input' + && node.props.type === 'checkbox' + && node.props['data-testid'] === 'route-select-1' + )); + + await act(async () => { + routeCheckbox.props.onChange({ target: { checked: true } }); + }); + await flushMicrotasks(); + + const batchDisableButton = findButtonByText(root!.root, '批量禁用'); + await act(async () => { + batchDisableButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(globalThis.window.confirm).toHaveBeenCalledWith('确认批量禁用 1 条路由?'); + expect(apiMock.batchUpdateRoutes).toHaveBeenCalledWith({ ids: [1], action: 'disable' }); + expect(collectText(root!.root)).toContain('批量操作'); + } finally { + root?.unmount(); + } + }); });