/** * Material upload 临时目录的统一安全清理协议。 * * 候选扫描可以在锁外进行,但任何删除决定都必须在跨容器独占锁内重新检查: * 会话、owner、目录年龄、seal 和 merged 文件。取消时先持久化 cancelled * tombstone,再清理 Redis + 当前进程内存会话;释放锁后才删除目录。 */ import { lstat, readFile, readdir, rm } from 'fs/promises' import path from 'path' import { acquireMaterialUploadStorageLock, type MaterialUploadStorageLockRelease, } from './material-upload-cross-process-lock' import { cancelUploadSessionSeal, cleanupExpiredUploadCompletionSeals, readUploadSessionSeal, type UploadSessionSealState, } from './material-upload-session-seal' import { MaterialUploadRequestError } from './material-upload-request-guard' import { normalizeSafeFileName, resolveStrictChildPath } from './path-security' import { getUploadRootDir } from './upload-paths' import { uploadSessions, type UploadSession } from './upload-sessions' const MAX_AGE_MS = 8 * 60 * 60 * 1000 const CLEANUP_INTERVAL_MS = 60 * 60 * 1000 const SAFE_UPLOAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ const REDIS_SESSION_KEY_PREFIX = 'upload:session:' const INTERNAL_CONSUMED_CLEANUP = Symbol('internal-consumed-material-upload-cleanup') let cleanupTimer: NodeJS.Timeout | null = null let isCleanupRunning = false export type SafeMaterialUploadCleanupStatus = | 'deleted' | 'absent' | 'skipped_active' | 'skipped_young' | 'protected_completed' | 'protected_sealing' | 'not_consumed' | 'owner_mismatch' | 'unavailable' export interface SafeMaterialUploadCleanupResult { status: SafeMaterialUploadCleanupStatus success: boolean deletedFiles: number freedSpace: number error?: string code?: string httpStatus?: number } export interface SafeMaterialUploadCleanupInput { uploadId: string intent: 'cancel' | 'sweep' expectedOwnerId?: string minAgeMs?: number allowYoungOrphaned?: boolean } interface InternalCleanupInput extends SafeMaterialUploadCleanupInput { intent: 'cancel' | 'sweep' capability?: typeof INTERNAL_CONSUMED_CLEANUP } interface StoredSessionEvidence { source: 'memory' | 'redis' userId: string fileName: string fileSize: number | null createdAt: number | null } interface UploadDirectorySnapshot { path: string exists: boolean mtimeMs: number | null metadata: { userId: string fileName: string fileSize: number createdAt: number } | null files: Array<{ name: string; size: number }> deletedFiles: number freedSpace: number } interface CleanupEvidence { directory: UploadDirectorySnapshot sessions: StoredSessionEvidence[] ownerId: string | null expectedFileSize: number | null activeSession: boolean ageMs: number | null hasValidMerged: boolean sealState: UploadSessionSealState | null assembledFilePath: string | null } /** * 公开取消接口和自动清理只能调用这个入口;它们没有能力进入“已消费”模式。 */ export async function safelyCleanupMaterialUploadSession( input: SafeMaterialUploadCleanupInput, ): Promise { return executeSafeCleanup(input) } /** * 仅供素材已经成功落库/转存后的服务端业务清理使用。这个入口不会写 cancelled, * 且只有 completed + 有效 merged 的会话才允许删除临时目录。公开 cleanup 路由 * 不持有 capability,不能借此绕过 completed 保护。 */ export async function cleanupConsumedUploadSession( uploadId: string, ): Promise { return executeSafeCleanup({ uploadId, intent: 'cancel', capability: INTERNAL_CONSUMED_CLEANUP, }) } /** * 兼容现有服务端素材建档调用。语义已收紧为“已消费清理”,不能用于用户取消。 * 新代码应使用 cleanupConsumedUploadSession 这个明确命名的入口。 */ export async function cleanupUploadSession(uploadId: string): Promise { const result = await cleanupConsumedUploadSession(uploadId) return result.success } /** * 启动时和每小时扫描一次。Redis/状态 I/O 任何异常都只计为跳过,绝不删目录。 */ export async function runCleanup(): Promise<{ cleaned: number failed: number freedBytes: number skippedActive: number skippedProtected: number skippedUnavailable: number }> { if (isCleanupRunning) { console.log('[TempCleanup] 清理任务正在运行,跳过') return emptyRunResult() } isCleanupRunning = true const startTime = Date.now() const result = emptyRunResult() try { const tempDir = getTempDir() try { const stats = await lstat(tempDir) if (stats.isSymbolicLink() || !stats.isDirectory()) { throw new Error('temp 路径不是安全目录') } } catch (error: any) { if (error?.code === 'ENOENT') { console.log('[TempCleanup] temp 目录不存在,跳过清理') return result } throw error } const dirs = await readdir(tempDir) const now = Date.now() console.log(`[TempCleanup] 检查 ${dirs.length} 个目录...`) for (const uploadId of dirs) { if (!SAFE_UPLOAD_ID_PATTERN.test(uploadId)) { continue } const dirPath = resolveStrictChildPath(tempDir, uploadId) if (!dirPath) { result.skippedUnavailable += 1 continue } try { // 锁外只筛候选,helper 会在独占锁内重新读取并判断年龄。 const stats = await lstat(dirPath) if (stats.isSymbolicLink() || !stats.isDirectory()) { result.skippedUnavailable += 1 continue } if (now - stats.mtimeMs < MAX_AGE_MS) { continue } const cleanup = await safelyCleanupMaterialUploadSession({ uploadId, intent: 'sweep', minAgeMs: MAX_AGE_MS, }) applyRunCleanupResult(result, cleanup) } catch (error) { result.failed += 1 console.warn(`[TempCleanup] 处理目录失败: ${uploadId}`, error) } } // terminal seal 至少保留 24 小时;sealing 永不由这里自动删除。 try { const cleanedSeals = await cleanupExpiredUploadCompletionSeals(now) if (cleanedSeals > 0) { console.log(`[TempCleanup] 已清理 ${cleanedSeals} 个过期上传终态标记`) } } catch (error) { console.warn('[TempCleanup] 清理上传终态标记失败:', error) } const duration = Date.now() - startTime const freedMB = (result.freedBytes / 1024 / 1024).toFixed(2) console.log( `[TempCleanup] 清理完成: 删除 ${result.cleaned} 个目录,释放 ${freedMB} MB,` + `跳过活跃 ${result.skippedActive} 个,保护完成态 ${result.skippedProtected} 个,` + `状态不确定 ${result.skippedUnavailable} 个,耗时 ${duration}ms`, ) return result } finally { isCleanupRunning = false } } export function startCleanupScheduler(): void { if (cleanupTimer) { console.log('[TempCleanup] 定时任务已在运行') return } console.log('[TempCleanup] 启动定时清理任务(每小时执行)') runCleanup().catch((error) => { console.error('[TempCleanup] 启动时清理失败:', error) }) cleanupTimer = setInterval(() => { runCleanup().catch((error) => { console.error('[TempCleanup] 定时清理失败:', error) }) }, CLEANUP_INTERVAL_MS) cleanupTimer.unref() } export function stopCleanupScheduler(): void { if (cleanupTimer) { clearInterval(cleanupTimer) cleanupTimer = null console.log('[TempCleanup] 定时清理任务已停止') } } async function executeSafeCleanup( input: InternalCleanupInput, ): Promise { const uploadId = String(input.uploadId || '').trim() if (!SAFE_UPLOAD_ID_PATTERN.test(uploadId)) { return unavailableResult('uploadId 无效', 'UPLOAD_ID_INVALID', 400) } let releaseLock: MaterialUploadStorageLockRelease | null = null let directoryToDelete: UploadDirectorySnapshot | null = null let assembledFileToDelete: string | null = null let decision: SafeMaterialUploadCleanupResult | null = null try { releaseLock = await acquireMaterialUploadStorageLock('exclusive') releaseLock.assertHeld() const evidence = await readCleanupEvidence(uploadId) releaseLock.assertHeld() decision = decideCleanup(input, evidence) if (!decision) { const isConsumedCleanup = input.capability === INTERNAL_CONSUMED_CLEANUP if (!isConsumedCleanup) { const tombstone = await cancelUploadSessionSeal(uploadId) if (tombstone.state === 'completed') { decision = protectedResult('protected_completed') } releaseLock.assertHeld() } if (!decision) { // Redis 必须先成功删除,再同步移除当前进程内存副本。Redis 异常时内存和 // 目录都保持不动;cancelled tombstone 留下用于阻止迟到 chunk/complete。 await deleteSessionStateFailClosed(uploadId) releaseLock.assertHeld() directoryToDelete = evidence.directory assembledFileToDelete = isConsumedCleanup ? evidence.assembledFilePath : null } } } catch (error) { console.warn('[TempCleanup] 安全清理状态确认失败,已保留目录:', { uploadId, error, }) decision = resultFromError(error) } finally { if (releaseLock) { try { await releaseLock() } catch (error) { console.warn('[TempCleanup] 释放上传存储锁失败,已保留目录:', { uploadId, error, }) directoryToDelete = null assembledFileToDelete = null decision ||= resultFromError(error) } } } if (decision) { return decision } if (!directoryToDelete?.exists && !assembledFileToDelete) { return successResult('absent', 0, 0) } try { if (assembledFileToDelete) { await rm(assembledFileToDelete, { force: true }) } if (directoryToDelete?.exists) { await rm(directoryToDelete.path, { recursive: true, force: true }) } console.log(`[TempCleanup] 已安全清理上传会话: ${uploadId}`) return successResult( 'deleted', (directoryToDelete?.deletedFiles || 0) + (assembledFileToDelete ? 1 : 0), directoryToDelete?.freedSpace || 0, ) } catch (error) { console.warn('[TempCleanup] tombstone 已写入,但目录删除失败并已保留:', { uploadId, error, }) return resultFromError(error) } } function decideCleanup( input: InternalCleanupInput, evidence: CleanupEvidence, ): SafeMaterialUploadCleanupResult | null { const expectedOwnerId = String(input.expectedOwnerId || '').trim() if (expectedOwnerId && evidence.ownerId !== expectedOwnerId) { return skippedResult( 'owner_mismatch', '上传会话不存在或不属于当前用户', 'UPLOAD_SESSION_NOT_OWNED', 404, ) } const isConsumedCleanup = input.capability === INTERNAL_CONSUMED_CLEANUP if (isConsumedCleanup) { if ( !evidence.directory.exists && evidence.sessions.length === 0 ) { return successResult('absent', 0, 0) } if (evidence.sealState !== 'completed') { return skippedResult( 'not_consumed', '未确认 completed 终态,已保留上传临时目录', 'UPLOAD_NOT_CONFIRMED_CONSUMED', 409, ) } return null } return decideCancellation(input, evidence) } function decideCancellation( input: SafeMaterialUploadCleanupInput, evidence: CleanupEvidence, ): SafeMaterialUploadCleanupResult | null { const sealState = evidence.sealState if (sealState === 'completed') { return protectedResult('protected_completed') } if ( sealState === 'sealing' && (input.intent === 'sweep' || evidence.hasValidMerged) ) { return protectedResult('protected_sealing') } if (!sealState && evidence.hasValidMerged) { return protectedResult('protected_sealing') } if (input.intent === 'sweep') { if (evidence.activeSession) { return skippedResult('skipped_active') } const minAgeMs = input.minAgeMs ?? MAX_AGE_MS const isOldEnough = evidence.ageMs !== null && evidence.ageMs >= minAgeMs if (!isOldEnough && !(input.allowYoungOrphaned && !evidence.activeSession)) { return skippedResult('skipped_young') } } if ( !evidence.directory.exists && evidence.sessions.length === 0 && !evidence.ownerId ) { return successResult('absent', 0, 0) } return null } async function readCleanupEvidence(uploadId: string): Promise { const directory = await readUploadDirectory(uploadId) const sessions = await readSessionEvidenceFailClosed(uploadId) const ownerIds = new Set() if (directory.metadata?.userId) { ownerIds.add(directory.metadata.userId) } sessions.forEach((session) => { if (session.userId) { ownerIds.add(session.userId) } }) if (ownerIds.size > 1) { throw new MaterialUploadRequestError( '上传会话 owner 状态冲突,已保留数据', 503, 'UPLOAD_CLEANUP_OWNER_CONFLICT', ) } const fileSizes = new Set() if (directory.metadata?.fileSize) { fileSizes.add(directory.metadata.fileSize) } sessions.forEach((session) => { if (session.fileSize) { fileSizes.add(session.fileSize) } }) if (fileSizes.size > 1) { throw new MaterialUploadRequestError( '上传会话文件大小状态冲突,已保留数据', 503, 'UPLOAD_CLEANUP_SESSION_CONFLICT', ) } const fileNames = new Set() if (directory.metadata?.fileName) { fileNames.add(directory.metadata.fileName) } sessions.forEach((session) => fileNames.add(session.fileName)) if (fileNames.size > 1) { throw new MaterialUploadRequestError( '上传会话文件名状态冲突,已保留数据', 503, 'UPLOAD_CLEANUP_SESSION_CONFLICT', ) } const expectedFileSize = fileSizes.values().next().value as number | undefined const hasValidTempMerged = directory.files.some((file) => ( file.name.startsWith('merged-') && file.size > 0 && (expectedFileSize === undefined || file.size === expectedFileSize) )) const safeFileName = fileNames.values().next().value as string | undefined const assembledFilePath = safeFileName ? await inspectAssembledFile(uploadId, safeFileName, expectedFileSize) : null const createdAt = directory.metadata?.createdAt ?? directory.mtimeMs const ageMs = createdAt === null ? null : Date.now() - createdAt if (ageMs !== null && (!Number.isFinite(ageMs) || ageMs < -5 * 60 * 1000)) { throw new MaterialUploadRequestError( '上传目录时间状态异常,已保留数据', 503, 'UPLOAD_CLEANUP_AGE_INVALID', ) } const seal = await readUploadSessionSeal(uploadId) return { directory, sessions, ownerId: ownerIds.values().next().value as string | undefined || null, expectedFileSize: expectedFileSize ?? null, activeSession: sessions.length > 0, ageMs, hasValidMerged: hasValidTempMerged || assembledFilePath !== null, sealState: seal?.state ?? null, assembledFilePath, } } async function readUploadDirectory(uploadId: string): Promise { const tempDir = getTempDir() const dirPath = resolveStrictChildPath(tempDir, uploadId) if (!dirPath) { throw new MaterialUploadRequestError( '上传临时目录路径无效', 503, 'UPLOAD_CLEANUP_PATH_INVALID', ) } let directoryStats: Awaited> try { directoryStats = await lstat(dirPath) } catch (error: any) { if (error?.code === 'ENOENT') { return { path: dirPath, exists: false, mtimeMs: null, metadata: null, files: [], deletedFiles: 0, freedSpace: 0, } } throw error } if (directoryStats.isSymbolicLink() || !directoryStats.isDirectory()) { throw new MaterialUploadRequestError( '上传临时目录类型异常,已保留数据', 503, 'UPLOAD_CLEANUP_PATH_INVALID', ) } const names = await readdir(dirPath) const files: Array<{ name: string; size: number }> = [] let freedSpace = 0 for (const name of names) { const filePath = resolveStrictChildPath(dirPath, name) if (!filePath) { throw new MaterialUploadRequestError( '上传临时文件路径异常,已保留数据', 503, 'UPLOAD_CLEANUP_PATH_INVALID', ) } const stats = await lstat(filePath) if (stats.isSymbolicLink() || !stats.isFile()) { throw new MaterialUploadRequestError( '上传临时目录包含未知文件类型,已保留数据', 503, 'UPLOAD_CLEANUP_FILE_INVALID', ) } files.push({ name, size: stats.size }) freedSpace += stats.size } const metadata = names.includes('meta.json') ? await readMetadataFailClosed(path.join(dirPath, 'meta.json')) : null return { path: dirPath, exists: true, mtimeMs: directoryStats.mtimeMs, metadata, files, deletedFiles: files.length, freedSpace, } } async function readMetadataFailClosed(metadataPath: string): Promise<{ userId: string fileName: string fileSize: number createdAt: number }> { try { const parsed = JSON.parse(await readFile(metadataPath, 'utf8')) as Record const userId = typeof parsed.userId === 'string' ? parsed.userId.trim() : '' const fileName = normalizeSafeFileName( typeof parsed.fileName === 'string' ? parsed.fileName : '', ) const fileSize = Number(parsed.fileSize) const createdAt = Number(parsed.createdAt) if ( !userId || !fileName || !Number.isFinite(fileSize) || fileSize <= 0 || !Number.isFinite(createdAt) || createdAt <= 0 ) { throw new Error('meta.json 字段无效') } return { userId, fileName, fileSize, createdAt } } catch (error) { throw new MaterialUploadRequestError( `无法确认上传目录元数据,已保留数据: ${error instanceof Error ? error.message : '未知错误'}`, 503, 'UPLOAD_CLEANUP_METADATA_UNAVAILABLE', ) } } async function readSessionEvidenceFailClosed( uploadId: string, ): Promise { const sessions: StoredSessionEvidence[] = [] const memorySession = uploadSessions.get(uploadId) if (memorySession) { sessions.push(normalizeSessionEvidence(memorySession, 'memory')) } if (process.env.USE_REDIS === 'true') { try { const { redis } = await import('./upload-sessions-redis') const raw = await redis.get(`${REDIS_SESSION_KEY_PREFIX}${uploadId}`) if (raw) { const parsed = JSON.parse(raw) as UploadSession sessions.push(normalizeSessionEvidence(parsed, 'redis')) } } catch (error) { throw new MaterialUploadRequestError( `无法确认 Redis 上传会话,已保留数据: ${error instanceof Error ? error.message : '未知错误'}`, 503, 'UPLOAD_CLEANUP_REDIS_UNAVAILABLE', ) } } return sessions } function normalizeSessionEvidence( session: Partial, source: StoredSessionEvidence['source'], ): StoredSessionEvidence { const userId = typeof session.userId === 'string' ? session.userId.trim() : '' const fileName = normalizeSafeFileName( typeof session.fileName === 'string' ? session.fileName : '', ) const fileSize = Number(session.fileSize) const createdAt = Number(session.createdAt) if ( !userId || !fileName || !Number.isFinite(fileSize) || fileSize <= 0 || !Number.isFinite(createdAt) || createdAt <= 0 ) { throw new MaterialUploadRequestError( '上传会话内容异常,已保留数据', 503, 'UPLOAD_CLEANUP_SESSION_INVALID', ) } return { source, userId, fileName, fileSize, createdAt } } async function inspectAssembledFile( uploadId: string, fileName: string, expectedFileSize?: number, ): Promise { const assembledDir = resolveStrictChildPath(getUploadRootDir(), 'assembled') const assembledPath = assembledDir ? resolveStrictChildPath(assembledDir, `${uploadId}-${fileName}`) : null if (!assembledPath) { throw new MaterialUploadRequestError( '上传合并文件路径异常,已保留数据', 503, 'UPLOAD_CLEANUP_PATH_INVALID', ) } try { const stats = await lstat(assembledPath) if ( stats.isSymbolicLink() || !stats.isFile() || stats.size <= 0 || (expectedFileSize !== undefined && stats.size !== expectedFileSize) ) { throw new MaterialUploadRequestError( '上传合并文件状态异常,已保留数据', 503, 'UPLOAD_CLEANUP_MERGED_INVALID', ) } return assembledPath } catch (error: any) { if (error?.code === 'ENOENT') { return null } throw error } } async function deleteSessionStateFailClosed(uploadId: string): Promise { if (process.env.USE_REDIS === 'true') { try { const { redis } = await import('./upload-sessions-redis') await redis.del(`${REDIS_SESSION_KEY_PREFIX}${uploadId}`) } catch (error) { throw new MaterialUploadRequestError( `无法删除 Redis 上传会话,已保留目录: ${error instanceof Error ? error.message : '未知错误'}`, 503, 'UPLOAD_CLEANUP_REDIS_UNAVAILABLE', ) } } uploadSessions.delete(uploadId) } function applyRunCleanupResult( run: ReturnType, cleanup: SafeMaterialUploadCleanupResult, ): void { switch (cleanup.status) { case 'deleted': run.cleaned += 1 run.freedBytes += cleanup.freedSpace break case 'skipped_active': run.skippedActive += 1 break case 'protected_completed': case 'protected_sealing': run.skippedProtected += 1 break case 'unavailable': run.skippedUnavailable += 1 break case 'absent': case 'skipped_young': case 'not_consumed': case 'owner_mismatch': break default: run.failed += 1 } } function emptyRunResult() { return { cleaned: 0, failed: 0, freedBytes: 0, skippedActive: 0, skippedProtected: 0, skippedUnavailable: 0, } } function getTempDir(): string { return path.join(getUploadRootDir(), 'temp') } function successResult( status: 'deleted' | 'absent', deletedFiles: number, freedSpace: number, ): SafeMaterialUploadCleanupResult { return { status, success: true, deletedFiles, freedSpace } } function protectedResult( status: 'protected_completed' | 'protected_sealing', ): SafeMaterialUploadCleanupResult { return skippedResult( status, status === 'protected_completed' ? '上传会话已经完成,公开清理不得删除合并文件' : '上传会话正在完成且已有有效合并文件,已保留数据', status === 'protected_completed' ? 'UPLOAD_SESSION_COMPLETED' : 'UPLOAD_SESSION_SEALING', 409, ) } function skippedResult( status: Exclude, error?: string, code?: string, httpStatus?: number, ): SafeMaterialUploadCleanupResult { return { status, success: false, deletedFiles: 0, freedSpace: 0, error, code, httpStatus, } } function unavailableResult( error: string, code = 'UPLOAD_CLEANUP_STATE_UNAVAILABLE', httpStatus = 503, ): SafeMaterialUploadCleanupResult { return { status: 'unavailable', success: false, deletedFiles: 0, freedSpace: 0, error, code, httpStatus, } } function resultFromError(error: unknown): SafeMaterialUploadCleanupResult { if (error instanceof MaterialUploadRequestError) { return unavailableResult(error.message, error.code, error.status) } return unavailableResult( error instanceof Error ? error.message : '无法确认清理状态,已保留数据', ) }