增强自定义模板校验与迁移稳定性
This commit is contained in:
parent
3d7fb9e081
commit
2756925698
@ -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<MigrationBatchResult>(
|
||||
`/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({
|
||||
<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">
|
||||
迁移后当前字段
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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<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,
|
||||
|
||||
@ -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 $$;
|
||||
`
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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个字符',
|
||||
'自定义编码格式不正确',
|
||||
]),
|
||||
}),
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user