强化商业化地基权限与模板一致性

This commit is contained in:
WuYongJie 2026-05-28 15:11:36 +08:00
parent 111bb29a1a
commit 5a1affe830
73 changed files with 2914 additions and 592 deletions

View File

@ -52,6 +52,7 @@ interface CategoryFieldMeta {
interface CategoriesTabProps {
showToast: (message: string, type?: 'success' | 'error') => void
onDataChange?: () => void
isSuperAdmin?: boolean
}
const sanitizeOptionLabel = (value: string): string => {
@ -115,7 +116,7 @@ const normalizeOptionArray = (values: string[]): string[] => {
return normalized
}
export default function CategoriesTab({ showToast, onDataChange }: CategoriesTabProps) {
export default function CategoriesTab({ showToast, onDataChange, isSuperAdmin = false }: CategoriesTabProps) {
const styles = useThemeStyles()
const theme = useTheme()
const { confirm, prompt } = useDialog()
@ -127,6 +128,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
const [renamingOption, setRenamingOption] = useState<Map<string, { fieldKey: string; oldValue: string; newValue: string }>>(new Map())
const [categoryFieldOptions, setCategoryFieldOptions] = useState<CategoryFieldMeta[]>([])
const [categoryFieldLoading, setCategoryFieldLoading] = useState(false)
const [categoryFieldSyncing, setCategoryFieldSyncing] = useState(false)
const [savingFieldKey, setSavingFieldKey] = useState<string | null>(null)
const [isEditingFields, setIsEditingFields] = useState(false)
const [categoryOrder, setCategoryOrder] = useState<string[]>([])
@ -268,6 +270,33 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
fetchCategoryFieldOptions()
}, [fetchCategoryFieldOptions])
const handleSyncCategoryFields = useCallback(async () => {
if (!isSuperAdmin) {
showToast('仅最高权限账号可同步分类字段', 'error')
return
}
try {
setCategoryFieldSyncing(true)
const response = await fetch('/api/category-fields', {
method: 'POST',
cache: 'no-store',
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || '同步失败')
}
showToast('字段池已同步')
await Promise.all([fetchCategoryFieldOptions(), fetchData()])
onDataChange?.()
} catch (error: any) {
console.error('[CategoriesTab] Failed to sync category fields:', error)
showToast(error?.message || '同步分类字段失败', 'error')
} finally {
setCategoryFieldSyncing(false)
}
}, [fetchCategoryFieldOptions, fetchData, isSuperAdmin, onDataChange, showToast])
const handleCategoryLabelChange = useCallback((fieldKey: string, value: string) => {
setCategoryLabelDrafts((prev) => {
const next = new Map(prev)
@ -746,6 +775,16 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
>
{isEditingFields ? (categoryConfigSaving ? '保存中…' : '保存') : '编辑'}
</button>
{!isEditingFields && isSuperAdmin && (
<button
type="button"
className={cn(styles.buttonStyles.secondary, 'flex-1')}
onClick={handleSyncCategoryFields}
disabled={categoryFieldLoading || categoryFieldSyncing || categoryConfigSaving}
>
{categoryFieldSyncing ? '同步中…' : '同步字段'}
</button>
)}
<button
type="button"
className={cn(styles.buttonStyles.secondary, 'flex-1')}
@ -755,7 +794,7 @@ export default function CategoriesTab({ showToast, onDataChange }: CategoriesTab
}
await Promise.all([fetchCategoryFieldOptions(), fetchData()])
}}
disabled={categoryFieldLoading || categoryConfigSaving}
disabled={categoryFieldLoading || categoryFieldSyncing || categoryConfigSaving}
>
{isEditingFields ? '取消' : '刷新'}
</button>

View File

@ -295,8 +295,8 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
<span className={cn(tabIndicatorLineClass, tabIndicatorClass, animations.slideInFromLeft)} />
)}
</button>
{/* 邀请管理 - 只有管理员及以上权限才能看到 */}
{sessionUser?.role === 'ADMIN' && (
{/* 邀请管理 - 只有最高权限账号才能看到 */}
{sessionUser?.isSuperAdmin && (
<button
type="button"
role="tab"
@ -363,7 +363,7 @@ const departmentCollator = useMemo(() => getDepartmentCollator(), [])
{/* 分类管理 */}
{activeTab === 'categories' && (
<TabErrorBoundary tabKey="categories" onRetry={fetchData}>
<CategoriesTab showToast={showToast} onDataChange={fetchData} />
<CategoriesTab showToast={showToast} onDataChange={fetchData} isSuperAdmin={currentIsSuperAdmin} />
</TabErrorBoundary>
)}

View File

@ -27,10 +27,6 @@ jest.mock('@/lib/s3', () => ({
isS3Url: jest.fn(() => false),
}))
jest.mock('@/lib/material-download-log', () => ({
ensureMaterialDownloadLogsTable: jest.fn(),
}))
jest.mock('@/lib/system-integrity', () => ({
checkSystemIntegrity: jest.fn(),
repairSystemIntegrity: jest.fn(),
@ -44,7 +40,6 @@ jest.mock('fs/promises', () => ({
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'
@ -120,7 +115,6 @@ describe('admin destructive actions require super admin', () => {
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(ensureMaterialDownloadLogsTable).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
@ -137,7 +131,6 @@ describe('admin destructive actions require super admin', () => {
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(ensureMaterialDownloadLogsTable).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
@ -162,7 +155,6 @@ describe('admin destructive actions require super admin', () => {
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(ensureMaterialDownloadLogsTable).toHaveBeenCalled()
expect(tx.$executeRawUnsafe).toHaveBeenCalledTimes(3)
})

View File

@ -23,12 +23,14 @@ jest.mock('@/lib/prisma', () => ({
category: {
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
scene: {
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
@ -55,8 +57,8 @@ 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 { POST as createCategory, PATCH as updateCategory, DELETE as deleteCategory } from '../../categories/route'
import { POST as createScene, 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'
@ -93,6 +95,34 @@ describe('全局字典和分类字段写操作权限', () => {
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
it('普通管理员不能新增店铺分类', async () => {
const response = await createCategory(
jsonRequest('http://localhost/api/categories', { name: '新店铺' }, 'POST')
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('仅最高权限账号可新增店铺分类')
expect(prisma.category.create).not.toHaveBeenCalled()
})
it('最高权限账号可以新增店铺分类', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.category.findUnique.mockResolvedValue(null)
prisma.category.create.mockResolvedValue({ id: 'cat-1', name: '新店铺' })
const response = await createCategory(
jsonRequest('http://localhost/api/categories', { name: '新店铺' }, 'POST')
)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.name).toBe('新店铺')
expect(prisma.category.create).toHaveBeenCalledWith({
data: { name: '新店铺' },
})
})
it('最高权限账号可以修改店铺分类并同步素材', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.category.findFirst.mockResolvedValue(null)
@ -128,6 +158,34 @@ describe('全局字典和分类字段写操作权限', () => {
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
it('普通管理员不能新增场景分类', async () => {
const response = await createScene(
jsonRequest('http://localhost/api/scenes', { name: '新场景' }, 'POST')
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('仅最高权限账号可新增场景分类')
expect(prisma.scene.create).not.toHaveBeenCalled()
})
it('最高权限账号可以新增场景分类', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.scene.findUnique.mockResolvedValue(null)
prisma.scene.create.mockResolvedValue({ id: 'scene-1', name: '新场景' })
const response = await createScene(
jsonRequest('http://localhost/api/scenes', { name: '新场景' }, 'POST')
)
const data = await response.json()
expect(response.status).toBe(201)
expect(data.name).toBe('新场景')
expect(prisma.scene.create).toHaveBeenCalledWith({
data: { name: '新场景' },
})
})
it('最高权限账号可以修改场景分类并同步素材', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.scene.findFirst.mockResolvedValue(null)
@ -261,9 +319,9 @@ describe('全局字典和分类字段写操作权限', () => {
where: {
key: 'brandName',
type: 'select',
addToCategory: true,
},
data: {
addToCategory: true,
optionsStatic: JSON.stringify(['A', 'B']),
},
})

View File

@ -10,6 +10,12 @@ jest.mock('next-auth', () => ({
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
material: {
count: jest.fn(),
findMany: jest.fn(),
@ -29,6 +35,18 @@ function createRequest(path = 'http://localhost/api/admin/materials/export') {
describe('admin materials export', () => {
beforeEach(() => {
jest.clearAllMocks()
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
role: 'ADMIN',
isSuperAdmin: true,
department: '设计部',
managedDepartment: null,
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '设计部-建模组' },
{ name: '运营部' },
])
})
it('未登录不能导出素材数据', async () => {
@ -46,6 +64,13 @@ describe('admin materials export', () => {
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
})
const response = await GET(createRequest())
const data = await response.json()
@ -104,4 +129,40 @@ describe('admin materials export', () => {
expect(data.materials[0].createdAt).toBe('2026-05-25T01:02:03.000Z')
expect(data.materials[0].updatedAt).toBe('2026-05-25T02:03:04.000Z')
})
it('普通管理员只能导出自己和管理部门范围内的素材数据', async () => {
getServerSession.mockResolvedValue({
user: { id: 'manager-1', role: 'ADMIN' },
})
prisma.user.findUnique.mockResolvedValue({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.count.mockResolvedValue(0)
prisma.material.findMany.mockResolvedValue([])
const response = await GET(createRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data.exportedCount).toBe(0)
expect(prisma.material.count).toHaveBeenCalledWith({
where: {
AND: [
{ deletedAt: null },
{
OR: [
{ userId: 'manager-1' },
{ ownerType: 'USER', ownerId: 'manager-1' },
{ user: { department: { in: ['设计部', '设计部-建模组'] } } },
{ user: { department: { startsWith: '设计部-' } } },
],
},
],
},
})
})
})

View File

@ -1,7 +1,9 @@
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 { buildMaterialManagementScopeWhere, getMaterialPermissionActor } from '@/lib/material-permissions'
export const dynamic = 'force-dynamic'
@ -24,13 +26,19 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: '未登录' }, { status: 401 })
}
if ((session.user as any).role !== 'ADMIN') {
const actor = await getMaterialPermissionActor(session)
if (!actor || (actor.role !== 'ADMIN' && !actor.isSuperAdmin)) {
return NextResponse.json({ error: '需要管理员权限' }, { status: 403 })
}
const { searchParams } = new URL(req.url)
const limit = parseExportLimit(searchParams.get('limit'))
const where = { deletedAt: null }
const scopeWhere = await buildMaterialManagementScopeWhere(actor)
const filters: Prisma.MaterialWhereInput[] = [{ deletedAt: null }]
if (Object.keys(scopeWhere).length > 0) {
filters.push(scopeWhere)
}
const where: Prisma.MaterialWhereInput = filters.length === 1 ? filters[0] : { AND: filters }
const [totalCount, rawMaterials] = await Promise.all([
prisma.material.count({ where }),

View File

@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
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'
@ -26,9 +25,6 @@ export async function POST(req: NextRequest) {
)
}
// 确保下载日志表存在(避免 TRUNCATE 报错)
await ensureMaterialDownloadLogsTable()
// 使用事务,确保一致性
const result = await prisma.$transaction(async (tx) => {
// 清空点赞记录

View File

@ -549,7 +549,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
}
}
const result = await prisma.$transaction(async (tx) => {
const template = await prisma.$transaction(async (tx) => {
console.log('[TemplateUpdate] Updating template:', {
templateId: id,
templateName: existing.name,
@ -626,16 +626,14 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
}
}
return updated
})
console.log('[TemplateUpdate] Syncing field pool in template update transaction...')
await syncFieldPoolFromTemplates(tx)
const template = await prisma.uploadTemplate.findUnique({
where: { id: result.id },
select: selectTemplate,
return tx.uploadTemplate.findUnique({
where: { id: updated.id },
select: selectTemplate,
})
})
console.log('[TemplateUpdate] Syncing field pool after template update...')
await syncFieldPoolFromTemplates()
console.log('[TemplateUpdate] Update completed:', {
templateId: template?.id,
@ -740,10 +738,12 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string
templateName: template.name,
})
await prisma.uploadTemplate.delete({ where: { id } })
console.log('[TemplateDelete] Syncing field pool after template deletion...')
await syncFieldPoolFromTemplates()
await prisma.$transaction(async (tx) => {
await tx.uploadTemplate.delete({ where: { id } })
console.log('[TemplateDelete] Syncing field pool in template deletion transaction...')
await syncFieldPoolFromTemplates(tx)
})
console.log('[TemplateDelete] Template deleted successfully:', {
templateId: id,

View File

@ -50,6 +50,22 @@ const { syncFieldPoolFromTemplates } = require('@/lib/field-pool')
const { canUseCustomTemplates } = require('@/lib/license')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
function makeTemplateTransactionMock(overrides: any = {}) {
return {
uploadTemplate: {
...prisma.uploadTemplate,
...(overrides.uploadTemplate || {}),
},
uploadField: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
update: jest.fn(),
create: jest.fn(),
...(overrides.uploadField || {}),
},
...(overrides.rest || {}),
}
}
// 导入API处理函数
import { GET as listTemplates, POST as createTemplate } from '../route'
import {
@ -67,6 +83,9 @@ describe('模板CRUD API测试', () => {
})
canUseCustomTemplates.mockResolvedValue(true)
isCurrentSuperAdmin.mockResolvedValue(true)
prisma.$transaction.mockImplementation(async (callback: (tx: any) => Promise<any>) =>
callback(makeTemplateTransactionMock())
)
})
describe('GET /api/admin/upload-templates - 获取模板列表', () => {
@ -195,6 +214,40 @@ describe('模板CRUD API测试', () => {
expect(syncFieldPoolFromTemplates).toHaveBeenCalled()
})
test('创建模板时字段池同步应复用同一个事务', async () => {
const tx = makeTemplateTransactionMock({
uploadTemplate: {
create: jest.fn().mockResolvedValue({
id: 'new-template-id',
name: '新模板',
materialType: '3DMAX',
materialKind: 'IMAGE',
status: 'draft',
version: 1,
}),
},
})
prisma.$transaction.mockImplementationOnce(async (callback: (tx: any) => Promise<any>) =>
callback(tx)
)
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
method: 'POST',
body: JSON.stringify({
name: '新模板',
materialType: '3DMAX',
materialKind: 'IMAGE',
fields: [],
}),
})
const response = await createTemplate(request)
expect(response.status).toBe(201)
expect(tx.uploadTemplate.create).toHaveBeenCalled()
expect(syncFieldPoolFromTemplates).toHaveBeenCalledWith(tx)
})
test('应该允许创建音频模板', async () => {
const mockTemplate = {
id: 'audio-template-id',
@ -461,11 +514,12 @@ describe('模板CRUD API测试', () => {
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
prisma.$transaction.mockImplementation(async (callback: (tx: any) => Promise<any>) => {
return callback({
return callback(makeTemplateTransactionMock({
uploadTemplate: {
update: jest.fn().mockResolvedValue({ id: 'template-1', version: 2 }),
findUnique: jest.fn().mockResolvedValue(updatedTemplate),
},
})
}))
})
prisma.uploadTemplate.findUnique.mockResolvedValueOnce(existingTemplate)
.mockResolvedValueOnce(updatedTemplate)
@ -485,6 +539,48 @@ describe('模板CRUD API测试', () => {
expect(data.version).toBe(2)
})
test('更新模板时字段池同步应复用同一个事务', async () => {
const existingTemplate = {
id: 'template-1',
name: '旧名称',
version: 1,
materialType: '3DMAX',
materialKind: 'IMAGE',
updatedAt: new Date(),
fields: [],
_count: { materials: 0 },
}
const tx = makeTemplateTransactionMock({
uploadTemplate: {
update: jest.fn().mockResolvedValue({ id: 'template-1', version: 2 }),
findUnique: jest.fn().mockResolvedValue({
id: 'template-1',
name: '新名称',
version: 2,
}),
},
})
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
prisma.$transaction.mockImplementationOnce(async (callback: (tx: any) => Promise<any>) =>
callback(tx)
)
const request = new NextRequest('http://localhost', {
method: 'PUT',
body: JSON.stringify({
name: '新名称',
version: 1,
}),
})
const response = await updateTemplate(request, { params: { id: 'template-1' } })
expect(response.status).toBe(200)
expect(tx.uploadTemplate.update).toHaveBeenCalled()
expect(syncFieldPoolFromTemplates).toHaveBeenCalledWith(tx)
})
test('应该允许将模板更新为音频大类', async () => {
prisma.uploadTemplate.findUnique.mockResolvedValueOnce({
id: 'template-1',
@ -498,16 +594,29 @@ describe('模板CRUD API测试', () => {
})
prisma.$transaction.mockImplementationOnce(async (callback: any) =>
callback({
callback(makeTemplateTransactionMock({
uploadTemplate: {
update: jest.fn().mockResolvedValue({ id: 'template-1', version: 2 }),
findUnique: jest.fn().mockResolvedValue({
id: 'template-1',
name: '音频模板',
materialType: '音频',
materialKind: 'AUDIO',
status: 'active',
version: 2,
fieldGroups: null,
displayConfig: null,
createdAt: new Date('2026-03-13T00:00:00.000Z'),
updatedAt: new Date('2026-03-13T00:00:01.000Z'),
fields: [],
}),
},
uploadField: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
update: jest.fn(),
create: jest.fn(),
},
})
}))
)
prisma.uploadTemplate.findUnique.mockResolvedValueOnce({
@ -761,6 +870,33 @@ describe('模板CRUD API测试', () => {
expect(syncFieldPoolFromTemplates).toHaveBeenCalled()
})
test('删除模板时字段池同步应复用同一个事务', async () => {
const mockTemplate = {
id: 'template-1',
name: '测试模板',
_count: { materials: 0 },
}
const tx = makeTemplateTransactionMock({
uploadTemplate: {
delete: jest.fn().mockResolvedValue(mockTemplate),
},
})
prisma.uploadTemplate.findUnique.mockResolvedValue(mockTemplate)
prisma.$transaction.mockImplementationOnce(async (callback: (tx: any) => Promise<any>) =>
callback(tx)
)
const response = await deleteTemplate(
new NextRequest('http://localhost'),
{ params: { id: 'template-1' } }
)
expect(response.status).toBe(200)
expect(tx.uploadTemplate.delete).toHaveBeenCalledWith({ where: { id: 'template-1' } })
expect(syncFieldPoolFromTemplates).toHaveBeenCalledWith(tx)
})
test('应该阻止删除正在使用的模板', async () => {
const mockTemplate = {
id: 'template-1',

View File

@ -246,12 +246,15 @@ export async function POST(req: NextRequest) {
: undefined,
} as Prisma.UploadTemplateCreateInput
const template = await prisma.uploadTemplate.create({
data: createData,
select: baseSelect,
})
const template = await prisma.$transaction(async (tx) => {
const created = await tx.uploadTemplate.create({
data: createData,
select: baseSelect,
})
await syncFieldPoolFromTemplates()
await syncFieldPoolFromTemplates(tx)
return created
})
return NextResponse.json(template, { status: 201 })
} catch (error: any) {
console.error('[TemplateCreate] Create error:', {

View File

@ -257,7 +257,7 @@ describe('admin user organization permissions', () => {
expect(prisma.$transaction).not.toHaveBeenCalled()
})
it('部门管理员可以在部门不变时修改成员姓名', async () => {
it('部门管理员可以在部门不变时修改成员姓名但不会批量改历史素材作者名', async () => {
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique
.mockResolvedValueOnce({ id: 'manager-1', managedDepartment: '设计部' })
@ -293,7 +293,7 @@ describe('admin user organization permissions', () => {
const data = await response.json()
expect(response.status).toBe(200)
expect(data.materialsUpdated).toBe(true)
expect(data.materialsUpdated).toBe(false)
expect(tx.user.update).toHaveBeenCalledWith({
where: { id: 'user-2' },
data: {
@ -301,6 +301,51 @@ describe('admin user organization permissions', () => {
department: '设计部',
},
})
expect(tx.material.updateMany).not.toHaveBeenCalled()
})
it('最高权限账号显式要求时才会同步历史素材作者名', async () => {
getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } })
prisma.user.findUnique
.mockResolvedValueOnce({ id: 'super-1', managedDepartment: null })
.mockResolvedValueOnce({ id: 'super-1', isSuperAdmin: true })
.mockResolvedValueOnce({ id: 'user-2', username: '旧姓名', department: '设计部' })
.mockResolvedValueOnce({ id: 'user-2', isSuperAdmin: false })
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: '新姓名',
syncMaterialAuthorName: true,
}),
})
)
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: '新姓名',
},
})
expect(tx.material.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-2' },
data: { authorName: '新姓名' },

View File

@ -4,7 +4,7 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
// 统一更新用户信息,并同步更新该用户上传的素材信息
// 统一更新用户信息;历史素材作者名只允许最高权限账号显式同步。
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
@ -16,7 +16,7 @@ export async function POST(req: NextRequest) {
)
}
const { userId, username, department, role, isActive, canDownload } = await req.json()
const { userId, username, department, role, isActive, canDownload, syncMaterialAuthorName } = await req.json()
if (!userId) {
return NextResponse.json(
@ -124,7 +124,13 @@ export async function POST(req: NextRequest) {
if (isActive !== undefined) updateData.isActive = isActive
if (canDownload !== undefined) updateData.canDownload = canDownload
// 使用事务更新用户和素材
const shouldSyncMaterialAuthorName =
Boolean(syncMaterialAuthorName) &&
Boolean(currentUserFull?.isSuperAdmin) &&
username !== undefined &&
username !== oldUser.username
// 使用事务更新用户;历史素材作者名同步仅限最高权限账号显式触发。
const result = await prisma.$transaction(async (tx) => {
// 更新用户信息
const updatedUser = await tx.user.update({
@ -132,8 +138,7 @@ export async function POST(req: NextRequest) {
data: updateData,
})
// 如果用户名发生变化,同步更新该用户上传的所有素材的authorName
if (username !== undefined && username !== oldUser.username) {
if (shouldSyncMaterialAuthorName) {
await tx.material.updateMany({
where: { userId: userId },
data: { authorName: username },
@ -147,13 +152,13 @@ export async function POST(req: NextRequest) {
userId: result.id,
username: result.username,
department: result.department,
materialsUpdated: username !== undefined && username !== oldUser.username
materialsUpdated: shouldSyncMaterialAuthorName
})
return NextResponse.json({
success: true,
user: result,
materialsUpdated: username !== undefined && username !== oldUser.username
materialsUpdated: shouldSyncMaterialAuthorName
})
} catch (error) {
console.error('[Update User] Error:', error)

View File

@ -58,6 +58,13 @@ export async function POST(req: NextRequest) {
)
}
if (!(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '仅最高权限账号可新增店铺分类' },
{ status: 403 }
)
}
const { name } = await req.json()
if (!name || !name.trim()) {

View File

@ -0,0 +1,137 @@
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(),
syncFieldPoolFromTemplates: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
fieldDefinition: {
findUnique: jest.fn(),
update: jest.fn(),
},
$transaction: jest.fn(),
},
}))
const { getServerSession } = require('next-auth')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { getFieldPoolRecords, syncFieldPoolFromTemplates } = require('@/lib/field-pool')
const { prisma } = require('@/lib/prisma')
const { GET, POST, PATCH, PUT } = require('../route')
function jsonRequest(body: unknown, method = 'POST') {
return new NextRequest('http://localhost/api/category-fields', {
method,
body: JSON.stringify(body),
})
}
describe('/api/category-fields', () => {
beforeEach(() => {
jest.clearAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN' },
})
isCurrentSuperAdmin.mockResolvedValue(false)
getFieldPoolRecords.mockResolvedValue([
{
key: 'brandName',
label: '品牌',
filterDisplayName: null,
source: 'custom',
type: 'select',
isFilterEnabled: true,
filterOrder: 2,
},
{
key: 'platformName',
label: '平台',
filterDisplayName: '平台筛选',
source: 'standard',
type: 'select',
isFilterEnabled: false,
filterOrder: 3,
},
])
})
it('读取分类字段配置时不触发字段池同步写入', async () => {
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(data).toEqual({
managedFields: ['brandName'],
availableFields: [
{
key: 'brandName',
label: '品牌',
source: 'custom',
type: 'select',
filterDisplayName: null,
filterOrder: 2,
},
{
key: 'platformName',
label: '平台筛选',
source: 'standard',
type: 'select',
filterDisplayName: '平台筛选',
filterOrder: 3,
},
],
})
})
it('普通管理员不能手动同步字段池', async () => {
const response = await POST()
expect(response.status).toBe(403)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(getFieldPoolRecords).not.toHaveBeenCalled()
})
it('最高权限账号可以手动同步字段池', async () => {
isCurrentSuperAdmin.mockResolvedValue(true)
const response = await POST()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(syncFieldPoolFromTemplates).toHaveBeenCalledTimes(1)
expect(getFieldPoolRecords).toHaveBeenCalledTimes(1)
expect(data.managedFields).toEqual(['brandName'])
})
it('普通管理员不能修改分类字段启用和排序', async () => {
const toggleResponse = await PATCH(
jsonRequest({ fieldKey: 'brandName', enabled: true }, 'PATCH')
)
const saveResponse = await PUT(
jsonRequest({
updates: [{ fieldKey: 'brandName', filterDisplayName: '品牌', filterOrder: 1 }],
}, 'PUT')
)
expect(toggleResponse.status).toBe(403)
expect(saveResponse.status).toBe(403)
expect(prisma.fieldDefinition.update).not.toHaveBeenCalled()
expect(prisma.$transaction).not.toHaveBeenCalled()
})
})

View File

@ -28,7 +28,6 @@ export async function GET() {
return NextResponse.json({ error: '权限不足' }, { status: 403 })
}
await syncFieldPoolFromTemplates()
const fields = (await getFieldPoolRecords()) as FieldPoolRecord[]
const managedFields = fields
@ -49,6 +48,34 @@ export async function GET() {
})
}
export async function POST() {
try {
const session = await getServerSession(authOptions)
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json({ error: '仅最高权限账号可同步分类字段' }, { status: 403 })
}
await syncFieldPoolFromTemplates()
const fields = (await getFieldPoolRecords()) as FieldPoolRecord[]
return NextResponse.json({
success: true,
managedFields: fields.filter((field) => field.isFilterEnabled).map((field) => field.key),
availableFields: fields.map((field) => ({
key: field.key,
label: field.filterDisplayName || field.label,
source: field.source === 'standard' ? 'standard' : 'custom',
type: field.type,
filterDisplayName: field.filterDisplayName,
filterOrder: field.filterOrder ?? 0,
})),
})
} catch (error) {
console.error('Sync category fields failed:', error)
return NextResponse.json({ error: '同步失败' }, { status: 500 })
}
}
export async function PATCH(req: NextRequest) {
try {
const session = await getServerSession(authOptions)

View File

@ -65,6 +65,7 @@ describe('GET /api/home-filter-fields 权限边界', () => {
const data = await response.json()
expect(response.status).toBe(200)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(data).toEqual({
fields: [
{
@ -98,6 +99,7 @@ describe('GET /api/home-filter-fields 权限边界', () => {
const data = await response.json()
expect(response.status).toBe(200)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2)
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
expect(data).toEqual({

View File

@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth'
import { Prisma } from '@prisma/client'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getFieldPoolRecords, syncFieldPoolFromTemplates, formatFieldOptionEntries } from '@/lib/field-pool'
import { getFieldPoolRecords, formatFieldOptionEntries } from '@/lib/field-pool'
// 字段池key到数据库列名的映射
// 注意:只有在 materials 表中存在对应列的字段才能统计
@ -107,7 +107,6 @@ export async function GET() {
)
}
await syncFieldPoolFromTemplates()
const fields = (await getFieldPoolRecords()) as Array<{
key: string
label: string

View File

@ -2,8 +2,9 @@ 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'
// PATCH - 更新邀请链接(仅管理员)
// PATCH - 更新邀请链接(仅最高权限账号)
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
@ -11,8 +12,7 @@ export async function PATCH(
try {
const session = await getServerSession(authOptions)
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
@ -44,7 +44,7 @@ export async function PATCH(
}
}
// DELETE - 删除邀请链接(仅管理员)
// DELETE - 删除邀请链接(仅最高权限账号)
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
@ -52,8 +52,7 @@ export async function DELETE(
try {
const session = await getServerSession(authOptions)
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }

View File

@ -0,0 +1,171 @@
import { NextRequest } from 'next/server'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
invitation: {
count: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
}))
jest.mock('@/lib/config', () => ({
resolveBaseUrl: jest.fn(() => 'http://localhost:3000'),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { resolveBaseUrl } = require('@/lib/config')
import { GET, POST } from '../route'
import { PATCH, DELETE } from '../[id]/route'
function jsonRequest(url: string, body: unknown, method = 'POST') {
return new NextRequest(url, {
method,
body: JSON.stringify(body),
})
}
describe('邀请链接管理权限', () => {
beforeEach(() => {
jest.resetAllMocks()
resolveBaseUrl.mockReturnValue('http://localhost:3000')
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: false },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false })
})
it('普通管理员不能查看或创建邀请链接', async () => {
const listResponse = await GET(new NextRequest('http://localhost/api/invitations'))
const createResponse = await POST(
jsonRequest('http://localhost/api/invitations', { maxUses: 1, expiresInDays: null })
)
expect(listResponse.status).toBe(403)
expect(createResponse.status).toBe(403)
expect(prisma.invitation.findMany).not.toHaveBeenCalled()
expect(prisma.invitation.create).not.toHaveBeenCalled()
})
it('旧会话带最高权限标记但数据库已撤销时不能创建邀请链接', async () => {
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: false })
const response = await POST(
jsonRequest('http://localhost/api/invitations', { maxUses: 1, expiresInDays: null })
)
expect(response.status).toBe(403)
expect(prisma.invitation.create).not.toHaveBeenCalled()
})
it('最高权限账号可以创建并查看邀请链接', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
prisma.invitation.create.mockResolvedValue({
id: 'invite-1',
code: 'ABCD1234',
createdBy: 'super-1',
maxUses: 3,
currentUses: 0,
expiresAt: null,
isActive: true,
createdAt: new Date('2026-05-28T00:00:00.000Z'),
})
prisma.invitation.count.mockResolvedValue(1)
prisma.invitation.findMany.mockResolvedValue([
{
id: 'invite-1',
code: 'ABCD1234',
createdBy: 'super-1',
maxUses: 3,
currentUses: 0,
expiresAt: null,
isActive: true,
createdAt: new Date('2026-05-28T00:00:00.000Z'),
},
])
const createResponse = await POST(
jsonRequest('http://localhost/api/invitations', { maxUses: 3, expiresInDays: null })
)
const listResponse = await GET(new NextRequest('http://localhost/api/invitations'))
const listData = await listResponse.json()
expect(createResponse.status).toBe(200)
expect(listResponse.status).toBe(200)
expect(prisma.invitation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
createdBy: 'super-1',
maxUses: 3,
}),
})
)
expect(listData.invitations[0].inviteUrl).toBe('http://localhost:3000/register?invite=ABCD1234')
})
it('普通管理员不能启停或删除邀请链接', async () => {
const patchResponse = await PATCH(
jsonRequest('http://localhost/api/invitations/invite-1', { isActive: false }, 'PATCH'),
{ params: { id: 'invite-1' } }
)
const deleteResponse = await DELETE(
new NextRequest('http://localhost/api/invitations/invite-1', { method: 'DELETE' }),
{ params: { id: 'invite-1' } }
)
expect(patchResponse.status).toBe(403)
expect(deleteResponse.status).toBe(403)
expect(prisma.invitation.update).not.toHaveBeenCalled()
expect(prisma.invitation.delete).not.toHaveBeenCalled()
})
it('最高权限账号可以启停和删除邀请链接', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN', isSuperAdmin: true },
})
prisma.user.findUnique.mockResolvedValue({ isSuperAdmin: true })
prisma.invitation.update.mockResolvedValue({ id: 'invite-1', isActive: false })
prisma.invitation.delete.mockResolvedValue({ id: 'invite-1' })
const patchResponse = await PATCH(
jsonRequest('http://localhost/api/invitations/invite-1', { isActive: false }, 'PATCH'),
{ params: { id: 'invite-1' } }
)
const deleteResponse = await DELETE(
new NextRequest('http://localhost/api/invitations/invite-1', { method: 'DELETE' }),
{ params: { id: 'invite-1' } }
)
expect(patchResponse.status).toBe(200)
expect(deleteResponse.status).toBe(200)
expect(prisma.invitation.update).toHaveBeenCalledWith({
where: { id: 'invite-1' },
data: { isActive: false },
})
expect(prisma.invitation.delete).toHaveBeenCalledWith({
where: { id: 'invite-1' },
})
})
})

View File

@ -4,14 +4,14 @@ import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { randomBytes } from 'crypto'
import { resolveBaseUrl } from '@/lib/config'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
// GET - 获取邀请链接列表(仅管理员)
// GET - 获取邀请链接列表(仅最高权限账号)
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }
@ -70,13 +70,12 @@ export async function GET(req: NextRequest) {
}
}
// POST - 创建邀请链接(仅管理员)
// POST - 创建邀请链接(仅最高权限账号)
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
if (!session || ((session.user as any)?.role !== 'ADMIN' && !isSuperAdmin)) {
if (!session || !(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '无权限' },
{ status: 403 }

View File

@ -2,6 +2,7 @@ import { NextRequest } from 'next/server';
import { POST } from '../route';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
jest.mock('@/lib/license/fingerprint-service', () => ({
generateFingerprint: jest.fn().mockResolvedValue({
@ -26,10 +27,15 @@ jest.mock('@/lib/license/license-service', () => ({
startHeartbeat: jest.fn(),
}));
jest.mock('@/lib/license/license-route-guards', () => ({
requireSuperAdminAfterInitialSetup: jest.fn(),
}));
const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction<typeof verifyLicenseSignature>;
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>;
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>;
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>;
const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction<typeof requireSuperAdminAfterInitialSetup>;
const signedFreeLicense = {
data: {
@ -90,6 +96,7 @@ describe('POST /api/license/activate-free', () => {
process.env.LICENSE_PROJECT_CODE = 'flnxi-sucai';
process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32);
mockVerifyLicenseSignature.mockReturnValue(true);
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true });
mockInitializeSystem.mockResolvedValue({
admin: {
created: false,
@ -124,6 +131,25 @@ describe('POST /api/license/activate-free', () => {
expect(fetchBody.contactInfo).toBe('13800000000');
});
it('已有最高权限账号后,未授权请求不能改写免费许可证', async () => {
mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({
allowed: false,
status: 403,
error: '系统已初始化,许可证变更需要最高权限账号登录',
});
const response = await POST(createMockRequest());
const data = await response.json();
expect(response.status).toBe(403);
expect(data.success).toBe(false);
expect(data.error).toBe('系统已初始化,许可证变更需要最高权限账号登录');
expect(global.fetch).not.toHaveBeenCalled();
expect(mockSaveLicenseLocally).not.toHaveBeenCalled();
expect(mockInitializeSystem).not.toHaveBeenCalled();
expect(mockStartHeartbeat).not.toHaveBeenCalled();
});
it('许可证服务没有返回签名许可证时拒绝激活,避免保存不可读取的本地许可证', async () => {
mockRegisterFreeSuccess({ license: undefined });

View File

@ -4,6 +4,7 @@ import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
function maskSecret(value: unknown): string {
if (typeof value !== 'string' || !value) return '****';
@ -34,6 +35,14 @@ function sanitizeLicenseServerLogPayload(payload: unknown) {
export async function POST(request: NextRequest) {
try {
const guard = await requireSuperAdminAfterInitialSetup();
if (!guard.allowed) {
return NextResponse.json(
{ success: false, error: guard.error },
{ status: guard.status }
);
}
const body = await request.json().catch(() => ({}));
const customerName = body.customerName?.trim();
const contactInfo = body.contactInfo?.trim();

View File

@ -3,6 +3,7 @@ import { POST } from '../route'
import { verifyLicenseSignature } from '@/lib/license/crypto-service'
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service'
import { syncPremiumModules } from '@/lib/license/premium-module-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
jest.mock('@/lib/license/fingerprint-service', () => ({
generateFingerprint: jest.fn().mockResolvedValue({
@ -31,11 +32,16 @@ jest.mock('@/lib/license/premium-module-service', () => ({
syncPremiumModules: jest.fn(),
}))
jest.mock('@/lib/license/license-route-guards', () => ({
requireSuperAdminAfterInitialSetup: jest.fn(),
}))
const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction<typeof verifyLicenseSignature>
const mockSaveLicenseLocally = saveLicenseLocally as jest.MockedFunction<typeof saveLicenseLocally>
const mockInitializeSystem = initializeSystem as jest.MockedFunction<typeof initializeSystem>
const mockStartHeartbeat = startHeartbeat as jest.MockedFunction<typeof startHeartbeat>
const mockSyncPremiumModules = syncPremiumModules as jest.MockedFunction<typeof syncPremiumModules>
const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction<typeof requireSuperAdminAfterInitialSetup>
const signedLicense = {
data: {
@ -92,6 +98,7 @@ describe('POST /api/license/activate', () => {
process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32)
mockVerifyLicenseSignature.mockReturnValue(true)
mockSyncPremiumModules.mockResolvedValue({ synced: true })
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true })
mockInitializeSystem.mockResolvedValue({
admin: {
created: false,
@ -133,6 +140,25 @@ describe('POST /api/license/activate', () => {
expect(fetchBody.licenseKey).toBe('ABCD-1234-EFGH-5678')
})
test('已有最高权限账号后,未授权请求不能改写本地许可证', async () => {
mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({
allowed: false,
status: 403,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
const response = await POST(createMockRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data.success).toBe(false)
expect(data.error).toBe('系统已初始化,许可证变更需要最高权限账号登录')
expect(global.fetch).not.toHaveBeenCalled()
expect(mockSaveLicenseLocally).not.toHaveBeenCalled()
expect(mockInitializeSystem).not.toHaveBeenCalled()
expect(mockStartHeartbeat).not.toHaveBeenCalled()
})
test('环境变量来源的初始管理员密码不应在激活响应中回显', async () => {
mockActivateSuccess()
mockInitializeSystem.mockResolvedValueOnce({

View File

@ -5,10 +5,19 @@ import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/lice
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import { syncPremiumModules } from '@/lib/license/premium-module-service';
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards';
import type { ActivateResponse } from '@/lib/license/types';
export async function POST(request: NextRequest) {
try {
const guard = await requireSuperAdminAfterInitialSetup();
if (!guard.allowed) {
return NextResponse.json(
{ success: false, error: guard.error },
{ status: guard.status }
);
}
const body = await request.json();
// 支持两种参数名:activationCode(新)和 licenseKey(旧)
const licenseKey = body.activationCode || body.licenseKey;

View File

@ -1,11 +1,11 @@
import { NextRequest } from 'next/server'
jest.mock('@/lib/license/license-service', () => ({
getLocalLicense: jest.fn(),
getLocalLicenseReadOnly: jest.fn(),
isLicenseConfigExpired: jest.fn(),
}))
const { getLocalLicense, isLicenseConfigExpired } = require('@/lib/license/license-service')
const { getLocalLicenseReadOnly, isLicenseConfigExpired } = require('@/lib/license/license-service')
import { GET } from '../route'
@ -16,7 +16,7 @@ describe('GET /api/license/check', () => {
})
test('本地许可证通过验签时应恢复激活状态并写入 cookie', async () => {
getLocalLicense.mockResolvedValue({
getLocalLicenseReadOnly.mockResolvedValue({
instanceId: 'test-instance-id',
licenseKey: 'license-key',
licenseData: 'encrypted-license',
@ -46,7 +46,7 @@ describe('GET /api/license/check', () => {
})
test('本地许可证为空或验签失败时应返回未激活', async () => {
getLocalLicense.mockResolvedValue(null)
getLocalLicenseReadOnly.mockResolvedValue(null)
const response = await GET(new NextRequest('http://localhost/api/license/check'))
const data = await response.json()
@ -58,7 +58,7 @@ describe('GET /api/license/check', () => {
test('本地许可证在线租约过期时不应恢复激活 cookie', async () => {
isLicenseConfigExpired.mockReturnValue(true)
getLocalLicense.mockResolvedValue({
getLocalLicenseReadOnly.mockResolvedValue({
instanceId: 'test-instance-id',
licenseKey: 'license-key',
licenseData: 'encrypted-license',

View File

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getLocalLicense, isLicenseConfigExpired } from '@/lib/license/license-service';
import { getLocalLicenseReadOnly, isLicenseConfigExpired } from '@/lib/license/license-service';
/**
* 检查数据库中是否有已激活的许可证
@ -7,7 +7,7 @@ import { getLocalLicense, isLicenseConfigExpired } from '@/lib/license/license-s
*/
export async function GET(request: NextRequest) {
try {
const license = await getLocalLicense();
const license = await getLocalLicenseReadOnly();
if (!license || isLicenseConfigExpired(license.cachedConfig)) {
return NextResponse.json({ activated: false });
}

View File

@ -0,0 +1,56 @@
import { NextRequest } from 'next/server'
import { POST } from '../route'
import { runLicenseHeartbeat } from '@/lib/license/license-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
jest.mock('@/lib/license/license-service', () => ({
runLicenseHeartbeat: jest.fn(),
}))
jest.mock('@/lib/license/license-route-guards', () => ({
requireSuperAdminAfterInitialSetup: jest.fn(),
}))
const mockRunLicenseHeartbeat = runLicenseHeartbeat as jest.MockedFunction<typeof runLicenseHeartbeat>
const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction<typeof requireSuperAdminAfterInitialSetup>
function createRequest() {
return new NextRequest('http://localhost:3000/api/license/heartbeat', {
method: 'POST',
})
}
describe('POST /api/license/heartbeat', () => {
beforeEach(() => {
jest.clearAllMocks()
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true })
mockRunLicenseHeartbeat.mockResolvedValue({ acknowledged: true })
})
it('已有最高权限账号后,未授权请求不能触发许可证心跳写入', async () => {
mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({
allowed: false,
status: 403,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
const response = await POST(createRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({
acknowledged: false,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
expect(mockRunLicenseHeartbeat).not.toHaveBeenCalled()
})
it('最高权限账号可触发服务端许可证心跳', async () => {
const response = await POST(createRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ acknowledged: true })
expect(mockRunLicenseHeartbeat).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,80 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { getLocalLicense, updateLocalLicense, clearLocalLicense } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { prisma } from '@/lib/prisma';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import { syncPremiumModules } from '@/lib/license/premium-module-service';
import type { HeartbeatResponse } from '@/lib/license/types';
import { NextRequest, NextResponse } from 'next/server'
import { runLicenseHeartbeat } from '@/lib/license/license-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
export async function POST(request: NextRequest) {
try {
const localLicense = await getLocalLicense();
if (!localLicense) {
return NextResponse.json({ acknowledged: false, error: '未找到许可证' });
}
// ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端)
const fingerprint = await generateFingerprint();
// 获取使用统计
const [userCount, materialCount] = await Promise.all([
prisma.user.count(),
prisma.material.count(),
]);
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const signedRequest = buildSignedLicenseRequest('heartbeat', {
instanceId: fingerprint.instanceId,
fingerprint,
usage: { userCount, materialCount },
});
const response = await fetch(buildLicenseServerLicenseApiUrl('heartbeat'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedRequest),
});
// 检查是否被吊销
if (response.status === 403 || response.status === 404) {
console.log('[License] 心跳检测到许可证已被吊销');
await clearLocalLicense();
return NextResponse.json({
acknowledged: false,
revoked: true,
error: '许可证已被吊销'
});
}
const data: HeartbeatResponse = await response.json();
if (data.acknowledged && data.license) {
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
return NextResponse.json({
acknowledged: false,
error: '许可证签名验证失败',
});
}
// ✅ 在后端更新本地缓存
await updateLocalLicense(data.license);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error);
}
}
return NextResponse.json({
acknowledged: data.acknowledged || false
});
} catch (error) {
console.error('心跳发送失败:', error);
return NextResponse.json({
acknowledged: false,
error: '网络错误'
});
export async function POST(_request: NextRequest) {
const guard = await requireSuperAdminAfterInitialSetup()
if (!guard.allowed) {
return NextResponse.json(
{ acknowledged: false, error: guard.error },
{ status: guard.status }
)
}
}
const result = await runLicenseHeartbeat()
return NextResponse.json(result)
}

View File

@ -0,0 +1,56 @@
import { NextRequest } from 'next/server'
import { POST } from '../route'
import { runLicenseVerification } from '@/lib/license/license-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
jest.mock('@/lib/license/license-service', () => ({
runLicenseVerification: jest.fn(),
}))
jest.mock('@/lib/license/license-route-guards', () => ({
requireSuperAdminAfterInitialSetup: jest.fn(),
}))
const mockRunLicenseVerification = runLicenseVerification as jest.MockedFunction<typeof runLicenseVerification>
const mockRequireSuperAdminAfterInitialSetup = requireSuperAdminAfterInitialSetup as jest.MockedFunction<typeof requireSuperAdminAfterInitialSetup>
function createRequest() {
return new NextRequest('http://localhost:3000/api/license/verify', {
method: 'POST',
})
}
describe('POST /api/license/verify', () => {
beforeEach(() => {
jest.clearAllMocks()
mockRequireSuperAdminAfterInitialSetup.mockResolvedValue({ allowed: true })
mockRunLicenseVerification.mockResolvedValue({ valid: true })
})
it('已有最高权限账号后,未授权请求不能触发许可证校验写入', async () => {
mockRequireSuperAdminAfterInitialSetup.mockResolvedValueOnce({
allowed: false,
status: 403,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
const response = await POST(createRequest())
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toEqual({
valid: false,
error: '系统已初始化,许可证变更需要最高权限账号登录',
})
expect(mockRunLicenseVerification).not.toHaveBeenCalled()
})
it('最高权限账号可触发服务端许可证校验', async () => {
const response = await POST(createRequest())
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({ valid: true })
expect(mockRunLicenseVerification).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,103 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { generateFingerprint } from '@/lib/license/fingerprint-service';
import {
updateLocalLicense,
getLocalLicense,
clearLocalLicense,
isLicenseConfigExpired,
} from '@/lib/license/license-service';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import { syncPremiumModules } from '@/lib/license/premium-module-service';
import type { VerifyResponse } from '@/lib/license/types';
import { NextRequest, NextResponse } from 'next/server'
import { runLicenseVerification } from '@/lib/license/license-service'
import { requireSuperAdminAfterInitialSetup } from '@/lib/license/license-route-guards'
// 宽限期(7天)
const GRACE_PERIOD_DAYS = 7;
const GRACE_PERIOD_MS = GRACE_PERIOD_DAYS * 24 * 60 * 60 * 1000;
function isWithinGracePeriod(lastVerified: Date | string | null): boolean {
if (!lastVerified) return false;
const lastVerifiedDate = typeof lastVerified === 'string' ? new Date(lastVerified) : lastVerified;
const now = new Date();
return (now.getTime() - lastVerifiedDate.getTime()) < GRACE_PERIOD_MS;
}
export async function POST(request: NextRequest) {
try {
// 先检查本地缓存
const localLicense = await getLocalLicense();
if (!localLicense) {
return NextResponse.json({ valid: false, error: '未找到许可证' });
}
// ✅ 安全改进:在后端生成硬件指纹(不暴露给客户端)
const fingerprint = await generateFingerprint();
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const signedRequest = buildSignedLicenseRequest('verify', {
instanceId: fingerprint.instanceId,
fingerprint,
});
const response = await fetch(buildLicenseServerLicenseApiUrl('verify'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedRequest),
});
const data: VerifyResponse = await response.json();
if (!data.valid || !data.license) {
// 检查是否是被吊销(禁用/删除/过期)
const isRevoked = response.status === 403 || response.status === 404;
if (isRevoked) {
// 许可证被吊销,清除本地缓存
console.log('[License] 许可证已被吊销:', data.error);
await clearLocalLicense();
return NextResponse.json({
valid: false,
error: data.error || '许可证已被吊销',
revoked: true
});
}
// 其他错误(如网络问题),检查宽限期
if (!isLicenseConfigExpired(localLicense.cachedConfig) && isWithinGracePeriod(localLicense.lastVerified)) {
return NextResponse.json({ valid: true });
}
return NextResponse.json({ valid: false, error: data.error || '验证失败' });
}
// ✅ 在后端验证签名
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
return NextResponse.json({ valid: false, error: '许可证签名验证失败' });
}
// ✅ 在后端更新本地缓存
await updateLocalLicense(data.license);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error);
}
return NextResponse.json({ valid: true });
} catch (error) {
console.error('验证失败:', error);
// 网络错误时检查宽限期
const localLicense = await getLocalLicense();
if (
localLicense &&
!isLicenseConfigExpired(localLicense.cachedConfig) &&
isWithinGracePeriod(localLicense.lastVerified)
) {
return NextResponse.json({ valid: true });
}
return NextResponse.json({ valid: false, error: '网络错误' });
export async function POST(_request: NextRequest) {
const guard = await requireSuperAdminAfterInitialSetup()
if (!guard.allowed) {
return NextResponse.json(
{ valid: false, error: guard.error },
{ status: guard.status }
)
}
}
const result = await runLicenseVerification()
return NextResponse.json(result)
}

View File

@ -0,0 +1,48 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
material: {
findMany: jest.fn(),
},
},
}))
export {}
const { prisma } = require('@/lib/prisma')
const { GET } = require('../route')
describe('GET /api/material-rankings', () => {
beforeEach(() => {
jest.clearAllMocks()
prisma.$queryRaw
.mockResolvedValueOnce([{ id: 'download-1' }])
.mockResolvedValueOnce([{ id: 'like-1' }])
prisma.material.findMany.mockResolvedValue([{ id: 'premium-1' }])
})
it('读取排行榜时只查询正式表,不执行运行期建表维护', async () => {
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual({
downloads: ['download-1'],
likes: ['like-1'],
premium: ['premium-1'],
})
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2)
expect(prisma.material.findMany).toHaveBeenCalledWith({
where: {
isPremium: true,
},
select: {
id: true,
},
orderBy: {
updatedAt: 'desc',
},
take: 10,
})
})
})

View File

@ -1,6 +1,5 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
export const dynamic = 'force-dynamic'
@ -10,9 +9,6 @@ export const dynamic = 'force-dynamic'
*/
export async function GET() {
try {
// 确保下载日志表存在(第一次使用时自动创建)
await ensureMaterialDownloadLogsTable()
// 下载榜:直接从下载日志表统计,按下载次数降序,相同次数按最近下载时间降序
const downloadRankings = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT

View File

@ -11,6 +11,7 @@ import {
readAllFieldValues,
} from '@/lib/field-data-storage'
import { validateSubmittedTemplateFields } from '@/lib/template-field-validation'
import { canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions'
// GET /api/materials/[id] - 获取单个素材详情
export async function GET(
@ -197,7 +198,15 @@ export async function PUT(
// 获取素材信息
const material = await prisma.material.findUnique({
where: { id: params.id }
where: { id: params.id },
include: {
user: {
select: {
id: true,
department: true,
},
},
},
})
if (!material) {
@ -207,9 +216,9 @@ export async function PUT(
)
}
// 权限检查:只有素材上传者或管理员可以编辑
const userFromSession = session.user as any
if (material.userId !== userFromSession.id && userFromSession.role !== 'ADMIN') {
// 权限检查:本人、最高权限账号,或管理部门范围内的管理员可以编辑
const actor = await getMaterialPermissionActor(session)
if (!(await canManageMaterial(material, actor))) {
return NextResponse.json(
{ error: '您没有权限编辑此素材' },
{ status: 403 }
@ -339,7 +348,7 @@ export async function PUT(
})
if (changes.length > 0) {
console.log(`[MaterialUpdate] Material ${params.id} updated by ${userFromSession.id}:`, changes.join('; '))
console.log(`[MaterialUpdate] Material ${params.id} updated by ${actor!.id}:`, changes.join('; '))
// 记录详细修改日志到数据库
const changesObj: Record<string, { oldValue: any; newValue: any }> = {}
@ -358,7 +367,7 @@ export async function PUT(
if (Object.keys(changesObj).length > 0) {
await createMaterialLog({
materialId: params.id,
userId: userFromSession.id,
userId: actor!.id,
action: 'UPDATE',
changes: changesObj,
})

View File

@ -14,6 +14,9 @@ jest.mock('@/lib/prisma', () => ({
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
material: {
findUnique: jest.fn(),
create: jest.fn(),
@ -158,9 +161,23 @@ describe('素材模板字段链路测试', () => {
allowed: true,
})
prisma.user.findUnique.mockResolvedValue({
username: '上传者A',
prisma.user.findUnique.mockImplementation(async ({ where }: any = {}) => {
const id = where?.id || 'admin-1'
const isAdmin = id === 'admin-1'
return {
id,
role: isAdmin ? 'ADMIN' : 'USER',
isSuperAdmin: false,
department: isAdmin ? '设计部' : '运营部',
managedDepartment: isAdmin ? '设计部' : null,
username: '上传者A',
}
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '设计部-建模组' },
{ name: '运营部' },
])
prisma.material.findUnique.mockResolvedValue(null)
@ -396,6 +413,11 @@ describe('素材模板字段链路测试', () => {
test('更新时应保留旧 metadata 字段并覆盖本次提交值', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
@ -503,10 +525,14 @@ describe('素材模板字段链路测试', () => {
)
})
test('管理员可以通过表单编辑别人上传的素材', async () => {
test('管理员可以通过表单编辑管理部门范围内的素材', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
@ -602,6 +628,11 @@ describe('素材模板字段链路测试', () => {
test('编辑时缺失模板必填字段应拒绝更新', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
@ -646,6 +677,11 @@ describe('素材模板字段链路测试', () => {
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
prisma.material.findUnique.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
@ -690,6 +726,11 @@ describe('素材模板字段链路测试', () => {
test('编辑时自定义字段数据过大应拒绝更新,不能截坏 metadata JSON', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
@ -889,10 +930,14 @@ describe('素材模板字段链路测试', () => {
)
})
test('旧 JSON 更新入口允许管理员编辑别人上传的素材', async () => {
test('旧 JSON 更新入口允许管理员编辑管理部门范围内的素材', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
})
const response = await updateMaterialById(
@ -918,6 +963,33 @@ describe('素材模板字段链路测试', () => {
)
})
test('旧 JSON 更新入口拒绝普通管理员编辑管理范围外素材', async () => {
prisma.material.findUnique.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
user: {
id: 'owner-1',
department: '运营部',
},
})
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '更新后的产品',
customCode: 'ADMIN-NOPE',
},
}),
{ params: { id: 'material-1' } }
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('您没有权限编辑此素材')
expect(prisma.material.update).not.toHaveBeenCalled()
expect(createMaterialLog).not.toHaveBeenCalled()
})
test('旧 JSON 更新入口拒绝非上传者普通用户编辑别人的素材', async () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'user-2', role: 'USER' },

View File

@ -30,14 +30,9 @@ jest.mock('@/lib/s3', () => ({
isS3Url: jest.fn(() => false),
}))
jest.mock('@/lib/material-download-log', () => ({
ensureMaterialDownloadLogsTable: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { getS3ObjectStream } = require('@/lib/s3')
const { ensureMaterialDownloadLogsTable } = require('@/lib/material-download-log')
describe('GET /api/materials/download', () => {
let tempDir: string
@ -83,7 +78,36 @@ describe('GET /api/materials/download', () => {
expect(response.headers.get('accept-ranges')).toBe('bytes')
expect(response.headers.get('content-disposition')).toContain("filename*=UTF-8''")
expect(getS3ObjectStream).not.toHaveBeenCalled()
expect(ensureMaterialDownloadLogsTable).not.toHaveBeenCalled()
})
test('下载别人素材时记录日志但不在 GET 中执行建表维护', async () => {
getServerSession.mockResolvedValue({
user: {
id: 'user-2',
role: 'USER',
email: 'user@example.com',
},
})
prisma.user.findUnique.mockResolvedValue({
id: 'user-2',
canDownload: true,
email: 'user@example.com',
username: 'user',
})
const response = await GET(new NextRequest('http://localhost/api/materials/download?id=material-1'))
await expect(response.text()).resolves.toBe('0123456789')
expect(response.status).toBe(200)
expect(prisma.$executeRaw).toHaveBeenCalledTimes(1)
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: {
downloadCount: {
increment: 1,
},
},
})
})
test('legacy 本地文件 Range 下载应只返回指定范围', async () => {
@ -101,4 +125,39 @@ describe('GET /api/materials/download', () => {
expect(response.headers.get('content-length')).toBe('4')
expect(response.headers.get('content-disposition')).toContain("filename*=UTF-8''")
})
test('legacy 本地文件不存在时不记录下载日志和次数', async () => {
getServerSession.mockResolvedValue({
user: {
id: 'user-2',
role: 'USER',
email: 'user@example.com',
},
})
prisma.user.findUnique.mockResolvedValue({
id: 'user-2',
canDownload: true,
email: 'user@example.com',
username: 'user',
})
prisma.material.findUnique.mockResolvedValue({
id: 'material-1',
userId: 'owner-1',
productModel: 'MODEL',
zipFilePath: path.join(tempDir, 'missing.zip'),
zipFileName: 'missing.zip',
materialType: '3DMAX',
categoryName: '分类',
sceneName: '场景',
authorName: '作者',
})
const response = await GET(new NextRequest('http://localhost/api/materials/download?id=material-1'))
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toContain('文件不存在')
expect(prisma.$executeRaw).not.toHaveBeenCalled()
expect(prisma.material.update).not.toHaveBeenCalled()
})
})

View File

@ -3,7 +3,6 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { getS3ObjectStream, isS3Url } from '@/lib/s3'
import { ensureMaterialDownloadLogsTable } from '@/lib/material-download-log'
import { createLocalFileStreamResponse } from '@/lib/local-file-stream'
import { access } from 'fs/promises'
import path from 'path'
@ -92,42 +91,6 @@ export async function GET(req: NextRequest) {
console.log(`[Download] Material ${id} (${material.productModel}) found. Owner: ${material.userId}, Requester: ${userId}`)
const isOwnMaterial = material.userId === userId
let shouldIncrementDownload = false
if (!isOwnMaterial) {
try {
await ensureMaterialDownloadLogsTable()
const now = new Date()
shouldIncrementDownload = true
// 每次下载都记录一条日志
await prisma.$executeRaw`
INSERT INTO material_download_logs (id, "materialId", "userId", "downloadedAt")
VALUES (gen_random_uuid(), ${id}, ${userId}, ${now})
`
console.log(`[Download] Created download log for material ${id}, user ${userId}`)
} catch (logError) {
console.error(`[Download] Error handling download log:`, logError)
// 如果日志记录失败,仍然允许下载,但不增加计数
shouldIncrementDownload = false
console.warn(`[Download] Continuing download without logging due to error`)
}
} else {
// 自己下载自己的素材,不记录下载日志,也不增加下载次数
console.log(`[Download] User ${userId} downloaded own material ${id}. Not incrementing count.`)
}
if (shouldIncrementDownload) {
await prisma.material.update({
where: { id },
data: {
downloadCount: {
increment: 1,
},
},
})
}
if (!material.zipFilePath) {
console.error(`[Download] Material ${id} has no zipFilePath`)
return NextResponse.json(
@ -174,6 +137,11 @@ export async function GET(req: NextRequest) {
if (isS3Url(material.zipFilePath)) {
const s3Object = await getS3ObjectStream(material.zipFilePath, rangeHeader)
const stream = toWebStream(s3Object.body)
await recordDownloadIfNeeded({
materialId: id,
userId,
isOwnMaterial: material.userId === userId,
})
const headers: Record<string, string> = {
'Content-Type': s3Object.contentType || contentType,
'Content-Disposition': contentDispositionHeader,
@ -214,6 +182,12 @@ export async function GET(req: NextRequest) {
{ status: 404 }
)
}
await recordDownloadIfNeeded({
materialId: id,
userId,
isOwnMaterial: material.userId === userId,
})
return createLocalFileStreamResponse(filePath, {
contentType,
@ -237,6 +211,41 @@ export async function GET(req: NextRequest) {
}
}
async function recordDownloadIfNeeded({
materialId,
userId,
isOwnMaterial,
}: {
materialId: string
userId: string
isOwnMaterial: boolean
}) {
if (isOwnMaterial) {
console.log(`[Download] User ${userId} downloaded own material ${materialId}. Not incrementing count.`)
return
}
try {
const now = new Date()
await prisma.$executeRaw`
INSERT INTO material_download_logs (id, "materialId", "userId", "downloadedAt")
VALUES (gen_random_uuid(), ${materialId}, ${userId}, ${now})
`
console.log(`[Download] Created download log for material ${materialId}, user ${userId}`)
await prisma.material.update({
where: { id: materialId },
data: {
downloadCount: {
increment: 1,
},
},
})
} catch (logError) {
console.error(`[Download] Error handling download log:`, logError)
console.warn(`[Download] Continuing download without logging due to error`)
}
}
function toWebStream(body: any) {
if (!body) {
throw new Error('S3 object body is empty')

View File

@ -18,6 +18,9 @@ jest.mock('@/lib/prisma', () => ({
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
},
}))
@ -65,7 +68,13 @@ describe('/api/materials/share', () => {
it('公开分享开关关闭时拒绝创建新分享票据', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
})
isPublicShareEnabled.mockResolvedValue(false)
const response = await POST(
@ -84,7 +93,13 @@ describe('/api/materials/share', () => {
it('已登录用户可以为未删除素材创建分享票据', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
})
prisma.material.findFirst.mockResolvedValue({
id: 'material-1',
userId: 'user-1',
@ -114,6 +129,12 @@ describe('/api/materials/share', () => {
ownerType: true,
ownerId: true,
createdByUserId: true,
user: {
select: {
id: true,
department: true,
},
},
},
})
expect(createMaterialShareToken).toHaveBeenCalledWith('material-1', 'user-1')
@ -122,7 +143,13 @@ describe('/api/materials/share', () => {
it('无关普通用户不能为别人的素材创建分享票据', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-3', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({ id: 'user-3', role: 'USER', isSuperAdmin: false })
prisma.user.findUnique.mockResolvedValue({
id: 'user-3',
role: 'USER',
isSuperAdmin: false,
department: '运营部',
managedDepartment: null,
})
prisma.material.findFirst.mockResolvedValue({
id: 'material-1',
userId: 'user-1',
@ -144,6 +171,81 @@ describe('/api/materials/share', () => {
expect(createMaterialShareToken).not.toHaveBeenCalled()
})
it('部门管理员只能为管理范围内素材创建分享票据', async () => {
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.findFirst.mockResolvedValue({
id: 'material-1',
userId: 'user-1',
ownerType: 'TEAM',
ownerId: 'team-1',
createdByUserId: 'user-1',
user: {
id: 'user-1',
department: '设计部-三维组',
},
})
createMaterialShareToken.mockResolvedValue({
token: 'share-token',
expiresAt: new Date('2026-05-31T00:00:00.000Z'),
})
const response = await POST(
new NextRequest('http://localhost/api/materials/share', {
method: 'POST',
body: JSON.stringify({ materialId: 'material-1' }),
})
)
expect(response.status).toBe(200)
expect(createMaterialShareToken).toHaveBeenCalledWith('material-1', 'manager-1')
expect(prisma.department.findMany).not.toHaveBeenCalled()
})
it('普通管理员不能跨部门创建分享票据', async () => {
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.findFirst.mockResolvedValue({
id: 'material-1',
userId: 'user-2',
ownerType: 'TEAM',
ownerId: 'team-1',
createdByUserId: 'user-2',
user: {
id: 'user-2',
department: '运营部',
},
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '运营部' },
])
const response = await POST(
new NextRequest('http://localhost/api/materials/share', {
method: 'POST',
body: JSON.stringify({ materialId: 'material-1' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('无权分享该素材')
expect(createMaterialShareToken).not.toHaveBeenCalled()
})
it('没有有效分享票据时拒绝公开读取素材', async () => {
findValidMaterialShareToken.mockResolvedValue(null)

View File

@ -65,6 +65,12 @@ export async function POST(req: NextRequest) {
ownerType: true,
ownerId: true,
createdByUserId: true,
user: {
select: {
id: true,
department: true,
},
},
},
})
@ -75,7 +81,7 @@ export async function POST(req: NextRequest) {
)
}
if (!canManageMaterialShare(material, actor)) {
if (!(await canManageMaterialShare(material, actor))) {
return NextResponse.json(
{ error: '无权分享该素材' },
{ status: 403 }

View File

@ -18,6 +18,9 @@ jest.mock('@/lib/prisma', () => ({
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
},
}))
@ -45,7 +48,13 @@ describe('DELETE /api/materials/share/tokens/[id]', () => {
it('无关普通用户不能撤销别人的素材分享链接', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-3', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({ id: 'user-3', role: 'USER', isSuperAdmin: false })
prisma.user.findUnique.mockResolvedValue({
id: 'user-3',
role: 'USER',
isSuperAdmin: false,
department: '运营部',
managedDepartment: null,
})
prisma.materialShareToken.findUnique.mockResolvedValue({
id: 'token-1',
revokedAt: null,
@ -72,7 +81,13 @@ describe('DELETE /api/materials/share/tokens/[id]', () => {
it('素材负责人可以撤销分享链接', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
})
prisma.materialShareToken.findUnique.mockResolvedValue({
id: 'token-1',
revokedAt: null,
@ -102,8 +117,14 @@ describe('DELETE /api/materials/share/tokens/[id]', () => {
})
it('已经撤销的分享链接再次撤销也返回成功且不重复写入', async () => {
getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({ id: 'admin-1', role: 'ADMIN', isSuperAdmin: false })
getServerSession.mockResolvedValue({ user: { id: 'super-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({
id: 'super-1',
role: 'ADMIN',
isSuperAdmin: true,
department: null,
managedDepartment: null,
})
prisma.materialShareToken.findUnique.mockResolvedValue({
id: 'token-1',
revokedAt: new Date('2026-05-24T00:00:00.000Z'),
@ -127,4 +148,84 @@ describe('DELETE /api/materials/share/tokens/[id]', () => {
expect(data.success).toBe(true)
expect(prisma.materialShareToken.update).not.toHaveBeenCalled()
})
it('部门管理员可以撤销管理范围内素材的分享链接', async () => {
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
department: '设计部',
managedDepartment: '设计部',
})
prisma.materialShareToken.findUnique.mockResolvedValue({
id: 'token-1',
revokedAt: null,
material: {
id: 'material-1',
userId: 'user-1',
ownerType: 'TEAM',
ownerId: 'team-1',
createdByUserId: 'user-1',
deletedAt: null,
user: {
id: 'user-1',
department: '设计部-三维组',
},
},
})
prisma.materialShareToken.update.mockResolvedValue({ id: 'token-1' })
const response = await DELETE(
new NextRequest('http://localhost/api/materials/share/tokens/token-1', { method: 'DELETE' }),
{ params: { id: 'token-1' } }
)
expect(response.status).toBe(200)
expect(prisma.materialShareToken.update).toHaveBeenCalledWith({
where: { id: 'token-1' },
data: { revokedAt: expect.any(Date) },
})
})
it('普通管理员不能跨部门撤销素材分享链接', async () => {
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
department: '设计部',
managedDepartment: '设计部',
})
prisma.materialShareToken.findUnique.mockResolvedValue({
id: 'token-1',
revokedAt: null,
material: {
id: 'material-1',
userId: 'user-2',
ownerType: 'TEAM',
ownerId: 'team-1',
createdByUserId: 'user-2',
deletedAt: null,
user: {
id: 'user-2',
department: '运营部',
},
},
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '运营部' },
])
const response = await DELETE(
new NextRequest('http://localhost/api/materials/share/tokens/token-1', { method: 'DELETE' }),
{ params: { id: 'token-1' } }
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('无权撤销该分享链接')
expect(prisma.materialShareToken.update).not.toHaveBeenCalled()
})
})

View File

@ -42,6 +42,12 @@ export async function DELETE(
ownerId: true,
createdByUserId: true,
deletedAt: true,
user: {
select: {
id: true,
department: true,
},
},
},
},
},
@ -54,7 +60,7 @@ export async function DELETE(
)
}
if (!canManageMaterialShare(token.material, actor)) {
if (!(await canManageMaterialShare(token.material, actor))) {
return NextResponse.json(
{ error: '无权撤销该分享链接' },
{ status: 403 }

View File

@ -20,6 +20,9 @@ jest.mock('@/lib/prisma', () => ({
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
},
}))
@ -46,7 +49,13 @@ describe('GET /api/materials/share/tokens', () => {
it('无关普通用户不能查看别人的素材分享链接', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-3', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({ id: 'user-3', role: 'USER', isSuperAdmin: false })
prisma.user.findUnique.mockResolvedValue({
id: 'user-3',
role: 'USER',
isSuperAdmin: false,
department: '运营部',
managedDepartment: null,
})
prisma.material.findFirst.mockResolvedValue({
id: 'material-1',
userId: 'user-1',
@ -67,7 +76,13 @@ describe('GET /api/materials/share/tokens', () => {
it('素材负责人可以查看分享链接状态,但不会返回 token 明文或 hash', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({ id: 'user-1', role: 'USER', isSuperAdmin: false })
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
})
prisma.material.findFirst.mockResolvedValue({
id: 'material-1',
userId: 'user-1',
@ -126,4 +141,75 @@ describe('GET /api/materials/share/tokens', () => {
expect(JSON.stringify(data)).not.toContain('tokenHash')
expect(JSON.stringify(data)).not.toContain('share-token')
})
it('部门管理员可以查看管理范围内素材的分享链接', async () => {
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.findFirst.mockResolvedValue({
id: 'material-1',
userId: 'user-1',
ownerType: 'TEAM',
ownerId: 'team-1',
createdByUserId: 'user-1',
user: {
id: 'user-1',
department: '设计部-三维组',
},
})
prisma.materialShareToken.findMany.mockResolvedValue([])
const response = await GET(
new NextRequest('http://localhost/api/materials/share/tokens?materialId=material-1')
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.tokens).toEqual([])
expect(prisma.materialShareToken.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { materialId: 'material-1' },
})
)
})
it('普通管理员不能跨部门查看素材分享链接', async () => {
getServerSession.mockResolvedValue({ user: { id: 'manager-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({
id: 'manager-1',
role: 'ADMIN',
isSuperAdmin: false,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.findFirst.mockResolvedValue({
id: 'material-1',
userId: 'user-2',
ownerType: 'TEAM',
ownerId: 'team-1',
createdByUserId: 'user-2',
user: {
id: 'user-2',
department: '运营部',
},
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '运营部' },
])
const response = await GET(
new NextRequest('http://localhost/api/materials/share/tokens?materialId=material-1')
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('无权查看该素材的分享链接')
expect(prisma.materialShareToken.findMany).not.toHaveBeenCalled()
})
})

View File

@ -40,6 +40,12 @@ export async function GET(req: NextRequest) {
ownerType: true,
ownerId: true,
createdByUserId: true,
user: {
select: {
id: true,
department: true,
},
},
},
})
@ -50,7 +56,7 @@ export async function GET(req: NextRequest) {
)
}
if (!canManageMaterialShare(material, actor)) {
if (!(await canManageMaterialShare(material, actor))) {
return NextResponse.json(
{ error: '无权查看该素材的分享链接' },
{ status: 403 }

View File

@ -15,6 +15,12 @@ jest.mock('@/lib/prisma', () => ({
findUnique: jest.fn(),
update: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
},
}))
@ -52,10 +58,23 @@ describe('/api/materials/transcode 权限边界', () => {
it('普通用户不能查询别人素材的转码状态', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
})
prisma.material.findUnique.mockResolvedValue({
id: 'material-1',
userId: 'user-2',
createdByUserId: 'user-2',
ownerType: 'TEAM',
ownerId: 'team-1',
user: {
id: 'user-2',
department: '运营部',
},
videoProcessingStatus: 'pending',
videoPreview540Url: null,
videoPreviewClipUrl: null,
@ -72,12 +91,25 @@ describe('/api/materials/transcode 权限边界', () => {
expect(data.error).toBe('无权查看该素材的转码状态')
})
it('素材上传者可以查询自己的转码状态', async () => {
it('当前负责人可以查询自己的转码状态', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
})
prisma.material.findUnique.mockResolvedValue({
id: 'material-1',
userId: 'user-2',
userId: 'user-1',
createdByUserId: 'user-1',
ownerType: 'USER',
ownerId: 'user-1',
user: {
id: 'user-1',
department: '设计部',
},
videoProcessingStatus: 'done',
videoPreview540Url: 'preview.mp4',
videoPreviewClipUrl: null,
@ -114,10 +146,23 @@ describe('/api/materials/transcode 权限边界', () => {
it('普通用户不能触发别人素材的转码任务', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
})
prisma.material.findUnique.mockResolvedValue({
id: 'material-1',
userId: 'user-2',
createdByUserId: 'user-2',
ownerType: 'TEAM',
ownerId: 'team-1',
user: {
id: 'user-2',
department: '运营部',
},
materialKind: 'VIDEO',
videoSourceUrl: 'source.mp4',
videoCoverUrl: null,
@ -139,19 +184,70 @@ describe('/api/materials/transcode 权限边界', () => {
expect(enqueueVideoTranscodeJob).not.toHaveBeenCalled()
})
it('管理员可以触发转码任务', async () => {
getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } })
it('历史上传者不再自动拥有转码管理权', async () => {
getServerSession.mockResolvedValue({ user: { id: 'user-1', role: 'USER' } })
prisma.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'USER',
isSuperAdmin: false,
department: '设计部',
managedDepartment: null,
})
prisma.material.findUnique.mockResolvedValue({
id: 'material-1',
userId: 'user-2',
createdByUserId: 'user-2',
createdByUserId: 'user-1',
ownerType: 'TEAM',
ownerId: 'team-1',
user: {
id: 'user-2',
department: '运营部',
},
materialKind: 'VIDEO',
videoSourceUrl: 'source.mp4',
videoCoverUrl: null,
videoProcessingStatus: 'pending',
materialType: '视频',
})
const response = await POST(
new NextRequest('http://localhost/api/materials/transcode', {
method: 'POST',
body: JSON.stringify({ materialId: 'material-1' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('无权触发该素材的转码任务')
expect(enqueueVideoTranscodeJob).not.toHaveBeenCalled()
})
it('部门管理员可以触发管理范围内素材的转码任务', async () => {
getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
role: 'ADMIN',
isSuperAdmin: false,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.findUnique.mockResolvedValue({
id: 'material-1',
userId: 'user-2',
createdByUserId: 'user-2',
ownerType: 'TEAM',
ownerId: 'team-1',
user: {
id: 'user-2',
department: '设计部-建模组',
},
materialKind: 'VIDEO',
videoSourceUrl: 'source.mp4',
videoCoverUrl: null,
videoProcessingStatus: 'pending',
materialType: '视频',
})
prisma.material.update.mockResolvedValue({})
enqueueVideoTranscodeJob.mockResolvedValue(undefined)
const response = await POST(
@ -172,6 +268,49 @@ describe('/api/materials/transcode 权限边界', () => {
})
})
it('普通管理员不能跨部门触发转码任务', async () => {
getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } })
prisma.user.findUnique.mockResolvedValue({
id: 'admin-1',
role: 'ADMIN',
isSuperAdmin: false,
department: '设计部',
managedDepartment: '设计部',
})
prisma.material.findUnique.mockResolvedValue({
id: 'material-1',
userId: 'user-2',
createdByUserId: 'user-2',
ownerType: 'TEAM',
ownerId: 'team-1',
user: {
id: 'user-2',
department: '运营部',
},
materialKind: 'VIDEO',
videoSourceUrl: 'source.mp4',
videoCoverUrl: null,
videoProcessingStatus: 'pending',
materialType: '视频',
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '运营部' },
])
const response = await POST(
new NextRequest('http://localhost/api/materials/transcode', {
method: 'POST',
body: JSON.stringify({ materialId: 'material-1' }),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('无权触发该素材的转码任务')
expect(enqueueVideoTranscodeJob).not.toHaveBeenCalled()
})
it('普通管理员不能触发卡住转码任务恢复', async () => {
getServerSession.mockResolvedValue({ user: { id: 'admin-1', role: 'ADMIN' } })
isCurrentSuperAdmin.mockResolvedValue(false)

View File

@ -12,22 +12,15 @@ import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { prisma } from '@/lib/prisma'
import {
canManageMaterial,
getMaterialPermissionActor,
} from '@/lib/material-permissions'
import { enqueueVideoTranscodeJob, recoverStaleVideoTranscodeJobs } from '@/lib/video-transcode-service'
export const dynamic = 'force-dynamic'
export const maxDuration = 3600 // 1 hour timeout for transcode
function getSessionUser(session: unknown) {
return (session as any)?.user as { id?: string; role?: string } | undefined
}
function canManageTranscode(session: unknown, material: { userId?: string | null; createdByUserId?: string | null }) {
const user = getSessionUser(session)
if (!user?.id) return false
if (user.role === 'ADMIN') return true
return material.userId === user.id || material.createdByUserId === user.id
}
/**
* GET /api/materials/transcode?materialId=xxx
* 查询视频转码状态
@ -35,7 +28,8 @@ function canManageTranscode(session: unknown, material: { userId?: string | null
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!getSessionUser(session)?.id) {
const actor = await getMaterialPermissionActor(session)
if (!actor) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
@ -58,6 +52,14 @@ export async function GET(req: NextRequest) {
id: true,
userId: true,
createdByUserId: true,
ownerType: true,
ownerId: true,
user: {
select: {
id: true,
department: true,
},
},
videoProcessingStatus: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
@ -73,7 +75,7 @@ export async function GET(req: NextRequest) {
)
}
if (!canManageTranscode(session, material)) {
if (!(await canManageMaterial(material, actor))) {
return NextResponse.json(
{ error: '无权查看该素材的转码状态' },
{ status: 403 }
@ -104,7 +106,8 @@ export async function GET(req: NextRequest) {
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!getSessionUser(session)?.id) {
const actor = await getMaterialPermissionActor(session)
if (!actor) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
@ -128,6 +131,14 @@ export async function POST(req: NextRequest) {
id: true,
userId: true,
createdByUserId: true,
ownerType: true,
ownerId: true,
user: {
select: {
id: true,
department: true,
},
},
materialKind: true,
videoSourceUrl: true,
videoCoverUrl: true,
@ -143,7 +154,7 @@ export async function POST(req: NextRequest) {
)
}
if (!canManageTranscode(session, material)) {
if (!(await canManageMaterial(material, actor))) {
return NextResponse.json(
{ error: '无权触发该素材的转码任务' },
{ status: 403 }

View File

@ -12,6 +12,7 @@ import {
readAllFieldValues,
checkMetadataSize,
} from '@/lib/field-data-storage'
import { canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions'
export async function PUT(req: NextRequest) {
try {
@ -85,6 +86,14 @@ export async function PUT(req: NextRequest) {
// Get existing material
const existingMaterial = await prisma.material.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
department: true,
},
},
},
})
if (!existingMaterial) {
@ -94,14 +103,14 @@ export async function PUT(req: NextRequest) {
)
}
const userId = (session.user as any).id
const userRole = (session.user as any).role
if (existingMaterial.userId !== userId && userRole !== 'ADMIN') {
const actor = await getMaterialPermissionActor(session)
if (!(await canManageMaterial(existingMaterial, actor))) {
return NextResponse.json(
{ error: '您没有权限编辑此素材' },
{ status: 403 }
)
}
const userId = actor!.id
let previewUrl = existingMaterial.previewUrl

View File

@ -68,6 +68,13 @@ export async function POST(req: NextRequest) {
)
}
if (!(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{ error: '仅最高权限账号可新增场景分类' },
{ status: 403 }
)
}
const { name } = await req.json()
if (!name || !name.trim()) {

View File

@ -61,6 +61,7 @@ const { prisma } = require('@/lib/prisma')
const { cache } = require('@/lib/cache')
const { canUseSystemSettings } = require('@/lib/license')
const { uploadToS3 } = require('@/lib/s3')
const { ensureSystemSettingsOptionalColumns } = require('@/lib/system-settings-columns')
const mockSettings = {
id: 'settings-1',
@ -129,6 +130,9 @@ describe('GET /api/system-settings', () => {
expect(data.enablePublicShare).toBeUndefined()
expect(data.enabledBuiltinTemplates).toBeUndefined()
expect(data.homeDeleteButtonMode).toBeUndefined()
expect(ensureSystemSettingsOptionalColumns).not.toHaveBeenCalled()
expect(prisma.systemSettings.create).not.toHaveBeenCalled()
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
})
it('普通管理员登录时返回后台设置但不返回对象存储密钥', async () => {
@ -199,6 +203,42 @@ describe('GET /api/system-settings', () => {
)
})
it('GET 没有系统设置记录时只返回内存默认值,不创建数据库记录', async () => {
getServerSession.mockResolvedValue(null)
prisma.systemSettings.findFirst.mockResolvedValue(null)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toMatchObject({
headerBgColor: '#1f2937',
siteName: 'FLNXI素材库',
headerLogoUrl: '',
loginLogoUrl: '',
})
expect(ensureSystemSettingsOptionalColumns).not.toHaveBeenCalled()
expect(prisma.systemSettings.create).not.toHaveBeenCalled()
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
})
it('GET 只在响应中整理旧 Logo,不写回数据库', async () => {
getServerSession.mockResolvedValue(null)
prisma.systemSettings.findFirst.mockResolvedValue({
...mockSettings,
headerLogoUrl: '/logo/logo1.png',
loginLogoUrl: '',
})
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data.headerLogoUrl).toBe('/logo/header.png')
expect(data.loginLogoUrl).toBe('/logo/login.png')
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
})
it('数据库异常时,未登录读取会返回公共兜底设置而不是 500', async () => {
getServerSession.mockResolvedValue(null)
prisma.systemSettings.findFirst.mockRejectedValue(new Error('database offline'))
@ -247,6 +287,7 @@ describe('PUT /api/system-settings 权限边界', () => {
expect(response.status).toBe(403)
expect(data.error).toContain('最高权限')
expect(ensureSystemSettingsOptionalColumns).toHaveBeenCalled()
expect(canUseSystemSettings).not.toHaveBeenCalled()
expect(prisma.systemSettings.update).not.toHaveBeenCalled()
})

View File

@ -98,7 +98,6 @@ function buildPublicFallbackSettings(): SystemSettingsRow {
// GET - 获取系统设置(带缓存)
export async function GET() {
try {
await ensureSystemSettingsOptionalColumns()
const session = await getServerSession(authOptions)
const canReadAdminSettings = isSystemSettingsAdmin(session)
const canReadSensitiveSecrets = canReadAdminSettings && await isCurrentSuperAdmin(session)
@ -168,61 +167,12 @@ export async function GET() {
}
if (!settings) {
// 如果不存在,只创建公开展示和功能开关默认值;不要在普通读取时写入对象存储密钥
settings = await prisma.systemSettings.create({
data: {
headerBgColor: '#1f2937',
headerLogoUrl: defaultLogos.header,
loginLogoUrl: defaultLogos.login,
siteName: 'FLNXI素材库',
premiumRatio: 10,
enableUserActivation: false,
enableRegistration: true,
enableAiImageEdit: featureFlags.aiEditEnabled,
enableTunnelService: featureFlags.tunnelEnabled,
},
}) as SystemSettingsRow
settings.enablePublicShare = true
settings = buildPublicFallbackSettings()
} else {
// 已存在的旧数据,如果 LOGO 为空则自动补上默认值,并保存一次;不要在 GET 中补写对象存储密钥
let needUpdate = false
const updateData: {
headerLogoUrl?: string
loginLogoUrl?: string
} = {}
const normalizedHeaderLogoUrl = normalizeLogoUrl(settings.headerLogoUrl)
const normalizedLoginLogoUrl = normalizeLogoUrl(settings.loginLogoUrl)
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) {
updateData.loginLogoUrl = defaultLogos.login
settings.loginLogoUrl = defaultLogos.login
needUpdate = true
}
if (needUpdate) {
await prisma.systemSettings.update({
where: { id: settings.id },
data: updateData,
})
}
settings.headerLogoUrl = normalizedHeaderLogoUrl || defaultLogos.header
settings.loginLogoUrl = normalizedLoginLogoUrl || defaultLogos.login
}
const responseSettings = canReadAdminSettings

View File

@ -21,6 +21,10 @@ jest.mock('@/lib/system-integrity', () => ({
BUILTIN_TEMPLATE_KEYS: ['3dmax-material'],
}))
jest.mock('@/lib/admin-permissions', () => ({
isCurrentSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/system-init', () => ({
getSystemInitToken: jest.fn(() => 'init-token'),
initializeSystem: jest.fn(),
@ -28,7 +32,8 @@ jest.mock('@/lib/system-init', () => ({
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { initializeSystem } = require('@/lib/system-init')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { getSystemInitToken, initializeSystem } = require('@/lib/system-init')
describe('POST /api/system/init', () => {
beforeEach(() => {
@ -41,6 +46,68 @@ describe('POST /api/system/init', () => {
},
})
prisma.user.findFirst.mockResolvedValue({ id: 'admin-1' })
isCurrentSuperAdmin.mockResolvedValue(true)
getSystemInitToken.mockReturnValue('init-token')
})
test('系统已有最高权限账号后,普通管理员不能触发初始化', async () => {
getServerSession.mockResolvedValue({
user: {
id: 'admin-2',
role: 'ADMIN',
isSuperAdmin: false,
},
})
prisma.user.findFirst.mockResolvedValue({ id: 'super-1' })
isCurrentSuperAdmin.mockResolvedValue(false)
const response = await POST(new NextRequest('http://localhost/api/system/init', { method: 'POST' }))
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('系统已初始化,后续初始化操作需要最高权限账号登录')
expect(initializeSystem).not.toHaveBeenCalled()
})
test('系统尚无最高权限账号时,必须携带初始化令牌', async () => {
getServerSession.mockResolvedValue(null)
prisma.user.findFirst.mockResolvedValue(null)
const response = await POST(new NextRequest('http://localhost/api/system/init', { method: 'POST' }))
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('初始化令牌无效')
expect(isCurrentSuperAdmin).not.toHaveBeenCalled()
expect(initializeSystem).not.toHaveBeenCalled()
})
test('系统尚无最高权限账号时,正确初始化令牌可以触发首次初始化', async () => {
getServerSession.mockResolvedValue(null)
prisma.user.findFirst.mockResolvedValue(null)
initializeSystem.mockResolvedValue({
alreadyInitialized: false,
initialized: true,
admin: null,
defaultDepartment: '默认部门',
defaultCategory: '默认店铺',
defaultScene: '默认场景',
templates: {
total: 1,
created: 0,
createdTemplates: [],
errors: [],
},
})
const response = await POST(new NextRequest('http://localhost/api/system/init', {
method: 'POST',
headers: { 'x-system-init-token': 'init-token' },
}))
expect(response.status).toBe(200)
expect(isCurrentSuperAdmin).not.toHaveBeenCalled()
expect(initializeSystem).toHaveBeenCalled()
})
test('环境变量来源的初始管理员密码不应在初始化响应中回显', async () => {

View File

@ -1,16 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import type { Session } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
import { BUILTIN_TEMPLATE_KEYS } from '@/lib/system-integrity'
import { getSystemInitToken, initializeSystem } from '@/lib/system-init'
function isAdminSession(session: Session | null) {
const user = session?.user
return Boolean(user && (user.role === 'ADMIN' || user.isSuperAdmin))
}
export async function POST(req: NextRequest) {
try {
const session = await getServerSession(authOptions)
@ -27,16 +22,17 @@ export async function POST(req: NextRequest) {
}
}
if (!isAdminSession(session)) {
if (existingSuperAdmin) {
if (existingSuperAdmin) {
if (!(await isCurrentSuperAdmin(session))) {
return NextResponse.json(
{
success: false,
error: '系统已初始化,后续初始化操作需要管理员登录',
error: '系统已初始化,后续初始化操作需要最高权限账号登录',
},
{ status: 403 }
)
}
} else {
const configuredInitToken = getSystemInitToken()
if (!configuredInitToken) {

View File

@ -3,14 +3,18 @@ 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'
import { normalizeFieldOptionValue } from '@/lib/field-pool'
export async function GET(
_: NextRequest,
_req: NextRequest,
{ params }: { params: { fieldKey: string } },
) {
try {
await syncFieldPoolFromTemplates()
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const field = await prisma.fieldDefinition.findUnique({
where: { key: params.fieldKey },
include: {
@ -109,9 +113,9 @@ export async function PUT(
where: {
key: field.key,
type: 'select',
addToCategory: true,
},
data: {
addToCategory: true,
optionsStatic: JSON.stringify(normalized),
},
})

View File

@ -0,0 +1,190 @@
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/prisma', () => ({
prisma: {
fieldDefinition: {
findUnique: jest.fn(),
},
$transaction: jest.fn(),
},
}))
jest.mock('@/lib/field-pool', () => ({
formatFieldOptionValues: jest.fn((options) => options.map((option: { value: string }) => option.value)),
getFieldPoolRecords: jest.fn(),
normalizeFieldOptionValue: jest.fn((value: string) => value.trim()),
syncFieldPoolFromTemplates: jest.fn(),
}))
const { prisma } = require('@/lib/prisma')
const { getServerSession } = require('next-auth')
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
const { getFieldPoolRecords, syncFieldPoolFromTemplates } = require('@/lib/field-pool')
const { GET: listTemplateCategories } = require('../route')
const {
GET: getTemplateCategoryOptions,
PUT: updateTemplateCategoryOptions,
} = require('../[fieldKey]/route')
describe('GET /api/template-categories', () => {
beforeEach(() => {
jest.clearAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'user-1', role: 'USER' },
})
isCurrentSuperAdmin.mockResolvedValue(false)
})
it('未登录读取模板分类列表时不读取字段池', async () => {
getServerSession.mockResolvedValue(null)
const response = await listTemplateCategories(
new NextRequest('http://localhost/api/template-categories')
)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(getFieldPoolRecords).not.toHaveBeenCalled()
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
})
it('读取模板分类列表时不触发字段池同步写入', async () => {
getFieldPoolRecords.mockResolvedValue([
{
key: 'brandName',
label: '品牌',
filterDisplayName: null,
isFilterEnabled: true,
filterOrder: 1,
options: [{ value: '品牌A' }],
},
{
key: 'hiddenField',
label: '隐藏字段',
filterDisplayName: null,
isFilterEnabled: false,
filterOrder: 2,
options: [{ value: '隐藏值' }],
},
])
const response = await listTemplateCategories(
new NextRequest('http://localhost/api/template-categories')
)
const data = await response.json()
expect(response.status).toBe(200)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(data).toEqual([
{
fieldKey: 'brandName',
fieldLabel: '品牌',
fieldDefaultLabel: '品牌',
filterOrder: 1,
options: ['品牌A'],
},
])
})
it('读取单个字段选项时不触发字段池同步写入', async () => {
prisma.fieldDefinition.findUnique.mockResolvedValue({
key: 'brandName',
label: '品牌',
filterDisplayName: '品牌筛选',
options: [
{ value: '品牌A' },
{ value: '品牌B' },
],
})
const response = await getTemplateCategoryOptions(
new NextRequest('http://localhost/api/template-categories/brandName'),
{ params: { fieldKey: 'brandName' } }
)
const data = await response.json()
expect(response.status).toBe(200)
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
expect(data).toEqual({
fieldKey: 'brandName',
fieldLabel: '品牌筛选',
options: ['品牌A', '品牌B'],
})
})
it('未登录读取单个字段选项时不读取数据库', async () => {
getServerSession.mockResolvedValue(null)
const response = await getTemplateCategoryOptions(
new NextRequest('http://localhost/api/template-categories/brandName'),
{ params: { fieldKey: 'brandName' } }
)
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(prisma.fieldDefinition.findUnique).not.toHaveBeenCalled()
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
})
it('保存分类选项时只更新已加入分类管理的同 key 下拉字段', async () => {
getServerSession.mockResolvedValue({
user: { id: 'super-1', role: 'ADMIN' },
})
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: 1 }),
},
}
prisma.$transaction.mockImplementation((callback: any) => callback(tx))
const response = await updateTemplateCategoryOptions(
new NextRequest('http://localhost/api/template-categories/brandName', {
method: 'PUT',
body: JSON.stringify({ options: ['品牌A', '品牌B'] }),
}),
{ params: { fieldKey: 'brandName' } }
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.options).toEqual(['品牌A', '品牌B'])
expect(tx.uploadField.updateMany).toHaveBeenCalledWith({
where: {
key: 'brandName',
type: 'select',
addToCategory: true,
},
data: {
optionsStatic: JSON.stringify(['品牌A', '品牌B']),
},
})
})
})

View File

@ -1,5 +1,7 @@
import { NextResponse } from 'next/server'
import { formatFieldOptionValues, getFieldPoolRecords, syncFieldPoolFromTemplates } from '@/lib/field-pool'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { formatFieldOptionValues, getFieldPoolRecords } from '@/lib/field-pool'
type FieldPoolRecord = {
key: string
@ -12,7 +14,11 @@ type FieldPoolRecord = {
export async function GET(request: Request) {
try {
await syncFieldPoolFromTemplates()
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const fields = (await getFieldPoolRecords()) as FieldPoolRecord[]
// 检查是否需要返回所有字段(用于上传表单)

View File

@ -1,6 +1,6 @@
# 上传模板系统 API 文档
更新时间:2026-05-13
更新时间:2026-05-28
本文档按当前代码重新核对,覆盖主应用里真实存在的模板、字段和分类相关 API。旧文档中的 `/api/field-pool`、`/api/admin/field-pool/sync` 当前没有路由,不应再作为可调用接口。
@ -10,7 +10,7 @@
- 认证方式:Session Cookie
- 管理端接口要求当前用户角色为 `ADMIN`
- 自定义模板的创建、更新、删除、迁移会检查许可证能力 `customTemplates`
- 字段池同步是内部实现,主要由 `syncFieldPoolFromTemplates()` 在模板和分类接口中触发
- 字段池同步是内部实现:模板创建、更新、删除后会自动同步;分类字段配置读取不再自动同步,需要最高权限账号通过 `POST /api/category-fields` 显式同步。
常见错误结构:
@ -220,6 +220,7 @@ GET /api/templates
```http
GET /api/category-fields
POST /api/category-fields
PATCH /api/category-fields
PUT /api/category-fields
```
@ -242,6 +243,8 @@ PUT /api/category-fields
}
```
`POST` 用于手动同步字段池,仅最高权限账号可用。普通读取不会触发同步写库。
`PATCH` 用于启用或停用某个字段作为首页筛选字段:
```json
@ -272,6 +275,8 @@ GET /api/template-categories
GET /api/template-categories?includeAll=true
```
该接口要求已登录。
默认只返回已启用为筛选项的字段;`includeAll=true` 返回全部字段。
返回示例:
@ -305,7 +310,7 @@ PUT /api/template-categories/:fieldKey
}
```
`PUT` 仅管理员可用,用于重写该字段的选项:
`GET` 要求已登录。`PUT` 仅最高权限账号可用,用于重写该字段的选项:
```json
{
@ -364,4 +369,4 @@ PUT /api/template-categories/:fieldKey
3. 迁移前仍建议先在测试环境核对字段差异;系统会保留旧字段备份,但管理员仍应确认目标模板字段设计。
4. 字段 key 虽然当前后端只强校验非空和唯一,但团队约定仍建议使用英文、数字、下划线,避免后续搜索、导出和脚本处理出问题。
5. 分类管理只适合稳定、选项可控的下拉字段;不要把高频变化的大字段放到首页筛选。
6. 不要直接调用不存在的字段池 API;需要刷新字段池时,通过模板保存或分类接口触发内部同步。
6. 不要直接调用不存在的字段池 API;需要刷新字段池时,优先通过后台分类管理里的“同步字段”维护动作触发,也就是最高权限账号调用 `POST /api/category-fields`。

View File

@ -180,7 +180,7 @@
### 字段池有没有独立 API?
当前没有 `/api/field-pool` 或 `/api/admin/field-pool/sync` 路由。字段池同步是内部实现,会在模板保存和分类接口中触发。
当前没有 `/api/field-pool` 或 `/api/admin/field-pool/sync` 路由。字段池同步是内部实现:模板保存会自动同步;分类管理页读取不会自动写库,需要最高权限账号在后台分类管理里点击“同步字段”维护动作。
## 11. 相关文档

View File

@ -1,6 +1,6 @@
# 商业化地基排查问题清单
更新时间:2026-05-25
更新时间:2026-05-28
本文件是当前项目面向企业/团队私有化交付前的地基排查记录。它不是对外销售文案,也不是旧参考源码功能吸收清单,而是后续整改的事实入口。它是内部整改跟踪记录,已修复、已完成和待整改条目会同时保留,阅读时必须先看每条状态,不能把整份文件理解成纯待办清单。
@ -41,7 +41,8 @@
- `app/api/system-settings/route.ts`:现在会按会话区分公开字段、普通管理员字段和最高权限账号字段。
- `app/api/system-settings/route.ts`:普通管理员响应会移除 `s3AccessKey` 和 `s3SecretKey`,最高权限账号才返回这两个字段。
- `app/api/system-settings/__tests__/route.test.ts`:测试锁定未登录只返回公开字段、普通管理员不返回对象存储密钥、最高权限账号才返回对象存储密钥,并确认 GET 不再补写对象存储密钥。
- `app/api/system-settings/route.ts`:GET 现在只读,缺少设置记录时只返回内存默认值,不再创建默认行;旧 Logo 也只在响应中整理,不再通过 GET 写回数据库。
- `app/api/system-settings/__tests__/route.test.ts`:测试锁定未登录只返回公开字段、普通管理员不返回对象存储密钥、最高权限账号才返回对象存储密钥,并确认 GET 不再补写对象存储密钥、不创建默认设置、不回写 Logo、不执行运行期 DDL。
- `prisma/schema.prisma:320`:底层表结构仍明文保存 `s3SecretKey`,所以密钥读取/修改继续按最高权限账号处理。
建议动作:
@ -74,7 +75,7 @@
客户影响:
即使业务接口加了登录,如果对象存储本身公开下载,拿到文件 URL 的人仍能绕过系统直接访问素材文件。当前已完成默认私有化、内部播放、分享票据、前端撤销、系统级公开分享开关和 MinIO 匿名访问收口:新部署和升级初始化默认会把素材 bucket 的匿名访问设为 `none`;新上传不再默认设置 `public-read`;内部素材预览类 URL 默认改为应用内代理地址;视频/音频流接口默认要求登录;公开分享必须携带短期分享票据;素材负责人/上传者/管理员可以在前端查看、刷新和撤销该素材的分享链接;最高权限账号可以在系统设置里关闭公开分享,关闭后不能新建外部分享链接,已有分享链接也不能继续公开访问。
即使业务接口加了登录,如果对象存储本身公开下载,拿到文件 URL 的人仍能绕过系统直接访问素材文件。当前已完成默认私有化、内部播放、分享票据、前端撤销、系统级公开分享开关和 MinIO 匿名访问收口:新部署和升级初始化默认会把素材 bucket 的匿名访问设为 `none`;新上传不再默认设置 `public-read`;内部素材预览类 URL 默认改为应用内代理地址;视频/音频流接口默认要求登录;公开分享必须携带短期分享票据;素材分享链接管理已复用统一素材管理范围,当前负责人、个人归属人、最高权限账号和管理部门范围内管理员可以创建、查看和撤销该素材的分享链接,普通管理员不能跨管理部门操作;最高权限账号可以在系统设置里关闭公开分享,关闭后不能新建外部分享链接,已有分享链接也不能继续公开访问。
证据:
@ -89,10 +90,10 @@
- `app/api/user/favorites/route.ts`、`app/api/user/likes/route.ts`、`app/api/user/history/route.ts`:个人中心相关素材列表也接入代理 URL。
- `prisma/schema.prisma`、`prisma/migrations/20260524102000_add_material_share_tokens/migration.sql`:新增素材分享票据表,只保存 token hash,并记录素材、创建人、过期时间和撤销时间。
- `lib/material-share-token.ts`:新增分享票据创建和校验逻辑,公开访问只接受未过期、未撤销且素材未软删除的票据。
- `lib/material-share-permissions.ts`:新增分享管理权限判断,素材负责人、上传者、个人归属人、管理员可以创建和管理该素材的分享链接。
- `app/api/materials/share/route.ts`:创建分享链接需要登录且有素材管理权;公开读取分享数据必须携带有效分享票据;返回的预览图、展示图、视频/音频地址会改写为带分享票据的应用代理地址。
- `app/api/materials/share/tokens/route.ts`:新增分享链接列表接口,可查看链接创建人、创建时间、过期时间、撤销时间和状态;不会返回 token 明文或 hash。
- `app/api/materials/share/tokens/[id]/route.ts`:新增撤销分享链接接口,通过写入 `revokedAt` 让链接失效,不硬删除记录。
- `lib/material-share-permissions.ts`:分享管理权限已复用 `lib/material-permissions.ts` 的统一素材管理范围,不再单独放行所有 `ADMIN`。
- `app/api/materials/share/route.ts`:创建分享链接需要登录且有统一素材管理权;公开读取分享数据必须携带有效分享票据;返回的预览图、展示图、视频/音频地址会改写为带分享票据的应用代理地址。
- `app/api/materials/share/tokens/route.ts`:新增分享链接列表接口,可查看链接创建人、创建时间、过期时间、撤销时间和状态;不会返回 token 明文或 hash;普通管理员只能查看管理范围内素材的分享链接。
- `app/api/materials/share/tokens/[id]/route.ts`:新增撤销分享链接接口,通过写入 `revokedAt` 让链接失效,不硬删除记录;普通管理员不能跨管理部门撤销分享链接。
- `app/page.tsx`:分享弹窗已接入已有分享链接列表、到期时间、撤销状态、刷新和撤销操作。
- `app/share/[id]/page.tsx`:公开分享页已隐藏下载按钮,避免外部访问者点击一个后端必然要求登录的下载入口;当前公开分享定位为可预览,不直接开放源文件下载。
- `prisma/schema.prisma`、`prisma/migrations/20260526090000_add_public_share_toggle/migration.sql`:系统设置新增 `enablePublicShare`,默认开启,旧库升级会自动补列。
@ -111,6 +112,7 @@
- 已完成:MinIO 初始化默认主动关闭素材 bucket 匿名访问,旧环境升级时可把历史 bucket policy 从公开下载拉回私有。
- 已完成:内部视频/音频播放不再允许未登录裸 ID 访问,改为登录态内同源播放。
- 已完成:公开分享不再只靠裸素材 ID,第一版改为“有权限用户创建分享票据,公开访问携带票据,票据短期有效并可后端撤销”。
- 已完成:分享链接创建、列表查看和撤销已复用统一素材管理范围;普通管理员不能跨管理部门操作公开分享链接。
- 已完成:主页分享弹窗已接入分享链接列表、有效期展示、撤销入口和刷新入口。
- 已完成:后台新增系统级公开分享开关,关闭后不能新建外部分享链接,已有分享链接也会停止公开访问。
- 已完成:公开分享页不再展示下载按钮;源文件下载继续走登录态和用户下载权限。
@ -249,12 +251,15 @@
- `app/api/home-filter-fields/route.ts`:标准字段仍按固定列统计;自定义字段改为从 `metadata.templateFields` 统计总数和选项数量。
- `app/api/materials/route.ts`:`homeField_xxx` 现在会先确认字段池中该字段已启用;标准字段走固定列,自定义字段走 `metadata.templateFields.xxx = 选中值`。
- `app/api/home-filter-fields/__tests__/route-auth.test.ts`:新增测试,锁定自定义首页筛选字段会从 metadata 统计数量。
- `app/api/home-filter-fields/__tests__/route-auth.test.ts`:新增测试,锁定自定义首页筛选字段会从 metadata 统计数量,并确认普通用户读取首页筛选时不触发字段池同步写入。
- `app/api/materials/__tests__/materials-list-auth.test.ts`:新增测试,锁定首页自定义字段筛选会按 metadata 字段值过滤素材。
建议动作:
- 已完成:自定义字段首页计数和筛选先用 JSON 查询打通。
- 已完成:普通首页筛选读取不再触发字段池同步写入,GET 只读取现有字段池和素材统计。
- 已完成:模板分类选项保存只会同步已加入分类管理的同 key 下拉字段,不再把同名但未勾选“添加到分类”的模板字段强行改选项或拉入分类管理。
- 已完成:字段池同步改为非破坏式。模板字段不再作为分类来源时,只清空来源记录并从普通读取接口隐藏,不物理删除 `FieldDefinition`;模板下拉选项减少时也不物理删除旧 `FieldOption`,避免客户已经配置过的首页筛选名称、排序、启用状态和历史选项丢失。
- 仍需继续:字段池同步目前主要围绕 `addToCategory + select` 字段;后台“首页筛选”和“添加到分类/选项管理”的产品口径还需要再统一。
- 仍需继续:多选、复杂字段和大数据量性能需要后续单独设计。
@ -281,7 +286,8 @@
客户影响:
当前只有 `USER` 和 `ADMIN`,部门管理员本质上也是管理员。此前很多组织、模板和删除类写操作只判断 `role === ADMIN`,会导致普通管理员或部门管理员做全局管理动作。当前已完成阶段性收口:删除用户、调整用户部门、任免部门管理员、部门结构增删改、模板创建/编辑/删除、模板迁移、素材永久删除、过期清理、系统维护、全局字典/字段配置和转码恢复维护,都改为最高权限账号才能执行;统一用户更新接口也已禁止非最高权限账号修改角色、部门、启用状态和下载权限;这些敏感写操作会回查数据库里的 `isSuperAdmin` 或使用数据库回查结果,不只信旧会话。
当前只有 `USER` 和 `ADMIN`,部门管理员本质上也是管理员。此前很多组织、模板和删除类写操作只判断 `role === ADMIN`,会导致普通管理员或部门管理员做全局管理动作。当前已完成阶段性收口:删除用户、调整用户部门、任免部门管理员、部门结构增删改、模板创建/编辑/删除、模板迁移、素材永久删除、过期清理、系统维护、全局字典/字段配置和转码恢复维护,都改为最高权限账号才能执行;统一用户更新接口也已禁止非最高权限账号修改角色、部门、启用状态和下载权限;部门管理员改成员姓名时不再批量改写该成员历史素材作者名,只有最高权限账号显式要求时才允许同步;素材编辑、导出、分享链接管理和单个素材转码触发已接入统一素材管理范围。这些敏感写操作会回查数据库里的 `isSuperAdmin` 或使用数据库回查结果,不只信旧会话。
邀请链接管理也已收口到最高权限账号。邀请链接会直接影响谁能注册进入私有化系统,不应由普通管理员或部门管理员创建、启停或删除;接口现在会回查数据库里的 `isSuperAdmin`,前端管理入口也只给最高权限账号显示。
证据:
@ -289,7 +295,7 @@
- `lib/admin-permissions.ts`:`isCurrentSuperAdmin()` 会按当前 session 用户 ID 回查数据库里的 `isSuperAdmin`。
- `app/api/admin/users/route.ts`:删除用户现在必须是最高权限账号;不能删除自己,也不能删除最高权限账号。
- `app/api/admin/users/set-role/route.ts`、`app/api/admin/users/toggle-active/route.ts`、`app/api/admin/users/toggle-download/route.ts`、`app/api/admin/users/update-hierarchy/route.ts`:旧用户管理入口现在也必须是最高权限账号,避免绕过统一用户更新接口。
- `app/api/admin/users/update/route.ts`:部门管理员只能在部门范围内改普通资料,不能通过统一更新接口提升角色、调整部门、启用状态或下载权限。
- `app/api/admin/users/update/route.ts`:部门管理员只能在部门范围内改普通资料,不能通过统一更新接口提升角色、调整部门、启用状态或下载权限;修改姓名不会批量改历史素材作者名,最高权限账号必须显式传入同步意图才会同步。
- `app/api/admin/users/update-department/route.ts`:调整用户部门现在必须是最高权限账号;不能调整最高权限账号的部门。
- `app/api/admin/users/set-department-manager/route.ts`:设置/取消部门管理员现在必须是最高权限账号;不能把最高权限账号设置成部门管理员或调整其部门管理员状态。
- `app/api/departments/route.ts`:创建、重命名、删除部门结构现在必须是最高权限账号;普通管理员不会进入许可证检查和数据库写入逻辑。
@ -303,20 +309,34 @@
- `app/api/system/integrity/route.ts`:完整性检查 GET 仍允许管理员查看;POST 修复动作必须是最高权限账号。
- `app/api/tunnel/start/route.ts`、`app/api/tunnel/stop/route.ts`、`app/api/tunnel/status/route.ts`:内网穿透启动、停止和状态维护会回查最高权限账号,不只相信旧会话。
- `app/api/materials/restore/route.ts`:恢复软删除素材按删除同一范围控制,最高权限账号可恢复任意素材,普通管理员只能恢复自己或管理部门范围内素材。
- `app/api/categories/route.ts`、`app/api/scenes/route.ts`:修改和删除店铺/场景分类现在必须是最高权限账号。
- `app/api/categories/route.ts`、`app/api/scenes/route.ts`:新增、修改和删除店铺/场景分类现在必须是最高权限账号。
- `app/api/platforms/route.ts`:新增、修改和删除平台分类现在必须是最高权限账号。
- `app/api/category-fields/route.ts`:分类字段启用、显示名和排序写入现在必须是最高权限账号。
- `app/api/template-categories/[fieldKey]/route.ts`:批量修改模板分类选项现在必须是最高权限账号。
- `app/api/materials/transcode/route.ts`:`PATCH action=recover` 转码恢复维护现在必须是最高权限账号;普通管理员仍可按既有规则触发单个素材转码任务。
- `app/api/admin/users/__tests__/organization-permissions.test.ts`:新增/补充测试覆盖普通管理员被拒绝、部门管理员不能通过统一更新接口提升用户角色或调整部门/下载权限、用户列表范围过滤、最高权限账号可继续执行、最高权限账号不能被删除或被纳入部门管理员调整。
- `app/api/category-fields/route.ts`:分类字段 GET 现在只读;手动同步字段池、分类字段启用、显示名和排序写入现在必须是最高权限账号。
- `app/api/template-categories/route.ts`、`app/api/template-categories/[fieldKey]/route.ts`:读取模板分类和单字段选项现在必须登录,避免未登录枚举客户业务分类、品牌和选项;批量修改模板分类选项现在必须是最高权限账号。
- `app/api/template-categories/[fieldKey]/route.ts`:保存模板分类选项时只更新 `addToCategory=true` 的同 key 下拉字段,不再隐式修改未加入分类管理的模板字段。
- `app/api/category-fields/route.ts`、`app/api/template-categories/route.ts`、`app/api/template-categories/[fieldKey]/route.ts`、`app/api/home-filter-fields/route.ts`:分类字段、模板分类和首页筛选 GET 现在只读,不再触发字段池同步写入;字段池同步改为最高权限账号显式维护动作。
- `app/api/system-settings/route.ts`、`lib/public-share-settings.ts`、`app/api/materials/download/route.ts`、`app/api/material-rankings/route.ts`、`app/api/admin/reset-material-rankings/route.ts`:系统设置、公开分享开关、下载、排行榜和排行榜重置不再执行运行期 DDL;缺少设置记录时只返回内存默认值,读接口不再创建或更新 `system_settings`。
- `lib/material-download-log.ts`:已删除旧的下载日志运行期建表 helper,下载日志表依赖 Prisma 正式迁移创建。
- `prisma/migrations/20260528090000_add_home_delete_button_mode/migration.sql`:补齐 `homeDeleteButtonMode` 正式迁移,避免读接口靠 `ALTER TABLE` 兜底。
- `app/api/system/init/route.ts`:系统尚无最高权限账号时仍允许携带 `SYSTEM_INIT_TOKEN` 完成首次初始化;一旦已有最高权限账号,后续初始化必须由数据库确认过的最高权限账号触发,普通管理员不能再触发。
- `app/api/materials/transcode/route.ts`:`GET/POST` 转码状态查询和单个素材转码触发已接入统一素材管理范围,普通管理员只能操作管理部门范围内素材;`PATCH action=recover` 转码恢复维护现在必须是最高权限账号。
- `app/api/invitations/route.ts`、`app/api/invitations/[id]/route.ts`:邀请链接查看、创建、启停和删除现在必须是最高权限账号;旧会话里残留的最高权限标记不会绕过数据库回查。
- `lib/material-permissions.ts`:新增统一素材管理范围,当前负责人、个人归属人、最高权限账号和管理部门范围内管理员可管理对应素材,历史上传者只保留追溯含义。
- `lib/material-share-permissions.ts`、`app/api/materials/share/route.ts`、`app/api/materials/share/tokens/route.ts`、`app/api/materials/share/tokens/[id]/route.ts`:素材分享链接创建、查看列表和撤销已复用统一素材管理范围,普通管理员不能跨管理部门操作公开分享链接。
- `app/admin/page.tsx`:邀请管理 Tab 只对最高权限账号显示,避免普通管理员看到入口后被后端拒绝。
- `app/api/admin/users/__tests__/organization-permissions.test.ts`:新增/补充测试覆盖普通管理员被拒绝、部门管理员不能通过统一更新接口提升用户角色或调整部门/下载权限、部门管理员改成员姓名不批量改历史素材作者名、最高权限账号显式同步历史作者名、用户列表范围过滤、最高权限账号可继续执行、最高权限账号不能被删除或被纳入部门管理员调整。
- `app/api/departments/__tests__/route.test.ts`:补充部门写操作普通管理员拒绝、最高权限账号继续受许可证门槛约束的测试。
- `app/api/admin/upload-templates/__tests__/template-crud.test.ts`:补充普通管理员不能创建/更新/删除模板,最高权限账号原有成功路径继续可用。
- `app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts`:补充普通管理员和旧会话最高权限已撤销时不能迁移模板素材。
- `app/api/materials/__tests__/delete-storage-safety.test.ts`:补充普通管理员不能永久删除、旧会话最高权限已撤销时不能永久删除、普通管理员仍可软删除自己素材、普通管理员不能清理过期删除素材。
- `app/api/admin/__tests__/destructive-actions-superadmin.test.ts`:补充素材字段迁移、完整性修复等维护动作必须是最高权限账号。
- `app/api/admin/__tests__/global-dictionaries-superadmin.test.ts`:补充店铺、场景、平台、分类字段和模板分类选项的普通管理员拒绝与最高权限账号成功路径。
- `app/api/admin/__tests__/global-dictionaries-superadmin.test.ts`:补充店铺、场景、平台、分类字段和模板分类选项的普通管理员拒绝与最高权限账号成功路径;新增店铺/场景分类也已覆盖。
- `app/api/tunnel/__tests__/route.test.ts`:补充内网穿透维护接口普通管理员拒绝、最高权限账号可执行。
- `app/api/materials/transcode/__tests__/route.test.ts`:补充普通管理员不能恢复卡住的转码任务、最高权限账号可恢复。
- `app/api/materials/transcode/__tests__/route.test.ts`:补充当前负责人可查询、历史上传者不再自动拥有转码管理权、部门管理员范围内可触发、普通管理员跨部门不可触发、普通管理员不能恢复卡住的转码任务、最高权限账号可恢复。
- `app/api/invitations/__tests__/route.test.ts`:新增测试覆盖普通管理员不能查看/创建/启停/删除邀请链接,旧会话最高权限已撤销时不能创建邀请链接,最高权限账号可继续管理邀请链接。
- `app/api/materials/share/__tests__/route.test.ts`、`app/api/materials/share/tokens/__tests__/route.test.ts`、`app/api/materials/share/tokens/[id]/__tests__/route.test.ts`:补充部门管理员范围内可操作、普通管理员跨部门不可操作的分享链接权限测试。
- `app/api/category-fields/__tests__/route.test.ts`、`app/api/template-categories/__tests__/route.test.ts`、`app/api/home-filter-fields/__tests__/route-auth.test.ts`、`app/api/system-settings/__tests__/route.test.ts`、`app/api/materials/download/__tests__/route.test.ts`、`app/api/material-rankings/__tests__/route.test.ts`、`lib/__tests__/public-share-settings.test.ts`:补充 GET 读接口不触发字段池同步、运行期 DDL、默认设置创建、旧 Logo 回写、未登录分类枚举、分类选项不误改未加入分类字段或失败下载计数的测试。
- `app/api/system/init/__tests__/route.test.ts`:补充已有最高权限账号后普通管理员不能触发初始化、首次初始化必须携带初始化令牌、正确令牌仍可完成首次初始化的测试。
验证:
@ -328,13 +348,29 @@
- `npm run lint`
- `npm run prisma:validate`
- `git diff --check -- app/api/admin/users/route.ts app/api/admin/upload-templates/route.ts app/api/admin/upload-templates/[id]/route.ts app/api/admin/upload-templates/[id]/migrate/route.ts app/api/admin/upload-templates/__tests__/template-crud.test.ts app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts app/api/materials/route.ts app/api/materials/cleanup/route.ts app/api/materials/__tests__/delete-storage-safety.test.ts docs/商业化地基排查问题清单_20260524.md`
- `npm test -- --runTestsByPath app/api/invitations/__tests__/route.test.ts app/api/admin/users/__tests__/organization-permissions.test.ts app/api/admin/__tests__/destructive-actions-superadmin.test.ts app/api/admin/__tests__/global-dictionaries-superadmin.test.ts app/api/materials/__tests__/template-fields-routes.test.ts app/api/admin/materials/export/__tests__/route.test.ts --runInBand`
- `npm test -- --runTestsByPath "app/api/admin/__tests__/global-dictionaries-superadmin.test.ts" "app/api/materials/share/__tests__/route.test.ts" "app/api/materials/share/tokens/__tests__/route.test.ts" "app/api/materials/share/tokens/[id]/__tests__/route.test.ts" --runInBand`
- `npm test -- --runTestsByPath "lib/__tests__/public-share-settings.test.ts" "app/api/system-settings/__tests__/route.test.ts" "app/api/template-categories/__tests__/route.test.ts" "app/api/home-filter-fields/__tests__/route-auth.test.ts" --runInBand`
- `npm test -- --runTestsByPath "app/api/system/init/__tests__/route.test.ts" --runInBand`
- `npx prisma validate`
- `npm run type-check`
建议动作:
- 已完成:组织结构、用户归属、统一用户更新、用户列表范围、模板写操作、模板迁移、素材永久删除、过期清理、系统维护、内网穿透维护、素材恢复、全局字典、分类字段、模板选项和转码恢复维护的敏感权限已阶段性收紧。
- 已完成:邀请链接管理已收紧到最高权限账号;普通管理员不能创建、启停、删除或查看邀请链接列表,前端入口也不再显示给普通管理员。
- 仍需继续:取消部门管理员当前只清 `managedDepartment`,不会自动把 `role: ADMIN` 降回 `USER`;这涉及“全局管理员”和“部门管理员”模型拆分,建议后续单独设计。
- 仍需继续:继续复核剩余 `ADMIN` 写接口,尤其是素材编辑、批量、导入导出类入口,避免新功能绕开最高权限边界。
- 仍需继续:`app/api/materials/[id]/route.ts` 的编辑边界、`app/api/admin/materials/export/route.ts` 的导出范围,以及店铺/场景分类 POST 是否允许普通上传者新增,需要按前端真实入口继续定规则。
- 已完成验证:素材编辑入口和后台素材导出已接入统一素材管理范围;当前负责人、个人归属人、最高权限账号和管理部门范围内管理员可管理对应素材,历史上传者只保留追溯含义,不再自动代表当前管理权;普通管理员不能跨管理部门编辑或导出全量素材。验证命令:`npm test -- --runTestsByPath "app/api/materials/__tests__/template-fields-routes.test.ts" "app/api/admin/materials/export/__tests__/route.test.ts" --runInBand`、`npm run type-check`。
- 已完成验证:素材分享链接创建、列表查看和撤销已接入统一素材管理范围;普通管理员只能操作管理部门范围内素材的分享链接,不能跨部门创建、查看或撤销。验证命令:`npm test -- --runTestsByPath "app/api/materials/share/__tests__/route.test.ts" "app/api/materials/share/tokens/__tests__/route.test.ts" "app/api/materials/share/tokens/[id]/__tests__/route.test.ts" --runInBand`、`npm run type-check`。
- 已完成验证:店铺/场景分类新增已收紧到最高权限账号;普通用户、普通管理员和部门管理员不能新增全局店铺/场景字典项,上传流程仍可读取已有选项。验证命令:`npm test -- --runTestsByPath "app/api/admin/__tests__/global-dictionaries-superadmin.test.ts" --runInBand`、`npm run type-check`。
- 已完成验证:转码状态查询和单个素材转码触发已接入统一素材管理范围;历史上传者不再自动拥有转码管理权,普通管理员不能跨部门触发转码任务。验证命令:`npm test -- --runTestsByPath "app/api/materials/transcode/__tests__/route.test.ts" --runInBand`、`npm run type-check`。
- 已完成验证:后台分类字段、模板分类、首页筛选、系统设置、公开分享开关、下载和排行榜的主要读取路径已阶段性收口;普通用户、普通管理员或未登录访问不再触发字段池同步、运行期 DDL、默认设置创建、旧 Logo 回写或模板分类枚举;字段池同步改为后台分类管理里的最高权限账号显式维护动作;下载日志表依赖正式迁移,不再在 GET 中补建表;本地文件不存在时不再写下载日志或增加下载次数。验证命令:`npm test -- --runTestsByPath "app/api/materials/download/__tests__/route.test.ts" "app/api/material-rankings/__tests__/route.test.ts" "app/api/admin/users/__tests__/organization-permissions.test.ts" "app/api/template-categories/__tests__/route.test.ts" "app/api/category-fields/__tests__/route.test.ts" --runInBand`、`npm run type-check`、`npx prisma validate`。
- 已完成验证:部门管理员修改成员姓名不会批量改写历史素材作者名,最高权限账号必须显式要求才会同步历史作者名。验证命令:`npm test -- --runTestsByPath "app/api/admin/users/__tests__/organization-permissions.test.ts" --runInBand`。
- 已完成验证:模板分类选项保存不再误改同 key 但未加入分类管理的模板字段。验证命令:`npm test -- --runTestsByPath "app/api/template-categories/__tests__/route.test.ts" "app/api/admin/__tests__/global-dictionaries-superadmin.test.ts" --runInBand`。
- 已完成验证:字段池同步已改为非破坏式,模板字段移出分类或选项减少时不再物理删除字段池记录和旧选项;普通读取接口只展示标准字段和仍有关联模板来源的字段。验证命令:`npm test -- --runTestsByPath "lib/__tests__/field-pool.test.ts" "app/api/category-fields/__tests__/route.test.ts" "app/api/template-categories/__tests__/route.test.ts" "app/api/home-filter-fields/__tests__/route-auth.test.ts" --runInBand`、`npm run type-check`、`npx prisma validate`。
- 已完成验证:系统初始化入口在已有最高权限账号后必须由最高权限账号触发;首次初始化仍保留 `SYSTEM_INIT_TOKEN` 保护的未登录安装路径。验证命令:`npm test -- --runTestsByPath "app/api/system/init/__tests__/route.test.ts" --runInBand`。
- 已完成验证:主应用许可证激活、免费激活、手动校验和手动心跳入口已收口;首次安装无最高权限账号时仍可激活并创建初始账号,已有最高权限账号后必须最高权限登录才允许改写或清除本地授权状态。验证命令:`npm test -- --runTestsByPath "app/api/license/activate/__tests__/route.test.ts" "app/api/license/activate-free/__tests__/route.test.ts" "app/api/license/verify/__tests__/route.test.ts" "app/api/license/heartbeat/__tests__/route.test.ts" "app/api/license/check/__tests__/route.test.ts" "lib/__tests__/license-service.test.ts" --runInBand`、`npm run type-check`。
- 仍需继续:继续复核剩余 `ADMIN` 写接口,尤其是批量、导入类入口,避免新功能绕开最高权限边界。
### P1-02 普通管理员可触发全局破坏性操作(已完成权限收口)
@ -397,6 +433,11 @@
- `lib/license/license-service.ts`、`app/api/license/verify/route.ts`:远端验证失败进入本地宽限期前,也会先检查签名许可证里的 `leaseExpiresAt`,避免租约已经过期还继续靠本地宽限期放行。
- `app/api/license/check/route.ts`:恢复 `license_activated` cookie 前会检查租约有效性,不能再靠一份过期本地许可证恢复激活状态。
- `app/api/license/heartbeat/route.ts`:心跳拿到验证服务器续发许可证后会先验签再写入本地缓存。
- `lib/license/license-route-guards.ts`:新增许可证入口守卫;无最高权限账号时保留首次安装激活窗口,已有最高权限账号后必须最高权限登录才允许触发授权状态写入。
- `app/api/license/activate/route.ts`、`app/api/license/activate-free/route.ts`:重新激活和免费激活在已有最高权限账号后不再允许匿名触发,避免外部请求切换客户本地授权。
- `app/api/license/verify/route.ts`、`app/api/license/heartbeat/route.ts`:HTTP 手动校验和手动心跳入口已要求最高权限账号,未授权请求不会触发 `updateLocalLicense()` 或 `clearLocalLicense()`。
- `lib/license/license-service.ts`:新增 `runLicenseVerification()`、`runLicenseHeartbeat()` 服务端内部函数;启动时初始化和定时心跳直接调用内部函数,不再通过需要浏览器登录态的 HTTP 路由,避免权限收口后打断机器自己的许可证心跳。
- `app/api/license/check/route.ts`:改为使用只读许可证读取函数,只恢复 cookie,不刷新 `lastSeenAt` 或写回本地时间状态。
- `lib/license/license-service.ts`:本地许可证新增 `lastSeenAt`、`clockRollbackDetectedAt` 和 `clockIntegrity` 时间状态;旧许可证首次读取会自动补齐时间锚点。系统时间小幅校时允许 10 分钟容差,明显回拨会拒绝功能、额度、许可证类型和本地宽限期放行。
- `lib/license/crypto-service.ts`:新增本地运行状态 HMAC 签名,时间锚点被直接改库篡改时会被视为无效许可证,降低通过改数据库字段绕过租约和回拨检测的成本。
- `app/api/license/info/route.ts`:管理端许可证信息改为读取 `getLocalLicense()` 的已校验本地许可证,不再只看进程内缓存,避免回拨阻断后管理页仍显示正常激活。
@ -411,6 +452,7 @@
- `lib/__tests__/license-route-coverage.test.ts`:新增关键业务入口覆盖测试,防止用户数、素材数、自定义模板、部门隔离、系统设置、内网穿透等服务端许可证检查被后续改动漏掉;同时锁定高价值付费能力必须依赖 `hasPremiumModule()`。
- `lib/__tests__/license-service.test.ts`:新增测试覆盖许可证过期、在线租约过期、高级功能不可用、数量限制不放行、本地许可证从 Pro 降级到 Free 后业务检查立即按 Free 生效,以及旧许可证缺少 `leaseExpiresAt` 时的兼容行为。
- `lib/__tests__/license-types.test.ts`、`app/api/license/check/__tests__/route.test.ts`:新增测试锁定默认租约天数、租约截断逻辑,以及租约过期时不恢复激活 cookie。
- `app/api/license/activate/__tests__/route.test.ts`、`app/api/license/activate-free/__tests__/route.test.ts`、`app/api/license/verify/__tests__/route.test.ts`、`app/api/license/heartbeat/__tests__/route.test.ts`、`app/api/license/check/__tests__/route.test.ts`、`lib/__tests__/license-service.test.ts`:新增测试锁定已有最高权限账号后未授权请求不能改写本地授权状态、服务端定时心跳不依赖 HTTP 登录态、许可证 check 保持只读。
- `lib/__tests__/license-request-signature.test.ts`、`license-server/lib/__tests__/request-signature.test.ts`:新增测试覆盖签名生成、篡改拒绝、重复 `nonce` 拒绝、过期拒绝和缺少服务端共享密钥时拒绝静默降级。
- `lib/__tests__/license-service.test.ts`:新增测试覆盖旧本地许可证自动补时间锚点、小幅回拨不误伤、明显回拨拒绝功能和额度、本地时间状态被篡改后拒绝,以及网络失败宽限期前也先检查回拨状态。
- `lib/__tests__/check-limits.test.ts`:功能检查 helper 已全部改为异步调用底层许可证检查。
@ -742,18 +784,19 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久
客户影响:
视频转码会消耗 CPU、内存和对象存储读写。之前接口没有登录校验,外部请求只要知道素材 ID,就可能查询转码状态或反复触发转码任务。对 NAS 私有化交付来说,这会带来后台资源被滥用、素材处理状态被误改的风险。
视频转码会消耗 CPU、内存和对象存储读写。之前接口没有登录校验,外部请求只要知道素材 ID,就可能查询转码状态或反复触发转码任务;后续又发现普通管理员和历史上传者权限过宽。对 NAS 私有化交付来说,这会带来后台资源被滥用、素材处理状态被误改的风险。
证据:
- `app/api/materials/transcode/route.ts`:GET 现在必须登录;普通用户只能查看自己上传或创建的素材转码状态,管理员可查看。
- `app/api/materials/transcode/route.ts`:POST 现在必须登录;普通用户只能触发自己上传或创建的素材转码任务,管理员可触发;未授权用户不会进入队列和状态更新逻辑。
- `app/api/materials/transcode/route.ts`:GET 现在必须登录并通过统一素材管理范围;当前负责人、个人归属人、最高权限账号和管理部门范围内管理员可查看,历史上传者不再自动放行。
- `app/api/materials/transcode/route.ts`:POST 现在必须登录并通过统一素材管理范围;普通管理员不能跨部门触发转码任务,未授权用户不会进入队列和状态更新逻辑。
- `app/api/materials/transcode/route.ts`:PATCH 恢复卡住转码任务属于全局维护动作,现在必须是最高权限账号。
- `app/api/materials/transcode/__tests__/route.test.ts`:新增测试覆盖未登录拒绝、普通用户不能操作他人素材、素材创建人可查看、管理员可触发转码任务。
- `app/api/materials/transcode/__tests__/route.test.ts`:新增/更新测试覆盖未登录拒绝、普通用户不能操作他人素材、当前负责人可查看、历史上传者不再自动拥有转码管理权、部门管理员范围内可触发、普通管理员跨部门不可触发、最高权限账号可恢复卡住任务。
验证:
- `npm test -- app/api/materials/transcode/__tests__/route.test.ts --runInBand`
- `npm test -- --runTestsByPath "app/api/materials/transcode/__tests__/route.test.ts" --runInBand`
- `npm test -- app/api/materials/share/__tests__/route.test.ts app/api/materials/asset/__tests__/route.test.ts app/api/materials/stream/__tests__/route.test.ts app/api/materials/transcode/__tests__/route.test.ts lib/__tests__/material-share-token.test.ts --runInBand`
- `npm run type-check`
- `npm run prisma:validate`
@ -1253,6 +1296,48 @@ NAS 私有化系统可能在平板或窄屏浏览器里使用。模板编辑器
- 旧测试服务器 `45.249.208.118:4320` 不再作为当前项目事实来源。
- 如果后续仍需要保留 `.env.local` 或 `.env` 中的旧值,只能作为本机私有临时文件,不能写进 Git 或交付文档。
2026-05-28 继续推进:
- 已新增 `scripts/prisma-local-dev.mjs`,本机数据库命令统一通过 `.env.development.local` 执行,并默认拒绝连接非本机数据库,避免 Prisma 命令误读根目录 `.env` 后连到废弃环境。
- `package.json` 已新增 `dev:local`、`prisma:dev:status`、`prisma:dev:apply`、`prisma:dev:migrate`、`prisma:dev:studio`,本机 Codex 调试默认使用 `http://localhost:3002`,避免和其他项目占用的 `3000` 冲突。
- `docs/当前工作流.md` 已补充本机 Prisma 命令入口,明确客户部署和正式服务器仍走部署脚本里的 `prisma migrate deploy`,不使用本机开发包装命令。
- 本机 `flnxi_local` 数据库已在写入前导出备份到 `tmp/flnxi-local-before-schema-sync-20260528-121058.sql`;随后补齐缺失的 `dingtalk_login_tokens`、`material_share_tokens`、`video_transcode_jobs`、`material_logs.materialSnapshot` 等结构,并将 Prisma 迁移账本登记为已应用。
- 排查中发现 `homeDeleteButtonMode` 已有迁移和接口使用,但 `prisma/schema.prisma` 未登记字段;已补齐 schema,避免后续迁移状态和代码事实不一致。
- 验证已通过:`npm run prisma:dev:status` 显示本机库 up to date;`node scripts/prisma-local-dev.mjs migrate diff --from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script` 输出空迁移;`npx prisma validate` 通过;`npm run dev:local` 启动后不再出现 `video_transcode_jobs` 缺表错误,`http://localhost:3002/login` 返回 200;`npm test -- --runTestsByPath "app/api/system-settings/__tests__/route.test.ts" "lib/__tests__/public-share-settings.test.ts" "app/api/materials/transcode/__tests__/route.test.ts" --runInBand` 通过;`npm run type-check` 通过。
### 字段池和自定义模板同步安全(2026-05-28 已完成第二轮)
客户影响:
自定义模板是当前项目的重要商业功能。字段池同步如果按当前模板“硬删除”字段或选项,客户在后台配置过的首页筛选名称、排序、启用状态和历史选项会丢失,表现为“模板改了一下,前台筛选配置突然没了”。如果模板已经保存成功、字段池同步失败,还会出现页面提示失败但模板实际已变的半成功状态。当前已把字段池同步改为非破坏式,并把模板创建、更新、删除与字段池同步放入同一个数据库事务:模板字段不再作为分类来源时只清空来源记录,旧记录普通读取接口不再展示,但数据库里的配置保留;模板下拉选项减少时旧选项也保留;字段池同步失败时模板写入也会跟随事务回滚。
证据:
- `lib/field-pool.ts`:全量字段池同步不再删除 `FieldDefinition` 或 `FieldOption`;移出模板来源的字段只把 `sourceTemplateIds` 清空。
- `lib/field-pool.ts`:`syncFieldPoolFromTemplates()` 支持传入事务客户端;没有传入时仍保持原来的自建事务行为,兼容后台分类管理手动同步。
- `lib/field-pool.ts`:字段池普通读取和首页筛选读取只返回标准字段或仍有关联模板来源的字段,避免废弃字段继续出现在前台。
- `lib/field-pool.ts`:增量同步也改为归档孤立字段,不再物理删除字段和选项。
- `app/api/admin/upload-templates/route.ts`:创建模板和字段池同步现在处于同一个 `prisma.$transaction`。
- `app/api/admin/upload-templates/[id]/route.ts`:更新模板、字段增删改、字段池同步和最终读取现在处于同一个 `prisma.$transaction`。
- `app/api/admin/upload-templates/[id]/route.ts`:删除未使用模板和字段池同步现在处于同一个 `prisma.$transaction`。
- `lib/__tests__/field-pool.test.ts`:新增测试覆盖模板集合移除字段和模板下拉选项减少时不物理删除字段池记录或旧选项。
- `app/api/admin/upload-templates/__tests__/template-crud.test.ts`:新增测试锁定创建、更新、删除模板时 `syncFieldPoolFromTemplates(tx)` 必须复用同一个事务。
验证:
- `npm test -- --runTestsByPath "lib/__tests__/field-pool.test.ts" --runInBand`:通过,30 个用例通过。
- `npm test -- --runTestsByPath "app/api/category-fields/__tests__/route.test.ts" "app/api/template-categories/__tests__/route.test.ts" "app/api/home-filter-fields/__tests__/route-auth.test.ts" "lib/__tests__/field-pool.test.ts" --runInBand`:通过,42 个用例通过。
- `npm test -- --runTestsByPath "app/api/admin/upload-templates/__tests__/template-crud.test.ts" --runInBand`:通过,32 个用例通过。
- `npm test -- --runTestsByPath "app/api/admin/upload-templates/__tests__/template-crud.test.ts" "app/api/category-fields/__tests__/route.test.ts" "app/api/template-categories/__tests__/route.test.ts" "app/api/home-filter-fields/__tests__/route-auth.test.ts" "lib/__tests__/field-pool.test.ts" --runInBand`:通过,74 个用例通过。
- `npm run type-check`:通过。
- `npx prisma validate`:通过。
- `git diff --check`:通过;仅有 Windows 行尾提示,不是空白错误。
仍需继续:
- 使用中的模板如从“加入分类”改为“不加入分类”,目前不会再丢字段池配置,但产品层仍建议给管理员明确提示或二次确认。
- 后续可以继续补“字段池同步失败时模板写入回滚”的更贴近集成测试;当前单元测试已锁定同步函数必须接收同一个事务客户端。
### 部署和升级链路默认值收口(2026-05-25 已推进)
客户影响:

View File

@ -8,11 +8,14 @@
- 复制环境变量:`cp .env.example .env`
- 生成 Prisma Client:`npm run prisma:generate`
- 启动主应用:`npm run dev`
- 本机 Codex 调试如 `3000` 端口已被其他项目占用,优先使用:`npm run dev:local`,访问 `http://localhost:3002`
本地调试前要确认实际加载的环境文件。Next.js 开发模式通常会加载 `.env.development.local`、`.env.local` 和 `.env`;如果 `.env.local` 或 `.env` 里还残留旧服务器地址,后台用户、部门、系统设置等接口会连到错误数据库并返回 500。
当前已确认旧测试服务器 `45.249.208.118:4320` 不再作为项目事实来源。本地开发应优先使用本机测试数据库和对象存储,例如 `.env.development.local` 中的 `localhost:15432` 与 `localhost:19000`。如发现后台接口持续 500,先核对 dev 服务启动日志里的 `Environments:` 和当前 `DATABASE_URL` 指向,不要直接按旧远程环境排查。
Prisma 命令和 Next.js 开发服务的环境加载方式不同。直接运行 `npx prisma ...` 或 `npm run prisma:*` 时,Prisma 会优先读取根目录 `.env`;如果 `.env` 还是旧服务器地址,就会误连废弃环境。本机开发需要操作数据库时,优先使用下面的 `prisma:dev:*` 命令,它会显式读取 `.env.development.local`,并拒绝连接非本机数据库。
如需联调许可证服务或安装代理,再分别进入:
- `license-server/`
@ -42,6 +45,12 @@ npm run check
- 将 schema 推到当前数据库:`npm run prisma:push`
- 校验 schema:`npm run prisma:validate`
- 查看数据:`npm run prisma:studio`
- 查看本机开发库迁移状态:`npm run prisma:dev:status`
- 将已有迁移应用到本机开发库:`npm run prisma:dev:apply`
- 创建/开发新迁移:`npm run prisma:dev:migrate`
- 查看本机开发库数据:`npm run prisma:dev:studio`
注意:`prisma:dev:*` 只用于本机开发库。客户部署、正式服务器和 Docker 容器启动仍按部署脚本里的 `prisma migrate deploy` 执行,不使用本机开发包装命令。
涉及模板/历史数据修复时,再考虑:

View File

@ -4,6 +4,29 @@
* 测试选项清洗逻辑和字段池同步功能
*/
jest.mock('@/lib/prisma', () => ({
prisma: {
uploadField: {
findMany: jest.fn(),
},
fieldDefinition: {
findMany: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
fieldOption: {
deleteMany: jest.fn(),
},
$transaction: jest.fn(),
},
}))
jest.mock('../upload-template-presets', () => ({
STANDARD_FIELDS: {},
}))
const { prisma: mockPrisma } = jest.requireMock('@/lib/prisma') as { prisma: any }
import {
normalizeFieldOptionValue,
OptionCleaningIssue,
@ -209,6 +232,99 @@ describe('字段池管理器测试', () => {
})
})
describe('syncFieldPoolFromTemplates - 非破坏式同步', () => {
beforeEach(() => {
jest.clearAllMocks()
})
test('模板集合移除字段时归档字段池记录,不物理删除字段和选项', async () => {
const tx = {
fieldDefinition: {
findMany: jest.fn().mockResolvedValue([
{ id: 'field-1', key: 'obsoleteField' },
]),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findUnique: jest.fn(),
upsert: jest.fn(),
deleteMany: jest.fn(),
delete: jest.fn(),
},
fieldOption: {
deleteMany: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
}
mockPrisma.uploadField.findMany.mockResolvedValue([])
mockPrisma.$transaction.mockImplementation((callback: any) => callback(tx))
const { syncFieldPoolFromTemplates } = require('../field-pool')
await syncFieldPoolFromTemplates()
expect(tx.fieldDefinition.updateMany).toHaveBeenCalledWith({
where: { key: { in: ['obsoleteField'] } },
data: {
sourceTemplateIds: null,
},
})
expect(tx.fieldOption.deleteMany).not.toHaveBeenCalled()
expect(tx.fieldDefinition.deleteMany).not.toHaveBeenCalled()
expect(tx.fieldDefinition.delete).not.toHaveBeenCalled()
})
test('模板下拉选项减少时保留字段池旧选项', async () => {
const tx = {
fieldDefinition: {
findMany: jest.fn().mockResolvedValue([
{ id: 'field-1', key: 'brand' },
]),
updateMany: jest.fn(),
findUnique: jest.fn().mockResolvedValue({
filterDisplayName: '品牌筛选',
}),
upsert: jest.fn().mockResolvedValue({
id: 'field-1',
}),
deleteMany: jest.fn(),
delete: jest.fn(),
},
fieldOption: {
deleteMany: jest.fn(),
findMany: jest.fn().mockResolvedValue([
{ id: 'option-old', value: '旧品牌' },
{ id: 'option-new', value: '新品牌' },
]),
update: jest.fn(),
create: jest.fn(),
},
}
mockPrisma.uploadField.findMany.mockResolvedValue([
{
key: 'brand',
label: '品牌',
type: 'select',
templateId: 'template-1',
optionsStatic: JSON.stringify(['新品牌']),
},
])
mockPrisma.$transaction.mockImplementation((callback: any) => callback(tx))
const { syncFieldPoolFromTemplates } = require('../field-pool')
await syncFieldPoolFromTemplates()
expect(tx.fieldOption.update).toHaveBeenCalledWith({
where: { id: 'option-new' },
data: {
label: '新品牌',
orderIndex: 0,
},
})
expect(tx.fieldOption.deleteMany).not.toHaveBeenCalled()
expect(tx.fieldDefinition.deleteMany).not.toHaveBeenCalled()
})
})
describe('OptionCleaningIssue - 清洗问题枚举', () => {
test('应该包含所有预期的问题类型', () => {
const { OptionCleaningIssue } = require('../field-pool')

View File

@ -78,11 +78,12 @@ global.fetch = mockFetch;
import { prisma } from '@/lib/prisma';
import { verifyLicenseSignature } from '../license/crypto-service';
import { getInstanceId } from '../license/fingerprint-service';
import { generateFingerprint, getInstanceId } from '../license/fingerprint-service';
import { LICENSE_CONFIGS } from '../license/types';
import type { SignedLicense } from '../license/types';
const mockVerifyLicenseSignature = verifyLicenseSignature as jest.MockedFunction<typeof verifyLicenseSignature>;
const mockGenerateFingerprint = generateFingerprint as jest.MockedFunction<typeof generateFingerprint>;
const mockGetInstanceId = getInstanceId as jest.MockedFunction<typeof getInstanceId>;
function createSignedLicense(overrides: Record<string, any> = {}): SignedLicense {
@ -158,12 +159,22 @@ describe('license-service', () => {
jest.clearAllMocks();
mockFetch.mockReset();
mockVerifyLicenseSignature.mockReturnValue(true);
process.env.LICENSE_PROJECT_CODE = 'flnxi-sucai';
process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32);
mockGenerateFingerprint.mockResolvedValue({
machineId: 'machine-1',
hostname: 'nas-1',
macAddress: 'AA:BB:CC:DD:EE:FF',
instanceId: 'instance-1',
});
mockGetInstanceId.mockResolvedValue('test-instance-id');
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
delete process.env.LICENSE_PROJECT_CODE;
delete process.env.LICENSE_REQUEST_SIGNING_SECRET;
});
describe('activateFreeTrial', () => {
@ -517,6 +528,39 @@ describe('license-service', () => {
});
});
describe('服务端许可证心跳', () => {
it('startHeartbeat 应直接调用内部心跳逻辑,不再依赖需要登录态的 HTTP 路由', async () => {
jest.useFakeTimers();
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'license',
value: JSON.stringify(createStoredLocalLicense()),
});
(prisma.user.count as jest.Mock).mockResolvedValue(1);
(prisma.material.count as jest.Mock).mockResolvedValue(0);
mockFetch.mockResolvedValueOnce({
status: 200,
json: () => Promise.resolve({ acknowledged: true }),
});
const { startHeartbeat, stopHeartbeat } = await import('../license/license-service');
startHeartbeat();
await jest.advanceTimersByTimeAsync(60 * 60 * 1000);
stopHeartbeat();
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/heartbeat'),
expect.objectContaining({
method: 'POST',
})
);
expect(mockFetch).not.toHaveBeenCalledWith(
'/api/license/heartbeat',
expect.anything()
);
});
});
describe('本地许可证验签收紧', () => {
it('应优先使用验签后的许可证内容,而不是本地 cachedConfig', async () => {
const tamperedLocalLicense = createStoredLocalLicense({
@ -593,6 +637,26 @@ describe('license-service', () => {
}));
});
it('只读读取许可证不应刷新本地时间状态', async () => {
const localLicense = createStoredLocalLicense();
delete (localLicense as Record<string, unknown>).lastSeenAt;
delete (localLicense as Record<string, unknown>).clockRollbackDetectedAt;
delete (localLicense as Record<string, unknown>).clockIntegrity;
(prisma.systemConfig.findUnique as jest.Mock).mockResolvedValue({
key: 'license',
value: JSON.stringify(localLicense),
});
const { getLocalLicenseReadOnly } = await import('../license/license-service');
const result = await getLocalLicenseReadOnly();
expect(result).toMatchObject({
licenseKey: 'FLNXI-FREE-TEST-1234-5678-ABCD',
});
expect(prisma.systemConfig.update).not.toHaveBeenCalled();
});
it('系统时间小幅回拨时不应误伤正常许可证', async () => {
const futureWithinTolerance = new Date(now.getTime() + 5 * 60 * 1000).toISOString();
const localLicense = addClockIntegrity(createStoredLocalLicense({

View File

@ -0,0 +1,37 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
},
}))
jest.mock('@/lib/system-settings-columns', () => ({
ensureSystemSettingsOptionalColumns: jest.fn(),
}))
export {}
const { prisma } = require('@/lib/prisma')
const { ensureSystemSettingsOptionalColumns } = require('@/lib/system-settings-columns')
describe('public-share-settings', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('读取公开分享开关时不执行运行期 DDL', async () => {
prisma.$queryRaw.mockResolvedValue([{ enablePublicShare: true }])
const { isPublicShareEnabled } = require('../public-share-settings')
await expect(isPublicShareEnabled()).resolves.toBe(true)
expect(ensureSystemSettingsOptionalColumns).not.toHaveBeenCalled()
expect(prisma.$queryRaw).toHaveBeenCalled()
})
it('读取失败时按关闭处理', async () => {
prisma.$queryRaw.mockRejectedValue(new Error('missing column'))
const { isPublicShareEnabled } = require('../public-share-settings')
await expect(isPublicShareEnabled()).resolves.toBe(false)
expect(ensureSystemSettingsOptionalColumns).not.toHaveBeenCalled()
})
})

View File

@ -23,6 +23,15 @@ type AggregatedField = {
options: Set<string>
}
function activeFieldPoolWhere() {
return {
OR: [
{ isStandard: true },
{ sourceTemplateIds: { not: null } },
],
}
}
/**
* 字段池同步结果
*/
@ -339,7 +348,8 @@ function aggregateUploadField(target: AggregatedField, field: UploadField): void
})
}
export async function syncFieldPoolFromTemplates(): Promise<void> {
export async function syncFieldPoolFromTemplates(client?: any): Promise<void> {
const db = client ?? prismaAny
const aggregated = new Map<string, AggregatedField>()
Object.values(STANDARD_FIELDS).forEach((field) => {
@ -355,7 +365,7 @@ export async function syncFieldPoolFromTemplates(): Promise<void> {
}
})
const categoryFields = await prismaAny.uploadField.findMany({
const categoryFields = await db.uploadField.findMany({
where: {
addToCategory: true,
type: 'select',
@ -398,7 +408,7 @@ export async function syncFieldPoolFromTemplates(): Promise<void> {
aggregated.set(key, entry)
})
await prismaAny.$transaction(async (tx: any) => {
const syncRecords = async (tx: any) => {
const existing = (await tx.fieldDefinition.findMany({
select: { id: true, key: true },
})) as Array<{ id: string; key: string }>
@ -409,11 +419,11 @@ export async function syncFieldPoolFromTemplates(): Promise<void> {
const removedKeys = Array.from(existingKeyMap.keys()).filter((key) => !activeKeys.has(key))
if (removedKeys.length) {
await tx.fieldOption.deleteMany({
where: { field: { key: { in: removedKeys } } },
})
await tx.fieldDefinition.deleteMany({
await tx.fieldDefinition.updateMany({
where: { key: { in: removedKeys } },
data: {
sourceTemplateIds: null,
},
})
}
@ -450,7 +460,14 @@ export async function syncFieldPoolFromTemplates(): Promise<void> {
await syncFieldOptions(tx, fieldDefinition.id, Array.from(entry.options))
}
})
}
if (client) {
await syncRecords(client)
return
}
await prismaAny.$transaction(syncRecords)
}
async function syncFieldOptions(tx: any, fieldId: string, options: string[]): Promise<void> {
@ -491,17 +508,13 @@ async function syncFieldOptions(tx: any, fieldId: string, options: string[]): Pr
orderIndex += 1
}
const keepValues = uniqueOptions
await tx.fieldOption.deleteMany({
where: {
fieldId,
value: { notIn: keepValues.length ? keepValues : ['__none__'] },
},
})
// 不再默认删除旧选项。客户可能已经用这些选项筛选或历史素材仍有旧值,
// 物理清理应走单独的预览/确认维护动作。
}
export async function getFieldPoolRecords() {
return prismaAny.fieldDefinition.findMany({
where: activeFieldPoolWhere(),
orderBy: [
{ filterOrder: 'asc' },
{ label: 'asc' },
@ -516,7 +529,10 @@ export async function getFieldPoolRecords() {
export async function getEnabledFilterFields() {
const fields = await prismaAny.fieldDefinition.findMany({
where: { isFilterEnabled: true },
where: {
isFilterEnabled: true,
...activeFieldPoolWhere(),
},
orderBy: [
{ filterOrder: 'asc' },
{ label: 'asc' },
@ -807,15 +823,16 @@ async function syncFieldPoolIncremental(): Promise<Omit<FieldPoolSyncResult, 'su
for (const orphaned of orphanedFields) {
try {
await prismaAny.fieldOption.deleteMany({
where: { fieldId: (orphaned as any).id },
})
await prismaAny.fieldDefinition.delete({
await prismaAny.fieldDefinition.update({
where: { id: (orphaned as any).id },
data: {
sourceTemplateIds: null,
updatedAt: new Date(),
},
})
fieldsRemoved++
} catch (error) {
const errorMsg = `Failed to remove orphaned field ${(orphaned as any).key}: ${error instanceof Error ? error.message : String(error)}`
const errorMsg = `Failed to archive orphaned field ${(orphaned as any).key}: ${error instanceof Error ? error.message : String(error)}`
console.error(`[FieldPool] ${errorMsg}`)
errors.push(errorMsg)
}
@ -880,18 +897,5 @@ async function syncFieldOptionsIncremental(
orderIndex++
}
const keepValues = uniqueOptions
const toDelete = existingOptions.filter((opt) => !keepValues.includes(opt.value))
if (toDelete.length > 0) {
await prismaAny.fieldOption.deleteMany({
where: {
fieldId,
value: { notIn: keepValues.length ? keepValues : ['__none__'] },
},
})
removed = toDelete.length
}
return { added, removed }
}

View File

@ -7,6 +7,8 @@ export {
activateFreeTrial,
verify,
heartbeat,
runLicenseVerification,
runLicenseHeartbeat,
getLicense,
isFeatureEnabled,
checkLimit,

View File

@ -0,0 +1,32 @@
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
export type LicenseRouteGuardResult =
| { allowed: true }
| { allowed: false; status: number; error: string }
export async function requireSuperAdminAfterInitialSetup(): Promise<LicenseRouteGuardResult> {
const existingSuperAdmin = await prisma.user.findFirst({
where: { isSuperAdmin: true },
select: { id: true },
})
if (!existingSuperAdmin) {
return { allowed: true }
}
const session = await getServerSession(authOptions)
const isSuperAdmin = await isCurrentSuperAdmin(session)
if (!isSuperAdmin) {
return {
allowed: false,
status: 403,
error: '系统已初始化,许可证变更需要最高权限账号登录',
}
}
return { allowed: true }
}

View File

@ -18,6 +18,9 @@ import type {
LocalLicenseData,
} from './types';
import { LICENSE_CONFIGS } from './types';
import { buildLicenseServerLicenseApiUrl } from './server-config';
import { buildSignedLicenseRequest } from './request-signature';
import { syncPremiumModules } from './premium-module-service';
// 宽限期(7天)
const GRACE_PERIOD_DAYS = 7;
@ -403,6 +406,75 @@ export async function verify(): Promise<{ valid: boolean; error?: string; revoke
}
}
export async function runLicenseVerification(): Promise<{ valid: boolean; error?: string; revoked?: boolean }> {
try {
const localLicense = await getLocalLicense();
if (!localLicense) {
return { valid: false, error: '未找到许可证' };
}
const fingerprint = await generateFingerprint();
const signedRequest = buildSignedLicenseRequest('verify', {
instanceId: fingerprint.instanceId,
fingerprint,
});
const response = await fetch(buildLicenseServerLicenseApiUrl('verify'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedRequest),
});
const data: VerifyResponse = await response.json();
if (!data.valid || !data.license) {
const isRevoked = response.status === 403 || response.status === 404;
if (isRevoked) {
console.log('[License] 许可证已被吊销:', data.error);
await clearLocalLicense();
return {
valid: false,
error: data.error || '许可证已被吊销',
revoked: true,
};
}
if (!isLicenseConfigExpired(localLicense.cachedConfig) && isWithinGracePeriod(localLicense.lastVerified)) {
return { valid: true };
}
return { valid: false, error: data.error || '验证失败' };
}
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
return { valid: false, error: '许可证签名验证失败' };
}
await updateLocalLicense(data.license);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error);
}
return { valid: true };
} catch (error) {
console.error('验证失败:', error);
const localLicense = await getLocalLicense();
if (
localLicense &&
!isLicenseConfigExpired(localLicense.cachedConfig) &&
isWithinGracePeriod(localLicense.lastVerified)
) {
return { valid: true };
}
return { valid: false, error: '网络错误' };
}
}
/**
* 发送心跳
*
@ -451,6 +523,70 @@ export async function heartbeat(): Promise<boolean> {
}
}
export async function runLicenseHeartbeat(): Promise<{ acknowledged: boolean; error?: string; revoked?: boolean }> {
try {
const localLicense = await getLocalLicense();
if (!localLicense) {
return { acknowledged: false, error: '未找到许可证' };
}
const fingerprint = await generateFingerprint();
const [userCount, materialCount] = await Promise.all([
prisma.user.count(),
prisma.material.count(),
]);
const signedRequest = buildSignedLicenseRequest('heartbeat', {
instanceId: fingerprint.instanceId,
fingerprint,
usage: { userCount, materialCount },
});
const response = await fetch(buildLicenseServerLicenseApiUrl('heartbeat'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(signedRequest),
});
if (response.status === 403 || response.status === 404) {
console.log('[License] 心跳检测到许可证已被吊销');
await clearLocalLicense();
return {
acknowledged: false,
revoked: true,
error: '许可证已被吊销',
};
}
const data: HeartbeatResponse = await response.json();
if (data.acknowledged && data.license) {
if (!verifyLicenseSignature(data.license.data, data.license.signature)) {
return {
acknowledged: false,
error: '许可证签名验证失败',
};
}
await updateLocalLicense(data.license);
const moduleSyncResult = await syncPremiumModules();
if (!moduleSyncResult.synced && !moduleSyncResult.skipped) {
console.warn('[License] 付费模块清单同步失败:', moduleSyncResult.error);
}
}
return {
acknowledged: data.acknowledged || false,
};
} catch (error) {
console.error('心跳发送失败:', error);
return {
acknowledged: false,
error: '网络错误',
};
}
}
/**
* 获取当前许可证配置
*/
@ -520,7 +656,9 @@ export function startHeartbeat(): void {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
}
heartbeatTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL);
heartbeatTimer = setInterval(() => {
void runLicenseHeartbeat();
}, HEARTBEAT_INTERVAL);
}
/**
@ -537,7 +675,7 @@ export function stopHeartbeat(): void {
* 初始化许可证服务
*/
export async function initLicenseService(): Promise<void> {
const result = await verify();
const result = await runLicenseVerification();
if (result.valid) {
startHeartbeat();
}
@ -637,6 +775,24 @@ export async function getLocalLicense(): Promise<LocalLicenseData | null> {
}
}
export async function getLocalLicenseReadOnly(): Promise<LocalLicenseData | null> {
try {
const config = await prisma.systemConfig.findUnique({
where: { key: 'license' },
});
if (!config) return null;
const parsed = JSON.parse(config.value) as LocalLicenseData;
const normalized = normalizeLocalLicenseData(parsed);
if (!normalized || !isClockStateIntact(normalized)) return null;
return normalized;
} catch {
return null;
}
}
/**
* 清除本地许可证缓存(许可证被吊销时调用)
*/

View File

@ -244,17 +244,17 @@ export async function syncPremiumModules(): Promise<{ synced: boolean; skipped?:
}
const cachedPackages = await getCachedPremiumModulePackages();
for (const module of data.manifest.modules) {
if (isCachedPackageValid(cachedPackages, module)) {
for (const moduleInfo of data.manifest.modules) {
if (isCachedPackageValid(cachedPackages, moduleInfo)) {
continue;
}
const modulePackage = await downloadPremiumModulePackage(module, fingerprint);
const modulePackage = await downloadPremiumModulePackage(moduleInfo, fingerprint);
if (!modulePackage) {
throw new Error(`付费模块 ${module.id} 下载结果为空`);
throw new Error(`付费模块 ${moduleInfo.id} 下载结果为空`);
}
cachedPackages.packages[module.id] = {
cachedPackages.packages[moduleInfo.id] = {
contentBase64: modulePackage.contentBase64,
sha256: modulePackage.sha256,
sizeBytes: modulePackage.sizeBytes,
@ -276,9 +276,9 @@ export async function hasPremiumModule(moduleId: string): Promise<boolean> {
const cached = await getCachedPremiumModuleManifest();
if (!cached) return false;
const module = cached.manifest.modules.find((item) => item.id === moduleId);
if (!module) return false;
const moduleInfo = cached.manifest.modules.find((item) => item.id === moduleId);
if (!moduleInfo) return false;
const cachedPackages = await getCachedPremiumModulePackages();
return isCachedPackageValid(cachedPackages, module);
return isCachedPackageValid(cachedPackages, moduleInfo);
}

View File

@ -1,55 +0,0 @@
import { prisma } from '@/lib/prisma'
let downloadLogsTableEnsured = false
async function executeSafely(sql: string) {
try {
await prisma.$executeRawUnsafe(sql)
} catch (error: any) {
const message = error?.message || ''
// 表已存在、扩展已存在等错误可以忽略
if (
message.includes('already exists') ||
message.includes('duplicate key value') ||
message.includes('extension') // pgcrypto 等扩展相关
) {
return
}
console.error('[DownloadLog] Failed to execute SQL safely:', error)
}
}
export async function ensureMaterialDownloadLogsTable() {
if (downloadLogsTableEnsured) return
console.log('[DownloadLog] Ensuring material_download_logs table and indexes...')
// 确保 pgcrypto 扩展存在(用于 gen_random_uuid)
await executeSafely(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`)
// 创建日志表
await executeSafely(`
CREATE TABLE IF NOT EXISTS "material_download_logs" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"materialId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"downloadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
// 索引
await executeSafely(
`CREATE INDEX IF NOT EXISTS "material_download_logs_materialId_idx" ON "material_download_logs" ("materialId")`
)
await executeSafely(
`CREATE INDEX IF NOT EXISTS "material_download_logs_userId_idx" ON "material_download_logs" ("userId")`
)
await executeSafely(
`CREATE INDEX IF NOT EXISTS "material_download_logs_downloadedAt_idx" ON "material_download_logs" ("downloadedAt" DESC)`
)
downloadLogsTableEnsured = true
console.log('[DownloadLog] material_download_logs table ensured.')
}

134
lib/material-permissions.ts Normal file
View File

@ -0,0 +1,134 @@
import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
export type MaterialPermissionActor = {
id: string
role: string
isSuperAdmin: boolean
department?: string | null
managedDepartment?: string | null
}
export type MaterialPermissionTarget = {
userId?: string | null
createdByUserId?: string | null
ownerType?: string | null
ownerId?: string | null
user?: {
id?: string | null
department?: string | null
} | null
}
const normalizeDepartment = (department?: string | null) => (department || '').trim()
export async function getMaterialPermissionActor(session: unknown): Promise<MaterialPermissionActor | null> {
const userId = (session as any)?.user?.id
if (!userId) {
return null
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
role: true,
isSuperAdmin: true,
department: true,
managedDepartment: true,
},
})
if (!user) {
return null
}
return {
id: user.id,
role: user.role,
isSuperAdmin: Boolean(user.isSuperAdmin),
department: user.department,
managedDepartment: user.managedDepartment,
}
}
export function isMaterialOwnedByActor(material: MaterialPermissionTarget, actor: MaterialPermissionActor | null) {
if (!actor) {
return false
}
return (
material.userId === actor.id ||
(material.ownerType === 'USER' && material.ownerId === actor.id)
)
}
export async function canManageMaterial(
material: MaterialPermissionTarget,
actor: MaterialPermissionActor | null
) {
if (!actor) {
return false
}
if (actor.isSuperAdmin) {
return true
}
if (isMaterialOwnedByActor(material, actor)) {
return true
}
if (actor.role !== 'ADMIN') {
return false
}
const scopeDepartment = normalizeDepartment(actor.managedDepartment || actor.department)
const targetDepartment = normalizeDepartment(material.user?.department)
if (!scopeDepartment || !targetDepartment) {
return false
}
if (targetDepartment === scopeDepartment || targetDepartment.startsWith(`${scopeDepartment}-`)) {
return true
}
const departments = await prisma.department.findMany({ select: { name: true } })
const descendantMap = buildDepartmentDescendants(departments)
return isDepartmentWithinScope(scopeDepartment, targetDepartment, descendantMap)
}
export async function buildMaterialManagementScopeWhere(
actor: MaterialPermissionActor | null
): Promise<Prisma.MaterialWhereInput> {
if (!actor) {
return { id: { in: [] } }
}
if (actor.isSuperAdmin) {
return {}
}
const scopeDepartment = normalizeDepartment(actor.managedDepartment || actor.department)
const ownWhere: Prisma.MaterialWhereInput[] = [
{ userId: actor.id },
{ ownerType: 'USER', ownerId: actor.id },
]
if (actor.role !== 'ADMIN' || !scopeDepartment) {
return { OR: ownWhere }
}
const departments = await prisma.department.findMany({ select: { name: true } })
const descendants = buildDepartmentDescendants(departments).get(scopeDepartment)
const scopedDepartments = Array.from(descendants || new Set([scopeDepartment]))
return {
OR: [
...ownWhere,
{ user: { department: { in: scopedDepartments } } },
{ user: { department: { startsWith: `${scopeDepartment}-` } } },
],
}
}

View File

@ -1,59 +1,25 @@
import { prisma } from '@/lib/prisma'
import type {
MaterialPermissionActor,
MaterialPermissionTarget,
} from '@/lib/material-permissions'
import {
canManageMaterial,
getMaterialPermissionActor,
} from '@/lib/material-permissions'
export type MaterialShareActor = {
id: string
role: string
isSuperAdmin: boolean
}
export type MaterialShareActor = MaterialPermissionActor
export type MaterialShareTarget = {
export type MaterialShareTarget = MaterialPermissionTarget & {
userId: string
ownerType?: string | null
ownerId?: string | null
createdByUserId?: string | null
}
export async function getMaterialShareActor(session: unknown): Promise<MaterialShareActor | null> {
const userId = (session as any)?.user?.id
if (!userId) {
return null
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
role: true,
isSuperAdmin: true,
},
})
if (!user) {
return null
}
return {
id: user.id,
role: user.role,
isSuperAdmin: Boolean(user.isSuperAdmin),
}
return getMaterialPermissionActor(session)
}
export function canManageMaterialShare(
export async function canManageMaterialShare(
material: MaterialShareTarget,
actor: MaterialShareActor | null
) {
if (!actor) {
return false
}
if (actor.isSuperAdmin || actor.role === 'ADMIN') {
return true
}
return (
material.userId === actor.id ||
material.createdByUserId === actor.id ||
(material.ownerType === 'USER' && material.ownerId === actor.id)
)
return canManageMaterial(material, actor)
}

View File

@ -1,10 +1,7 @@
import { prisma } from '@/lib/prisma'
import { ensureSystemSettingsOptionalColumns } from '@/lib/system-settings-columns'
export async function isPublicShareEnabled(): Promise<boolean> {
try {
await ensureSystemSettingsOptionalColumns()
const rows = await prisma.$queryRaw<Array<{ enablePublicShare: boolean | null }>>`
SELECT "enablePublicShare" FROM "system_settings" LIMIT 1
`

View File

@ -5,6 +5,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"dev:local": "next dev -p 3002",
"build": "prisma generate && next build",
"build:prod": "prisma generate && next build && node scripts/build-obfuscated.js",
"start": "next start",
@ -15,6 +16,11 @@
"check": "npm run type-check && npm run lint && npm run prisma:validate",
"type-check": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:dev": "node scripts/prisma-local-dev.mjs",
"prisma:dev:status": "node scripts/prisma-local-dev.mjs migrate status",
"prisma:dev:apply": "node scripts/prisma-local-dev.mjs migrate deploy",
"prisma:dev:migrate": "node scripts/prisma-local-dev.mjs migrate dev",
"prisma:dev:studio": "node scripts/prisma-local-dev.mjs studio",
"prisma:push": "prisma db push",
"prisma:validate": "prisma validate",
"prisma:studio": "prisma studio",

View File

@ -0,0 +1,2 @@
ALTER TABLE "system_settings"
ADD COLUMN IF NOT EXISTS "homeDeleteButtonMode" TEXT DEFAULT 'ADMIN_ONLY';

View File

@ -386,6 +386,7 @@ model SystemSettings {
enableAiImageEdit Boolean @default(false)
enableTunnelService Boolean @default(false)
enablePublicShare Boolean @default(true)
homeDeleteButtonMode String? @default("ADMIN_ONLY")
enabledBuiltinTemplates String? // JSON array of enabled builtin template keys (empty array or null = all enabled)
categoryManagedFields String? // JSON array,记录分类管理已启用的字段 key

View File

@ -0,0 +1,126 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const projectRoot = path.resolve(__dirname, '..')
const envFile = path.join(projectRoot, '.env.development.local')
const args = process.argv.slice(2)
function parseEnvFile(filePath) {
const values = {}
const raw = fs.readFileSync(filePath, 'utf8')
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const normalized = trimmed.startsWith('export ') ? trimmed.slice(7).trim() : trimmed
const eqIndex = normalized.indexOf('=')
if (eqIndex <= 0) continue
const key = normalized.slice(0, eqIndex).trim()
let value = normalized.slice(eqIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
values[key] = value
}
return values
}
function describeDatabase(url) {
try {
const parsed = new URL(url)
const port = parsed.port ? `:${parsed.port}` : ''
const database = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname.slice(1) : '(default)'
return {
host: parsed.hostname,
label: `${parsed.hostname}${port}/${database}`,
}
} catch {
return {
host: '',
label: '(invalid DATABASE_URL)',
}
}
}
function isLocalDatabaseHost(host) {
const normalized = host.toLowerCase()
return [
'localhost',
'127.0.0.1',
'::1',
'[::1]',
'host.docker.internal',
].includes(normalized)
}
if (args.length === 0) {
console.error('Usage: npm run prisma:dev -- <prisma arguments>')
console.error('Example: npm run prisma:dev -- migrate status')
process.exit(1)
}
if (!fs.existsSync(envFile)) {
console.error('[prisma-local-dev] Missing .env.development.local.')
console.error('[prisma-local-dev] Refusing to guess a database target from .env or .env.local.')
process.exit(1)
}
const localEnv = parseEnvFile(envFile)
const databaseUrl = localEnv.DATABASE_URL
if (!databaseUrl) {
console.error('[prisma-local-dev] .env.development.local does not define DATABASE_URL.')
process.exit(1)
}
const database = describeDatabase(databaseUrl)
if (!isLocalDatabaseHost(database.host) && process.env.ALLOW_NONLOCAL_PRISMA_DEV_DB !== '1') {
console.error(`[prisma-local-dev] Refusing non-local DATABASE_URL host: ${database.host || '(unknown)'}`)
console.error('[prisma-local-dev] Set ALLOW_NONLOCAL_PRISMA_DEV_DB=1 only when you intentionally need this.')
process.exit(1)
}
const childEnv = {
...process.env,
...localEnv,
}
console.log(`[prisma-local-dev] Using .env.development.local -> ${database.label}`)
const prismaCli = path.join(projectRoot, 'node_modules', 'prisma', 'build', 'index.js')
if (!fs.existsSync(prismaCli)) {
console.error('[prisma-local-dev] Prisma CLI is missing. Run npm install first.')
process.exit(1)
}
const child = spawn(process.execPath, [prismaCli, ...args], {
cwd: projectRoot,
env: childEnv,
stdio: 'inherit',
})
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})