forked from boos/BoosAPI
feat: enhance TokenRoutes with edit functionality and route visibility management
- Added editing capabilities for route management in TokenRoutes, allowing users to modify existing routes. - Introduced a reset function for the route form to clear inputs after adding or editing routes. - Implemented a visibility filter for routes, ensuring exact routes covered by group routes are hidden from the main list. - Updated tests to cover new functionalities, including editing routes and verifying visibility logic. - Created a new helper function to build a list of visible routes based on defined criteria. - Enhanced schema compatibility checks for site configurations, ensuring necessary columns are added to the database.
This commit is contained in:
@@ -5,6 +5,7 @@ import { drizzle as drizzleSqliteProxy } from 'drizzle-orm/sqlite-proxy';
|
|||||||
import { drizzle as drizzleMysqlProxy } from 'drizzle-orm/mysql-proxy';
|
import { drizzle as drizzleMysqlProxy } from 'drizzle-orm/mysql-proxy';
|
||||||
import { drizzle as drizzlePgProxy } from 'drizzle-orm/pg-proxy';
|
import { drizzle as drizzlePgProxy } from 'drizzle-orm/pg-proxy';
|
||||||
import * as schema from './schema.js';
|
import * as schema from './schema.js';
|
||||||
|
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { mkdirSync } from 'fs';
|
import { mkdirSync } from 'fs';
|
||||||
import { dirname, resolve } from 'path';
|
import { dirname, resolve } from 'path';
|
||||||
@@ -221,6 +222,73 @@ function ensureSiteGlobalWeightSchema() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSqliteSiteSchemaInspector(): SiteSchemaInspector {
|
||||||
|
return {
|
||||||
|
dialect: 'sqlite',
|
||||||
|
tableExists: async (table) => tableExists(table),
|
||||||
|
columnExists: async (table, column) => tableColumnExists(table, column),
|
||||||
|
execute: async (sqlText) => {
|
||||||
|
requireSqliteConnection().exec(sqlText);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSiteCompatibilityColumns(): Promise<void> {
|
||||||
|
if (runtimeDbDialect === 'sqlite') {
|
||||||
|
await ensureSiteSchemaCompatibility(createSqliteSiteSchemaInspector());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeDbDialect === 'mysql') {
|
||||||
|
if (!mysqlPool) return;
|
||||||
|
|
||||||
|
await ensureSiteSchemaCompatibility({
|
||||||
|
dialect: 'mysql',
|
||||||
|
tableExists: async (table) => {
|
||||||
|
const [rows] = await mysqlPool!.query(
|
||||||
|
'SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1',
|
||||||
|
[table],
|
||||||
|
);
|
||||||
|
return Array.isArray(rows) && rows.length > 0;
|
||||||
|
},
|
||||||
|
columnExists: async (table, column) => {
|
||||||
|
const [rows] = await mysqlPool!.query(
|
||||||
|
'SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? LIMIT 1',
|
||||||
|
[table, column],
|
||||||
|
);
|
||||||
|
return Array.isArray(rows) && rows.length > 0;
|
||||||
|
},
|
||||||
|
execute: async (sqlText) => {
|
||||||
|
await mysqlPool!.query(sqlText);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pgPool) return;
|
||||||
|
|
||||||
|
await ensureSiteSchemaCompatibility({
|
||||||
|
dialect: 'postgres',
|
||||||
|
tableExists: async (table) => {
|
||||||
|
const result = await pgPool!.query(
|
||||||
|
'SELECT 1 FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1 LIMIT 1',
|
||||||
|
[table],
|
||||||
|
);
|
||||||
|
return Number(result.rowCount || 0) > 0;
|
||||||
|
},
|
||||||
|
columnExists: async (table, column) => {
|
||||||
|
const result = await pgPool!.query(
|
||||||
|
'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1',
|
||||||
|
[table, column],
|
||||||
|
);
|
||||||
|
return Number(result.rowCount || 0) > 0;
|
||||||
|
},
|
||||||
|
execute: async (sqlText) => {
|
||||||
|
await pgPool!.query(sqlText);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function ensureRouteGroupingSchema() {
|
function ensureRouteGroupingSchema() {
|
||||||
const sqlite = requireSqliteConnection();
|
const sqlite = requireSqliteConnection();
|
||||||
if (!tableExists('token_routes') || !tableExists('route_channels')) {
|
if (!tableExists('token_routes') || !tableExists('route_channels')) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
|
||||||
|
|
||||||
|
function createInspector(
|
||||||
|
dialect: SiteSchemaInspector['dialect'],
|
||||||
|
options?: {
|
||||||
|
hasSitesTable?: boolean;
|
||||||
|
existingColumns?: string[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const executedSql: string[] = [];
|
||||||
|
const hasSitesTable = options?.hasSitesTable ?? true;
|
||||||
|
const existingColumns = new Set(options?.existingColumns ?? []);
|
||||||
|
|
||||||
|
const inspector: SiteSchemaInspector = {
|
||||||
|
dialect,
|
||||||
|
async tableExists(table) {
|
||||||
|
return table === 'sites' && hasSitesTable;
|
||||||
|
},
|
||||||
|
async columnExists(table, column) {
|
||||||
|
return table === 'sites' && existingColumns.has(column);
|
||||||
|
},
|
||||||
|
async execute(sqlText) {
|
||||||
|
executedSql.push(sqlText);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { inspector, executedSql };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ensureSiteSchemaCompatibility', () => {
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
dialect: 'postgres' as const,
|
||||||
|
expectedSql: [
|
||||||
|
'ALTER TABLE "sites" ADD COLUMN "proxy_url" TEXT',
|
||||||
|
'ALTER TABLE "sites" ADD COLUMN "use_system_proxy" BOOLEAN DEFAULT FALSE',
|
||||||
|
'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
|
||||||
|
'ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT',
|
||||||
|
'ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1',
|
||||||
|
'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dialect: 'mysql' as const,
|
||||||
|
expectedSql: [
|
||||||
|
'ALTER TABLE `sites` ADD COLUMN `proxy_url` TEXT NULL',
|
||||||
|
'ALTER TABLE `sites` ADD COLUMN `use_system_proxy` BOOLEAN DEFAULT FALSE',
|
||||||
|
'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL',
|
||||||
|
'ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT NULL',
|
||||||
|
'ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1',
|
||||||
|
'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])('adds missing site proxy columns for $dialect', async ({ dialect, expectedSql }) => {
|
||||||
|
const { inspector, executedSql } = createInspector(dialect);
|
||||||
|
|
||||||
|
await ensureSiteSchemaCompatibility(inspector);
|
||||||
|
|
||||||
|
expect(executedSql).toEqual(expectedSql);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips schema changes when sites table does not exist', async () => {
|
||||||
|
const { inspector, executedSql } = createInspector('postgres', { hasSitesTable: false });
|
||||||
|
|
||||||
|
await ensureSiteSchemaCompatibility(inspector);
|
||||||
|
|
||||||
|
expect(executedSql).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
export type SiteSchemaDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
|
|
||||||
|
export interface SiteSchemaInspector {
|
||||||
|
dialect: SiteSchemaDialect;
|
||||||
|
tableExists(table: string): Promise<boolean>;
|
||||||
|
columnExists(table: string, column: string): Promise<boolean>;
|
||||||
|
execute(sqlText: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteColumnCompatibilitySpec = {
|
||||||
|
column: string;
|
||||||
|
addSql: Record<SiteSchemaDialect, string>;
|
||||||
|
normalizeSql?: Record<SiteSchemaDialect, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [
|
||||||
|
{
|
||||||
|
column: 'proxy_url',
|
||||||
|
addSql: {
|
||||||
|
sqlite: 'ALTER TABLE sites ADD COLUMN proxy_url text;',
|
||||||
|
mysql: 'ALTER TABLE `sites` ADD COLUMN `proxy_url` TEXT NULL',
|
||||||
|
postgres: 'ALTER TABLE "sites" ADD COLUMN "proxy_url" TEXT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: 'use_system_proxy',
|
||||||
|
addSql: {
|
||||||
|
sqlite: 'ALTER TABLE sites ADD COLUMN use_system_proxy integer DEFAULT 0;',
|
||||||
|
mysql: 'ALTER TABLE `sites` ADD COLUMN `use_system_proxy` BOOLEAN DEFAULT FALSE',
|
||||||
|
postgres: 'ALTER TABLE "sites" ADD COLUMN "use_system_proxy" BOOLEAN DEFAULT FALSE',
|
||||||
|
},
|
||||||
|
normalizeSql: {
|
||||||
|
sqlite: 'UPDATE sites SET use_system_proxy = 0 WHERE use_system_proxy IS NULL;',
|
||||||
|
mysql: 'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL',
|
||||||
|
postgres: 'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: 'external_checkin_url',
|
||||||
|
addSql: {
|
||||||
|
sqlite: 'ALTER TABLE sites ADD COLUMN external_checkin_url text;',
|
||||||
|
mysql: 'ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT NULL',
|
||||||
|
postgres: 'ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
column: 'global_weight',
|
||||||
|
addSql: {
|
||||||
|
sqlite: 'ALTER TABLE sites ADD COLUMN global_weight real DEFAULT 1;',
|
||||||
|
mysql: 'ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1',
|
||||||
|
postgres: 'ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1',
|
||||||
|
},
|
||||||
|
normalizeSql: {
|
||||||
|
sqlite: 'UPDATE sites SET global_weight = 1 WHERE global_weight IS NULL OR global_weight <= 0;',
|
||||||
|
mysql: 'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0',
|
||||||
|
postgres: 'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||||
|
if (typeof error === 'object' && error && 'message' in error) {
|
||||||
|
return String((error as { message?: unknown }).message || '');
|
||||||
|
}
|
||||||
|
return String(error || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDuplicateColumnError(error: unknown): boolean {
|
||||||
|
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
|
||||||
|
return lowered.includes('duplicate column')
|
||||||
|
|| lowered.includes('already exists')
|
||||||
|
|| lowered.includes('duplicate column name');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeAddColumn(inspector: SiteSchemaInspector, sqlText: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await inspector.execute(sqlText);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isDuplicateColumnError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSiteSchemaCompatibility(inspector: SiteSchemaInspector): Promise<void> {
|
||||||
|
const hasSitesTable = await inspector.tableExists('sites');
|
||||||
|
if (!hasSitesTable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const spec of SITE_COLUMN_COMPATIBILITY_SPECS) {
|
||||||
|
const hasColumn = await inspector.columnExists('sites', spec.column);
|
||||||
|
if (!hasColumn) {
|
||||||
|
await executeAddColumn(inspector, spec.addSql[inspector.dialect]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.normalizeSql) {
|
||||||
|
await inspector.execute(spec.normalizeSql[inspector.dialect]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -27,7 +27,15 @@ import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
|||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, normalize, resolve, sep } from 'path';
|
import { dirname, normalize, resolve, sep } from 'path';
|
||||||
import { db, ensureProxyLogBillingDetailsColumn, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js';
|
import {
|
||||||
|
db,
|
||||||
|
ensureProxyLogBillingDetailsColumn,
|
||||||
|
ensureSiteCompatibilityColumns,
|
||||||
|
runtimeDbDialect,
|
||||||
|
schema,
|
||||||
|
switchRuntimeDatabase,
|
||||||
|
type RuntimeDbDialect,
|
||||||
|
} from './db/index.js';
|
||||||
|
|
||||||
let sqliteMigrationsBootstrapped = false;
|
let sqliteMigrationsBootstrapped = false;
|
||||||
|
|
||||||
@@ -207,6 +215,7 @@ try {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureSiteCompatibilityColumns();
|
||||||
const finalRows = await db.select().from(schema.settings).all();
|
const finalRows = await db.select().from(schema.settings).all();
|
||||||
const finalMap = toSettingsMap(finalRows);
|
const finalMap = toSettingsMap(finalRows);
|
||||||
applyRuntimeSettings(finalMap);
|
applyRuntimeSettings(finalMap);
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
|
||||||
|
type ConfigModule = typeof import('../../config.js');
|
||||||
|
|
||||||
|
describe('sites token-router cache invalidation', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let tokenRouter: TokenRouterModule['tokenRouter'];
|
||||||
|
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||||
|
let config: ConfigModule['config'];
|
||||||
|
let originalCacheTtlMs = 0;
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-token-router-cache-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
const routesModule = await import('./sites.js');
|
||||||
|
const tokenRouterModule = await import('../../services/tokenRouter.js');
|
||||||
|
const configModule = await import('../../config.js');
|
||||||
|
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
tokenRouter = tokenRouterModule.tokenRouter;
|
||||||
|
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||||
|
config = configModule.config;
|
||||||
|
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(routesModule.sitesRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
config.tokenRouterCacheTtlMs = 60_000;
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recomputes probabilities with the new site weight after updating a site', async () => {
|
||||||
|
const targetSite = await db.insert(schema.sites).values({
|
||||||
|
name: 'weighted-target',
|
||||||
|
url: 'https://weighted-target.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
globalWeight: 1,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const competitorSite = await db.insert(schema.sites).values({
|
||||||
|
name: 'weighted-competitor',
|
||||||
|
url: 'https://weighted-competitor.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
globalWeight: 1,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const targetAccount = await db.insert(schema.accounts).values({
|
||||||
|
siteId: targetSite.id,
|
||||||
|
username: 'target-user',
|
||||||
|
accessToken: 'target-access-token',
|
||||||
|
apiToken: 'sk-target-api-token',
|
||||||
|
status: 'active',
|
||||||
|
unitCost: 1,
|
||||||
|
balance: 0,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const competitorAccount = await db.insert(schema.accounts).values({
|
||||||
|
siteId: competitorSite.id,
|
||||||
|
username: 'competitor-user',
|
||||||
|
accessToken: 'competitor-access-token',
|
||||||
|
apiToken: 'sk-competitor-api-token',
|
||||||
|
status: 'active',
|
||||||
|
unitCost: 1,
|
||||||
|
balance: 0,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.routeChannels).values([
|
||||||
|
{
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: targetAccount.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: competitorAccount.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const before = await tokenRouter.explainSelection('gpt-4o-mini');
|
||||||
|
const beforeTarget = before.candidates.find((candidate) => candidate.accountId === targetAccount.id);
|
||||||
|
expect(beforeTarget?.probability).toBeCloseTo(50, 1);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/sites/${targetSite.id}`,
|
||||||
|
payload: {
|
||||||
|
globalWeight: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const after = await tokenRouter.explainSelection('gpt-4o-mini');
|
||||||
|
const afterTarget = after.candidates.find((candidate) => candidate.accountId === targetAccount.id);
|
||||||
|
|
||||||
|
expect(afterTarget?.probability || 0).toBeGreaterThan(90);
|
||||||
|
expect(afterTarget?.reason || '').toContain('站点权重=100.00');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { and, eq, sql } from 'drizzle-orm';
|
|||||||
import { detectSite } from '../../services/siteDetector.js';
|
import { detectSite } from '../../services/siteDetector.js';
|
||||||
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
|
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||||
|
|
||||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||||
if (input === undefined || input === null) return null;
|
if (input === undefined || input === null) return null;
|
||||||
@@ -74,6 +75,11 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sitesRoutes(app: FastifyInstance) {
|
export async function sitesRoutes(app: FastifyInstance) {
|
||||||
|
function invalidateSiteCaches() {
|
||||||
|
invalidateSiteProxyCache();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
}
|
||||||
|
|
||||||
async function applySiteStatusSideEffects(
|
async function applySiteStatusSideEffects(
|
||||||
siteId: number,
|
siteId: number,
|
||||||
existingSiteName: string,
|
existingSiteName: string,
|
||||||
@@ -216,7 +222,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (!result) {
|
if (!result) {
|
||||||
return reply.code(500).send({ error: 'Create site failed' });
|
return reply.code(500).send({ error: 'Create site failed' });
|
||||||
}
|
}
|
||||||
invalidateSiteProxyCache();
|
invalidateSiteCaches();
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -280,12 +286,13 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
|
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
|
||||||
updates.updatedAt = new Date().toISOString();
|
updates.updatedAt = new Date().toISOString();
|
||||||
await 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) {
|
if (body.status !== undefined && normalizedStatus) {
|
||||||
await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
|
await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
invalidateSiteCaches();
|
||||||
|
|
||||||
return await 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,7 +300,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
app.delete<{ Params: { id: string } }>('/api/sites/:id', async (request) => {
|
app.delete<{ Params: { id: string } }>('/api/sites/:id', async (request) => {
|
||||||
const id = parseInt(request.params.id);
|
const id = parseInt(request.params.id);
|
||||||
await db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
|
await db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
|
||||||
invalidateSiteProxyCache();
|
invalidateSiteCaches();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,7 +351,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateSiteProxyCache();
|
invalidateSiteCaches();
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
successIds,
|
successIds,
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
|
describe('PUT /api/routes/:id route rebuild', () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let dataDir = '';
|
||||||
|
let seedId = 0;
|
||||||
|
|
||||||
|
const nextId = () => {
|
||||||
|
seedId += 1;
|
||||||
|
return seedId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seedAccountWithToken = async (modelName: string) => {
|
||||||
|
const id = nextId();
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: `site-${id}`,
|
||||||
|
url: `https://example.com/${id}`,
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: `user-${id}`,
|
||||||
|
accessToken: `access-${id}`,
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const token = await db.insert(schema.accountTokens).values({
|
||||||
|
accountId: account.id,
|
||||||
|
name: `token-${id}`,
|
||||||
|
token: `sk-token-${id}`,
|
||||||
|
enabled: true,
|
||||||
|
isDefault: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.tokenModelAvailability).values({
|
||||||
|
tokenId: token.id,
|
||||||
|
modelName,
|
||||||
|
available: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
return { site, account, token };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-route-update-rebuild-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../../db/migrate.js');
|
||||||
|
const dbModule = await import('../../db/index.js');
|
||||||
|
const routesModule = await import('./tokens.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
|
||||||
|
app = Fastify();
|
||||||
|
await app.register(routesModule.tokensRoutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
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();
|
||||||
|
seedId = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebuilds only automatic channels when modelPattern changes', async () => {
|
||||||
|
const oldCandidate = await seedAccountWithToken('claude-opus-4-5');
|
||||||
|
const newCandidate = await seedAccountWithToken('gemini-2.0-flash');
|
||||||
|
const manualCandidate = await seedAccountWithToken('manual-special');
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 're:^claude-.*$',
|
||||||
|
displayName: 'old-group',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const autoChannel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: oldCandidate.account.id,
|
||||||
|
tokenId: oldCandidate.token.id,
|
||||||
|
sourceModel: 'claude-opus-4-5',
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: false,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const manualChannel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: manualCandidate.account.id,
|
||||||
|
tokenId: manualCandidate.token.id,
|
||||||
|
sourceModel: 'manual-special',
|
||||||
|
priority: 7,
|
||||||
|
weight: 3,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: `/api/routes/${route.id}`,
|
||||||
|
payload: {
|
||||||
|
modelPattern: 're:^gemini-.*$',
|
||||||
|
displayName: 'new-group',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
id: route.id,
|
||||||
|
modelPattern: 're:^gemini-.*$',
|
||||||
|
displayName: 'new-group',
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeChannels = await db.select().from(schema.routeChannels)
|
||||||
|
.where(eq(schema.routeChannels.routeId, route.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
expect(routeChannels.some((channel) => channel.id === manualChannel.id)).toBe(true);
|
||||||
|
expect(routeChannels.some((channel) => channel.id === autoChannel.id)).toBe(false);
|
||||||
|
|
||||||
|
const rebuiltAuto = routeChannels.find((channel) =>
|
||||||
|
channel.accountId === newCandidate.account.id
|
||||||
|
&& channel.tokenId === newCandidate.token.id
|
||||||
|
&& channel.sourceModel === 'gemini-2.0-flash',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(rebuiltAuto).toBeDefined();
|
||||||
|
expect(rebuiltAuto?.manualOverride).toBe(false);
|
||||||
|
expect(rebuiltAuto?.priority).toBe(0);
|
||||||
|
expect(rebuiltAuto?.weight).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -169,6 +169,30 @@ async function populateRouteChannelsByModelPattern(routeId: number, modelPattern
|
|||||||
return created;
|
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 = {
|
type BatchChannelPriorityUpdate = {
|
||||||
id: number;
|
id: number;
|
||||||
priority: number;
|
priority: number;
|
||||||
@@ -445,19 +469,32 @@ export async function tokensRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update a route
|
// Update a route
|
||||||
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request) => {
|
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseInt(request.params.id, 10);
|
||||||
const body = request.body as Record<string, unknown>;
|
const body = request.body as Record<string, unknown>;
|
||||||
|
const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
|
||||||
|
if (!existingRoute) {
|
||||||
|
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
|
let nextModelPattern = existingRoute.modelPattern;
|
||||||
|
|
||||||
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
||||||
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
|
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
|
||||||
if (body.modelPattern !== undefined) updates.modelPattern = body.modelPattern;
|
if (body.modelPattern !== undefined) {
|
||||||
|
nextModelPattern = String(body.modelPattern);
|
||||||
|
updates.modelPattern = nextModelPattern;
|
||||||
|
}
|
||||||
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
|
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
|
||||||
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
||||||
updates.updatedAt = new Date().toISOString();
|
updates.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
await 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();
|
||||||
|
const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern;
|
||||||
|
if (modelPatternChanged) {
|
||||||
|
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
|
||||||
|
}
|
||||||
invalidateTokenRouterCache();
|
invalidateTokenRouterCache();
|
||||||
return await 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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -209,4 +209,79 @@ describe('/v1/models route', () => {
|
|||||||
expect(ids).toContain('allowed-model');
|
expect(ids).toContain('allowed-model');
|
||||||
expect(ids).not.toContain('blocked-model');
|
expect(ids).not.toContain('blocked-model');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns only selected group route alias for managed key with allowedRouteIds policy', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'test-site',
|
||||||
|
url: 'https://upstream.example.com',
|
||||||
|
platform: 'openai',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
accessToken: 'account-access-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const token = await db.insert(schema.accountTokens).values({
|
||||||
|
accountId: account.id,
|
||||||
|
name: 'default',
|
||||||
|
token: 'account-api-token',
|
||||||
|
enabled: true,
|
||||||
|
isDefault: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.modelAvailability).values([
|
||||||
|
{
|
||||||
|
accountId: account.id,
|
||||||
|
modelName: 'claude-opus-4-5',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: account.id,
|
||||||
|
modelName: 'claude-sonnet-4-5',
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const groupRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 're:^claude-(opus|sonnet)-4-5$',
|
||||||
|
displayName: 'claude-opus-4-6',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: groupRoute.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: token.id,
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
await db.insert(schema.downstreamApiKeys).values({
|
||||||
|
name: 'managed-key',
|
||||||
|
key: 'sk-managed-group-only',
|
||||||
|
enabled: true,
|
||||||
|
allowedRouteIds: JSON.stringify([groupRoute.id]),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/v1/models',
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer sk-managed-group-only',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as {
|
||||||
|
object: 'list';
|
||||||
|
data: Array<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ids = body.data.map((item) => item.id);
|
||||||
|
expect(ids).toContain('claude-opus-4-6');
|
||||||
|
expect(ids).not.toContain('claude-opus-4-5');
|
||||||
|
expect(ids).not.toContain('claude-sonnet-4-5');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { maskConnectionString, normalizeMigrationInput } from './databaseMigrationService.js';
|
import {
|
||||||
|
__databaseMigrationServiceTestUtils,
|
||||||
|
maskConnectionString,
|
||||||
|
normalizeMigrationInput,
|
||||||
|
} from './databaseMigrationService.js';
|
||||||
|
|
||||||
describe('databaseMigrationService', () => {
|
describe('databaseMigrationService', () => {
|
||||||
it('accepts postgres migration input with normalized url', () => {
|
it('accepts postgres migration input with normalized url', () => {
|
||||||
@@ -96,5 +100,69 @@ describe('databaseMigrationService', () => {
|
|||||||
});
|
});
|
||||||
expect(normalized.ssl).toBe(false);
|
expect(normalized.ssl).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
it.each(['postgres', 'mysql', 'sqlite'] as const)('creates or patches sites schema with use_system_proxy for %s', async (dialect) => {
|
||||||
|
const executedSql: string[] = [];
|
||||||
|
await __databaseMigrationServiceTestUtils.ensureSchema({
|
||||||
|
dialect,
|
||||||
|
begin: async () => {},
|
||||||
|
commit: async () => {},
|
||||||
|
rollback: async () => {},
|
||||||
|
execute: async (sqlText) => {
|
||||||
|
executedSql.push(sqlText);
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
queryScalar: async (sqlText, params = []) => {
|
||||||
|
if (sqlText.includes('sqlite_master') || sqlText.includes('information_schema.tables')) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (sqlText.includes('pragma_table_info') || sqlText.includes('information_schema.columns')) {
|
||||||
|
const columnName = String(params[1] ?? sqlText.match(/name = '([^']+)'/)?.[1] ?? '');
|
||||||
|
return columnName === 'use_system_proxy' ? 0 : 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
close: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const useSystemProxySql = executedSql.find((sqlText) => sqlText.includes('use_system_proxy'));
|
||||||
|
|
||||||
|
expect(useSystemProxySql).toContain('use_system_proxy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes useSystemProxy when building site migration statements', () => {
|
||||||
|
const statements = __databaseMigrationServiceTestUtils.buildStatements({
|
||||||
|
version: 'test',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
accounts: {
|
||||||
|
sites: [{
|
||||||
|
id: 1,
|
||||||
|
name: 'demo',
|
||||||
|
url: 'https://example.com',
|
||||||
|
platform: 'openai',
|
||||||
|
useSystemProxy: true,
|
||||||
|
status: 'active',
|
||||||
|
}],
|
||||||
|
accounts: [],
|
||||||
|
accountTokens: [],
|
||||||
|
checkinLogs: [],
|
||||||
|
modelAvailability: [],
|
||||||
|
tokenModelAvailability: [],
|
||||||
|
tokenRoutes: [],
|
||||||
|
routeChannels: [],
|
||||||
|
proxyLogs: [],
|
||||||
|
downstreamApiKeys: [],
|
||||||
|
events: [],
|
||||||
|
},
|
||||||
|
preferences: {
|
||||||
|
settings: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const siteStatement = statements.find((statement) => statement.table === 'sites');
|
||||||
|
const useSystemProxyIndex = siteStatement?.columns.indexOf('use_system_proxy') ?? -1;
|
||||||
|
|
||||||
|
expect(useSystemProxyIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(siteStatement?.values[useSystemProxyIndex]).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import pg from 'pg';
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync } from 'node:fs';
|
||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
|
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from '../db/siteSchemaCompatibility.js';
|
||||||
|
|
||||||
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres';
|
export type MigrationDialect = 'sqlite' | 'mysql' | 'postgres';
|
||||||
|
|
||||||
@@ -307,10 +308,81 @@ async function createClient(input: NormalizedDatabaseMigrationInput): Promise<Sq
|
|||||||
return createSqliteClient(input.connectionString);
|
return createSqliteClient(input.connectionString);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateIdentifier(identifier: string): string {
|
||||||
|
if (!/^[a-z_][a-z0-9_]*$/i.test(identifier)) {
|
||||||
|
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
||||||
|
}
|
||||||
|
return identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSiteSchemaInspector(client: SqlClient): SiteSchemaInspector {
|
||||||
|
if (client.dialect === 'sqlite') {
|
||||||
|
return {
|
||||||
|
dialect: 'sqlite',
|
||||||
|
tableExists: async (table) => {
|
||||||
|
const normalizedTable = validateIdentifier(table);
|
||||||
|
return (await client.queryScalar(
|
||||||
|
`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '${normalizedTable}'`,
|
||||||
|
)) > 0;
|
||||||
|
},
|
||||||
|
columnExists: async (table, column) => {
|
||||||
|
const normalizedTable = validateIdentifier(table);
|
||||||
|
const normalizedColumn = validateIdentifier(column);
|
||||||
|
return (await client.queryScalar(
|
||||||
|
`SELECT COUNT(*) FROM pragma_table_info('${normalizedTable}') WHERE name = '${normalizedColumn}'`,
|
||||||
|
)) > 0;
|
||||||
|
},
|
||||||
|
execute: async (sqlText) => {
|
||||||
|
await client.execute(sqlText);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.dialect === 'mysql') {
|
||||||
|
return {
|
||||||
|
dialect: 'mysql',
|
||||||
|
tableExists: async (table) => {
|
||||||
|
return (await client.queryScalar(
|
||||||
|
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?',
|
||||||
|
[table],
|
||||||
|
)) > 0;
|
||||||
|
},
|
||||||
|
columnExists: async (table, column) => {
|
||||||
|
return (await client.queryScalar(
|
||||||
|
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?',
|
||||||
|
[table, column],
|
||||||
|
)) > 0;
|
||||||
|
},
|
||||||
|
execute: async (sqlText) => {
|
||||||
|
await client.execute(sqlText);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dialect: 'postgres',
|
||||||
|
tableExists: async (table) => {
|
||||||
|
return (await client.queryScalar(
|
||||||
|
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = $1',
|
||||||
|
[table],
|
||||||
|
)) > 0;
|
||||||
|
},
|
||||||
|
columnExists: async (table, column) => {
|
||||||
|
return (await client.queryScalar(
|
||||||
|
'SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2',
|
||||||
|
[table, column],
|
||||||
|
)) > 0;
|
||||||
|
},
|
||||||
|
execute: async (sqlText) => {
|
||||||
|
await client.execute(sqlText);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureSchema(client: SqlClient): Promise<void> {
|
async function ensureSchema(client: SqlClient): Promise<void> {
|
||||||
const statements = client.dialect === 'postgres'
|
const statements = client.dialect === 'postgres'
|
||||||
? [
|
? [
|
||||||
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
||||||
@@ -325,7 +397,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
|
|||||||
]
|
]
|
||||||
: client.dialect === 'mysql'
|
: client.dialect === 'mysql'
|
||||||
? [
|
? [
|
||||||
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
@@ -339,7 +411,7 @@ async function ensureSchema(client: SqlClient): Promise<void> {
|
|||||||
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
||||||
@@ -356,6 +428,8 @@ async function ensureSchema(client: SqlClient): Promise<void> {
|
|||||||
for (const sqlText of statements) {
|
for (const sqlText of statements) {
|
||||||
await client.execute(sqlText);
|
await client.execute(sqlText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureSiteSchemaCompatibility(createSiteSchemaInspector(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {
|
async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise<void> {
|
||||||
@@ -392,7 +466,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
|||||||
for (const row of snapshot.accounts.sites) {
|
for (const row of snapshot.accounts.sites) {
|
||||||
statements.push({
|
statements.push({
|
||||||
table: 'sites',
|
table: 'sites',
|
||||||
columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'],
|
columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'use_system_proxy', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'],
|
||||||
values: [
|
values: [
|
||||||
asNumber(row.id, 0),
|
asNumber(row.id, 0),
|
||||||
asNullableString(row.name),
|
asNullableString(row.name),
|
||||||
@@ -400,6 +474,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
|||||||
asNullableString(row.externalCheckinUrl),
|
asNullableString(row.externalCheckinUrl),
|
||||||
asNullableString(row.platform),
|
asNullableString(row.platform),
|
||||||
asNullableString(row.proxyUrl),
|
asNullableString(row.proxyUrl),
|
||||||
|
asBoolean(row.useSystemProxy, false),
|
||||||
asNullableString(row.status) ?? 'active',
|
asNullableString(row.status) ?? 'active',
|
||||||
asBoolean(row.isPinned, false),
|
asBoolean(row.isPinned, false),
|
||||||
asNumber(row.sortOrder, 0),
|
asNumber(row.sortOrder, 0),
|
||||||
@@ -730,3 +805,8 @@ export async function testDatabaseConnection(input: DatabaseMigrationInput): Pro
|
|||||||
connection: maskConnectionString(normalized.connectionString),
|
connection: maskConnectionString(normalized.connectionString),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __databaseMigrationServiceTestUtils = {
|
||||||
|
ensureSchema,
|
||||||
|
buildStatements,
|
||||||
|
};
|
||||||
|
|||||||
@@ -116,9 +116,10 @@ describe('downstreamApiKeyService', () => {
|
|||||||
expect(service.isModelAllowedByPolicy('gemini-2.0-flash', result.policy)).toBe(false);
|
expect(service.isModelAllowedByPolicy('gemini-2.0-flash', result.policy)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats selected groups as additional allowed model scope (union semantics)', async () => {
|
it('treats selected groups as additional allowed exposed route scope (union semantics)', async () => {
|
||||||
const claudeGroup = await db.insert(schema.tokenRoutes).values({
|
const claudeGroup = await db.insert(schema.tokenRoutes).values({
|
||||||
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||||
|
displayName: 'claude-4-6-group',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}).returning().get();
|
}).returning().get();
|
||||||
|
|
||||||
@@ -128,8 +129,10 @@ describe('downstreamApiKeyService', () => {
|
|||||||
siteWeightMultipliers: {},
|
siteWeightMultipliers: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(service.isModelAllowedByPolicy('claude-opus-4-6', policy)).toBe(false);
|
expect(service.isModelAllowedByPolicy('claude-4-6-group', policy)).toBe(false);
|
||||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-4-6-group', policy)).toBe(true);
|
||||||
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(false);
|
||||||
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(true);
|
||||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +152,7 @@ describe('downstreamApiKeyService', () => {
|
|||||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-6', policy)).toBe(false);
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-6', policy)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('authorizes models by selected route display name alias', async () => {
|
it('only authorizes selected route display name alias, not models covered by group pattern', async () => {
|
||||||
const aliasRoute = await db.insert(schema.tokenRoutes).values({
|
const aliasRoute = await db.insert(schema.tokenRoutes).values({
|
||||||
modelPattern: 're:^claude-(opus|sonnet)-4-5$',
|
modelPattern: 're:^claude-(opus|sonnet)-4-5$',
|
||||||
displayName: 'claude-opus-4-6',
|
displayName: 'claude-opus-4-6',
|
||||||
@@ -163,8 +166,8 @@ describe('downstreamApiKeyService', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(true);
|
||||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-5', policy)).toBe(true);
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-sonnet-4-5', policy)).toBe(false);
|
||||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(true);
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-5', policy)).toBe(false);
|
||||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
|
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ function maskSecret(value: string): string {
|
|||||||
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExposedRouteName(route: { modelPattern: string; displayName: string | null }): string {
|
||||||
|
return (route.displayName || '').trim() || route.modelPattern.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePositiveNumberOrNull(value: unknown): number | null {
|
function normalizePositiveNumberOrNull(value: unknown): number | null {
|
||||||
if (value === null || value === undefined || value === '') return null;
|
if (value === null || value === undefined || value === '') return null;
|
||||||
const n = Number(value);
|
const n = Number(value);
|
||||||
@@ -191,11 +195,7 @@ async function isModelMatchedByAllowedRoutes(model: string, allowedRouteIds: num
|
|||||||
))
|
))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
return routes.some((route) => {
|
return routes.some((route) => getExposedRouteName(route) === model);
|
||||||
if (matchesDownstreamModelPattern(model, route.modelPattern)) return true;
|
|
||||||
const alias = (route.displayName || '').trim();
|
|
||||||
return !!alias && alias === model;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, policy: DownstreamRoutingPolicy): Promise<boolean> {
|
export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, policy: DownstreamRoutingPolicy): Promise<boolean> {
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../db/index.js');
|
||||||
|
type TokenRouterModule = typeof import('./tokenRouter.js');
|
||||||
|
|
||||||
|
describe('TokenRouter session decoupling', () => {
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||||
|
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||||
|
let dataDir = '';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-token-router-session-decoupling-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../db/migrate.js');
|
||||||
|
const dbModule = await import('../db/index.js');
|
||||||
|
const tokenRouterModule = await import('./tokenRouter.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
TokenRouter = tokenRouterModule.TokenRouter;
|
||||||
|
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
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();
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
invalidateTokenRouterCache();
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps explicit token-bound channels routable when account session is expired', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'managed-site',
|
||||||
|
url: 'https://managed.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'expired-session-user',
|
||||||
|
accessToken: 'expired-session-token',
|
||||||
|
apiToken: null,
|
||||||
|
status: 'expired',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const token = await db.insert(schema.accountTokens).values({
|
||||||
|
accountId: account.id,
|
||||||
|
name: 'stable-token',
|
||||||
|
token: 'sk-stable-token',
|
||||||
|
enabled: true,
|
||||||
|
isDefault: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: token.id,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: false,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
const selected = await router.selectChannel('gpt-4o-mini');
|
||||||
|
|
||||||
|
expect(selected).not.toBeNull();
|
||||||
|
expect(selected?.channel.id).toBe(channel.id);
|
||||||
|
expect(selected?.tokenValue).toBe('sk-stable-token');
|
||||||
|
expect(selected?.account.id).toBe(account.id);
|
||||||
|
|
||||||
|
const decision = await router.explainSelection('gpt-4o-mini');
|
||||||
|
const candidate = decision.candidates.find((item) => item.channelId === channel.id);
|
||||||
|
expect(candidate?.eligible).toBe(true);
|
||||||
|
expect(candidate?.reason).not.toContain('账号状态=expired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still blocks fallback account-token channels when account session is expired', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'legacy-site',
|
||||||
|
url: 'https://legacy.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'legacy-user',
|
||||||
|
accessToken: 'expired-session-token',
|
||||||
|
apiToken: 'sk-fallback-account-token',
|
||||||
|
status: 'expired',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-4.1-mini',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const channel = await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: false,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const router = new TokenRouter();
|
||||||
|
const selected = await router.selectChannel('gpt-4.1-mini');
|
||||||
|
expect(selected).toBeNull();
|
||||||
|
|
||||||
|
const decision = await router.explainSelection('gpt-4.1-mini');
|
||||||
|
const candidate = decision.candidates.find((item) => item.channelId === channel.id);
|
||||||
|
expect(candidate?.eligible).toBe(false);
|
||||||
|
expect(candidate?.reason).toContain('账号状态=expired');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -195,6 +195,13 @@ type ExplainSelectionOptions = {
|
|||||||
downstreamPolicy?: DownstreamRoutingPolicy;
|
downstreamPolicy?: DownstreamRoutingPolicy;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CandidateEligibilityOptions = {
|
||||||
|
requestedModel: string;
|
||||||
|
bypassSourceModelCheck?: boolean;
|
||||||
|
excludeChannelIds?: number[];
|
||||||
|
nowIso?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type CostSignal = {
|
type CostSignal = {
|
||||||
unitCost: number;
|
unitCost: number;
|
||||||
source: 'observed' | 'configured' | 'catalog' | 'fallback';
|
source: 'observed' | 'configured' | 'catalog' | 'fallback';
|
||||||
@@ -364,6 +371,10 @@ function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: s
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExplicitTokenChannel(candidate: RouteChannelCandidate): boolean {
|
||||||
|
return typeof candidate.channel.tokenId === 'number' && candidate.channel.tokenId > 0;
|
||||||
|
}
|
||||||
|
|
||||||
export class TokenRouter {
|
export class TokenRouter {
|
||||||
/**
|
/**
|
||||||
* Find matching route and select a channel for the given model.
|
* Find matching route and select a channel for the given model.
|
||||||
@@ -379,17 +390,15 @@ export class TokenRouter {
|
|||||||
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
|
||||||
const bypassSourceModelCheck = requestedByDisplayName;
|
const bypassSourceModelCheck = requestedByDisplayName;
|
||||||
|
|
||||||
// Filter channels: enabled, not in cooldown, account/site active with token
|
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const available = match.channels.filter((c) =>
|
const available = match.channels.filter((candidate) => (
|
||||||
(bypassSourceModelCheck || channelSupportsRequestedModel(c.channel.sourceModel, requestedModel)) &&
|
this.getCandidateEligibilityReasons(candidate, {
|
||||||
c.channel.enabled &&
|
requestedModel,
|
||||||
c.account.status === 'active' &&
|
bypassSourceModelCheck,
|
||||||
!isSiteDisabled(c.site.status) &&
|
nowIso,
|
||||||
!!this.resolveChannelTokenValue(c) &&
|
}).length === 0
|
||||||
(!c.channel.cooldownUntil || c.channel.cooldownUntil <= nowIso),
|
));
|
||||||
);
|
|
||||||
|
|
||||||
if (available.length === 0) return null;
|
if (available.length === 0) return null;
|
||||||
|
|
||||||
@@ -459,15 +468,14 @@ export class TokenRouter {
|
|||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const available = match.channels.filter((c) =>
|
const available = match.channels.filter((candidate) => (
|
||||||
(bypassSourceModelCheck || channelSupportsRequestedModel(c.channel.sourceModel, requestedModel)) &&
|
this.getCandidateEligibilityReasons(candidate, {
|
||||||
c.channel.enabled &&
|
requestedModel,
|
||||||
c.account.status === 'active' &&
|
bypassSourceModelCheck,
|
||||||
!isSiteDisabled(c.site.status) &&
|
excludeChannelIds,
|
||||||
!!this.resolveChannelTokenValue(c) &&
|
nowIso,
|
||||||
(!c.channel.cooldownUntil || c.channel.cooldownUntil <= nowIso) &&
|
}).length === 0
|
||||||
!excludeChannelIds.includes(c.channel.id),
|
));
|
||||||
);
|
|
||||||
|
|
||||||
if (available.length === 0) return null;
|
if (available.length === 0) return null;
|
||||||
|
|
||||||
@@ -578,17 +586,12 @@ export class TokenRouter {
|
|||||||
const candidateMap = new Map<number, RouteDecisionCandidate>();
|
const candidateMap = new Map<number, RouteDecisionCandidate>();
|
||||||
|
|
||||||
for (const row of match.channels) {
|
for (const row of match.channels) {
|
||||||
const reasonParts: string[] = [];
|
const reasonParts = this.getCandidateEligibilityReasons(row, {
|
||||||
if (!bypassSourceModelCheck && !channelSupportsRequestedModel(row.channel.sourceModel, requestedModel)) {
|
requestedModel,
|
||||||
reasonParts.push(`来源模型不匹配=${row.channel.sourceModel || ''}`);
|
bypassSourceModelCheck,
|
||||||
}
|
excludeChannelIds,
|
||||||
if (!row.channel.enabled) reasonParts.push('通道禁用');
|
nowIso,
|
||||||
if (row.account.status !== 'active') reasonParts.push(`账号状态=${row.account.status}`);
|
});
|
||||||
if (isSiteDisabled(row.site.status)) reasonParts.push(`站点状态=${row.site.status || 'disabled'}`);
|
|
||||||
if (excludeChannelIds.includes(row.channel.id)) reasonParts.push('当前请求已尝试');
|
|
||||||
const tokenValue = this.resolveChannelTokenValue(row);
|
|
||||||
if (!tokenValue) reasonParts.push('令牌不可用');
|
|
||||||
if (row.channel.cooldownUntil && row.channel.cooldownUntil > nowIso) reasonParts.push('冷却中');
|
|
||||||
|
|
||||||
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC);
|
const recentlyFailed = isChannelRecentlyFailed(row.channel, nowMs, RECENT_FAILURE_AVOID_SEC);
|
||||||
const eligible = reasonParts.length === 0;
|
const eligible = reasonParts.length === 0;
|
||||||
@@ -838,6 +841,47 @@ export class TokenRouter {
|
|||||||
return fallback || null;
|
return fallback || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCandidateEligibilityReasons(
|
||||||
|
candidate: RouteChannelCandidate,
|
||||||
|
options: CandidateEligibilityOptions,
|
||||||
|
): string[] {
|
||||||
|
const reasonParts: string[] = [];
|
||||||
|
const bypassSourceModelCheck = options.bypassSourceModelCheck ?? false;
|
||||||
|
const excludeChannelIds = options.excludeChannelIds ?? [];
|
||||||
|
const nowIso = options.nowIso ?? new Date().toISOString();
|
||||||
|
|
||||||
|
if (!bypassSourceModelCheck && !channelSupportsRequestedModel(candidate.channel.sourceModel, options.requestedModel)) {
|
||||||
|
reasonParts.push(`来源模型不匹配=${candidate.channel.sourceModel || ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!candidate.channel.enabled) reasonParts.push('通道禁用');
|
||||||
|
|
||||||
|
if (isExplicitTokenChannel(candidate)) {
|
||||||
|
if (candidate.account.status === 'disabled') {
|
||||||
|
reasonParts.push(`账号状态=${candidate.account.status}`);
|
||||||
|
}
|
||||||
|
} else if (candidate.account.status !== 'active') {
|
||||||
|
reasonParts.push(`账号状态=${candidate.account.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSiteDisabled(candidate.site.status)) {
|
||||||
|
reasonParts.push(`站点状态=${candidate.site.status || 'disabled'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (excludeChannelIds.includes(candidate.channel.id)) {
|
||||||
|
reasonParts.push('当前请求已尝试');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenValue = this.resolveChannelTokenValue(candidate);
|
||||||
|
if (!tokenValue) reasonParts.push('令牌不可用');
|
||||||
|
|
||||||
|
if (candidate.channel.cooldownUntil && candidate.channel.cooldownUntil > nowIso) {
|
||||||
|
reasonParts.push('冷却中');
|
||||||
|
}
|
||||||
|
|
||||||
|
return reasonParts;
|
||||||
|
}
|
||||||
|
|
||||||
private weightedRandomSelect(
|
private weightedRandomSelect(
|
||||||
candidates: RouteChannelCandidate[],
|
candidates: RouteChannelCandidate[],
|
||||||
modelName: string | ((candidate: RouteChannelCandidate) => string),
|
modelName: string | ((candidate: RouteChannelCandidate) => string),
|
||||||
|
|||||||
+100
-24
@@ -34,6 +34,7 @@ import {
|
|||||||
normalizeMissingTokenModels,
|
normalizeMissingTokenModels,
|
||||||
type MissingTokenModelsByName,
|
type MissingTokenModelsByName,
|
||||||
} from './helpers/routeMissingTokenHints.js';
|
} from './helpers/routeMissingTokenHints.js';
|
||||||
|
import { buildVisibleRouteList } from './helpers/routeListVisibility.js';
|
||||||
|
|
||||||
type RouteSortBy = 'modelPattern' | 'channelCount';
|
type RouteSortBy = 'modelPattern' | 'channelCount';
|
||||||
type RouteSortDir = 'asc' | 'desc';
|
type RouteSortDir = 'asc' | 'desc';
|
||||||
@@ -705,6 +706,7 @@ export default function TokenRoutes() {
|
|||||||
const filterPanelPresence = useAnimatedVisibility(!filterCollapsed, 220);
|
const filterPanelPresence = useAnimatedVisibility(!filterCollapsed, 220);
|
||||||
const manualPanelPresence = useAnimatedVisibility(showManual, 220);
|
const manualPanelPresence = useAnimatedVisibility(showManual, 220);
|
||||||
const [form, setForm] = useState({ modelPattern: '', displayName: '', displayIcon: '' });
|
const [form, setForm] = useState({ modelPattern: '', displayName: '', displayIcon: '' });
|
||||||
|
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [rebuilding, setRebuilding] = useState(false);
|
const [rebuilding, setRebuilding] = useState(false);
|
||||||
|
|
||||||
@@ -849,7 +851,8 @@ export default function TokenRoutes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const exactRouteCount = useMemo(
|
const exactRouteCount = useMemo(
|
||||||
() => routes.filter((route) => isExactModelPattern(route.modelPattern)).length,
|
() => buildVisibleRouteList(routes, isExactModelPattern, matchesModelPattern)
|
||||||
|
.filter((route) => isExactModelPattern(route.modelPattern)).length,
|
||||||
[routes],
|
[routes],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -887,30 +890,65 @@ export default function TokenRoutes() {
|
|||||||
&& !!form.modelPattern.trim()
|
&& !!form.modelPattern.trim()
|
||||||
&& !modelPatternError;
|
&& !modelPatternError;
|
||||||
|
|
||||||
|
const resetRouteForm = () => {
|
||||||
|
setForm({ modelPattern: '', displayName: '', displayIcon: '' });
|
||||||
|
setEditingRouteId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddRoute = async () => {
|
const handleAddRoute = async () => {
|
||||||
if (!form.modelPattern.trim()) return;
|
if (!form.modelPattern.trim()) return;
|
||||||
if (modelPatternError) {
|
if (modelPatternError) {
|
||||||
toast.error(modelPatternError);
|
toast.error(modelPatternError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trimmedModelPattern = form.modelPattern.trim();
|
||||||
|
const trimmedDisplayName = form.displayName.trim() ? form.displayName.trim() : undefined;
|
||||||
|
const trimmedDisplayIcon = form.displayIcon.trim() ? form.displayIcon.trim() : undefined;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.addRoute({
|
if (editingRouteId) {
|
||||||
modelPattern: form.modelPattern.trim(),
|
const currentRoute = routes.find((route) => route.id === editingRouteId) || null;
|
||||||
displayName: form.displayName.trim() ? form.displayName.trim() : undefined,
|
const modelPatternChanged = !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern;
|
||||||
displayIcon: form.displayIcon.trim() ? form.displayIcon.trim() : undefined,
|
await api.updateRoute(editingRouteId, {
|
||||||
|
modelPattern: trimmedModelPattern,
|
||||||
|
displayName: trimmedDisplayName,
|
||||||
|
displayIcon: trimmedDisplayIcon,
|
||||||
|
});
|
||||||
|
toast.success(modelPatternChanged ? tr('群组已更新并重新匹配通道') : tr('群组已更新'));
|
||||||
|
} else {
|
||||||
|
await api.addRoute({
|
||||||
|
modelPattern: trimmedModelPattern,
|
||||||
|
displayName: trimmedDisplayName,
|
||||||
|
displayIcon: trimmedDisplayIcon,
|
||||||
});
|
});
|
||||||
setShowManual(false);
|
|
||||||
setForm({ modelPattern: '', displayName: '', displayIcon: '' });
|
|
||||||
toast.success(tr('群组已创建'));
|
toast.success(tr('群组已创建'));
|
||||||
|
}
|
||||||
|
setShowManual(false);
|
||||||
|
resetRouteForm();
|
||||||
await load();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(e.message || tr('创建群组失败'));
|
toast.error(e.message || (editingRouteId ? tr('更新群组失败') : tr('创建群组失败')));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEditRoute = (route: RouteRow) => {
|
||||||
|
setEditingRouteId(route.id);
|
||||||
|
setForm({
|
||||||
|
modelPattern: route.modelPattern || '',
|
||||||
|
displayName: route.displayName || '',
|
||||||
|
displayIcon: route.displayIcon || '',
|
||||||
|
});
|
||||||
|
setShowManual(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEditRoute = () => {
|
||||||
|
resetRouteForm();
|
||||||
|
setShowManual(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteRoute = async (routeId: number) => {
|
const handleDeleteRoute = async (routeId: number) => {
|
||||||
try {
|
try {
|
||||||
await api.deleteRoute(routeId);
|
await api.deleteRoute(routeId);
|
||||||
@@ -945,11 +983,16 @@ export default function TokenRoutes() {
|
|||||||
return next;
|
return next;
|
||||||
}, [routes]);
|
}, [routes]);
|
||||||
|
|
||||||
|
const listVisibleRoutes = useMemo(
|
||||||
|
() => buildVisibleRouteList(routes, isExactModelPattern, matchesModelPattern),
|
||||||
|
[routes],
|
||||||
|
);
|
||||||
|
|
||||||
const brandList = useMemo(() => {
|
const brandList = useMemo(() => {
|
||||||
const grouped = new Map<string, { count: number; brand: BrandInfo }>();
|
const grouped = new Map<string, { count: number; brand: BrandInfo }>();
|
||||||
let otherCount = 0;
|
let otherCount = 0;
|
||||||
|
|
||||||
for (const route of routes) {
|
for (const route of listVisibleRoutes) {
|
||||||
const brand = routeBrandById.get(route.id) || null;
|
const brand = routeBrandById.get(route.id) || null;
|
||||||
if (!brand) {
|
if (!brand) {
|
||||||
otherCount++;
|
otherCount++;
|
||||||
@@ -971,12 +1014,12 @@ export default function TokenRoutes() {
|
|||||||
}),
|
}),
|
||||||
otherCount,
|
otherCount,
|
||||||
};
|
};
|
||||||
}, [routes, routeBrandById]);
|
}, [listVisibleRoutes, routeBrandById]);
|
||||||
|
|
||||||
const siteList = useMemo(() => {
|
const siteList = useMemo(() => {
|
||||||
const grouped = new Map<string, { count: number; siteId: number }>();
|
const grouped = new Map<string, { count: number; siteId: number }>();
|
||||||
|
|
||||||
for (const route of routes) {
|
for (const route of listVisibleRoutes) {
|
||||||
const seenSites = new Set<string>();
|
const seenSites = new Set<string>();
|
||||||
for (const channel of route.channels || []) {
|
for (const channel of route.channels || []) {
|
||||||
const siteName = channel.site?.name;
|
const siteName = channel.site?.name;
|
||||||
@@ -997,7 +1040,7 @@ export default function TokenRoutes() {
|
|||||||
if (a[1].count === b[1].count) return a[0].localeCompare(b[0]);
|
if (a[1].count === b[1].count) return a[0].localeCompare(b[0]);
|
||||||
return b[1].count - a[1].count;
|
return b[1].count - a[1].count;
|
||||||
});
|
});
|
||||||
}, [routes]);
|
}, [listVisibleRoutes]);
|
||||||
|
|
||||||
const routeEndpointTypesByRouteId = useMemo(() => {
|
const routeEndpointTypesByRouteId = useMemo(() => {
|
||||||
const index: Record<number, Set<string>> = {};
|
const index: Record<number, Set<string>> = {};
|
||||||
@@ -1031,7 +1074,7 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
const endpointTypeList = useMemo(() => {
|
const endpointTypeList = useMemo(() => {
|
||||||
const grouped = new Map<string, number>();
|
const grouped = new Map<string, number>();
|
||||||
for (const route of routes) {
|
for (const route of listVisibleRoutes) {
|
||||||
const endpointTypes = routeEndpointTypesByRouteId[route.id] || new Set<string>();
|
const endpointTypes = routeEndpointTypesByRouteId[route.id] || new Set<string>();
|
||||||
for (const endpointType of endpointTypes) {
|
for (const endpointType of endpointTypes) {
|
||||||
grouped.set(endpointType, (grouped.get(endpointType) || 0) + 1);
|
grouped.set(endpointType, (grouped.get(endpointType) || 0) + 1);
|
||||||
@@ -1041,7 +1084,7 @@ export default function TokenRoutes() {
|
|||||||
if (a[1] === b[1]) return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
if (a[1] === b[1]) return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
|
||||||
return b[1] - a[1];
|
return b[1] - a[1];
|
||||||
});
|
});
|
||||||
}, [routes, routeEndpointTypesByRouteId]);
|
}, [listVisibleRoutes, routeEndpointTypesByRouteId]);
|
||||||
|
|
||||||
const routeBrandIconCandidates = useMemo(() => {
|
const routeBrandIconCandidates = useMemo(() => {
|
||||||
const byIcon = new Map<string, BrandInfo>();
|
const byIcon = new Map<string, BrandInfo>();
|
||||||
@@ -1078,7 +1121,7 @@ export default function TokenRoutes() {
|
|||||||
const routeIconSelectValue = routeIconOptionValues.has(form.displayIcon) ? form.displayIcon : '';
|
const routeIconSelectValue = routeIconOptionValues.has(form.displayIcon) ? form.displayIcon : '';
|
||||||
|
|
||||||
const groupRouteList = useMemo(() => (
|
const groupRouteList = useMemo(() => (
|
||||||
routes
|
listVisibleRoutes
|
||||||
.filter((route) => !isExactModelPattern(route.modelPattern))
|
.filter((route) => !isExactModelPattern(route.modelPattern))
|
||||||
.map((route) => ({
|
.map((route) => ({
|
||||||
id: route.id,
|
id: route.id,
|
||||||
@@ -1092,15 +1135,15 @@ export default function TokenRoutes() {
|
|||||||
if (a.channelCount === b.channelCount) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' });
|
if (a.channelCount === b.channelCount) return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' });
|
||||||
return b.channelCount - a.channelCount;
|
return b.channelCount - a.channelCount;
|
||||||
})
|
})
|
||||||
), [routes, routeBrandById]);
|
), [listVisibleRoutes, routeBrandById]);
|
||||||
|
|
||||||
const activeGroupRoute = useMemo(() => {
|
const activeGroupRoute = useMemo(() => {
|
||||||
if (typeof activeGroupFilter !== 'number') return null;
|
if (typeof activeGroupFilter !== 'number') return null;
|
||||||
return routes.find((route) => route.id === activeGroupFilter) || null;
|
return listVisibleRoutes.find((route) => route.id === activeGroupFilter) || null;
|
||||||
}, [activeGroupFilter, routes]);
|
}, [activeGroupFilter, listVisibleRoutes]);
|
||||||
|
|
||||||
const sortedRoutes = useMemo(() => (
|
const sortedRoutes = useMemo(() => (
|
||||||
[...routes].sort((a, b) => {
|
[...listVisibleRoutes].sort((a, b) => {
|
||||||
if (sortBy === 'channelCount') {
|
if (sortBy === 'channelCount') {
|
||||||
const countCmp = (a.channels?.length ?? 0) - (b.channels?.length ?? 0);
|
const countCmp = (a.channels?.length ?? 0) - (b.channels?.length ?? 0);
|
||||||
if (countCmp !== 0) return sortDir === 'asc' ? countCmp : -countCmp;
|
if (countCmp !== 0) return sortDir === 'asc' ? countCmp : -countCmp;
|
||||||
@@ -1109,7 +1152,7 @@ export default function TokenRoutes() {
|
|||||||
const nameCmp = a.modelPattern.localeCompare(b.modelPattern, undefined, { sensitivity: 'base' });
|
const nameCmp = a.modelPattern.localeCompare(b.modelPattern, undefined, { sensitivity: 'base' });
|
||||||
return sortDir === 'asc' ? nameCmp : -nameCmp;
|
return sortDir === 'asc' ? nameCmp : -nameCmp;
|
||||||
})
|
})
|
||||||
), [routes, sortBy, sortDir]);
|
), [listVisibleRoutes, sortBy, sortDir]);
|
||||||
|
|
||||||
const filteredRoutes = useMemo(() => {
|
const filteredRoutes = useMemo(() => {
|
||||||
let list = sortedRoutes;
|
let list = sortedRoutes;
|
||||||
@@ -1618,11 +1661,21 @@ export default function TokenRoutes() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowManual((v) => !v)}
|
onClick={() => {
|
||||||
|
if (showManual) {
|
||||||
|
setShowManual(false);
|
||||||
|
resetRouteForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowManual(true);
|
||||||
|
resetRouteForm();
|
||||||
|
}}
|
||||||
className="btn btn-ghost"
|
className="btn btn-ghost"
|
||||||
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
|
||||||
>
|
>
|
||||||
{showManual ? tr('收起群组创建') : tr('新建群组')}
|
{editingRouteId
|
||||||
|
? tr('取消编辑')
|
||||||
|
: (showManual ? tr('收起群组创建') : tr('新建群组'))}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1681,7 +1734,9 @@ export default function TokenRoutes() {
|
|||||||
{manualPanelPresence.shouldRender && (
|
{manualPanelPresence.shouldRender && (
|
||||||
<div className={`card panel-presence ${manualPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
|
<div className={`card panel-presence ${manualPanelPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||||
{tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
|
{editingRouteId
|
||||||
|
? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。')
|
||||||
|
: tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 10 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 10 }}>
|
||||||
@@ -1783,6 +1838,7 @@ export default function TokenRoutes() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddRoute}
|
onClick={handleAddRoute}
|
||||||
disabled={!canSaveRoute}
|
disabled={!canSaveRoute}
|
||||||
@@ -1798,9 +1854,19 @@ export default function TokenRoutes() {
|
|||||||
保存中...
|
保存中...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
tr('创建群组')
|
tr(editingRouteId ? '保存群组' : '创建群组')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
{editingRouteId ? (
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEditRoute}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ alignSelf: 'flex-start', border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
{tr('取消编辑')}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1985,6 +2051,15 @@ export default function TokenRoutes() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
{!exactRoute ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditRoute(route)}
|
||||||
|
className="btn btn-link"
|
||||||
|
>
|
||||||
|
{tr('编辑群组')}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteRoute(route.id)}
|
onClick={() => handleDeleteRoute(route.id)}
|
||||||
className="btn btn-link btn-link-danger"
|
className="btn btn-link btn-link-danger"
|
||||||
@@ -1992,6 +2067,7 @@ export default function TokenRoutes() {
|
|||||||
{tr('删除路由')}
|
{tr('删除路由')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!exactRoute && (
|
{!exactRoute && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export type RouteListVisibilityItem = {
|
||||||
|
id: number;
|
||||||
|
modelPattern: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasCustomDisplayName(route: Pick<RouteListVisibilityItem, 'modelPattern' | 'displayName'>): boolean {
|
||||||
|
const displayName = (route.displayName || '').trim();
|
||||||
|
const modelPattern = (route.modelPattern || '').trim();
|
||||||
|
return !!displayName && displayName !== modelPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
|
||||||
|
routes: T[],
|
||||||
|
isExactModelPattern: (pattern: string) => boolean,
|
||||||
|
matchesModelPattern: (model: string, pattern: string) => boolean,
|
||||||
|
): T[] {
|
||||||
|
const coveringGroups = routes.filter((route) => (
|
||||||
|
route.enabled
|
||||||
|
&& !isExactModelPattern(route.modelPattern)
|
||||||
|
&& hasCustomDisplayName(route)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (coveringGroups.length === 0) return routes;
|
||||||
|
|
||||||
|
return routes.filter((route) => {
|
||||||
|
if (!isExactModelPattern(route.modelPattern)) return true;
|
||||||
|
if (hasCustomDisplayName(route)) return true;
|
||||||
|
|
||||||
|
const exactModel = (route.modelPattern || '').trim();
|
||||||
|
if (!exactModel) return true;
|
||||||
|
|
||||||
|
return !coveringGroups.some((groupRoute) => (
|
||||||
|
groupRoute.id !== route.id
|
||||||
|
&& matchesModelPattern(exactModel, groupRoute.modelPattern)
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ const { apiMock } = vi.hoisted(() => ({
|
|||||||
getModelTokenCandidates: vi.fn(),
|
getModelTokenCandidates: vi.fn(),
|
||||||
getRouteDecisionsBatch: vi.fn(),
|
getRouteDecisionsBatch: vi.fn(),
|
||||||
getRouteWideDecisionsBatch: vi.fn(),
|
getRouteWideDecisionsBatch: vi.fn(),
|
||||||
|
updateRoute: vi.fn(),
|
||||||
|
addRoute: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -40,6 +42,14 @@ function findButtonByText(root: ReactTestInstance, text: string): ReactTestInsta
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string): ReactTestInstance {
|
||||||
|
return root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& typeof node.props.placeholder === 'string'
|
||||||
|
&& node.props.placeholder.includes(placeholderText)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
async function flushMicrotasks() {
|
async function flushMicrotasks() {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -53,6 +63,8 @@ describe('TokenRoutes grouped source models', () => {
|
|||||||
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||||
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||||
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||||
|
apiMock.updateRoute.mockResolvedValue({});
|
||||||
|
apiMock.addRoute.mockResolvedValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -364,4 +376,208 @@ describe('TokenRoutes grouped source models', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides exact routes covered by a group route from the main route list', async () => {
|
||||||
|
apiMock.getRoutes.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
modelPattern: 'minimax-m2.1',
|
||||||
|
displayName: 'minimax-m2.1',
|
||||||
|
enabled: true,
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
modelPattern: 'minimaxai/minimax-m2.1',
|
||||||
|
displayName: 'minimaxai/minimax-m2.1',
|
||||||
|
enabled: true,
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
modelPattern: 're:^(minimax-m2\\.1|minimaxai/minimax-m2\\.1)$',
|
||||||
|
displayName: 'minimax2.1',
|
||||||
|
enabled: true,
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const normalizedText = collectText(root.root).replace(/\s+/g, '');
|
||||||
|
expect(normalizedText).toContain('共1条路由');
|
||||||
|
expect(normalizedText).not.toContain('共3条路由');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enters edit mode and seeds the group form with the current route values', async () => {
|
||||||
|
apiMock.getRoutes.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 31,
|
||||||
|
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||||
|
displayName: 'claude-4-6-group',
|
||||||
|
displayIcon: 'anthropic',
|
||||||
|
enabled: true,
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const editButton = findButtonByText(root.root, '编辑群组');
|
||||||
|
await act(async () => {
|
||||||
|
editButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(findInputByPlaceholder(root.root, '群组显示名').props.value).toBe('claude-4-6-group');
|
||||||
|
expect(findInputByPlaceholder(root.root, '模型匹配').props.value).toBe('re:^claude-(opus|sonnet)-4-6$');
|
||||||
|
expect(collectText(root.root)).toContain('保存群组');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates route metadata from edit mode and reloads routes afterwards', async () => {
|
||||||
|
apiMock.getRoutes
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 41,
|
||||||
|
modelPattern: 're:^claude-.*$',
|
||||||
|
displayName: 'old-group',
|
||||||
|
displayIcon: '',
|
||||||
|
enabled: true,
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 41,
|
||||||
|
modelPattern: 're:^claude-.*$',
|
||||||
|
displayName: 'new-group',
|
||||||
|
displayIcon: '',
|
||||||
|
enabled: true,
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByText(root.root, '编辑群组').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findInputByPlaceholder(root.root, '群组显示名').props.onChange({ target: { value: 'new-group' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByText(root.root, '保存群组').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateRoute).toHaveBeenCalledWith(41, expect.objectContaining({
|
||||||
|
displayName: 'new-group',
|
||||||
|
modelPattern: 're:^claude-.*$',
|
||||||
|
}));
|
||||||
|
expect(apiMock.getRoutes).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloads route data after saving an edited model pattern', async () => {
|
||||||
|
apiMock.getRoutes
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 51,
|
||||||
|
modelPattern: 're:^claude-.*$',
|
||||||
|
displayName: 'group-a',
|
||||||
|
displayIcon: '',
|
||||||
|
enabled: true,
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 51,
|
||||||
|
modelPattern: 're:^gemini-.*$',
|
||||||
|
displayName: 'group-a',
|
||||||
|
displayIcon: '',
|
||||||
|
enabled: true,
|
||||||
|
channels: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByText(root.root, '编辑群组').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findInputByPlaceholder(root.root, '模型匹配').props.onChange({ target: { value: 're:^gemini-.*$' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
findButtonByText(root.root, '保存群组').props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({
|
||||||
|
modelPattern: 're:^gemini-.*$',
|
||||||
|
}));
|
||||||
|
expect(apiMock.getRoutes).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user