diff --git a/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts b/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts index 357c849..244f122 100644 --- a/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts +++ b/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts @@ -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 = {}) { + return { + id: 'template-1', + name: '模板', + materialType: '3DMAX', + materialKind: 'IMAGE', + fields: [], + _count: { materials: 0 }, + ...overrides, + } +} + +function createMaterials(count: number, overrides: Record = {}) { + 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) }) }) }) diff --git a/app/api/admin/upload-templates/[id]/migrate/route.ts b/app/api/admin/upload-templates/[id]/migrate/route.ts index 9a8bb3f..7f08f96 100644 --- a/app/api/admin/upload-templates/[id]/migrate/route.ts +++ b/app/api/admin/upload-templates/[id]/migrate/route.ts @@ -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 + droppedStandardFields?: Record +} + +const CLEARABLE_NON_TARGET_FIELD_DEFAULTS: Record = { + 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 { + if (!metadata) { + return {} + } + + try { + const parsed = JSON.parse(metadata) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // 保持迁移可继续,坏 metadata 不再扩大影响面。 + } + + return {} +} + +function getRecordValue(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record + } + return {} +} + +function hasBackupValue(value: unknown): boolean { + return value !== undefined && value !== null && value !== '' +} + +function getExistingMigrationBackups(metadata: Record): unknown[] { + return Array.isArray(metadata.templateMigrationBackups) + ? metadata.templateMigrationBackups + : [] +} + +function buildMigrationMetadata(params: { + existingMetadata: string | null + targetMetadata: string | null + droppedStandardFields: Record + targetFieldKeys: Set + 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 = {} + 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 = { + ...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, + targetFieldKeys: Set +): Record { + const droppedFields: Record = {} + + 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, targetFieldKeys: Set) { + for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) { + if (!targetFieldKeys.has(key)) { + updateData[key] = emptyValue + } + } +} + +function buildMaterialFieldValues(material: Record) { + 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) + const nextTemplateFieldValues = (targetTemplate.fields as TemplateFieldSummary[]).reduce>( + (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, + targetTemplateId, + parsedMetadata.previewUrls + ) + const droppedStandardFields = getDroppedStandardFields( + material as Record, + 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:', { diff --git a/app/api/admin/upload-templates/[id]/route.ts b/app/api/admin/upload-templates/[id]/route.ts index 60c04be..94c92c4 100644 --- a/app/api/admin/upload-templates/[id]/route.ts +++ b/app/api/admin/upload-templates/[id]/route.ts @@ -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 diff --git a/app/api/admin/upload-templates/__tests__/template-crud.test.ts b/app/api/admin/upload-templates/__tests__/template-crud.test.ts index 0547b53..61349e7 100644 --- a/app/api/admin/upload-templates/__tests__/template-crud.test.ts +++ b/app/api/admin/upload-templates/__tests__/template-crud.test.ts @@ -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', diff --git a/app/api/admin/upload-templates/route.ts b/app/api/admin/upload-templates/route.ts index 8dde280..f9d7f51 100644 --- a/app/api/admin/upload-templates/route.ts +++ b/app/api/admin/upload-templates/route.ts @@ -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) diff --git a/app/api/material-types/__tests__/material-types-route.test.ts b/app/api/material-types/__tests__/material-types-route.test.ts index 981e668..0165a33 100644 --- a/app/api/material-types/__tests__/material-types-route.test.ts +++ b/app/api/material-types/__tests__/material-types-route.test.ts @@ -10,6 +10,8 @@ * 4. 启用/禁用模板过滤 */ +export {} + // 内置模板配置(与 route.ts 保持一致) const BUILTIN_TEMPLATE_KEYS = [ '3dmax-material', diff --git a/app/api/material-types/route.ts b/app/api/material-types/route.ts index 03b6bd9..206ab19 100644 --- a/app/api/material-types/route.ts +++ b/app/api/material-types/route.ts @@ -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 = { - '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: { diff --git a/app/api/templates/__tests__/templates-route.test.ts b/app/api/templates/__tests__/templates-route.test.ts index 1d4a228..bd83916 100644 --- a/app/api/templates/__tests__/templates-route.test.ts +++ b/app/api/templates/__tests__/templates-route.test.ts @@ -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 = { - '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)