1141 lines
37 KiB
TypeScript
1141 lines
37 KiB
TypeScript
/**
|
||
* 任务类型素材的通用处理逻辑
|
||
* 用于:任务-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;
|
||
}
|