// 分片上传完成合并接口 // 负责将分片文件按顺序合并为完整文件,校验大小,并返回服务器侧可复用的文件路径 // 供前端在第二步调用正式上传接口时复用,避免重复上传大文件 import { lstat, rm } from 'fs/promises' import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { acquireUploadMergeDiskReservation, mergeUploadChunksAtomically, type UploadChunkToMerge, } from '@/lib/material-upload-merge' import { getExpectedUploadChunkBytes, resolveUploadChunkSize, } from '@/lib/material-upload-chunk-policy' import { MaterialUploadRequestError, materialUploadMergeRequestLimiter, } from '@/lib/material-upload-request-guard' import { deleteUploadSession, getOwnedUploadSession, isSafeUploadId, } from '@/lib/upload-sessions' import { getUploadRootDir } from '@/lib/upload-paths' import { normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security' import { acquireMaterialUploadStorageLock, type MaterialUploadStorageLockRelease, } from '@/lib/material-upload-cross-process-lock' import { abortUploadCompletionSeal, beginUploadCompletionSeal, completeUploadCompletionSeal, type UploadCompletionSealHandle, } from '@/lib/material-upload-session-seal' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export const maxDuration = 10800 // 最长3小时 export async function POST(req: NextRequest) { let releaseMergeSlot: (() => void) | null = null let releaseCrossProcessLock: MaterialUploadStorageLockRelease | null = null let releaseStorageReservation: (() => void) | null = null let completionSealHandle: UploadCompletionSealHandle | null = null let completionSealFinalized = false try { const session = await getServerSession(authOptions) const userId = String((session?.user as { id?: unknown } | undefined)?.id || '').trim() if (!userId) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } const body = await req.json() const uploadId = String(body?.uploadId || '').trim() if (!isSafeUploadId(uploadId)) { return NextResponse.json({ error: 'uploadId 无效' }, { status: 400 }) } // Redis 会话可能在第一次成功响应前后被清理;归属明确的 meta.json 是 // 幂等重试依据,保证公网/FRP 丢响应后无需重新上传整个大文件。 const uploadSession = await getOwnedUploadSession(uploadId, userId) if (!uploadSession) { return NextResponse.json({ error: '上传会话不存在、已过期或无权访问' }, { status: 404 }) } const mergedFileName = normalizeSafeFileName(uploadSession.fileName) const uploadRoot = getUploadRootDir() const tempDir = resolveStrictChildPath(uploadRoot, 'temp', uploadId) const mergedPath = mergedFileName ? resolveStrictChildPath(tempDir || '', `merged-${mergedFileName}`) : null if (!tempDir || !mergedFileName || !mergedPath) { return NextResponse.json({ error: '上传会话路径无效' }, { status: 400 }) } const chunkSize = resolveUploadChunkSize( uploadSession.fileSize, uploadSession.totalChunks, uploadSession.chunkSize, ) if (!chunkSize) { return NextResponse.json( { error: '上传会话的分片布局无效,请重新开始上传', code: 'CHUNK_LAYOUT_INVALID' }, { status: 409 }, ) } const chunks: UploadChunkToMerge[] = [] for (let index = 0; index < uploadSession.totalChunks; index += 1) { const expectedBytes = getExpectedUploadChunkBytes({ fileSize: uploadSession.fileSize, totalChunks: uploadSession.totalChunks, chunkSize, chunkIndex: index, }) const chunkPath = resolveStrictChildPath(tempDir, `chunk-${index}`) if (!expectedBytes || !chunkPath) { return NextResponse.json( { error: '上传会话的分片布局无效,请重新开始上传', code: 'CHUNK_LAYOUT_INVALID' }, { status: 409 }, ) } chunks.push({ path: chunkPath, expectedBytes }) } releaseMergeSlot = await materialUploadMergeRequestLimiter.acquire() releaseCrossProcessLock = await acquireMaterialUploadStorageLock('exclusive') const completionSeal = await beginUploadCompletionSeal(uploadId) if (completionSeal.state === 'sealing') { completionSealHandle = completionSeal.handle } // 第一次请求可能已经成功,只是响应在公网链路中丢失。完整且大小一致的 // merged 文件是同一会话的确定结果,重复调用直接返回同一路径。 const existingMerged = await inspectExistingMergedFile( mergedPath, uploadSession.fileSize, ) if (existingMerged === 'valid') { if (completionSealHandle) { await completeUploadCompletionSeal(completionSealHandle) completionSealFinalized = true } await cleanupCompletedUpload(uploadId, chunks.map((chunk) => chunk.path)) return createCompletedResponse({ uploadId, mergedPath, mergedFileName, fileSize: uploadSession.fileSize, }) } if (completionSeal.state === 'completed') { throw new MaterialUploadRequestError( '上传会话已完成,但合并文件状态异常;服务器已停止覆盖,请联系管理员核查', 409, 'UPLOAD_COMPLETED_FILE_INVALID', ) } if (existingMerged === 'invalid') { await rm(mergedPath, { force: true }) } for (let index = 0; index < chunks.length; index += 1) { const chunk = chunks[index] try { const stats = await lstat(chunk.path) if ( stats.isSymbolicLink() || !stats.isFile() || stats.size !== chunk.expectedBytes ) { return NextResponse.json( { error: `分片 ${index} 大小不匹配,请重新上传该文件`, code: 'CHUNK_SIZE_MISMATCH' }, { status: 400 }, ) } } catch (error: any) { if (error?.code === 'ENOENT') { return NextResponse.json( { error: '分片不完整,请继续上传后重试', code: 'CHUNK_INCOMPLETE' }, { status: 400 }, ) } throw error } } // init 的空间判断只是上传开始时的快照。这里在唯一合并槽内重新确认 // “第二份完整文件 + 2GiB 安全余量”,否则必须 fail-closed。 releaseStorageReservation = await acquireUploadMergeDiskReservation( tempDir, uploadSession.fileSize, ) await mergeUploadChunksAtomically({ chunks, expectedTotalBytes: uploadSession.fileSize, outputPath: mergedPath, assertLockHeld: () => releaseCrossProcessLock?.assertHeld(), }) if (!completionSealHandle) { throw new Error('上传完成标记句柄缺失') } await completeUploadCompletionSeal(completionSealHandle) completionSealFinalized = true await cleanupCompletedUpload(uploadId, chunks.map((chunk) => chunk.path)) return createCompletedResponse({ uploadId, mergedPath, mergedFileName, fileSize: uploadSession.fileSize, }) } catch (error) { if (error instanceof MaterialUploadRequestError) { return NextResponse.json( { error: error.message, code: error.code }, { status: error.status, headers: error.retryAfterSeconds ? { 'Retry-After': String(error.retryAfterSeconds) } : undefined, }, ) } console.error('[UploadComplete] Error:', error) return NextResponse.json({ error: '合并分片失败' }, { status: 500 }) } finally { releaseStorageReservation?.() if (completionSealHandle && !completionSealFinalized) { await abortUploadCompletionSeal(completionSealHandle).catch((error) => { console.error('[UploadComplete] 回收未完成封存标记失败:', error) }) } await releaseCrossProcessLock?.() releaseMergeSlot?.() } } async function inspectExistingMergedFile( mergedPath: string, expectedBytes: number, ): Promise<'missing' | 'valid' | 'invalid'> { try { const stats = await lstat(mergedPath) return !stats.isSymbolicLink() && stats.isFile() && stats.size === expectedBytes ? 'valid' : 'invalid' } catch (error: any) { if (error?.code === 'ENOENT') { return 'missing' } throw error } } async function cleanupCompletedUpload(uploadId: string, chunkFiles: string[]): Promise { for (const chunkFile of chunkFiles) { try { await rm(chunkFile, { force: true }) } catch (error) { console.warn('[UploadComplete] 清理分片异常(保留已验证的合并文件):', { chunkFile, error, }) } } try { await deleteUploadSession(uploadId) console.log('[UploadComplete] 已删除会话:', uploadId) } catch (error) { console.warn('[UploadComplete] 删除会话失败(保留已验证的合并文件):', error) } } function createCompletedResponse(input: { uploadId: string mergedPath: string mergedFileName: string fileSize: number }) { return NextResponse.json({ message: '分片合并完成', existingFilePath: input.mergedPath, fileName: input.mergedFileName, fileSize: input.fileSize, uploadId: input.uploadId, }) }