主要改动: 1. TCP缓冲区优化(16MB)- 解决公网上传速度慢问题 2. 并发数降低到1-3 - 避免4并发导致ERR_CONNECTION_RESET 3. 动态并发控制 - 根据网络环境自动调整 4. Nginx超时增加到120s - 支持大分片上传 5. 上传进度修复 - 修复剩余时间计算 6. 新增任务-页面上传器和详情组件 7. 统一部署文档为deploy-remote-build.ps1 问题修复: - 公网用户上传从200-900KB/s提升到预期3-5MB/s - 内网用户上传更稳定,无连接重置错误 - UploadProgress组件剩余时间计算修正 Co-Authored-By: Claude <noreply@anthropic.com>
326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
// 媒体资源代理 API - 解决混合内容问题
|
||
// 将 HTTP 资源通过 HTTPS 代理提供给前端
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import dns from 'dns';
|
||
import { promisify } from 'util';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
export const runtime = 'nodejs';
|
||
|
||
// 设置DNS服务器(使用docker-compose.yml中配置的DNS)
|
||
dns.setServers(['223.5.5.5', '8.8.8.8', '114.114.114.114']);
|
||
|
||
// DNS 解析缓存(避免高并发时DNS解析失败)
|
||
const dnsCache = new Map<string, { ip: string; expires: number }>();
|
||
const DNS_CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
|
||
const MAX_DNS_CACHE_SIZE = 1000; // 最大缓存1000个域名
|
||
const resolve4 = promisify(dns.resolve4);
|
||
|
||
// 定期清理过期的DNS缓存(每10分钟)
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
const keysToDelete: string[] = [];
|
||
|
||
dnsCache.forEach((value, key) => {
|
||
if (value.expires < now) {
|
||
keysToDelete.push(key);
|
||
}
|
||
});
|
||
|
||
keysToDelete.forEach(key => dnsCache.delete(key));
|
||
|
||
if (keysToDelete.length > 0) {
|
||
console.log(`[DNS Cache] 清理 ${keysToDelete.length} 个过期缓存,当前缓存数: ${dnsCache.size}`);
|
||
}
|
||
}, 10 * 60 * 1000);
|
||
|
||
// DNS 解析函数(带缓存和重试)
|
||
async function resolveHostnameWithRetry(hostname: string, retries = 3): Promise<void> {
|
||
// 检查缓存
|
||
const cached = dnsCache.get(hostname);
|
||
if (cached && cached.expires > Date.now()) {
|
||
return; // 缓存有效,直接返回
|
||
}
|
||
|
||
// 尝试解析(预热DNS缓存)
|
||
for (let i = 0; i < retries; i++) {
|
||
try {
|
||
const addresses = await resolve4(hostname);
|
||
if (addresses && addresses.length > 0) {
|
||
const ip = addresses[0];
|
||
|
||
// 检查缓存大小,如果超过限制,删除最旧的缓存(LRU)
|
||
if (dnsCache.size >= MAX_DNS_CACHE_SIZE) {
|
||
const firstKey = dnsCache.keys().next().value;
|
||
if (firstKey) {
|
||
dnsCache.delete(firstKey);
|
||
console.log(`[DNS Cache] 缓存已满,删除最旧的缓存: ${firstKey}`);
|
||
}
|
||
}
|
||
|
||
// 更新缓存
|
||
dnsCache.set(hostname, {
|
||
ip,
|
||
expires: Date.now() + DNS_CACHE_TTL,
|
||
});
|
||
return; // 解析成功
|
||
}
|
||
} catch (error: any) {
|
||
if (i === retries - 1) {
|
||
// 最后一次重试失败,记录但继续(让fetch自己处理DNS)
|
||
console.warn(`[Proxy Media] DNS预解析失败: ${hostname} - ${error.message}`);
|
||
return;
|
||
}
|
||
// 等待后重试(指数退避)
|
||
await new Promise(resolve => setTimeout(resolve, 100 * Math.pow(2, i)));
|
||
}
|
||
}
|
||
}
|
||
|
||
// 允许的域名白名单
|
||
const ALLOWED_DOMAINS = [
|
||
's3.1080p.hk',
|
||
's3.yydd.ai',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
];
|
||
|
||
// 允许的文件扩展名
|
||
const ALLOWED_EXTENSIONS = [
|
||
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', // 图片
|
||
'.mp4', '.webm', '.ogg', // 视频
|
||
'.mp3', '.wav', '.ogg', '.m4a', // 音频
|
||
];
|
||
|
||
export async function GET(request: NextRequest) {
|
||
let url: string | null = null;
|
||
try {
|
||
const searchParams = request.nextUrl.searchParams;
|
||
url = searchParams.get('url');
|
||
|
||
if (!url) {
|
||
console.warn('[Proxy Media] 缺少 url 参数');
|
||
return NextResponse.json(
|
||
{ error: '缺少 url 参数' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// 解析 URL
|
||
let targetUrl: URL;
|
||
try {
|
||
// 解码 URL(可能被双重编码)
|
||
const decodedUrl = decodeURIComponent(url);
|
||
targetUrl = new URL(decodedUrl);
|
||
} catch (error) {
|
||
console.error('[Proxy Media] 无效的 URL:', url, error);
|
||
return NextResponse.json(
|
||
{ error: '无效的 URL' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
// 检查域名是否在白名单中
|
||
const hostname = targetUrl.hostname.toLowerCase();
|
||
const isAllowed = ALLOWED_DOMAINS.some(domain =>
|
||
hostname === domain || hostname.endsWith(`.${domain}`)
|
||
);
|
||
|
||
if (!isAllowed) {
|
||
console.warn('[Proxy Media] 不允许的域名:', hostname, 'URL:', targetUrl.toString());
|
||
return NextResponse.json(
|
||
{ error: '不允许的域名' },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
// 检查文件扩展名(从路径中提取,忽略查询参数)
|
||
const pathname = targetUrl.pathname.toLowerCase();
|
||
// 移除查询参数和片段,只检查路径部分
|
||
const pathWithoutQuery = pathname.split('?')[0];
|
||
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(ext =>
|
||
pathWithoutQuery.endsWith(ext)
|
||
);
|
||
|
||
if (!hasAllowedExtension) {
|
||
console.warn('[Proxy Media] 不允许的文件类型:', pathname, 'URL:', targetUrl.toString());
|
||
return NextResponse.json(
|
||
{ error: '不允许的文件类型' },
|
||
{ status: 403 }
|
||
);
|
||
}
|
||
|
||
// 检查是否有 Range 请求(用于视频播放)
|
||
const rangeHeader = request.headers.get('range');
|
||
|
||
// 构建请求头
|
||
const fetchHeaders: HeadersInit = {
|
||
'User-Agent': 'Mozilla/5.0 (compatible; MediaProxy/1.0)',
|
||
};
|
||
|
||
// 如果有 Range 请求,转发给源服务器
|
||
if (rangeHeader) {
|
||
fetchHeaders['Range'] = rangeHeader;
|
||
}
|
||
|
||
// 创建超时控制器(兼容Node.js 18)
|
||
// 优化:根据是否有Range请求设置不同的超时时间
|
||
// Range请求(视频播放)需要更长超时,完整请求可以更快超时
|
||
const hasRangeRequest = !!request.headers.get('range')
|
||
const timeout = hasRangeRequest ? 30000 : 10000 // Range请求30秒,完整请求10秒
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||
|
||
try {
|
||
// 优化:对于内网MinIO地址,跳过DNS预解析(内网地址不需要DNS)
|
||
const isIntranetMinIO = /^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname) || hostname === 'localhost' || hostname === '127.0.0.1'
|
||
|
||
// 预热DNS缓存(仅对公网域名,内网地址跳过)
|
||
if (!isIntranetMinIO) {
|
||
// 使用Promise.race确保不会无限等待,减少DNS解析时间
|
||
await Promise.race([
|
||
resolveHostnameWithRetry(hostname),
|
||
new Promise(resolve => setTimeout(resolve, 300)), // 减少到300ms,加快响应
|
||
]).catch(() => {
|
||
// 静默失败,继续执行fetch(让fetch自己处理DNS)
|
||
});
|
||
}
|
||
|
||
// 获取资源(fetch会使用Node.js的DNS缓存和我们的预解析结果)
|
||
// 优化:添加keepalive和更短的超时时间
|
||
const response = await fetch(targetUrl.toString(), {
|
||
headers: {
|
||
...fetchHeaders,
|
||
'Connection': 'keep-alive', // 保持连接,减少连接建立时间
|
||
},
|
||
signal: controller.signal,
|
||
// 优化:减少超时时间,加快失败响应
|
||
// 注意:超时由AbortController控制,这里只是设置更短的超时
|
||
});
|
||
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok && response.status !== 206) { // 206 是部分内容响应,是正常的
|
||
// 对于 404 错误,记录详细信息以便诊断
|
||
if (response.status === 404) {
|
||
console.error(`[Proxy Media] 资源不存在 (404): ${targetUrl.toString()}`);
|
||
console.error(`[Proxy Media] 原始请求 URL: ${url}`);
|
||
console.error(`[Proxy Media] 解析后的 URL: ${targetUrl.toString()}`);
|
||
} else {
|
||
console.error(`[Proxy Media] 资源获取失败: ${response.status} - ${targetUrl.toString()}`);
|
||
}
|
||
// 对于 404,直接返回 404,让前端可以处理(比如显示占位图)
|
||
return NextResponse.json(
|
||
{ error: `资源获取失败: ${response.status}`, url: targetUrl.toString() },
|
||
{ status: response.status }
|
||
);
|
||
}
|
||
|
||
// 获取内容类型
|
||
const contentType = response.headers.get('content-type') || 'application/octet-stream';
|
||
const contentLength = response.headers.get('content-length');
|
||
const contentRange = response.headers.get('content-range');
|
||
const acceptRanges = response.headers.get('accept-ranges');
|
||
|
||
// 构建响应头(优化缓存策略)
|
||
const responseHeaders: HeadersInit = {
|
||
'Content-Type': contentType,
|
||
// 优化缓存:视频文件缓存1年,但允许重新验证
|
||
'Cache-Control': response.status === 206
|
||
? 'public, max-age=31536000, immutable' // Range请求:长期缓存
|
||
: 'public, max-age=3600, must-revalidate', // 完整请求:1小时缓存,需要重新验证
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||
'Access-Control-Allow-Headers': 'Range',
|
||
// 添加ETag支持(如果源服务器提供)
|
||
...(response.headers.get('etag') && { 'ETag': response.headers.get('etag')! }),
|
||
// 添加Last-Modified支持(如果源服务器提供)
|
||
...(response.headers.get('last-modified') && { 'Last-Modified': response.headers.get('last-modified')! }),
|
||
};
|
||
|
||
// 添加 Range 相关头(如果存在)
|
||
if (contentLength) {
|
||
responseHeaders['Content-Length'] = contentLength;
|
||
}
|
||
if (contentRange) {
|
||
responseHeaders['Content-Range'] = contentRange;
|
||
}
|
||
if (acceptRanges) {
|
||
responseHeaders['Accept-Ranges'] = acceptRanges;
|
||
}
|
||
|
||
// 使用流式传输,避免大文件导致内存溢出
|
||
// 将 ReadableStream 转换为 NextResponse 可以使用的流
|
||
if (response.body) {
|
||
return new NextResponse(response.body, {
|
||
status: response.status, // 可能是 200 或 206
|
||
headers: responseHeaders,
|
||
});
|
||
} else {
|
||
// 如果 response.body 不存在,回退到 arrayBuffer(小文件)
|
||
const buffer = await response.arrayBuffer();
|
||
return new NextResponse(buffer, {
|
||
status: response.status,
|
||
headers: responseHeaders,
|
||
});
|
||
}
|
||
} catch (fetchError: any) {
|
||
clearTimeout(timeoutId);
|
||
|
||
// 如果是超时或中止错误
|
||
if (fetchError.name === 'AbortError' || fetchError.name === 'TimeoutError') {
|
||
console.error(`[Proxy Media] 请求超时: ${targetUrl.toString()}`);
|
||
return NextResponse.json(
|
||
{ error: '请求超时', url: targetUrl.toString() },
|
||
{ status: 504 }
|
||
);
|
||
}
|
||
|
||
// 其他fetch错误(如连接失败)
|
||
console.error(`[Proxy Media] Fetch错误: ${fetchError.message} - ${targetUrl.toString()}`);
|
||
console.error(`[Proxy Media] 原始请求 URL: ${url}`);
|
||
throw fetchError; // 重新抛出,让外层catch处理
|
||
}
|
||
} catch (error: any) {
|
||
console.error('[Proxy Media] Error:', error);
|
||
console.error('[Proxy Media] 原始请求 URL:', url);
|
||
|
||
// 如果是DNS解析错误
|
||
if (error.code === 'EAI_AGAIN' || error.code === 'ENOTFOUND' || error.message?.includes('getaddrinfo')) {
|
||
console.error(`[Proxy Media] DNS解析错误: ${error.code} - ${error.message}`);
|
||
return NextResponse.json(
|
||
{ error: 'DNS解析失败,请稍后重试', details: error.message },
|
||
{ status: 502 }
|
||
);
|
||
}
|
||
|
||
// 如果是网络连接错误
|
||
if (error.message?.includes('ECONNREFUSED') || error.message?.includes('ENOTFOUND') || error.message?.includes('ERR_CONNECTION')) {
|
||
console.error(`[Proxy Media] 网络连接错误: ${error.message}`);
|
||
return NextResponse.json(
|
||
{ error: '无法连接到资源服务器', details: error.message },
|
||
{ status: 502 }
|
||
);
|
||
}
|
||
|
||
// 如果是超时错误
|
||
if (error.name === 'AbortError' || error.name === 'TimeoutError') {
|
||
return NextResponse.json(
|
||
{ error: '请求超时' },
|
||
{ status: 504 }
|
||
);
|
||
}
|
||
|
||
console.error(`[Proxy Media] 未知错误: ${error.message}`, error);
|
||
// 记录完整的错误信息(包括cause)
|
||
if (error.cause) {
|
||
console.error(`[Proxy Media] 错误原因:`, error.cause);
|
||
}
|
||
return NextResponse.json(
|
||
{ error: '代理请求失败', details: error.message },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|
||
|