// Material upload 取消/过期清理公开接口。 // 所有入口只做认证和锁外候选筛选;真实删除统一交给独占锁安全 helper。 import { lstat, readdir } from 'fs/promises' import path from 'path' import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { hasSystemAdminPermission } from '@/lib/department-permission' import { resolveStrictChildPath } from '@/lib/path-security' import { safelyCleanupMaterialUploadSession, type SafeMaterialUploadCleanupResult, } from '@/lib/temp-cleanup' import { isSafeUploadId, } from '@/lib/upload-sessions' import { getUploadRootDir } from '@/lib/upload-paths' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export const maxDuration = 300 const RATE_LIMIT_WINDOW_MS = 60 * 1000 const requestCounts = new Map() function consumeRateLimit(key: string, maxRequests: number): number | null { const now = Date.now() const current = requestCounts.get(key) if (!current || current.resetAt <= now) { requestCounts.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }) return null } if (current.count >= maxRequests) { return Math.max(1, Math.ceil((current.resetAt - now) / 1000)) } current.count += 1 return null } function rateLimitResponse(retryAfter: number) { return NextResponse.json( { error: '清理请求过于频繁,请稍后再试' }, { status: 429, headers: { 'Retry-After': String(retryAfter) } }, ) } /** * GET - 系统管理员清理单个会话,或扫描过期/孤儿目录。 */ export async function GET(req: NextRequest) { try { const session = await getServerSession(authOptions) if (!session) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } const sessionUser = session.user as any if (!hasSystemAdminPermission(sessionUser?.role, Boolean(sessionUser?.isSuperAdmin))) { return NextResponse.json({ error: '仅系统管理员可执行批量清理' }, { status: 403 }) } const retryAfter = consumeRateLimit(`bulk:${String(sessionUser?.id || 'unknown')}`, 5) if (retryAfter) { return rateLimitResponse(retryAfter) } const { searchParams } = new URL(req.url) const uploadId = String(searchParams.get('uploadId') || '').trim() const expiredOnly = searchParams.get('expiredOnly') !== 'false' const maxAgeHours = Number(searchParams.get('maxAge') || '8') const scanLimit = Number(searchParams.get('limit') || '100') if (!Number.isInteger(maxAgeHours) || maxAgeHours < 1 || maxAgeHours > 168) { return NextResponse.json({ error: 'maxAge 必须是 1 到 168 之间的整数' }, { status: 400 }) } if (!Number.isInteger(scanLimit) || scanLimit < 1 || scanLimit > 500) { return NextResponse.json({ error: 'limit 必须是 1 到 500 之间的整数' }, { status: 400 }) } if (uploadId && !isSafeUploadId(uploadId)) { return NextResponse.json({ error: 'uploadId 格式无效' }, { status: 400 }) } if (uploadId) { const cleanup = await safelyCleanupMaterialUploadSession({ uploadId, intent: 'cancel', }) return singleCleanupResponse(cleanup) } const tempBaseDir = path.join(getUploadRootDir(), 'temp') try { const stats = await lstat(tempBaseDir) if (stats.isSymbolicLink() || !stats.isDirectory()) { return NextResponse.json( { error: '临时目录状态异常,已停止清理' }, { status: 503 }, ) } } catch (error: any) { if (error?.code === 'ENOENT') { return NextResponse.json({ message: '临时目录不存在,无需清理', cleaned: 0, freedSpace: 0, skipped: 0, }) } throw error } const candidates = (await readdir(tempBaseDir)).slice(0, scanLimit) let cleaned = 0 let totalFreedSpace = 0 let skipped = 0 const errors: string[] = [] const maxAgeMs = maxAgeHours * 60 * 60 * 1000 for (const candidate of candidates) { if (!isSafeUploadId(candidate)) { continue } // 路径检查只负责候选边界;helper 会在独占锁内重新 lstat/读 meta。 if (!resolveStrictChildPath(tempBaseDir, candidate)) { skipped += 1 continue } const cleanup = await safelyCleanupMaterialUploadSession({ uploadId: candidate, intent: 'sweep', minAgeMs: maxAgeMs, allowYoungOrphaned: !expiredOnly, }) if (cleanup.success && cleanup.status === 'deleted') { cleaned += cleanup.deletedFiles totalFreedSpace += cleanup.freedSpace continue } skipped += 1 if (cleanup.status === 'unavailable') { errors.push(`${candidate}: ${cleanup.error || '状态无法确认,已保留数据'}`) } } return NextResponse.json({ message: '清理完成', cleaned, freedSpace: totalFreedSpace, freedSpaceFormatted: formatBytes(totalFreedSpace), skipped, errors: errors.length > 0 ? errors : undefined, }) } catch (error: any) { console.error('[Cleanup] GET Error:', error) return NextResponse.json( { error: '清理失败: ' + (error.message || '未知错误') }, { status: 503 }, ) } } /** * POST - 当前用户取消自己的上传。候选 owner 可在锁外读取,但 helper 会在 * 独占锁内从 Redis/内存/meta.json 重新核对,且 completed 永远不能公开删除。 */ export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) if (!session) { return NextResponse.json({ error: '请先登录' }, { status: 401 }) } const currentUserId = String((session.user as any)?.id || '').trim() if (!currentUserId) { return NextResponse.json({ error: '登录会话缺少用户标识' }, { status: 401 }) } const retryAfter = consumeRateLimit(`own:${currentUserId}`, 30) if (retryAfter) { return rateLimitResponse(retryAfter) } const body = await req.json() const uploadId = typeof body?.uploadId === 'string' ? body.uploadId.trim() : '' const existingFilePath = typeof body?.existingFilePath === 'string' ? body.existingFilePath.trim() : '' if (!uploadId && !existingFilePath) { return NextResponse.json({ error: '缺少 uploadId 或 existingFilePath' }, { status: 400 }) } if (uploadId && !isSafeUploadId(uploadId)) { return NextResponse.json({ error: 'uploadId 格式无效' }, { status: 400 }) } let effectiveUploadId = uploadId if (existingFilePath) { const derivedUploadId = deriveUploadIdCandidate(existingFilePath, uploadId || null) if (!derivedUploadId) { return NextResponse.json({ error: '合并文件路径无效' }, { status: 400 }) } effectiveUploadId = derivedUploadId } const cleanup = await safelyCleanupMaterialUploadSession({ uploadId: effectiveUploadId, intent: 'cancel', expectedOwnerId: currentUserId, }) return singleCleanupResponse(cleanup) } catch (error: any) { console.error('[Cleanup] POST Error:', error) return NextResponse.json( { error: '清理失败: ' + (error.message || '未知错误') }, { status: 503 }, ) } } function deriveUploadIdCandidate( existingFilePath: string, suppliedUploadId: string | null, ): string | null { const tempRoot = path.resolve(getUploadRootDir(), 'temp') const targetPath = path.resolve(existingFilePath) const sessionDir = path.dirname(targetPath) const derivedUploadId = path.basename(sessionDir) if ( path.dirname(sessionDir) !== tempRoot || !path.basename(targetPath).startsWith('merged-') || !isSafeUploadId(derivedUploadId) || (suppliedUploadId && suppliedUploadId !== derivedUploadId) ) { return null } return derivedUploadId } function singleCleanupResponse(cleanup: SafeMaterialUploadCleanupResult) { if (!cleanup.success) { return NextResponse.json( { error: cleanup.error || '该上传会话当前不能清理', code: cleanup.code, }, { status: cleanup.httpStatus || (cleanup.status === 'unavailable' ? 503 : 409) }, ) } return NextResponse.json({ message: cleanup.status === 'absent' ? '上传会话已清理' : '清理成功', deletedFiles: cleanup.deletedFiles, freedSpace: cleanup.freedSpace, freedSpaceFormatted: formatBytes(cleanup.freedSpace), }) } function formatBytes(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] }