chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

575 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { lookup as mimeTypeLookup } from 'mime-types'
import path from 'path'
import { readFile, unlink } from 'fs/promises'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
CERTIFICATE_REPORT_ASSET_TYPE,
CERTIFICATE_REPORT_MATERIAL_TYPE,
CERTIFICATE_REPORT_UPLOAD_LIMITS,
detectFileKindFromName,
isSupportedCertificateReportFileName,
} from '@/lib/certificate-report'
import {
buildCertificateReportPublicDisplayPath,
uploadPublicDisplayFileToS3,
uploadPublicDisplayToS3,
} from '@/lib/public-display'
import { cleanupTempFiles } from '@/lib/task-material-processor'
import { createMaterialLog } from '@/lib/material-log'
import { deleteFromS3 } from '@/lib/s3'
import { generatePdfPreview } from '@/lib/pdf-processor'
import { inspectBoundedRegularFile } from '@/lib/bounded-file-reader'
import { getOwnedUploadedFile } from '@/lib/upload-sessions'
import {
acquireTaskMaterialCompletionLock,
acquireTaskMaterialUploadSessionLocks,
type MaterialUploadStorageLockRelease,
} from '@/lib/material-upload-cross-process-lock'
import { MaterialUploadRequestError } from '@/lib/material-upload-request-guard'
interface UploadedReportFile {
uploadId: string
fileName: string
fileSize: number
sortOrder: number
fileKind: 'pdf' | 'image'
}
interface CertificateReportAssetSnapshot {
assetType: string
fileUrl: string
fileName: string | null
fileSize: bigint | number | string
}
interface CertificateReportCompletionSnapshot {
id: string
userId: string
materialType: string
taskStatus: string | null
previewUrl: string
zipFileName: string
zipFilePath: string
zipFileSize: bigint | number | string
deletedAt: Date | null
assets: CertificateReportAssetSnapshot[]
}
type CertificateReportCompletionInspection =
| { state: 'completed'; snapshot: CertificateReportCompletionSnapshot }
| { state: 'completed_incomplete'; snapshot: CertificateReportCompletionSnapshot }
| { state: 'other'; snapshot: CertificateReportCompletionSnapshot | null }
| { state: 'unknown'; error: unknown }
class CertificateReportRequestError extends Error {
constructor(message: string, readonly status = 400) {
super(message)
this.name = 'CertificateReportRequestError'
}
}
function sanitizeFileNameForStorage(fileName: string): string {
return String(fileName || '')
.trim()
.replace(/[\/\\:*?"<>|]/g, '_')
}
function toPositiveBigInt(value: unknown): bigint | null {
try {
const normalized = BigInt(value as bigint | number | string)
return normalized > BigInt(0) ? normalized : null
} catch {
return null
}
}
function hasCompleteCertificateReportEvidence(
material: CertificateReportCompletionSnapshot,
): boolean {
if (material.taskStatus !== 'completed') {
return false
}
const previewUrl = String(material.previewUrl || '').trim()
const summaryFileName = String(material.zipFileName || '').trim()
const summarySize = toPositiveBigInt(material.zipFileSize)
const reportAssets = Array.isArray(material.assets) ? material.assets : []
if (!previewUrl || !summaryFileName || !summarySize || reportAssets.length === 0) {
return false
}
let assetSizeTotal = BigInt(0)
for (const asset of reportAssets) {
if (
asset.assetType !== CERTIFICATE_REPORT_ASSET_TYPE.PDF
&& asset.assetType !== CERTIFICATE_REPORT_ASSET_TYPE.IMAGE
) {
return false
}
if (!String(asset.fileUrl || '').trim() || !String(asset.fileName || '').trim()) {
return false
}
const assetSize = toPositiveBigInt(asset.fileSize)
if (!assetSize) {
return false
}
assetSizeTotal += assetSize
}
return assetSizeTotal === summarySize
}
async function readCertificateReportCompletionSnapshot(
materialId: string,
): Promise<CertificateReportCompletionSnapshot | null> {
return prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
userId: true,
materialType: true,
taskStatus: true,
previewUrl: true,
zipFileName: true,
zipFilePath: true,
zipFileSize: true,
deletedAt: true,
assets: {
where: {
assetType: {
in: [CERTIFICATE_REPORT_ASSET_TYPE.PDF, CERTIFICATE_REPORT_ASSET_TYPE.IMAGE],
},
},
select: {
assetType: true,
fileUrl: true,
fileName: true,
fileSize: true,
},
},
},
}) as Promise<CertificateReportCompletionSnapshot | null>
}
async function inspectCertificateReportCompletionAfterError(
materialId: string,
): Promise<CertificateReportCompletionInspection> {
try {
const snapshot = await readCertificateReportCompletionSnapshot(materialId)
if (snapshot?.taskStatus === 'completed') {
return hasCompleteCertificateReportEvidence(snapshot)
? { state: 'completed', snapshot }
: { state: 'completed_incomplete', snapshot }
}
return { state: 'other', snapshot }
} catch (error) {
return { state: 'unknown', error }
}
}
function buildErrorResponse(error: unknown): NextResponse {
const message = error instanceof Error ? error.message : '完成上传失败'
if (error instanceof MaterialUploadRequestError) {
const headers = error.retryAfterSeconds
? { 'Retry-After': String(error.retryAfterSeconds) }
: undefined
return NextResponse.json(
{ error: message, code: error.code },
{ status: error.status, headers },
)
}
return NextResponse.json(
{ error: message },
{ status: error instanceof CertificateReportRequestError ? error.status : 500 },
)
}
export async function POST(request: NextRequest) {
const tempFiles: string[] = []
const uploadIds: string[] = []
const uploadedAssetUrls: string[] = []
const localPreviewPaths = new Set<string>()
let previewUrlToCleanup = ''
let materialCommitted = false
let committedMaterialId = ''
let authenticatedUserId = ''
let releaseCompletionLock: MaterialUploadStorageLockRelease | null = null
let releaseUploadSessionLocks: MaterialUploadStorageLockRelease | null = null
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const userId = String((session.user as any).id || '').trim()
authenticatedUserId = userId
const body = await request.json()
const materialId = String(body.materialId || '').trim()
committedMaterialId = materialId
const rawFiles = Array.isArray(body.files) ? body.files : []
if (rawFiles.length > CERTIFICATE_REPORT_UPLOAD_LIMITS.maxFiles) {
throw new CertificateReportRequestError(
`检测报告最多上传 ${CERTIFICATE_REPORT_UPLOAD_LIMITS.maxFiles} 个文件,请分批提交`,
413,
)
}
const files: UploadedReportFile[] = rawFiles
.map((item: any, index: number): UploadedReportFile => ({
uploadId: String(item.uploadId || '').trim(),
fileName: String(item.fileName || '').trim(),
fileSize: Number(item.fileSize || 0),
sortOrder: Number.isFinite(Number(item.sortOrder)) ? Number(item.sortOrder) : index,
fileKind: detectFileKindFromName(item.fileName),
}))
.filter((item: UploadedReportFile) => item.uploadId && item.fileName)
.sort((left: UploadedReportFile, right: UploadedReportFile) => left.sortOrder - right.sortOrder)
if (!materialId || files.length === 0) {
return NextResponse.json({ error: '缺少必填字段' }, { status: 400 })
}
if (files.length !== rawFiles.length) {
throw new CertificateReportRequestError('检测报告文件参数不完整,请重新上传')
}
if (files.some((item) => !Number.isSafeInteger(item.sortOrder) || item.sortOrder < 0)) {
throw new CertificateReportRequestError('检测报告文件顺序无效,请重新上传')
}
if (new Set(files.map((item) => item.uploadId)).size !== files.length) {
throw new CertificateReportRequestError('检测报告上传会话编号重复,请重新选择文件')
}
const unsupportedFile = files.find(
(item: UploadedReportFile) => !isSupportedCertificateReportFileName(item.fileName),
)
if (unsupportedFile) {
throw new CertificateReportRequestError(
`检测报告仅支持 PDF、JPEG、PNG、WebP:${unsupportedFile.fileName}`,
)
}
uploadIds.push(...files.map((item) => item.uploadId))
// 加锁前只做鉴权和类型检查。真正决定状态必须在跨容器锁内重新读取。
const initialMaterial = await prisma.material.findUnique({
where: { id: materialId },
select: {
id: true,
userId: true,
materialType: true,
deletedAt: true,
},
})
if (!initialMaterial || initialMaterial.deletedAt) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
if (initialMaterial.userId !== userId) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
}
if (initialMaterial.materialType !== CERTIFICATE_REPORT_MATERIAL_TYPE) {
return NextResponse.json({ error: '素材类型不匹配' }, { status: 400 })
}
releaseCompletionLock = await acquireTaskMaterialCompletionLock(materialId)
releaseUploadSessionLocks = await acquireTaskMaterialUploadSessionLocks(uploadIds)
const material = await readCertificateReportCompletionSnapshot(materialId)
if (!material || material.deletedAt) {
throw new CertificateReportRequestError('素材不存在', 404)
}
if (material.userId !== userId || material.materialType !== CERTIFICATE_REPORT_MATERIAL_TYPE) {
throw new CertificateReportRequestError('素材归属或类型已变化,请刷新后重试', 409)
}
if (material.taskStatus === 'completed') {
if (!hasCompleteCertificateReportEvidence(material)) {
throw new CertificateReportRequestError(
'检测报告完成记录不完整,服务器已保留文件,请联系管理员核查',
409,
)
}
releaseUploadSessionLocks.assertHeld()
await cleanupTempFiles([], uploadIds).catch((cleanupError) => {
console.warn('[Certificate Report Complete] 幂等请求确认已完成,但源会话清理失败:', cleanupError)
})
return NextResponse.json({ success: true, materialId, alreadyCompleted: true })
}
const isLegacyEmptyUpload = (
material.taskStatus == null
&& !material.previewUrl
&& !material.zipFilePath
&& material.assets.length === 0
)
if (material.taskStatus !== 'uploading' && !isLegacyEmptyUpload) {
throw new CertificateReportRequestError('该检测报告正在处理或状态异常,请稍后刷新', 409)
}
if (material.assets.length > 0) {
throw new CertificateReportRequestError(
'检测报告上传记录已有未确认资产,服务器已保留文件,请联系管理员核查',
409,
)
}
const assetRecords: Array<{
materialId: string
assetType: string
fileUrl: string
fileName: string
fileSize: bigint
sortOrder: number
}> = []
let nextPreviewUrl = ''
let totalFileSize = 0
let firstFileName = ''
const resolvedFiles: Array<UploadedReportFile & { filePath: string; actualSize: number }> = []
for (const item of files) {
const filePath = await getOwnedUploadedFile(item.uploadId, userId)
if (!filePath) {
throw new CertificateReportRequestError(`找不到上传文件:${item.fileName}`)
}
const maxBytes = item.fileKind === 'pdf'
? CERTIFICATE_REPORT_UPLOAD_LIMITS.maxPdfBytes
: CERTIFICATE_REPORT_UPLOAD_LIMITS.maxImageBytes
let actualSize: number
try {
actualSize = (await inspectBoundedRegularFile(filePath, {
maxBytes,
expectedBytes: item.fileSize,
label: `检测报告文件 ${item.fileName}`,
})).size
} catch (error) {
throw new CertificateReportRequestError(
error instanceof Error ? error.message : `检测报告文件校验失败:${item.fileName}`,
413,
)
}
totalFileSize += actualSize
if (
!Number.isSafeInteger(totalFileSize)
|| totalFileSize > CERTIFICATE_REPORT_UPLOAD_LIMITS.maxTotalBytes
) {
throw new CertificateReportRequestError('检测报告文件总大小不能超过 1GB,请分批提交', 413)
}
tempFiles.push(filePath)
resolvedFiles.push({ ...item, filePath, actualSize })
if (!firstFileName) {
firstFileName = item.fileName
}
}
const targetRelativePaths = new Set<string>()
for (const item of resolvedFiles) {
const safeFileName = sanitizeFileNameForStorage(item.fileName)
const fileKind = item.fileKind
const contentType =
mimeTypeLookup(path.extname(item.fileName))?.toString()
|| (fileKind === 'pdf' ? 'application/pdf' : 'image/jpeg')
const targetRelativePath = buildCertificateReportPublicDisplayPath(
materialId,
fileKind === 'pdf' ? 'pdf' : 'image',
`${String(item.sortOrder).padStart(3, '0')}-${safeFileName}`,
)
if (targetRelativePaths.has(targetRelativePath)) {
throw new CertificateReportRequestError(
`检测报告文件名与顺序重复:${item.fileName}`,
)
}
targetRelativePaths.add(targetRelativePath)
const uploadedUrl = await uploadPublicDisplayFileToS3(
item.filePath,
targetRelativePath,
contentType,
item.actualSize,
)
uploadedAssetUrls.push(uploadedUrl)
assetRecords.push({
materialId,
assetType: fileKind === 'pdf'
? CERTIFICATE_REPORT_ASSET_TYPE.PDF
: CERTIFICATE_REPORT_ASSET_TYPE.IMAGE,
fileUrl: uploadedUrl,
fileName: item.fileName,
fileSize: BigInt(item.actualSize),
sortOrder: item.sortOrder,
})
if (!nextPreviewUrl) {
if (fileKind === 'image') {
nextPreviewUrl = uploadedUrl
} else {
const previewResult = await generatePdfPreview(item.filePath)
if (previewResult.success && previewResult.previewPath) {
localPreviewPaths.add(previewResult.previewPath)
const previewBuffer = await readFile(previewResult.previewPath)
const previewPath = buildCertificateReportPublicDisplayPath(
materialId,
'preview',
`report-preview-${Date.now()}-${safeFileName.replace(/\.pdf$/i, '.jpg')}`,
)
nextPreviewUrl = await uploadPublicDisplayToS3(previewBuffer, previewPath, 'image/jpeg')
previewUrlToCleanup = nextPreviewUrl
await unlink(previewResult.previewPath).catch(() => undefined)
}
}
}
}
if (!nextPreviewUrl || assetRecords.length !== resolvedFiles.length) {
throw new Error('检测报告预览或文件资产生成不完整,本次素材未保存')
}
releaseCompletionLock.assertHeld()
releaseUploadSessionLocks.assertHeld()
await prisma.$transaction(async (tx) => {
await tx.materialAsset.createMany({ data: assetRecords })
const finalizeResult = await tx.material.updateMany({
where: {
id: materialId,
userId,
materialType: CERTIFICATE_REPORT_MATERIAL_TYPE,
deletedAt: null,
OR: [
{ taskStatus: 'uploading' },
{ taskStatus: null, previewUrl: '', zipFilePath: '' },
],
},
data: {
previewUrl: nextPreviewUrl,
zipFileName: firstFileName,
zipFileSize: BigInt(totalFileSize),
taskStatus: 'completed',
} as any,
})
if (finalizeResult.count !== 1) {
throw new Error('检测报告完成状态已变化,已停止写入')
}
})
materialCommitted = true
// 只有 completed 事务确认后才消费源上传会话。清理失败不能反向删除正式资产。
await cleanupTempFiles(tempFiles, uploadIds).catch((cleanupError) => {
console.warn('[Certificate Report Complete] 清理源上传会话失败,但素材已保存:', cleanupError)
})
await createMaterialLog({
materialId,
userId,
action: 'UPLOAD',
changes: {
uploadType: {
oldValue: null,
newValue: CERTIFICATE_REPORT_MATERIAL_TYPE,
},
assetCount: {
oldValue: 0,
newValue: assetRecords.length,
},
},
}).catch((logError) => {
console.warn('[Certificate Report Complete] 上传日志记录失败,但素材已保存:', logError)
})
return NextResponse.json({ success: true, materialId })
} catch (error) {
console.error('[Certificate Report Complete Error]', error)
if (materialCommitted) {
return NextResponse.json({
success: true,
materialId: committedMaterialId || undefined,
warning: '素材已保存,但收尾步骤存在异常',
})
}
if (releaseCompletionLock && committedMaterialId) {
const completionState = await inspectCertificateReportCompletionAfterError(committedMaterialId)
if (completionState.state === 'completed') {
if (releaseUploadSessionLocks) {
try {
releaseUploadSessionLocks.assertHeld()
await cleanupTempFiles(tempFiles, uploadIds)
} catch (cleanupError) {
console.warn('[Certificate Report Complete] 数据库已完成,但源会话清理失败或锁已失效:', cleanupError)
}
}
return NextResponse.json({
success: true,
materialId: committedMaterialId,
warning: '素材已保存,但响应确认阶段出现异常',
})
}
if (completionState.state === 'completed_incomplete') {
console.error('[Certificate Report Complete] 数据库已标记完成,但关键资产证据不完整:', completionState)
return NextResponse.json(
{ error: '检测报告完成记录不完整,服务器已保留文件,请联系管理员核查' },
{ status: 409 },
)
}
if (completionState.state === 'unknown') {
console.error('[Certificate Report Complete] 无法确认数据库最终状态,保留全部文件:', completionState.error)
return NextResponse.json(
{ error: '完成状态暂时无法确认,服务器已保留上传文件,请勿重复提交' },
{ status: 500 },
)
}
const snapshot = completionState.snapshot
const isRetryableUploadState = Boolean(
snapshot
&& !snapshot.deletedAt
&& snapshot.userId === authenticatedUserId
&& snapshot.materialType === CERTIFICATE_REPORT_MATERIAL_TYPE
&& (snapshot.taskStatus === 'uploading' || snapshot.taskStatus == null),
)
if (isRetryableUploadState) {
const cleanupTargets = previewUrlToCleanup
? Array.from(new Set([...uploadedAssetUrls, previewUrlToCleanup]))
: Array.from(new Set(uploadedAssetUrls))
let cleanupLocksHeld = true
try {
releaseCompletionLock.assertHeld()
if (cleanupTargets.length > 0) {
if (!releaseUploadSessionLocks) {
cleanupLocksHeld = false
} else {
releaseUploadSessionLocks.assertHeld()
}
}
} catch (lockError) {
cleanupLocksHeld = false
console.error('[Certificate Report Cleanup] 完成锁已失效,保留派生文件:', lockError)
}
if (cleanupLocksHeld) {
for (const fileUrl of cleanupTargets) {
await deleteFromS3(fileUrl).catch((cleanupError) => {
console.error('[Certificate Report Cleanup] 删除本次派生 S3 文件失败:', cleanupError)
})
}
}
} else {
console.warn('[Certificate Report Complete] 素材状态已由其它流程接管,保留全部文件:', completionState)
}
}
return buildErrorResponse(error)
} finally {
for (const previewPath of Array.from(localPreviewPaths)) {
await unlink(previewPath).catch(() => undefined)
}
await releaseUploadSessionLocks?.().catch((releaseError) => {
console.error('[Certificate Report Complete] 释放上传会话锁失败:', releaseError)
})
await releaseCompletionLock?.().catch((releaseError) => {
console.error('[Certificate Report Complete] 释放素材完成锁失败:', releaseError)
})
}
}