diff --git a/src/server/config.ts b/src/server/config.ts index 5bef4d1..b005d6d 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -49,6 +49,11 @@ export function buildConfig(env: NodeJS.ProcessEnv) { accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token', checkinCron: env.CHECKIN_CRON || '0 8 * * *', balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *', + logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *', + logCleanupConfigured: false, + logCleanupUsageLogsEnabled: parseBoolean(env.LOG_CLEANUP_USAGE_LOGS_ENABLED, false), + logCleanupProgramLogsEnabled: parseBoolean(env.LOG_CLEANUP_PROGRAM_LOGS_ENABLED, false), + logCleanupRetentionDays: Math.max(1, Math.trunc(parseNumber(env.LOG_CLEANUP_RETENTION_DAYS, 30))), webhookUrl: env.WEBHOOK_URL || '', barkUrl: env.BARK_URL || '', webhookEnabled: parseBoolean(env.WEBHOOK_ENABLED, true), diff --git a/src/server/db/index.ts b/src/server/db/index.ts index 0fb8001..5fead67 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -270,6 +270,17 @@ function ensureSiteUseSystemProxySchema() { `); } +function ensureSiteCustomHeadersSchema() { + const sqlite = requireSqliteConnection(); + if (!tableExists('sites')) { + return; + } + + if (!tableColumnExists('sites', 'custom_headers')) { + sqlite.exec(`ALTER TABLE sites ADD COLUMN custom_headers text;`); + } +} + function ensureSiteExternalCheckinUrlSchema() { const sqlite = requireSqliteConnection(); if (!tableExists('sites')) { @@ -428,11 +439,11 @@ function ensureRouteGroupingSchema() { } if (!tableColumnExists('route_channels', 'consecutive_fail_count')) { - sqlite.exec(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer DEFAULT 0;`); + sqlite.exec(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;`); } if (!tableColumnExists('route_channels', 'cooldown_level')) { - sqlite.exec(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer DEFAULT 0;`); + sqlite.exec(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;`); } } @@ -818,6 +829,7 @@ function initSqliteDb() { ensureSiteStatusSchema(); ensureSiteProxySchema(); ensureSiteUseSystemProxySchema(); + ensureSiteCustomHeadersSchema(); ensureSiteExternalCheckinUrlSchema(); ensureSiteGlobalWeightSchema(); ensureRouteGroupingSchema(); diff --git a/src/server/db/routeGroupingSchemaCompatibility.test.ts b/src/server/db/routeGroupingSchemaCompatibility.test.ts index b3d7166..3c1db60 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.test.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.test.ts @@ -37,6 +37,20 @@ function createInspector( describe('ensureRouteGroupingSchemaCompatibility', () => { it.each([ + { + dialect: 'sqlite' as const, + expectedSql: [ + 'ALTER TABLE token_routes ADD COLUMN display_name text;', + 'ALTER TABLE token_routes ADD COLUMN display_icon text;', + 'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;', + 'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;', + 'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';', + 'ALTER TABLE route_channels ADD COLUMN source_model text;', + 'ALTER TABLE route_channels ADD COLUMN last_selected_at text;', + 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;', + 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;', + ], + }, { dialect: 'postgres' as const, expectedSql: [ @@ -47,8 +61,8 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { 'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'', 'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT', 'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT', - 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER DEFAULT 0', - 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER DEFAULT 0', + 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0', ], }, { diff --git a/src/server/db/routeGroupingSchemaCompatibility.ts b/src/server/db/routeGroupingSchemaCompatibility.ts index d602909..c36183c 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.ts @@ -81,18 +81,18 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit table: 'route_channels', column: 'consecutive_fail_count', addSql: { - sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer DEFAULT 0;', + sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;', mysql: 'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0', - postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER DEFAULT 0', + postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0', }, }, { table: 'route_channels', column: 'cooldown_level', addSql: { - sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer DEFAULT 0;', + sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;', mysql: 'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0', - postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER DEFAULT 0', + postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0', }, }, ]; diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts index 8910e3f..627d4b7 100644 --- a/src/server/db/runtimeSchemaBootstrap.ts +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -186,14 +186,14 @@ export async function createRuntimeSchemaClient(input: RuntimeSchemaConnectionIn export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): Promise { 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, "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 "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, "custom_headers" 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 "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 "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 "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER DEFAULT 0, "cooldown_level" INTEGER DEFAULT 0, "cooldown_until" TEXT)`, + `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`, @@ -203,14 +203,14 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): ] : 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, \`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 \`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, \`custom_headers\` 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 \`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 \`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 \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`routing_strategy\` VARCHAR(32) NULL DEFAULT 'weighted', \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, - `CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT DEFAULT 0, \`cooldown_level\` INT DEFAULT 0, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`, + `CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT NOT NULL DEFAULT 0, \`cooldown_level\` INT NOT NULL DEFAULT 0, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`, `CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`, `CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`, @@ -219,14 +219,14 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): `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, "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 "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, "custom_headers" 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 "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 "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 "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`, - `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER DEFAULT 0, "cooldown_level" INTEGER DEFAULT 0, "cooldown_until" TEXT)`, + `CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`, `CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`, diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 16cdeb0..c0e882f 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -9,6 +9,7 @@ export const sites = sqliteTable('sites', { platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' proxyUrl: text('proxy_url'), useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false), + customHeaders: text('custom_headers'), status: text('status').notNull().default('active'), // 'active' | 'disabled' isPinned: integer('is_pinned', { mode: 'boolean' }).default(false), sortOrder: integer('sort_order').default(0), @@ -137,8 +138,8 @@ export const routeChannels = sqliteTable('route_channels', { lastUsedAt: text('last_used_at'), lastSelectedAt: text('last_selected_at'), lastFailAt: text('last_fail_at'), - consecutiveFailCount: integer('consecutive_fail_count').default(0), - cooldownLevel: integer('cooldown_level').default(0), + consecutiveFailCount: integer('consecutive_fail_count').notNull().default(0), + cooldownLevel: integer('cooldown_level').notNull().default(0), cooldownUntil: text('cooldown_until'), }, (table) => ({ routeIdIdx: index('route_channels_route_id_idx').on(table.routeId), diff --git a/src/server/db/siteSchemaCompatibility.test.ts b/src/server/db/siteSchemaCompatibility.test.ts index 7a9a997..6b876f7 100644 --- a/src/server/db/siteSchemaCompatibility.test.ts +++ b/src/server/db/siteSchemaCompatibility.test.ts @@ -36,6 +36,7 @@ describe('ensureSiteSchemaCompatibility', () => { '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 "custom_headers" TEXT', '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', @@ -47,6 +48,7 @@ describe('ensureSiteSchemaCompatibility', () => { '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 `custom_headers` TEXT 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', diff --git a/src/server/db/siteSchemaCompatibility.ts b/src/server/db/siteSchemaCompatibility.ts index f90e61b..69293ce 100644 --- a/src/server/db/siteSchemaCompatibility.ts +++ b/src/server/db/siteSchemaCompatibility.ts @@ -35,6 +35,14 @@ const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [ postgres: 'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL', }, }, + { + column: 'custom_headers', + addSql: { + sqlite: 'ALTER TABLE sites ADD COLUMN custom_headers text;', + mysql: 'ALTER TABLE `sites` ADD COLUMN `custom_headers` TEXT NULL', + postgres: 'ALTER TABLE "sites" ADD COLUMN "custom_headers" TEXT', + }, + }, { column: 'external_checkin_url', addSql: { diff --git a/src/server/index.ts b/src/server/index.ts index 0c080ab..05be356 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -19,7 +19,7 @@ import { monitorRoutes } from './routes/api/monitor.js'; import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; -import { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; +import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; @@ -29,6 +29,7 @@ import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, normalize, resolve, sep } from 'path'; +import { normalizeLogCleanupRetentionDays } from './services/logCleanupService.js'; import { db, ensureProxyFileCompatibilityColumns, @@ -104,6 +105,17 @@ function extractSavedRuntimeDatabaseConfig(settingsMap: Map): { }; } +const LOG_CLEANUP_SETTING_KEYS = [ + 'log_cleanup_cron', + 'log_cleanup_usage_logs_enabled', + 'log_cleanup_program_logs_enabled', + 'log_cleanup_retention_days', +] as const; + +function hasExplicitLogCleanupSettings(settingsMap: Map): boolean { + return LOG_CLEANUP_SETTING_KEYS.some((key) => settingsMap.has(key)); +} + function applyRuntimeSettings(settingsMap: Map) { const authToken = parseSettingFromMap(settingsMap, 'auth_token'); if (typeof authToken === 'string' && authToken) config.authToken = authToken; @@ -120,6 +132,24 @@ function applyRuntimeSettings(settingsMap: Map) { const balanceRefreshCron = parseSettingFromMap(settingsMap, 'balance_refresh_cron'); if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron; + const logCleanupCron = parseSettingFromMap(settingsMap, 'log_cleanup_cron'); + if (typeof logCleanupCron === 'string' && logCleanupCron) config.logCleanupCron = logCleanupCron; + + const logCleanupUsageLogsEnabled = parseSettingFromMap(settingsMap, 'log_cleanup_usage_logs_enabled'); + if (typeof logCleanupUsageLogsEnabled === 'boolean') { + config.logCleanupUsageLogsEnabled = logCleanupUsageLogsEnabled; + } + + const logCleanupProgramLogsEnabled = parseSettingFromMap(settingsMap, 'log_cleanup_program_logs_enabled'); + if (typeof logCleanupProgramLogsEnabled === 'boolean') { + config.logCleanupProgramLogsEnabled = logCleanupProgramLogsEnabled; + } + + const logCleanupRetentionDays = parseSettingFromMap(settingsMap, 'log_cleanup_retention_days'); + if (typeof logCleanupRetentionDays === 'number' && Number.isFinite(logCleanupRetentionDays) && logCleanupRetentionDays >= 1) { + config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(logCleanupRetentionDays); + } + const routingWeights = parseSettingFromMap>(settingsMap, 'routing_weights'); if (routingWeights && typeof routingWeights === 'object') { config.routingWeights = { @@ -232,6 +262,12 @@ try { const finalRows = await db.select().from(schema.settings).all(); const finalMap = toSettingsMap(finalRows); applyRuntimeSettings(finalMap); + config.logCleanupConfigured = hasExplicitLogCleanupSettings(finalMap); + if (!config.logCleanupConfigured && config.proxyLogRetentionDays > 0) { + config.logCleanupUsageLogsEnabled = true; + config.logCleanupProgramLogsEnabled = false; + config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(config.proxyLogRetentionDays); + } await ensureProxyLogBillingDetailsColumn(); await repairStoredCreatedAtValues(); await migrateSiteApiKeysToAccounts(); @@ -302,7 +338,7 @@ if (existsSync(webDir)) { // Start scheduler await startScheduler(); -startProxyLogRetentionService(); +setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured); app.addHook('onClose', async () => { stopProxyLogRetentionService(); }); diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index 2f21334..a7a0b16 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -43,6 +43,11 @@ describe('settings and auth events', () => { config.systemProxyUrl = ''; config.checkinCron = '0 8 * * *'; config.balanceRefreshCron = '0 * * * *'; + config.logCleanupConfigured = false; + config.logCleanupCron = '0 6 * * *'; + config.logCleanupUsageLogsEnabled = false; + config.logCleanupProgramLogsEnabled = false; + config.logCleanupRetentionDays = 30; config.routingFallbackUnitCost = 1; }); @@ -219,6 +224,80 @@ describe('settings and auth events', () => { expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890'); }); + it('persists and returns log cleanup settings from runtime settings', async () => { + const updateResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + logCleanupCron: '15 4 * * *', + logCleanupUsageLogsEnabled: true, + logCleanupProgramLogsEnabled: true, + logCleanupRetentionDays: 14, + }, + }); + + expect(updateResponse.statusCode).toBe(200); + const updated = updateResponse.json() as { + logCleanupCron?: string; + logCleanupUsageLogsEnabled?: boolean; + logCleanupProgramLogsEnabled?: boolean; + logCleanupRetentionDays?: number; + }; + expect(updated.logCleanupCron).toBe('15 4 * * *'); + expect(updated.logCleanupUsageLogsEnabled).toBe(true); + expect(updated.logCleanupProgramLogsEnabled).toBe(true); + expect(updated.logCleanupRetentionDays).toBe(14); + expect(config.logCleanupCron).toBe('15 4 * * *'); + expect(config.logCleanupUsageLogsEnabled).toBe(true); + expect(config.logCleanupProgramLogsEnabled).toBe(true); + expect(config.logCleanupRetentionDays).toBe(14); + + const rows = await db.select().from(schema.settings).all(); + const settingsMap = new Map(rows.map((row) => [row.key, row.value])); + expect(settingsMap.get('log_cleanup_cron')).toBe(JSON.stringify('15 4 * * *')); + expect(settingsMap.get('log_cleanup_usage_logs_enabled')).toBe(JSON.stringify(true)); + expect(settingsMap.get('log_cleanup_program_logs_enabled')).toBe(JSON.stringify(true)); + expect(settingsMap.get('log_cleanup_retention_days')).toBe(JSON.stringify(14)); + + const getResponse = await app.inject({ + method: 'GET', + url: '/api/settings/runtime', + }); + expect(getResponse.statusCode).toBe(200); + const runtime = getResponse.json() as { + logCleanupCron?: string; + logCleanupUsageLogsEnabled?: boolean; + logCleanupProgramLogsEnabled?: boolean; + logCleanupRetentionDays?: number; + }; + expect(runtime.logCleanupCron).toBe('15 4 * * *'); + expect(runtime.logCleanupUsageLogsEnabled).toBe(true); + expect(runtime.logCleanupProgramLogsEnabled).toBe(true); + expect(runtime.logCleanupRetentionDays).toBe(14); + }); + + it('rejects invalid log cleanup cron and retention days', async () => { + const invalidCronResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + logCleanupCron: 'invalid cron', + }, + }); + expect(invalidCronResponse.statusCode).toBe(400); + expect((invalidCronResponse.json() as { message?: string }).message).toContain('日志清理 Cron'); + + const invalidRetentionResponse = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + logCleanupRetentionDays: 0, + }, + }); + expect(invalidRetentionResponse.statusCode).toBe(400); + expect((invalidRetentionResponse.json() as { message?: string }).message).toContain('保留天数'); + }); + it('invalidates cached site proxy resolution when system proxy url changes', async () => { await db.insert(schema.sites).values({ name: 'proxy-site', diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index cc79f7f..44a8578 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -4,7 +4,7 @@ import { config } from '../../config.js'; import { db, runtimeDbDialect, schema } from '../../db/index.js'; import { upsertSetting } from '../../db/upsertSetting.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; -import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/checkinScheduler.js'; +import { updateBalanceRefreshCron, updateCheckinCron, updateLogCleanupSettings } from '../../services/checkinScheduler.js'; import { sendNotification } from '../../services/notifyService.js'; import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; @@ -19,6 +19,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { extractClientIp, isIpAllowed } from '../../middleware/auth.js'; import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js'; import { performFactoryReset } from '../../services/factoryResetService.js'; +import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js'; +import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js'; type RoutingWeights = typeof config.routingWeights; @@ -27,6 +29,10 @@ interface RuntimeSettingsBody { systemProxyUrl?: string; checkinCron?: string; balanceRefreshCron?: string; + logCleanupCron?: string; + logCleanupUsageLogsEnabled?: boolean; + logCleanupProgramLogsEnabled?: boolean; + logCleanupRetentionDays?: number; webhookUrl?: string; barkUrl?: string; webhookEnabled?: boolean; @@ -145,6 +151,35 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { updateBalanceRefreshCron(value); return; } + case 'log_cleanup_cron': { + if (typeof value !== 'string' || !value || !cron.validate(value)) return; + config.logCleanupConfigured = true; + updateLogCleanupSettings({ cronExpr: value }); + stopProxyLogRetentionService(); + return; + } + case 'log_cleanup_usage_logs_enabled': { + if (typeof value !== 'boolean') return; + config.logCleanupConfigured = true; + updateLogCleanupSettings({ usageLogsEnabled: value }); + stopProxyLogRetentionService(); + return; + } + case 'log_cleanup_program_logs_enabled': { + if (typeof value !== 'boolean') return; + config.logCleanupConfigured = true; + updateLogCleanupSettings({ programLogsEnabled: value }); + stopProxyLogRetentionService(); + return; + } + case 'log_cleanup_retention_days': { + const retentionDays = Number(value); + if (!Number.isFinite(retentionDays) || retentionDays < 1) return; + config.logCleanupConfigured = true; + updateLogCleanupSettings({ retentionDays: Math.trunc(retentionDays) }); + stopProxyLogRetentionService(); + return; + } case 'proxy_token': { if (typeof value !== 'string') return; const nextToken = value.trim(); @@ -274,6 +309,10 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { return { checkinCron: config.checkinCron, balanceRefreshCron: config.balanceRefreshCron, + logCleanupCron: config.logCleanupCron, + logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled, + logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled, + logCleanupRetentionDays: config.logCleanupRetentionDays, routingFallbackUnitCost: config.routingFallbackUnitCost, routingWeights: config.routingWeights, webhookUrl: config.webhookUrl, @@ -458,6 +497,61 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('balance_refresh_cron', body.balanceRefreshCron); } + const logCleanupTouched = + body.logCleanupCron !== undefined + || body.logCleanupUsageLogsEnabled !== undefined + || body.logCleanupProgramLogsEnabled !== undefined + || body.logCleanupRetentionDays !== undefined; + + if (logCleanupTouched) { + const nextLogCleanupCron = body.logCleanupCron !== undefined + ? String(body.logCleanupCron || '').trim() + : config.logCleanupCron; + if (!cron.validate(nextLogCleanupCron)) { + return reply.code(400).send({ success: false, message: '日志清理 Cron 表达式无效' }); + } + + const rawRetentionDays = body.logCleanupRetentionDays !== undefined + ? Number(body.logCleanupRetentionDays) + : config.logCleanupRetentionDays; + if (!Number.isFinite(rawRetentionDays) || rawRetentionDays < 1) { + return reply.code(400).send({ success: false, message: '日志清理保留天数必须是大于等于 1 的整数' }); + } + const nextLogCleanupRetentionDays = normalizeLogCleanupRetentionDays(rawRetentionDays); + const nextUsageLogsEnabled = body.logCleanupUsageLogsEnabled !== undefined + ? !!body.logCleanupUsageLogsEnabled + : config.logCleanupUsageLogsEnabled; + const nextProgramLogsEnabled = body.logCleanupProgramLogsEnabled !== undefined + ? !!body.logCleanupProgramLogsEnabled + : config.logCleanupProgramLogsEnabled; + + if (nextLogCleanupCron !== config.logCleanupCron) { + changedLabels.push(`日志清理 Cron(${config.logCleanupCron} -> ${nextLogCleanupCron})`); + } + if (nextUsageLogsEnabled !== config.logCleanupUsageLogsEnabled) { + changedLabels.push(`自动清理使用日志(${config.logCleanupUsageLogsEnabled ? '开启' : '关闭'} -> ${nextUsageLogsEnabled ? '开启' : '关闭'})`); + } + if (nextProgramLogsEnabled !== config.logCleanupProgramLogsEnabled) { + changedLabels.push(`自动清理程序日志(${config.logCleanupProgramLogsEnabled ? '开启' : '关闭'} -> ${nextProgramLogsEnabled ? '开启' : '关闭'})`); + } + if (nextLogCleanupRetentionDays !== config.logCleanupRetentionDays) { + changedLabels.push(`日志清理保留天数(${config.logCleanupRetentionDays} -> ${nextLogCleanupRetentionDays})`); + } + + config.logCleanupConfigured = true; + updateLogCleanupSettings({ + cronExpr: nextLogCleanupCron, + usageLogsEnabled: nextUsageLogsEnabled, + programLogsEnabled: nextProgramLogsEnabled, + retentionDays: nextLogCleanupRetentionDays, + }); + stopProxyLogRetentionService(); + upsertSetting('log_cleanup_cron', nextLogCleanupCron); + upsertSetting('log_cleanup_usage_logs_enabled', nextUsageLogsEnabled); + upsertSetting('log_cleanup_program_logs_enabled', nextProgramLogsEnabled); + upsertSetting('log_cleanup_retention_days', nextLogCleanupRetentionDays); + } + if (body.proxyToken !== undefined) { const proxyToken = String(body.proxyToken).trim(); if (!proxyToken.startsWith(PROXY_TOKEN_PREFIX)) { diff --git a/src/server/routes/api/sites.proxyUrl.test.ts b/src/server/routes/api/sites.proxyUrl.test.ts index e0e2c54..428544c 100644 --- a/src/server/routes/api/sites.proxyUrl.test.ts +++ b/src/server/routes/api/sites.proxyUrl.test.ts @@ -36,7 +36,7 @@ describe('sites system proxy settings', () => { delete process.env.DATA_DIR; }); - it('stores useSystemProxy and external checkin url when creating a site', async () => { + it('stores useSystemProxy, external checkin url, and custom headers when creating a site', async () => { const response = await app.inject({ method: 'POST', url: '/api/sites', @@ -45,6 +45,10 @@ describe('sites system proxy settings', () => { url: 'https://proxy-site.example.com', platform: 'new-api', useSystemProxy: true, + customHeaders: JSON.stringify({ + 'cf-access-client-id': 'site-client-id', + 'x-site-scope': 'internal', + }), externalCheckinUrl: 'https://checkin.example.com/welfare', globalWeight: 1.5, }, @@ -53,10 +57,12 @@ describe('sites system proxy settings', () => { expect(response.statusCode).toBe(200); const payload = response.json() as { useSystemProxy?: boolean; + customHeaders?: string | null; externalCheckinUrl?: string | null; globalWeight?: number; }; expect(payload.useSystemProxy).toBe(true); + expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}'); expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare'); expect(payload.globalWeight).toBe(1.5); }); @@ -134,4 +140,38 @@ describe('sites system proxy settings', () => { expect(response.statusCode).toBe(200); expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true); }); + + it('rejects invalid custom headers json', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'headers-site', + url: 'https://headers-site.example.com', + platform: 'new-api', + customHeaders: '{invalid-json}', + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { error?: string }).error).toContain('Invalid customHeaders'); + }); + + it('rejects custom headers with non-string values', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'headers-site', + url: 'https://headers-site.example.com', + platform: 'new-api', + customHeaders: JSON.stringify({ + 'x-site-scope': true, + }), + }, + }); + + expect(response.statusCode).toBe(400); + expect((response.json() as { error?: string }).error).toContain('must use a string value'); + }); }); diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index bf75276..a08cbf9 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -5,6 +5,7 @@ import { detectSite } from '../../services/siteDetector.js'; import { invalidateSiteProxyCache } from '../../services/siteProxy.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { invalidateTokenRouterCache } from '../../services/tokenRouter.js'; +import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js'; function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null { if (input === undefined || input === null) return null; @@ -160,13 +161,14 @@ export async function sitesRoutes(app: FastifyInstance) { url: string; platform?: string; useSystemProxy?: boolean; + customHeaders?: string | null; externalCheckinUrl?: string | null; status?: string; isPinned?: boolean; sortOrder?: number; globalWeight?: number; } }>('/api/sites', async (request, reply) => { - const { name, url, platform, useSystemProxy, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body; + const { name, url, platform, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body; const normalizedStatus = normalizeSiteStatus(status); if (status !== undefined && !normalizedStatus) { return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); @@ -191,6 +193,10 @@ export async function sitesRoutes(app: FastifyInstance) { if (globalWeight !== undefined && normalizedGlobalWeight === null) { return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' }); } + const normalizedCustomHeaders = parseSiteCustomHeadersInput(customHeaders); + if (!normalizedCustomHeaders.valid) { + return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' }); + } const existingSites = await db.select().from(schema.sites).all(); const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1); @@ -208,6 +214,7 @@ export async function sitesRoutes(app: FastifyInstance) { url: url.replace(/\/+$/, ''), platform: detectedPlatform, useSystemProxy: normalizedUseSystemProxy ?? false, + customHeaders: normalizedCustomHeaders.customHeaders, externalCheckinUrl: normalizedExternalCheckinUrl.url, status: normalizedStatus ?? 'active', isPinned: normalizedPinned ?? false, @@ -232,6 +239,7 @@ export async function sitesRoutes(app: FastifyInstance) { url?: string; platform?: string; useSystemProxy?: boolean; + customHeaders?: string | null; externalCheckinUrl?: string | null; status?: string; isPinned?: boolean; @@ -274,11 +282,16 @@ export async function sitesRoutes(app: FastifyInstance) { if (body.globalWeight !== undefined && normalizedGlobalWeight === null) { return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' }); } + const normalizedCustomHeaders = parseSiteCustomHeadersInput(body.customHeaders); + if (!normalizedCustomHeaders.valid) { + return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' }); + } if (body.name !== undefined) updates.name = body.name; if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, ''); if (body.platform !== undefined) updates.platform = body.platform; if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy; + if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders; if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url; if (body.status !== undefined) updates.status = normalizedStatus; if (body.isPinned !== undefined) updates.isPinned = normalizedPinned; diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index ed0f250..97115f2 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -1,5 +1,5 @@ import { fetch } from 'undici'; -import { withExplicitProxyRequestInit } from '../../services/siteProxy.js'; +import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; import { summarizeUpstreamError } from './upstreamError.js'; import type { UpstreamEndpoint } from './upstreamEndpoint.js'; @@ -68,7 +68,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom const request = input.buildRequest(endpoint, endpointIndex); const targetUrl = `${input.siteUrl}${request.path}`; - let response = await fetch(targetUrl, withExplicitProxyRequestInit(input.proxyUrl, { + let response = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { method: 'POST', headers: request.headers, body: JSON.stringify(request.body), diff --git a/src/server/routes/proxy/videos.ts b/src/server/routes/proxy/videos.ts index 95feaa7..00ada07 100644 --- a/src/server/routes/proxy/videos.ts +++ b/src/server/routes/proxy/videos.ts @@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js'; import { estimateProxyCost } from '../../services/modelPricingService.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; -import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; +import { withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; import { deleteProxyVideoTaskByPublicId, @@ -180,12 +180,13 @@ export async function videosProxyRoute(app: FastifyInstance) { }); } - const upstream = await fetch(`${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`, { + const targetUrl = `${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`; + const upstream = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { method: 'GET', headers: { Authorization: `Bearer ${mapping.tokenValue}`, }, - }); + })); const text = await upstream.text(); try { const data = JSON.parse(text); @@ -210,12 +211,13 @@ export async function videosProxyRoute(app: FastifyInstance) { }); } - const upstream = await fetch(`${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`, { + const targetUrl = `${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`; + const upstream = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { method: 'DELETE', headers: { Authorization: `Bearer ${mapping.tokenValue}`, }, - }); + })); if (upstream.ok) { await deleteProxyVideoTaskByPublicId(mapping.publicId); return reply.code(upstream.status).send(); diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index 2a1de05..fc0f07f 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -49,7 +49,12 @@ describe('backupService', () => { name: 'roundtrip-site', url: 'https://roundtrip.example.com', platform: 'new-api', + externalCheckinUrl: 'https://checkin.roundtrip.example.com', proxyUrl: 'http://127.0.0.1:8080', + useSystemProxy: true, + customHeaders: JSON.stringify({ + 'cf-access-client-id': 'roundtrip-client', + }), status: 'active', isPinned: true, sortOrder: 9, @@ -128,6 +133,9 @@ describe('backupService', () => { const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).get(); expect(restoredSite?.proxyUrl).toBe('http://127.0.0.1:8080'); + expect(restoredSite?.externalCheckinUrl).toBe('https://checkin.roundtrip.example.com'); + expect(restoredSite?.useSystemProxy).toBe(true); + expect(restoredSite?.customHeaders).toBe('{"cf-access-client-id":"roundtrip-client"}'); expect(restoredSite?.isPinned).toBe(true); expect(restoredSite?.sortOrder).toBe(9); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index 1976f05..af7e538 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -164,6 +164,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS platform, proxyUrl: null, useSystemProxy: false, + customHeaders: null, status: 'active', isPinned: false, sortOrder: sites.length, @@ -293,10 +294,18 @@ function isFiniteNumber(value: unknown): value is number { } function isSettingValueAcceptable(key: string, value: unknown): boolean { - if (key === 'checkin_cron' || key === 'balance_refresh_cron') { + if (key === 'checkin_cron' || key === 'balance_refresh_cron' || key === 'log_cleanup_cron') { return typeof value === 'string' && cron.validate(value); } + if (key === 'log_cleanup_usage_logs_enabled' || key === 'log_cleanup_program_logs_enabled') { + return typeof value === 'boolean'; + } + + if (key === 'log_cleanup_retention_days') { + return isFiniteNumber(value) && value >= 1; + } + if (key === 'proxy_token') { return typeof value === 'string' && value.trim().length >= 6 @@ -465,8 +474,11 @@ async function importAccountsSection(section: AccountsBackupSection): Promise { const baseUrl = params.baseUrl.trim(); const accessToken = params.accessToken.trim(); @@ -272,7 +271,8 @@ async function fetchTodayIncomeFromLogs(params: { }); try { - const response = await fetch(`${baseUrl}/api/log/self?${query.toString()}`, withExplicitProxyRequestInit(params.proxyUrl, { + const requestUrl = `${baseUrl}/api/log/self?${query.toString()}`; + const response = await fetch(requestUrl, await withSiteProxyRequestInit(requestUrl, { method: 'GET', headers, })); @@ -466,7 +466,6 @@ export async function refreshBalance(accountId: number) { accessToken: activeAccessToken, platform: site.platform, platformUserId, - proxyUrl: resolveProxyUrlForSite(site), }); if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) { balanceInfo.todayIncome = fallbackIncome; diff --git a/src/server/services/checkinScheduler.ts b/src/server/services/checkinScheduler.ts index 7c8e98f..3df0e7d 100644 --- a/src/server/services/checkinScheduler.ts +++ b/src/server/services/checkinScheduler.ts @@ -7,19 +7,26 @@ import { checkinAll } from './checkinService.js'; import { refreshModelsAndRebuildRoutes } from './modelService.js'; import { sendNotification } from './notifyService.js'; import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js'; +import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js'; let checkinTask: cron.ScheduledTask | null = null; let balanceTask: cron.ScheduledTask | null = null; let dailySummaryTask: cron.ScheduledTask | null = null; +let logCleanupTask: cron.ScheduledTask | null = null; const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *'; +const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *'; -async function resolveCronSetting(settingKey: string, fallback: string): Promise { +async function resolveJsonSetting( + settingKey: string, + isValid: (value: unknown) => value is T, + fallback: T, +): Promise { try { const row = await db.select().from(schema.settings).where(eq(schema.settings.key, settingKey)).get(); if (row?.value) { const parsed = JSON.parse(row.value); - if (typeof parsed === 'string' && cron.validate(parsed)) { + if (isValid(parsed)) { return parsed; } } @@ -27,6 +34,22 @@ async function resolveCronSetting(settingKey: string, fallback: string): Promise return fallback; } +async function resolveCronSetting(settingKey: string, fallback: string): Promise { + return resolveJsonSetting(settingKey, (value): value is string => typeof value === 'string' && cron.validate(value), fallback); +} + +async function resolveBooleanSetting(settingKey: string, fallback: boolean): Promise { + return resolveJsonSetting(settingKey, (value): value is boolean => typeof value === 'boolean', fallback); +} + +async function resolvePositiveIntegerSetting(settingKey: string, fallback: number): Promise { + return resolveJsonSetting( + settingKey, + (value): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 1, + fallback, + ); +} + function createCheckinTask(cronExpr: string) { return cron.schedule(cronExpr, async () => { console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`); @@ -72,20 +95,67 @@ function createDailySummaryTask(cronExpr: string) { }); } +function createLogCleanupTask(cronExpr: string) { + return cron.schedule(cronExpr, async () => { + if (!config.logCleanupConfigured) { + console.log('[Scheduler] Log cleanup skipped: legacy fallback mode is active'); + return; + } + console.log(`[Scheduler] Running log cleanup at ${new Date().toISOString()}`); + try { + const result = await cleanupConfiguredLogs(); + if (!result.enabled) { + console.log('[Scheduler] Log cleanup skipped: no log target enabled'); + return; + } + console.log( + `[Scheduler] Log cleanup complete: usage=${result.usageLogsDeleted}, program=${result.programLogsDeleted}, cutoff=${result.cutoffUtc}`, + ); + } catch (err) { + console.error('[Scheduler] Log cleanup error:', err); + } + }); +} + export async function startScheduler() { const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron); const activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron); const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON); + const activeLogCleanupCron = await resolveCronSetting('log_cleanup_cron', config.logCleanupCron || LOG_CLEANUP_DEFAULT_CRON); + const activeLogCleanupUsageLogsEnabled = await resolveBooleanSetting( + 'log_cleanup_usage_logs_enabled', + config.logCleanupUsageLogsEnabled, + ); + const activeLogCleanupProgramLogsEnabled = await resolveBooleanSetting( + 'log_cleanup_program_logs_enabled', + config.logCleanupProgramLogsEnabled, + ); + const activeLogCleanupRetentionDays = await resolvePositiveIntegerSetting( + 'log_cleanup_retention_days', + normalizeLogCleanupRetentionDays(config.logCleanupRetentionDays), + ); config.checkinCron = activeCheckinCron; config.balanceRefreshCron = activeBalanceCron; + config.logCleanupCron = activeLogCleanupCron; + config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled; + config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled; + config.logCleanupRetentionDays = activeLogCleanupRetentionDays; + checkinTask?.stop(); + balanceTask?.stop(); + dailySummaryTask?.stop(); + logCleanupTask?.stop(); checkinTask = createCheckinTask(activeCheckinCron); balanceTask = createBalanceTask(activeBalanceCron); dailySummaryTask = createDailySummaryTask(activeDailySummaryCron); + logCleanupTask = createLogCleanupTask(activeLogCleanupCron); console.log(`[Scheduler] Check-in cron: ${activeCheckinCron}`); console.log(`[Scheduler] Balance refresh cron: ${activeBalanceCron}`); console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`); + console.log( + `[Scheduler] Log cleanup cron: ${activeLogCleanupCron} (configured=${config.logCleanupConfigured}, usage=${activeLogCleanupUsageLogsEnabled}, program=${activeLogCleanupProgramLogsEnabled}, retentionDays=${activeLogCleanupRetentionDays})`, + ); } export function updateCheckinCron(cronExpr: string) { @@ -101,3 +171,23 @@ export function updateBalanceRefreshCron(cronExpr: string) { balanceTask?.stop(); balanceTask = createBalanceTask(cronExpr); } + +export function updateLogCleanupSettings(input: { + cronExpr?: string; + usageLogsEnabled?: boolean; + programLogsEnabled?: boolean; + retentionDays?: number; +}) { + const cronExpr = input.cronExpr ?? config.logCleanupCron; + if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`); + + const retentionDays = normalizeLogCleanupRetentionDays(input.retentionDays ?? config.logCleanupRetentionDays); + + config.logCleanupCron = cronExpr; + if (input.usageLogsEnabled !== undefined) config.logCleanupUsageLogsEnabled = !!input.usageLogsEnabled; + if (input.programLogsEnabled !== undefined) config.logCleanupProgramLogsEnabled = !!input.programLogsEnabled; + config.logCleanupRetentionDays = retentionDays; + + logCleanupTask?.stop(); + logCleanupTask = createLogCleanupTask(cronExpr); +} diff --git a/src/server/services/databaseMigrationService.test.ts b/src/server/services/databaseMigrationService.test.ts index e7afb0b..9ae81fa 100644 --- a/src/server/services/databaseMigrationService.test.ts +++ b/src/server/services/databaseMigrationService.test.ts @@ -101,7 +101,7 @@ describe('databaseMigrationService', () => { 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) => { + it.each(['postgres', 'mysql', 'sqlite'] as const)('creates or patches sites schema with use_system_proxy and custom_headers for %s', async (dialect) => { const executedSql: string[] = []; await __databaseMigrationServiceTestUtils.ensureSchema({ dialect, @@ -118,7 +118,7 @@ describe('databaseMigrationService', () => { } 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 columnName === 'use_system_proxy' || columnName === 'custom_headers' ? 0 : 1; } return 0; }, @@ -126,8 +126,10 @@ describe('databaseMigrationService', () => { }); const useSystemProxySql = executedSql.find((sqlText) => sqlText.includes('use_system_proxy')); + const customHeadersSql = executedSql.find((sqlText) => sqlText.includes('custom_headers')); expect(useSystemProxySql).toContain('use_system_proxy'); + expect(customHeadersSql).toContain('custom_headers'); }); it.each(['postgres', 'mysql'] as const)('patches token_routes decision snapshot columns for %s', async (dialect) => { @@ -159,7 +161,7 @@ describe('databaseMigrationService', () => { ).toBe(true); }); - it('includes useSystemProxy when building site migration statements', () => { + it('includes useSystemProxy and customHeaders when building site migration statements', () => { const statements = __databaseMigrationServiceTestUtils.buildStatements({ version: 'test', timestamp: Date.now(), @@ -170,6 +172,7 @@ describe('databaseMigrationService', () => { url: 'https://example.com', platform: 'openai', useSystemProxy: true, + customHeaders: '{"x-site-scope":"internal"}', status: 'active', }], accounts: [], @@ -190,8 +193,11 @@ describe('databaseMigrationService', () => { const siteStatement = statements.find((statement) => statement.table === 'sites'); const useSystemProxyIndex = siteStatement?.columns.indexOf('use_system_proxy') ?? -1; + const customHeadersIndex = siteStatement?.columns.indexOf('custom_headers') ?? -1; expect(useSystemProxyIndex).toBeGreaterThanOrEqual(0); expect(siteStatement?.values[useSystemProxyIndex]).toBe(true); + expect(customHeadersIndex).toBeGreaterThanOrEqual(0); + expect(siteStatement?.values[customHeadersIndex]).toBe('{"x-site-scope":"internal"}'); }); }); diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index d0019e5..3939c57 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -259,7 +259,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.sites) { statements.push({ table: 'sites', - 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'], + columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'use_system_proxy', 'custom_headers', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'], values: [ asNumber(row.id, 0), asNullableString(row.name), @@ -268,6 +268,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { asNullableString(row.platform), asNullableString(row.proxyUrl), asBoolean(row.useSystemProxy, false), + asNullableString(row.customHeaders), asNullableString(row.status) ?? 'active', asBoolean(row.isPinned, false), asNumber(row.sortOrder, 0), diff --git a/src/server/services/factoryResetService.ts b/src/server/services/factoryResetService.ts index d7094b8..2061a7c 100644 --- a/src/server/services/factoryResetService.ts +++ b/src/server/services/factoryResetService.ts @@ -1,7 +1,8 @@ import { buildConfig, config } from '../config.js'; import { db, schema, switchRuntimeDatabase } from '../db/index.js'; -import { updateBalanceRefreshCron, updateCheckinCron } from './checkinScheduler.js'; +import { updateBalanceRefreshCron, updateCheckinCron, updateLogCleanupSettings } from './checkinScheduler.js'; import { ensureDefaultSitesSeeded } from './defaultSiteSeedService.js'; +import { startProxyLogRetentionService } from './proxyLogRetentionService.js'; import { invalidateSiteProxyCache } from './siteProxy.js'; export const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token'; @@ -38,8 +39,19 @@ function resetRuntimeConfigToInitialState() { config.dbType = 'sqlite'; config.dbUrl = ''; config.dbSsl = false; + config.logCleanupConfigured = false; + config.logCleanupUsageLogsEnabled = config.proxyLogRetentionDays > 0; + config.logCleanupProgramLogsEnabled = false; + config.logCleanupRetentionDays = Math.max(1, Math.trunc(config.proxyLogRetentionDays || config.logCleanupRetentionDays || 30)); updateCheckinCron(config.checkinCron); updateBalanceRefreshCron(config.balanceRefreshCron); + updateLogCleanupSettings({ + cronExpr: config.logCleanupCron, + usageLogsEnabled: config.logCleanupUsageLogsEnabled, + programLogsEnabled: config.logCleanupProgramLogsEnabled, + retentionDays: config.logCleanupRetentionDays, + }); + startProxyLogRetentionService(); invalidateSiteProxyCache(); } diff --git a/src/server/services/logCleanupService.test.ts b/src/server/services/logCleanupService.test.ts new file mode 100644 index 0000000..ac032e4 --- /dev/null +++ b/src/server/services/logCleanupService.test.ts @@ -0,0 +1,156 @@ +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 ConfigModule = typeof import('../config.js'); +type CleanupModule = typeof import('./logCleanupService.js'); + +describe('logCleanupService', () => { + let db: DbModule['db']; + let schema: DbModule['schema']; + let config: ConfigModule['config']; + let cleanupConfiguredLogs: CleanupModule['cleanupConfiguredLogs']; + let dataDir = ''; + let originalConfig: { + logCleanupUsageLogsEnabled: boolean; + logCleanupProgramLogsEnabled: boolean; + logCleanupRetentionDays: number; + }; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-log-cleanup-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const configModule = await import('../config.js'); + const cleanupModule = await import('./logCleanupService.js'); + db = dbModule.db; + schema = dbModule.schema; + config = configModule.config; + cleanupConfiguredLogs = cleanupModule.cleanupConfiguredLogs; + originalConfig = { + logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled, + logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled, + logCleanupRetentionDays: config.logCleanupRetentionDays, + }; + }); + + beforeEach(async () => { + await db.delete(schema.events).run(); + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + + config.logCleanupUsageLogsEnabled = false; + config.logCleanupProgramLogsEnabled = false; + config.logCleanupRetentionDays = 30; + }); + + afterAll(() => { + config.logCleanupUsageLogsEnabled = originalConfig.logCleanupUsageLogsEnabled; + config.logCleanupProgramLogsEnabled = originalConfig.logCleanupProgramLogsEnabled; + config.logCleanupRetentionDays = originalConfig.logCleanupRetentionDays; + delete process.env.DATA_DIR; + }); + + async function seedAccount() { + const site = await db.insert(schema.sites).values({ + name: 'cleanup-site', + url: 'https://cleanup.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + return await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'cleanup-user', + accessToken: 'cleanup-access-token', + apiToken: 'cleanup-api-token', + status: 'active', + }).returning().get(); + } + + it('cleans usage logs and program logs older than retention days', async () => { + const account = await seedAccount(); + + await db.insert(schema.proxyLogs).values([ + { + accountId: account.id, + modelRequested: 'gpt-4.1-mini', + status: 'success', + createdAt: '2026-02-01 08:00:00', + }, + { + accountId: account.id, + modelRequested: 'gpt-4.1-mini', + status: 'success', + createdAt: '2026-03-10 08:00:00', + }, + ]).run(); + + await db.insert(schema.events).values([ + { + type: 'status', + title: 'old event', + message: 'cleanup me', + createdAt: '2026-02-01 08:00:00', + }, + { + type: 'status', + title: 'new event', + message: 'keep me', + createdAt: '2026-03-10 08:00:00', + }, + ]).run(); + + const result = await cleanupConfiguredLogs({ + usageLogsEnabled: true, + programLogsEnabled: true, + retentionDays: 7, + nowMs: Date.parse('2026-03-12T00:00:00Z'), + }); + + expect(result.enabled).toBe(true); + expect(result.usageLogsDeleted).toBe(1); + expect(result.programLogsDeleted).toBe(1); + expect(result.totalDeleted).toBe(2); + + const remainingProxyLogs = await db.select().from(schema.proxyLogs).all(); + const remainingEvents = await db.select().from(schema.events).all(); + expect(remainingProxyLogs).toHaveLength(1); + expect(remainingProxyLogs[0]?.createdAt).toBe('2026-03-10 08:00:00'); + expect(remainingEvents).toHaveLength(1); + expect(remainingEvents[0]?.title).toBe('new event'); + }); + + it('skips cleanup when no target is enabled', async () => { + const account = await seedAccount(); + + await db.insert(schema.proxyLogs).values({ + accountId: account.id, + modelRequested: 'gpt-4.1-mini', + status: 'success', + createdAt: '2026-01-01 08:00:00', + }).run(); + await db.insert(schema.events).values({ + type: 'status', + title: 'still here', + createdAt: '2026-01-01 08:00:00', + }).run(); + + const result = await cleanupConfiguredLogs({ + usageLogsEnabled: false, + programLogsEnabled: false, + retentionDays: 7, + nowMs: Date.parse('2026-03-12T00:00:00Z'), + }); + + expect(result.enabled).toBe(false); + expect(result.totalDeleted).toBe(0); + expect(await db.select().from(schema.proxyLogs).all()).toHaveLength(1); + expect(await db.select().from(schema.events).all()).toHaveLength(1); + }); +}); diff --git a/src/server/services/logCleanupService.ts b/src/server/services/logCleanupService.ts new file mode 100644 index 0000000..0e8203a --- /dev/null +++ b/src/server/services/logCleanupService.ts @@ -0,0 +1,138 @@ +import { lt } from 'drizzle-orm'; +import { config } from '../config.js'; +import { db, schema } from '../db/index.js'; +import { formatUtcSqlDateTime } from './localTimeService.js'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type LogCleanupOptions = { + usageLogsEnabled?: boolean; + programLogsEnabled?: boolean; + retentionDays?: number; + nowMs?: number; +}; + +export type LogCleanupResult = { + enabled: boolean; + usageLogsEnabled: boolean; + programLogsEnabled: boolean; + retentionDays: number; + cutoffUtc: string | null; + usageLogsDeleted: number; + programLogsDeleted: number; + totalDeleted: number; +}; + +export function normalizeLogCleanupRetentionDays(value: unknown, fallback = 30): number { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed >= 1) return Math.trunc(parsed); + + const fallbackParsed = Number(fallback); + if (Number.isFinite(fallbackParsed) && fallbackParsed >= 1) return Math.trunc(fallbackParsed); + + return 30; +} + +export function getLogCleanupCutoffUtc(retentionDays: number, nowMs = Date.now()): string | null { + const normalizedDays = normalizeLogCleanupRetentionDays(retentionDays); + return formatUtcSqlDateTime(new Date(nowMs - normalizedDays * DAY_MS)); +} + +export async function cleanupUsageLogs(retentionDays: number, nowMs = Date.now()): Promise<{ + retentionDays: number; + cutoffUtc: string | null; + deleted: number; +}> { + const normalizedDays = normalizeLogCleanupRetentionDays(retentionDays); + const cutoffUtc = getLogCleanupCutoffUtc(normalizedDays, nowMs); + if (!cutoffUtc) { + return { + retentionDays: normalizedDays, + cutoffUtc: null, + deleted: 0, + }; + } + + const deleted = ( + await db.delete(schema.proxyLogs) + .where(lt(schema.proxyLogs.createdAt, cutoffUtc)) + .run() + ).changes; + + return { + retentionDays: normalizedDays, + cutoffUtc, + deleted, + }; +} + +export async function cleanupProgramLogs(retentionDays: number, nowMs = Date.now()): Promise<{ + retentionDays: number; + cutoffUtc: string | null; + deleted: number; +}> { + const normalizedDays = normalizeLogCleanupRetentionDays(retentionDays); + const cutoffUtc = getLogCleanupCutoffUtc(normalizedDays, nowMs); + if (!cutoffUtc) { + return { + retentionDays: normalizedDays, + cutoffUtc: null, + deleted: 0, + }; + } + + const deleted = ( + await db.delete(schema.events) + .where(lt(schema.events.createdAt, cutoffUtc)) + .run() + ).changes; + + return { + retentionDays: normalizedDays, + cutoffUtc, + deleted, + }; +} + +export async function cleanupConfiguredLogs(options: LogCleanupOptions = {}): Promise { + const usageLogsEnabled = options.usageLogsEnabled ?? config.logCleanupUsageLogsEnabled; + const programLogsEnabled = options.programLogsEnabled ?? config.logCleanupProgramLogsEnabled; + const retentionDays = normalizeLogCleanupRetentionDays( + options.retentionDays ?? config.logCleanupRetentionDays, + config.logCleanupRetentionDays, + ); + const nowMs = options.nowMs ?? Date.now(); + const enabled = usageLogsEnabled || programLogsEnabled; + const cutoffUtc = enabled ? getLogCleanupCutoffUtc(retentionDays, nowMs) : null; + + if (!enabled || !cutoffUtc) { + return { + enabled: false, + usageLogsEnabled, + programLogsEnabled, + retentionDays, + cutoffUtc, + usageLogsDeleted: 0, + programLogsDeleted: 0, + totalDeleted: 0, + }; + } + + const usageResult = usageLogsEnabled + ? await cleanupUsageLogs(retentionDays, nowMs) + : { deleted: 0 }; + const programResult = programLogsEnabled + ? await cleanupProgramLogs(retentionDays, nowMs) + : { deleted: 0 }; + + return { + enabled: true, + usageLogsEnabled, + programLogsEnabled, + retentionDays, + cutoffUtc, + usageLogsDeleted: usageResult.deleted, + programLogsDeleted: programResult.deleted, + totalDeleted: usageResult.deleted + programResult.deleted, + }; +} diff --git a/src/server/services/proxyLogRetentionService.ts b/src/server/services/proxyLogRetentionService.ts index 259a454..ce6da66 100644 --- a/src/server/services/proxyLogRetentionService.ts +++ b/src/server/services/proxyLogRetentionService.ts @@ -1,16 +1,12 @@ -import { lt } from 'drizzle-orm'; import { config } from '../config.js'; -import { db, schema } from '../db/index.js'; -import { formatUtcSqlDateTime } from './localTimeService.js'; - -const DAY_MS = 24 * 60 * 60 * 1000; +import { cleanupUsageLogs, getLogCleanupCutoffUtc } from './logCleanupService.js'; let retentionTimer: ReturnType | null = null; export function getProxyLogRetentionCutoffUtc(nowMs = Date.now()): string | null { const days = Math.max(0, Math.trunc(config.proxyLogRetentionDays)); if (days <= 0) return null; - return formatUtcSqlDateTime(new Date(nowMs - days * DAY_MS)); + return getLogCleanupCutoffUtc(days, nowMs); } export async function cleanupExpiredProxyLogs(nowMs = Date.now()): Promise<{ @@ -30,10 +26,7 @@ export async function cleanupExpiredProxyLogs(nowMs = Date.now()): Promise<{ }; } - const deleted = (await db.delete(schema.proxyLogs) - .where(lt(schema.proxyLogs.createdAt, cutoffUtc)) - .run()) - .changes; + const { deleted } = await cleanupUsageLogs(retentionDays, nowMs); return { enabled: true, @@ -68,3 +61,11 @@ export function stopProxyLogRetentionService(): void { clearInterval(retentionTimer); retentionTimer = null; } + +export function setLegacyProxyLogRetentionFallbackEnabled(enabled: boolean): void { + if (enabled) { + startProxyLogRetentionService(); + return; + } + stopProxyLogRetentionService(); +} diff --git a/src/server/services/siteCustomHeaders.ts b/src/server/services/siteCustomHeaders.ts new file mode 100644 index 0000000..a1c2538 --- /dev/null +++ b/src/server/services/siteCustomHeaders.ts @@ -0,0 +1,122 @@ +import { Headers, type HeadersInit } from 'undici'; + +export type SiteCustomHeadersRecord = Record; + +export type ParsedSiteCustomHeadersInput = { + present: boolean; + valid: boolean; + customHeaders: string | null; + headers: SiteCustomHeadersRecord | null; + error?: string; +}; + +function isPlainObject(value: unknown): value is Record { + return Object.prototype.toString.call(value) === '[object Object]'; +} + +function normalizeSiteCustomHeadersRecord(input: Record): SiteCustomHeadersRecord | null { + const normalized = new Headers(); + + for (const [rawKey, rawValue] of Object.entries(input)) { + const key = rawKey.trim(); + if (!key) { + throw new Error('Header name cannot be empty.'); + } + if (typeof rawValue !== 'string') { + throw new Error(`Header "${key}" must use a string value.`); + } + normalized.set(key, rawValue); + } + + const entries = Array.from(normalized.entries()).sort(([left], [right]) => left.localeCompare(right)); + if (entries.length === 0) { + return null; + } + + return Object.fromEntries(entries); +} + +export function parseSiteCustomHeadersInput(input: unknown): ParsedSiteCustomHeadersInput { + if (input === undefined) { + return { present: false, valid: true, customHeaders: null, headers: null }; + } + if (input === null) { + return { present: true, valid: true, customHeaders: null, headers: null }; + } + + let parsedInput: unknown = input; + if (typeof input === 'string') { + const trimmed = input.trim(); + if (!trimmed) { + return { present: true, valid: true, customHeaders: null, headers: null }; + } + try { + parsedInput = JSON.parse(trimmed); + } catch { + return { + present: true, + valid: false, + customHeaders: null, + headers: null, + error: 'Invalid customHeaders. Expected a JSON object like {"x-header":"value"}.', + }; + } + } + + if (!isPlainObject(parsedInput)) { + return { + present: true, + valid: false, + customHeaders: null, + headers: null, + error: 'Invalid customHeaders. Expected a JSON object like {"x-header":"value"}.', + }; + } + + try { + const headers = normalizeSiteCustomHeadersRecord(parsedInput); + return { + present: true, + valid: true, + customHeaders: headers ? JSON.stringify(headers) : null, + headers, + }; + } catch (error) { + return { + present: true, + valid: false, + customHeaders: null, + headers: null, + error: error instanceof Error + ? `Invalid customHeaders. ${error.message}` + : 'Invalid customHeaders. Expected a JSON object like {"x-header":"value"}.', + }; + } +} + +export function readSiteCustomHeaders(input: unknown): SiteCustomHeadersRecord | null { + const parsed = parseSiteCustomHeadersInput(input); + if (!parsed.valid) { + return null; + } + return parsed.headers; +} + +export function mergeHeadersWithSiteCustomHeaders( + siteCustomHeaders: unknown, + requestHeaders?: HeadersInit, +): HeadersInit | undefined { + const normalizedSiteHeaders = readSiteCustomHeaders(siteCustomHeaders); + if (!normalizedSiteHeaders) { + return requestHeaders; + } + + const merged = new Headers(normalizedSiteHeaders); + if (requestHeaders) { + const explicitHeaders = new Headers(requestHeaders); + explicitHeaders.forEach((value, key) => { + merged.set(key, value); + }); + } + return merged; +} diff --git a/src/server/services/siteProxy.test.ts b/src/server/services/siteProxy.test.ts index 028ef35..5cdb5f9 100644 --- a/src/server/services/siteProxy.test.ts +++ b/src/server/services/siteProxy.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { sql } from 'drizzle-orm'; +import { Headers } from 'undici'; type DbModule = typeof import('../db/index.js'); @@ -69,4 +70,49 @@ describe('siteProxy', () => { expect('dispatcher' in requestInit).toBe(true); }); + + it('merges site custom headers by matched request url and keeps explicit headers authoritative', async () => { + await db.insert(schema.sites).values({ + name: 'headers-site', + url: 'https://headers-site.example.com', + platform: 'new-api', + customHeaders: JSON.stringify({ + 'cf-access-client-id': 'site-client', + authorization: 'Bearer site-default', + }), + }).run(); + + const { withSiteProxyRequestInit } = await import('./siteProxy.js'); + const requestInit = await withSiteProxyRequestInit('https://headers-site.example.com/v1/models', { + method: 'GET', + headers: { + Authorization: 'Bearer request-token', + 'X-Trace-Id': 'trace-1', + }, + }); + const headers = new Headers(requestInit.headers); + + expect(headers.get('cf-access-client-id')).toBe('site-client'); + expect(headers.get('authorization')).toBe('Bearer request-token'); + expect(headers.get('x-trace-id')).toBe('trace-1'); + }); + + it('merges site custom headers from site records even without cache lookup', async () => { + const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js'); + const requestInit = withSiteRecordProxyRequestInit({ + useSystemProxy: false, + customHeaders: JSON.stringify({ + 'x-site-scope': 'site-level', + }), + }, { + method: 'POST', + headers: { + 'X-Request-Id': 'req-1', + }, + }); + const headers = new Headers(requestInit.headers); + + expect(headers.get('x-site-scope')).toBe('site-level'); + expect(headers.get('x-request-id')).toBe('req-1'); + }); }); diff --git a/src/server/services/siteProxy.ts b/src/server/services/siteProxy.ts index 2b3eff4..77b46c7 100644 --- a/src/server/services/siteProxy.ts +++ b/src/server/services/siteProxy.ts @@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm'; import { config } from '../config.js'; import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici'; import { ProxyAgent } from 'undici'; +import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js'; const SITE_PROXY_CACHE_TTL_MS = 3_000; const SUPPORTED_PROXY_PROTOCOLS = new Set([ @@ -18,6 +19,7 @@ const SUPPORTED_PROXY_PROTOCOLS = new Set([ type SiteProxyRow = { siteUrl: string; useSystemProxy: boolean; + customHeaders: string | null; }; type ParsedSiteProxyInput = { @@ -28,6 +30,7 @@ type ParsedSiteProxyInput = { type SiteProxyConfigLike = { useSystemProxy?: boolean | null; + customHeaders?: string | null; }; let siteProxyCache: { @@ -67,6 +70,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise ({ siteUrl: normalizeSiteUrl(row.siteUrl), useSystemProxy: !!row.useSystemProxy, + customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null, })), systemProxyUrl: parsedSystemProxyUrl, }; @@ -167,18 +172,11 @@ export function invalidateSiteProxyCache(): void { siteProxyCache = { loadedAt: 0, rows: [], systemProxyUrl: null }; } -export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise { - const normalizedRequestUrl = normalizeSiteUrl(requestUrl); - if (!normalizedRequestUrl) return null; - - const rows = await getCachedSiteProxyRows(); - const systemProxyUrl = siteProxyCache.systemProxyUrl; - if (!systemProxyUrl) return null; - let bestMatch: string | null = null; +function findBestMatchingSiteRow(rows: SiteProxyRow[], normalizedRequestUrl: string): SiteProxyRow | null { + let bestMatch: SiteProxyRow | null = null; let bestMatchLength = -1; for (const row of rows) { - if (!row.useSystemProxy) continue; if (!row.siteUrl) continue; const isPrefixMatch = ( @@ -189,7 +187,7 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi if (!isPrefixMatch) continue; if (row.siteUrl.length > bestMatchLength) { - bestMatch = systemProxyUrl; + bestMatch = row; bestMatchLength = row.siteUrl.length; } } @@ -197,18 +195,53 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi return bestMatch; } +async function resolveSiteRequestConfigByRequestUrl(requestUrl: string): Promise<{ + proxyUrl: string | null; + customHeaders: string | null; +}> { + const normalizedRequestUrl = normalizeSiteUrl(requestUrl); + if (!normalizedRequestUrl) { + return { proxyUrl: null, customHeaders: null }; + } + + const rows = await getCachedSiteProxyRows(); + const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl); + const proxyUrl = matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null; + return { + proxyUrl: proxyUrl || null, + customHeaders: matchedRow?.customHeaders ?? null, + }; +} + +export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise { + const resolved = await resolveSiteRequestConfigByRequestUrl(requestUrl); + return resolved.proxyUrl; +} + export async function withSiteProxyRequestInit( requestUrl: string, options?: UndiciRequestInit, ): Promise { - const proxyUrl = await resolveSiteProxyUrlByRequestUrl(requestUrl); - if (!proxyUrl) return options ?? {}; + const resolved = await resolveSiteRequestConfigByRequestUrl(requestUrl); + const nextOptions: UndiciRequestInit = { + ...(options || {}), + }; + const mergedHeaders = mergeHeadersWithSiteCustomHeaders(resolved.customHeaders, options?.headers); + if (mergedHeaders) { + nextOptions.headers = mergedHeaders; + } - const dispatcher = getDispatcherByProxyUrl(proxyUrl); - if (!dispatcher) return options ?? {}; + if (!resolved.proxyUrl) { + return nextOptions; + } + + const dispatcher = getDispatcherByProxyUrl(resolved.proxyUrl); + if (!dispatcher) { + return nextOptions; + } return { - ...(options || {}), + ...nextOptions, dispatcher, }; } @@ -238,5 +271,12 @@ export function withSiteRecordProxyRequestInit( site: SiteProxyConfigLike | null | undefined, options?: UndiciRequestInit, ): UndiciRequestInit { - return withExplicitProxyRequestInit(resolveProxyUrlForSite(site), options); + const nextOptions: UndiciRequestInit = { + ...(options || {}), + }; + const mergedHeaders = mergeHeadersWithSiteCustomHeaders(site?.customHeaders, options?.headers); + if (mergedHeaders) { + nextOptions.headers = mergedHeaders; + } + return withExplicitProxyRequestInit(resolveProxyUrlForSite(site), nextOptions); } diff --git a/src/web/pages/Models.marketplace-text.test.tsx b/src/web/pages/Models.marketplace-text.test.tsx index b3de51a..98a25cd 100644 --- a/src/web/pages/Models.marketplace-text.test.tsx +++ b/src/web/pages/Models.marketplace-text.test.tsx @@ -320,4 +320,202 @@ describe('Models marketplace text', () => { root?.unmount(); } }); + + it('re-sorts models using site-scoped counts after selecting a site filter', async () => { + apiMock.getModelsMarketplace.mockResolvedValue({ + models: [ + { + name: 'gpt-4o', + accountCount: 4, + tokenCount: 4, + avgLatency: 300, + successRate: 98, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 1, + site: '站点 A', + username: 'user-a-1', + latency: 300, + balance: 8, + tokens: [{ id: 1, name: 'token-a-1', isDefault: true }], + }, + { + id: 2, + site: '站点 B', + username: 'user-b-1', + latency: 200, + balance: 8, + tokens: [{ id: 2, name: 'token-b-1', isDefault: true }], + }, + { + id: 3, + site: '站点 B', + username: 'user-b-2', + latency: 250, + balance: 8, + tokens: [{ id: 3, name: 'token-b-2', isDefault: true }], + }, + { + id: 4, + site: '站点 B', + username: 'user-b-3', + latency: 260, + balance: 8, + tokens: [{ id: 4, name: 'token-b-3', isDefault: true }], + }, + ], + }, + { + name: 'claude-3-5-sonnet', + accountCount: 2, + tokenCount: 2, + avgLatency: 420, + successRate: 95, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 5, + site: '站点 A', + username: 'user-a-2', + latency: 410, + balance: 9, + tokens: [{ id: 5, name: 'token-a-2', isDefault: true }], + }, + { + id: 6, + site: '站点 A', + username: 'user-a-3', + latency: 430, + balance: 9, + tokens: [{ id: 6, name: 'token-a-3', isDefault: true }], + }, + ], + }, + ], + }); + + let root: ReturnType | null = null; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const siteFilterItem = root!.root.find((node) => ( + node.type === 'div' + && typeof node.props.className === 'string' + && node.props.className.includes('filter-item') + && typeof node.props.onClick === 'function' + && collectText(node).includes('站点 A') + )); + + await act(async () => { + siteFilterItem.props.onClick(); + }); + await flushMicrotasks(); + + const cards = root!.root.findAll((node) => ( + node.type === 'div' + && typeof node.props.className === 'string' + && node.props.className.split(' ').includes('model-card') + && typeof node.props.onClick === 'function' + )); + + expect(cards.length).toBe(2); + expect(collectText(cards[0]!)).toContain('claude-3-5-sonnet'); + expect(collectText(cards[1]!)).toContain('gpt-4o'); + } finally { + root?.unmount(); + } + }); + + it('renders unknown latency instead of falling back to another site latency', async () => { + apiMock.getModelsMarketplace.mockResolvedValue({ + models: [ + { + name: 'gpt-4o', + accountCount: 2, + tokenCount: 2, + avgLatency: 680, + successRate: 93, + description: null, + tags: [], + supportedEndpointTypes: [], + pricingSources: [], + accounts: [ + { + id: 1, + site: '站点 A', + username: 'user-a', + latency: null, + balance: 12, + tokens: [{ id: 1, name: 'token-a', isDefault: true }], + }, + { + id: 2, + site: '站点 B', + username: 'user-b', + latency: 680, + balance: 12, + tokens: [{ id: 2, name: 'token-b', isDefault: true }], + }, + ], + }, + ], + }); + + let root: ReturnType | null = null; + + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const siteFilterItem = root!.root.find((node) => ( + node.type === 'div' + && typeof node.props.className === 'string' + && node.props.className.includes('filter-item') + && typeof node.props.onClick === 'function' + && collectText(node).includes('站点 A') + )); + + await act(async () => { + siteFilterItem.props.onClick(); + }); + await flushMicrotasks(); + + const latencyBadge = root!.root.find((node) => ( + node.type === 'span' + && node.props['data-tooltip'] === '平均延迟' + )); + + expect(String(latencyBadge.props.className || '')).toContain('badge-muted'); + expect(collectText(latencyBadge)).toContain('延迟'); + expect(collectText(latencyBadge)).toContain('—'); + expect(collectText(root!.root)).not.toContain('680ms'); + } finally { + root?.unmount(); + } + }); }); diff --git a/src/web/pages/Models.tsx b/src/web/pages/Models.tsx index 0b5378a..38d4fe1 100644 --- a/src/web/pages/Models.tsx +++ b/src/web/pages/Models.tsx @@ -49,7 +49,7 @@ interface ModelRow { name: string; accountCount: number; tokenCount: number; - avgLatency: number; + avgLatency: number | null; successRate: number | null; description: string | null; tags: string[]; @@ -68,7 +68,13 @@ interface ModelsMarketplaceResponse { refreshJobId?: string | null; }; } -function getMetricColor(latency: number) { + +function isKnownLatency(latency: number | null | undefined): latency is number { + return typeof latency === 'number' && Number.isFinite(latency); +} + +function getMetricColor(latency: number | null) { + if (!isKnownLatency(latency)) return 'var(--color-text-muted)'; if (latency >= 3000) return 'var(--color-danger)'; if (latency >= 2000) return 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))'; if (latency >= 1500) return 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))'; @@ -77,12 +83,17 @@ function getMetricColor(latency: number) { return 'var(--color-success)'; } -function getLatencyBadgeClass(latency: number) { +function getLatencyBadgeClass(latency: number | null) { + if (!isKnownLatency(latency)) return 'badge-muted'; if (latency >= 3000) return 'badge-error'; if (latency >= 1000) return 'badge-warning'; return 'badge-success'; } +function formatLatency(latency: number | null): string { + return isKnownLatency(latency) ? `${latency}ms` : '—'; +} + function getSuccessBadgeClass(rate: number | null) { if (rate == null) return 'badge-muted'; if (rate >= 90) return 'badge-success'; @@ -113,6 +124,29 @@ function renderGroupPricingValue(pricing: ModelGroupPricing): string { const PAGE_SIZES = [10, 20, 50]; +function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'asc' | 'desc'): number { + if (sortBy === 'name') { + const cmp = a.name.localeCompare(b.name); + return sortDir === 'asc' ? cmp : -cmp; + } + + const resolveNumericSortValue = (model: ModelRow) => { + if (sortBy === 'successRate') return model.successRate ?? -1; + if (sortBy === 'avgLatency') { + if (!isKnownLatency(model.avgLatency)) { + return sortDir === 'asc' ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + } + return model.avgLatency; + } + return model[sortBy] ?? 0; + }; + + const va = resolveNumericSortValue(a); + const vb = resolveNumericSortValue(b); + if (va === vb) return a.name.localeCompare(b.name); + return sortDir === 'desc' ? vb - va : va - vb; +} + /* ---- component ---- */ export default function Models() { const toast = useToast(); @@ -259,7 +293,7 @@ export default function Models() { return [...m.entries()].sort((a, b) => b[1] - a[1]); }, [data.models]); - /* ---- filtered + sorted ---- */ + /* ---- filtered ---- */ const filteredModels = useMemo(() => { let list = data.models; @@ -280,29 +314,19 @@ export default function Models() { list = list.filter(m => m.name.toLowerCase().includes(q)); } - return [...list].sort((a, b) => { - if (sortBy === 'name') { - const cmp = a.name.localeCompare(b.name); - return sortDir === 'asc' ? cmp : -cmp; - } - const va = sortBy === 'successRate' ? (a.successRate ?? -1) : (a[sortBy] ?? 0); - const vb = sortBy === 'successRate' ? (b.successRate ?? -1) : (b[sortBy] ?? 0); - if (va === vb) return a.name.localeCompare(b.name); - return sortDir === 'desc' ? vb - va : va - vb; - }); - }, [data.models, search, activeSite, activeBrand, sortBy, sortDir]); + return list; + }, [data.models, search, activeSite, activeBrand]); // Keep expanded detail consistent with filters (especially site filter). // The list-level filter uses "model has at least one account on this site" semantics; // once a model is shown, its detail should honor the active site as well. const detailModels = useMemo(() => { - if (!activeSite) return filteredModels; - return filteredModels.map((model) => { + const scopedModels = activeSite ? filteredModels.map((model) => { const accounts = model.accounts.filter((account) => account.site === activeSite); const pricingSources = model.pricingSources.filter((source) => source.siteName === activeSite); const latencyValues = accounts .map((account) => account.latency) - .filter((latency): latency is number => typeof latency === 'number' && Number.isFinite(latency)); + .filter(isKnownLatency); return { ...model, accounts, @@ -311,13 +335,15 @@ export default function Models() { tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0), avgLatency: latencyValues.length > 0 ? Math.round(latencyValues.reduce((sum, latency) => sum + latency, 0) / latencyValues.length) - : model.avgLatency, + : null, }; - }); - }, [filteredModels, activeSite]); + }) : filteredModels; + + return [...scopedModels].sort((a, b) => compareModels(a, b, sortBy, sortDir)); + }, [filteredModels, activeSite, sortBy, sortDir]); /* ---- pagination ---- */ - const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize)); + const totalPages = Math.max(1, Math.ceil(detailModels.length / pageSize)); const safePageVal = Math.min(page, totalPages); const paged = detailModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize); @@ -334,9 +360,12 @@ export default function Models() { } return ids.size; })(); - const avgLatency = detailModels.length - ? Math.round(detailModels.reduce((s, m) => s + m.avgLatency, 0) / detailModels.length) - : 0; + const latencyMetrics = detailModels + .map((model) => model.avgLatency) + .filter(isKnownLatency); + const avgLatency = latencyMetrics.length > 0 + ? Math.round(latencyMetrics.reduce((sum, latency) => sum + latency, 0) / latencyMetrics.length) + : null; /* ---- copy ---- */ const copyName = (name: string) => { @@ -532,12 +561,12 @@ export default function Models() { {tr('去重账号')} {uniqueAccountCount} - {tr('平均延迟')} {avgLatency}ms + {tr('平均延迟')} {formatLatency(avgLatency)} {/* Empty */} - {filteredModels.length === 0 ? ( + {detailModels.length === 0 ? (
@@ -572,7 +601,7 @@ export default function Models() { style={{ fontVariantNumeric: 'tabular-nums' }} data-tooltip={tr('平均延迟')} > - {tr('延迟')} {m.avgLatency}ms + {tr('延迟')} {formatLatency(m.avgLatency)} {tr('风险')} )} - {m.avgLatency <= 500 && ( + {isKnownLatency(m.avgLatency) && m.avgLatency <= 500 && ( {tr('低延迟')} )}
@@ -761,7 +790,7 @@ export default function Models() { className={`badge ${getLatencyBadgeClass(m.avgLatency)}`} style={{ fontSize: 12, fontVariantNumeric: 'tabular-nums' }} > - {m.avgLatency}ms + {formatLatency(m.avgLatency)} diff --git a/src/web/pages/Settings.tsx b/src/web/pages/Settings.tsx index c0caffc..a661708 100644 --- a/src/web/pages/Settings.tsx +++ b/src/web/pages/Settings.tsx @@ -25,6 +25,10 @@ type DbDialect = 'sqlite' | 'mysql' | 'postgres'; type RuntimeSettings = { checkinCron: string; balanceRefreshCron: string; + logCleanupCron: string; + logCleanupUsageLogsEnabled: boolean; + logCleanupProgramLogsEnabled: boolean; + logCleanupRetentionDays: number; routingFallbackUnitCost: number; routingWeights: RoutingWeights; systemProxyUrl: string; @@ -179,6 +183,10 @@ export default function Settings() { const [runtime, setRuntime] = useState({ checkinCron: '0 8 * * *', balanceRefreshCron: '0 * * * *', + logCleanupCron: '0 6 * * *', + logCleanupUsageLogsEnabled: false, + logCleanupProgramLogsEnabled: false, + logCleanupRetentionDays: 30, routingFallbackUnitCost: 1, routingWeights: defaultWeights, systemProxyUrl: '', @@ -381,6 +389,12 @@ export default function Settings() { setRuntime({ checkinCron: runtimeInfo.checkinCron || '0 8 * * *', balanceRefreshCron: runtimeInfo.balanceRefreshCron || '0 * * * *', + logCleanupCron: runtimeInfo.logCleanupCron || '0 6 * * *', + logCleanupUsageLogsEnabled: !!runtimeInfo.logCleanupUsageLogsEnabled, + logCleanupProgramLogsEnabled: !!runtimeInfo.logCleanupProgramLogsEnabled, + logCleanupRetentionDays: Number(runtimeInfo.logCleanupRetentionDays) >= 1 + ? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays)) + : 30, routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0 ? Number(runtimeInfo.routingFallbackUnitCost) : 1, @@ -452,8 +466,12 @@ export default function Settings() { await api.updateRuntimeSettings({ checkinCron: runtime.checkinCron, balanceRefreshCron: runtime.balanceRefreshCron, + logCleanupCron: runtime.logCleanupCron, + logCleanupUsageLogsEnabled: runtime.logCleanupUsageLogsEnabled, + logCleanupProgramLogsEnabled: runtime.logCleanupProgramLogsEnabled, + logCleanupRetentionDays: runtime.logCleanupRetentionDays, }); - toast.success('Schedule settings saved'); + toast.success('定时任务设置已保存'); } catch (err: any) { toast.error(err?.message || '保存失败'); } finally { @@ -903,6 +921,66 @@ export default function Settings() { />
+
+
自动清理日志
+
+
+
清理 Cron
+ setRuntime((prev) => ({ ...prev, logCleanupCron: e.target.value }))} + style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} + /> +
+
+
保留天数
+ setRuntime((prev) => { + const nextValue = Number(e.target.value); + return { + ...prev, + logCleanupRetentionDays: Number.isFinite(nextValue) && nextValue >= 1 + ? Math.trunc(nextValue) + : prev.logCleanupRetentionDays, + }; + })} + style={inputStyle} + /> +
+
+
+ + +
+
+ 默认每天早上 6 点执行。按每次定时任务执行时间,清理早于“保留天数”的日志;两个选项都不勾选时不会实际删除日志。 +
+
); } - diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index a567ec0..e86993c 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../api.js'; +import CenteredModal from '../components/CenteredModal.js'; import { useToast } from '../components/Toast.js'; import ModernSelect from '../components/ModernSelect.js'; import { MobileCard, MobileField } from '../components/MobileCard.js'; -import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useIsMobile } from '../components/useIsMobile.js'; import { formatDateTimeLocal } from './helpers/checkinLogTime.js'; import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js'; @@ -14,7 +14,9 @@ import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js'; import { resolveInitialConnectionSegment } from './helpers/defaultConnectionSegment.js'; import { buildSiteSaveAction, + emptySiteCustomHeader, emptySiteForm, + serializeSiteCustomHeaders, siteFormFromSite, type SiteEditorState, type SiteForm, @@ -28,6 +30,7 @@ type SiteRow = { platform?: string; status?: string; useSystemProxy?: boolean; + customHeaders?: string | null; globalWeight?: number; isPinned?: boolean; sortOrder?: number; @@ -35,6 +38,10 @@ type SiteRow = { createdAt?: string; }; +function hasConfiguredCustomHeaders(customHeaders?: string | null): boolean { + return typeof customHeaders === 'string' && customHeaders.trim().length > 0; +} + const platformColors: Record = { 'new-api': 'badge-info', 'one-api': 'badge-success', @@ -83,7 +90,6 @@ export default function Sites() { const [expandedSiteIds, setExpandedSiteIds] = useState([]); const isMobile = useIsMobile(768); const [batchActionLoading, setBatchActionLoading] = useState(false); - const editorPresence = useAnimatedVisibility(Boolean(editor), 220); const lastEditorRef = useRef(null); const rowRefs = useRef>(new Map()); const highlightTimerRef = useRef(null); @@ -93,6 +99,16 @@ export default function Sites() { const activeEditor = editor || lastEditorRef.current; const isEditing = activeEditor?.mode === 'edit'; const isAdding = activeEditor?.mode === 'add'; + const formInputStyle = { + width: '100%', + padding: '10px 14px', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: 13, + outline: 'none', + background: 'var(--color-bg)', + color: 'var(--color-text-primary)', + } as const; const load = async () => { try { @@ -190,6 +206,11 @@ export default function Sites() { toast.error('全局权重必须是大于 0 的数字'); return; } + const serializedCustomHeaders = serializeSiteCustomHeaders(form.customHeaders); + if (!serializedCustomHeaders.valid) { + toast.error(serializedCustomHeaders.error || '自定义请求头格式不正确'); + return; + } const payload = { name: form.name.trim(), @@ -197,6 +218,7 @@ export default function Sites() { externalCheckinUrl: form.externalCheckinUrl.trim(), platform: form.platform.trim(), useSystemProxy: !!form.useSystemProxy, + customHeaders: serializedCustomHeaders.customHeaders, globalWeight: Number(parsedGlobalWeight.toFixed(3)), }; if (!payload.name || !payload.url) { @@ -238,6 +260,34 @@ export default function Sites() { } }; + const updateCustomHeaderRow = (index: number, field: 'key' | 'value', value: string) => { + setForm((prev) => ({ + ...prev, + customHeaders: prev.customHeaders.map((item, itemIndex) => ( + itemIndex === index + ? { ...item, [field]: value } + : item + )), + })); + }; + + const addCustomHeaderRow = () => { + setForm((prev) => ({ + ...prev, + customHeaders: [...prev.customHeaders, emptySiteCustomHeader()], + })); + }; + + const removeCustomHeaderRow = (index: number) => { + setForm((prev) => { + const nextHeaders = prev.customHeaders.filter((_, itemIndex) => itemIndex !== index); + return { + ...prev, + customHeaders: nextHeaders.length > 0 ? nextHeaders : [emptySiteCustomHeader()], + }; + }); + }; + const handleDetect = async () => { if (!form.url.trim()) { toast.error('请先输入 URL'); @@ -465,138 +515,174 @@ export default function Sites() { 站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。 - {editorPresence.shouldRender && activeEditor && ( -
-
+ {activeEditor && ( + {isEditing ? '编辑站点' : '添加站点'}
- + + + )} + > + setForm((prev) => ({ ...prev, name: e.target.value }))} + style={formInputStyle} + /> +
+ setForm((prev) => ({ ...prev, url: e.target.value }))} + onBlur={() => { + if (form.url.trim() && !form.platform.trim()) { + handleDetect(); + } + }} + style={{ ...formInputStyle, flex: 1 }} + /> +
-
- setForm((prev) => ({ ...prev, name: e.target.value }))} - style={{ - width: '100%', - padding: '10px 14px', - border: '1px solid var(--color-border)', - borderRadius: 'var(--radius-sm)', - fontSize: 13, - outline: 'none', - background: 'var(--color-bg)', - color: 'var(--color-text-primary)', - }} +
+ setForm((prev) => ({ ...prev, platform: value }))} + options={platformOptions} + placeholder="平台类型(可自动检测)" /> -
- setForm((prev) => ({ ...prev, url: e.target.value }))} - onBlur={() => { - if (form.url.trim() && !form.platform.trim()) { - handleDetect(); - } - }} - style={{ - flex: 1, - padding: '10px 14px', - border: '1px solid var(--color-border)', - borderRadius: 'var(--radius-sm)', - fontSize: 13, - outline: 'none', - background: 'var(--color-bg)', - color: 'var(--color-text-primary)', - }} - /> - -
-
- setForm((prev) => ({ ...prev, platform: value }))} - options={platformOptions} - placeholder="平台类型(可自动检测)" - /> -
- setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))} - style={{ - width: '100%', - padding: '10px 14px', - border: '1px solid var(--color-border)', - borderRadius: 'var(--radius-sm)', - fontSize: 13, - outline: 'none', - background: 'var(--color-bg)', - color: 'var(--color-text-primary)', - }} - /> -