diff --git a/README.md b/README.md index 8227bee..a218878 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - 基于 Next.js 14 App Router 的素材协作与分发系统 - 默认以新素材流程为主,不再把“新老素材兼容”作为日常主路径 - 同时使用 PostgreSQL、Prisma、S3/MinIO、本地 `uploads` 目录,以及可选 Redis 上传会话 -- 已覆盖首页浏览、素材上传、分享下载、后台管理、外部接口、网络诊断、健康检查等完整链路 +- 已覆盖首页浏览、素材上传、分享下载、后台管理、外部接口、内外网状态识别、健康检查等完整链路 - 当前项目已经在线上运行,涉及权限、下载、上传、转码、批量删除等改动时需要先验证再发布 ## 当前核心能力 @@ -53,7 +53,7 @@ ### 5. 运行与诊断 -- 网络诊断页面与接口 +- 内外网状态识别、健康检查与受控运维监控 - 健康检查接口与内存检查接口 - 快速性能检查接口 - 内外网 URL 重写与下载链路适配 diff --git a/app/api/network-diagnose/route.ts b/app/api/network-diagnose/route.ts deleted file mode 100644 index 8ec8e3f..0000000 --- a/app/api/network-diagnose/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -// 网络诊断API - 用于测试上传/下载速度 -import { NextRequest, NextResponse } from 'next/server'; - -export const dynamic = 'force-dynamic'; -export const maxDuration = 60; // 最长60秒 - -// 生成测试数据 -function generateTestData(size = 10 * 1024 * 1024): Uint8Array { - const data = new Uint8Array(size); - for (let i = 0; i < data.length; i++) { - data[i] = i % 251; // 使用简单的模式填充 - } - return data; -} - -// GET - 下载测试(返回测试数据) -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const mode = searchParams.get('mode') || 'download'; - - // 下载速度测试 - if (mode === 'download') { - const testData = generateTestData(); - return new Response(testData as any, { - status: 200, - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Length': testData.byteLength.toString(), - 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0', - }, - }); - } - - // S3存储测试 - if (mode === 'storage') { - const s3Url = getS3PublicUrl(); - const startTime = Date.now(); - - try { - const response = await fetch(s3Url, { - method: 'GET', - redirect: 'manual', - cache: 'no-store', - }); - - const duration = Date.now() - startTime; - - return NextResponse.json({ - ok: response.ok, - status: response.status, - durationMs: duration, - url: s3Url, - contentLength: response.headers.get('content-length'), - location: response.headers.get('location'), - }); - } catch (error: any) { - return NextResponse.json( - { - ok: false, - error: error?.message || String(error), - url: s3Url, - }, - { status: 500 } - ); - } - } - - // Ping测试 - if (mode === 'ping') { - return NextResponse.json({ - ok: true, - message: 'pong', - timestamp: new Date().toISOString(), - }); - } - - // 默认返回测试数据 - const testData = generateTestData(); - return new Response(testData as any, { - status: 200, - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Length': testData.byteLength.toString(), - 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0', - }, - }); -} - -// POST - 上传测试(接收测试数据) -export async function POST(request: NextRequest) { - try { - const data = await request.arrayBuffer(); - return NextResponse.json({ - ok: true, - receivedBytes: data.byteLength, - timestamp: new Date().toISOString(), - }); - } catch (error: any) { - return NextResponse.json( - { - ok: false, - error: error?.message || String(error), - }, - { status: 500 } - ); - } -} - -// 获取S3公网URL -function getS3PublicUrl(): string { - const s3Domain = process.env.S3_PUBLIC_DOMAIN || process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL; - - if (!s3Domain) { - return 'https://s3.jiansun.vip'; - } - - // 确保URL格式正确 - if (/^https?:\/\//i.test(s3Domain)) { - return s3Domain.replace(/\/$/, ''); - } - - return `https://${s3Domain.replace(/\/$/, '')}`; -} diff --git a/app/api/network/test-download/route.ts b/app/api/network/test-download/route.ts deleted file mode 100644 index c7b4504..0000000 --- a/app/api/network/test-download/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { authOptions } from '@/lib/auth' -import { hasSystemAdminPermission } from '@/lib/department-permission' - -const MAX_TEST_SIZE_BYTES = 5 * 1024 * 1024 -const RATE_LIMIT_WINDOW_MS = 60 * 1000 -const RATE_LIMIT_MAX_REQUESTS = 10 -const requestCounts = new Map() - -function consumeRateLimit(userId: string): number | null { - const now = Date.now() - const current = requestCounts.get(userId) - if (!current || current.resetAt <= now) { - requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }) - return null - } - if (current.count >= RATE_LIMIT_MAX_REQUESTS) { - return Math.max(1, Math.ceil((current.resetAt - now) / 1000)) - } - current.count += 1 - return null -} - -// 下载测速API -export async function POST(request: NextRequest) { - try { - const session = await getServerSession(authOptions) - if (!session?.user) { - return NextResponse.json({ error: '请先登录' }, { status: 401 }) - } - - const user = session.user as any - if (!hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) { - return NextResponse.json({ error: '仅系统管理员可执行下载测速' }, { status: 403 }) - } - - const retryAfter = consumeRateLimit(String(user.id || 'unknown')) - if (retryAfter) { - return NextResponse.json( - { error: '请求过于频繁,请稍后再试' }, - { status: 429, headers: { 'Retry-After': String(retryAfter) } }, - ) - } - - const body = await request.json() - const size = body?.size === undefined ? 1024 * 1024 : Number(body.size) - if (!Number.isInteger(size) || size < 1 || size > MAX_TEST_SIZE_BYTES) { - return NextResponse.json( - { error: `size 必须是 1 到 ${MAX_TEST_SIZE_BYTES} 之间的整数` }, - { status: 400 }, - ) - } - - // 生成测试数据 - const buffer = Buffer.alloc(size, 0) - - return new NextResponse(buffer, { - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Length': size.toString(), - 'Cache-Control': 'no-store', - } - }) - } catch (error) { - console.error('下载测速失败:', error) - return NextResponse.json({ error: '下载测速失败' }, { status: 500 }) - } -} diff --git a/app/api/network/test-storage/route.ts b/app/api/network/test-storage/route.ts deleted file mode 100644 index 4a21957..0000000 --- a/app/api/network/test-storage/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NextResponse } from 'next/server' -import { testS3Connection } from '@/lib/s3' - -// 存储连通性测试API -export async function GET() { - try { - // 测试S3连接 - const s3Connected = await testS3Connection() - - if (s3Connected) { - return NextResponse.json({ - success: true, - message: 'S3存储服务连接正常' - }) - } else { - return NextResponse.json({ - success: false, - message: 'S3存储服务连接失败' - }, { status: 500 }) - } - } catch (error) { - console.error('存储连通性测试失败:', error) - return NextResponse.json({ - success: false, - message: '存储服务连接失败' - }, { status: 500 }) - } -} diff --git a/app/api/network/test-upload/route.ts b/app/api/network/test-upload/route.ts deleted file mode 100644 index 402fd02..0000000 --- a/app/api/network/test-upload/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' - -// 上传测速API -export async function POST(request: NextRequest) { - try { - const formData = await request.formData() - const file = formData.get('file') as File - - if (!file) { - return NextResponse.json({ error: '缺少文件' }, { status: 400 }) - } - - // 读取文件内容(模拟上传处理) - await file.arrayBuffer() - - return NextResponse.json({ - success: true, - size: file.size, - message: '上传测试完成' - }) - } catch (error) { - console.error('上传测速失败:', error) - return NextResponse.json({ error: '上传测速失败' }, { status: 500 }) - } -} diff --git a/app/api/test-s3-speed/route.ts b/app/api/test-s3-speed/route.ts deleted file mode 100644 index bc6f9c0..0000000 --- a/app/api/test-s3-speed/route.ts +++ /dev/null @@ -1,708 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server' -import { getServerSession } from 'next-auth' -import { S3Client, GetObjectCommand, PutObjectCommand, ListObjectsV2Command, DeleteObjectCommand, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3' -import { randomBytes } from 'crypto' -import { detectIsIntranet } from '@/lib/network-detection' -import { authOptions } from '@/lib/auth' -import { hasSystemAdminPermission } from '@/lib/department-permission' - -export const dynamic = 'force-dynamic' - -const ALLOWED_TEST_TYPES = new Set(['full', 'concurrent', 'mixed', 'quick']) -const MAX_CONCURRENCY = 10 -const MAX_REQUEST_COUNT = 50 -const MAX_MIXED_DURATION_MS = 10_000 -const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000 -const RATE_LIMIT_MAX_REQUESTS = 2 -const requestCounts = new Map() - -function consumeRateLimit(userId: string): number | null { - const now = Date.now() - const current = requestCounts.get(userId) - if (!current || current.resetAt <= now) { - requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }) - return null - } - if (current.count >= RATE_LIMIT_MAX_REQUESTS) { - return Math.max(1, Math.ceil((current.resetAt - now) / 1000)) - } - current.count += 1 - return null -} - -// 创建S3客户端 -const s3Client = new S3Client({ - endpoint: process.env.S3_ENDPOINT || '', - region: process.env.S3_REGION || 'us-east-1', - credentials: { - accessKeyId: process.env.S3_ACCESS_KEY_ID!, - secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, - }, - forcePathStyle: true, - requestHandler: { - requestTimeout: 60000, // 增加到60秒以支持大文件 - connectionTimeout: 10000, - }, -}) - -// 计算统计信息 -function calculateStats(durations: number[]) { - if (durations.length === 0) { - return { - min: 0, - max: 0, - avg: 0, - p50: 0, - p90: 0, - p95: 0, - p99: 0, - total: 0, - } - } - const sorted = [...durations].sort((a, b) => a - b) - return { - min: sorted[0], - max: sorted[sorted.length - 1], - avg: durations.reduce((a, b) => a + b, 0) / durations.length, - p50: sorted[Math.floor(sorted.length * 0.5)], - p90: sorted[Math.floor(sorted.length * 0.9)], - p95: sorted[Math.floor(sorted.length * 0.95)], - p99: sorted[Math.floor(sorted.length * 0.99)], - total: durations.reduce((a, b) => a + b, 0), - } -} - -// 测试连接速度 -async function testConnectionSpeed(): Promise { - const startTime = Date.now() - try { - const command = new ListObjectsV2Command({ - Bucket: process.env.S3_BUCKET!, - MaxKeys: 1, - }) - await s3Client.send(command) - return Date.now() - startTime - } catch (error: any) { - return Date.now() - startTime - } -} - -// 测试上传速度(单个) -async function testUploadSpeed(sizeKB: number, testKey?: string): Promise<{ duration: number; speed: number; success: boolean; error?: string; key?: string }> { - const key = testKey || `speed-test-${Date.now()}-${Math.random().toString(36).substring(7)}.bin` - const testData = randomBytes(sizeKB * 1024) - - const startTime = Date.now() - try { - const command = new PutObjectCommand({ - Bucket: process.env.S3_BUCKET!, - Key: key, - Body: testData, - ContentType: 'application/octet-stream', - }) - await s3Client.send(command) - const duration = Date.now() - startTime - const speed = (sizeKB / duration) * 1000 // KB/s - return { duration, speed, success: true, key } - } catch (error: any) { - const duration = Date.now() - startTime - return { duration, speed: 0, success: false, error: error.message, key } - } -} - -// 测试下载速度(通过HTTP) -async function testDownloadSpeed(url: string): Promise<{ duration: number; speed: number; ttfb: number; success: boolean; error?: string }> { - const startTime = Date.now() - let ttfb = 0 - try { - const response = await fetch(url, { - method: 'HEAD', - signal: AbortSignal.timeout(30000), - }) - ttfb = Date.now() - startTime - - if (response.ok) { - const contentLength = response.headers.get('content-length') - if (contentLength) { - const sizeKB = parseInt(contentLength) / 1024 - - // 如果文件较小,尝试完整下载测试 - if (sizeKB < 5000) { // 增加到5MB - const downloadStart = Date.now() - const downloadResponse = await fetch(url, { - signal: AbortSignal.timeout(60000), - }) - if (downloadResponse.ok) { - await downloadResponse.arrayBuffer() - const downloadDuration = Date.now() - downloadStart - const speed = (sizeKB / downloadDuration) * 1000 // KB/s - return { duration: downloadDuration, speed, ttfb, success: true } - } - } - return { duration: ttfb, speed: 0, ttfb, success: true } - } - return { duration: ttfb, speed: 0, ttfb, success: true } - } else { - return { duration: ttfb, speed: 0, ttfb, success: false, error: `HTTP ${response.status}` } - } - } catch (error: any) { - const duration = Date.now() - startTime - return { duration, speed: 0, ttfb: duration, success: false, error: error.message } - } -} - -// 并发上传测试 -async function testConcurrentUploads( - concurrency: number, - sizeKB: number, - count: number -): Promise<{ - total: number - success: number - failed: number - durations: number[] - speeds: number[] - stats: ReturnType - throughput: number // 总吞吐量 MB/s -}> { - const durations: number[] = [] - const speeds: number[] = [] - let success = 0 - let failed = 0 - const keys: string[] = [] - - const startTime = Date.now() - - // 分批执行,每批concurrency个 - for (let i = 0; i < count; i += concurrency) { - const batch = [] - for (let j = 0; j < concurrency && i + j < count; j++) { - batch.push(testUploadSpeed(sizeKB)) - } - - const results = await Promise.allSettled(batch) - - for (const result of results) { - if (result.status === 'fulfilled') { - const r = result.value - if (r.success) { - durations.push(r.duration) - speeds.push(r.speed) - success++ - if (r.key) keys.push(r.key) - } else { - failed++ - } - } else { - failed++ - } - } - } - - const totalTime = Date.now() - startTime - const totalSizeMB = (success * sizeKB) / 1024 - const throughput = totalTime > 0 ? (totalSizeMB / totalTime) * 1000 : 0 // MB/s - - // 清理测试文件 - if (keys.length > 0) { - await Promise.allSettled( - keys.map(key => - s3Client.send( - new DeleteObjectCommand({ - Bucket: process.env.S3_BUCKET!, - Key: key, - }) - ) - ) - ) - } - - return { - total: count, - success, - failed, - durations, - speeds, - stats: calculateStats(durations), - throughput, - } -} - -// 并发下载测试 -async function testConcurrentDownloads( - concurrency: number, - urls: string[], - count: number -): Promise<{ - total: number - success: number - failed: number - durations: number[] - speeds: number[] - ttfb: number[] - stats: ReturnType - ttfbStats: ReturnType - throughput: number // 总吞吐量 MB/s -}> { - const durations: number[] = [] - const speeds: number[] = [] - const ttfb: number[] = [] - let success = 0 - let failed = 0 - - const startTime = Date.now() - - // 循环使用URLs - for (let i = 0; i < count; i += concurrency) { - const batch = [] - for (let j = 0; j < concurrency && i + j < count; j++) { - const url = urls[(i + j) % urls.length] - batch.push(testDownloadSpeed(url)) - } - - const results = await Promise.allSettled(batch) - - for (const result of results) { - if (result.status === 'fulfilled') { - const r = result.value - if (r.success) { - durations.push(r.duration) - speeds.push(r.speed) - ttfb.push(r.ttfb) - success++ - } else { - failed++ - } - } else { - failed++ - } - } - } - - const totalTime = Date.now() - startTime - // 估算总大小(假设每个文件平均大小) - const avgSizeKB = speeds.length > 0 ? (speeds.reduce((a, b) => a + b, 0) / speeds.length) * (durations.reduce((a, b) => a + b, 0) / durations.length) / 1000 : 0 - const totalSizeMB = (success * avgSizeKB) / 1024 - const throughput = totalTime > 0 ? (totalSizeMB / totalTime) * 1000 : 0 // MB/s - - return { - total: count, - success, - failed, - durations, - speeds, - ttfb, - stats: calculateStats(durations), - ttfbStats: calculateStats(ttfb), - throughput, - } -} - -// 混合负载测试(同时上传和下载) -async function testMixedLoad( - uploadConcurrency: number, - downloadConcurrency: number, - uploadSizeKB: number, - downloadUrls: string[], - duration: number // 测试持续时间(毫秒) -): Promise<{ - upload: { - total: number - success: number - failed: number - throughput: number - } - download: { - total: number - success: number - failed: number - throughput: number - } -}> { - let uploadTotal = 0 - let uploadSuccess = 0 - let uploadFailed = 0 - let uploadTotalSizeMB = 0 - let downloadTotal = 0 - let downloadSuccess = 0 - let downloadFailed = 0 - let downloadTotalSizeMB = 0 - - const startTime = Date.now() - const endTime = startTime + duration - - // 启动上传任务 - const uploadTask = (async () => { - while (Date.now() < endTime) { - const batch = [] - for (let i = 0; i < uploadConcurrency; i++) { - batch.push(testUploadSpeed(uploadSizeKB)) - } - const results = await Promise.allSettled(batch) - - for (const result of results) { - uploadTotal++ - if (result.status === 'fulfilled') { - const r = result.value - if (r.success) { - uploadSuccess++ - uploadTotalSizeMB += uploadSizeKB / 1024 - // 清理 - if (r.key) { - s3Client.send( - new DeleteObjectCommand({ - Bucket: process.env.S3_BUCKET!, - Key: r.key, - }) - ).catch(() => {}) - } - } else { - uploadFailed++ - } - } else { - uploadFailed++ - } - } - } - })() - - // 启动下载任务 - const downloadTask = (async () => { - while (Date.now() < endTime) { - const batch = [] - for (let i = 0; i < downloadConcurrency; i++) { - const url = downloadUrls[i % downloadUrls.length] - if (url) { - batch.push(testDownloadSpeed(url)) - } - } - const results = await Promise.allSettled(batch) - - for (const result of results) { - downloadTotal++ - if (result.status === 'fulfilled') { - const r = result.value - if (r.success) { - downloadSuccess++ - // 估算大小 - if (r.speed > 0) { - downloadTotalSizeMB += (r.speed * r.duration) / (1024 * 1000) - } - } else { - downloadFailed++ - } - } else { - downloadFailed++ - } - } - } - })() - - await Promise.all([uploadTask, downloadTask]) - - const actualDuration = Date.now() - startTime - - return { - upload: { - total: uploadTotal, - success: uploadSuccess, - failed: uploadFailed, - throughput: actualDuration > 0 ? (uploadTotalSizeMB / actualDuration) * 1000 : 0, - }, - download: { - total: downloadTotal, - success: downloadSuccess, - failed: downloadFailed, - throughput: actualDuration > 0 ? (downloadTotalSizeMB / actualDuration) * 1000 : 0, - }, - } -} - -// 获取测试文件URLs -async function getTestFileUrls(count: number = 10): Promise { - const urls: string[] = [] - try { - const command = new ListObjectsV2Command({ - Bucket: process.env.S3_BUCKET!, - MaxKeys: count, - }) - const response = await s3Client.send(command) - if (response.Contents && response.Contents.length > 0) { - for (const file of response.Contents) { - const key = file.Key || '' - // 跳过测试文件 - if (key && !key.includes('speed-test-')) { - urls.push(`${process.env.S3_PUBLIC_DOMAIN}/${key}`) - } - } - } - } catch (error) { - // ignore - } - return urls -} - -// 清理所有测试文件(以防有遗漏) -async function cleanupAllTestFiles(): Promise<{ deleted: number; failed: number }> { - let deleted = 0 - let failed = 0 - let continuationToken: string | undefined = undefined - - try { - do { - const listCommand: ListObjectsV2Command = new ListObjectsV2Command({ - Bucket: process.env.S3_BUCKET!, - Prefix: 'speed-test-', - ContinuationToken: continuationToken, - MaxKeys: 1000, - }) - - const listResponse = await s3Client.send(listCommand) as ListObjectsV2CommandOutput - - if (listResponse.Contents && listResponse.Contents.length > 0) { - const deletePromises = listResponse.Contents.map((file) => { - if (file.Key) { - return s3Client.send( - new DeleteObjectCommand({ - Bucket: process.env.S3_BUCKET!, - Key: file.Key, - }) - ).then(() => { - deleted++ - }).catch(() => { - failed++ - }) - } - return Promise.resolve() - }) - - await Promise.allSettled(deletePromises) - } - - continuationToken = listResponse.NextContinuationToken - } while (continuationToken) - } catch (error) { - console.warn('[S3 Speed Test] Cleanup error:', error) - } - - return { deleted, failed } -} - -export async function GET(request: NextRequest) { - try { - const session = await getServerSession(authOptions) - if (!session?.user) { - return NextResponse.json({ error: '请先登录' }, { status: 401 }) - } - - const user = session.user as any - if (!hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) { - return NextResponse.json({ error: '仅系统管理员可执行 S3 测速' }, { status: 403 }) - } - - const retryAfter = consumeRateLimit(String(user.id || 'unknown')) - if (retryAfter) { - return NextResponse.json( - { error: 'S3 测速请求过于频繁,请稍后再试' }, - { status: 429, headers: { 'Retry-After': String(retryAfter) } }, - ) - } - - const searchParams = request.nextUrl.searchParams - const testTypeRaw = searchParams.get('type') || 'quick' - const concurrency = Number(searchParams.get('concurrency') || '5') - const count = Number(searchParams.get('count') || '20') - const mixedDurationMs = Number(searchParams.get('durationMs') || '5000') - if (!ALLOWED_TEST_TYPES.has(testTypeRaw)) { - return NextResponse.json({ error: 'type 参数无效' }, { status: 400 }) - } - if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) { - return NextResponse.json( - { error: `concurrency 必须是 1 到 ${MAX_CONCURRENCY} 之间的整数` }, - { status: 400 }, - ) - } - if (!Number.isInteger(count) || count < 1 || count > MAX_REQUEST_COUNT) { - return NextResponse.json( - { error: `count 必须是 1 到 ${MAX_REQUEST_COUNT} 之间的整数` }, - { status: 400 }, - ) - } - if (!Number.isInteger(mixedDurationMs) || mixedDurationMs < 1000 || mixedDurationMs > MAX_MIXED_DURATION_MS) { - return NextResponse.json( - { error: `durationMs 必须是 1000 到 ${MAX_MIXED_DURATION_MS} 之间的整数` }, - { status: 400 }, - ) - } - const testType = testTypeRaw as 'full' | 'concurrent' | 'mixed' | 'quick' - - const isIntranet = detectIsIntranet(request) - - console.log('[S3 Speed Test] Starting comprehensive tests...') - console.log(`[S3 Speed Test] Type: ${testType}, Concurrency: ${concurrency}, Count: ${count}`) - console.log(`[S3 Speed Test] Is Intranet: ${isIntranet}`) - - const result: any = { - success: true, - isIntranet, - config: { - s3Endpoint: process.env.S3_ENDPOINT, - s3PublicDomain: process.env.S3_PUBLIC_DOMAIN, - serverIp: process.env.SERVER_IP, - minioPort: process.env.MINIO_API_PORT || '9000', - }, - timestamp: new Date().toISOString(), - } - - // 1. 基础连接测试 - console.log('[S3 Speed Test] 1. Testing connection...') - const connectionTime = await testConnectionSpeed() - result.connection = { duration: connectionTime } - - // 2. 单文件上传测试(不同大小) - if (testType === 'full' || testType === 'quick') { - console.log('[S3 Speed Test] 2. Testing single file uploads (various sizes)...') - const uploadSizes: number[] = [10, 100, 1024] - - result.upload = {} - for (const sizeKB of uploadSizes) { - const test = await testUploadSpeed(sizeKB) - result.upload[`${sizeKB}KB`] = { - sizeKB, - duration: test.duration, - speedKBps: test.speed, - speedMBps: test.speed / 1024, - success: test.success, - error: test.error, - } - // 清理 - if (test.key) { - s3Client.send( - new DeleteObjectCommand({ - Bucket: process.env.S3_BUCKET!, - Key: test.key, - }) - ).catch(() => {}) - } - } - } - - // 3. 并发上传测试 - if (testType === 'full' || testType === 'concurrent' || testType === 'mixed' || testType === 'quick') { - console.log('[S3 Speed Test] 3. Testing concurrent uploads...') - const concurrentSizes = testType === 'quick' - ? [{ size: 100, concurrency: Math.min(5, concurrency), count: Math.min(20, count) }] - : [ - { size: 10, concurrency, count }, - { size: 100, concurrency, count }, - { size: 1024, concurrency: Math.min(5, concurrency), count: Math.min(20, count) }, - ] - - result.concurrentUpload = {} - for (const config of concurrentSizes) { - const test = await testConcurrentUploads(config.concurrency, config.size, config.count) - result.concurrentUpload[`${config.size}KB_${config.concurrency}并发_${config.count}次`] = { - ...test, - config, - } - } - } - - // 4. 下载测试 - if (testType === 'full' || testType === 'quick' || testType === 'concurrent' || testType === 'mixed') { - console.log('[S3 Speed Test] 4. Testing downloads...') - const testFileUrls = await getTestFileUrls(20) - - if (testFileUrls.length > 0) { - // 公网下载测试 - const publicDownload = await testDownloadSpeed(testFileUrls[0]) - result.download = { - public: { - url: testFileUrls[0], - ...publicDownload, - speedMBps: publicDownload.speed / 1024, - }, - } - - // 内网下载测试 - if (isIntranet && process.env.SERVER_IP) { - try { - const url = new URL(testFileUrls[0]) - const intranetUrl = `http://${process.env.SERVER_IP}:${process.env.MINIO_API_PORT || '9000'}${url.pathname}` - const intranetDownload = await testDownloadSpeed(intranetUrl) - result.download.intranet = { - url: intranetUrl, - ...intranetDownload, - speedMBps: intranetDownload.speed / 1024, - speedup: publicDownload.speed > 0 ? (intranetDownload.speed / publicDownload.speed) : 0, - } - } catch (error) { - console.warn('[S3 Speed Test] Cannot test intranet download') - } - } - - // 并发下载测试 - if (testType === 'full' || testType === 'concurrent' || testType === 'mixed' || testType === 'quick') { - console.log('[S3 Speed Test] 5. Testing concurrent downloads...') - const concurrentConfigs: Array<{ concurrency: number; count: number }> = testType === 'quick' - ? [{ concurrency: Math.min(5, concurrency), count: Math.min(20, count) }] - : [ - { concurrency, count }, - ] - - result.concurrentDownload = {} - for (const config of concurrentConfigs) { - const test = await testConcurrentDownloads(config.concurrency, testFileUrls, config.count) - result.concurrentDownload[`${config.concurrency}并发_${config.count}次`] = { - ...test, - config, - } - } - } - } - } - - // 6. 混合负载测试 - if (testType === 'full' || testType === 'mixed') { - console.log('[S3 Speed Test] 6. Testing mixed load (upload + download)...') - const testFileUrls = await getTestFileUrls(10) - if (testFileUrls.length > 0) { - const mixedTest = await testMixedLoad( - concurrency, - concurrency, - 100, // 100KB上传 - testFileUrls, - mixedDurationMs - ) - result.mixedLoad = mixedTest - } - } - - // 7. 压力测试(高并发) - if (testType === 'full') { - console.log('[S3 Speed Test] 7. Testing stress (high concurrency)...') - const stressTest = await testConcurrentUploads(concurrency, 50, count) - result.stressTest = stressTest - } - - // 8. 最终清理:确保所有测试文件都被删除 - console.log('[S3 Speed Test] 8. Cleaning up all test files...') - const cleanupResult = await cleanupAllTestFiles() - result.cleanup = cleanupResult - console.log(`[S3 Speed Test] Cleanup: ${cleanupResult.deleted} deleted, ${cleanupResult.failed} failed`) - - console.log('[S3 Speed Test] All tests completed') - - return NextResponse.json(result) - } catch (error: any) { - console.error('[S3 Speed Test] Error:', error) - return NextResponse.json( - { - success: false, - error: error.message || '测试失败', - stack: process.env.NODE_ENV === 'development' ? error.stack : undefined, - }, - { status: 500 } - ) - } -} diff --git a/app/network-diagnose/page.tsx b/app/network-diagnose/page.tsx deleted file mode 100644 index 7b808f5..0000000 --- a/app/network-diagnose/page.tsx +++ /dev/null @@ -1,324 +0,0 @@ -'use client' - -import { useState, useEffect } from 'react' -import { initializeNetwork, clearNetworkCache } from '@/lib/network' - -interface NetworkDebugInfo { - success: boolean - isIntranet: boolean - isPublicDomain: boolean - clientIp: string - clientIpSource: string - hostname: string - debug: { - forwardedIpList: string[] - hasInternalIpInForwarded: boolean - realIp: string | null - cfConnectingIp: string | null - xClientIp: string | null - clientIpIsInternal: boolean | null - } - timestamp: string -} - -export default function NetworkDiagnosePage() { - const [loading, setLoading] = useState(true) - const [networkInfo, setNetworkInfo] = useState(null) - const [frontendStatus, setFrontendStatus] = useState<{ isInternal: boolean; host: string } | null>(null) - const [error, setError] = useState(null) - const [testResults, setTestResults] = useState<{ - internalMinIO: { success: boolean; time: number; error?: string } | null - publicS3: { success: boolean; time: number; error?: string } | null - }>({ internalMinIO: null, publicS3: null }) - - const fetchNetworkStatus = async () => { - setLoading(true) - setError(null) - - try { - // 清除前端缓存,强制重新检测 - clearNetworkCache() - - // 获取前端网络状态 - const frontendResult = await initializeNetwork(true) - setFrontendStatus(frontendResult) - - // 获取后端网络状态(带诊断信息) - const response = await fetch('/api/network-status', { cache: 'no-store' }) - if (response.ok) { - const data = await response.json() - setNetworkInfo(data) - } else { - setError('获取网络状态失败') - } - } catch (err) { - setError(err instanceof Error ? err.message : '未知错误') - } finally { - setLoading(false) - } - } - - // 测试内网MinIO连接 - const testInternalMinIO = async () => { - setTestResults(prev => ({ ...prev, internalMinIO: null })) - const startTime = Date.now() - - try { - // 尝试访问内网MinIO的一个测试图片 - const testUrl = 'http://192.168.58.20:9000/gongxiangpan/由壹点AI/test-connection.txt' - const response = await fetch(testUrl, { - method: 'HEAD', - mode: 'no-cors', // 跨域请求 - cache: 'no-store' - }) - - const time = Date.now() - startTime - // no-cors 模式下无法读取响应状态,但如果没有抛出错误,说明网络可达 - setTestResults(prev => ({ - ...prev, - internalMinIO: { success: true, time, error: '网络可达(无法确认文件是否存在)' } - })) - } catch (err) { - const time = Date.now() - startTime - setTestResults(prev => ({ - ...prev, - internalMinIO: { - success: false, - time, - error: err instanceof Error ? err.message : '连接失败' - } - })) - } - } - - // 测试公网S3连接 - const testPublicS3 = async () => { - setTestResults(prev => ({ ...prev, publicS3: null })) - const startTime = Date.now() - - try { - // 尝试访问公网S3的一个测试图片 - const testUrl = 'https://s3.jiansun.vip/gongxiangpan/由壹点AI/test-connection.txt' - const response = await fetch(testUrl, { - method: 'HEAD', - mode: 'no-cors', - cache: 'no-store' - }) - - const time = Date.now() - startTime - setTestResults(prev => ({ - ...prev, - publicS3: { success: true, time, error: '网络可达(无法确认文件是否存在)' } - })) - } catch (err) { - const time = Date.now() - startTime - setTestResults(prev => ({ - ...prev, - publicS3: { - success: false, - time, - error: err instanceof Error ? err.message : '连接失败' - } - })) - } - } - - useEffect(() => { - fetchNetworkStatus() - }, []) - - return ( -
-
-

