709 lines
22 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.

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 }
)
}
}