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
+}