网络诊断

- - {/* 刷新按钮 */} -
- -
- - {error && ( -
- {error} -
- )} - - {/* 检测结果 */} -
- {/* 前端检测结果 */} -
-

前端检测结果

- {frontendStatus ? ( -
-
- 网络类型: - - {frontendStatus.isInternal ? '✅ 内网' : '🌐 外网'} - -
-
- 当前Host: - {frontendStatus.host} -
-
- ) : ( -
加载中...
- )} -
- - {/* 后端检测结果 */} -
-

后端检测结果

- {networkInfo ? ( -
-
- 网络类型: - - {networkInfo.isIntranet ? '✅ 内网' : '🌐 外网'} - -
-
- 客户端IP: - {networkInfo.clientIp} -
-
- IP来源: - {networkInfo.clientIpSource} -
-
- Hostname: - {networkInfo.hostname} -
-
- ) : ( -
加载中...
- )} -
-
- - {/* 详细诊断信息 */} - {networkInfo?.debug && ( -
-

详细诊断信息

-
-
- X-Forwarded-For IP列表: -
- {networkInfo.debug.forwardedIpList.length > 0 - ? networkInfo.debug.forwardedIpList.join(' → ') - : '(空)'} -
-
-
- X-Forwarded-For中有内网IP: - - {networkInfo.debug.hasInternalIpInForwarded ? '是' : '否'} - -
-
- X-Real-IP: - {networkInfo.debug.realIp || '(空)'} -
-
- CF-Connecting-IP: - {networkInfo.debug.cfConnectingIp || '(空)'} -
-
- X-Client-IP: - {networkInfo.debug.xClientIp || '(空)'} -
-
- 客户端IP是内网: - - {networkInfo.debug.clientIpIsInternal === null ? '未知' : networkInfo.debug.clientIpIsInternal ? '是' : '否'} - -
-
-
- )} - - {/* 连接测试 */} -
-

