275 lines
8.4 KiB
TypeScript
275 lines
8.4 KiB
TypeScript
// 清理过期或失败的临时上传文件
|
||
// 用于清理:1. 过期的上传会话对应的临时文件 2. 失败的上传临时文件 3. 用户取消上传的临时文件
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getServerSession } from 'next-auth'
|
||
import { authOptions } from '@/lib/auth'
|
||
import { getUploadSession } from '@/lib/upload-sessions'
|
||
import { promises as fs } from 'fs'
|
||
import path from 'path'
|
||
|
||
export const runtime = 'nodejs'
|
||
export const dynamic = 'force-dynamic'
|
||
export const maxDuration = 300 // 5分钟
|
||
|
||
/**
|
||
* 清理单个上传会话的临时文件
|
||
*/
|
||
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)
|
||
|
||
if (result.success) {
|
||
// 如果之前没有统计到文件,至少算删除了目录本身
|
||
if (deletedFiles === 0) {
|
||
deletedFiles = 1
|
||
}
|
||
} else {
|
||
console.warn(`[Cleanup] 删除目录失败: ${tempDir}`, result.error)
|
||
}
|
||
|
||
return { success: true, deletedFiles, freedSpace }
|
||
} 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 user = await prisma.user.findUnique({ where: { id: session.user.id } })
|
||
// if (user?.role !== 'SUPER_ADMIN') {
|
||
// return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||
// }
|
||
|
||
const { searchParams } = new URL(req.url)
|
||
const uploadId = searchParams.get('uploadId')
|
||
const expiredOnly = searchParams.get('expiredOnly') !== 'false'
|
||
const maxAgeHours = parseInt(searchParams.get('maxAge') || '8', 10)
|
||
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)
|
||
|
||
for (const dir of tempDirs) {
|
||
const uploadIdFromDir = dir
|
||
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 { uploadId } = await req.json()
|
||
|
||
if (!uploadId) {
|
||
return NextResponse.json({ error: '缺少 uploadId' }, { status: 400 })
|
||
}
|
||
|
||
// 验证会话归属
|
||
const uploadSession = await getUploadSession(uploadId)
|
||
if (uploadSession && uploadSession.userId !== (session.user as any).id) {
|
||
return NextResponse.json({ error: '无权清理此上传会话' }, { status: 403 })
|
||
}
|
||
|
||
const uploadDir = process.env.UPLOAD_DIR || './uploads'
|
||
const result = await cleanupUploadSession(uploadId, uploadDir)
|
||
|
||
if (result.success) {
|
||
// 删除上传会话
|
||
try {
|
||
const { deleteUploadSession } = await import('@/lib/upload-sessions')
|
||
await deleteUploadSession(uploadId)
|
||
} catch (err) {
|
||
console.warn('[Cleanup] 删除上传会话失败:', err)
|
||
}
|
||
|
||
return NextResponse.json({
|
||
message: '清理成功',
|
||
deletedFiles: result.deletedFiles,
|
||
freedSpace: result.freedSpace,
|
||
freedSpaceFormatted: formatBytes(result.freedSpace)
|
||
})
|
||
} else {
|
||
return NextResponse.json(
|
||
{ error: result.error || '清理失败' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
} 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]
|
||
}
|
||
|