Merge remote-tracking branch 'origin/main' into feat/account-level-proxy

# Conflicts:
#	src/server/routes/proxy/completions.ts
#	src/server/services/siteProxy.ts
This commit is contained in:
Hureru
2026-03-19 00:00:10 +08:00
20 changed files with 1747 additions and 137 deletions
@@ -12,6 +12,8 @@ const createApiTokenMock = vi.fn();
const getUserGroupsMock = vi.fn();
const deleteApiTokenMock = vi.fn();
type AccountTokenServiceModule = typeof import('../../services/accountTokenService.js');
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
@@ -28,6 +30,7 @@ describe('account tokens sync routes with site status', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let maskToken: AccountTokenServiceModule['maskToken'];
let dataDir = '';
let seedId = 0;
@@ -63,9 +66,11 @@ describe('account tokens sync routes with site status', () => {
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const accountTokenServiceModule = await import('../../services/accountTokenService.js');
const routesModule = await import('./accountTokens.js');
db = dbModule.db;
schema = dbModule.schema;
maskToken = accountTokenServiceModule.maskToken;
app = Fastify();
await app.register(routesModule.accountTokensRoutes);
@@ -196,6 +201,247 @@ describe('account tokens sync routes with site status', () => {
]);
});
it('reuses an existing ready token when upstream only returns the matching masked token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const fullToken = 'sk-real-token-1234';
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: fullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: maskToken(fullToken), enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 1,
maskedPending: 0,
total: 1,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: fullToken,
source: 'sync',
enabled: true,
isDefault: true,
tokenGroup: 'default',
});
expect((tokenRows[0] as any).valueStatus).toBe('ready');
});
it('removes matching masked_pending placeholders after reusing a ready token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const fullToken = 'sk-real-token-1234';
const maskedToken = maskToken(fullToken);
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: fullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: maskedToken,
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
valueStatus: 'masked_pending' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: maskedToken, enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 1,
maskedPending: 0,
total: 1,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: fullToken,
source: 'sync',
enabled: true,
isDefault: true,
tokenGroup: 'default',
});
expect((tokenRows[0] as any).valueStatus).toBe('ready');
});
it('does not reuse a different ready token when another logical token shares the same masked value', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const firstFullToken = 'sk-real-token-1234';
const secondFullToken = 'sk-real-zzzzz-1234';
const sharedMaskedToken = maskToken(firstFullToken);
expect(maskToken(secondFullToken)).toBe(sharedMaskedToken);
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'first-token',
token: firstFullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'second-token',
token: sharedMaskedToken,
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
valueStatus: 'masked_pending' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'second-token', key: sharedMaskedToken, enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 1,
maskedPending: 1,
total: 2,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(2);
expect(tokenRows.find((row) => row.token === firstFullToken)).toMatchObject({
name: 'first-token',
token: firstFullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
});
const maskedRow = tokenRows.find((row) => row.name === 'second-token');
expect(maskedRow).toMatchObject({
token: sharedMaskedToken,
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
});
expect((maskedRow as any)?.valueStatus).toBe('masked_pending');
});
it('keeps fully ambiguous short masks as masked_pending instead of reusing a ready token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const fullToken = 'sk-abcd';
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'short-token',
token: fullToken,
source: 'manual',
enabled: true,
isDefault: true,
tokenGroup: 'default',
valueStatus: 'ready' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'short-token', key: maskToken(fullToken), enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
reason: 'upstream_masked_tokens',
created: 1,
updated: 0,
maskedPending: 1,
total: 2,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(2);
expect(tokenRows.find((row) => row.token === fullToken)).toBeDefined();
const maskedRow = tokenRows.find((row) => row.token === 'sk-***');
expect(maskedRow).toMatchObject({
name: 'short-token',
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
});
expect((maskedRow as any)?.valueStatus).toBe('masked_pending');
});
it('rejects sync and token management for apikey connections', async () => {
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
await db.update(schema.accounts)
@@ -273,6 +273,60 @@ describe('settings and auth events', () => {
expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890');
});
it('splits proxy error keywords on newlines and commas when saving runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
proxyErrorKeywords: 'quota exceeded\nbad gateway,too many requests',
proxyEmptyContentFailEnabled: true,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as {
proxyErrorKeywords?: string[];
proxyEmptyContentFailEnabled?: boolean;
};
expect(updated.proxyErrorKeywords).toEqual([
'quota exceeded',
'bad gateway',
'too many requests',
]);
expect(updated.proxyEmptyContentFailEnabled).toBe(true);
expect(config.proxyErrorKeywords).toEqual([
'quota exceeded',
'bad gateway',
'too many requests',
]);
expect(config.proxyEmptyContentFailEnabled).toBe(true);
const rows = await db.select().from(schema.settings).all();
const settingsMap = new Map(rows.map((row) => [row.key, row.value]));
expect(settingsMap.get('proxy_error_keywords')).toBe(JSON.stringify([
'quota exceeded',
'bad gateway',
'too many requests',
]));
expect(settingsMap.get('proxy_empty_content_fail_enabled')).toBe(JSON.stringify(true));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as {
proxyErrorKeywords?: string[];
proxyEmptyContentFailEnabled?: boolean;
};
expect(runtime.proxyErrorKeywords).toEqual([
'quota exceeded',
'bad gateway',
'too many requests',
]);
expect(runtime.proxyEmptyContentFailEnabled).toBe(true);
});
it('persists and returns log cleanup settings from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
+83
View File
@@ -55,6 +55,8 @@ interface RuntimeSettingsBody {
adminIpAllowlist?: string[] | string;
routingFallbackUnitCost?: number;
routingWeights?: Partial<RoutingWeights>;
proxyErrorKeywords?: string[] | string;
proxyEmptyContentFailEnabled?: boolean;
}
interface DatabaseMigrationBody {
@@ -127,6 +129,33 @@ function toStringList(value: unknown): string[] {
return [];
}
function parseProxyErrorKeywords(value: unknown): string[] {
const splitKeywords = (input: string): string[] => input
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter((item) => item.length > 0);
if (Array.isArray(value)) {
const keywords = value.flatMap((item) => {
if (typeof item !== 'string') return [];
return splitKeywords(item);
});
return keywords;
}
if (typeof value === 'string') {
const keywords = splitKeywords(value);
return keywords;
}
throw new Error('上游错误关键词格式无效:需要 string 或 string[]');
}
function parseBooleanFlag(value: unknown, label: string): boolean {
if (typeof value === 'boolean') return value;
throw new Error(`${label}格式无效:需要 boolean`);
}
function isValidHttpUrl(raw: string): boolean {
const value = String(raw || '').trim();
if (!value) return false;
@@ -197,6 +226,22 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
return;
}
case 'proxy_error_keywords': {
try {
config.proxyErrorKeywords = parseProxyErrorKeywords(value);
} catch {
return;
}
return;
}
case 'proxy_empty_content_fail_enabled': {
try {
config.proxyEmptyContentFailEnabled = parseBooleanFlag(value, '空内容判定失败开关');
} catch {
return;
}
return;
}
case 'webhook_url': {
if (typeof value !== 'string') return;
config.webhookUrl = value.trim();
@@ -347,6 +392,8 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
adminIpAllowlist: config.adminIpAllowlist,
currentAdminIp,
systemProxyUrl: config.systemProxyUrl,
proxyErrorKeywords: config.proxyErrorKeywords,
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
proxyTokenMasked: maskSecret(config.proxyToken),
};
}
@@ -603,6 +650,42 @@ export async function settingsRoutes(app: FastifyInstance) {
invalidateSiteProxyCache();
}
if (body.proxyErrorKeywords !== undefined) {
let nextKeywords: string[] = [];
try {
nextKeywords = parseProxyErrorKeywords(body.proxyErrorKeywords);
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '上游错误关键词格式无效',
});
}
if (JSON.stringify(nextKeywords) !== JSON.stringify(config.proxyErrorKeywords || [])) {
changedLabels.push('上游错误关键词');
}
config.proxyErrorKeywords = nextKeywords;
upsertSetting('proxy_error_keywords', config.proxyErrorKeywords);
}
if (body.proxyEmptyContentFailEnabled !== undefined) {
let nextValue = false;
try {
nextValue = parseBooleanFlag(body.proxyEmptyContentFailEnabled, '空内容判定失败开关');
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '空内容判定失败开关格式无效',
});
}
if (nextValue !== config.proxyEmptyContentFailEnabled) {
changedLabels.push('空内容判定失败');
}
config.proxyEmptyContentFailEnabled = nextValue;
upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled);
}
if (body.webhookUrl !== undefined) {
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
changedLabels.push('Webhook 地址');