chenpei 0ce59be34e 备份:组织架构设计方案讨论前的代码快照
- 包含所有当前修改和新功能
- 准备开始组织架构优化实施方案
2025-12-30 13:20:26 +08:00

484 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 素材上传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 }
);
}
}