699 lines
20 KiB
TypeScript
699 lines
20 KiB
TypeScript
/**
|
||
* 任务-详情素材类型 - 核心业务逻辑
|
||
*/
|
||
|
||
import { prisma } from './prisma';
|
||
import type { Prisma } from '@prisma/client';
|
||
import path from 'path';
|
||
import {
|
||
getDingtalkApprovalNoError,
|
||
normalizeDingtalkApprovalNo,
|
||
} from './dingtalk-approval-no';
|
||
import { LIST_VISIBLE_MATERIAL_DATA } from './material-query';
|
||
|
||
// 任务号格式:TASK + 12-18位数字 + 可选的多个子任务号(-2位数字,可重复)
|
||
// 示例:
|
||
// TASK202601030184 - 基础任务号(无子任务)
|
||
// TASK202601071437194808-01 - 一级子任务
|
||
// TASK202601141713525624-01-01 - 二级子任务(任务号-子任务-子任务)
|
||
// TASK202601141713525624-01-01-01 - 三级子任务(可无限层级)
|
||
const TASK_NO_REGEX = /^TASK\d{12,18}(-\d{2})*$/;
|
||
const DELETED_TASK_NO_SUFFIX_REGEX = /_DELETED_\d+_\d+$/;
|
||
|
||
/**
|
||
* 按规范化任务号串行化最终占用动作。锁函数返回 void,必须藏在子查询中,
|
||
* 只让 Prisma 反序列化普通整数列。
|
||
*/
|
||
export async function acquireTaskNoAssignmentLock(
|
||
tx: Prisma.TransactionClient,
|
||
normalizedTaskNo: string,
|
||
) {
|
||
await tx.$queryRaw`
|
||
SELECT 1 AS locked
|
||
FROM (
|
||
SELECT pg_advisory_xact_lock(hashtext(${'task-no:' + normalizedTaskNo}))
|
||
) AS lock_result
|
||
`;
|
||
}
|
||
|
||
// 任务状态
|
||
export const TASK_STATUS = {
|
||
UPLOADING: 'uploading',
|
||
COMPLETED: 'completed',
|
||
FETCHED: 'fetched',
|
||
} as const;
|
||
|
||
export type TaskStatus = typeof TASK_STATUS[keyof typeof TASK_STATUS];
|
||
|
||
// 资产类型
|
||
export const ASSET_TYPE = {
|
||
PREVIEW: 'preview', // 预览图(S3)
|
||
ZIP: 'zip', // 压缩包(本地)
|
||
MAIN: 'main', // 主图(zip或S3)
|
||
SKU: 'sku', // SKU图(zip或S3)
|
||
DETAIL: 'detail', // 详情图(zip或S3)
|
||
ALL: 'all', // 全部压缩包(本地)
|
||
PSD: 'psd', // PSD源文件(本地)
|
||
} as const;
|
||
|
||
export type AssetType = typeof ASSET_TYPE[keyof typeof ASSET_TYPE];
|
||
|
||
/**
|
||
* 校验任务号格式
|
||
*/
|
||
export function validateTaskNo(taskNo: string): boolean {
|
||
return TASK_NO_REGEX.test(taskNo);
|
||
}
|
||
|
||
/**
|
||
* 从历史软删除 taskNo 中恢复原始任务号
|
||
*/
|
||
export function getOriginalTaskNo(taskNo?: string | null): string | null {
|
||
if (!taskNo) {
|
||
return null;
|
||
}
|
||
|
||
const trimmed = taskNo.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
return trimmed.replace(DELETED_TASK_NO_SUFFIX_REGEX, '');
|
||
}
|
||
|
||
/**
|
||
* 检查任务号是否已存在(排除已删除的素材)
|
||
*/
|
||
export async function isTaskNoExists(taskNo: string): Promise<boolean> {
|
||
const material = await prisma.material.findFirst({
|
||
where: {
|
||
taskNo,
|
||
deletedAt: null, // 只检查未删除的素材
|
||
},
|
||
select: { id: true },
|
||
});
|
||
return !!material;
|
||
}
|
||
|
||
/**
|
||
* 更新任务状态
|
||
*/
|
||
export async function updateTaskStatus(
|
||
materialId: string,
|
||
status: TaskStatus,
|
||
options?: { taskFetchedAt?: Date }
|
||
): Promise<void> {
|
||
await prisma.material.update({
|
||
where: { id: materialId },
|
||
data: {
|
||
taskStatus: status,
|
||
...(options?.taskFetchedAt && { taskFetchedAt: options.taskFetchedAt }),
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 通过任务号获取素材
|
||
*/
|
||
export async function getMaterialByTaskNo(taskNo: string) {
|
||
return prisma.material.findFirst({
|
||
where: { taskNo, deletedAt: null, taskDetailPrivate: false },
|
||
include: {
|
||
assets: {
|
||
orderBy: { sortOrder: 'asc' },
|
||
},
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
realName: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取素材的资产列表(按类型分组)
|
||
* 兼容新旧两种资产类型格式
|
||
*/
|
||
export async function getAssetsByMaterialId(materialId: string) {
|
||
const assets = await prisma.materialAsset.findMany({
|
||
where: { materialId },
|
||
orderBy: { sortOrder: 'asc' },
|
||
});
|
||
|
||
return {
|
||
main: assets.filter(a => a.assetType === ASSET_TYPE.MAIN),
|
||
sku: assets.filter(a => a.assetType === ASSET_TYPE.SKU),
|
||
detail: assets.filter(a => a.assetType === ASSET_TYPE.DETAIL),
|
||
psd: assets.find(a => a.assetType === ASSET_TYPE.PSD) || null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 添加资产
|
||
*/
|
||
export async function addAsset(data: {
|
||
materialId: string;
|
||
assetType: AssetType;
|
||
fileUrl: string;
|
||
fileName?: string;
|
||
fileSize?: bigint;
|
||
sortOrder?: number;
|
||
}) {
|
||
return prisma.materialAsset.create({
|
||
data: {
|
||
materialId: data.materialId,
|
||
assetType: data.assetType,
|
||
fileUrl: data.fileUrl,
|
||
fileName: data.fileName,
|
||
fileSize: data.fileSize || BigInt(0),
|
||
sortOrder: data.sortOrder || 0,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 批量添加资产
|
||
*/
|
||
export async function addAssets(assets: Array<{
|
||
materialId: string;
|
||
assetType: AssetType;
|
||
fileUrl: string;
|
||
fileName?: string;
|
||
fileSize?: bigint;
|
||
sortOrder?: number;
|
||
}>) {
|
||
return prisma.materialAsset.createMany({
|
||
data: assets.map(a => ({
|
||
materialId: a.materialId,
|
||
assetType: a.assetType,
|
||
fileUrl: a.fileUrl,
|
||
fileName: a.fileName,
|
||
fileSize: a.fileSize || BigInt(0),
|
||
sortOrder: a.sortOrder || 0,
|
||
})),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 删除资产
|
||
*/
|
||
export async function deleteAsset(assetId: string) {
|
||
return prisma.materialAsset.delete({
|
||
where: { id: assetId },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 更新资产排序
|
||
*/
|
||
export async function updateAssetSortOrder(
|
||
orders: Array<{ assetId: string; sortOrder: number }>
|
||
) {
|
||
const updates = orders.map(({ assetId, sortOrder }) =>
|
||
prisma.materialAsset.update({
|
||
where: { id: assetId },
|
||
data: { sortOrder },
|
||
})
|
||
);
|
||
return prisma.$transaction(updates);
|
||
}
|
||
|
||
/**
|
||
* 获取素材封面图(用于卡片展示)
|
||
* 优先使用第一张主图,没有则使用第一张SKU图
|
||
* 兼容新旧两种资产类型格式
|
||
*/
|
||
export async function getMaterialCoverUrl(materialId: string): Promise<string | null> {
|
||
// 先找主图(新逻辑)
|
||
const mainAsset = await prisma.materialAsset.findFirst({
|
||
where: { materialId, assetType: ASSET_TYPE.MAIN },
|
||
orderBy: { sortOrder: 'asc' },
|
||
select: { fileUrl: true },
|
||
});
|
||
|
||
if (mainAsset) {
|
||
return mainAsset.fileUrl;
|
||
}
|
||
|
||
// 没有主图,找SKU图(新逻辑)
|
||
const skuAsset = await prisma.materialAsset.findFirst({
|
||
where: { materialId, assetType: ASSET_TYPE.SKU },
|
||
orderBy: { sortOrder: 'asc' },
|
||
select: { fileUrl: true },
|
||
});
|
||
|
||
return skuAsset?.fileUrl || null;
|
||
}
|
||
|
||
/**
|
||
* 软删除素材(通过任务号)
|
||
*/
|
||
export async function softDeleteByTaskNo(taskNo: string) {
|
||
return prisma.material.updateMany({
|
||
where: {
|
||
taskNo,
|
||
deletedAt: null,
|
||
},
|
||
data: { deletedAt: new Date() },
|
||
});
|
||
}
|
||
|
||
export async function getMaterialById(materialId: string) {
|
||
return prisma.material.findFirst({
|
||
where: { id: materialId, deletedAt: null, taskDetailPrivate: false },
|
||
include: {
|
||
assets: {
|
||
orderBy: { sortOrder: 'asc' },
|
||
},
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
realName: true,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 兼容旧调用名:任务号复用完成后只盘点并记录旧软删素材,不执行物理 GC。
|
||
*
|
||
* 旧素材可能与其它记录共享文件 URL,而且文件系统/S3 删除与数据库级联无法
|
||
* 组成同一原子事务。自动清理会在任一阶段失败时留下不可恢复的数据缺口,
|
||
* 因此这里明确 fail-safe:保留文件、关联记录和 Material 根记录,交给独立、
|
||
* 可审计且具备引用检查的离线流程处理。
|
||
*/
|
||
export async function purgeDeletedMaterialsByTaskNo(taskNo: string): Promise<{
|
||
deletedMaterials: number;
|
||
deletedFiles: number;
|
||
failedFiles: number;
|
||
}> {
|
||
const deletedMaterials = await prisma.material.findMany({
|
||
where: {
|
||
taskNo,
|
||
deletedAt: { not: null },
|
||
},
|
||
select: {
|
||
id: true,
|
||
materialType: true,
|
||
deletedAt: true,
|
||
},
|
||
});
|
||
|
||
if (deletedMaterials.length > 0) {
|
||
console.warn('[ProductPackageGC] 自动物理 GC 已禁用;旧软删素材及其文件已保留,需交由独立审计流程处理:', {
|
||
taskNo,
|
||
deferredMaterials: deletedMaterials.length,
|
||
materialIds: deletedMaterials.map((material) => material.id),
|
||
});
|
||
}
|
||
|
||
// 保持历史返回结构;所有计数为 0 表示本请求没有发起任何物理删除。
|
||
return {
|
||
deletedMaterials: 0,
|
||
deletedFiles: 0,
|
||
failedFiles: 0,
|
||
};
|
||
}
|
||
|
||
async function reportDeferredDeletedMaterialsAfterCompletion(taskNo: string) {
|
||
try {
|
||
return await purgeDeletedMaterialsByTaskNo(taskNo);
|
||
} catch (error) {
|
||
// 新素材已持久完成,旧数据盘点失败不得把成功结果改判为失败。
|
||
// 本路径不会因盘点失败而尝试删除任何文件或数据库记录。
|
||
console.error('[ProductPackageGC] 新素材已完成,旧软删素材盘点失败;自动物理 GC 仍保持禁用:', {
|
||
taskNo,
|
||
error,
|
||
});
|
||
return {
|
||
deletedMaterials: 0,
|
||
deletedFiles: 0,
|
||
failedFiles: 1,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 在素材成功完成后再写入任务关联字段,避免失败中的半成品占用任务号
|
||
*/
|
||
export async function assignTaskNoOnComplete(
|
||
materialId: string,
|
||
taskNo?: string | null,
|
||
dingtalkApprovalNo?: string | null,
|
||
): Promise<{
|
||
deletedMaterials: number;
|
||
deletedFiles: number;
|
||
failedFiles: number;
|
||
} | null> {
|
||
return finalizeTaskMaterialOnComplete({
|
||
materialId,
|
||
taskNo,
|
||
dingtalkApprovalNo,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 把任务号占用、任务资产最终写入和 completed 状态放进同一事务。
|
||
* 文件生成发生在事务外;调用方只有在所有派生文件已经验证完成后才调用。
|
||
*/
|
||
export async function finalizeTaskMaterialOnComplete(input: {
|
||
materialId: string;
|
||
taskNo?: string | null;
|
||
dingtalkApprovalNo?: string | null;
|
||
applyCompletion?: (tx: Prisma.TransactionClient) => Promise<void>;
|
||
expectedTaskStatuses?: Array<string | null>;
|
||
expectedUserId?: string;
|
||
expectedMaterialType?: string;
|
||
}): Promise<{
|
||
deletedMaterials: number;
|
||
deletedFiles: number;
|
||
failedFiles: number;
|
||
} | null> {
|
||
const {
|
||
materialId,
|
||
taskNo,
|
||
dingtalkApprovalNo,
|
||
applyCompletion,
|
||
expectedTaskStatuses,
|
||
expectedUserId,
|
||
expectedMaterialType,
|
||
} = input;
|
||
const normalizedTaskNo = taskNo?.trim();
|
||
const hasDingtalkApprovalNo = dingtalkApprovalNo !== undefined;
|
||
const normalizedDingtalkApprovalNo = normalizeDingtalkApprovalNo(dingtalkApprovalNo);
|
||
const dingtalkApprovalNoError = getDingtalkApprovalNoError(normalizedDingtalkApprovalNo);
|
||
if (dingtalkApprovalNoError) {
|
||
throw new Error(dingtalkApprovalNoError);
|
||
}
|
||
|
||
const assertExpectedCompletionState = async (
|
||
tx: Prisma.TransactionClient,
|
||
) => {
|
||
if (!expectedTaskStatuses || expectedTaskStatuses.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// 行锁把“验证可完成状态”和后续资产/完成状态写入放在同一临界区。
|
||
// 不能在 applyCompletion 之后再用 previewUrl='' 作 CAS;事务自己的预览更新
|
||
// 会让 legacy null 状态把自己挡住。
|
||
const rows = await tx.$queryRaw<Array<{
|
||
taskStatus: string | null;
|
||
previewUrl: string | null;
|
||
zipFilePath: string | null;
|
||
deletedAt: Date | null;
|
||
userId: string;
|
||
materialType: string;
|
||
}>>`
|
||
SELECT
|
||
"task_status" AS "taskStatus",
|
||
"previewUrl",
|
||
"zipFilePath",
|
||
"deletedAt",
|
||
"userId",
|
||
"materialType"
|
||
FROM "materials"
|
||
WHERE "id" = ${materialId}
|
||
FOR UPDATE
|
||
`;
|
||
const material = rows[0];
|
||
const matchesExpectedStatus = Boolean(material) && expectedTaskStatuses.some((taskStatus) => {
|
||
if (taskStatus == null) {
|
||
return material.taskStatus == null && !material.previewUrl && !material.zipFilePath;
|
||
}
|
||
return material.taskStatus === taskStatus;
|
||
});
|
||
if (
|
||
!material
|
||
|| material.deletedAt
|
||
|| !matchesExpectedStatus
|
||
|| (expectedUserId && material.userId !== expectedUserId)
|
||
|| (expectedMaterialType && material.materialType !== expectedMaterialType)
|
||
) {
|
||
throw new Error('素材完成状态已变化,本次结果未写入,请刷新后重试');
|
||
}
|
||
};
|
||
|
||
if (!normalizedTaskNo) {
|
||
await prisma.$transaction(async (tx) => {
|
||
await assertExpectedCompletionState(tx);
|
||
await applyCompletion?.(tx);
|
||
await tx.material.update({
|
||
where: { id: materialId },
|
||
data: {
|
||
...(hasDingtalkApprovalNo
|
||
? { dingtalkApprovalNo: normalizedDingtalkApprovalNo }
|
||
: {}),
|
||
taskStatus: TASK_STATUS.COMPLETED,
|
||
...LIST_VISIBLE_MATERIAL_DATA,
|
||
} as any,
|
||
});
|
||
});
|
||
return null;
|
||
}
|
||
|
||
if (!validateTaskNo(normalizedTaskNo)) {
|
||
throw new Error('任务号格式错误');
|
||
}
|
||
|
||
const existingMaterial = await prisma.material.findFirst({
|
||
where: {
|
||
taskNo: normalizedTaskNo,
|
||
deletedAt: null,
|
||
NOT: { id: materialId },
|
||
} as any,
|
||
select: {
|
||
id: true,
|
||
productModel: true,
|
||
materialType: true,
|
||
},
|
||
});
|
||
|
||
if (existingMaterial) {
|
||
throw new Error(
|
||
`任务号 ${normalizedTaskNo} 已存在,对应素材:${existingMaterial.productModel}(${existingMaterial.materialType})`
|
||
);
|
||
}
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
await acquireTaskNoAssignmentLock(tx, normalizedTaskNo);
|
||
// 这是并发完成时决定谁能最终占用任务号的权威门禁。
|
||
const concurrentOwner = await tx.material.findFirst({
|
||
where: {
|
||
taskNo: normalizedTaskNo,
|
||
deletedAt: null,
|
||
NOT: { id: materialId },
|
||
} as any,
|
||
select: {
|
||
id: true,
|
||
productModel: true,
|
||
materialType: true,
|
||
},
|
||
});
|
||
if (concurrentOwner) {
|
||
throw new Error(
|
||
`任务号 ${normalizedTaskNo} 已存在,对应素材:${concurrentOwner.productModel}(${concurrentOwner.materialType})`
|
||
);
|
||
}
|
||
|
||
await assertExpectedCompletionState(tx);
|
||
await applyCompletion?.(tx);
|
||
|
||
await tx.material.update({
|
||
where: { id: materialId },
|
||
data: {
|
||
taskNo: normalizedTaskNo,
|
||
...(hasDingtalkApprovalNo
|
||
? { dingtalkApprovalNo: normalizedDingtalkApprovalNo }
|
||
: {}),
|
||
taskStatus: TASK_STATUS.COMPLETED,
|
||
...LIST_VISIBLE_MATERIAL_DATA,
|
||
} as any,
|
||
});
|
||
});
|
||
|
||
// 新素材完整提交后只盘点并记录旧软删数据;不发起物理 GC,
|
||
// 不回滚、也不遮蔽已经完成的新素材。
|
||
return reportDeferredDeletedMaterialsAfterCompletion(normalizedTaskNo);
|
||
}
|
||
|
||
/**
|
||
* 通用上传入口中的任务素材必须把 Material、资产、任务号和 completed
|
||
* 状态一次提交。任何资产写入失败都会回滚整条新素材,不能留下“已完成但
|
||
* 缺资产”的半成品。
|
||
*/
|
||
export async function createTaskMaterialOnComplete(input: {
|
||
materialData: Record<string, unknown>;
|
||
assets: Array<{
|
||
assetType: string;
|
||
fileUrl: string;
|
||
fileName: string;
|
||
fileSize: bigint;
|
||
sortOrder: number;
|
||
}>;
|
||
taskNo?: string | null;
|
||
dingtalkApprovalNo?: string | null;
|
||
}) {
|
||
const normalizedTaskNo = String(input.taskNo || '').trim();
|
||
if (normalizedTaskNo && !validateTaskNo(normalizedTaskNo)) {
|
||
throw new Error('任务号格式错误');
|
||
}
|
||
const normalizedDingtalkApprovalNo = normalizeDingtalkApprovalNo(input.dingtalkApprovalNo);
|
||
const dingtalkApprovalNoError = getDingtalkApprovalNoError(normalizedDingtalkApprovalNo);
|
||
if (dingtalkApprovalNoError) {
|
||
throw new Error(dingtalkApprovalNoError);
|
||
}
|
||
|
||
const material = await prisma.$transaction(async (tx) => {
|
||
if (normalizedTaskNo) {
|
||
await acquireTaskNoAssignmentLock(tx, normalizedTaskNo);
|
||
const concurrentOwner = await tx.material.findFirst({
|
||
where: {
|
||
taskNo: normalizedTaskNo,
|
||
deletedAt: null,
|
||
},
|
||
select: {
|
||
id: true,
|
||
productModel: true,
|
||
materialType: true,
|
||
},
|
||
});
|
||
if (concurrentOwner) {
|
||
throw new Error(
|
||
`任务号 ${normalizedTaskNo} 已存在,对应素材:${concurrentOwner.productModel}(${concurrentOwner.materialType})`,
|
||
);
|
||
}
|
||
}
|
||
|
||
const createdMaterial = await tx.material.create({
|
||
data: {
|
||
...input.materialData,
|
||
...(normalizedTaskNo ? { taskNo: normalizedTaskNo } : {}),
|
||
dingtalkApprovalNo: normalizedDingtalkApprovalNo,
|
||
taskStatus: TASK_STATUS.COMPLETED,
|
||
...LIST_VISIBLE_MATERIAL_DATA,
|
||
} as any,
|
||
include: {
|
||
user: {
|
||
select: { username: true },
|
||
},
|
||
},
|
||
});
|
||
|
||
if (input.assets.length > 0) {
|
||
await tx.materialAsset.createMany({
|
||
data: input.assets.map((asset) => ({
|
||
...asset,
|
||
materialId: createdMaterial.id,
|
||
})),
|
||
});
|
||
}
|
||
return createdMaterial;
|
||
});
|
||
|
||
if (normalizedTaskNo) {
|
||
await reportDeferredDeletedMaterialsAfterCompletion(normalizedTaskNo);
|
||
}
|
||
return material;
|
||
}
|
||
|
||
/**
|
||
* 保留旧调用名,避免其它尚未迁移的调用方绕回非原子实现。
|
||
*/
|
||
export async function createTaskOtherMaterialOnComplete(input: Parameters<
|
||
typeof createTaskMaterialOnComplete
|
||
>[0]) {
|
||
return createTaskMaterialOnComplete(input);
|
||
}
|
||
|
||
/**
|
||
* 清理任务-详情素材的所有文件(用于上传失败时的回滚)
|
||
* @param materialId 素材ID
|
||
* @param fileUrls 可选,如果提供则直接清理这些文件(用于资产记录还未创建的情况)
|
||
*/
|
||
export async function cleanupProductPackageFiles(
|
||
materialId: string,
|
||
fileUrls?: string[]
|
||
): Promise<{
|
||
deletedFiles: number;
|
||
failedFiles: number;
|
||
}> {
|
||
const { deleteFromS3, isS3Url } = await import('./s3');
|
||
const fs = await import('fs/promises');
|
||
let deletedFiles = 0;
|
||
let failedFiles = 0;
|
||
|
||
try {
|
||
let filesToDelete: string[] = [];
|
||
|
||
if (fileUrls && fileUrls.length > 0) {
|
||
// 如果提供了文件 URL 列表,直接使用(资产记录可能还未创建)
|
||
filesToDelete = fileUrls;
|
||
} else {
|
||
// 否则查询数据库中的资产文件
|
||
const assets = await prisma.materialAsset.findMany({
|
||
where: { materialId },
|
||
select: { fileUrl: true },
|
||
});
|
||
filesToDelete = assets.map(a => a.fileUrl).filter(Boolean);
|
||
}
|
||
|
||
// 删除所有文件(使用统一的文件删除工具)
|
||
const { deleteFile } = await import('./file-cleanup');
|
||
for (const fileUrl of filesToDelete) {
|
||
if (!fileUrl) continue;
|
||
|
||
const result = await deleteFile(fileUrl);
|
||
if (result.success) {
|
||
deletedFiles++;
|
||
const fileType = result.isDirectory ? 'directory' : 'file';
|
||
console.log(`[Cleanup] Deleted ${fileType}: ${fileUrl}`);
|
||
} else {
|
||
failedFiles++;
|
||
console.error(`[Cleanup] Failed to delete file: ${fileUrl}`, result.error);
|
||
}
|
||
}
|
||
|
||
// 删除 PSD 文件目录(如果存在)
|
||
const psdDir = path.join(process.cwd(), 'uploads', 'product-package', materialId);
|
||
try {
|
||
await fs.rm(psdDir, { recursive: true, force: true });
|
||
console.log(`[Cleanup] Deleted PSD directory: ${psdDir}`);
|
||
} catch (err: any) {
|
||
if (err.code !== 'ENOENT') {
|
||
console.warn(`[Cleanup] Failed to delete PSD directory: ${psdDir}`, err);
|
||
}
|
||
}
|
||
|
||
// 删除资产记录(如果存在)
|
||
try {
|
||
await prisma.materialAsset.deleteMany({
|
||
where: { materialId },
|
||
});
|
||
} catch (err) {
|
||
// 资产记录可能不存在,忽略错误
|
||
console.warn(`[Cleanup] Failed to delete asset records (may not exist):`, err);
|
||
}
|
||
|
||
// 删除素材记录
|
||
try {
|
||
await prisma.material.delete({
|
||
where: { id: materialId },
|
||
});
|
||
} catch (err) {
|
||
console.warn(`[Cleanup] Failed to delete material record: ${materialId}`, err);
|
||
}
|
||
|
||
console.log(`[Cleanup] Cleaned up product package material ${materialId}: ${deletedFiles} files deleted, ${failedFiles} failed`);
|
||
} catch (error) {
|
||
console.error(`[Cleanup] Error cleaning up product package material ${materialId}:`, error);
|
||
throw error;
|
||
}
|
||
|
||
return { deletedFiles, failedFiles };
|
||
}
|