Add zod payload contracts for admin API routes (#358)
* Add zod route payload contracts for admin APIs * Fix review follow-ups for zod admin payloads * Refactor account payload parser helpers
This commit is contained in:
Generated
+11
-1
@@ -31,7 +31,8 @@
|
||||
"pg": "^8.16.3",
|
||||
"socks": "^2.8.7",
|
||||
"undici": "^6.24.1",
|
||||
"ws": "^8.20.0"
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "^3.1.0",
|
||||
@@ -17177,6 +17178,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
+2
-1
@@ -88,7 +88,8 @@
|
||||
"pg": "^8.16.3",
|
||||
"socks": "^2.8.7",
|
||||
"undici": "^6.24.1",
|
||||
"ws": "^8.20.0"
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron/notarize": "^3.1.0",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const accountTokenCreatePayloadSchema = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
name: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
source: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
unlimitedQuota: z.boolean().optional(),
|
||||
remainQuota: z.union([z.number(), z.string()]).optional(),
|
||||
expiredTime: z.union([z.number(), z.string()]).optional(),
|
||||
allowIps: z.string().optional(),
|
||||
modelLimitsEnabled: z.boolean().optional(),
|
||||
modelLimits: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountTokenBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountTokenUpdatePayloadSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
group: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
source: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountTokenSyncAllPayloadSchema = z.object({
|
||||
wait: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AccountTokenBatchPayload = z.output<typeof accountTokenBatchPayloadSchema>;
|
||||
export type AccountTokenCreatePayload = z.output<typeof accountTokenCreatePayloadSchema>;
|
||||
export type AccountTokenSyncAllPayload = z.output<typeof accountTokenSyncAllPayloadSchema>;
|
||||
export type AccountTokenUpdatePayload = z.output<typeof accountTokenUpdatePayloadSchema>;
|
||||
|
||||
function normalizeAccountTokenPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatAccountTokenPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'accountId') {
|
||||
return 'Invalid accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'token') {
|
||||
return 'Invalid token. Expected string.';
|
||||
}
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected string.';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'isDefault') {
|
||||
return 'Invalid isDefault. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'source') {
|
||||
return 'Invalid source. Expected string.';
|
||||
}
|
||||
if (firstPath === 'group') {
|
||||
return 'Invalid group. Expected string.';
|
||||
}
|
||||
if (firstPath === 'unlimitedQuota') {
|
||||
return 'Invalid unlimitedQuota. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'modelLimitsEnabled') {
|
||||
return 'Invalid modelLimitsEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'wait') {
|
||||
return 'Invalid wait. Expected boolean.';
|
||||
}
|
||||
return 'Invalid account token payload.';
|
||||
}
|
||||
|
||||
export function parseAccountTokenCreatePayload(input: unknown):
|
||||
{ success: true; data: AccountTokenCreatePayload } | { success: false; error: string } {
|
||||
const result = accountTokenCreatePayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountTokenBatchPayload(input: unknown):
|
||||
{ success: true; data: AccountTokenBatchPayload } | { success: false; error: string } {
|
||||
const result = accountTokenBatchPayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountTokenUpdatePayload(input: unknown):
|
||||
{ success: true; data: AccountTokenUpdatePayload } | { success: false; error: string } {
|
||||
const result = accountTokenUpdatePayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountTokenSyncAllPayload(input: unknown):
|
||||
{ success: true; data: AccountTokenSyncAllPayload } | { success: false; error: string } {
|
||||
const result = accountTokenSyncAllPayloadSchema.safeParse(normalizeAccountTokenPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountTokenPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const accountCredentialModeSchema = z.enum(['auto', 'session', 'apikey']);
|
||||
|
||||
const accountCreatePayloadSchema = z.object({
|
||||
siteId: z.number().int().positive(),
|
||||
username: z.string().optional(),
|
||||
accessToken: z.string(),
|
||||
apiToken: z.string().optional(),
|
||||
platformUserId: z.number().int().positive().optional(),
|
||||
checkinEnabled: z.boolean().optional(),
|
||||
credentialMode: accountCredentialModeSchema.optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
tokenExpiresAt: z.union([z.number(), z.string()]).optional(),
|
||||
skipModelFetch: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountUpdatePayloadSchema = z.object({
|
||||
username: z.string().optional(),
|
||||
accessToken: z.string().optional(),
|
||||
apiToken: z.union([z.string(), z.null()]).optional(),
|
||||
status: z.string().optional(),
|
||||
checkinEnabled: z.boolean().optional(),
|
||||
unitCost: z.number().optional(),
|
||||
extraConfig: z.union([z.string(), z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
tokenExpiresAt: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
isPinned: z.boolean().optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountRebindSessionPayloadSchema = z.object({
|
||||
accessToken: z.string().optional(),
|
||||
platformUserId: z.number().int().positive().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
tokenExpiresAt: z.union([z.number(), z.string()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountHealthRefreshPayloadSchema = z.object({
|
||||
accountId: z.number().int().positive().optional(),
|
||||
wait: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountLoginPayloadSchema = z.object({
|
||||
siteId: z.number().int().positive(),
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
const accountVerifyTokenPayloadSchema = z.object({
|
||||
siteId: z.number().int().positive(),
|
||||
accessToken: z.string().optional(),
|
||||
platformUserId: z.number().int().positive().optional(),
|
||||
credentialMode: accountCredentialModeSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const accountManualModelsPayloadSchema = z.object({
|
||||
models: z.array(z.string()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AccountBatchPayload = z.output<typeof accountBatchPayloadSchema>;
|
||||
export type AccountCreatePayload = z.output<typeof accountCreatePayloadSchema>;
|
||||
export type AccountHealthRefreshPayload = z.output<typeof accountHealthRefreshPayloadSchema>;
|
||||
export type AccountLoginPayload = z.output<typeof accountLoginPayloadSchema>;
|
||||
export type AccountManualModelsPayload = z.output<typeof accountManualModelsPayloadSchema>;
|
||||
export type AccountRebindSessionPayload = z.output<typeof accountRebindSessionPayloadSchema>;
|
||||
export type AccountUpdatePayload = z.output<typeof accountUpdatePayloadSchema>;
|
||||
export type AccountVerifyTokenPayload = z.output<typeof accountVerifyTokenPayloadSchema>;
|
||||
|
||||
function normalizeAccountsPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatAccountsPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'siteId') {
|
||||
return 'Invalid siteId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'accessToken') {
|
||||
return 'Invalid accessToken. Expected string.';
|
||||
}
|
||||
if (firstPath === 'username') {
|
||||
return 'Invalid username. Expected string.';
|
||||
}
|
||||
if (firstPath === 'password') {
|
||||
return 'Invalid password. Expected string.';
|
||||
}
|
||||
if (firstPath === 'apiToken') {
|
||||
return 'Invalid apiToken. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'checkinEnabled') {
|
||||
return 'Invalid checkinEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'credentialMode') {
|
||||
return 'Invalid credentialMode. Expected auto/session/apikey.';
|
||||
}
|
||||
if (firstPath === 'skipModelFetch') {
|
||||
return 'Invalid skipModelFetch. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'isPinned') {
|
||||
return 'Invalid isPinned. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'sortOrder') {
|
||||
return 'Invalid sortOrder. Expected non-negative integer.';
|
||||
}
|
||||
if (firstPath === 'proxyUrl') {
|
||||
return 'Invalid proxyUrl. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'platformUserId') {
|
||||
return 'Invalid platformUserId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'refreshToken') {
|
||||
return 'Invalid refreshToken. Expected string.';
|
||||
}
|
||||
if (firstPath === 'tokenExpiresAt') {
|
||||
return 'Invalid tokenExpiresAt. Expected number or string.';
|
||||
}
|
||||
if (firstPath === 'accountId') {
|
||||
return '账号 ID 无效';
|
||||
}
|
||||
if (firstPath === 'wait') {
|
||||
return 'Invalid wait. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'models') {
|
||||
return 'Invalid models. Expected string[].';
|
||||
}
|
||||
return 'Invalid account payload.';
|
||||
}
|
||||
|
||||
function parseAccountsPayload<T>(schema: z.ZodType<T>, input: unknown):
|
||||
{ success: true; data: T } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeAccountsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatAccountsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAccountCreatePayload(input: unknown):
|
||||
{ success: true; data: AccountCreatePayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountUpdatePayload(input: unknown):
|
||||
{ success: true; data: AccountUpdatePayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountBatchPayload(input: unknown):
|
||||
{ success: true; data: AccountBatchPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountBatchPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountRebindSessionPayload(input: unknown):
|
||||
{ success: true; data: AccountRebindSessionPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountRebindSessionPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountHealthRefreshPayload(input: unknown):
|
||||
{ success: true; data: AccountHealthRefreshPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountHealthRefreshPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountLoginPayload(input: unknown):
|
||||
{ success: true; data: AccountLoginPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountLoginPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountVerifyTokenPayload(input: unknown):
|
||||
{ success: true; data: AccountVerifyTokenPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountVerifyTokenPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseAccountManualModelsPayload(input: unknown):
|
||||
{ success: true; data: AccountManualModelsPayload } | { success: false; error: string } {
|
||||
return parseAccountsPayload(accountManualModelsPayloadSchema, input);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const downstreamApiKeyPayloadSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
description: z.union([z.string(), z.null()]).optional(),
|
||||
groupName: z.union([z.string(), z.null()]).optional(),
|
||||
tags: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
expiresAt: z.union([z.string(), z.null()]).optional(),
|
||||
maxCost: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
maxRequests: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
supportedModels: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
allowedRouteIds: z.union([z.string(), z.array(z.union([z.number(), z.string()]))]).optional(),
|
||||
siteWeightMultipliers: z.union([
|
||||
z.string(),
|
||||
z.record(z.string(), z.union([z.number(), z.string()])),
|
||||
]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const downstreamApiKeyBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
groupOperation: z.string().optional(),
|
||||
groupName: z.union([z.string(), z.null()]).optional(),
|
||||
tagOperation: z.string().optional(),
|
||||
tags: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type DownstreamApiKeyBatchPayload = z.output<typeof downstreamApiKeyBatchPayloadSchema>;
|
||||
export type DownstreamApiKeyPayload = z.output<typeof downstreamApiKeyPayloadSchema>;
|
||||
|
||||
function normalizeDownstreamApiKeyPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatDownstreamApiKeyPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const [firstPath] = firstIssue?.path ?? [];
|
||||
if (!firstPath) {
|
||||
return '参数无效:请求体必须是对象';
|
||||
}
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected string.';
|
||||
}
|
||||
if (firstPath === 'key') {
|
||||
return 'Invalid key. Expected string.';
|
||||
}
|
||||
if (firstPath === 'description') {
|
||||
return 'Invalid description. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'groupName') {
|
||||
return 'Invalid groupName. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'tags') {
|
||||
return 'Invalid tags. Expected string or string[].';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'expiresAt') {
|
||||
return 'Invalid expiresAt. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'maxCost') {
|
||||
return 'Invalid maxCost. Expected number, string, or null.';
|
||||
}
|
||||
if (firstPath === 'maxRequests') {
|
||||
return 'Invalid maxRequests. Expected number, string, or null.';
|
||||
}
|
||||
if (firstPath === 'supportedModels') {
|
||||
return 'Invalid supportedModels. Expected string or string[].';
|
||||
}
|
||||
if (firstPath === 'allowedRouteIds') {
|
||||
return 'Invalid allowedRouteIds. Expected string or array.';
|
||||
}
|
||||
if (firstPath === 'siteWeightMultipliers') {
|
||||
return 'Invalid siteWeightMultipliers. Expected JSON object or string.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'groupOperation') {
|
||||
return 'Invalid groupOperation. Expected string.';
|
||||
}
|
||||
if (firstPath === 'tagOperation') {
|
||||
return 'Invalid tagOperation. Expected string.';
|
||||
}
|
||||
return '参数无效';
|
||||
}
|
||||
|
||||
function parseDownstreamApiKeyRoutePayload<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
input: unknown,
|
||||
): { success: true; data: z.output<T> } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeDownstreamApiKeyPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatDownstreamApiKeyPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDownstreamApiKeyPayload(input: unknown):
|
||||
{ success: true; data: DownstreamApiKeyPayload } | { success: false; error: string } {
|
||||
return parseDownstreamApiKeyRoutePayload(downstreamApiKeyPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseDownstreamApiKeyBatchPayload(input: unknown):
|
||||
{ success: true; data: DownstreamApiKeyBatchPayload } | { success: false; error: string } {
|
||||
return parseDownstreamApiKeyRoutePayload(downstreamApiKeyBatchPayloadSchema, input);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const backupExportTypeSchema = z.enum(['all', 'accounts', 'preferences']);
|
||||
const migrationDialectSchema = z.enum(['sqlite', 'mysql', 'postgres']);
|
||||
|
||||
const runtimeSettingsPayloadSchema = z.object({
|
||||
webhookEnabled: z.boolean().optional(),
|
||||
barkEnabled: z.boolean().optional(),
|
||||
serverChanEnabled: z.boolean().optional(),
|
||||
telegramEnabled: z.boolean().optional(),
|
||||
telegramUseSystemProxy: z.boolean().optional(),
|
||||
smtpEnabled: z.boolean().optional(),
|
||||
smtpSecure: z.boolean().optional(),
|
||||
logCleanupUsageLogsEnabled: z.boolean().optional(),
|
||||
logCleanupProgramLogsEnabled: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const systemProxyTestPayloadSchema = z.object({
|
||||
proxyUrl: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const databaseMigrationPayloadSchema = z.object({
|
||||
dialect: migrationDialectSchema,
|
||||
connectionString: z.string().trim().min(1),
|
||||
overwrite: z.boolean().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const backupWebdavConfigPayloadSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
fileUrl: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
clearPassword: z.boolean().optional(),
|
||||
exportType: backupExportTypeSchema.optional(),
|
||||
autoSyncEnabled: z.boolean().optional(),
|
||||
autoSyncCron: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const backupWebdavExportPayloadSchema = z.object({
|
||||
type: backupExportTypeSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const backupImportPayloadSchema = z.object({
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
}).passthrough();
|
||||
|
||||
export type BackupWebdavConfigPayload = z.output<typeof backupWebdavConfigPayloadSchema>;
|
||||
export type BackupWebdavExportPayload = z.output<typeof backupWebdavExportPayloadSchema>;
|
||||
export type BackupImportPayload = z.output<typeof backupImportPayloadSchema>;
|
||||
export type DatabaseMigrationPayload = z.output<typeof databaseMigrationPayloadSchema>;
|
||||
export type RuntimeSettingsPayload = z.output<typeof runtimeSettingsPayloadSchema>;
|
||||
export type SystemProxyTestPayload = z.output<typeof systemProxyTestPayloadSchema>;
|
||||
|
||||
function normalizeSettingsPayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatSettingsPayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'exportType') {
|
||||
return 'Invalid exportType. Expected all/accounts/preferences.';
|
||||
}
|
||||
if (firstPath === 'type') {
|
||||
return 'Invalid type. Expected all/accounts/preferences.';
|
||||
}
|
||||
if (firstPath === 'proxyUrl') {
|
||||
return '系统代理地址格式无效:需要 string';
|
||||
}
|
||||
if (firstPath === 'dialect') {
|
||||
return 'Invalid dialect. Expected sqlite/mysql/postgres.';
|
||||
}
|
||||
if (firstPath === 'connectionString') {
|
||||
return 'Invalid connectionString. Expected non-empty string.';
|
||||
}
|
||||
if (firstPath === 'overwrite') {
|
||||
return 'Invalid overwrite. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'ssl') {
|
||||
return 'Invalid ssl. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'data') {
|
||||
return '导入数据格式错误:需要 JSON 对象';
|
||||
}
|
||||
if (firstPath === 'webhookEnabled') {
|
||||
return 'Webhook 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'barkEnabled') {
|
||||
return 'Bark 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'serverChanEnabled') {
|
||||
return 'Server 酱开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'telegramEnabled') {
|
||||
return 'Telegram 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'telegramUseSystemProxy') {
|
||||
return 'Telegram 使用系统代理格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'smtpEnabled') {
|
||||
return 'SMTP 开关格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'smtpSecure') {
|
||||
return 'SMTP 安全连接格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'logCleanupUsageLogsEnabled') {
|
||||
return '自动清理使用日志格式无效:需要 boolean';
|
||||
}
|
||||
if (firstPath === 'logCleanupProgramLogsEnabled') {
|
||||
return '自动清理程序日志格式无效:需要 boolean';
|
||||
}
|
||||
return 'Invalid settings payload.';
|
||||
}
|
||||
|
||||
export function parseRuntimeSettingsPayload(input: unknown):
|
||||
{ success: true; data: RuntimeSettingsPayload } | { success: false; error: string } {
|
||||
const result = runtimeSettingsPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSystemProxyTestPayload(input: unknown):
|
||||
{ success: true; data: SystemProxyTestPayload } | { success: false; error: string } {
|
||||
const result = systemProxyTestPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseDatabaseMigrationPayload(input: unknown):
|
||||
{ success: true; data: DatabaseMigrationPayload } | { success: false; error: string } {
|
||||
const result = databaseMigrationPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBackupWebdavConfigPayload(input: unknown):
|
||||
{ success: true; data: BackupWebdavConfigPayload } | { success: false; error: string } {
|
||||
const result = backupWebdavConfigPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBackupImportPayload(input: unknown):
|
||||
{ success: true; data: BackupImportPayload } | { success: false; error: string } {
|
||||
const result = backupImportPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBackupWebdavExportPayload(input: unknown):
|
||||
{ success: true; data: BackupWebdavExportPayload } | { success: false; error: string } {
|
||||
const result = backupWebdavExportPayloadSchema.safeParse(normalizeSettingsPayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSettingsPayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const requiredTrimmedString = z.string().trim().min(1);
|
||||
const unknownField = z.unknown().optional();
|
||||
|
||||
const siteCreatePayloadSchema = z.object({
|
||||
name: requiredTrimmedString,
|
||||
url: requiredTrimmedString,
|
||||
platform: z.string().trim().optional(),
|
||||
initializationPresetId: z.union([z.string(), z.null()]).optional(),
|
||||
proxyUrl: unknownField,
|
||||
useSystemProxy: unknownField,
|
||||
customHeaders: unknownField,
|
||||
externalCheckinUrl: unknownField,
|
||||
status: unknownField,
|
||||
isPinned: unknownField,
|
||||
sortOrder: unknownField,
|
||||
globalWeight: unknownField,
|
||||
}).passthrough();
|
||||
|
||||
const siteUpdatePayloadSchema = z.object({
|
||||
name: requiredTrimmedString.optional(),
|
||||
url: requiredTrimmedString.optional(),
|
||||
platform: requiredTrimmedString.optional(),
|
||||
proxyUrl: unknownField,
|
||||
useSystemProxy: unknownField,
|
||||
customHeaders: unknownField,
|
||||
externalCheckinUrl: unknownField,
|
||||
status: unknownField,
|
||||
isPinned: unknownField,
|
||||
sortOrder: unknownField,
|
||||
globalWeight: unknownField,
|
||||
}).passthrough();
|
||||
|
||||
const siteBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const siteDisabledModelsPayloadSchema = z.object({
|
||||
models: z.array(z.string()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const siteDetectPayloadSchema = z.object({
|
||||
url: requiredTrimmedString,
|
||||
}).passthrough();
|
||||
|
||||
export type SiteBatchPayload = z.output<typeof siteBatchPayloadSchema>;
|
||||
export type SiteCreatePayload = z.output<typeof siteCreatePayloadSchema>;
|
||||
export type SiteDetectPayload = z.output<typeof siteDetectPayloadSchema>;
|
||||
export type SiteDisabledModelsPayload = z.output<typeof siteDisabledModelsPayloadSchema>;
|
||||
export type SiteUpdatePayload = z.output<typeof siteUpdatePayloadSchema>;
|
||||
|
||||
function normalizeSitePayloadInput(input: unknown): unknown {
|
||||
if (input === undefined) return {};
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) return input;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatSitePayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const firstPath = firstIssue?.path[0];
|
||||
if (firstPath === 'name') {
|
||||
return 'Invalid name. Expected non-empty string.';
|
||||
}
|
||||
if (firstPath === 'url') {
|
||||
return 'Invalid url. Expected non-empty string.';
|
||||
}
|
||||
if (firstPath === 'platform') {
|
||||
return 'Invalid platform. Expected string.';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'models') {
|
||||
return 'Invalid models. Expected string[].';
|
||||
}
|
||||
return 'Invalid site payload.';
|
||||
}
|
||||
|
||||
export function parseSiteCreatePayload(input: unknown):
|
||||
{ success: true; data: SiteCreatePayload } | { success: false; error: string } {
|
||||
const result = siteCreatePayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteUpdatePayload(input: unknown):
|
||||
{ success: true; data: SiteUpdatePayload } | { success: false; error: string } {
|
||||
const result = siteUpdatePayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteBatchPayload(input: unknown):
|
||||
{ success: true; data: SiteBatchPayload } | { success: false; error: string } {
|
||||
const result = siteBatchPayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteDisabledModelsPayload(input: unknown):
|
||||
{ success: true; data: SiteDisabledModelsPayload } | { success: false; error: string } {
|
||||
const result = siteDisabledModelsPayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSiteDetectPayload(input: unknown):
|
||||
{ success: true; data: SiteDetectPayload } | { success: false; error: string } {
|
||||
const result = siteDetectPayloadSchema.safeParse(normalizeSitePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSitePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateCenterVersionSourceSchema = z.enum(['docker-hub-tag', 'github-release']);
|
||||
|
||||
const authChangePayloadSchema = z.object({
|
||||
oldToken: z.string().optional(),
|
||||
newToken: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const monitorConfigPayloadSchema = z.object({
|
||||
ldohCookie: z.union([z.string(), z.null()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthStartPayloadSchema = z.object({
|
||||
accountId: z.number().int().positive().optional(),
|
||||
projectId: z.string().optional(),
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthManualCallbackPayloadSchema = z.object({
|
||||
callbackUrl: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const oauthConnectionRebindPayloadSchema = z.object({
|
||||
proxyUrl: z.union([z.string(), z.null()]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const updateCenterConfigPayloadSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
helperBaseUrl: z.string().optional(),
|
||||
namespace: z.string().optional(),
|
||||
releaseName: z.string().optional(),
|
||||
chartRef: z.string().optional(),
|
||||
imageRepository: z.string().optional(),
|
||||
githubReleasesEnabled: z.boolean().optional(),
|
||||
dockerHubTagsEnabled: z.boolean().optional(),
|
||||
defaultDeploySource: updateCenterVersionSourceSchema.optional(),
|
||||
}).passthrough();
|
||||
|
||||
const updateCenterDeployPayloadSchema = z.object({
|
||||
source: updateCenterVersionSourceSchema.optional(),
|
||||
targetVersion: z.string().optional(),
|
||||
targetTag: z.string().optional(),
|
||||
targetDigest: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const updateCenterRollbackPayloadSchema = z.object({
|
||||
targetRevision: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AuthChangePayload = z.output<typeof authChangePayloadSchema>;
|
||||
export type MonitorConfigPayload = z.output<typeof monitorConfigPayloadSchema>;
|
||||
export type OauthConnectionRebindPayload = z.output<typeof oauthConnectionRebindPayloadSchema>;
|
||||
export type OauthManualCallbackPayload = z.output<typeof oauthManualCallbackPayloadSchema>;
|
||||
export type OauthStartPayload = z.output<typeof oauthStartPayloadSchema>;
|
||||
export type UpdateCenterConfigPayload = z.output<typeof updateCenterConfigPayloadSchema>;
|
||||
export type UpdateCenterDeployPayload = z.output<typeof updateCenterDeployPayloadSchema>;
|
||||
export type UpdateCenterRollbackPayload = z.output<typeof updateCenterRollbackPayloadSchema>;
|
||||
|
||||
function normalizeSupportRoutePayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatSupportRoutePayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const [firstPath] = firstIssue?.path ?? [];
|
||||
if (!firstPath) {
|
||||
return '请求体必须是对象';
|
||||
}
|
||||
if (firstPath === 'oldToken') {
|
||||
return 'Invalid oldToken. Expected string.';
|
||||
}
|
||||
if (firstPath === 'newToken') {
|
||||
return 'Invalid newToken. Expected string.';
|
||||
}
|
||||
if (firstPath === 'ldohCookie') {
|
||||
return 'Invalid ldohCookie. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'accountId') {
|
||||
return 'Invalid accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'projectId') {
|
||||
return 'Invalid projectId. Expected string.';
|
||||
}
|
||||
if (firstPath === 'proxyUrl') {
|
||||
return 'Invalid proxyUrl. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'callbackUrl') {
|
||||
return 'Invalid callbackUrl. Expected string.';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'helperBaseUrl') {
|
||||
return 'Invalid helperBaseUrl. Expected string.';
|
||||
}
|
||||
if (firstPath === 'namespace') {
|
||||
return 'Invalid namespace. Expected string.';
|
||||
}
|
||||
if (firstPath === 'releaseName') {
|
||||
return 'Invalid releaseName. Expected string.';
|
||||
}
|
||||
if (firstPath === 'chartRef') {
|
||||
return 'Invalid chartRef. Expected string.';
|
||||
}
|
||||
if (firstPath === 'imageRepository') {
|
||||
return 'Invalid imageRepository. Expected string.';
|
||||
}
|
||||
if (firstPath === 'githubReleasesEnabled') {
|
||||
return 'Invalid githubReleasesEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'dockerHubTagsEnabled') {
|
||||
return 'Invalid dockerHubTagsEnabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'defaultDeploySource') {
|
||||
return 'Invalid defaultDeploySource. Expected docker-hub-tag/github-release.';
|
||||
}
|
||||
if (firstPath === 'source') {
|
||||
return 'Invalid source. Expected docker-hub-tag/github-release.';
|
||||
}
|
||||
if (firstPath === 'targetVersion') {
|
||||
return 'Invalid targetVersion. Expected string.';
|
||||
}
|
||||
if (firstPath === 'targetTag') {
|
||||
return 'Invalid targetTag. Expected string.';
|
||||
}
|
||||
if (firstPath === 'targetDigest') {
|
||||
return 'Invalid targetDigest. Expected string.';
|
||||
}
|
||||
if (firstPath === 'targetRevision') {
|
||||
return 'Invalid targetRevision. Expected string.';
|
||||
}
|
||||
return 'Invalid support route payload.';
|
||||
}
|
||||
|
||||
function parseSupportRoutePayload<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
input: unknown,
|
||||
): { success: true; data: z.output<T> } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeSupportRoutePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatSupportRoutePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAuthChangePayload(input: unknown):
|
||||
{ success: true; data: AuthChangePayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(authChangePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseMonitorConfigPayload(input: unknown):
|
||||
{ success: true; data: MonitorConfigPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(monitorConfigPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthStartPayload(input: unknown):
|
||||
{ success: true; data: OauthStartPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthStartPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthManualCallbackPayload(input: unknown):
|
||||
{ success: true; data: OauthManualCallbackPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthManualCallbackPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseOauthConnectionRebindPayload(input: unknown):
|
||||
{ success: true; data: OauthConnectionRebindPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(oauthConnectionRebindPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseUpdateCenterConfigPayload(input: unknown):
|
||||
{ success: true; data: UpdateCenterConfigPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(updateCenterConfigPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseUpdateCenterDeployPayload(input: unknown):
|
||||
{ success: true; data: UpdateCenterDeployPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(updateCenterDeployPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseUpdateCenterRollbackPayload(input: unknown):
|
||||
{ success: true; data: UpdateCenterRollbackPayload } | { success: false; error: string } {
|
||||
return parseSupportRoutePayload(updateCenterRollbackPayloadSchema, input);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const routeChannelCreatePayloadSchema = z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
tokenId: z.union([z.number().int().positive(), z.null()]).optional(),
|
||||
sourceModel: z.string().optional(),
|
||||
priority: z.number().optional(),
|
||||
weight: z.number().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const routeChannelBatchCreatePayloadSchema = z.object({
|
||||
channels: z.array(z.object({
|
||||
accountId: z.number().int().positive(),
|
||||
tokenId: z.union([z.number().int().positive(), z.null()]).optional(),
|
||||
sourceModel: z.string().optional(),
|
||||
}).passthrough()).min(1),
|
||||
}).passthrough();
|
||||
|
||||
const routeChannelUpdatePayloadSchema = z.object({
|
||||
tokenId: z.union([z.number().int().positive(), z.null()]).optional(),
|
||||
sourceModel: z.union([z.string(), z.null()]).optional(),
|
||||
priority: z.number().optional(),
|
||||
weight: z.number().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const tokenRouteCreatePayloadSchema = z.object({
|
||||
routeMode: z.string().optional(),
|
||||
modelPattern: z.string().optional(),
|
||||
displayName: z.union([z.string(), z.null()]).optional(),
|
||||
displayIcon: z.union([z.string(), z.null()]).optional(),
|
||||
modelMapping: z.union([z.string(), z.null()]).optional(),
|
||||
routingStrategy: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
sourceRouteIds: z.array(z.number().int().positive()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const tokenRouteUpdatePayloadSchema = z.object({
|
||||
routeMode: z.string().optional(),
|
||||
modelPattern: z.string().optional(),
|
||||
displayName: z.union([z.string(), z.null()]).optional(),
|
||||
displayIcon: z.union([z.string(), z.null()]).optional(),
|
||||
modelMapping: z.union([z.string(), z.null()]).optional(),
|
||||
routingStrategy: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
sourceRouteIds: z.array(z.number().int().positive()).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const tokenRouteBatchPayloadSchema = z.object({
|
||||
ids: z.array(z.number().int().positive()).optional(),
|
||||
action: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const routeRebuildPayloadSchema = z.object({
|
||||
refreshModels: z.boolean().optional(),
|
||||
wait: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type RouteChannelBatchCreatePayload = z.output<typeof routeChannelBatchCreatePayloadSchema>;
|
||||
export type RouteChannelCreatePayload = z.output<typeof routeChannelCreatePayloadSchema>;
|
||||
export type RouteChannelUpdatePayload = z.output<typeof routeChannelUpdatePayloadSchema>;
|
||||
export type RouteRebuildPayload = z.output<typeof routeRebuildPayloadSchema>;
|
||||
export type TokenRouteBatchPayload = z.output<typeof tokenRouteBatchPayloadSchema>;
|
||||
export type TokenRouteCreatePayload = z.output<typeof tokenRouteCreatePayloadSchema>;
|
||||
export type TokenRouteUpdatePayload = z.output<typeof tokenRouteUpdatePayloadSchema>;
|
||||
|
||||
function normalizeTokenRoutePayloadInput(input: unknown): unknown {
|
||||
return input === undefined ? {} : input;
|
||||
}
|
||||
|
||||
function formatTokenRoutePayloadError(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
const [firstPath, secondPath, thirdPath] = firstIssue?.path ?? [];
|
||||
if (!firstPath) {
|
||||
return '请求体必须是对象';
|
||||
}
|
||||
if (firstPath === 'routeMode') {
|
||||
return 'Invalid routeMode. Expected string.';
|
||||
}
|
||||
if (firstPath === 'modelPattern') {
|
||||
return 'Invalid modelPattern. Expected string.';
|
||||
}
|
||||
if (firstPath === 'displayName') {
|
||||
return 'Invalid displayName. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'displayIcon') {
|
||||
return 'Invalid displayIcon. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'modelMapping') {
|
||||
return 'Invalid modelMapping. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'routingStrategy') {
|
||||
return 'Invalid routingStrategy. Expected string.';
|
||||
}
|
||||
if (firstPath === 'enabled') {
|
||||
return 'Invalid enabled. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'sourceRouteIds') {
|
||||
return 'Invalid sourceRouteIds. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'ids') {
|
||||
return 'Invalid ids. Expected number[].';
|
||||
}
|
||||
if (firstPath === 'action') {
|
||||
return 'Invalid action. Expected string.';
|
||||
}
|
||||
if (firstPath === 'accountId') {
|
||||
return 'Invalid accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'tokenId') {
|
||||
return 'Invalid tokenId. Expected positive number or null.';
|
||||
}
|
||||
if (firstPath === 'sourceModel') {
|
||||
return 'Invalid sourceModel. Expected string or null.';
|
||||
}
|
||||
if (firstPath === 'priority') {
|
||||
return 'Invalid priority. Expected number.';
|
||||
}
|
||||
if (firstPath === 'weight') {
|
||||
return 'Invalid weight. Expected number.';
|
||||
}
|
||||
if (firstPath === 'refreshModels') {
|
||||
return 'Invalid refreshModels. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'wait') {
|
||||
return 'Invalid wait. Expected boolean.';
|
||||
}
|
||||
if (firstPath === 'channels' && thirdPath === 'accountId') {
|
||||
return 'Invalid channels[].accountId. Expected positive number.';
|
||||
}
|
||||
if (firstPath === 'channels' && thirdPath === 'tokenId') {
|
||||
return 'Invalid channels[].tokenId. Expected positive number or null.';
|
||||
}
|
||||
if (firstPath === 'channels' && thirdPath === 'sourceModel') {
|
||||
return 'Invalid channels[].sourceModel. Expected string.';
|
||||
}
|
||||
if (firstPath === 'channels') {
|
||||
return 'Invalid channels. Expected channel array.';
|
||||
}
|
||||
return 'Invalid token route payload.';
|
||||
}
|
||||
|
||||
function parseTokenRoutePayload<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
input: unknown,
|
||||
): { success: true; data: z.output<T> } | { success: false; error: string } {
|
||||
const result = schema.safeParse(normalizeTokenRoutePayloadInput(input));
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatTokenRoutePayloadError(result.error),
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTokenRouteCreatePayload(input: unknown):
|
||||
{ success: true; data: TokenRouteCreatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(tokenRouteCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseTokenRouteUpdatePayload(input: unknown):
|
||||
{ success: true; data: TokenRouteUpdatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(tokenRouteUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseTokenRouteBatchPayload(input: unknown):
|
||||
{ success: true; data: TokenRouteBatchPayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(tokenRouteBatchPayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteChannelCreatePayload(input: unknown):
|
||||
{ success: true; data: RouteChannelCreatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeChannelCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteChannelBatchCreatePayload(input: unknown):
|
||||
{ success: true; data: RouteChannelBatchCreatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeChannelBatchCreatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteChannelUpdatePayload(input: unknown):
|
||||
{ success: true; data: RouteChannelUpdatePayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeChannelUpdatePayloadSchema, input);
|
||||
}
|
||||
|
||||
export function parseRouteRebuildPayload(input: unknown):
|
||||
{ success: true; data: RouteRebuildPayload } | { success: false; error: string } {
|
||||
return parseTokenRoutePayload(routeRebuildPayloadSchema, input);
|
||||
}
|
||||
@@ -172,4 +172,18 @@ describe('account token batch routes', () => {
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('action');
|
||||
});
|
||||
|
||||
it('rejects batch payloads whose ids include non-number values', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/account-tokens/batch',
|
||||
payload: {
|
||||
ids: [1, '2'],
|
||||
action: 'enable',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('ids');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -572,6 +572,46 @@ describe('account tokens sync routes with site status', () => {
|
||||
expect(syncedDefaultToken?.token).toBe('sk-synced-token');
|
||||
});
|
||||
|
||||
it('rejects non-boolean wait when syncing all account tokens', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/account-tokens/sync-all',
|
||||
payload: {
|
||||
wait: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid wait. Expected boolean.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the refreshed default state after creating the first manual token', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/account-tokens',
|
||||
payload: {
|
||||
accountId: account.id,
|
||||
name: 'manual-default',
|
||||
token: 'sk-manual-default-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
token: expect.objectContaining({
|
||||
name: 'manual-default',
|
||||
isDefault: true,
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates token via upstream api and syncs into local store when manual token is omitted', async () => {
|
||||
const { account, site } = await seedAccount({ siteStatus: 'active' });
|
||||
createApiTokenMock.mockResolvedValue(true);
|
||||
@@ -643,6 +683,40 @@ describe('account tokens sync routes with site status', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-string token payload when creating manual account token', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/account-tokens',
|
||||
payload: {
|
||||
accountId: account.id,
|
||||
token: { value: 'bad-token' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('token');
|
||||
});
|
||||
|
||||
it('rejects non-boolean unlimitedQuota payload when creating upstream account token', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/account-tokens',
|
||||
payload: {
|
||||
accountId: account.id,
|
||||
name: 'typed-token',
|
||||
unlimitedQuota: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('unlimitedQuota');
|
||||
expect(createApiTokenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 400 when limited token misses remainQuota', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
|
||||
@@ -890,6 +964,66 @@ describe('account tokens sync routes with site status', () => {
|
||||
expect((latest as any)?.valueStatus).toBe('ready');
|
||||
});
|
||||
|
||||
it('returns the refreshed default state after promoting an existing token to default', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default-token',
|
||||
token: 'sk-default-token',
|
||||
source: 'manual',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).run();
|
||||
const secondary = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'secondary-token',
|
||||
token: 'sk-secondary-token',
|
||||
source: 'manual',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/account-tokens/${secondary.id}`,
|
||||
payload: {
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: true,
|
||||
token: expect.objectContaining({
|
||||
id: secondary.id,
|
||||
isDefault: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-string name payload when updating account token', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'typed-token',
|
||||
token: 'sk-real-token',
|
||||
source: 'manual',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/account-tokens/${token.id}`,
|
||||
payload: {
|
||||
name: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('name');
|
||||
});
|
||||
|
||||
it('deletes masked_pending placeholders locally without calling upstream delete', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
convergeAccountMutation,
|
||||
refreshAccountCoverageBatch,
|
||||
} from '../../services/accountMutationWorkflow.js';
|
||||
import {
|
||||
parseAccountTokenBatchPayload,
|
||||
parseAccountTokenCreatePayload,
|
||||
parseAccountTokenSyncAllPayload,
|
||||
parseAccountTokenUpdatePayload,
|
||||
} from '../../contracts/accountTokensRoutePayloads.js';
|
||||
|
||||
type AccountWithSiteRow = {
|
||||
accounts: typeof schema.accounts.$inferSelect;
|
||||
@@ -469,22 +475,13 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return listTokensWithRelations(Number.isFinite(accountId as number) ? accountId : undefined);
|
||||
});
|
||||
|
||||
app.post<{ Body: {
|
||||
accountId: number;
|
||||
name?: string;
|
||||
token?: string;
|
||||
enabled?: boolean;
|
||||
isDefault?: boolean;
|
||||
source?: string;
|
||||
group?: string;
|
||||
unlimitedQuota?: boolean | string;
|
||||
remainQuota?: number | string;
|
||||
expiredTime?: number | string;
|
||||
allowIps?: string;
|
||||
modelLimitsEnabled?: boolean | string;
|
||||
modelLimits?: string;
|
||||
} }>('/api/account-tokens', async (request, reply) => {
|
||||
const body = request.body;
|
||||
app.post<{ Body: unknown }>('/api/account-tokens', async (request, reply) => {
|
||||
const parsedBody = parseAccountTokenCreatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const row = await db.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
@@ -530,7 +527,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
if (createdId <= 0) {
|
||||
return reply.code(500).send({ success: false, message: '创建令牌失败' });
|
||||
}
|
||||
const created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, createdId)).get();
|
||||
let created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, createdId)).get();
|
||||
if (!created) {
|
||||
return reply.code(500).send({ success: false, message: '创建令牌失败' });
|
||||
}
|
||||
@@ -541,6 +538,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
await setDefaultToken(created.id);
|
||||
}
|
||||
const coverageRefresh = await refreshCoverageForAccounts([body.accountId]);
|
||||
created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, created.id)).get();
|
||||
if (!created) {
|
||||
return reply.code(500).send({ success: false, message: '创建令牌失败' });
|
||||
}
|
||||
return { success: true, token: created, coverageRefresh };
|
||||
}
|
||||
|
||||
@@ -690,9 +691,14 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/account-tokens/batch', async (request, reply) => {
|
||||
const ids = normalizeBatchIds(request.body?.ids);
|
||||
const action = String(request.body?.action || '').trim();
|
||||
app.post<{ Body: unknown }>('/api/account-tokens/batch', async (request, reply) => {
|
||||
const parsedBody = parseAccountTokenBatchPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
|
||||
const ids = normalizeBatchIds(parsedBody.data.ids);
|
||||
const action = String(parsedBody.data.action || '').trim();
|
||||
if (ids.length === 0) {
|
||||
return reply.code(400).send({ message: 'ids is required' });
|
||||
}
|
||||
@@ -757,7 +763,12 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.put<{ Params: { id: string }; Body: { name?: string; token?: string; group?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => {
|
||||
app.put<{ Params: { id: string }; Body: unknown }>('/api/account-tokens/:id', async (request, reply) => {
|
||||
const parsedBody = parseAccountTokenUpdatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const tokenId = Number.parseInt(request.params.id, 10);
|
||||
if (Number.isNaN(tokenId)) {
|
||||
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
|
||||
@@ -776,7 +787,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
const body = parsedBody.data;
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
|
||||
let nextValueStatus = resolveAccountTokenValueStatus(existing);
|
||||
|
||||
@@ -811,19 +822,24 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
|
||||
await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run();
|
||||
|
||||
const latest = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
|
||||
let latest = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
|
||||
if (!latest) {
|
||||
return reply.code(500).send({ success: false, message: '更新失败' });
|
||||
}
|
||||
|
||||
if (body.isDefault === true && isUsableAccountToken(latest)) {
|
||||
setDefaultToken(tokenId);
|
||||
await setDefaultToken(tokenId);
|
||||
} else if (latest.isDefault && isUsableAccountToken(latest)) {
|
||||
setDefaultToken(tokenId);
|
||||
await setDefaultToken(tokenId);
|
||||
} else if (existing.isDefault && !isUsableAccountToken(latest)) {
|
||||
repairDefaultToken(existing.accountId);
|
||||
await repairDefaultToken(existing.accountId);
|
||||
} else if (body.isDefault === false && existing.isDefault) {
|
||||
repairDefaultToken(existing.accountId);
|
||||
await repairDefaultToken(existing.accountId);
|
||||
}
|
||||
|
||||
latest = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
|
||||
if (!latest) {
|
||||
return reply.code(500).send({ success: false, message: '更新失败' });
|
||||
}
|
||||
|
||||
return { success: true, token: latest };
|
||||
@@ -982,8 +998,13 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return { success: true, ...result, coverageRefresh };
|
||||
});
|
||||
|
||||
app.post<{ Body?: { wait?: boolean } }>('/api/account-tokens/sync-all', async (request, reply) => {
|
||||
if (request.body?.wait) {
|
||||
app.post<{ Body: unknown }>('/api/account-tokens/sync-all', async (request, reply) => {
|
||||
const parsedBody = parseAccountTokenSyncAllPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
if (parsedBody.data.wait) {
|
||||
const syncResult = await executeSyncAllAccountTokens();
|
||||
return { success: true, ...syncResult };
|
||||
}
|
||||
|
||||
@@ -108,4 +108,18 @@ describe('accounts batch routes', () => {
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('action');
|
||||
});
|
||||
|
||||
it('rejects batch payloads whose ids include non-number values', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/batch',
|
||||
payload: {
|
||||
ids: [1, '2'],
|
||||
action: 'enable',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('ids');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,54 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
||||
expect(parsedExtra.credentialMode).toBe('apikey');
|
||||
});
|
||||
|
||||
it('rejects malformed verify-token payloads at the route boundary', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/verify-token',
|
||||
payload: {
|
||||
siteId: '1',
|
||||
accessToken: 'sk-fast-verify',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid siteId. Expected positive number.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects array payloads when adding account', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts',
|
||||
payload: [],
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('account payload');
|
||||
});
|
||||
|
||||
it('rejects non-string accessToken when adding account', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Typed Site',
|
||||
url: 'https://typed.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts',
|
||||
payload: {
|
||||
siteId: site.id,
|
||||
accessToken: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('accessToken');
|
||||
});
|
||||
|
||||
it('marks apikey connection healthy in account list after model discovery succeeds', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Healthy API Key Site',
|
||||
@@ -349,4 +397,52 @@ describe('accounts credential mode', { timeout: 15_000 }, () => {
|
||||
expect(updated?.isPinned).toBe(true);
|
||||
expect(updated?.sortOrder).toBe(5);
|
||||
});
|
||||
|
||||
it('rejects array payloads when updating account', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Update Site',
|
||||
url: 'https://update.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'update-user',
|
||||
accessToken: 'access-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/accounts/${account.id}`,
|
||||
payload: [],
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('account payload');
|
||||
});
|
||||
|
||||
it('rejects non-string username when updating account', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Update Site',
|
||||
url: 'https://update.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'update-user',
|
||||
accessToken: 'access-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/accounts/${account.id}`,
|
||||
payload: {
|
||||
username: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('username');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,6 +109,28 @@ describe('accounts health refresh runtime state', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-boolean wait payload when refreshing health', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/health/refresh',
|
||||
payload: { wait: 'true' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('wait');
|
||||
});
|
||||
|
||||
it('rejects non-number accountId payload when refreshing health', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/health/refresh',
|
||||
payload: { accountId: '1', wait: true },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('账号 ID');
|
||||
});
|
||||
|
||||
it('returns readable task messages when starting a background refresh for all accounts', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Wind Hub',
|
||||
|
||||
@@ -128,4 +128,22 @@ describe('accounts login shield detection', () => {
|
||||
message: '请求过于频繁,请稍后再试',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed login payloads at the route boundary', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/accounts/login',
|
||||
payload: {
|
||||
siteId: '1',
|
||||
username: 'demo-user',
|
||||
password: 'demo-password',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid siteId. Expected positive number.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,4 +153,30 @@ describe('accounts manual models endpoint', () => {
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects non-string manual model entries at the route boundary', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Test Site',
|
||||
url: 'https://test.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
accessToken: 'test-token',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/accounts/${account.id}/models/manual`,
|
||||
payload: {
|
||||
models: ['gpt-4-manual', 123],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
message: 'Invalid models. Expected string[].',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,6 +88,33 @@ describe('accounts rebind-session api', { timeout: 15_000 }, () => {
|
||||
expect(latest?.status).toBe('expired');
|
||||
});
|
||||
|
||||
it('rejects non-string accessToken payload before session rebind verification', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Rebind Site',
|
||||
url: 'https://rebind.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'linuxdo_1001',
|
||||
accessToken: 'old-access-token',
|
||||
status: 'expired',
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/accounts/${account.id}/rebind-session`,
|
||||
payload: {
|
||||
accessToken: { value: 'bad-token' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('accessToken');
|
||||
expect(verifyTokenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns rebind hint when verify reports invalid access token', async () => {
|
||||
verifyTokenMock.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@ import {
|
||||
import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
|
||||
import { parseSiteProxyUrlInput, withAccountProxyOverride, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||
import {
|
||||
parseAccountBatchPayload,
|
||||
parseAccountCreatePayload,
|
||||
parseAccountHealthRefreshPayload,
|
||||
parseAccountLoginPayload,
|
||||
parseAccountManualModelsPayload,
|
||||
parseAccountRebindSessionPayload,
|
||||
parseAccountUpdatePayload,
|
||||
parseAccountVerifyTokenPayload,
|
||||
} from '../../contracts/accountsRoutePayloads.js';
|
||||
|
||||
type AccountWithSiteRow = {
|
||||
accounts: typeof schema.accounts.$inferSelect;
|
||||
@@ -520,11 +530,16 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Login to a site and auto-create account
|
||||
app.post<{ Body: { siteId: number; username: string; password: string } }>(
|
||||
app.post<{ Body: unknown }>(
|
||||
'/api/accounts/login',
|
||||
{ preHandler: [limitAccountLogin] },
|
||||
async (request) => {
|
||||
const { siteId, username, password } = request.body;
|
||||
async (request, reply) => {
|
||||
const parsedBody = parseAccountLoginPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const { siteId, username, password } = parsedBody.data;
|
||||
|
||||
// Get site info
|
||||
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
|
||||
@@ -629,13 +644,18 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Verify credentials against a site.
|
||||
app.post<{ Body: { siteId: number; accessToken: string; platformUserId?: number; credentialMode?: AccountCredentialMode } }>(
|
||||
app.post<{ Body: unknown }>(
|
||||
'/api/accounts/verify-token',
|
||||
{ preHandler: [limitAccountVerifyToken] },
|
||||
async (request) => {
|
||||
const { siteId, platformUserId } = request.body;
|
||||
const accessToken = (request.body.accessToken || '').trim();
|
||||
const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode);
|
||||
async (request, reply) => {
|
||||
const parsedBody = parseAccountVerifyTokenPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const { siteId, platformUserId } = parsedBody.data;
|
||||
const accessToken = (parsedBody.data.accessToken || '').trim();
|
||||
const credentialMode = resolveRequestedCredentialMode(parsedBody.data.credentialMode);
|
||||
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
|
||||
if (!site) return { success: false, message: 'site not found' };
|
||||
|
||||
@@ -934,15 +954,20 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { accessToken: string; platformUserId?: number; refreshToken?: string; tokenExpiresAt?: number | string } }>(
|
||||
app.post<{ Params: { id: string }; Body: unknown }>(
|
||||
'/api/accounts/:id/rebind-session',
|
||||
async (request, reply) => {
|
||||
const parsedBody = parseAccountRebindSessionPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const accountId = Number.parseInt(request.params.id, 10);
|
||||
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||||
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
|
||||
}
|
||||
|
||||
const nextAccessToken = (request.body?.accessToken || '').trim();
|
||||
const nextAccessToken = (parsedBody.data.accessToken || '').trim();
|
||||
if (!nextAccessToken) {
|
||||
return reply.code(400).send({ success: false, message: '请提供新的 Session Token' });
|
||||
}
|
||||
@@ -963,7 +988,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: `platform not supported: ${site.platform}` });
|
||||
}
|
||||
|
||||
const bodyPlatformUserId = Number.parseInt(String(request.body?.platformUserId ?? ''), 10);
|
||||
const bodyPlatformUserId = Number.parseInt(String(parsedBody.data.platformUserId ?? ''), 10);
|
||||
const candidatePlatformUserId = Number.isFinite(bodyPlatformUserId) && bodyPlatformUserId > 0
|
||||
? bodyPlatformUserId
|
||||
: resolvePlatformUserId(account.extraConfig, account.username);
|
||||
@@ -1017,8 +1042,8 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
if ((site.platform || '').toLowerCase() === 'sub2api') {
|
||||
const existingManagedAuth = getSub2ApiAuthFromExtraConfig(account.extraConfig);
|
||||
const requestedRefreshToken = normalizeManagedRefreshToken(request.body?.refreshToken);
|
||||
const requestedTokenExpiresAt = normalizeManagedTokenExpiresAt(request.body?.tokenExpiresAt);
|
||||
const requestedRefreshToken = normalizeManagedRefreshToken(parsedBody.data.refreshToken);
|
||||
const requestedTokenExpiresAt = normalizeManagedTokenExpiresAt(parsedBody.data.tokenExpiresAt);
|
||||
const nextRefreshToken = requestedRefreshToken || existingManagedAuth?.refreshToken;
|
||||
const nextTokenExpiresAt = requestedTokenExpiresAt ?? existingManagedAuth?.tokenExpiresAt;
|
||||
if (nextRefreshToken) {
|
||||
@@ -1054,8 +1079,13 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Add an account (manual credential input)
|
||||
app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean; credentialMode?: AccountCredentialMode; refreshToken?: string; tokenExpiresAt?: number | string; skipModelFetch?: boolean } }>('/api/accounts', async (request, reply) => {
|
||||
const body = request.body;
|
||||
app.post<{ Body: unknown }>('/api/accounts', async (request, reply) => {
|
||||
const parsedBody = parseAccountCreatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
|
||||
if (!site) {
|
||||
return reply.code(400).send({ success: false, message: 'site not found' });
|
||||
@@ -1234,9 +1264,13 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Update an account
|
||||
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request, reply) => {
|
||||
app.put<{ Params: { id: string }; Body: unknown }>('/api/accounts/:id', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const parsedBody = parseAccountUpdatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
const body = parsedBody.data as Record<string, unknown>;
|
||||
const row = await db.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
@@ -1342,9 +1376,14 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/accounts/batch', async (request, reply) => {
|
||||
const ids = normalizeBatchIds(request.body?.ids);
|
||||
const action = String(request.body?.action || '').trim();
|
||||
app.post<{ Body: unknown }>('/api/accounts/batch', async (request, reply) => {
|
||||
const parsedBody = parseAccountBatchPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
|
||||
const ids = normalizeBatchIds(parsedBody.data.ids);
|
||||
const action = String(parsedBody.data.action || '').trim();
|
||||
if (ids.length === 0) {
|
||||
return reply.code(400).send({ message: 'ids is required' });
|
||||
}
|
||||
@@ -1402,16 +1441,15 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/accounts/health/refresh', async (request, reply) => {
|
||||
const rawAccountId = request.body?.accountId as unknown;
|
||||
const hasAccountId = rawAccountId !== undefined && rawAccountId !== null && String(rawAccountId).trim() !== '';
|
||||
const accountId = hasAccountId ? Number.parseInt(String(rawAccountId), 10) : undefined;
|
||||
const wait = request.body?.wait === true;
|
||||
|
||||
if (hasAccountId && (!Number.isFinite(accountId) || (accountId as number) <= 0)) {
|
||||
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
|
||||
app.post<{ Body: unknown }>('/api/accounts/health/refresh', async (request, reply) => {
|
||||
const parsedBody = parseAccountHealthRefreshPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const accountId = parsedBody.data.accountId;
|
||||
const wait = parsedBody.data.wait === true;
|
||||
|
||||
if (wait) {
|
||||
const result = await executeRefreshAccountRuntimeHealth(accountId);
|
||||
if (accountId && result.summary.total === 0) {
|
||||
@@ -1527,13 +1565,18 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Add models manually to an account
|
||||
app.post<{ Params: { id: string }; Body: { models: string[] } }>('/api/accounts/:id/models/manual', async (request, reply) => {
|
||||
app.post<{ Params: { id: string }; Body: unknown }>('/api/accounts/:id/models/manual', async (request, reply) => {
|
||||
const parsedBody = parseAccountManualModelsPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
|
||||
const accountId = parseInt(request.params.id, 10);
|
||||
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||||
return reply.code(400).send({ message: '账号 ID 无效' });
|
||||
}
|
||||
|
||||
const { models } = request.body;
|
||||
const { models } = parsedBody.data;
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
return reply.code(400).send({ message: '模型列表不能为空' });
|
||||
}
|
||||
@@ -1608,4 +1651,3 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
type ConfigModule = typeof import('../../config.js');
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('auth routes', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let config: ConfigModule['config'];
|
||||
let dataDir = '';
|
||||
let originalDataDir: string | undefined;
|
||||
let originalAuthToken = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-auth-routes-'));
|
||||
originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const configModule = await import('../../config.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./auth.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
config = configModule.config;
|
||||
originalAuthToken = config.authToken;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.authRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
config.authToken = 'secret-token';
|
||||
await db.delete(schema.events).run();
|
||||
await db.delete(schema.settings).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
config.authToken = originalAuthToken;
|
||||
await app.close();
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
});
|
||||
|
||||
it('rejects malformed auth change payloads at the route boundary', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/auth/change',
|
||||
payload: {
|
||||
oldToken: 'secret-token',
|
||||
newToken: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid newToken. Expected string.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { config } from '../../config.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||
import { parseAuthChangePayload } from '../../contracts/supportRoutePayloads.js';
|
||||
|
||||
const limitAdminTokenChange = createRateLimitGuard({
|
||||
bucket: 'auth-change',
|
||||
@@ -13,11 +14,16 @@ const limitAdminTokenChange = createRateLimitGuard({
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
// Change admin auth token (requires old token verification)
|
||||
app.post<{ Body: { oldToken: string; newToken: string } }>(
|
||||
app.post<{ Body: unknown }>(
|
||||
'/api/settings/auth/change',
|
||||
{ preHandler: [limitAdminTokenChange] },
|
||||
async (request, reply) => {
|
||||
const { oldToken, newToken } = request.body;
|
||||
const parsedBody = parseAuthChangePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const { oldToken, newToken } = parsedBody.data;
|
||||
|
||||
if (!oldToken || !newToken) {
|
||||
return reply.code(400).send({ success: false, message: '请填写所有字段' });
|
||||
|
||||
@@ -352,6 +352,61 @@ describe('downstream api keys routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed create, update, and batch payloads at the route boundary', async () => {
|
||||
const invalidCreateRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/downstream-keys',
|
||||
payload: {
|
||||
name: 'bad-create',
|
||||
key: 'sk-bad-create-001',
|
||||
enabled: 'false',
|
||||
},
|
||||
});
|
||||
expect(invalidCreateRes.statusCode).toBe(400);
|
||||
expect(invalidCreateRes.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid enabled. Expected boolean.',
|
||||
});
|
||||
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/downstream-keys',
|
||||
payload: {
|
||||
name: 'shape-ok',
|
||||
key: 'sk-shape-ok-001',
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const keyId = createRes.json().item.id as number;
|
||||
|
||||
const invalidUpdateRes = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/downstream-keys/${keyId}`,
|
||||
payload: {
|
||||
tags: ['valid', 123],
|
||||
},
|
||||
});
|
||||
expect(invalidUpdateRes.statusCode).toBe(400);
|
||||
expect(invalidUpdateRes.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid tags. Expected string or string[].',
|
||||
});
|
||||
|
||||
const invalidBatchRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/downstream-keys/batch',
|
||||
payload: {
|
||||
ids: [String(keyId)],
|
||||
action: 'disable',
|
||||
},
|
||||
});
|
||||
expect(invalidBatchRes.statusCode).toBe(400);
|
||||
expect(invalidBatchRes.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid ids. Expected number[].',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns summary, overview and trend aggregated from proxy logs', async () => {
|
||||
const inserted = await db.insert(schema.downstreamApiKeys).values({
|
||||
name: 'analytics-key',
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
toPersistenceJson,
|
||||
} from '../../services/downstreamApiKeyService.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import {
|
||||
parseDownstreamApiKeyBatchPayload,
|
||||
parseDownstreamApiKeyPayload,
|
||||
} from '../../contracts/downstreamApiKeyRoutePayloads.js';
|
||||
|
||||
function parseRouteId(raw: string): number | null {
|
||||
const id = Number.parseInt(raw, 10);
|
||||
@@ -439,25 +443,16 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{
|
||||
Body: {
|
||||
name?: unknown;
|
||||
key?: unknown;
|
||||
description?: unknown;
|
||||
groupName?: unknown;
|
||||
tags?: unknown;
|
||||
enabled?: unknown;
|
||||
expiresAt?: unknown;
|
||||
maxCost?: unknown;
|
||||
maxRequests?: unknown;
|
||||
supportedModels?: unknown;
|
||||
allowedRouteIds?: unknown;
|
||||
siteWeightMultipliers?: unknown;
|
||||
};
|
||||
}>('/api/downstream-keys', async (request, reply) => {
|
||||
app.post<{ Body: unknown }>('/api/downstream-keys', async (request, reply) => {
|
||||
const parsedBody = parseDownstreamApiKeyPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
let normalized: ReturnType<typeof normalizeDownstreamApiKeyPayload>;
|
||||
try {
|
||||
normalized = normalizeDownstreamApiKeyPayload(request.body || {});
|
||||
normalized = normalizeDownstreamApiKeyPayload(body);
|
||||
} catch (error: unknown) {
|
||||
return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' });
|
||||
}
|
||||
@@ -523,23 +518,12 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.put<{
|
||||
Params: { id: string };
|
||||
Body: {
|
||||
name?: unknown;
|
||||
key?: unknown;
|
||||
description?: unknown;
|
||||
groupName?: unknown;
|
||||
tags?: unknown;
|
||||
enabled?: unknown;
|
||||
expiresAt?: unknown;
|
||||
maxCost?: unknown;
|
||||
maxRequests?: unknown;
|
||||
supportedModels?: unknown;
|
||||
allowedRouteIds?: unknown;
|
||||
siteWeightMultipliers?: unknown;
|
||||
};
|
||||
}>('/api/downstream-keys/:id', async (request, reply) => {
|
||||
app.put<{ Params: { id: string }; Body: unknown }>('/api/downstream-keys/:id', async (request, reply) => {
|
||||
const parsedBody = parseDownstreamApiKeyPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const id = parseRouteId(request.params.id);
|
||||
if (!id) {
|
||||
return reply.code(400).send({ success: false, message: 'id 无效' });
|
||||
@@ -554,21 +538,22 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const existingView = toDownstreamApiKeyPolicyView(existing);
|
||||
const body = parsedBody.data;
|
||||
let normalized: ReturnType<typeof normalizeDownstreamApiKeyPayload>;
|
||||
try {
|
||||
normalized = normalizeDownstreamApiKeyPayload({
|
||||
name: request.body?.name ?? existing.name,
|
||||
key: request.body?.key ?? existing.key,
|
||||
description: request.body?.description ?? existing.description,
|
||||
groupName: request.body?.groupName ?? existing.groupName,
|
||||
tags: request.body?.tags ?? existingView.tags,
|
||||
enabled: request.body?.enabled ?? existing.enabled,
|
||||
expiresAt: request.body?.expiresAt ?? existing.expiresAt,
|
||||
maxCost: request.body?.maxCost ?? existing.maxCost,
|
||||
maxRequests: request.body?.maxRequests ?? existing.maxRequests,
|
||||
supportedModels: request.body?.supportedModels ?? existingView.supportedModels,
|
||||
allowedRouteIds: request.body?.allowedRouteIds ?? existingView.allowedRouteIds,
|
||||
siteWeightMultipliers: request.body?.siteWeightMultipliers ?? existingView.siteWeightMultipliers,
|
||||
name: body.name ?? existing.name,
|
||||
key: body.key ?? existing.key,
|
||||
description: body.description ?? existing.description,
|
||||
groupName: body.groupName ?? existing.groupName,
|
||||
tags: body.tags ?? existingView.tags,
|
||||
enabled: body.enabled ?? existing.enabled,
|
||||
expiresAt: body.expiresAt ?? existing.expiresAt,
|
||||
maxCost: body.maxCost ?? existing.maxCost,
|
||||
maxRequests: body.maxRequests ?? existing.maxRequests,
|
||||
supportedModels: body.supportedModels ?? existingView.supportedModels,
|
||||
allowedRouteIds: body.allowedRouteIds ?? existingView.allowedRouteIds,
|
||||
siteWeightMultipliers: body.siteWeightMultipliers ?? existingView.siteWeightMultipliers,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return reply.code(400).send({ success: false, message: (error as Error)?.message || '参数无效' });
|
||||
@@ -663,16 +648,15 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
app.post<{ Body?: {
|
||||
ids?: number[];
|
||||
action?: string;
|
||||
groupOperation?: string;
|
||||
groupName?: unknown;
|
||||
tagOperation?: string;
|
||||
tags?: unknown;
|
||||
} }>('/api/downstream-keys/batch', async (request, reply) => {
|
||||
const ids = normalizeBatchIds(request.body?.ids);
|
||||
const action = String(request.body?.action || '').trim();
|
||||
app.post<{ Body: unknown }>('/api/downstream-keys/batch', async (request, reply) => {
|
||||
const parsedBody = parseDownstreamApiKeyBatchPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const ids = normalizeBatchIds(body.ids);
|
||||
const action = String(body.action || '').trim();
|
||||
if (ids.length === 0) {
|
||||
return reply.code(400).send({ success: false, message: 'ids is required' });
|
||||
}
|
||||
@@ -680,10 +664,10 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: 'Invalid action' });
|
||||
}
|
||||
|
||||
const groupOperation = String(request.body?.groupOperation || 'keep').trim();
|
||||
const tagOperation = String(request.body?.tagOperation || 'keep').trim();
|
||||
const normalizedGroupName = normalizeDownstreamApiKeyPayload({ groupName: request.body?.groupName }).groupName;
|
||||
const normalizedTags = normalizeDownstreamApiKeyPayload({ tags: request.body?.tags }).tags;
|
||||
const groupOperation = String(body.groupOperation || 'keep').trim();
|
||||
const tagOperation = String(body.tagOperation || 'keep').trim();
|
||||
const normalizedGroupName = normalizeDownstreamApiKeyPayload({ groupName: body.groupName }).groupName;
|
||||
const normalizedTags = normalizeDownstreamApiKeyPayload({ tags: body.tags }).tags;
|
||||
|
||||
if (action === 'updateMetadata') {
|
||||
if (!['keep', 'set', 'clear'].includes(groupOperation)) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('monitor routes', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-monitor-routes-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const routesModule = await import('./monitor.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(routesModule.monitorRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.settings).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('rejects malformed monitor config payloads at the route boundary', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/monitor/config',
|
||||
payload: {
|
||||
ldohCookie: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid ldohCookie. Expected string or null.',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { upsertSetting } from '../../db/upsertSetting.js';
|
||||
import { config } from '../../config.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
|
||||
import { parseMonitorConfigPayload } from '../../contracts/supportRoutePayloads.js';
|
||||
|
||||
const MONITOR_AUTH_COOKIE = 'meta_monitor_auth';
|
||||
const LDOH_BASE_URL = 'https://ldoh.105117.xyz';
|
||||
@@ -136,11 +137,16 @@ export async function monitorRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.put<{ Body: { ldohCookie?: string | null } }>(
|
||||
app.put<{ Body: unknown }>(
|
||||
'/api/monitor/config',
|
||||
{ preHandler: [limitMonitorConfigWrite] },
|
||||
async (request, reply) => {
|
||||
const raw = String(request.body?.ldohCookie || '').trim();
|
||||
const parsedBody = parseMonitorConfigPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const raw = String(parsedBody.data.ldohCookie || '').trim();
|
||||
if (!raw) {
|
||||
await upsertSetting(LDOH_COOKIE_SETTING_KEY, '');
|
||||
return { success: true, message: 'LDOH Cookie 已清空', ldohCookieConfigured: false };
|
||||
|
||||
@@ -121,6 +121,55 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed oauth payloads at the route boundary', async () => {
|
||||
const invalidStartResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/providers/antigravity/start',
|
||||
payload: {
|
||||
accountId: '1',
|
||||
},
|
||||
});
|
||||
expect(invalidStartResponse.statusCode).toBe(400);
|
||||
expect(invalidStartResponse.json()).toMatchObject({
|
||||
message: 'Invalid accountId. Expected positive number.',
|
||||
});
|
||||
|
||||
const startResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/providers/antigravity/start',
|
||||
headers: {
|
||||
host: 'metapi.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
});
|
||||
expect(startResponse.statusCode).toBe(200);
|
||||
const state = (startResponse.json() as { state: string }).state;
|
||||
|
||||
const invalidCallbackResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/oauth/sessions/${encodeURIComponent(state)}/manual-callback`,
|
||||
payload: {
|
||||
callbackUrl: 123,
|
||||
},
|
||||
});
|
||||
expect(invalidCallbackResponse.statusCode).toBe(400);
|
||||
expect(invalidCallbackResponse.json()).toMatchObject({
|
||||
message: 'Invalid callbackUrl. Expected string.',
|
||||
});
|
||||
|
||||
const invalidRebindResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/oauth/connections/1/rebind',
|
||||
payload: {
|
||||
proxyUrl: 123,
|
||||
},
|
||||
});
|
||||
expect(invalidRebindResponse.statusCode).toBe(400);
|
||||
expect(invalidRebindResponse.json()).toMatchObject({
|
||||
message: 'Invalid proxyUrl. Expected string or null.',
|
||||
});
|
||||
});
|
||||
|
||||
it('starts an antigravity oauth session and returns provider metadata', async () => {
|
||||
const startResponse = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
submitOauthManualCallback,
|
||||
} from '../../services/oauth/service.js';
|
||||
import { parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
||||
import {
|
||||
parseOauthConnectionRebindPayload,
|
||||
parseOauthManualCallbackPayload,
|
||||
parseOauthStartPayload,
|
||||
} from '../../contracts/supportRoutePayloads.js';
|
||||
|
||||
const limitOauthProviderRead = createRateLimitGuard({
|
||||
bucket: 'oauth-provider-read',
|
||||
@@ -111,21 +116,27 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
providers: listOauthProviders(),
|
||||
}));
|
||||
|
||||
app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string; proxyUrl?: string | null } }>(
|
||||
app.post<{ Params: { provider: string }; Body: unknown }>(
|
||||
'/api/oauth/providers/:provider/start',
|
||||
{ preHandler: [limitOauthStart] },
|
||||
async (request, reply) => {
|
||||
const rebindAccountId = request.body?.accountId === undefined
|
||||
const parsedBody = parseOauthStartPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const rebindAccountId = body.accountId === undefined
|
||||
? undefined
|
||||
: parsePositiveInteger(request.body.accountId);
|
||||
if (request.body?.accountId !== undefined && rebindAccountId === null) {
|
||||
: parsePositiveInteger(body.accountId);
|
||||
if (body.accountId !== undefined && rebindAccountId === null) {
|
||||
return reply.code(400).send({ message: 'invalid account id' });
|
||||
}
|
||||
const projectId = parseOptionalProjectId(request.body?.projectId);
|
||||
if (request.body?.projectId !== undefined && projectId === null) {
|
||||
const projectId = parseOptionalProjectId(body.projectId);
|
||||
if (body.projectId !== undefined && projectId === null) {
|
||||
return reply.code(400).send({ message: 'invalid project id' });
|
||||
}
|
||||
const normalizedProxyUrl = parseSiteProxyUrlInput(request.body?.proxyUrl);
|
||||
const normalizedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl);
|
||||
if (normalizedProxyUrl.present && !normalizedProxyUrl.valid) {
|
||||
return reply.code(400).send({ message: 'invalid proxy url' });
|
||||
}
|
||||
@@ -156,12 +167,17 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { state: string }; Body: { callbackUrl?: string } }>(
|
||||
app.post<{ Params: { state: string }; Body: unknown }>(
|
||||
'/api/oauth/sessions/:state/manual-callback',
|
||||
{ preHandler: [limitOauthSessionMutate] },
|
||||
async (request, reply) => {
|
||||
const callbackUrl = typeof request.body?.callbackUrl === 'string'
|
||||
? request.body.callbackUrl.trim()
|
||||
const parsedBody = parseOauthManualCallbackPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
|
||||
const callbackUrl = typeof parsedBody.data.callbackUrl === 'string'
|
||||
? parsedBody.data.callbackUrl.trim()
|
||||
: '';
|
||||
if (!callbackUrl) {
|
||||
return reply.code(400).send({ message: 'invalid oauth callback url' });
|
||||
@@ -209,15 +225,20 @@ export async function oauthRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { accountId: string }; Body?: { proxyUrl?: string | null } }>(
|
||||
app.post<{ Params: { accountId: string }; Body: unknown }>(
|
||||
'/api/oauth/connections/:accountId/rebind',
|
||||
{ preHandler: [limitOauthConnectionMutate] },
|
||||
async (request, reply) => {
|
||||
const parsedBody = parseOauthConnectionRebindPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
|
||||
const accountId = parsePositiveInteger(request.params.accountId);
|
||||
if (accountId === null) {
|
||||
return reply.code(400).send({ message: 'invalid account id' });
|
||||
}
|
||||
const normalizedProxyUrl = parseSiteProxyUrlInput(request.body?.proxyUrl);
|
||||
const normalizedProxyUrl = parseSiteProxyUrlInput(parsedBody.data.proxyUrl);
|
||||
if (normalizedProxyUrl.present && !normalizedProxyUrl.valid) {
|
||||
return reply.code(400).send({ message: 'invalid proxy url' });
|
||||
}
|
||||
|
||||
@@ -84,31 +84,80 @@ describe('settings backup webdav api', () => {
|
||||
it('exports current backup to webdav through settings route', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValue(new Response(null, { status: 201 }));
|
||||
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 * * * *',
|
||||
}),
|
||||
}).run();
|
||||
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'backup_webdav_config_v1',
|
||||
value: JSON.stringify({
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/backup/webdav/export',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as { success?: boolean; fileUrl?: string };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.fileUrl).toBe('https://dav.example.com/backups/metapi.json');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid exportType payload when saving webdav config', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/backup/webdav',
|
||||
payload: {
|
||||
enabled: true,
|
||||
fileUrl: 'https://dav.example.com/backups/metapi.json',
|
||||
username: 'alice',
|
||||
password: 'secret-pass',
|
||||
exportType: 'all',
|
||||
autoSyncEnabled: false,
|
||||
autoSyncCron: '0 * * * *',
|
||||
}),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/backup/webdav/export',
|
||||
exportType: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as { success?: boolean; fileUrl?: string };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.fileUrl).toBe('https://dav.example.com/backups/metapi.json');
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('exportType');
|
||||
});
|
||||
|
||||
fetchSpy.mockRestore();
|
||||
it('rejects invalid export body type instead of silently falling back', async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValue(new Response(null, { status: 201 }));
|
||||
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 * * * *',
|
||||
}),
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/backup/webdav/export',
|
||||
payload: {
|
||||
type: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('type');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,5 +138,20 @@ describe('settings database migration api', () => {
|
||||
targetDb.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-boolean overwrite payload when migrating through settings api', async () => {
|
||||
const targetPath = join(dataDir, 'target-migrate-invalid.db');
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/database/migrate',
|
||||
payload: {
|
||||
dialect: 'sqlite',
|
||||
connectionString: targetPath,
|
||||
overwrite: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('overwrite');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +176,21 @@ describe('settings database runtime config api', () => {
|
||||
expect(putBody.saved?.ssl).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-boolean ssl payload when saving runtime database config', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/database/runtime',
|
||||
payload: {
|
||||
dialect: 'postgres',
|
||||
connectionString: 'postgres://user:pass@db.example.com:5432/mydb',
|
||||
ssl: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('ssl');
|
||||
});
|
||||
|
||||
it('includes ssl in active runtime state', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
|
||||
@@ -278,6 +278,21 @@ describe('settings and auth events', () => {
|
||||
expect(body.message).toContain('Webhook URL');
|
||||
});
|
||||
|
||||
it('rejects non-boolean webhookEnabled payloads instead of coercing them', async () => {
|
||||
(config as any).webhookEnabled = false;
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
webhookEnabled: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('Webhook 开关');
|
||||
expect(config.webhookEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects telegram config when bot token is missing but telegram is enabled', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
@@ -405,6 +420,20 @@ describe('settings and auth events', () => {
|
||||
expect(runtime.telegramUseSystemProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-boolean telegram use system proxy payloads instead of coercing them', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/settings/runtime',
|
||||
payload: {
|
||||
telegramUseSystemProxy: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('Telegram 使用系统代理');
|
||||
expect((config as any).telegramUseSystemProxy).toBe(false);
|
||||
});
|
||||
|
||||
it('persists and returns routing fallback unit cost from runtime settings', async () => {
|
||||
const updateResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
|
||||
@@ -103,6 +103,32 @@ describe('settings system proxy test route', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects array payloads for system proxy test', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/system-proxy/test',
|
||||
payload: [],
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('settings payload');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-string proxyUrl payloads for system proxy test', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/system-proxy/test',
|
||||
payload: {
|
||||
proxyUrl: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('string');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 502 when the proxy probe request fails', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('fetch failed', {
|
||||
cause: new Error('connect ECONNREFUSED 127.0.0.1:7890'),
|
||||
|
||||
@@ -26,6 +26,14 @@ import {
|
||||
testDatabaseConnection,
|
||||
type MigrationDialect,
|
||||
} from '../../services/databaseMigrationService.js';
|
||||
import {
|
||||
parseSystemProxyTestPayload,
|
||||
parseDatabaseMigrationPayload,
|
||||
parseBackupImportPayload,
|
||||
parseRuntimeSettingsPayload,
|
||||
parseBackupWebdavConfigPayload,
|
||||
parseBackupWebdavExportPayload,
|
||||
} from '../../contracts/settingsRoutePayloads.js';
|
||||
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
|
||||
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
||||
import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||
@@ -798,10 +806,18 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return { brands: getAllBrandNames() };
|
||||
});
|
||||
|
||||
app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
|
||||
const rawProxyUrl = request.body?.proxyUrl === undefined
|
||||
app.post<{ Body: unknown }>('/api/settings/system-proxy/test', async (request, reply) => {
|
||||
const parsedBody = parseSystemProxyTestPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: parsedBody.error,
|
||||
});
|
||||
}
|
||||
|
||||
const rawProxyUrl = parsedBody.data.proxyUrl === undefined
|
||||
? config.systemProxyUrl
|
||||
: String(request.body.proxyUrl || '').trim();
|
||||
: String(parsedBody.data.proxyUrl || '').trim();
|
||||
const normalizedProxyUrl = rawProxyUrl
|
||||
? normalizeSiteProxyUrl(rawProxyUrl)
|
||||
: '';
|
||||
@@ -835,8 +851,16 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
|
||||
const body = request.body || {};
|
||||
app.put<{ Body: unknown }>('/api/settings/runtime', async (request, reply) => {
|
||||
const parsedBody = parseRuntimeSettingsPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: parsedBody.error,
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsedBody.data as RuntimeSettingsBody;
|
||||
const changedLabels: string[] = [];
|
||||
const currentRequestIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
||||
|
||||
@@ -1590,9 +1614,17 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.put<{ Body: DatabaseMigrationBody }>('/api/settings/database/runtime', async (request, reply) => {
|
||||
app.put<{ Body: unknown }>('/api/settings/database/runtime', async (request, reply) => {
|
||||
try {
|
||||
const normalized = normalizeMigrationInput(request.body || {});
|
||||
const parsedBody = parseDatabaseMigrationPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: parsedBody.error,
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = normalizeMigrationInput(parsedBody.data);
|
||||
await upsertSetting(DB_TYPE_SETTING_KEY, normalized.dialect);
|
||||
await upsertSetting(DB_URL_SETTING_KEY, normalized.connectionString);
|
||||
await upsertSetting(DB_SSL_SETTING_KEY, normalized.ssl);
|
||||
@@ -1622,9 +1654,17 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/test-connection', async (request, reply) => {
|
||||
app.post<{ Body: unknown }>('/api/settings/database/test-connection', async (request, reply) => {
|
||||
try {
|
||||
const result = await testDatabaseConnection(request.body || {});
|
||||
const parsedBody = parseDatabaseMigrationPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: parsedBody.error,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await testDatabaseConnection(parsedBody.data);
|
||||
return {
|
||||
success: true,
|
||||
message: '目标数据库连接成功',
|
||||
@@ -1638,9 +1678,17 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/migrate', async (request, reply) => {
|
||||
app.post<{ Body: unknown }>('/api/settings/database/migrate', async (request, reply) => {
|
||||
try {
|
||||
const result = await migrateCurrentDatabase(request.body || {});
|
||||
const parsedBody = parseDatabaseMigrationPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: parsedBody.error,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await migrateCurrentDatabase(parsedBody.data);
|
||||
appendSettingsEvent({
|
||||
type: 'status',
|
||||
title: '数据库迁移已完成',
|
||||
@@ -1668,14 +1716,14 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return await exportBackup(type);
|
||||
});
|
||||
|
||||
app.post<{ Body: { data?: Record<string, unknown> } }>('/api/settings/backup/import', async (request, reply) => {
|
||||
const payload = request.body?.data;
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
app.post<{ Body: unknown }>('/api/settings/backup/import', async (request, reply) => {
|
||||
const parsedBody = parseBackupImportPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: '导入数据格式错误:需要 JSON 对象' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await importBackup(payload);
|
||||
const result = await importBackup(parsedBody.data.data);
|
||||
for (const item of result.appliedSettings) {
|
||||
applyImportedSettingToRuntime(item.key, item.value);
|
||||
}
|
||||
@@ -1701,7 +1749,15 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
|
||||
app.put<{ Body: BackupWebdavConfigBody }>('/api/settings/backup/webdav', async (request, reply) => {
|
||||
try {
|
||||
const body = request.body || {};
|
||||
const parsedBody = parseBackupWebdavConfigPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: parsedBody.error,
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const result = await saveBackupWebdavConfig({
|
||||
enabled: body.enabled === undefined ? undefined : body.enabled === true,
|
||||
fileUrl: body.fileUrl === undefined ? undefined : String(body.fileUrl || ''),
|
||||
@@ -1723,7 +1779,15 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post<{ Body: { type?: string } }>('/api/settings/backup/webdav/export', async (request, reply) => {
|
||||
try {
|
||||
const rawType = typeof request.body?.type === 'string' ? request.body.type.trim().toLowerCase() : '';
|
||||
const parsedBody = parseBackupWebdavExportPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: parsedBody.error,
|
||||
});
|
||||
}
|
||||
|
||||
const rawType = typeof parsedBody.data.type === 'string' ? parsedBody.data.type.trim().toLowerCase() : '';
|
||||
const type: BackupExportType | undefined = rawType === 'all' || rawType === 'accounts' || rawType === 'preferences'
|
||||
? rawType
|
||||
: undefined;
|
||||
|
||||
@@ -89,4 +89,20 @@ describe('sites batch routes', () => {
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { message?: string }).message).toContain('action');
|
||||
});
|
||||
|
||||
it('rejects non-number site ids at the route boundary', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites/batch',
|
||||
payload: {
|
||||
ids: ['1'],
|
||||
action: 'enable',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
message: 'Invalid ids. Expected number[].',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +178,22 @@ describe('sites disabled models API', () => {
|
||||
expect(resp.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when models contains non-string entries', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'test-site',
|
||||
url: 'https://test-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const resp = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sites/${site.id}/disabled-models`,
|
||||
payload: { models: ['gpt-4o', 123] },
|
||||
});
|
||||
expect(resp.statusCode).toBe(400);
|
||||
expect(resp.json()).toMatchObject({ error: 'Invalid models. Expected string[].' });
|
||||
});
|
||||
|
||||
it('deduplicates model names', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'test-site',
|
||||
|
||||
@@ -96,6 +96,32 @@ describe('sites proxy settings', () => {
|
||||
expect((duplicate.json() as { error?: string }).error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('normalizes platform before conflict checks when creating a site', 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',
|
||||
@@ -226,6 +252,31 @@ describe('sites proxy settings', () => {
|
||||
expect((response.json() as { error?: string }).error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('rejects blank platform updates instead of persisting an empty platform', async () => {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'editable-site',
|
||||
url: 'https://editable-site.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(200);
|
||||
const site = created.json() as { id: number };
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sites/${site.id}`,
|
||||
payload: {
|
||||
platform: ' ',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { error?: string }).error).toContain('platform');
|
||||
});
|
||||
|
||||
it('rejects invalid custom headers json', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -304,7 +355,7 @@ describe('sites proxy settings', () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: null,
|
||||
payload: [],
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
@@ -327,7 +378,7 @@ describe('sites proxy settings', () => {
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sites/${site.id}`,
|
||||
payload: null,
|
||||
payload: [],
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
@@ -368,6 +419,19 @@ describe('sites proxy settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects empty detect payload urls at the route boundary', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites/detect',
|
||||
payload: {
|
||||
url: ' ',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { error?: string }).error).toContain('url');
|
||||
});
|
||||
|
||||
it('does not force CodingPlan preset metadata when the user explicitly chooses generic openai', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -407,66 +471,6 @@ describe('sites proxy settings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('canonicalizes create payload url and platform before persistence and conflict checks', async () => {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'Aliyun Generic OpenAI',
|
||||
url: ' coding.dashscope.aliyuncs.com/v1/ ',
|
||||
platform: ' OPENAI ',
|
||||
},
|
||||
});
|
||||
|
||||
expect(created.statusCode).toBe(200);
|
||||
expect(created.json()).toMatchObject({
|
||||
name: 'Aliyun Generic OpenAI',
|
||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
platform: 'openai',
|
||||
});
|
||||
|
||||
const duplicate = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'Aliyun Generic OpenAI Duplicate',
|
||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
platform: 'openai',
|
||||
},
|
||||
});
|
||||
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect((duplicate.json() as { error?: string }).error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('canonicalizes update payload url and platform before saving', async () => {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'Update Canonicalization',
|
||||
url: 'https://update-canonicalization.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(200);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sites/${(created.json() as { id: number }).id}`,
|
||||
payload: {
|
||||
url: ' coding.dashscope.aliyuncs.com/v1/ ',
|
||||
platform: ' OPENAI ',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
platform: 'openai',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects Zhipu Coding Plan OpenAI endpoint with initialization preset metadata', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -2,13 +2,19 @@ import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { db, schema } from '../../db/index.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { detectSite } from '../../services/siteDetector.js';
|
||||
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
||||
import { invalidateSiteProxyCache, normalizeSiteUrl, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
|
||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
||||
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
import {
|
||||
parseSiteBatchPayload,
|
||||
parseSiteCreatePayload,
|
||||
parseSiteDetectPayload,
|
||||
parseSiteDisabledModelsPayload,
|
||||
parseSiteUpdatePayload,
|
||||
} from '../../contracts/siteRoutePayloads.js';
|
||||
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
|
||||
import { stripTrailingSlashes } from '../../services/urlNormalization.js';
|
||||
|
||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||
if (input === undefined || input === null) return null;
|
||||
@@ -84,15 +90,10 @@ type ErrorLike = {
|
||||
cause?: unknown;
|
||||
};
|
||||
|
||||
function normalizeSiteUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`;
|
||||
return stripTrailingSlashes(withScheme);
|
||||
}
|
||||
|
||||
function normalizeSitePlatform(platform?: string): string | undefined {
|
||||
if (platform === undefined) return undefined;
|
||||
return platform.trim().toLowerCase();
|
||||
function normalizeOptionalPlatform(value: string | undefined): string | null {
|
||||
if (value === undefined) return null;
|
||||
const normalized = value.trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function getErrorChain(error: unknown): ErrorLike[] {
|
||||
@@ -285,23 +286,12 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Add a site
|
||||
app.post<{ Body: {
|
||||
name: string;
|
||||
url: string;
|
||||
platform?: string;
|
||||
initializationPresetId?: string | null;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
customHeaders?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
status?: string;
|
||||
isPinned?: boolean;
|
||||
sortOrder?: number;
|
||||
globalWeight?: number;
|
||||
} }>('/api/sites', async (request, reply) => {
|
||||
if (!request.body || typeof request.body !== 'object' || Array.isArray(request.body)) {
|
||||
return reply.code(400).send({ error: 'Invalid site payload. Expected object.' });
|
||||
app.post<{ Body: unknown }>('/api/sites', async (request, reply) => {
|
||||
const parsedBody = parseSiteCreatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ error: parsedBody.error });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
url,
|
||||
@@ -315,16 +305,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
isPinned,
|
||||
sortOrder,
|
||||
globalWeight,
|
||||
} = request.body;
|
||||
if (typeof name !== 'string' || !name.trim()) {
|
||||
return reply.code(400).send({ error: 'Invalid name. Expected non-empty string.' });
|
||||
}
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
return reply.code(400).send({ error: 'Invalid url. Expected non-empty string.' });
|
||||
}
|
||||
if (platform !== undefined && typeof platform !== 'string') {
|
||||
return reply.code(400).send({ error: 'Invalid platform. Expected string.' });
|
||||
}
|
||||
} = parsedBody.data;
|
||||
const normalizedStatus = normalizeSiteStatus(status);
|
||||
if (status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
@@ -366,17 +347,16 @@ 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 canonicalUrl = normalizeSiteUrl(url);
|
||||
const canonicalPlatform = normalizeSitePlatform(platform);
|
||||
const normalizedUrl = normalizeSiteUrl(url);
|
||||
|
||||
let detectedPlatform = canonicalPlatform;
|
||||
let detectedPlatform = normalizeOptionalPlatform(platform);
|
||||
let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null;
|
||||
if (!detectedPlatform) {
|
||||
if (explicitInitializationPreset) {
|
||||
detectedPlatform = explicitInitializationPreset.platform;
|
||||
} else {
|
||||
const detected = await detectSite(canonicalUrl);
|
||||
detectedPlatform = detected?.platform;
|
||||
const detected = await detectSite(url);
|
||||
detectedPlatform = detected?.platform ?? null;
|
||||
responseInitializationPresetId = detected?.initializationPresetId || null;
|
||||
}
|
||||
}
|
||||
@@ -386,16 +366,16 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (!detectedPlatform) {
|
||||
return { error: 'Could not detect platform. Please specify manually.' };
|
||||
}
|
||||
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, canonicalUrl);
|
||||
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl);
|
||||
if (conflictingSite) {
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
||||
}
|
||||
|
||||
let inserted;
|
||||
try {
|
||||
inserted = await db.insert(schema.sites).values({
|
||||
name,
|
||||
url: canonicalUrl,
|
||||
url: normalizedUrl,
|
||||
platform: detectedPlatform,
|
||||
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||
@@ -408,7 +388,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
}).run();
|
||||
} catch (error) {
|
||||
if (isSitesPlatformUrlConflict(error)) {
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -428,19 +408,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Update a site
|
||||
app.put<{ Params: { id: string }; Body: {
|
||||
name?: string;
|
||||
url?: string;
|
||||
platform?: string;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
customHeaders?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
status?: string;
|
||||
isPinned?: boolean;
|
||||
sortOrder?: number;
|
||||
globalWeight?: number;
|
||||
} }>('/api/sites/:id', async (request, reply) => {
|
||||
app.put<{ Params: { id: string }; Body: unknown }>('/api/sites/:id', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid site id' });
|
||||
@@ -451,20 +419,13 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ error: 'Site not found' });
|
||||
}
|
||||
|
||||
const parsedBody = parseSiteUpdatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ error: parsedBody.error });
|
||||
}
|
||||
|
||||
const updates: any = {};
|
||||
if (!request.body || typeof request.body !== 'object' || Array.isArray(request.body)) {
|
||||
return reply.code(400).send({ error: 'Invalid site payload. Expected object.' });
|
||||
}
|
||||
const body = request.body;
|
||||
if (body.name !== undefined && (typeof body.name !== 'string' || !body.name.trim())) {
|
||||
return reply.code(400).send({ error: 'Invalid name. Expected non-empty string.' });
|
||||
}
|
||||
if (body.url !== undefined && (typeof body.url !== 'string' || !body.url.trim())) {
|
||||
return reply.code(400).send({ error: 'Invalid url. Expected non-empty string.' });
|
||||
}
|
||||
if (body.platform !== undefined && typeof body.platform !== 'string') {
|
||||
return reply.code(400).send({ error: 'Invalid platform. Expected string.' });
|
||||
}
|
||||
const body = parsedBody.data;
|
||||
const normalizedStatus = normalizeSiteStatus(body.status);
|
||||
if (body.status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
@@ -498,9 +459,13 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
||||
}
|
||||
|
||||
const canonicalPlatform = body.platform !== undefined ? normalizeSitePlatform(body.platform) : undefined;
|
||||
const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url;
|
||||
const nextPlatform = canonicalPlatform !== undefined ? canonicalPlatform : existingSite.platform;
|
||||
const nextPlatform = body.platform !== undefined
|
||||
? normalizeOptionalPlatform(body.platform)
|
||||
: existingSite.platform;
|
||||
if (body.platform !== undefined && !nextPlatform) {
|
||||
return reply.code(400).send({ error: 'Invalid platform. Expected non-empty string.' });
|
||||
}
|
||||
const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform;
|
||||
if (siteIdentityChanged) {
|
||||
const siteRows = await db.select({
|
||||
@@ -516,7 +481,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.url !== undefined) updates.url = nextUrl;
|
||||
if (body.platform !== undefined) updates.platform = canonicalPlatform;
|
||||
if (body.platform !== undefined) updates.platform = nextPlatform;
|
||||
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
||||
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
||||
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
|
||||
@@ -552,9 +517,14 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/sites/batch', async (request, reply) => {
|
||||
const ids = normalizeBatchIds(request.body?.ids);
|
||||
const action = String(request.body?.action || '').trim();
|
||||
app.post<{ Body: unknown }>('/api/sites/batch', async (request, reply) => {
|
||||
const parsedBody = parseSiteBatchPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ message: parsedBody.error });
|
||||
}
|
||||
|
||||
const ids = normalizeBatchIds(parsedBody.data.ids);
|
||||
const action = String(parsedBody.data.action || '').trim();
|
||||
if (ids.length === 0) {
|
||||
return reply.code(400).send({ message: 'ids is required' });
|
||||
}
|
||||
@@ -625,7 +595,12 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Update disabled models for a site (full replace)
|
||||
app.put<{ Params: { id: string }; Body: { models?: string[] } }>('/api/sites/:id/disabled-models', async (request, reply) => {
|
||||
app.put<{ Params: { id: string }; Body: unknown }>('/api/sites/:id/disabled-models', async (request, reply) => {
|
||||
const parsedBody = parseSiteDisabledModelsPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ error: parsedBody.error });
|
||||
}
|
||||
|
||||
const id = parseInt(request.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid site id' });
|
||||
@@ -634,7 +609,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (!existingSite) {
|
||||
return reply.code(404).send({ error: 'Site not found' });
|
||||
}
|
||||
const rawModels = request.body?.models;
|
||||
const rawModels = parsedBody.data.models;
|
||||
if (!Array.isArray(rawModels)) {
|
||||
return reply.code(400).send({ error: 'models must be an array of strings' });
|
||||
}
|
||||
@@ -703,8 +678,13 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Detect platform for a URL
|
||||
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
|
||||
const result = await detectSite(request.body.url);
|
||||
app.post<{ Body: unknown }>('/api/sites/detect', async (request, reply) => {
|
||||
const parsedBody = parseSiteDetectPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ error: parsedBody.error });
|
||||
}
|
||||
|
||||
const result = await detectSite(parsedBody.data.url);
|
||||
return result || { error: 'Could not detect platform' };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,36 +155,6 @@ describe('PUT /api/channels/batch', () => {
|
||||
expect(dbB?.manualOverride).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves duplicate priorities in batch updates', async () => {
|
||||
const channelA = await seedChannel({ priority: 9, weight: 17, manualOverride: false });
|
||||
const channelB = await seedChannel({ priority: 8, weight: 23, manualOverride: false });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/channels/batch',
|
||||
payload: {
|
||||
updates: [
|
||||
{ id: channelA.id, priority: 0 },
|
||||
{ id: channelB.id, priority: 0 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as {
|
||||
success: boolean;
|
||||
channels: Array<{ id: number; priority: number; weight: number; manualOverride: boolean }>;
|
||||
};
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.channels.find((channel) => channel.id === channelA.id)?.priority).toBe(0);
|
||||
expect(body.channels.find((channel) => channel.id === channelB.id)?.priority).toBe(0);
|
||||
|
||||
const dbA = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelA.id)).get();
|
||||
const dbB = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelB.id)).get();
|
||||
expect(dbA?.priority).toBe(0);
|
||||
expect(dbB?.priority).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the number of routes actually updated in route batch operations', async () => {
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
@@ -209,4 +179,37 @@ describe('PUT /api/channels/batch', () => {
|
||||
const updatedRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get();
|
||||
expect(updatedRoute?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects route batch payloads whose ids include non-number values', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/routes/batch',
|
||||
payload: {
|
||||
ids: ['1'],
|
||||
action: 'disable',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid ids. Expected number[].',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-boolean wait when rebuilding routes', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/routes/rebuild',
|
||||
payload: {
|
||||
wait: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid wait. Expected boolean.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -434,6 +434,150 @@ describe('PUT /api/routes/:id route rebuild', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-string displayName when creating explicit-group routes', async () => {
|
||||
const sourceRoute = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'claude-opus-4-5',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/routes',
|
||||
payload: {
|
||||
routeMode: 'explicit_group',
|
||||
displayName: 123,
|
||||
sourceRouteIds: [sourceRoute.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid displayName. Expected string or null.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-number sourceRouteIds when creating explicit-group routes', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/routes',
|
||||
payload: {
|
||||
routeMode: 'explicit_group',
|
||||
displayName: 'claude-opus-4-6',
|
||||
sourceRouteIds: ['1'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid sourceRouteIds. Expected number[].',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-boolean enabled when updating routes', async () => {
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/routes/${route.id}`,
|
||||
payload: {
|
||||
enabled: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid enabled. Expected boolean.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-number accountId when adding route channels', async () => {
|
||||
const seeded = await seedAccountWithToken('gpt-4o-mini');
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/routes/${route.id}/channels`,
|
||||
payload: {
|
||||
accountId: String(seeded.account.id),
|
||||
tokenId: seeded.token.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid accountId. Expected positive number.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-boolean enabled when updating channels', async () => {
|
||||
const seeded = await seedAccountWithToken('gpt-4o-mini');
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
const channel = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: seeded.account.id,
|
||||
tokenId: seeded.token.id,
|
||||
sourceModel: 'gpt-4o-mini',
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/channels/${channel.id}`,
|
||||
payload: {
|
||||
enabled: 'false',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid enabled. Expected boolean.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-number accountId when batch-adding route channels', async () => {
|
||||
const seeded = await seedAccountWithToken('gpt-4o-mini');
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/routes/${route.id}/channels/batch`,
|
||||
payload: {
|
||||
channels: [
|
||||
{
|
||||
accountId: String(seeded.account.id),
|
||||
tokenId: seeded.token.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid channels[].accountId. Expected positive number.',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers an exact route over a colliding explicit-group display name', async () => {
|
||||
const exactCandidate = await seedAccountWithToken('claude-opus-4-6');
|
||||
const groupedCandidate = await seedAccountWithToken('claude-opus-4-5');
|
||||
|
||||
@@ -20,6 +20,15 @@ import {
|
||||
saveRouteDecisionSnapshots,
|
||||
} from '../../services/routeDecisionSnapshotStore.js';
|
||||
import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js';
|
||||
import {
|
||||
parseRouteChannelBatchCreatePayload,
|
||||
parseRouteChannelCreatePayload,
|
||||
parseRouteChannelUpdatePayload,
|
||||
parseRouteRebuildPayload,
|
||||
parseTokenRouteBatchPayload,
|
||||
parseTokenRouteCreatePayload,
|
||||
parseTokenRouteUpdatePayload,
|
||||
} from '../../contracts/tokenRoutePayloads.js';
|
||||
|
||||
function isExactModelPattern(modelPattern: string): boolean {
|
||||
const normalized = modelPattern.trim();
|
||||
@@ -760,9 +769,14 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Batch add channels to a route
|
||||
app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => {
|
||||
app.post<{ Params: { id: string }; Body: unknown }>('/api/routes/:id/channels/batch', async (request, reply) => {
|
||||
const parsedBody = parseRouteChannelBatchCreatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const routeId = parseInt(request.params.id, 10);
|
||||
const body = request.body;
|
||||
const body = parsedBody.data;
|
||||
|
||||
const route = await getRouteWithSources(routeId);
|
||||
if (!route) {
|
||||
@@ -772,10 +786,6 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' });
|
||||
}
|
||||
|
||||
if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) {
|
||||
return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' });
|
||||
}
|
||||
|
||||
const existingChannels = await db.select().from(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.routeId, routeId))
|
||||
.all();
|
||||
@@ -792,11 +802,6 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const item of body.channels) {
|
||||
if (!item?.accountId || typeof item.accountId !== 'number') {
|
||||
errors.push('无效的 accountId');
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceModel = typeof item.sourceModel === 'string'
|
||||
? item.sourceModel.trim()
|
||||
: (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : '');
|
||||
@@ -955,8 +960,13 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Create a route
|
||||
app.post<{ Body: { routeMode?: string; modelPattern?: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean; sourceRouteIds?: number[] } }>('/api/routes', async (request, reply) => {
|
||||
const body = request.body;
|
||||
app.post<{ Body: unknown }>('/api/routes', async (request, reply) => {
|
||||
const parsedBody = parseTokenRouteCreatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const routeMode = normalizeRouteMode(body.routeMode);
|
||||
const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : '';
|
||||
const sourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds);
|
||||
@@ -1014,9 +1024,14 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Update a route
|
||||
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
|
||||
app.put<{ Params: { id: string }; Body: unknown }>('/api/routes/:id', async (request, reply) => {
|
||||
const parsedBody = parseTokenRouteUpdatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const id = parseInt(request.params.id, 10);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const body = parsedBody.data;
|
||||
const existingRoute = await getRouteWithSources(id);
|
||||
if (!existingRoute) {
|
||||
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||
@@ -1113,11 +1128,12 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
|
||||
|
||||
// Batch update routes (enable/disable)
|
||||
app.post<{ Body: { ids: number[]; action: 'enable' | 'disable' } }>('/api/routes/batch', async (request, reply) => {
|
||||
const body = request.body;
|
||||
if (!body || typeof body !== 'object') {
|
||||
return reply.code(400).send({ success: false, message: '请求体必须是对象' });
|
||||
app.post<{ Body: unknown }>('/api/routes/batch', async (request, reply) => {
|
||||
const parsedBody = parseTokenRouteBatchPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
const body = parsedBody.data;
|
||||
const action = body.action;
|
||||
if (action !== 'enable' && action !== 'disable') {
|
||||
return reply.code(400).send({ success: false, message: 'action 必须是 enable 或 disable' });
|
||||
@@ -1154,9 +1170,14 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
return { success: true, updatedCount: Number(updateResult?.changes || 0) };
|
||||
});
|
||||
// Add a channel to a route
|
||||
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
|
||||
app.post<{ Params: { id: string }; Body: unknown }>('/api/routes/:id/channels', async (request, reply) => {
|
||||
const parsedBody = parseRouteChannelCreatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const routeId = parseInt(request.params.id, 10);
|
||||
const body = request.body;
|
||||
const body = parsedBody.data;
|
||||
|
||||
const route = await getRouteWithSources(routeId);
|
||||
if (!route) {
|
||||
@@ -1247,9 +1268,14 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Update a channel
|
||||
app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => {
|
||||
app.put<{ Params: { channelId: string }; Body: unknown }>('/api/channels/:channelId', async (request, reply) => {
|
||||
const parsedBody = parseRouteChannelUpdatePayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const channelId = parseInt(request.params.channelId, 10);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const body = parsedBody.data;
|
||||
|
||||
const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||
if (!channel) {
|
||||
@@ -1282,9 +1308,10 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
else updates.sourceModel = String(body.sourceModel).trim() || null;
|
||||
}
|
||||
|
||||
for (const key of ['priority', 'weight', 'enabled', 'tokenId']) {
|
||||
if (body[key] !== undefined) updates[key] = body[key];
|
||||
}
|
||||
if (body.priority !== undefined) updates.priority = body.priority;
|
||||
if (body.weight !== undefined) updates.weight = body.weight;
|
||||
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
||||
if (body.tokenId !== undefined) updates.tokenId = body.tokenId;
|
||||
|
||||
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
await clearRouteDecisionSnapshot(channel.routeId);
|
||||
@@ -1307,14 +1334,19 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Rebuild routes/channels from model availability.
|
||||
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
|
||||
const body = (request.body || {}) as { refreshModels?: boolean };
|
||||
app.post<{ Body: unknown }>('/api/routes/rebuild', async (request, reply) => {
|
||||
const parsedBody = parseRouteRebuildPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
if (body.refreshModels === false) {
|
||||
const rebuild = await routeRefreshWorkflow.rebuildRoutesOnly();
|
||||
return { success: true, rebuild };
|
||||
}
|
||||
|
||||
if ((request.body as { wait?: boolean } | undefined)?.wait) {
|
||||
if (body.wait) {
|
||||
const result = await routeRefreshWorkflow.refreshModelsAndRebuildRoutes();
|
||||
return { success: true, ...result };
|
||||
}
|
||||
|
||||
@@ -256,6 +256,75 @@ describe('update center routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed config, deploy, and rollback payloads at the route boundary', async () => {
|
||||
const invalidConfigResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/update-center/config',
|
||||
payload: {
|
||||
enabled: 'false',
|
||||
},
|
||||
});
|
||||
expect(invalidConfigResponse.statusCode).toBe(400);
|
||||
expect(invalidConfigResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid enabled. Expected boolean.',
|
||||
});
|
||||
|
||||
const invalidDeployResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/update-center/deploy',
|
||||
payload: {
|
||||
targetTag: 123,
|
||||
},
|
||||
});
|
||||
expect(invalidDeployResponse.statusCode).toBe(400);
|
||||
expect(invalidDeployResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid targetTag. Expected string.',
|
||||
});
|
||||
|
||||
const invalidRollbackResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/update-center/rollback',
|
||||
payload: {
|
||||
targetRevision: 123,
|
||||
},
|
||||
});
|
||||
expect(invalidRollbackResponse.statusCode).toBe(400);
|
||||
expect(invalidRollbackResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid targetRevision. Expected string.',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid update-center source enums at the route boundary', async () => {
|
||||
const invalidConfigResponse = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/update-center/config',
|
||||
payload: {
|
||||
defaultDeploySource: 'nightly',
|
||||
},
|
||||
});
|
||||
expect(invalidConfigResponse.statusCode).toBe(400);
|
||||
expect(invalidConfigResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid defaultDeploySource. Expected docker-hub-tag/github-release.',
|
||||
});
|
||||
|
||||
const invalidDeployResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/update-center/deploy',
|
||||
payload: {
|
||||
source: 'nightly',
|
||||
},
|
||||
});
|
||||
expect(invalidDeployResponse.statusCode).toBe(400);
|
||||
expect(invalidDeployResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'Invalid source. Expected docker-hub-tag/github-release.',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the shared config helper token when request-time env lookup is unavailable', async () => {
|
||||
fetchLatestStableGitHubReleaseMock.mockResolvedValue({
|
||||
source: 'github-release',
|
||||
|
||||
@@ -31,6 +31,11 @@ import {
|
||||
UPDATE_CENTER_DEPLOY_DEDUPE_KEY,
|
||||
UPDATE_CENTER_DEPLOY_TASK_TYPE,
|
||||
} from '../../services/updateCenterTaskConstants.js';
|
||||
import {
|
||||
parseUpdateCenterConfigPayload,
|
||||
parseUpdateCenterDeployPayload,
|
||||
parseUpdateCenterRollbackPayload,
|
||||
} from '../../contracts/supportRoutePayloads.js';
|
||||
|
||||
type DeployBody = {
|
||||
source?: UpdateCenterVersionSource;
|
||||
@@ -84,8 +89,13 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
||||
return (await refreshUpdateCenterStatusCache()).status;
|
||||
});
|
||||
|
||||
app.put<{ Body: UpdateCenterConfig }>('/api/update-center/config', async (request) => {
|
||||
const next = normalizeUpdateCenterConfig(request.body || getDefaultUpdateCenterConfig());
|
||||
app.put<{ Body: unknown }>('/api/update-center/config', async (request, reply) => {
|
||||
const parsedBody = parseUpdateCenterConfigPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({ success: false, message: parsedBody.error });
|
||||
}
|
||||
|
||||
const next = normalizeUpdateCenterConfig(parsedBody.data || getDefaultUpdateCenterConfig());
|
||||
const saved = await saveUpdateCenterConfig(next);
|
||||
return {
|
||||
success: true,
|
||||
@@ -93,7 +103,16 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Body: DeployBody }>('/api/update-center/deploy', async (request, reply) => {
|
||||
app.post<{ Body: unknown }>('/api/update-center/deploy', async (request, reply) => {
|
||||
const parsedBody = parseUpdateCenterDeployPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: parsedBody.error,
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const config = await loadUpdateCenterConfig();
|
||||
try {
|
||||
assertDeployableConfig(config);
|
||||
@@ -105,13 +124,13 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
||||
}
|
||||
const helperToken = getUpdateCenterHelperToken();
|
||||
|
||||
const source = request.body?.source === 'docker-hub-tag'
|
||||
const source = body.source === 'docker-hub-tag'
|
||||
? 'docker-hub-tag'
|
||||
: request.body?.source === 'github-release'
|
||||
: body.source === 'github-release'
|
||||
? 'github-release'
|
||||
: config.defaultDeploySource;
|
||||
const targetTag = String(request.body?.targetTag || request.body?.targetVersion || '').trim();
|
||||
const targetDigest = normalizeUpdateCenterTargetDigest(request.body?.targetDigest);
|
||||
const targetTag = String(body.targetTag || body.targetVersion || '').trim();
|
||||
const targetDigest = normalizeUpdateCenterTargetDigest(body.targetDigest);
|
||||
if (!targetTag) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
@@ -175,7 +194,16 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
||||
});
|
||||
});
|
||||
|
||||
app.post<{ Body: RollbackBody }>('/api/update-center/rollback', async (request, reply) => {
|
||||
app.post<{ Body: unknown }>('/api/update-center/rollback', async (request, reply) => {
|
||||
const parsedBody = parseUpdateCenterRollbackPayload(request.body);
|
||||
if (!parsedBody.success) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: parsedBody.error,
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsedBody.data;
|
||||
const config = await loadUpdateCenterConfig();
|
||||
try {
|
||||
assertHelperConfig(config);
|
||||
@@ -186,7 +214,7 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const targetRevision = String(request.body?.targetRevision || '').trim();
|
||||
const targetRevision = String(body.targetRevision || '').trim();
|
||||
if (!targetRevision) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
|
||||
@@ -95,7 +95,7 @@ type UndiciConnectOptions = {
|
||||
httpSocket?: Socket;
|
||||
};
|
||||
|
||||
function normalizeSiteUrl(value: string): string {
|
||||
export function normalizeSiteUrl(value: string): string {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user