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:
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildStoredSub2ApiSubscriptionSummary,
|
||||
getCredentialModeFromExtraConfig,
|
||||
getPlatformUserIdFromExtraConfig,
|
||||
getProxyUrlFromExtraConfig,
|
||||
getSub2ApiAuthFromExtraConfig,
|
||||
getSub2ApiSubscriptionFromExtraConfig,
|
||||
guessPlatformUserIdFromUsername,
|
||||
mergeAccountExtraConfig,
|
||||
normalizeCredentialMode,
|
||||
@@ -120,4 +122,37 @@ describe('accountExtraConfig', () => {
|
||||
extraConfig: JSON.stringify({ credentialMode: 'session' }),
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('parses stored sub2api subscription summary from extra config', () => {
|
||||
const extraConfig = mergeAccountExtraConfig(null, {
|
||||
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
|
||||
activeCount: 1,
|
||||
totalUsedUsd: 3.5,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 7,
|
||||
groupName: 'Pro',
|
||||
expiresAt: '2026-04-01T00:00:00.000Z',
|
||||
monthlyUsedUsd: 3.5,
|
||||
monthlyLimitUsd: 20,
|
||||
},
|
||||
],
|
||||
}, 1760000000000),
|
||||
});
|
||||
|
||||
expect(getSub2ApiSubscriptionFromExtraConfig(extraConfig)).toEqual({
|
||||
activeCount: 1,
|
||||
totalUsedUsd: 3.5,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 7,
|
||||
groupName: 'Pro',
|
||||
expiresAt: '2026-04-01T00:00:00.000Z',
|
||||
monthlyUsedUsd: 3.5,
|
||||
monthlyLimitUsd: 20,
|
||||
},
|
||||
],
|
||||
updatedAt: 1760000000000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SubscriptionPlanSummary, SubscriptionSummary } from './platforms/base.js';
|
||||
|
||||
type AutoReloginConfig = {
|
||||
username?: unknown;
|
||||
passwordCipher?: unknown;
|
||||
@@ -9,6 +11,13 @@ type Sub2ApiAuthConfig = {
|
||||
tokenExpiresAt?: unknown;
|
||||
};
|
||||
|
||||
type Sub2ApiSubscriptionConfig = {
|
||||
updatedAt?: unknown;
|
||||
activeCount?: unknown;
|
||||
totalUsedUsd?: unknown;
|
||||
subscriptions?: unknown;
|
||||
};
|
||||
|
||||
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
|
||||
|
||||
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
|
||||
@@ -26,6 +35,7 @@ type AccountExtraConfig = {
|
||||
};
|
||||
autoRelogin?: AutoReloginConfig;
|
||||
sub2apiAuth?: Sub2ApiAuthConfig;
|
||||
sub2apiSubscription?: Sub2ApiSubscriptionConfig;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -64,6 +74,39 @@ function normalizeTimestampMs(raw: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeNonNegativeNumber(raw: unknown): number | undefined {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
|
||||
return Math.round(raw * 1_000_000) / 1_000_000;
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw.trim());
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return Math.round(parsed * 1_000_000) / 1_000_000;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeIsoDateTime(raw: unknown): string | undefined {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
@@ -134,6 +177,10 @@ export type ManagedSub2ApiAuth = {
|
||||
tokenExpiresAt?: number;
|
||||
};
|
||||
|
||||
export type StoredSub2ApiSubscriptionSummary = SubscriptionSummary & {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
|
||||
const parsed = parseExtraConfig(extraConfig);
|
||||
const raw = parsed.sub2apiAuth;
|
||||
@@ -146,6 +193,103 @@ export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): Mana
|
||||
: { refreshToken };
|
||||
}
|
||||
|
||||
function normalizeSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
|
||||
const item = raw as Record<string, unknown>;
|
||||
const normalized: SubscriptionPlanSummary = {};
|
||||
|
||||
const id = normalizeUserId(item.id);
|
||||
if (id) normalized.id = id;
|
||||
|
||||
const groupId = normalizeUserId(item.groupId ?? item.group_id);
|
||||
if (groupId) normalized.groupId = groupId;
|
||||
|
||||
const groupName = normalizeNonEmptyString(item.groupName ?? item.group_name);
|
||||
if (groupName) normalized.groupName = groupName;
|
||||
|
||||
const status = normalizeNonEmptyString(item.status);
|
||||
if (status) normalized.status = status;
|
||||
|
||||
const expiresAt = normalizeIsoDateTime(
|
||||
item.expiresAt
|
||||
?? item.expires_at
|
||||
?? item.expiredAt
|
||||
?? item.expired_at
|
||||
?? item.endAt
|
||||
?? item.end_at,
|
||||
);
|
||||
if (expiresAt) normalized.expiresAt = expiresAt;
|
||||
|
||||
const dailyUsedUsd = normalizeNonNegativeNumber(item.dailyUsedUsd ?? item.daily_used_usd);
|
||||
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
|
||||
|
||||
const dailyLimitUsd = normalizeNonNegativeNumber(item.dailyLimitUsd ?? item.daily_limit_usd);
|
||||
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
|
||||
|
||||
const weeklyUsedUsd = normalizeNonNegativeNumber(item.weeklyUsedUsd ?? item.weekly_used_usd);
|
||||
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
|
||||
|
||||
const weeklyLimitUsd = normalizeNonNegativeNumber(item.weeklyLimitUsd ?? item.weekly_limit_usd);
|
||||
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
|
||||
|
||||
const monthlyUsedUsd = normalizeNonNegativeNumber(item.monthlyUsedUsd ?? item.monthly_used_usd);
|
||||
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
|
||||
|
||||
const monthlyLimitUsd = normalizeNonNegativeNumber(item.monthlyLimitUsd ?? item.monthly_limit_usd);
|
||||
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((item) => normalizeSubscriptionItem(item))
|
||||
.filter((item): item is SubscriptionPlanSummary => !!item);
|
||||
}
|
||||
|
||||
export function normalizeSub2ApiSubscriptionSummary(
|
||||
raw: unknown,
|
||||
): StoredSub2ApiSubscriptionSummary | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
|
||||
const body = raw as Record<string, unknown>;
|
||||
const subscriptions = normalizeSubscriptionItems(body.subscriptions);
|
||||
const activeCount = normalizeNonNegativeNumber(body.activeCount ?? body.active_count);
|
||||
const totalUsedUsd = normalizeNonNegativeNumber(body.totalUsedUsd ?? body.total_used_usd);
|
||||
const updatedAt = normalizeTimestampMs(body.updatedAt ?? body.updated_at);
|
||||
|
||||
return {
|
||||
activeCount: Math.trunc(activeCount ?? subscriptions.length),
|
||||
totalUsedUsd: totalUsedUsd ?? 0,
|
||||
subscriptions,
|
||||
updatedAt: updatedAt ?? Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStoredSub2ApiSubscriptionSummary(
|
||||
summary: SubscriptionSummary,
|
||||
updatedAt = Date.now(),
|
||||
): StoredSub2ApiSubscriptionSummary {
|
||||
return normalizeSub2ApiSubscriptionSummary({
|
||||
...summary,
|
||||
updatedAt,
|
||||
}) || {
|
||||
activeCount: Math.max(0, Math.trunc(summary.activeCount || 0)),
|
||||
totalUsedUsd: normalizeNonNegativeNumber(summary.totalUsedUsd) ?? 0,
|
||||
subscriptions: normalizeSubscriptionItems(summary.subscriptions),
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSub2ApiSubscriptionFromExtraConfig(
|
||||
extraConfig?: string | null,
|
||||
): StoredSub2ApiSubscriptionSummary | null {
|
||||
const parsed = parseExtraConfig(extraConfig);
|
||||
return normalizeSub2ApiSubscriptionSummary(parsed.sub2apiSubscription);
|
||||
}
|
||||
|
||||
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
|
||||
const text = (username || '').trim();
|
||||
if (!text) return undefined;
|
||||
|
||||
@@ -596,4 +596,254 @@ describe('backupService', () => {
|
||||
expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com');
|
||||
expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id');
|
||||
});
|
||||
|
||||
it('exports configured backup payload to webdav and records sync state', 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-preferences.json',
|
||||
username: 'alice',
|
||||
password: 'secret-pass',
|
||||
exportType: 'preferences',
|
||||
autoSyncEnabled: false,
|
||||
autoSyncCron: '0 * * * *',
|
||||
}),
|
||||
}).run();
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'ui_locale',
|
||||
value: JSON.stringify('zh-CN'),
|
||||
}).run();
|
||||
|
||||
const result = await (backupService as any).exportBackupToWebdav();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
fileUrl: 'https://dav.example.com/backups/metapi-preferences.json',
|
||||
exportType: 'preferences',
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [targetUrl, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(targetUrl).toBe('https://dav.example.com/backups/metapi-preferences.json');
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: `Basic ${Buffer.from('alice:secret-pass').toString('base64')}`,
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
const payload = JSON.parse(String(init.body));
|
||||
expect(payload.type).toBe('preferences');
|
||||
expect(payload.preferences.settings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'ui_locale', value: 'zh-CN' }),
|
||||
]),
|
||||
);
|
||||
|
||||
const syncState = await db.select().from(schema.settings).where(eq(schema.settings.key, 'backup_webdav_state_v1')).get();
|
||||
expect(syncState?.value).toContain('"lastSyncAt"');
|
||||
expect(syncState?.value).toContain('"lastError":null');
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('imports backup payload from webdav into local data', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
const remotePayload = {
|
||||
version: '2.0',
|
||||
timestamp: Date.now(),
|
||||
accounts: {
|
||||
sites: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'remote-site',
|
||||
url: 'https://remote.example.com',
|
||||
platform: 'new-api',
|
||||
externalCheckinUrl: null,
|
||||
proxyUrl: null,
|
||||
useSystemProxy: false,
|
||||
customHeaders: null,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
globalWeight: 1,
|
||||
apiKey: null,
|
||||
createdAt: '2026-03-20T00:00:00.000Z',
|
||||
updatedAt: '2026-03-20T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
siteId: 1,
|
||||
username: 'remote-user',
|
||||
accessToken: 'remote-session',
|
||||
apiToken: null,
|
||||
oauthProvider: null,
|
||||
oauthAccountKey: null,
|
||||
oauthProjectId: null,
|
||||
balance: 0,
|
||||
balanceUsed: 0,
|
||||
quota: 0,
|
||||
unitCost: null,
|
||||
valueScore: 0,
|
||||
status: 'active',
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
checkinEnabled: false,
|
||||
lastCheckinAt: null,
|
||||
lastBalanceRefresh: null,
|
||||
extraConfig: null,
|
||||
createdAt: '2026-03-20T00:00:00.000Z',
|
||||
updatedAt: '2026-03-20T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
accountTokens: [],
|
||||
tokenRoutes: [],
|
||||
routeChannels: [],
|
||||
routeGroupSources: [],
|
||||
},
|
||||
};
|
||||
fetchSpy.mockResolvedValue(new Response(JSON.stringify(remotePayload), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'backup_webdav_config_v1',
|
||||
value: JSON.stringify({
|
||||
enabled: true,
|
||||
fileUrl: 'https://dav.example.com/backups/metapi-all.json',
|
||||
username: 'alice',
|
||||
password: 'secret-pass',
|
||||
exportType: 'all',
|
||||
autoSyncEnabled: false,
|
||||
autoSyncCron: '0 * * * *',
|
||||
}),
|
||||
}).run();
|
||||
|
||||
const result = await (backupService as any).importBackupFromWebdav();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.sections.accounts).toBe(true);
|
||||
const sites = await db.select().from(schema.sites).all();
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
expect(sites).toHaveLength(1);
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0].username).toBe('remote-user');
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'https://dav.example.com/backups/metapi-all.json',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
);
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('times out stalled webdav export requests', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockImplementation((_, init) => {
|
||||
const signal = init?.signal as AbortSignal | undefined;
|
||||
if (!signal) {
|
||||
return Promise.reject(new Error('missing abort signal')) as Promise<Response>;
|
||||
}
|
||||
return new Promise<Response>((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
}, { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
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 */6 * * *',
|
||||
}),
|
||||
}).run();
|
||||
|
||||
const exportAssertion = expect(backupService.exportBackupToWebdav()).rejects.toThrow('WebDAV 请求超时');
|
||||
await vi.advanceTimersByTimeAsync(16_000);
|
||||
await exportAssertion;
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('times out stalled webdav import requests', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockImplementation((_, init) => {
|
||||
const signal = init?.signal as AbortSignal | undefined;
|
||||
if (!signal) {
|
||||
return Promise.reject(new Error('missing abort signal')) as Promise<Response>;
|
||||
}
|
||||
return new Promise<Response>((_, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
|
||||
}, { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
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 */6 * * *',
|
||||
}),
|
||||
}).run();
|
||||
|
||||
const importAssertion = expect(backupService.importBackupFromWebdav()).rejects.toThrow('WebDAV 请求超时');
|
||||
await vi.advanceTimersByTimeAsync(16_000);
|
||||
await importAssertion;
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not schedule malformed imported webdav config', async () => {
|
||||
const cronModule = await import('node-cron');
|
||||
const scheduleSpy = vi.spyOn(cronModule.default, 'schedule');
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||
|
||||
try {
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'backup_webdav_config_v1',
|
||||
value: JSON.stringify({
|
||||
enabled: true,
|
||||
fileUrl: 'not-a-valid-url',
|
||||
username: 'alice',
|
||||
password: 'secret-pass',
|
||||
exportType: 'all',
|
||||
autoSyncEnabled: true,
|
||||
autoSyncCron: '0 */6 * * *',
|
||||
}),
|
||||
}).run();
|
||||
|
||||
await expect(backupService.reloadBackupWebdavScheduler()).resolves.toBeUndefined();
|
||||
expect(scheduleSpy).not.toHaveBeenCalled();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid config'));
|
||||
} finally {
|
||||
backupService.__resetBackupWebdavSchedulerForTests();
|
||||
scheduleSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { asc } from 'drizzle-orm';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import cron from 'node-cron';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { upsertSetting } from '../db/upsertSetting.js';
|
||||
@@ -9,6 +9,32 @@ const BACKUP_VERSION = '2.0';
|
||||
|
||||
export type BackupExportType = 'all' | 'accounts' | 'preferences';
|
||||
|
||||
export interface BackupWebdavConfig {
|
||||
enabled: boolean;
|
||||
fileUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
exportType: BackupExportType;
|
||||
autoSyncEnabled: boolean;
|
||||
autoSyncCron: string;
|
||||
}
|
||||
|
||||
export interface BackupWebdavConfigView {
|
||||
enabled: boolean;
|
||||
fileUrl: string;
|
||||
username: string;
|
||||
exportType: BackupExportType;
|
||||
autoSyncEnabled: boolean;
|
||||
autoSyncCron: string;
|
||||
hasPassword: boolean;
|
||||
passwordMasked: string;
|
||||
}
|
||||
|
||||
export interface BackupWebdavState {
|
||||
lastSyncAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
type SiteRow = typeof schema.sites.$inferSelect;
|
||||
type AccountRow = typeof schema.accounts.$inferSelect;
|
||||
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
|
||||
@@ -76,6 +102,11 @@ const EXCLUDED_SETTING_KEYS = new Set<string>([
|
||||
// Keep current admin login credential unchanged to avoid accidental lock-out.
|
||||
'auth_token',
|
||||
]);
|
||||
const BACKUP_WEBDAV_CONFIG_SETTING_KEY = 'backup_webdav_config_v1';
|
||||
const BACKUP_WEBDAV_STATE_SETTING_KEY = 'backup_webdav_state_v1';
|
||||
const BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON = '0 */6 * * *';
|
||||
const BACKUP_WEBDAV_FETCH_TIMEOUT_MS = 15_000;
|
||||
let backupWebdavTask: cron.ScheduledTask | null = null;
|
||||
|
||||
const DIRECT_API_PLATFORMS = new Set([
|
||||
'openai',
|
||||
@@ -722,6 +753,131 @@ function stringifySettingValue(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function isValidBackupExportType(value: unknown): value is BackupExportType {
|
||||
return value === 'all' || value === 'accounts' || value === 'preferences';
|
||||
}
|
||||
|
||||
function maskSecret(value: string): string {
|
||||
if (!value) return '';
|
||||
if (value.length <= 4) return '****';
|
||||
return `${value.slice(0, 2)}****${value.slice(-2)}`;
|
||||
}
|
||||
|
||||
function isValidHttpUrl(raw: string): boolean {
|
||||
if (!raw.trim()) return false;
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBackupWebdavConfig(raw: unknown): BackupWebdavConfig {
|
||||
const source = isRecord(raw) ? raw : {};
|
||||
const exportType = isValidBackupExportType(source.exportType) ? source.exportType : 'all';
|
||||
const autoSyncCron = typeof source.autoSyncCron === 'string' && cron.validate(source.autoSyncCron)
|
||||
? source.autoSyncCron
|
||||
: BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON;
|
||||
|
||||
return {
|
||||
enabled: source.enabled === true,
|
||||
fileUrl: asString(source.fileUrl),
|
||||
username: asString(source.username),
|
||||
password: typeof source.password === 'string' ? source.password : '',
|
||||
exportType,
|
||||
autoSyncEnabled: source.autoSyncEnabled === true,
|
||||
autoSyncCron,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBackupWebdavState(raw: unknown): BackupWebdavState {
|
||||
const source = isRecord(raw) ? raw : {};
|
||||
return {
|
||||
lastSyncAt: typeof source.lastSyncAt === 'string' && source.lastSyncAt.trim() ? source.lastSyncAt : null,
|
||||
lastError: typeof source.lastError === 'string' && source.lastError.trim() ? source.lastError : null,
|
||||
};
|
||||
}
|
||||
|
||||
function toBackupWebdavConfigView(config: BackupWebdavConfig): BackupWebdavConfigView {
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
fileUrl: config.fileUrl,
|
||||
username: config.username,
|
||||
exportType: config.exportType,
|
||||
autoSyncEnabled: config.autoSyncEnabled,
|
||||
autoSyncCron: config.autoSyncCron,
|
||||
hasPassword: config.password.length > 0,
|
||||
passwordMasked: maskSecret(config.password),
|
||||
};
|
||||
}
|
||||
|
||||
async function readSettingValue(key: string): Promise<unknown> {
|
||||
const row = await db.select({ value: schema.settings.value }).from(schema.settings).where(eq(schema.settings.key, key)).get();
|
||||
return parseSettingValue(row?.value ?? null);
|
||||
}
|
||||
|
||||
async function loadBackupWebdavConfig(): Promise<BackupWebdavConfig> {
|
||||
return normalizeBackupWebdavConfig(await readSettingValue(BACKUP_WEBDAV_CONFIG_SETTING_KEY));
|
||||
}
|
||||
|
||||
async function loadBackupWebdavState(): Promise<BackupWebdavState> {
|
||||
return normalizeBackupWebdavState(await readSettingValue(BACKUP_WEBDAV_STATE_SETTING_KEY));
|
||||
}
|
||||
|
||||
async function writeBackupWebdavState(next: BackupWebdavState) {
|
||||
await upsertSetting(BACKUP_WEBDAV_STATE_SETTING_KEY, next);
|
||||
}
|
||||
|
||||
function resolveBackupWebdavAuthHeader(config: BackupWebdavConfig): string | null {
|
||||
if (!config.username && !config.password) return null;
|
||||
return `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
|
||||
}
|
||||
|
||||
function validateBackupWebdavConfig(config: BackupWebdavConfig) {
|
||||
if (config.enabled && !isValidHttpUrl(config.fileUrl)) {
|
||||
throw new Error('WebDAV 文件地址无效,请填写 http/https 文件 URL');
|
||||
}
|
||||
if (!isValidBackupExportType(config.exportType)) {
|
||||
throw new Error('WebDAV 导出类型无效,仅支持 all/accounts/preferences');
|
||||
}
|
||||
if (!cron.validate(config.autoSyncCron)) {
|
||||
throw new Error('WebDAV 自动同步 Cron 表达式无效');
|
||||
}
|
||||
if (config.autoSyncEnabled && !config.enabled) {
|
||||
throw new Error('启用自动同步前请先启用 WebDAV 备份');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBackupWebdav(url: string, init: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, BACKUP_WEBDAV_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`WebDAV 请求超时(${Math.max(1, Math.round(BACKUP_WEBDAV_FETCH_TIMEOUT_MS / 1000))}s)`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopBackupWebdavScheduler() {
|
||||
backupWebdavTask?.stop();
|
||||
backupWebdavTask = null;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
@@ -1103,3 +1259,169 @@ export async function importBackup(data: RawBackupData): Promise<BackupImportRes
|
||||
warnings: importMetadata.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBackupWebdavConfig() {
|
||||
const [config, state] = await Promise.all([
|
||||
loadBackupWebdavConfig(),
|
||||
loadBackupWebdavState(),
|
||||
]);
|
||||
return {
|
||||
success: true,
|
||||
config: toBackupWebdavConfigView(config),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveBackupWebdavConfig(input: Partial<BackupWebdavConfig> & { password?: string; clearPassword?: boolean }) {
|
||||
const existing = await loadBackupWebdavConfig();
|
||||
const next: BackupWebdavConfig = {
|
||||
enabled: input.enabled !== undefined ? input.enabled === true : existing.enabled,
|
||||
fileUrl: input.fileUrl !== undefined ? asString(input.fileUrl) : existing.fileUrl,
|
||||
username: input.username !== undefined ? asString(input.username) : existing.username,
|
||||
password: input.clearPassword
|
||||
? ''
|
||||
: (input.password !== undefined
|
||||
? String(input.password)
|
||||
: existing.password),
|
||||
exportType: isValidBackupExportType(input.exportType) ? input.exportType : existing.exportType,
|
||||
autoSyncEnabled: input.autoSyncEnabled !== undefined ? input.autoSyncEnabled === true : existing.autoSyncEnabled,
|
||||
autoSyncCron: typeof input.autoSyncCron === 'string' && input.autoSyncCron.trim()
|
||||
? input.autoSyncCron.trim()
|
||||
: existing.autoSyncCron,
|
||||
};
|
||||
|
||||
if (!next.enabled) {
|
||||
next.autoSyncEnabled = false;
|
||||
}
|
||||
validateBackupWebdavConfig(next);
|
||||
|
||||
await upsertSetting(BACKUP_WEBDAV_CONFIG_SETTING_KEY, next);
|
||||
await reloadBackupWebdavScheduler();
|
||||
return getBackupWebdavConfig();
|
||||
}
|
||||
|
||||
export async function exportBackupToWebdav(type?: BackupExportType) {
|
||||
const config = await loadBackupWebdavConfig();
|
||||
validateBackupWebdavConfig(config);
|
||||
if (!config.enabled) {
|
||||
throw new Error('WebDAV 备份未启用');
|
||||
}
|
||||
if (!config.fileUrl) {
|
||||
throw new Error('WebDAV 文件地址不能为空');
|
||||
}
|
||||
|
||||
const exportType = type && isValidBackupExportType(type) ? type : config.exportType;
|
||||
const payload = await exportBackup(exportType);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const authHeader = resolveBackupWebdavAuthHeader(config);
|
||||
if (authHeader) headers.Authorization = authHeader;
|
||||
|
||||
try {
|
||||
const response = await fetchBackupWebdav(config.fileUrl, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(payload, null, 2),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`WebDAV 导出失败:HTTP ${response.status}${text ? ` ${text.slice(0, 120)}` : ''}`);
|
||||
}
|
||||
|
||||
const syncedAt = new Date().toISOString();
|
||||
await writeBackupWebdavState({
|
||||
lastSyncAt: syncedAt,
|
||||
lastError: null,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
fileUrl: config.fileUrl,
|
||||
exportType,
|
||||
syncedAt,
|
||||
lastSyncAt: syncedAt,
|
||||
lastError: null,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const previousState = await loadBackupWebdavState();
|
||||
await writeBackupWebdavState({
|
||||
lastSyncAt: previousState.lastSyncAt,
|
||||
lastError: error?.message || 'WebDAV 导出失败',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importBackupFromWebdav() {
|
||||
const config = await loadBackupWebdavConfig();
|
||||
validateBackupWebdavConfig(config);
|
||||
if (!config.enabled) {
|
||||
throw new Error('WebDAV 备份未启用');
|
||||
}
|
||||
if (!config.fileUrl) {
|
||||
throw new Error('WebDAV 文件地址不能为空');
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const authHeader = resolveBackupWebdavAuthHeader(config);
|
||||
if (authHeader) headers.Authorization = authHeader;
|
||||
|
||||
try {
|
||||
const response = await fetchBackupWebdav(config.fileUrl, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`WebDAV 导入失败:HTTP ${response.status}${text ? ` ${text.slice(0, 120)}` : ''}`);
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
const parsed = JSON.parse(raw) as RawBackupData;
|
||||
const result = await importBackup(parsed);
|
||||
const syncedAt = new Date().toISOString();
|
||||
await writeBackupWebdavState({
|
||||
lastSyncAt: syncedAt,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fileUrl: config.fileUrl,
|
||||
syncedAt,
|
||||
lastSyncAt: syncedAt,
|
||||
lastError: null,
|
||||
...result,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const previousState = await loadBackupWebdavState();
|
||||
await writeBackupWebdavState({
|
||||
lastSyncAt: previousState.lastSyncAt,
|
||||
lastError: error?.message || 'WebDAV 导入失败',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadBackupWebdavScheduler() {
|
||||
stopBackupWebdavScheduler();
|
||||
const config = await loadBackupWebdavConfig();
|
||||
if (!config.enabled || !config.autoSyncEnabled) return;
|
||||
|
||||
try {
|
||||
validateBackupWebdavConfig(config);
|
||||
} catch (error: any) {
|
||||
console.warn(`[backup/webdav] invalid config: ${error?.message || 'unknown error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
backupWebdavTask = cron.schedule(config.autoSyncCron, () => {
|
||||
void exportBackupToWebdav(config.exportType).catch((error) => {
|
||||
console.warn(`[backup/webdav] auto sync failed: ${(error as Error)?.message || 'unknown error'}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function __resetBackupWebdavSchedulerForTests() {
|
||||
stopBackupWebdavScheduler();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
|
||||
import { reportTokenExpired } from './alertService.js';
|
||||
import {
|
||||
buildStoredSub2ApiSubscriptionSummary,
|
||||
getAutoReloginConfig,
|
||||
getCredentialModeFromExtraConfig,
|
||||
getProxyUrlFromExtraConfig,
|
||||
@@ -483,6 +484,11 @@ export async function refreshBalance(accountId: number) {
|
||||
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
|
||||
nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, balanceInfo.todayIncome);
|
||||
}
|
||||
if (balanceInfo.subscriptionSummary && isSub2ApiPlatform(site.platform)) {
|
||||
nextExtraConfig = mergeAccountExtraConfig(nextExtraConfig, {
|
||||
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary(balanceInfo.subscriptionSummary),
|
||||
});
|
||||
}
|
||||
|
||||
const existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig);
|
||||
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const cronStopMock = vi.fn();
|
||||
const scheduleMock = vi.fn(() => ({
|
||||
stop: cronStopMock,
|
||||
}));
|
||||
const validateMock = vi.fn(() => true);
|
||||
const allMock = vi.fn();
|
||||
|
||||
vi.mock('node-cron', () => ({
|
||||
default: {
|
||||
schedule: (...args: unknown[]) => scheduleMock(...args),
|
||||
validate: (...args: unknown[]) => validateMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../db/index.js', () => {
|
||||
const queryChain = {
|
||||
where: () => queryChain,
|
||||
get: () => undefined,
|
||||
all: () => [],
|
||||
from: () => queryChain,
|
||||
innerJoin: () => queryChain,
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: () => queryChain,
|
||||
},
|
||||
schema: {
|
||||
settings: { key: 'key' },
|
||||
accounts: { checkinEnabled: 'checkinEnabled', status: 'status' },
|
||||
sites: { id: 'id' },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./checkinService.js', () => ({
|
||||
checkinAll: (...args: unknown[]) => allMock(...args),
|
||||
}));
|
||||
|
||||
describe('checkinScheduler', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
cronStopMock.mockReset();
|
||||
scheduleMock.mockClear();
|
||||
validateMock.mockClear();
|
||||
allMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const scheduler = await import('./checkinScheduler.js');
|
||||
scheduler.__resetCheckinSchedulerForTests();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('switches from cron mode to interval mode and back', async () => {
|
||||
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
|
||||
const scheduler = await import('./checkinScheduler.js');
|
||||
|
||||
scheduler.updateCheckinSchedule({
|
||||
mode: 'cron',
|
||||
cronExpr: '0 8 * * *',
|
||||
intervalHours: 6,
|
||||
});
|
||||
expect(scheduleMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
scheduler.updateCheckinSchedule({
|
||||
mode: 'interval',
|
||||
intervalHours: 6,
|
||||
});
|
||||
expect(cronStopMock).toHaveBeenCalledTimes(1);
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
scheduler.updateCheckinSchedule({
|
||||
mode: 'cron',
|
||||
cronExpr: '5 9 * * *',
|
||||
intervalHours: 6,
|
||||
});
|
||||
expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
|
||||
expect(scheduleMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('selects due accounts from the last successful checkin time', async () => {
|
||||
const scheduler = await import('./checkinScheduler.js');
|
||||
const now = new Date('2026-03-20T12:00:00.000Z');
|
||||
|
||||
expect(scheduler.selectDueIntervalCheckinAccountIds([
|
||||
{ id: 1, lastCheckinAt: null },
|
||||
{ id: 2, lastCheckinAt: '2026-03-20T05:59:59.000Z' },
|
||||
{ id: 3, lastCheckinAt: '2026-03-20T06:30:00.000Z' },
|
||||
], 6, now)).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
@@ -9,13 +9,18 @@ import { sendNotification } from './notifyService.js';
|
||||
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
|
||||
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
|
||||
|
||||
export type CheckinScheduleMode = 'cron' | 'interval';
|
||||
|
||||
let checkinTask: cron.ScheduledTask | null = null;
|
||||
let checkinIntervalTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let balanceTask: cron.ScheduledTask | null = null;
|
||||
let dailySummaryTask: cron.ScheduledTask | null = null;
|
||||
let logCleanupTask: cron.ScheduledTask | null = null;
|
||||
const intervalAttemptByAccount = new Map<number, number>();
|
||||
|
||||
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
|
||||
const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *';
|
||||
const CHECKIN_INTERVAL_POLL_MS = 60_000;
|
||||
|
||||
async function resolveJsonSetting<T>(
|
||||
settingKey: string,
|
||||
@@ -54,7 +59,7 @@ function createCheckinTask(cronExpr: string) {
|
||||
return cron.schedule(cronExpr, async () => {
|
||||
console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`);
|
||||
try {
|
||||
const results = await checkinAll();
|
||||
const results = await checkinAll({ scheduleMode: 'cron' });
|
||||
const success = results.filter((r) => r.result.success).length;
|
||||
const failed = results.length - success;
|
||||
console.log(`[Scheduler] Check-in complete: ${success} success, ${failed} failed`);
|
||||
@@ -64,6 +69,94 @@ function createCheckinTask(cronExpr: string) {
|
||||
});
|
||||
}
|
||||
|
||||
type IntervalCheckinCandidate = {
|
||||
id: number;
|
||||
lastCheckinAt?: string | null;
|
||||
};
|
||||
|
||||
export function selectDueIntervalCheckinAccountIds(
|
||||
rows: IntervalCheckinCandidate[],
|
||||
intervalHours: number,
|
||||
now = new Date(),
|
||||
attemptState = intervalAttemptByAccount,
|
||||
) {
|
||||
const nowMs = now.getTime();
|
||||
const intervalMs = Math.max(1, intervalHours) * 60 * 60 * 1000;
|
||||
|
||||
return rows
|
||||
.filter((row) => {
|
||||
const lastCheckinMs = row.lastCheckinAt ? Date.parse(row.lastCheckinAt) : Number.NaN;
|
||||
const lastAttemptMs = attemptState.get(row.id);
|
||||
if (Number.isFinite(lastCheckinMs)) {
|
||||
if (nowMs - lastCheckinMs < intervalMs) return false;
|
||||
if (typeof lastAttemptMs === 'number' && lastAttemptMs >= lastCheckinMs && nowMs - lastAttemptMs < intervalMs) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (typeof lastAttemptMs === 'number' && nowMs - lastAttemptMs < intervalMs) return false;
|
||||
return true;
|
||||
})
|
||||
.map((row) => row.id);
|
||||
}
|
||||
|
||||
async function runIntervalCheckinPass(now = new Date()) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.all();
|
||||
|
||||
const dueAccountIds = selectDueIntervalCheckinAccountIds(
|
||||
rows
|
||||
.filter((row: any) => row.accounts?.checkinEnabled === true && row.accounts?.status === 'active' && row.sites?.status !== 'disabled')
|
||||
.map((row: any) => ({
|
||||
id: row.accounts.id,
|
||||
lastCheckinAt: row.accounts.lastCheckinAt,
|
||||
})),
|
||||
config.checkinIntervalHours,
|
||||
now,
|
||||
);
|
||||
|
||||
if (dueAccountIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const results = await checkinAll({
|
||||
accountIds: dueAccountIds,
|
||||
scheduleMode: 'interval',
|
||||
});
|
||||
const nowMs = now.getTime();
|
||||
for (const item of results) {
|
||||
intervalAttemptByAccount.set(item.accountId, nowMs);
|
||||
}
|
||||
const success = results.filter((r) => r.result.success).length;
|
||||
const failed = results.length - success;
|
||||
console.log(`[Scheduler] Interval check-in complete: ${success} success, ${failed} failed`);
|
||||
} catch (err) {
|
||||
console.error('[Scheduler] Interval check-in error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function stopCheckinSchedule() {
|
||||
checkinTask?.stop();
|
||||
checkinTask = null;
|
||||
if (checkinIntervalTimer) {
|
||||
clearInterval(checkinIntervalTimer);
|
||||
checkinIntervalTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startCheckinSchedule() {
|
||||
stopCheckinSchedule();
|
||||
if (config.checkinScheduleMode === 'interval') {
|
||||
checkinIntervalTimer = setInterval(() => {
|
||||
void runIntervalCheckinPass();
|
||||
}, CHECKIN_INTERVAL_POLL_MS);
|
||||
return;
|
||||
}
|
||||
checkinTask = createCheckinTask(config.checkinCron);
|
||||
}
|
||||
|
||||
function createBalanceTask(cronExpr: string) {
|
||||
return cron.schedule(cronExpr, async () => {
|
||||
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
|
||||
@@ -119,6 +212,15 @@ function createLogCleanupTask(cronExpr: string) {
|
||||
|
||||
export async function startScheduler() {
|
||||
const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron);
|
||||
const activeCheckinScheduleMode = await resolveJsonSetting<CheckinScheduleMode>(
|
||||
'checkin_schedule_mode',
|
||||
(value): value is CheckinScheduleMode => value === 'cron' || value === 'interval',
|
||||
config.checkinScheduleMode as CheckinScheduleMode,
|
||||
);
|
||||
const activeCheckinIntervalHours = await resolvePositiveIntegerSetting(
|
||||
'checkin_interval_hours',
|
||||
config.checkinIntervalHours,
|
||||
);
|
||||
const activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
|
||||
const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
|
||||
const activeLogCleanupCron = await resolveCronSetting('log_cleanup_cron', config.logCleanupCron || LOG_CLEANUP_DEFAULT_CRON);
|
||||
@@ -135,22 +237,24 @@ export async function startScheduler() {
|
||||
normalizeLogCleanupRetentionDays(config.logCleanupRetentionDays),
|
||||
);
|
||||
config.checkinCron = activeCheckinCron;
|
||||
config.checkinScheduleMode = activeCheckinScheduleMode;
|
||||
config.checkinIntervalHours = Math.min(24, Math.max(1, activeCheckinIntervalHours));
|
||||
config.balanceRefreshCron = activeBalanceCron;
|
||||
config.logCleanupCron = activeLogCleanupCron;
|
||||
config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled;
|
||||
config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled;
|
||||
config.logCleanupRetentionDays = activeLogCleanupRetentionDays;
|
||||
|
||||
checkinTask?.stop();
|
||||
stopCheckinSchedule();
|
||||
balanceTask?.stop();
|
||||
dailySummaryTask?.stop();
|
||||
logCleanupTask?.stop();
|
||||
checkinTask = createCheckinTask(activeCheckinCron);
|
||||
startCheckinSchedule();
|
||||
balanceTask = createBalanceTask(activeBalanceCron);
|
||||
dailySummaryTask = createDailySummaryTask(activeDailySummaryCron);
|
||||
logCleanupTask = createLogCleanupTask(activeLogCleanupCron);
|
||||
|
||||
console.log(`[Scheduler] Check-in cron: ${activeCheckinCron}`);
|
||||
console.log(`[Scheduler] Check-in schedule: ${config.checkinScheduleMode} (${config.checkinScheduleMode === 'cron' ? activeCheckinCron : `${config.checkinIntervalHours}h`})`);
|
||||
console.log(`[Scheduler] Balance refresh cron: ${activeBalanceCron}`);
|
||||
console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`);
|
||||
console.log(
|
||||
@@ -159,10 +263,35 @@ export async function startScheduler() {
|
||||
}
|
||||
|
||||
export function updateCheckinCron(cronExpr: string) {
|
||||
if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`);
|
||||
config.checkinCron = cronExpr;
|
||||
checkinTask?.stop();
|
||||
checkinTask = createCheckinTask(cronExpr);
|
||||
updateCheckinSchedule({
|
||||
mode: 'cron',
|
||||
cronExpr,
|
||||
intervalHours: config.checkinIntervalHours,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateCheckinSchedule(input: {
|
||||
mode: CheckinScheduleMode;
|
||||
cronExpr?: string;
|
||||
intervalHours?: number;
|
||||
}) {
|
||||
const nextMode = input.mode;
|
||||
if (nextMode !== 'cron' && nextMode !== 'interval') {
|
||||
throw new Error(`Invalid checkin schedule mode: ${String(nextMode)}`);
|
||||
}
|
||||
|
||||
const nextCronExpr = input.cronExpr ?? config.checkinCron;
|
||||
if (!cron.validate(nextCronExpr)) throw new Error(`Invalid cron: ${nextCronExpr}`);
|
||||
|
||||
const nextIntervalHours = input.intervalHours ?? config.checkinIntervalHours;
|
||||
if (!Number.isFinite(nextIntervalHours) || nextIntervalHours < 1 || nextIntervalHours > 24) {
|
||||
throw new Error(`Invalid interval hours: ${String(nextIntervalHours)}`);
|
||||
}
|
||||
|
||||
config.checkinScheduleMode = nextMode;
|
||||
config.checkinCron = nextCronExpr;
|
||||
config.checkinIntervalHours = Math.trunc(nextIntervalHours);
|
||||
startCheckinSchedule();
|
||||
}
|
||||
|
||||
export function updateBalanceRefreshCron(cronExpr: string) {
|
||||
@@ -191,3 +320,14 @@ export function updateLogCleanupSettings(input: {
|
||||
logCleanupTask?.stop();
|
||||
logCleanupTask = createLogCleanupTask(cronExpr);
|
||||
}
|
||||
|
||||
export function __resetCheckinSchedulerForTests() {
|
||||
stopCheckinSchedule();
|
||||
balanceTask?.stop();
|
||||
dailySummaryTask?.stop();
|
||||
logCleanupTask?.stop();
|
||||
balanceTask = null;
|
||||
dailySummaryTask = null;
|
||||
logCleanupTask = null;
|
||||
intervalAttemptByAccount.clear();
|
||||
}
|
||||
|
||||
@@ -248,6 +248,63 @@ describe('checkinService auto relogin', () => {
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not advance lastCheckinAt for already checked in responses in interval mode', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 16,
|
||||
username: 'interval-user',
|
||||
accessToken: 'token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 16,
|
||||
name: 'demo',
|
||||
url: 'https://example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin.mockResolvedValue({ success: false, message: '今天已经签到过啦' });
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
const result = await checkinAccount(16, { scheduleMode: 'interval' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe('success');
|
||||
expect(updateSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('advances lastCheckinAt when interval mode gets a direct success', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 17,
|
||||
username: 'interval-success',
|
||||
accessToken: 'token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 17,
|
||||
name: 'demo',
|
||||
url: 'https://example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin.mockResolvedValue({ success: true, message: '签到成功' });
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
const result = await checkinAccount(17, { scheduleMode: 'interval' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('treats unsupported checkin endpoint responses as skipped', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
@@ -283,6 +340,40 @@ describe('checkinService auto relogin', () => {
|
||||
expect(notifyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips account updates when unsupported checkin responses do not change account state', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
accounts: {
|
||||
id: 18,
|
||||
username: 'plain-user',
|
||||
accessToken: 'token',
|
||||
status: 'active',
|
||||
extraConfig: null,
|
||||
},
|
||||
sites: {
|
||||
id: 18,
|
||||
name: 'done-hub',
|
||||
url: 'https://done.example.com',
|
||||
platform: 'donehub',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
adapterMock.checkin.mockResolvedValue({
|
||||
success: false,
|
||||
message: 'checkin endpoint not found',
|
||||
});
|
||||
|
||||
const { checkinAccount } = await import('./checkinService.js');
|
||||
const result = await checkinAccount(18);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe('skipped');
|
||||
expect(updateSetMock).not.toHaveBeenCalled();
|
||||
const firstInsertPayload = insertValuesMock.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(firstInsertPayload?.status).toBe('skipped');
|
||||
});
|
||||
|
||||
it('treats sub2api checkin unsupported message as skipped', async () => {
|
||||
selectAllMock.mockReturnValue([
|
||||
{
|
||||
|
||||
@@ -120,7 +120,7 @@ async function tryAutoRelogin(account: any, site: any): Promise<string | null> {
|
||||
return result.accessToken;
|
||||
}
|
||||
|
||||
export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean }) {
|
||||
export async function checkinAccount(accountId: number, options?: { skipEvent?: boolean; scheduleMode?: 'cron' | 'interval' }) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.accounts)
|
||||
@@ -200,6 +200,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
|
||||
const effectiveSuccess = result.success || alreadyCheckedIn || unsupportedCheckin || manualVerificationRequired;
|
||||
const shouldRefreshBalance = result.success || alreadyCheckedIn;
|
||||
const directCheckinSuccess = result.success && !alreadyCheckedIn && !unsupportedCheckin;
|
||||
const shouldAdvanceLastCheckinAt = directCheckinSuccess || (alreadyCheckedIn && options?.scheduleMode !== 'interval');
|
||||
const normalizedStatus: CheckinExecutionStatus = effectiveSuccess
|
||||
? ((unsupportedCheckin || manualVerificationRequired) ? 'skipped' : 'success')
|
||||
: 'failed';
|
||||
@@ -219,9 +220,10 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
|
||||
source: 'checkin',
|
||||
});
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
lastCheckinAt: new Date().toISOString(),
|
||||
};
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (shouldAdvanceLastCheckinAt) {
|
||||
updates.lastCheckinAt = new Date().toISOString();
|
||||
}
|
||||
if (!storedPlatformUserId && guessedPlatformUserId) {
|
||||
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, {
|
||||
platformUserId: guessedPlatformUserId,
|
||||
@@ -232,10 +234,12 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
await db.update(schema.accounts)
|
||||
.set(updates)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.run();
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await db.update(schema.accounts)
|
||||
.set(updates)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.run();
|
||||
}
|
||||
|
||||
if (shouldRefreshBalance) {
|
||||
try {
|
||||
@@ -316,7 +320,7 @@ export async function checkinAccount(accountId: number, options?: { skipEvent?:
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkinAll() {
|
||||
export async function checkinAll(options?: { accountIds?: number[]; scheduleMode?: 'cron' | 'interval' }) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.accounts)
|
||||
@@ -329,10 +333,12 @@ export async function checkinAll() {
|
||||
)
|
||||
.all();
|
||||
|
||||
const scopedAccountIds = options?.accountIds ? new Set(options.accountIds) : null;
|
||||
const results: Array<{ accountId: number; username: string | null; site: string; result: any }> = [];
|
||||
|
||||
const grouped = new Map<number, typeof rows>();
|
||||
for (const row of rows) {
|
||||
if (scopedAccountIds && !scopedAccountIds.has(row.accounts.id)) continue;
|
||||
const siteId = row.sites.id;
|
||||
if (!grouped.has(siteId)) grouped.set(siteId, []);
|
||||
grouped.get(siteId)!.push(row);
|
||||
@@ -340,7 +346,10 @@ export async function checkinAll() {
|
||||
|
||||
const promises = Array.from(grouped.entries()).map(async ([_, siteRows]) => {
|
||||
for (const row of siteRows) {
|
||||
const r = await checkinAccount(row.accounts.id, { skipEvent: true });
|
||||
const r = await checkinAccount(row.accounts.id, {
|
||||
skipEvent: true,
|
||||
scheduleMode: options?.scheduleMode,
|
||||
});
|
||||
results.push({
|
||||
accountId: row.accounts.id,
|
||||
username: row.accounts.username,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import currentContract from '../db/generated/schemaContract.json' with { type: 'json' };
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
__databaseMigrationServiceTestUtils,
|
||||
maskConnectionString,
|
||||
@@ -10,6 +10,42 @@ function cloneContract<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function createDbSchemaMock() {
|
||||
return {
|
||||
settings: { __table: 'settings' },
|
||||
sites: { __table: 'sites' },
|
||||
siteAnnouncements: { __table: 'siteAnnouncements' },
|
||||
siteDisabledModels: { __table: 'siteDisabledModels' },
|
||||
accounts: { __table: 'accounts' },
|
||||
accountTokens: { __table: 'accountTokens' },
|
||||
checkinLogs: { __table: 'checkinLogs' },
|
||||
modelAvailability: { __table: 'modelAvailability' },
|
||||
tokenModelAvailability: { __table: 'tokenModelAvailability' },
|
||||
tokenRoutes: { __table: 'tokenRoutes' },
|
||||
routeChannels: { __table: 'routeChannels' },
|
||||
routeGroupSources: { __table: 'routeGroupSources' },
|
||||
proxyLogs: { __table: 'proxyLogs' },
|
||||
proxyVideoTasks: { __table: 'proxyVideoTasks' },
|
||||
proxyFiles: { __table: 'proxyFiles' },
|
||||
downstreamApiKeys: { __table: 'downstreamApiKeys' },
|
||||
events: { __table: 'events' },
|
||||
};
|
||||
}
|
||||
|
||||
function createDbMock(rowsByTable: Record<string, unknown[]>) {
|
||||
return {
|
||||
select() {
|
||||
return {
|
||||
from(table: { __table: string }) {
|
||||
return {
|
||||
all: async () => rowsByTable[table.__table] ?? [],
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('databaseMigrationService', () => {
|
||||
it('accepts postgres migration input with normalized url', () => {
|
||||
const normalized = normalizeMigrationInput({
|
||||
@@ -179,6 +215,7 @@ describe('databaseMigrationService', () => {
|
||||
customHeaders: '{"x-site-scope":"internal"}',
|
||||
status: 'active',
|
||||
}],
|
||||
siteAnnouncements: [],
|
||||
siteDisabledModels: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
@@ -214,6 +251,7 @@ describe('databaseMigrationService', () => {
|
||||
timestamp: Date.now(),
|
||||
accounts: {
|
||||
sites: [],
|
||||
siteAnnouncements: [],
|
||||
siteDisabledModels: [{
|
||||
id: 3,
|
||||
siteId: 12,
|
||||
@@ -295,4 +333,136 @@ describe('databaseMigrationService', () => {
|
||||
expect(routeModeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(tokenRouteStatement?.values[routeModeIndex]).toBe('explicit_group');
|
||||
});
|
||||
|
||||
it('includes site announcements in migration statements', () => {
|
||||
const statements = __databaseMigrationServiceTestUtils.buildStatements({
|
||||
version: 'test',
|
||||
timestamp: Date.now(),
|
||||
accounts: {
|
||||
sites: [],
|
||||
siteDisabledModels: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
checkinLogs: [],
|
||||
modelAvailability: [],
|
||||
tokenModelAvailability: [],
|
||||
tokenRoutes: [],
|
||||
routeChannels: [],
|
||||
routeGroupSources: [],
|
||||
proxyLogs: [],
|
||||
proxyVideoTasks: [],
|
||||
proxyFiles: [],
|
||||
downstreamApiKeys: [],
|
||||
events: [],
|
||||
siteAnnouncements: [{
|
||||
id: 11,
|
||||
siteId: 3,
|
||||
platform: 'openai',
|
||||
sourceKey: 'notice-1',
|
||||
title: '????',
|
||||
content: '????',
|
||||
level: 'warning',
|
||||
sourceUrl: 'https://example.com/notice',
|
||||
startsAt: '2026-03-20T00:00:00.000Z',
|
||||
endsAt: '2026-03-21T00:00:00.000Z',
|
||||
upstreamCreatedAt: '2026-03-19T00:00:00.000Z',
|
||||
upstreamUpdatedAt: '2026-03-20T00:00:00.000Z',
|
||||
firstSeenAt: '2026-03-20T00:00:00.000Z',
|
||||
lastSeenAt: '2026-03-20T01:00:00.000Z',
|
||||
readAt: null,
|
||||
dismissedAt: null,
|
||||
rawPayload: '{"id":"notice-1"}',
|
||||
}],
|
||||
},
|
||||
preferences: {
|
||||
settings: [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
const statement = statements.find((item) => item.table === 'site_announcements');
|
||||
expect(statement).toBeDefined();
|
||||
expect(statement?.columns).toContain('source_key');
|
||||
expect(statement?.values[statement?.columns.indexOf('title') ?? -1]).toBe('????');
|
||||
});
|
||||
|
||||
it('includes site announcements in migration summary', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
const rowsByTable = {
|
||||
settings: [],
|
||||
sites: [],
|
||||
siteAnnouncements: [{
|
||||
id: 11,
|
||||
siteId: 3,
|
||||
platform: 'openai',
|
||||
sourceKey: 'notice-1',
|
||||
title: '????',
|
||||
content: '????',
|
||||
level: 'warning',
|
||||
sourceUrl: 'https://example.com/notice',
|
||||
startsAt: '2026-03-20T00:00:00.000Z',
|
||||
endsAt: '2026-03-21T00:00:00.000Z',
|
||||
upstreamCreatedAt: '2026-03-19T00:00:00.000Z',
|
||||
upstreamUpdatedAt: '2026-03-20T00:00:00.000Z',
|
||||
firstSeenAt: '2026-03-20T00:00:00.000Z',
|
||||
lastSeenAt: '2026-03-20T01:00:00.000Z',
|
||||
readAt: null,
|
||||
dismissedAt: null,
|
||||
rawPayload: '{"id":"notice-1"}',
|
||||
}],
|
||||
siteDisabledModels: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
checkinLogs: [],
|
||||
modelAvailability: [],
|
||||
tokenModelAvailability: [],
|
||||
tokenRoutes: [],
|
||||
routeChannels: [],
|
||||
routeGroupSources: [],
|
||||
proxyLogs: [],
|
||||
proxyVideoTasks: [],
|
||||
proxyFiles: [],
|
||||
downstreamApiKeys: [],
|
||||
events: [],
|
||||
};
|
||||
|
||||
const client = {
|
||||
dialect: 'sqlite',
|
||||
connectionString: ':memory:',
|
||||
ssl: false,
|
||||
begin: vi.fn(async () => {}),
|
||||
commit: vi.fn(async () => {}),
|
||||
rollback: vi.fn(async () => {}),
|
||||
execute: vi.fn(async () => []),
|
||||
queryScalar: vi.fn(async () => 0),
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
vi.doMock('../db/index.js', () => ({
|
||||
db: createDbMock(rowsByTable),
|
||||
schema: createDbSchemaMock(),
|
||||
}));
|
||||
vi.doMock('../db/runtimeSchemaBootstrap.js', () => ({
|
||||
createRuntimeSchemaClient: async () => client,
|
||||
ensureRuntimeDatabaseSchema: async () => {},
|
||||
}));
|
||||
|
||||
try {
|
||||
const { migrateCurrentDatabase } = await import('./databaseMigrationService.js');
|
||||
const summary = await migrateCurrentDatabase({
|
||||
dialect: 'sqlite',
|
||||
connectionString: ':memory:',
|
||||
overwrite: true,
|
||||
});
|
||||
|
||||
expect(summary.rows.siteAnnouncements).toBe(1);
|
||||
expect(client.begin).toHaveBeenCalledTimes(1);
|
||||
expect(client.commit).toHaveBeenCalledTimes(1);
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.doUnmock('../db/index.js');
|
||||
vi.doUnmock('../db/runtimeSchemaBootstrap.js');
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ type BackupSnapshot = {
|
||||
timestamp: number;
|
||||
accounts: {
|
||||
sites: Array<Record<string, unknown>>;
|
||||
siteAnnouncements: Array<Record<string, unknown>>;
|
||||
siteDisabledModels: Array<Record<string, unknown>>;
|
||||
accounts: Array<Record<string, unknown>>;
|
||||
accountTokens: Array<Record<string, unknown>>;
|
||||
@@ -56,6 +57,7 @@ export interface DatabaseMigrationSummary {
|
||||
timestamp: number;
|
||||
rows: {
|
||||
sites: number;
|
||||
siteAnnouncements: number;
|
||||
siteDisabledModels: number;
|
||||
accounts: number;
|
||||
accountTokens: number;
|
||||
@@ -212,6 +214,7 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
|
||||
timestamp: Date.now(),
|
||||
accounts: {
|
||||
sites: await db.select().from(schema.sites).all() as Array<Record<string, unknown>>,
|
||||
siteAnnouncements: await db.select().from(schema.siteAnnouncements).all() as Array<Record<string, unknown>>,
|
||||
siteDisabledModels: await db.select().from(schema.siteDisabledModels).all() as Array<Record<string, unknown>>,
|
||||
accounts: await db.select().from(schema.accounts).all() as Array<Record<string, unknown>>,
|
||||
accountTokens: await db.select().from(schema.accountTokens).all() as Array<Record<string, unknown>>,
|
||||
@@ -257,6 +260,7 @@ async function clearTargetData(client: SqlClient): Promise<void> {
|
||||
'proxy_files',
|
||||
'account_tokens',
|
||||
'accounts',
|
||||
'site_announcements',
|
||||
'site_disabled_models',
|
||||
'token_routes',
|
||||
'sites',
|
||||
@@ -309,6 +313,50 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of snapshot.accounts.siteAnnouncements || []) {
|
||||
statements.push({
|
||||
table: 'site_announcements',
|
||||
columns: [
|
||||
'id',
|
||||
'site_id',
|
||||
'platform',
|
||||
'source_key',
|
||||
'title',
|
||||
'content',
|
||||
'level',
|
||||
'source_url',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'upstream_created_at',
|
||||
'upstream_updated_at',
|
||||
'first_seen_at',
|
||||
'last_seen_at',
|
||||
'read_at',
|
||||
'dismissed_at',
|
||||
'raw_payload',
|
||||
],
|
||||
values: [
|
||||
asNumber(row.id, 0),
|
||||
asNumber(row.siteId, 0),
|
||||
asNullableString(row.platform),
|
||||
asNullableString(row.sourceKey),
|
||||
asNullableString(row.title),
|
||||
asNullableString(row.content),
|
||||
asNullableString(row.level) ?? 'info',
|
||||
asNullableString(row.sourceUrl),
|
||||
asNullableString(row.startsAt),
|
||||
asNullableString(row.endsAt),
|
||||
asNullableString(row.upstreamCreatedAt),
|
||||
asNullableString(row.upstreamUpdatedAt),
|
||||
asNullableString(row.firstSeenAt),
|
||||
asNullableString(row.lastSeenAt),
|
||||
asNullableString(row.readAt),
|
||||
asNullableString(row.dismissedAt),
|
||||
asNullableString(row.rawPayload),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of snapshot.accounts.accounts) {
|
||||
statements.push({
|
||||
table: 'accounts',
|
||||
@@ -617,6 +665,7 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
|
||||
if (client.dialect !== 'postgres') return;
|
||||
const tables = [
|
||||
'sites',
|
||||
'site_announcements',
|
||||
'site_disabled_models',
|
||||
'accounts',
|
||||
'account_tokens',
|
||||
@@ -684,6 +733,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro
|
||||
timestamp: snapshot.timestamp,
|
||||
rows: {
|
||||
sites: snapshot.accounts.sites.length,
|
||||
siteAnnouncements: snapshot.accounts.siteAnnouncements.length,
|
||||
siteDisabledModels: snapshot.accounts.siteDisabledModels.length,
|
||||
accounts: snapshot.accounts.accounts.length,
|
||||
accountTokens: snapshot.accounts.accountTokens.length,
|
||||
|
||||
@@ -116,6 +116,12 @@ describe('downstreamApiKeyService', () => {
|
||||
expect(service.isModelAllowedByPolicy('gemini-2.0-flash', result.policy)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps all explicitly selected supported models when list exceeds 200 items', () => {
|
||||
const selectedModels = Array.from({ length: 260 }, (_, index) => `model-${String(index + 1).padStart(3, '0')}`);
|
||||
|
||||
expect(service.normalizeSupportedModelsInput(selectedModels)).toEqual(selectedModels);
|
||||
});
|
||||
|
||||
it('treats selected groups as additional allowed exposed route scope (union semantics)', async () => {
|
||||
const claudeGroup = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||
@@ -136,6 +142,18 @@ describe('downstreamApiKeyService', () => {
|
||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gemini-2.0-flash', policy)).toBe(false);
|
||||
});
|
||||
|
||||
it('denies all models when both supportedModels and allowedRouteIds are empty', async () => {
|
||||
const policy = {
|
||||
supportedModels: [],
|
||||
allowedRouteIds: [],
|
||||
siteWeightMultipliers: {},
|
||||
denyAllWhenEmpty: true,
|
||||
};
|
||||
|
||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('gpt-4o-mini', policy)).toBe(false);
|
||||
expect(await service.isModelAllowedByPolicyOrAllowedRoutes('claude-opus-4-6', policy)).toBe(false);
|
||||
});
|
||||
|
||||
it('authorizes by selected group model pattern only, not arbitrary internal models', async () => {
|
||||
const virtualModelGroup = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'claude-opus-4-6',
|
||||
|
||||
@@ -90,13 +90,25 @@ function normalizePositiveIntegerOrNull(value: unknown): number | null {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseJson(value: string | null | undefined): unknown {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
function parseJson(value: unknown): unknown {
|
||||
if (value === null || value === undefined) return null;
|
||||
|
||||
// PostgreSQL JSONB columns are returned as parsed objects/arrays by the pg driver
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// SQLite TEXT columns store JSON as strings that need parsing
|
||||
if (typeof value === 'string') {
|
||||
if (value === '') return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeGroupNameInput(input: unknown): string | null {
|
||||
@@ -131,16 +143,14 @@ export function normalizeSupportedModelsInput(input: unknown): string[] {
|
||||
if (Array.isArray(input)) {
|
||||
return input
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
|
||||
.slice(0, 200);
|
||||
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index);
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
return input
|
||||
.split(/\r?\n|,/g)
|
||||
.map((item) => item.trim())
|
||||
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
|
||||
.slice(0, 200);
|
||||
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index);
|
||||
}
|
||||
|
||||
return [];
|
||||
@@ -234,7 +244,7 @@ export async function isModelAllowedByPolicyOrAllowedRoutes(model: string, polic
|
||||
const hasPatternRules = patterns.length > 0;
|
||||
const hasRouteRules = allowedRouteIds.length > 0;
|
||||
|
||||
if (!hasPatternRules && !hasRouteRules) return true;
|
||||
if (!hasPatternRules && !hasRouteRules) return policy.denyAllWhenEmpty === true ? false : true;
|
||||
|
||||
if (hasPatternRules && patterns.some((pattern) => matchesDownstreamModelPattern(model, pattern))) {
|
||||
return true;
|
||||
@@ -278,6 +288,7 @@ export function toPolicyFromView(view: Pick<DownstreamApiKeyPolicyView, 'support
|
||||
supportedModels: normalizeSupportedModelsInput(view.supportedModels),
|
||||
allowedRouteIds: normalizeAllowedRouteIdsInput(view.allowedRouteIds),
|
||||
siteWeightMultipliers: normalizeSiteWeightMultipliersInput(view.siteWeightMultipliers),
|
||||
denyAllWhenEmpty: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface DownstreamRoutingPolicy {
|
||||
supportedModels: string[];
|
||||
allowedRouteIds: number[];
|
||||
siteWeightMultipliers: Record<number, number>;
|
||||
denyAllWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
export const EMPTY_DOWNSTREAM_ROUTING_POLICY: DownstreamRoutingPolicy = {
|
||||
|
||||
@@ -248,6 +248,55 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
expect(parsed.runtimeHealth?.checkedAt).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('does not scan hidden managed tokens for direct apikey connections', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => (
|
||||
token === 'sk-direct-credential' ? ['gpt-4.1'] : ['legacy-should-not-be-used']
|
||||
));
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'apikey-direct-site',
|
||||
url: 'https://apikey-direct.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'apikey-direct-user',
|
||||
accessToken: '',
|
||||
apiToken: 'sk-direct-credential',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
const hiddenToken = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'legacy-hidden',
|
||||
token: 'sk-legacy-hidden',
|
||||
source: 'legacy',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
modelCount: 1,
|
||||
modelsPreview: ['gpt-4.1'],
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: true,
|
||||
});
|
||||
|
||||
const tokenRows = await db.select().from(schema.tokenModelAvailability)
|
||||
.where(eq(schema.tokenModelAvailability.tokenId, hiddenToken.id))
|
||||
.all();
|
||||
expect(tokenRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns structured result when account missing', async () => {
|
||||
const result = await refreshModelsForAccount(9999);
|
||||
|
||||
|
||||
@@ -85,6 +85,67 @@ describe('rebuildTokenRoutesFromAvailability', () => {
|
||||
expect(channels[0]?.manualOverride).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores hidden account_tokens for direct apikey connections when rebuilding routes', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'apikey-legacy-site',
|
||||
url: 'https://apikey-legacy.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'apikey-legacy-user',
|
||||
accessToken: '',
|
||||
apiToken: 'sk-direct-credential',
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
const hiddenToken = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'legacy-hidden',
|
||||
token: 'sk-hidden-legacy-token',
|
||||
source: 'legacy',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'gpt-4.1',
|
||||
available: true,
|
||||
latencyMs: 200,
|
||||
checkedAt: '2026-03-20T08:00:00.000Z',
|
||||
}).run();
|
||||
|
||||
await db.insert(schema.tokenModelAvailability).values({
|
||||
tokenId: hiddenToken.id,
|
||||
modelName: 'gpt-4.1',
|
||||
available: true,
|
||||
latencyMs: 180,
|
||||
checkedAt: '2026-03-20T08:00:00.000Z',
|
||||
}).run();
|
||||
|
||||
const rebuild = await rebuildTokenRoutesFromAvailability();
|
||||
|
||||
expect(rebuild.models).toBe(1);
|
||||
|
||||
const route = await db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-4.1'))
|
||||
.get();
|
||||
expect(route).toBeDefined();
|
||||
|
||||
const channels = await db.select().from(schema.routeChannels)
|
||||
.where(and(
|
||||
eq(schema.routeChannels.routeId, route!.id),
|
||||
eq(schema.routeChannels.accountId, account.id),
|
||||
))
|
||||
.all();
|
||||
|
||||
expect(channels).toHaveLength(1);
|
||||
expect(channels[0]?.tokenId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('creates an exact route with an account-direct channel for oauth model availability', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'codex-site',
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getCredentialModeFromExtraConfig,
|
||||
getProxyUrlFromExtraConfig,
|
||||
mergeAccountExtraConfig,
|
||||
requiresManagedAccountTokens,
|
||||
resolvePlatformUserId,
|
||||
supportsDirectAccountRoutingConnection,
|
||||
} from './accountExtraConfig.js';
|
||||
@@ -142,12 +143,6 @@ function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return !(account.accessToken || '').trim();
|
||||
}
|
||||
|
||||
function normalizeModels(models: string[]): string[] {
|
||||
const normalizedModels: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -828,18 +823,21 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
||||
} catch { }
|
||||
}
|
||||
|
||||
let enabledTokens = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(and(
|
||||
eq(schema.accountTokens.accountId, account.id),
|
||||
eq(schema.accountTokens.enabled, true),
|
||||
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
|
||||
))
|
||||
.all();
|
||||
const usesManagedTokens = requiresManagedAccountTokens(account);
|
||||
let enabledTokens = usesManagedTokens
|
||||
? await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(and(
|
||||
eq(schema.accountTokens.accountId, account.id),
|
||||
eq(schema.accountTokens.enabled, true),
|
||||
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
|
||||
))
|
||||
.all()
|
||||
: [];
|
||||
enabledTokens = enabledTokens.filter(isUsableAccountToken);
|
||||
|
||||
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
|
||||
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
|
||||
if (usesManagedTokens && enabledTokens.length === 0) {
|
||||
const fallback = discoveredApiToken || account.apiToken || null;
|
||||
if (fallback) {
|
||||
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
||||
@@ -1027,7 +1025,10 @@ export async function rebuildTokenRoutesFromAvailability() {
|
||||
),
|
||||
)
|
||||
.all();
|
||||
const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens));
|
||||
const usableTokenRows = tokenRows.filter((row) => (
|
||||
isUsableAccountToken(row.account_tokens)
|
||||
&& requiresManagedAccountTokens(row.accounts)
|
||||
));
|
||||
|
||||
const accountRows = await db.select().from(schema.modelAvailability)
|
||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||
|
||||
@@ -17,12 +17,24 @@ vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
const withExplicitProxyRequestInitMock = vi.fn(
|
||||
(_proxyUrl: unknown, options?: Record<string, unknown>) => {
|
||||
if (_proxyUrl) return { ...(options || {}), dispatcher: 'mock-proxy-dispatcher' };
|
||||
return options ?? {};
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock('./siteProxy.js', () => ({
|
||||
withExplicitProxyRequestInit: (...args: unknown[]) => withExplicitProxyRequestInitMock(...args),
|
||||
}));
|
||||
|
||||
describe('notifyService', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
sendMailMock.mockReset();
|
||||
createTransportMock.mockClear();
|
||||
fetchMock.mockReset();
|
||||
withExplicitProxyRequestInitMock.mockClear();
|
||||
|
||||
const { config } = await import('../config.js');
|
||||
config.notifyCooldownSec = 300;
|
||||
@@ -35,6 +47,9 @@ describe('notifyService', () => {
|
||||
(config as any).telegramEnabled = false;
|
||||
(config as any).telegramBotToken = '';
|
||||
(config as any).telegramChatId = '';
|
||||
(config as any).telegramUseSystemProxy = false;
|
||||
config.systemProxyUrl = '';
|
||||
(config as any).telegramMessageThreadId = '';
|
||||
config.smtpEnabled = true;
|
||||
config.smtpHost = 'smtp.example.com';
|
||||
config.smtpPort = 465;
|
||||
@@ -167,11 +182,12 @@ describe('notifyService', () => {
|
||||
expect(payload?.text || '').toContain('UTC Time:');
|
||||
});
|
||||
|
||||
it('sends telegram message when telegram channel is enabled', async () => {
|
||||
it('sends telegram message without topic when telegram thread id is empty', async () => {
|
||||
const { config } = await import('../config.js');
|
||||
(config as any).telegramEnabled = true;
|
||||
(config as any).telegramBotToken = '123456:telegram-token';
|
||||
(config as any).telegramChatId = '-1001234567890';
|
||||
(config as any).telegramMessageThreadId = '';
|
||||
config.smtpEnabled = false;
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
@@ -192,13 +208,35 @@ describe('notifyService', () => {
|
||||
);
|
||||
|
||||
const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string };
|
||||
const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string };
|
||||
const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string; message_thread_id?: number };
|
||||
expect(payload.chat_id).toBe('-1001234567890');
|
||||
expect(payload.message_thread_id).toBeUndefined();
|
||||
expect(payload.text || '').toContain('Level: warning');
|
||||
expect(payload.text || '').toContain('Local Time:');
|
||||
expect(payload.text || '').toContain('UTC Time:');
|
||||
});
|
||||
|
||||
it('sends telegram topic id when telegram thread id is configured', async () => {
|
||||
const { config } = await import('../config.js');
|
||||
(config as any).telegramEnabled = true;
|
||||
(config as any).telegramBotToken = '123456:telegram-token';
|
||||
(config as any).telegramChatId = '-1001234567890';
|
||||
(config as any).telegramMessageThreadId = '77';
|
||||
config.smtpEnabled = false;
|
||||
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
await sendNotification('测试通知', 'message', 'warning', { bypassThrottle: true, throwOnFailure: true });
|
||||
|
||||
const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string };
|
||||
const payload = JSON.parse(rawBody?.body || '{}') as { message_thread_id?: number };
|
||||
expect(payload.message_thread_id).toBe(77);
|
||||
});
|
||||
|
||||
it('uses TELEGRAM_API_BASE_URL when configured', async () => {
|
||||
const { config } = await import('../config.js');
|
||||
(config as any).telegramEnabled = true;
|
||||
@@ -222,4 +260,50 @@ describe('notifyService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies system proxy dispatcher when telegramUseSystemProxy is enabled', async () => {
|
||||
const { config } = await import('../config.js');
|
||||
(config as any).telegramEnabled = true;
|
||||
(config as any).telegramBotToken = '123456:telegram-token';
|
||||
(config as any).telegramChatId = '-1001234567890';
|
||||
(config as any).telegramUseSystemProxy = true;
|
||||
config.systemProxyUrl = 'http://127.0.0.1:7890';
|
||||
config.smtpEnabled = false;
|
||||
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
await sendNotification('测试通知', 'proxy-test', 'info', { bypassThrottle: true, throwOnFailure: true });
|
||||
|
||||
expect(withExplicitProxyRequestInitMock).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:7890',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/sendMessage'),
|
||||
expect.objectContaining({ dispatcher: 'mock-proxy-dispatcher' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply proxy dispatcher when telegramUseSystemProxy is disabled', async () => {
|
||||
const { config } = await import('../config.js');
|
||||
(config as any).telegramEnabled = true;
|
||||
(config as any).telegramBotToken = '123456:telegram-token';
|
||||
(config as any).telegramChatId = '-1001234567890';
|
||||
(config as any).telegramUseSystemProxy = false;
|
||||
config.systemProxyUrl = 'http://127.0.0.1:7890';
|
||||
config.smtpEnabled = false;
|
||||
|
||||
fetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const { sendNotification } = await import('./notifyService.js');
|
||||
await sendNotification('测试通知', 'no-proxy-test', 'info', { bypassThrottle: true, throwOnFailure: true });
|
||||
|
||||
expect(withExplicitProxyRequestInitMock).toHaveBeenCalledWith(
|
||||
null,
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
const fetchOptions = fetchMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(fetchOptions.dispatcher).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetch } from 'undici';
|
||||
import { config } from '../config.js';
|
||||
import { withExplicitProxyRequestInit } from './siteProxy.js';
|
||||
import nodemailer, { type Transporter } from 'nodemailer';
|
||||
import {
|
||||
createNotificationSignature,
|
||||
@@ -221,18 +222,26 @@ export async function sendNotification(
|
||||
const telegramApiBaseUrl = String(config.telegramApiBaseUrl || 'https://api.telegram.org').replace(/\/+$/, '');
|
||||
const telegramApiUrl = `${telegramApiBaseUrl}/bot${config.telegramBotToken}/sendMessage`;
|
||||
const text = buildTelegramText(title, resolvedMessage, level, timeFootnote);
|
||||
const telegramMessageThreadId = Number.parseInt(String(config.telegramMessageThreadId || '').trim(), 10);
|
||||
tasks.push({
|
||||
channel: 'telegram',
|
||||
run: async () => {
|
||||
const response = await fetch(telegramApiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: config.telegramChatId,
|
||||
text,
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
});
|
||||
const telegramRequestInit = withExplicitProxyRequestInit(
|
||||
config.telegramUseSystemProxy ? config.systemProxyUrl : null,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: config.telegramChatId,
|
||||
...(Number.isFinite(telegramMessageThreadId) && telegramMessageThreadId > 0
|
||||
? { message_thread_id: telegramMessageThreadId }
|
||||
: {}),
|
||||
text,
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const response = await fetch(telegramApiUrl, telegramRequestInit);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Telegram 响应状态 ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { minimatch } from 'minimatch';
|
||||
|
||||
export type PayloadRuleModel = {
|
||||
name: string;
|
||||
protocol?: string;
|
||||
};
|
||||
|
||||
export type PayloadValueRule = {
|
||||
models: PayloadRuleModel[];
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PayloadFilterRule = {
|
||||
models: PayloadRuleModel[];
|
||||
params: string[];
|
||||
};
|
||||
|
||||
export type PayloadRulesConfig = {
|
||||
default: PayloadValueRule[];
|
||||
defaultRaw: PayloadValueRule[];
|
||||
override: PayloadValueRule[];
|
||||
overrideRaw: PayloadValueRule[];
|
||||
filter: PayloadFilterRule[];
|
||||
};
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneJsonValue<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonValue(item)) as T;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]),
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function toPathSegments(path: string): string[] {
|
||||
const normalized = asTrimmedString(path).replace(/^\.+/, '');
|
||||
return normalized
|
||||
.split('.')
|
||||
.map((segment) => segment.trim())
|
||||
.filter((segment) => segment.length > 0);
|
||||
}
|
||||
|
||||
function parseIndexSegment(segment: string): number | null {
|
||||
if (!/^\d+$/.test(segment)) return null;
|
||||
const parsed = Number.parseInt(segment, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function hasPath(target: unknown, path: string): boolean {
|
||||
const segments = toPathSegments(path);
|
||||
if (segments.length <= 0) return false;
|
||||
|
||||
let current: unknown = target;
|
||||
for (const segment of segments) {
|
||||
const index = parseIndexSegment(segment);
|
||||
if (index !== null) {
|
||||
if (!Array.isArray(current) || index >= current.length) return false;
|
||||
current = current[index];
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) return false;
|
||||
current = current[segment];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function setPath(target: Record<string, unknown>, path: string, value: unknown): void {
|
||||
const segments = toPathSegments(path);
|
||||
if (segments.length <= 0) return;
|
||||
|
||||
let current: unknown = target;
|
||||
for (let index = 0; index < segments.length; index += 1) {
|
||||
const segment = segments[index];
|
||||
const nextSegment = segments[index + 1];
|
||||
const segmentIndex = parseIndexSegment(segment);
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
if (segmentIndex !== null) {
|
||||
if (!Array.isArray(current)) return;
|
||||
while (current.length <= segmentIndex) current.push(undefined);
|
||||
if (isLast) {
|
||||
current[segmentIndex] = cloneJsonValue(value);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(current[segmentIndex]) && !Array.isArray(current[segmentIndex])) {
|
||||
current[segmentIndex] = parseIndexSegment(nextSegment) !== null ? [] : {};
|
||||
}
|
||||
current = current[segmentIndex];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isRecord(current)) return;
|
||||
if (isLast) {
|
||||
current[segment] = cloneJsonValue(value);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(current[segment]) && !Array.isArray(current[segment])) {
|
||||
current[segment] = parseIndexSegment(nextSegment) !== null ? [] : {};
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
}
|
||||
|
||||
function deletePath(target: Record<string, unknown>, path: string): void {
|
||||
const segments = toPathSegments(path);
|
||||
if (segments.length <= 0) return;
|
||||
|
||||
let current: unknown = target;
|
||||
for (let index = 0; index < segments.length - 1; index += 1) {
|
||||
const segment = segments[index];
|
||||
const segmentIndex = parseIndexSegment(segment);
|
||||
if (segmentIndex !== null) {
|
||||
if (!Array.isArray(current) || segmentIndex >= current.length) return;
|
||||
current = current[segmentIndex];
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(current) || !Object.prototype.hasOwnProperty.call(current, segment)) return;
|
||||
current = current[segment];
|
||||
}
|
||||
|
||||
const lastSegment = segments[segments.length - 1];
|
||||
const lastIndex = parseIndexSegment(lastSegment);
|
||||
if (lastIndex !== null) {
|
||||
if (!Array.isArray(current) || lastIndex >= current.length) return;
|
||||
current.splice(lastIndex, 1);
|
||||
return;
|
||||
}
|
||||
if (!isRecord(current)) return;
|
||||
delete current[lastSegment];
|
||||
}
|
||||
|
||||
function normalizePayloadRuleModels(value: unknown): PayloadRuleModel[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
const protocol = asTrimmedString(item.protocol);
|
||||
return {
|
||||
name,
|
||||
...(protocol ? { protocol } : {}),
|
||||
};
|
||||
})
|
||||
.filter((item): item is PayloadRuleModel => !!item);
|
||||
}
|
||||
|
||||
function normalizePayloadValueRules(value: unknown): PayloadValueRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const models = normalizePayloadRuleModels(item.models);
|
||||
const params = isRecord(item.params) ? cloneJsonValue(item.params) : null;
|
||||
if (models.length <= 0 || !params) return null;
|
||||
return { models, params };
|
||||
})
|
||||
.filter((item): item is PayloadValueRule => !!item);
|
||||
}
|
||||
|
||||
function normalizePayloadFilterRules(value: unknown): PayloadFilterRule[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return null;
|
||||
const models = normalizePayloadRuleModels(item.models);
|
||||
const params = Array.isArray(item.params)
|
||||
? item.params
|
||||
.map((entry) => asTrimmedString(entry))
|
||||
.filter((entry) => entry.length > 0)
|
||||
: [];
|
||||
if (models.length <= 0 || params.length <= 0) return null;
|
||||
return { models, params };
|
||||
})
|
||||
.filter((item): item is PayloadFilterRule => !!item);
|
||||
}
|
||||
|
||||
function modelRuleMatches(rule: PayloadRuleModel, protocol: string, candidates: string[]): boolean {
|
||||
if (!rule.name || candidates.length <= 0) return false;
|
||||
const ruleProtocol = asTrimmedString(rule.protocol).toLowerCase();
|
||||
if (ruleProtocol && protocol && ruleProtocol !== protocol) return false;
|
||||
return candidates.some((candidate) => minimatch(candidate, rule.name, { nocase: true }));
|
||||
}
|
||||
|
||||
function rulesMatch(models: PayloadRuleModel[], protocol: string, candidates: string[]): boolean {
|
||||
if (models.length <= 0 || candidates.length <= 0) return false;
|
||||
return models.some((rule) => modelRuleMatches(rule, protocol, candidates));
|
||||
}
|
||||
|
||||
function parseRawRuleValue(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return cloneJsonValue(value);
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmptyPayloadRulesConfig(): PayloadRulesConfig {
|
||||
return {
|
||||
default: [],
|
||||
defaultRaw: [],
|
||||
override: [],
|
||||
overrideRaw: [],
|
||||
filter: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePayloadRulesConfig(value: unknown): PayloadRulesConfig {
|
||||
if (!isRecord(value)) return createEmptyPayloadRulesConfig();
|
||||
return {
|
||||
default: normalizePayloadValueRules(value.default),
|
||||
defaultRaw: normalizePayloadValueRules(value.defaultRaw ?? value['default-raw']),
|
||||
override: normalizePayloadValueRules(value.override),
|
||||
overrideRaw: normalizePayloadValueRules(value.overrideRaw ?? value['override-raw']),
|
||||
filter: normalizePayloadFilterRules(value.filter),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyPayloadRules(input: {
|
||||
rules: PayloadRulesConfig;
|
||||
payload: Record<string, unknown>;
|
||||
modelName?: string;
|
||||
requestedModel?: string;
|
||||
protocol?: string;
|
||||
}): Record<string, unknown> {
|
||||
const candidates = Array.from(new Set(
|
||||
[input.modelName, input.requestedModel]
|
||||
.map((value) => asTrimmedString(value))
|
||||
.filter((value) => value.length > 0),
|
||||
));
|
||||
if (candidates.length <= 0) return input.payload;
|
||||
|
||||
const rules = input.rules;
|
||||
const hasAnyRules = rules.default.length > 0
|
||||
|| rules.defaultRaw.length > 0
|
||||
|| rules.override.length > 0
|
||||
|| rules.overrideRaw.length > 0
|
||||
|| rules.filter.length > 0;
|
||||
if (!hasAnyRules) return input.payload;
|
||||
|
||||
const protocol = asTrimmedString(input.protocol).toLowerCase();
|
||||
const original = cloneJsonValue(input.payload);
|
||||
const output = cloneJsonValue(input.payload);
|
||||
const appliedDefaults = new Set<string>();
|
||||
|
||||
for (const rule of rules.default) {
|
||||
if (!rulesMatch(rule.models, protocol, candidates)) continue;
|
||||
for (const [path, value] of Object.entries(rule.params)) {
|
||||
const normalizedPath = asTrimmedString(path);
|
||||
if (!normalizedPath || hasPath(original, normalizedPath) || appliedDefaults.has(normalizedPath)) continue;
|
||||
setPath(output, normalizedPath, value);
|
||||
appliedDefaults.add(normalizedPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of rules.defaultRaw) {
|
||||
if (!rulesMatch(rule.models, protocol, candidates)) continue;
|
||||
for (const [path, value] of Object.entries(rule.params)) {
|
||||
const normalizedPath = asTrimmedString(path);
|
||||
if (!normalizedPath || hasPath(original, normalizedPath) || appliedDefaults.has(normalizedPath)) continue;
|
||||
const parsed = parseRawRuleValue(value);
|
||||
if (parsed === undefined) continue;
|
||||
setPath(output, normalizedPath, parsed);
|
||||
appliedDefaults.add(normalizedPath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of rules.override) {
|
||||
if (!rulesMatch(rule.models, protocol, candidates)) continue;
|
||||
for (const [path, value] of Object.entries(rule.params)) {
|
||||
const normalizedPath = asTrimmedString(path);
|
||||
if (!normalizedPath) continue;
|
||||
setPath(output, normalizedPath, value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of rules.overrideRaw) {
|
||||
if (!rulesMatch(rule.models, protocol, candidates)) continue;
|
||||
for (const [path, value] of Object.entries(rule.params)) {
|
||||
const normalizedPath = asTrimmedString(path);
|
||||
if (!normalizedPath) continue;
|
||||
const parsed = parseRawRuleValue(value);
|
||||
if (parsed === undefined) continue;
|
||||
setPath(output, normalizedPath, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
for (const rule of rules.filter) {
|
||||
if (!rulesMatch(rule.models, protocol, candidates)) continue;
|
||||
for (const path of rule.params) {
|
||||
const normalizedPath = asTrimmedString(path);
|
||||
if (!normalizedPath) continue;
|
||||
deletePath(output, normalizedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
||||
|
||||
@@ -7,12 +8,33 @@ export interface CheckinResult {
|
||||
reward?: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionPlanSummary {
|
||||
id?: number;
|
||||
groupId?: number;
|
||||
groupName?: string;
|
||||
status?: string;
|
||||
expiresAt?: string;
|
||||
dailyUsedUsd?: number;
|
||||
dailyLimitUsd?: number;
|
||||
weeklyUsedUsd?: number;
|
||||
weeklyLimitUsd?: number;
|
||||
monthlyUsedUsd?: number;
|
||||
monthlyLimitUsd?: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionSummary {
|
||||
activeCount: number;
|
||||
totalUsedUsd: number;
|
||||
subscriptions: SubscriptionPlanSummary[];
|
||||
}
|
||||
|
||||
export interface BalanceInfo {
|
||||
balance: number;
|
||||
used: number;
|
||||
quota: number;
|
||||
todayIncome?: number;
|
||||
todayQuotaConsumption?: number;
|
||||
subscriptionSummary?: SubscriptionSummary;
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
@@ -44,6 +66,19 @@ export interface ApiTokenInfo {
|
||||
tokenGroup?: string | null;
|
||||
}
|
||||
|
||||
export interface SiteAnnouncement {
|
||||
sourceKey: string;
|
||||
title: string;
|
||||
content: string;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
sourceUrl?: string | null;
|
||||
startsAt?: string | null;
|
||||
endsAt?: string | null;
|
||||
upstreamCreatedAt?: string | null;
|
||||
upstreamUpdatedAt?: string | null;
|
||||
rawPayload?: unknown;
|
||||
}
|
||||
|
||||
export interface CreateApiTokenOptions {
|
||||
name?: string;
|
||||
group?: string;
|
||||
@@ -66,6 +101,7 @@ export interface PlatformAdapter {
|
||||
getModels(baseUrl: string, token: string, platformUserId?: number): Promise<string[]>;
|
||||
getApiToken(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string | null>;
|
||||
getApiTokens(baseUrl: string, accessToken: string, platformUserId?: number): Promise<ApiTokenInfo[]>;
|
||||
getSiteAnnouncements(baseUrl: string, accessToken: string, platformUserId?: number): Promise<SiteAnnouncement[]>;
|
||||
getUserGroups(baseUrl: string, accessToken: string, platformUserId?: number): Promise<string[]>;
|
||||
createApiToken(baseUrl: string, accessToken: string, platformUserId?: number, options?: CreateApiTokenOptions): Promise<boolean>;
|
||||
deleteApiToken(baseUrl: string, accessToken: string, tokenKey: string, platformUserId?: number): Promise<boolean>;
|
||||
@@ -149,6 +185,14 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
|
||||
return [{ name: 'default', key: token, enabled: true, tokenGroup: 'default' }];
|
||||
}
|
||||
|
||||
async getSiteAnnouncements(
|
||||
_baseUrl: string,
|
||||
_accessToken: string,
|
||||
_platformUserId?: number,
|
||||
): Promise<SiteAnnouncement[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async createApiToken(
|
||||
_baseUrl: string,
|
||||
_accessToken: string,
|
||||
@@ -192,4 +236,9 @@ export abstract class BasePlatformAdapter implements PlatformAdapter {
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
protected buildNoticeSourceKey(content: string): string {
|
||||
const normalized = (content || '').trim();
|
||||
return `notice:${createHash('sha1').update(normalized).digest('hex')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DoneHubAdapter } from './doneHub.js';
|
||||
|
||||
describe('DoneHubAdapter', () => {
|
||||
@@ -48,6 +49,15 @@ describe('DoneHubAdapter', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/notice') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: 'Scheduled maintenance tonight',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not found' }));
|
||||
});
|
||||
@@ -93,4 +103,20 @@ describe('DoneHubAdapter', () => {
|
||||
expect(balance.used).toBe(60);
|
||||
expect(balance.quota).toBe(100);
|
||||
});
|
||||
|
||||
it('normalizes the global site notice from /api/notice', async () => {
|
||||
const adapter = new DoneHubAdapter();
|
||||
const rows = await adapter.getSiteAnnouncements(baseUrl, 'token');
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
sourceKey: `notice:${createHash('sha1').update('Scheduled maintenance tonight').digest('hex')}`,
|
||||
title: 'Site notice',
|
||||
content: 'Scheduled maintenance tonight',
|
||||
level: 'info',
|
||||
sourceUrl: '/api/notice',
|
||||
rawPayload: { success: true, data: 'Scheduled maintenance tonight' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OneHubAdapter } from './oneHub.js';
|
||||
import type { BalanceInfo, CheckinResult } from './base.js';
|
||||
import type { BalanceInfo, CheckinResult, SiteAnnouncement } from './base.js';
|
||||
|
||||
export class DoneHubAdapter extends OneHubAdapter {
|
||||
readonly platformName: string = 'done-hub';
|
||||
@@ -32,6 +32,26 @@ export class DoneHubAdapter extends OneHubAdapter {
|
||||
return { balance: quotaRemaining, used, quota: total, todayIncome, todayQuotaConsumption };
|
||||
}
|
||||
|
||||
override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise<SiteAnnouncement[]> {
|
||||
try {
|
||||
const payload = await this.fetchJson<any>(`${baseUrl}/api/notice`);
|
||||
const content = typeof payload?.data === 'string'
|
||||
? payload.data.trim()
|
||||
: (typeof payload === 'string' ? payload.trim() : '');
|
||||
if (!content) return [];
|
||||
return [{
|
||||
sourceKey: this.buildNoticeSourceKey(content),
|
||||
title: 'Site notice',
|
||||
content,
|
||||
level: 'info',
|
||||
sourceUrl: '/api/notice',
|
||||
rawPayload: payload,
|
||||
}];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// getModels is inherited from OneHubAdapter which already has /api/available_model fallback.
|
||||
// No need to override here — OneHub's implementation handles this correctly.
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { NewApiAdapter } from './newApi.js';
|
||||
|
||||
@@ -142,6 +143,15 @@ describe('NewApiAdapter', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/api/notice') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: 'Welcome to the site',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/api/token/')) {
|
||||
if (typeof req.headers.authorization === 'string' && req.headers.authorization === `Bearer ${COOKIE_SHIELDED_TOKEN}`) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
@@ -616,4 +626,20 @@ describe('NewApiAdapter', () => {
|
||||
expect(receivedHeaders['rix-api-user']).toBe('42');
|
||||
expect(receivedHeaders['neo-api-user']).toBe('42');
|
||||
});
|
||||
|
||||
it('normalizes the global site notice from /api/notice', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const rows = await adapter.getSiteAnnouncements(baseUrl, 'session-token');
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
sourceKey: `notice:${createHash('sha1').update('Welcome to the site').digest('hex')}`,
|
||||
title: 'Site notice',
|
||||
content: 'Welcome to the site',
|
||||
level: 'info',
|
||||
sourceUrl: '/api/notice',
|
||||
rawPayload: { success: true, data: 'Welcome to the site' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions } from './base.js';
|
||||
import { ApiTokenInfo, BasePlatformAdapter, CheckinResult, BalanceInfo, UserInfo, TokenVerifyResult, CreateApiTokenOptions, type SiteAnnouncement } from './base.js';
|
||||
import type { RequestInit as UndiciRequestInit } from 'undici';
|
||||
import { createContext, runInContext } from 'node:vm';
|
||||
import { withSiteProxyRequestInit } from '../siteProxy.js';
|
||||
@@ -15,6 +15,26 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
override async getSiteAnnouncements(baseUrl: string, _accessToken: string): Promise<SiteAnnouncement[]> {
|
||||
try {
|
||||
const payload = await this.fetchJson<any>(`${baseUrl}/api/notice`);
|
||||
const content = typeof payload?.data === 'string'
|
||||
? payload.data.trim()
|
||||
: (typeof payload === 'string' ? payload.trim() : '');
|
||||
if (!content) return [];
|
||||
return [{
|
||||
sourceKey: this.buildNoticeSourceKey(content),
|
||||
title: 'Site notice',
|
||||
content,
|
||||
level: 'info',
|
||||
sourceUrl: '/api/notice',
|
||||
rawPayload: payload,
|
||||
}];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private tryDecodeUserId(token: string): number | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BasePlatformAdapter,
|
||||
type BalanceInfo,
|
||||
type CheckinResult,
|
||||
type PlatformAdapter,
|
||||
type SiteAnnouncement,
|
||||
} from './base.js';
|
||||
|
||||
class UnsupportedAnnouncementAdapter extends BasePlatformAdapter {
|
||||
override readonly platformName = 'unsupported';
|
||||
|
||||
override async detect(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
override async checkin(): Promise<CheckinResult> {
|
||||
return { success: false, message: 'unsupported' };
|
||||
}
|
||||
|
||||
override async getBalance(): Promise<BalanceInfo> {
|
||||
return { balance: 0, used: 0, quota: 0 };
|
||||
}
|
||||
|
||||
override async getModels(): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class SingleNoticeAdapter extends UnsupportedAnnouncementAdapter {
|
||||
override readonly platformName = 'single-notice';
|
||||
|
||||
override async getSiteAnnouncements(): Promise<SiteAnnouncement[]> {
|
||||
return [{
|
||||
sourceKey: 'notice:welcome',
|
||||
title: 'Site notice',
|
||||
content: 'Welcome to the site',
|
||||
level: 'info',
|
||||
sourceUrl: '/api/notice',
|
||||
rawPayload: { notice: 'Welcome to the site' },
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
class ListAnnouncementAdapter extends UnsupportedAnnouncementAdapter {
|
||||
override readonly platformName = 'list-notice';
|
||||
|
||||
override async getSiteAnnouncements(): Promise<SiteAnnouncement[]> {
|
||||
return [
|
||||
{
|
||||
sourceKey: 'announcement:1',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'warning',
|
||||
startsAt: '2026-03-20T10:00:00Z',
|
||||
upstreamCreatedAt: '2026-03-20T09:00:00Z',
|
||||
rawPayload: { id: 1 },
|
||||
},
|
||||
{
|
||||
sourceKey: 'announcement:2',
|
||||
title: 'New model online',
|
||||
content: 'gpt-4.1 is available',
|
||||
level: 'info',
|
||||
endsAt: '2026-03-21T00:00:00Z',
|
||||
upstreamUpdatedAt: '2026-03-20T12:00:00Z',
|
||||
rawPayload: { id: 2 },
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function expectAnnouncementShape(row: SiteAnnouncement) {
|
||||
expect(row.sourceKey).toBeTruthy();
|
||||
expect(row.title).toBeTruthy();
|
||||
expect(row.content).toBeTruthy();
|
||||
expect(['info', 'warning', 'error']).toContain(row.level);
|
||||
}
|
||||
|
||||
describe('site announcement platform contract', () => {
|
||||
it('allows unsupported adapters to return an empty list', async () => {
|
||||
const adapter: PlatformAdapter = new UnsupportedAnnouncementAdapter();
|
||||
|
||||
await expect(adapter.getSiteAnnouncements('https://example.com', 'token')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes single-notice platforms into the shared announcement shape', async () => {
|
||||
const adapter: PlatformAdapter = new SingleNoticeAdapter();
|
||||
|
||||
const rows = await adapter.getSiteAnnouncements('https://example.com', 'token');
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expectAnnouncementShape(rows[0]);
|
||||
});
|
||||
|
||||
it('normalizes list-style platforms into the shared announcement shape', async () => {
|
||||
const adapter: PlatformAdapter = new ListAnnouncementAdapter();
|
||||
|
||||
const rows = await adapter.getSiteAnnouncements('https://example.com', 'token');
|
||||
|
||||
expect(rows).toHaveLength(2);
|
||||
rows.forEach(expectAnnouncementShape);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Sub2ApiAdapter } from './sub2api.js';
|
||||
|
||||
class MockSub2ApiAdapter extends Sub2ApiAdapter {
|
||||
constructor(private readonly responses: Record<string, unknown>) {
|
||||
super();
|
||||
}
|
||||
|
||||
protected override async fetchJson<T>(url: string): Promise<T> {
|
||||
const parsed = new URL(url);
|
||||
const key = `${parsed.pathname}${parsed.search}`;
|
||||
const value = this.responses[key];
|
||||
if (value instanceof Error) throw value;
|
||||
if (value === undefined) throw new Error(`Unexpected request: ${key}`);
|
||||
return value as T;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Sub2ApiAdapter subscription summary parsing', () => {
|
||||
it('returns summary data from /api/v1/subscriptions/summary', async () => {
|
||||
const adapter = new MockSub2ApiAdapter({
|
||||
'/api/v1/auth/me': {
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 },
|
||||
},
|
||||
'/api/v1/subscriptions/summary': {
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
active_count: 1,
|
||||
total_used_usd: 3.2,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 9,
|
||||
group_name: 'Pro',
|
||||
status: 'active',
|
||||
expires_at: '2026-04-01T00:00:00Z',
|
||||
monthly_used_usd: 3.2,
|
||||
monthly_limit_usd: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token');
|
||||
expect(balance.subscriptionSummary).toEqual({
|
||||
activeCount: 1,
|
||||
totalUsedUsd: 3.2,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 9,
|
||||
groupName: 'Pro',
|
||||
status: 'active',
|
||||
expiresAt: '2026-04-01T00:00:00.000Z',
|
||||
monthlyUsedUsd: 3.2,
|
||||
monthlyLimitUsd: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to /api/v1/subscriptions/active when summary endpoint fails', async () => {
|
||||
const adapter = new MockSub2ApiAdapter({
|
||||
'/api/v1/auth/me': {
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 },
|
||||
},
|
||||
'/api/v1/subscriptions/summary': new Error('HTTP 404'),
|
||||
'/api/v1/subscriptions/active': {
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: [
|
||||
{
|
||||
id: 10,
|
||||
group_name: 'Fallback',
|
||||
expires_at: '2026-05-01T00:00:00Z',
|
||||
monthly_used_usd: 1.5,
|
||||
monthly_limit_usd: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token');
|
||||
expect(balance.subscriptionSummary).toEqual({
|
||||
activeCount: 1,
|
||||
totalUsedUsd: 1.5,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 10,
|
||||
groupName: 'Fallback',
|
||||
expiresAt: '2026-05-01T00:00:00.000Z',
|
||||
monthlyUsedUsd: 1.5,
|
||||
monthlyLimitUsd: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,111 @@ describe('Sub2ApiAdapter', () => {
|
||||
expect(balance.used).toBe(0);
|
||||
});
|
||||
|
||||
it('includes subscription summary from /api/v1/subscriptions/summary when available', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/auth/me') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/subscriptions/summary') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
active_count: 1,
|
||||
total_used_usd: 3.75,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 3,
|
||||
group_name: 'Pro',
|
||||
status: 'active',
|
||||
expires_at: '2026-04-01T00:00:00Z',
|
||||
monthly_used_usd: 3.75,
|
||||
monthly_limit_usd: 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
|
||||
expect(balance.subscriptionSummary).toEqual({
|
||||
activeCount: 1,
|
||||
totalUsedUsd: 3.75,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 3,
|
||||
groupName: 'Pro',
|
||||
status: 'active',
|
||||
expiresAt: '2026-04-01T00:00:00.000Z',
|
||||
monthlyUsedUsd: 3.75,
|
||||
monthlyLimitUsd: 20,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to active subscriptions when summary endpoint is unavailable', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/auth/me') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/subscriptions/summary') {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ code: 404, message: 'not found' }));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/api/v1/subscriptions/active') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: [
|
||||
{
|
||||
id: 9,
|
||||
group_name: 'Fallback',
|
||||
expires_at: '2026-05-01T00:00:00Z',
|
||||
monthly_used_usd: 2.5,
|
||||
monthly_limit_usd: 15,
|
||||
},
|
||||
],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
|
||||
expect(balance.subscriptionSummary).toEqual({
|
||||
activeCount: 1,
|
||||
totalUsedUsd: 2.5,
|
||||
subscriptions: [
|
||||
{
|
||||
id: 9,
|
||||
groupName: 'Fallback',
|
||||
expiresAt: '2026-05-01T00:00:00.000Z',
|
||||
monthlyUsedUsd: 2.5,
|
||||
monthlyLimitUsd: 15,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches user info from /api/v1/auth/me', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/auth/me') {
|
||||
@@ -438,4 +543,79 @@ describe('Sub2ApiAdapter', () => {
|
||||
const deleted = await adapter.deleteApiToken(baseUrl, 'jwt-token', 'sk-delete-me');
|
||||
expect(deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('normalizes announcements from /api/v1/announcements', async () => {
|
||||
await startServer((req, res) => {
|
||||
if (req.url === '/api/v1/announcements?page=1&page_size=100') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 11,
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
starts_at: '2026-03-20T10:00:00Z',
|
||||
ends_at: '2026-03-20T12:00:00Z',
|
||||
created_at: '2026-03-20T09:00:00Z',
|
||||
updated_at: '2026-03-20T09:30:00Z',
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: 'New model online',
|
||||
content: 'gpt-4.1 is available',
|
||||
read_at: '2026-03-20T12:05:00Z',
|
||||
created_at: '2026-03-20T12:00:00Z',
|
||||
updated_at: '2026-03-20T12:01:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
|
||||
const rows = await adapter.getSiteAnnouncements(baseUrl, 'jwt-token');
|
||||
|
||||
expect(rows).toEqual([
|
||||
{
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
startsAt: '2026-03-20T10:00:00Z',
|
||||
endsAt: '2026-03-20T12:00:00Z',
|
||||
upstreamCreatedAt: '2026-03-20T09:00:00Z',
|
||||
upstreamUpdatedAt: '2026-03-20T09:30:00Z',
|
||||
rawPayload: {
|
||||
id: 11,
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
starts_at: '2026-03-20T10:00:00Z',
|
||||
ends_at: '2026-03-20T12:00:00Z',
|
||||
created_at: '2026-03-20T09:00:00Z',
|
||||
updated_at: '2026-03-20T09:30:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
sourceKey: 'announcement:12',
|
||||
title: 'New model online',
|
||||
content: 'gpt-4.1 is available',
|
||||
level: 'info',
|
||||
upstreamCreatedAt: '2026-03-20T12:00:00Z',
|
||||
upstreamUpdatedAt: '2026-03-20T12:01:00Z',
|
||||
rawPayload: {
|
||||
id: 12,
|
||||
title: 'New model online',
|
||||
content: 'gpt-4.1 is available',
|
||||
read_at: '2026-03-20T12:05:00Z',
|
||||
created_at: '2026-03-20T12:00:00Z',
|
||||
updated_at: '2026-03-20T12:01:00Z',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
CheckinResult,
|
||||
BalanceInfo,
|
||||
CreateApiTokenOptions,
|
||||
SubscriptionPlanSummary,
|
||||
SubscriptionSummary,
|
||||
type SiteAnnouncement,
|
||||
UserInfo,
|
||||
} from './base.js';
|
||||
|
||||
@@ -21,6 +24,193 @@ function normalizeBaseUrl(baseUrl: string): string {
|
||||
export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||
readonly platformName = 'sub2api';
|
||||
|
||||
private roundCurrency(value: number): number {
|
||||
return Math.round(value * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
private parsePositiveInteger(raw: unknown): number | undefined {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return Math.trunc(raw);
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number.parseInt(raw.trim(), 10);
|
||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private parseNonNegativeNumber(raw: unknown): number | undefined {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
|
||||
return this.roundCurrency(raw);
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw.trim());
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return this.roundCurrency(parsed);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private parseDateTime(raw: unknown): string | undefined {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private parseSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
||||
|
||||
const item = raw as Record<string, unknown>;
|
||||
const group = item.group && typeof item.group === 'object' && !Array.isArray(item.group)
|
||||
? item.group as Record<string, unknown>
|
||||
: null;
|
||||
|
||||
const normalized: SubscriptionPlanSummary = {};
|
||||
|
||||
const id = this.parsePositiveInteger(item.id);
|
||||
if (id) normalized.id = id;
|
||||
|
||||
const groupId = this.parsePositiveInteger(item.group_id ?? item.groupId ?? group?.id);
|
||||
if (groupId) normalized.groupId = groupId;
|
||||
|
||||
const groupNameCandidates = [
|
||||
item.group_name,
|
||||
item.groupName,
|
||||
item.name,
|
||||
item.title,
|
||||
group?.name,
|
||||
group?.title,
|
||||
];
|
||||
for (const candidate of groupNameCandidates) {
|
||||
if (typeof candidate !== 'string') continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) continue;
|
||||
normalized.groupName = trimmed;
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeof item.status === 'string' && item.status.trim()) {
|
||||
normalized.status = item.status.trim();
|
||||
}
|
||||
|
||||
const expiresAt = this.parseDateTime(
|
||||
item.expires_at
|
||||
?? item.expiresAt
|
||||
?? item.expired_at
|
||||
?? item.expiredAt
|
||||
?? item.end_at
|
||||
?? item.endAt
|
||||
?? item.end_time
|
||||
?? item.endTime
|
||||
?? item.current_period_end
|
||||
?? item.currentPeriodEnd,
|
||||
);
|
||||
if (expiresAt) normalized.expiresAt = expiresAt;
|
||||
|
||||
const dailyUsedUsd = this.parseNonNegativeNumber(item.daily_used_usd ?? item.dailyUsedUsd);
|
||||
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
|
||||
|
||||
const dailyLimitUsd = this.parseNonNegativeNumber(item.daily_limit_usd ?? item.dailyLimitUsd);
|
||||
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
|
||||
|
||||
const weeklyUsedUsd = this.parseNonNegativeNumber(item.weekly_used_usd ?? item.weeklyUsedUsd);
|
||||
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
|
||||
|
||||
const weeklyLimitUsd = this.parseNonNegativeNumber(item.weekly_limit_usd ?? item.weeklyLimitUsd);
|
||||
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
|
||||
|
||||
const monthlyUsedUsd = this.parseNonNegativeNumber(
|
||||
item.monthly_used_usd
|
||||
?? item.monthlyUsedUsd
|
||||
?? item.used_usd
|
||||
?? item.usedUsd
|
||||
?? item.total_used_usd
|
||||
?? item.totalUsedUsd,
|
||||
);
|
||||
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
|
||||
|
||||
const monthlyLimitUsd = this.parseNonNegativeNumber(
|
||||
item.monthly_limit_usd
|
||||
?? item.monthlyLimitUsd
|
||||
?? item.limit_usd
|
||||
?? item.limitUsd
|
||||
?? item.total_limit_usd
|
||||
?? item.totalLimitUsd,
|
||||
);
|
||||
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
private parseSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
|
||||
const rawItems = (() => {
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (raw && typeof raw === 'object') {
|
||||
const body = raw as Record<string, unknown>;
|
||||
if (Array.isArray(body.subscriptions)) return body.subscriptions;
|
||||
if (Array.isArray(body.items)) return body.items;
|
||||
if (Array.isArray(body.list)) return body.list;
|
||||
if (Array.isArray(body.data)) return body.data;
|
||||
}
|
||||
return [];
|
||||
})();
|
||||
|
||||
return rawItems
|
||||
.map((item) => this.parseSubscriptionItem(item))
|
||||
.filter((item): item is SubscriptionPlanSummary => !!item);
|
||||
}
|
||||
|
||||
private buildSubscriptionSummary(payload: unknown): SubscriptionSummary {
|
||||
const body = payload && typeof payload === 'object' && !Array.isArray(payload)
|
||||
? payload as Record<string, unknown>
|
||||
: {};
|
||||
const subscriptions = this.parseSubscriptionItems(payload);
|
||||
const activeCount = this.parsePositiveInteger(body.active_count ?? body.activeCount);
|
||||
const totalUsedUsd = this.parseNonNegativeNumber(body.total_used_usd ?? body.totalUsedUsd);
|
||||
const inferredUsedUsd = subscriptions.reduce((sum, item) => sum + (item.monthlyUsedUsd || 0), 0);
|
||||
|
||||
return {
|
||||
activeCount: activeCount ?? subscriptions.length,
|
||||
totalUsedUsd: totalUsedUsd ?? this.roundCurrency(inferredUsedUsd),
|
||||
subscriptions,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchSubscriptionSummary(baseUrl: string, accessToken: string): Promise<SubscriptionSummary | undefined> {
|
||||
const headers = this.buildAuthHeader(accessToken);
|
||||
const summaryEndpoint = '/api/v1/subscriptions/summary';
|
||||
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}${summaryEndpoint}`, { headers });
|
||||
const data = this.parseSub2ApiEnvelope<any>(res, summaryEndpoint);
|
||||
return this.buildSubscriptionSummary(data);
|
||||
} catch {}
|
||||
|
||||
const fallbackEndpoints = ['/api/v1/subscriptions/active'];
|
||||
for (const endpoint of fallbackEndpoints) {
|
||||
try {
|
||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, { headers });
|
||||
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
return this.buildSubscriptionSummary(data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private stripBearerPrefix(value?: string | null): string {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
@@ -461,13 +651,18 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||
|
||||
// --- Balance ---
|
||||
async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
|
||||
const user = await this.fetchAuthMe(baseUrl, accessToken);
|
||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||
const [user, subscriptionSummary] = await Promise.all([
|
||||
this.fetchAuthMe(normalizedBase, accessToken),
|
||||
this.fetchSubscriptionSummary(normalizedBase, accessToken),
|
||||
]);
|
||||
const quotaValue = this.usdToQuota(user.balance);
|
||||
// Sub2API only provides current balance, no usage breakdown
|
||||
return {
|
||||
balance: quotaValue / 500000,
|
||||
used: 0,
|
||||
quota: quotaValue / 500000,
|
||||
subscriptionSummary,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -486,6 +681,41 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
||||
return this.fetchModelsByToken(normalizedBase, discoveredApiToken);
|
||||
}
|
||||
|
||||
override async getSiteAnnouncements(baseUrl: string, accessToken: string): Promise<SiteAnnouncement[]> {
|
||||
try {
|
||||
const endpoint = '/api/v1/announcements?page=1&page_size=100';
|
||||
const res = await this.fetchJson<any>(`${normalizeBaseUrl(baseUrl)}${endpoint}`, {
|
||||
headers: this.buildAuthHeader(accessToken),
|
||||
});
|
||||
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
|
||||
const rawItems = Array.isArray(data)
|
||||
? data
|
||||
: (Array.isArray(data?.items) ? data.items : []);
|
||||
const rows: SiteAnnouncement[] = [];
|
||||
for (const item of rawItems) {
|
||||
const id = Number.parseInt(String(item?.id), 10);
|
||||
if (!Number.isFinite(id) || id <= 0) continue;
|
||||
const title = typeof item?.title === 'string' ? item.title.trim() : '';
|
||||
const content = typeof item?.content === 'string' ? item.content.trim() : '';
|
||||
if (!title && !content) continue;
|
||||
rows.push({
|
||||
sourceKey: `announcement:${id}`,
|
||||
title: title || `Announcement ${id}`,
|
||||
content: content || title,
|
||||
level: 'info',
|
||||
startsAt: typeof item?.starts_at === 'string' ? item.starts_at : undefined,
|
||||
endsAt: typeof item?.ends_at === 'string' ? item.ends_at : undefined,
|
||||
upstreamCreatedAt: typeof item?.created_at === 'string' ? item.created_at : undefined,
|
||||
upstreamUpdatedAt: typeof item?.updated_at === 'string' ? item.updated_at : undefined,
|
||||
rawPayload: item,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
override async getApiTokens(baseUrl: string, accessToken: string): Promise<ApiTokenInfo[]> {
|
||||
try {
|
||||
const keys = await this.listApiKeys(normalizeBaseUrl(baseUrl), accessToken);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type StoreModule = typeof import('./proxyFileStore.js');
|
||||
type RetentionModule = typeof import('./proxyFileRetentionService.js');
|
||||
type ConfigModule = typeof import('../config.js');
|
||||
|
||||
describe('proxyFileRetentionService', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let store: StoreModule;
|
||||
let retention: RetentionModule;
|
||||
let config: ConfigModule['config'];
|
||||
let dataDir = '';
|
||||
let originalRetentionDays = 0;
|
||||
let originalPruneIntervalMinutes = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-proxy-file-retention-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const storeModule = await import('./proxyFileStore.js');
|
||||
const retentionModule = await import('./proxyFileRetentionService.js');
|
||||
const configModule = await import('../config.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
store = storeModule;
|
||||
retention = retentionModule;
|
||||
config = configModule.config;
|
||||
originalRetentionDays = config.proxyFileRetentionDays;
|
||||
originalPruneIntervalMinutes = config.proxyFileRetentionPruneIntervalMinutes;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
config.proxyFileRetentionDays = originalRetentionDays;
|
||||
config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes;
|
||||
await db.delete(schema.proxyFiles).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
config.proxyFileRetentionDays = originalRetentionDays;
|
||||
config.proxyFileRetentionPruneIntervalMinutes = originalPruneIntervalMinutes;
|
||||
const dbModule = await import('../db/index.js');
|
||||
await dbModule.closeDbConnections();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('returns disabled when proxy file retention is turned off', async () => {
|
||||
config.proxyFileRetentionDays = 0;
|
||||
|
||||
const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z'));
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
retentionDays: 0,
|
||||
cutoffUtc: null,
|
||||
deleted: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('purges files older than the configured retention window', async () => {
|
||||
config.proxyFileRetentionDays = 7;
|
||||
|
||||
await db.insert(schema.proxyFiles).values([
|
||||
{
|
||||
publicId: 'file-metapi-old',
|
||||
ownerType: 'global_proxy_token',
|
||||
ownerId: 'global',
|
||||
filename: 'old.txt',
|
||||
mimeType: 'text/plain',
|
||||
purpose: 'assistants',
|
||||
byteSize: 3,
|
||||
sha256: 'old-hash',
|
||||
contentBase64: Buffer.from('old').toString('base64'),
|
||||
createdAt: '2026-03-10 00:00:00',
|
||||
updatedAt: '2026-03-10 00:00:00',
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
publicId: 'file-metapi-new',
|
||||
ownerType: 'global_proxy_token',
|
||||
ownerId: 'global',
|
||||
filename: 'new.txt',
|
||||
mimeType: 'text/plain',
|
||||
purpose: 'assistants',
|
||||
byteSize: 3,
|
||||
sha256: 'new-hash',
|
||||
contentBase64: Buffer.from('new').toString('base64'),
|
||||
createdAt: '2026-03-18 00:00:00',
|
||||
updatedAt: '2026-03-18 00:00:00',
|
||||
deletedAt: null,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const result = await retention.cleanupExpiredProxyFiles(Date.parse('2026-03-20T00:00:00Z'));
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
retentionDays: 7,
|
||||
cutoffUtc: '2026-03-13 00:00:00',
|
||||
deleted: 1,
|
||||
});
|
||||
|
||||
const remaining = await store.listProxyFilesByOwner({ ownerType: 'global_proxy_token', ownerId: 'global' });
|
||||
expect(remaining.map((item) => item.publicId)).toEqual(['file-metapi-new']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { config } from '../config.js';
|
||||
import { getLogCleanupCutoffUtc } from './logCleanupService.js';
|
||||
import { purgeExpiredProxyFiles } from './proxyFileStore.js';
|
||||
|
||||
let retentionTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function getProxyFileRetentionCutoffUtc(nowMs = Date.now()): string | null {
|
||||
const days = Math.max(0, Math.trunc(config.proxyFileRetentionDays));
|
||||
if (days <= 0) return null;
|
||||
return getLogCleanupCutoffUtc(days, nowMs);
|
||||
}
|
||||
|
||||
export async function cleanupExpiredProxyFiles(nowMs = Date.now()): Promise<{
|
||||
enabled: boolean;
|
||||
retentionDays: number;
|
||||
cutoffUtc: string | null;
|
||||
deleted: number;
|
||||
}> {
|
||||
const retentionDays = Math.max(0, Math.trunc(config.proxyFileRetentionDays));
|
||||
const cutoffUtc = getProxyFileRetentionCutoffUtc(nowMs);
|
||||
if (!cutoffUtc) {
|
||||
return {
|
||||
enabled: false,
|
||||
retentionDays,
|
||||
cutoffUtc: null,
|
||||
deleted: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const deleted = await purgeExpiredProxyFiles(cutoffUtc);
|
||||
return {
|
||||
enabled: true,
|
||||
retentionDays,
|
||||
cutoffUtc,
|
||||
deleted,
|
||||
};
|
||||
}
|
||||
|
||||
export function startProxyFileRetentionService(): void {
|
||||
if (retentionTimer) return;
|
||||
|
||||
const intervalMinutes = Math.max(1, Math.trunc(config.proxyFileRetentionPruneIntervalMinutes));
|
||||
const intervalMs = intervalMinutes * 60 * 1000;
|
||||
const runCleanup = async () => {
|
||||
try {
|
||||
const result = await cleanupExpiredProxyFiles();
|
||||
if (!result.enabled || result.deleted <= 0) return;
|
||||
console.info(`[proxy-file-retention] deleted ${result.deleted} files before ${result.cutoffUtc}`);
|
||||
} catch (error) {
|
||||
console.warn('[proxy-file-retention] cleanup failed', error);
|
||||
}
|
||||
};
|
||||
|
||||
void runCleanup();
|
||||
retentionTimer = setInterval(() => { void runCleanup(); }, intervalMs);
|
||||
retentionTimer.unref?.();
|
||||
}
|
||||
|
||||
export function stopProxyFileRetentionService(): void {
|
||||
if (!retentionTimer) return;
|
||||
clearInterval(retentionTimer);
|
||||
retentionTimer = null;
|
||||
}
|
||||
@@ -112,4 +112,45 @@ describe('proxyFileStore', () => {
|
||||
});
|
||||
expect(loaded).toBeNull();
|
||||
});
|
||||
|
||||
it('purges expired proxy files before a cutoff to control database growth', async () => {
|
||||
await db.insert(schema.proxyFiles).values([
|
||||
{
|
||||
publicId: 'file-metapi-old',
|
||||
ownerType: 'global_proxy_token',
|
||||
ownerId: 'global',
|
||||
filename: 'old.txt',
|
||||
mimeType: 'text/plain',
|
||||
purpose: 'assistants',
|
||||
byteSize: 3,
|
||||
sha256: 'old-hash',
|
||||
contentBase64: Buffer.from('old').toString('base64'),
|
||||
createdAt: '2026-03-01 00:00:00',
|
||||
updatedAt: '2026-03-01 00:00:00',
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
publicId: 'file-metapi-new',
|
||||
ownerType: 'global_proxy_token',
|
||||
ownerId: 'global',
|
||||
filename: 'new.txt',
|
||||
mimeType: 'text/plain',
|
||||
purpose: 'assistants',
|
||||
byteSize: 3,
|
||||
sha256: 'new-hash',
|
||||
contentBase64: Buffer.from('new').toString('base64'),
|
||||
createdAt: '2026-03-19 00:00:00',
|
||||
updatedAt: '2026-03-19 00:00:00',
|
||||
deletedAt: null,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const purgeExpiredProxyFiles = (store as Record<string, any>).purgeExpiredProxyFiles;
|
||||
const deleted = await purgeExpiredProxyFiles?.('2026-03-10 00:00:00');
|
||||
|
||||
expect(deleted).toBe(1);
|
||||
|
||||
const remainingRows = await db.select().from(schema.proxyFiles).all();
|
||||
expect(remainingRows.map((row) => row.publicId)).toEqual(['file-metapi-new']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, isNull, or } from 'drizzle-orm';
|
||||
import { and, desc, eq, isNull, lt, or } from 'drizzle-orm';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import type { ProxyResourceOwner } from '../middleware/auth.js';
|
||||
@@ -204,3 +204,17 @@ export async function softDeleteProxyFile(publicId: string): Promise<boolean> {
|
||||
.run();
|
||||
return Number(result?.changes || 0) > 0;
|
||||
}
|
||||
|
||||
export async function purgeExpiredProxyFiles(cutoffUtc: string): Promise<number> {
|
||||
const normalizedCutoff = cutoffUtc.trim();
|
||||
if (!normalizedCutoff) return 0;
|
||||
|
||||
const result = await db.delete(schema.proxyFiles)
|
||||
.where(or(
|
||||
lt(schema.proxyFiles.createdAt, normalizedCutoff),
|
||||
and(isNull(schema.proxyFiles.createdAt), lt(schema.proxyFiles.updatedAt, normalizedCutoff)),
|
||||
))
|
||||
.run();
|
||||
|
||||
return Number(result?.changes || 0);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProxyResourceOwner } from '../middleware/auth.js';
|
||||
import { getProxyFileByPublicIdForOwner, LOCAL_PROXY_FILE_ID_PREFIX } from './proxyFileStore.js';
|
||||
import { ensureBase64DataUrl } from '../transformers/shared/inputFile.js';
|
||||
import { summarizeConversationFileInputsInOpenAiBody } from '../proxy-core/capabilities/conversationFileCapabilities.js';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
@@ -306,15 +307,5 @@ export async function resolveResponsesBodyInputFiles(
|
||||
}
|
||||
|
||||
export function hasNonImageFileInputInOpenAiBody(body: Record<string, unknown>): boolean {
|
||||
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||
return messages.some((message) => {
|
||||
if (!isRecord(message)) return false;
|
||||
const content = message.content;
|
||||
if (isRecord(content)) {
|
||||
const type = asTrimmedString(content.type).toLowerCase();
|
||||
return type === 'file' || type === 'input_file';
|
||||
}
|
||||
if (!Array.isArray(content)) return false;
|
||||
return content.some((item) => isRecord(item) && ['file', 'input_file'].includes(asTrimmedString(item.type).toLowerCase()));
|
||||
});
|
||||
return summarizeConversationFileInputsInOpenAiBody(body).hasDocument;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
hasProxyLogBillingDetailsColumnMock,
|
||||
hasProxyLogClientColumnsMock,
|
||||
hasProxyLogDownstreamApiKeyIdColumnMock,
|
||||
dbInsertMock,
|
||||
dbInsertValuesMock,
|
||||
@@ -9,6 +10,7 @@ const {
|
||||
proxyLogsSchema,
|
||||
} = vi.hoisted(() => ({
|
||||
hasProxyLogBillingDetailsColumnMock: vi.fn(),
|
||||
hasProxyLogClientColumnsMock: vi.fn(),
|
||||
hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(),
|
||||
dbInsertMock: vi.fn(),
|
||||
dbInsertValuesMock: vi.fn(),
|
||||
@@ -28,6 +30,10 @@ const {
|
||||
totalTokens: 'total_tokens',
|
||||
estimatedCost: 'estimated_cost',
|
||||
billingDetails: 'billing_details',
|
||||
clientFamily: 'client_family',
|
||||
clientAppId: 'client_app_id',
|
||||
clientAppName: 'client_app_name',
|
||||
clientConfidence: 'client_confidence',
|
||||
errorMessage: 'error_message',
|
||||
retryCount: 'retry_count',
|
||||
createdAt: 'created_at',
|
||||
@@ -42,6 +48,7 @@ vi.mock('../db/index.js', () => ({
|
||||
proxyLogs: proxyLogsSchema,
|
||||
},
|
||||
hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args),
|
||||
hasProxyLogClientColumns: (...args: unknown[]) => hasProxyLogClientColumnsMock(...args),
|
||||
hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args),
|
||||
}));
|
||||
|
||||
@@ -50,11 +57,13 @@ import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js';
|
||||
describe('proxyLogStore', () => {
|
||||
beforeEach(() => {
|
||||
hasProxyLogBillingDetailsColumnMock.mockReset();
|
||||
hasProxyLogClientColumnsMock.mockReset();
|
||||
hasProxyLogDownstreamApiKeyIdColumnMock.mockReset();
|
||||
dbInsertMock.mockReset();
|
||||
dbInsertValuesMock.mockReset();
|
||||
dbInsertRunMock.mockReset();
|
||||
hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false);
|
||||
hasProxyLogClientColumnsMock.mockResolvedValue(false);
|
||||
hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false);
|
||||
|
||||
dbInsertMock.mockReturnValue({
|
||||
@@ -132,4 +141,56 @@ describe('proxyLogStore', () => {
|
||||
expect(dbInsertValuesMock.mock.calls[2][0].billingDetails).toBeUndefined();
|
||||
expect(dbInsertValuesMock.mock.calls[2][0].downstreamApiKeyId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes structured client fields when the schema supports them', async () => {
|
||||
hasProxyLogClientColumnsMock.mockResolvedValue(true);
|
||||
|
||||
await insertProxyLog({
|
||||
modelRequested: 'gpt-5',
|
||||
clientFamily: 'codex',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
});
|
||||
|
||||
expect(dbInsertValuesMock).toHaveBeenCalledTimes(1);
|
||||
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
|
||||
modelRequested: 'gpt-5',
|
||||
clientFamily: 'codex',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
});
|
||||
});
|
||||
|
||||
it('retries proxy log inserts without structured client fields when those columns are missing', async () => {
|
||||
hasProxyLogClientColumnsMock.mockResolvedValue(true);
|
||||
dbInsertRunMock
|
||||
.mockRejectedValueOnce(new Error('column proxy_logs.client_app_id does not exist'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await insertProxyLog({
|
||||
modelRequested: 'gpt-5',
|
||||
clientFamily: 'codex',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
});
|
||||
|
||||
expect(dbInsertValuesMock).toHaveBeenCalledTimes(2);
|
||||
expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({
|
||||
modelRequested: 'gpt-5',
|
||||
clientFamily: 'codex',
|
||||
clientAppId: 'cherry_studio',
|
||||
clientAppName: 'Cherry Studio',
|
||||
clientConfidence: 'exact',
|
||||
});
|
||||
expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({
|
||||
modelRequested: 'gpt-5',
|
||||
});
|
||||
expect(dbInsertValuesMock.mock.calls[1][0].clientFamily).toBeUndefined();
|
||||
expect(dbInsertValuesMock.mock.calls[1][0].clientAppId).toBeUndefined();
|
||||
expect(dbInsertValuesMock.mock.calls[1][0].clientAppName).toBeUndefined();
|
||||
expect(dbInsertValuesMock.mock.calls[1][0].clientConfidence).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { db, schema, hasProxyLogBillingDetailsColumn, hasProxyLogDownstreamApiKeyIdColumn } from '../db/index.js';
|
||||
import {
|
||||
db,
|
||||
schema,
|
||||
hasProxyLogBillingDetailsColumn,
|
||||
hasProxyLogClientColumns,
|
||||
hasProxyLogDownstreamApiKeyIdColumn,
|
||||
} from '../db/index.js';
|
||||
|
||||
export type ProxyLogInsertInput = {
|
||||
routeId?: number | null;
|
||||
@@ -15,12 +21,16 @@ export type ProxyLogInsertInput = {
|
||||
totalTokens?: number | null;
|
||||
estimatedCost?: number | null;
|
||||
billingDetails?: unknown;
|
||||
clientFamily?: string | null;
|
||||
clientAppId?: string | null;
|
||||
clientAppName?: string | null;
|
||||
clientConfidence?: string | null;
|
||||
errorMessage?: string | null;
|
||||
retryCount?: number | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
function buildProxyLogBaseSelectFields() {
|
||||
function buildProxyLogCoreSelectFields() {
|
||||
return {
|
||||
id: schema.proxyLogs.id,
|
||||
routeId: schema.proxyLogs.routeId,
|
||||
@@ -42,47 +52,93 @@ function buildProxyLogBaseSelectFields() {
|
||||
};
|
||||
}
|
||||
|
||||
export function getProxyLogBaseSelectFields() {
|
||||
return buildProxyLogBaseSelectFields();
|
||||
function buildProxyLogClientSelectFields() {
|
||||
return {
|
||||
clientFamily: schema.proxyLogs.clientFamily,
|
||||
clientAppId: schema.proxyLogs.clientAppId,
|
||||
clientAppName: schema.proxyLogs.clientAppName,
|
||||
clientConfidence: schema.proxyLogs.clientConfidence,
|
||||
};
|
||||
}
|
||||
|
||||
export type ProxyLogSelectFields = ReturnType<typeof buildProxyLogBaseSelectFields> & {
|
||||
billingDetails?: typeof schema.proxyLogs.billingDetails;
|
||||
};
|
||||
function buildProxyLogSelectFields(options?: {
|
||||
includeBillingDetails?: boolean;
|
||||
includeClientFields?: boolean;
|
||||
}) {
|
||||
return {
|
||||
...buildProxyLogCoreSelectFields(),
|
||||
...(options?.includeClientFields ? buildProxyLogClientSelectFields() : {}),
|
||||
...(options?.includeBillingDetails ? { billingDetails: schema.proxyLogs.billingDetails } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function getProxyLogBaseSelectFields() {
|
||||
return buildProxyLogCoreSelectFields();
|
||||
}
|
||||
|
||||
export type ProxyLogSelectFields = ReturnType<typeof buildProxyLogSelectFields>;
|
||||
|
||||
export type ResolvedProxyLogSelectFields = {
|
||||
includeBillingDetails: boolean;
|
||||
includeClientFields: boolean;
|
||||
fields: ProxyLogSelectFields;
|
||||
};
|
||||
|
||||
export async function resolveProxyLogSelectFields(options?: { includeBillingDetails?: boolean }) {
|
||||
export async function resolveProxyLogSelectFields(options?: {
|
||||
includeBillingDetails?: boolean;
|
||||
includeClientFields?: boolean;
|
||||
}) {
|
||||
const includeBillingDetails = options?.includeBillingDetails === true
|
||||
&& await hasProxyLogBillingDetailsColumn();
|
||||
const includeClientFields = options?.includeClientFields !== false
|
||||
&& await hasProxyLogClientColumns();
|
||||
|
||||
return {
|
||||
includeBillingDetails,
|
||||
fields: includeBillingDetails
|
||||
? { ...buildProxyLogBaseSelectFields(), billingDetails: schema.proxyLogs.billingDetails }
|
||||
: buildProxyLogBaseSelectFields(),
|
||||
includeClientFields,
|
||||
fields: buildProxyLogSelectFields({
|
||||
includeBillingDetails,
|
||||
includeClientFields,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function withProxyLogSelectFields<T>(
|
||||
runner: (selection: ResolvedProxyLogSelectFields) => Promise<T>,
|
||||
options?: { includeBillingDetails?: boolean },
|
||||
options?: { includeBillingDetails?: boolean; includeClientFields?: boolean },
|
||||
): Promise<T> {
|
||||
const selection = await resolveProxyLogSelectFields(options);
|
||||
let selection = await resolveProxyLogSelectFields(options);
|
||||
|
||||
try {
|
||||
return await runner(selection);
|
||||
} catch (error) {
|
||||
if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
||||
return await runner({
|
||||
includeBillingDetails: false,
|
||||
fields: buildProxyLogBaseSelectFields(),
|
||||
});
|
||||
while (true) {
|
||||
try {
|
||||
return await runner(selection);
|
||||
} catch (error) {
|
||||
if (selection.includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
||||
selection = {
|
||||
includeBillingDetails: false,
|
||||
includeClientFields: selection.includeClientFields,
|
||||
fields: buildProxyLogSelectFields({
|
||||
includeBillingDetails: false,
|
||||
includeClientFields: selection.includeClientFields,
|
||||
}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (selection.includeClientFields && isMissingProxyLogClientColumnsError(error)) {
|
||||
selection = {
|
||||
includeBillingDetails: selection.includeBillingDetails,
|
||||
includeClientFields: false,
|
||||
fields: buildProxyLogSelectFields({
|
||||
includeBillingDetails: selection.includeBillingDetails,
|
||||
includeClientFields: false,
|
||||
}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,11 +152,15 @@ export function parseProxyLogBillingDetails(value: unknown): Record<string, unkn
|
||||
}
|
||||
}
|
||||
|
||||
export function isMissingBillingDetailsColumnError(error: unknown): boolean {
|
||||
function normalizeProxyLogStoreErrorMessage(error: unknown): string {
|
||||
const message = typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: unknown }).message || '')
|
||||
: String(error || '');
|
||||
const lowered = message.toLowerCase();
|
||||
return message.toLowerCase();
|
||||
}
|
||||
|
||||
export function isMissingBillingDetailsColumnError(error: unknown): boolean {
|
||||
const lowered = normalizeProxyLogStoreErrorMessage(error);
|
||||
return lowered.includes('billing_details')
|
||||
&& (
|
||||
lowered.includes('does not exist')
|
||||
@@ -111,10 +171,7 @@ export function isMissingBillingDetailsColumnError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean {
|
||||
const message = typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: unknown }).message || '')
|
||||
: String(error || '');
|
||||
const lowered = message.toLowerCase();
|
||||
const lowered = normalizeProxyLogStoreErrorMessage(error);
|
||||
return lowered.includes('downstream_api_key_id')
|
||||
&& (
|
||||
lowered.includes('does not exist')
|
||||
@@ -124,6 +181,24 @@ export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean
|
||||
);
|
||||
}
|
||||
|
||||
export function isMissingProxyLogClientColumnsError(error: unknown): boolean {
|
||||
const lowered = normalizeProxyLogStoreErrorMessage(error);
|
||||
const hasClientColumnReference = [
|
||||
'client_family',
|
||||
'client_app_id',
|
||||
'client_app_name',
|
||||
'client_confidence',
|
||||
].some((columnName) => lowered.includes(columnName));
|
||||
|
||||
return hasClientColumnReference
|
||||
&& (
|
||||
lowered.includes('does not exist')
|
||||
|| lowered.includes('unknown column')
|
||||
|| lowered.includes('no such column')
|
||||
|| lowered.includes('has no column named')
|
||||
);
|
||||
}
|
||||
|
||||
export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void> {
|
||||
const baseValues = {
|
||||
routeId: input.routeId ?? null,
|
||||
@@ -149,43 +224,54 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise<void>
|
||||
&& await hasProxyLogBillingDetailsColumn();
|
||||
const includeDownstreamApiKeyId = input.downstreamApiKeyId != null
|
||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
||||
const requestedClientFields = [
|
||||
input.clientFamily,
|
||||
input.clientAppId,
|
||||
input.clientAppName,
|
||||
input.clientConfidence,
|
||||
].some((value) => value != null && String(value).trim().length > 0);
|
||||
const includeClientFields = requestedClientFields
|
||||
&& await hasProxyLogClientColumns();
|
||||
|
||||
try {
|
||||
await db.insert(schema.proxyLogs).values(
|
||||
includeBillingDetails || includeDownstreamApiKeyId
|
||||
let allowBillingDetails = includeBillingDetails;
|
||||
let allowDownstreamApiKeyId = includeDownstreamApiKeyId;
|
||||
let allowClientFields = includeClientFields;
|
||||
|
||||
while (true) {
|
||||
const values = {
|
||||
...baseValues,
|
||||
...(allowBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
|
||||
...(allowDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
|
||||
...(allowClientFields
|
||||
? {
|
||||
...baseValues,
|
||||
...(includeBillingDetails ? { billingDetails: serializedBillingDetails } : {}),
|
||||
...(includeDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}),
|
||||
clientFamily: input.clientFamily ?? null,
|
||||
clientAppId: input.clientAppId ?? null,
|
||||
clientAppName: input.clientAppName ?? null,
|
||||
clientConfidence: input.clientConfidence ?? null,
|
||||
}
|
||||
: baseValues,
|
||||
).run();
|
||||
} catch (error) {
|
||||
if (includeBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
||||
try {
|
||||
await db.insert(schema.proxyLogs).values(
|
||||
includeDownstreamApiKeyId
|
||||
? { ...baseValues, downstreamApiKeyId: input.downstreamApiKeyId }
|
||||
: baseValues,
|
||||
).run();
|
||||
} catch (fallbackError) {
|
||||
if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(fallbackError)) {
|
||||
await db.insert(schema.proxyLogs).values(baseValues).run();
|
||||
return;
|
||||
}
|
||||
throw fallbackError;
|
||||
}
|
||||
return;
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) {
|
||||
await db.insert(schema.proxyLogs).values(
|
||||
includeBillingDetails
|
||||
? { ...baseValues, billingDetails: serializedBillingDetails }
|
||||
: baseValues,
|
||||
).run();
|
||||
try {
|
||||
await db.insert(schema.proxyLogs).values(values).run();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (allowBillingDetails && isMissingBillingDetailsColumnError(error)) {
|
||||
allowBillingDetails = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) {
|
||||
allowDownstreamApiKeyId = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (allowClientFields && isMissingProxyLogClientColumnsError(error)) {
|
||||
allowClientFields = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,19 @@ describe('proxyRetryPolicy', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('retries on generic client request errors for cross-channel fallback', () => {
|
||||
it('does not retry obvious request-shape errors that will fail on every channel', () => {
|
||||
expect(
|
||||
shouldRetryProxyRequest(400, '{"error":{"message":"invalid request body"}}'),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps retrying channel-local compatibility and auth failures', () => {
|
||||
expect(
|
||||
shouldRetryProxyRequest(401, '{"error":{"message":"invalid access token"}}'),
|
||||
).toBe(true);
|
||||
@@ -31,10 +40,7 @@ describe('proxyRetryPolicy', () => {
|
||||
shouldRetryProxyRequest(403, '{"error":{"message":"forbidden"}}'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
|
||||
shouldRetryProxyRequest(400, 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.'),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,14 +14,61 @@ const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [
|
||||
/you\s+do\s+not\s+have\s+access\s+to\s+the\s+model/i,
|
||||
];
|
||||
|
||||
const RETRYABLE_CHANNEL_LOCAL_PATTERNS: RegExp[] = [
|
||||
/unsupported\s+legacy\s+protocol/i,
|
||||
/please\s+use\s+\/v1\/responses/i,
|
||||
/please\s+use\s+\/v1\/messages/i,
|
||||
/please\s+use\s+\/v1\/chat\/completions/i,
|
||||
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i,
|
||||
/unsupported\s+endpoint/i,
|
||||
/unsupported\s+path/i,
|
||||
/unknown\s+endpoint/i,
|
||||
/unrecognized\s+request\s+url/i,
|
||||
/no\s+route\s+matched/i,
|
||||
/invalid\s+api\s+key/i,
|
||||
/invalid\s+access\s+token/i,
|
||||
/forbidden/i,
|
||||
/rate\s+limit/i,
|
||||
/quota/i,
|
||||
/bad\s+gateway/i,
|
||||
/gateway\s+time-?out/i,
|
||||
/service\s+unavailable/i,
|
||||
/cpu\s+overloaded/i,
|
||||
/timeout/i,
|
||||
];
|
||||
|
||||
const NON_RETRYABLE_REQUEST_PATTERNS: RegExp[] = [
|
||||
/invalid\s+request\s+body/i,
|
||||
/validation/i,
|
||||
/missing\s+required/i,
|
||||
/required\s+parameter/i,
|
||||
/unknown\s+parameter/i,
|
||||
/unrecognized\s+(field|key|parameter)/i,
|
||||
/malformed/i,
|
||||
/invalid\s+json/i,
|
||||
/cannot\s+parse/i,
|
||||
/unsupported\s+media\s+type/i,
|
||||
];
|
||||
|
||||
function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean {
|
||||
const text = (rawMessage || '').trim();
|
||||
if (!text) return false;
|
||||
return MODEL_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
|
||||
if (status >= 400) return true;
|
||||
if (!upstreamErrorText) return false;
|
||||
return isModelUnsupportedErrorMessage(upstreamErrorText);
|
||||
function matchesAnyPattern(patterns: RegExp[], rawMessage?: string | null): boolean {
|
||||
const text = (rawMessage || '').trim();
|
||||
if (!text) return false;
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
|
||||
if (status >= 500) return true;
|
||||
if (status === 408 || status === 409 || status === 425 || status === 429) return true;
|
||||
if (status === 401 || status === 403) return true;
|
||||
if (isModelUnsupportedErrorMessage(upstreamErrorText)) return true;
|
||||
if (matchesAnyPattern(NON_RETRYABLE_REQUEST_PATTERNS, upstreamErrorText)) return false;
|
||||
if (matchesAnyPattern(RETRYABLE_CHANNEL_LOCAL_PATTERNS, upstreamErrorText)) return true;
|
||||
if (status === 400 || status === 404 || status === 422) return false;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
|
||||
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
|
||||
|
||||
export const DEFAULT_ROUTE_ROUTING_STRATEGY: RouteRoutingStrategy = 'weighted';
|
||||
|
||||
export function normalizeRouteRoutingStrategy(value: unknown): RouteRoutingStrategy {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'round_robin') return 'round_robin';
|
||||
if (normalized === 'stable_first') return 'stable_first';
|
||||
return DEFAULT_ROUTE_ROUTING_STRATEGY;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const syncSiteAnnouncementsMock = vi.fn();
|
||||
|
||||
vi.mock('./siteAnnouncementService.js', () => ({
|
||||
syncSiteAnnouncements: (...args: unknown[]) => syncSiteAnnouncementsMock(...args),
|
||||
}));
|
||||
|
||||
describe('siteAnnouncementPollingService', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
syncSiteAnnouncementsMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const module = await import('./siteAnnouncementPollingService.js');
|
||||
module.stopSiteAnnouncementPolling();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('runs one immediate sync and then continues on the configured interval', async () => {
|
||||
const module = await import('./siteAnnouncementPollingService.js');
|
||||
syncSiteAnnouncementsMock.mockResolvedValue(undefined);
|
||||
|
||||
module.startSiteAnnouncementPolling(60_000);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(syncSiteAnnouncementsMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(syncSiteAnnouncementsMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { syncSiteAnnouncements } from './siteAnnouncementService.js';
|
||||
|
||||
const DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS = 15 * 60 * 1000;
|
||||
|
||||
let pollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let syncRunning = false;
|
||||
|
||||
async function runSyncOnce() {
|
||||
if (syncRunning) return;
|
||||
syncRunning = true;
|
||||
try {
|
||||
await syncSiteAnnouncements();
|
||||
} catch (error) {
|
||||
console.error('[SiteAnnouncementPolling] Sync failed:', error);
|
||||
} finally {
|
||||
syncRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startSiteAnnouncementPolling(intervalMs = DEFAULT_SITE_ANNOUNCEMENT_INTERVAL_MS) {
|
||||
stopSiteAnnouncementPolling();
|
||||
pollingTimer = setInterval(() => {
|
||||
void runSyncOnce();
|
||||
}, Math.max(10_000, intervalMs));
|
||||
pollingTimer.unref?.();
|
||||
void runSyncOnce();
|
||||
return { intervalMs: Math.max(10_000, intervalMs) };
|
||||
}
|
||||
|
||||
export function stopSiteAnnouncementPolling() {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
pollingTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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';
|
||||
|
||||
const getAdapterMock = vi.fn();
|
||||
const sendNotificationMock = vi.fn();
|
||||
|
||||
vi.mock('./platforms/index.js', () => ({
|
||||
getAdapter: (...args: unknown[]) => getAdapterMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./notifyService.js', () => ({
|
||||
sendNotification: (...args: unknown[]) => sendNotificationMock(...args),
|
||||
}));
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type ServiceModule = typeof import('./siteAnnouncementService.js');
|
||||
|
||||
describe('siteAnnouncementService', () => {
|
||||
let dataDir = '';
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let closeDbConnections: DbModule['closeDbConnections'];
|
||||
let syncSiteAnnouncements: ServiceModule['syncSiteAnnouncements'];
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-announcements-service-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const serviceModule = await import('./siteAnnouncementService.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
closeDbConnections = dbModule.closeDbConnections;
|
||||
syncSiteAnnouncements = serviceModule.syncSiteAnnouncements;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
getAdapterMock.mockReset();
|
||||
sendNotificationMock.mockReset();
|
||||
|
||||
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 () => {
|
||||
vi.useRealTimers();
|
||||
if (typeof closeDbConnections === 'function') {
|
||||
await closeDbConnections();
|
||||
}
|
||||
if (dataDir) {
|
||||
try {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('stores first-seen announcements, creates one event, and sends one notification', async () => {
|
||||
vi.setSystemTime(new Date('2026-03-20T10:00:00Z'));
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub Site',
|
||||
url: 'https://sub.example.com',
|
||||
platform: 'sub2api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'demo-user',
|
||||
accessToken: 'jwt-token',
|
||||
status: 'active',
|
||||
}).run();
|
||||
await db.insert(schema.sites).values({
|
||||
name: 'Unsupported Site',
|
||||
url: 'https://unsupported.example.com',
|
||||
platform: 'openai',
|
||||
status: 'active',
|
||||
}).run();
|
||||
|
||||
getAdapterMock.mockImplementation((platform: string) => {
|
||||
if (platform === 'sub2api') {
|
||||
return {
|
||||
getSiteAnnouncements: vi.fn(async (_baseUrl: string, accessToken: string) => {
|
||||
expect(accessToken).toBe('jwt-token');
|
||||
return [{
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
rawPayload: { id: 11, title: 'Maintenance' },
|
||||
}];
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
getSiteAnnouncements: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await syncSiteAnnouncements();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
scannedSites: 2,
|
||||
inserted: 1,
|
||||
updated: 0,
|
||||
unsupported: 1,
|
||||
notifications: 1,
|
||||
events: 1,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
const announcementRows = await db.select().from(schema.siteAnnouncements).all();
|
||||
expect(announcementRows).toHaveLength(1);
|
||||
expect(announcementRows[0]).toMatchObject({
|
||||
siteId: site.id,
|
||||
platform: 'sub2api',
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
});
|
||||
|
||||
const eventRows = await db.select().from(schema.events).all();
|
||||
expect(eventRows).toHaveLength(1);
|
||||
expect(eventRows[0]).toMatchObject({
|
||||
type: 'site_notice',
|
||||
relatedType: 'site_announcement',
|
||||
});
|
||||
expect(Number(eventRows[0]?.relatedId)).toBe(Number(announcementRows[0]?.id));
|
||||
|
||||
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendNotificationMock.mock.calls[0]?.[0]).toContain('Sub Site');
|
||||
expect(sendNotificationMock.mock.calls[0]?.[1]).toContain('Window starts at 10:00');
|
||||
expect(sendNotificationMock.mock.calls[0]?.[2]).toBe('info');
|
||||
});
|
||||
|
||||
it('updates existing announcements without duplicating events or notifications', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub Site',
|
||||
url: 'https://sub.example.com',
|
||||
platform: 'sub2api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'demo-user',
|
||||
accessToken: 'jwt-token',
|
||||
status: 'active',
|
||||
}).run();
|
||||
|
||||
getAdapterMock.mockReturnValue({
|
||||
getSiteAnnouncements: vi.fn(async () => [{
|
||||
sourceKey: 'announcement:11',
|
||||
title: 'Maintenance',
|
||||
content: 'Window starts at 10:00',
|
||||
level: 'info',
|
||||
rawPayload: { id: 11, title: 'Maintenance' },
|
||||
}]),
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-03-20T10:00:00Z'));
|
||||
await syncSiteAnnouncements({ siteId: site.id });
|
||||
const firstRow = await db.select().from(schema.siteAnnouncements).get();
|
||||
|
||||
vi.setSystemTime(new Date('2026-03-20T11:00:00Z'));
|
||||
const result = await syncSiteAnnouncements({ siteId: site.id });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
scannedSites: 1,
|
||||
inserted: 0,
|
||||
updated: 1,
|
||||
notifications: 0,
|
||||
events: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
const announcementRows = await db.select().from(schema.siteAnnouncements).all();
|
||||
expect(announcementRows).toHaveLength(1);
|
||||
expect(announcementRows[0]?.firstSeenAt).toBe(firstRow?.firstSeenAt);
|
||||
expect(announcementRows[0]?.lastSeenAt).not.toBe(firstRow?.lastSeenAt);
|
||||
|
||||
const eventRows = await db.select().from(schema.events).all();
|
||||
expect(eventRows).toHaveLength(1);
|
||||
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { sendNotification } from './notifyService.js';
|
||||
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||
import type { SiteAnnouncement } from './platforms/base.js';
|
||||
|
||||
export type SiteAnnouncementSyncResult = {
|
||||
scannedSites: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
unsupported: number;
|
||||
notifications: number;
|
||||
events: number;
|
||||
failed: number;
|
||||
failedSites: Array<{ siteId: number; siteName: string; message: string }>;
|
||||
};
|
||||
|
||||
function toStoredPayload(rawPayload: unknown): string | null {
|
||||
if (rawPayload === undefined) return null;
|
||||
try {
|
||||
return JSON.stringify(rawPayload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAnnouncementMessage(row: SiteAnnouncement): string {
|
||||
const title = String(row.title || '').trim();
|
||||
const content = String(row.content || '').trim();
|
||||
if (title && content && title !== content && title.toLowerCase() !== 'site notice') {
|
||||
return `${title}\n${content}`;
|
||||
}
|
||||
return content || title;
|
||||
}
|
||||
|
||||
async function resolveSiteAccessToken(siteId: number, siteApiKey?: string | null): Promise<string> {
|
||||
const direct = String(siteApiKey || '').trim();
|
||||
if (direct) return direct;
|
||||
|
||||
const account = await db.select()
|
||||
.from(schema.accounts)
|
||||
.where(and(
|
||||
eq(schema.accounts.siteId, siteId),
|
||||
eq(schema.accounts.status, 'active'),
|
||||
))
|
||||
.orderBy(asc(schema.accounts.id))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
return String(account?.accessToken || '').trim();
|
||||
}
|
||||
|
||||
async function listTargetSites(siteId?: number | null) {
|
||||
if (siteId && Number.isFinite(siteId) && siteId > 0) {
|
||||
return await db.select()
|
||||
.from(schema.sites)
|
||||
.where(eq(schema.sites.id, siteId))
|
||||
.all();
|
||||
}
|
||||
|
||||
return await db.select()
|
||||
.from(schema.sites)
|
||||
.where(eq(schema.sites.status, 'active'))
|
||||
.all();
|
||||
}
|
||||
|
||||
export async function syncSiteAnnouncements(options?: { siteId?: number | null }): Promise<SiteAnnouncementSyncResult> {
|
||||
const result: SiteAnnouncementSyncResult = {
|
||||
scannedSites: 0,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
unsupported: 0,
|
||||
notifications: 0,
|
||||
events: 0,
|
||||
failed: 0,
|
||||
failedSites: [],
|
||||
};
|
||||
|
||||
const sites = await listTargetSites(options?.siteId ?? null);
|
||||
|
||||
for (const site of sites) {
|
||||
result.scannedSites += 1;
|
||||
const adapter = getAdapter(String(site.platform || ''));
|
||||
if (!adapter || typeof adapter.getSiteAnnouncements !== 'function') {
|
||||
result.unsupported += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await resolveSiteAccessToken(site.id, site.apiKey);
|
||||
const announcements = await adapter.getSiteAnnouncements(site.url, accessToken);
|
||||
const seenAt = formatUtcSqlDateTime(new Date());
|
||||
|
||||
for (const announcement of announcements) {
|
||||
const existing = await db.select()
|
||||
.from(schema.siteAnnouncements)
|
||||
.where(and(
|
||||
eq(schema.siteAnnouncements.siteId, site.id),
|
||||
eq(schema.siteAnnouncements.sourceKey, announcement.sourceKey),
|
||||
))
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
const patch = {
|
||||
platform: String(site.platform || '').trim(),
|
||||
title: announcement.title,
|
||||
content: announcement.content,
|
||||
level: announcement.level,
|
||||
sourceUrl: announcement.sourceUrl ?? null,
|
||||
startsAt: announcement.startsAt ?? null,
|
||||
endsAt: announcement.endsAt ?? null,
|
||||
upstreamCreatedAt: announcement.upstreamCreatedAt ?? null,
|
||||
upstreamUpdatedAt: announcement.upstreamUpdatedAt ?? null,
|
||||
lastSeenAt: seenAt,
|
||||
rawPayload: toStoredPayload(announcement.rawPayload),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await db.update(schema.siteAnnouncements)
|
||||
.set(patch)
|
||||
.where(eq(schema.siteAnnouncements.id, existing.id))
|
||||
.run();
|
||||
result.updated += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const inserted = await db.insert(schema.siteAnnouncements).values({
|
||||
siteId: site.id,
|
||||
sourceKey: announcement.sourceKey,
|
||||
firstSeenAt: seenAt,
|
||||
...patch,
|
||||
}).returning().get();
|
||||
result.inserted += 1;
|
||||
|
||||
const title = `站点公告:${site.name}`;
|
||||
const message = buildAnnouncementMessage(announcement);
|
||||
await db.insert(schema.events).values({
|
||||
type: 'site_notice',
|
||||
title,
|
||||
message,
|
||||
level: announcement.level,
|
||||
relatedId: inserted.id,
|
||||
relatedType: 'site_announcement',
|
||||
createdAt: seenAt,
|
||||
}).run();
|
||||
result.events += 1;
|
||||
|
||||
await sendNotification(title, message, announcement.level);
|
||||
result.notifications += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
result.failed += 1;
|
||||
result.failedSites.push({
|
||||
siteId: site.id,
|
||||
siteName: site.name,
|
||||
message: error instanceof Error && error.message ? error.message : 'unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -57,6 +57,22 @@ describe('siteProxy', () => {
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it('prefers site-specific proxy url over the shared system proxy', async () => {
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'system_proxy_url',
|
||||
value: JSON.stringify('http://127.0.0.1:7890'),
|
||||
}).run();
|
||||
|
||||
await db.run(sql`
|
||||
INSERT INTO sites (name, url, platform, proxy_url, use_system_proxy)
|
||||
VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 'socks5://127.0.0.1:1080', 1)
|
||||
`);
|
||||
|
||||
const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js');
|
||||
expect(await resolveSiteProxyUrlByRequestUrl('https://proxy-site.example.com/v1/models'))
|
||||
.toBe('socks5://127.0.0.1:1080');
|
||||
});
|
||||
|
||||
it('injects dispatcher when a site opts into the configured system proxy', async () => {
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'system_proxy_url',
|
||||
@@ -75,6 +91,20 @@ describe('siteProxy', () => {
|
||||
expect('dispatcher' in requestInit).toBe(true);
|
||||
});
|
||||
|
||||
it('injects dispatcher when a site defines its own proxy url', async () => {
|
||||
await db.run(sql`
|
||||
INSERT INTO sites (name, url, platform, proxy_url, use_system_proxy)
|
||||
VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 'http://127.0.0.1:7890', 0)
|
||||
`);
|
||||
|
||||
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
|
||||
const requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
expect('dispatcher' in requestInit).toBe(true);
|
||||
});
|
||||
|
||||
it('injects a working dispatcher for socks5 system proxies', async () => {
|
||||
const upstreamServer = createServer((_request, response) => {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
@@ -170,6 +200,7 @@ describe('siteProxy', () => {
|
||||
it('merges site custom headers from site records even without cache lookup', async () => {
|
||||
const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js');
|
||||
const requestInit = withSiteRecordProxyRequestInit({
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
useSystemProxy: false,
|
||||
customHeaders: JSON.stringify({
|
||||
'x-site-scope': 'site-level',
|
||||
@@ -184,6 +215,7 @@ describe('siteProxy', () => {
|
||||
|
||||
expect(headers.get('x-site-scope')).toBe('site-level');
|
||||
expect(headers.get('x-request-id')).toBe('req-1');
|
||||
expect('dispatcher' in requestInit).toBe(true);
|
||||
});
|
||||
|
||||
it('resolveChannelProxyUrl prefers account proxy over site proxy', async () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ const DEFAULT_PROXY_KEEPALIVE_INITIAL_DELAY_MS = 60_000;
|
||||
|
||||
type SiteProxyRow = {
|
||||
siteUrl: string;
|
||||
proxyUrl: string | null;
|
||||
useSystemProxy: boolean;
|
||||
customHeaders: string | null;
|
||||
};
|
||||
@@ -44,6 +45,7 @@ type ParsedSiteProxyInput = {
|
||||
};
|
||||
|
||||
type SiteProxyConfigLike = {
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean | null;
|
||||
customHeaders?: string | null;
|
||||
};
|
||||
@@ -116,6 +118,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
|
||||
db
|
||||
.select({
|
||||
siteUrl: schema.sites.url,
|
||||
proxyUrl: schema.sites.proxyUrl,
|
||||
useSystemProxy: schema.sites.useSystemProxy,
|
||||
customHeaders: schema.sites.customHeaders,
|
||||
})
|
||||
@@ -142,6 +145,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
|
||||
loadedAt: nowMs,
|
||||
rows: rows.map((row) => ({
|
||||
siteUrl: normalizeSiteUrl(row.siteUrl),
|
||||
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl),
|
||||
useSystemProxy: !!row.useSystemProxy,
|
||||
customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null,
|
||||
})),
|
||||
@@ -385,7 +389,8 @@ async function resolveSiteRequestConfigByRequestUrl(requestUrl: string): Promise
|
||||
|
||||
const rows = await getCachedSiteProxyRows();
|
||||
const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl);
|
||||
const proxyUrl = matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null;
|
||||
const proxyUrl = matchedRow?.proxyUrl
|
||||
|| (matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null);
|
||||
return {
|
||||
proxyUrl: proxyUrl || null,
|
||||
customHeaders: matchedRow?.customHeaders ?? null,
|
||||
@@ -446,6 +451,8 @@ export function withExplicitProxyRequestInit(
|
||||
}
|
||||
|
||||
export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null {
|
||||
const explicitProxyUrl = normalizeSiteProxyUrl(site?.proxyUrl);
|
||||
if (explicitProxyUrl) return explicitProxyUrl;
|
||||
if (!site?.useSystemProxy) return null;
|
||||
return normalizeSiteProxyUrl(config.systemProxyUrl);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('TokenRouter runtime cache', () => {
|
||||
let schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
|
||||
let config: ConfigModule['config'];
|
||||
let dataDir = '';
|
||||
let originalCacheTtlMs = 0;
|
||||
@@ -29,6 +30,7 @@ describe('TokenRouter runtime cache', () => {
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
|
||||
config = configModule.config;
|
||||
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
|
||||
});
|
||||
@@ -36,16 +38,19 @@ describe('TokenRouter runtime cache', () => {
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
await db.delete(schema.settings).run();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
config.tokenRouterCacheTtlMs = 60_000;
|
||||
invalidateTokenRouterCache();
|
||||
resetSiteRuntimeHealthState();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
|
||||
invalidateTokenRouterCache();
|
||||
resetSiteRuntimeHealthState();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
|
||||
@@ -102,6 +102,47 @@ describe('TokenRouter downstream policy', () => {
|
||||
expect(blockedPick).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects route selection when both supportedModels and allowedRouteIds are empty', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-deny-all',
|
||||
url: 'https://deny-all.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'user-deny-all',
|
||||
accessToken: 'access-deny-all',
|
||||
apiToken: 'sk-deny-all',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).run();
|
||||
|
||||
const router = new TokenRouter();
|
||||
const deniedPick = await router.selectChannel('gpt-4o-mini', {
|
||||
allowedRouteIds: [],
|
||||
supportedModels: [],
|
||||
siteWeightMultipliers: {},
|
||||
denyAllWhenEmpty: true,
|
||||
});
|
||||
|
||||
expect(deniedPick).toBeNull();
|
||||
});
|
||||
|
||||
it('applies site weight multipliers to probability explanation', async () => {
|
||||
const siteHigh = await db.insert(schema.sites).values({
|
||||
name: 'high-site',
|
||||
|
||||
@@ -93,6 +93,27 @@ describe('TokenRouter patterns and model mapping', () => {
|
||||
return { route, channel };
|
||||
}
|
||||
|
||||
async function createExplicitGroupRoute(
|
||||
displayName: string,
|
||||
sourceRouteIds: number[],
|
||||
) {
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: displayName,
|
||||
displayName,
|
||||
routeMode: 'explicit_group',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.routeGroupSources).values(
|
||||
sourceRouteIds.map((sourceRouteId) => ({
|
||||
groupRouteId: route.id,
|
||||
sourceRouteId,
|
||||
})),
|
||||
).run();
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
it('matches routes with re: regex patterns', async () => {
|
||||
await createRouteWithSingleChannel('re:^claude-(opus|sonnet)-4-6$');
|
||||
const router = new TokenRouter();
|
||||
@@ -181,4 +202,19 @@ describe('TokenRouter patterns and model mapping', () => {
|
||||
expect(selected?.actualModel).toBe('claude-opus-4-6');
|
||||
expect(decision.actualModel).toBe('claude-opus-4-6');
|
||||
});
|
||||
|
||||
it('falls back to the source exact-route model when explicit-group channels omit sourceModel', async () => {
|
||||
const source = await createRouteWithSingleChannel('claude-opus-4-5');
|
||||
await createExplicitGroupRoute('claude-test-4.6-sonnet', [source.route.id]);
|
||||
const router = new TokenRouter();
|
||||
|
||||
const selected = await router.selectChannel('claude-test-4.6-sonnet');
|
||||
const decision = await router.explainSelection('claude-test-4.6-sonnet');
|
||||
|
||||
expect(selected).toBeTruthy();
|
||||
expect(selected?.actualModel).toBe('claude-opus-4-5');
|
||||
expect(decision.actualModel).toBe('claude-opus-4-5');
|
||||
expect(decision.summary).toContain('按显示名命中:claude-test-4.6-sonnet');
|
||||
expect(decision.summary).toContain('实际转发模型:claude-opus-4-5');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type TokenRouterModule = typeof import('./tokenRouter.js');
|
||||
@@ -24,6 +25,8 @@ describe('TokenRouter selection scoring', () => {
|
||||
let schema: DbModule['schema'];
|
||||
let TokenRouter: TokenRouterModule['TokenRouter'];
|
||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
|
||||
let flushSiteRuntimeHealthPersistence: TokenRouterModule['flushSiteRuntimeHealthPersistence'];
|
||||
let config: ConfigModule['config'];
|
||||
let dataDir = '';
|
||||
let idSeed = 0;
|
||||
@@ -47,6 +50,8 @@ describe('TokenRouter selection scoring', () => {
|
||||
schema = dbModule.schema;
|
||||
TokenRouter = tokenRouterModule.TokenRouter;
|
||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
|
||||
flushSiteRuntimeHealthPersistence = tokenRouterModule.flushSiteRuntimeHealthPersistence;
|
||||
config = configModule.config;
|
||||
originalRoutingWeights = { ...config.routingWeights };
|
||||
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
|
||||
@@ -58,16 +63,19 @@ describe('TokenRouter selection scoring', () => {
|
||||
mockedCatalogRoutingCost.mockReturnValue(null);
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
await db.delete(schema.settings).run();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
invalidateTokenRouterCache();
|
||||
resetSiteRuntimeHealthState();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
config.routingWeights = { ...originalRoutingWeights };
|
||||
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
|
||||
invalidateTokenRouterCache();
|
||||
resetSiteRuntimeHealthState();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
@@ -398,4 +406,400 @@ describe('TokenRouter selection scoring', () => {
|
||||
expect(catalogCandidate?.reason || '').toContain('成本=目录:0.200000');
|
||||
expect(fallbackCandidate?.reason || '').toContain('成本=默认:100.000000');
|
||||
});
|
||||
|
||||
it('downweights a site after transient failures and restores it quickly after success', async () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 1,
|
||||
valueScoreFactor: 0,
|
||||
costWeight: 0,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
|
||||
const route = await createRoute('gpt-5.4');
|
||||
|
||||
const siteA = await createSite('runtime-a');
|
||||
const accountA = await createAccount(siteA.id, 'runtime-user-a');
|
||||
const tokenA = await createToken(accountA.id, 'runtime-token-a');
|
||||
const channelA = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: tokenA.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const siteB = await createSite('runtime-b');
|
||||
const accountB = await createAccount(siteB.id, 'runtime-user-b');
|
||||
const tokenB = await createToken(accountB.id, 'runtime-token-b');
|
||||
const channelB = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountB.id,
|
||||
tokenId: tokenB.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
let decision = await router.explainSelection('gpt-5.4');
|
||||
let candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||
let candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||
expect(candidateA?.probability).toBeCloseTo(50, 1);
|
||||
expect(candidateB?.probability).toBeCloseTo(50, 1);
|
||||
|
||||
await router.recordFailure(channelA.id, {
|
||||
status: 502,
|
||||
errorText: 'Bad gateway',
|
||||
});
|
||||
await db.update(schema.routeChannels).set({
|
||||
cooldownUntil: null,
|
||||
lastFailAt: null,
|
||||
failCount: 0,
|
||||
}).where(eq(schema.routeChannels.id, channelA.id)).run();
|
||||
invalidateTokenRouterCache();
|
||||
|
||||
decision = await router.explainSelection('gpt-5.4');
|
||||
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||
expect(candidateA).toBeTruthy();
|
||||
expect(candidateB).toBeTruthy();
|
||||
expect((candidateA?.probability || 0)).toBeLessThan(30);
|
||||
expect(candidateA?.reason || '').toContain('运行时健康=');
|
||||
expect((candidateB?.probability || 0)).toBeGreaterThan(70);
|
||||
|
||||
await router.recordSuccess(channelA.id, 800, 0);
|
||||
invalidateTokenRouterCache();
|
||||
|
||||
decision = await router.explainSelection('gpt-5.4');
|
||||
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||
expect((candidateA?.probability || 0)).toBeGreaterThan(40);
|
||||
expect((candidateB?.probability || 0)).toBeLessThan(60);
|
||||
});
|
||||
|
||||
it('opens a site breaker after repeated transient failures and closes it after recovery', async () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 1,
|
||||
valueScoreFactor: 0,
|
||||
costWeight: 0,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
|
||||
const route = await createRoute('gpt-5.3');
|
||||
|
||||
const siteA = await createSite('breaker-a');
|
||||
const accountA = await createAccount(siteA.id, 'breaker-user-a');
|
||||
const tokenA = await createToken(accountA.id, 'breaker-token-a');
|
||||
const channelA = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: tokenA.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const siteB = await createSite('breaker-b');
|
||||
const accountB = await createAccount(siteB.id, 'breaker-user-b');
|
||||
const tokenB = await createToken(accountB.id, 'breaker-token-b');
|
||||
const channelB = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountB.id,
|
||||
tokenId: tokenB.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await router.recordFailure(channelA.id, {
|
||||
status: 502,
|
||||
errorText: 'Gateway timeout',
|
||||
});
|
||||
}
|
||||
await db.update(schema.routeChannels).set({
|
||||
cooldownUntil: null,
|
||||
lastFailAt: null,
|
||||
failCount: 0,
|
||||
}).where(eq(schema.routeChannels.id, channelA.id)).run();
|
||||
invalidateTokenRouterCache();
|
||||
|
||||
let decision = await router.explainSelection('gpt-5.3');
|
||||
const breakerCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||
const breakerCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||
expect(breakerCandidateA?.reason || '').toContain('站点熔断');
|
||||
expect((breakerCandidateA?.probability || 0)).toBe(0);
|
||||
expect((breakerCandidateB?.probability || 0)).toBe(100);
|
||||
expect(decision.summary.join(' ')).toContain('站点熔断避让');
|
||||
|
||||
await router.recordSuccess(channelA.id, 600, 0);
|
||||
invalidateTokenRouterCache();
|
||||
|
||||
decision = await router.explainSelection('gpt-5.3');
|
||||
const recoveredCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||
const recoveredCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||
expect((recoveredCandidateA?.probability || 0)).toBeGreaterThan(30);
|
||||
expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70);
|
||||
});
|
||||
|
||||
it('uses persisted site success and latency history to prefer historically healthier sites', async () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 1,
|
||||
valueScoreFactor: 0,
|
||||
costWeight: 0,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
|
||||
const route = await createRoute('claude-4-sonnet');
|
||||
|
||||
const siteStable = await createSite('history-stable');
|
||||
const accountStable = await createAccount(siteStable.id, 'history-user-stable');
|
||||
const tokenStable = await createToken(accountStable.id, 'history-token-stable');
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountStable.id,
|
||||
tokenId: tokenStable.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
successCount: 90,
|
||||
failCount: 10,
|
||||
totalLatencyMs: 90 * 240,
|
||||
}).run();
|
||||
|
||||
const siteWeak = await createSite('history-weak');
|
||||
const accountWeak = await createAccount(siteWeak.id, 'history-user-weak');
|
||||
const tokenWeak = await createToken(accountWeak.id, 'history-token-weak');
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountWeak.id,
|
||||
tokenId: tokenWeak.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
successCount: 20,
|
||||
failCount: 30,
|
||||
totalLatencyMs: 20 * 5200,
|
||||
}).run();
|
||||
|
||||
const decision = await new TokenRouter().explainSelection('claude-4-sonnet');
|
||||
const stableCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-stable'));
|
||||
const weakCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-weak'));
|
||||
|
||||
expect(stableCandidate).toBeTruthy();
|
||||
expect(weakCandidate).toBeTruthy();
|
||||
expect((stableCandidate?.probability || 0)).toBeGreaterThan(weakCandidate?.probability || 0);
|
||||
expect(stableCandidate?.reason || '').toContain('历史健康=');
|
||||
expect(stableCandidate?.reason || '').toContain('成功率=90.0%');
|
||||
expect(weakCandidate?.reason || '').toContain('成功率=40.0%');
|
||||
});
|
||||
|
||||
it('reloads persisted runtime health after in-memory reset', async () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 1,
|
||||
valueScoreFactor: 0,
|
||||
costWeight: 0,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
|
||||
const route = await createRoute('gpt-4o-mini');
|
||||
|
||||
const siteA = await createSite('persist-a');
|
||||
const accountA = await createAccount(siteA.id, 'persist-user-a');
|
||||
const tokenA = await createToken(accountA.id, 'persist-token-a');
|
||||
const channelA = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: tokenA.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const siteB = await createSite('persist-b');
|
||||
const accountB = await createAccount(siteB.id, 'persist-user-b');
|
||||
const tokenB = await createToken(accountB.id, 'persist-token-b');
|
||||
const channelB = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountB.id,
|
||||
tokenId: tokenB.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
await router.recordFailure(channelA.id, {
|
||||
status: 502,
|
||||
errorText: 'Gateway timeout',
|
||||
modelName: 'gpt-4o-mini',
|
||||
});
|
||||
await db.update(schema.routeChannels).set({
|
||||
cooldownUntil: null,
|
||||
lastFailAt: null,
|
||||
failCount: 0,
|
||||
}).where(eq(schema.routeChannels.id, channelA.id)).run();
|
||||
await flushSiteRuntimeHealthPersistence();
|
||||
|
||||
const persisted = await db.select().from(schema.settings)
|
||||
.where(eq(schema.settings.key, 'token_router_site_runtime_health_v1'))
|
||||
.get();
|
||||
expect(persisted?.value).toBeTruthy();
|
||||
|
||||
resetSiteRuntimeHealthState();
|
||||
invalidateTokenRouterCache();
|
||||
|
||||
const decision = await new TokenRouter().explainSelection('gpt-4o-mini');
|
||||
const candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
|
||||
const candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
|
||||
|
||||
expect(candidateA).toBeTruthy();
|
||||
expect(candidateB).toBeTruthy();
|
||||
expect((candidateA?.probability || 0)).toBeLessThan((candidateB?.probability || 0));
|
||||
expect(candidateA?.reason || '').toContain('运行时健康=');
|
||||
});
|
||||
|
||||
it('penalizes the failed model more than unrelated models on the same site', async () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 1,
|
||||
valueScoreFactor: 0,
|
||||
costWeight: 0,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
|
||||
const gptRoute = await createRoute('gpt-5.4');
|
||||
const claudeRoute = await createRoute('claude-sonnet-4-6');
|
||||
|
||||
const siteA = await createSite('model-aware-a');
|
||||
const accountA = await createAccount(siteA.id, 'model-aware-user-a');
|
||||
const tokenA = await createToken(accountA.id, 'model-aware-token-a');
|
||||
const gptChannelA = await db.insert(schema.routeChannels).values({
|
||||
routeId: gptRoute.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: tokenA.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId: claudeRoute.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: tokenA.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).run();
|
||||
|
||||
const siteB = await createSite('model-aware-b');
|
||||
const accountB = await createAccount(siteB.id, 'model-aware-user-b');
|
||||
const tokenB = await createToken(accountB.id, 'model-aware-token-b');
|
||||
await db.insert(schema.routeChannels).values([
|
||||
{
|
||||
routeId: gptRoute.id,
|
||||
accountId: accountB.id,
|
||||
tokenId: tokenB.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
routeId: claudeRoute.id,
|
||||
accountId: accountB.id,
|
||||
tokenId: tokenB.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const router = new TokenRouter();
|
||||
await router.recordFailure(gptChannelA.id, {
|
||||
status: 502,
|
||||
errorText: 'Bad gateway',
|
||||
modelName: 'gpt-5.4',
|
||||
});
|
||||
await db.update(schema.routeChannels).set({
|
||||
cooldownUntil: null,
|
||||
lastFailAt: null,
|
||||
failCount: 0,
|
||||
}).where(eq(schema.routeChannels.id, gptChannelA.id)).run();
|
||||
invalidateTokenRouterCache();
|
||||
|
||||
const gptDecision = await router.explainSelection('gpt-5.4');
|
||||
const claudeDecision = await router.explainSelection('claude-sonnet-4-6');
|
||||
const gptCandidateA = gptDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
|
||||
const claudeCandidateA = claudeDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
|
||||
|
||||
expect(gptCandidateA).toBeTruthy();
|
||||
expect(claudeCandidateA).toBeTruthy();
|
||||
expect((gptCandidateA?.probability || 0)).toBeLessThan((claudeCandidateA?.probability || 0));
|
||||
expect(gptCandidateA?.reason || '').toContain('模型=');
|
||||
});
|
||||
|
||||
it('stable_first deterministically chooses the healthiest candidate', async () => {
|
||||
config.routingWeights = {
|
||||
baseWeightFactor: 1,
|
||||
valueScoreFactor: 0,
|
||||
costWeight: 0,
|
||||
balanceWeight: 0,
|
||||
usageWeight: 0,
|
||||
};
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-5.1',
|
||||
routingStrategy: 'stable_first',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const siteA = await createSite('stable-first-a');
|
||||
const accountA = await createAccount(siteA.id, 'stable-first-user-a');
|
||||
const tokenA = await createToken(accountA.id, 'stable-first-token-a');
|
||||
const channelA = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountA.id,
|
||||
tokenId: tokenA.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const siteB = await createSite('stable-first-b');
|
||||
const accountB = await createAccount(siteB.id, 'stable-first-user-b');
|
||||
const tokenB = await createToken(accountB.id, 'stable-first-token-b');
|
||||
const channelB = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: accountB.id,
|
||||
tokenId: tokenB.id,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const router = new TokenRouter();
|
||||
await router.recordFailure(channelA.id, {
|
||||
status: 502,
|
||||
errorText: 'Gateway timeout',
|
||||
modelName: 'gpt-5.1',
|
||||
});
|
||||
await db.update(schema.routeChannels).set({
|
||||
cooldownUntil: null,
|
||||
lastFailAt: null,
|
||||
failCount: 0,
|
||||
}).where(eq(schema.routeChannels.id, channelA.id)).run();
|
||||
invalidateTokenRouterCache();
|
||||
|
||||
const preview = await router.previewSelectedChannel('gpt-5.1');
|
||||
const decision = await router.explainSelection('gpt-5.1');
|
||||
|
||||
expect(preview?.channel.id).toBe(channelB.id);
|
||||
expect(decision.summary.join(' ')).toContain('稳定优先');
|
||||
expect(decision.selectedChannelId).toBe(channelB.id);
|
||||
});
|
||||
});
|
||||
|
||||
+964
-130
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user