Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
278 lines
9.3 KiB
TypeScript
278 lines
9.3 KiB
TypeScript
// 分片上传完成合并接口
|
|
// 负责将分片文件按顺序合并为完整文件,校验大小,并返回服务器侧可复用的文件路径
|
|
// 供前端在第二步调用正式上传接口时复用,避免重复上传大文件
|
|
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<void> {
|
|
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,
|
|
})
|
|
}
|