feat: implement macOS notarization and ad-hoc signing in afterSign script

feat: add run-server script to manage server execution and compatibility checks

feat: create main application logic for desktop with window management, server handling, and auto-updater

test: add unit tests for desktop runtime helpers

feat: implement desktop server environment configuration and health check

test: add tests for server configuration and desktop routes

feat: add native module guard for better-sqlite3 compatibility

test: add tests for native module guard functionality

test: add comprehensive tests for account verification feedback handling

feat: implement account verification feedback helpers

chore: add TypeScript configuration for desktop builds
This commit is contained in:
cita
2026-03-06 16:24:41 +08:00
parent acdc846b13
commit 2a810facb8
45 changed files with 6712 additions and 405 deletions
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { buildConfig } from './config.js';
describe('buildConfig', () => {
it('defaults to external listen host for server deployments', () => {
const config = buildConfig({});
expect(config.listenHost).toBe('0.0.0.0');
expect(config.port).toBe(4000);
expect(config.dataDir).toBe('./data');
});
it('keeps desktop deployments bound to loopback', () => {
const config = buildConfig({
HOST: '0.0.0.0',
METAPI_DESKTOP: '1',
PORT: '4312',
DATA_DIR: '/tmp/metapi-data',
});
expect(config.listenHost).toBe('127.0.0.1');
expect(config.port).toBe(4312);
expect(config.dataDir).toBe('/tmp/metapi-data');
});
it('honors explicit loopback host outside desktop mode', () => {
const config = buildConfig({
HOST: '127.0.0.1',
});
expect(config.listenHost).toBe('127.0.0.1');
});
});
+56 -42
View File
@@ -28,46 +28,60 @@ function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres'
return 'sqlite';
}
const dataDir = process.env.DATA_DIR || './data';
function parseListenHost(env: NodeJS.ProcessEnv): string {
const requestedHost = (env.HOST || '0.0.0.0').trim() || '0.0.0.0';
if (parseBoolean(env.METAPI_DESKTOP, false)) {
return '127.0.0.1';
}
return requestedHost;
}
export const config = {
authToken: process.env.AUTH_TOKEN || 'change-me-admin-token',
proxyToken: process.env.PROXY_TOKEN || 'change-me-proxy-sk-token',
accountCredentialSecret: process.env.ACCOUNT_CREDENTIAL_SECRET || process.env.AUTH_TOKEN || 'change-me-admin-token',
checkinCron: process.env.CHECKIN_CRON || '0 8 * * *',
balanceRefreshCron: process.env.BALANCE_REFRESH_CRON || '0 * * * *',
webhookUrl: process.env.WEBHOOK_URL || '',
barkUrl: process.env.BARK_URL || '',
webhookEnabled: parseBoolean(process.env.WEBHOOK_ENABLED, true),
barkEnabled: parseBoolean(process.env.BARK_ENABLED, true),
serverChanEnabled: parseBoolean(process.env.SERVERCHAN_ENABLED, true),
serverChanKey: process.env.SERVERCHAN_KEY || '',
telegramEnabled: parseBoolean(process.env.TELEGRAM_ENABLED, false),
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '',
telegramChatId: process.env.TELEGRAM_CHAT_ID || '',
smtpEnabled: parseBoolean(process.env.SMTP_ENABLED, false),
smtpHost: process.env.SMTP_HOST || '',
smtpPort: parseInt(process.env.SMTP_PORT || '587'),
smtpSecure: parseBoolean(process.env.SMTP_SECURE, false),
smtpUser: process.env.SMTP_USER || '',
smtpPass: process.env.SMTP_PASS || '',
smtpFrom: process.env.SMTP_FROM || '',
smtpTo: process.env.SMTP_TO || '',
notifyCooldownSec: Math.max(0, Math.trunc(parseNumber(process.env.NOTIFY_COOLDOWN_SEC, 300))),
adminIpAllowlist: parseCsvList(process.env.ADMIN_IP_ALLOWLIST),
port: Math.trunc(parseNumber(process.env.PORT, 4000)),
dataDir,
dbType: parseDbType(process.env.DB_TYPE),
dbUrl: (process.env.DB_URL || '').trim(),
routingFallbackUnitCost: Math.max(1e-6, parseNumber(process.env.ROUTING_FALLBACK_UNIT_COST, 1)),
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(process.env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(process.env.PROXY_LOG_RETENTION_DAYS, 30))),
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(process.env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
routingWeights: {
baseWeightFactor: parseNumber(process.env.BASE_WEIGHT_FACTOR, 0.5),
valueScoreFactor: parseNumber(process.env.VALUE_SCORE_FACTOR, 0.5),
costWeight: parseNumber(process.env.COST_WEIGHT, 0.4),
balanceWeight: parseNumber(process.env.BALANCE_WEIGHT, 0.3),
usageWeight: parseNumber(process.env.USAGE_WEIGHT, 0.3),
},
};
export function buildConfig(env: NodeJS.ProcessEnv) {
const dataDir = env.DATA_DIR || './data';
return {
authToken: env.AUTH_TOKEN || 'change-me-admin-token',
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
webhookUrl: env.WEBHOOK_URL || '',
barkUrl: env.BARK_URL || '',
webhookEnabled: parseBoolean(env.WEBHOOK_ENABLED, true),
barkEnabled: parseBoolean(env.BARK_ENABLED, true),
serverChanEnabled: parseBoolean(env.SERVERCHAN_ENABLED, true),
serverChanKey: env.SERVERCHAN_KEY || '',
telegramEnabled: parseBoolean(env.TELEGRAM_ENABLED, false),
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
telegramChatId: env.TELEGRAM_CHAT_ID || '',
smtpEnabled: parseBoolean(env.SMTP_ENABLED, false),
smtpHost: env.SMTP_HOST || '',
smtpPort: parseInt(env.SMTP_PORT || '587'),
smtpSecure: parseBoolean(env.SMTP_SECURE, false),
smtpUser: env.SMTP_USER || '',
smtpPass: env.SMTP_PASS || '',
smtpFrom: env.SMTP_FROM || '',
smtpTo: env.SMTP_TO || '',
notifyCooldownSec: Math.max(0, Math.trunc(parseNumber(env.NOTIFY_COOLDOWN_SEC, 300))),
adminIpAllowlist: parseCsvList(env.ADMIN_IP_ALLOWLIST),
port: Math.trunc(parseNumber(env.PORT, 4000)),
listenHost: parseListenHost(env),
dataDir,
dbType: parseDbType(env.DB_TYPE),
dbUrl: (env.DB_URL || '').trim(),
dbSsl: parseBoolean(env.DB_SSL, false),
routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)),
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
routingWeights: {
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
costWeight: parseNumber(env.COST_WEIGHT, 0.4),
balanceWeight: parseNumber(env.BALANCE_WEIGHT, 0.3),
usageWeight: parseNumber(env.USAGE_WEIGHT, 0.3),
},
};
}
export const config = buildConfig(process.env);
+16 -3
View File
@@ -532,7 +532,11 @@ function initMysqlDb(): AppDb {
if (!config.dbUrl) {
throw new Error('DB_URL is required when DB_TYPE=mysql');
}
mysqlPool = mysql.createPool(config.dbUrl);
const poolOptions: mysql.PoolOptions = { uri: config.dbUrl };
if (config.dbSsl) {
poolOptions.ssl = { rejectUnauthorized: false };
}
mysqlPool = mysql.createPool(poolOptions);
const rawDb = drizzleMysqlProxy(
(sqlText, params, method) => mysqlProxyQuery(mysqlPool!, sqlText, params, method as SqlMethod),
@@ -564,7 +568,11 @@ function initPostgresDb(): AppDb {
if (!config.dbUrl) {
throw new Error('DB_URL is required when DB_TYPE=postgres');
}
pgPool = new pg.Pool({ connectionString: config.dbUrl });
const poolOptions: pg.PoolConfig = { connectionString: config.dbUrl };
if (config.dbSsl) {
poolOptions.ssl = { rejectUnauthorized: false };
}
pgPool = new pg.Pool(poolOptions);
const rawDb = drizzlePgProxy(
(sqlText, params, method) => pgProxyQuery(pgPool!, sqlText, params, method as SqlMethod),
@@ -622,16 +630,20 @@ export async function closeDbConnections(): Promise<void> {
}
}
export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextDbUrl: string): Promise<void> {
export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextDbUrl: string, nextDbSsl?: boolean): Promise<void> {
const previousDialect = runtimeDbDialect;
const previousDbUrl = config.dbUrl;
const previousConfigDialect = config.dbType;
const previousDbSsl = config.dbSsl;
await closeDbConnections();
runtimeDbDialect = nextDialect;
config.dbType = nextDialect;
config.dbUrl = nextDbUrl;
if (nextDbSsl !== undefined) {
config.dbSsl = nextDbSsl;
}
try {
activeDb = initDb();
@@ -640,6 +652,7 @@ export async function switchRuntimeDatabase(nextDialect: RuntimeDbDialect, nextD
runtimeDbDialect = previousDialect;
config.dbType = previousConfigDialect;
config.dbUrl = previousDbUrl;
config.dbSsl = previousDbSsl;
activeDb = initDb();
throw error;
}
+24
View File
@@ -0,0 +1,24 @@
import Fastify from 'fastify';
import { describe, expect, it } from 'vitest';
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
describe('desktop server routes', () => {
it('marks only the desktop health route as public', () => {
expect(isPublicApiRoute('/api/desktop/health')).toBe(true);
expect(isPublicApiRoute('/api/stats/dashboard')).toBe(false);
});
it('registers a public desktop health probe', async () => {
const app = Fastify();
await registerDesktopRoutes(app);
const response = await app.inject({
method: 'GET',
url: '/api/desktop/health',
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ok: true });
await app.close();
});
});
+11
View File
@@ -0,0 +1,11 @@
import type { FastifyInstance } from 'fastify';
const DESKTOP_HEALTH_ROUTE = '/api/desktop/health';
export function isPublicApiRoute(url: string): boolean {
return url === DESKTOP_HEALTH_ROUTE;
}
export async function registerDesktopRoutes(app: FastifyInstance) {
app.get(DESKTOP_HEALTH_ROUTE, async () => ({ ok: true }));
}
+14 -8
View File
@@ -22,6 +22,7 @@ import { startScheduler } from './services/checkinScheduler.js';
import { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
import { buildStartupSummaryLines } from './services/startupInfo.js';
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
import { existsSync } from 'fs';
import { normalize, resolve, sep } from 'path';
import { db, runtimeDbDialect, schema, switchRuntimeDatabase, type RuntimeDbDialect } from './db/index.js';
@@ -74,14 +75,19 @@ function validateSavedDbUrl(dialect: RuntimeDbDialect, value: unknown): string |
return null;
}
function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): { dialect: RuntimeDbDialect; dbUrl: string } | null {
function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): { dialect: RuntimeDbDialect; dbUrl: string; ssl: boolean } | null {
const rawType = parseSettingFromMap<unknown>(settingsMap, 'db_type');
const rawUrl = parseSettingFromMap<unknown>(settingsMap, 'db_url');
const rawSsl = parseSettingFromMap<boolean>(settingsMap, 'db_ssl');
const dialect = normalizeSavedDbType(rawType);
if (!dialect) return null;
const dbUrl = validateSavedDbUrl(dialect, rawUrl);
if (!dbUrl) return null;
return { dialect, dbUrl };
return {
dialect,
dbUrl,
ssl: typeof rawSsl === 'boolean' ? rawSsl : false,
};
}
function applyRuntimeSettings(settingsMap: Map<string, string>) {
@@ -171,9 +177,9 @@ try {
const initialMap = toSettingsMap(initialRows);
const savedDbConfig = extractSavedRuntimeDatabaseConfig(initialMap);
const activeDbUrl = (config.dbUrl || '').trim();
if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl)) {
if (savedDbConfig && (savedDbConfig.dialect !== runtimeDbDialect || savedDbConfig.dbUrl !== activeDbUrl || savedDbConfig.ssl !== config.dbSsl)) {
try {
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl);
await switchRuntimeDatabase(savedDbConfig.dialect, savedDbConfig.dbUrl, savedDbConfig.ssl);
console.log(`Loaded runtime DB config from settings: ${savedDbConfig.dialect}`);
} catch (error) {
console.warn(`Failed to switch runtime DB from settings: ${(error as Error)?.message || 'unknown error'}`);
@@ -194,12 +200,13 @@ await app.register(cors);
// Auth middleware for /api routes
app.addHook('onRequest', async (request, reply) => {
if (request.url.startsWith('/api/')) {
if (request.url.startsWith('/api/') && !isPublicApiRoute(request.url)) {
await authMiddleware(request, reply);
}
});
// Register API routes
await app.register(registerDesktopRoutes);
await app.register(sitesRoutes);
await app.register(accountsRoutes);
await app.register(checkinRoutes);
@@ -254,11 +261,10 @@ app.addHook('onClose', async () => {
// Start server
try {
const listenHost = '0.0.0.0';
await app.listen({ port: config.port, host: listenHost });
await app.listen({ port: config.port, host: config.listenHost });
const summaryLines = buildStartupSummaryLines({
port: config.port,
host: listenHost,
host: config.listenHost,
authToken: config.authToken,
proxyToken: config.proxyToken,
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import {
buildBetterSqliteRebuildCommand,
isNodeModuleVersionMismatch,
} from './nativeModuleGuard.js';
describe('nativeModuleGuard', () => {
it('detects better-sqlite3 ABI mismatch errors', () => {
const error = Object.assign(
new Error('NODE_MODULE_VERSION 133. This version of Node.js requires NODE_MODULE_VERSION 137.'),
{ code: 'ERR_DLOPEN_FAILED' },
);
expect(isNodeModuleVersionMismatch(error)).toBe(true);
});
it('ignores unrelated native module errors', () => {
const error = Object.assign(
new Error('Cannot find module'),
{ code: 'MODULE_NOT_FOUND' },
);
expect(isNodeModuleVersionMismatch(error)).toBe(false);
});
it('builds a rebuild command for the current npm client', () => {
const command = buildBetterSqliteRebuildCommand({
npmExecPath: '/tmp/npm-cli.js',
});
expect(command.command).toBe(process.execPath);
expect(command.args).toEqual([
'/tmp/npm-cli.js',
'rebuild',
'better-sqlite3',
'--no-audit',
'--fund=false',
]);
});
});
+37
View File
@@ -0,0 +1,37 @@
type RebuildCommandInput = {
npmExecPath?: string;
};
type RebuildCommand = {
command: string;
args: string[];
};
export function isNodeModuleVersionMismatch(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const code = 'code' in error ? String((error as { code?: unknown }).code || '') : '';
const message = 'message' in error ? String((error as { message?: unknown }).message || '') : '';
return code === 'ERR_DLOPEN_FAILED' && message.includes('NODE_MODULE_VERSION');
}
export function buildBetterSqliteRebuildCommand(input: RebuildCommandInput = {}): RebuildCommand {
const npmExecPath = input.npmExecPath || process.env.npm_execpath;
if (npmExecPath && npmExecPath.length > 0) {
return {
command: process.execPath,
args: [
npmExecPath,
'rebuild',
'better-sqlite3',
'--no-audit',
'--fund=false',
],
};
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
return {
command: npmCommand,
args: ['rebuild', 'better-sqlite3', '--no-audit', '--fund=false'],
};
}
@@ -207,6 +207,53 @@ describe('accounts health refresh runtime state', () => {
}
}, 1000);
it('skips runtime refresh for proxy-only accounts', async () => {
const site = await db.insert(schema.sites).values({
name: 'Proxy Site',
url: 'https://proxy.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: null,
accessToken: 'api-key-token',
status: 'expired',
extraConfig: JSON.stringify({
credentialMode: 'apikey',
}),
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/health/refresh',
payload: { accountId: account.id, wait: true },
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success: boolean;
summary: {
total: number;
skipped: number;
failed: number;
};
results: Array<{ status: string; state: string; message: string }>;
};
expect(body.success).toBe(true);
expect(body.summary).toMatchObject({
total: 1,
skipped: 1,
failed: 0,
});
expect(body.results[0]).toMatchObject({
status: 'skipped',
state: 'unknown',
});
expect(refreshBalanceMock).not.toHaveBeenCalled();
});
it('finishes the background refresh-all task after the 10 second site timeout instead of staying in progress', async () => {
vi.useFakeTimers();
try {
+211 -17
View File
@@ -47,6 +47,8 @@ type AccountCapabilities = {
proxyOnly: boolean;
};
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
function hasSessionTokenValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
@@ -126,6 +128,8 @@ type LoginFailureInfo = {
};
const ACCOUNT_HEALTH_REFRESH_TIMEOUT_MS = 10_000;
const ACCOUNT_VERIFY_TIMEOUT_MS = 10_000;
const ACCOUNT_VERIFY_DIAG_TIMEOUT_MS = 2_500;
function normalizeLoginFailure(message: string | null | undefined): LoginFailureInfo {
const raw = (message || '').trim();
@@ -184,12 +188,53 @@ async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMe
}
}
function isVerificationTimeoutError(error: unknown): boolean {
const name = typeof error === 'object' && error && 'name' in error
? String((error as { name?: unknown }).name || '')
: '';
const message = typeof error === 'object' && error && 'message' in error
? String((error as { message?: unknown }).message || '')
: String(error || '');
const lowered = `${name} ${message}`.toLowerCase();
return lowered.includes('timeout') || lowered.includes('timed out') || lowered.includes('abort');
}
function resolveUserIdFailureReason(message: string, hasProvidedUserId: boolean): VerifyFailureReason {
const lowered = String(message || '').trim().toLowerCase();
if (!lowered) return null;
if (
lowered.includes('mismatch')
|| lowered.includes('not match')
|| lowered.includes('invalid user id')
|| lowered.includes('wrong user id')
) {
return 'invalid-user-id';
}
if (
lowered.includes('missing new-api-user')
|| lowered.includes('new-api-user required')
|| lowered.includes('requires user id')
|| lowered.includes('missing user id')
) {
return 'needs-user-id';
}
if (lowered.includes('new-api-user') || lowered.includes('user id')) {
return hasProvidedUserId ? 'invalid-user-id' : 'needs-user-id';
}
return null;
}
async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<AccountHealthRefreshResult> {
const accountId = row.accounts.id;
const username = row.accounts.username;
const siteName = row.sites.name;
if ((row.accounts.status || 'active') === 'disabled' || (row.sites.status || 'active') === 'disabled') {
const accountId = row.accounts.id;
const username = row.accounts.username;
const siteName = row.sites.name;
const capabilities = buildCapabilitiesForAccount(row.accounts);
if ((row.accounts.status || 'active') === 'disabled' || (row.sites.status || 'active') === 'disabled') {
setAccountRuntimeHealth(accountId, {
state: 'disabled',
reason: '账号或站点已禁用',
@@ -202,7 +247,18 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
status: 'skipped',
state: 'disabled',
message: '账号或站点已禁用',
};
};
}
if (capabilities.proxyOnly) {
return {
accountId,
username,
siteName,
status: 'skipped',
state: 'unknown',
message: '仅代理账号不支持会话健康检查',
};
}
try {
@@ -218,6 +274,7 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
accountStatus: refreshedAccount?.status || row.accounts.status,
siteStatus: row.sites.status,
extraConfig: refreshedAccount?.extraConfig ?? row.accounts.extraConfig,
sessionCapable: capabilities.canRefreshBalance,
});
return {
@@ -333,6 +390,10 @@ export async function accountsRoutes(app: FastifyInstance) {
accountStatus: r.accounts.status,
siteStatus: r.sites.status,
extraConfig: r.accounts.extraConfig,
sessionCapable: buildCapabilitiesFromCredentialMode(
credentialMode,
hasSessionTokenValue(r.accounts.accessToken),
).canRefreshBalance,
}),
};
});
@@ -464,9 +525,119 @@ export async function accountsRoutes(app: FastifyInstance) {
const adapter = getAdapter(site.platform);
if (!adapter) return { success: false, message: `婵炴垶鎸哥粔鐢稿极椤曗偓楠炴劖鎷呴悜妯兼殸濡ょ姷鍋涢崯鑳亹? ${site.platform}` };
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
const hasProvidedUserId = Number.isFinite(platformUserId) && platformUserId > 0;
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
const diagnoseVerificationFailure = async (): Promise<VerifyFailureReason> => {
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
const text = bodyText || '';
const ct = (contentType || '').toLowerCase();
if (
!skipRawShieldDetection
&& ct.includes('text/html')
&& /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)
) {
return 'shield-blocked';
}
try {
const body = JSON.parse(text) as any;
const message = typeof body?.message === 'string' ? body.message : '';
const userIdReason = resolveUserIdFailureReason(message, hasProvidedUserId);
if (userIdReason) return userIdReason;
if (!skipRawShieldDetection && /shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) {
return 'shield-blocked';
}
} catch { }
return null;
};
try {
const { fetch } = await import('undici');
const candidates = new Set<string>();
const raw = accessToken.startsWith('Bearer ') ? accessToken.slice(7).trim() : accessToken;
if (raw) {
if (raw.includes('=')) candidates.add(raw);
candidates.add(`session=${raw}`);
candidates.add(`token=${raw}`);
}
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
const headerVariants: Record<string, string>[] = [
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
];
for (const cookie of candidates) {
headerVariants.push({
Cookie: cookie,
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...(hasProvidedUserId ? { 'New-Api-User': diagnosticUserId } : {}),
});
}
for (const headers of headerVariants) {
try {
const testRes = await fetch(
`${site.url}/api/user/self`,
withExplicitProxyRequestInit(site.proxyUrl, {
headers,
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
}),
);
const bodyText = await testRes.text();
const contentType = testRes.headers.get('content-type') || '';
const reason = parseFailureReason(bodyText, contentType);
if (reason) return reason;
} catch { }
}
} catch { }
return null;
};
const buildVerificationFailureResponse = (failureReason: VerifyFailureReason) => {
if (failureReason === 'needs-user-id') {
return {
success: false,
needsUserId: true,
message: 'This site requires a user ID. Please fill in your site user ID.',
};
}
if (failureReason === 'invalid-user-id') {
return {
success: false,
invalidUserId: true,
message: 'The provided user ID does not match this token. Please check your site user ID.',
};
}
if (failureReason === 'shield-blocked') {
return {
success: false,
shieldBlocked: true,
message: 'This site is shielded by anti-bot challenge. Create an API key on the target site and import that key.',
};
}
return null;
};
if (!platformUserId && (normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter')) {
const preflightReason = await diagnoseVerificationFailure();
if (preflightReason === 'needs-user-id') {
return buildVerificationFailureResponse(preflightReason);
}
}
if (credentialMode === 'apikey') {
try {
const models = await adapter.getModels(site.url, accessToken, platformUserId);
const models = await withTimeout(
() => adapter.getModels(site.url, accessToken, platformUserId),
ACCOUNT_VERIFY_TIMEOUT_MS,
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
);
const availableModels = Array.isArray(models) ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) : [];
if (availableModels.length === 0) {
return {
@@ -481,6 +652,10 @@ export async function accountsRoutes(app: FastifyInstance) {
models: availableModels.slice(0, 10),
};
} catch (err: any) {
if (isVerificationTimeoutError(err)) {
const failure = buildVerificationFailureResponse(await diagnoseVerificationFailure());
if (failure) return failure;
}
return {
success: false,
message: err?.message || 'API Key 验证失败',
@@ -490,8 +665,16 @@ export async function accountsRoutes(app: FastifyInstance) {
let result: any;
try {
result = await adapter.verifyToken(site.url, accessToken, platformUserId);
result = await withTimeout(
() => adapter.verifyToken(site.url, accessToken, platformUserId),
ACCOUNT_VERIFY_TIMEOUT_MS,
`Token verification timed out (${Math.max(1, Math.round(ACCOUNT_VERIFY_TIMEOUT_MS / 1000))}s)`,
);
} catch (err: any) {
if (isVerificationTimeoutError(err)) {
const failure = buildVerificationFailureResponse(await diagnoseVerificationFailure());
if (failure) return failure;
}
return {
success: false,
message: appendSessionTokenRebindHint(err?.message || 'Token 验证失败'),
@@ -524,12 +707,6 @@ export async function accountsRoutes(app: FastifyInstance) {
}
// Try to explain unknown failures: missing user id vs anti-bot challenge page.
const normalizedPlatform = String(adapter.platformName || site.platform || '').trim().toLowerCase();
// New-API family already runs shield-aware probing inside adapters.
// Raw fallback probe below does not include challenge-solving and can
// misclassify valid Cookie/Session flows as shield-blocked.
const skipRawShieldDetection = normalizedPlatform === 'new-api' || normalizedPlatform === 'anyrouter';
type VerifyFailureReason = 'needs-user-id' | 'shield-blocked' | null;
const detectVerifyFailureReason = async (): Promise<VerifyFailureReason> => {
const parseFailureReason = (bodyText: string, contentType: string): VerifyFailureReason => {
const text = bodyText || '';
@@ -545,7 +722,8 @@ export async function accountsRoutes(app: FastifyInstance) {
try {
const body = JSON.parse(text) as any;
const message = typeof body?.message === 'string' ? body.message : '';
if (/mismatch|new-api-user|user id/i.test(message)) return 'needs-user-id';
const userIdReason = resolveUserIdFailureReason(message, hasProvidedUserId);
if (userIdReason) return userIdReason;
if (!skipRawShieldDetection && /shield|challenge|captcha|acw_sc__v2|arg1/i.test(message)) {
return 'shield-blocked';
}
@@ -564,8 +742,9 @@ export async function accountsRoutes(app: FastifyInstance) {
candidates.add(`token=${raw}`);
}
const diagnosticUserId = hasProvidedUserId ? String(platformUserId) : '0';
const headerVariants: Record<string, string>[] = [
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': '0' },
{ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'New-Api-User': diagnosticUserId },
];
for (const cookie of candidates) {
@@ -573,12 +752,19 @@ export async function accountsRoutes(app: FastifyInstance) {
Cookie: cookie,
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...(hasProvidedUserId ? { 'New-Api-User': diagnosticUserId } : {}),
});
}
for (const headers of headerVariants) {
try {
const testRes = await fetch(`${site.url}/api/user/self`, withExplicitProxyRequestInit(site.proxyUrl, { headers }));
const testRes = await fetch(
`${site.url}/api/user/self`,
withExplicitProxyRequestInit(site.proxyUrl, {
headers,
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
}),
);
const bodyText = await testRes.text();
const contentType = testRes.headers.get('content-type') || '';
const reason = parseFailureReason(bodyText, contentType);
@@ -599,6 +785,14 @@ export async function accountsRoutes(app: FastifyInstance) {
};
}
if (failureReason === 'invalid-user-id') {
return {
success: false,
invalidUserId: true,
message: 'The provided user ID does not match this token. Please check your site user ID.',
};
}
if (failureReason === 'shield-blocked') {
return {
success: false,
@@ -187,4 +187,114 @@ describe('accounts verify-token shield detection', () => {
});
expect(undiciFetchMock).toHaveBeenCalled();
});
it('falls back to needsUserId diagnosis when verifyToken hangs', async () => {
vi.useFakeTimers();
verifyTokenMock.mockImplementationOnce(() => new Promise(() => {}));
undiciFetchMock.mockResolvedValue({
text: async () => JSON.stringify({ success: false, message: 'missing New-Api-User' }),
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
},
});
const site = await db.insert(schema.sites).values({
name: 'Needs User Id',
url: 'https://needs-user-id.example.com',
platform: 'new-api',
}).returning().get();
const responsePromise = app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'slow-session-token',
},
});
await vi.advanceTimersByTimeAsync(10_100);
const response = await responsePromise;
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: false,
needsUserId: true,
});
expect(undiciFetchMock).toHaveBeenCalled();
vi.useRealTimers();
});
it('short-circuits to needsUserId before heavy verification when probe already confirms it', async () => {
verifyTokenMock.mockResolvedValueOnce({
tokenType: 'session',
userInfo: { username: 'should-not-run' },
balance: null,
apiToken: null,
});
undiciFetchMock.mockResolvedValue({
text: async () => JSON.stringify({ success: false, message: 'missing New-Api-User' }),
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
},
});
const site = await db.insert(schema.sites).values({
name: 'Needs User Id Fast',
url: 'https://needs-user-id-fast.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'token-without-user-id',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: false,
needsUserId: true,
});
expect(verifyTokenMock).not.toHaveBeenCalled();
});
it('returns invalidUserId when the provided user id does not match the token', async () => {
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
undiciFetchMock.mockResolvedValue({
text: async () => JSON.stringify({ success: false, message: 'user id mismatch' }),
headers: {
get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json; charset=utf-8' : null),
},
});
const site = await db.insert(schema.sites).values({
name: 'Wrong User Id',
url: 'https://wrong-user-id.example.com',
platform: 'new-api',
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/accounts/verify-token',
payload: {
siteId: site.id,
accessToken: 'token-with-wrong-user-id',
platformUserId: 2,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: false,
invalidUserId: true,
});
expect(response.json()).not.toMatchObject({
needsUserId: true,
});
expect(undiciFetchMock).toHaveBeenCalled();
});
});
@@ -122,4 +122,69 @@ describe('settings database runtime config api', () => {
expect(body.success).toBe(false);
expect(body.message || '').toContain('SQLite');
});
it('saves and loads ssl setting correctly', async () => {
const putResponse = await app.inject({
method: 'PUT',
url: '/api/settings/database/runtime',
payload: {
dialect: 'mysql',
connectionString: 'mysql://root:pass@tidb.example.com:4000/db',
ssl: true,
},
});
expect(putResponse.statusCode).toBe(200);
const putBody = putResponse.json() as {
success?: boolean;
saved?: { dialect?: string; ssl?: boolean };
};
expect(putBody.success).toBe(true);
expect(putBody.saved?.ssl).toBe(true);
const dbSsl = await db.select().from(schema.settings).where(eq(schema.settings.key, 'db_ssl')).get();
expect(dbSsl?.value).toBe(JSON.stringify(true));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/database/runtime',
});
const getBody = getResponse.json() as {
success?: boolean;
saved?: { ssl?: boolean };
};
expect(getBody.success).toBe(true);
expect(getBody.saved?.ssl).toBe(true);
});
it('defaults ssl to false when not provided', async () => {
const putResponse = await app.inject({
method: 'PUT',
url: '/api/settings/database/runtime',
payload: {
dialect: 'postgres',
connectionString: 'postgres://user:pass@db.example.com:5432/mydb',
},
});
expect(putResponse.statusCode).toBe(200);
const putBody = putResponse.json() as {
success?: boolean;
saved?: { ssl?: boolean };
};
expect(putBody.success).toBe(true);
expect(putBody.saved?.ssl).toBe(false);
});
it('includes ssl in active runtime state', async () => {
const response = await app.inject({
method: 'GET',
url: '/api/settings/database/runtime',
});
const body = response.json() as {
active?: { ssl?: boolean };
};
expect(typeof body.active?.ssl).toBe('boolean');
});
});
+15 -2
View File
@@ -50,16 +50,19 @@ interface DatabaseMigrationBody {
dialect?: unknown;
connectionString?: unknown;
overwrite?: unknown;
ssl?: unknown;
}
type RuntimeDatabaseConfig = {
dialect: MigrationDialect;
connectionString: string;
ssl: boolean;
};
const PROXY_TOKEN_PREFIX = 'sk-';
const DB_TYPE_SETTING_KEY = 'db_type';
const DB_URL_SETTING_KEY = 'db_url';
const DB_SSL_SETTING_KEY = 'db_ssl';
function isValidProxyToken(value: string): boolean {
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
@@ -316,6 +319,7 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
const map = new Map(settingsRows.map((row) => [row.key, row.value]));
const rawDialect = parseJsonValue(map.get(DB_TYPE_SETTING_KEY));
const rawConnection = parseJsonValue(map.get(DB_URL_SETTING_KEY));
const rawSsl = parseJsonValue(map.get(DB_SSL_SETTING_KEY));
if (typeof rawDialect !== 'string' || typeof rawConnection !== 'string') {
return null;
}
@@ -324,10 +328,12 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
const normalized = normalizeMigrationInput({
dialect: rawDialect,
connectionString: rawConnection,
ssl: rawSsl,
});
return {
dialect: normalized.dialect,
connectionString: normalized.connectionString,
ssl: normalized.ssl,
};
} catch {
return null;
@@ -337,19 +343,24 @@ async function loadSavedRuntimeDatabaseConfig(): Promise<RuntimeDatabaseConfig |
function buildRuntimeDatabaseState(saved: RuntimeDatabaseConfig | null) {
const activeDialect = runtimeDbDialect;
const activeConnection = (config.dbUrl || '').trim();
const activeSsl = config.dbSsl;
const restartRequired = !!saved && (
saved.dialect !== activeDialect || saved.connectionString.trim() !== activeConnection
saved.dialect !== activeDialect ||
saved.connectionString.trim() !== activeConnection ||
saved.ssl !== activeSsl
);
return {
active: {
dialect: activeDialect,
connection: maskRuntimeConnection(activeDialect, activeConnection),
ssl: activeSsl,
},
saved: saved
? {
dialect: saved.dialect,
connection: maskRuntimeConnection(saved.dialect, saved.connectionString),
ssl: saved.ssl,
}
: null,
restartRequired,
@@ -690,16 +701,18 @@ export async function settingsRoutes(app: FastifyInstance) {
const normalized = normalizeMigrationInput(request.body || {});
await upsertSetting(DB_TYPE_SETTING_KEY, normalized.dialect);
await upsertSetting(DB_URL_SETTING_KEY, normalized.connectionString);
await upsertSetting(DB_SSL_SETTING_KEY, normalized.ssl);
await appendSettingsEvent({
type: 'status',
title: '数据库运行配置已更新',
message: `已保存运行数据库配置:${normalized.dialect}(重启后生效)`,
message: `已保存运行数据库配置:${normalized.dialect}${normalized.ssl ? ' (SSL)' : ''}(重启后生效)`,
});
const saved: RuntimeDatabaseConfig = {
dialect: normalized.dialect,
connectionString: normalized.connectionString,
ssl: normalized.ssl,
};
return {
@@ -20,6 +20,21 @@ describe('accountHealthService', () => {
expect(health.state).toBe('unhealthy');
});
it('does not reuse expired session-token health for proxy-only accounts', () => {
const health = buildRuntimeHealthForAccount({
accountStatus: 'expired',
siteStatus: 'active',
extraConfig: null,
sessionCapable: false,
});
expect(health).toMatchObject({
state: 'unknown',
source: 'none',
});
expect(health.reason).not.toContain('访问令牌已过期');
});
it('returns stored runtime health from extra config when available', () => {
const health = buildRuntimeHealthForAccount({
accountStatus: 'active',
+2 -1
View File
@@ -86,6 +86,7 @@ export function buildRuntimeHealthForAccount(input: {
accountStatus?: string | null;
siteStatus?: string | null;
extraConfig?: string | null;
sessionCapable?: boolean;
}): RuntimeHealthInfo {
const accountStatus = (input.accountStatus || 'active').toLowerCase();
const siteStatus = (input.siteStatus || 'active').toLowerCase();
@@ -99,7 +100,7 @@ export function buildRuntimeHealthForAccount(input: {
};
}
if (accountStatus === 'expired') {
if (accountStatus === 'expired' && input.sessionCapable !== false) {
return {
state: 'unhealthy',
reason: '访问令牌已过期',
@@ -13,6 +13,7 @@ describe('databaseMigrationService', () => {
dialect: 'postgres',
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
overwrite: true,
ssl: false,
});
});
@@ -24,6 +25,7 @@ describe('databaseMigrationService', () => {
expect(normalized.dialect).toBe('mysql');
expect(normalized.overwrite).toBe(true);
expect(normalized.ssl).toBe(false);
});
it('accepts sqlite file migration target path', () => {
@@ -37,6 +39,7 @@ describe('databaseMigrationService', () => {
dialect: 'sqlite',
connectionString: './data/target.db',
overwrite: false,
ssl: false,
});
});
@@ -58,5 +61,40 @@ describe('databaseMigrationService', () => {
const masked = maskConnectionString('postgres://admin:super-secret@db.example.com:5432/metapi');
expect(masked).toBe('postgres://admin:***@db.example.com:5432/metapi');
});
it('normalizes ssl boolean from input', () => {
const normalized = normalizeMigrationInput({
dialect: 'mysql',
connectionString: 'mysql://user:pass@tidb.example.com:4000/db',
ssl: true,
});
expect(normalized.ssl).toBe(true);
});
it('defaults ssl to false when not provided', () => {
const normalized = normalizeMigrationInput({
dialect: 'postgres',
connectionString: 'postgres://user:pass@db.example.com:5432/metapi',
});
expect(normalized.ssl).toBe(false);
});
it('parses ssl from string values', () => {
const normalized = normalizeMigrationInput({
dialect: 'mysql',
connectionString: 'mysql://user:pass@host:3306/db',
ssl: '1',
});
expect(normalized.ssl).toBe(true);
});
it('parses ssl false from string "0"', () => {
const normalized = normalizeMigrationInput({
dialect: 'mysql',
connectionString: 'mysql://user:pass@host:3306/db',
ssl: '0',
});
expect(normalized.ssl).toBe(false);
});
});
@@ -11,12 +11,14 @@ export interface DatabaseMigrationInput {
dialect?: unknown;
connectionString?: unknown;
overwrite?: unknown;
ssl?: unknown;
}
export interface NormalizedDatabaseMigrationInput {
dialect: MigrationDialect;
connectionString: string;
overwrite: boolean;
ssl: boolean;
}
type BackupSnapshot = {
@@ -173,6 +175,7 @@ export function normalizeMigrationInput(input: DatabaseMigrationInput): Normaliz
dialect,
connectionString,
overwrite: input.overwrite === undefined ? true : asBoolean(input.overwrite, true),
ssl: asBoolean(input.ssl, false),
};
}
@@ -224,8 +227,12 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
};
}
async function createPostgresClient(connectionString: string): Promise<SqlClient> {
const client = new pg.Client({ connectionString });
async function createPostgresClient(connectionString: string, ssl: boolean): Promise<SqlClient> {
const clientOptions: pg.ClientConfig = { connectionString };
if (ssl) {
clientOptions.ssl = { rejectUnauthorized: false };
}
const client = new pg.Client(clientOptions);
await client.connect();
return {
@@ -244,8 +251,12 @@ async function createPostgresClient(connectionString: string): Promise<SqlClient
};
}
async function createMySqlClient(connectionString: string): Promise<SqlClient> {
const connection = await mysql.createConnection(connectionString);
async function createMySqlClient(connectionString: string, ssl: boolean): Promise<SqlClient> {
const connectionOptions: mysql.ConnectionOptions = { uri: connectionString };
if (ssl) {
connectionOptions.ssl = { rejectUnauthorized: false };
}
const connection = await mysql.createConnection(connectionOptions);
return {
dialect: 'mysql',
@@ -291,8 +302,8 @@ async function createSqliteClient(connectionString: string): Promise<SqlClient>
}
async function createClient(input: NormalizedDatabaseMigrationInput): Promise<SqlClient> {
if (input.dialect === 'postgres') return createPostgresClient(input.connectionString);
if (input.dialect === 'mysql') return createMySqlClient(input.connectionString);
if (input.dialect === 'postgres') return createPostgresClient(input.connectionString, input.ssl);
if (input.dialect === 'mysql') return createMySqlClient(input.connectionString, input.ssl);
return createSqliteClient(input.connectionString);
}