统一API Base URL保存与请求时去除末尾斜杠

This commit is contained in:
mtvpls
2026-07-25 10:57:04 +08:00
parent 19f4957160
commit ca798177b9
16 changed files with 191 additions and 46 deletions
+5 -4
View File
@@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { normalizeApiBaseUrl } from '@/lib/url';
export const runtime = 'nodejs';
@@ -156,22 +157,22 @@ export async function POST(request: NextRequest) {
Enabled,
Provider,
OpenAIApiKey,
OpenAIBaseURL,
OpenAIBaseURL: normalizeApiBaseUrl(OpenAIBaseURL),
OpenAIModel,
ClaudeApiKey,
ClaudeModel,
CustomApiKey,
CustomBaseURL,
CustomBaseURL: normalizeApiBaseUrl(CustomBaseURL),
CustomModel,
EnableDecisionModel,
DecisionProvider,
DecisionOpenAIApiKey,
DecisionOpenAIBaseURL,
DecisionOpenAIBaseURL: normalizeApiBaseUrl(DecisionOpenAIBaseURL),
DecisionOpenAIModel,
DecisionClaudeApiKey,
DecisionClaudeModel,
DecisionCustomApiKey,
DecisionCustomBaseURL,
DecisionCustomBaseURL: normalizeApiBaseUrl(DecisionCustomBaseURL),
DecisionCustomModel,
EnableWebSearch,
WebSearchProvider,
+2 -1
View File
@@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig, setCachedConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { normalizeApiBaseUrl } from '@/lib/url';
export const runtime = 'nodejs';
@@ -63,7 +64,7 @@ export async function POST(request: NextRequest) {
// 更新缓存中的音乐配置
adminConfig.MusicConfig = {
Enabled,
BaseUrl,
BaseUrl: normalizeApiBaseUrl(BaseUrl),
Token,
ProxyEnabled: ProxyEnabled ?? true,
};
+13 -8
View File
@@ -11,6 +11,7 @@ import {
normalizePathMetaMap,
type OpenListPathMetaMap,
} from '@/lib/openlist-path-meta';
import { normalizeApiBaseUrl } from '@/lib/url';
export const runtime = 'nodejs';
@@ -82,18 +83,20 @@ export async function POST(request: NextRequest) {
if (action === 'save') {
const cleanedPathMeta = parsePathMeta(PathMeta);
const normalizedURL = normalizeApiBaseUrl(URL);
const normalizedOfflineDownloadURL = normalizeApiBaseUrl(OfflineDownloadURL);
// 如果功能未启用,允许保存空配置
if (!Enabled) {
adminConfig.OpenListConfig = {
Enabled: false,
URL: URL || '',
URL: normalizedURL,
Username: Username || '',
Password: Password || '',
RootPaths: RootPaths || ['/'],
OfflineDownloadPath: OfflineDownloadPath || '/',
OfflineDownloadUseCustomSource: OfflineDownloadUseCustomSource || false,
OfflineDownloadURL: OfflineDownloadURL || '',
OfflineDownloadURL: normalizedOfflineDownloadURL,
OfflineDownloadUsername: OfflineDownloadUsername || '',
OfflineDownloadPassword: OfflineDownloadPassword || '',
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
@@ -113,7 +116,7 @@ export async function POST(request: NextRequest) {
}
// 功能启用时,验证必填字段
if (!URL || !Username || !Password) {
if (!normalizedURL || !Username || !Password) {
return NextResponse.json(
{ error: '请提供 URL、账号和密码' },
{ status: 400 }
@@ -122,7 +125,9 @@ export async function POST(request: NextRequest) {
if (
OfflineDownloadUseCustomSource &&
(!OfflineDownloadURL || !OfflineDownloadUsername || !OfflineDownloadPassword)
(!normalizedOfflineDownloadURL ||
!OfflineDownloadUsername ||
!OfflineDownloadPassword)
) {
return NextResponse.json(
{ error: '请提供离线下载 OpenList URL、账号和密码' },
@@ -153,7 +158,7 @@ export async function POST(request: NextRequest) {
// 验证账号密码是否正确
try {
console.log('[OpenList Config] 验证账号密码');
await OpenListClient.login(URL, Username, Password);
await OpenListClient.login(normalizedURL, Username, Password);
console.log('[OpenList Config] 账号密码验证成功');
} catch (error) {
console.error('[OpenList Config] 账号密码验证失败:', error);
@@ -167,7 +172,7 @@ export async function POST(request: NextRequest) {
try {
console.log('[OpenList Config] 验证离线下载 OpenList 账号密码');
await OpenListClient.login(
OfflineDownloadURL,
normalizedOfflineDownloadURL,
OfflineDownloadUsername,
OfflineDownloadPassword
);
@@ -183,13 +188,13 @@ export async function POST(request: NextRequest) {
adminConfig.OpenListConfig = {
Enabled: true,
URL,
URL: normalizedURL,
Username,
Password,
RootPaths: cleanedRootPaths,
OfflineDownloadPath: OfflineDownloadPath || '/',
OfflineDownloadUseCustomSource: OfflineDownloadUseCustomSource || false,
OfflineDownloadURL: OfflineDownloadURL || '',
OfflineDownloadURL: normalizedOfflineDownloadURL,
OfflineDownloadUsername: OfflineDownloadUsername || '',
OfflineDownloadPassword: OfflineDownloadPassword || '',
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
+17 -15
View File
@@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { normalizeApiBaseUrl } from '@/lib/url';
export const runtime = 'nodejs';
@@ -264,6 +265,7 @@ export async function POST(request: NextRequest) {
}
// 更新缓存中的站点设置
// API Base URL 统一去尾斜杠,避免运行时拼接路径出现 //
adminConfig.SiteConfig = {
SiteName,
Announcement,
@@ -271,33 +273,33 @@ export async function POST(request: NextRequest) {
SearchDownstreamMaxPage,
SiteInterfaceCacheTime,
DoubanProxyType,
DoubanProxy,
DoubanProxy: normalizeApiBaseUrl(DoubanProxy),
DoubanImageProxyType,
DoubanImageProxy,
DoubanImageProxy: normalizeApiBaseUrl(DoubanImageProxy),
DisableYellowFilter,
FluidSearch,
DanmakuSourceType,
DanmakuApiBase,
DanmakuApiBase: normalizeApiBaseUrl(DanmakuApiBase),
DanmakuApiToken,
DanmakuAutoLoadDefault,
TMDBApiKey,
TMDBProxy,
TMDBReverseProxy,
TMDBProxy: normalizeApiBaseUrl(TMDBProxy),
TMDBReverseProxy: normalizeApiBaseUrl(TMDBReverseProxy),
BangumiDataSource,
BangumiApiBaseUrl,
BangumiImageBaseUrl,
BangumiProxy,
BangumiApiBaseUrl: normalizeApiBaseUrl(BangumiApiBaseUrl),
BangumiImageBaseUrl: normalizeApiBaseUrl(BangumiImageBaseUrl),
BangumiProxy: normalizeApiBaseUrl(BangumiProxy),
BannerDataSource,
RecommendationDataSource,
PansouApiUrl,
PansouApiUrl: normalizeApiBaseUrl(PansouApiUrl),
PansouUsername,
PansouPassword,
PansouKeywordBlocklist,
MagnetProxy,
MagnetMikanReverseProxy,
MagnetDmhyReverseProxy,
MagnetAcgripReverseProxy,
MagnetNyaaReverseProxy,
MagnetProxy: normalizeApiBaseUrl(MagnetProxy),
MagnetMikanReverseProxy: normalizeApiBaseUrl(MagnetMikanReverseProxy),
MagnetDmhyReverseProxy: normalizeApiBaseUrl(MagnetDmhyReverseProxy),
MagnetAcgripReverseProxy: normalizeApiBaseUrl(MagnetAcgripReverseProxy),
MagnetNyaaReverseProxy: normalizeApiBaseUrl(MagnetNyaaReverseProxy),
EnableComments,
CustomAdFilterCode,
CustomAdFilterVersion,
@@ -311,7 +313,7 @@ export async function POST(request: NextRequest) {
DefaultUserTags,
EnableOIDCLogin,
EnableOIDCRegistration,
OIDCIssuer,
OIDCIssuer: normalizeApiBaseUrl(OIDCIssuer),
OIDCAuthorizationEndpoint,
OIDCTokenEndpoint,
OIDCUserInfoEndpoint,
+3 -2
View File
@@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { normalizeApiBaseUrl } from '@/lib/url';
import { XiaoyaClient } from '@/lib/xiaoya.client';
export const runtime = 'nodejs';
@@ -27,7 +28,7 @@ export async function POST(request: NextRequest) {
// 测试连接
try {
const client = new XiaoyaClient(
configData.ServerURL,
normalizeApiBaseUrl(configData.ServerURL),
configData.Username,
configData.Password,
configData.Token
@@ -51,7 +52,7 @@ export async function POST(request: NextRequest) {
config.XiaoyaConfig = {
Enabled: configData.Enabled || false,
ServerURL: configData.ServerURL || '',
ServerURL: normalizeApiBaseUrl(configData.ServerURL),
Token: configData.Token,
Username: configData.Username,
Password: configData.Password,
+4 -1
View File
@@ -1,5 +1,7 @@
// AI评论生成核心逻辑
import { normalizeApiBaseUrl } from '@/lib/url';
export interface AIComment {
id: string;
userName: string;
@@ -164,7 +166,8 @@ export async function generateAIComments(
const prompt = buildCommentPrompt(movieName, movieInfo, searchResults, count);
// 3. 调用AI API
const response = await fetch(`${aiConfig.CustomBaseURL}/chat/completions`, {
const baseURL = normalizeApiBaseUrl(aiConfig.CustomBaseURL);
const response = await fetch(`${baseURL}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${aiConfig.CustomApiKey}`,
+4 -1
View File
@@ -6,6 +6,7 @@
import { fetchDoubanData as fetchDoubanAPI } from '@/lib/douban';
import { getNextApiKey } from '@/lib/tmdb.client';
import { normalizeApiBaseUrl } from '@/lib/url';
export interface VideoContext {
title?: string;
@@ -468,7 +469,9 @@ ${availableSources.length === 0 ? '⚠️ 没有可用的数据源,请返回
}
} else {
// OpenAI 或 自定义 (OpenAI兼容格式)
const baseURL = config.baseURL || 'https://api.openai.com/v1';
const baseURL = normalizeApiBaseUrl(
config.baseURL || 'https://api.openai.com/v1'
);
response = await fetch(`${baseURL}/chat/completions`, {
method: 'POST',
headers: {
+95
View File
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any, no-console, @typescript-eslint/no-non-null-assertion */
import { db } from '@/lib/db';
import { normalizeApiBaseUrl } from '@/lib/url';
import { AdminConfig } from './admin.types';
@@ -1047,6 +1048,100 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
}
}
// API Base URL 统一去尾斜杠,避免拼接路径时出现 //
const site = adminConfig.SiteConfig;
if (site) {
site.DoubanProxy = normalizeApiBaseUrl(site.DoubanProxy);
site.DoubanImageProxy = normalizeApiBaseUrl(site.DoubanImageProxy);
site.DanmakuApiBase = normalizeApiBaseUrl(site.DanmakuApiBase);
site.TMDBProxy = normalizeApiBaseUrl(site.TMDBProxy);
site.TMDBReverseProxy = normalizeApiBaseUrl(site.TMDBReverseProxy);
site.BangumiApiBaseUrl = normalizeApiBaseUrl(site.BangumiApiBaseUrl);
site.BangumiImageBaseUrl = normalizeApiBaseUrl(site.BangumiImageBaseUrl);
site.BangumiProxy = normalizeApiBaseUrl(site.BangumiProxy);
site.PansouApiUrl = normalizeApiBaseUrl(site.PansouApiUrl);
site.MagnetProxy = normalizeApiBaseUrl(site.MagnetProxy);
site.MagnetMikanReverseProxy = normalizeApiBaseUrl(
site.MagnetMikanReverseProxy
);
site.MagnetDmhyReverseProxy = normalizeApiBaseUrl(
site.MagnetDmhyReverseProxy
);
site.MagnetAcgripReverseProxy = normalizeApiBaseUrl(
site.MagnetAcgripReverseProxy
);
site.MagnetNyaaReverseProxy = normalizeApiBaseUrl(
site.MagnetNyaaReverseProxy
);
site.OIDCIssuer = normalizeApiBaseUrl(site.OIDCIssuer);
}
if (adminConfig.OpenListConfig) {
adminConfig.OpenListConfig.URL = normalizeApiBaseUrl(
adminConfig.OpenListConfig.URL
);
adminConfig.OpenListConfig.OfflineDownloadURL = normalizeApiBaseUrl(
adminConfig.OpenListConfig.OfflineDownloadURL
);
}
if (adminConfig.MusicConfig) {
adminConfig.MusicConfig.BaseUrl = normalizeApiBaseUrl(
adminConfig.MusicConfig.BaseUrl
);
}
if (adminConfig.XiaoyaConfig) {
adminConfig.XiaoyaConfig.ServerURL = normalizeApiBaseUrl(
adminConfig.XiaoyaConfig.ServerURL
);
}
if (adminConfig.SuwayomiConfig) {
adminConfig.SuwayomiConfig.ServerURL = normalizeApiBaseUrl(
adminConfig.SuwayomiConfig.ServerURL
);
}
if (adminConfig.EmbyConfig) {
if (adminConfig.EmbyConfig.ServerURL) {
adminConfig.EmbyConfig.ServerURL = normalizeApiBaseUrl(
adminConfig.EmbyConfig.ServerURL
);
}
if (Array.isArray(adminConfig.EmbyConfig.Sources)) {
adminConfig.EmbyConfig.Sources = adminConfig.EmbyConfig.Sources.map(
(source) => ({
...source,
ServerURL: normalizeApiBaseUrl(source.ServerURL),
})
);
}
}
if (adminConfig.AIConfig) {
adminConfig.AIConfig.OpenAIBaseURL = normalizeApiBaseUrl(
adminConfig.AIConfig.OpenAIBaseURL
);
adminConfig.AIConfig.CustomBaseURL = normalizeApiBaseUrl(
adminConfig.AIConfig.CustomBaseURL
);
adminConfig.AIConfig.DecisionOpenAIBaseURL = normalizeApiBaseUrl(
adminConfig.AIConfig.DecisionOpenAIBaseURL
);
adminConfig.AIConfig.DecisionCustomBaseURL = normalizeApiBaseUrl(
adminConfig.AIConfig.DecisionCustomBaseURL
);
}
if (adminConfig.TelegramConfig) {
adminConfig.TelegramConfig.apiBaseUrl = normalizeApiBaseUrl(
adminConfig.TelegramConfig.apiBaseUrl
);
}
// 注意:OPDS source.url 是完整目录 URL,保留末尾 / 以便相对路径解析
return adminConfig;
}
+3 -1
View File
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { normalizeApiBaseUrl } from '@/lib/url';
interface EmbyConfig {
ServerURL: string;
ApiKey?: string;
@@ -96,7 +98,7 @@ export class EmbyClient {
private embyAuthorizationHeader: string;
constructor(config: EmbyConfig) {
let serverUrl = config.ServerURL.replace(/\/$/, '');
let serverUrl = normalizeApiBaseUrl(config.ServerURL);
// 存储高级选项
this.removeEmbyPrefix = config.removeEmbyPrefix || false;
+4 -1
View File
@@ -1,4 +1,5 @@
import { getConfig } from '@/lib/config';
import { normalizeApiBaseUrl } from '@/lib/url';
export const runtime = 'nodejs';
@@ -209,7 +210,9 @@ export async function getMusicV2Config() {
const musicConfig = config?.MusicConfig;
const enabled = musicConfig?.Enabled ?? false;
const baseUrl = (musicConfig?.BaseUrl || process.env.MUSIC_V2_BASE_URL || '').replace(/\/$/, '');
const baseUrl = normalizeApiBaseUrl(
musicConfig?.BaseUrl || process.env.MUSIC_V2_BASE_URL || ''
);
const token = musicConfig?.Token || process.env.MUSIC_V2_TOKEN || '';
return { enabled, baseUrl, token };
+9 -3
View File
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { normalizeApiBaseUrl } from '@/lib/url';
// Token 内存缓存
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
@@ -34,12 +36,15 @@ export interface OpenListGetResponse {
export class OpenListClient {
private token = '';
private baseURL: string;
constructor(
private baseURL: string,
baseURL: string,
private username: string,
private password: string
) {}
) {
this.baseURL = normalizeApiBaseUrl(baseURL);
}
/**
* 使用账号密码登录获取Token
@@ -49,7 +54,8 @@ export class OpenListClient {
username: string,
password: string
): Promise<string> {
const response = await fetch(`${baseURL}/api/auth/login`, {
const normalizedBaseURL = normalizeApiBaseUrl(baseURL);
const response = await fetch(`${normalizedBaseURL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+9 -4
View File
@@ -5,6 +5,8 @@
* 文档: https://github.com/fish2018/pansou
*/
import { normalizeApiBaseUrl } from '@/lib/url';
// Token 缓存
let cachedToken: string | null = null;
let tokenExpiry: number | null = null;
@@ -40,7 +42,8 @@ export async function loginPansou(
password: string
): Promise<string> {
try {
const response = await fetch(`${apiUrl}/api/auth/login`, {
const baseUrl = normalizeApiBaseUrl(apiUrl);
const response = await fetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -111,9 +114,10 @@ export async function searchPansou(
}
): Promise<PansouSearchResult> {
try {
const baseUrl = normalizeApiBaseUrl(apiUrl);
// 获取 Token(如果需要认证)
const token = await getValidToken(
apiUrl,
baseUrl,
options?.username,
options?.password
);
@@ -141,7 +145,7 @@ export async function searchPansou(
body.cloud_types = options.cloudTypes;
}
const response = await fetch(`${apiUrl}/api/search`, {
const response = await fetch(`${baseUrl}/api/search`, {
method: 'POST',
headers,
body: JSON.stringify(body),
@@ -201,7 +205,8 @@ export function clearPansouToken(): void {
*/
export async function checkPansouHealth(apiUrl: string): Promise<boolean> {
try {
const response = await fetch(`${apiUrl}/api/health`, {
const baseUrl = normalizeApiBaseUrl(apiUrl);
const response = await fetch(`${baseUrl}/api/health`, {
method: 'GET',
});
+2 -1
View File
@@ -13,6 +13,7 @@ import {
MangaSearchResult,
MangaSource,
} from './manga.types';
import { normalizeApiBaseUrl } from './url';
interface GraphQLResponse<T> {
data?: T;
@@ -167,7 +168,7 @@ async function resolveSuwayomiConfig(options: SuwayomiClientOptions = {}): Promi
throw new Error('Suwayomi 未配置,请先在管理面板或环境变量中设置服务地址');
}
const normalizedBaseUrl = serverUrl.replace(/\/$/, '');
const normalizedBaseUrl = normalizeApiBaseUrl(serverUrl);
return {
serverBaseUrl: normalizedBaseUrl,
+2 -1
View File
@@ -13,6 +13,7 @@ import { getConfig } from './config';
import { db, getStorage } from './db';
import { lockManager } from './lock';
import type { IStorage, Notification } from './types';
import { normalizeApiBaseUrl } from './url';
import { getNotificationClickUrl } from './web-push';
export interface TelegramConfig {
@@ -426,7 +427,7 @@ function isCloudflareEnvironment(): boolean {
}
function normalizeTelegramApiBaseUrl(input?: string | null): string {
const base = (input || 'https://api.telegram.org').trim().replace(/\/+$/, '');
const base = normalizeApiBaseUrl(input || 'https://api.telegram.org');
return base || 'https://api.telegram.org';
}
+10
View File
@@ -0,0 +1,10 @@
/**
* 规范化 API Base URL:trim 并去除末尾斜杠,避免拼接路径时出现双斜杠。
*/
export function normalizeApiBaseUrl(
url: string | undefined | null
): string {
return String(url || '')
.trim()
.replace(/\/+$/, '');
}
+9 -3
View File
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { normalizeApiBaseUrl } from '@/lib/url';
// Token 内存缓存
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
@@ -17,13 +19,16 @@ export interface XiaoyaListResponse {
export class XiaoyaClient {
private token = '';
private baseURL: string;
constructor(
private baseURL: string,
baseURL: string,
private username?: string,
private password?: string,
private configToken?: string
) {}
) {
this.baseURL = normalizeApiBaseUrl(baseURL);
}
/**
* 使用账号密码登录获取Token
@@ -33,7 +38,8 @@ export class XiaoyaClient {
username: string,
password: string
): Promise<string> {
const response = await fetch(`${baseURL}/api/auth/login`, {
const normalizedBaseURL = normalizeApiBaseUrl(baseURL);
const response = await fetch(`${normalizedBaseURL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',