保留素材硬删除审计并收紧清理权限

This commit is contained in:
WuYongJie 2026-05-25 16:32:51 +08:00
parent b596c5d08b
commit ab73b5ec75
11 changed files with 752 additions and 244 deletions

View File

@ -27,72 +27,50 @@ export async function GET(req: NextRequest) {
// 获取素材操作日志 // 获取素材操作日志
let materialLogs: any[] = [] let materialLogs: any[] = []
try { try {
// 尝试使用 Prisma 查询 const rawLogs = await prisma.$queryRaw<Array<{
materialLogs = await (prisma as any).materialLog.findMany({ id: string
take: limit, action: string
skip: offset, materialId: string
orderBy: { timestamp: 'desc' }, userId: string
include: { timestamp: Date
user: { changes: any
select: { materialSnapshot: any
username: true, username: string | null
email: true, email: string | null
}, productModel: string | null
}, }>>`
material: { SELECT
select: { ml.id,
productModel: true, ml."action",
}, ml."materialId",
}, ml."userId",
}, ml."timestamp",
}) ml."changes",
} catch (prismaError: any) { ml."materialSnapshot",
console.error('[MaterialLogs] Prisma query error:', prismaError) u.username,
// 如果 Prisma 查询失败,尝试使用原始 SQL u.email,
try { m."productModel"
const rawLogs = await prisma.$queryRaw<Array<{ FROM "material_logs" ml
id: string LEFT JOIN "users" u ON ml."userId" = u.id
action: string LEFT JOIN "materials" m ON ml."materialId" = m.id
materialId: string ORDER BY ml."timestamp" DESC
userId: string LIMIT ${limit}
timestamp: Date OFFSET ${offset}
changes: any `
username: string | null materialLogs = rawLogs.map((log) => ({
email: string | null id: log.id,
productModel: string | null action: log.action,
}>>` materialId: log.materialId,
SELECT userId: log.userId,
ml.id, timestamp: log.timestamp,
ml."action", changes: log.changes,
ml."materialId", materialSnapshot: log.materialSnapshot,
ml."userId", user: log.username ? { username: log.username, email: log.email } : null,
ml."timestamp", material: log.productModel ? { productModel: log.productModel } : null,
ml."changes", }))
u.username, } catch (sqlError: any) {
u.email, console.error('[MaterialLogs] SQL query error:', sqlError)
m."productModel" materialLogs = []
FROM "material_logs" ml
LEFT JOIN "users" u ON ml."userId" = u.id
LEFT JOIN "materials" m ON ml."materialId" = m.id
ORDER BY ml."timestamp" DESC
LIMIT ${limit}
OFFSET ${offset}
`
materialLogs = rawLogs.map((log) => ({
id: log.id,
action: log.action,
materialId: log.materialId,
userId: log.userId,
timestamp: log.timestamp,
changes: log.changes,
user: log.username ? { username: log.username, email: log.email } : null,
material: log.productModel ? { productModel: log.productModel } : null,
}))
} catch (sqlError: any) {
console.error('[MaterialLogs] SQL query error:', sqlError)
// 如果SQL也失败,返回空数组而不是抛出错误
materialLogs = []
}
} }
// 字段名映射 // 字段名映射
@ -136,7 +114,11 @@ export async function GET(req: NextRequest) {
// 构建详细修改内容 // 构建详细修改内容
const changeDetails: string[] = [] const changeDetails: string[] = []
const materialName = log.material?.productModel || '未知素材' const snapshot = log.materialSnapshot && typeof log.materialSnapshot === 'object'
? log.materialSnapshot as Record<string, unknown>
: null
const snapshotName = typeof snapshot?.productModel === 'string' ? snapshot.productModel : ''
const materialName = log.material?.productModel || snapshotName || '已删除素材'
const username = log.user?.username || '未知用户' const username = log.user?.username || '未知用户'
if (log.changes && typeof log.changes === 'object') { if (log.changes && typeof log.changes === 'object') {
@ -182,4 +164,3 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: '获取日志失败' }, { status: 500 }) return NextResponse.json({ error: '获取日志失败' }, { status: 500 })
} }
} }

View File

@ -0,0 +1,388 @@
import { NextRequest } from 'next/server'
import { DELETE } from '../route'
import { POST as cleanupDeletedMaterials } from '../cleanup/route'
import { POST as restoreMaterial } from '../restore/route'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
material: {
findUnique: jest.fn(),
delete: jest.fn(),
update: jest.fn(),
deleteMany: jest.fn(),
count: jest.fn(),
findMany: jest.fn(),
},
uploadField: {
findMany: jest.fn(),
},
fieldDefinition: {
findMany: jest.fn(),
},
$queryRaw: jest.fn(),
},
}))
jest.mock('@/lib/material-log', () => ({
createMaterialLog: jest.fn(),
}))
jest.mock('@/lib/material-storage-cleanup', () => ({
collectMaterialStorageUrls: jest.fn(() => ['s3://bucket/material.zip']),
deleteMaterialStorageFiles: jest.fn(),
}))
jest.mock('@/lib/admin-permissions', () => ({
isCurrentSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/url-rewrite', () => ({
rewriteMaterialStorageUrlsForClient: jest.fn((material) => material),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { createMaterialLog } = require('@/lib/material-log')
const { collectMaterialStorageUrls, deleteMaterialStorageFiles } = require('@/lib/material-storage-cleanup')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
function createMaterial(overrides: Record<string, unknown> = {}) {
return {
id: 'material-1',
userId: 'user-1',
productModel: '测试素材',
categoryName: '分类A',
sceneName: '场景A',
materialType: '3DMAX',
authorName: '作者A',
zipFilePath: 's3://bucket/material.zip',
previewUrl: '',
previewUrls: null,
displayImages: '[]',
videoSourceUrl: null,
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
user: {
id: 'user-1',
department: '',
},
...overrides,
}
}
describe('DELETE /api/materials 永久删除存储安全', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({
id: 'super-1',
department: '',
managedDepartment: '',
isSuperAdmin: true,
})
prisma.material.findUnique.mockResolvedValue(createMaterial())
isCurrentSuperAdmin.mockResolvedValue(true)
})
it('普通管理员不能永久删除素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
department: '设计部',
managedDepartment: '设计部',
isSuperAdmin: false,
})
const response = await DELETE(
new NextRequest('http://localhost/api/materials?id=material-1&permanent=true', {
method: 'DELETE',
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(deleteMaterialStorageFiles).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
expect(createMaterialLog).not.toHaveBeenCalled()
})
it('旧会话带最高权限标记但数据库已撤销时不能永久删除素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
department: '设计部',
managedDepartment: '设计部',
isSuperAdmin: false,
})
const response = await DELETE(
new NextRequest('http://localhost/api/materials?id=material-1&permanent=true', {
method: 'DELETE',
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(deleteMaterialStorageFiles).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
})
it('普通管理员仍可软删除自己上传的素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
department: '设计部',
managedDepartment: '设计部',
isSuperAdmin: false,
})
prisma.material.findUnique.mockResolvedValue(createMaterial({
userId: 'admin-1',
user: {
id: 'admin-1',
department: '设计部',
},
}))
prisma.material.update.mockResolvedValue(createMaterial())
const response = await DELETE(
new NextRequest('http://localhost/api/materials?id=material-1', {
method: 'DELETE',
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.message).toBe('删除成功')
expect(deleteMaterialStorageFiles).not.toHaveBeenCalled()
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { deletedAt: expect.any(Date) },
})
expect(createMaterialLog).toHaveBeenCalled()
})
it('文件删除失败时保留素材数据库记录,不写删除日志', async () => {
deleteMaterialStorageFiles.mockResolvedValue({
deletedFiles: 0,
skippedFiles: 0,
failedFiles: 1,
failures: [{ fileUrl: 's3://bucket/material.zip', reason: 'S3 unavailable' }],
})
const response = await DELETE(
new NextRequest('http://localhost/api/materials?id=material-1&permanent=true', {
method: 'DELETE',
})
)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toContain('素材记录已保留')
expect(prisma.material.delete).not.toHaveBeenCalled()
expect(createMaterialLog).not.toHaveBeenCalled()
})
it('文件全部删除成功后才写审计日志并硬删除数据库记录', async () => {
deleteMaterialStorageFiles.mockResolvedValue({
deletedFiles: 1,
skippedFiles: 0,
failedFiles: 0,
failures: [],
})
prisma.material.delete.mockResolvedValue(createMaterial())
const response = await DELETE(
new NextRequest('http://localhost/api/materials?id=material-1&permanent=true', {
method: 'DELETE',
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.message).toBe('删除成功')
expect(createMaterialLog).toHaveBeenCalledWith(
expect.objectContaining({
materialId: 'material-1',
action: 'DELETE',
materialSnapshot: expect.objectContaining({
productModel: '测试素材',
categoryName: '分类A',
}),
})
)
expect(prisma.material.delete).toHaveBeenCalledWith({
where: { id: 'material-1' },
})
})
})
describe('POST /api/materials/cleanup 过期删除素材清理权限', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.material.findMany.mockResolvedValue([])
prisma.material.deleteMany.mockResolvedValue({ count: 0 })
deleteMaterialStorageFiles.mockResolvedValue({
deletedFiles: 0,
skippedFiles: 0,
failedFiles: 0,
failures: [],
})
})
it('普通管理员不能清理过期删除素材', async () => {
isCurrentSuperAdmin.mockResolvedValue(false)
const response = await cleanupDeletedMaterials(
new NextRequest('http://localhost/api/materials/cleanup', { method: 'POST' })
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(prisma.material.findMany).not.toHaveBeenCalled()
expect(prisma.material.deleteMany).not.toHaveBeenCalled()
})
it('最高权限账号可以清理过期删除素材', async () => {
prisma.material.findMany.mockResolvedValue([
createMaterial({ id: 'material-1', deletedAt: new Date('2026-05-01T00:00:00.000Z') }),
])
const response = await cleanupDeletedMaterials(
new NextRequest('http://localhost/api/materials/cleanup', { method: 'POST' })
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.deletedCount).toBe(1)
expect(deleteMaterialStorageFiles).toHaveBeenCalled()
expect(collectMaterialStorageUrls).toHaveBeenCalled()
expect(prisma.material.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ['material-1'] } },
})
})
})
describe('POST /api/materials/restore 恢复素材权限', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
department: '设计部',
managedDepartment: '设计部',
isSuperAdmin: false,
})
prisma.material.findUnique.mockResolvedValue(createMaterial({
userId: 'user-2',
user: {
id: 'user-2',
department: '财务部',
},
}))
})
it('普通管理员不能恢复管理范围外的素材', async () => {
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '财务部' },
])
const response = await restoreMaterial(
new NextRequest('http://localhost/api/materials/restore', {
method: 'POST',
body: JSON.stringify({ id: 'material-1' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('恢复')
expect(prisma.material.update).not.toHaveBeenCalled()
})
it('普通管理员可以恢复自己上传的素材', async () => {
prisma.material.findUnique.mockResolvedValue(createMaterial({
userId: 'admin-1',
user: {
id: 'admin-1',
department: '设计部',
},
}))
prisma.material.update.mockResolvedValue(createMaterial())
const response = await restoreMaterial(
new NextRequest('http://localhost/api/materials/restore', {
method: 'POST',
body: JSON.stringify({ id: 'material-1' }),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.message).toBe('恢复成功')
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { deletedAt: null },
})
})
it('最高权限账号可以恢复任意素材', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({
id: 'super-1',
department: '',
managedDepartment: '',
isSuperAdmin: true,
})
prisma.material.update.mockResolvedValue(createMaterial())
const response = await restoreMaterial(
new NextRequest('http://localhost/api/materials/restore', {
method: 'POST',
body: JSON.stringify({ id: 'material-1' }),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.message).toBe('恢复成功')
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { deletedAt: null },
})
})
})

