chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

1597 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// 素材上传API - 支持多种素材类型
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { prisma } from '@/lib/prisma';
import { uploadToS3 } from '@/lib/s3';
import { promises as fs } from 'fs';
import path from 'path';
import { randomBytes, 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?.();
}
}