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:
Hureru
2026-03-21 10:09:19 +08:00
198 changed files with 20039 additions and 1105 deletions
+22 -1
View File
@@ -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();
});
});
+30 -7
View File
@@ -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();
});
});
+81 -1
View File
@@ -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');
});
});
+348 -9
View File
@@ -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' })]);
});
});
+196
View File
@@ -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,
};
});
}
+26 -4
View File
@@ -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,
});
});
});
+84 -10
View File
@@ -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);
});
});
+154 -2
View File
@@ -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),