View File

@ -2,17 +2,24 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { deleteFromS3, isS3Url } from '@/lib/s3' import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { unlink } from 'fs/promises' import {
collectMaterialStorageUrls,
deleteMaterialStorageFiles,
} from '@/lib/material-storage-cleanup'
// Cleanup materials deleted more than 7 days ago (admin only) // Cleanup materials deleted more than 7 days ago (admin only)
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const session = await getServerSession(authOptions) const session = await getServerSession(authOptions)
if (!session || (session.user as any).role !== 'ADMIN') { if (
!session ||
(session.user as any).role !== 'ADMIN' ||
!(await isCurrentSuperAdmin(session))
) {
return NextResponse.json( return NextResponse.json(
{ error: '无权限' }, { error: '仅最高权限账号可清理过期删除素材' },
{ status: 403 } { status: 403 }
) )
} }
@ -31,89 +38,50 @@ export async function POST(req: NextRequest) {
}, },
}) })
// 收集所有需要删除的文件并删除
let deletedFiles = 0 let deletedFiles = 0
let skippedFiles = 0
let failedFiles = 0 let failedFiles = 0
const safeToDeleteMaterialIds: string[] = []
const failedMaterials: Array<{
id: string
failedFiles: number
failures: Array<{ fileUrl: string; reason: string }>
}> = []
for (const material of deletedMaterials) { for (const material of deletedMaterials) {
const filesToDelete: string[] = [] const cleanupResult = await deleteMaterialStorageFiles(collectMaterialStorageUrls(material), {
logPrefix: `[Cleanup] Material ${material.id}`,
})
// 源文件(zipFilePath 或 videoSourceUrl) deletedFiles += cleanupResult.deletedFiles
if (material.zipFilePath) filesToDelete.push(material.zipFilePath) skippedFiles += cleanupResult.skippedFiles
if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== material.zipFilePath) { failedFiles += cleanupResult.failedFiles
filesToDelete.push((material as any).videoSourceUrl)
}
// 预览图 if (cleanupResult.failedFiles > 0) {
if (material.previewUrl) filesToDelete.push(material.previewUrl) failedMaterials.push({
id: material.id,
// 多预览图(JSON数组) failedFiles: cleanupResult.failedFiles,
if ((material as any).previewUrls) { failures: cleanupResult.failures.slice(0, 10),
try { })
const previewUrls = JSON.parse((material as any).previewUrls) } else {
if (Array.isArray(previewUrls)) { safeToDeleteMaterialIds.push(material.id)
filesToDelete.push(...previewUrls.filter((url: string) => url && url.trim()))
}
} catch {
// 忽略解析错误
}
}
// 视频相关文件
if ((material as any).videoPreview540Url) filesToDelete.push((material as any).videoPreview540Url)
if ((material as any).videoPreviewClipUrl) filesToDelete.push((material as any).videoPreviewClipUrl)
if ((material as any).videoCoverUrl) filesToDelete.push((material as any).videoCoverUrl)
// 展示图片(JSON数组)
if (material.displayImages) {
try {
const displayImages = JSON.parse(material.displayImages)
if (Array.isArray(displayImages)) {
filesToDelete.push(...displayImages.filter((url: string) => url && url.trim()))
}
} catch {
// 忽略解析错误
}
}
// 去重并过滤空值
const uniqueFiles = Array.from(new Set(filesToDelete.filter((url) => url && url.trim())))
// 删除所有文件
for (const fileUrl of uniqueFiles) {
try {
if (isS3Url(fileUrl)) {
await deleteFromS3(fileUrl)
deletedFiles++
} else {
try {
await unlink(fileUrl)
deletedFiles++
} catch (fileError: any) {
if (fileError.code !== 'ENOENT') {
failedFiles++
}
}
}
} catch (error) {
console.error(`[Cleanup] Failed to delete file: ${fileUrl}`, error)
failedFiles++
}
} }
} }
// 永久删除数据库记录 if (safeToDeleteMaterialIds.length > 0) {
const deletePromises = deletedMaterials.map((material) => await prisma.material.deleteMany({
prisma.material.delete({ where: { id: { in: safeToDeleteMaterialIds } },
where: { id: material.id },
}) })
) }
await Promise.all(deletePromises)
return NextResponse.json({ return NextResponse.json({
message: '清理完成', message: failedMaterials.length > 0 ? '部分清理完成,文件删除失败的素材记录已保留' : '清理完成',
deletedCount: deletedMaterials.length deletedCount: safeToDeleteMaterialIds.length,
retainedCount: failedMaterials.length,
deletedFiles,
skippedFiles,
failedFiles,
failedMaterials,
}) })
} catch (error) { } catch (error) {
console.error('[Materials] CLEANUP error:', error) console.error('[Materials] CLEANUP error:', error)

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
// Restore material from trash (admin only) // Restore material from trash (admin only)
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@ -26,6 +27,76 @@ export async function POST(req: NextRequest) {
) )
} }
const operatorId = (session.user as any).id
const operator = await prisma.user.findUnique({
where: { id: operatorId },
select: {
id: true,
department: true,
managedDepartment: true,
isSuperAdmin: true,
},
})
if (!operator) {
return NextResponse.json(
{ error: '当前用户不存在' },
{ status: 404 }
)
}
const material = await prisma.material.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
department: true,
},
},
},
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
if (!operator.isSuperAdmin) {
const isOwnMaterial = material.userId === operatorId
if (!isOwnMaterial) {
const operatorScope = (operator.managedDepartment || operator.department || '').trim()
const targetDepartment = (material.user?.department || '').trim()
if (!operatorScope) {
return NextResponse.json(
{ error: '您只能恢复自己上传的素材' },
{ status: 403 }
)
}
let hasAccess =
targetDepartment === operatorScope ||
targetDepartment.startsWith(`${operatorScope}-`)
if (!hasAccess) {
const departments = await prisma.department.findMany({ select: { name: true } })
const descendantMap = buildDepartmentDescendants(departments)
hasAccess = isDepartmentWithinScope(operatorScope, targetDepartment, descendantMap)
}
if (!hasAccess) {
return NextResponse.json(
{ error: '您只能恢复自己或所管理部门成员上传的素材' },
{ status: 403 }
)
}
}
}
// Restore material: set deletedAt to null // Restore material: set deletedAt to null
await prisma.material.update({ await prisma.material.update({
where: { id }, where: { id },

View File

@ -4,8 +4,11 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite' import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite'
import { createMaterialLog } from '@/lib/material-log' import { createMaterialLog } from '@/lib/material-log'
import { deleteFromS3, isS3Url } from '@/lib/s3'
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments' import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
import {
collectMaterialStorageUrls,
deleteMaterialStorageFiles,
} from '@/lib/material-storage-cleanup'
function parseDisplayImages(value: string | null): string[] { function parseDisplayImages(value: string | null): string[] {
if (!value) return [] if (!value) return []
@ -36,6 +39,22 @@ const ALLOWED_DYNAMIC_FILTER_FIELDS = new Set([
'authorName', 'authorName',
]) ])
function buildMaterialSnapshot(material: {
productModel?: string | null
categoryName?: string | null
sceneName?: string | null
materialType?: string | null
authorName?: string | null
}) {
return {
productModel: material.productModel || null,
categoryName: material.categoryName || null,
sceneName: material.sceneName || null,
materialType: material.materialType || null,
authorName: material.authorName || null,
}
}
// Get all materials with search and filter support // Get all materials with search and filter support
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
try { try {
@ -352,7 +371,6 @@ export async function DELETE(req: NextRequest) {
// 获取当前操作者信息 // 获取当前操作者信息
const operatorId = (session.user as any).id const operatorId = (session.user as any).id
const isSuperAdmin = Boolean((session.user as any).isSuperAdmin)
// 获取操作者的部门管理信息 // 获取操作者的部门管理信息
const operator = await prisma.user.findUnique({ const operator = await prisma.user.findUnique({
@ -361,8 +379,10 @@ export async function DELETE(req: NextRequest) {
id: true, id: true,
department: true, department: true,
managedDepartment: true, managedDepartment: true,
isSuperAdmin: true,
}, },
}) })
const isSuperAdmin = Boolean(operator?.isSuperAdmin)
// Get material to delete files, including uploader's department info // Get material to delete files, including uploader's department info
const material = await prisma.material.findUnique({ const material = await prisma.material.findUnique({
@ -384,11 +404,17 @@ export async function DELETE(req: NextRequest) {
) )
} }
if (permanent && !isSuperAdmin) {
return NextResponse.json(
{ error: '仅最高权限账号可永久删除素材' },
{ status: 403 }
)
}
// 获取用户ID用于日志记录 // 获取用户ID用于日志记录
const userId = operatorId const userId = operatorId
// 权限验证 // 权限验证:最高权限账号可以删除任意素材;普通管理员只能软删除自己或管理范围内的素材。
// 超级管理员可以删除任意素材
if (!isSuperAdmin) { if (!isSuperAdmin) {
// 检查是否是自己上传的素材 // 检查是否是自己上传的素材
const isOwnMaterial = material.userId === operatorId const isOwnMaterial = material.userId === operatorId
@ -426,7 +452,28 @@ export async function DELETE(req: NextRequest) {
} }
if (permanent) { if (permanent) {
// 永久删除前记录日志 const cleanupResult = await deleteMaterialStorageFiles(collectMaterialStorageUrls(material), {
logPrefix: `[Delete] Material ${id}`,
})
console.log(
`[Delete] Material ${id} files cleanup: ${cleanupResult.deletedFiles} deleted, ${cleanupResult.skippedFiles} skipped, ${cleanupResult.failedFiles} failed`
)
if (cleanupResult.failedFiles > 0) {
return NextResponse.json(
{
error: '文件删除失败,素材记录已保留,请稍后重试',
deletedFiles: cleanupResult.deletedFiles,
skippedFiles: cleanupResult.skippedFiles,
failedFiles: cleanupResult.failedFiles,
failures: cleanupResult.failures.slice(0, 10),
},
{ status: 500 }
)
}
// 文件确认删除后再记录硬删除日志,避免失败时误报“已删除”。
await createMaterialLog({ await createMaterialLog({
materialId: id, materialId: id,
userId, userId,
@ -437,84 +484,8 @@ export async function DELETE(req: NextRequest) {
newValue: true, newValue: true,
}, },
}, },
materialSnapshot: buildMaterialSnapshot(material),
}) })
// 收集所有需要删除的文件URL
const filesToDelete: string[] = []
// 源文件(zipFilePath 或 videoSourceUrl)
if (material.zipFilePath) filesToDelete.push(material.zipFilePath)
if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== material.zipFilePath) {
filesToDelete.push((material as any).videoSourceUrl)
}
// 预览图
if (material.previewUrl) filesToDelete.push(material.previewUrl)
// 多预览图(JSON数组)
if (material.previewUrls) {
try {
const previewUrls = JSON.parse(material.previewUrls)
if (Array.isArray(previewUrls)) {
filesToDelete.push(...previewUrls.filter((url: string) => url && url.trim()))
}
} catch {
// 忽略解析错误
}
}
// 视频相关文件
if ((material as any).videoPreview540Url) filesToDelete.push((material as any).videoPreview540Url)
if ((material as any).videoPreviewClipUrl) filesToDelete.push((material as any).videoPreviewClipUrl)
if ((material as any).videoCoverUrl) filesToDelete.push((material as any).videoCoverUrl)
// 展示图片(JSON数组)
if (material.displayImages) {
try {
const displayImages = JSON.parse(material.displayImages)
if (Array.isArray(displayImages)) {
filesToDelete.push(...displayImages.filter((url: string) => url && url.trim()))
}
} catch {
// 忽略解析错误
}
}
// 去重并过滤空值
const uniqueFiles = Array.from(new Set(filesToDelete.filter((url) => url && url.trim())))
// 删除所有文件(S3或本地)
let deletedFiles = 0
let failedFiles = 0
for (const fileUrl of uniqueFiles) {
try {
if (isS3Url(fileUrl)) {
await deleteFromS3(fileUrl)
deletedFiles++
console.log(`[Delete] Deleted S3 file: ${fileUrl}`)
} else {
// 本地文件(如果存在)
const { unlink } = await import('fs/promises')
try {
await unlink(fileUrl)
deletedFiles++
console.log(`[Delete] Deleted local file: ${fileUrl}`)
} catch (fileError: any) {
if (fileError.code !== 'ENOENT') {
console.error(`[Delete] Failed to delete local file: ${fileUrl}`, fileError)
failedFiles++
}
}
}
} catch (error) {
console.error(`[Delete] Failed to delete file: ${fileUrl}`, error)
failedFiles++
}
}
console.log(`[Delete] Material ${id} files cleanup: ${deletedFiles} deleted, ${failedFiles} failed`)
// 永久删除数据库记录 // 永久删除数据库记录
await prisma.material.delete({ await prisma.material.delete({
where: { id }, where: { id },
@ -537,6 +508,7 @@ export async function DELETE(req: NextRequest) {
newValue: false, newValue: false,
}, },
}, },
materialSnapshot: buildMaterialSnapshot(material),
}) })
} }

