gongxiangp2/lib/product-package.ts

699 lines
20 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 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 };
}