chenpei ea9076df45 feat: 实现统一的下载错误处理系统并修复产品全案数据库Schema
- 创建统一的下载错误处理模块 (lib/download-errors.ts)
- 实现13种错误类型分类和中文错误消息
- 后端统一使用 createDownloadError 创建标准化错误响应
- 前端统一使用 parseDownloadError 解析错误并显示
- 修复正式系统数据库Schema,添加产品全案相关字段和表
- 执行数据库迁移:添加 task_no, task_status, task_fetched_at 字段
- 创建 material_assets, api_keys, api_call_logs 表
2026-01-05 17:52:42 +08:00

275 lines
8.4 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 { 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]
}