View File

@ -0,0 +1,52 @@
export {}
jest.mock('@/lib/prisma', () => ({
prisma: {
$executeRawUnsafe: jest.fn(),
materialLog: {
create: jest.fn(),
},
},
}))
const { prisma } = require('@/lib/prisma')
const { ensureMaterialLogsTable, createMaterialLog } = require('../material-log')
describe('material log persistence safety', () => {
beforeEach(() => {
jest.resetAllMocks()
})
it('兜底建表会移除素材级联删除外键', async () => {
await ensureMaterialLogsTable()
const executedSql = prisma.$executeRawUnsafe.mock.calls.map(([sql]: [string]) => sql).join('\n')
expect(executedSql).toContain('ADD COLUMN IF NOT EXISTS "materialSnapshot" JSONB')
expect(executedSql).toContain('DROP CONSTRAINT IF EXISTS "material_logs_materialId_fkey"')
expect(executedSql).not.toContain(
'FOREIGN KEY ("materialId") REFERENCES "materials"("id") ON DELETE CASCADE'
)
})
it('创建日志时会保存素材快照', async () => {
await createMaterialLog({
materialId: 'material-1',
userId: 'user-1',
action: 'DELETE',
materialSnapshot: {
productModel: '测试素材',
},
})
expect(prisma.materialLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
materialId: 'material-1',
action: 'DELETE',
materialSnapshot: {
productModel: '测试素材',
},
}),
})
})
})

