484 lines
20 KiB
TypeScript
484 lines
20 KiB
TypeScript
// 素材上传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';
|
||
|
||
export const runtime = 'nodejs';
|
||
export const dynamic = 'force-dynamic';
|
||
export const maxDuration = 10800; // 3小时
|
||
|
||
// 音频素材占位符预览图(SVG格式)
|
||
const AUDIO_PLACEHOLDER = (() => {
|
||
const svg = `<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#1e293b"/><circle cx="300" cy="180" r="60" fill="#3b82f6" opacity="0.8"/><polygon points="285,155 285,205 325,180" fill="#ffffff"/><text x="50%" y="75%" font-size="20" fill="#94a3b8" text-anchor="middle" font-family="PingFang SC, 'Microsoft YaHei', 'Segoe UI', sans-serif">音频素材</text></svg>`
|
||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||
})()
|
||
|
||
// 生成唯一文件名
|
||
function generateUniqueFilename(prefix: string, ext: string): string {
|
||
const timestamp = Date.now();
|
||
const random = randomBytes(8).toString('hex');
|
||
return `${prefix}-${timestamp}-${random}${ext}`;
|
||
}
|
||
|
||
// POST - 上传素材
|
||
export async function POST(request: NextRequest) {
|
||
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 productModel = 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 materialType = (formData.get('materialType') as string) || '3D场景';
|
||
const rendererParam = formData.get('renderer') as string | null;
|
||
// 统一处理renderer:3D场景必须有renderer,其他类型可选
|
||
const renderer = requiresRenderer(materialType)
|
||
? (rendererParam || getDefaultRenderer(materialType))
|
||
: (rendererParam || '');
|
||
|
||
console.log('[Upload] Form data received:', {
|
||
hasPreviewImage: !!previewImage,
|
||
previewImagesCount: previewImages.length,
|
||
hasDisplayImage: !!displayImage,
|
||
modelImagesCount: modelImages.length,
|
||
hasZipFile: !!zipFile,
|
||
hasExistingFilePath: !!existingFilePath,
|
||
productModel,
|
||
categoryName,
|
||
sceneName,
|
||
materialType,
|
||
});
|
||
|
||
// 使用统一的验证逻辑验证必填字段
|
||
if (!productModel) {
|
||
return NextResponse.json(
|
||
{ error: '产品型号是必填的' },
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) {
|
||
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.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 uploadToS3(compressedBuffer, filename, 'image/webp');
|
||
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 uploadToS3(compressedBuffer, filename, 'image/webp');
|
||
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 (materialType === '详情页' && displayImage) {
|
||
const buffer = Buffer.from(await displayImage.arrayBuffer());
|
||
const filename = generateUniqueFilename('display', path.extname(displayImage.name));
|
||
const url = await uploadToS3(buffer, filename, displayImage.type);
|
||
displayImages.push(url);
|
||
|
||
if (!previewUrl) {
|
||
previewUrl = url;
|
||
}
|
||
console.log('[Upload] Display image uploaded:', url);
|
||
}
|
||
|
||
// 处理模特图片
|
||
if (['模特', '实拍图片', '买家秀图片'].includes(materialType) && modelImages.length > 0) {
|
||
for (let i = 0; i < modelImages.length; i++) {
|
||
const image = modelImages[i];
|
||
const buffer = Buffer.from(await image.arrayBuffer());
|
||
const filename = generateUniqueFilename(`model-${i + 1}`, path.extname(image.name));
|
||
const url = await uploadToS3(buffer, filename, image.type);
|
||
displayImages.push(url);
|
||
|
||
if (i === 0 && !previewUrl) {
|
||
previewUrl = url;
|
||
}
|
||
console.log(`[Upload] Model image ${i + 1} uploaded:`, url);
|
||
}
|
||
}
|
||
|
||
// 处理通用展示图(反编译:买家秀/小红书/主图模版/SKU模版/品牌VI 等)
|
||
if (displayImagesFromForm.length > 0) {
|
||
for (let i = 0; i < displayImagesFromForm.length; i++) {
|
||
const image = displayImagesFromForm[i];
|
||
const buffer = Buffer.from(await image.arrayBuffer());
|
||
const filename = generateUniqueFilename(`display-${i + 1}`, path.extname(image.name));
|
||
const url = await uploadToS3(buffer, filename, image.type);
|
||
displayImages.push(url);
|
||
if (!previewUrl) {
|
||
previewUrl = url;
|
||
}
|
||
console.log(`[Upload] Display image ${i + 1} uploaded:`, url);
|
||
}
|
||
}
|
||
|
||
// 处理素材文件(主文件: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) || '.zip';
|
||
console.log('[Upload] Using existing merged file:', sourcePath, 'size:', stat.size);
|
||
}
|
||
|
||
const isVideoOrAudio = ['主图视频', '安装视频', '种草视频', '音乐'].includes(materialType);
|
||
|
||
// 主文件一律本地化存储到 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 (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;
|
||
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 {
|
||
await fs.unlink(sourcePath);
|
||
console.log('[Upload] 已清理临时合并文件:', sourcePath);
|
||
|
||
// 同时清理整个临时目录(如果为空)
|
||
const tempDir = path.dirname(sourcePath);
|
||
try {
|
||
const files = await fs.readdir(tempDir);
|
||
if (files.length === 0) {
|
||
await fs.rmdir(tempDir);
|
||
console.log('[Upload] 已清理空临时目录:', tempDir);
|
||
}
|
||
} catch (dirError) {
|
||
// 忽略目录清理错误
|
||
}
|
||
} 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')
|
||
}
|
||
|
||
// 创建数据库记录(包含所有字段,确保数据不丢失)
|
||
// 对于视频类型,videoSourceUrl 保存本地相对路径(如:/uploads/xxx.mp4),仅用于转码和回放
|
||
const videoSourceUrl =
|
||
['主图视频', '安装视频', '种草视频'].includes(materialType) && zipFileName
|
||
? `/uploads/${zipFileName}`
|
||
: null;
|
||
|
||
const material = await prisma.material.create({
|
||
data: {
|
||
previewUrl,
|
||
productModel,
|
||
categoryName,
|
||
sceneName,
|
||
platformName,
|
||
productCategoryName,
|
||
materialType,
|
||
renderer,
|
||
authorName: user.username,
|
||
zipFileName,
|
||
zipFilePath,
|
||
zipFileSize,
|
||
displayImages,
|
||
videoSourceUrl, // 视频类型时保存视频URL
|
||
videoProcessingStatus: videoSourceUrl ? 'pending' : null, // 视频类型时初始化为pending状态
|
||
userId: session.user.id,
|
||
} as any,
|
||
include: {
|
||
user: {
|
||
select: { username: true },
|
||
},
|
||
},
|
||
});
|
||
|
||
console.log('[Upload] Database record created:', material.id);
|
||
|
||
// 验证文件是否存在(特别是音乐和视频文件)
|
||
if (zipFilePath && (materialType === '音乐' || ['主图视频', '安装视频', '种草视频'].includes(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 {
|
||
await fs.unlink(zipFilePath);
|
||
console.log('[Upload] 🧹 Removed source video file after enqueue failure:', zipFilePath);
|
||
} 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) {
|
||
console.error('Upload error:', error);
|
||
return NextResponse.json(
|
||
{ error: '上传失败,请稍后再试' },
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
}
|