chenpei cd6e40d9d2 优化:网络传输性能和稳定性 - TCP缓冲区+并发控制优化
主要改动:
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>
2026-01-27 09:33:09 +08:00

326 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 媒体资源代理 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 }
);
}
}