View File

@ -0,0 +1,48 @@
export {}
jest.mock('fs/promises', () => ({
unlink: jest.fn(),
}))
jest.mock('@/lib/s3', () => ({
deleteFromS3: jest.fn(),
isS3Url: jest.fn((value: string) => /^https?:\/\//.test(value)),
}))
const { unlink } = require('fs/promises')
const { deleteFromS3 } = require('@/lib/s3')
describe('material-storage-cleanup', () => {
const originalEnv = process.env
beforeEach(() => {
jest.resetAllMocks()
process.env = { ...originalEnv }
})
afterEach(() => {
process.env = originalEnv
})
it('S3 存储下会把对象 key-only 路径交给 S3 删除', async () => {
process.env.STORAGE_PROVIDER = 's3'
const { deleteMaterialStorageFiles } = require('../material-storage-cleanup')
const result = await deleteMaterialStorageFiles(['materials/file.zip'])
expect(result.deletedFiles).toBe(1)
expect(deleteFromS3).toHaveBeenCalledWith('materials/file.zip')
expect(unlink).not.toHaveBeenCalled()
})
it('显式本地路径仍走本地删除', async () => {
process.env.STORAGE_PROVIDER = 's3'
const { deleteMaterialStorageFiles } = require('../material-storage-cleanup')
const result = await deleteMaterialStorageFiles(['./uploads/file.zip'])
expect(result.deletedFiles).toBe(1)
expect(unlink).toHaveBeenCalledWith('./uploads/file.zip')
expect(deleteFromS3).not.toHaveBeenCalled()
})
})

View File

@ -8,6 +8,8 @@ type MaterialLogChanges = Record<
} }
> >
type MaterialSnapshot = Record<string, unknown>
let materialLogsTableEnsured = false let materialLogsTableEnsured = false
async function executeSafely(sql: string) { async function executeSafely(sql: string) {
@ -28,10 +30,13 @@ export async function ensureMaterialLogsTable() {
"userId" TEXT NOT NULL, "userId" TEXT NOT NULL,
"action" TEXT NOT NULL, "action" TEXT NOT NULL,
"changes" JSONB, "changes" JSONB,
"materialSnapshot" JSONB,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
) )
`) `)
await executeSafely(`ALTER TABLE "material_logs" ADD COLUMN IF NOT EXISTS "materialSnapshot" JSONB`)
await executeSafely( await executeSafely(
`CREATE INDEX IF NOT EXISTS "material_logs_materialId_idx" ON "material_logs" ("materialId")` `CREATE INDEX IF NOT EXISTS "material_logs_materialId_idx" ON "material_logs" ("materialId")`
) )
@ -40,19 +45,7 @@ export async function ensureMaterialLogsTable() {
`CREATE INDEX IF NOT EXISTS "material_logs_timestamp_idx" ON "material_logs" ("timestamp")` `CREATE INDEX IF NOT EXISTS "material_logs_timestamp_idx" ON "material_logs" ("timestamp")`
) )
await executeSafely(` await executeSafely(`ALTER TABLE "material_logs" DROP CONSTRAINT IF EXISTS "material_logs_materialId_fkey"`)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'material_logs_materialId_fkey'
) THEN
ALTER TABLE "material_logs"
ADD CONSTRAINT "material_logs_materialId_fkey"
FOREIGN KEY ("materialId") REFERENCES "materials"("id") ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END
$$;
`)
await executeSafely(` await executeSafely(`
DO $$ DO $$
@ -76,6 +69,7 @@ interface CreateMaterialLogParams {
userId: string userId: string
action: string action: string
changes?: MaterialLogChanges | null changes?: MaterialLogChanges | null
materialSnapshot?: MaterialSnapshot | null
} }
export async function createMaterialLog({ export async function createMaterialLog({
@ -83,6 +77,7 @@ export async function createMaterialLog({
userId, userId,
action, action,
changes = null, changes = null,
materialSnapshot = null,
}: CreateMaterialLogParams) { }: CreateMaterialLogParams) {
try { try {
await ensureMaterialLogsTable() await ensureMaterialLogsTable()
@ -92,6 +87,7 @@ export async function createMaterialLog({
userId, userId,
action, action,
changes, changes,
materialSnapshot,
}, },
}) })
} catch (error) { } catch (error) {

View File

@ -1,4 +1,5 @@
import { unlink } from 'fs/promises' import { unlink } from 'fs/promises'
import { isAbsolute } from 'path'
import { deleteFromS3, isS3Url } from '@/lib/s3' import { deleteFromS3, isS3Url } from '@/lib/s3'
export interface MaterialStorageLike { export interface MaterialStorageLike {
@ -60,6 +61,21 @@ function getErrorMessage(error: unknown): string {
return '未知错误' return '未知错误'
} }
function isExplicitLocalPath(fileUrl: string): boolean {
return fileUrl.startsWith('file://') ||
isAbsolute(fileUrl) ||
/^[a-zA-Z]:[\\/]/.test(fileUrl) ||
fileUrl.startsWith('./') ||
fileUrl.startsWith('../')
}
function shouldDeleteFromS3(fileUrl: string): boolean {
if (isS3Url(fileUrl)) return true
const storageProvider = (process.env.STORAGE_PROVIDER || '').trim().toLowerCase()
return storageProvider === 's3' && !isExplicitLocalPath(fileUrl)
}
export async function deleteMaterialStorageFiles( export async function deleteMaterialStorageFiles(
fileUrls: string[], fileUrls: string[],
options: { logPrefix?: string } = {} options: { logPrefix?: string } = {}
@ -75,7 +91,7 @@ export async function deleteMaterialStorageFiles(
for (const fileUrl of uniqueFiles) { for (const fileUrl of uniqueFiles) {
try { try {
if (isS3Url(fileUrl)) { if (shouldDeleteFromS3(fileUrl)) {
await deleteFromS3(fileUrl) await deleteFromS3(fileUrl)
result.deletedFiles++ result.deletedFiles++
continue continue

View File

@ -0,0 +1,17 @@
ALTER TABLE "material_logs"
ADD COLUMN IF NOT EXISTS "materialSnapshot" JSONB;
UPDATE "material_logs" ml
SET "materialSnapshot" = jsonb_build_object(
'productModel', m."productModel",
'categoryName', m."categoryName",
'sceneName', m."sceneName",
'materialType', m."materialType",
'authorName', m."authorName"
)
FROM "materials" m
WHERE ml."materialId" = m.id
AND ml."materialSnapshot" IS NULL;
ALTER TABLE "material_logs"
DROP CONSTRAINT IF EXISTS "material_logs_materialId_fkey";

View File

@ -238,7 +238,6 @@ model Material {
likes MaterialLike[] likes MaterialLike[]
favorites MaterialFavorite[] favorites MaterialFavorite[]
downloadLogs MaterialDownloadLog[] downloadLogs MaterialDownloadLog[]
logs MaterialLog[]
browseHistory BrowseHistory[] browseHistory BrowseHistory[]
shareTokens MaterialShareToken[] shareTokens MaterialShareToken[]
videoTranscodeJob VideoTranscodeJob? videoTranscodeJob VideoTranscodeJob?
@ -329,8 +328,8 @@ model MaterialLog {
userId String userId String
action String // 'UPLOAD', 'UPDATE', 'DELETE', 'TOGGLE_PREMIUM' action String // 'UPLOAD', 'UPDATE', 'DELETE', 'TOGGLE_PREMIUM'
changes Json? // 详细变更记录,格式:{ "fieldName": { "oldValue": "...", "newValue": "..." } } changes Json? // 详细变更记录,格式:{ "fieldName": { "oldValue": "...", "newValue": "..." } }
materialSnapshot Json? // 素材硬删除后仍用于审计展示的最小快照
timestamp DateTime @default(now()) timestamp DateTime @default(now())
material Material @relation(fields: [materialId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([materialId]) @@index([materialId])