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 { LIST_VISIBLE_MATERIAL_DATA } from '@/lib/material-query' 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 { 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 } async function inspectCertificateReportCompletionAfterError( materialId: string, ): Promise { 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() 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 = [] 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() 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', ...LIST_VISIBLE_MATERIAL_DATA, } 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) }) } }