356 lines
11 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.

// 清理过期或失败的临时上传文件
// 用于清理:1. 过期的上传会话对应的临时文件 2. 失败的上传临时文件 3. 用户取消上传的临时文件
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import {
deleteOwnedUploadSession,
getOwnedUploadSession,
getUploadSession,
isSafeUploadId,
resolveOwnedMergedUpload,
} from '@/lib/upload-sessions'
import { hasSystemAdminPermission } from '@/lib/department-permission'
import { promises as fs } from 'fs'
import path from 'path'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 300 // 5分钟
const RATE_LIMIT_WINDOW_MS = 60 * 1000
const requestCounts = new Map<string, { count: number; resetAt: number }>()
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) } },
)
}
/**
* 清理单个上传会话的临时文件
*/
async function cleanupUploadSession(uploadId: string, uploadDir: string): Promise<{
success: boolean
deletedFiles: number
freedSpace: number
error?: string
}> {
const tempDir = path.join(uploadDir, 'temp', uploadId)
let deletedFiles = 0
let freedSpace = 0
try {
// 检查目录是否存在
try {
await fs.access(tempDir)
} catch {
// 目录不存在,说明已经清理过了
return { success: true, deletedFiles: 0, freedSpace: 0 }
}
// 先计算目录大小(用于统计释放空间)
try {
const files = await fs.readdir(tempDir)
for (const file of files) {
const filePath = path.join(tempDir, file)
try {
const stat = await fs.stat(filePath)
if (stat.isFile()) {
freedSpace += stat.size
deletedFiles++ // 统计文件数量
}
} catch {
// 忽略单个文件的错误
}
}
} catch {
// 忽略读取目录的错误
}
// 使用统一的文件删除工具递归删除整个目录
const { deleteLocalFile } = await import('@/lib/file-cleanup')
const result = await deleteLocalFile(tempDir, {
allowDirectoryDelete: true,
})
if (result.success) {
// 如果之前没有统计到文件,至少算删除了目录本身
if (deletedFiles === 0) {
deletedFiles = 1
}
} else {
console.warn(`[Cleanup] 删除目录失败: ${tempDir}`, result.error)
}
return {
success: result.success,
deletedFiles,
freedSpace,
error: result.success ? undefined : result.error || '删除临时目录失败',
}
} catch (error: any) {
return {
success: false,
deletedFiles,
freedSpace,
error: error.message || '清理失败'
}
}
}
/**
* GET - 清理过期的临时上传文件
* 参数:
* - uploadId: 可选,指定要清理的上传ID
* - expiredOnly: 可选,是否只清理过期的(默认true)
* - maxAge: 可选,最大年龄(小时),默认8小时
*/
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 = searchParams.get('uploadId')
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 })
}
const maxAge = maxAgeHours * 60 * 60 * 1000 // 转换为毫秒
const uploadDir = process.env.UPLOAD_DIR || './uploads'
const tempBaseDir = path.join(uploadDir, 'temp')
// 确保临时目录存在
try {
await fs.access(tempBaseDir)
} catch {
return NextResponse.json({
message: '临时目录不存在,无需清理',
cleaned: 0,
freedSpace: 0
})
}
let cleaned = 0
let totalFreedSpace = 0
const errors: string[] = []
if (uploadId) {
// 清理指定的上传ID
const result = await cleanupUploadSession(uploadId, uploadDir)
if (result.success) {
cleaned = result.deletedFiles
totalFreedSpace = result.freedSpace
} else {
errors.push(result.error || '清理失败')
}
} else {
// 清理所有过期或无效的临时文件
const now = Date.now()
const tempDirs = (await fs.readdir(tempBaseDir)).slice(0, scanLimit)
for (const dir of tempDirs) {
const uploadIdFromDir = dir
if (!isSafeUploadId(uploadIdFromDir)) {
continue
}
const tempDir = path.join(tempBaseDir, dir)
try {
// 检查目录
const stat = await fs.stat(tempDir)
if (!stat.isDirectory()) {
continue
}
// 读取元数据文件(如果存在)
const metaPath = path.join(tempDir, 'meta.json')
let createdAt = stat.mtimeMs // 使用目录修改时间作为fallback
try {
const metaContent = await fs.readFile(metaPath, 'utf-8')
const meta = JSON.parse(metaContent)
if (meta.createdAt) {
createdAt = meta.createdAt
}
} catch {
// 元数据文件不存在,使用目录修改时间
}
// 检查是否过期
const age = now - createdAt
const isExpired = age > maxAge
// 检查会话是否存在
const session = await getUploadSession(uploadIdFromDir)
const sessionExists = !!session
// 决定是否清理
let shouldCleanup = false
if (expiredOnly) {
// 只清理过期的
shouldCleanup = isExpired
} else {
// 清理所有:过期的 或 会话不存在的
shouldCleanup = isExpired || !sessionExists
}
if (shouldCleanup) {
const result = await cleanupUploadSession(uploadIdFromDir, uploadDir)
if (result.success) {
cleaned += result.deletedFiles
totalFreedSpace += result.freedSpace
console.log(`[Cleanup] 清理上传会话: ${uploadIdFromDir}, 文件数: ${result.deletedFiles}, 释放空间: ${formatBytes(result.freedSpace)}`)
} else {
errors.push(`${uploadIdFromDir}: ${result.error}`)
}
}
} catch (err: any) {
errors.push(`${dir}: ${err.message || '读取失败'}`)
}
}
}
return NextResponse.json({
message: '清理完成',
cleaned,
freedSpace: totalFreedSpace,
freedSpaceFormatted: formatBytes(totalFreedSpace),
errors: errors.length > 0 ? errors : undefined
})
} catch (error: any) {
console.error('[Cleanup] Error:', error)
return NextResponse.json(
{ error: '清理失败: ' + (error.message || '未知错误') },
{ status: 500 }
)
}
}
/**
* POST - 清理指定的上传会话(用于用户取消上传时调用)
*/
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const retryAfter = consumeRateLimit(`own:${String((session.user as any)?.id || 'unknown')}`, 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 })
}
const uploadDir = process.env.UPLOAD_DIR || './uploads'
let deletedFiles = 0
let freedSpace = 0
const currentUserId = String((session.user as any).id || '')
let effectiveUploadId = uploadId
if (existingFilePath) {
const ownedMergedUpload = await resolveOwnedMergedUpload({
existingFilePath,
uploadId: uploadId || null,
userId: currentUserId,
})
if (!ownedMergedUpload) {
return NextResponse.json({ error: '上传会话不存在或不属于当前用户' }, { status: 404 })
}
effectiveUploadId = ownedMergedUpload.uploadId
} else {
const ownedSession = await getOwnedUploadSession(uploadId, currentUserId)
if (!ownedSession) {
return NextResponse.json({ error: '上传会话不存在或不属于当前用户' }, { status: 404 })
}
}
const deletedSession = await deleteOwnedUploadSession(effectiveUploadId, currentUserId)
if (!deletedSession) {
return NextResponse.json({ error: '上传会话不存在或不属于当前用户' }, { status: 404 })
}
if (effectiveUploadId) {
const result = await cleanupUploadSession(effectiveUploadId, uploadDir)
if (!result.success) {
return NextResponse.json(
{ error: result.error || '清理失败' },
{ status: 500 }
)
}
deletedFiles += result.deletedFiles
freedSpace += result.freedSpace
}
return NextResponse.json({
message: '清理成功',
deletedFiles,
freedSpace,
freedSpaceFormatted: formatBytes(freedSpace)
})
} catch (error: any) {
console.error('[Cleanup] Error:', error)
return NextResponse.json(
{ error: '清理失败: ' + (error.message || '未知错误') },
{ status: 500 }
)
}
}
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]
}