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:
cita
2026-03-07 19:56:40 +08:00
parent 5ad5b87b16
commit ed05b37626
18 changed files with 1476 additions and 153 deletions
+68
View File
@@ -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 drizzlePgProxy } from 'drizzle-orm/pg-proxy';
import * as schema from './schema.js';
import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js';
import { config } from '../config.js';
import { mkdirSync } from 'fs';
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() {
const sqlite = requireSqliteConnection();
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([]);
});
});
+101
View File
@@ -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]);
}
}
}