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