import { NextRequest, NextResponse } from 'next/server' import { getAuthenticatedUploadUserId } from '@/lib/upload-auth' import { saveUploadSession } from '@/lib/upload-sessions' import { getUploadRootDir } from '@/lib/upload-paths' import { ensureSafeDirectoryWithinRoot, normalizeSafeFileName, } from '@/lib/path-security' import { randomBytes } from 'crypto' import { rm, statfs, writeFile } from 'fs/promises' import path from 'path' import { MAX_LOGICAL_UPLOAD_BYTES, MAX_UPLOAD_CHUNKS, resolveUploadChunkSize, } from '@/lib/material-upload-chunk-policy' const MEBIBYTE = 1024 * 1024 const GIBIBYTE = 1024 * MEBIBYTE const MIN_FREE_SPACE_RESERVE_BYTES = 2 * GIBIBYTE export async function POST(req: NextRequest) { try { const userId = await getAuthenticatedUploadUserId(req) if (!userId) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } let body try { body = await req.json() } catch (jsonError) { console.error('[UploadInit] JSON解析错误:', jsonError) return NextResponse.json( { error: '请求体格式错误,需要JSON格式' }, { status: 400 } ) } const rawFileName = typeof body?.fileName === 'string' ? body.fileName : '' const normalizedFileName = normalizeSafeFileName(rawFileName) const normalizedFileSize = Number(body?.fileSize) const normalizedTotalChunks = Number(body?.totalChunks) if (!rawFileName.trim()) { return NextResponse.json({ error: '文件名不能为空' }, { status: 400 }) } if (!normalizedFileName) { return NextResponse.json({ error: '文件名包含不安全的路径或字符' }, { status: 400 }) } if (!Number.isSafeInteger(normalizedFileSize) || normalizedFileSize <= 0) { return NextResponse.json({ error: '上传文件不能为空' }, { status: 400 }) } if (normalizedFileSize > MAX_LOGICAL_UPLOAD_BYTES) { return NextResponse.json( { error: '单个源文件不能超过 10GB', code: 'SOURCE_FILE_TOO_LARGE' }, { status: 413 }, ) } if ( !Number.isInteger(normalizedTotalChunks) || normalizedTotalChunks <= 0 || normalizedTotalChunks > MAX_UPLOAD_CHUNKS ) { return NextResponse.json({ error: '上传分片参数无效' }, { status: 400 }) } const normalizedChunkSize = resolveUploadChunkSize( normalizedFileSize, normalizedTotalChunks, body?.chunkSize, ) if (!normalizedChunkSize) { return NextResponse.json( { error: '上传分片大小与文件总大小不匹配', code: 'CHUNK_LAYOUT_INVALID' }, { status: 400 }, ) } // 生成上传ID const uploadId = `upload-${Date.now()}-${randomBytes(16).toString('hex')}` // 创建临时目录 const uploadDir = getUploadRootDir() const tempDir = await ensureSafeDirectoryWithinRoot(uploadDir, 'temp', uploadId) if (!tempDir) { throw new Error('上传临时目录不安全') } // 分片合并期间会同时保留分片与完整输出,必须为两份源文件再加固定 // 安全余量预留空间。无法确认剩余空间时保守拒绝,避免写满 uploads/NAS。 try { const disk = await statfs(tempDir, { bigint: true }) const availableBytes = disk.bavail * disk.bsize const requiredBytes = BigInt(normalizedFileSize) * BigInt(2) + BigInt(MIN_FREE_SPACE_RESERVE_BYTES) if (availableBytes < requiredBytes) { await rm(tempDir, { recursive: true, force: true }) return NextResponse.json( { error: '上传存储空间不足,请联系管理员清理后重试', code: 'UPLOAD_STORAGE_LOW' }, { status: 507 }, ) } } catch (diskError) { await rm(tempDir, { recursive: true, force: true }).catch(() => undefined) console.error('[UploadInit] 无法确认上传盘剩余空间:', diskError) return NextResponse.json( { error: '暂时无法确认上传存储空间,请稍后重试', code: 'UPLOAD_STORAGE_UNKNOWN' }, { status: 503 }, ) } // 写入元数据,方便在Redis丢失时回溯 const metaPath = path.join(tempDir, 'meta.json') const metaPayload = { fileName: normalizedFileName, fileSize: normalizedFileSize, totalChunks: normalizedTotalChunks, chunkSize: normalizedChunkSize, userId, createdAt: Date.now(), } await writeFile(metaPath, JSON.stringify(metaPayload), 'utf-8') // 保存会话(使用统一的接口,自动选择Redis或内存Map) const now = Date.now() await saveUploadSession(uploadId, { fileName: normalizedFileName, fileSize: normalizedFileSize, totalChunks: normalizedTotalChunks, chunkSize: normalizedChunkSize, chunks: new Map(), createdAt: now, lastActivityAt: now, uploadedBytes: 0, currentSpeed: 0, _speedSampleAt: now, _speedSampleBytes: 0, userId, }) console.log(`[UploadInit] Created upload session: ${uploadId}, chunks: ${normalizedTotalChunks}`) return NextResponse.json({ uploadId }) } catch (error) { console.error('[UploadInit] Error:', error) return NextResponse.json( { error: '初始化上传失败' }, { status: 500 } ) } }