收紧模板管理权限并保护迁移数据
This commit is contained in:
parent
720c163a6b
commit
c759d0a37b
@ -25,24 +25,88 @@ jest.mock('@/lib/prisma', () => ({
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
material: {
|
||||
findMany: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/license', () => ({
|
||||
canUseCustomTemplates: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/admin-permissions', () => ({
|
||||
isCurrentSuperAdmin: jest.fn(),
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
const { canUseCustomTemplates } = require('@/lib/license')
|
||||
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
|
||||
|
||||
// 导入 API 处理函数
|
||||
import { POST as migrateTemplate } from '../route'
|
||||
|
||||
function createTemplate(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id: 'template-1',
|
||||
name: '模板',
|
||||
materialType: '3DMAX',
|
||||
materialKind: 'IMAGE',
|
||||
fields: [],
|
||||
_count: { materials: 0 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createMaterials(count: number, overrides: Record<string, any> = {}) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `mat-${index + 1}`,
|
||||
productModel: `产品${index + 1}`,
|
||||
metadata: null,
|
||||
brandName: null,
|
||||
seriesName: null,
|
||||
skuCode: null,
|
||||
materialTexture: null,
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
platformName: '',
|
||||
materialType: '3DMAX',
|
||||
materialKind: 'IMAGE',
|
||||
renderer: 'CR',
|
||||
renderTime: null,
|
||||
imageResolution: null,
|
||||
colorMode: null,
|
||||
authorName: '作者A',
|
||||
audioDuration: null,
|
||||
audioCategory: null,
|
||||
audioScene: null,
|
||||
...overrides,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('模板素材迁移 API 测试', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.resetAllMocks()
|
||||
// 默认模拟管理员会话
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'admin-id', role: 'ADMIN' },
|
||||
})
|
||||
canUseCustomTemplates.mockResolvedValue(true)
|
||||
isCurrentSuperAdmin.mockResolvedValue(true)
|
||||
prisma.material.findMany.mockResolvedValue([])
|
||||
prisma.material.updateMany.mockResolvedValue({ count: 1 })
|
||||
prisma.$transaction.mockImplementation(async (callback: any) => {
|
||||
if (typeof callback === 'function') {
|
||||
return callback({
|
||||
material: {
|
||||
updateMany: prisma.material.updateMany,
|
||||
},
|
||||
})
|
||||
}
|
||||
return Promise.all(callback)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/admin/upload-templates/[id]/migrate - 迁移素材', () => {
|
||||
@ -53,46 +117,48 @@ describe('模板素材迁移 API 测试', () => {
|
||||
* **Validates: Requirements 8.3**
|
||||
*/
|
||||
test('应该成功迁移素材到目标模板', async () => {
|
||||
const sourceTemplate = {
|
||||
id: 'builtin-ai-material',
|
||||
name: 'AI 工作流模板',
|
||||
materialType: 'AI 工作流',
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-source',
|
||||
name: '源模板',
|
||||
_count: { materials: 5 },
|
||||
}
|
||||
})
|
||||
|
||||
const targetTemplate = {
|
||||
id: 'builtin-other-material',
|
||||
name: '其他素材模板',
|
||||
const targetTemplate = createTemplate({
|
||||
id: 'template-target',
|
||||
name: '目标模板',
|
||||
materialType: '其他',
|
||||
}
|
||||
materialKind: 'OTHER',
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
.mockResolvedValueOnce(targetTemplate)
|
||||
|
||||
prisma.material.updateMany.mockResolvedValue({ count: 5 })
|
||||
prisma.material.findMany.mockResolvedValue(createMaterials(5))
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/builtin-ai-material/migrate',
|
||||
'http://localhost/api/admin/upload-templates/template-source/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetTemplateId: 'builtin-other-material',
|
||||
targetTemplateId: 'template-target',
|
||||
updateMaterialType: true,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await migrateTemplate(request, {
|
||||
params: { id: 'builtin-ai-material' },
|
||||
params: { id: 'template-source' },
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.migratedCount).toBe(5)
|
||||
expect(data.sourceTemplate.id).toBe('builtin-ai-material')
|
||||
expect(data.targetTemplate.id).toBe('builtin-other-material')
|
||||
expect(data.sourceTemplate.id).toBe('template-source')
|
||||
expect(data.targetTemplate.id).toBe('template-target')
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.material.updateMany).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
test('应该拒绝非管理员访问', async () => {
|
||||
@ -119,6 +185,83 @@ describe('模板素材迁移 API 测试', () => {
|
||||
expect(data.error).toBe('无权限')
|
||||
})
|
||||
|
||||
test('普通管理员不能迁移模板素材', async () => {
|
||||
isCurrentSuperAdmin.mockResolvedValue(false)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-1/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetTemplateId: 'template-2',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await migrateTemplate(request, {
|
||||
params: { id: 'template-1' },
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toContain('最高权限')
|
||||
expect(canUseCustomTemplates).not.toHaveBeenCalled()
|
||||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||||
expect(prisma.material.findMany).not.toHaveBeenCalled()
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('旧会话带最高权限标记但数据库已撤销时不能迁移模板素材', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'admin-id', role: 'ADMIN', isSuperAdmin: true },
|
||||
})
|
||||
isCurrentSuperAdmin.mockResolvedValue(false)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-1/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetTemplateId: 'template-2',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await migrateTemplate(request, {
|
||||
params: { id: 'template-1' },
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toContain('最高权限')
|
||||
expect(canUseCustomTemplates).not.toHaveBeenCalled()
|
||||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('许可证关闭时应拒绝迁移素材', async () => {
|
||||
canUseCustomTemplates.mockResolvedValue(false)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-1/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetTemplateId: 'template-2',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await migrateTemplate(request, {
|
||||
params: { id: 'template-1' },
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.errorCode).toBe('LICENSE_REQUIRED')
|
||||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||||
expect(prisma.material.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('应该验证目标模板ID必填', async () => {
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-1/migrate',
|
||||
@ -180,12 +323,11 @@ describe('模板素材迁移 API 测试', () => {
|
||||
})
|
||||
|
||||
test('应该返回404当目标模板不存在', async () => {
|
||||
const sourceTemplate = {
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-1',
|
||||
name: '源模板',
|
||||
materialType: '3DMAX',
|
||||
_count: { materials: 5 },
|
||||
}
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
@ -211,12 +353,11 @@ describe('模板素材迁移 API 测试', () => {
|
||||
})
|
||||
|
||||
test('当源模板没有素材时应返回成功但迁移数为0', async () => {
|
||||
const sourceTemplate = {
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-1',
|
||||
name: '空模板',
|
||||
materialType: '3DMAX',
|
||||
_count: { materials: 0 },
|
||||
}
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique.mockResolvedValue(sourceTemplate)
|
||||
|
||||
@ -242,69 +383,73 @@ describe('模板素材迁移 API 测试', () => {
|
||||
})
|
||||
|
||||
test('应该同时更新素材的materialType字段', async () => {
|
||||
const sourceTemplate = {
|
||||
id: 'builtin-ai-material',
|
||||
name: 'AI 工作流模板',
|
||||
materialType: 'AI 工作流',
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-source',
|
||||
name: '源模板',
|
||||
_count: { materials: 3 },
|
||||
}
|
||||
})
|
||||
|
||||
const targetTemplate = {
|
||||
id: 'builtin-other-material',
|
||||
name: '其他素材模板',
|
||||
const targetTemplate = createTemplate({
|
||||
id: 'template-target',
|
||||
name: '目标模板',
|
||||
materialType: '其他',
|
||||
}
|
||||
materialKind: 'OTHER',
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
.mockResolvedValueOnce(targetTemplate)
|
||||
|
||||
prisma.material.updateMany.mockResolvedValue({ count: 3 })
|
||||
prisma.material.findMany.mockResolvedValue(createMaterials(3))
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/builtin-ai-material/migrate',
|
||||
'http://localhost/api/admin/upload-templates/template-source/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetTemplateId: 'builtin-other-material',
|
||||
targetTemplateId: 'template-target',
|
||||
updateMaterialType: true,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
await migrateTemplate(request, {
|
||||
params: { id: 'builtin-ai-material' },
|
||||
params: { id: 'template-source' },
|
||||
})
|
||||
|
||||
// 验证 updateMany 被调用时包含 materialType 更新
|
||||
expect(prisma.material.updateMany).toHaveBeenCalledWith({
|
||||
where: { templateId: 'builtin-ai-material' },
|
||||
data: {
|
||||
templateId: 'builtin-other-material',
|
||||
materialType: '其他',
|
||||
},
|
||||
})
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.material.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
templateId: 'template-source',
|
||||
}),
|
||||
data: expect.objectContaining({
|
||||
templateId: 'template-target',
|
||||
materialType: '其他',
|
||||
materialKind: 'OTHER',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('当updateMaterialType为false时不应更新materialType', async () => {
|
||||
const sourceTemplate = {
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-1',
|
||||
name: '源模板',
|
||||
materialType: '3DMAX',
|
||||
_count: { materials: 2 },
|
||||
}
|
||||
})
|
||||
|
||||
const targetTemplate = {
|
||||
const targetTemplate = createTemplate({
|
||||
id: 'template-2',
|
||||
name: '目标模板',
|
||||
materialType: 'PSD',
|
||||
}
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
.mockResolvedValueOnce(targetTemplate)
|
||||
|
||||
prisma.material.updateMany.mockResolvedValue({ count: 2 })
|
||||
prisma.material.findMany.mockResolvedValue(createMaterials(2))
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-1/migrate',
|
||||
@ -321,34 +466,35 @@ describe('模板素材迁移 API 测试', () => {
|
||||
params: { id: 'template-1' },
|
||||
})
|
||||
|
||||
// 验证 updateMany 被调用时不包含 materialType 更新
|
||||
expect(prisma.material.updateMany).toHaveBeenCalledWith({
|
||||
where: { templateId: 'template-1' },
|
||||
data: {
|
||||
templateId: 'template-2',
|
||||
},
|
||||
})
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.material.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.not.objectContaining({
|
||||
materialType: 'PSD',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('应该处理数据库错误', async () => {
|
||||
const sourceTemplate = {
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-1',
|
||||
name: '源模板',
|
||||
materialType: '3DMAX',
|
||||
_count: { materials: 5 },
|
||||
}
|
||||
})
|
||||
|
||||
const targetTemplate = {
|
||||
const targetTemplate = createTemplate({
|
||||
id: 'template-2',
|
||||
name: '目标模板',
|
||||
materialType: 'PSD',
|
||||
}
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
.mockResolvedValueOnce(targetTemplate)
|
||||
|
||||
prisma.material.updateMany.mockRejectedValue(new Error('数据库连接失败'))
|
||||
prisma.material.findMany.mockResolvedValue(createMaterials(1))
|
||||
prisma.$transaction.mockRejectedValue(new Error('数据库连接失败'))
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-1/migrate',
|
||||
@ -367,6 +513,311 @@ describe('模板素材迁移 API 测试', () => {
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.errorCode).toBe('MIGRATION_FAILED')
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.material.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('目标模板缺少可自动补齐的必填字段时应拒绝迁移', async () => {
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-1',
|
||||
name: '源模板',
|
||||
_count: { materials: 1 },
|
||||
})
|
||||
|
||||
const targetTemplate = createTemplate({
|
||||
id: 'template-2',
|
||||
name: '目标模板',
|
||||
fields: [
|
||||
{ key: 'productModel', label: '产品型号', type: 'text', required: true },
|
||||
{ key: 'customCode', label: '自定义编码', type: 'text', required: true },
|
||||
],
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
.mockResolvedValueOnce(targetTemplate)
|
||||
|
||||
prisma.material.findMany.mockResolvedValue(
|
||||
createMaterials(1, {
|
||||
metadata: JSON.stringify({
|
||||
templateId: 'template-1',
|
||||
templateFields: {},
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-1/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetTemplateId: 'template-2',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await migrateTemplate(request, {
|
||||
params: { id: 'template-1' },
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(data.errorCode).toBe('TARGET_TEMPLATE_FIELDS_UNRESOLVED')
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
expect(prisma.material.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('迁移时应把目标模板不接收的旧自定义字段放入备份区', async () => {
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-source',
|
||||
name: '旧模板',
|
||||
_count: { materials: 1 },
|
||||
})
|
||||
|
||||
const targetTemplate = createTemplate({
|
||||
id: 'template-target',
|
||||
name: '新模板',
|
||||
fields: [
|
||||
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
|
||||
{ key: 'keepCustom', label: '保留字段', type: 'text', required: false },
|
||||
],
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
.mockResolvedValueOnce(targetTemplate)
|
||||
|
||||
prisma.material.findMany.mockResolvedValue(
|
||||
createMaterials(1, {
|
||||
metadata: JSON.stringify({
|
||||
templateId: 'template-source',
|
||||
templateFields: {
|
||||
keepCustom: '目标模板接收的值',
|
||||
oldOnlyField: '旧模板独有的值',
|
||||
},
|
||||
previewUrls: ['/preview-a.png'],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-source/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetTemplateId: 'template-target',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await migrateTemplate(request, {
|
||||
params: { id: 'template-source' },
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.migrationBackup).toEqual({
|
||||
materialsWithDroppedFields: 1,
|
||||
droppedTemplateFieldCount: 1,
|
||||
droppedStandardFieldCount: 6,
|
||||
})
|
||||
|
||||
const updateCall = prisma.material.updateMany.mock.calls[0][0]
|
||||
const metadata = JSON.parse(updateCall.data.metadata)
|
||||
|
||||
expect(metadata.templateId).toBe('template-target')
|
||||
expect(metadata.previewUrls).toEqual(['/preview-a.png'])
|
||||
expect(metadata.templateFields).toEqual({
|
||||
keepCustom: '目标模板接收的值',
|
||||
})
|
||||
expect(metadata.templateFields.oldOnlyField).toBeUndefined()
|
||||
expect(metadata.templateMigrationBackups).toHaveLength(1)
|
||||
expect(metadata.templateMigrationBackups[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sourceTemplateId: 'template-source',
|
||||
sourceTemplateName: '旧模板',
|
||||
targetTemplateId: 'template-target',
|
||||
targetTemplateName: '新模板',
|
||||
droppedTemplateFields: {
|
||||
oldOnlyField: '旧模板独有的值',
|
||||
},
|
||||
droppedStandardFields: expect.objectContaining({
|
||||
categoryName: '店铺A',
|
||||
sceneName: '场景A',
|
||||
materialType: '3DMAX',
|
||||
materialKind: 'IMAGE',
|
||||
renderer: 'CR',
|
||||
authorName: '作者A',
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(updateCall.data.brandName).toBeNull()
|
||||
expect(updateCall.data.seriesName).toBeNull()
|
||||
expect(updateCall.data.skuCode).toBeNull()
|
||||
expect(updateCall.data.materialTexture).toBeNull()
|
||||
expect(updateCall.data.renderTime).toBeNull()
|
||||
expect(updateCall.data.imageResolution).toBeNull()
|
||||
expect(updateCall.data.colorMode).toBeNull()
|
||||
expect(updateCall.data.platformName).toBe('')
|
||||
})
|
||||
|
||||
test('迁移后metadata超限时应拒绝并且不写库', async () => {
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-source',
|
||||
name: '旧模板',
|
||||
_count: { materials: 1 },
|
||||
})
|
||||
|
||||
const targetTemplate = createTemplate({
|
||||
id: 'template-target',
|
||||
name: '新模板',
|
||||
fields: [
|
||||
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
|
||||
],
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
.mockResolvedValueOnce(targetTemplate)
|
||||
|
||||
prisma.material.findMany.mockResolvedValue(
|
||||
createMaterials(1, {
|
||||
metadata: JSON.stringify({
|
||||
templateId: 'template-source',
|
||||
templateFields: {
|
||||
oldOnlyField: 'x'.repeat(70000),
|
||||
},
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-source/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetTemplateId: 'template-target',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await migrateTemplate(request, {
|
||||
params: { id: 'template-source' },
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(data.errorCode).toBe('METADATA_TOO_LARGE')
|
||||
expect(data.details.oversizedCount).toBe(1)
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
expect(prisma.material.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('metadata大小按字节判断,中文内容超限也应拒绝', async () => {
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-source',
|
||||
name: '旧模板',
|
||||
_count: { materials: 1 },
|
||||
})
|
||||
|
||||
const targetTemplate = createTemplate({
|
||||
id: 'template-target',
|
||||
name: '新模板',
|
||||
fields: [
|
||||
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
|
||||
],
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
.mockResolvedValueOnce(targetTemplate)
|
||||
|
||||
prisma.material.findMany.mockResolvedValue(
|
||||
createMaterials(1, {
|
||||
metadata: JSON.stringify({
|
||||
templateId: 'template-source',
|
||||
templateFields: {
|
||||
oldOnlyField: '超'.repeat(23000),
|
||||
},
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-source/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetTemplateId: 'template-target',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await migrateTemplate(request, {
|
||||
params: { id: 'template-source' },
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(data.errorCode).toBe('METADATA_TOO_LARGE')
|
||||
expect(data.details.samples[0].size).toBeGreaterThan(65535)
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
expect(prisma.material.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('事务内某条素材已不属于源模板时应整体失败', async () => {
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-source',
|
||||
name: '源模板',
|
||||
_count: { materials: 2 },
|
||||
})
|
||||
|
||||
const targetTemplate = createTemplate({
|
||||
id: 'template-target',
|
||||
name: '目标模板',
|
||||
fields: [
|
||||
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
|
||||
],
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
.mockResolvedValueOnce(targetTemplate)
|
||||
prisma.material.findMany.mockResolvedValue(createMaterials(2))
|
||||
prisma.material.updateMany
|
||||
.mockResolvedValueOnce({ count: 1 })
|
||||
.mockResolvedValueOnce({ count: 0 })
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-source/migrate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
targetTemplateId: 'template-target',
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const response = await migrateTemplate(request, {
|
||||
params: { id: 'template-source' },
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(500)
|
||||
expect(data.errorCode).toBe('MIGRATION_FAILED')
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.material.updateMany).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
where: { id: 'mat-1', templateId: 'template-source' },
|
||||
})
|
||||
)
|
||||
expect(prisma.material.updateMany).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: { id: 'mat-2', templateId: 'template-source' },
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -394,24 +845,24 @@ describe('模板素材迁移 API 测试', () => {
|
||||
})
|
||||
|
||||
test('应该处理大量素材迁移', async () => {
|
||||
const sourceTemplate = {
|
||||
const materialCount = 300
|
||||
const sourceTemplate = createTemplate({
|
||||
id: 'template-1',
|
||||
name: '大量素材模板',
|
||||
materialType: '3DMAX',
|
||||
_count: { materials: 10000 },
|
||||
}
|
||||
_count: { materials: materialCount },
|
||||
})
|
||||
|
||||
const targetTemplate = {
|
||||
const targetTemplate = createTemplate({
|
||||
id: 'template-2',
|
||||
name: '目标模板',
|
||||
materialType: 'PSD',
|
||||
}
|
||||
})
|
||||
|
||||
prisma.uploadTemplate.findUnique
|
||||
.mockResolvedValueOnce(sourceTemplate)
|
||||
.mockResolvedValueOnce(targetTemplate)
|
||||
|
||||
prisma.material.updateMany.mockResolvedValue({ count: 10000 })
|
||||
prisma.material.findMany.mockResolvedValue(createMaterials(materialCount))
|
||||
|
||||
const request = new NextRequest(
|
||||
'http://localhost/api/admin/upload-templates/template-1/migrate',
|
||||
@ -429,7 +880,9 @@ describe('模板素材迁移 API 测试', () => {
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.migratedCount).toBe(10000)
|
||||
expect(data.migratedCount).toBe(materialCount)
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.material.updateMany).toHaveBeenCalledTimes(materialCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -8,6 +8,214 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { canUseCustomTemplates } from '@/lib/license'
|
||||
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
|
||||
import {
|
||||
checkMetadataSize,
|
||||
compressMetadata,
|
||||
parseMetadata,
|
||||
prepareMaterialData,
|
||||
readAllFieldValues,
|
||||
} from '@/lib/field-data-storage'
|
||||
import { hasFieldValue } from '@/lib/template-field-validation'
|
||||
|
||||
type TemplateFieldSummary = {
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
type TemplateMigrationBackup = {
|
||||
sourceTemplateId: string
|
||||
sourceTemplateName: string
|
||||
targetTemplateId: string
|
||||
targetTemplateName: string
|
||||
migratedAt: string
|
||||
droppedTemplateFields?: Record<string, unknown>
|
||||
droppedStandardFields?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const CLEARABLE_NON_TARGET_FIELD_DEFAULTS: Record<string, string | null> = {
|
||||
brandName: null,
|
||||
seriesName: null,
|
||||
skuCode: null,
|
||||
materialTexture: null,
|
||||
platformName: '',
|
||||
renderTime: null,
|
||||
imageResolution: null,
|
||||
colorMode: null,
|
||||
audioDuration: null,
|
||||
audioCategory: null,
|
||||
audioScene: null,
|
||||
}
|
||||
|
||||
const MIGRATION_BACKUP_STANDARD_FIELDS = [
|
||||
'productModel',
|
||||
'brandName',
|
||||
'seriesName',
|
||||
'skuCode',
|
||||
'materialTexture',
|
||||
'categoryName',
|
||||
'sceneName',
|
||||
'materialType',
|
||||
'materialKind',
|
||||
'renderer',
|
||||
'renderTime',
|
||||
'imageResolution',
|
||||
'colorMode',
|
||||
'authorName',
|
||||
'platformName',
|
||||
'audioDuration',
|
||||
'audioCategory',
|
||||
'audioScene',
|
||||
] as const
|
||||
|
||||
function parseMetadataObject(metadata: string | null | undefined): Record<string, unknown> {
|
||||
if (!metadata) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(metadata)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// 保持迁移可继续,坏 metadata 不再扩大影响面。
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function getRecordValue(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function hasBackupValue(value: unknown): boolean {
|
||||
return value !== undefined && value !== null && value !== ''
|
||||
}
|
||||
|
||||
function getExistingMigrationBackups(metadata: Record<string, unknown>): unknown[] {
|
||||
return Array.isArray(metadata.templateMigrationBackups)
|
||||
? metadata.templateMigrationBackups
|
||||
: []
|
||||
}
|
||||
|
||||
function buildMigrationMetadata(params: {
|
||||
existingMetadata: string | null
|
||||
targetMetadata: string | null
|
||||
droppedStandardFields: Record<string, unknown>
|
||||
targetFieldKeys: Set<string>
|
||||
sourceTemplateId: string
|
||||
sourceTemplateName: string
|
||||
targetTemplateId: string
|
||||
targetTemplateName: string
|
||||
migratedAt: string
|
||||
}): { metadata: string | null; droppedTemplateFieldCount: number; droppedStandardFieldCount: number } {
|
||||
const existingMetadataObject = parseMetadataObject(params.existingMetadata)
|
||||
const targetMetadataObject = parseMetadataObject(params.targetMetadata)
|
||||
const existingTemplateFields = getRecordValue(existingMetadataObject.templateFields)
|
||||
const targetTemplateFields = getRecordValue(targetMetadataObject.templateFields)
|
||||
const droppedTemplateFields: Record<string, unknown> = {}
|
||||
const droppedStandardFields = Object.fromEntries(
|
||||
Object.entries(params.droppedStandardFields).filter(([, value]) => hasBackupValue(value))
|
||||
)
|
||||
|
||||
for (const [key, value] of Object.entries(existingTemplateFields)) {
|
||||
if (!params.targetFieldKeys.has(key) && hasBackupValue(value)) {
|
||||
droppedTemplateFields[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const nextMetadata: Record<string, unknown> = {
|
||||
...existingMetadataObject,
|
||||
...targetMetadataObject,
|
||||
templateId: params.targetTemplateId,
|
||||
}
|
||||
|
||||
if (Object.keys(targetTemplateFields).length > 0) {
|
||||
nextMetadata.templateFields = targetTemplateFields
|
||||
} else {
|
||||
delete nextMetadata.templateFields
|
||||
}
|
||||
|
||||
const droppedTemplateFieldCount = Object.keys(droppedTemplateFields).length
|
||||
const droppedStandardFieldCount = Object.keys(droppedStandardFields).length
|
||||
|
||||
if (droppedTemplateFieldCount > 0 || droppedStandardFieldCount > 0) {
|
||||
const backup: TemplateMigrationBackup = {
|
||||
sourceTemplateId: params.sourceTemplateId,
|
||||
sourceTemplateName: params.sourceTemplateName,
|
||||
targetTemplateId: params.targetTemplateId,
|
||||
targetTemplateName: params.targetTemplateName,
|
||||
migratedAt: params.migratedAt,
|
||||
}
|
||||
if (droppedTemplateFieldCount > 0) {
|
||||
backup.droppedTemplateFields = droppedTemplateFields
|
||||
}
|
||||
if (droppedStandardFieldCount > 0) {
|
||||
backup.droppedStandardFields = droppedStandardFields
|
||||
}
|
||||
nextMetadata.templateMigrationBackups = [
|
||||
...getExistingMigrationBackups(existingMetadataObject),
|
||||
backup,
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: JSON.stringify(nextMetadata),
|
||||
droppedTemplateFieldCount,
|
||||
droppedStandardFieldCount,
|
||||
}
|
||||
}
|
||||
|
||||
function getDroppedStandardFields(
|
||||
material: Record<string, unknown>,
|
||||
targetFieldKeys: Set<string>
|
||||
): Record<string, unknown> {
|
||||
const droppedFields: Record<string, unknown> = {}
|
||||
|
||||
for (const key of MIGRATION_BACKUP_STANDARD_FIELDS) {
|
||||
const value = material[key]
|
||||
if (!targetFieldKeys.has(key) && hasBackupValue(value)) {
|
||||
droppedFields[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return droppedFields
|
||||
}
|
||||
|
||||
function clearNonTargetStandardFields(updateData: Record<string, unknown>, targetFieldKeys: Set<string>) {
|
||||
for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) {
|
||||
if (!targetFieldKeys.has(key)) {
|
||||
updateData[key] = emptyValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildMaterialFieldValues(material: Record<string, unknown>) {
|
||||
const values = readAllFieldValues(material)
|
||||
|
||||
;['audioDuration', 'audioCategory', 'audioScene'].forEach((key) => {
|
||||
const value = material[key]
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
values[key] = value as any
|
||||
}
|
||||
})
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | null {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
async function ensureAdmin() {
|
||||
const session = await getServerSession(authOptions)
|
||||
@ -35,6 +243,16 @@ export async function POST(
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
}
|
||||
if (!(await isCurrentSuperAdmin(session))) {
|
||||
return NextResponse.json({ error: '仅最高权限账号可迁移模板素材' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!(await canUseCustomTemplates())) {
|
||||
return NextResponse.json({
|
||||
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
|
||||
errorCode: 'LICENSE_REQUIRED',
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const sourceTemplateId = params.id
|
||||
const body = await req.json()
|
||||
@ -61,6 +279,16 @@ export async function POST(
|
||||
id: true,
|
||||
name: true,
|
||||
materialType: true,
|
||||
materialKind: true,
|
||||
fields: {
|
||||
select: {
|
||||
key: true,
|
||||
label: true,
|
||||
type: true,
|
||||
required: true,
|
||||
},
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
},
|
||||
_count: { select: { materials: true } }
|
||||
},
|
||||
})
|
||||
@ -79,6 +307,16 @@ export async function POST(
|
||||
id: true,
|
||||
name: true,
|
||||
materialType: true,
|
||||
materialKind: true,
|
||||
fields: {
|
||||
select: {
|
||||
key: true,
|
||||
label: true,
|
||||
type: true,
|
||||
required: true,
|
||||
},
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -108,32 +346,199 @@ export async function POST(
|
||||
updateMaterialType,
|
||||
})
|
||||
|
||||
// 构建更新数据
|
||||
const updateData: any = {
|
||||
templateId: targetTemplateId,
|
||||
}
|
||||
|
||||
// 如果需要更新 materialType
|
||||
if (updateMaterialType && targetTemplate.materialType) {
|
||||
updateData.materialType = targetTemplate.materialType
|
||||
}
|
||||
|
||||
// 批量更新素材
|
||||
const result = await prisma.material.updateMany({
|
||||
const materials = await prisma.material.findMany({
|
||||
where: { templateId: sourceTemplateId },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
metadata: true,
|
||||
productModel: true,
|
||||
brandName: true,
|
||||
seriesName: true,
|
||||
skuCode: true,
|
||||
materialTexture: true,
|
||||
categoryName: true,
|
||||
sceneName: true,
|
||||
platformName: true,
|
||||
materialType: true,
|
||||
materialKind: true,
|
||||
renderer: true,
|
||||
renderTime: true,
|
||||
imageResolution: true,
|
||||
colorMode: true,
|
||||
authorName: true,
|
||||
// @ts-ignore - 音频字段已在数据库中添加
|
||||
audioDuration: true,
|
||||
// @ts-ignore
|
||||
audioCategory: true,
|
||||
// @ts-ignore
|
||||
audioScene: true,
|
||||
},
|
||||
})
|
||||
|
||||
const incompatibleMaterials: Array<{
|
||||
materialId: string
|
||||
productModel: string | null
|
||||
missingRequiredFields: string[]
|
||||
}> = []
|
||||
const oversizedMetadataMaterials: Array<{
|
||||
materialId: string
|
||||
productModel: string | null
|
||||
size: number
|
||||
maxSize: number
|
||||
}> = []
|
||||
const targetFieldKeys = new Set(
|
||||
(targetTemplate.fields as TemplateFieldSummary[]).map((field) => field.key)
|
||||
)
|
||||
const migratedAt = new Date().toISOString()
|
||||
let materialsWithDroppedFields = 0
|
||||
let droppedTemplateFieldCount = 0
|
||||
let droppedStandardFieldCount = 0
|
||||
|
||||
const preparedUpdates = materials.map((material) => {
|
||||
const allValues = buildMaterialFieldValues(material as Record<string, unknown>)
|
||||
const nextTemplateFieldValues = (targetTemplate.fields as TemplateFieldSummary[]).reduce<Record<string, unknown>>(
|
||||
(acc, field) => {
|
||||
if (allValues[field.key] !== undefined) {
|
||||
acc[field.key] = allValues[field.key]
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
const missingRequiredFields = (targetTemplate.fields as TemplateFieldSummary[])
|
||||
.filter((field) => field.required)
|
||||
.filter((field) => !hasFieldValue(nextTemplateFieldValues[field.key], field.type))
|
||||
.map((field) => field.label || field.key)
|
||||
|
||||
if (missingRequiredFields.length > 0) {
|
||||
incompatibleMaterials.push({
|
||||
materialId: material.id,
|
||||
productModel: material.productModel,
|
||||
missingRequiredFields,
|
||||
})
|
||||
}
|
||||
|
||||
const parsedMetadata = parseMetadata(material.metadata)
|
||||
const { standardData, metadata: rawMetadata } = prepareMaterialData(
|
||||
nextTemplateFieldValues as Record<string, any>,
|
||||
targetTemplateId,
|
||||
parsedMetadata.previewUrls
|
||||
)
|
||||
const droppedStandardFields = getDroppedStandardFields(
|
||||
material as Record<string, unknown>,
|
||||
targetFieldKeys
|
||||
)
|
||||
const migrationMetadata = buildMigrationMetadata({
|
||||
existingMetadata: material.metadata,
|
||||
targetMetadata: rawMetadata,
|
||||
droppedStandardFields,
|
||||
targetFieldKeys,
|
||||
sourceTemplateId,
|
||||
sourceTemplateName: sourceTemplate.name,
|
||||
targetTemplateId,
|
||||
targetTemplateName: targetTemplate.name,
|
||||
migratedAt,
|
||||
})
|
||||
const metadata = compressMetadata(migrationMetadata.metadata)
|
||||
const metadataSize = checkMetadataSize(metadata)
|
||||
|
||||
if (migrationMetadata.droppedTemplateFieldCount > 0 || migrationMetadata.droppedStandardFieldCount > 0) {
|
||||
materialsWithDroppedFields += 1
|
||||
droppedTemplateFieldCount += migrationMetadata.droppedTemplateFieldCount
|
||||
droppedStandardFieldCount += migrationMetadata.droppedStandardFieldCount
|
||||
}
|
||||
|
||||
if (!metadataSize.ok) {
|
||||
oversizedMetadataMaterials.push({
|
||||
materialId: material.id,
|
||||
productModel: material.productModel,
|
||||
size: metadataSize.size,
|
||||
maxSize: metadataSize.maxSize,
|
||||
})
|
||||
}
|
||||
|
||||
const updateData: any = {
|
||||
templateId: targetTemplateId,
|
||||
materialKind: targetTemplate.materialKind,
|
||||
metadata,
|
||||
...standardData,
|
||||
}
|
||||
clearNonTargetStandardFields(updateData, targetFieldKeys)
|
||||
|
||||
if (updateMaterialType && targetTemplate.materialType) {
|
||||
updateData.materialType = targetTemplate.materialType
|
||||
}
|
||||
|
||||
if ('audioDuration' in nextTemplateFieldValues) {
|
||||
updateData.audioDuration = normalizeOptionalString(nextTemplateFieldValues.audioDuration)
|
||||
}
|
||||
if ('audioCategory' in nextTemplateFieldValues) {
|
||||
updateData.audioCategory = normalizeOptionalString(nextTemplateFieldValues.audioCategory)
|
||||
}
|
||||
if ('audioScene' in nextTemplateFieldValues) {
|
||||
updateData.audioScene = normalizeOptionalString(nextTemplateFieldValues.audioScene)
|
||||
}
|
||||
|
||||
return {
|
||||
id: material.id,
|
||||
data: updateData,
|
||||
}
|
||||
})
|
||||
|
||||
if (incompatibleMaterials.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: `目标模板“${targetTemplate.name}”存在无法从旧素材自动补齐的必填字段,请先补齐后再迁移。`,
|
||||
errorCode: 'TARGET_TEMPLATE_FIELDS_UNRESOLVED',
|
||||
details: {
|
||||
targetTemplateId: targetTemplate.id,
|
||||
targetTemplateName: targetTemplate.name,
|
||||
incompatibleCount: incompatibleMaterials.length,
|
||||
samples: incompatibleMaterials.slice(0, 10),
|
||||
},
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
if (oversizedMetadataMaterials.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: '模板迁移后 metadata 数据过大,请先减少自定义字段内容后再迁移。',
|
||||
errorCode: 'METADATA_TOO_LARGE',
|
||||
details: {
|
||||
oversizedCount: oversizedMetadataMaterials.length,
|
||||
samples: oversizedMetadataMaterials.slice(0, 10),
|
||||
},
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const update of preparedUpdates) {
|
||||
const result = await tx.material.updateMany({
|
||||
where: {
|
||||
id: update.id,
|
||||
templateId: sourceTemplateId,
|
||||
},
|
||||
data: update.data,
|
||||
})
|
||||
|
||||
if (result.count !== 1) {
|
||||
throw new Error(`素材 ${update.id} 已不属于源模板,迁移已整体回滚`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[TemplateMigrate] Migration completed:', {
|
||||
sourceTemplateId,
|
||||
targetTemplateId,
|
||||
migratedCount: result.count,
|
||||
migratedCount: preparedUpdates.length,
|
||||
materialsWithDroppedFields,
|
||||
droppedTemplateFieldCount,
|
||||
droppedStandardFieldCount,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
migratedCount: result.count,
|
||||
message: `成功将 ${result.count} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}"`,
|
||||
migratedCount: preparedUpdates.length,
|
||||
message: `成功将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}"`,
|
||||
sourceTemplate: {
|
||||
id: sourceTemplate.id,
|
||||
name: sourceTemplate.name,
|
||||
@ -142,6 +547,11 @@ export async function POST(
|
||||
id: targetTemplate.id,
|
||||
name: targetTemplate.name,
|
||||
},
|
||||
migrationBackup: {
|
||||
materialsWithDroppedFields,
|
||||
droppedTemplateFieldCount,
|
||||
droppedStandardFieldCount,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('[TemplateMigrate] Migration error:', {
|
||||
|
||||
@ -3,6 +3,8 @@ import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
|
||||
import { canUseCustomTemplates } from '@/lib/license'
|
||||
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
|
||||
|
||||
const selectTemplate = {
|
||||
id: true,
|
||||
@ -49,6 +51,116 @@ async function ensureAdmin() {
|
||||
return session
|
||||
}
|
||||
|
||||
type TemplateStructureChange =
|
||||
| {
|
||||
type: 'materialTypeChanged'
|
||||
before: string
|
||||
after: string
|
||||
}
|
||||
| {
|
||||
type: 'materialKindChanged'
|
||||
before: string
|
||||
after: string
|
||||
}
|
||||
| {
|
||||
type: 'fieldDeleted'
|
||||
fieldId: string
|
||||
fieldKey: string
|
||||
fieldLabel: string
|
||||
}
|
||||
| {
|
||||
type: 'fieldKeyChanged'
|
||||
fieldId: string
|
||||
before: string
|
||||
after: string
|
||||
fieldLabel: string
|
||||
}
|
||||
| {
|
||||
type: 'fieldTypeChanged'
|
||||
fieldId: string
|
||||
fieldKey: string
|
||||
fieldLabel: string
|
||||
before: string
|
||||
after: string
|
||||
}
|
||||
|
||||
function detectDangerousTemplateChanges(args: {
|
||||
existingTemplate: {
|
||||
materialType: string
|
||||
materialKind: string
|
||||
fields: Array<{ id: string; key: string; type: string; label: string }>
|
||||
}
|
||||
nextMaterialType?: string
|
||||
nextMaterialKind?: string
|
||||
nextFields?: Array<{ id?: string | null; key: string; type: string; label: string }>
|
||||
}): TemplateStructureChange[] {
|
||||
const { existingTemplate, nextMaterialType, nextMaterialKind, nextFields } = args
|
||||
const changes: TemplateStructureChange[] = []
|
||||
|
||||
if (nextMaterialType && nextMaterialType !== existingTemplate.materialType) {
|
||||
changes.push({
|
||||
type: 'materialTypeChanged',
|
||||
before: existingTemplate.materialType,
|
||||
after: nextMaterialType,
|
||||
})
|
||||
}
|
||||
|
||||
if (nextMaterialKind && nextMaterialKind !== existingTemplate.materialKind) {
|
||||
changes.push({
|
||||
type: 'materialKindChanged',
|
||||
before: existingTemplate.materialKind,
|
||||
after: nextMaterialKind,
|
||||
})
|
||||
}
|
||||
|
||||
if (!nextFields) {
|
||||
return changes
|
||||
}
|
||||
|
||||
const incomingById = new Map(
|
||||
nextFields
|
||||
.filter((field) => field.id)
|
||||
.map((field) => [field.id as string, field])
|
||||
)
|
||||
|
||||
for (const existingField of existingTemplate.fields) {
|
||||
const incomingField = incomingById.get(existingField.id)
|
||||
|
||||
if (!incomingField) {
|
||||
changes.push({
|
||||
type: 'fieldDeleted',
|
||||
fieldId: existingField.id,
|
||||
fieldKey: existingField.key,
|
||||
fieldLabel: existingField.label,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (incomingField.key !== existingField.key) {
|
||||
changes.push({
|
||||
type: 'fieldKeyChanged',
|
||||
fieldId: existingField.id,
|
||||
before: existingField.key,
|
||||
after: incomingField.key,
|
||||
fieldLabel: existingField.label,
|
||||
})
|
||||
}
|
||||
|
||||
if (incomingField.type !== existingField.type) {
|
||||
changes.push({
|
||||
type: 'fieldTypeChanged',
|
||||
fieldId: existingField.id,
|
||||
fieldKey: existingField.key,
|
||||
fieldLabel: existingField.label,
|
||||
before: existingField.type,
|
||||
after: incomingField.type,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
export async function GET(_: NextRequest, { params }: { params: { id: string } }) {
|
||||
try {
|
||||
const session = await ensureAdmin()
|
||||
@ -201,6 +313,16 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
|
||||
try {
|
||||
const session = await ensureAdmin()
|
||||
if (!session) return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
if (!(await isCurrentSuperAdmin(session))) {
|
||||
return NextResponse.json({ error: '仅最高权限账号可更新上传模板' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!(await canUseCustomTemplates())) {
|
||||
return NextResponse.json({
|
||||
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
|
||||
errorCode: 'LICENSE_REQUIRED',
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = params
|
||||
const body = await req.json()
|
||||
@ -223,6 +345,22 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
|
||||
version: true,
|
||||
name: true,
|
||||
updatedAt: true,
|
||||
materialType: true,
|
||||
materialKind: true,
|
||||
fields: {
|
||||
select: {
|
||||
id: true,
|
||||
key: true,
|
||||
type: true,
|
||||
label: true,
|
||||
},
|
||||
orderBy: { orderIndex: 'asc' },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
materials: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!existing) {
|
||||
@ -280,7 +418,7 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
|
||||
|
||||
// 验证materialKind
|
||||
if (materialKind !== undefined) {
|
||||
const allowedKinds = ['IMAGE', 'VIDEO', 'OTHER'] as const
|
||||
const allowedKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] as const
|
||||
const rawKind = typeof materialKind === 'string' ? materialKind.trim().toUpperCase() : ''
|
||||
if (!rawKind || !(allowedKinds as readonly string[]).includes(rawKind)) {
|
||||
console.warn('[TemplateUpdate] Validation failed: invalid materialKind:', materialKind)
|
||||
@ -379,6 +517,38 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
|
||||
})
|
||||
}
|
||||
|
||||
if (existing._count.materials > 0) {
|
||||
const dangerousChanges = detectDangerousTemplateChanges({
|
||||
existingTemplate: {
|
||||
materialType: existing.materialType,
|
||||
materialKind: existing.materialKind,
|
||||
fields: existing.fields,
|
||||
},
|
||||
nextMaterialType: updateData.materialType,
|
||||
nextMaterialKind: updateData.materialKind,
|
||||
nextFields: formattedFields || undefined,
|
||||
})
|
||||
|
||||
if (dangerousChanges.length > 0) {
|
||||
console.warn('[TemplateUpdate] Dangerous structure change blocked:', {
|
||||
templateId: id,
|
||||
templateName: existing.name,
|
||||
materialCount: existing._count.materials,
|
||||
dangerousChanges,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
error: `模板“${existing.name}”已有 ${existing._count.materials} 个素材在使用,不能直接修改素材大类、素材类型,也不能删除字段、修改字段 key 或修改字段类型。请新建模板后迁移素材。`,
|
||||
errorCode: 'TEMPLATE_STRUCTURE_LOCKED',
|
||||
details: {
|
||||
templateName: existing.name,
|
||||
materialCount: existing._count.materials,
|
||||
dangerousChanges,
|
||||
},
|
||||
}, { status: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
console.log('[TemplateUpdate] Updating template:', {
|
||||
templateId: id,
|
||||
@ -517,6 +687,16 @@ export async function DELETE(_: NextRequest, { params }: { params: { id: string
|
||||
try {
|
||||
const session = await ensureAdmin()
|
||||
if (!session) return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
if (!(await isCurrentSuperAdmin(session))) {
|
||||
return NextResponse.json({ error: '仅最高权限账号可删除上传模板' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (!(await canUseCustomTemplates())) {
|
||||
return NextResponse.json({
|
||||
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
|
||||
errorCode: 'LICENSE_REQUIRED',
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = params
|
||||
|
||||
|
||||
@ -36,9 +36,19 @@ jest.mock('@/lib/field-pool', () => ({
|
||||
syncFieldPoolFromTemplates: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/license', () => ({
|
||||
canUseCustomTemplates: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/admin-permissions', () => ({
|
||||
isCurrentSuperAdmin: jest.fn(),
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
const { syncFieldPoolFromTemplates } = require('@/lib/field-pool')
|
||||
const { canUseCustomTemplates } = require('@/lib/license')
|
||||
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
|
||||
|
||||
// 导入API处理函数
|
||||
import { GET as listTemplates, POST as createTemplate } from '../route'
|
||||
@ -50,11 +60,13 @@ import {
|
||||
|
||||
describe('模板CRUD API测试', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.resetAllMocks()
|
||||
// 默认模拟管理员会话
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'admin-id', role: 'ADMIN' }
|
||||
})
|
||||
canUseCustomTemplates.mockResolvedValue(true)
|
||||
isCurrentSuperAdmin.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
describe('GET /api/admin/upload-templates - 获取模板列表', () => {
|
||||
@ -111,6 +123,48 @@ describe('模板CRUD API测试', () => {
|
||||
})
|
||||
|
||||
describe('POST /api/admin/upload-templates - 创建模板', () => {
|
||||
test('普通管理员不能创建模板', async () => {
|
||||
isCurrentSuperAdmin.mockResolvedValue(false)
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: '普通管理员模板',
|
||||
materialType: '3DMAX',
|
||||
materialKind: 'IMAGE',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await createTemplate(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toContain('最高权限')
|
||||
expect(canUseCustomTemplates).not.toHaveBeenCalled()
|
||||
expect(prisma.uploadTemplate.create).not.toHaveBeenCalled()
|
||||
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('许可证关闭时应拒绝创建模板', async () => {
|
||||
canUseCustomTemplates.mockResolvedValue(false)
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: '受限模板',
|
||||
materialType: '3DMAX',
|
||||
materialKind: 'IMAGE',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await createTemplate(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.errorCode).toBe('LICENSE_REQUIRED')
|
||||
expect(prisma.uploadTemplate.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('应该成功创建模板', async () => {
|
||||
const mockTemplate = {
|
||||
id: 'new-template-id',
|
||||
@ -141,6 +195,35 @@ describe('模板CRUD API测试', () => {
|
||||
expect(syncFieldPoolFromTemplates).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('应该允许创建音频模板', async () => {
|
||||
const mockTemplate = {
|
||||
id: 'audio-template-id',
|
||||
name: '音频模板',
|
||||
materialType: '音频',
|
||||
materialKind: 'AUDIO',
|
||||
status: 'draft',
|
||||
version: 1,
|
||||
}
|
||||
|
||||
prisma.uploadTemplate.create.mockResolvedValue(mockTemplate)
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: '音频模板',
|
||||
materialType: '音频',
|
||||
materialKind: 'AUDIO',
|
||||
fields: [],
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await createTemplate(request)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(data.materialKind).toBe('AUDIO')
|
||||
})
|
||||
|
||||
test('应该验证必填字段 - name', async () => {
|
||||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||||
method: 'POST',
|
||||
@ -317,12 +400,57 @@ describe('模板CRUD API测试', () => {
|
||||
})
|
||||
|
||||
describe('PUT /api/admin/upload-templates/[id] - 更新模板', () => {
|
||||
test('普通管理员不能更新模板', async () => {
|
||||
isCurrentSuperAdmin.mockResolvedValue(false)
|
||||
|
||||
const request = new NextRequest('http://localhost', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: '普通管理员更新',
|
||||
version: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toContain('最高权限')
|
||||
expect(canUseCustomTemplates).not.toHaveBeenCalled()
|
||||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('许可证关闭时应拒绝更新模板', async () => {
|
||||
canUseCustomTemplates.mockResolvedValue(false)
|
||||
|
||||
const request = new NextRequest('http://localhost', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: '新名称',
|
||||
version: 1,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.errorCode).toBe('LICENSE_REQUIRED')
|
||||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('应该成功更新模板并递增版本号', async () => {
|
||||
const existingTemplate = {
|
||||
id: 'template-1',
|
||||
name: '旧名称',
|
||||
version: 1,
|
||||
materialType: '3DMAX',
|
||||
materialKind: 'IMAGE',
|
||||
updatedAt: new Date(),
|
||||
fields: [],
|
||||
_count: { materials: 0 },
|
||||
}
|
||||
|
||||
const updatedTemplate = {
|
||||
@ -357,12 +485,73 @@ describe('模板CRUD API测试', () => {
|
||||
expect(data.version).toBe(2)
|
||||
})
|
||||
|
||||
test('应该允许将模板更新为音频大类', async () => {
|
||||
prisma.uploadTemplate.findUnique.mockResolvedValueOnce({
|
||||
id: 'template-1',
|
||||
version: 1,
|
||||
name: '测试模板',
|
||||
materialType: '音频',
|
||||
materialKind: 'AUDIO',
|
||||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||||
fields: [],
|
||||
_count: { materials: 0 },
|
||||
})
|
||||
|
||||
prisma.$transaction.mockImplementationOnce(async (callback: any) =>
|
||||
callback({
|
||||
uploadTemplate: {
|
||||
update: jest.fn().mockResolvedValue({ id: 'template-1', version: 2 }),
|
||||
},
|
||||
uploadField: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
prisma.uploadTemplate.findUnique.mockResolvedValueOnce({
|
||||
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: [],
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload-templates/template-1', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
name: '音频模板',
|
||||
materialType: '音频',
|
||||
materialKind: 'AUDIO',
|
||||
version: 1,
|
||||
fields: [],
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.materialKind).toBe('AUDIO')
|
||||
})
|
||||
|
||||
test('应该检测版本冲突', async () => {
|
||||
const existingTemplate = {
|
||||
id: 'template-1',
|
||||
name: '模板',
|
||||
version: 3, // 服务器版本已更新
|
||||
materialType: '3DMAX',
|
||||
materialKind: 'IMAGE',
|
||||
updatedAt: new Date(),
|
||||
fields: [],
|
||||
_count: { materials: 0 },
|
||||
}
|
||||
|
||||
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
|
||||
@ -388,7 +577,12 @@ describe('模板CRUD API测试', () => {
|
||||
const existingTemplate = {
|
||||
id: 'template-1',
|
||||
version: 1,
|
||||
name: '模板',
|
||||
materialType: '3DMAX',
|
||||
materialKind: 'IMAGE',
|
||||
updatedAt: new Date(),
|
||||
fields: [],
|
||||
_count: { materials: 0 },
|
||||
}
|
||||
|
||||
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
|
||||
@ -410,9 +604,142 @@ describe('模板CRUD API测试', () => {
|
||||
expect(response.status).toBe(400)
|
||||
expect(data.errorCode).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
test('已被素材使用的模板不应允许删除字段', async () => {
|
||||
prisma.uploadTemplate.findUnique.mockResolvedValue({
|
||||
id: 'template-1',
|
||||
version: 1,
|
||||
name: '使用中的模板',
|
||||
materialType: '3DMAX',
|
||||
materialKind: 'IMAGE',
|
||||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||||
fields: [
|
||||
{ id: 'field-1', key: 'productModel', type: 'text', label: 'SPU型号' },
|
||||
{ id: 'field-2', key: 'customField', type: 'text', label: '自定义字段' },
|
||||
],
|
||||
_count: { materials: 3 },
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
version: 1,
|
||||
fields: [
|
||||
{ id: 'field-1', key: 'productModel', label: 'SPU型号', type: 'text' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
|
||||
expect(data.details.materialCount).toBe(3)
|
||||
expect(data.details.dangerousChanges[0].type).toBe('fieldDeleted')
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('已被素材使用的模板不应允许修改字段 key', async () => {
|
||||
prisma.uploadTemplate.findUnique.mockResolvedValue({
|
||||
id: 'template-1',
|
||||
version: 1,
|
||||
name: '使用中的模板',
|
||||
materialType: '3DMAX',
|
||||
materialKind: 'IMAGE',
|
||||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||||
fields: [
|
||||
{ id: 'field-1', key: 'customField', type: 'text', label: '自定义字段' },
|
||||
],
|
||||
_count: { materials: 2 },
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
version: 1,
|
||||
fields: [
|
||||
{ id: 'field-1', key: 'renamedField', label: '自定义字段', type: 'text' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
|
||||
expect(data.details.dangerousChanges[0].type).toBe('fieldKeyChanged')
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('已被素材使用的模板不应允许修改素材大类', async () => {
|
||||
prisma.uploadTemplate.findUnique.mockResolvedValue({
|
||||
id: 'template-1',
|
||||
version: 1,
|
||||
name: '音频模板',
|
||||
materialType: '音频',
|
||||
materialKind: 'AUDIO',
|
||||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||||
fields: [
|
||||
{ id: 'field-1', key: 'productModel', type: 'text', label: '音频名称' },
|
||||
],
|
||||
_count: { materials: 4 },
|
||||
})
|
||||
|
||||
const request = new NextRequest('http://localhost', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
version: 1,
|
||||
materialKind: 'IMAGE',
|
||||
fields: [
|
||||
{ id: 'field-1', key: 'productModel', label: '音频名称', type: 'text' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
|
||||
expect(data.details.dangerousChanges[0].type).toBe('materialKindChanged')
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/admin/upload-templates/[id] - 删除模板', () => {
|
||||
test('普通管理员不能删除模板', async () => {
|
||||
isCurrentSuperAdmin.mockResolvedValue(false)
|
||||
|
||||
const response = await deleteTemplate(
|
||||
new NextRequest('http://localhost'),
|
||||
{ params: { id: 'template-1' } }
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.error).toContain('最高权限')
|
||||
expect(canUseCustomTemplates).not.toHaveBeenCalled()
|
||||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||||
expect(prisma.uploadTemplate.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('许可证关闭时应拒绝删除模板', async () => {
|
||||
canUseCustomTemplates.mockResolvedValue(false)
|
||||
|
||||
const response = await deleteTemplate(
|
||||
new NextRequest('http://localhost'),
|
||||
{ params: { id: 'template-1' } }
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(data.errorCode).toBe('LICENSE_REQUIRED')
|
||||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||||
expect(prisma.uploadTemplate.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('应该成功删除未使用的模板', async () => {
|
||||
const mockTemplate = {
|
||||
id: 'template-1',
|
||||
|
||||
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { syncFieldPoolFromTemplates } from '@/lib/field-pool'
|
||||
import { canUseCustomTemplates } from '@/lib/license'
|
||||
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
|
||||
|
||||
const baseSelect = {
|
||||
id: true,
|
||||
@ -96,9 +97,12 @@ export async function POST(req: NextRequest) {
|
||||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||||
}
|
||||
if (!(await isCurrentSuperAdmin(session))) {
|
||||
return NextResponse.json({ error: '仅最高权限账号可创建上传模板' }, { status: 403 })
|
||||
}
|
||||
|
||||
// 检查自定义模板功能是否可用
|
||||
if (!canUseCustomTemplates()) {
|
||||
if (!(await canUseCustomTemplates())) {
|
||||
return NextResponse.json({
|
||||
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
|
||||
errorCode: 'LICENSE_REQUIRED',
|
||||
@ -138,7 +142,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// 验证materialKind
|
||||
const allowedKinds = ['IMAGE', 'VIDEO', 'OTHER'] as const
|
||||
const allowedKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] as const
|
||||
const rawKind = typeof materialKind === 'string' && materialKind.trim() ? materialKind.trim().toUpperCase() : ''
|
||||
if (!rawKind || !(allowedKinds as readonly string[]).includes(rawKind)) {
|
||||
console.warn('[TemplateCreate] Validation failed: invalid materialKind:', materialKind)
|
||||
|
||||
@ -10,6 +10,8 @@
|
||||
* 4. 启用/禁用模板过滤
|
||||
*/
|
||||
|
||||
export {}
|
||||
|
||||
// 内置模板配置(与 route.ts 保持一致)
|
||||
const BUILTIN_TEMPLATE_KEYS = [
|
||||
'3dmax-material',
|
||||
|
||||
@ -6,28 +6,14 @@
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
BUILTIN_TEMPLATE_KEYS,
|
||||
BUILTIN_TEMPLATE_TYPES,
|
||||
} from '@/lib/upload-template-presets'
|
||||
|
||||
// 强制此 API 每次请求都在运行时执行,而不是在构建阶段预渲染成静态结果
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const BUILTIN_TEMPLATE_KEYS = [
|
||||
'3dmax-material',
|
||||
'psd-material',
|
||||
'audio-material',
|
||||
'video-material',
|
||||
'model-material',
|
||||
'other-material',
|
||||
]
|
||||
|
||||
const BUILTIN_TEMPLATE_TYPES: Record<string, string> = {
|
||||
'3dmax-material': '3DMAX',
|
||||
'psd-material': 'PSD',
|
||||
'audio-material': '音频',
|
||||
'video-material': '视频',
|
||||
'model-material': '模特',
|
||||
'other-material': '其他',
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
// 使用 console.error 确保日志输出到 stderr,在 Docker 中更容易被捕获
|
||||
console.error('[material-types] ========== API CALLED ==========')
|
||||
@ -164,7 +150,7 @@ export async function GET() {
|
||||
console.error('[material-types] Error fetching material types:', error)
|
||||
console.error('[material-types] Error stack:', error instanceof Error ? error.stack : 'No stack trace')
|
||||
// 返回内置类型作为fallback(与首页 FALLBACK_MATERIAL_TYPES 保持一致,但去掉"素材模板"后缀)
|
||||
const fallbackTypes = ['3DMAX', 'PSD', 'AI 工作流', '视频', '模特', '其他']
|
||||
const fallbackTypes = Array.from(new Set(Object.values(BUILTIN_TEMPLATE_TYPES)))
|
||||
console.error('[material-types] Returning fallback types:', fallbackTypes)
|
||||
return NextResponse.json(fallbackTypes, {
|
||||
headers: {
|
||||
|
||||
@ -9,26 +9,12 @@
|
||||
* 3. 模板合并和排序
|
||||
*/
|
||||
|
||||
// 内置模板配置(与 route.ts 保持一致)
|
||||
const BUILTIN_TEMPLATE_KEYS = [
|
||||
'3dmax-material',
|
||||
'psd-material',
|
||||
'audio-material',
|
||||
'video-material',
|
||||
'model-material',
|
||||
'other-material',
|
||||
]
|
||||
import {
|
||||
BUILTIN_TEMPLATE_KEYS,
|
||||
BUILTIN_TEMPLATE_NAMES,
|
||||
} from '@/lib/upload-template-presets'
|
||||
|
||||
const BUILTIN_TEMPLATE_NAMES: Record<string, string> = {
|
||||
'3dmax-material': '3DMAX 素材模板',
|
||||
'psd-material': 'PSD 素材模板',
|
||||
'audio-material': '音频素材模板',
|
||||
'video-material': '视频素材模板',
|
||||
'model-material': '模特素材模板',
|
||||
'other-material': '其他素材模板',
|
||||
}
|
||||
|
||||
function createBuiltinTemplate(key: string) {
|
||||
function createBuiltinTemplateListItem(key: string) {
|
||||
return {
|
||||
id: `builtin-${key}`,
|
||||
name: BUILTIN_TEMPLATE_NAMES[key] || key,
|
||||
@ -67,7 +53,7 @@ describe('模板列表 API - 内置模板配置', () => {
|
||||
|
||||
describe('createBuiltinTemplate', () => {
|
||||
it('应该生成正确格式的内置模板对象', () => {
|
||||
const template = createBuiltinTemplate('3dmax-material')
|
||||
const template = createBuiltinTemplateListItem('3dmax-material')
|
||||
|
||||
expect(template).toEqual({
|
||||
id: 'builtin-3dmax-material',
|
||||
@ -78,7 +64,7 @@ describe('模板列表 API - 内置模板配置', () => {
|
||||
|
||||
it('内置模板ID应该以builtin-开头', () => {
|
||||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||||
const template = createBuiltinTemplate(key)
|
||||
const template = createBuiltinTemplateListItem(key)
|
||||
expect(template.id).toMatch(/^builtin-/)
|
||||
expect(template.id).toBe(`builtin-${key}`)
|
||||
}
|
||||
@ -86,13 +72,13 @@ describe('模板列表 API - 内置模板配置', () => {
|
||||
|
||||
it('所有内置模板应该标记isBuiltin为true', () => {
|
||||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||||
const template = createBuiltinTemplate(key)
|
||||
const template = createBuiltinTemplateListItem(key)
|
||||
expect(template.isBuiltin).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('未知key应该使用key作为名称', () => {
|
||||
const template = createBuiltinTemplate('unknown-key')
|
||||
const template = createBuiltinTemplateListItem('unknown-key')
|
||||
expect(template.name).toBe('unknown-key')
|
||||
})
|
||||
})
|
||||
@ -109,7 +95,7 @@ describe('模板列表 API - 模板过滤逻辑', () => {
|
||||
|
||||
const builtinTemplates = BUILTIN_TEMPLATE_KEYS
|
||||
.filter(key => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key))
|
||||
.map(key => createBuiltinTemplate(key))
|
||||
.map(key => createBuiltinTemplateListItem(key))
|
||||
|
||||
expect(builtinTemplates).toHaveLength(6)
|
||||
})
|
||||
@ -119,7 +105,7 @@ describe('模板列表 API - 模板过滤逻辑', () => {
|
||||
|
||||
const builtinTemplates = BUILTIN_TEMPLATE_KEYS
|
||||
.filter(key => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key))
|
||||
.map(key => createBuiltinTemplate(key))
|
||||
.map(key => createBuiltinTemplateListItem(key))
|
||||
|
||||
expect(builtinTemplates).toHaveLength(2)
|
||||
expect(builtinTemplates.map(t => t.id)).toEqual([
|
||||
@ -133,7 +119,7 @@ describe('模板列表 API - 模板过滤逻辑', () => {
|
||||
|
||||
const builtinTemplates = BUILTIN_TEMPLATE_KEYS
|
||||
.filter(key => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key))
|
||||
.map(key => createBuiltinTemplate(key))
|
||||
.map(key => createBuiltinTemplateListItem(key))
|
||||
|
||||
expect(builtinTemplates).toHaveLength(1)
|
||||
expect(builtinTemplates[0].id).toBe('builtin-video-material')
|
||||
@ -254,12 +240,12 @@ describe('模板列表 API - 模板过滤逻辑', () => {
|
||||
})
|
||||
|
||||
it('合并后不应该有重复的模板', () => {
|
||||
const builtinTemplates = BUILTIN_TEMPLATE_KEYS.map(key => createBuiltinTemplate(key))
|
||||
const builtinTemplateItems = BUILTIN_TEMPLATE_KEYS.map(key => createBuiltinTemplateListItem(key))
|
||||
const customTemplates = [
|
||||
{ id: 'custom-1', name: '自定义1', isBuiltin: false },
|
||||
]
|
||||
|
||||
const allTemplates = [...builtinTemplates, ...customTemplates]
|
||||
const allTemplates = [...builtinTemplateItems, ...customTemplates]
|
||||
const ids = allTemplates.map(t => t.id)
|
||||
const uniqueIds = new Set(ids)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user