fix(ui): 移除网络诊断与缓存刷新入口
This commit is contained in:
parent
69c2c62be3
commit
88c98cc081
@ -9,7 +9,7 @@
|
||||
- 基于 Next.js 14 App Router 的素材协作与分发系统
|
||||
- 默认以新素材流程为主,不再把“新老素材兼容”作为日常主路径
|
||||
- 同时使用 PostgreSQL、Prisma、S3/MinIO、本地 `uploads` 目录,以及可选 Redis 上传会话
|
||||
- 已覆盖首页浏览、素材上传、分享下载、后台管理、外部接口、网络诊断、健康检查等完整链路
|
||||
- 已覆盖首页浏览、素材上传、分享下载、后台管理、外部接口、内外网状态识别、健康检查等完整链路
|
||||
- 当前项目已经在线上运行,涉及权限、下载、上传、转码、批量删除等改动时需要先验证再发布
|
||||
|
||||
## 当前核心能力
|
||||
@ -53,7 +53,7 @@
|
||||
|
||||
### 5. 运行与诊断
|
||||
|
||||
- 网络诊断页面与接口
|
||||
- 内外网状态识别、健康检查与受控运维监控
|
||||
- 健康检查接口与内存检查接口
|
||||
- 快速性能检查接口
|
||||
- 内外网 URL 重写与下载链路适配
|
||||
|
||||
@ -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(/\/$/, '')}`;
|
||||
}
|
||||
@ -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<string, { count: number; resetAt: number }>()
|
||||
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
@ -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<string, { count: number; resetAt: number }>()
|
||||
|
||||
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<number> {
|
||||
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<typeof calculateStats>
|
||||
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<typeof calculateStats>
|
||||
ttfbStats: ReturnType<typeof calculateStats>
|
||||
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<string[]> {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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<NetworkDebugInfo | null>(null)
|
||||
const [frontendStatus, setFrontendStatus] = useState<{ isInternal: boolean; host: string } | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">网络诊断</h1>
|
||||
|
||||
{/* 刷新按钮 */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={fetchNetworkStatus}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '检测中...' : '重新检测'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 检测结果 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
{/* 前端检测结果 */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">前端检测结果</h2>
|
||||
{frontendStatus ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">网络类型:</span>
|
||||
<span className={`font-medium ${frontendStatus.isInternal ? 'text-green-600' : 'text-blue-600'}`}>
|
||||
{frontendStatus.isInternal ? '✅ 内网' : '🌐 外网'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">当前Host:</span>
|
||||
<span className="font-mono text-sm">{frontendStatus.host}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 后端检测结果 */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">后端检测结果</h2>
|
||||
{networkInfo ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">网络类型:</span>
|
||||
<span className={`font-medium ${networkInfo.isIntranet ? 'text-green-600' : 'text-blue-600'}`}>
|
||||
{networkInfo.isIntranet ? '✅ 内网' : '🌐 外网'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">客户端IP:</span>
|
||||
<span className="font-mono text-sm">{networkInfo.clientIp}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">IP来源:</span>
|
||||
<span className="font-mono text-sm">{networkInfo.clientIpSource}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Hostname:</span>
|
||||
<span className="font-mono text-sm">{networkInfo.hostname}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细诊断信息 */}
|
||||
{networkInfo?.debug && (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">详细诊断信息</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-gray-600">X-Forwarded-For IP列表:</span>
|
||||
<div className="font-mono text-sm bg-gray-100 p-2 rounded mt-1">
|
||||
{networkInfo.debug.forwardedIpList.length > 0
|
||||
? networkInfo.debug.forwardedIpList.join(' → ')
|
||||
: '(空)'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">X-Forwarded-For中有内网IP:</span>
|
||||
<span className={networkInfo.debug.hasInternalIpInForwarded ? 'text-green-600' : 'text-gray-500'}>
|
||||
{networkInfo.debug.hasInternalIpInForwarded ? '是' : '否'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">X-Real-IP:</span>
|
||||
<span className="font-mono text-sm">{networkInfo.debug.realIp || '(空)'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">CF-Connecting-IP:</span>
|
||||
<span className="font-mono text-sm">{networkInfo.debug.cfConnectingIp || '(空)'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">X-Client-IP:</span>
|
||||
<span className="font-mono text-sm">{networkInfo.debug.xClientIp || '(空)'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">客户端IP是内网:</span>
|
||||
<span className={networkInfo.debug.clientIpIsInternal ? 'text-green-600' : 'text-blue-600'}>
|
||||
{networkInfo.debug.clientIpIsInternal === null ? '未知' : networkInfo.debug.clientIpIsInternal ? '是' : '否'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 连接测试 */}
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">连接测试</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 内网MinIO测试 */}
|
||||
<div className="border rounded p-4">
|
||||
<h3 className="font-medium mb-2">内网MinIO (192.168.58.20:9000)</h3>
|
||||
<button
|
||||
onClick={testInternalMinIO}
|
||||
className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300 text-sm mb-2"
|
||||
>
|
||||
测试连接
|
||||
</button>
|
||||
{testResults.internalMinIO && (
|
||||
<div className={`text-sm ${testResults.internalMinIO.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{testResults.internalMinIO.success ? '✅' : '❌'} {testResults.internalMinIO.time}ms
|
||||
{testResults.internalMinIO.error && (
|
||||
<div className="text-gray-500 text-xs mt-1">{testResults.internalMinIO.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 公网S3测试 */}
|
||||
<div className="border rounded p-4">
|
||||
<h3 className="font-medium mb-2">公网S3 (s3.jiansun.vip)</h3>
|
||||
<button
|
||||
onClick={testPublicS3}
|
||||
className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300 text-sm mb-2"
|
||||
>
|
||||
测试连接
|
||||
</button>
|
||||
{testResults.publicS3 && (
|
||||
<div className={`text-sm ${testResults.publicS3.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{testResults.publicS3.success ? '✅' : '❌'} {testResults.publicS3.time}ms
|
||||
{testResults.publicS3.error && (
|
||||
<div className="text-gray-500 text-xs mt-1">{testResults.publicS3.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 浏览器信息 */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">浏览器信息</h2>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">User Agent:</span>
|
||||
<span className="font-mono text-xs max-w-md truncate" title={typeof navigator !== 'undefined' ? navigator.userAgent : ''}>
|
||||
{typeof navigator !== 'undefined' ? navigator.userAgent : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">当前URL:</span>
|
||||
<span className="font-mono text-xs">{typeof window !== 'undefined' ? window.location.href : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">检测时间:</span>
|
||||
<span className="font-mono text-xs">{networkInfo?.timestamp || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 说明 */}
|
||||
<div className="mt-6 text-sm text-gray-500">
|
||||
<p>💡 提示:如果在内网但检测为外网,可能是以下原因:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>代理/VPN 导致 IP 地址被识别为公网</li>
|
||||
<li>浏览器扩展修改了请求头</li>
|
||||
<li>DNS 解析到了公网地址而非内网地址</li>
|
||||
<li>X-Forwarded-For 头中没有内网IP</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
components/Header.test.tsx
Normal file
87
components/Header.test.tsx
Normal file
@ -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(
|
||||
<Header
|
||||
session={
|
||||
{
|
||||
user: {
|
||||
id: 'user-1',
|
||||
name: '测试用户',
|
||||
email: 'user@example.com',
|
||||
role: 'USER',
|
||||
},
|
||||
} as any
|
||||
}
|
||||
/>,
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -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<string>((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<HTMLDivElement>(null)
|
||||
|
||||
// 性能监控悬停卡片状态
|
||||
@ -532,6 +485,8 @@ export default function Header({ session }: HeaderProps) {
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
aria-label="打开用户菜单"
|
||||
aria-expanded={showDropdown}
|
||||
className="flex items-center p-1.5 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-900 transition-all"
|
||||
>
|
||||
{/* 圆形头像 */}
|
||||
@ -657,119 +612,6 @@ export default function Header({ session }: HeaderProps) {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 网络诊断 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDropdown(false)
|
||||
setShowNetworkDiagnose(true)
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span>网络诊断</span>
|
||||
</button>
|
||||
|
||||
{/* 清除缓存(仅清除素材网相关缓存) */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowDropdown(false)
|
||||
try {
|
||||
const currentOrigin = window.location.origin
|
||||
|
||||
// 1. 清除当前素材库域名下的 Cache Storage。
|
||||
// 浏览器同源策略会限制 caches.keys() 只能看到当前域名,不会影响其它网站。
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)))
|
||||
console.log(`已清除 ${cacheNames.length} 个当前域名 Cache Storage:`, cacheNames)
|
||||
}
|
||||
|
||||
// 2. 注销当前素材库域名下的 Service Worker
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
const materialRegistrations = registrations.filter(reg => {
|
||||
return reg.scope.startsWith(currentOrigin)
|
||||
})
|
||||
await Promise.all(materialRegistrations.map(reg => reg.unregister()))
|
||||
console.log(`已清除 ${materialRegistrations.length} 个Service Worker`)
|
||||
}
|
||||
|
||||
// 3. 精准清除素材库写入的 localStorage,不清其它无关 key。
|
||||
try {
|
||||
const keysToRemove = removeMaterialStorageItems(
|
||||
localStorage,
|
||||
MATERIAL_LOCAL_STORAGE_KEYS,
|
||||
MATERIAL_LOCAL_STORAGE_PREFIXES,
|
||||
)
|
||||
console.log(`已清除 ${keysToRemove.length} 个localStorage项:`, keysToRemove)
|
||||
} catch (e) {
|
||||
console.warn('清除localStorage失败:', e)
|
||||
}
|
||||
|
||||
// 4. 精准清除素材库写入的 sessionStorage,包含首页筛选/滚动缓存。
|
||||
try {
|
||||
const keysToRemove = removeMaterialStorageItems(
|
||||
sessionStorage,
|
||||
MATERIAL_SESSION_STORAGE_KEYS,
|
||||
MATERIAL_SESSION_STORAGE_PREFIXES,
|
||||
)
|
||||
console.log(`已清除 ${keysToRemove.length} 个sessionStorage项:`, keysToRemove)
|
||||
} catch (e) {
|
||||
console.warn('清除sessionStorage失败:', e)
|
||||
}
|
||||
|
||||
// 5. 清除素材库断点上传 IndexedDB
|
||||
try {
|
||||
if ('indexedDB' in window) {
|
||||
const deletedDatabases = await Promise.all(
|
||||
MATERIAL_INDEXED_DB_NAMES.map((name) => deleteIndexedDatabase(name))
|
||||
)
|
||||
console.log(`已清除 ${deletedDatabases.length} 个IndexedDB数据库:`, deletedDatabases)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('清除IndexedDB失败:', e)
|
||||
}
|
||||
|
||||
// 6. 清除素材库版本 cookie,但保留登录 cookie,避免用户被登出。
|
||||
try {
|
||||
clearMaterialVersionCookie()
|
||||
console.log('已清除素材库版本 cookie: app-version')
|
||||
} catch (e) {
|
||||
console.warn('清除版本 cookie 失败:', e)
|
||||
}
|
||||
|
||||
// 显示提示信息
|
||||
console.log('✅ 素材网缓存已清除,正在强制刷新页面...')
|
||||
|
||||
// 延迟一小段时间让用户看到提示,然后强制刷新
|
||||
setTimeout(() => {
|
||||
// 使用硬刷新(Ctrl+F5 效果)
|
||||
// 方法1:通过修改 URL 添加时间戳参数强制绕过缓存
|
||||
const url = new URL(window.location.href)
|
||||
// 移除旧的缓存参数
|
||||
url.searchParams.delete('_t')
|
||||
url.searchParams.delete('_clear_cache')
|
||||
// 添加新的时间戳参数
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
|
||||
// 使用 location.replace 强制刷新(不会在历史记录中留下记录)
|
||||
window.location.replace(url.toString())
|
||||
}, 300)
|
||||
} catch (error) {
|
||||
console.error('清除缓存失败:', error)
|
||||
alert('清除缓存失败,请手动刷新页面(Ctrl+F5 或 Cmd+Shift+R)')
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>清除缓存并刷新</span>
|
||||
</button>
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
@ -823,12 +665,6 @@ export default function Header({ session }: HeaderProps) {
|
||||
updateLog={currentLog || undefined}
|
||||
showAllHistory={showAllHistory}
|
||||
/>
|
||||
|
||||
{/* 网络诊断弹窗 */}
|
||||
<NetworkDiagnoseModal
|
||||
isOpen={showNetworkDiagnose}
|
||||
onClose={() => setShowNetworkDiagnose(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user