From 0dabf6cee83fcb0129053ad40c93d177ce90f849 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 25 May 2026 15:13:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E7=AE=A1=E7=90=86=E6=9D=83?= =?UTF-8?q?=E9=99=90=E4=B8=8E=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/_components/UsersTab.tsx | 42 +- app/admin/_components/users/UserEditModal.tsx | 12 +- .../destructive-actions-superadmin.test.ts | 297 ++++++++ .../global-dictionaries-superadmin.test.ts | 271 ++++++++ .../__tests__/system-status-auth.test.ts | 101 +++ app/api/admin/cleanup-database/route.ts | 94 ++- .../admin/materials/transfer-owner/route.ts | 7 +- .../admin/migrate-material-fields/route.ts | 5 +- .../admin/reset-material-rankings/route.ts | 10 +- app/api/admin/system-status/route.ts | 9 +- .../organization-permissions.test.ts | 637 ++++++++++++++++++ app/api/admin/users/route.ts | 88 ++- .../users/set-department-manager/route.ts | 35 +- app/api/admin/users/set-role/route.ts | 33 +- app/api/admin/users/toggle-active/route.ts | 69 +- app/api/admin/users/toggle-download/route.ts | 48 +- .../admin/users/update-department/route.ts | 32 +- app/api/admin/users/update-hierarchy/route.ts | 4 + app/api/admin/users/update/route.ts | 13 + app/api/categories/route.ts | 9 +- app/api/category-fields/route.ts | 9 +- app/api/departments/__tests__/route.test.ts | 150 +++++ app/api/departments/route.ts | 35 +- app/api/platforms/route.ts | 13 +- app/api/scenes/route.ts | 9 +- .../system-settings/__tests__/route.test.ts | 347 ++++++++++ app/api/system-settings/route.ts | 217 +++--- app/api/system-settings/upload-logo/route.ts | 7 +- app/api/system/integrity/route.ts | 5 +- .../template-categories/[fieldKey]/route.ts | 5 +- lib/material-storage-cleanup.ts | 104 +++ 31 files changed, 2383 insertions(+), 334 deletions(-) create mode 100644 app/api/admin/__tests__/destructive-actions-superadmin.test.ts create mode 100644 app/api/admin/__tests__/global-dictionaries-superadmin.test.ts create mode 100644 app/api/admin/__tests__/system-status-auth.test.ts create mode 100644 app/api/admin/users/__tests__/organization-permissions.test.ts create mode 100644 app/api/departments/__tests__/route.test.ts create mode 100644 app/api/system-settings/__tests__/route.test.ts create mode 100644 lib/material-storage-cleanup.ts diff --git a/app/admin/_components/UsersTab.tsx b/app/admin/_components/UsersTab.tsx index 83788e4..9f49bd7 100644 --- a/app/admin/_components/UsersTab.tsx +++ b/app/admin/_components/UsersTab.tsx @@ -184,6 +184,9 @@ export default function UsersTab({ const handleSaveUser = useCallback(async () => { if (!editingUser) return + if (!currentIsSuperAdmin && selectedDepartment !== (editingUser.department || '')) { + return + } const success = await onUpdateUser(editingUser, { username: editingUsername, department: selectedDepartment, @@ -191,7 +194,7 @@ export default function UsersTab({ if (success) { handleCloseEditModal() } - }, [editingUser, editingUsername, onUpdateUser, selectedDepartment, handleCloseEditModal]) + }, [currentIsSuperAdmin, editingUser, editingUsername, onUpdateUser, selectedDepartment, handleCloseEditModal]) const handleModalRole = useCallback(async () => { if (!editingUser) return @@ -213,7 +216,7 @@ export default function UsersTab({ const { confirm } = useDialog() const handleDeleteUserInternal = useCallback(async () => { - if (!editingUser) return + if (!currentIsSuperAdmin || !editingUser) return const confirmed = await confirm({ title: '确认删除', message: '确定要删除这个用户吗?该用户的所有素材也会被删除。', @@ -224,7 +227,7 @@ export default function UsersTab({ if (success) { handleCloseEditModal() } - }, [editingUser, onDeleteUser, handleCloseEditModal, confirm]) + }, [currentIsSuperAdmin, editingUser, onDeleteUser, handleCloseEditModal, confirm]) const showTransferButton = useMemo( () => Boolean(currentIsSuperAdmin && editingUser && editingUser.email !== currentUserEmail), @@ -398,25 +401,25 @@ export default function UsersTab({ styles.buttonStyles.secondary, () => handleEditUser(user), )} - {actionButton( + {currentIsSuperAdmin && actionButton( user.isActive ? '禁用' : '激活', - user.isActive ? styles.buttonStyles.danger : styles.buttonStyles.success, - () => onToggleActive(user.id, user.isActive), - )} - {actionButton( - user.canDownload ? '禁止下载' : '允许下载', - styles.buttonStyles.secondary, - () => onToggleDownload(user.id, user.canDownload), - )} - {actionButton( - user.role === 'ADMIN' ? '取消管理员' : '设为管理员', - styles.buttonStyles.secondary, // 改为使用 secondary 样式,与"禁止下载"按钮一致 - () => onSetRole(user.id, user.role), - options?.showRoleToggle !== false && !user.isSuperAdmin, - )} + user.isActive ? styles.buttonStyles.danger : styles.buttonStyles.success, + () => onToggleActive(user.id, user.isActive), + )} + {currentIsSuperAdmin && actionButton( + user.canDownload ? '禁止下载' : '允许下载', + styles.buttonStyles.secondary, + () => onToggleDownload(user.id, user.canDownload), + )} + {currentIsSuperAdmin && actionButton( + user.role === 'ADMIN' ? '取消管理员' : '设为管理员', + styles.buttonStyles.secondary, // 改为使用 secondary 样式,与"禁止下载"按钮一致 + () => onSetRole(user.id, user.role), + options?.showRoleToggle !== false && !user.isSuperAdmin, + )} ) - }, [canManageUser, currentUserId, handleEditUser, onToggleActive, onToggleDownload, onSetRole]) + }, [canManageUser, currentIsSuperAdmin, currentUserId, handleEditUser, onToggleActive, onToggleDownload, onSetRole]) const renderUserRow = useCallback( (user: User, isManager = false) => ( @@ -547,6 +550,7 @@ export default function UsersTab({ selectedDepartment={selectedDepartment} managerNames={managerNames} showTransferButton={showTransferButton} + canChangeProtectedFields={Boolean(currentIsSuperAdmin)} onChangeUsername={setEditingUsername} onChangeDepartment={setSelectedDepartment} onClose={handleCloseEditModal} diff --git a/app/admin/_components/users/UserEditModal.tsx b/app/admin/_components/users/UserEditModal.tsx index bb94a16..50bde12 100644 --- a/app/admin/_components/users/UserEditModal.tsx +++ b/app/admin/_components/users/UserEditModal.tsx @@ -13,6 +13,7 @@ interface UserEditModalProps { selectedDepartment: string managerNames: string[] showTransferButton: boolean + canChangeProtectedFields: boolean onChangeUsername: (value: string) => void onChangeDepartment: (value: string) => void onClose: () => void @@ -33,6 +34,7 @@ export default function UserEditModal({ selectedDepartment, managerNames, showTransferButton, + canChangeProtectedFields, onChangeUsername, onChangeDepartment, onClose, @@ -116,6 +118,7 @@ export default function UserEditModal({

- 更改部门后将自动为其匹配该部门的管理员作为直属上级。 + {canChangeProtectedFields + ? '更改部门后将自动为其匹配该部门的管理员作为直属上级。' + : '只有最高权限账号可以调整用户部门。'}

+ {canChangeProtectedFields && (
@@ -157,7 +163,9 @@ export default function UserEditModal({
+ )} + {canChangeProtectedFields && (
+ )} {showTransferButton && (
@@ -213,4 +222,3 @@ export default function UserEditModal({ ) } - diff --git a/app/api/admin/__tests__/destructive-actions-superadmin.test.ts b/app/api/admin/__tests__/destructive-actions-superadmin.test.ts new file mode 100644 index 0000000..b40d643 --- /dev/null +++ b/app/api/admin/__tests__/destructive-actions-superadmin.test.ts @@ -0,0 +1,297 @@ +import { NextRequest } from 'next/server' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + $executeRawUnsafe: jest.fn(), + $queryRawUnsafe: jest.fn(), + $transaction: jest.fn(), + material: { + updateMany: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/s3', () => ({ + deleteFromS3: jest.fn(), + isS3Url: jest.fn(() => false), +})) + +jest.mock('@/lib/material-download-log', () => ({ + ensureMaterialDownloadLogsTable: jest.fn(), +})) + +jest.mock('@/lib/system-integrity', () => ({ + checkSystemIntegrity: jest.fn(), + repairSystemIntegrity: jest.fn(), + ensureBuiltinTemplates: jest.fn(), +})) + +jest.mock('fs/promises', () => ({ + access: jest.fn(), + unlink: jest.fn(), +})) + +const { getServerSession } = require('next-auth') +const { prisma } = require('@/lib/prisma') +const { ensureMaterialDownloadLogsTable } = require('@/lib/material-download-log') +const { repairSystemIntegrity } = require('@/lib/system-integrity') + +import { POST as cleanupDatabase } from '../cleanup-database/route' +import { POST as migrateMaterialFields } from '../migrate-material-fields/route' +import { POST as resetMaterialRankings } from '../reset-material-rankings/route' +import { POST as transferMaterialOwner } from '../materials/transfer-owner/route' +import { POST as repairSystemIntegrityRoute } from '../../system/integrity/route' + +describe('admin destructive actions require super admin', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('普通管理员不能清理数据库', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await cleanupDatabase( + new NextRequest('http://localhost/api/admin/cleanup-database', { method: 'POST' }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: 'admin-1' }, + select: { isSuperAdmin: true }, + }) + expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled() + expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled() + }) + + it('最高权限账号可以清理数据库', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + prisma.$executeRawUnsafe + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(3) + prisma.$queryRawUnsafe + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { table_name: 'invitations', count: 0 }, + { table_name: 'danmakus', count: 0 }, + { table_name: 'materials', count: 0 }, + { table_name: 'users', count: 1 }, + ]) + + const response = await cleanupDatabase( + new NextRequest('http://localhost/api/admin/cleanup-database', { method: 'POST' }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(prisma.$executeRawUnsafe).toHaveBeenCalled() + }) + + it('普通管理员不能重置排行榜数据', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await resetMaterialRankings( + new NextRequest('http://localhost/api/admin/reset-material-rankings', { method: 'POST' }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(ensureMaterialDownloadLogsTable).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('即使旧会话带最高权限标记,只要数据库已撤销也不能重置排行榜数据', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await resetMaterialRankings( + new NextRequest('http://localhost/api/admin/reset-material-rankings', { method: 'POST' }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(ensureMaterialDownloadLogsTable).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('最高权限账号可以重置排行榜数据', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + const tx = { + $executeRawUnsafe: jest + .fn() + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(3), + } + prisma.$transaction.mockImplementation((callback: any) => callback(tx)) + + const response = await resetMaterialRankings( + new NextRequest('http://localhost/api/admin/reset-material-rankings', { method: 'POST' }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(ensureMaterialDownloadLogsTable).toHaveBeenCalled() + expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(3) + }) + + it('普通管理员不能批量移交素材归属', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await transferMaterialOwner( + new NextRequest('http://localhost/api/admin/materials/transfer-owner', { + method: 'POST', + body: JSON.stringify({ materialIds: ['material-1'], targetUserId: 'user-2' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.material.updateMany).not.toHaveBeenCalled() + }) + + it('最高权限账号可以批量移交素材归属', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ + id: 'user-2', + username: 'target-user', + realName: '目标用户', + email: 'target@example.com', + }) + prisma.material.updateMany.mockResolvedValue({ count: 1 }) + + const response = await transferMaterialOwner( + new NextRequest('http://localhost/api/admin/materials/transfer-owner', { + method: 'POST', + body: JSON.stringify({ materialIds: ['material-1'], targetUserId: 'user-2' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(prisma.material.updateMany).toHaveBeenCalledWith({ + where: { deletedAt: null, id: { in: ['material-1'] } }, + data: expect.objectContaining({ + ownerType: 'USER', + ownerId: 'user-2', + userId: 'user-2', + authorName: '目标用户', + }), + }) + }) + + it('普通管理员不能执行素材字段迁移', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await migrateMaterialFields( + new NextRequest('http://localhost/api/admin/migrate-material-fields', { method: 'POST' }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.$executeRawUnsafe).not.toHaveBeenCalled() + expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled() + }) + + it('最高权限账号可以执行素材字段迁移', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + prisma.$executeRawUnsafe.mockResolvedValue(1) + prisma.$queryRawUnsafe.mockResolvedValue(1) + + const response = await migrateMaterialFields( + new NextRequest('http://localhost/api/admin/migrate-material-fields', { method: 'POST' }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(prisma.$executeRawUnsafe).toHaveBeenCalled() + expect(prisma.$queryRawUnsafe).toHaveBeenCalled() + }) + + it('普通管理员不能执行系统完整性修复', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await repairSystemIntegrityRoute( + new NextRequest('http://localhost/api/system/integrity', { + method: 'POST', + body: JSON.stringify({ fixOrphanedMaterials: true }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(repairSystemIntegrity).not.toHaveBeenCalled() + }) + + it('最高权限账号可以执行系统完整性修复', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + repairSystemIntegrity.mockResolvedValue({ success: true, repaired: 1, errors: [] }) + + const response = await repairSystemIntegrityRoute( + new NextRequest('http://localhost/api/system/integrity', { + method: 'POST', + body: JSON.stringify({ fixOrphanedMaterials: true }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.mode).toBe('full') + expect(repairSystemIntegrity).toHaveBeenCalledWith({ fixOrphanedMaterials: true }) + }) +}) diff --git a/app/api/admin/__tests__/global-dictionaries-superadmin.test.ts b/app/api/admin/__tests__/global-dictionaries-superadmin.test.ts new file mode 100644 index 0000000..500e3ed --- /dev/null +++ b/app/api/admin/__tests__/global-dictionaries-superadmin.test.ts @@ -0,0 +1,271 @@ +import { NextRequest } from 'next/server' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/admin-permissions', () => ({ + isCurrentSuperAdmin: jest.fn(), +})) + +jest.mock('@/lib/field-pool', () => ({ + getFieldPoolRecords: jest.fn(), + normalizeFieldOptionValue: jest.fn((value: string) => value.trim()), + syncFieldPoolFromTemplates: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + category: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + scene: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + platform: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + material: { + count: jest.fn(), + updateMany: jest.fn(), + }, + fieldDefinition: { + findUnique: jest.fn(), + update: jest.fn(), + }, + $transaction: jest.fn(), + }, +})) + +const { getServerSession } = require('next-auth') +const { isCurrentSuperAdmin } = require('@/lib/admin-permissions') +const { prisma } = require('@/lib/prisma') +const { normalizeFieldOptionValue } = require('@/lib/field-pool') + +import { PATCH as updateCategory, DELETE as deleteCategory } from '../../categories/route' +import { PATCH as updateScene, DELETE as deleteScene } from '../../scenes/route' +import { PUT as updatePlatform, DELETE as deletePlatform } from '../../platforms/route' +import { PATCH as toggleCategoryField, PUT as saveCategoryFields } from '../../category-fields/route' +import { PUT as updateTemplateCategoryOptions } from '../../template-categories/[fieldKey]/route' + +function jsonRequest(url: string, body: unknown, method = 'PUT') { + return new NextRequest(url, { + method, + body: JSON.stringify(body), + }) +} + +describe('全局字典和分类字段写操作权限', () => { + beforeEach(() => { + jest.resetAllMocks() + normalizeFieldOptionValue.mockImplementation((value: string) => value.trim()) + getServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN' }, + }) + isCurrentSuperAdmin.mockResolvedValue(false) + }) + + it('普通管理员不能修改或删除店铺分类', async () => { + const updateResponse = await updateCategory( + jsonRequest('http://localhost/api/categories', { id: 'cat-1', name: '新店铺' }, 'PATCH') + ) + const deleteResponse = await deleteCategory( + new NextRequest('http://localhost/api/categories?id=cat-1', { method: 'DELETE' }) + ) + + expect(updateResponse.status).toBe(403) + expect(deleteResponse.status).toBe(403) + expect(prisma.category.update).not.toHaveBeenCalled() + expect(prisma.category.delete).not.toHaveBeenCalled() + expect(prisma.material.updateMany).not.toHaveBeenCalled() + }) + + it('最高权限账号可以修改店铺分类并同步素材', async () => { + isCurrentSuperAdmin.mockResolvedValue(true) + prisma.category.findFirst.mockResolvedValue(null) + prisma.category.findUnique.mockResolvedValue({ id: 'cat-1', name: '旧店铺' }) + prisma.category.update.mockResolvedValue({ id: 'cat-1', name: '新店铺' }) + prisma.material.updateMany.mockResolvedValue({ count: 3 }) + + const response = await updateCategory( + jsonRequest('http://localhost/api/categories', { id: 'cat-1', name: '新店铺' }, 'PATCH') + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.name).toBe('新店铺') + expect(prisma.material.updateMany).toHaveBeenCalledWith({ + where: { categoryName: '旧店铺' }, + data: { categoryName: '新店铺' }, + }) + }) + + it('普通管理员不能修改或删除场景分类', async () => { + const updateResponse = await updateScene( + jsonRequest('http://localhost/api/scenes', { id: 'scene-1', name: '新场景' }, 'PATCH') + ) + const deleteResponse = await deleteScene( + new NextRequest('http://localhost/api/scenes?id=scene-1', { method: 'DELETE' }) + ) + + expect(updateResponse.status).toBe(403) + expect(deleteResponse.status).toBe(403) + expect(prisma.scene.update).not.toHaveBeenCalled() + expect(prisma.scene.delete).not.toHaveBeenCalled() + expect(prisma.material.updateMany).not.toHaveBeenCalled() + }) + + it('最高权限账号可以修改场景分类并同步素材', async () => { + isCurrentSuperAdmin.mockResolvedValue(true) + prisma.scene.findFirst.mockResolvedValue(null) + prisma.scene.findUnique.mockResolvedValue({ id: 'scene-1', name: '旧场景' }) + prisma.scene.update.mockResolvedValue({ id: 'scene-1', name: '新场景' }) + prisma.material.updateMany.mockResolvedValue({ count: 2 }) + + const response = await updateScene( + jsonRequest('http://localhost/api/scenes', { id: 'scene-1', name: '新场景' }, 'PATCH') + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.name).toBe('新场景') + expect(prisma.material.updateMany).toHaveBeenCalledWith({ + where: { sceneName: '旧场景' }, + data: { sceneName: '新场景' }, + }) + }) + + it('普通管理员不能修改或删除平台分类', async () => { + const updateResponse = await updatePlatform( + jsonRequest('http://localhost/api/platforms', { id: 'platform-1', name: '新平台' }) + ) + const deleteResponse = await deletePlatform( + new NextRequest('http://localhost/api/platforms?id=platform-1', { method: 'DELETE' }) + ) + + expect(updateResponse.status).toBe(403) + expect(deleteResponse.status).toBe(403) + expect(prisma.platform.update).not.toHaveBeenCalled() + expect(prisma.platform.delete).not.toHaveBeenCalled() + expect(prisma.material.updateMany).not.toHaveBeenCalled() + }) + + it('最高权限账号可以修改平台分类并同步素材', async () => { + isCurrentSuperAdmin.mockResolvedValue(true) + prisma.platform.findFirst.mockResolvedValue(null) + prisma.platform.findUnique.mockResolvedValue({ id: 'platform-1', name: '旧平台' }) + prisma.platform.update.mockResolvedValue({ id: 'platform-1', name: '新平台' }) + prisma.material.updateMany.mockResolvedValue({ count: 1 }) + + const response = await updatePlatform( + jsonRequest('http://localhost/api/platforms', { id: 'platform-1', name: '新平台' }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.name).toBe('新平台') + expect(prisma.material.updateMany).toHaveBeenCalledWith({ + where: { platformName: '旧平台' }, + data: { platformName: '新平台' }, + }) + }) + + it('普通管理员不能修改分类字段启用和排序', async () => { + const toggleResponse = await toggleCategoryField( + jsonRequest('http://localhost/api/category-fields', { fieldKey: 'brandName', enabled: true }, 'PATCH') + ) + const saveResponse = await saveCategoryFields( + jsonRequest('http://localhost/api/category-fields', { + updates: [{ fieldKey: 'brandName', filterDisplayName: '品牌', filterOrder: 1 }], + }) + ) + + expect(toggleResponse.status).toBe(403) + expect(saveResponse.status).toBe(403) + expect(prisma.fieldDefinition.update).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('最高权限账号可以修改分类字段配置', async () => { + isCurrentSuperAdmin.mockResolvedValue(true) + prisma.fieldDefinition.findUnique.mockResolvedValue({ id: 'field-1', key: 'brandName' }) + prisma.fieldDefinition.update.mockResolvedValue({ id: 'field-1', isFilterEnabled: true }) + + const response = await toggleCategoryField( + jsonRequest('http://localhost/api/category-fields', { fieldKey: 'brandName', enabled: true }, 'PATCH') + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(prisma.fieldDefinition.update).toHaveBeenCalledWith({ + where: { id: 'field-1' }, + data: { isFilterEnabled: true }, + }) + }) + + it('普通管理员不能批量修改模板分类选项', async () => { + const response = await updateTemplateCategoryOptions( + jsonRequest('http://localhost/api/template-categories/brandName', { options: ['A', 'B'] }), + { params: { fieldKey: 'brandName' } } + ) + + expect(response.status).toBe(403) + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('最高权限账号可以批量修改模板分类选项', async () => { + isCurrentSuperAdmin.mockResolvedValue(true) + const tx = { + fieldDefinition: { + findUnique: jest.fn().mockResolvedValue({ + id: 'field-1', + key: 'brandName', + options: [], + }), + }, + fieldOption: { + update: jest.fn(), + create: jest.fn().mockResolvedValue({}), + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + uploadField: { + updateMany: jest.fn().mockResolvedValue({ count: 2 }), + }, + } + prisma.$transaction.mockImplementation((callback: any) => callback(tx)) + + const response = await updateTemplateCategoryOptions( + jsonRequest('http://localhost/api/template-categories/brandName', { options: ['A', 'B'] }), + { params: { fieldKey: 'brandName' } } + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.options).toEqual(['A', 'B']) + expect(tx.fieldOption.create).toHaveBeenCalledTimes(2) + expect(tx.uploadField.updateMany).toHaveBeenCalledWith({ + where: { + key: 'brandName', + type: 'select', + }, + data: { + addToCategory: true, + optionsStatic: JSON.stringify(['A', 'B']), + }, + }) + }) +}) diff --git a/app/api/admin/__tests__/system-status-auth.test.ts b/app/api/admin/__tests__/system-status-auth.test.ts new file mode 100644 index 0000000..a2e3b5f --- /dev/null +++ b/app/api/admin/__tests__/system-status-auth.test.ts @@ -0,0 +1,101 @@ +import { NextRequest } from 'next/server' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + aggregate: jest.fn(), + count: jest.fn(), + groupBy: jest.fn(), + }, + material: { + aggregate: jest.fn(), + count: jest.fn(), + findMany: jest.fn(), + }, + systemSettings: { + findFirst: jest.fn(), + }, + $queryRaw: jest.fn(), + }, +})) + +const { getServerSession } = require('next-auth') +const { prisma } = require('@/lib/prisma') + +import { GET } from '../system-status/route' + +function createRequest() { + return new NextRequest('http://localhost/api/admin/system-status') +} + +describe('admin system status auth', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('未登录不能读取系统监控数据', async () => { + getServerSession.mockResolvedValue(null) + + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data.error).toContain('未登录') + expect(prisma.user.count).not.toHaveBeenCalled() + expect(prisma.material.count).not.toHaveBeenCalled() + }) + + it('普通用户不能读取系统监控数据', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'user-1', role: 'USER' }, + }) + + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('管理员') + expect(prisma.user.count).not.toHaveBeenCalled() + expect(prisma.material.count).not.toHaveBeenCalled() + }) + + it('管理员可以读取系统监控数据', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-1', role: 'ADMIN' }, + }) + + prisma.user.aggregate.mockResolvedValue({ _count: { id: 1 } }) + prisma.material.aggregate.mockResolvedValue({ _count: { id: 0 } }) + prisma.systemSettings.findFirst.mockResolvedValue({ + enableRegistration: true, + enableUserActivation: false, + siteName: 'FLNXI素材库', + }) + prisma.material.findMany.mockResolvedValue([]) + prisma.user.count + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(1) + prisma.material.count + .mockResolvedValueOnce(0) + .mockResolvedValueOnce(0) + prisma.user.groupBy + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + prisma.$queryRaw.mockResolvedValue([]) + + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.overview.totalUsers).toBe(1) + expect(data.status.database).toBe('healthy') + }) +}) diff --git a/app/api/admin/cleanup-database/route.ts b/app/api/admin/cleanup-database/route.ts index e4b398e..806cacb 100644 --- a/app/api/admin/cleanup-database/route.ts +++ b/app/api/admin/cleanup-database/route.ts @@ -2,8 +2,11 @@ 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, access } from 'fs/promises' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' +import { + collectMaterialStorageUrls, + deleteMaterialStorageFiles, +} from '@/lib/material-storage-cleanup' export const dynamic = 'force-dynamic' @@ -11,9 +14,9 @@ export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '需要管理员权限' }, + { error: '仅最高权限账号可执行数据库清理' }, { status: 403 } ) } @@ -38,39 +41,73 @@ export async function POST(req: NextRequest) { // 3. 清理所有素材数据和记录 // 先获取所有素材记录以便删除文件 - const materials = await prisma.$queryRawUnsafe>(` - SELECT id, "zipFilePath", "zipFileName" FROM materials + const materials = await prisma.$queryRawUnsafe>(` + SELECT + id, + "zipFilePath", + "previewUrl", + "previewUrls", + "displayImages", + "videoSourceUrl", + "videoPreview540Url", + "videoPreviewClipUrl", + "videoCoverUrl" + FROM materials `) let deletedFiles = 0 + let skippedFiles = 0 let failedFiles = 0 + const failedMaterials: Array<{ + id: string + failedFiles: number + failures: Array<{ fileUrl: string; reason: string }> + }> = [] - // 删除存储文件(优先S3,其次本地兼容) + // 删除存储文件。文件删除失败时保留数据库记录,避免清库后无法追踪残留对象。 for (const material of materials) { - if (material.zipFilePath && material.zipFilePath !== '') { - const filePath = material.zipFilePath - if (isS3Url(filePath)) { - try { - await deleteFromS3(filePath) - deletedFiles++ - } catch (error) { - console.error('[Cleanup] Failed to delete S3 object:', filePath, error) - failedFiles++ - } - continue - } + const cleanupResult = await deleteMaterialStorageFiles(collectMaterialStorageUrls(material), { + logPrefix: `[Cleanup] Material ${material.id}`, + }) - try { - await access(filePath) - await unlink(filePath) - deletedFiles++ - } catch (fileError: any) { - if (fileError.code !== 'ENOENT') { - failedFiles++ - } - } + 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), + }) } } + + if (failedMaterials.length > 0) { + return NextResponse.json( + { + error: '素材文件删除失败,数据库记录已保留,请处理存储连接后重试', + deleted: { + ...results, + materialFilesDeleted: deletedFiles, + materialFilesSkipped: skippedFiles, + materialFilesFailed: failedFiles, + }, + failedMaterials, + }, + { status: 500 } + ) + } // 删除所有素材记录 const deletedMaterials = await prisma.$executeRawUnsafe(` @@ -78,6 +115,7 @@ export async function POST(req: NextRequest) { `) results.materials = Number(deletedMaterials) results.materialFilesDeleted = deletedFiles + results.materialFilesSkipped = skippedFiles results.materialFilesFailed = failedFiles // 4. 获取清理后的统计信息 diff --git a/app/api/admin/materials/transfer-owner/route.ts b/app/api/admin/materials/transfer-owner/route.ts index 6330874..20de605 100644 --- a/app/api/admin/materials/transfer-owner/route.ts +++ b/app/api/admin/materials/transfer-owner/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 { isCurrentSuperAdmin } from '@/lib/admin-permissions' export const dynamic = 'force-dynamic' @@ -19,12 +20,10 @@ interface TransferRequestBody { export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) - const user = session?.user as any - const isAdmin = user?.role === 'ADMIN' || Boolean(user?.isSuperAdmin) - if (!session || !isAdmin) { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '需要管理员权限' }, + { error: '仅最高权限账号可批量移交素材归属' }, { status: 403 } ) } diff --git a/app/api/admin/migrate-material-fields/route.ts b/app/api/admin/migrate-material-fields/route.ts index e08baa8..1aeea7a 100644 --- a/app/api/admin/migrate-material-fields/route.ts +++ b/app/api/admin/migrate-material-fields/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 { isCurrentSuperAdmin } from '@/lib/admin-permissions' export const dynamic = 'force-dynamic' @@ -9,9 +10,9 @@ export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '需要管理员权限' }, + { error: '仅最高权限账号可执行素材字段迁移' }, { status: 403 } ) } diff --git a/app/api/admin/reset-material-rankings/route.ts b/app/api/admin/reset-material-rankings/route.ts index a32c53c..30ffae8 100644 --- a/app/api/admin/reset-material-rankings/route.ts +++ b/app/api/admin/reset-material-rankings/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' export const dynamic = 'force-dynamic' @@ -12,18 +13,15 @@ export const dynamic = 'force-dynamic' * - 清空下载日志表 material_download_logs * - 将 materials 表中的 likeCount / downloadCount 归零 * - * 仅管理员可用(ADMIN 或 isSuperAdmin) + * 仅最高权限账号可用。 */ export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) - const user = session?.user as any - const isAdmin = user?.role === 'ADMIN' || Boolean(user?.isSuperAdmin) - - if (!session || !isAdmin) { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '需要管理员权限' }, + { error: '仅最高权限账号可重置素材排行榜数据' }, { status: 403 } ) } diff --git a/app/api/admin/system-status/route.ts b/app/api/admin/system-status/route.ts index 0b8ed8c..7216d63 100644 --- a/app/api/admin/system-status/route.ts +++ b/app/api/admin/system-status/route.ts @@ -26,6 +26,13 @@ export async function GET(req: NextRequest) { ) } + if ((session.user as any)?.role !== 'ADMIN') { + return NextResponse.json( + { error: '需要管理员权限' }, + { status: 403 } + ) + } + // 并行获取各项统计数据(优化:减少查询次数) const thirtyDaysAgoForStats = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) @@ -306,4 +313,4 @@ export async function GET(req: NextRequest) { { status: 500 } ) } -} \ No newline at end of file +} diff --git a/app/api/admin/users/__tests__/organization-permissions.test.ts b/app/api/admin/users/__tests__/organization-permissions.test.ts new file mode 100644 index 0000000..bc43d1f --- /dev/null +++ b/app/api/admin/users/__tests__/organization-permissions.test.ts @@ -0,0 +1,637 @@ +import { NextRequest } from 'next/server' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + findFirst: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + department: { + findMany: jest.fn(), + }, + $transaction: jest.fn(), + }, +})) + +const { getServerSession } = require('next-auth') +const { prisma } = require('@/lib/prisma') + +import { GET as listUsers, DELETE as deleteUser } from '../route' +import { POST as updateUser } from '../update/route' +import { POST as updateDepartment } from '../update-department/route' +import { POST as setRole } from '../set-role/route' +import { POST as toggleActive } from '../toggle-active/route' +import { POST as toggleDownload } from '../toggle-download/route' +import { POST as updateHierarchy } from '../update-hierarchy/route' +import { + POST as setDepartmentManager, + DELETE as removeDepartmentManager, +} from '../set-department-manager/route' + +describe('admin user organization permissions', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('最高权限账号可以查看全量用户列表', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ + id: 'super-1', + role: 'ADMIN', + department: '管理部', + managedDepartment: null, + isSuperAdmin: true, + }) + prisma.user.count.mockResolvedValue(2) + prisma.user.findMany.mockResolvedValue([ + { id: 'super-1', username: '最高权限账号' }, + { id: 'user-2', username: '员工A' }, + ]) + + const response = await listUsers( + new NextRequest('http://localhost/api/admin/users?page=1&pageSize=20', { method: 'GET' }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.pagination.total).toBe(2) + expect(prisma.department.findMany).not.toHaveBeenCalled() + expect(prisma.user.count).toHaveBeenCalledWith({ where: {} }) + }) + + it('部门管理员只能查看管理部门树范围内的用户列表', async () => { + getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ + id: 'manager-1', + role: 'ADMIN', + department: '设计部', + managedDepartment: '设计部', + isSuperAdmin: false, + }) + prisma.department.findMany.mockResolvedValue([ + { name: '设计部' }, + { name: '设计部-建模组' }, + { name: '财务部' }, + ]) + prisma.user.count.mockResolvedValue(2) + prisma.user.findMany.mockResolvedValue([ + { id: 'manager-1', username: '设计主管' }, + { id: 'user-2', username: '建模员工' }, + ]) + + const response = await listUsers( + new NextRequest('http://localhost/api/admin/users?search=建模', { method: 'GET' }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.users).toHaveLength(2) + expect(prisma.user.count).toHaveBeenCalledWith({ + where: { + AND: [ + { + OR: [ + { id: 'manager-1' }, + { department: { in: ['设计部', '设计部-建模组'] } }, + { department: { startsWith: '设计部-' } }, + ], + }, + { + OR: [ + { username: { contains: '建模', mode: 'insensitive' } }, + { email: { contains: '建模', mode: 'insensitive' } }, + { department: { contains: '建模', mode: 'insensitive' } }, + ], + }, + ], + }, + }) + }) + + it('没有管理部门的普通管理员只能在用户列表中看到自己', async () => { + getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ + id: 'admin-1', + role: 'ADMIN', + department: '', + managedDepartment: null, + isSuperAdmin: false, + }) + prisma.user.count.mockResolvedValue(1) + prisma.user.findMany.mockResolvedValue([{ id: 'admin-1', username: '普通管理员' }]) + + const response = await listUsers( + new NextRequest('http://localhost/api/admin/users', { method: 'GET' }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.users).toHaveLength(1) + expect(prisma.user.count).toHaveBeenCalledWith({ + where: { AND: [{ id: 'admin-1' }] }, + }) + }) + + it('普通管理员不能删除用户', async () => { + getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await deleteUser( + new NextRequest('http://localhost/api/admin/users?id=user-2', { method: 'DELETE' }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.user.delete).not.toHaveBeenCalled() + }) + + it('最高权限账号不能删除另一个最高权限账号', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ isSuperAdmin: true }) + + const response = await deleteUser( + new NextRequest('http://localhost/api/admin/users?id=super-2', { method: 'DELETE' }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限账号') + expect(prisma.user.delete).not.toHaveBeenCalled() + }) + + it('最高权限账号可以删除普通用户', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ isSuperAdmin: false }) + prisma.user.delete.mockResolvedValue({ id: 'user-2' }) + + const response = await deleteUser( + new NextRequest('http://localhost/api/admin/users?id=user-2', { method: 'DELETE' }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.message).toBe('删除成功') + expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: 'user-2' } }) + }) + + it('普通管理员不能调整用户部门', async () => { + getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await updateDepartment( + new NextRequest('http://localhost/api/admin/users/update-department', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', department: '设计部' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('部门管理员不能通过统一更新接口提升用户角色', async () => { + getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ id: 'manager-1', managedDepartment: '设计部' }) + .mockResolvedValueOnce({ id: 'manager-1', isSuperAdmin: false }) + .mockResolvedValueOnce({ id: 'user-2', username: '员工A', department: '设计部' }) + .mockResolvedValueOnce({ id: 'user-2', isSuperAdmin: false }) + prisma.department.findMany.mockResolvedValue([{ name: '设计部' }]) + + const response = await updateUser( + new NextRequest('http://localhost/api/admin/users/update', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', username: '员工A', role: 'ADMIN' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('部门管理员不能通过统一更新接口调整部门和下载权限', async () => { + getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ id: 'manager-1', managedDepartment: '设计部' }) + .mockResolvedValueOnce({ id: 'manager-1', isSuperAdmin: false }) + .mockResolvedValueOnce({ id: 'user-2', username: '员工A', department: '设计部' }) + .mockResolvedValueOnce({ id: 'user-2', isSuperAdmin: false }) + prisma.department.findMany.mockResolvedValue([{ name: '设计部' }]) + + const response = await updateUser( + new NextRequest('http://localhost/api/admin/users/update', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-2', + department: '财务部', + canDownload: false, + }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('部门管理员可以在部门不变时修改成员姓名', async () => { + getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ id: 'manager-1', managedDepartment: '设计部' }) + .mockResolvedValueOnce({ id: 'manager-1', isSuperAdmin: false }) + .mockResolvedValueOnce({ id: 'user-2', username: '旧姓名', department: '设计部' }) + .mockResolvedValueOnce({ id: 'user-2', isSuperAdmin: false }) + prisma.department.findMany.mockResolvedValue([{ name: '设计部' }]) + + const tx = { + user: { + update: jest.fn().mockResolvedValue({ + id: 'user-2', + username: '新姓名', + department: '设计部', + }), + }, + material: { + updateMany: jest.fn().mockResolvedValue({ count: 2 }), + }, + } + prisma.$transaction.mockImplementation((callback: any) => callback(tx)) + + const response = await updateUser( + new NextRequest('http://localhost/api/admin/users/update', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-2', + username: '新姓名', + department: '设计部', + }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.materialsUpdated).toBe(true) + expect(tx.user.update).toHaveBeenCalledWith({ + where: { id: 'user-2' }, + data: { + username: '新姓名', + department: '设计部', + }, + }) + expect(tx.material.updateMany).toHaveBeenCalledWith({ + where: { userId: 'user-2' }, + data: { authorName: '新姓名' }, + }) + }) + + it('普通管理员不能通过旧接口修改用户角色', async () => { + getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await setRole( + new NextRequest('http://localhost/api/admin/users/set-role', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', role: 'ADMIN' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('最高权限账号可以通过旧接口修改普通用户角色', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN', email: 'super@example.com' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ id: 'user-2', email: 'user2@example.com', department: '设计部' }) + .mockResolvedValueOnce({ isSuperAdmin: false }) + prisma.user.update.mockResolvedValue({ + id: 'user-2', + email: 'user2@example.com', + username: 'user2', + role: 'ADMIN', + }) + + const response = await setRole( + new NextRequest('http://localhost/api/admin/users/set-role', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', role: 'ADMIN' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.user.role).toBe('ADMIN') + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-2' }, + data: { role: 'ADMIN' }, + }) + }) + + it('普通管理员不能通过旧接口启用或禁用用户', async () => { + getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await toggleActive( + new NextRequest('http://localhost/api/admin/users/toggle-active', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', isActive: false }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('最高权限账号可以通过旧接口启用或禁用普通用户', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ id: 'user-2', department: '设计部', isSuperAdmin: false }) + prisma.user.update.mockResolvedValue({ id: 'user-2', isActive: false }) + + const response = await toggleActive( + new NextRequest('http://localhost/api/admin/users/toggle-active', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', isActive: false }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.message).toContain('禁用') + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-2' }, + data: { isActive: false }, + }) + }) + + it('普通管理员不能通过旧接口修改下载权限', async () => { + getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await toggleDownload( + new NextRequest('http://localhost/api/admin/users/toggle-download', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('最高权限账号可以通过旧接口修改普通用户下载权限', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ + id: 'user-2', + department: '设计部', + role: 'USER', + canDownload: true, + isSuperAdmin: false, + }) + prisma.user.update.mockResolvedValue({ id: 'user-2', canDownload: false }) + + const response = await toggleDownload( + new NextRequest('http://localhost/api/admin/users/toggle-download', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.canDownload).toBe(false) + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-2' }, + data: { canDownload: false }, + }) + }) + + it('普通管理员不能通过旧接口调整用户层级', async () => { + getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await updateHierarchy( + new NextRequest('http://localhost/api/admin/users/update-hierarchy', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', parentId: 'manager-1' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('最高权限账号可以通过旧接口调整普通用户层级', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ id: 'super-1', isSuperAdmin: true }) + .mockResolvedValueOnce({ id: 'user-2', parentId: null, isSuperAdmin: false }) + prisma.user.findMany.mockResolvedValue([ + { id: 'super-1', parentId: null }, + { id: 'user-2', parentId: null }, + ]) + prisma.user.update.mockResolvedValue({ id: 'user-2', parentId: null }) + + const response = await updateHierarchy( + new NextRequest('http://localhost/api/admin/users/update-hierarchy', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', parentId: null }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-2' }, + data: { parentId: null }, + select: { id: true, parentId: true }, + }) + }) + + it('最高权限账号不能调整最高权限账号的部门', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ isSuperAdmin: true }) + + const response = await updateDepartment( + new NextRequest('http://localhost/api/admin/users/update-department', { + method: 'POST', + body: JSON.stringify({ userId: 'super-2', department: '设计部' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限账号') + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('最高权限账号可以调整普通用户部门', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ isSuperAdmin: false }) + prisma.user.update.mockResolvedValue({ id: 'user-2', username: 'user2', department: '设计部' }) + + const response = await updateDepartment( + new NextRequest('http://localhost/api/admin/users/update-department', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', department: '设计部' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.department).toBe('设计部') + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-2' }, + data: { department: '设计部' }, + }) + }) + + it('普通管理员不能设置部门管理员', async () => { + getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await setDepartmentManager( + new NextRequest('http://localhost/api/admin/users/set-department-manager', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', departmentName: '设计部' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('最高权限账号不能把最高权限账号设置为部门管理员', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ isSuperAdmin: true }) + + const response = await setDepartmentManager( + new NextRequest('http://localhost/api/admin/users/set-department-manager', { + method: 'POST', + body: JSON.stringify({ userId: 'super-2', departmentName: '设计部' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限账号') + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + it('最高权限账号可以设置普通用户为部门管理员', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ isSuperAdmin: false }) + prisma.user.findFirst.mockResolvedValue(null) + const tx = { + user: { + update: jest.fn().mockResolvedValue({ id: 'user-2', managedDepartment: '设计部' }), + updateMany: jest.fn().mockResolvedValue({ count: 2 }), + }, + } + prisma.$transaction.mockImplementation((callback: any) => callback(tx)) + + const response = await setDepartmentManager( + new NextRequest('http://localhost/api/admin/users/set-department-manager', { + method: 'POST', + body: JSON.stringify({ userId: 'user-2', departmentName: '设计部' }), + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.message).toContain('设置成功') + expect(tx.user.update).toHaveBeenCalledWith({ + where: { id: 'user-2' }, + data: { + managedDepartment: '设计部', + department: '设计部', + role: 'ADMIN', + }, + }) + }) + + it('普通管理员不能取消部门管理员', async () => { + getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await removeDepartmentManager( + new NextRequest('http://localhost/api/admin/users/set-department-manager?userId=user-2', { + method: 'DELETE', + }) + ) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(prisma.user.update).not.toHaveBeenCalled() + }) + + it('最高权限账号可以取消普通用户的部门管理员状态', async () => { + getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } }) + prisma.user.findUnique + .mockResolvedValueOnce({ isSuperAdmin: true }) + .mockResolvedValueOnce({ isSuperAdmin: false }) + prisma.user.update.mockResolvedValue({ id: 'user-2', managedDepartment: null }) + + const response = await removeDepartmentManager( + new NextRequest('http://localhost/api/admin/users/set-department-manager?userId=user-2', { + method: 'DELETE', + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.message).toContain('已取消') + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'user-2' }, + data: { managedDepartment: null }, + }) + }) +}) diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 6406f54..218efd7 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -1,7 +1,10 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' +import { Prisma } from '@prisma/client' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' +import { buildDepartmentDescendants } from '@/lib/departments' // Get all users (admin only) - 支持分页 export async function GET(request: NextRequest) { @@ -15,23 +18,63 @@ export async function GET(request: NextRequest) { ) } + const currentUser = await prisma.user.findUnique({ + where: { id: (session.user as any).id }, + select: { + id: true, + role: true, + department: true, + managedDepartment: true, + isSuperAdmin: true, + }, + }) + + if (!currentUser || (currentUser.role !== 'ADMIN' && !currentUser.isSuperAdmin)) { + return NextResponse.json( + { error: '无权限' }, + { status: 403 } + ) + } + const { searchParams } = new URL(request.url) const page = parseInt(searchParams.get('page') || '1', 10) const pageSize = Math.min(parseInt(searchParams.get('pageSize') || '20', 10), 100) // 最大100条 const skip = (page - 1) * pageSize const keyword = searchParams.get('search')?.trim() || '' + const keywordWhere: Prisma.UserWhereInput | undefined = keyword + ? { + OR: [ + { username: { contains: keyword, mode: 'insensitive' as const } }, + { email: { contains: keyword, mode: 'insensitive' as const } }, + { department: { contains: keyword, mode: 'insensitive' as const } }, + ], + } + : undefined + + let scopeWhere: Prisma.UserWhereInput | undefined + if (!currentUser.isSuperAdmin) { + const scopeDepartment = (currentUser.managedDepartment || currentUser.department || '').trim() + + if (scopeDepartment) { + const departments = await prisma.department.findMany({ select: { name: true } }) + const descendants = buildDepartmentDescendants(departments).get(scopeDepartment) + const scopedDepartments = Array.from(descendants || new Set([scopeDepartment])) + + scopeWhere = { + OR: [ + { id: currentUser.id }, + { department: { in: scopedDepartments } }, + { department: { startsWith: `${scopeDepartment}-` } }, + ], + } + } else { + scopeWhere = { id: currentUser.id } + } + } // 并行查询总数和数据 - const where = - keyword - ? { - OR: [ - { username: { contains: keyword, mode: 'insensitive' as const } }, - { email: { contains: keyword, mode: 'insensitive' as const } }, - { department: { contains: keyword, mode: 'insensitive' as const } }, - ], - } - : {} + const filters = [scopeWhere, keywordWhere].filter((filter): filter is Prisma.UserWhereInput => Boolean(filter)) + const where: Prisma.UserWhereInput = filters.length ? { AND: filters } : {} const [total, users] = await Promise.all([ prisma.user.count({ where }), @@ -81,14 +124,14 @@ export async function GET(request: NextRequest) { } } -// Delete user (admin only) +// Delete user (super admin only) export async function DELETE(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any).role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '无权限' }, + { error: '仅最高权限账号可删除用户' }, { status: 403 } ) } @@ -111,6 +154,25 @@ export async function DELETE(req: NextRequest) { ) } + const targetUser = await prisma.user.findUnique({ + where: { id }, + select: { isSuperAdmin: true }, + }) + + if (!targetUser) { + return NextResponse.json( + { error: '用户不存在' }, + { status: 404 } + ) + } + + if (targetUser.isSuperAdmin) { + return NextResponse.json( + { error: '不能删除最高权限账号' }, + { status: 403 } + ) + } + await prisma.user.delete({ where: { id }, }) diff --git a/app/api/admin/users/set-department-manager/route.ts b/app/api/admin/users/set-department-manager/route.ts index 93ec910..53c9a09 100644 --- a/app/api/admin/users/set-department-manager/route.ts +++ b/app/api/admin/users/set-department-manager/route.ts @@ -2,13 +2,14 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || session.user.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + if (!session || !(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '仅最高权限账号可设置部门管理员' }, { status: 403 }) } const { userId, departmentName } = await req.json() @@ -17,6 +18,19 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: '用户ID和部门名称不能为空' }, { status: 400 }) } + const targetUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { isSuperAdmin: true }, + }) + + if (!targetUser) { + return NextResponse.json({ error: '用户不存在' }, { status: 404 }) + } + + if (targetUser.isSuperAdmin) { + return NextResponse.json({ error: '不能把最高权限账号设置为部门管理员' }, { status: 403 }) + } + // 检查该部门是否已经有管理员 const existingManager = await prisma.user.findFirst({ where: { @@ -74,8 +88,8 @@ export async function DELETE(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || session.user.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + if (!session || !(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '仅最高权限账号可取消部门管理员' }, { status: 403 }) } const { searchParams } = new URL(req.url) @@ -85,6 +99,19 @@ export async function DELETE(req: NextRequest) { return NextResponse.json({ error: '用户ID不能为空' }, { status: 400 }) } + const targetUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { isSuperAdmin: true }, + }) + + if (!targetUser) { + return NextResponse.json({ error: '用户不存在' }, { status: 404 }) + } + + if (targetUser.isSuperAdmin) { + return NextResponse.json({ error: '不能调整最高权限账号的部门管理员状态' }, { status: 403 }) + } + // 取消部门管理员 const user = await prisma.user.update({ where: { id: userId }, diff --git a/app/api/admin/users/set-role/route.ts b/app/api/admin/users/set-role/route.ts index 4b05241..1595c04 100644 --- a/app/api/admin/users/set-role/route.ts +++ b/app/api/admin/users/set-role/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 { isCurrentSuperAdmin } from '@/lib/admin-permissions' export async function POST(req: NextRequest) { try { @@ -14,6 +15,12 @@ export async function POST(req: NextRequest) { { status: 403 } ) } + if (!(await isCurrentSuperAdmin(session))) { + return NextResponse.json( + { error: '权限不足:只有最高权限账号可以修改用户角色' }, + { status: 403 } + ) + } const { userId, role } = await req.json() @@ -32,24 +39,6 @@ export async function POST(req: NextRequest) { ) } - // 获取当前登录用户信息 - const currentUser = await prisma.user.findUnique({ - where: { id: (session.user as any).id }, - select: { id: true, email: true, managedDepartment: true } - }) - - if (!currentUser) { - return NextResponse.json( - { error: '当前用户不存在' }, - { status: 404 } - ) - } - - // 获取当前用户的完整信息(包括 isSuperAdmin) - const currentUserFull = await prisma.user.findUnique({ - where: { id: (session.user as any).id }, - }) as any - // 检查目标用户是否存在 const targetUser = await prisma.user.findUnique({ where: { id: userId }, @@ -76,14 +65,6 @@ export async function POST(req: NextRequest) { ) } - // 权限检查:只有最高权限账号可以修改角色 - if (!currentUserFull?.isSuperAdmin) { - return NextResponse.json( - { error: '权限不足:只有最高权限账号可以修改用户角色' }, - { status: 403 } - ) - } - // 最高权限账号不能被修改角色 if (targetUserFull?.isSuperAdmin) { return NextResponse.json( diff --git a/app/api/admin/users/toggle-active/route.ts b/app/api/admin/users/toggle-active/route.ts index 5d0f903..7c1de5b 100644 --- a/app/api/admin/users/toggle-active/route.ts +++ b/app/api/admin/users/toggle-active/route.ts @@ -2,7 +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' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' // 激活/禁用用户 export async function POST(req: NextRequest) { @@ -15,6 +15,12 @@ export async function POST(req: NextRequest) { { status: 403 } ) } + if (!(await isCurrentSuperAdmin(session))) { + return NextResponse.json( + { error: '权限不足:只有最高权限账号可以启用或禁用用户' }, + { status: 403 } + ) + } const { userId, isActive } = await req.json() @@ -25,34 +31,10 @@ export async function POST(req: NextRequest) { ) } - // 获取当前登录用户信息 - const currentUser = await prisma.user.findUnique({ - where: { id: (session.user as any).id }, - select: { id: true, managedDepartment: true } - }) as any - - if (!currentUser) { - return NextResponse.json( - { error: '当前用户不存在' }, - { status: 404 } - ) - } - - // 获取当前用户的完整信息(包括 isSuperAdmin) - const currentUserFull = await prisma.user.findUnique({ - where: { id: (session.user as any).id }, - }) as any - - let departmentDescendantsMap: Map> | undefined - if (currentUser.managedDepartment && !currentUserFull?.isSuperAdmin) { - const departments = await prisma.department.findMany({ select: { name: true } }) - departmentDescendantsMap = buildDepartmentDescendants(departments) - } - // 获取目标用户信息 const targetUser = await prisma.user.findUnique({ where: { id: userId }, - select: { id: true, department: true } + select: { id: true, department: true, isSuperAdmin: true } }) if (!targetUser) { @@ -62,11 +44,6 @@ export async function POST(req: NextRequest) { ) } - // 获取目标用户的完整信息(包括 isSuperAdmin) - const targetUserFull = await prisma.user.findUnique({ - where: { id: userId }, - }) as any - // 不能禁用自己 if (userId === (session.user as any).id) { return NextResponse.json( @@ -75,34 +52,10 @@ export async function POST(req: NextRequest) { ) } - // 权限检查:只有最高权限账号或目标用户所属部门的管理员可以操作 - if (!currentUserFull?.isSuperAdmin) { - // 如果是部门管理员,只能操作自己管理的部门下的用户 - if (currentUser.managedDepartment) { - const allowed = isDepartmentWithinScope( - currentUser.managedDepartment, - targetUser.department, - departmentDescendantsMap - ) - if (!allowed) { - return NextResponse.json( - { error: '权限不足:只能操作自己管理的部门树范围内的用户' }, - { status: 403 } - ) - } - } else { - // 没有管理部门的管理员不能操作任何人 - return NextResponse.json( - { error: '权限不足' }, - { status: 403 } - ) - } - } - - // 最高权限账号不能被非最高权限账号操作 - if (targetUserFull?.isSuperAdmin && !currentUserFull?.isSuperAdmin) { + // 最高权限账号不能被禁用,避免客户现场误锁死系统。 + if (targetUser.isSuperAdmin) { return NextResponse.json( - { error: '权限不足:不能操作最高权限账号' }, + { error: '不能操作最高权限账号' }, { status: 403 } ) } diff --git a/app/api/admin/users/toggle-download/route.ts b/app/api/admin/users/toggle-download/route.ts index 52480e9..290483c 100644 --- a/app/api/admin/users/toggle-download/route.ts +++ b/app/api/admin/users/toggle-download/route.ts @@ -2,7 +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' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' // Toggle user download permission (admin only) export async function POST(req: NextRequest) { @@ -16,6 +16,12 @@ export async function POST(req: NextRequest) { { status: 403 } ) } + if (!(await isCurrentSuperAdmin(session))) { + return NextResponse.json( + { error: '权限不足:只有最高权限账号可以修改下载权限' }, + { status: 403 } + ) + } const { userId } = await req.json() @@ -34,24 +40,6 @@ export async function POST(req: NextRequest) { ) } - const currentUser = await prisma.user.findUnique({ - where: { id: (session.user as any).id }, - select: { id: true, managedDepartment: true, isSuperAdmin: true }, - }) - - if (!currentUser) { - return NextResponse.json( - { error: '当前用户不存在' }, - { status: 404 } - ) - } - - let departmentDescendantsMap: Map> | undefined - if (currentUser.managedDepartment && !currentUser.isSuperAdmin) { - const departments = await prisma.department.findMany({ select: { name: true } }) - departmentDescendantsMap = buildDepartmentDescendants(departments) - } - // Get target user const targetUser = await prisma.user.findUnique({ where: { id: userId }, @@ -79,33 +67,13 @@ export async function POST(req: NextRequest) { ) } - if (targetUser.isSuperAdmin && !currentUser.isSuperAdmin) { + if (targetUser.isSuperAdmin) { return NextResponse.json( { error: '不能修改最高权限账号的下载权限' }, { status: 403 } ) } - if (!currentUser.isSuperAdmin) { - if (!currentUser.managedDepartment) { - return NextResponse.json( - { error: '权限不足:未被授权管理任何分支' }, - { status: 403 } - ) - } - const allowed = isDepartmentWithinScope( - currentUser.managedDepartment, - targetUser.department, - departmentDescendantsMap - ) - if (!allowed) { - return NextResponse.json( - { error: '权限不足:只能管理自己部门树范围内的用户下载权限' }, - { status: 403 } - ) - } - } - // Toggle download permission const updatedUser = await prisma.user.update({ where: { id: userId }, diff --git a/app/api/admin/users/update-department/route.ts b/app/api/admin/users/update-department/route.ts index a729372..62cce98 100644 --- a/app/api/admin/users/update-department/route.ts +++ b/app/api/admin/users/update-department/route.ts @@ -2,23 +2,22 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' // 更新用户部门 export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '权限不足' }, + { error: '仅最高权限账号可调整用户部门' }, { status: 403 } ) } const { userId, department } = await req.json() - console.log('[Update Department] Request:', { userId, department }) - if (!userId) { return NextResponse.json( { error: '缺少用户ID' }, @@ -26,6 +25,25 @@ export async function POST(req: NextRequest) { ) } + const targetUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { isSuperAdmin: true }, + }) + + if (!targetUser) { + return NextResponse.json( + { error: '用户不存在' }, + { status: 404 } + ) + } + + if (targetUser.isSuperAdmin) { + return NextResponse.json( + { error: '不能调整最高权限账号的部门' }, + { status: 403 } + ) + } + // 使用事务更新用户和素材(如果需要同步更新素材信息) const user = await prisma.user.update({ where: { id: userId }, @@ -35,12 +53,6 @@ export async function POST(req: NextRequest) { // 注意:部门信息通常不需要同步到素材,但如果将来需要,可以在这里添加 // 目前素材表中没有部门字段,所以不需要同步 - console.log('[Update Department] Success:', { - userId: user.id, - username: user.username, - department: user.department - }) - return NextResponse.json(user) } catch (error) { console.error('[Update Department] Error:', error) diff --git a/app/api/admin/users/update-hierarchy/route.ts b/app/api/admin/users/update-hierarchy/route.ts index 5511958..38182a2 100644 --- a/app/api/admin/users/update-hierarchy/route.ts +++ b/app/api/admin/users/update-hierarchy/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 { isCurrentSuperAdmin } from '@/lib/admin-permissions' function buildParentMap(users: Array<{ id: string; parentId: string | null }>) { const map = new Map() @@ -25,6 +26,9 @@ export async function POST(req: NextRequest) { if (!session || sessionUser?.role !== 'ADMIN') { return NextResponse.json({ error: '无权限' }, { status: 403 }) } + if (!(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '权限不足:只有最高权限账号可以调整用户层级' }, { status: 403 }) + } const { userId, parentId } = await req.json() if (!userId) { diff --git a/app/api/admin/users/update/route.ts b/app/api/admin/users/update/route.ts index 11c760b..fec4ea4 100644 --- a/app/api/admin/users/update/route.ts +++ b/app/api/admin/users/update/route.ts @@ -103,6 +103,19 @@ export async function POST(req: NextRequest) { ) } + const hasProtectedPermissionFields = + role !== undefined || + (department !== undefined && (department || '') !== (oldUser.department || '')) || + isActive !== undefined || + canDownload !== undefined + + if (!currentUserFull?.isSuperAdmin && hasProtectedPermissionFields) { + return NextResponse.json( + { error: '权限不足:只有最高权限账号可以修改角色、部门、启用状态或下载权限' }, + { status: 403 } + ) + } + // 构建更新数据 const updateData: any = {} if (username !== undefined) updateData.username = username diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts index 95a5f8a..f2c32c6 100644 --- a/app/api/categories/route.ts +++ b/app/api/categories/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 { isCurrentSuperAdmin } from '@/lib/admin-permissions' // 获取所有使用店铺 export async function GET() { @@ -100,9 +101,9 @@ export async function PATCH(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '权限不足' }, + { error: '仅最高权限账号可修改店铺分类' }, { status: 403 } ) } @@ -190,9 +191,9 @@ export async function DELETE(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '权限不足' }, + { error: '仅最高权限账号可删除店铺分类' }, { status: 403 } ) } diff --git a/app/api/category-fields/route.ts b/app/api/category-fields/route.ts index cd02ab5..f34d660 100644 --- a/app/api/category-fields/route.ts +++ b/app/api/category-fields/route.ts @@ -8,6 +8,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' import { getFieldPoolRecords, syncFieldPoolFromTemplates } from '@/lib/field-pool' import { prisma } from '@/lib/prisma' @@ -51,8 +52,8 @@ export async function GET() { export async function PATCH(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { - return NextResponse.json({ error: '权限不足' }, { status: 403 }) + if (!session || !(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '仅最高权限账号可修改分类字段配置' }, { status: 403 }) } const { fieldKey, enabled } = await req.json() @@ -84,8 +85,8 @@ export async function PATCH(req: NextRequest) { export async function PUT(req: NextRequest) { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { - return NextResponse.json({ error: '权限不足' }, { status: 403 }) + if (!session || !(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '仅最高权限账号可修改分类字段配置' }, { status: 403 }) } try { diff --git a/app/api/departments/__tests__/route.test.ts b/app/api/departments/__tests__/route.test.ts new file mode 100644 index 0000000..ad594b9 --- /dev/null +++ b/app/api/departments/__tests__/route.test.ts @@ -0,0 +1,150 @@ +import { NextRequest } from 'next/server' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + department: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + deleteMany: jest.fn(), + }, + user: { + findUnique: jest.fn(), + updateMany: jest.fn(), + }, + $transaction: jest.fn(), + }, +})) + +jest.mock('@/lib/license', () => ({ + canUseDepartmentIsolation: jest.fn(), +})) + +const { getServerSession } = require('next-auth') +const { prisma } = require('@/lib/prisma') +const { canUseDepartmentIsolation } = require('@/lib/license') + +import { POST, PATCH, DELETE } from '../route' + +describe('departments route license guard', () => { + beforeEach(() => { + jest.clearAllMocks() + getServerSession.mockResolvedValue({ + user: { id: 'admin-id', role: 'ADMIN' }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + canUseDepartmentIsolation.mockResolvedValue(true) + }) + + test('POST 普通管理员不能创建部门', async () => { + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const request = new NextRequest('http://localhost/api/departments', { + method: 'POST', + body: JSON.stringify({ name: '设计部' }), + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(canUseDepartmentIsolation).not.toHaveBeenCalled() + expect(prisma.department.create).not.toHaveBeenCalled() + }) + + test('POST 最高权限账号可以创建部门', async () => { + prisma.department.findUnique.mockResolvedValue(null) + prisma.department.create.mockResolvedValue({ id: 'dept-1', name: '设计部' }) + + const request = new NextRequest('http://localhost/api/departments', { + method: 'POST', + body: JSON.stringify({ name: '设计部' }), + }) + + const response = await POST(request) + const data = await response.json() + + expect(response.status).toBe(201) + expect(data.name).toBe('设计部') + expect(prisma.department.create).toHaveBeenCalledWith({ data: { name: '设计部' } }) + }) + + test('PATCH 应在许可证不支持时返回 403', async () => { + canUseDepartmentIsolation.mockResolvedValue(false) + + const request = new NextRequest('http://localhost/api/departments', { + method: 'PATCH', + body: JSON.stringify({ + id: 'dept-1', + name: '新部门', + }), + }) + + const response = await PATCH(request) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('部门隔离功能需要专业版许可证') + expect(prisma.department.findUnique).not.toHaveBeenCalled() + }) + + test('PATCH 普通管理员不能重命名部门', async () => { + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const request = new NextRequest('http://localhost/api/departments', { + method: 'PATCH', + body: JSON.stringify({ + id: 'dept-1', + name: '新部门', + }), + }) + + const response = await PATCH(request) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(canUseDepartmentIsolation).not.toHaveBeenCalled() + expect(prisma.department.findUnique).not.toHaveBeenCalled() + }) + + test('DELETE 应在许可证不支持时返回 403', async () => { + canUseDepartmentIsolation.mockResolvedValue(false) + + const request = new NextRequest('http://localhost/api/departments?id=dept-1', { + method: 'DELETE', + }) + + const response = await DELETE(request) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('部门隔离功能需要专业版许可证') + expect(prisma.department.findUnique).not.toHaveBeenCalled() + }) + + test('DELETE 普通管理员不能删除部门', async () => { + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const request = new NextRequest('http://localhost/api/departments?id=dept-1', { + method: 'DELETE', + }) + + const response = await DELETE(request) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(canUseDepartmentIsolation).not.toHaveBeenCalled() + expect(prisma.department.findUnique).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/departments/route.ts b/app/api/departments/route.ts index 78895d3..4935350 100644 --- a/app/api/departments/route.ts +++ b/app/api/departments/route.ts @@ -3,6 +3,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { canUseDepartmentIsolation } from '@/lib/license' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' // Get all departments export async function GET() { @@ -23,20 +24,20 @@ export async function GET() { } } -// Create a new department (admin only) +// Create a new department (super admin only) export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any).role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '无权限' }, + { error: '仅最高权限账号可维护部门结构' }, { status: 403 } ) } // 检查部门隔离功能是否可用 - if (!canUseDepartmentIsolation()) { + if (!(await canUseDepartmentIsolation())) { return NextResponse.json( { error: '部门隔离功能需要专业版许可证,请升级后使用' }, { status: 403 } @@ -78,14 +79,21 @@ export async function POST(req: NextRequest) { } } -// Rename a department (admin only) +// Rename a department (super admin only) export async function PATCH(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any).role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '无权限' }, + { error: '仅最高权限账号可维护部门结构' }, + { status: 403 } + ) + } + + if (!(await canUseDepartmentIsolation())) { + return NextResponse.json( + { error: '部门隔离功能需要专业版许可证,请升级后使用' }, { status: 403 } ) } @@ -235,14 +243,21 @@ export async function PATCH(req: NextRequest) { } } -// Delete a department (admin only) +// Delete a department (super admin only) export async function DELETE(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any).role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '无权限' }, + { error: '仅最高权限账号可维护部门结构' }, + { status: 403 } + ) + } + + if (!(await canUseDepartmentIsolation())) { + return NextResponse.json( + { error: '部门隔离功能需要专业版许可证,请升级后使用' }, { status: 403 } ) } diff --git a/app/api/platforms/route.ts b/app/api/platforms/route.ts index 03df2a9..7162da3 100644 --- a/app/api/platforms/route.ts +++ b/app/api/platforms/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' // GET - 获取所有平台分类(包含数量统计) export async function GET(request: NextRequest) { @@ -47,8 +48,8 @@ export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || session.user.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + if (!session || !(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '仅最高权限账号可修改平台分类' }, { status: 403 }) } const { name } = await request.json() @@ -82,8 +83,8 @@ export async function PUT(request: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || session.user.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + if (!session || !(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '仅最高权限账号可修改平台分类' }, { status: 403 }) } const { id, name, options } = await request.json() @@ -154,8 +155,8 @@ export async function DELETE(request: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || session.user.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + if (!session || !(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '仅最高权限账号可删除平台分类' }, { status: 403 }) } const { searchParams } = new URL(request.url) diff --git a/app/api/scenes/route.ts b/app/api/scenes/route.ts index ff0aa77..fa12623 100644 --- a/app/api/scenes/route.ts +++ b/app/api/scenes/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 { isCurrentSuperAdmin } from '@/lib/admin-permissions' // 获取所有场景类别 export async function GET(req: NextRequest) { @@ -110,9 +111,9 @@ export async function PATCH(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '权限不足' }, + { error: '仅最高权限账号可修改场景分类' }, { status: 403 } ) } @@ -200,9 +201,9 @@ export async function DELETE(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '权限不足' }, + { error: '仅最高权限账号可删除场景分类' }, { status: 403 } ) } diff --git a/app/api/system-settings/__tests__/route.test.ts b/app/api/system-settings/__tests__/route.test.ts new file mode 100644 index 0000000..69d3ae1 --- /dev/null +++ b/app/api/system-settings/__tests__/route.test.ts @@ -0,0 +1,347 @@ +import { NextRequest } from 'next/server' +import { GET, PUT } from '../route' +import { POST as uploadLogo } from '../upload-logo/route' + +jest.mock('@/lib/auth', () => ({ + authOptions: {}, +})) + +jest.mock('next-auth', () => ({ + getServerSession: jest.fn(), +})) + +jest.mock('@/lib/prisma', () => ({ + prisma: { + systemSettings: { + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + $queryRaw: jest.fn(), + $executeRawUnsafe: jest.fn(), + }, +})) + +jest.mock('@/lib/cache', () => ({ + cache: { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }, +})) + +jest.mock('@/lib/system-settings-columns', () => ({ + ensureSystemSettingsOptionalColumns: jest.fn(), +})) + +jest.mock('@/lib/config', () => ({ + defaultLogos: { + header: '/logo/header.png', + login: '/logo/login.png', + }, + featureFlags: { + aiEditEnabled: false, + tunnelEnabled: false, + }, +})) + +jest.mock('@/lib/license', () => ({ + canUseSystemSettings: jest.fn(), +})) + +jest.mock('@/lib/s3', () => ({ + uploadToS3: jest.fn(), +})) + +const { getServerSession } = require('next-auth') +const { prisma } = require('@/lib/prisma') +const { cache } = require('@/lib/cache') +const { canUseSystemSettings } = require('@/lib/license') +const { uploadToS3 } = require('@/lib/s3') + +const mockSettings = { + id: 'settings-1', + headerBgColor: '#1f2937', + siteName: 'FLNXI素材库', + premiumRatio: 10, + updatedAt: new Date('2026-05-24T00:00:00.000Z'), + createdAt: new Date('2026-05-23T00:00:00.000Z'), + headerLogoUrl: '/logo/header.png', + loginLogoUrl: '/logo/login.png', + s3AccessKey: 'access-key', + s3Bucket: 'private-bucket', + s3Endpoint: 'http://minio:9000', + s3PublicUrl: 'http://minio.local/materials', + s3Region: 'us-east-1', + s3SecretKey: 'secret-key', + enableRegistration: true, + enableUserActivation: false, + enableAiImageEdit: true, + enableTunnelService: true, +} + +describe('GET /api/system-settings', () => { + const originalEnv = process.env + + beforeEach(() => { + jest.resetAllMocks() + process.env = { ...originalEnv } + cache.get.mockReturnValue(null) + prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings }) + prisma.$queryRaw.mockResolvedValue([ + { + enabledBuiltinTemplates: '["3dmax-material"]', + homeDeleteButtonMode: 'ADMIN_ONLY', + premiumRatio: 10, + }, + ]) + }) + + afterAll(() => { + process.env = originalEnv + }) + + it('未登录时只返回公开展示字段,不返回对象存储和功能开关', async () => { + getServerSession.mockResolvedValue(null) + + const response = await GET() + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toMatchObject({ + headerBgColor: '#1f2937', + siteName: 'FLNXI素材库', + headerLogoUrl: '/logo/header.png', + loginLogoUrl: '/logo/login.png', + }) + expect(data.s3AccessKey).toBeUndefined() + expect(data.s3SecretKey).toBeUndefined() + expect(data.s3Bucket).toBeUndefined() + expect(data.s3Endpoint).toBeUndefined() + expect(data.enableRegistration).toBeUndefined() + expect(data.enableUserActivation).toBeUndefined() + expect(data.enableAiImageEdit).toBeUndefined() + expect(data.enableTunnelService).toBeUndefined() + expect(data.enabledBuiltinTemplates).toBeUndefined() + expect(data.homeDeleteButtonMode).toBeUndefined() + }) + + it('普通管理员登录时返回后台设置但不返回对象存储密钥', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await GET() + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.s3AccessKey).toBeUndefined() + expect(data.s3SecretKey).toBeUndefined() + expect(data.s3Bucket).toBe('private-bucket') + expect(data.enableRegistration).toBe(true) + expect(data.enableAiImageEdit).toBe(true) + expect(data.enabledBuiltinTemplates).toBe('["3dmax-material"]') + expect(data.homeDeleteButtonMode).toBe('ADMIN_ONLY') + }) + + it('最高权限账号登录时才返回对象存储密钥', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + + const response = await GET() + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.s3AccessKey).toBe('access-key') + expect(data.s3SecretKey).toBe('secret-key') + expect(data.s3Bucket).toBe('private-bucket') + }) + + it('GET 不会用环境变量补写对象存储密钥', async () => { + getServerSession.mockResolvedValue(null) + prisma.systemSettings.findFirst.mockResolvedValue({ + ...mockSettings, + s3AccessKey: null, + s3SecretKey: null, + s3Bucket: null, + s3Endpoint: null, + s3PublicUrl: null, + }) + process.env.S3_ACCESS_KEY_ID = 'env-access-key' + process.env.S3_SECRET_ACCESS_KEY = 'env-secret-key' + process.env.S3_BUCKET = 'env-bucket' + process.env.S3_ENDPOINT = 'http://env-minio:9000' + + await GET() + + expect(prisma.systemSettings.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + s3AccessKey: expect.any(String), + }), + }), + ) + expect(prisma.systemSettings.update).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + s3SecretKey: expect.any(String), + }), + }), + ) + }) + + it('数据库异常时,未登录读取会返回公共兜底设置而不是 500', async () => { + getServerSession.mockResolvedValue(null) + prisma.systemSettings.findFirst.mockRejectedValue(new Error('database offline')) + + const response = await GET() + const data = await response.json() + + expect(response.status).toBe(200) + expect(data).toMatchObject({ + headerBgColor: '#1f2937', + siteName: 'FLNXI素材库', + headerLogoUrl: '', + loginLogoUrl: '', + }) + }) +}) + +describe('PUT /api/system-settings 权限边界', () => { + beforeEach(() => { + jest.resetAllMocks() + cache.get.mockReturnValue(null) + canUseSystemSettings.mockResolvedValue(true) + prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings }) + prisma.systemSettings.update.mockResolvedValue({ + ...mockSettings, + siteName: '新系统名', + }) + }) + + it('普通管理员不能修改任何系统设置', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await PUT(new NextRequest('http://localhost/api/system-settings', { + method: 'PUT', + body: JSON.stringify({ + siteName: '普通管理员不应能改', + s3SecretKey: 'new-secret', + enableRegistration: false, + }), + })) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(canUseSystemSettings).not.toHaveBeenCalled() + expect(prisma.systemSettings.update).not.toHaveBeenCalled() + }) + + it('旧会话仍带最高权限标记但数据库已撤销时不能修改系统设置', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await PUT(new NextRequest('http://localhost/api/system-settings', { + method: 'PUT', + body: JSON.stringify({ siteName: '不应写入' }), + })) + + expect(response.status).toBe(403) + expect(prisma.systemSettings.update).not.toHaveBeenCalled() + }) + + it('最高权限账号可以修改系统设置', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + + const response = await PUT(new NextRequest('http://localhost/api/system-settings', { + method: 'PUT', + body: JSON.stringify({ + siteName: '新系统名', + s3SecretKey: 'new-secret', + enableRegistration: false, + }), + })) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.siteName).toBe('新系统名') + expect(prisma.systemSettings.update).toHaveBeenCalledWith({ + where: { id: 'settings-1' }, + data: expect.objectContaining({ + siteName: '新系统名', + s3SecretKey: 'new-secret', + enableRegistration: false, + }), + }) + }) +}) + +describe('POST /api/system-settings/upload-logo 权限边界', () => { + beforeEach(() => { + jest.resetAllMocks() + uploadToS3.mockResolvedValue('https://cdn.example.com/header-logo.png') + prisma.systemSettings.findFirst.mockResolvedValue({ ...mockSettings }) + prisma.systemSettings.update.mockResolvedValue({ + ...mockSettings, + headerLogoUrl: 'https://cdn.example.com/header-logo.png', + }) + }) + + function createLogoRequest() { + const formData = new FormData() + formData.set('type', 'header') + formData.set('logo', new File([Buffer.from('logo')], 'logo.png', { type: 'image/png' })) + return new NextRequest('http://localhost/api/system-settings/upload-logo', { + method: 'POST', + body: formData, + }) + } + + it('普通管理员不能上传系统 LOGO', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: false }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false }) + + const response = await uploadLogo(createLogoRequest()) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('最高权限') + expect(uploadToS3).not.toHaveBeenCalled() + expect(prisma.systemSettings.update).not.toHaveBeenCalled() + }) + + it('最高权限账号可以上传系统 LOGO', async () => { + getServerSession.mockResolvedValue({ + user: { id: 'super-id', role: 'ADMIN', isSuperAdmin: true }, + }) + prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true }) + + const response = await uploadLogo(createLogoRequest()) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.logoUrl).toBe('https://cdn.example.com/header-logo.png') + expect(uploadToS3).toHaveBeenCalled() + expect(prisma.systemSettings.update).toHaveBeenCalledWith({ + where: { id: 'settings-1' }, + data: { headerLogoUrl: 'https://cdn.example.com/header-logo.png' }, + }) + }) +}) diff --git a/app/api/system-settings/route.ts b/app/api/system-settings/route.ts index fbb2905..706baef 100644 --- a/app/api/system-settings/route.ts +++ b/app/api/system-settings/route.ts @@ -6,16 +6,108 @@ import { defaultLogos, featureFlags } from '@/lib/config' import { cache } from '@/lib/cache' import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns' import { canUseSystemSettings } from '@/lib/license' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' const CACHE_KEY = 'system-settings' const CACHE_TTL = 60000 // 1分钟缓存 +const LEGACY_LOGO_PATHS = new Set(['/logo/logo1.png', '/logo/logo2.png', '/logo/logo1', '/logo/logo2']) + +type SystemSettingsRow = { + id: string + headerBgColor: string | null + siteName: string | null + premiumRatio?: number | null + updatedAt: Date + createdAt: Date + headerLogoUrl: string | null + loginLogoUrl: string | null + s3AccessKey?: string | null + s3Bucket?: string | null + s3Endpoint?: string | null + s3PublicUrl?: string | null + s3Region?: string | null + s3SecretKey?: string | null + enableRegistration: boolean + enableUserActivation: boolean + enableAiImageEdit: boolean + enableTunnelService: boolean + enabledBuiltinTemplates?: string | null + homeDeleteButtonMode?: string | null +} + +function getPublicSystemSettings(settings: SystemSettingsRow) { + return { + headerBgColor: settings.headerBgColor, + siteName: settings.siteName, + updatedAt: settings.updatedAt, + createdAt: settings.createdAt, + headerLogoUrl: normalizeLogoUrl(settings.headerLogoUrl), + loginLogoUrl: normalizeLogoUrl(settings.loginLogoUrl), + } +} + +function getAdminSystemSettings(settings: SystemSettingsRow, includeSensitiveSecrets: boolean) { + if (includeSensitiveSecrets) { + return settings + } + + const { s3AccessKey: _s3AccessKey, s3SecretKey: _s3SecretKey, ...safeSettings } = settings + return safeSettings +} + +function isSystemSettingsAdmin(session: unknown) { + return Boolean((session as any)?.user?.role === 'ADMIN') +} + +function normalizeLogoUrl(url: string | null | undefined) { + if (!url) return '' + const trimmed = url.trim() + if (!trimmed) return '' + if (LEGACY_LOGO_PATHS.has(trimmed)) return '' + return trimmed +} + +function buildPublicFallbackSettings(): SystemSettingsRow { + const now = new Date(0) + return { + id: 'fallback-public', + headerBgColor: '#1f2937', + siteName: 'FLNXI素材库', + premiumRatio: 10, + updatedAt: now, + createdAt: now, + headerLogoUrl: '', + loginLogoUrl: '', + s3AccessKey: '', + s3Bucket: '', + s3Endpoint: '', + s3PublicUrl: '', + s3Region: 'us-east-1', + s3SecretKey: '', + enableRegistration: true, + enableUserActivation: false, + enableAiImageEdit: false, + enableTunnelService: false, + enabledBuiltinTemplates: null, + homeDeleteButtonMode: 'ADMIN_ONLY', + } +} // GET - 获取系统设置(带缓存) export async function GET() { try { await ensureSystemSettingsOptionalColumns() + const session = await getServerSession(authOptions) + const canReadAdminSettings = isSystemSettingsAdmin(session) + const canReadSensitiveSecrets = canReadAdminSettings && await isCurrentSuperAdmin(session) + // 先尝试从缓存获取 - const cachedSettings = cache.get(CACHE_KEY) + const cacheKey = canReadSensitiveSecrets + ? `${CACHE_KEY}:super-admin` + : canReadAdminSettings + ? `${CACHE_KEY}:admin` + : `${CACHE_KEY}:public` + const cachedSettings = cache.get(cacheKey) if (cachedSettings) { return NextResponse.json(cachedSettings) } @@ -45,7 +137,7 @@ export async function GET() { // homeDeleteButtonMode 是可选列,这里不直接在 select 中声明,避免旧版本类型不匹配 // enabledBuiltinTemplates 可能不存在,先不查询 }, - }) as any + }) as SystemSettingsRow | null // 如果字段存在,尝试查询它 try { @@ -66,11 +158,8 @@ export async function GET() { } } - const OLD_HEADER_DEFAULT = '/logo/logo2.png' - const OLD_LOGIN_DEFAULT = '/logo/logo1.png' - if (!settings) { - // 如果不存在,创建默认设置(包含默认 LOGO 与对象存储配置) + // 如果不存在,只创建公开展示和功能开关默认值;不要在普通读取时写入对象存储密钥 settings = await prisma.systemSettings.create({ data: { headerBgColor: '#1f2937', @@ -82,77 +171,41 @@ export async function GET() { enableRegistration: true, enableAiImageEdit: featureFlags.aiEditEnabled, enableTunnelService: featureFlags.tunnelEnabled, - // 对象存储相关字段:初次创建时直接带上当前环境值,便于在面板中展示“系统当前值” - s3Endpoint: process.env.S3_ENDPOINT || undefined, - s3AccessKey: process.env.S3_ACCESS_KEY_ID || undefined, - s3SecretKey: process.env.S3_SECRET_ACCESS_KEY || undefined, - s3Bucket: process.env.S3_BUCKET || undefined, - s3Region: process.env.S3_REGION || 'us-east-1', - s3PublicUrl: process.env.S3_PUBLIC_DOMAIN || undefined, }, - }) as any + }) as SystemSettingsRow } else { - // 已存在的旧数据,如果 LOGO 或对象存储字段为空,则自动补上默认值,并保存一次 + // 已存在的旧数据,如果 LOGO 为空则自动补上默认值,并保存一次;不要在 GET 中补写对象存储密钥 let needUpdate = false const updateData: { headerLogoUrl?: string loginLogoUrl?: string - s3Endpoint?: string - s3AccessKey?: string - s3SecretKey?: string - s3Bucket?: string - s3Region?: string - s3PublicUrl?: string } = {} - // LOGO 默认值兜底或纠正 - const shouldSwapLegacyDefaults = - settings.headerLogoUrl === OLD_HEADER_DEFAULT && - settings.loginLogoUrl === OLD_LOGIN_DEFAULT + const normalizedHeaderLogoUrl = normalizeLogoUrl(settings.headerLogoUrl) + const normalizedLoginLogoUrl = normalizeLogoUrl(settings.loginLogoUrl) - if (!settings.headerLogoUrl || shouldSwapLegacyDefaults) { + if (normalizedHeaderLogoUrl !== (settings.headerLogoUrl || '')) { + updateData.headerLogoUrl = normalizedHeaderLogoUrl + settings.headerLogoUrl = normalizedHeaderLogoUrl + needUpdate = true + } + if (normalizedLoginLogoUrl !== (settings.loginLogoUrl || '')) { + updateData.loginLogoUrl = normalizedLoginLogoUrl + settings.loginLogoUrl = normalizedLoginLogoUrl + needUpdate = true + } + + if (!settings.headerLogoUrl) { updateData.headerLogoUrl = defaultLogos.header settings.headerLogoUrl = defaultLogos.header needUpdate = true } - if (!settings.loginLogoUrl || shouldSwapLegacyDefaults) { + if (!settings.loginLogoUrl) { updateData.loginLogoUrl = defaultLogos.login settings.loginLogoUrl = defaultLogos.login needUpdate = true } - // 对象存储字段:为空时,用当前环境变量补上,方便面板展示“系统当前值” - if (!settings.s3Endpoint && process.env.S3_ENDPOINT) { - updateData.s3Endpoint = process.env.S3_ENDPOINT - settings.s3Endpoint = process.env.S3_ENDPOINT - needUpdate = true - } - if (!settings.s3AccessKey && process.env.S3_ACCESS_KEY_ID) { - updateData.s3AccessKey = process.env.S3_ACCESS_KEY_ID - settings.s3AccessKey = process.env.S3_ACCESS_KEY_ID - needUpdate = true - } - if (!settings.s3SecretKey && process.env.S3_SECRET_ACCESS_KEY) { - updateData.s3SecretKey = process.env.S3_SECRET_ACCESS_KEY - settings.s3SecretKey = process.env.S3_SECRET_ACCESS_KEY - needUpdate = true - } - if (!settings.s3Bucket && process.env.S3_BUCKET) { - updateData.s3Bucket = process.env.S3_BUCKET - settings.s3Bucket = process.env.S3_BUCKET - needUpdate = true - } - if (!settings.s3Region && process.env.S3_REGION) { - updateData.s3Region = process.env.S3_REGION - settings.s3Region = process.env.S3_REGION - needUpdate = true - } - if (!settings.s3PublicUrl && process.env.S3_PUBLIC_DOMAIN) { - updateData.s3PublicUrl = process.env.S3_PUBLIC_DOMAIN - settings.s3PublicUrl = process.env.S3_PUBLIC_DOMAIN - needUpdate = true - } - if (needUpdate) { await prisma.systemSettings.update({ where: { id: settings.id }, @@ -161,36 +214,39 @@ export async function GET() { } } - // 存入缓存 - cache.set(CACHE_KEY, settings, CACHE_TTL) + const responseSettings = canReadAdminSettings + ? getAdminSystemSettings(settings, canReadSensitiveSecrets) + : getPublicSystemSettings(settings) - return NextResponse.json(settings) + // 存入缓存 + cache.set(cacheKey, responseSettings, CACHE_TTL) + + return NextResponse.json(responseSettings) } catch (error) { console.error('[SystemSettings] Get error:', error) - return NextResponse.json( - { error: '获取系统设置失败' }, - { status: 500 } - ) + // 公共页面在数据库不可用时仍返回兜底值,避免登录/注册页直接 500 + const fallbackSettings = buildPublicFallbackSettings() + const fallbackResponse = NextResponse.json(getPublicSystemSettings(fallbackSettings)) + fallbackResponse.headers.set('x-system-settings-fallback', '1') + return fallbackResponse } } -// PUT - 更新系统设置(管理员权限) +// PUT - 更新系统设置(最高权限) export async function PUT(request: NextRequest) { try { await ensureSystemSettingsOptionalColumns() const session = await getServerSession(authOptions) - const userRole = (session?.user as any)?.role - const isSuperAdmin = Boolean((session?.user as any)?.isSuperAdmin) - if (!session || userRole !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '只有管理员可以访问系统设置' }, + { error: '只有最高权限账号可以修改系统设置' }, { status: 403 } ) } // 检查系统设置功能是否可用(免费版只能查看,不能修改) - if (!canUseSystemSettings()) { + if (!(await canUseSystemSettings())) { return NextResponse.json( { error: '系统设置功能需要标准版或专业版许可证,请升级后使用' }, { status: 403 } @@ -230,19 +286,6 @@ export async function PUT(request: NextRequest) { sanitizedPremiumRatio = Math.min(100, Math.max(0, Number(numericRatio.toFixed(2)))) } - // 只有最高权限账号可以修改 enableUserActivation - if ( - (enableUserActivation !== undefined || - enableAiImageEdit !== undefined || - enableTunnelService !== undefined) && - !isSuperAdmin - ) { - return NextResponse.json( - { error: '只有最高权限账号可以修改受保护的功能开关' }, - { status: 403 } - ) - } - // 获取或创建系统设置 let settings = await prisma.systemSettings.findFirst() @@ -315,6 +358,9 @@ export async function PUT(request: NextRequest) { // 清除缓存,强制下次重新加载 cache.delete(CACHE_KEY) + cache.delete(`${CACHE_KEY}:public`) + cache.delete(`${CACHE_KEY}:admin`) + cache.delete(`${CACHE_KEY}:super-admin`) return NextResponse.json(settings) } catch (error) { @@ -325,4 +371,3 @@ export async function PUT(request: NextRequest) { ) } } - diff --git a/app/api/system-settings/upload-logo/route.ts b/app/api/system-settings/upload-logo/route.ts index 11a87f4..a947d9a 100644 --- a/app/api/system-settings/upload-logo/route.ts +++ b/app/api/system-settings/upload-logo/route.ts @@ -3,15 +3,16 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { uploadToS3 } from '@/lib/s3' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' -// POST - 上传LOGO(仅管理员) +// POST - 上传LOGO(最高权限) export async function POST(request: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || session.user.role !== 'ADMIN') { + if (!session || !(await isCurrentSuperAdmin(session))) { return NextResponse.json( - { error: '无权限' }, + { error: '只有最高权限账号可以修改系统LOGO' }, { status: 403 } ) } diff --git a/app/api/system/integrity/route.ts b/app/api/system/integrity/route.ts index 0b11855..9bd48a9 100644 --- a/app/api/system/integrity/route.ts +++ b/app/api/system/integrity/route.ts @@ -10,6 +10,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' +import { isCurrentSuperAdmin } from '@/lib/admin-permissions' import { checkSystemIntegrity, repairSystemIntegrity, @@ -63,8 +64,8 @@ export async function GET() { export async function POST(req: NextRequest) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { - return NextResponse.json({ error: '需要管理员权限' }, { status: 403 }) + if (!session || !(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '仅最高权限账号可修复系统完整性问题' }, { status: 403 }) } let body: any = {} diff --git a/app/api/template-categories/[fieldKey]/route.ts b/app/api/template-categories/[fieldKey]/route.ts index 99dd022..9ff73f3 100644 --- a/app/api/template-categories/[fieldKey]/route.ts +++ b/app/api/template-categories/[fieldKey]/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 { isCurrentSuperAdmin } from '@/lib/admin-permissions' import { normalizeFieldOptionValue, syncFieldPoolFromTemplates } from '@/lib/field-pool' export async function GET( @@ -40,8 +41,8 @@ export async function PUT( ) { try { const session = await getServerSession(authOptions) - if (!session || (session.user as any)?.role !== 'ADMIN') { - return NextResponse.json({ error: '权限不足' }, { status: 403 }) + if (!session || !(await isCurrentSuperAdmin(session))) { + return NextResponse.json({ error: '仅最高权限账号可修改分类字段选项' }, { status: 403 }) } const { options } = await req.json() diff --git a/lib/material-storage-cleanup.ts b/lib/material-storage-cleanup.ts new file mode 100644 index 0000000..953a9b2 --- /dev/null +++ b/lib/material-storage-cleanup.ts @@ -0,0 +1,104 @@ +import { unlink } from 'fs/promises' +import { deleteFromS3, isS3Url } from '@/lib/s3' + +export interface MaterialStorageLike { + zipFilePath?: string | null + previewUrl?: string | null + previewUrls?: string | null + displayImages?: string | null + videoSourceUrl?: string | null + videoPreview540Url?: string | null + videoPreviewClipUrl?: string | null + videoCoverUrl?: string | null +} + +export interface MaterialStorageCleanupFailure { + fileUrl: string + reason: string +} + +export interface MaterialStorageCleanupResult { + deletedFiles: number + skippedFiles: number + failedFiles: number + failures: MaterialStorageCleanupFailure[] +} + +function parseUrlList(value?: string | null): string[] { + if (!value) return [] + + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) { + return parsed.filter((url): url is string => typeof url === 'string' && url.trim().length > 0) + } + } catch { + // 兼容历史单值字段。 + } + + return value.trim() ? [value] : [] +} + +export function collectMaterialStorageUrls(material: MaterialStorageLike): string[] { + const urls = [ + material.zipFilePath, + material.videoSourceUrl && material.videoSourceUrl !== material.zipFilePath ? material.videoSourceUrl : null, + material.previewUrl, + material.videoPreview540Url, + material.videoPreviewClipUrl, + material.videoCoverUrl, + ...parseUrlList(material.previewUrls), + ...parseUrlList(material.displayImages), + ] + + return Array.from(new Set(urls.filter((url): url is string => Boolean(url?.trim())))) +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message + if (typeof error === 'string') return error + return '未知错误' +} + +export async function deleteMaterialStorageFiles( + fileUrls: string[], + options: { logPrefix?: string } = {} +): Promise { + const logPrefix = options.logPrefix || '[MaterialStorageCleanup]' + const uniqueFiles = Array.from(new Set(fileUrls.filter((url) => url && url.trim()))) + const result: MaterialStorageCleanupResult = { + deletedFiles: 0, + skippedFiles: 0, + failedFiles: 0, + failures: [], + } + + for (const fileUrl of uniqueFiles) { + try { + if (isS3Url(fileUrl)) { + await deleteFromS3(fileUrl) + result.deletedFiles++ + continue + } + + try { + await unlink(fileUrl) + result.deletedFiles++ + } catch (fileError: any) { + if (fileError?.code === 'ENOENT') { + result.skippedFiles++ + continue + } + + throw fileError + } + } catch (error) { + const reason = getErrorMessage(error) + console.error(`${logPrefix} Failed to delete file: ${fileUrl}`, error) + result.failedFiles++ + result.failures.push({ fileUrl, reason }) + } + } + + return result +}