gongxiangp2/lib/task-image-material-completion.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

288 lines
10 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 '@/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?.();
}
}