2026-05-07 16:37:22 +08:00

250 lines
7.9 KiB
TypeScript

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 })
}
}