保留素材硬删除审计并收紧清理权限
This commit is contained in:
parent
b596c5d08b
commit
ab73b5ec75
@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
388
app/api/materials/__tests__/delete-storage-safety.test.ts
Normal file
388
app/api/materials/__tests__/delete-storage-safety.test.ts
Normal 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 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
lib/__tests__/material-log.test.ts
Normal file
52
lib/__tests__/material-log.test.ts
Normal 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: '测试素材',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
48
lib/__tests__/material-storage-cleanup.test.ts
Normal file
48
lib/__tests__/material-storage-cleanup.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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";
|
||||||
@ -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])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user