250 lines
7.9 KiB
TypeScript
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 })
|
|
}
|
|
}
|