diff --git a/app/admin/_components/TemplateManager.tsx b/app/admin/_components/TemplateManager.tsx index 6481146..0af9085 100644 --- a/app/admin/_components/TemplateManager.tsx +++ b/app/admin/_components/TemplateManager.tsx @@ -60,7 +60,10 @@ type MigrationPreviewResponse = { droppedStandardFieldCount: number } migrationPreview: { + totalMaterialCount: number totalMaterials: number + previewLimited?: boolean + previewLimit?: number targetCurrentFields: Array<{ key: string label: string @@ -80,6 +83,17 @@ type MigrationPreviewResponse = { } } +type MigrationBatchResult = { + success: boolean + migratedCount: number + totalMaterialCount?: number + remainingMaterialCount?: number + batchLimited?: boolean + message: string +} + +const MIGRATION_BATCH_LIMIT = 1000 + // 默认需要进入分类管理的字段集合,可被后台实时配置覆盖 const DEFAULT_CATEGORY_FIELDS = [ 'categoryName', @@ -1776,17 +1790,39 @@ export default function TemplateManager({ setMigrating(true) try { - // 1. 迁移素材 - const migrateResult = await requestJson<{ success: boolean; migratedCount: number; message: string }>( - `/api/admin/upload-templates/${migrationSourceTemplate.id}/migrate`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetTemplateId: migrationTargetTemplateId }), + let remaining = migrationPreview.migrationPreview.totalMaterialCount || migrationSourceTemplate.materialCount + let migratedTotal = 0 + let lastMessage = '' + const maxBatches = Math.ceil(Math.max(remaining, 1) / MIGRATION_BATCH_LIMIT) + 5 + + for (let batchIndex = 0; remaining > 0 && batchIndex < maxBatches; batchIndex += 1) { + const migrateResult = await requestJson( + `/api/admin/upload-templates/${migrationSourceTemplate.id}/migrate`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + targetTemplateId: migrationTargetTemplateId, + migrationLimit: MIGRATION_BATCH_LIMIT, + forceLargeMigration: true, + }), + } + ) + + migratedTotal += migrateResult.migratedCount + remaining = migrateResult.remainingMaterialCount ?? Math.max(remaining - migrateResult.migratedCount, 0) + lastMessage = migrateResult.message + + if (migrateResult.migratedCount === 0 && remaining > 0) { + throw new Error('迁移没有继续推进,请刷新后重试。') } - ) - - showToast(migrateResult.message, 'success') + } + + if (remaining > 0) { + throw new Error(`仍有 ${remaining} 个素材未迁移,请刷新后重试。`) + } + + showToast(lastMessage || `成功迁移 ${migratedTotal} 个素材`, 'success') // 2. 删除源模板 await requestJson(`/api/admin/upload-templates/${migrationSourceTemplate.id}`, { method: 'DELETE' }) @@ -2150,9 +2186,9 @@ export default function TemplateManager({
-

可迁移素材

+

素材总数

- {migrationPreview.migratableCount} + {migrationPreview.migrationPreview.totalMaterialCount}

@@ -2169,6 +2205,12 @@ export default function TemplateManager({
+ {migrationPreview.migrationPreview.previewLimited && ( +

+ 当前只预览前 {migrationPreview.migrationPreview.totalMaterials} 个素材;确认后会按每批 {MIGRATION_BATCH_LIMIT} 个自动分批迁移。 +

+ )} +

迁移后当前字段 diff --git a/app/api/admin/materials/export/__tests__/route.test.ts b/app/api/admin/materials/export/__tests__/route.test.ts index 8e32bd5..cbe3849 100644 --- a/app/api/admin/materials/export/__tests__/route.test.ts +++ b/app/api/admin/materials/export/__tests__/route.test.ts @@ -97,11 +97,20 @@ describe('admin materials export', () => { sceneName: '场景类别', platformName: '平台', materialType: '3DMAX', + materialKind: 'IMAGE', renderer: 'CR', renderTime: '10min', imageResolution: '2000x2000', colorMode: 'RGB', authorName: '张三', + metadata: JSON.stringify({ + templateId: 'template-from-metadata', + templateFields: { + productModel: 'SPU-001', + customCode: 'C-001', + }, + }), + templateId: 'template-1', zipFileName: 'asset.zip', zipFileSize: BigInt(123456), downloadCount: 2, @@ -128,6 +137,11 @@ describe('admin materials export', () => { expect(data.materials[0].zipFileSize).toBe('123456') expect(data.materials[0].createdAt).toBe('2026-05-25T01:02:03.000Z') expect(data.materials[0].updatedAt).toBe('2026-05-25T02:03:04.000Z') + expect(data.materials[0].templateId).toBe('template-1') + expect(data.materials[0].templateFields).toEqual({ + productModel: 'SPU-001', + customCode: 'C-001', + }) }) it('普通管理员只能导出自己和管理部门范围内的素材数据', async () => { diff --git a/app/api/admin/materials/export/route.ts b/app/api/admin/materials/export/route.ts index 45c74aa..1a58d92 100644 --- a/app/api/admin/materials/export/route.ts +++ b/app/api/admin/materials/export/route.ts @@ -55,11 +55,14 @@ export async function GET(req: NextRequest) { sceneName: true, platformName: true, materialType: true, + materialKind: true, renderer: true, renderTime: true, imageResolution: true, colorMode: true, authorName: true, + metadata: true, + templateId: true, zipFileName: true, zipFileSize: true, downloadCount: true, @@ -78,12 +81,34 @@ export async function GET(req: NextRequest) { }), ]) - const materials = rawMaterials.map((material) => ({ - ...material, - zipFileSize: material.zipFileSize.toString(), - createdAt: material.createdAt.toISOString(), - updatedAt: material.updatedAt.toISOString(), - })) + const materials = rawMaterials.map((material) => { + let parsedMetadata: Record | null = null + if (material.metadata) { + try { + const parsed = JSON.parse(material.metadata) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + parsedMetadata = parsed as Record + } + } catch { + parsedMetadata = null + } + } + + const templateFields = + parsedMetadata?.templateFields && typeof parsedMetadata.templateFields === 'object' && !Array.isArray(parsedMetadata.templateFields) + ? parsedMetadata.templateFields + : {} + + return { + ...material, + metadata: material.metadata, + templateId: material.templateId ?? (typeof parsedMetadata?.templateId === 'string' ? parsedMetadata.templateId : null), + templateFields, + zipFileSize: material.zipFileSize.toString(), + createdAt: material.createdAt.toISOString(), + updatedAt: material.updatedAt.toISOString(), + } + }) return NextResponse.json({ materials, diff --git a/app/api/admin/migrate-material-fields/route.ts b/app/api/admin/migrate-material-fields/route.ts index 1aeea7a..76e522c 100644 --- a/app/api/admin/migrate-material-fields/route.ts +++ b/app/api/admin/migrate-material-fields/route.ts @@ -56,7 +56,7 @@ export async function POST(req: NextRequest) { SELECT 1 FROM pg_constraint WHERE conname = 'materials_templateId_fkey' ) THEN - ALTER TABLE "materials" ADD CONSTRAINT "materials_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "upload_templates"("id") ON DELETE SET NULL ON UPDATE CASCADE; + ALTER TABLE "materials" ADD CONSTRAINT "materials_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "UploadTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE; END IF; END $$; ` 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 132503f..1fe899d 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,6 +25,7 @@ jest.mock('@/lib/prisma', () => ({ findUnique: jest.fn(), }, material: { + count: jest.fn(), findMany: jest.fn(), updateMany: jest.fn(), }, @@ -95,6 +96,7 @@ describe('模板素材迁移 API 测试', () => { }) canUseCustomTemplates.mockResolvedValue(true) isCurrentSuperAdmin.mockResolvedValue(true) + prisma.material.count.mockResolvedValue(undefined) prisma.material.findMany.mockResolvedValue([]) prisma.material.updateMany.mockResolvedValue({ count: 1 }) prisma.$transaction.mockImplementation(async (callback: any) => { @@ -996,7 +998,7 @@ describe('模板素材迁移 API 测试', () => { expect(data.error).toBe('无权限') }) - test('应该处理大量素材迁移', async () => { + test('默认不应直接执行超过单次安全数量的大批量迁移', async () => { const materialCount = 300 const sourceTemplate = createTemplate({ id: 'template-1', @@ -1013,8 +1015,7 @@ describe('模板素材迁移 API 测试', () => { prisma.uploadTemplate.findUnique .mockResolvedValueOnce(sourceTemplate) .mockResolvedValueOnce(targetTemplate) - - prisma.material.findMany.mockResolvedValue(createMaterials(materialCount)) + prisma.material.count.mockResolvedValue(materialCount) const request = new NextRequest( 'http://localhost/api/admin/upload-templates/template-1/migrate', @@ -1031,10 +1032,205 @@ describe('模板素材迁移 API 测试', () => { }) const data = await response.json() + expect(response.status).toBe(409) + expect(data.errorCode).toBe('MIGRATION_CONFIRM_REQUIRED') + expect(data.details.totalMaterialCount).toBe(materialCount) + expect(data.details.safeLimit).toBe(200) + expect(data.details.migrationLimit).toBe(200) + expect(data.details.maxBatchLimit).toBe(1000) + expect(prisma.material.findMany).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(prisma.material.updateMany).not.toHaveBeenCalled() + }) + + test('提高单次上限但未确认时仍应先要求预览和备份确认', async () => { + const materialCount = 300 + const sourceTemplate = createTemplate({ + id: 'template-1', + name: '大量素材模板', + _count: { materials: materialCount }, + }) + + const targetTemplate = createTemplate({ + id: 'template-2', + name: '目标模板', + materialType: 'PSD', + }) + + prisma.uploadTemplate.findUnique + .mockResolvedValueOnce(sourceTemplate) + .mockResolvedValueOnce(targetTemplate) + prisma.material.count.mockResolvedValue(materialCount) + + const request = new NextRequest( + 'http://localhost/api/admin/upload-templates/template-1/migrate', + { + method: 'POST', + body: JSON.stringify({ + targetTemplateId: 'template-2', + migrationLimit: 300, + }), + } + ) + + const response = await migrateTemplate(request, { + params: { id: 'template-1' }, + }) + const data = await response.json() + + expect(response.status).toBe(409) + expect(data.errorCode).toBe('MIGRATION_CONFIRM_REQUIRED') + expect(data.details.safeLimit).toBe(200) + expect(data.details.migrationLimit).toBe(300) + expect(prisma.material.findMany).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(prisma.material.updateMany).not.toHaveBeenCalled() + }) + + test('大批量迁移必须显式确认并提高单次上限后才执行', async () => { + const materialCount = 300 + const sourceTemplate = createTemplate({ + id: 'template-1', + name: '大量素材模板', + _count: { materials: materialCount }, + }) + + const targetTemplate = createTemplate({ + id: 'template-2', + name: '目标模板', + materialType: 'PSD', + }) + + prisma.uploadTemplate.findUnique + .mockResolvedValueOnce(sourceTemplate) + .mockResolvedValueOnce(targetTemplate) + prisma.material.count.mockResolvedValue(materialCount) + + prisma.material.findMany.mockResolvedValue(createMaterials(materialCount)) + + const request = new NextRequest( + 'http://localhost/api/admin/upload-templates/template-1/migrate', + { + method: 'POST', + body: JSON.stringify({ + targetTemplateId: 'template-2', + migrationLimit: 300, + forceLargeMigration: true, + }), + } + ) + + const response = await migrateTemplate(request, { + params: { id: 'template-1' }, + }) + const data = await response.json() + expect(response.status).toBe(200) expect(data.migratedCount).toBe(materialCount) + expect(data.totalMaterialCount).toBe(materialCount) expect(prisma.$transaction).toHaveBeenCalledTimes(1) expect(prisma.material.updateMany).toHaveBeenCalledTimes(materialCount) }) + + test('超过单批上限时应先迁移一批并返回剩余数量', async () => { + const materialCount = 1200 + const batchCount = 1000 + const sourceTemplate = createTemplate({ + id: 'template-1', + name: '超大素材模板', + _count: { materials: materialCount }, + }) + + const targetTemplate = createTemplate({ + id: 'template-2', + name: '目标模板', + materialType: 'PSD', + }) + + prisma.uploadTemplate.findUnique + .mockResolvedValueOnce(sourceTemplate) + .mockResolvedValueOnce(targetTemplate) + prisma.material.count.mockResolvedValue(materialCount) + prisma.material.findMany.mockResolvedValue(createMaterials(batchCount)) + + const request = new NextRequest( + 'http://localhost/api/admin/upload-templates/template-1/migrate', + { + method: 'POST', + body: JSON.stringify({ + targetTemplateId: 'template-2', + migrationLimit: batchCount, + forceLargeMigration: true, + }), + } + ) + + const response = await migrateTemplate(request, { + params: { id: 'template-1' }, + }) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.migratedCount).toBe(batchCount) + expect(data.totalMaterialCount).toBe(materialCount) + expect(data.remainingMaterialCount).toBe(200) + expect(data.batchLimited).toBe(true) + expect(prisma.material.findMany).toHaveBeenCalledWith(expect.objectContaining({ + take: batchCount, + })) + expect(prisma.$transaction).toHaveBeenCalledTimes(1) + expect(prisma.material.updateMany).toHaveBeenCalledTimes(batchCount) + }) + + test('大批量预览只取安全数量且不写库', async () => { + const materialCount = 300 + const previewCount = 50 + const sourceTemplate = createTemplate({ + id: 'template-1', + name: '大量素材模板', + _count: { materials: materialCount }, + }) + + const targetTemplate = createTemplate({ + id: 'template-2', + name: '目标模板', + materialType: 'PSD', + }) + + prisma.uploadTemplate.findUnique + .mockResolvedValueOnce(sourceTemplate) + .mockResolvedValueOnce(targetTemplate) + prisma.material.count.mockResolvedValue(materialCount) + prisma.material.findMany.mockResolvedValue(createMaterials(previewCount)) + + const request = new NextRequest( + 'http://localhost/api/admin/upload-templates/template-1/migrate', + { + method: 'POST', + body: JSON.stringify({ + targetTemplateId: 'template-2', + previewOnly: true, + migrationLimit: previewCount, + }), + } + ) + + const response = await migrateTemplate(request, { + params: { id: 'template-1' }, + }) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.previewOnly).toBe(true) + expect(data.migratableCount).toBe(previewCount) + expect(data.migrationPreview.totalMaterialCount).toBe(materialCount) + expect(data.migrationPreview.previewLimited).toBe(true) + expect(data.migrationPreview.previewLimit).toBe(previewCount) + expect(prisma.material.findMany).toHaveBeenCalledWith(expect.objectContaining({ + take: previewCount, + })) + expect(prisma.$transaction).not.toHaveBeenCalled() + expect(prisma.material.updateMany).not.toHaveBeenCalled() + }) }) }) diff --git a/app/api/admin/upload-templates/[id]/migrate/route.ts b/app/api/admin/upload-templates/[id]/migrate/route.ts index 7f8931f..0b2fb30 100644 --- a/app/api/admin/upload-templates/[id]/migrate/route.ts +++ b/app/api/admin/upload-templates/[id]/migrate/route.ts @@ -47,6 +47,9 @@ type MigrationPreviewFieldImpact = MigrationPreviewFieldSummary & { affectedCount: number } +const DEFAULT_MIGRATION_SAFE_LIMIT = 200 +const MAX_MIGRATION_FORCE_LIMIT = 1000 + const CLEARABLE_NON_TARGET_FIELD_DEFAULTS: Record = { brandName: null, seriesName: null, @@ -288,6 +291,19 @@ function normalizeOptionalString(value: unknown): string | null { return String(value) } +function parsePositiveInteger(value: unknown): number | null { + if (value === undefined || value === null || value === '') { + return null + } + + const parsed = Number.parseInt(String(value), 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + return null + } + + return parsed +} + async function ensureAdmin() { const session = await getServerSession(authOptions) if (!session || (session.user as any)?.role !== 'ADMIN') { @@ -305,6 +321,8 @@ async function ensureAdmin() { * - targetTemplateId: 目标模板ID * - updateMaterialType: 是否同时更新素材的 materialType 字段(可选,默认 true) * - previewOnly: 是否只返回迁移预览、不实际写库(可选,默认 false) + * - forceLargeMigration: 超过安全数量时是否确认执行(可选,默认 false) + * - migrationLimit: 本批处理的最大素材数(可选,默认 200,上限 1000) */ export async function POST( req: NextRequest, @@ -328,7 +346,16 @@ export async function POST( const sourceTemplateId = params.id const body = await req.json() - const { targetTemplateId, updateMaterialType = true, previewOnly = false } = body + const { + targetTemplateId, + updateMaterialType = true, + previewOnly = false, + forceLargeMigration = false, + } = body + const migrationLimit = Math.min( + parsePositiveInteger(body?.migrationLimit) ?? DEFAULT_MIGRATION_SAFE_LIMIT, + MAX_MIGRATION_FORCE_LIMIT + ) if (!targetTemplateId) { return NextResponse.json({ @@ -399,7 +426,15 @@ export async function POST( }, { status: 404 }) } - const totalMaterialCount = sourceTemplate._count.materials + const activeMaterialCount = await prisma.material.count({ + where: { + templateId: sourceTemplateId, + deletedAt: null, + }, + }) + const totalMaterialCount = Number.isFinite(activeMaterialCount) + ? activeMaterialCount + : sourceTemplate._count.materials if (totalMaterialCount === 0) { return NextResponse.json({ @@ -409,6 +444,27 @@ export async function POST( }) } + if ( + !previewOnly && + totalMaterialCount > DEFAULT_MIGRATION_SAFE_LIMIT && + !forceLargeMigration + ) { + return NextResponse.json({ + error: `该模板当前有 ${totalMaterialCount} 个素材,超过默认安全迁移数量 ${DEFAULT_MIGRATION_SAFE_LIMIT}。请先预览迁移影响,确认备份后再执行。`, + errorCode: 'MIGRATION_CONFIRM_REQUIRED', + details: { + totalMaterialCount, + safeLimit: DEFAULT_MIGRATION_SAFE_LIMIT, + migrationLimit, + maxBatchLimit: MAX_MIGRATION_FORCE_LIMIT, + sourceTemplateId, + targetTemplateId, + nextStep: '使用 previewOnly=true 查看迁移影响;确认备份后传入 forceLargeMigration=true,系统会按批迁移直到完成。', + }, + }, { status: 409 }) + } + + const batchLimited = previewOnly || (forceLargeMigration && totalMaterialCount > migrationLimit) const materials = await prisma.material.findMany({ where: { templateId: sourceTemplateId, @@ -439,6 +495,11 @@ export async function POST( // @ts-ignore audioScene: true, }, + orderBy: [ + { createdAt: 'asc' }, + { id: 'asc' }, + ], + take: batchLimited ? migrationLimit : undefined, }) const materialCount = materials.length @@ -457,6 +518,10 @@ export async function POST( targetTemplateId, targetTemplateName: targetTemplate.name, materialCount, + totalMaterialCount, + migrationLimit, + previewOnly, + forceLargeMigration, updateMaterialType, }) @@ -625,7 +690,10 @@ export async function POST( } const migrationPreview = { + totalMaterialCount, totalMaterials: preparedUpdates.length, + previewLimited: previewOnly && totalMaterialCount > preparedUpdates.length, + previewLimit: previewOnly ? migrationLimit : undefined, targetCurrentFields: (targetTemplate.fields as TemplateFieldSummary[]).map((field) => ({ key: field.key, label: field.label || field.key, @@ -691,7 +759,13 @@ export async function POST( return NextResponse.json({ success: true, migratedCount: preparedUpdates.length, - message: `成功将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}"`, + totalMaterialCount, + remainingMaterialCount: Math.max(totalMaterialCount - preparedUpdates.length, 0), + batchLimited, + message: + totalMaterialCount > preparedUpdates.length + ? `已将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}",剩余 ${totalMaterialCount - preparedUpdates.length} 个将继续分批处理` + : `成功将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}"`, sourceTemplate: { id: sourceTemplate.id, name: sourceTemplate.name, 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 8e58130..c471790 100644 --- a/app/api/admin/upload-templates/__tests__/template-crud.test.ts +++ b/app/api/admin/upload-templates/__tests__/template-crud.test.ts @@ -372,6 +372,79 @@ describe('模板CRUD API测试', () => { expect(data.details.duplicateKeys).toContain('field1') }) + test('应该拒绝不符合规则的字段key', async () => { + const request = new NextRequest('http://localhost/api/admin/upload-templates', { + method: 'POST', + body: JSON.stringify({ + name: '测试模板', + materialType: '3DMAX', + materialKind: 'IMAGE', + fields: [ + { key: '1bad-key', label: '坏字段', type: 'text' }, + ], + }), + }) + + const response = await createTemplate(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.errorCode).toBe('VALIDATION_ERROR') + expect(data.details.fieldKey).toBe('1bad-key') + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + test('应该拒绝后台不支持的字段类型', async () => { + const request = new NextRequest('http://localhost/api/admin/upload-templates', { + method: 'POST', + body: JSON.stringify({ + name: '测试模板', + materialType: '3DMAX', + materialKind: 'IMAGE', + fields: [ + { key: 'fileField', label: '文件字段', type: 'file' }, + ], + }), + }) + + const response = await createTemplate(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.errorCode).toBe('VALIDATION_ERROR') + expect(data.details.fieldType).toBe('file') + expect(data.details.allowedTypes).not.toContain('file') + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + test('应该拒绝和字段类型不匹配的验证规则', async () => { + const request = new NextRequest('http://localhost/api/admin/upload-templates', { + method: 'POST', + body: JSON.stringify({ + name: '测试模板', + materialType: '3DMAX', + materialKind: 'IMAGE', + fields: [ + { + key: 'title', + label: '标题', + type: 'text', + validations: { min: 1 }, + }, + ], + }), + }) + + const response = await createTemplate(request) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data.errorCode).toBe('VALIDATION_ERROR') + expect(data.details.fieldKey).toBe('title') + expect(data.error).toContain('不支持验证规则') + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + test('应该拒绝必填单选字段缺少选项', async () => { const request = new NextRequest('http://localhost/api/admin/upload-templates', { method: 'POST', diff --git a/app/api/materials/__tests__/template-fields-routes.test.ts b/app/api/materials/__tests__/template-fields-routes.test.ts index 928d8ef..bd60457 100644 --- a/app/api/materials/__tests__/template-fields-routes.test.ts +++ b/app/api/materials/__tests__/template-fields-routes.test.ts @@ -19,6 +19,7 @@ jest.mock('@/lib/prisma', () => ({ }, material: { findUnique: jest.fn(), + findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), }, @@ -210,6 +211,8 @@ describe('素材模板字段链路测试', () => { id, role: isAdmin ? 'ADMIN' : 'USER', isSuperAdmin: false, + isActive: true, + canDownload: true, department: isAdmin ? '设计部' : '运营部', managedDepartment: isAdmin ? '设计部' : null, username: '上传者A', @@ -222,6 +225,7 @@ describe('素材模板字段链路测试', () => { ]) prisma.material.findUnique.mockResolvedValue(null) + prisma.material.findFirst.mockResolvedValue(null) prisma.material.create.mockImplementation(async ({ data }: any) => ({ id: data.id, @@ -953,11 +957,11 @@ describe('素材模板字段链路测试', () => { expect(response.status).toBe(401) expect(data.error).toBe('请先登录') - expect(prisma.material.findUnique).not.toHaveBeenCalled() + expect(prisma.material.findFirst).not.toHaveBeenCalled() }) test('读取素材详情时应回传 metadata 中的模板字段', async () => { - prisma.material.findUnique.mockResolvedValueOnce({ + prisma.material.findFirst.mockResolvedValueOnce({ id: 'material-1', userId: 'admin-1', productModel: '已保存产品', @@ -1018,6 +1022,55 @@ describe('素材模板字段链路测试', () => { legacyKey: 'legacy-value', }) expect(data.zipFileSize).toBe('16') + expect(prisma.material.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'material-1', + deletedAt: null, + }, + }) + ) + }) + + test('禁用用户即使带着旧会话也不能读取素材详情', async () => { + prisma.user.findUnique.mockResolvedValueOnce({ + id: 'disabled-user', + role: 'USER', + isSuperAdmin: false, + isActive: false, + canDownload: true, + department: '设计部', + managedDepartment: null, + }) + + const response = await getMaterial(new NextRequest('http://localhost'), { + params: { id: 'material-1' }, + }) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data.error).toContain('账号不可用') + expect(prisma.material.findFirst).not.toHaveBeenCalled() + }) + + test('软删除素材详情不应通过旧 ID 入口返回', async () => { + prisma.material.findFirst.mockResolvedValueOnce(null) + + const response = await getMaterial(new NextRequest('http://localhost'), { + params: { id: 'deleted-material' }, + }) + const data = await response.json() + + expect(response.status).toBe(404) + expect(data.error).toBe('素材不存在') + expect(prisma.material.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'deleted-material', + deletedAt: null, + }, + }) + ) }) }) @@ -1073,7 +1126,7 @@ describe('素材模板字段链路测试', () => { getServerSession.mockResolvedValueOnce({ user: { id: 'owner-1', role: 'USER' }, }) - prisma.material.findUnique.mockResolvedValueOnce({ + prisma.material.findFirst.mockResolvedValueOnce({ ...existingMaterialForJsonUpdate, userId: 'owner-1', }) @@ -1102,7 +1155,7 @@ describe('素材模板字段链路测试', () => { }) test('旧 JSON 更新入口允许管理员编辑管理部门范围内的素材', async () => { - prisma.material.findUnique.mockResolvedValueOnce({ + prisma.material.findFirst.mockResolvedValueOnce({ ...existingMaterialForJsonUpdate, userId: 'owner-1', user: { @@ -1142,10 +1195,12 @@ describe('素材模板字段链路测试', () => { id: 'admin-no-scope', role: 'ADMIN', isSuperAdmin: false, + isActive: true, + canDownload: true, department: '设计部', managedDepartment: null, }) - prisma.material.findUnique.mockResolvedValueOnce({ + prisma.material.findFirst.mockResolvedValueOnce({ ...existingMaterialForJsonUpdate, userId: 'owner-1', user: { @@ -1171,7 +1226,7 @@ describe('素材模板字段链路测试', () => { }) test('旧 JSON 更新入口拒绝普通管理员编辑管理范围外素材', async () => { - prisma.material.findUnique.mockResolvedValueOnce({ + prisma.material.findFirst.mockResolvedValueOnce({ ...existingMaterialForJsonUpdate, userId: 'owner-1', user: { @@ -1201,7 +1256,7 @@ describe('素材模板字段链路测试', () => { getServerSession.mockResolvedValueOnce({ user: { id: 'user-2', role: 'USER' }, }) - prisma.material.findUnique.mockResolvedValueOnce({ + prisma.material.findFirst.mockResolvedValueOnce({ ...existingMaterialForJsonUpdate, userId: 'owner-1', }) @@ -1225,7 +1280,7 @@ describe('素材模板字段链路测试', () => { test('旧 JSON 更新入口也应执行条件必填校验,不能绕过模板规则', async () => { getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition()) - prisma.material.findUnique.mockResolvedValueOnce(existingMaterialForJsonUpdate) + prisma.material.findFirst.mockResolvedValueOnce(existingMaterialForJsonUpdate) const response = await updateMaterialById( buildJsonUpdateRequest({ @@ -1244,7 +1299,7 @@ describe('素材模板字段链路测试', () => { test('旧 JSON 更新入口应过滤条件不满足的隐藏字段并保留历史字段', async () => { getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition()) - prisma.material.findUnique.mockResolvedValueOnce(existingMaterialForJsonUpdate) + prisma.material.findFirst.mockResolvedValueOnce(existingMaterialForJsonUpdate) const response = await updateMaterialById( buildJsonUpdateRequest({ @@ -1278,6 +1333,34 @@ describe('素材模板字段链路测试', () => { expect(response.status).toBe(400) expect(data.errorCode).toBe('INVALID_TEMPLATE_FIELDS') expect(prisma.material.findUnique).not.toHaveBeenCalled() + expect(prisma.material.findFirst).not.toHaveBeenCalled() + expect(prisma.material.update).not.toHaveBeenCalled() + }) + + test('旧 JSON 更新入口不应修改回收站里的素材', async () => { + prisma.material.findFirst.mockResolvedValueOnce(null) + + const response = await updateMaterialById( + buildJsonUpdateRequest({ + templateFields: { + productModel: '更新后的产品', + customCode: 'DELETED-NOPE', + }, + }), + { params: { id: 'deleted-material' } } + ) + const data = await response.json() + + expect(response.status).toBe(404) + expect(data.error).toBe('素材不存在') + expect(prisma.material.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'deleted-material', + deletedAt: null, + }, + }) + ) expect(prisma.material.update).not.toHaveBeenCalled() }) }) diff --git a/lib/__tests__/template-field-validation.test.ts b/lib/__tests__/template-field-validation.test.ts index 692b37d..199eb51 100644 --- a/lib/__tests__/template-field-validation.test.ts +++ b/lib/__tests__/template-field-validation.test.ts @@ -157,4 +157,46 @@ describe('template-field-validation', () => { expect(result.valid).toBe(true) }) + + test('应按模板字段校验规则拦截不合规内容', async () => { + getTemplateWithFields.mockResolvedValue({ + id: 'template-1', + name: '测试模板', + fields: [ + { + key: 'customCode', + label: '自定义编码', + type: 'text', + required: false, + validations: JSON.stringify({ + maxLength: 6, + pattern: '^CODE-', + }), + }, + ], + }) + + const result = await validateSubmittedTemplateFields({ + templateId: 'template-1', + templateFields: { + customCode: 'BAD-CODE-001', + }, + allowUnknownKeys: false, + }) + + expect(result.valid).toBe(false) + if (!result.valid) { + expect(result.errorCode).toBe('TEMPLATE_FIELD_VALIDATION_FAILED') + expect(result.details?.invalidFields).toEqual([ + expect.objectContaining({ + key: 'customCode', + label: '自定义编码', + errors: expect.arrayContaining([ + '自定义编码长度不能超过6个字符', + '自定义编码格式不正确', + ]), + }), + ]) + } + }) }) diff --git a/lib/template-field-validation.ts b/lib/template-field-validation.ts index 2c0211a..72b1c38 100644 --- a/lib/template-field-validation.ts +++ b/lib/template-field-validation.ts @@ -1,5 +1,6 @@ import { getTemplateWithFields } from './template-validator' import { filterVisibleFieldValues, getVisibleTemplateFields, hasFieldValue } from './field-conditions' +import { parseValidationRules, validateFieldValue } from './field-validation' type TemplateFieldLike = { key: string @@ -7,6 +8,7 @@ type TemplateFieldLike = { type?: string | null required?: boolean | null conditions?: string | null + validations?: string | null } export type TemplateFieldValidationResult = @@ -90,6 +92,36 @@ export async function validateSubmittedTemplateFields(args: { } } + const invalidFields = visibleFields + .map((field) => { + const result = validateFieldValue( + visibleTemplateFields[field.key], + field.type || 'text', + parseValidationRules(field.validations), + field.label || field.key + ) + + return { + key: field.key, + label: field.label || field.key, + type: field.type || 'text', + errors: result.errors, + } + }) + .filter((field) => field.errors.length > 0) + + if (invalidFields.length > 0) { + return { + valid: false, + error: `模板字段填写不符合规则:${invalidFields.map((field) => `${field.label}:${field.errors.join('、')}`).join(';')}`, + errorCode: 'TEMPLATE_FIELD_VALIDATION_FAILED', + details: { + templateId, + invalidFields, + }, + } + } + return { valid: true, templateName: typeof template.name === 'string' ? template.name : undefined, diff --git a/lib/upload-template-save-validation.ts b/lib/upload-template-save-validation.ts index 99c7310..ba3fbaa 100644 --- a/lib/upload-template-save-validation.ts +++ b/lib/upload-template-save-validation.ts @@ -4,8 +4,24 @@ import { parseFieldConditions, type FieldConditions, } from './field-conditions' +import { + parseValidationRules, + validateValidationRules, + getSupportedValidations, + type ValidationRule, +} from './field-validation' const ALLOWED_TEMPLATE_STATUSES = new Set(['draft', 'active', 'archived']) +const ALLOWED_FIELD_TYPES = new Set([ + 'text', + 'textarea', + 'number', + 'select', + 'checkbox', + 'radio', + 'date', + 'uppercase', +]) const OPTION_FIELD_TYPES = new Set(['select', 'radio']) const CONDITION_OPERATORS = new Set(getAllOperators().map((operator) => operator.value)) const CONDITION_OPERATORS_REQUIRING_VALUE = new Set( @@ -100,6 +116,65 @@ function parseNormalizedOptions(value: string | null): string[] { } } +function normalizeValidations( + value: unknown, + fieldType: string, +): { value: string | null; invalid?: string; rules?: ValidationRule } { + if (value === null || value === undefined) { + return { value: null } + } + + if (typeof value === 'string' && value.trim() === '') { + return { value: null } + } + + let raw: unknown = value + if (typeof value === 'string') { + try { + raw = JSON.parse(value) + } catch { + return { value: null, invalid: '验证规则必须是合法 JSON 对象' } + } + } + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { value: null, invalid: '验证规则必须是对象' } + } + + const parsedRules = parseValidationRules(JSON.stringify(raw)) + if (!parsedRules) { + return { value: null, invalid: '验证规则必须是对象' } + } + + const validation = validateValidationRules(parsedRules) + if (!validation.valid) { + return { value: null, invalid: validation.error || '验证规则配置无效' } + } + + const supportedValidations = new Set(getSupportedValidations(fieldType)) + const unsupportedRules = [ + ['minLength', parsedRules.minLength], + ['maxLength', parsedRules.maxLength], + ['pattern', parsedRules.pattern], + ['min', parsedRules.min], + ['max', parsedRules.max], + ] + .filter(([ruleKey, ruleValue]) => ruleValue !== undefined && !supportedValidations.has(String(ruleKey))) + .map(([ruleKey]) => String(ruleKey)) + + if (unsupportedRules.length > 0) { + return { + value: null, + invalid: `字段类型 ${fieldType} 不支持验证规则:${unsupportedRules.join(', ')}`, + } + } + + return { + value: JSON.stringify(parsedRules), + rules: parsedRules, + } +} + function normalizeConditions( value: unknown, ): { value: string | null; invalid: boolean; conditions?: FieldConditions } { @@ -186,13 +261,36 @@ export function validateAndFormatUploadTemplateFields( } const label = String(field.label || '').trim() || `字段${index + 1}` - const type = String(field.type || 'text') + const type = String(field.type || 'text').trim() || 'text' const addToCategory = !!field.addToCategory const normalizedOptions = normalizeStaticOptions(field.optionsStatic) const optionsStatic = normalizedOptions.value + const normalizedValidations = normalizeValidations(field.validations, type) const normalizedConditions = normalizeConditions(field.conditions) const conditions = normalizedConditions.value + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) { + return { + valid: false, + error: `字段“${label}”的 key 必须以字母开头,并且只能包含字母、数字和下划线`, + field: 'fields', + details: { fieldKey: key }, + } + } + + if (!ALLOWED_FIELD_TYPES.has(type)) { + return { + valid: false, + error: `字段“${label}”的类型不支持:${type}`, + field: 'fields', + details: { + fieldKey: key, + fieldType: type, + allowedTypes: Array.from(ALLOWED_FIELD_TYPES), + }, + } + } + if (OPTION_FIELD_TYPES.has(type)) { if (normalizedOptions.invalid) { return { @@ -214,6 +312,15 @@ export function validateAndFormatUploadTemplateFields( } } + if (normalizedValidations.invalid) { + return { + valid: false, + error: `字段“${label}”的验证规则不正确:${normalizedValidations.invalid}`, + field: 'fields', + details: { fieldKey: key }, + } + } + if (normalizedConditions.invalid) { return { valid: false, @@ -231,7 +338,7 @@ export function validateAndFormatUploadTemplateFields( required: !!field.required, orderIndex: field.orderIndex ?? index, layoutWidth: field.layoutWidth || 'full', - validations: field.validations === null || field.validations === undefined ? null : String(field.validations), + validations: normalizedValidations.value, optionsSource: field.optionsSource === null || field.optionsSource === undefined ? null : String(field.optionsSource), optionsStatic, allowedExtensions: field.allowedExtensions === null || field.allowedExtensions === undefined ? null : String(field.allowedExtensions), diff --git a/scripts/verify-template-data.ts b/scripts/verify-template-data.ts index 83c3d59..288eec2 100644 --- a/scripts/verify-template-data.ts +++ b/scripts/verify-template-data.ts @@ -57,7 +57,7 @@ async function verifyTemplateData(): Promise { }) // 1.3 检查materialKind字段 - const validKinds = ['IMAGE', 'VIDEO', 'OTHER'] + const validKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER'] const templatesWithInvalidKind = await prisma.uploadTemplate.count({ where: { materialKind: {