增强自定义模板校验与迁移稳定性

This commit is contained in:
WuYongJie 2026-06-01 11:24:54 +08:00
parent 3d7fb9e081
commit 2756925698
12 changed files with 725 additions and 37 deletions

View File

@ -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 }>(
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<MigrationBatchResult>(
`/api/admin/upload-templates/${migrationSourceTemplate.id}/migrate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetTemplateId: migrationTargetTemplateId }),
body: JSON.stringify({
targetTemplateId: migrationTargetTemplateId,
migrationLimit: MIGRATION_BATCH_LIMIT,
forceLargeMigration: true,
}),
}
)
showToast(migrateResult.message, 'success')
migratedTotal += migrateResult.migratedCount
remaining = migrateResult.remainingMaterialCount ?? Math.max(remaining - migrateResult.migratedCount, 0)
lastMessage = migrateResult.message
if (migrateResult.migratedCount === 0 && remaining > 0) {
throw new Error('迁移没有继续推进,请刷新后重试。')
}
}
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({
<div className="mt-3 rounded-lg border border-slate-200 dark:border-slate-700 p-4 space-y-4">
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400">可迁移素材</p>
<p className="text-xs text-slate-500 dark:text-slate-400">素材总数</p>
<p className="mt-1 font-semibold text-slate-900 dark:text-white">
{migrationPreview.migratableCount}
{migrationPreview.migrationPreview.totalMaterialCount}
</p>
</div>
<div>
@ -2169,6 +2205,12 @@ export default function TemplateManager({
</div>
</div>
{migrationPreview.migrationPreview.previewLimited && (
<p className="text-xs text-slate-500 dark:text-slate-400">
当前只预览前 {migrationPreview.migrationPreview.totalMaterials} 个素材;确认后会按每批 {MIGRATION_BATCH_LIMIT} 个自动分批迁移。
</p>
)}
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">
迁移后当前字段

View File

@ -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 () => {

View File

@ -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) => ({
const materials = rawMaterials.map((material) => {
let parsedMetadata: Record<string, unknown> | null = null
if (material.metadata) {
try {
const parsed = JSON.parse(material.metadata)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
parsedMetadata = parsed as Record<string, unknown>
}
} 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,

View File

@ -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 $$;
`

View File

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

View File

@ -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<string, string | null> = {
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,

View File

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

View File

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

View File

@ -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个字符',
'自定义编码格式不正确',
]),
}),
])
}
})
})

View File

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

View File

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

View File

@ -57,7 +57,7 @@ async function verifyTemplateData(): Promise<boolean> {
})
// 1.3 检查materialKind字段
const validKinds = ['IMAGE', 'VIDEO', 'OTHER']
const validKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER']
const templatesWithInvalidKind = await prisma.uploadTemplate.count({
where: {
materialKind: {