Merge origin/main into codex/single-source-consolidation

This commit is contained in:
Cita
2026-03-23 14:44:52 +08:00
21 changed files with 1409 additions and 224 deletions
+55
View File
@@ -5,6 +5,7 @@ import { config } from '../../config.js';
import { db, runtimeDbDialect, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js';
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
import { getAllBrandNames } from '../../services/brandMatcher.js';
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { sendNotification } from '../../services/notifyService.js';
import {
@@ -71,6 +72,7 @@ interface RuntimeSettingsBody {
routingWeights?: Partial<RoutingWeights>;
proxyErrorKeywords?: string[] | string;
proxyEmptyContentFailEnabled?: boolean;
globalBlockedBrands?: string[];
}
interface DatabaseMigrationBody {
@@ -395,6 +397,29 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
}
return;
}
case 'global_blocked_brands': {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (Array.isArray(parsed)) {
const nextBrands = parsed.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean);
const prev = JSON.stringify(config.globalBlockedBrands);
config.globalBlockedBrands = nextBrands;
if (prev !== JSON.stringify(nextBrands)) {
startBackgroundTask(
{
type: 'maintenance',
title: '品牌屏蔽变更后重建路由',
dedupeKey: 'refresh-models-and-rebuild-routes',
},
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
);
}
}
} catch {
return;
}
return;
}
case 'webhook_url': {
if (typeof value !== 'string') return;
config.webhookUrl = value.trim();
@@ -562,6 +587,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
proxyErrorKeywords: config.proxyErrorKeywords,
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
proxyTokenMasked: maskSecret(config.proxyToken),
globalBlockedBrands: config.globalBlockedBrands,
};
}
@@ -639,6 +665,10 @@ export async function settingsRoutes(app: FastifyInstance) {
return getRuntimeSettingsResponse(currentAdminIp);
});
app.get('/api/settings/brand-list', async () => {
return { brands: getAllBrandNames() };
});
app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
const rawProxyUrl = request.body?.proxyUrl === undefined
? config.systemProxyUrl
@@ -946,6 +976,31 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('proxy_empty_content_fail_enabled', config.proxyEmptyContentFailEnabled);
}
if (body.globalBlockedBrands !== undefined) {
if (!Array.isArray(body.globalBlockedBrands)) {
return reply.code(400).send({ error: 'globalBlockedBrands must be an array of strings' });
}
const nextBrands = body.globalBlockedBrands.filter((b): b is string => typeof b === 'string').map((b) => b.trim()).filter(Boolean);
const uniqueBrands = Array.from(new Set(nextBrands));
const prev = JSON.stringify(config.globalBlockedBrands);
const next = JSON.stringify(uniqueBrands);
if (prev !== next) {
changedLabels.push('全局品牌屏蔽');
}
config.globalBlockedBrands = uniqueBrands;
upsertSetting('global_blocked_brands', JSON.stringify(uniqueBrands));
if (prev !== next) {
startBackgroundTask(
{
type: 'maintenance',
title: '品牌屏蔽变更后重建路由',
dedupeKey: 'refresh-models-and-rebuild-routes',
},
async () => routeRefreshWorkflow.refreshModelsAndRebuildRoutes(),
);
}
}
if (body.webhookUrl !== undefined) {
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
changedLabels.push('Webhook 地址');
@@ -70,6 +70,32 @@ describe('sites proxy settings', () => {
expect(payload.globalWeight).toBe(1.5);
});
it('returns a conflict response when the same platform and url already exist', async () => {
const first = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'existing-site',
url: 'https://duplicate-site.example.com/',
platform: 'new-api',
},
});
expect(first.statusCode).toBe(200);
const duplicate = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'duplicate-site',
url: 'https://duplicate-site.example.com',
platform: 'new-api',
},
});
expect(duplicate.statusCode).toBe(409);
expect((duplicate.json() as { error?: string }).error).toContain('already exists');
});
it('rejects invalid useSystemProxy flag', async () => {
const response = await app.inject({
method: 'POST',
@@ -163,6 +189,43 @@ describe('sites proxy settings', () => {
expect(payload.useSystemProxy).toBe(true);
});
it('returns a conflict response when updating a site to an existing platform and url', async () => {
const first = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'first-site',
url: 'https://first-site.example.com',
platform: 'new-api',
},
});
expect(first.statusCode).toBe(200);
const second = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'second-site',
url: 'https://second-site.example.com',
platform: 'new-api',
},
});
expect(second.statusCode).toBe(200);
const { id } = second.json() as { id: number };
const response = await app.inject({
method: 'PUT',
url: `/api/sites/${id}`,
payload: {
url: 'https://first-site.example.com/',
platform: 'new-api',
},
});
expect(response.statusCode).toBe(409);
expect((response.json() as { error?: string }).error).toContain('already exists');
});
it('rejects invalid custom headers json', async () => {
const response = await app.inject({
method: 'POST',
+154 -16
View File
@@ -1,4 +1,4 @@
import { FastifyInstance } from 'fastify';
import { FastifyInstance, FastifyReply } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
@@ -76,6 +76,64 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
}
type ErrorLike = {
message?: string;
code?: string | number;
cause?: unknown;
};
function normalizeSiteUrl(url: string): string {
return url.replace(/\/+$/, '');
}
function getErrorChain(error: unknown): ErrorLike[] {
const chain: ErrorLike[] = [];
const seen = new Set<unknown>();
let current: unknown = error;
while (current && typeof current === 'object' && !seen.has(current)) {
seen.add(current);
chain.push(current as ErrorLike);
current = (current as ErrorLike).cause;
}
return chain;
}
function isSitesPlatformUrlConflict(error: unknown): boolean {
return getErrorChain(error).some((entry) => {
const message = String(entry.message || '');
const lowered = message.toLowerCase();
const code = String(entry.code || '');
const isSqliteSitesUnique = (
(code === 'SQLITE_CONSTRAINT' || code === 'SQLITE_CONSTRAINT_UNIQUE')
&& lowered.includes('unique constraint failed: sites.platform, sites.url')
);
return (code === '23505' && lowered.includes('sites_platform_url_unique'))
|| (code === 'ER_DUP_ENTRY' && lowered.includes('sites_platform_url_unique'))
|| isSqliteSitesUnique
|| (lowered.includes('duplicate key value violates unique constraint') && lowered.includes('sites_platform_url_unique'))
|| (lowered.includes('duplicate entry') && lowered.includes('sites_platform_url_unique'));
});
}
function findExistingSiteBinding(
siteRows: Array<{ id: number; url: string; platform: string }>,
platform: string,
normalizedUrl: string,
excludedSiteId?: number,
) {
return siteRows.find((site) => (
site.platform === platform
&& site.url === normalizedUrl
&& site.id !== excludedSiteId
));
}
function sendSiteBindingConflict(reply: FastifyReply, platform: string, normalizedUrl: string) {
return reply.code(409).send({
error: `A ${platform} site with URL ${normalizedUrl} already exists.`,
});
}
type SiteSubscriptionAggregate = {
activeCount: number;
totalUsedUsd: number;
@@ -267,6 +325,7 @@ export async function sitesRoutes(app: FastifyInstance) {
const existingSites = await db.select().from(schema.sites).all();
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
const normalizedUrl = normalizeSiteUrl(url);
let detectedPlatform = platform;
if (!detectedPlatform) {
@@ -276,19 +335,32 @@ export async function sitesRoutes(app: FastifyInstance) {
if (!detectedPlatform) {
return { error: 'Could not detect platform. Please specify manually.' };
}
const inserted = await db.insert(schema.sites).values({
name,
url: url.replace(/\/+$/, ''),
platform: detectedPlatform,
proxyUrl: normalizedProxyUrl.proxyUrl,
useSystemProxy: normalizedUseSystemProxy ?? false,
customHeaders: normalizedCustomHeaders.customHeaders,
externalCheckinUrl: normalizedExternalCheckinUrl.url,
status: normalizedStatus ?? 'active',
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
globalWeight: normalizedGlobalWeight ?? 1,
}).run();
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl);
if (conflictingSite) {
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
}
let inserted;
try {
inserted = await db.insert(schema.sites).values({
name,
url: normalizedUrl,
platform: detectedPlatform,
proxyUrl: normalizedProxyUrl.proxyUrl,
useSystemProxy: normalizedUseSystemProxy ?? false,
customHeaders: normalizedCustomHeaders.customHeaders,
externalCheckinUrl: normalizedExternalCheckinUrl.url,
status: normalizedStatus ?? 'active',
isPinned: normalizedPinned ?? false,
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
globalWeight: normalizedGlobalWeight ?? 1,
}).run();
} catch (error) {
if (isSitesPlatformUrlConflict(error)) {
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
}
throw error;
}
const siteId = Number(inserted.lastInsertRowid || 0);
if (siteId <= 0) {
return reply.code(500).send({ error: 'Create site failed' });
@@ -360,8 +432,23 @@ export async function sitesRoutes(app: FastifyInstance) {
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
}
const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url;
const nextPlatform = body.platform !== undefined ? body.platform : existingSite.platform;
const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform;
if (siteIdentityChanged) {
const siteRows = await db.select({
id: schema.sites.id,
url: schema.sites.url,
platform: schema.sites.platform,
}).from(schema.sites).all();
const conflictingSite = findExistingSiteBinding(siteRows, nextPlatform, nextUrl, id);
if (conflictingSite) {
return sendSiteBindingConflict(reply, nextPlatform, nextUrl);
}
}
if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.url !== undefined) updates.url = nextUrl;
if (body.platform !== undefined) updates.platform = body.platform;
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
@@ -372,7 +459,14 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
updates.updatedAt = new Date().toISOString();
await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
try {
await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
} catch (error) {
if (isSitesPlatformUrlConflict(error)) {
return sendSiteBindingConflict(reply, nextPlatform, nextUrl);
}
throw error;
}
if (body.status !== undefined && normalizedStatus) {
await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
@@ -497,6 +591,50 @@ export async function sitesRoutes(app: FastifyInstance) {
return { siteId: id, models: uniqueModels };
});
// Get all discovered models for a site (from model_availability and token_model_availability)
app.get<{ Params: { id: string } }>('/api/sites/:id/available-models', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
}
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
if (!existingSite) {
return reply.code(404).send({ error: 'Site not found' });
}
// Get models from model_availability (account-level)
const accountModels = await db.select({ modelName: schema.modelAvailability.modelName })
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.where(
and(
eq(schema.accounts.siteId, id),
eq(schema.modelAvailability.available, true),
),
)
.all();
// Get models from token_model_availability (token-level)
const tokenModels = await db.select({ modelName: schema.tokenModelAvailability.modelName })
.from(schema.tokenModelAvailability)
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.where(
and(
eq(schema.accounts.siteId, id),
eq(schema.tokenModelAvailability.available, true),
),
)
.all();
const models = Array.from(new Set([
...accountModels.map((r) => r.modelName.trim()),
...tokenModels.map((r) => r.modelName.trim()),
])).filter((m) => m.length > 0).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
return { siteId: id, models };
});
// Detect platform for a URL
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
const result = await detectSite(request.body.url);