diff --git a/app/api/admin/material-logs/route.ts b/app/api/admin/material-logs/route.ts index 4ea744b..84143f6 100644 --- a/app/api/admin/material-logs/route.ts +++ b/app/api/admin/material-logs/route.ts @@ -27,72 +27,50 @@ export async function GET(req: NextRequest) { // 获取素材操作日志 let materialLogs: any[] = [] try { - // 尝试使用 Prisma 查询 - materialLogs = await (prisma as any).materialLog.findMany({ - take: limit, - skip: offset, - orderBy: { timestamp: 'desc' }, - include: { - user: { - select: { - username: true, - email: true, - }, - }, - material: { - select: { - productModel: true, - }, - }, - }, - }) - } catch (prismaError: any) { - console.error('[MaterialLogs] Prisma query error:', prismaError) - // 如果 Prisma 查询失败,尝试使用原始 SQL - try { - const rawLogs = await prisma.$queryRaw>` - SELECT - ml.id, - ml."action", - ml."materialId", - ml."userId", - ml."timestamp", - ml."changes", - u.username, - u.email, - m."productModel" - 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 = [] - } + const rawLogs = await prisma.$queryRaw>` + SELECT + ml.id, + ml."action", + ml."materialId", + ml."userId", + ml."timestamp", + ml."changes", + ml."materialSnapshot", + u.username, + u.email, + m."productModel" + 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, + materialSnapshot: log.materialSnapshot, + 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) + materialLogs = [] } // 字段名映射 @@ -136,7 +114,11 @@ export async function GET(req: NextRequest) { // 构建详细修改内容 const changeDetails: string[] = [] - const materialName = log.material?.productModel || '未知素材' + const snapshot = log.materialSnapshot && typeof log.materialSnapshot === 'object' + ? log.materialSnapshot as Record + : null + const snapshotName = typeof snapshot?.productModel === 'string' ? snapshot.productModel : '' + const materialName = log.material?.productModel || snapshotName || '已删除素材' const username = log.user?.username || '未知用户' if (log.changes && typeof log.changes === 'object') { @@ -182,4 +164,3 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: '获取日志失败' }, { status: 500 }) } } - diff --git a/app/api/materials/__tests__/delete-storage-safety.test.ts b/app/api/materials/__tests__/delete-storage-safety.test.ts new file mode 100644 index 0000000..97eec1b --- /dev/null +++ b/app/api/materials/__tests__/delete-storage-safety.test.ts @@ -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 = {}) { + 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 }, + }) + }) +}) diff --git a/app/api/materials/cleanup/route.ts b/app/api/materials/cleanup/route.ts index 9feba3e..95c9236 100644 --- a/app/api/materials/cleanup/route.ts +++ b/app/api/materials/cleanup/route.ts @@ -2,17 +2,24 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' -import { deleteFromS3, isS3Url } from '@/lib/s3' -import { unlink } from 'fs/promises' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' +import { + collectMaterialStorageUrls, + deleteMaterialStorageFiles, +} from '@/lib/material-storage-cleanup' // Cleanup materials deleted more than 7 days ago (admin only) export async function POST(req: NextRequest) { try { 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( - { error: '无权限' }, + { error: '仅最高权限账号可清理过期删除素材' }, { status: 403 } ) } @@ -31,89 +38,50 @@ export async function POST(req: NextRequest) { }, }) - // 收集所有需要删除的文件并删除 let deletedFiles = 0 + let skippedFiles = 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) { - 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 as any).previewUrls) { - try { - const previewUrls = JSON.parse((material as any).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()))) - - // 删除所有文件 - 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++ - } + const cleanupResult = await deleteMaterialStorageFiles(collectMaterialStorageUrls(material), { + logPrefix: `[Cleanup] Material ${material.id}`, + }) + + deletedFiles += cleanupResult.deletedFiles + skippedFiles += cleanupResult.skippedFiles + failedFiles += cleanupResult.failedFiles + + if (cleanupResult.failedFiles > 0) { + failedMaterials.push({ + id: material.id, + failedFiles: cleanupResult.failedFiles, + failures: cleanupResult.failures.slice(0, 10), + }) + } else { + safeToDeleteMaterialIds.push(material.id) } } - // 永久删除数据库记录 - const deletePromises = deletedMaterials.map((material) => - prisma.material.delete({ - where: { id: material.id }, + if (safeToDeleteMaterialIds.length > 0) { + await prisma.material.deleteMany({ + where: { id: { in: safeToDeleteMaterialIds } }, }) - ) - - await Promise.all(deletePromises) + } return NextResponse.json({ - message: '清理完成', - deletedCount: deletedMaterials.length + message: failedMaterials.length > 0 ? '部分清理完成,文件删除失败的素材记录已保留' : '清理完成', + deletedCount: safeToDeleteMaterialIds.length, + retainedCount: failedMaterials.length, + deletedFiles, + skippedFiles, + failedFiles, + failedMaterials, }) } catch (error) { console.error('[Materials] CLEANUP error:', error) diff --git a/app/api/materials/restore/route.ts b/app/api/materials/restore/route.ts index f1a479a..c009fbe 100644 --- a/app/api/materials/restore/route.ts +++ b/app/api/materials/restore/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' +import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments' // Restore material from trash (admin only) 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 await prisma.material.update({ where: { id }, diff --git a/app/api/materials/route.ts b/app/api/materials/route.ts index 9fdff58..790833b 100644 --- a/app/api/materials/route.ts +++ b/app/api/materials/route.ts @@ -4,8 +4,11 @@ import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite' import { createMaterialLog } from '@/lib/material-log' -import { deleteFromS3, isS3Url } from '@/lib/s3' import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments' +import { + collectMaterialStorageUrls, + deleteMaterialStorageFiles, +} from '@/lib/material-storage-cleanup' function parseDisplayImages(value: string | null): string[] { if (!value) return [] @@ -36,6 +39,22 @@ const ALLOWED_DYNAMIC_FILTER_FIELDS = new Set([ '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 export async function GET(req: NextRequest) { try { @@ -352,7 +371,6 @@ export async function DELETE(req: NextRequest) { // 获取当前操作者信息 const operatorId = (session.user as any).id - const isSuperAdmin = Boolean((session.user as any).isSuperAdmin) // 获取操作者的部门管理信息 const operator = await prisma.user.findUnique({ @@ -361,8 +379,10 @@ export async function DELETE(req: NextRequest) { id: true, department: true, managedDepartment: true, + isSuperAdmin: true, }, }) + const isSuperAdmin = Boolean(operator?.isSuperAdmin) // Get material to delete files, including uploader's department info 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用于日志记录 const userId = operatorId - // 权限验证 - // 超级管理员可以删除任意素材 + // 权限验证:最高权限账号可以删除任意素材;普通管理员只能软删除自己或管理范围内的素材。 if (!isSuperAdmin) { // 检查是否是自己上传的素材 const isOwnMaterial = material.userId === operatorId @@ -426,7 +452,28 @@ export async function DELETE(req: NextRequest) { } 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({ materialId: id, userId, @@ -437,84 +484,8 @@ export async function DELETE(req: NextRequest) { 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({ where: { id }, @@ -537,6 +508,7 @@ export async function DELETE(req: NextRequest) { newValue: false, }, }, + materialSnapshot: buildMaterialSnapshot(material), }) } diff --git a/lib/__tests__/material-log.test.ts b/lib/__tests__/material-log.test.ts new file mode 100644 index 0000000..47ed0c8 --- /dev/null +++ b/lib/__tests__/material-log.test.ts @@ -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: '测试素材', + }, + }), + }) + }) +}) diff --git a/lib/__tests__/material-storage-cleanup.test.ts b/lib/__tests__/material-storage-cleanup.test.ts new file mode 100644 index 0000000..6a6d107 --- /dev/null +++ b/lib/__tests__/material-storage-cleanup.test.ts @@ -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() + }) +}) diff --git a/lib/material-log.ts b/lib/material-log.ts index 237abb5..a48fcab 100644 --- a/lib/material-log.ts +++ b/lib/material-log.ts @@ -8,6 +8,8 @@ type MaterialLogChanges = Record< } > +type MaterialSnapshot = Record + let materialLogsTableEnsured = false async function executeSafely(sql: string) { @@ -28,10 +30,13 @@ export async function ensureMaterialLogsTable() { "userId" TEXT NOT NULL, "action" TEXT NOT NULL, "changes" JSONB, + "materialSnapshot" JSONB, "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP ) `) + await executeSafely(`ALTER TABLE "material_logs" ADD COLUMN IF NOT EXISTS "materialSnapshot" JSONB`) + await executeSafely( `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")` ) - await executeSafely(` - 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(`ALTER TABLE "material_logs" DROP CONSTRAINT IF EXISTS "material_logs_materialId_fkey"`) await executeSafely(` DO $$ @@ -76,6 +69,7 @@ interface CreateMaterialLogParams { userId: string action: string changes?: MaterialLogChanges | null + materialSnapshot?: MaterialSnapshot | null } export async function createMaterialLog({ @@ -83,6 +77,7 @@ export async function createMaterialLog({ userId, action, changes = null, + materialSnapshot = null, }: CreateMaterialLogParams) { try { await ensureMaterialLogsTable() @@ -92,6 +87,7 @@ export async function createMaterialLog({ userId, action, changes, + materialSnapshot, }, }) } catch (error) { diff --git a/lib/material-storage-cleanup.ts b/lib/material-storage-cleanup.ts index 953a9b2..54549a1 100644 --- a/lib/material-storage-cleanup.ts +++ b/lib/material-storage-cleanup.ts @@ -1,4 +1,5 @@ import { unlink } from 'fs/promises' +import { isAbsolute } from 'path' import { deleteFromS3, isS3Url } from '@/lib/s3' export interface MaterialStorageLike { @@ -60,6 +61,21 @@ function getErrorMessage(error: unknown): string { 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( fileUrls: string[], options: { logPrefix?: string } = {} @@ -75,7 +91,7 @@ export async function deleteMaterialStorageFiles( for (const fileUrl of uniqueFiles) { try { - if (isS3Url(fileUrl)) { + if (shouldDeleteFromS3(fileUrl)) { await deleteFromS3(fileUrl) result.deletedFiles++ continue diff --git a/prisma/migrations/20260525010000_preserve_material_audit_logs/migration.sql b/prisma/migrations/20260525010000_preserve_material_audit_logs/migration.sql new file mode 100644 index 0000000..3861adf --- /dev/null +++ b/prisma/migrations/20260525010000_preserve_material_audit_logs/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b606996..48a5637 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -238,7 +238,6 @@ model Material { likes MaterialLike[] favorites MaterialFavorite[] downloadLogs MaterialDownloadLog[] - logs MaterialLog[] browseHistory BrowseHistory[] shareTokens MaterialShareToken[] videoTranscodeJob VideoTranscodeJob? @@ -329,8 +328,8 @@ model MaterialLog { userId String action String // 'UPLOAD', 'UPDATE', 'DELETE', 'TOGGLE_PREMIUM' changes Json? // 详细变更记录,格式:{ "fieldName": { "oldValue": "...", "newValue": "..." } } + materialSnapshot Json? // 素材硬删除后仍用于审计展示的最小快照 timestamp DateTime @default(now()) - material Material @relation(fields: [materialId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([materialId])