连接测试

-
- {/* 内网MinIO测试 */} -
-

内网MinIO (192.168.58.20:9000)

- - {testResults.internalMinIO && ( -
- {testResults.internalMinIO.success ? '✅' : '❌'} {testResults.internalMinIO.time}ms - {testResults.internalMinIO.error && ( -
{testResults.internalMinIO.error}
- )} -
- )} -
- - {/* 公网S3测试 */} -
-

公网S3 (s3.jiansun.vip)

- - {testResults.publicS3 && ( -
- {testResults.publicS3.success ? '✅' : '❌'} {testResults.publicS3.time}ms - {testResults.publicS3.error && ( -
{testResults.publicS3.error}
- )} -
- )} -
-
-
- - {/* 浏览器信息 */} -
-

浏览器信息

-
-
- User Agent: - - {typeof navigator !== 'undefined' ? navigator.userAgent : 'N/A'} - -
-
- 当前URL: - {typeof window !== 'undefined' ? window.location.href : 'N/A'} -
-
- 检测时间: - {networkInfo?.timestamp || 'N/A'} -
-
-
- - {/* 说明 */} -
-

💡 提示:如果在内网但检测为外网,可能是以下原因:

-
    -
  • 代理/VPN 导致 IP 地址被识别为公网
  • -
  • 浏览器扩展修改了请求头
  • -
  • DNS 解析到了公网地址而非内网地址
  • -
  • X-Forwarded-For 头中没有内网IP
  • -
