forked from boos/BoosAPI
Merge remote-tracking branch 'origin/main' into feat/account-level-proxy
# Conflicts: # src/server/proxy-core/surfaces/chatSurface.ts # src/server/proxy-core/surfaces/openAiResponsesSurface.ts # src/server/routes/proxy/embeddings.ts # src/server/routes/proxy/images.ts # src/server/services/modelService.ts
This commit is contained in:
@@ -10,7 +10,7 @@ vi.mock('../../services/checkinService.js', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../services/checkinScheduler.js', () => ({
|
||||
updateCheckinCron: vi.fn(),
|
||||
updateCheckinSchedule: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../db/index.js', () => {
|
||||
@@ -87,4 +87,25 @@ describe('POST /api/checkin/trigger background task dedupe', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('accepts the legacy cron-only schedule payload', async () => {
|
||||
const { checkinRoutes } = await import('./checkin.js');
|
||||
const schedulerModule = await import('../../services/checkinScheduler.js');
|
||||
const app = Fastify();
|
||||
await app.register(checkinRoutes);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/checkin/schedule',
|
||||
payload: { cron: '0 8 * * *' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect((schedulerModule as any).updateCheckinSchedule).toHaveBeenCalledWith({
|
||||
mode: 'cron',
|
||||
cronExpr: '0 8 * * *',
|
||||
intervalHours: undefined,
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { config } from '../../config.js';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||
import { eq, desc } from 'drizzle-orm';
|
||||
import { checkinAccount, checkinAll } from '../../services/checkinService.js';
|
||||
import { updateCheckinCron } from '../../services/checkinScheduler.js';
|
||||
import { updateCheckinSchedule } from '../../services/checkinScheduler.js';
|
||||
import { startBackgroundTask, summarizeCheckinResults } from '../../services/backgroundTaskService.js';
|
||||
import { classifyFailureReason } from '../../services/failureReasonService.js';
|
||||
|
||||
@@ -84,7 +85,7 @@ export async function checkinRoutes(app: FastifyInstance) {
|
||||
failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`,
|
||||
},
|
||||
async () => {
|
||||
const results = await checkinAll();
|
||||
const results = await checkinAll({ scheduleMode: config.checkinScheduleMode });
|
||||
return {
|
||||
summary: summarizeCheckinResults(results),
|
||||
total: results.length,
|
||||
@@ -108,7 +109,7 @@ export async function checkinRoutes(app: FastifyInstance) {
|
||||
// Trigger check-in for a specific account
|
||||
app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const result = await checkinAccount(id);
|
||||
const result = await checkinAccount(id, { scheduleMode: config.checkinScheduleMode });
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -142,11 +143,33 @@ export async function checkinRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Update check-in schedule
|
||||
app.put<{ Body: { cron: string } }>('/api/checkin/schedule', async (request) => {
|
||||
app.put<{ Body: { mode?: 'cron' | 'interval'; cron?: string; intervalHours?: number } }>('/api/checkin/schedule', async (request) => {
|
||||
try {
|
||||
updateCheckinCron(request.body.cron);
|
||||
await upsertSetting('checkin_cron', request.body.cron);
|
||||
return { success: true, cron: request.body.cron };
|
||||
const body = request.body || {};
|
||||
const nextMode: 'cron' | 'interval' = body.mode === 'interval' ? 'interval' : 'cron';
|
||||
const nextCron = typeof body.cron === 'string' ? body.cron : undefined;
|
||||
const nextIntervalHours = body.intervalHours !== undefined ? Number(body.intervalHours) : undefined;
|
||||
const normalizedIntervalHours = typeof nextIntervalHours === 'number' && Number.isFinite(nextIntervalHours)
|
||||
? Math.trunc(nextIntervalHours)
|
||||
: undefined;
|
||||
|
||||
updateCheckinSchedule({
|
||||
mode: nextMode,
|
||||
cronExpr: nextCron,
|
||||
intervalHours: normalizedIntervalHours,
|
||||
});
|
||||
|
||||
await upsertSetting('checkin_schedule_mode', nextMode);
|
||||
if (nextCron !== undefined) await upsertSetting('checkin_cron', nextCron);
|
||||
if (normalizedIntervalHours !== undefined) {
|
||||
await upsertSetting('checkin_interval_hours', normalizedIntervalHours);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
mode: nextMode,
|
||||
cron: nextCron,
|
||||
intervalHours: normalizedIntervalHours,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return { error: err.message };
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ function validateKeyShape(key: string): boolean {
|
||||
|
||||
function looksLikeUniqueViolation(error: unknown): boolean {
|
||||
const message = (error as Error | undefined)?.message || '';
|
||||
if (runtimeDbDialect === 'postgres') {
|
||||
return message.includes('duplicate key value violates unique constraint') &&
|
||||
message.includes('downstream_api_keys_key_unique');
|
||||
}
|
||||
return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('settings backup webdav api', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-backup-webdav-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const settingsRoutesModule = await import('./settings.js');
|
||||
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(settingsRoutesModule.settingsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.settings).run();
|
||||
await db.delete(schema.events).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('saves and returns masked webdav config', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/backup/webdav',
|
||||
payload: {
|
||||
enabled: true,
|
||||
fileUrl: 'https://dav.example.com/backups/metapi.json',
|
||||
username: 'alice',
|
||||
password: 'secret-pass',
|
||||
exportType: 'accounts',
|
||||
autoSyncEnabled: true,
|
||||
autoSyncCron: '0 */6 * * *',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
success?: boolean;
|
||||
config?: {
|
||||
fileUrl?: string;
|
||||
username?: string;
|
||||
exportType?: string;
|
||||
autoSyncEnabled?: boolean;
|
||||
autoSyncCron?: string;
|
||||
hasPassword?: boolean;
|
||||
passwordMasked?: string;
|
||||
};
|
||||
};
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.config).toMatchObject({
|
||||
fileUrl: 'https://dav.example.com/backups/metapi.json',
|
||||
username: 'alice',
|
||||
exportType: 'accounts',
|
||||
autoSyncEnabled: true,
|
||||
autoSyncCron: '0 */6 * * *',
|
||||
hasPassword: true,
|
||||
});
|
||||
expect(body.config?.passwordMasked).toBeTruthy();
|
||||
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'backup_webdav_config_v1')).get();
|
||||
expect(saved?.value).toContain('"fileUrl":"https://dav.example.com/backups/metapi.json"');
|
||||
expect(saved?.value).toContain('"password":"secret-pass"');
|
||||
});
|
||||
|
||||
it('exports current backup to webdav through settings route', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValue(new Response(null, { status: 201 }));
|
||||
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'backup_webdav_config_v1',
|
||||
value: JSON.stringify({
|
||||
enabled: true,
|
||||
fileUrl: 'https://dav.example.com/backups/metapi.json',
|
||||
username: 'alice',
|
||||
password: 'secret-pass',
|
||||
exportType: 'all',
|
||||
autoSyncEnabled: false,
|
||||
autoSyncCron: '0 * * * *',
|
||||
}),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/backup/webdav/export',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as { success?: boolean; fileUrl?: string };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.fileUrl).toBe('https://dav.example.com/backups/metapi.json');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,8 @@ describe('settings and auth events', () => {
|
||||
config.proxyToken = 'sk-old-proxy-token-123';
|
||||
config.systemProxyUrl = '';
|
||||
config.checkinCron = '0 8 * * *';
|
||||
(config as any).checkinScheduleMode = 'cron';
|
||||
(config as any).checkinIntervalHours = 6;
|
||||
config.balanceRefreshCron = '0 * * * *';
|
||||
config.logCleanupConfigured = false;
|
||||
config.logCleanupCron = '0 6 * * *';
|
||||
@@ -55,6 +57,8 @@ describe('settings and auth events', () => {
|
||||
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
||||
(config as any).telegramBotToken = '';
|
||||
(config as any).telegramChatId = '';
|
||||
(config as any).telegramUseSystemProxy = false;
|
||||
(config as any).telegramMessageThreadId = '';
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -85,6 +89,28 @@ describe('settings and auth events', () => {
|
||||
expect(events[0].message || '').toContain('签到 Cron');
|
||||
});
|
||||
|
||||
it('persists and returns checkin interval mode from runtime settings', async () => {
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
checkinScheduleMode: 'interval',
|
||||
checkinIntervalHours: 8,
|
||||
checkinCron: '0 8 * * *',
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
const updated = updateResponse.json() as { checkinScheduleMode?: string; checkinIntervalHours?: number };
|
||||
expect(updated.checkinScheduleMode).toBe('interval');
|
||||
expect(updated.checkinIntervalHours).toBe(8);
|
||||
|
||||
const savedMode = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_schedule_mode')).get();
|
||||
const savedInterval = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_interval_hours')).get();
|
||||
expect(savedMode?.value).toBe(JSON.stringify('interval'));
|
||||
expect(savedInterval?.value).toBe(JSON.stringify(8));
|
||||
});
|
||||
|
||||
it('returns current recognized admin IP in runtime settings response', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
@@ -96,8 +122,10 @@ describe('settings and auth events', () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as { currentAdminIp?: string };
|
||||
const body = response.json() as { currentAdminIp?: string; serverTimeZone?: string };
|
||||
expect(body.currentAdminIp).toBe('203.0.113.5');
|
||||
expect(typeof body.serverTimeZone).toBe('string');
|
||||
expect((body.serverTimeZone || '').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects proxy token that does not start with sk-', async () => {
|
||||
@@ -202,6 +230,32 @@ describe('settings and auth events', () => {
|
||||
expect(runtime.telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom');
|
||||
});
|
||||
|
||||
it('persists and returns telegram message thread id from runtime settings', async () => {
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
telegramMessageThreadId: '77',
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
const updated = updateResponse.json() as { telegramMessageThreadId?: string };
|
||||
expect(updated.telegramMessageThreadId).toBe('77');
|
||||
expect((config as any).telegramMessageThreadId).toBe('77');
|
||||
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_message_thread_id')).get();
|
||||
expect(saved?.value).toBe(JSON.stringify('77'));
|
||||
|
||||
const getResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/settings/runtime',
|
||||
});
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
const runtime = getResponse.json() as { telegramMessageThreadId?: string };
|
||||
expect(runtime.telegramMessageThreadId).toBe('77');
|
||||
});
|
||||
|
||||
it('rejects invalid telegram api base url when telegram is enabled', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
@@ -219,6 +273,32 @@ describe('settings and auth events', () => {
|
||||
expect(body.message).toContain('Telegram API Base URL');
|
||||
});
|
||||
|
||||
it('persists and returns telegram use system proxy from runtime settings', async () => {
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
telegramUseSystemProxy: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
const updated = updateResponse.json() as { telegramUseSystemProxy?: boolean };
|
||||
expect(updated.telegramUseSystemProxy).toBe(true);
|
||||
expect((config as any).telegramUseSystemProxy).toBe(true);
|
||||
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_use_system_proxy')).get();
|
||||
expect(saved?.value).toBe(JSON.stringify(true));
|
||||
|
||||
const getResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/settings/runtime',
|
||||
});
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
const runtime = getResponse.json() as { telegramUseSystemProxy?: boolean };
|
||||
expect(runtime.telegramUseSystemProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('persists and returns routing fallback unit cost from runtime settings', async () => {
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.mock('undici', async () => {
|
||||
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||
return {
|
||||
...actual,
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
type ConfigModule = typeof import('../../config.js');
|
||||
|
||||
describe('settings system proxy test route', () => {
|
||||
let app: FastifyInstance;
|
||||
let config: ConfigModule['config'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-system-proxy-test-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const configModule = await import('../../config.js');
|
||||
const settingsRoutesModule = await import('./settings.js');
|
||||
|
||||
config = configModule.config;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(settingsRoutesModule.settingsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
config.systemProxyUrl = '';
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('tests the provided system proxy url and returns latency', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(null, {
|
||||
status: 204,
|
||||
headers: { 'content-type': 'text/plain' },
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/system-proxy/test',
|
||||
payload: {
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
probeUrl: 'https://www.gstatic.com/generate_204',
|
||||
finalUrl: 'https://www.gstatic.com/generate_204',
|
||||
reachable: true,
|
||||
ok: true,
|
||||
statusCode: 204,
|
||||
});
|
||||
expect((response.json() as { latencyMs?: number }).latencyMs).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const [url, requestInit] = fetchMock.mock.calls[0] as [string, Record<string, unknown>];
|
||||
expect(url).toBe('https://www.gstatic.com/generate_204');
|
||||
expect(requestInit.method).toBe('GET');
|
||||
expect(requestInit.dispatcher).toBeTruthy();
|
||||
});
|
||||
|
||||
it('uses the saved system proxy url when request body is empty', async () => {
|
||||
config.systemProxyUrl = 'socks5://127.0.0.1:1080';
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/system-proxy/test',
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect((response.json() as { proxyUrl?: string }).proxyUrl).toBe('socks5://127.0.0.1:1080');
|
||||
});
|
||||
|
||||
it('rejects missing system proxy url', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/system-proxy/test',
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('请先填写系统代理地址');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 502 when the proxy probe request fails', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed', {
|
||||
cause: new Error('connect ECONNREFUSED 127.0.0.1:7890'),
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/system-proxy/test',
|
||||
payload: {
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(502);
|
||||
expect((response.json() as { message?: string }).message).toContain('连接被拒绝');
|
||||
expect((response.json() as { message?: string }).message).not.toContain('fetch failed');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,22 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import cron from 'node-cron';
|
||||
import { fetch } from 'undici';
|
||||
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, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
|
||||
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
|
||||
import { sendNotification } from '../../services/notifyService.js';
|
||||
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
|
||||
import {
|
||||
exportBackup,
|
||||
exportBackupToWebdav,
|
||||
getBackupWebdavConfig,
|
||||
importBackup,
|
||||
importBackupFromWebdav,
|
||||
reloadBackupWebdavScheduler,
|
||||
saveBackupWebdavConfig,
|
||||
type BackupExportType,
|
||||
} from '../../services/backupService.js';
|
||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import {
|
||||
maskConnectionString,
|
||||
@@ -15,9 +25,9 @@ import {
|
||||
testDatabaseConnection,
|
||||
type MigrationDialect,
|
||||
} from '../../services/databaseMigrationService.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
|
||||
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
||||
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
|
||||
import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { performFactoryReset } from '../../services/factoryResetService.js';
|
||||
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
|
||||
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
|
||||
@@ -28,6 +38,8 @@ interface RuntimeSettingsBody {
|
||||
proxyToken?: string;
|
||||
systemProxyUrl?: string;
|
||||
checkinCron?: string;
|
||||
checkinScheduleMode?: 'cron' | 'interval';
|
||||
checkinIntervalHours?: number;
|
||||
balanceRefreshCron?: string;
|
||||
logCleanupCron?: string;
|
||||
logCleanupUsageLogsEnabled?: boolean;
|
||||
@@ -43,6 +55,8 @@ interface RuntimeSettingsBody {
|
||||
telegramApiBaseUrl?: string;
|
||||
telegramBotToken?: string;
|
||||
telegramChatId?: string;
|
||||
telegramUseSystemProxy?: boolean;
|
||||
telegramMessageThreadId?: string;
|
||||
smtpEnabled?: boolean;
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
@@ -66,6 +80,21 @@ interface DatabaseMigrationBody {
|
||||
ssl?: unknown;
|
||||
}
|
||||
|
||||
interface SystemProxyTestBody {
|
||||
proxyUrl?: unknown;
|
||||
}
|
||||
|
||||
interface BackupWebdavConfigBody {
|
||||
enabled?: unknown;
|
||||
fileUrl?: unknown;
|
||||
username?: unknown;
|
||||
password?: unknown;
|
||||
clearPassword?: unknown;
|
||||
exportType?: unknown;
|
||||
autoSyncEnabled?: unknown;
|
||||
autoSyncCron?: unknown;
|
||||
}
|
||||
|
||||
type RuntimeDatabaseConfig = {
|
||||
dialect: MigrationDialect;
|
||||
connectionString: string;
|
||||
@@ -76,6 +105,8 @@ const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
const DB_TYPE_SETTING_KEY = 'db_type';
|
||||
const DB_URL_SETTING_KEY = 'db_url';
|
||||
const DB_SSL_SETTING_KEY = 'db_ssl';
|
||||
const SYSTEM_PROXY_TEST_PROBE_URL = 'https://www.gstatic.com/generate_204';
|
||||
const SYSTEM_PROXY_TEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
function isValidProxyToken(value: string): boolean {
|
||||
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
||||
@@ -114,6 +145,94 @@ function toPositiveNumberOrFallback(value: unknown, fallback: number) {
|
||||
return n;
|
||||
}
|
||||
|
||||
function extractNestedErrorMessages(error: unknown): string[] {
|
||||
const messages: string[] = [];
|
||||
const visited = new Set<unknown>();
|
||||
let current: any = error;
|
||||
|
||||
while (current && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
const message = typeof current?.message === 'string' ? current.message.trim() : '';
|
||||
if (message) {
|
||||
messages.push(message);
|
||||
}
|
||||
current = current?.cause;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function describeSystemProxyTestFailure(error: unknown): string {
|
||||
const messages = extractNestedErrorMessages(error);
|
||||
const detail = messages.find((message) => message && message !== 'fetch failed')
|
||||
|| messages[0]
|
||||
|| '未知错误';
|
||||
|
||||
if (/ECONNREFUSED/i.test(detail)) {
|
||||
return '系统代理测试失败:连接被拒绝,请检查代理地址、端口和本地代理程序是否已启动';
|
||||
}
|
||||
|
||||
if (/ETIMEDOUT|timed out|timeout/i.test(detail)) {
|
||||
return '系统代理测试失败:连接超时,请检查代理服务或当前网络是否可用';
|
||||
}
|
||||
|
||||
if (/ENOTFOUND|EAI_AGAIN/i.test(detail)) {
|
||||
return '系统代理测试失败:域名解析失败,请检查网络或代理的 DNS 配置';
|
||||
}
|
||||
|
||||
if (/ECONNRESET/i.test(detail)) {
|
||||
return '系统代理测试失败:连接被对端重置,请检查代理链路是否稳定';
|
||||
}
|
||||
|
||||
if (/407/.test(detail) || /proxy authentication/i.test(detail)) {
|
||||
return '系统代理测试失败:代理要求认证,请检查用户名、密码或代理配置';
|
||||
}
|
||||
|
||||
return `系统代理测试失败:${detail}`;
|
||||
}
|
||||
|
||||
async function testSystemProxyConnectivity(proxyUrl: string) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), SYSTEM_PROXY_TEST_TIMEOUT_MS);
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
SYSTEM_PROXY_TEST_PROBE_URL,
|
||||
withExplicitProxyRequestInit(proxyUrl, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'cache-control': 'no-cache',
|
||||
'user-agent': 'metapi-system-proxy-tester/1.0',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await response.arrayBuffer();
|
||||
} catch {
|
||||
// Ignore body drain failures; reachability is determined by receiving a response.
|
||||
}
|
||||
|
||||
return {
|
||||
reachable: true,
|
||||
ok: response.ok,
|
||||
statusCode: response.status,
|
||||
latencyMs: Math.max(1, Date.now() - startedAt),
|
||||
probeUrl: SYSTEM_PROXY_TEST_PROBE_URL,
|
||||
finalUrl: response.url || SYSTEM_PROXY_TEST_PROBE_URL,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`系统代理测试超时(${Math.round(SYSTEM_PROXY_TEST_TIMEOUT_MS / 1000)}s)`);
|
||||
}
|
||||
throw new Error(describeSystemProxyTestFailure(error));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function toStringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
@@ -171,12 +290,46 @@ function normalizeTelegramApiBaseUrl(raw: string): string {
|
||||
return String(raw || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function normalizeTelegramMessageThreadId(raw: unknown): string {
|
||||
return String(raw || '').trim();
|
||||
}
|
||||
|
||||
function isValidTelegramMessageThreadId(raw: string): boolean {
|
||||
return /^[1-9]\d*$/.test(raw);
|
||||
}
|
||||
|
||||
function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
switch (key) {
|
||||
case 'checkin_cron': {
|
||||
if (typeof value !== 'string' || !value || !cron.validate(value)) return;
|
||||
config.checkinCron = value;
|
||||
updateCheckinCron(value);
|
||||
updateCheckinSchedule({
|
||||
mode: config.checkinScheduleMode,
|
||||
cronExpr: config.checkinCron,
|
||||
intervalHours: config.checkinIntervalHours,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'checkin_schedule_mode': {
|
||||
if (value !== 'cron' && value !== 'interval') return;
|
||||
const nextMode: 'cron' | 'interval' = value;
|
||||
config.checkinScheduleMode = nextMode;
|
||||
updateCheckinSchedule({
|
||||
mode: config.checkinScheduleMode,
|
||||
cronExpr: config.checkinCron,
|
||||
intervalHours: config.checkinIntervalHours,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'checkin_interval_hours': {
|
||||
const intervalHours = Number(value);
|
||||
if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) return;
|
||||
config.checkinIntervalHours = Math.trunc(intervalHours);
|
||||
updateCheckinSchedule({
|
||||
mode: config.checkinScheduleMode,
|
||||
cronExpr: config.checkinCron,
|
||||
intervalHours: config.checkinIntervalHours,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'balance_refresh_cron': {
|
||||
@@ -288,6 +441,15 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
config.telegramChatId = value.trim();
|
||||
return;
|
||||
}
|
||||
case 'telegram_use_system_proxy': {
|
||||
config.telegramUseSystemProxy = !!value;
|
||||
return;
|
||||
}
|
||||
case 'telegram_message_thread_id': {
|
||||
if (typeof value !== 'string') return;
|
||||
config.telegramMessageThreadId = value.trim();
|
||||
return;
|
||||
}
|
||||
case 'smtp_enabled': {
|
||||
config.smtpEnabled = !!value;
|
||||
return;
|
||||
@@ -363,6 +525,8 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
return {
|
||||
checkinCron: config.checkinCron,
|
||||
checkinScheduleMode: config.checkinScheduleMode,
|
||||
checkinIntervalHours: config.checkinIntervalHours,
|
||||
balanceRefreshCron: config.balanceRefreshCron,
|
||||
logCleanupCron: config.logCleanupCron,
|
||||
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
|
||||
@@ -380,6 +544,8 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
telegramApiBaseUrl: config.telegramApiBaseUrl,
|
||||
telegramBotTokenMasked: maskSecret(config.telegramBotToken),
|
||||
telegramChatId: config.telegramChatId,
|
||||
telegramUseSystemProxy: config.telegramUseSystemProxy,
|
||||
telegramMessageThreadId: config.telegramMessageThreadId,
|
||||
smtpEnabled: config.smtpEnabled,
|
||||
smtpHost: config.smtpHost,
|
||||
smtpPort: config.smtpPort,
|
||||
@@ -391,6 +557,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
notifyCooldownSec: config.notifyCooldownSec,
|
||||
adminIpAllowlist: config.adminIpAllowlist,
|
||||
currentAdminIp,
|
||||
serverTimeZone: getResolvedTimeZone(),
|
||||
systemProxyUrl: config.systemProxyUrl,
|
||||
proxyErrorKeywords: config.proxyErrorKeywords,
|
||||
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
|
||||
@@ -472,6 +639,43 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return getRuntimeSettingsResponse(currentAdminIp);
|
||||
});
|
||||
|
||||
app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
|
||||
const rawProxyUrl = request.body?.proxyUrl === undefined
|
||||
? config.systemProxyUrl
|
||||
: String(request.body.proxyUrl || '').trim();
|
||||
const normalizedProxyUrl = rawProxyUrl
|
||||
? normalizeSiteProxyUrl(rawProxyUrl)
|
||||
: '';
|
||||
|
||||
if (!rawProxyUrl) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: '请先填写系统代理地址',
|
||||
});
|
||||
}
|
||||
|
||||
if (!normalizedProxyUrl) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: '系统代理地址无效,请填写合法的 http(s)/socks 代理 URL',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await testSystemProxyConnectivity(normalizedProxyUrl);
|
||||
return {
|
||||
success: true,
|
||||
proxyUrl: normalizedProxyUrl,
|
||||
...result,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return reply.code(502).send({
|
||||
success: false,
|
||||
message: error?.message || '系统代理测试失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
|
||||
const body = request.body || {};
|
||||
const changedLabels: string[] = [];
|
||||
@@ -512,7 +716,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const telegramTouched = body.telegramEnabled !== undefined
|
||||
|| body.telegramApiBaseUrl !== undefined
|
||||
|| body.telegramBotToken !== undefined
|
||||
|| body.telegramChatId !== undefined;
|
||||
|| body.telegramChatId !== undefined
|
||||
|| body.telegramUseSystemProxy !== undefined
|
||||
|| body.telegramMessageThreadId !== undefined;
|
||||
const nextTelegramEnabled = body.telegramEnabled !== undefined
|
||||
? !!body.telegramEnabled
|
||||
: config.telegramEnabled;
|
||||
@@ -525,6 +731,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
const nextTelegramChatId = body.telegramChatId !== undefined
|
||||
? String(body.telegramChatId || '').trim()
|
||||
: config.telegramChatId;
|
||||
const nextTelegramMessageThreadId = body.telegramMessageThreadId !== undefined
|
||||
? normalizeTelegramMessageThreadId(body.telegramMessageThreadId)
|
||||
: config.telegramMessageThreadId;
|
||||
if (telegramTouched && nextTelegramEnabled) {
|
||||
if (!nextTelegramBotToken) {
|
||||
return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' });
|
||||
@@ -535,13 +744,22 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
if (!nextTelegramChatId) {
|
||||
return reply.code(400).send({ success: false, message: 'Telegram Chat ID 不能为空(启用 Telegram 时)' });
|
||||
}
|
||||
if (nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) {
|
||||
return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' });
|
||||
}
|
||||
if (nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
|
||||
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
|
||||
}
|
||||
} else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
|
||||
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
|
||||
} else if (body.telegramMessageThreadId !== undefined && nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) {
|
||||
return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' });
|
||||
}
|
||||
|
||||
const checkinScheduleTouched = body.checkinCron !== undefined
|
||||
|| body.checkinScheduleMode !== undefined
|
||||
|| body.checkinIntervalHours !== undefined;
|
||||
|
||||
if (body.checkinCron !== undefined) {
|
||||
if (!cron.validate(body.checkinCron)) {
|
||||
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
|
||||
@@ -549,8 +767,50 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
if (body.checkinCron !== config.checkinCron) {
|
||||
changedLabels.push(`签到 Cron(${config.checkinCron} -> ${body.checkinCron})`);
|
||||
}
|
||||
updateCheckinCron(body.checkinCron);
|
||||
upsertSetting('checkin_cron', body.checkinCron);
|
||||
}
|
||||
|
||||
if (body.checkinScheduleMode !== undefined) {
|
||||
if (body.checkinScheduleMode !== 'cron' && body.checkinScheduleMode !== 'interval') {
|
||||
return reply.code(400).send({ success: false, message: '签到方式无效:仅支持 cron 或 interval' });
|
||||
}
|
||||
if (body.checkinScheduleMode !== config.checkinScheduleMode) {
|
||||
changedLabels.push('签到方式');
|
||||
}
|
||||
config.checkinScheduleMode = body.checkinScheduleMode;
|
||||
}
|
||||
|
||||
if (body.checkinIntervalHours !== undefined) {
|
||||
const intervalHours = Number(body.checkinIntervalHours);
|
||||
if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) {
|
||||
return reply.code(400).send({ success: false, message: '签到间隔必须是 1 到 24 的整数小时' });
|
||||
}
|
||||
const nextIntervalHours = Math.trunc(intervalHours);
|
||||
if (nextIntervalHours !== config.checkinIntervalHours) {
|
||||
changedLabels.push(`签到间隔(${config.checkinIntervalHours}h -> ${nextIntervalHours}h)`);
|
||||
}
|
||||
config.checkinIntervalHours = nextIntervalHours;
|
||||
}
|
||||
|
||||
if (checkinScheduleTouched) {
|
||||
const nextCheckinCron = body.checkinCron !== undefined ? body.checkinCron : config.checkinCron;
|
||||
const nextCheckinScheduleMode: 'cron' | 'interval' = body.checkinScheduleMode !== undefined
|
||||
? body.checkinScheduleMode
|
||||
: config.checkinScheduleMode;
|
||||
const nextCheckinIntervalHours = body.checkinIntervalHours !== undefined
|
||||
? Math.trunc(Number(body.checkinIntervalHours))
|
||||
: config.checkinIntervalHours;
|
||||
|
||||
updateCheckinSchedule({
|
||||
mode: nextCheckinScheduleMode,
|
||||
cronExpr: nextCheckinCron,
|
||||
intervalHours: nextCheckinIntervalHours,
|
||||
});
|
||||
config.checkinCron = nextCheckinCron;
|
||||
config.checkinScheduleMode = nextCheckinScheduleMode;
|
||||
config.checkinIntervalHours = nextCheckinIntervalHours;
|
||||
upsertSetting('checkin_cron', config.checkinCron);
|
||||
upsertSetting('checkin_schedule_mode', config.checkinScheduleMode);
|
||||
upsertSetting('checkin_interval_hours', config.checkinIntervalHours);
|
||||
}
|
||||
|
||||
if (body.balanceRefreshCron !== undefined) {
|
||||
@@ -768,6 +1028,23 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
upsertSetting('telegram_chat_id', config.telegramChatId);
|
||||
}
|
||||
|
||||
if (body.telegramUseSystemProxy !== undefined) {
|
||||
if (!!body.telegramUseSystemProxy !== config.telegramUseSystemProxy) {
|
||||
changedLabels.push('Telegram 使用系统代理');
|
||||
}
|
||||
config.telegramUseSystemProxy = !!body.telegramUseSystemProxy;
|
||||
upsertSetting('telegram_use_system_proxy', config.telegramUseSystemProxy);
|
||||
}
|
||||
|
||||
if (body.telegramMessageThreadId !== undefined) {
|
||||
const nextTelegramMessageThreadId = normalizeTelegramMessageThreadId(body.telegramMessageThreadId);
|
||||
if (nextTelegramMessageThreadId !== config.telegramMessageThreadId) {
|
||||
changedLabels.push('Telegram Topic ID');
|
||||
}
|
||||
config.telegramMessageThreadId = nextTelegramMessageThreadId;
|
||||
upsertSetting('telegram_message_thread_id', config.telegramMessageThreadId);
|
||||
}
|
||||
|
||||
if (body.smtpEnabled !== undefined) {
|
||||
if (!!body.smtpEnabled !== config.smtpEnabled) {
|
||||
changedLabels.push('SMTP 开关');
|
||||
@@ -1010,6 +1287,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
for (const item of result.appliedSettings) {
|
||||
applyImportedSettingToRuntime(item.key, item.value);
|
||||
}
|
||||
if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) {
|
||||
await reloadBackupWebdavScheduler();
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: '导入完成',
|
||||
@@ -1023,6 +1303,65 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/settings/backup/webdav', async () => {
|
||||
return getBackupWebdavConfig();
|
||||
});
|
||||
|
||||
app.put<{ Body: BackupWebdavConfigBody }>('/api/settings/backup/webdav', async (request, reply) => {
|
||||
try {
|
||||
const body = request.body || {};
|
||||
const result = await saveBackupWebdavConfig({
|
||||
enabled: body.enabled === undefined ? undefined : body.enabled === true,
|
||||
fileUrl: body.fileUrl === undefined ? undefined : String(body.fileUrl || ''),
|
||||
username: body.username === undefined ? undefined : String(body.username || ''),
|
||||
password: body.password === undefined ? undefined : String(body.password),
|
||||
clearPassword: body.clearPassword === true,
|
||||
exportType: body.exportType === undefined ? undefined : String(body.exportType || '') as BackupExportType,
|
||||
autoSyncEnabled: body.autoSyncEnabled === undefined ? undefined : body.autoSyncEnabled === true,
|
||||
autoSyncCron: body.autoSyncCron === undefined ? undefined : String(body.autoSyncCron || ''),
|
||||
});
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: err?.message || 'WebDAV 配置保存失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: { type?: string } }>('/api/settings/backup/webdav/export', async (request, reply) => {
|
||||
try {
|
||||
const rawType = typeof request.body?.type === 'string' ? request.body.type.trim().toLowerCase() : '';
|
||||
const type: BackupExportType | undefined = rawType === 'all' || rawType === 'accounts' || rawType === 'preferences'
|
||||
? rawType
|
||||
: undefined;
|
||||
return await exportBackupToWebdav(type);
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: err?.message || 'WebDAV 导出失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/settings/backup/webdav/import', async (_, reply) => {
|
||||
try {
|
||||
const result = await importBackupFromWebdav();
|
||||
for (const item of result.appliedSettings) {
|
||||
applyImportedSettingToRuntime(item.key, item.value);
|
||||
}
|
||||
if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) {
|
||||
await reloadBackupWebdavScheduler();
|
||||
}
|
||||
return result;
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: err?.message || 'WebDAV 导入失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/settings/notify/test', async (_, reply) => {
|
||||
try {
|
||||
const result = await sendNotification(
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const syncSiteAnnouncementsMock = vi.fn();
|
||||
|
||||
vi.mock('../../services/siteAnnouncementService.js', () => ({
|
||||
syncSiteAnnouncements: (...args: unknown[]) => syncSiteAnnouncementsMock(...args),
|
||||
}));
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('site announcements routes', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let closeDbConnections: DbModule['closeDbConnections'] | undefined;
|
||||
let getBackgroundTask: ((taskId: string) => { id: string; status: string } | null) | null = null;
|
||||
let resetBackgroundTasks: (() => void) | null = null;
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-announcements-routes-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./siteAnnouncements.js');
|
||||
const eventsModule = await import('./events.js');
|
||||
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
closeDbConnections = dbModule.closeDbConnections;
|
||||
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
|
||||
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.siteAnnouncementsRoutes);
|
||||
await app.register(eventsModule.eventsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
syncSiteAnnouncementsMock.mockReset();
|
||||
resetBackgroundTasks?.();
|
||||
await db.delete(schema.siteAnnouncements).run();
|
||||
await db.delete(schema.events).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) {
|
||||
await app.close();
|
||||
}
|
||||
if (typeof closeDbConnections === 'function') {
|
||||
await closeDbConnections();
|
||||
}
|
||||
if (dataDir) {
|
||||
try {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('lists announcements with site and unread filters', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub Site',
|
||||
url: 'https://sub.example.com',
|
||||
platform: 'sub2api',
|
||||
}).returning().get();
|
||||
const otherSite = await db.insert(schema.sites).values({
|
||||
name: 'Other Site',
|
||||
url: 'https://other.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteAnnouncements).values([
|
||||
{
|
||||
siteId: site.id,
|
||||
platform: 'sub2api',
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
firstSeenAt: '2026-03-20 10:00:00',
|
||||
lastSeenAt: '2026-03-20 10:00:00',
|
||||
},
|
||||
{
|
||||
siteId: otherSite.id,
|
||||
platform: 'new-api',
|
||||
sourceKey: 'announcement:12',
|
||||
title: 'Other',
|
||||
content: 'Other notice',
|
||||
level: 'info',
|
||||
firstSeenAt: '2026-03-20 09:00:00',
|
||||
lastSeenAt: '2026-03-20 09:00:00',
|
||||
readAt: '2026-03-20 09:05:00',
|
||||
},
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/site-announcements?siteId=${site.id}&read=false`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as Array<{ siteId: number; sourceKey: string }>;
|
||||
expect(body).toEqual([
|
||||
expect.objectContaining({
|
||||
siteId: site.id,
|
||||
sourceKey: 'announcement:11',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes postgres Date values for managed timestamps before filtering unread rows', async () => {
|
||||
const routesModule = await import('./siteAnnouncements.js');
|
||||
|
||||
const rows = routesModule.buildSiteAnnouncementsResponseRows([
|
||||
{
|
||||
id: 1,
|
||||
siteId: 7,
|
||||
platform: 'sub2api',
|
||||
sourceKey: 'announcement:read',
|
||||
title: 'Read row',
|
||||
content: 'Already read',
|
||||
level: 'info',
|
||||
sourceUrl: null,
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
upstreamCreatedAt: '2026-03-10T04:30:08.200Z',
|
||||
upstreamUpdatedAt: '2026-03-10T04:30:08.200Z',
|
||||
firstSeenAt: new Date('2026-03-19T22:41:26.000Z') as never,
|
||||
lastSeenAt: new Date('2026-03-19T22:41:26.000Z') as never,
|
||||
readAt: new Date('2026-03-19T22:45:00.000Z') as never,
|
||||
dismissedAt: null,
|
||||
rawPayload: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
siteId: 8,
|
||||
platform: 'new-api',
|
||||
sourceKey: 'announcement:unread',
|
||||
title: 'Unread row',
|
||||
content: 'Still unread',
|
||||
level: 'info',
|
||||
sourceUrl: null,
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
upstreamCreatedAt: null,
|
||||
upstreamUpdatedAt: null,
|
||||
firstSeenAt: new Date('2026-03-19T22:55:27.000Z') as never,
|
||||
lastSeenAt: new Date('2026-03-19T22:55:27.000Z') as never,
|
||||
readAt: null,
|
||||
dismissedAt: null,
|
||||
rawPayload: null,
|
||||
},
|
||||
] as Array<typeof schema.siteAnnouncements.$inferSelect>, {
|
||||
read: 'false',
|
||||
timeZone: 'Asia/Shanghai',
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 2,
|
||||
firstSeenAt: '2026-03-20 06:55:27',
|
||||
lastSeenAt: '2026-03-20 06:55:27',
|
||||
readAt: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks one announcement as read and then marks all as read', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub Site',
|
||||
url: 'https://sub.example.com',
|
||||
platform: 'sub2api',
|
||||
}).returning().get();
|
||||
const first = await db.insert(schema.siteAnnouncements).values({
|
||||
siteId: site.id,
|
||||
platform: 'sub2api',
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
firstSeenAt: '2026-03-20 10:00:00',
|
||||
lastSeenAt: '2026-03-20 10:00:00',
|
||||
}).returning().get();
|
||||
await db.insert(schema.siteAnnouncements).values({
|
||||
siteId: site.id,
|
||||
platform: 'sub2api',
|
||||
sourceKey: 'announcement:12',
|
||||
title: 'Model online',
|
||||
content: 'gpt-4.1 is available',
|
||||
level: 'info',
|
||||
firstSeenAt: '2026-03-20 11:00:00',
|
||||
lastSeenAt: '2026-03-20 11:00:00',
|
||||
}).run();
|
||||
|
||||
const singleRead = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/site-announcements/${first.id}/read`,
|
||||
});
|
||||
expect(singleRead.statusCode).toBe(200);
|
||||
|
||||
const afterSingle = await db.select().from(schema.siteAnnouncements).where(eq(schema.siteAnnouncements.id, first.id)).get();
|
||||
expect(afterSingle?.readAt).toBeTruthy();
|
||||
|
||||
const bulkRead = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/site-announcements/read-all',
|
||||
});
|
||||
expect(bulkRead.statusCode).toBe(200);
|
||||
|
||||
const rows = await db.select().from(schema.siteAnnouncements).all();
|
||||
expect(rows.every((row) => typeof row.readAt === 'string' && row.readAt.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('clears local announcement rows without touching program events', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub Site',
|
||||
url: 'https://sub.example.com',
|
||||
platform: 'sub2api',
|
||||
}).returning().get();
|
||||
await db.insert(schema.siteAnnouncements).values({
|
||||
siteId: site.id,
|
||||
platform: 'sub2api',
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
firstSeenAt: '2026-03-20 10:00:00',
|
||||
lastSeenAt: '2026-03-20 10:00:00',
|
||||
}).run();
|
||||
await db.insert(schema.events).values({
|
||||
type: 'site_notice',
|
||||
title: '站点公告:Sub Site',
|
||||
message: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
relatedId: 123,
|
||||
relatedType: 'site_announcement',
|
||||
createdAt: '2026-03-20 10:00:00',
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/site-announcements',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(await db.select().from(schema.siteAnnouncements).all()).toEqual([]);
|
||||
expect(await db.select().from(schema.events).all()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('queues a background sync task and returns its task id', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub Site',
|
||||
url: 'https://sub.example.com',
|
||||
platform: 'sub2api',
|
||||
}).returning().get();
|
||||
|
||||
syncSiteAnnouncementsMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/site-announcements/sync',
|
||||
payload: { siteId: site.id },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as { taskId: string; queued: boolean };
|
||||
expect(body.queued).toBe(true);
|
||||
expect(typeof body.taskId).toBe('string');
|
||||
expect(getBackgroundTask?.(body.taskId)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns site_notice rows through the generic events api filter', async () => {
|
||||
await db.insert(schema.events).values({
|
||||
type: 'site_notice',
|
||||
title: '站点公告:Sub Site',
|
||||
message: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
relatedId: 1,
|
||||
relatedType: 'site',
|
||||
createdAt: '2026-03-20 10:00:00',
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/events?type=site_notice',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as Array<{ type: string }>;
|
||||
expect(body).toEqual([expect.objectContaining({ type: 'site_notice' })]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
|
||||
import { syncSiteAnnouncements } from '../../services/siteAnnouncementService.js';
|
||||
|
||||
type SiteAnnouncementRow = typeof schema.siteAnnouncements.$inferSelect;
|
||||
type SiteAnnouncementsResponseFilters = {
|
||||
read?: string;
|
||||
status?: string;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
function formatDateTimePartsInTimeZone(value: Date, timeZone: string): string | null {
|
||||
if (Number.isNaN(value.getTime())) return null;
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = formatter.formatToParts(value);
|
||||
const lookup = new Map(parts.map((part) => [part.type, part.value]));
|
||||
const year = lookup.get('year');
|
||||
const month = lookup.get('month');
|
||||
const day = lookup.get('day');
|
||||
const hour = lookup.get('hour');
|
||||
const minute = lookup.get('minute');
|
||||
const second = lookup.get('second');
|
||||
if (!year || !month || !day || !hour || !minute || !second) return null;
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
function normalizeManagedDateTimeValue(input: unknown, timeZone = getResolvedTimeZone()): string | null {
|
||||
if (input == null) return null;
|
||||
if (input instanceof Date) {
|
||||
return formatDateTimePartsInTimeZone(input, timeZone);
|
||||
}
|
||||
const raw = String(input).trim();
|
||||
return raw || null;
|
||||
}
|
||||
|
||||
function parseTimeValue(input?: unknown): number | null {
|
||||
if (input instanceof Date) {
|
||||
return Number.isNaN(input.getTime()) ? null : input.getTime();
|
||||
}
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw) return null;
|
||||
const parsed = Date.parse(raw.includes('T') ? raw : raw.replace(' ', 'T') + 'Z');
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function hasDateTimeValue(input: unknown): boolean {
|
||||
if (input instanceof Date) {
|
||||
return !Number.isNaN(input.getTime());
|
||||
}
|
||||
return typeof input === 'string' && input.trim().length > 0;
|
||||
}
|
||||
|
||||
export function normalizeSiteAnnouncementRowForResponse(row: SiteAnnouncementRow, timeZone = getResolvedTimeZone()) {
|
||||
return {
|
||||
...row,
|
||||
firstSeenAt: normalizeManagedDateTimeValue((row as { firstSeenAt?: unknown }).firstSeenAt, timeZone),
|
||||
lastSeenAt: normalizeManagedDateTimeValue((row as { lastSeenAt?: unknown }).lastSeenAt, timeZone),
|
||||
readAt: normalizeManagedDateTimeValue((row as { readAt?: unknown }).readAt, timeZone),
|
||||
dismissedAt: normalizeManagedDateTimeValue((row as { dismissedAt?: unknown }).dismissedAt, timeZone),
|
||||
};
|
||||
}
|
||||
|
||||
function applyReadFilter(rows: SiteAnnouncementRow[], read?: string): SiteAnnouncementRow[] {
|
||||
if (read === 'true') {
|
||||
return rows.filter((row) => hasDateTimeValue((row as { readAt?: unknown }).readAt));
|
||||
}
|
||||
if (read === 'false') {
|
||||
return rows.filter((row) => !hasDateTimeValue((row as { readAt?: unknown }).readAt));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function applyStatusFilter(rows: SiteAnnouncementRow[], status?: string): SiteAnnouncementRow[] {
|
||||
const normalized = String(status || '').trim().toLowerCase();
|
||||
if (!normalized) return rows;
|
||||
const now = Date.now();
|
||||
if (normalized === 'dismissed') {
|
||||
return rows.filter((row) => hasDateTimeValue((row as { dismissedAt?: unknown }).dismissedAt));
|
||||
}
|
||||
if (normalized === 'expired') {
|
||||
return rows.filter((row) => !row.dismissedAt && (() => {
|
||||
const endsAt = parseTimeValue((row as { endsAt?: unknown }).endsAt);
|
||||
return endsAt !== null && endsAt < now;
|
||||
})());
|
||||
}
|
||||
if (normalized === 'active') {
|
||||
return rows.filter((row) => !row.dismissedAt && (() => {
|
||||
const endsAt = parseTimeValue((row as { endsAt?: unknown }).endsAt);
|
||||
return endsAt === null || endsAt >= now;
|
||||
})());
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildSiteAnnouncementsResponseRows(
|
||||
rows: SiteAnnouncementRow[],
|
||||
options?: SiteAnnouncementsResponseFilters,
|
||||
) {
|
||||
const normalizedRows = rows.map((row) => normalizeSiteAnnouncementRowForResponse(row, options?.timeZone));
|
||||
return applyStatusFilter(applyReadFilter(normalizedRows, options?.read), options?.status);
|
||||
}
|
||||
|
||||
export async function siteAnnouncementsRoutes(app: FastifyInstance) {
|
||||
app.get<{
|
||||
Querystring: {
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
siteId?: string;
|
||||
platform?: string;
|
||||
read?: string;
|
||||
status?: string;
|
||||
};
|
||||
}>('/api/site-announcements', async (request) => {
|
||||
const limit = Math.max(1, Math.min(500, Number.parseInt(request.query.limit || '50', 10)));
|
||||
const offset = Math.max(0, Number.parseInt(request.query.offset || '0', 10));
|
||||
const filters: any[] = [];
|
||||
|
||||
const siteId = Number.parseInt(String(request.query.siteId || ''), 10);
|
||||
if (Number.isFinite(siteId) && siteId > 0) {
|
||||
filters.push(eq(schema.siteAnnouncements.siteId, siteId));
|
||||
}
|
||||
|
||||
const platform = String(request.query.platform || '').trim();
|
||||
if (platform) {
|
||||
filters.push(eq(schema.siteAnnouncements.platform, platform));
|
||||
}
|
||||
|
||||
const base = db.select().from(schema.siteAnnouncements);
|
||||
const rows = filters.length > 0
|
||||
? await base.where(and(...filters)).orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all()
|
||||
: await base.orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all();
|
||||
|
||||
const filtered = buildSiteAnnouncementsResponseRows(rows, {
|
||||
read: request.query.read,
|
||||
status: request.query.status,
|
||||
});
|
||||
return filtered.slice(offset, offset + limit);
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/site-announcements/:id/read', async (request) => {
|
||||
const id = Number.parseInt(request.params.id, 10);
|
||||
const readAt = formatUtcSqlDateTime(new Date());
|
||||
await db.update(schema.siteAnnouncements)
|
||||
.set({ readAt })
|
||||
.where(eq(schema.siteAnnouncements.id, id))
|
||||
.run();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
app.post('/api/site-announcements/read-all', async () => {
|
||||
const readAt = formatUtcSqlDateTime(new Date());
|
||||
await db.update(schema.siteAnnouncements)
|
||||
.set({ readAt })
|
||||
.run();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
app.delete('/api/site-announcements', async () => {
|
||||
await db.delete(schema.siteAnnouncements).run();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
app.post<{ Body: { siteId?: number | string | null } }>('/api/site-announcements/sync', async (request) => {
|
||||
const parsedSiteId = Number.parseInt(String(request.body?.siteId ?? ''), 10);
|
||||
const siteId = Number.isFinite(parsedSiteId) && parsedSiteId > 0 ? parsedSiteId : null;
|
||||
const { task, reused } = startBackgroundTask(
|
||||
{
|
||||
type: 'site-announcements-sync',
|
||||
title: siteId ? `同步站点公告 #${siteId}` : '同步站点公告',
|
||||
dedupeKey: siteId ? `site-announcements:${siteId}` : 'site-announcements:all',
|
||||
notifyOnSuccess: false,
|
||||
notifyOnFailure: false,
|
||||
},
|
||||
() => syncSiteAnnouncements(siteId ? { siteId } : undefined),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
queued: true,
|
||||
reused,
|
||||
taskId: task.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('sites system proxy settings', () => {
|
||||
describe('sites proxy settings', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
@@ -36,7 +36,7 @@ describe('sites system proxy settings', () => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('stores useSystemProxy, external checkin url, and custom headers when creating a site', async () => {
|
||||
it('stores proxy settings, external checkin url, and custom headers when creating a site', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
@@ -44,6 +44,7 @@ describe('sites system proxy settings', () => {
|
||||
name: 'proxy-site',
|
||||
url: 'https://proxy-site.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||
useSystemProxy: true,
|
||||
customHeaders: JSON.stringify({
|
||||
'cf-access-client-id': 'site-client-id',
|
||||
@@ -56,11 +57,13 @@ describe('sites system proxy settings', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json() as {
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
customHeaders?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
globalWeight?: number;
|
||||
};
|
||||
expect(payload.proxyUrl).toBe('socks5://127.0.0.1:1080');
|
||||
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');
|
||||
@@ -83,6 +86,22 @@ describe('sites system proxy settings', () => {
|
||||
expect((response.json() as { error?: string }).error).toContain('Invalid useSystemProxy');
|
||||
});
|
||||
|
||||
it('rejects invalid proxy url', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'proxy-site',
|
||||
url: 'https://proxy-site.example.com',
|
||||
platform: 'new-api',
|
||||
proxyUrl: 'not-a-proxy',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl');
|
||||
});
|
||||
|
||||
it('rejects invalid site global weight', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -115,7 +134,7 @@ describe('sites system proxy settings', () => {
|
||||
expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl');
|
||||
});
|
||||
|
||||
it('updates useSystemProxy for an existing site', async () => {
|
||||
it('updates per-site proxy settings for an existing site', async () => {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
@@ -133,12 +152,15 @@ describe('sites system proxy settings', () => {
|
||||
method: 'PUT',
|
||||
url: `/api/sites/${site.id}`,
|
||||
payload: {
|
||||
proxyUrl: 'http://127.0.0.1:8080',
|
||||
useSystemProxy: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true);
|
||||
const payload = response.json() as { proxyUrl?: string | null; useSystemProxy?: boolean };
|
||||
expect(payload.proxyUrl).toBe('http://127.0.0.1:8080');
|
||||
expect(payload.useSystemProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid custom headers json', async () => {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import {
|
||||
buildStoredSub2ApiSubscriptionSummary,
|
||||
mergeAccountExtraConfig,
|
||||
} from '../../services/accountExtraConfig.js';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('sites route subscription summary aggregation', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-subscription-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./sites.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.sitesRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('aggregates stored sub2api subscription summaries into /api/sites', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'sub2api-site',
|
||||
url: 'https://sub2api.example.com',
|
||||
platform: 'sub2api',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.accounts).values([
|
||||
{
|
||||
siteId: site.id,
|
||||
username: 'user-a',
|
||||
accessToken: 'token-a',
|
||||
balance: 10,
|
||||
status: 'active',
|
||||
extraConfig: mergeAccountExtraConfig(null, {
|
||||
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
|
||||
activeCount: 1,
|
||||
totalUsedUsd: 3,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 11,
|
||||
groupName: 'Pro',
|
||||
expiresAt: '2026-04-10T00:00:00.000Z',
|
||||
monthlyUsedUsd: 3,
|
||||
monthlyLimitUsd: 20,
|
||||
},
|
||||
],
|
||||
}, 1760000000000),
|
||||
}),
|
||||
},
|
||||
{
|
||||
siteId: site.id,
|
||||
username: 'user-b',
|
||||
accessToken: 'token-b',
|
||||
balance: 5,
|
||||
status: 'active',
|
||||
extraConfig: mergeAccountExtraConfig(null, {
|
||||
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
|
||||
activeCount: 1,
|
||||
totalUsedUsd: 2,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 12,
|
||||
groupName: 'Lite',
|
||||
expiresAt: '2026-03-25T00:00:00.000Z',
|
||||
monthlyUsedUsd: 2,
|
||||
monthlyLimitUsd: 10,
|
||||
},
|
||||
],
|
||||
}, 1760000002000),
|
||||
}),
|
||||
},
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sites',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as Array<{
|
||||
totalBalance: number;
|
||||
subscriptionSummary: {
|
||||
activeCount: number;
|
||||
totalUsedUsd: number;
|
||||
totalMonthlyLimitUsd: number | null;
|
||||
totalRemainingUsd: number | null;
|
||||
nextExpiresAt: string | null;
|
||||
planNames: string[];
|
||||
updatedAt: number | null;
|
||||
} | null;
|
||||
}>;
|
||||
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]?.totalBalance).toBe(15);
|
||||
expect(body[0]?.subscriptionSummary).toEqual({
|
||||
activeCount: 2,
|
||||
totalUsedUsd: 5,
|
||||
totalMonthlyLimitUsd: 30,
|
||||
totalRemainingUsd: 25,
|
||||
nextExpiresAt: '2026-03-25T00:00:00.000Z',
|
||||
planNames: ['Pro', 'Lite'],
|
||||
updatedAt: 1760000002000,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { detectSite } from '../../services/siteDetector.js';
|
||||
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
|
||||
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
||||
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
|
||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||
if (input === undefined || input === null) return null;
|
||||
@@ -75,6 +76,65 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
|
||||
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
|
||||
}
|
||||
|
||||
type SiteSubscriptionAggregate = {
|
||||
activeCount: number;
|
||||
totalUsedUsd: number;
|
||||
totalMonthlyLimitUsd: number | null;
|
||||
totalRemainingUsd: number | null;
|
||||
nextExpiresAt: string | null;
|
||||
planNames: string[];
|
||||
updatedAt: number | null;
|
||||
};
|
||||
|
||||
function roundMetric(value: number): number {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function pickEarlierIsoDate(current?: string | null, next?: string | null): string | null {
|
||||
if (!current) return next || null;
|
||||
if (!next) return current;
|
||||
const currentMs = Date.parse(current);
|
||||
const nextMs = Date.parse(next);
|
||||
if (!Number.isFinite(currentMs)) return next;
|
||||
if (!Number.isFinite(nextMs)) return current;
|
||||
return nextMs < currentMs ? next : current;
|
||||
}
|
||||
|
||||
function aggregateSiteSubscription(
|
||||
current: SiteSubscriptionAggregate | undefined,
|
||||
extraConfig?: string | null,
|
||||
): SiteSubscriptionAggregate | undefined {
|
||||
const stored = getSub2ApiSubscriptionFromExtraConfig(extraConfig);
|
||||
if (!stored) return current;
|
||||
|
||||
const planNames = new Set(current?.planNames || []);
|
||||
let totalMonthlyLimitUsd = current?.totalMonthlyLimitUsd ?? null;
|
||||
let nextExpiresAt = current?.nextExpiresAt ?? null;
|
||||
|
||||
for (const item of stored.subscriptions) {
|
||||
if (item.groupName) planNames.add(item.groupName);
|
||||
if (typeof item.monthlyLimitUsd === 'number' && Number.isFinite(item.monthlyLimitUsd)) {
|
||||
totalMonthlyLimitUsd = roundMetric((totalMonthlyLimitUsd ?? 0) + item.monthlyLimitUsd);
|
||||
}
|
||||
nextExpiresAt = pickEarlierIsoDate(nextExpiresAt, item.expiresAt);
|
||||
}
|
||||
|
||||
const totalUsedUsd = roundMetric((current?.totalUsedUsd || 0) + stored.totalUsedUsd);
|
||||
const totalRemainingUsd = totalMonthlyLimitUsd == null
|
||||
? null
|
||||
: roundMetric(Math.max(0, totalMonthlyLimitUsd - totalUsedUsd));
|
||||
|
||||
return {
|
||||
activeCount: (current?.activeCount || 0) + stored.activeCount,
|
||||
totalUsedUsd,
|
||||
totalMonthlyLimitUsd,
|
||||
totalRemainingUsd,
|
||||
nextExpiresAt,
|
||||
planNames: Array.from(planNames),
|
||||
updatedAt: Math.max(current?.updatedAt || 0, stored.updatedAt || 0) || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sitesRoutes(app: FastifyInstance) {
|
||||
function invalidateSiteCaches() {
|
||||
invalidateSiteProxyCache();
|
||||
@@ -137,21 +197,23 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
// List all sites
|
||||
app.get('/api/sites', async () => {
|
||||
const siteRows = await db.select().from(schema.sites).all();
|
||||
const accountBalanceRows = await db.select({
|
||||
const accountRows = await db.select({
|
||||
siteId: schema.accounts.siteId,
|
||||
totalBalance: sql<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
|
||||
}).from(schema.accounts)
|
||||
.groupBy(schema.accounts.siteId)
|
||||
.all();
|
||||
balance: schema.accounts.balance,
|
||||
extraConfig: schema.accounts.extraConfig,
|
||||
}).from(schema.accounts).all();
|
||||
|
||||
const totalBalanceBySiteId: Record<number, number> = {};
|
||||
for (const row of accountBalanceRows) {
|
||||
totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0);
|
||||
const subscriptionBySiteId: Record<number, SiteSubscriptionAggregate | undefined> = {};
|
||||
for (const row of accountRows) {
|
||||
totalBalanceBySiteId[row.siteId] = roundMetric((totalBalanceBySiteId[row.siteId] || 0) + Number(row.balance || 0));
|
||||
subscriptionBySiteId[row.siteId] = aggregateSiteSubscription(subscriptionBySiteId[row.siteId], row.extraConfig);
|
||||
}
|
||||
|
||||
return siteRows.map((site) => ({
|
||||
...site,
|
||||
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
|
||||
subscriptionSummary: subscriptionBySiteId[site.id] || null,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -160,6 +222,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name: string;
|
||||
url: string;
|
||||
platform?: string;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
customHeaders?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
@@ -168,7 +231,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
sortOrder?: number;
|
||||
globalWeight?: number;
|
||||
} }>('/api/sites', async (request, reply) => {
|
||||
const { name, url, platform, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
|
||||
const { name, url, platform, proxyUrl, 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.' });
|
||||
@@ -177,6 +240,10 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
|
||||
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
|
||||
}
|
||||
const normalizedProxyUrl = parseSiteProxyUrlInput(proxyUrl);
|
||||
if (!normalizedProxyUrl.valid) {
|
||||
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
|
||||
}
|
||||
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl);
|
||||
if (!normalizedExternalCheckinUrl.valid) {
|
||||
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
|
||||
@@ -213,6 +280,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name,
|
||||
url: url.replace(/\/+$/, ''),
|
||||
platform: detectedPlatform,
|
||||
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||
customHeaders: normalizedCustomHeaders.customHeaders,
|
||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
||||
@@ -238,6 +306,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name?: string;
|
||||
url?: string;
|
||||
platform?: string;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
customHeaders?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
@@ -266,6 +335,10 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
|
||||
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
|
||||
}
|
||||
const normalizedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl);
|
||||
if (!normalizedProxyUrl.valid) {
|
||||
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
|
||||
}
|
||||
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl);
|
||||
if (!normalizedExternalCheckinUrl.valid) {
|
||||
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
|
||||
@@ -290,6 +363,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
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 (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
||||
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
||||
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
|
||||
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
|
||||
|
||||
@@ -75,6 +75,10 @@ describe('stats proxy logs routes', () => {
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
clientFamily: 'generic',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
promptTokens: 10,
|
||||
completionTokens: 5,
|
||||
totalTokens: 15,
|
||||
@@ -88,6 +92,7 @@ describe('stats proxy logs routes', () => {
|
||||
modelRequested: 'gpt-4o-mini',
|
||||
modelActual: 'gpt-4o-mini',
|
||||
status: 'failed',
|
||||
clientFamily: 'codex',
|
||||
promptTokens: 8,
|
||||
completionTokens: 2,
|
||||
totalTokens: 10,
|
||||
@@ -139,6 +144,10 @@ describe('stats proxy logs routes', () => {
|
||||
totalCost: number;
|
||||
totalTokensAll: number;
|
||||
};
|
||||
clientOptions: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(body.page).toBe(2);
|
||||
@@ -150,7 +159,14 @@ describe('stats proxy logs routes', () => {
|
||||
expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key');
|
||||
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A');
|
||||
expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']);
|
||||
expect(body.items[0]?.clientFamily).toBe('codex');
|
||||
expect(body.items[0]?.clientAppId).toBe(null);
|
||||
expect(body.items[0]?.clientAppName).toBe(null);
|
||||
expect(body.items[0]?.clientConfidence).toBe(null);
|
||||
expect(body.items[0]).not.toHaveProperty('billingDetails');
|
||||
expect(body.clientOptions).toEqual([
|
||||
{ value: 'family:codex', label: '协议 · Codex' },
|
||||
]);
|
||||
expect(body.summary).toEqual({
|
||||
totalCount: 3,
|
||||
successCount: 1,
|
||||
@@ -188,6 +204,10 @@ describe('stats proxy logs routes', () => {
|
||||
modelRequested: 'gpt-5',
|
||||
modelActual: 'gpt-5',
|
||||
status: 'success',
|
||||
clientFamily: 'codex',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
promptTokens: 100,
|
||||
completionTokens: 20,
|
||||
totalTokens: 120,
|
||||
@@ -214,6 +234,10 @@ describe('stats proxy logs routes', () => {
|
||||
downstreamKeyName: string | null;
|
||||
downstreamKeyGroupName: string | null;
|
||||
downstreamKeyTags: string[];
|
||||
clientFamily: string | null;
|
||||
clientAppId: string | null;
|
||||
clientAppName: string | null;
|
||||
clientConfidence: string | null;
|
||||
billingDetails: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
@@ -223,6 +247,10 @@ describe('stats proxy logs routes', () => {
|
||||
expect(body.downstreamKeyName).toBe('detail-key');
|
||||
expect(body.downstreamKeyGroupName).toBe('测试项目');
|
||||
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
|
||||
expect(body.clientFamily).toBe('codex');
|
||||
expect(body.clientAppId).toBe('cherry_studio');
|
||||
expect(body.clientAppName).toBe('Cherry Studio');
|
||||
expect(body.clientConfidence).toBe('exact');
|
||||
expect(body.billingDetails).toMatchObject({
|
||||
breakdown: { totalCost: 0.12 },
|
||||
usage: { promptTokens: 100, completionTokens: 20 },
|
||||
@@ -392,4 +420,118 @@ describe('stats proxy logs routes', () => {
|
||||
totalTokensAll: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters proxy logs by app id while keeping client options scoped only by the other filters', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'client-filter-site',
|
||||
url: 'https://client-filter.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'client-filter-user',
|
||||
accessToken: 'client-filter-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.proxyLogs).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'success',
|
||||
clientFamily: 'generic',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
totalTokens: 12,
|
||||
estimatedCost: 0.12,
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:00:00.000Z')),
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4.1',
|
||||
modelActual: 'gpt-4.1',
|
||||
status: 'failed',
|
||||
clientFamily: 'codex',
|
||||
totalTokens: 22,
|
||||
estimatedCost: 0.22,
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:05:00.000Z')),
|
||||
},
|
||||
]).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stats/proxy-logs?client=app%3Acherry_studio',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
total: number;
|
||||
items: Array<Record<string, unknown>>;
|
||||
clientOptions: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(body.total).toBe(1);
|
||||
expect(body.items[0]?.clientAppId).toBe('cherry_studio');
|
||||
expect(body.clientOptions).toEqual([
|
||||
{ value: 'app:cherry_studio', label: '应用 · Cherry Studio' },
|
||||
{ value: 'family:codex', label: '协议 · Codex' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to legacy client prefixes for old logs without inferring an app fingerprint', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'legacy-site',
|
||||
url: 'https://legacy.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'legacy-user',
|
||||
accessToken: 'legacy-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const inserted = await db.insert(schema.proxyLogs).values({
|
||||
accountId: account.id,
|
||||
modelRequested: 'gpt-4o',
|
||||
modelActual: 'gpt-4o',
|
||||
status: 'failed',
|
||||
errorMessage: '[client:codex] [session:turn-123] [downstream:/v1/responses] upstream error',
|
||||
totalTokens: 9,
|
||||
estimatedCost: 0.09,
|
||||
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T12:00:00.000Z')),
|
||||
}).run();
|
||||
|
||||
const logId = Number(inserted.lastInsertRowid || 0);
|
||||
const listResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/stats/proxy-logs',
|
||||
});
|
||||
const detailResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/stats/proxy-logs/${logId}`,
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(detailResponse.statusCode).toBe(200);
|
||||
|
||||
const listBody = listResponse.json() as {
|
||||
items: Array<Record<string, unknown>>;
|
||||
};
|
||||
const detailBody = detailResponse.json() as Record<string, unknown>;
|
||||
|
||||
expect(listBody.items[0]?.clientFamily).toBe('codex');
|
||||
expect(listBody.items[0]?.clientAppId).toBe(null);
|
||||
expect(listBody.items[0]?.clientAppName).toBe(null);
|
||||
expect(detailBody.clientFamily).toBe('codex');
|
||||
expect(detailBody.clientAppId).toBe(null);
|
||||
expect(detailBody.clientAppName).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
parseProxyLogBillingDetails,
|
||||
withProxyLogSelectFields,
|
||||
} from '../../services/proxyLogStore.js';
|
||||
import { parseProxyLogMessageMeta } from '../proxy/logPathMeta.js';
|
||||
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
|
||||
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
|
||||
import {
|
||||
@@ -85,6 +86,22 @@ function proxyCostSqlExpression() {
|
||||
}
|
||||
|
||||
type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
||||
type ProxyLogClientFilter = {
|
||||
kind: 'app' | 'family';
|
||||
value: string;
|
||||
} | null;
|
||||
|
||||
type ProxyLogClientOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
|
||||
codex: 'Codex',
|
||||
claude_code: 'Claude Code',
|
||||
gemini_cli: 'Gemini CLI',
|
||||
generic: '通用',
|
||||
};
|
||||
|
||||
function normalizeProxyLogPageSize(raw?: string): number {
|
||||
const parsed = Number.parseInt(raw || '50', 10);
|
||||
@@ -115,6 +132,20 @@ function normalizeProxyLogSiteId(raw?: string): number | null {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeProxyLogClientFilter(raw?: string): ProxyLogClientFilter {
|
||||
const text = (raw || '').trim();
|
||||
if (!text) return null;
|
||||
const separatorIndex = text.indexOf(':');
|
||||
if (separatorIndex <= 0) return null;
|
||||
const kind = text.slice(0, separatorIndex).trim().toLowerCase();
|
||||
const value = text.slice(separatorIndex + 1).trim().toLowerCase();
|
||||
if (!value) return null;
|
||||
if (kind === 'app' || kind === 'family') {
|
||||
return { kind, value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeProxyLogTimeBoundary(raw?: string): string | null {
|
||||
const text = (raw || '').trim();
|
||||
if (!text) return null;
|
||||
@@ -166,9 +197,18 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildProxyLogClientCondition(client: ProxyLogClientFilter) {
|
||||
if (!client) return null;
|
||||
if (client.kind === 'app') {
|
||||
return eq(schema.proxyLogs.clientAppId, client.value);
|
||||
}
|
||||
return eq(schema.proxyLogs.clientFamily, client.value);
|
||||
}
|
||||
|
||||
function buildProxyLogWhereClause(params: {
|
||||
status?: ProxyLogStatusFilter;
|
||||
search?: string;
|
||||
client?: ProxyLogClientFilter;
|
||||
siteId?: number | null;
|
||||
fromUtc?: string | null;
|
||||
toUtc?: string | null;
|
||||
@@ -176,6 +216,7 @@ function buildProxyLogWhereClause(params: {
|
||||
const conditions = [
|
||||
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
||||
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
||||
params.client ? buildProxyLogClientCondition(params.client) : null,
|
||||
params.siteId ? eq(schema.sites.id, params.siteId) : null,
|
||||
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
|
||||
params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : null,
|
||||
@@ -189,6 +230,83 @@ function toRoundedMicroNumber(value: number | null | undefined): number {
|
||||
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
function normalizeNullableText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function normalizeClientConfidence(value: unknown): string | null {
|
||||
const normalized = normalizeNullableText(value)?.toLowerCase() || null;
|
||||
if (normalized === 'exact' || normalized === 'heuristic' || normalized === 'unknown') {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function displayProxyLogClientFamily(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
return PROXY_LOG_CLIENT_FAMILY_LABELS[value] || value;
|
||||
}
|
||||
|
||||
function resolveProxyLogClientMeta(proxyLog: Record<string, unknown>) {
|
||||
const clientFamily = normalizeNullableText(proxyLog.clientFamily)?.toLowerCase() || null;
|
||||
const clientAppId = normalizeNullableText(proxyLog.clientAppId)?.toLowerCase() || null;
|
||||
const clientAppName = normalizeNullableText(proxyLog.clientAppName) || null;
|
||||
const clientConfidence = normalizeClientConfidence(proxyLog.clientConfidence);
|
||||
|
||||
if (clientFamily || clientAppId || clientAppName || clientConfidence) {
|
||||
return {
|
||||
clientFamily,
|
||||
clientAppId,
|
||||
clientAppName,
|
||||
clientConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyMeta = parseProxyLogMessageMeta(typeof proxyLog.errorMessage === 'string' ? proxyLog.errorMessage : '');
|
||||
return {
|
||||
clientFamily: normalizeNullableText(legacyMeta.clientKind)?.toLowerCase() || null,
|
||||
clientAppId: null,
|
||||
clientAppName: null,
|
||||
clientConfidence: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildProxyLogClientOptions(rows: Array<{
|
||||
clientFamily?: string | null;
|
||||
clientAppId?: string | null;
|
||||
clientAppName?: string | null;
|
||||
}>): ProxyLogClientOption[] {
|
||||
const appOptions = new Map<string, ProxyLogClientOption>();
|
||||
const familyOptions = new Map<string, ProxyLogClientOption>();
|
||||
|
||||
for (const row of rows) {
|
||||
const clientAppId = normalizeNullableText(row.clientAppId)?.toLowerCase() || null;
|
||||
const clientAppName = normalizeNullableText(row.clientAppName) || null;
|
||||
const clientFamily = normalizeNullableText(row.clientFamily)?.toLowerCase() || null;
|
||||
|
||||
if (clientAppId && clientAppName && !appOptions.has(clientAppId)) {
|
||||
appOptions.set(clientAppId, {
|
||||
value: `app:${clientAppId}`,
|
||||
label: `应用 · ${clientAppName}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (clientFamily && clientFamily !== 'generic' && !familyOptions.has(clientFamily)) {
|
||||
familyOptions.set(clientFamily, {
|
||||
value: `family:${clientFamily}`,
|
||||
label: `协议 · ${displayProxyLogClientFamily(clientFamily) || clientFamily}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...Array.from(appOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
|
||||
...Array.from(familyOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
|
||||
];
|
||||
}
|
||||
|
||||
const SITE_AVAILABILITY_BUCKET_COUNT = 24;
|
||||
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
|
||||
|
||||
@@ -351,11 +469,16 @@ function mapProxyLogRow(
|
||||
},
|
||||
options?: { includeBillingDetails?: boolean },
|
||||
) {
|
||||
const clientMeta = resolveProxyLogClientMeta(row.proxy_logs);
|
||||
return {
|
||||
...row.proxy_logs,
|
||||
...(options?.includeBillingDetails
|
||||
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
||||
: {}),
|
||||
clientFamily: clientMeta.clientFamily,
|
||||
clientAppId: clientMeta.clientAppId,
|
||||
clientAppName: clientMeta.clientAppName,
|
||||
clientConfidence: clientMeta.clientConfidence,
|
||||
username: row.accounts?.username || null,
|
||||
siteId: row.sites?.id || null,
|
||||
siteName: row.sites?.name || null,
|
||||
@@ -547,6 +670,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
offset?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
client?: string;
|
||||
siteId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -555,11 +679,13 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
const offset = normalizeProxyLogOffset(request.query.offset);
|
||||
const status = normalizeProxyLogStatusFilter(request.query.status);
|
||||
const search = normalizeProxyLogSearch(request.query.search);
|
||||
const client = normalizeProxyLogClientFilter(request.query.client);
|
||||
const siteId = normalizeProxyLogSiteId(request.query.siteId);
|
||||
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
|
||||
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
|
||||
const listWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
||||
const summaryWhere = buildProxyLogWhereClause({ search, siteId, fromUtc, toUtc });
|
||||
const listWhere = buildProxyLogWhereClause({ status, search, client, siteId, fromUtc, toUtc });
|
||||
const summaryWhere = buildProxyLogWhereClause({ search, client, siteId, fromUtc, toUtc });
|
||||
const clientOptionsWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
||||
|
||||
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
||||
let query = db.select({
|
||||
@@ -604,6 +730,31 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
const totalRow = await totalQuery.get();
|
||||
|
||||
const clientOptionRows = await withProxyLogSelectFields(({ fields, includeClientFields }) => {
|
||||
if (!includeClientFields) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
let query = db.select({
|
||||
clientFamily: fields.clientFamily!,
|
||||
clientAppId: fields.clientAppId!,
|
||||
clientAppName: fields.clientAppName!,
|
||||
}).from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
|
||||
|
||||
if (clientOptionsWhere) {
|
||||
query = query.where(clientOptionsWhere) as typeof query;
|
||||
}
|
||||
|
||||
return query.all();
|
||||
}, { includeBillingDetails: false, includeClientFields: true }) as Array<{
|
||||
clientFamily?: string | null;
|
||||
clientAppId?: string | null;
|
||||
clientAppName?: string | null;
|
||||
}>;
|
||||
|
||||
let summaryQuery = db.select({
|
||||
totalCount: sql<number>`count(*)`,
|
||||
successCount: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
|
||||
@@ -624,6 +775,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
total: Number(totalRow?.total || 0),
|
||||
page: Math.floor(offset / limit) + 1,
|
||||
pageSize: limit,
|
||||
clientOptions: buildProxyLogClientOptions(clientOptionRows),
|
||||
summary: {
|
||||
totalCount: Number(summaryRow?.totalCount || 0),
|
||||
successCount: Number(summaryRow?.successCount || 0),
|
||||
|
||||
Reference in New Issue
Block a user