gongxiangp2/lib/task-material-processor.ts

1141 lines
37 KiB
TypeScript
Raw Permalink 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.

/**
* 任务类型素材的通用处理逻辑
* 用于:任务-3D原图、任务-AI原图、任务-实拍原图、任务-视频截图、任务-详情
*/
import { prisma } from '@/lib/prisma';
import { deleteFromS3 } from '@/lib/s3';
import {
buildMaterialPublicDisplayPath,
uploadPublicDisplayToS3,
} from '@/lib/public-display';
import { compressImage } from '@/lib/image-processor';
import { buildStoredUploadPath, getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths';
import {
deleteStoredUploadFileFromRemote,
syncStoredUploadFileToRemote,
} from '@/lib/remote-upload-sync';
import { randomUUID } from 'crypto';
import { lstat, mkdir, open, rename, unlink, rm } from 'fs/promises';
import { createWriteStream } from 'fs';
import path from 'path';
import archiver from 'archiver';
import type { Prisma } from '@prisma/client';
import { Transform } from 'stream';
import {
acquireMaterialUploadArchiveLock,
acquireMaterialUploadStorageLock,
type MaterialUploadStorageLockRelease,
} from '@/lib/material-upload-cross-process-lock';
import { acquireUploadMergeDiskReservation } from '@/lib/material-upload-merge';
import { materialUploadMergeRequestLimiter } from '@/lib/material-upload-request-guard';
import { readBoundedRegularFile } from '@/lib/bounded-file-reader';
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy';
import { finalizeTaskMaterialOnComplete } from '@/lib/product-package';
// 资产类型常量
export const ASSET_TYPE = {
PREVIEW: 'preview',
MAIN_PREVIEW: 'main-preview',
SKU_PREVIEW: 'sku-preview',
DETAIL_PREVIEW: 'detail-preview',
ZIP: 'zip',
MAIN: 'main',
SKU: 'sku',
DETAIL: 'detail',
ALL: 'all',
PSD: 'psd',
} as const;
export interface ProductPackagePreviewAssetInput {
fileUrl: string;
fileName: string;
sortOrder?: number;
skuCode?: string;
barcodes?: string[];
}
export interface SavedProductPackageSkuPreviewAsset {
id: string;
fileUrl: string;
fileName: string | null;
sortOrder: number;
skuCode: string | null;
createdAt: Date;
updatedAt: Date;
barcodes: string[];
}
export interface ProductPackageAssetGroups {
main: ProductPackagePreviewAssetInput[];
sku: ProductPackagePreviewAssetInput[];
detail: ProductPackagePreviewAssetInput[];
}
export interface ProductPackageZipInfos {
main?: { filePath: string; fileName: string; fileSize: number };
sku?: { filePath: string; fileName: string; fileSize: number };
detail?: { filePath: string; fileName: string; fileSize: number };
all: { filePath: string; fileName: string; fileSize: number };
}
export interface ProductPackagePsdInfo {
filePath: string;
fileName: string;
fileSize: number;
}
function normalizeMaterialAssetFileUrl(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (value && typeof value === 'object') {
const nestedFileUrl = (value as { fileUrl?: unknown }).fileUrl;
if (typeof nestedFileUrl === 'string') {
return nestedFileUrl;
}
}
return String(value || '');
}
export function resolveUniqueZipEntryName(
originalName: string | null | undefined,
index: number,
usedNames: Set<string>,
): string {
const fallbackName = `file-${String(index + 1).padStart(3, '0')}`;
const safeName = String(originalName || fallbackName)
.replace(/[\0\r\n]/g, ' ')
.replace(/[\/\\]+/g, '_')
.trim() || fallbackName;
const addIfUnique = (candidate: string) => {
const key = candidate.toLowerCase();
if (usedNames.has(key)) {
return false;
}
usedNames.add(key);
return true;
};
if (addIfUnique(safeName)) {
return safeName;
}
const ext = path.extname(safeName);
const baseName = (ext ? safeName.slice(0, -ext.length) : safeName) || fallbackName;
for (let duplicateIndex = 2; duplicateIndex < 10000; duplicateIndex += 1) {
const candidate = `${baseName}-${duplicateIndex}${ext}`;
if (addIfUnique(candidate)) {
return candidate;
}
}
const lastResortName = `${baseName}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}${ext}`;
usedNames.add(lastResortName.toLowerCase());
return lastResortName;
}
/** Use the exact archive names for both the ZIP and its preview index. */
export function resolveProductPackageZipEntryNames(names: string[]): string[] {
const usedBases = new Set<string>();
return names.map((name, index) => {
// Preview metadata and archive entries must use the same portable basename.
const portableName = String(name || `file-${String(index + 1).padStart(3, '0')}`)
.replace(/[\/\\:*?"<>|\0\r\n]/g, '_').trim();
const extension = path.extname(portableName);
const base = (extension ? portableName.slice(0, -extension.length) : portableName) || `file-${index + 1}`;
let uniqueBase = base;
for (let suffix = 2; usedBases.has(uniqueBase.toLowerCase()); suffix += 1) {
uniqueBase = `${base}-${suffix}`;
}
usedBases.add(uniqueBase.toLowerCase());
return `${uniqueBase}${extension}`;
});
}
export function buildTaskDetailPreviewNameForZipEntry(
entryName: string,
preview: { kind?: string; sliceCount?: number; sliceIndex?: number },
fallbackSliceIndex = 0,
): string {
const baseName = path.parse(entryName).name;
return preview.kind === 'slice' && (preview.sliceCount || 0) > 1
? `${baseName}-${String((preview.sliceIndex ?? fallbackSliceIndex) + 1).padStart(3, '0')}.webp`
: `${baseName}-preview.webp`;
}
interface SafeZipEntry {
path: string;
name: string;
size: number;
}
const ZIP_MINIMUM_OVERHEAD_BYTES = 16 * 1024 * 1024;
const ZIP_OUTPUT_OVERHEAD_RATIO = 0.05;
export const TASK_MATERIAL_PREVIEW_SOURCE_MAX_BYTES = 100 * 1024 * 1024;
export const TASK_MATERIAL_ZIP_TIMEOUT_MS = 30 * 60 * 1000;
export const TASK_MATERIAL_ZIP_COMPRESSION_LEVEL = 0;
export class TaskMaterialSourceValidationError extends Error {
constructor(message: string, readonly status: number = 400) {
super(message);
this.name = 'TaskMaterialSourceValidationError';
}
}
export interface TaskMaterialSourceAssetInput {
uploadId?: unknown;
fileName?: unknown;
fileSize?: unknown;
}
/**
* 任务图片完成接口不能信任客户端声明的大小。这里以合并后的普通文件为准,
* 统一限制数量、单文件和总字节数,并拒绝重复 uploadId 与符号链接。
*/
export async function resolveOwnedTaskMaterialSourceFiles(input: {
assets: TaskMaterialSourceAssetInput[];
userId: string;
}): Promise<{
files: Array<{ path: string; name: string; size: number }>;
uploadIds: string[];
tempFiles: string[];
}> {
const assets = Array.isArray(input.assets) ? input.assets : [];
if (assets.length === 0) {
throw new TaskMaterialSourceValidationError('至少需要上传一张图片');
}
if (assets.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) {
throw new TaskMaterialSourceValidationError(
`图片最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批提交`,
);
}
const uploadIds = assets.map((asset) => String(asset?.uploadId || '').trim());
if (uploadIds.some((uploadId) => !uploadId)) {
throw new TaskMaterialSourceValidationError('上传会话编号无效');
}
if (new Set(uploadIds).size !== uploadIds.length) {
throw new TaskMaterialSourceValidationError('上传会话编号重复,请重新选择文件');
}
const { getOwnedUploadedFile } = await import('@/lib/upload-sessions');
const files: Array<{ path: string; name: string; size: number }> = [];
const tempFiles: string[] = [];
let totalSourceBytes = 0;
for (let index = 0; index < assets.length; index += 1) {
const asset = assets[index];
const uploadId = uploadIds[index];
const fileName = String(asset?.fileName || `image-${index + 1}`).trim() || `image-${index + 1}`;
const filePath = await getOwnedUploadedFile(uploadId, input.userId);
if (!filePath) {
throw new TaskMaterialSourceValidationError(`找不到上传文件: ${fileName}`);
}
const fileStats = await lstat(filePath);
if (fileStats.isSymbolicLink() || !fileStats.isFile() || fileStats.size <= 0) {
throw new TaskMaterialSourceValidationError(`上传文件无效: ${fileName}`);
}
if (fileStats.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) {
throw new TaskMaterialSourceValidationError(
`图片「${fileName}」超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制`,
413,
);
}
totalSourceBytes += fileStats.size;
if (!Number.isSafeInteger(totalSourceBytes) || totalSourceBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) {
throw new TaskMaterialSourceValidationError('本次图片总大小超过 1GB,请分批提交', 413);
}
files.push({ path: filePath, name: fileName, size: fileStats.size });
tempFiles.push(filePath);
}
return { files, uploadIds, tempFiles };
}
async function inspectSafeZipEntries(
entries: Array<{ path: string; name: string }>,
): Promise<{ entries: SafeZipEntry[]; maximumOutputBytes: number }> {
const inspected: SafeZipEntry[] = [];
let totalBytes = 0;
for (const entry of entries) {
const stats = await lstat(entry.path);
if (stats.isSymbolicLink() || !stats.isFile() || stats.size < 0) {
throw new Error(`文件不是可打包的普通文件: ${entry.path}`);
}
totalBytes += stats.size;
if (!Number.isSafeInteger(totalBytes)) {
throw new Error('待打包文件总大小超出安全范围');
}
inspected.push({ ...entry, size: stats.size });
}
const overheadBytes = Math.max(
ZIP_MINIMUM_OVERHEAD_BYTES,
Math.ceil(totalBytes * ZIP_OUTPUT_OVERHEAD_RATIO),
);
const maximumOutputBytes = totalBytes + overheadBytes;
if (!Number.isSafeInteger(maximumOutputBytes) || maximumOutputBytes <= 0) {
throw new Error('ZIP 输出大小超出安全范围');
}
return { entries: inspected, maximumOutputBytes };
}
async function createZipAtomically(input: {
entries: SafeZipEntry[];
outputPath: string;
maximumOutputBytes: number;
timeoutMs: number;
compressionLevel: number;
}): Promise<number> {
const partialPath = `${input.outputPath}.partial-${randomUUID()}`;
let releaseMergeSlot: (() => void) | null = null;
let releaseArchiveLock: MaterialUploadStorageLockRelease | null = null;
let releaseCrossProcessLock: MaterialUploadStorageLockRelease | null = null;
let releaseStorageReservation: (() => void) | null = null;
try {
releaseMergeSlot = await materialUploadMergeRequestLimiter.acquire();
// 大 ZIP 之间跨容器串行,但只持全局 shared 存储锁:
// 分片可以继续短临界区发布,而 complete/清理的 exclusive
// 操作仍会等 ZIP 完整落盘。锁顺序固定为 archive -> storage。
releaseArchiveLock = await acquireMaterialUploadArchiveLock();
releaseCrossProcessLock = await acquireMaterialUploadStorageLock('shared');
releaseStorageReservation = await acquireUploadMergeDiskReservation(
path.dirname(input.outputPath),
input.maximumOutputBytes,
);
try {
await lstat(input.outputPath);
throw new Error(`ZIP 目标文件已存在,拒绝覆盖: ${input.outputPath}`);
} catch (error: any) {
if (error?.code !== 'ENOENT') {
throw error;
}
}
await new Promise<void>((resolve, reject) => {
const output = createWriteStream(partialPath, { flags: 'wx' });
const archive = archiver('zip', {
zlib: { level: Math.max(0, Math.min(9, Math.round(input.compressionLevel))) },
});
let emittedBytes = 0;
let settled = false;
const limiter = new Transform({
transform(chunk, _encoding, callback) {
emittedBytes += Buffer.byteLength(chunk);
if (emittedBytes > input.maximumOutputBytes) {
callback(new Error('ZIP 输出超过预留空间上限'));
return;
}
callback(null, chunk);
},
});
const finish = (error?: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
if (error) {
archive.abort();
output.destroy();
limiter.destroy();
reject(error);
} else {
resolve();
}
};
const timeout = setTimeout(() => {
finish(new Error(`打包超时(${Math.ceil(input.timeoutMs / 1000)}秒)`));
}, input.timeoutMs);
timeout.unref?.();
output.once('close', () => finish());
output.once('error', (error) => finish(new Error(`写入文件失败: ${error.message}`)));
limiter.once('error', (error) => finish(error));
archive.once('error', (error) => finish(new Error(`打包失败: ${error.message}`)));
archive.on('warning', (error: any) => {
// 任务类素材要求所有条目完整。即使只是 ENOENT warning,也不能生成
// 缺文件的 ZIP 后继续写数据库或同步远端。
finish(error instanceof Error ? error : new Error(String(error)));
});
archive.pipe(limiter).pipe(output);
const usedNames = new Set<string>();
input.entries.forEach((entry, index) => {
const zipEntryName = resolveUniqueZipEntryName(entry.name, index, usedNames);
archive.file(entry.path, { name: zipEntryName });
});
void archive.finalize().catch((error: Error) => finish(error));
});
const syncHandle = await open(partialPath, 'r+');
try {
await syncHandle.sync();
} finally {
await syncHandle.close();
}
const partialStats = await lstat(partialPath);
if (
partialStats.isSymbolicLink()
|| !partialStats.isFile()
|| partialStats.size <= 0
|| partialStats.size > input.maximumOutputBytes
) {
throw new Error('ZIP 输出文件校验失败');
}
releaseCrossProcessLock.assertHeld();
await rename(partialPath, input.outputPath);
return partialStats.size;
} catch (error) {
await rm(partialPath, { force: true }).catch(() => undefined);
throw error;
} finally {
releaseStorageReservation?.();
await releaseCrossProcessLock?.();
await releaseArchiveLock?.();
releaseMergeSlot?.();
}
}
/**
* 从chunk临时目录打包文件成zip
*/
export async function createZipFromFiles(
files: Array<{ path: string; name: string; size: number }>,
materialId: string,
productModel: string,
materialType: string,
zipType: string = 'all', // 'main', 'sku', 'detail', 'all'
authorName?: string, // 作者名(上传人姓名)
categoryName?: string, // 店铺名
timeoutMs: number = 30000,
compressionLevel: number = 9,
): Promise<{ filePath: string; fileName: string; fileSize: number }> {
if (!files || files.length === 0) {
throw new Error('没有文件需要打包');
}
// 创建目标目录
const uploadsDir = path.join(getUploadRootDir(), 'materials');
await mkdir(uploadsDir, { recursive: true });
// 生成zip文件名和路径(保留中文,只清理文件系统不安全字符)
const timestamp = Date.now();
// 只清理文件系统不安全的字符:/ \ : * ? " < > |
const sanitizedProductModel = productModel.replace(/[\/\\:*?"<>|]/g, '_');
// 如果提供了作者名和店铺名,使用新的命名格式:SPU型号_作者名_店铺名_类型.zip
let zipFileName: string;
if (authorName && categoryName && zipType !== 'all') {
const sanitizedAuthorName = authorName.replace(/[\/\\:*?"<>|]/g, '_');
const sanitizedCategoryName = categoryName.replace(/[\/\\:*?"<>|]/g, '_');
zipFileName = `${sanitizedProductModel}_${sanitizedAuthorName}_${sanitizedCategoryName}_${zipType}_${materialId}_${timestamp}.zip`;
} else {
// 保持原有命名格式(用于all.zip或其他情况)
zipFileName = `${sanitizedProductModel}_${zipType}_${materialId}_${timestamp}.zip`;
}
const zipFilePath = path.join(uploadsDir, zipFileName);
const relativeZipPath = buildStoredUploadPath('materials', zipFileName);
console.log(`[Zip] Creating ${zipType} zip file for ${materialType}:`, zipFilePath);
console.log(`[Zip] Files count:`, files.length);
const inspected = await inspectSafeZipEntries(
files.map((file) => ({ path: file.path, name: file.name })),
);
const fileSize = await createZipAtomically({
entries: inspected.entries,
outputPath: zipFilePath,
maximumOutputBytes: inspected.maximumOutputBytes,
timeoutMs,
compressionLevel,
});
try {
await syncStoredUploadFileToRemote(zipFilePath, relativeZipPath, fileSize);
} catch (error) {
await deleteStoredUploadFileFromRemote(relativeZipPath).catch(() => undefined);
await rm(zipFilePath, { force: true }).catch(() => undefined);
throw error;
}
console.log(`[Zip] ${zipType} zip file created successfully: ${fileSize} bytes`);
return {
filePath: relativeZipPath,
fileName: zipFileName,
fileSize,
};
}
/**
* 打包已有的zip文件成一个总zip(用于任务-详情的all.zip)
* 注意:不再打包PSD文件,PSD文件单独存储和下载
*/
export async function createZipFromZips(
zipFiles: Array<{ path: string; name: string }>,
materialId: string,
productModel: string,
materialType: string,
timeoutMs: number = 60000,
compressionLevel: number = 9,
): Promise<{ filePath: string; fileName: string; fileSize: number }> {
if (zipFiles.length === 0) {
throw new Error('没有文件需要打包');
}
// 创建目标目录
const uploadsDir = path.join(getUploadRootDir(), 'materials');
await mkdir(uploadsDir, { recursive: true });
// 生成zip文件名和路径
const timestamp = Date.now();
const sanitizedProductModel = productModel.replace(/[\/\\:*?"<>|]/g, '_');
const zipFileName = `${sanitizedProductModel}_all_${materialId}_${timestamp}.zip`;
const zipFilePath = path.join(uploadsDir, zipFileName);
const relativeZipPath = buildStoredUploadPath('materials', zipFileName);
console.log(`[Zip] Creating all.zip for ${materialType}:`, zipFilePath);
console.log(`[Zip] Zip files count:`, zipFiles.length);
const inspected = await inspectSafeZipEntries(
zipFiles.map((file) => ({
path: path.isAbsolute(file.path) ? file.path : resolveStoredUploadPath(file.path),
name: file.name,
})),
);
const fileSize = await createZipAtomically({
entries: inspected.entries,
outputPath: zipFilePath,
maximumOutputBytes: inspected.maximumOutputBytes,
timeoutMs,
compressionLevel,
});
try {
await syncStoredUploadFileToRemote(zipFilePath, relativeZipPath, fileSize);
} catch (error) {
await deleteStoredUploadFileFromRemote(relativeZipPath).catch(() => undefined);
await rm(zipFilePath, { force: true }).catch(() => undefined);
throw error;
}
console.log(`[Zip] all.zip created successfully: ${fileSize} bytes`);
return {
filePath: relativeZipPath,
fileName: zipFileName,
fileSize,
};
}
/**
* 为图片生成预览图并上传到S3
* 注意:必须为所有图片生成预览图,如果任何一张失败则整个流程失败
*/
export async function generatePreviewImages(
uploadIds: Array<{ uploadId: string; fileName: string; filePath?: string }>,
materialType: string,
compressOptions?: {
maxWidth?: number;
maxHeight?: number;
maxSizeKB?: number;
},
onPreviewUploaded?: (previewUrl: string) => void,
): Promise<string[]> {
if (!uploadIds || uploadIds.length === 0) {
return [];
}
const { getUploadedFile } = await import('@/lib/upload-sessions');
const uploadedPreviewUrls: string[] = [];
const errors: string[] = [];
// 默认压缩参数(任务-3D原图使用)
const defaultOptions = {
maxWidth: 1440,
maxHeight: 2400,
maxSizeKB: 500,
};
// 合并压缩参数
const options = { ...defaultOptions, ...compressOptions };
console.log(`[Preview] Generating ${uploadIds.length} preview images for ${materialType}...`);
console.log(`[Preview] Compress options:`, options);
for (let i = 0; i < uploadIds.length; i++) {
const item = uploadIds[i];
try {
// 完成状态机优先传入已做 owner + lstat 校验的固定路径,避免长处理期间
// 再次从无 owner 参数的会话解析同一个 uploadId。旧内部调用仍可按 uploadId 解析。
const filePath = item.filePath || await getUploadedFile(item.uploadId);
if (!filePath) {
const error = `文件不存在: ${item.fileName}`;
console.error(`[Preview] ${error}`);
errors.push(error);
continue;
}
const buffer = await readBoundedRegularFile(filePath, {
maxBytes: TASK_MATERIAL_PREVIEW_SOURCE_MAX_BYTES,
label: `预览源图片 ${item.fileName}`,
});
// 验证是否为图片
if (!buffer || buffer.length === 0) {
const error = `文件为空: ${item.fileName}`;
console.error(`[Preview] ${error}`);
errors.push(error);
continue;
}
// 压缩并上传预览图
const compressedBuffer = await compressImage(buffer, {
maxWidth: options.maxWidth,
maxHeight: options.maxHeight,
maxSizeKB: options.maxSizeKB,
format: 'webp',
});
const timestamp = Date.now();
const randomSuffix = Math.random().toString(36).substring(2, 9);
const fileName = `preview-${timestamp}-${i}-${randomSuffix}.webp`;
const previewUrl = await uploadPublicDisplayToS3(
compressedBuffer,
buildMaterialPublicDisplayPath(fileName),
'image/webp',
);
uploadedPreviewUrls.push(previewUrl);
onPreviewUploaded?.(previewUrl);
console.log(`[Preview] Generated preview ${i + 1}/${uploadIds.length}:`, previewUrl);
} catch (err) {
const error = `生成预览图失败 ${item.fileName}: ${err instanceof Error ? err.message : String(err)}`;
console.error(`[Preview] ${error}`);
errors.push(error);
}
}
// 检查是否所有预览图都生成成功
if (errors.length > 0) {
console.error(`[Preview] Failed to generate ${errors.length} preview images:`);
errors.forEach(err => console.error(` - ${err}`));
throw new Error(`预览图生成失败:${errors.length}/${uploadIds.length} 张失败。详情:${errors.join('; ')}`);
}
// 验证生成的预览图数量是否与原图数量一致
if (uploadedPreviewUrls.length !== uploadIds.length) {
throw new Error(`预览图数量不匹配:期望 ${uploadIds.length} 张,实际生成 ${uploadedPreviewUrls.length} 张`);
}
console.log(`[Preview] Successfully generated all ${uploadedPreviewUrls.length} preview images`);
return uploadedPreviewUrls;
}
/**
* 清理临时文件和目录
*/
export async function cleanupTempFiles(
tempFiles: string[],
uploadIds: string[]
): Promise<void> {
if (uploadIds.length === 0) {
for (const filePath of tempFiles) {
try {
await unlink(filePath);
} catch (err) {
console.warn(`[Cleanup] Failed to delete standalone temp file: ${filePath}`, err);
}
}
return;
}
// 分片/assembled 文件只能在 completed seal 下通过跨容器独占锁清理。
// helper 会同时删除 temp、assembled、内存和 Redis 会话,并保留 tombstone
// 挡住迟到分片;这里绝不能先 unlink 或 rm。
const { cleanupConsumedUploadSession } = await import('@/lib/temp-cleanup');
const failures: string[] = [];
for (const uploadId of uploadIds) {
try {
const result = await cleanupConsumedUploadSession(uploadId);
if (!result.success) {
failures.push(`${uploadId}: ${result.code || result.error || '状态不允许清理'}`);
}
} catch (err) {
failures.push(`${uploadId}: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (failures.length > 0) {
throw new Error(`上传会话受控清理未完成:${failures.join('; ')}`);
}
}
/**
* 清理失败时的资源(S3文件、本地zip、临时文件、数据库记录)
*/
export async function cleanupOnError(
uploadedPreviewUrls: string[],
zipFilePaths: string | string[] | null,
tempFiles: string[],
materialId: string | null,
uploadIds: string[] = [],
deleteMaterial = true,
deleteMaterialCondition?: Record<string, unknown>,
): Promise<void> {
console.log('[Cleanup] Starting error cleanup...');
// 清理已上传的S3预览图
if (uploadedPreviewUrls.length > 0) {
console.log(`[Cleanup] Cleaning up ${uploadedPreviewUrls.length} S3 preview images...`);
for (const fileUrl of uploadedPreviewUrls) {
try {
await deleteFromS3(fileUrl);
console.log(`[Cleanup] Deleted S3 file: ${fileUrl}`);
} catch (cleanupError) {
console.error(`[Cleanup] Failed to delete S3 file ${fileUrl}:`, cleanupError);
}
}
}
// 清理本地zip文件(支持单个或多个)
if (zipFilePaths) {
const paths = Array.isArray(zipFilePaths) ? zipFilePaths : [zipFilePaths];
console.log(`[Cleanup] Cleaning up ${paths.length} local zip files...`);
for (const zipFilePath of paths) {
try {
await deleteStoredUploadFileFromRemote(zipFilePath);
} catch (cleanupError) {
console.error('[Cleanup] Failed to delete remote uploads file:', cleanupError);
}
try {
const fullPath = resolveStoredUploadPath(zipFilePath);
await unlink(fullPath);
console.log(`[Cleanup] Deleted local zip file: ${fullPath}`);
} catch (cleanupError) {
console.error('[Cleanup] Failed to delete local zip file:', cleanupError);
}
}
}
// assembled/temp/分片/会话必须统一走受控清理。uploadIds 存在时,
// helper 只会在 completed seal 下持跨容器独占锁删除,并保留 tombstone
// 阻止迟到分片复活目录;绝不能在这里先 unlink assembled 文件。
if (tempFiles.length > 0 || uploadIds.length > 0) {
try {
await cleanupTempFiles(tempFiles, uploadIds);
} catch (cleanupError) {
// 失败清理是 best-effort:状态不确定时保留数据,同时不覆盖原始
// 业务异常,便于用户重试和后续排障。
console.warn('[Cleanup] Controlled upload-session cleanup was not completed; files retained:', cleanupError);
}
}
// 删除素材记录
if (materialId && deleteMaterial) {
try {
if (deleteMaterialCondition) {
const result = await prisma.material.deleteMany({
where: { id: materialId, ...deleteMaterialCondition } as any,
});
if (result.count === 1) {
console.log(`[Cleanup] Deleted conditionally claimed material record: ${materialId}`);
} else {
console.warn(`[Cleanup] Skipped material deletion because claim state changed: ${materialId}`);
}
} else {
await prisma.material.delete({
where: { id: materialId },
});
console.log(`[Cleanup] Deleted material record: ${materialId}`);
}
} catch (cleanupError) {
console.error('[Cleanup] Failed to delete material record:', cleanupError);
}
}
console.log('[Cleanup] Error cleanup completed');
}
/**
* 保存资产记录到数据库
*/
export async function saveAssets(
materialId: string,
previewUrls: string[],
zipInfo: { filePath: string; fileName: string; fileSize: number } | null
): Promise<void> {
await prisma.$transaction(async (tx) => {
await applySimpleTaskMaterialAssets(tx, materialId, previewUrls, zipInfo);
});
console.log(`[Database] Saved ${previewUrls.length + (zipInfo ? 1 : 0)} asset records`);
}
async function applySimpleTaskMaterialAssets(
tx: Prisma.TransactionClient,
materialId: string,
previewUrls: string[],
zipInfo: { filePath: string; fileName: string; fileSize: number } | null,
): Promise<void> {
const assetRecords: Array<{
materialId: string;
assetType: string;
fileUrl: string;
fileName?: string;
fileSize?: bigint;
sortOrder?: number;
}> = [];
// 添加预览图资产
if (previewUrls.length > 0) {
previewUrls.forEach((url, index) => {
assetRecords.push({
materialId,
assetType: ASSET_TYPE.PREVIEW,
fileUrl: url,
fileName: `preview-${index}.webp`,
sortOrder: index,
});
});
}
// 添加zip资产
if (zipInfo) {
assetRecords.push({
materialId,
assetType: ASSET_TYPE.ZIP,
fileUrl: zipInfo.filePath,
fileName: zipInfo.fileName,
fileSize: BigInt(zipInfo.fileSize),
sortOrder: 0,
});
}
if (assetRecords.length > 0) {
await tx.materialAsset.createMany({
data: assetRecords,
});
}
const firstPreview = previewUrls[0] || '';
await tx.material.update({
where: { id: materialId },
data: {
previewUrl: firstPreview,
zipFilePath: zipInfo?.filePath || '',
zipFileName: zipInfo?.fileName || '',
zipFileSize: zipInfo?.fileSize ? BigInt(zipInfo.fileSize) : BigInt(0),
},
});
}
/**
* 简单任务图片的资产、任务号与 completed 状态必须在同一数据库事务提交。
* 事务确认不确定时,调用方会重新读取 taskStatus 并保留所有文件。
*/
export async function saveTaskMaterialAssetsAndComplete(input: {
materialId: string;
previewUrls: string[];
zipInfo: { filePath: string; fileName: string; fileSize: number } | null;
taskNo?: string | null;
dingtalkApprovalNo?: string | null;
userId?: string;
materialType?: string;
}): Promise<void> {
await finalizeTaskMaterialOnComplete({
materialId: input.materialId,
taskNo: input.taskNo,
dingtalkApprovalNo: input.dingtalkApprovalNo,
expectedTaskStatuses: ['uploading', null],
expectedUserId: input.userId,
expectedMaterialType: input.materialType,
applyCompletion: async (tx) => {
await applySimpleTaskMaterialAssets(
tx,
input.materialId,
input.previewUrls,
input.zipInfo,
);
},
});
console.log(
`[Database] Atomically completed task material with ${input.previewUrls.length + (input.zipInfo ? 1 : 0)} assets`,
);
}
/**
* 保存任务-详情的资产记录到数据库
* 包括:预览图(S3)、分类zip(本地)、总zip(本地)、PSD(本地)
*/
export async function saveProductPackageAssets(
materialId: string,
previewAssets: ProductPackageAssetGroups,
zipInfos: ProductPackageZipInfos,
psdInfos?: ProductPackagePsdInfo[],
options?: {
transaction?: Prisma.TransactionClient;
replaceExistingAssets?: boolean;
materialUpdate?: {
previewUrl: string;
isFullSet: boolean;
};
},
): Promise<{ createdSkuPreviewAssets: SavedProductPackageSkuPreviewAsset[] }> {
const assetRecords: Array<{
materialId: string;
assetType: string;
fileUrl: string;
fileName?: string;
fileSize?: bigint;
sortOrder?: number;
}> = [];
// 添加预览图资产(所有预览图)
let previewIndex = 0;
// 主图预览
previewAssets.main.forEach((asset, index) => {
assetRecords.push({
materialId,
assetType: ASSET_TYPE.MAIN_PREVIEW,
fileUrl: normalizeMaterialAssetFileUrl(asset.fileUrl),
fileName: asset.fileName || `main-preview-${index}.webp`,
sortOrder: previewIndex++,
});
});
// SKU图预览
const skuPreviewRecords = previewAssets.sku.map((asset, index) => ({
materialId,
assetType: ASSET_TYPE.SKU_PREVIEW,
fileUrl: normalizeMaterialAssetFileUrl(asset.fileUrl),
fileName: asset.fileName || `sku-preview-${index}.webp`,
sortOrder: previewIndex++,
skuCode: asset.skuCode?.trim() || null,
barcodes: Array.from(new Set((asset.barcodes || []).filter(Boolean))),
}));
// 详情图预览
previewAssets.detail.forEach((asset, index) => {
assetRecords.push({
materialId,
assetType: ASSET_TYPE.DETAIL_PREVIEW,
fileUrl: normalizeMaterialAssetFileUrl(asset.fileUrl),
fileName: asset.fileName || `detail-preview-${index}.webp`,
sortOrder: previewIndex++,
});
});
// 添加分类zip资产
if (zipInfos.main) {
assetRecords.push({
materialId,
assetType: ASSET_TYPE.MAIN,
fileUrl: zipInfos.main.filePath,
fileName: zipInfos.main.fileName,
fileSize: BigInt(zipInfos.main.fileSize),
sortOrder: 0,
});
}
if (zipInfos.sku) {
assetRecords.push({
materialId,
assetType: ASSET_TYPE.SKU,
fileUrl: zipInfos.sku.filePath,
fileName: zipInfos.sku.fileName,
fileSize: BigInt(zipInfos.sku.fileSize),
sortOrder: 0,
});
}
if (zipInfos.detail) {
assetRecords.push({
materialId,
assetType: ASSET_TYPE.DETAIL,
fileUrl: zipInfos.detail.filePath,
fileName: zipInfos.detail.fileName,
fileSize: BigInt(zipInfos.detail.fileSize),
sortOrder: 0,
});
}
// 添加总zip资产
assetRecords.push({
materialId,
assetType: ASSET_TYPE.ALL,
fileUrl: zipInfos.all.filePath,
fileName: zipInfos.all.fileName,
fileSize: BigInt(zipInfos.all.fileSize),
sortOrder: 0,
});
// 添加PSD资产
if (psdInfos && psdInfos.length > 0) {
psdInfos.forEach((psdInfo, index) => {
assetRecords.push({
materialId,
assetType: ASSET_TYPE.PSD,
fileUrl: psdInfo.filePath,
fileName: psdInfo.fileName,
fileSize: BigInt(psdInfo.fileSize),
sortOrder: index,
});
});
}
const applyAssets = async (tx: Prisma.TransactionClient) => {
// uploading 状态的重试可能面对旧版本留下的半成品记录。先在同一事务内
// 清掉旧资产再重建;后续任一步失败都会整体回滚,不会重复叠加资产。
if (options?.replaceExistingAssets) {
await tx.materialAsset.deleteMany({ where: { materialId } });
}
// 批量创建资产记录
if (assetRecords.length > 0) {
await tx.materialAsset.createMany({
data: assetRecords,
});
}
// 更新素材记录(zip信息,previewUrl在Step 6中更新)
await tx.material.update({
where: { id: materialId },
data: {
zipFilePath: zipInfos.all.filePath,
zipFileName: zipInfos.all.fileName,
zipFileSize: BigInt(zipInfos.all.fileSize),
},
});
const createdAssets: SavedProductPackageSkuPreviewAsset[] = [];
for (const skuPreviewRecord of skuPreviewRecords) {
const createdAsset = await tx.materialAsset.create({
data: {
materialId: skuPreviewRecord.materialId,
assetType: skuPreviewRecord.assetType,
fileUrl: skuPreviewRecord.fileUrl,
fileName: skuPreviewRecord.fileName,
sortOrder: skuPreviewRecord.sortOrder,
skuCode: skuPreviewRecord.skuCode,
},
select: {
id: true,
fileUrl: true,
fileName: true,
sortOrder: true,
createdAt: true,
updatedAt: true,
},
});
if (skuPreviewRecord.barcodes.length > 0) {
await tx.materialAssetBarcode.createMany({
data: skuPreviewRecord.barcodes.map((barcode) => ({
assetId: createdAsset.id,
barcode,
})),
});
}
createdAssets.push({
...createdAsset,
skuCode: skuPreviewRecord.skuCode,
barcodes: skuPreviewRecord.barcodes,
});
}
if (options?.materialUpdate) {
await tx.material.update({
where: { id: materialId },
data: {
previewUrl: options.materialUpdate.previewUrl,
isFullSet: options.materialUpdate.isFullSet,
},
});
}
return createdAssets;
};
const createdSkuPreviewAssets = options?.transaction
? await applyAssets(options.transaction)
: await prisma.$transaction(applyAssets);
if (!options?.transaction) {
console.log(`[Database] Saved ${assetRecords.length + createdSkuPreviewAssets.length} asset records for product package`);
}
return { createdSkuPreviewAssets };
}
/**
* 任务-详情的资产、卡片预览、ZIP、任务号和 completed 状态必须一次提交。
* 文件生成仍在事务外完成;调用方遇到事务响应确认异常时必须重读完整状态,
* 不能仅凭本函数抛错就删除文件。
*/
export async function saveProductPackageAssetsAndComplete(input: {
materialId: string;
previewAssets: ProductPackageAssetGroups;
zipInfos: ProductPackageZipInfos;
psdInfos?: ProductPackagePsdInfo[];
cardPreviewUrl: string;
isFullSet: boolean;
taskNo?: string | null;
dingtalkApprovalNo?: string | null;
userId: string;
materialType?: string;
}): Promise<{ createdSkuPreviewAssets: SavedProductPackageSkuPreviewAsset[] }> {
let saveResult: { createdSkuPreviewAssets: SavedProductPackageSkuPreviewAsset[] } = {
createdSkuPreviewAssets: [],
};
await finalizeTaskMaterialOnComplete({
materialId: input.materialId,
taskNo: input.taskNo,
dingtalkApprovalNo: input.dingtalkApprovalNo,
expectedTaskStatuses: ['uploading', null],
expectedUserId: input.userId,
expectedMaterialType: input.materialType || '任务-详情',
applyCompletion: async (tx) => {
saveResult = await saveProductPackageAssets(
input.materialId,
input.previewAssets,
input.zipInfos,
input.psdInfos,
{
transaction: tx,
replaceExistingAssets: true,
materialUpdate: {
previewUrl: input.cardPreviewUrl,
isFullSet: input.isFullSet,
},
},
);
},
});
console.log('[Database] Atomically completed product package');
return saveResult;
}