957 lines
38 KiB
TypeScript
957 lines
38 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';
|
||
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';
|
||
|
||
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')}`
|
||
})()
|
||
|
||
// PDF素材占位符预览图(SVG格式)
|
||
const PDF_PLACEHOLDER = (() => {
|
||
const svg = `<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#1e293b"/><rect x="200" y="100" width="200" height="200" fill="none" stroke="#3b82f6" stroke-width="4" rx="10"/><text x="300" y="190" font-size="48" fill="#3b82f6" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold">PDF</text><text x="50%" y="75%" font-size="20" fill="#94a3b8" text-anchor="middle" font-family="PingFang SC, 'Microsoft YaHei', 'Segoe UI', sans-serif">品牌VI灵感</text></svg>`
|
||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||
})()
|
||
|
||
// SKU模板占位符预览图(SVG格式)
|
||
const SKU_TEMPLATE_PLACEHOLDER = (() => {
|
||
const svg = `<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="#1e293b"/><rect x="150" y="80" width="300" height="240" fill="none" stroke="#8b5cf6" stroke-width="4" rx="8"/><rect x="180" y="110" width="240" height="60" fill="#8b5cf6" opacity="0.3" rx="4"/><rect x="180" y="190" width="240" height="40" fill="#8b5cf6" opacity="0.2" rx="4"/><rect x="180" y="245" width="160" height="40" fill="#8b5cf6" opacity="0.2" rx="4"/><text x="300" y="355" font-size="20" fill="#a78bfa" text-anchor="middle" font-family="PingFang SC, 'Microsoft YaHei', 'Segoe UI', sans-serif">SKU模版</text></svg>`
|
||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
|
||
})()
|
||
|
||
const VIDEO_TYPES = new Set(['视频', 'VIDEO', '主图视频', '安装视频', '种草视频'])
|
||
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<string, string> = {
|
||
'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<string | null> {
|
||
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<string> {
|
||
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 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 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 materialTypeRaw = formData.get('materialType') as string | null;
|
||
const materialType = normalizeMaterialType(materialTypeRaw);
|
||
const rendererParam = formData.get('renderer') as string | null;
|
||
// 统一处理renderer:3D场景必须有renderer,其他类型可选
|
||
const renderer = requiresRenderer(materialType)
|
||
? (rendererParam || getDefaultRenderer(materialType))
|
||
: (rendererParam || '');
|
||
const taskNo = (formData.get('taskNo') as string) || '';
|
||
|
||
console.log('[Upload] Form data received:', {
|
||
hasPreviewImage: !!previewImage,
|
||
previewImagesCount: previewImages.length,
|
||
hasDisplayImage: !!displayImage,
|
||
modelImagesCount: modelImages.length,
|
||
hasZipFile: !!zipFile,
|
||
hasExistingFilePath: !!existingFilePath,
|
||
productModel,
|
||
categoryName,
|
||
sceneName,
|
||
materialType,
|
||
materialTypeRaw,
|
||
});
|
||
|
||
// 使用统一的验证逻辑验证必填字段
|
||
if (!productModel) {
|
||
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 }
|
||
);
|
||
}
|
||
|
||
// 根据素材类型验证主文件(与前端保持一致:除「模特」「标准模型」「买家秀图片」「小红书种草」外都需要主文件)
|
||
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 uploadToS3(compressedBuffer, 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 uploadToS3(compressedBuffer, 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 uploadToS3(sliceBuffer, 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 uploadToS3(buffer, 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 uploadToS3(buffer, 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('未找到可写入的素材文件数据');
|
||
}
|
||
|
||
// 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,
|
||
sceneName,
|
||
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: categoryName,
|
||
},
|
||
sceneName: {
|
||
oldValue: null,
|
||
newValue: sceneName,
|
||
},
|
||
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 }
|
||
);
|
||
}
|
||
}
|