收紧管理权限与系统设置安全边界
This commit is contained in:
parent
6b4ba0f861
commit
0dabf6cee8
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
297
app/api/admin/__tests__/destructive-actions-superadmin.test.ts
Normal file
297
app/api/admin/__tests__/destructive-actions-superadmin.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
271
app/api/admin/__tests__/global-dictionaries-superadmin.test.ts
Normal file
271
app/api/admin/__tests__/global-dictionaries-superadmin.test.ts
Normal 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']),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
101
app/api/admin/__tests__/system-status-auth.test.ts
Normal file
101
app/api/admin/__tests__/system-status-auth.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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. 获取清理后的统计信息
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
637
app/api/admin/users/__tests__/organization-permissions.test.ts
Normal file
637
app/api/admin/users/__tests__/organization-permissions.test.ts
Normal 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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 },
|
||||
})
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
150
app/api/departments/__tests__/route.test.ts
Normal file
150
app/api/departments/__tests__/route.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
347
app/api/system-settings/__tests__/route.test.ts
Normal file
347
app/api/system-settings/__tests__/route.test.ts
Normal 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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 = {}
|
||||
|
||||
@ -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()
|
||||
|
||||
104
lib/material-storage-cleanup.ts
Normal file
104
lib/material-storage-cleanup.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user