230 lines
7.6 KiB
TypeScript
230 lines
7.6 KiB
TypeScript
import { once } from 'events'
|
|
import { createWriteStream } from 'fs'
|
|
import { mkdir, writeFile } from 'fs/promises'
|
|
import path from 'path'
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import { getServerSession } from 'next-auth'
|
|
import { authOptions } from '@/lib/auth'
|
|
import { getUploadSession, updateChunkWithStats } from '@/lib/upload-sessions'
|
|
|
|
export const runtime = 'nodejs'
|
|
export const dynamic = 'force-dynamic'
|
|
export const maxDuration = 10800
|
|
|
|
interface ChunkRequestPayload {
|
|
uploadId: string
|
|
chunkIndex: number
|
|
fileName: string
|
|
chunkSizeHint: number
|
|
mode: 'raw' | 'multipart'
|
|
chunkFile?: File | null
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const requestStartTime = Date.now()
|
|
console.log(`[UploadChunk] 📥 收到请求 at ${new Date().toISOString()}`)
|
|
|
|
try {
|
|
const authStartedAt = Date.now()
|
|
const session = await getServerSession(authOptions)
|
|
const authTime = Date.now() - authStartedAt
|
|
console.log(`[UploadChunk] 🔐 认证完成: ${authTime}ms`)
|
|
|
|
if (!session) {
|
|
return NextResponse.json({ error: '请先登录' }, { status: 401 })
|
|
}
|
|
|
|
const payloadStartedAt = Date.now()
|
|
const payload = await resolveChunkPayload(req)
|
|
const payloadResolveTime = Date.now() - payloadStartedAt
|
|
console.log(
|
|
`[UploadChunk] 📦 请求参数解析完成: ${payloadResolveTime}ms, mode=${payload.mode}, chunkIndex=${payload.chunkIndex}`,
|
|
)
|
|
|
|
if (!payload.uploadId || Number.isNaN(payload.chunkIndex)) {
|
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 })
|
|
}
|
|
|
|
const sessionStartedAt = Date.now()
|
|
const sessionData = await getUploadSession(payload.uploadId)
|
|
const getSessionTime = Date.now() - sessionStartedAt
|
|
console.log(`[UploadChunk] 🔍 获取会话完成: ${getSessionTime}ms, 会话存在: ${!!sessionData}`)
|
|
|
|
if (!sessionData) {
|
|
console.log(`[UploadChunk] ❌ 会话不存在: ${payload.uploadId}`)
|
|
return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 })
|
|
}
|
|
|
|
if (sessionData.userId !== (session.user as any).id) {
|
|
return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 })
|
|
}
|
|
|
|
if (payload.chunkIndex < 0 || payload.chunkIndex >= sessionData.totalChunks) {
|
|
console.error(
|
|
`[UploadChunk] ❌ chunkIndex超出范围: ${payload.chunkIndex}, 总数: ${sessionData.totalChunks}`,
|
|
)
|
|
return NextResponse.json(
|
|
{ error: `chunkIndex超出范围: ${payload.chunkIndex}/${sessionData.totalChunks}` },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
const wasChunkAlreadyRecorded = sessionData.chunks.has(payload.chunkIndex)
|
|
const nextUploadedChunkCount = wasChunkAlreadyRecorded
|
|
? sessionData.chunks.size
|
|
: sessionData.chunks.size + 1
|
|
|
|
const mkdirStartedAt = Date.now()
|
|
const tempDir = path.join(process.env.UPLOAD_DIR || './uploads', 'temp', payload.uploadId)
|
|
await mkdir(tempDir, { recursive: true })
|
|
const mkdirTime = Date.now() - mkdirStartedAt
|
|
|
|
const chunkFilePath = path.join(tempDir, `chunk-${payload.chunkIndex}`)
|
|
const writeStartedAt = Date.now()
|
|
const writtenBytes =
|
|
payload.mode === 'raw'
|
|
? await writeRawRequestBodyToFile(req, chunkFilePath)
|
|
: await writeMultipartChunkToFile(payload.chunkFile, chunkFilePath)
|
|
const writeTime = Date.now() - writeStartedAt
|
|
|
|
const statsStartedAt = Date.now()
|
|
await updateChunkWithStats(payload.uploadId, payload.chunkIndex, chunkFilePath, writtenBytes)
|
|
const updateChunkTime = Date.now() - statsStartedAt
|
|
|
|
const totalTime = Date.now() - requestStartTime
|
|
console.log(`[UploadChunk] ⏱️ Chunk ${payload.chunkIndex}/${sessionData.totalChunks} 总耗时: ${totalTime}ms`, {
|
|
认证: `${authTime}ms (${percent(authTime, totalTime)}%)`,
|
|
请求参数解析: `${payloadResolveTime}ms (${percent(payloadResolveTime, totalTime)}%)`,
|
|
获取会话: `${getSessionTime}ms (${percent(getSessionTime, totalTime)}%)`,
|
|
创建目录: `${mkdirTime}ms (${percent(mkdirTime, totalTime)}%)`,
|
|
流式写盘: `${writeTime}ms (${percent(writeTime, totalTime)}%)`,
|
|
更新会话: `${updateChunkTime}ms (${percent(updateChunkTime, totalTime)}%)`,
|
|
大小: `${(writtenBytes / 1024 / 1024).toFixed(2)}MB`,
|
|
模式: payload.mode,
|
|
})
|
|
|
|
if (writeTime / totalTime > 0.8) {
|
|
console.log(
|
|
`[UploadChunk] ⚠️ 流式写盘阶段占比 ${percent(writeTime, totalTime)}%,当前主要瓶颈仍在网络接收`,
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
uploadedChunks: nextUploadedChunkCount,
|
|
totalChunks: sessionData.totalChunks,
|
|
serverProcessTime: totalTime,
|
|
receivedBytes: writtenBytes,
|
|
mode: payload.mode,
|
|
})
|
|
} catch (error) {
|
|
console.error('[UploadChunk] Error:', error)
|
|
return NextResponse.json({ error: '上传块失败' }, { status: 500 })
|
|
}
|
|
}
|
|
|
|
async function resolveChunkPayload(req: NextRequest): Promise<ChunkRequestPayload> {
|
|
const contentType = (req.headers.get('content-type') || '').toLowerCase()
|
|
|
|
if (!contentType.includes('multipart/form-data')) {
|
|
const uploadId = (req.headers.get('x-upload-id') || req.nextUrl.searchParams.get('uploadId') || '').trim()
|
|
const chunkIndexRaw =
|
|
req.headers.get('x-chunk-index') || req.nextUrl.searchParams.get('chunkIndex') || ''
|
|
const fileNameRaw =
|
|
req.headers.get('x-file-name') || req.nextUrl.searchParams.get('fileName') || ''
|
|
const chunkSizeHint = Number(
|
|
req.headers.get('x-chunk-size') || req.headers.get('content-length') || 0,
|
|
)
|
|
|
|
return {
|
|
uploadId,
|
|
chunkIndex: Number.parseInt(chunkIndexRaw, 10),
|
|
fileName: safeDecodeURIComponent(fileNameRaw),
|
|
chunkSizeHint: Number.isFinite(chunkSizeHint) ? chunkSizeHint : 0,
|
|
mode: 'raw',
|
|
chunkFile: null,
|
|
}
|
|
}
|
|
|
|
const formData = await req.formData()
|
|
const uploadId = (formData.get('uploadId') as string) || ''
|
|
const chunkIndex = Number.parseInt((formData.get('chunkIndex') as string) || '', 10)
|
|
const fileName = (formData.get('fileName') as string) || ''
|
|
const chunk = formData.get('chunk') as File | null
|
|
|
|
return {
|
|
uploadId: uploadId.trim(),
|
|
chunkIndex,
|
|
fileName,
|
|
chunkSizeHint: Number(chunk?.size || 0),
|
|
mode: 'multipart',
|
|
chunkFile: chunk,
|
|
}
|
|
}
|
|
|
|
async function writeRawRequestBodyToFile(req: NextRequest, chunkFilePath: string): Promise<number> {
|
|
if (!req.body) {
|
|
throw new Error('请求体为空')
|
|
}
|
|
|
|
const reader = req.body.getReader()
|
|
const fileStream = createWriteStream(chunkFilePath)
|
|
let writtenBytes = 0
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) {
|
|
break
|
|
}
|
|
|
|
if (!value || value.length === 0) {
|
|
continue
|
|
}
|
|
|
|
writtenBytes += value.length
|
|
if (!fileStream.write(Buffer.from(value))) {
|
|
await once(fileStream, 'drain')
|
|
}
|
|
}
|
|
} catch (error) {
|
|
fileStream.destroy()
|
|
throw error
|
|
} finally {
|
|
reader.releaseLock()
|
|
}
|
|
|
|
fileStream.end()
|
|
await once(fileStream, 'close')
|
|
return writtenBytes
|
|
}
|
|
|
|
async function writeMultipartChunkToFile(chunk: File | null | undefined, chunkFilePath: string): Promise<number> {
|
|
if (!chunk) {
|
|
throw new Error('缺少 chunk 文件')
|
|
}
|
|
|
|
const buffer = Buffer.from(await chunk.arrayBuffer())
|
|
await writeFile(chunkFilePath, buffer)
|
|
return buffer.length
|
|
}
|
|
|
|
function safeDecodeURIComponent(value: string): string {
|
|
if (!value) {
|
|
return ''
|
|
}
|
|
|
|
try {
|
|
return decodeURIComponent(value)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
function percent(part: number, total: number): string {
|
|
if (total <= 0) {
|
|
return '0.0'
|
|
}
|
|
return ((part / total) * 100).toFixed(1)
|
|
}
|