收紧管理权限与系统设置安全边界

This commit is contained in:
WuYongJie 2026-05-25 15:13:49 +08:00
parent 6b4ba0f861
commit 0dabf6cee8
31 changed files with 2383 additions and 334 deletions

View File

@ -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,
)}
</div>
)
}, [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}

View File

@ -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({
<select
value={selectedDepartment}
onChange={(e) => onChangeDepartment(e.target.value)}
disabled={!canChangeProtectedFields}
className={cn(styles.inputStyles.base, 'pr-10')}
>
<option value="">-- 未分配部门 --</option>
@ -126,10 +129,13 @@ export default function UserEditModal({
))}
</select>
<p className="text-xs text-slate-500 dark:text-slate-400">
更改部门后将自动为其匹配该部门的管理员作为直属上级。
{canChangeProtectedFields
? '更改部门后将自动为其匹配该部门的管理员作为直属上级。'
: '只有最高权限账号可以调整用户部门。'}
</p>
</section>
{canChangeProtectedFields && (
<section className="space-y-3">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">角色与高危操作</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
@ -157,7 +163,9 @@ export default function UserEditModal({
</button>
</div>
</section>
)}
{canChangeProtectedFields && (
<section className="grid grid-cols-1 md:grid-cols-2 gap-3">
<button
type="button"
@ -186,6 +194,7 @@ export default function UserEditModal({
{user.canDownload ? '禁止下载' : '允许下载'}
</button>
</section>
)}
{showTransferButton && (
<section className="space-y-2 border-t border-slate-200 dark:border-slate-700 pt-4">
@ -213,4 +222,3 @@ export default function UserEditModal({
</Modal>
)
}

View File

@ -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 })
})
})

View File

@ -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']),
},
})
})
})

View File

@ -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')
})
})

View File

@ -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<Array<{ id: string; "zipFilePath": string; "zipFileName": string }>>(`
SELECT id, "zipFilePath", "zipFileName" FROM materials
const materials = await prisma.$queryRawUnsafe<Array<{
id: string
zipFilePath: string | null
previewUrl: string | null
previewUrls: string | null
displayImages: string | null
videoSourceUrl: string | null
videoPreview540Url: string | null
videoPreviewClipUrl: string | null
videoCoverUrl: string | null
}>>(`
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. 获取清理后的统计信息

View File

@ -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 }
)
}

View File

@ -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 }
)
}

View File

@ -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 }
)
}

View File

@ -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 }
)
}
}
}

View File

@ -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 },
})
})
})

View File

@ -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 },
})

View File

@ -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 },

View File

@ -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(

View File

@ -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<string, Set<string>> | 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 }
)
}

View File

@ -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<string, Set<string>> | 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 },

View File

@ -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)

View File

@ -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<string, string | null>()
@ -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) {

View File

@ -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

View File

@ -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 }
)
}

View File

@ -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 {

View File

@ -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()
})
})

View File

@ -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 }
)
}

View File

@ -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)

View File

@ -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 }
)
}

View File

@ -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' },
})
})
})

View File

@ -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) {
)
}
}

View File

@ -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 }
)
}

View File

@ -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 = {}

View File

@ -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()

View File

@ -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<MaterialStorageCleanupResult> {
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
}