import { randomUUID } from 'crypto' import { lstat, open, readFile, readdir, rename, rm } from 'fs/promises' import path from 'path' import { MaterialUploadRequestError } from '@/lib/material-upload-request-guard' import { getUploadRootDir } from '@/lib/upload-paths' import { ensureSafeDirectoryWithinRoot, resolveStrictChildPath, } from '@/lib/path-security' const SAFE_UPLOAD_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ const COMPLETED_SEAL_RETENTION_MS = 24 * 60 * 60 * 1000 interface StoredUploadCompletionSeal { version: 1 state: 'sealing' | 'completed' | 'cancelled' token: string createdAt: number completedAt?: number cancelledAt?: number } export type UploadSessionSealState = StoredUploadCompletionSeal['state'] export interface UploadSessionSealSnapshot { state: UploadSessionSealState createdAt: number completedAt?: number cancelledAt?: number } export interface UploadCompletionSealHandle { uploadId: string token: string } export type BeginUploadCompletionSealResult = | { state: 'completed' } | { state: 'sealing'; handle: UploadCompletionSealHandle } /** * 必须在 material upload 的跨容器独占锁内调用。 * * sealing 标记会先于最终文件发布而写入共享 uploads。若进程在原子 rename 后、 * 写 completed 前崩溃,迟到分片仍会被挡住;下一次 complete 取得独占锁后可 * 根据最终文件继续收口,不会把旧会话复活。 */ export async function beginUploadCompletionSeal( uploadId: string, ): Promise { const normalizedUploadId = normalizeUploadId(uploadId) const existing = await readStoredSeal(normalizedUploadId) if (existing?.state === 'cancelled') { throw createUploadSessionCancelledError() } if (existing?.state === 'completed') { return { state: 'completed' } } const token = randomUUID() await writeStoredSeal(normalizedUploadId, { version: 1, state: 'sealing', token, createdAt: Date.now(), }) return { state: 'sealing', handle: { uploadId: normalizedUploadId, token }, } } export async function completeUploadCompletionSeal( handle: UploadCompletionSealHandle, ): Promise { const uploadId = normalizeUploadId(handle.uploadId) const current = await readStoredSeal(uploadId) if (current?.state === 'cancelled') { throw createUploadSessionCancelledError() } if (!current || current.state !== 'sealing' || current.token !== handle.token) { throw new MaterialUploadRequestError( '上传完成标记已变化,已停止发布结果', 409, 'UPLOAD_SESSION_SEAL_CHANGED', ) } await writeStoredSeal(uploadId, { ...current, state: 'completed', completedAt: Date.now(), }) } export async function abortUploadCompletionSeal( handle: UploadCompletionSealHandle, ): Promise { const uploadId = normalizeUploadId(handle.uploadId) const current = await readStoredSeal(uploadId).catch(() => null) if (!current || current.state !== 'sealing' || current.token !== handle.token) { return } const sealPath = await resolveSealPath(uploadId, false) if (sealPath) { await rm(sealPath, { force: true }) } } /** * 分片在短共享锁内、发布 chunk-N 之前调用。任何 sealing/completed 标记都 * 代表完成流程已经接管该 uploadId;此时必须删除本请求 partial 并拒绝更新 * 内存/Redis 会话。 */ export async function assertUploadSessionAcceptingChunks(uploadId: string): Promise { const normalizedUploadId = normalizeUploadId(uploadId) const seal = await readStoredSeal(normalizedUploadId) if (seal?.state === 'cancelled') { throw createUploadSessionCancelledError() } if (seal) { throw new MaterialUploadRequestError( '上传会话已经进入完成阶段,迟到分片已拒绝', 409, 'UPLOAD_SESSION_SEALED', ) } } /** * cleanup 在 material upload 独占锁内调用。cancelled 是持久终态:一旦写入, * complete 和迟到 chunk 都只能结构化失败,不能把已经取消的 uploadId 复活。 */ export async function cancelUploadSessionSeal( uploadId: string, ): Promise<{ state: 'cancelled' } | { state: 'completed' }> { const normalizedUploadId = normalizeUploadId(uploadId) const existing = await readStoredSeal(normalizedUploadId) if (existing?.state === 'completed') { return { state: 'completed' } } if (existing?.state === 'cancelled') { return { state: 'cancelled' } } const now = Date.now() await writeStoredSeal(normalizedUploadId, { version: 1, state: 'cancelled', token: randomUUID(), createdAt: existing?.createdAt ?? now, cancelledAt: now, }) return { state: 'cancelled' } } export async function readUploadSessionSeal( uploadId: string, ): Promise { const seal = await readStoredSeal(normalizeUploadId(uploadId)) return seal ? { state: seal.state, createdAt: seal.createdAt, completedAt: seal.completedAt, cancelledAt: seal.cancelledAt, } : null } export async function cleanupExpiredUploadCompletionSeals( now = Date.now(), ): Promise { const sealDir = await resolveSealDirectory(false) if (!sealDir) { return 0 } let names: string[] try { names = await readdir(sealDir) } catch (error: any) { if (error?.code === 'ENOENT') { return 0 } throw error } let removed = 0 for (const name of names) { if (!name.endsWith('.json')) { continue } const uploadId = name.slice(0, -'.json'.length) if (!SAFE_UPLOAD_ID_PATTERN.test(uploadId)) { continue } const sealPath = resolveStrictChildPath(sealDir, name) if (!sealPath) { continue } try { const stats = await lstat(sealPath) if ( stats.isSymbolicLink() || !stats.isFile() || now - stats.mtimeMs < COMPLETED_SEAL_RETENTION_MS ) { continue } const seal = await readStoredSeal(uploadId) if (!seal || seal.state === 'sealing') { continue } // 终态标记只有在对应临时目录已经不存在后才可过期。否则先删 seal 会让 // 下一轮自动清理把仍待业务消费的 completed merged 文件误判为孤儿。 const uploadTempPath = resolveStrictChildPath( getUploadRootDir(), 'temp', uploadId, ) if (uploadTempPath) { try { await lstat(uploadTempPath) continue } catch (error: any) { if (error?.code !== 'ENOENT') { throw error } } } await rm(sealPath, { force: true }) removed += 1 } catch (error: any) { if (error?.code !== 'ENOENT') { console.warn('[UploadSessionSeal] 清理过期完成标记失败:', { sealPath, error }) } } } return removed } function normalizeUploadId(uploadId: string): string { const normalized = String(uploadId || '').trim() if (!SAFE_UPLOAD_ID_PATTERN.test(normalized)) { throw new MaterialUploadRequestError('uploadId 无效', 400, 'UPLOAD_ID_INVALID') } return normalized } async function resolveSealDirectory(create: boolean): Promise { const uploadRoot = getUploadRootDir() if (create) { return ensureSafeDirectoryWithinRoot( uploadRoot, '.locks', 'completed-upload-sessions', ) } const sealDir = resolveStrictChildPath(uploadRoot, '.locks', 'completed-upload-sessions') if (!sealDir) { return null } try { const stats = await lstat(sealDir) if (stats.isSymbolicLink() || !stats.isDirectory()) { throw new MaterialUploadRequestError( '上传完成标记目录异常,已停止写入', 503, 'UPLOAD_SESSION_SEAL_INVALID', ) } return sealDir } catch (error: any) { if (error?.code === 'ENOENT') { return null } throw error } } async function resolveSealPath(uploadId: string, createDirectory: boolean): Promise { const sealDir = await resolveSealDirectory(createDirectory) return sealDir ? resolveStrictChildPath(sealDir, `${uploadId}.json`) : null } async function readStoredSeal(uploadId: string): Promise { const sealPath = await resolveSealPath(uploadId, false) if (!sealPath) { return null } try { const stats = await lstat(sealPath) if (stats.isSymbolicLink() || !stats.isFile() || stats.size > 4096) { throw new MaterialUploadRequestError( '上传完成标记异常,已停止写入', 503, 'UPLOAD_SESSION_SEAL_INVALID', ) } const parsed = JSON.parse(await readFile(sealPath, 'utf8')) as Partial if ( parsed.version !== 1 || ( parsed.state !== 'sealing' && parsed.state !== 'completed' && parsed.state !== 'cancelled' ) || typeof parsed.token !== 'string' || !parsed.token || !Number.isFinite(parsed.createdAt) || (parsed.state === 'cancelled' && !Number.isFinite(parsed.cancelledAt)) ) { throw new MaterialUploadRequestError( '上传完成标记异常,已停止写入', 503, 'UPLOAD_SESSION_SEAL_INVALID', ) } return parsed as StoredUploadCompletionSeal } catch (error: any) { if (error?.code === 'ENOENT') { return null } if (error instanceof MaterialUploadRequestError) { throw error } throw new MaterialUploadRequestError( '无法确认上传会话完成状态,已停止写入', 503, 'UPLOAD_SESSION_SEAL_UNAVAILABLE', ) } } function createUploadSessionCancelledError(): MaterialUploadRequestError { return new MaterialUploadRequestError( '上传会话已取消,不能继续上传或完成', 409, 'UPLOAD_SESSION_CANCELLED', ) } async function writeStoredSeal( uploadId: string, seal: StoredUploadCompletionSeal, ): Promise { const sealPath = await resolveSealPath(uploadId, true) if (!sealPath) { throw new MaterialUploadRequestError( '无法创建上传完成标记,已停止写入', 503, 'UPLOAD_SESSION_SEAL_UNAVAILABLE', ) } const partialPath = `${sealPath}.partial-${randomUUID()}` let fileHandle: Awaited> | null = null try { fileHandle = await open(partialPath, 'wx', 0o600) await fileHandle.writeFile(JSON.stringify(seal), 'utf8') await fileHandle.sync() await fileHandle.close() fileHandle = null // 生产 Linux/NAS 上 rename 会原子替换旧标记,不能先 rm 留出“无标记” // 窗口;否则进程恰在两步之间退出时,迟到分片可能重新发布。Windows 仅 // 用于本地/Jest,且由同进程读写锁保护,需要先移除目标以兼容其语义。 if (process.platform === 'win32') { await rm(sealPath, { force: true }) } await rename(partialPath, sealPath) } catch (error) { if (fileHandle) { await fileHandle.close().catch(() => undefined) } await rm(partialPath, { force: true }).catch(() => undefined) if (error instanceof MaterialUploadRequestError) { throw error } throw new MaterialUploadRequestError( '无法持久化上传完成标记,已停止写入', 503, 'UPLOAD_SESSION_SEAL_UNAVAILABLE', ) } }