// 素材上传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 } from 'crypto';
import { parseAudioMetadata, isAudioFile } from '@/lib/audio-processor';
import { enqueueVideoTranscodeTask } 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 { deleteMaterialRelatedRecords } from '@/lib/material-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';
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 = ``
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
})()
// SKU模板占位符预览图(SVG格式)
const SKU_TEMPLATE_PLACEHOLDER = (() => {
const svg = ``
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}`;
}
// 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;
try {
console.log('[Upload] Starting upload process...');
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
);
}
const formData = await request.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 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);
const isTaskOtherMaterial = materialType === '任务-其他';
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 productModel = productModelInput || (isTaskOtherMaterial ? taskNo : '');
const shouldAutoFillGeneralCategoryScene =
AUTO_GENERAL_TYPES.includes(materialType) || materialType === '标准模型';
const resolvedCategoryName = shouldAutoFillGeneralCategoryScene ? '通用' : categoryName;
const resolvedSceneName = shouldAutoFillGeneralCategoryScene ? '通用' : sceneName;
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 ? '任务-其他必须填写任务号' : '产品型号是必填的' },
{ status: 400 }
);
}
if (isTaskOtherMaterial && !taskNo) {
return NextResponse.json(
{ error: '任务-其他必须填写任务号' },
{ status: 400 }
);
}
// 任务号验证(可选,如果提供则验证格式和唯一性)
if (taskNo && taskNo.trim()) {
const { validateTaskNo, isTaskNoExists, purgeDeletedMaterialsByTaskNo } = 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 }
);
}
try {
const cleanupResult = await purgeDeletedMaterialsByTaskNo(taskNo);
if (cleanupResult.deletedMaterials > 0) {
console.log(
`[Upload] 已清理任务号 ${taskNo} 的旧软删素材 ${cleanupResult.deletedMaterials} 条,删除文件 ${cleanupResult.deletedFiles} 个,失败 ${cleanupResult.failedFiles} 个`,
);
}
} catch (cleanupError) {
console.error('[Upload] 清理旧软删素材失败:', cleanupError);
}
}
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);
}
// 处理其他预览图(作为展示图)- 并行处理以提高速度
// 详情页/详情模板:其他预览图也作为展示图处理
if (previewImages.length > 0 && !['模特'].includes(materialType)) {
console.log(`[Upload] Processing ${previewImages.length} preview images in parallel...`);
// 验证所有图片大小
for (let i = 0; i < previewImages.length; i++) {
if (previewImages[i].size > 50 * 1024 * 1024) {
return NextResponse.json(
{ error: `预览图 ${i + 2} 过大,请使用小于50MB的图片` },
{ status: 400 }
);
}
}
// 并行处理所有预览图
const uploadPromises = previewImages.map(async (image, 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);
return url;
});
// 等待所有预览图上传完成
const urls = await Promise.all(uploadPromises);
displayImages.push(...urls);
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 = '';
let 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 = existingFilePath;
const stat = await fs.stat(sourcePath);
zipFileSize = BigInt(stat.size);
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';
zipFileName = generateUniqueFilename(basePrefix, ext || '.bin');
zipFilePath = path.join(uploadDir, zipFileName);
if (sourcePath && !zipFile) {
// 分片上传场景:直接移动合并后的大文件,避免重复读写 50MB+ 文件
await fs.rename(sourcePath, zipFilePath);
// 已经移动到最终位置,不再额外清理
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;
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;
// 最终保存前的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;
}
// 创建素材记录(不写入taskNo,上传完成后再写入)
const material = await prisma.material.create({
data: {
previewUrl,
productModel,
categoryName: resolvedCategoryName,
sceneName: resolvedSceneName,
platformName,
productCategoryName,
materialType,
renderer,
authorName: user.username,
zipFileName,
zipFilePath,
zipFileSize,
displayImages,
videoSourceUrl, // 视频类型时保存视频URL
videoProcessingStatus: videoSourceUrl ? 'pending' : null, // 视频类型时初始化为pending状态
// taskNo: 不写入,上传完成后再写入
// taskStatus: 不写入,上传完成后再写入
userId: (session.user as any).id,
} as any,
include: {
user: {
select: { username: true },
},
},
});
console.log('[Upload] Database record created:', material.id);
materialId = material.id; // 保存materialId,用于失败时清理
// 为任务类型素材创建 material_assets 记录
const taskMaterialTypes = ['任务-其他', '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图'];
if (taskMaterialTypes.includes(materialType)) {
try {
console.log('[Upload] Creating material_assets records for task material...');
const assetRecords: Array<{
materialId: string;
assetType: string;
fileUrl: string;
fileName: string;
fileSize: bigint;
sortOrder: number;
}> = [];
// 添加预览图资产
if (previewUrl) {
assetRecords.push({
materialId: material.id,
assetType: 'preview',
fileUrl: previewUrl,
fileName: 'preview-0.webp',
fileSize: BigInt(0),
sortOrder: 0,
});
}
// 添加ZIP资产
if (zipFilePath) {
assetRecords.push({
materialId: material.id,
assetType: 'zip',
fileUrl: zipFilePath,
fileName: zipFileName,
fileSize: zipFileSize,
sortOrder: 0,
});
}
// 批量创建资产记录
if (assetRecords.length > 0) {
await prisma.materialAsset.createMany({
data: assetRecords,
});
console.log(`[Upload] Created ${assetRecords.length} material_assets records`);
}
} catch (assetError) {
console.error('[Upload] Failed to create material_assets records:', assetError);
// 资产记录创建失败不影响上传流程,只记录错误
}
}
// 如果提供了taskNo,上传完成后写入taskNo(所有素材类型统一处理)
if (taskNo && taskNo.trim()) {
try {
// 双重验证:再次检查taskNo唯一性(防止并发问题)
const { validateTaskNo, isTaskNoExists } = await import('@/lib/product-package');
if (validateTaskNo(taskNo)) {
const exists = await isTaskNoExists(taskNo);
if (!exists) {
// 写入taskNo和taskStatus
await prisma.material.update({
where: { id: material.id },
data: {
taskNo: taskNo,
taskStatus: 'completed',
} as any,
});
console.log('[Upload] TaskNo written to database:', taskNo);
} else {
console.warn('[Upload] TaskNo already exists, skipping write:', taskNo);
}
}
} catch (taskNoError) {
console.error('[Upload] Failed to write taskNo:', taskNoError);
// taskNo写入失败不影响上传流程,只记录错误
}
}
// 记录上传日志
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,
},
},
});
// 验证文件是否存在(特别是音乐和视频文件)
if (zipFilePath && (materialType === '音乐' || isVideoMaterial(materialType))) {
try {
const stats = await fs.stat(zipFilePath);
console.log('[Upload] File verified:', { path: zipFilePath, size: stats.size });
} catch (fileError: any) {
console.error('[Upload] ⚠️ File not found after upload:', {
path: zipFilePath,
error: fileError.message,
materialId: material.id,
});
// 文件不存在,但记录已创建,这会导致播放失败
// 这里只记录错误,不阻止流程,因为可能是其他问题
}
}
// 清理上传会话(如果使用了分片上传)
const uploadId = formData.get('uploadId') as string | null;
if (uploadId) {
try {
const { deleteUploadSession } = await import('@/lib/upload-sessions');
await deleteUploadSession(uploadId);
console.log('[Upload] 已清理上传会话:', uploadId);
} catch (cleanupError) {
console.warn('[Upload] 清理上传会话失败(忽略):', cleanupError);
}
}
// 如果是视频类型素材,上传完成后立即入队触发后台转码(异步,不阻塞返回)
if (videoSourceUrl) {
const needsCoverImage = !previewUrl || previewUrl.trim() === '';
// 不 await,让入队操作在后台异步执行,不阻塞上传接口返回 201
enqueueVideoTranscodeTask(material.id, videoSourceUrl, needsCoverImage)
.then(() => {
console.log('[Upload] ✅ Video transcode task enqueued successfully:', material.id);
})
.catch(async (e: any) => {
// 入队失败时,记录详细错误信息到数据库,并尝试清理可能无效的源文件,避免产生垃圾文件
const errorMsg = e?.message || String(e) || '未知错误';
const errorStack = e?.stack ? `\n堆栈: ${e.stack.substring(0, 1000)}` : '';
const fullErrorMsg = `入队失败: ${errorMsg}${errorStack}`;
console.error('[Upload] ❌ Failed to enqueue video transcode task:', {
materialId: material.id,
videoSourceUrl,
error: errorMsg,
stack: e?.stack,
});
try {
// 标记为失败并记录错误信息,方便后续诊断
await prisma.material.update({
where: { id: material.id },
data: {
videoProcessingStatus: 'failed',
transcodeLastError: fullErrorMsg.substring(0, 1000),
} as any,
});
console.log('[Upload] ⚠️ Updated material status with enqueue error (status: failed)');
} catch (dbError) {
console.error('[Upload] ❌ Failed to update material status:', dbError);
}
// 尝试清理源视频文件,避免长时间占用磁盘空间(垃圾文件)
if (zipFilePath) {
try {
const { deleteLocalFile } = await import('@/lib/file-cleanup');
const result = await deleteLocalFile(zipFilePath);
if (result.success) {
console.log('[Upload] 🧹 Removed source video file after enqueue failure:', zipFilePath);
} else {
console.warn('[Upload] ⚠️ Failed to remove source video file:', zipFilePath, result.error);
}
} catch (removeError) {
console.warn('[Upload] ⚠️ Failed to remove source video file after enqueue failure (ignored):', {
path: zipFilePath,
error: (removeError as any)?.message || String(removeError),
});
}
}
});
}
return NextResponse.json(
{
message: '上传成功',
material: {
...material,
zipFileSize: material.zipFileSize.toString(),
},
},
{ status: 201 }
);
} catch (error) {
// 输出详细的错误信息以便调试
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,
});
// 清理已上传的文件和已创建的素材记录(防止垃圾文件)
try {
const filesToDelete: string[] = [...uploadedFileUrls];
if (zipFilePath) {
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`);
}
// 如果素材记录已创建但没有taskNo,删除记录(避免残留)
if (materialId) {
try {
const material = await prisma.material.findUnique({
where: { id: materialId },
select: { taskNo: true },
});
// 如果没有taskNo,说明上传未完成,删除记录
if (material && !material.taskNo) {
// 先删除关联记录
const materialType = (error as any)?.materialType || '未知';
await deleteMaterialRelatedRecords(materialId, materialType);
// 再删除素材记录
await prisma.material.delete({
where: { id: materialId },
});
console.log(`[Upload] Deleted incomplete material record: ${materialId}`);
}
} catch (deleteError) {
console.error('[Upload] Failed to delete material record:', deleteError);
// 删除失败不影响错误返回
}
}
} catch (cleanupError) {
console.error('[Upload] Cleanup failed:', cleanupError);
// 清理失败不影响错误返回
}
// 返回更详细的错误信息(开发环境)或通用错误(生产环境)
const isDevelopment = process.env.NODE_ENV === 'development';
return NextResponse.json(
{
error: isDevelopment
? `上传失败: ${errorMessage}`
: '上传失败,请稍后再试',
...(isDevelopment && errorStack ? { stack: errorStack.substring(0, 500) } : {})
},
{ status: 500 }
);
}
}