240 lines
7.9 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.

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isSystemAdmin } from '@/lib/department-permission'
import { deleteFiles } from '@/lib/file-cleanup'
import { collectMaterialFiles, deleteMaterialRelatedRecords } from '@/lib/material-cleanup'
import { createMaterialLog } from '@/lib/material-log'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
// UUID 格式校验正则
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/**
* 批量删除素材(软删除或永久删除)
*
* 改进点:
* 1. 使用 isSystemAdmin() 统一权限校验
* 2. 永久删除时会物理删除 S3/本地文件
* 3. 永久删除时会删除关联记录(MaterialAsset, MaterialSku)
* 4. 增加 ID 格式校验
*/
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
// 使用统一的权限检查函数(与单条删除保持一致)
if (!session || !isSystemAdmin((session.user as any)?.role)) {
return NextResponse.json({ error: '无权限,需要系统管理员权限' }, { status: 403 })
}
const { ids, permanent } = await request.json()
const operatorId = (session.user as any).id
if (!operatorId) {
return NextResponse.json({ error: '缺少操作人信息' }, { status: 401 })
}
// 校验 ids 是数组且非空
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: '请提供要删除的素材ID列表' }, { status: 400 })
}
// 校验每个 ID 的格式(防止注入)
const invalidIds = ids.filter(id => typeof id !== 'string' || !UUID_REGEX.test(id))
if (invalidIds.length > 0) {
return NextResponse.json({
error: `存在无效的素材ID格式: ${invalidIds.slice(0, 3).join(', ')}${invalidIds.length > 3 ? '...' : ''}`
}, { status: 400 })
}
if (permanent) {
// ========== 永久删除:物理删除文件 + 删除数据库记录 ==========
console.log(`[BatchDelete] 开始永久删除 ${ids.length} 条素材...`)
// 确保下载日志表已应用“删除素材后保留日志”策略
await ensureMaterialDownloadLogsTable()
// 1. 先查询所有素材(获取文件路径和素材类型)
const materials = await prisma.material.findMany({
where: { id: { in: ids } },
})
if (materials.length === 0) {
return NextResponse.json({ error: '未找到要删除的素材' }, { status: 404 })
}
// 2. 收集所有文件并删除
let totalFilesDeleted = 0
let totalFilesFailed = 0
const allErrors: Array<{ materialId: string; fileUrl: string; error: string }> = []
for (const material of materials) {
const materialType = (material as any).materialType || ''
// 记录永久删除日志(先记录,避免数据库删除后日志级联删除前丢失上下文)
await createMaterialLog({
materialId: material.id,
userId: operatorId,
action: 'DELETE',
changes: {
materialSnapshot: {
oldValue: null,
newValue: {
id: material.id,
productModel: material.productModel,
materialType: materialType || null,
},
},
deletedAt: {
oldValue: material.deletedAt ? material.deletedAt.toISOString() : null,
newValue: 'PERMANENTLY_DELETED',
},
deleteMode: {
oldValue: material.deletedAt ? 'soft' : 'active',
newValue: 'permanent',
},
materialType: {
oldValue: materialType || null,
newValue: materialType || null,
},
},
})
// 收集该素材的所有文件
const fileInfo = await collectMaterialFiles(material.id, material)
const filesToDelete = [...fileInfo.fileUrls, ...fileInfo.directories]
console.log(`[BatchDelete] Material ${material.id} (${materialType}):`, {
fileUrls: fileInfo.fileUrls.length,
directories: fileInfo.directories.length,
relatedRecords: fileInfo.relatedRecords,
})
// 删除文件
if (filesToDelete.length > 0) {
const result = await deleteFiles(filesToDelete)
totalFilesDeleted += result.deletedCount
totalFilesFailed += result.failedCount
if (result.errors.length > 0) {
allErrors.push(...result.errors.map(e => ({
materialId: material.id,
fileUrl: e.fileUrl,
error: e.error,
})))
}
}
// 删除关联记录(MaterialAsset, MaterialSku)
if (materialType) {
await deleteMaterialRelatedRecords(material.id, materialType)
}
}
// 3. 删除数据库记录
const deleteResult = await prisma.material.deleteMany({
where: { id: { in: ids } }
})
console.log(`[BatchDelete] 永久删除完成:`, {
requestedCount: ids.length,
deletedRecords: deleteResult.count,
filesDeleted: totalFilesDeleted,
filesFailed: totalFilesFailed,
errorCount: allErrors.length,
})
// 如果有文件删除失败,记录警告但不影响整体结果
if (allErrors.length > 0) {
console.warn(`[BatchDelete] 部分文件删除失败:`, allErrors.slice(0, 5))
}
return NextResponse.json({
success: true,
message: `批量永久删除成功`,
details: {
deletedRecords: deleteResult.count,
filesDeleted: totalFilesDeleted,
filesFailed: totalFilesFailed,
}
})
} else {
// ========== 软删除:仅标记 deletedAt ==========
// 查询素材(需要处理任务号)
const materials = await prisma.material.findMany({
where: { id: { in: ids } },
select: { id: true, taskNo: true, productModel: true, materialType: true }
})
if (materials.length === 0) {
return NextResponse.json({ error: '未找到要删除的素材' }, { status: 404 })
}
const deletedAt = new Date()
await prisma.$transaction([
prisma.material.updateMany({
where: { id: { in: materials.map((m) => m.id) } },
data: {
deletedAt,
},
}),
prisma.materialSku.updateMany({
where: {
materialId: { in: materials.map((m) => m.id) },
deletedAt: null,
},
data: {
deletedAt,
},
}),
])
await Promise.all(
materials.map((material) =>
createMaterialLog({
materialId: material.id,
userId: operatorId,
action: 'DELETE',
changes: {
materialSnapshot: {
oldValue: null,
newValue: {
id: material.id,
productModel: material.productModel,
materialType: material.materialType,
},
},
deletedAt: {
oldValue: null,
newValue: deletedAt.toISOString(),
},
deleteMode: {
oldValue: 'active',
newValue: 'soft',
},
},
})
)
)
console.log(`[BatchDelete] 软删除完成: ${materials.length} 条素材`)
return NextResponse.json({
success: true,
message: '批量删除成功(已移入回收站)',
details: {
deletedRecords: materials.length,
}
})
}
} catch (error) {
console.error('[BatchDelete] Error:', error)
return NextResponse.json({ error: '批量删除失败' }, { status: 500 })
}
}