482 lines
12 KiB
TypeScript
482 lines
12 KiB
TypeScript
/**
|
||
* 任务-详情素材类型 - 核心业务逻辑
|
||
*/
|
||
|
||
import { prisma } from './prisma';
|
||
import path from 'path';
|
||
import {
|
||
getDingtalkApprovalNoError,
|
||
normalizeDingtalkApprovalNo,
|
||
} from './dingtalk-approval-no';
|
||
|
||
// 任务号格式: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+$/;
|
||
|
||
// 任务状态
|
||
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 },
|
||
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 },
|
||
include: {
|
||
assets: {
|
||
orderBy: { sortOrder: 'asc' },
|
||
},
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
realName: true,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 当任务号被复用时,清理此前已软删除的旧素材,避免残留孤儿文件
|
||
*/
|
||
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) {
|
||
return {
|
||
deletedMaterials: 0,
|
||
deletedFiles: 0,
|
||
failedFiles: 0,
|
||
};
|
||
}
|
||
|
||
const { collectMaterialFiles, deleteMaterialRelatedRecords } = await import('./material-cleanup');
|
||
const { deleteFiles } = await import('./file-cleanup');
|
||
const { ensureMaterialDownloadLogsTable } = await import('./material-download-log');
|
||
|
||
await ensureMaterialDownloadLogsTable();
|
||
|
||
let deletedFiles = 0;
|
||
let failedFiles = 0;
|
||
|
||
for (const material of deletedMaterials) {
|
||
const fileInfo = await collectMaterialFiles(material.id, material);
|
||
const filesToDelete = [...fileInfo.fileUrls, ...fileInfo.directories];
|
||
|
||
if (filesToDelete.length > 0) {
|
||
const result = await deleteFiles(filesToDelete);
|
||
deletedFiles += result.deletedCount;
|
||
failedFiles += result.failedCount;
|
||
}
|
||
|
||
await deleteMaterialRelatedRecords(material.id, material.materialType);
|
||
await prisma.material.delete({
|
||
where: { id: material.id },
|
||
});
|
||
}
|
||
|
||
return {
|
||
deletedMaterials: deletedMaterials.length,
|
||
deletedFiles,
|
||
failedFiles,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 在素材成功完成后再写入任务关联字段,避免失败中的半成品占用任务号
|
||
*/
|
||
export async function assignTaskNoOnComplete(
|
||
materialId: string,
|
||
taskNo?: string | null,
|
||
dingtalkApprovalNo?: string | null,
|
||
): Promise<{
|
||
deletedMaterials: number;
|
||
deletedFiles: number;
|
||
failedFiles: number;
|
||
} | null> {
|
||
const normalizedTaskNo = taskNo?.trim();
|
||
const hasDingtalkApprovalNo = dingtalkApprovalNo !== undefined;
|
||
const normalizedDingtalkApprovalNo = normalizeDingtalkApprovalNo(dingtalkApprovalNo);
|
||
const dingtalkApprovalNoError = getDingtalkApprovalNoError(normalizedDingtalkApprovalNo);
|
||
if (dingtalkApprovalNoError) {
|
||
throw new Error(dingtalkApprovalNoError);
|
||
}
|
||
|
||
if (!normalizedTaskNo) {
|
||
await prisma.material.update({
|
||
where: { id: materialId },
|
||
data: {
|
||
...(hasDingtalkApprovalNo
|
||
? { dingtalkApprovalNo: normalizedDingtalkApprovalNo }
|
||
: {}),
|
||
taskStatus: TASK_STATUS.COMPLETED,
|
||
} 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})`
|
||
);
|
||
}
|
||
|
||
const cleanupResult = await purgeDeletedMaterialsByTaskNo(normalizedTaskNo);
|
||
|
||
await prisma.material.update({
|
||
where: { id: materialId },
|
||
data: {
|
||
taskNo: normalizedTaskNo,
|
||
...(hasDingtalkApprovalNo
|
||
? { dingtalkApprovalNo: normalizedDingtalkApprovalNo }
|
||
: {}),
|
||
taskStatus: TASK_STATUS.COMPLETED,
|
||
} as any,
|
||
});
|
||
|
||
return cleanupResult;
|
||
}
|
||
|
||
/**
|
||
* 清理任务-详情素材的所有文件(用于上传失败时的回滚)
|
||
* @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 };
|
||
}
|