Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import { prisma } from '@/lib/prisma';
|
||
import { createMaterialLog } from '@/lib/material-log';
|
||
import {
|
||
acquireTaskMaterialCompletionLock,
|
||
acquireTaskMaterialUploadSessionLocks,
|
||
type MaterialUploadStorageLockRelease,
|
||
} from '@/lib/material-upload-cross-process-lock';
|
||
import { MaterialUploadRequestError } from '@/lib/material-upload-request-guard';
|
||
import { inspectTaskMaterialCompletionAfterError } from '@/lib/task-material-completion-safety';
|
||
import {
|
||
cleanupOnError,
|
||
cleanupTempFiles,
|
||
createZipFromFiles,
|
||
generatePreviewImages,
|
||
resolveOwnedTaskMaterialSourceFiles,
|
||
saveTaskMaterialAssetsAndComplete,
|
||
TaskMaterialSourceValidationError,
|
||
TASK_MATERIAL_ZIP_COMPRESSION_LEVEL,
|
||
TASK_MATERIAL_ZIP_TIMEOUT_MS,
|
||
type TaskMaterialSourceAssetInput,
|
||
} from '@/lib/task-material-processor';
|
||
|
||
export class TaskImageMaterialCompletionError extends Error {
|
||
constructor(message: string, readonly status: number) {
|
||
super(message);
|
||
this.name = 'TaskImageMaterialCompletionError';
|
||
}
|
||
}
|
||
|
||
export interface CompleteTaskImageMaterialInput {
|
||
materialId: string;
|
||
userId: string;
|
||
materialType: string;
|
||
fallbackProductModel: string;
|
||
assets: TaskMaterialSourceAssetInput[];
|
||
taskNo?: string | null;
|
||
dingtalkApprovalNo?: string | null;
|
||
compressOptions?: {
|
||
maxWidth?: number;
|
||
maxHeight?: number;
|
||
maxSizeKB?: number;
|
||
};
|
||
}
|
||
|
||
export interface CompleteTaskImageMaterialResult {
|
||
materialId: string;
|
||
alreadyCompleted?: boolean;
|
||
warning?: string;
|
||
}
|
||
|
||
function throwCompletionError(message: string, status: number): never {
|
||
throw new TaskImageMaterialCompletionError(message, status);
|
||
}
|
||
|
||
/**
|
||
* 五类任务图片共用的安全完成状态机。
|
||
*
|
||
* 长时间 ZIP/图片处理期间数据库保持 uploading,可在进程异常退出后自然重试;
|
||
* 同一素材由共享盘上的 per-material flock 跨蓝绿容器互斥。只有全部派生文件
|
||
* 验证成功后,资产、任务号和 completed 状态才在同一事务提交。
|
||
*/
|
||
export async function completeTaskImageMaterial(
|
||
input: CompleteTaskImageMaterialInput,
|
||
): Promise<CompleteTaskImageMaterialResult> {
|
||
const materialId = String(input.materialId || '').trim();
|
||
const assets = Array.isArray(input.assets) ? input.assets : [];
|
||
if (!materialId) {
|
||
throwCompletionError('缺少素材编号', 400);
|
||
}
|
||
if (assets.length === 0) {
|
||
throwCompletionError('至少需要上传一张图片', 400);
|
||
}
|
||
|
||
let releaseCompletionLock: MaterialUploadStorageLockRelease | null = null;
|
||
let releaseUploadSessionLocks: MaterialUploadStorageLockRelease | null = null;
|
||
let completed = false;
|
||
let zipFilePath: string | null = null;
|
||
const uploadedPreviewUrls: string[] = [];
|
||
const uploadIds = assets.map((asset) => String(asset?.uploadId || '').trim());
|
||
let resolved: Awaited<ReturnType<typeof resolveOwnedTaskMaterialSourceFiles>> | null = null;
|
||
|
||
try {
|
||
const initialMaterial = await prisma.material.findUnique({
|
||
where: { id: materialId },
|
||
select: {
|
||
id: true,
|
||
userId: true,
|
||
materialType: true,
|
||
taskStatus: true,
|
||
deletedAt: true,
|
||
},
|
||
});
|
||
if (!initialMaterial || initialMaterial.deletedAt) {
|
||
throwCompletionError('素材不存在', 404);
|
||
}
|
||
if (initialMaterial.userId !== input.userId) {
|
||
throwCompletionError('无权限操作此素材', 403);
|
||
}
|
||
if (initialMaterial.materialType !== input.materialType) {
|
||
throwCompletionError('素材类型不匹配', 400);
|
||
}
|
||
releaseCompletionLock = await acquireTaskMaterialCompletionLock(materialId);
|
||
releaseUploadSessionLocks = await acquireTaskMaterialUploadSessionLocks(uploadIds);
|
||
|
||
// 拿到跨容器锁后必须重新读取,前一次查询只用于鉴权,不能作为状态依据。
|
||
const material = await prisma.material.findUnique({
|
||
where: { id: materialId },
|
||
select: {
|
||
id: true,
|
||
userId: true,
|
||
materialType: true,
|
||
productModel: true,
|
||
taskStatus: true,
|
||
previewUrl: true,
|
||
zipFilePath: true,
|
||
deletedAt: true,
|
||
assets: {
|
||
where: { assetType: { in: ['preview', 'zip'] } },
|
||
select: { assetType: true },
|
||
},
|
||
},
|
||
});
|
||
if (!material || material.deletedAt) {
|
||
throwCompletionError('素材不存在', 404);
|
||
}
|
||
if (material.userId !== input.userId || material.materialType !== input.materialType) {
|
||
throwCompletionError('素材归属或类型已变化,请刷新后重试', 409);
|
||
}
|
||
if (material.taskStatus === 'completed') {
|
||
const assetTypes = new Set(material.assets.map((asset) => asset.assetType));
|
||
if (
|
||
!String(material.previewUrl || '').trim()
|
||
|| !String(material.zipFilePath || '').trim()
|
||
|| !assetTypes.has('preview')
|
||
|| !assetTypes.has('zip')
|
||
) {
|
||
throwCompletionError('素材完成记录不完整,服务器已保留文件,请联系管理员核查', 409);
|
||
}
|
||
releaseUploadSessionLocks.assertHeld();
|
||
await cleanupTempFiles([], uploadIds).catch((cleanupError) => {
|
||
console.warn(`[${input.materialType}] 幂等请求确认已完成,但源会话清理失败:`, cleanupError);
|
||
});
|
||
return { materialId, alreadyCompleted: true };
|
||
}
|
||
const isLegacyEmptyUpload = (
|
||
material.taskStatus == null
|
||
&& !material.previewUrl
|
||
&& !material.zipFilePath
|
||
);
|
||
if (material.taskStatus !== 'uploading' && !isLegacyEmptyUpload) {
|
||
throwCompletionError('该素材正在处理或状态异常,请稍后刷新', 409);
|
||
}
|
||
|
||
const resolvedSources = await resolveOwnedTaskMaterialSourceFiles({
|
||
assets,
|
||
userId: input.userId,
|
||
});
|
||
resolved = resolvedSources;
|
||
|
||
const zipInfo = await createZipFromFiles(
|
||
resolvedSources.files,
|
||
materialId,
|
||
material.productModel || input.fallbackProductModel,
|
||
input.materialType,
|
||
'all',
|
||
undefined,
|
||
undefined,
|
||
TASK_MATERIAL_ZIP_TIMEOUT_MS,
|
||
TASK_MATERIAL_ZIP_COMPRESSION_LEVEL,
|
||
);
|
||
zipFilePath = zipInfo.filePath;
|
||
|
||
const previewUrls = await generatePreviewImages(
|
||
assets.map((asset, index) => ({
|
||
uploadId: String(asset.uploadId || '').trim(),
|
||
fileName: String(asset.fileName || `image-${index + 1}`),
|
||
filePath: resolvedSources.files[index]?.path,
|
||
})),
|
||
input.materialType,
|
||
input.compressOptions,
|
||
(previewUrl) => uploadedPreviewUrls.push(previewUrl),
|
||
);
|
||
if (uploadedPreviewUrls.length === 0) {
|
||
uploadedPreviewUrls.push(...previewUrls);
|
||
}
|
||
if (uploadedPreviewUrls.length !== assets.length) {
|
||
throw new Error('预览图生成数量不完整,本次素材未保存');
|
||
}
|
||
|
||
releaseCompletionLock.assertHeld();
|
||
releaseUploadSessionLocks.assertHeld();
|
||
await saveTaskMaterialAssetsAndComplete({
|
||
materialId,
|
||
previewUrls: uploadedPreviewUrls,
|
||
zipInfo,
|
||
taskNo: input.taskNo,
|
||
dingtalkApprovalNo: input.dingtalkApprovalNo,
|
||
userId: input.userId,
|
||
materialType: input.materialType,
|
||
});
|
||
releaseCompletionLock.assertHeld();
|
||
releaseUploadSessionLocks.assertHeld();
|
||
completed = true;
|
||
|
||
// completed 已由数据库确认;清理失败只能记录告警,不能反向删除正式资产。
|
||
await cleanupTempFiles(resolved.tempFiles, resolved.uploadIds).catch((cleanupError) => {
|
||
console.warn(`[${input.materialType}] 上传已完成,但源会话清理失败:`, cleanupError);
|
||
});
|
||
|
||
await createMaterialLog({
|
||
materialId,
|
||
userId: input.userId,
|
||
action: 'UPLOAD',
|
||
changes: {
|
||
uploadType: { oldValue: null, newValue: input.materialType },
|
||
previewCount: { oldValue: 0, newValue: uploadedPreviewUrls.length },
|
||
zipFileName: { oldValue: null, newValue: zipInfo.fileName },
|
||
},
|
||
}).catch((logError) => {
|
||
console.error(`[${input.materialType}] 上传已完成,但日志记录失败:`, logError);
|
||
});
|
||
|
||
return { materialId };
|
||
} catch (error) {
|
||
if (releaseCompletionLock && !completed) {
|
||
const completionState = await inspectTaskMaterialCompletionAfterError(materialId);
|
||
if (completionState.state === 'completed') {
|
||
releaseUploadSessionLocks?.assertHeld();
|
||
await cleanupTempFiles(resolved?.tempFiles || [], resolved?.uploadIds || uploadIds).catch((cleanupError) => {
|
||
console.warn(`[${input.materialType}] 数据库已完成,但源会话清理失败:`, cleanupError);
|
||
});
|
||
return {
|
||
materialId,
|
||
warning: '素材已保存,但响应确认阶段出现异常',
|
||
};
|
||
}
|
||
if (completionState.state === 'completed_incomplete') {
|
||
console.error(`[${input.materialType}] 数据库标记已完成,但预览/ZIP/资产证据不完整:`, completionState);
|
||
throw new TaskImageMaterialCompletionError(
|
||
'素材完成记录不完整,服务器已保留文件,请联系管理员核查',
|
||
409,
|
||
);
|
||
}
|
||
if (completionState.state === 'unknown') {
|
||
console.error(`[${input.materialType}] 无法确认数据库最终状态,保留全部文件:`, completionState.error);
|
||
throw new TaskImageMaterialCompletionError(
|
||
'完成状态暂时无法确认,服务器已保留上传文件,请勿重复提交',
|
||
500,
|
||
);
|
||
}
|
||
|
||
// 只有仍处于可重试上传态时,才删除本次生成的预览和 ZIP。
|
||
// 源文件、分片、上传会话和素材记录全部保留,用户无需重新上传。
|
||
if (
|
||
completionState.state === 'other'
|
||
&& (completionState.taskStatus === 'uploading' || completionState.taskStatus == null)
|
||
) {
|
||
releaseCompletionLock.assertHeld();
|
||
await cleanupOnError(
|
||
uploadedPreviewUrls,
|
||
zipFilePath,
|
||
[],
|
||
null,
|
||
[],
|
||
false,
|
||
);
|
||
} else {
|
||
console.warn(`[${input.materialType}] 素材状态已由其它流程接管,保留全部文件:`, completionState);
|
||
}
|
||
}
|
||
|
||
if (
|
||
error instanceof TaskImageMaterialCompletionError
|
||
|| error instanceof TaskMaterialSourceValidationError
|
||
|| error instanceof MaterialUploadRequestError
|
||
) {
|
||
throw error;
|
||
}
|
||
throw new TaskImageMaterialCompletionError(
|
||
error instanceof Error ? error.message : '完成上传失败',
|
||
500,
|
||
);
|
||
} finally {
|
||
await releaseUploadSessionLocks?.();
|
||
await releaseCompletionLock?.();
|
||
}
|
||
}
|