-
-
-
- ) -} diff --git a/components/Header.test.tsx b/components/Header.test.tsx new file mode 100644 index 0000000..c533091 --- /dev/null +++ b/components/Header.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from '@testing-library/react' + +import Header from '@/components/Header' + +jest.mock('next-auth/react', () => ({ + signOut: jest.fn(), +})) + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: jest.fn(), + refresh: jest.fn(), + }), +})) + +jest.mock('@/lib/network', () => ({ + initializeNetwork: jest.fn().mockResolvedValue({ isInternal: true }), + setupNetworkChangeDetection: jest.fn(), + addNetworkChangeListener: jest.fn(() => jest.fn()), +})) + +jest.mock('@/components/UpdateLogModal', () => ({ + __esModule: true, + default: () => null, + showUpdateLog: jest.fn(), + useUpdateLog: () => ({ + showModal: false, + currentLog: null, + showAllHistory: false, + handleClose: jest.fn(), + }), +})) + +jest.mock('@/components/MaintenanceBanner', () => ({ + __esModule: true, + default: () => null, +})) + +jest.mock('@/components/HeaderTaobaoSearch', () => ({ + __esModule: true, + default: () => null, +})) + +describe('Header 用户菜单', () => { + beforeEach(() => { + jest.clearAllMocks() + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + headerBgColor: '#1f2937', + headerLogoUrl: '/api/favicon', + siteName: '由壹点3D素材', + }), + }) as jest.Mock + }) + + test('保留常用入口并移除网络诊断和清除缓存', () => { + render( +
, + ) + + const menuButton = screen.getByRole('button', { name: '打开用户菜单' }) + expect(menuButton).toHaveAttribute('aria-expanded', 'false') + + fireEvent.click(menuButton) + + expect(menuButton).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByRole('link', { name: '个人中心' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: '考核调研' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: '作品过稿看板' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '更新记录' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '退出登录' })).toBeInTheDocument() + expect(screen.queryByText('网络诊断')).not.toBeInTheDocument() + expect(screen.queryByText('清除缓存并刷新')).not.toBeInTheDocument() + }) +}) diff --git a/components/Header.tsx b/components/Header.tsx index a009fcd..bb1f97b 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -8,7 +8,6 @@ import { Session } from 'next-auth' import { useState, useRef, useEffect, type MouseEvent as ReactMouseEvent } from 'react' import { initializeNetwork, setupNetworkChangeDetection, addNetworkChangeListener } from '@/lib/network' import UpdateLogModal, { useUpdateLog, showUpdateLog } from '@/components/UpdateLogModal' -import NetworkDiagnoseModal from '@/components/NetworkDiagnoseModal' import MaintenanceBanner from '@/components/MaintenanceBanner' import HeaderTaobaoSearch from '@/components/HeaderTaobaoSearch' import { formatHumanSpeed, formatTransferProgressSummary } from '@/lib/human-readable-transfer' @@ -18,51 +17,6 @@ interface HeaderProps { } const DEFAULT_HEADER_LOGO_URL = '/api/favicon' -const MATERIAL_LOCAL_STORAGE_KEYS = [ - 'searchHistory', - 'ranking_panel_state', - 'shadowIndicatorPosition', -] -const MATERIAL_LOCAL_STORAGE_PREFIXES = ['update_log_viewed_version'] -const MATERIAL_SESSION_STORAGE_KEYS = [ - 'home_scroll_position', - 'home_materials_cache', -] -const MATERIAL_SESSION_STORAGE_PREFIXES = ['maintenance_dismissed_'] -const MATERIAL_INDEXED_DB_NAMES = ['chunk-upload-db'] - -function removeMaterialStorageItems( - storage: Storage, - exactKeys: string[], - prefixKeys: string[] = [], -) { - const keysToRemove: string[] = [] - - for (let i = 0; i < storage.length; i++) { - const key = storage.key(i) - if (!key) continue - - if (exactKeys.includes(key) || prefixKeys.some((prefix) => key.startsWith(prefix))) { - keysToRemove.push(key) - } - } - - keysToRemove.forEach((key) => storage.removeItem(key)) - return keysToRemove -} - -function deleteIndexedDatabase(name: string) { - return new Promise((resolve) => { - const request = indexedDB.deleteDatabase(name) - request.onsuccess = () => resolve(name) - request.onerror = () => resolve(name) - request.onblocked = () => resolve(name) - }) -} - -function clearMaterialVersionCookie() { - document.cookie = 'app-version=; Max-Age=0; path=/; SameSite=Lax' -} export default function Header({ session }: HeaderProps) { const router = useRouter() @@ -74,7 +28,6 @@ export default function Header({ session }: HeaderProps) { const [networkChecked, setNetworkChecked] = useState(false) // 网络切换横幅已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false) - const [showNetworkDiagnose, setShowNetworkDiagnose] = useState(false) const dropdownRef = useRef(null) // 性能监控悬停卡片状态 @@ -532,6 +485,8 @@ export default function Header({ session }: HeaderProps) {
- - {/* 清除缓存(仅清除素材网相关缓存) */} - - {/* 退出登录 */} -
-

- 全面测试网络连接、存储服务和文件加载性能 -

- - - {/* Content */} -
- {results.length === 0 ? ( -
- - - -

- 点击下方按钮开始完整诊断 -

-

- 将测试33项网络、API、数据库和业务流程指标,预计耗时60-120秒 -

-
- ) : ( -
- {results.map((result, index) => ( -
-
- {getStatusIcon(result.status)} -
-
-

- {getTypeText(result.type)} -

-

- {result.message || '测试中...'} -

- {result.details && ( -
- {result.details.dnsTime !== undefined && ( -
DNS解析: {result.details.dnsTime.toFixed(0)}ms
- )} - {result.details.connectTime !== undefined && ( -
TCP连接: {result.details.connectTime.toFixed(0)}ms
- )} - {result.details.sslTime !== undefined && ( -
SSL握手: {result.details.sslTime.toFixed(0)}ms
- )} - {result.details.ttfb !== undefined && ( -
首字节时间: {result.details.ttfb.toFixed(0)}ms
- )} -
- )} -
-
- ))} -
- )} -
- - {/* Footer */} -
- - - {results.length > 0 && !testing && ( - - )} -
- - - {/* 统一的Toast提示 */} - {toast && toast.show && ( - - )} - - ) -}