import { constants as fsConstants } from 'fs' import { copyFile, lstat, mkdir, rm } from 'fs/promises' import path from 'path' import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock' import { normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security' import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' import { getUploadRootDir } from '@/lib/upload-paths' import { getOwnedUploadSession, getUploadedFile, isSafeUploadId, } from '@/lib/upload-sessions' const REQUEST_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i const STAGING_FOLDER = 'task-detail-change-requests' export type TaskDetailStagedFile = { relativePath: string fileName: string fileSize: number } function getStagingRoot() { return path.join(getUploadRootDir(), STAGING_FOLDER) } function getRequestDirectory(requestId: string) { if (!REQUEST_ID_PATTERN.test(String(requestId || '').trim())) { throw new Error('图片变更申请标识无效') } const directory = resolveStrictChildPath(getStagingRoot(), requestId) if (!directory) throw new Error('图片变更暂存目录无效') return directory } export async function withTaskDetailStagedUploads(input: { requestId: string ownerUserId: string uploadIds: string[] commit: (files: Map) => Promise }): Promise { const uploadIds = Array.from(new Set( input.uploadIds.map((value) => String(value || '').trim()).filter(isSafeUploadId), )) if (uploadIds.length !== input.uploadIds.length) { throw new Error('存在无效或重复的上传会话,请重新选择图片') } const requestDirectory = getRequestDirectory(input.requestId) await mkdir(getStagingRoot(), { recursive: true }) const release = uploadIds.length > 0 ? await acquireTaskMaterialUploadSessionLocks(uploadIds) : null let committed = false try { release?.assertHeld() await mkdir(requestDirectory, { recursive: false }) const staged = new Map() for (let index = 0; index < uploadIds.length; index += 1) { release?.assertHeld() const uploadId = uploadIds[index] const session = await getOwnedUploadSession(uploadId, input.ownerUserId) if (!session) throw new Error('图片上传会话已失效,请重新选择图片') const sourcePath = await getUploadedFile(uploadId) if (!sourcePath) throw new Error('已上传图片文件缺失,请重新选择图片') const sourceStats = await lstat(sourcePath) if (!sourceStats.isFile() || sourceStats.isSymbolicLink() || sourceStats.size <= 0) { throw new Error('已上传图片文件状态异常,请重新选择图片') } const safeName = normalizeSafeFileName(session.fileName) if (!safeName) throw new Error('图片文件名无效') const storedName = `${String(index).padStart(3, '0')}-${safeName}` const targetPath = resolveStrictChildPath(requestDirectory, storedName) if (!targetPath) throw new Error('图片暂存路径无效') await copyFile(sourcePath, targetPath, fsConstants.COPYFILE_EXCL) const targetStats = await lstat(targetPath) if (!targetStats.isFile() || targetStats.isSymbolicLink() || targetStats.size !== sourceStats.size) { throw new Error('图片持久暂存校验失败') } staged.set(uploadId, { relativePath: path.posix.join(STAGING_FOLDER, input.requestId, storedName), fileName: safeName, fileSize: targetStats.size, }) } release?.assertHeld() const result = await input.commit(staged) committed = true await Promise.allSettled(uploadIds.map((uploadId) => cleanupConsumedUploadSession(uploadId))) return result } finally { try { if (release) await release() } finally { if (!committed) { await rm(requestDirectory, { recursive: true, force: true }).catch(() => undefined) } } } } export function resolveTaskDetailStagedFile( requestId: string, file: TaskDetailStagedFile, ): { trusted: true; filePath: string; fileName: string; fileSize: number } { const requestDirectory = getRequestDirectory(requestId) const expectedPrefix = `${STAGING_FOLDER}/${requestId}/` const normalized = String(file?.relativePath || '').replace(/\\/g, '/') if (!normalized.startsWith(expectedPrefix)) { throw new Error('图片审批暂存引用超出申请范围') } const baseName = normalized.slice(expectedPrefix.length) const filePath = resolveStrictChildPath(requestDirectory, baseName) if (!filePath) throw new Error('图片审批暂存引用无效') const fileName = normalizeSafeFileName(file.fileName) if (!fileName) throw new Error('图片审批暂存文件名无效') return { trusted: true, filePath, fileName, fileSize: Number(file.fileSize), } } export async function cleanupTaskDetailChangeStaging(requestId: string) { await rm(getRequestDirectory(requestId), { recursive: true, force: true }) }