// 素材上传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, USER_TRANSCODE_FAILURE_MESSAGE } 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';
import {
getSpuProductModelValidationError,
isPlausibleSpuProductModel,
normalizeSpuProductModel,
} from '@/lib/product-model';
import {
isLiveClipType,
parseLiveDate,
validateLiveClipFields,
} from '@/lib/live-clip';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 10800; // 3小时
// 音频素材占位符预览图(SVG格式)
const AUDIO_PLACEHOLDER = (() => {
const svg = ``
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
})()
// PDF素材占位符预览图(SVG格式)
const PDF_PLACEHOLDER = (() => {
const svg = ``
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
})()
// SKU模板占位符预览图(SVG格式)
const SKU_TEMPLATE_PLACEHOLDER = (() => {
const svg = ``
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
})()
const VIDEO_TYPES = new Set(LEGACY_COMPATIBLE_VIDEO_MATERIAL_TYPES)
const AUDIO_TYPES = new Set(['音乐'])
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.m4v', '.mkv', '.avi', '.3gp'])
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.flac', '.m4a', '.aac'])
function normalizeExtension(ext: string): string {
if (!ext) return ''
return ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}`
}
function detectExtFromMime(mime?: string | null): string | null {
if (!mime) return null
const map: Record = {
'video/mp4': '.mp4',
'video/quicktime': '.mov',
'video/x-matroska': '.mkv',
'video/x-msvideo': '.avi',
'video/3gpp': '.3gp',
'audio/mpeg': '.mp3',
'audio/wav': '.wav',
'audio/flac': '.flac',
'audio/mp4': '.m4a',
'audio/aac': '.aac',
}
return map[mime] || null
}
async function detectExtFromFile(filePath?: string): Promise {
if (!filePath) return null
try {
const absolute = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath)
const handle = await fs.open(absolute, 'r')
try {
const buffer = Buffer.alloc(16)
await handle.read(buffer, 0, 16, 0)
const signature = buffer.toString('hex')
if (buffer.slice(4, 8).toString('ascii').startsWith('ftyp')) {
return '.mp4'
}
if (buffer.slice(0, 4).toString('ascii') === 'RIFF') {
return '.avi'
}
if (signature.startsWith('1a45dfa3')) {
return '.mkv'
}
if (buffer.slice(0, 3).toString('ascii') === 'ID3') {
return '.mp3'
}
if (buffer.slice(0, 4).toString('ascii') === 'fLaC') {
return '.flac'
}
return null
} finally {
await handle.close()
}
} catch (error) {
console.warn('[Upload] Failed to detect extension from file:', filePath, error)
return null
}
}
async function ensureMediaExtension(params: {
extCandidate: string
mimeType?: string | null
filePath?: string
fileBuffer?: Buffer | null
materialType: string
}): Promise {
const { extCandidate, mimeType, filePath, fileBuffer, materialType } = params
const targetSet = VIDEO_TYPES.has(materialType) ? VIDEO_EXTENSIONS : AUDIO_TYPES.has(materialType) ? AUDIO_EXTENSIONS : null
if (!targetSet) {
return extCandidate || '.zip'
}
if (fileBuffer && fileBuffer.length >= 16) {
const signature = fileBuffer.toString('hex', 0, 16)
const ftyp = fileBuffer.slice(4, 8).toString('ascii')
const riff = fileBuffer.slice(0, 4).toString('ascii')
const id3 = fileBuffer.slice(0, 3).toString('ascii')
const flac = fileBuffer.slice(0, 4).toString('ascii')
if (ftyp.startsWith('ftyp') && targetSet.has('.mp4')) return '.mp4'
if (riff === 'RIFF' && targetSet.has('.avi')) return '.avi'
if (signature.startsWith('1a45dfa3') && targetSet.has('.mkv')) return '.mkv'
if (id3 === 'ID3' && targetSet.has('.mp3')) return '.mp3'
if (flac === 'fLaC' && targetSet.has('.flac')) return '.flac'
}
const normalized = normalizeExtension(extCandidate)
if (normalized && targetSet.has(normalized)) {
return normalized
}
const mimeExt = detectExtFromMime(mimeType)
if (mimeExt && targetSet.has(mimeExt)) {
return mimeExt
}
const detected = await detectExtFromFile(filePath)
if (detected && targetSet.has(detected)) {
return detected
}
return VIDEO_TYPES.has(materialType) ? '.mp4' : '.mp3'
}
function isVideoMaterial(materialType: string) {
return VIDEO_TYPES.has(materialType)
}
function isAudioMaterial(materialType: string) {
return AUDIO_TYPES.has(materialType)
}
function normalizeMaterialType(materialTypeRaw?: string | null): string {
const trimmed = (materialTypeRaw || '').trim()
if (!trimmed) {
return '3D场景'
}
if (trimmed.toLowerCase() === 'video') {
return 'VIDEO'
}
return normalizeVideoMaterialType(trimmed)
}
// 生成唯一文件名
function generateUniqueFilename(prefix: string, ext: string): string {
const timestamp = Date.now();
const random = randomBytes(8).toString('hex');
return `${prefix}-${timestamp}-${random}${ext}`;
}
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 = normalizeDingtalkApprovalNo(rawDingtalkApprovalNo);
const rawProductModel = productModelInput || (isTaskOtherMaterial ? taskNo : '');
const isLiveClip = isLiveClipType(materialType);
// 直播切片的 productModel 存「标题」(自由文本),不做 SPU 规范化/校验
const productModel = VIDEO_TYPES.has(materialType) && !isLiveClip
? normalizeSpuProductModel(rawProductModel)
: rawProductModel.trim();
const shouldAutoFillGeneralCategoryScene =
AUTO_GENERAL_TYPES.includes(materialType) || materialType === '标准模型';
const resolvedCategoryName = shouldAutoFillGeneralCategoryScene ? '通用' : categoryName;
const resolvedSceneName = shouldAutoFillGeneralCategoryScene ? '通用' : sceneName;
const liveDateInput = (formData.get('liveDate') as string) || '';
const liveHostInput = (formData.get('liveHost') as string) || '';
const liveHostUserIdInput = (formData.get('liveHostUserId') as string) || '';
const livePlatformInput = (formData.get('livePlatform') as string) || '';
if (isLiveClip) {
const liveClipErrors = validateLiveClipFields({
title: productModel,
liveDate: liveDateInput,
livePlatform: livePlatformInput,
});
if (liveClipErrors.length > 0) {
return NextResponse.json(
{ error: liveClipErrors.map((e) => e.message).join(';') },
{ status: 400 },
);
}
}
if (VIDEO_TYPES.has(materialType) && !isLiveClip && !isPlausibleSpuProductModel(productModel)) {
return NextResponse.json(
{
error: `视频素材 SPU 不合规:${getSpuProductModelValidationError(productModel) || '请检查后重试'}`,
},
{ status: 400 },
);
}
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
? '任务-其他必须填写任务号'
: isLiveClip
? '请填写标题'
: '产品型号是必填的',
},
{ status: 400 }
);
}
if (isTaskOtherMaterial && !taskNo) {
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状态
...(isLiveClip
? {
liveDate: parseLiveDate(liveDateInput) || parseLiveDate(new Date().toISOString().slice(0, 10)),
liveHost: liveHostInput.trim() || null,
liveHostUserId: liveHostUserIdInput.trim() || null,
livePlatform: livePlatformInput.trim() || null,
}
: {}),
dingtalkApprovalNo,
// 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 秒周期扫描会重新装载该任务,错误信息仅用于诊断。
console.error('[Upload] Failed to enqueue video transcode task:', {
materialId: material.id,
videoSourceUrl,
error: e,
});
try {
// 保持为可恢复的 pending;failed 不会被周期恢复扫描重新入队。
await prisma.material.update({
where: { id: material.id },
data: {
videoProcessingStatus: 'pending',
transcodeLastError: USER_TRANSCODE_FAILURE_MESSAGE,
} 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?.();
}
}