// 素材上传API - 支持多种素材类型 import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { prisma } from '@/lib/prisma'; import { uploadToS3 } from '@/lib/s3'; import { promises as fs } from 'fs'; import path from 'path'; import { randomBytes, randomUUID } from 'crypto'; import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor'; import { enqueueVideoTranscodeTask, USER_TRANSCODE_FAILURE_MESSAGE } from '@/lib/video-transcode-task'; import { AUTO_GENERAL_TYPES, requiresCategoryAndScene, requiresRenderer, getDefaultRenderer } from '@/lib/material-validation'; import { compressImage, sanitizeFileName } from '@/lib/image-processor'; import { processPdfUpload } from '@/lib/pdf-upload-handler'; import { deleteFiles } from '@/lib/file-cleanup'; import { createMaterialLog } from '@/lib/material-log'; import { buildMaterialPublicDisplayPath, uploadPublicDisplayToS3, } from '@/lib/public-display'; import { LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES, normalizeVideoMaterialType, } from '@/lib/video-material-types'; import { deleteOwnedUploadSession, getOwnedUploadSession, isSafeUploadId, resolveOwnedMergedUpload, type OwnedMergedUpload, } from '@/lib/upload-sessions'; import { getDingtalkApprovalNoError, normalizeDingtalkApprovalNo, } from '@/lib/dingtalk-approval-no'; import { MATERIAL_UPLOAD_REQUEST_LIMITS, MaterialUploadRequestError, materialUploadRequestLimiter, validateMaterialUploadFormData, validateMaterialUploadRequestHeaders, } from '@/lib/material-upload-request-guard'; import { recoverMaterialUploadConsumptionJournal, removeMaterialUploadConsumptionJournal, syncMaterialUploadRootDirectory, writeMaterialUploadConsumptionJournal, type MaterialUploadConsumptionRecord, type RecoveredMaterialUploadConsumption, } from '@/lib/material-upload-consumption-journal'; import { acquireTaskMaterialUploadSessionLocks, type MaterialUploadStorageLockRelease, } from '@/lib/material-upload-cross-process-lock'; import { CanonicalMaterialSourceValidationError, validateCanonicalMaterialSource, } from '@/lib/material-canonical-source'; import { getSpuProductModelValidationError, isPlausibleSpuProductModel, normalizeSpuProductModel, } from '@/lib/product-model'; import { isLiveClipType, parseLiveDate, validateLiveClipFields, } from '@/lib/live-clip'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const maxDuration = 10800; // 3小时 // 音频素材占位符预览图(SVG格式) const AUDIO_PLACEHOLDER = (() => { const svg = `音频素材` return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() // PDF素材占位符预览图(SVG格式) const PDF_PLACEHOLDER = (() => { const svg = `PDF品牌VI灵感` return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() // SKU模板占位符预览图(SVG格式) const SKU_TEMPLATE_PLACEHOLDER = (() => { const svg = `SKU模版` return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}` })() const VIDEO_TYPES = new Set(LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES) const AUDIO_TYPES = new Set(['音乐']) const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.m4v', '.mkv', '.avi', '.3gp']) const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.flac', '.m4a', '.aac']) function normalizeExtension(ext: string): string { if (!ext) return '' return ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}` } function detectExtFromMime(mime?: string | null): string | null { if (!mime) return null const map: Record = { 'video/mp4': '.mp4', 'video/quicktime': '.mov', 'video/x-matroska': '.mkv', 'video/x-msvideo': '.avi', 'video/3gpp': '.3gp', 'audio/mpeg': '.mp3', 'audio/wav': '.wav', 'audio/flac': '.flac', 'audio/mp4': '.m4a', 'audio/aac': '.aac', } return map[mime] || null } async function detectExtFromFile(filePath?: string): Promise { if (!filePath) return null try { const absolute = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath) const handle = await fs.open(absolute, 'r') try { const buffer = Buffer.alloc(16) await handle.read(buffer, 0, 16, 0) const signature = buffer.toString('hex') if (buffer.slice(4, 8).toString('ascii').startsWith('ftyp')) { return '.mp4' } if (buffer.slice(0, 4).toString('ascii') === 'RIFF') { return '.avi' } if (signature.startsWith('1a45dfa3')) { return '.mkv' } if (buffer.slice(0, 3).toString('ascii') === 'ID3') { return '.mp3' } if (buffer.slice(0, 4).toString('ascii') === 'fLaC') { return '.flac' } return null } finally { await handle.close() } } catch (error) { console.warn('[Upload] Failed to detect extension from file:', filePath, error) return null } } async function ensureMediaExtension(params: { extCandidate: string mimeType?: string | null filePath?: string fileBuffer?: Buffer | null materialType: string }): Promise { const { extCandidate, mimeType, filePath, fileBuffer, materialType } = params const targetSet = VIDEO_TYPES.has(materialType) ? VIDEO_EXTENSIONS : AUDIO_TYPES.has(materialType) ? AUDIO_EXTENSIONS : null if (!targetSet) { return extCandidate || '.zip' } if (fileBuffer && fileBuffer.length >= 16) { const signature = fileBuffer.toString('hex', 0, 16) const ftyp = fileBuffer.slice(4, 8).toString('ascii') const riff = fileBuffer.slice(0, 4).toString('ascii') const id3 = fileBuffer.slice(0, 3).toString('ascii') const flac = fileBuffer.slice(0, 4).toString('ascii') if (ftyp.startsWith('ftyp') && targetSet.has('.mp4')) return '.mp4' if (riff === 'RIFF' && targetSet.has('.avi')) return '.avi' if (signature.startsWith('1a45dfa3') && targetSet.has('.mkv')) return '.mkv' if (id3 === 'ID3' && targetSet.has('.mp3')) return '.mp3' if (flac === 'fLaC' && targetSet.has('.flac')) return '.flac' } const normalized = normalizeExtension(extCandidate) if (normalized && targetSet.has(normalized)) { return normalized } const mimeExt = detectExtFromMime(mimeType) if (mimeExt && targetSet.has(mimeExt)) { return mimeExt } const detected = await detectExtFromFile(filePath) if (detected && targetSet.has(detected)) { return detected } return VIDEO_TYPES.has(materialType) ? '.mp4' : '.mp3' } function isVideoMaterial(materialType: string) { return VIDEO_TYPES.has(materialType) } function isAudioMaterial(materialType: string) { return AUDIO_TYPES.has(materialType) } function normalizeMaterialType(materialTypeRaw?: string | null): string { const trimmed = (materialTypeRaw || '').trim() if (!trimmed) { return '3D场景' } if (trimmed.toLowerCase() === 'video') { return 'VIDEO' } return normalizeVideoMaterialType(trimmed) } // 生成唯一文件名 function generateUniqueFilename(prefix: string, ext: string): string { const timestamp = Date.now(); const random = randomBytes(8).toString('hex'); return `${prefix}-${timestamp}-${random}${ext}`; } function serializeMaterial(material: any) { // 转码 lease token 是服务端内部租约凭证,状态查询和上传响应都不得下发。 const { transcodeLeaseToken: _transcodeLeaseToken, transcodeLeaseExpiresAt: _transcodeLeaseExpiresAt, ...publicMaterial } = material || {}; return { ...publicMaterial, ...(typeof material?.zipFileSize === 'bigint' ? { zipFileSize: material.zipFileSize.toString() } : {}), }; } function createUploadSuccessResponse(material: any, status = 201, replayed = false) { return NextResponse.json( { message: '上传成功', material: serializeMaterial(material), ...(replayed ? { replayed: true } : {}), }, { status }, ); } async function findOwnedMaterialByUploadId(uploadId: string, userId: string) { return prisma.material.findFirst({ // uploadId 的唯一性只覆盖未软删素材。隔离/软删记录不得被当成幂等 // 成功返回,否则用户会拿到已不可见或已判坏的旧记录。 where: { uploadId, userId, deletedAt: null }, include: { user: { select: { username: true }, }, }, }); } function requiresCanonicalSourceValidation(materialType: unknown): boolean { const normalizedType = String(materialType || '').trim(); return normalizedType === '音乐' || isVideoMaterial(normalizedType); } async function validateCanonicalSourceForMaterial(material: { materialType?: unknown; zipFilePath?: unknown; zipFileSize?: unknown; }) { if (!requiresCanonicalSourceValidation(material.materialType)) { return; } await validateCanonicalMaterialSource({ storedPath: String(material.zipFilePath || ''), expectedSize: material.zipFileSize as bigint | number | string, }); } async function quarantineCommittedMaterialAfterCanonicalSourceFailure(input: { material: any; phase: 'post-commit' | 'idempotent-replay'; error: unknown; }) { const errorCode = input.error instanceof CanonicalMaterialSourceValidationError ? input.error.code : 'CANONICAL_SOURCE_STAT_FAILED'; const diagnostic = `canonical 源文件完整性校验失败 (${input.phase}/${errorCode})`; try { await prisma.material.update({ where: { id: input.material.id }, data: { // DB 已提交后绝不能删除唯一原件。先软隔离记录,等待人工核验或安全恢复。 deletedAt: new Date(), ...(isVideoMaterial(String(input.material.materialType || '')) ? { videoProcessingStatus: 'failed', transcodeLastError: diagnostic.substring(0, 1000), } : {}), } as any, }); } catch (quarantineError) { // 即使隔离写入暂时失败,也必须保留文件并返回 503,不能把坏记录误报成功。 console.error('[Upload] canonical 源文件异常且素材隔离失败,保留全部文件:', { materialId: input.material.id, phase: input.phase, validationError: input.error, quarantineError, }); } return NextResponse.json( { error: '源文件完整性校验失败,已停止返回成功结果,请联系管理员核验', code: 'CANONICAL_SOURCE_INTEGRITY_FAILED', }, { status: 503 }, ); } async function validateCanonicalSourceBeforeSuccess(input: { material: any; phase: 'post-commit' | 'idempotent-replay'; }) { try { await validateCanonicalSourceForMaterial(input.material); return null; } catch (error) { console.error('[Upload] 拒绝返回 canonical 源文件不完整的成功结果:', { materialId: input.material.id, phase: input.phase, error, }); return quarantineCommittedMaterialAfterCanonicalSourceFailure({ ...input, error, }); } } async function restoreMovedMergedUpload(input: { mergedPath: string; finalPath: string; }): Promise<'restored' | 'unknown'> { try { // 同一文件系统内 rename 是原子的;优先把尚未建档的源文件放回会话目录。 await fs.rename(input.finalPath, input.mergedPath); return 'restored'; } catch (restoreError) { try { const [mergedExists, finalExists] = await Promise.all([ fs.stat(input.mergedPath).then(() => true).catch(() => false), fs.stat(input.finalPath).then(() => true).catch(() => false), ]); if (mergedExists && !finalExists) { return 'restored'; } } catch { // 状态仍不明确时由调用方保守保留两个候选位置。 } console.error('[Upload] 无法确认 merged 文件恢复结果,保守保留源文件:', { error: restoreError, }); return 'unknown'; } } export async function GET(request: NextRequest) { const session = await getServerSession(authOptions); const userId = String((session?.user as { id?: unknown } | undefined)?.id || '').trim(); if (!userId) { return NextResponse.json({ error: '请先登录' }, { status: 401 }); } const uploadId = String(request.nextUrl.searchParams.get('uploadId') || '').trim(); if (!isSafeUploadId(uploadId)) { return NextResponse.json({ error: 'uploadId 无效' }, { status: 400 }); } const material = await findOwnedMaterialByUploadId(uploadId, userId); if (material) { const statusValidationFailure = await validateCanonicalSourceBeforeSuccess({ material, phase: 'idempotent-replay', }); if (statusValidationFailure) { return statusValidationFailure; } return NextResponse.json({ status: 'completed', material: serializeMaterial(material), }); } const uploadSession = await getOwnedUploadSession(uploadId, userId); if (uploadSession) { return NextResponse.json({ status: 'processing' }); } // 不区分“确实不存在”和“属于其他用户”,避免 uploadId 枚举泄露。 return NextResponse.json( { status: 'not_found', error: '上传记录不存在或无权访问' }, { status: 404 }, ); } // POST - 上传素材 import { isDetailTemplateType } from '@/lib/material-type-mapper'; export async function POST(request: NextRequest) { // 用于清理的文件URL列表(上传失败时使用) const uploadedFileUrls: string[] = []; let zipFilePath = ''; let materialId: string | null = null; let attemptedMaterialId: string | null = null; let attemptedMaterialMustBeCompleted = false; let ownedMergedUpload: OwnedMergedUpload | null = null; let movedMergedUpload: { mergedPath: string; finalPath: string } | null = null; let uploadConsumptionRecord: MaterialUploadConsumptionRecord | null = null; let recoveredUploadConsumption: RecoveredMaterialUploadConsumption | null = null; let sessionUserId = ''; let releaseUploadSlot: (() => void) | null = null; let releaseUploadSessionLock: MaterialUploadStorageLockRelease | null = null; try { console.log('[Upload] Starting upload process...'); const session = await getServerSession(authOptions); if (!session) { return NextResponse.json( { error: '请先登录' }, { status: 401 } ); } sessionUserId = String((session.user as any).id || '').trim(); validateMaterialUploadRequestHeaders(request.headers); releaseUploadSlot = await materialUploadRequestLimiter.acquire(); const formData = await request.formData(); // 必须在任何 arrayBuffer、Sharp、S3 或数据库写入前完成统一校验。 validateMaterialUploadFormData(formData); const previewImage = formData.get('previewImage') as File | null; // 第一张预览图(主预览图) const previewImages = formData.getAll('previewImages') as File[]; // 其他预览图(作为展示图) const displayImage = formData.get('displayImage') as File | null; const modelImages = formData.getAll('modelImages') as File[]; const displayImagesFromForm = formData.getAll('displayImages') as File[]; const zipFile = formData.get('zipFile') as File | null; // 支持分片上传后复用服务器已合并的文件 const existingFilePath = (formData.get('existingFilePath') as string) || ''; const suppliedUploadId = String(formData.get('uploadId') || '').trim() || null; const originalFileName = (formData.get('originalFileName') as string) || ''; const productModelInput = (formData.get('productModel') as string) || ''; const categoryName = formData.get('categoryName') as string; const sceneName = formData.get('sceneName') as string; const platformName = (formData.get('platformName') as string) || ''; const productCategoryName = (formData.get('productCategoryName') as string) || ''; const materialTypeRaw = formData.get('materialType') as string | null; const materialType = normalizeMaterialType(materialTypeRaw); if (materialType === '任务-详情' || materialType === '任务-页面') { return NextResponse.json( { error: materialType === '任务-详情' ? '任务-详情必须使用专用上传入口,并选择需求运营人员' : '任务-页面必须使用专用上传入口', }, { status: 400 } ); } const isTaskOtherMaterial = materialType === '任务-其他'; const isTaskMaterial = materialType.startsWith('任务-'); const rendererParam = formData.get('renderer') as string | null; // 统一处理renderer:3D场景必须有renderer,其他类型可选 const renderer = requiresRenderer(materialType) ? (rendererParam || getDefaultRenderer(materialType)) : (rendererParam || ''); const rawTaskNo = (formData.get('taskNo') as string) || ''; const taskNo = isTaskOtherMaterial ? rawTaskNo.trim().toUpperCase() : rawTaskNo; const rawDingtalkApprovalNo = (formData.get('dingtalkApprovalNo') as string) || ''; const dingtalkApprovalNo = normalizeDingtalkApprovalNo(rawDingtalkApprovalNo); const rawProductModel = productModelInput || (isTaskOtherMaterial ? taskNo : ''); const isLiveClip = isLiveClipType(materialType); // 直播切片的 productModel 存「标题」(自由文本),不做 SPU 规范化/校验 const productModel = VIDEO_TYPES.has(materialType) && !isLiveClip ? normalizeSpuProductModel(rawProductModel) : rawProductModel.trim(); const shouldAutoFillGeneralCategoryScene = AUTO_GENERAL_TYPES.includes(materialType) || materialType === '标准模型'; const resolvedCategoryName = shouldAutoFillGeneralCategoryScene ? '通用' : categoryName; const resolvedSceneName = shouldAutoFillGeneralCategoryScene ? '通用' : sceneName; const liveDateInput = (formData.get('liveDate') as string) || ''; const liveHostInput = (formData.get('liveHost') as string) || ''; const liveHostUserIdInput = (formData.get('liveHostUserId') as string) || ''; const livePlatformInput = (formData.get('livePlatform') as string) || ''; if (isLiveClip) { const liveClipErrors = validateLiveClipFields({ title: productModel, liveDate: liveDateInput, livePlatform: livePlatformInput, }); if (liveClipErrors.length > 0) { return NextResponse.json( { error: liveClipErrors.map((e) => e.message).join(';') }, { status: 400 }, ); } } if (VIDEO_TYPES.has(materialType) && !isLiveClip && !isPlausibleSpuProductModel(productModel)) { return NextResponse.json( { error: `视频素材 SPU 不合规:${getSpuProductModelValidationError(productModel) || '请检查后重试'}`, }, { status: 400 }, ); } if (zipFile && existingFilePath) { return NextResponse.json( { error: '不能同时提交上传文件和服务器合并文件' }, { status: 400 } ); } if (existingFilePath && !suppliedUploadId) { return NextResponse.json( { error: '分片合并文件必须同时提供 uploadId' }, { status: 400 }, ); } if (suppliedUploadId && !isSafeUploadId(suppliedUploadId)) { return NextResponse.json( { error: 'uploadId 无效' }, { status: 400 }, ); } if (existingFilePath && suppliedUploadId) { // 与任务图片、任务详情共用同一把跨容器 uploadId 锁;从恢复判断开始一直 // 持有到数据库提交和临时会话清理完成,防止另一个流程移动或删除同一源。 releaseUploadSessionLock = await acquireTaskMaterialUploadSessionLocks([ suppliedUploadId, ]); } if (existingFilePath && suppliedUploadId) { // 响应可能在公网链路中丢失。临时目录已清理时,先按“当前用户 + // uploadId”返回既有结果,不再要求 merged 文件仍存在。 const replayedMaterial = await findOwnedMaterialByUploadId( suppliedUploadId, sessionUserId, ); if (replayedMaterial) { const replayValidationFailure = await validateCanonicalSourceBeforeSuccess({ material: replayedMaterial, phase: 'idempotent-replay', }); if (replayValidationFailure) { // 既有记录的 canonical 原件异常时保留 journal/会话和源文件, // 绝不能用 replayed: true 把坏记录伪装成成功。 return replayValidationFailure; } try { await removeMaterialUploadConsumptionJournal({ ownerId: sessionUserId, uploadId: suppliedUploadId, expectedFinalPath: replayedMaterial.zipFilePath || undefined, }); } catch (journalCleanupError) { console.warn('[Upload] 已有素材的消费记录暂未清理,将保留供后续核验:', { uploadId: suppliedUploadId, materialId: replayedMaterial.id, error: journalCleanupError, }); } return createUploadSuccessResponse(replayedMaterial, 201, true); } } if (existingFilePath) { if (suppliedUploadId) { recoveredUploadConsumption = await recoverMaterialUploadConsumptionJournal({ ownerId: sessionUserId, uploadId: suppliedUploadId, }); } if (recoveredUploadConsumption) { if ( path.resolve(existingFilePath) !== recoveredUploadConsumption.record.mergedPath ) { return NextResponse.json( { error: '上传恢复记录与本次请求不匹配' }, { status: 409 }, ); } const record = recoveredUploadConsumption.record; uploadConsumptionRecord = record; ownedMergedUpload = { uploadId: record.uploadId, filePath: recoveredUploadConsumption.sourcePath, session: { fileName: record.fileName, fileSize: record.size, totalChunks: 1, chunks: new Map(), createdAt: record.createdAt, userId: record.ownerId, }, }; } else { ownedMergedUpload = await resolveOwnedMergedUpload({ existingFilePath, uploadId: suppliedUploadId, userId: sessionUserId, }); } if (!ownedMergedUpload) { return NextResponse.json( { error: '上传会话不存在、已失效或不属于当前用户' }, { status: 403 } ); } const mergedSourceStats = await fs.stat(ownedMergedUpload.filePath); if (mergedSourceStats.size > MATERIAL_UPLOAD_REQUEST_LIMITS.maxLogicalSourceBytes) { throw new MaterialUploadRequestError( '单个源文件不能超过 10GB', 413, 'SOURCE_FILE_TOO_LARGE', ); } } console.log('[Upload] Form data received:', { hasPreviewImage: !!previewImage, previewImagesCount: previewImages.length, hasDisplayImage: !!displayImage, modelImagesCount: modelImages.length, hasZipFile: !!zipFile, hasExistingFilePath: !!existingFilePath, productModel, categoryName: resolvedCategoryName, sceneName: resolvedSceneName, materialType, materialTypeRaw, }); // 使用统一的验证逻辑验证必填字段 if (!productModel) { return NextResponse.json( { error: isTaskOtherMaterial ? '任务-其他必须填写任务号' : isLiveClip ? '请填写标题' : '产品型号是必填的', }, { status: 400 } ); } if (isTaskOtherMaterial && !taskNo) { return NextResponse.json( { error: '任务-其他必须填写任务号' }, { status: 400 } ); } const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo); if (dingtalkApprovalNoError) { return NextResponse.json( { error: dingtalkApprovalNoError }, { status: 400 } ); } // 任务号验证(可选,如果提供则验证格式和唯一性) if (taskNo && taskNo.trim()) { const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package'); if (!validateTaskNo(taskNo)) { return NextResponse.json( { error: '任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)' }, { status: 400 } ); } // 检查任务号是否已存在 if (await isTaskNoExists(taskNo)) { return NextResponse.json( { error: '任务号已存在,请使用其他任务号' }, { status: 400 } ); } } if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) { return NextResponse.json( { error: '请填写所有必填字段(店铺、场景)' }, { status: 400 } ); } if (isTaskOtherMaterial && !previewImage && previewImages.length === 0) { return NextResponse.json( { error: '任务-其他必须上传至少一张预览图' }, { status: 400 } ); } // 根据素材类型验证主文件(与前端保持一致:除「模特」「标准模型」「买家秀图片」「小红书种草」外都需要主文件) const requiresMainFile = !['模特', '标准模型', '买家秀图片', '小红书种草'].includes(materialType); const hasMainFile = !!zipFile || !!existingFilePath; if (requiresMainFile && !hasMainFile) { return NextResponse.json( { error: '请上传素材文件' }, { status: 400 } ); } // 获取用户信息 const user = await prisma.user.findUnique({ where: { id: (session.user as any).id }, select: { username: true }, }); if (!user) { return NextResponse.json( { error: '用户不存在' }, { status: 404 } ); } let previewUrl = ''; const displayImages: string[] = []; // 处理预览图(第一张作为主预览图) // 详情页/详情模板:如果用户上传了预览图,优先使用;否则使用展示图切片的第一张 if (previewImage && !['模特'].includes(materialType)) { if (previewImage.size > 50 * 1024 * 1024) { return NextResponse.json( { error: `预览图过大,请使用小于50MB的图片` }, { status: 400 } ); } console.log('[Upload] Processing preview image (1/1)...'); const buffer = Buffer.from(await previewImage.arrayBuffer()); // 压缩预览图 console.log('[Upload] Compressing preview image...'); const compressedBuffer = await compressImage(buffer, { maxSizeKB: 300, maxWidth: 1200, maxHeight: 1600, format: 'webp' }); const fileName = sanitizeFileName(previewImage.name); const filename = generateUniqueFilename('preview', '.webp'); console.log('[Upload] Uploading compressed preview image to S3...'); previewUrl = await uploadPublicDisplayToS3( compressedBuffer, buildMaterialPublicDisplayPath(filename), 'image/webp', ); uploadedFileUrls.push(previewUrl); // 记录已上传的文件,用于失败时清理 console.log('[Upload] Preview image uploaded:', previewUrl); } // 处理其他预览图(作为展示图)。图片解码会使用 Sharp 原生内存, // 这里必须串行处理,避免一个请求内同时解码多张大图。 // 详情页/详情模板:其他预览图也作为展示图处理 if (previewImages.length > 0 && !['模特'].includes(materialType)) { console.log(`[Upload] Processing ${previewImages.length} preview images sequentially...`); // 验证所有图片大小 for (let i = 0; i < previewImages.length; i++) { if (previewImages[i].size > 50 * 1024 * 1024) { return NextResponse.json( { error: `预览图 ${i + 2} 过大,请使用小于50MB的图片` }, { status: 400 } ); } } for (let index = 0; index < previewImages.length; index += 1) { const image = previewImages[index] console.log(`[Upload] Processing preview image ${index + 2}/${previewImages.length + 1}...`); const buffer = Buffer.from(await image.arrayBuffer()); // 压缩预览图 console.log(`[Upload] Compressing preview image ${index + 2}...`); const compressedBuffer = await compressImage(buffer, { maxSizeKB: 300, maxWidth: 1200, maxHeight: 1600, format: 'webp' }); const filename = generateUniqueFilename(`preview-${index + 2}`, '.webp'); console.log(`[Upload] Uploading compressed preview image ${index + 2} to S3...`); const url = await uploadPublicDisplayToS3( compressedBuffer, buildMaterialPublicDisplayPath(filename), 'image/webp', ); uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 console.log(`[Upload] Preview image ${index + 2} uploaded:`, url); displayImages.push(url); } console.log(`[Upload] All ${previewImages.length} preview images uploaded successfully`); } // 处理展示图片(详情模板)- 后端自动压缩和切片 if (isDetailTemplateType(materialType) && displayImage) { const buffer = Buffer.from(await displayImage.arrayBuffer()); // 使用后端的 sliceImage 函数自动压缩和切片 const { sliceImage } = await import('@/lib/image-processor'); console.log('[Upload] Processing display image (backend slicing)...'); const slices = await sliceImage(buffer, 1500, 500); // maxHeight=1500px, maxSizeKB=500KB // 上传所有切片 for (let i = 0; i < slices.length; i++) { const sliceBuffer = slices[i]; const filename = generateUniqueFilename(`display-${i + 1}`, '.webp'); const url = await uploadPublicDisplayToS3( sliceBuffer, buildMaterialPublicDisplayPath(filename), 'image/webp', ); displayImages.push(url); uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 // 如果用户没有上传预览图,使用第一张切片作为预览图 if (i === 0 && !previewUrl) { previewUrl = url; console.log('[Upload] Using first display image slice as preview (no user preview uploaded)'); } console.log(`[Upload] Display image slice ${i + 1}/${slices.length} uploaded:`, url); } } // 处理模特图片 if (['模特', '实拍图片', '买家秀图片', '小红书种草'].includes(materialType) && modelImages.length > 0) { for (let i = 0; i < modelImages.length; i++) { try { const image = modelImages[i]; if (!image || image.size === 0) { console.warn(`[Upload] Model image ${i + 1} is empty, skipping`); continue; } const buffer = Buffer.from(await image.arrayBuffer()); const filename = generateUniqueFilename(`model-${i + 1}`, path.extname(image.name)); const url = await uploadPublicDisplayToS3( buffer, buildMaterialPublicDisplayPath(filename), image.type, ); displayImages.push(url); uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 if (i === 0 && !previewUrl) { previewUrl = url; } console.log(`[Upload] Model image ${i + 1} uploaded:`, url); } catch (imageError) { console.error(`[Upload] Failed to process model image ${i + 1}:`, imageError); throw new Error(`处理图片 ${i + 1} 失败: ${imageError instanceof Error ? imageError.message : String(imageError)}`); } } } // 处理通用展示图(买家秀/小红书/主图模版/SKU模版/品牌VI 等) if (displayImagesFromForm.length > 0) { for (let i = 0; i < displayImagesFromForm.length; i++) { try { const image = displayImagesFromForm[i]; if (!image || image.size === 0) { console.warn(`[Upload] Display image ${i + 1} is empty, skipping`); continue; } const buffer = Buffer.from(await image.arrayBuffer()); const filename = generateUniqueFilename(`display-${i + 1}`, path.extname(image.name)); const url = await uploadPublicDisplayToS3( buffer, buildMaterialPublicDisplayPath(filename), image.type, ); displayImages.push(url); uploadedFileUrls.push(url); // 记录已上传的文件,用于失败时清理 if (!previewUrl) { previewUrl = url; } console.log(`[Upload] Display image ${i + 1} uploaded:`, url); } catch (imageError) { console.error(`[Upload] Failed to process display image ${i + 1}:`, imageError); throw new Error(`处理展示图 ${i + 1} 失败: ${imageError instanceof Error ? imageError.message : String(imageError)}`); } } } // 处理素材文件(主文件:3D场景/PSD/视频源等) let zipFileName = ''; zipFilePath = ''; let zipFileSize = BigInt(0); if (zipFile || existingFilePath) { let ext = ''; let buffer: Buffer | null = null; let sourcePath = ''; if (zipFile) { ext = path.extname(zipFile.name); buffer = Buffer.from(await zipFile.arrayBuffer()); zipFileSize = BigInt(buffer.length); console.log('[Upload] Using uploaded file buffer'); } else { // 使用分片合并后的服务器侧文件 sourcePath = ownedMergedUpload!.filePath; let stat; try { stat = await fs.stat(sourcePath); } catch (error) { if (requiresCanonicalSourceValidation(materialType)) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_STAT_FAILED', '无法读取已合并的 canonical 源文件状态', error, ); } throw error; } // zipFileSize 以已校验上传会话的声明总大小为“已知值”,后续 canonical // 校验再独立读取正式文件实际大小并逐字节数比较,不能用同一次 stat // 同时充当期望值和实际值而让大小校验失去意义。 zipFileSize = BigInt(ownedMergedUpload!.session.fileSize); if ( requiresCanonicalSourceValidation(materialType) && BigInt(stat.size) !== zipFileSize ) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_SIZE_MISMATCH', '已合并的 canonical 源文件大小与上传会话不一致', ); } ext = path.extname(originalFileName || sourcePath); console.log('[Upload] Using existing merged file:', sourcePath, 'size:', stat.size); } const isVideoOrAudio = isVideoMaterial(materialType) || isAudioMaterial(materialType); if (isVideoMaterial(materialType)) { ext = await ensureMediaExtension({ extCandidate: ext, mimeType: zipFile?.type, filePath: sourcePath || existingFilePath, fileBuffer: buffer, materialType, }); } else if (isAudioMaterial(materialType)) { ext = await ensureMediaExtension({ extCandidate: ext, mimeType: zipFile?.type, filePath: sourcePath || existingFilePath, fileBuffer: buffer, materialType, }); } else { ext = normalizeExtension(ext) || '.zip'; } // 主文件一律本地化存储到 uploads 目录,MinIO 只用于预览文件 const uploadDir = process.env.UPLOAD_DIR || './uploads'; await fs.mkdir(uploadDir, { recursive: true }); // 根据类型决定文件名前缀 const basePrefix = isVideoOrAudio ? materialType === '音乐' ? 'audio-source' : 'video' : 'material'; if (uploadConsumptionRecord) { zipFileName = uploadConsumptionRecord.fileName; zipFilePath = uploadConsumptionRecord.storedFinalPath; } else { zipFileName = generateUniqueFilename(basePrefix, ext || '.bin'); zipFilePath = path.join(uploadDir, zipFileName); } if (sourcePath && !zipFile) { // 分片上传场景:直接移动合并后的大文件,避免重复读写 50MB+ 文件 const mergedPathBeforeMove = uploadConsumptionRecord?.mergedPath || sourcePath; const finalPathAfterMove = uploadConsumptionRecord?.finalPath || path.resolve(zipFilePath); if (!uploadConsumptionRecord) { uploadConsumptionRecord = await writeMaterialUploadConsumptionJournal({ uploadId: ownedMergedUpload!.uploadId, ownerId: sessionUserId, mergedPath: mergedPathBeforeMove, finalPath: finalPathAfterMove, storedFinalPath: zipFilePath, fileName: zipFileName, size: Number(zipFileSize), }); } if (path.resolve(sourcePath) !== finalPathAfterMove) { await fs.rename(sourcePath, finalPathAfterMove); movedMergedUpload = { mergedPath: mergedPathBeforeMove, finalPath: finalPathAfterMove, }; await syncMaterialUploadRootDirectory(); } else { // 上次进程已完成 rename、但尚未来得及建档;直接复用相同正式路径。 movedMergedUpload = { mergedPath: mergedPathBeforeMove, finalPath: finalPathAfterMove, }; } // 已经移动到最终位置,不再额外清理 sourcePath = ''; console.log('[Upload] Moved merged file to final path (no re-copy):', zipFilePath); } else if (buffer) { // 直传场景:使用内存中的 buffer 写入 await fs.writeFile(zipFilePath, buffer); console.log('[Upload] File saved locally:', zipFilePath); } else { throw new Error('未找到可写入的素材文件数据'); } if (isTaskOtherMaterial) { const sourceName = sanitizeFileName(originalFileName || zipFile?.name || zipFileName); if (sourceName) { zipFileName = sourceName; } } // PDF:从PDF文件提取第一页作为预览图(在文件保存后) if (materialType === '品牌VI灵感') { console.log('[Upload] 开始处理品牌VI灵感PDF预览图提取...'); console.log('[Upload] PDF文件路径:', zipFilePath); // 强制提取PDF预览图,即使已有previewUrl也要重新提取(确保是图片而不是PDF文件) const pdfResult = await processPdfUpload(zipFilePath, ''); console.log('[Upload] PDF预览图提取结果:', { success: pdfResult.success, hasPreviewUrl: !!pdfResult.previewUrl, previewUrl: pdfResult.previewUrl, error: pdfResult.error }); if (pdfResult.success && pdfResult.previewUrl) { previewUrl = pdfResult.previewUrl; uploadedFileUrls.push(previewUrl); console.log('[Upload] ✅ PDF预览图提取成功,已设置previewUrl:', previewUrl); } else { const errorMsg = pdfResult.error || '未知错误'; console.error('[Upload] ❌ PDF预览图提取失败:', errorMsg); console.error('[Upload] 使用占位符作为降级方案'); // 降级方案:使用PDF占位符,确保首页卡片有内容显示 previewUrl = PDF_PLACEHOLDER; console.warn('[Upload] ⚠️ 已使用占位符,建议后续手动上传预览图或重新上传PDF'); } } // 音频:尝试从本地文件/缓冲中解析封面信息,仅用于生成预览图,源文件仍本地化 if (materialType === '音乐' && zipFile && buffer && isAudioFile(zipFile.type)) { try { console.log('[Upload] Audio file detected, parsing metadata and extracting cover image...'); const audioMetadata = await parseAudioMetadata(buffer, zipFile.type); if (audioMetadata) { console.log('[Upload] Audio metadata parsed:', { duration: audioMetadata.duration, durationFormatted: audioMetadata.durationFormatted, format: audioMetadata.format, hasPicture: !!audioMetadata.picture, }); // 如果音频文件包含封面图,提取并上传到 MinIO 作为预览 if (audioMetadata.picture && !previewUrl) { try { const pictureBuffer = audioMetadata.picture.data; const pictureFormat = audioMetadata.picture.format || 'image/jpeg'; const pictureExt = pictureFormat.includes('png') ? '.png' : '.jpg'; const pictureFilename = generateUniqueFilename('audio-cover', pictureExt); const pictureUrl = await uploadToS3(pictureBuffer, pictureFilename, pictureFormat); previewUrl = pictureUrl; uploadedFileUrls.push(pictureUrl); // 记录已上传的文件,用于失败时清理 console.log('[Upload] Audio cover image extracted and uploaded:', pictureUrl); } catch (coverError) { console.error('[Upload] Failed to upload audio cover image:', coverError); } } } } catch (audioError) { console.error('[Upload] Failed to parse audio metadata:', audioError); // 音频解析失败不影响上传,继续处理 } } // 如果是复用服务器合并文件,用后清理整个临时目录 if (sourcePath) { try { const tempDir = path.dirname(sourcePath); // 直接删除整个临时目录(包括合并文件、meta.json等所有内容) const { cleanupUploadSession } = await import('@/lib/temp-cleanup'); const uploadId = path.basename(tempDir); const success = await cleanupUploadSession(uploadId); if (success) { console.log('[Upload] 已清理临时目录:', tempDir); } else { console.warn('[Upload] 清理临时目录失败:', tempDir); } } catch (cleanupError) { console.warn('[Upload] 清理临时目录失败(忽略):', cleanupError); } } } // 音频素材:如果没有预览图,使用占位符 if (materialType === '音乐' && (!previewUrl || previewUrl.trim() === '')) { previewUrl = AUDIO_PLACEHOLDER console.log('[Upload] Audio material without cover image, using audio placeholder') } // 验证买家秀图片必须有至少一张图片和预览图 if (materialType === '买家秀图片') { if (displayImages.length === 0) { return NextResponse.json( { error: '买家秀图片必须上传至少一张图片' }, { status: 400 } ); } if (!previewUrl || previewUrl.trim() === '') { return NextResponse.json( { error: '买家秀图片必须有一张预览图' }, { status: 400 } ); } } // 创建数据库记录(包含所有字段,确保数据不丢失) // 对于视频类型,videoSourceUrl 保存本地相对路径(如:/uploads/xxx.mp4),仅用于转码和回放 const videoSourceUrl = isVideoMaterial(materialType) && zipFileName ? `/uploads/${zipFileName}` : null; // 手动预览图同时是视频封面。否则转码恢复会误判为“缺少封面”, // 再生成一张自动封面并覆盖用户选择。 const videoCoverUrl = isVideoMaterial(materialType) && previewUrl.trim() !== '' ? previewUrl : null; // 最终保存前的previewUrl验证 if (materialType === '品牌VI灵感') { console.log('[Upload] ========== 品牌VI灵感预览图最终检查 =========='); console.log('[Upload] previewUrl:', previewUrl); console.log('[Upload] previewUrl是否为空:', !previewUrl || previewUrl.trim() === ''); console.log('[Upload] previewUrl是否为PDF文件:', previewUrl && previewUrl.endsWith('.pdf')); console.log('[Upload] previewUrl是否为图片文件:', previewUrl && (previewUrl.endsWith('.jpg') || previewUrl.endsWith('.jpeg') || previewUrl.endsWith('.png') || previewUrl.endsWith('.webp'))); console.log('[Upload] ============================================'); } // SKU模板:如果没有预览图,使用占位符 if (materialType === 'SKU模版' && (!previewUrl || previewUrl.trim() === '')) { console.warn('[Upload] ⚠️ SKU模板没有预览图,使用占位符'); previewUrl = SKU_TEMPLATE_PLACEHOLDER; } // 音乐/视频的本地文件是 canonical 唯一原件。必须在任何 Material 提交前 // 确认它位于受信 uploads 根目录、是非空普通非符号链接文件,且实际大小 // 与即将写入 zipFileSize 的已知大小完全一致。 await validateCanonicalSourceForMaterial({ materialType, zipFilePath, zipFileSize, }); // 由应用端预先生成主键。这样即使数据库已经提交、但提交响应在网络中丢失, // catch 仍能按确定 ID 复查最终状态,而不是误删已经被数据库引用的文件。 attemptedMaterialId = randomUUID(); const materialCreateData = { id: attemptedMaterialId, previewUrl, productModel, categoryName: resolvedCategoryName, sceneName: resolvedSceneName, platformName, productCategoryName, materialType, renderer, authorName: user.username, zipFileName, zipFilePath, zipFileSize, displayImages, // 只写入服务器从已校验 merged 路径派生出的 uploadId;直传和 // 客户端单独声明的 uploadId 均不能进入幂等键。 uploadId: ownedMergedUpload?.uploadId || null, videoSourceUrl, // 视频类型时保存视频URL videoCoverUrl, videoProcessingStatus: videoSourceUrl ? 'pending' : null, // 视频类型时初始化为pending状态 ...(isLiveClip ? { liveDate: parseLiveDate(liveDateInput) || parseLiveDate(new Date().toISOString().slice(0, 10)), liveHost: liveHostInput.trim() || null, liveHostUserId: liveHostUserIdInput.trim() || null, livePlatform: livePlatformInput.trim() || null, } : {}), dingtalkApprovalNo, // taskNo: 不写入,上传完成后再写入 // taskStatus: 不写入,上传完成后再写入 userId: (session.user as any).id, }; const taskMaterialTypes = [ '任务-其他', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', ]; const usesAtomicTaskMaterialCreate = taskMaterialTypes.includes(materialType); const needsPostCreateTaskCompletion = ( !usesAtomicTaskMaterialCreate && (isTaskMaterial || Boolean(taskNo.trim())) ); attemptedMaterialMustBeCompleted = ( usesAtomicTaskMaterialCreate || needsPostCreateTaskCompletion ); const taskMaterialAssets = [ ...(previewUrl ? [{ assetType: 'preview', fileUrl: previewUrl, fileName: 'preview-0.webp', fileSize: BigInt(0), sortOrder: 0, }] : []), ...(zipFilePath ? [{ assetType: 'zip', fileUrl: zipFilePath, fileName: zipFileName, fileSize: zipFileSize, sortOrder: 0, }] : []), ]; // 通用入口里的任务素材必须把 Material、必要资产、taskNo 和 completed // 放在同一事务;资产写入失败时不会留下“已完成但缺文件清单”的半成品。 const material: any = usesAtomicTaskMaterialCreate ? await (async () => { const { createTaskMaterialOnComplete } = await import('@/lib/product-package'); return createTaskMaterialOnComplete({ materialData: materialCreateData, assets: taskMaterialAssets, taskNo, dingtalkApprovalNo, }); })() : await prisma.material.create({ data: materialCreateData as any, include: { user: { select: { username: true }, }, }, }); console.log('[Upload] Database record created:', material.id); materialId = material.id; // 保存materialId,用于失败时清理 // 文件成功落盘后再写入任务关联字段,避免半成品占用任务号。 if (needsPostCreateTaskCompletion) { const { assignTaskNoOnComplete } = await import('@/lib/product-package'); await assignTaskNoOnComplete(material.id, taskNo, dingtalkApprovalNo); } const postCommitValidationFailure = await validateCanonicalSourceBeforeSuccess({ material, phase: 'post-commit', }); if (postCommitValidationFailure) { // 数据库已经提交:保留 canonical 原件、消费 journal 和上传会话, // 只隔离记录并返回可重试的 503,避免 catch 清理误删唯一源文件。 return postCommitValidationFailure; } if (uploadConsumptionRecord) { try { await removeMaterialUploadConsumptionJournal({ ownerId: sessionUserId, uploadId: uploadConsumptionRecord.uploadId, expectedFinalPath: zipFilePath, }); uploadConsumptionRecord = null; } catch (journalCleanupError) { // Material 已提交,消费记录保留是安全的;重试会先按 uploadId 返回该素材。 console.warn('[Upload] 素材已建档,但消费记录清理失败(保留):', { materialId: material.id, uploadId: ownedMergedUpload?.uploadId, error: journalCleanupError, }); } } // 素材和任务状态已提交后,审计日志属于附属记录。日志服务瞬时失败不能 // 把一个已完成素材改判为上传失败,更不能触发源文件/预览文件回滚。 try { await createMaterialLog({ materialId: material.id, userId: (session.user as any).id, action: 'UPLOAD', changes: { productModel: { oldValue: null, newValue: productModel, }, materialType: { oldValue: null, newValue: materialType, }, categoryName: { oldValue: null, newValue: resolvedCategoryName, }, sceneName: { oldValue: null, newValue: resolvedSceneName, }, taskNo: { oldValue: null, newValue: taskNo || null, }, dingtalkApprovalNo: { oldValue: null, newValue: dingtalkApprovalNo, }, }, }); } catch (logError) { console.error('[Upload] 素材已完成,但上传审计日志写入失败:', { materialId: material.id, error: logError, }); } // 清理上传会话(如果使用了分片上传) if (ownedMergedUpload) { try { const deleted = await deleteOwnedUploadSession( ownedMergedUpload.uploadId, (session.user as any).id ); if (!deleted) { throw new Error('上传会话所有权校验失败'); } const { cleanupUploadSession } = await import('@/lib/temp-cleanup'); await cleanupUploadSession(ownedMergedUpload.uploadId); console.log('[Upload] 已清理上传会话:', ownedMergedUpload.uploadId); } catch (cleanupError) { console.warn('[Upload] 清理上传会话失败(忽略):', cleanupError); } } // 如果是视频类型素材,上传完成后立即入队触发后台转码(异步,不阻塞返回) if (videoSourceUrl) { const needsCoverImage = !videoCoverUrl; // 不 await,让入队操作在后台异步执行,不阻塞上传接口返回 201 enqueueVideoTranscodeTask(material.id, videoSourceUrl, needsCoverImage) .then(() => { console.log('[Upload] ✅ Video transcode task enqueued successfully:', material.id); }) .catch(async (e: any) => { // 入队失败时保持 pending 可恢复状态。canonical 源视频必须保留, // 60 秒周期扫描会重新装载该任务,错误信息仅用于诊断。 console.error('[Upload] Failed to enqueue video transcode task:', { materialId: material.id, videoSourceUrl, error: e, }); try { // 保持为可恢复的 pending;failed 不会被周期恢复扫描重新入队。 await prisma.material.update({ where: { id: material.id }, data: { videoProcessingStatus: 'pending', transcodeLastError: USER_TRANSCODE_FAILURE_MESSAGE, } as any, }); console.log('[Upload] ⚠️ Kept material recoverable after enqueue error (status: pending)'); } catch (dbError) { console.error('[Upload] ❌ Failed to update material status:', dbError); } }); } return createUploadSuccessResponse(material); } catch (error) { // 跨进程竞争或第一次成功响应丢失时,唯一索引会让其中一个 create // 返回 P2002;也可能在 loser 移动源文件前后出现 ENOENT。只要当前 // 用户的同一 uploadId 已有赢家,就返回赢家且不清理任何候选文件。 let preserveAllAttemptArtifacts = false; if (!materialId && ownedMergedUpload?.uploadId && sessionUserId) { try { const winner = await findOwnedMaterialByUploadId( ownedMergedUpload.uploadId, sessionUserId, ); if (winner) { console.warn('[Upload] 幂等竞争已由既有素材完成:', { uploadId: ownedMergedUpload.uploadId, materialId: winner.id, errorCode: (error as any)?.code, }); const winnerValidationFailure = await validateCanonicalSourceBeforeSuccess({ material: winner, phase: 'idempotent-replay', }); if (winnerValidationFailure) { // 赢家异常时保留本次及赢家的所有候选原件,不执行恢复或删除。 return winnerValidationFailure; } return createUploadSuccessResponse(winner, 201, true); } if ((error as any)?.code === 'P2002') { // 唯一冲突却暂时查不到赢家时,可能是跨节点可见性延迟。 // 不能恢复或删除任何候选文件,留给后续 owner-scoped 查询确认。 preserveAllAttemptArtifacts = true; } } catch (lookupError) { preserveAllAttemptArtifacts = true; console.error('[Upload] 无法确认幂等赢家,保守保留全部候选文件:', lookupError); } } if (!materialId && attemptedMaterialId && sessionUserId && !preserveAllAttemptArtifacts) { try { const committedMaterial = await prisma.material.findUnique({ where: { id: attemptedMaterialId }, include: { user: { select: { username: true }, }, }, }); if (committedMaterial) { if (committedMaterial.userId !== sessionUserId) { // 主键不应跨用户碰撞;若现场数据与本次 owner 不符,绝不能自动清理。 preserveAllAttemptArtifacts = true; } else if ( attemptedMaterialMustBeCompleted && committedMaterial.taskStatus !== 'completed' ) { // create 已提交但后续任务完成步骤尚未发生。把记录交给下方条件回滚, // 不能把 uploading/null 状态误报为完整成功。 materialId = committedMaterial.id; } else { console.warn('[Upload] 数据库提交响应丢失,但已按预生成 ID 确认素材完成:', { materialId: committedMaterial.id, }); const committedValidationFailure = await validateCanonicalSourceBeforeSuccess({ material: committedMaterial, phase: 'idempotent-replay', }); if (committedValidationFailure) { return committedValidationFailure; } return createUploadSuccessResponse(committedMaterial, 201, true); } } } catch (commitInspectionError) { preserveAllAttemptArtifacts = true; console.error('[Upload] 无法确认数据库最终状态,保守保留全部候选文件:', { materialId: attemptedMaterialId, error: commitInspectionError, }); } } let preserveMovedSourceFile = false; if (!materialId && movedMergedUpload && !preserveAllAttemptArtifacts) { const restoreResult = await restoreMovedMergedUpload(movedMergedUpload); if (restoreResult === 'restored') { zipFilePath = ''; movedMergedUpload = null; } else { // 不能证明 finalPath 是否仍是唯一完整源文件时,绝不删除它。 preserveMovedSourceFile = true; } } else if (movedMergedUpload && preserveAllAttemptArtifacts) { preserveMovedSourceFile = true; } if (error instanceof MaterialUploadRequestError) { const headers = error.retryAfterSeconds ? { 'Retry-After': String(error.retryAfterSeconds) } : undefined; return NextResponse.json( { error: error.message, code: error.code }, { status: error.status, headers }, ); } // 输出详细的错误信息以便调试 const errorMessage = error instanceof Error ? error.message : String(error); const errorStack = error instanceof Error ? error.stack : undefined; console.error('[Upload] ❌ Upload error:', { message: errorMessage, stack: errorStack, materialType: (error as any)?.materialType, }); // 清理已上传的文件和已创建的素材记录(防止垃圾文件)。必须先确认数据库 // 记录是否已经 completed;状态查询失败时保守保留文件,不能因数据库瞬断 // 把可能已经提交成功的数据永久删除。 try { let shouldDeleteFiles = !preserveAllAttemptArtifacts; let materialCanBeDeleted = false; if (materialId) { try { const material = await prisma.material.findUnique({ where: { id: materialId }, select: { taskStatus: true }, }); if (material?.taskStatus === 'completed') { shouldDeleteFiles = false; console.warn(`[Upload] 素材已完成,跳过失败清理并保留文件: ${materialId}`); } else if (material) { materialCanBeDeleted = true; // 数据库根记录尚存在时,任何文件都不能先删;必须先用条件删除 // 证明这条未完成记录已成功回滚,再清理本次产生的派生文件。 shouldDeleteFiles = false; } } catch (statusError) { shouldDeleteFiles = false; console.error('[Upload] 无法确认素材完成状态,保守保留全部文件:', { materialId, error: statusError, }); } } if (materialId && materialCanBeDeleted && !preserveAllAttemptArtifacts) { try { const deleteResult = await prisma.material.deleteMany({ where: { id: materialId, OR: [ { taskStatus: null }, { taskStatus: { not: 'completed' } }, ], } as any, }); if (deleteResult.count === 1) { materialId = null; shouldDeleteFiles = true; console.log('[Upload] 已条件回滚未完成素材,开始清理本次派生文件'); } else { shouldDeleteFiles = false; console.warn('[Upload] 未能确认未完成素材已回滚,保守保留全部文件'); } } catch (deleteError) { shouldDeleteFiles = false; console.error('[Upload] 条件回滚未完成素材失败,保守保留全部文件:', deleteError); } } if (shouldDeleteFiles) { const filesToDelete: string[] = [...uploadedFileUrls]; if (zipFilePath && !preserveMovedSourceFile && !uploadConsumptionRecord) { filesToDelete.push(zipFilePath); } if (filesToDelete.length > 0) { const { deleteFiles } = await import('@/lib/file-cleanup'); const cleanupResult = await deleteFiles(filesToDelete); console.log(`[Upload] Cleaned up ${cleanupResult.deletedCount} files, ${cleanupResult.failedCount} failed`); } } } catch (cleanupError) { console.error('[Upload] Cleanup failed:', cleanupError); // 清理失败不影响错误返回 } if (error instanceof CanonicalMaterialSourceValidationError) { return NextResponse.json( { error: '源文件完整性校验失败,请重新上传或联系管理员核验存储', code: error.code, }, { status: 503 }, ); } // 返回更详细的错误信息(开发环境)或通用错误(生产环境) const isDevelopment = process.env.NODE_ENV === 'development'; return NextResponse.json( { error: isDevelopment ? `上传失败: ${errorMessage}` : '上传失败,请稍后再试', ...(isDevelopment && errorStack ? { stack: errorStack.substring(0, 500) } : {}) }, { status: 500 } ); } finally { if (releaseUploadSessionLock) { await releaseUploadSessionLock().catch((releaseError) => { console.error('[Upload] 释放 uploadId 互斥锁失败:', releaseError); }); } releaseUploadSlot?.(); } }