收紧模板管理权限并保护迁移数据

This commit is contained in:
WuYongJie 2026-05-25 16:48:19 +08:00
parent 720c163a6b
commit c759d0a37b
8 changed files with 1485 additions and 137 deletions

View File

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

View File

@ -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,
const materials = await prisma.material.findMany({
where: { templateId: sourceTemplateId },
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,
})
}
// 如果需要更新 materialType
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
}
// 批量更新素材
const result = await prisma.material.updateMany({
where: { templateId: sourceTemplateId },
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:', {

View File

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

View File

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

View File

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

View File

@ -10,6 +10,8 @@
* 4. 启用/禁用模板过滤
*/
export {}
// 内置模板配置(与 route.ts 保持一致)
const BUILTIN_TEMPLATE_KEYS = [
'3dmax-material',

View File

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

View File

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