// 分片上传完成合并接口 // 负责将分片文件按顺序合并为完整文件,校验大小,并返回服务器侧可复用的文件路径 // 供前端在第二步调用正式上传接口时复用,避免重复上传大文件 import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import path from 'path' import fs from 'fs/promises' import { createWriteStream, createReadStream } from 'fs' import { pipeline } from 'stream/promises' import { getUploadSession, deleteUploadSession } from '@/lib/upload-sessions' import { sanitizeFileName } from '@/lib/image-processor' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export const maxDuration = 10800 // 最长3小时 export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) if (!session) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } const { uploadId, originalFileName } = await req.json() if (!uploadId) { return NextResponse.json({ error: '缺少 uploadId' }, { status: 400 }) } const uploadDir = process.env.UPLOAD_DIR || './uploads' const tempDir = path.join(uploadDir, 'temp', uploadId) // 读取会话 const uploadSession = await getUploadSession(uploadId) if (!uploadSession) { return NextResponse.json({ error: '上传会话不存在或已过期' }, { status: 404 }) } // 会话归属校验 if (uploadSession.userId !== (session.user as any).id) { return NextResponse.json({ error: '无权访问此上传会话' }, { status: 403 }) } const totalChunks = uploadSession.totalChunks const fileSize = uploadSession.fileSize // 收集分片文件 const chunkFiles = [] for (let i = 0; i < totalChunks; i++) { const chunkPath = path.join(tempDir, `chunk-${i}`) chunkFiles.push(chunkPath) } // 校验分片是否齐全 for (const chunkPath of chunkFiles) { try { await fs.access(chunkPath) } catch { return NextResponse.json({ error: '分片不完整,请重新上传' }, { status: 400 }) } } // 合并文件 await fs.mkdir(tempDir, { recursive: true }) // 处理文件名:提取纯文件名(去除路径),并清理特殊字符,避免中文路径或特殊字符导致的问题 const rawFileName = originalFileName || uploadSession.fileName || `upload-${uploadId}` const baseFileName = path.basename(rawFileName) // 提取文件名部分,去除路径 const mergedFileName = sanitizeFileName(baseFileName) || `upload-${uploadId}` // 清理特殊字符 const mergedPath = path.join(tempDir, `merged-${mergedFileName}`) const mergedStream = createWriteStream(mergedPath) for (const chunkPath of chunkFiles) { const chunkStream = createReadStream(chunkPath) await pipeline(chunkStream, mergedStream, { end: false }) } // 手动结束写入并等待关闭 mergedStream.end() await new Promise((resolve, reject) => { mergedStream.on('close', () => resolve()) mergedStream.on('error', (err) => reject(err)) }) // 校验大小 const stat = await fs.stat(mergedPath) if (stat.size !== fileSize) { return NextResponse.json( { error: `合并后的文件大小不匹配,期望 ${fileSize} 实际 ${stat.size}` }, { status: 400 } ) } // 清理分片,保留合并后的文件供后续正式上传使用 // 使用统一的文件删除工具 const { deleteFiles } = await import('@/lib/file-cleanup') const result = await deleteFiles(chunkFiles) if (result.failedCount > 0) { console.warn('[UploadComplete] 清理分片失败:', result.errors) } // 上传完成后,立即删除会话(不再占用Redis内存) try { const { deleteUploadSession } = await import('@/lib/upload-sessions-redis') await deleteUploadSession(uploadId) console.log('[UploadComplete] 已删除会话:', uploadId) } catch (deleteError) { console.warn('[UploadComplete] 删除会话失败(忽略):', deleteError) } // 返回合并结果 return NextResponse.json({ message: '分片合并完成', existingFilePath: mergedPath, fileName: mergedFileName, fileSize: stat.size, uploadId, }) } catch (error) { console.error('[UploadComplete] Error:', error) return NextResponse.json({ error: '合并分片失败' }, { status: 500 }) } }