gongxiangp2/lib/product-package.ts

482 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 任务-详情素材类型 - 核心业务逻辑
*/
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 };
}