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, detectFileKindFromName, } from '@/lib/certificate-report' import { buildCertificateReportPublicDisplayPath, 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' interface UploadedReportFile { uploadId: string fileName: string fileSize: number sortOrder: number fileKind: 'pdf' | 'image' } function sanitizeFileNameForStorage(fileName: string): string { return String(fileName || '') .trim() .replace(/[\/\\:*?"<>|]/g, '_') } export async function POST(request: NextRequest) { const tempFiles: string[] = [] const uploadIds: string[] = [] const uploadedAssetUrls: string[] = [] let previewUrlToCleanup = '' let materialCommitted = false let committedMaterialId = '' try { const session = await getServerSession(authOptions) if (!session?.user) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } const body = await request.json() const materialId = String(body.materialId || '').trim() committedMaterialId = materialId const rawFiles = Array.isArray(body.files) ? body.files : [] const files = rawFiles .map((item: any) => ({ uploadId: String(item.uploadId || '').trim(), fileName: String(item.fileName || '').trim(), fileSize: Number(item.fileSize || 0), sortOrder: Number.isFinite(Number(item.sortOrder)) ? Number(item.sortOrder) : 0, fileKind: item.fileKind === 'pdf' ? 'pdf' : 'image', })) .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 }) } const material = await prisma.material.findUnique({ where: { id: materialId }, select: { id: true, userId: true, materialType: true, productModel: true, reportTitle: true, } as any, }) if (!material) { return NextResponse.json({ error: '素材不存在' }, { status: 404 }) } const materialRecord = material as any if (materialRecord.userId !== (session.user as any).id) { return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 }) } if (materialRecord.materialType !== CERTIFICATE_REPORT_MATERIAL_TYPE) { return NextResponse.json({ error: '素材类型不匹配' }, { status: 400 }) } const { getUploadedFile } = await import('@/lib/upload-sessions') const assetRecords: Array<{ materialId: string assetType: string fileUrl: string fileName: string fileSize: bigint sortOrder: number }> = [] let nextPreviewUrl = '' let totalFileSize = BigInt(0) let firstFileName = '' for (let index = 0; index < files.length; index += 1) { const item = files[index] const filePath = await getUploadedFile(item.uploadId) if (!filePath) { throw new Error(`找不到上传文件:${item.fileName}`) } tempFiles.push(filePath) uploadIds.push(item.uploadId) totalFileSize += BigInt(item.fileSize || 0) if (!firstFileName) { firstFileName = item.fileName } const buffer = await readFile(filePath) const safeFileName = sanitizeFileNameForStorage(item.fileName) const fileKind = item.fileKind || detectFileKindFromName(item.fileName) 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}`, ) const uploadedUrl = await uploadPublicDisplayToS3(buffer, targetRelativePath, contentType) 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.fileSize || 0), sortOrder: item.sortOrder, }) if (!nextPreviewUrl) { if (fileKind === 'image') { nextPreviewUrl = uploadedUrl } else { const previewResult = await generatePdfPreview(filePath) if (previewResult.success && 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(() => {}) } } } } if (!nextPreviewUrl) { const firstImageAsset = assetRecords.find((item) => item.assetType === CERTIFICATE_REPORT_ASSET_TYPE.IMAGE) if (firstImageAsset) { nextPreviewUrl = firstImageAsset.fileUrl } } await prisma.$transaction([ prisma.materialAsset.createMany({ data: assetRecords, }), prisma.material.update({ where: { id: materialId }, data: { previewUrl: nextPreviewUrl, zipFileName: firstFileName, zipFileSize: totalFileSize, } as any, }), ]) materialCommitted = true try { await cleanupTempFiles(tempFiles, uploadIds) } catch (cleanupError) { console.warn('[Certificate Report Complete] 清理临时文件失败,但素材已保存:', cleanupError) } try { await createMaterialLog({ materialId, userId: (session.user as any).id, 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: any) { console.error('[Certificate Report Complete Error]', error) if (materialCommitted) { return NextResponse.json({ success: true, materialId: committedMaterialId || undefined, warning: '素材已保存,但收尾步骤存在异常', }) } const cleanupTargets = previewUrlToCleanup ? Array.from(new Set([...uploadedAssetUrls, previewUrlToCleanup])) : uploadedAssetUrls for (const fileUrl of cleanupTargets) { try { await deleteFromS3(fileUrl) } catch (cleanupError) { console.error('[Certificate Report Cleanup] 删除S3文件失败:', cleanupError) } } await cleanupTempFiles(tempFiles, uploadIds).catch((cleanupError) => { console.error('[Certificate Report Cleanup] 清理临时文件失败:', cleanupError) }) return NextResponse.json({ error: error.message || '完成上传失败' }, { status: 500 }) } }