356 lines
11 KiB
TypeScript
356 lines
11 KiB
TypeScript
// 清理过期或失败的临时上传文件
|
||
// 用于清理: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]
|
||
}
|