569 lines
17 KiB
TypeScript
569 lines
17 KiB
TypeScript
/**
|
||
* 模板素材迁移 API
|
||
*
|
||
* POST: 将使用指定模板的素材迁移到另一个模板
|
||
*/
|
||
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getServerSession } from 'next-auth'
|
||
import { authOptions } from '@/lib/auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { canUseCustomTemplates } from '@/lib/license'
|
||
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
|
||
import {
|
||
checkMetadataSize,
|
||
compressMetadata,
|
||
parseMetadata,
|
||
prepareMaterialData,
|
||
readAllFieldValues,
|
||
} from '@/lib/field-data-storage'
|
||
import { hasFieldValue } from '@/lib/template-field-validation'
|
||
|
||
type TemplateFieldSummary = {
|
||
key: string
|
||
label: string
|
||
type: string
|
||
required: boolean
|
||
}
|
||
|
||
type TemplateMigrationBackup = {
|
||
sourceTemplateId: string
|
||
sourceTemplateName: string
|
||
targetTemplateId: string
|
||
targetTemplateName: string
|
||
migratedAt: string
|
||
droppedTemplateFields?: Record<string, unknown>
|
||
droppedStandardFields?: Record<string, unknown>
|
||
}
|
||
|
||
const CLEARABLE_NON_TARGET_FIELD_DEFAULTS: Record<string, string | null> = {
|
||
brandName: null,
|
||
seriesName: null,
|
||
skuCode: null,
|
||
materialTexture: null,
|
||
platformName: '',
|
||
renderTime: null,
|
||
imageResolution: null,
|
||
colorMode: null,
|
||
audioDuration: null,
|
||
audioCategory: null,
|
||
audioScene: null,
|
||
}
|
||
|
||
const MIGRATION_BACKUP_STANDARD_FIELDS = [
|
||
'productModel',
|
||
'brandName',
|
||
'seriesName',
|
||
'skuCode',
|
||
'materialTexture',
|
||
'categoryName',
|
||
'sceneName',
|
||
'materialType',
|
||
'materialKind',
|
||
'renderer',
|
||
'renderTime',
|
||
'imageResolution',
|
||
'colorMode',
|
||
'authorName',
|
||
'platformName',
|
||
'audioDuration',
|
||
'audioCategory',
|
||
'audioScene',
|
||
] as const
|
||
|
||
function parseMetadataObject(metadata: string | null | undefined): Record<string, unknown> {
|
||
if (!metadata) {
|
||
return {}
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(metadata)
|
||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||
return parsed as Record<string, unknown>
|
||
}
|
||
} catch {
|
||
// 保持迁移可继续,坏 metadata 不再扩大影响面。
|
||
}
|
||
|
||
return {}
|
||
}
|
||
|
||
function getRecordValue(value: unknown): Record<string, unknown> {
|
||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||
return value as Record<string, unknown>
|
||
}
|
||
return {}
|
||
}
|
||
|
||
function hasBackupValue(value: unknown): boolean {
|
||
return value !== undefined && value !== null && value !== ''
|
||
}
|
||
|
||
function getExistingMigrationBackups(metadata: Record<string, unknown>): unknown[] {
|
||
return Array.isArray(metadata.templateMigrationBackups)
|
||
? metadata.templateMigrationBackups
|
||
: []
|
||
}
|
||
|
||
function buildMigrationMetadata(params: {
|
||
existingMetadata: string | null
|
||
targetMetadata: string | null
|
||
droppedStandardFields: Record<string, unknown>
|
||
targetFieldKeys: Set<string>
|
||
sourceTemplateId: string
|
||
sourceTemplateName: string
|
||
targetTemplateId: string
|
||
targetTemplateName: string
|
||
migratedAt: string
|
||
}): { metadata: string | null; droppedTemplateFieldCount: number; droppedStandardFieldCount: number } {
|
||
const existingMetadataObject = parseMetadataObject(params.existingMetadata)
|
||
const targetMetadataObject = parseMetadataObject(params.targetMetadata)
|
||
const existingTemplateFields = getRecordValue(existingMetadataObject.templateFields)
|
||
const targetTemplateFields = getRecordValue(targetMetadataObject.templateFields)
|
||
const droppedTemplateFields: Record<string, unknown> = {}
|
||
const droppedStandardFields = Object.fromEntries(
|
||
Object.entries(params.droppedStandardFields).filter(([, value]) => hasBackupValue(value))
|
||
)
|
||
|
||
for (const [key, value] of Object.entries(existingTemplateFields)) {
|
||
if (!params.targetFieldKeys.has(key) && hasBackupValue(value)) {
|
||
droppedTemplateFields[key] = value
|
||
}
|
||
}
|
||
|
||
const nextMetadata: Record<string, unknown> = {
|
||
...existingMetadataObject,
|
||
...targetMetadataObject,
|
||
templateId: params.targetTemplateId,
|
||
}
|
||
|
||
if (Object.keys(targetTemplateFields).length > 0) {
|
||
nextMetadata.templateFields = targetTemplateFields
|
||
} else {
|
||
delete nextMetadata.templateFields
|
||
}
|
||
|
||
const droppedTemplateFieldCount = Object.keys(droppedTemplateFields).length
|
||
const droppedStandardFieldCount = Object.keys(droppedStandardFields).length
|
||
|
||
if (droppedTemplateFieldCount > 0 || droppedStandardFieldCount > 0) {
|
||
const backup: TemplateMigrationBackup = {
|
||
sourceTemplateId: params.sourceTemplateId,
|
||
sourceTemplateName: params.sourceTemplateName,
|
||
targetTemplateId: params.targetTemplateId,
|
||
targetTemplateName: params.targetTemplateName,
|
||
migratedAt: params.migratedAt,
|
||
}
|
||
if (droppedTemplateFieldCount > 0) {
|
||
backup.droppedTemplateFields = droppedTemplateFields
|
||
}
|
||
if (droppedStandardFieldCount > 0) {
|
||
backup.droppedStandardFields = droppedStandardFields
|
||
}
|
||
nextMetadata.templateMigrationBackups = [
|
||
...getExistingMigrationBackups(existingMetadataObject),
|
||
backup,
|
||
]
|
||
}
|
||
|
||
return {
|
||
metadata: JSON.stringify(nextMetadata),
|
||
droppedTemplateFieldCount,
|
||
droppedStandardFieldCount,
|
||
}
|
||
}
|
||
|
||
function getDroppedStandardFields(
|
||
material: Record<string, unknown>,
|
||
targetFieldKeys: Set<string>
|
||
): Record<string, unknown> {
|
||
const droppedFields: Record<string, unknown> = {}
|
||
|
||
for (const key of MIGRATION_BACKUP_STANDARD_FIELDS) {
|
||
const value = material[key]
|
||
if (!targetFieldKeys.has(key) && hasBackupValue(value)) {
|
||
droppedFields[key] = value
|
||
}
|
||
}
|
||
|
||
return droppedFields
|
||
}
|
||
|
||
function clearNonTargetStandardFields(updateData: Record<string, unknown>, targetFieldKeys: Set<string>) {
|
||
for (const [key, emptyValue] of Object.entries(CLEARABLE_NON_TARGET_FIELD_DEFAULTS)) {
|
||
if (!targetFieldKeys.has(key)) {
|
||
updateData[key] = emptyValue
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildMaterialFieldValues(material: Record<string, unknown>) {
|
||
const values = readAllFieldValues(material)
|
||
|
||
;['audioDuration', 'audioCategory', 'audioScene'].forEach((key) => {
|
||
const value = material[key]
|
||
if (value !== undefined && value !== null && value !== '') {
|
||
values[key] = value as any
|
||
}
|
||
})
|
||
|
||
return values
|
||
}
|
||
|
||
function normalizeOptionalString(value: unknown): string | null {
|
||
if (value === undefined || value === null || value === '') {
|
||
return null
|
||
}
|
||
return String(value)
|
||
}
|
||
|
||
async function ensureAdmin() {
|
||
const session = await getServerSession(authOptions)
|
||
if (!session || (session.user as any)?.role !== 'ADMIN') {
|
||
return null
|
||
}
|
||
return session
|
||
}
|
||
|
||
/**
|
||
* POST /api/admin/upload-templates/[id]/migrate
|
||
*
|
||
* 将使用源模板的素材迁移到目标模板
|
||
*
|
||
* Body:
|
||
* - targetTemplateId: 目标模板ID
|
||
* - updateMaterialType: 是否同时更新素材的 materialType 字段(可选,默认 true)
|
||
*/
|
||
export async function POST(
|
||
req: NextRequest,
|
||
{ params }: { params: { id: string } }
|
||
) {
|
||
try {
|
||
const session = await ensureAdmin()
|
||
if (!session) {
|
||
return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||
}
|
||
if (!(await isCurrentSuperAdmin(session))) {
|
||
return NextResponse.json({ error: '仅最高权限账号可迁移模板素材' }, { status: 403 })
|
||
}
|
||
|
||
if (!(await canUseCustomTemplates())) {
|
||
return NextResponse.json({
|
||
error: '自定义模板功能需要标准版或专业版许可证,请升级后使用',
|
||
errorCode: 'LICENSE_REQUIRED',
|
||
}, { status: 403 })
|
||
}
|
||
|
||
const sourceTemplateId = params.id
|
||
const body = await req.json()
|
||
const { targetTemplateId, updateMaterialType = true } = body
|
||
|
||
if (!targetTemplateId) {
|
||
return NextResponse.json({
|
||
error: '请选择目标模板',
|
||
errorCode: 'MISSING_TARGET',
|
||
}, { status: 400 })
|
||
}
|
||
|
||
if (sourceTemplateId === targetTemplateId) {
|
||
return NextResponse.json({
|
||
error: '源模板和目标模板不能相同',
|
||
errorCode: 'SAME_TEMPLATE',
|
||
}, { status: 400 })
|
||
}
|
||
|
||
// 检查源模板是否存在
|
||
const sourceTemplate = await prisma.uploadTemplate.findUnique({
|
||
where: { id: sourceTemplateId },
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
materialType: true,
|
||
materialKind: true,
|
||
fields: {
|
||
select: {
|
||
key: true,
|
||
label: true,
|
||
type: true,
|
||
required: true,
|
||
},
|
||
orderBy: { orderIndex: 'asc' },
|
||
},
|
||
_count: { select: { materials: true } }
|
||
},
|
||
})
|
||
|
||
if (!sourceTemplate) {
|
||
return NextResponse.json({
|
||
error: '源模板不存在',
|
||
errorCode: 'SOURCE_NOT_FOUND',
|
||
}, { status: 404 })
|
||
}
|
||
|
||
// 检查目标模板是否存在
|
||
const targetTemplate = await prisma.uploadTemplate.findUnique({
|
||
where: { id: targetTemplateId },
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
materialType: true,
|
||
materialKind: true,
|
||
fields: {
|
||
select: {
|
||
key: true,
|
||
label: true,
|
||
type: true,
|
||
required: true,
|
||
},
|
||
orderBy: { orderIndex: 'asc' },
|
||
},
|
||
},
|
||
})
|
||
|
||
if (!targetTemplate) {
|
||
return NextResponse.json({
|
||
error: '目标模板不存在',
|
||
errorCode: 'TARGET_NOT_FOUND',
|
||
}, { status: 404 })
|
||
}
|
||
|
||
const materialCount = sourceTemplate._count.materials
|
||
|
||
if (materialCount === 0) {
|
||
return NextResponse.json({
|
||
success: true,
|
||
migratedCount: 0,
|
||
message: '没有需要迁移的素材',
|
||
})
|
||
}
|
||
|
||
console.log('[TemplateMigrate] Starting migration:', {
|
||
sourceTemplateId,
|
||
sourceTemplateName: sourceTemplate.name,
|
||
targetTemplateId,
|
||
targetTemplateName: targetTemplate.name,
|
||
materialCount,
|
||
updateMaterialType,
|
||
})
|
||
|
||
const materials = await prisma.material.findMany({
|
||
where: { templateId: sourceTemplateId },
|
||
select: {
|
||
id: true,
|
||
metadata: true,
|
||
productModel: true,
|
||
brandName: true,
|
||
seriesName: true,
|
||
skuCode: true,
|
||
materialTexture: true,
|
||
categoryName: true,
|
||
sceneName: true,
|
||
platformName: true,
|
||
materialType: true,
|
||
materialKind: true,
|
||
renderer: true,
|
||
renderTime: true,
|
||
imageResolution: true,
|
||
colorMode: true,
|
||
authorName: true,
|
||
// @ts-ignore - 音频字段已在数据库中添加
|
||
audioDuration: true,
|
||
// @ts-ignore
|
||
audioCategory: true,
|
||
// @ts-ignore
|
||
audioScene: true,
|
||
},
|
||
})
|
||
|
||
const incompatibleMaterials: Array<{
|
||
materialId: string
|
||
productModel: string | null
|
||
missingRequiredFields: string[]
|
||
}> = []
|
||
const oversizedMetadataMaterials: Array<{
|
||
materialId: string
|
||
productModel: string | null
|
||
size: number
|
||
maxSize: number
|
||
}> = []
|
||
const targetFieldKeys = new Set(
|
||
(targetTemplate.fields as TemplateFieldSummary[]).map((field) => field.key)
|
||
)
|
||
const migratedAt = new Date().toISOString()
|
||
let materialsWithDroppedFields = 0
|
||
let droppedTemplateFieldCount = 0
|
||
let droppedStandardFieldCount = 0
|
||
|
||
const preparedUpdates = materials.map((material) => {
|
||
const allValues = buildMaterialFieldValues(material as Record<string, unknown>)
|
||
const nextTemplateFieldValues = (targetTemplate.fields as TemplateFieldSummary[]).reduce<Record<string, unknown>>(
|
||
(acc, field) => {
|
||
if (allValues[field.key] !== undefined) {
|
||
acc[field.key] = allValues[field.key]
|
||
}
|
||
return acc
|
||
},
|
||
{}
|
||
)
|
||
|
||
const missingRequiredFields = (targetTemplate.fields as TemplateFieldSummary[])
|
||
.filter((field) => field.required)
|
||
.filter((field) => !hasFieldValue(nextTemplateFieldValues[field.key], field.type))
|
||
.map((field) => field.label || field.key)
|
||
|
||
if (missingRequiredFields.length > 0) {
|
||
incompatibleMaterials.push({
|
||
materialId: material.id,
|
||
productModel: material.productModel,
|
||
missingRequiredFields,
|
||
})
|
||
}
|
||
|
||
const parsedMetadata = parseMetadata(material.metadata)
|
||
const { standardData, metadata: rawMetadata } = prepareMaterialData(
|
||
nextTemplateFieldValues as Record<string, any>,
|
||
targetTemplateId,
|
||
parsedMetadata.previewUrls
|
||
)
|
||
const droppedStandardFields = getDroppedStandardFields(
|
||
material as Record<string, unknown>,
|
||
targetFieldKeys
|
||
)
|
||
const migrationMetadata = buildMigrationMetadata({
|
||
existingMetadata: material.metadata,
|
||
targetMetadata: rawMetadata,
|
||
droppedStandardFields,
|
||
targetFieldKeys,
|
||
sourceTemplateId,
|
||
sourceTemplateName: sourceTemplate.name,
|
||
targetTemplateId,
|
||
targetTemplateName: targetTemplate.name,
|
||
migratedAt,
|
||
})
|
||
const metadata = compressMetadata(migrationMetadata.metadata)
|
||
const metadataSize = checkMetadataSize(metadata)
|
||
|
||
if (migrationMetadata.droppedTemplateFieldCount > 0 || migrationMetadata.droppedStandardFieldCount > 0) {
|
||
materialsWithDroppedFields += 1
|
||
droppedTemplateFieldCount += migrationMetadata.droppedTemplateFieldCount
|
||
droppedStandardFieldCount += migrationMetadata.droppedStandardFieldCount
|
||
}
|
||
|
||
if (!metadataSize.ok) {
|
||
oversizedMetadataMaterials.push({
|
||
materialId: material.id,
|
||
productModel: material.productModel,
|
||
size: metadataSize.size,
|
||
maxSize: metadataSize.maxSize,
|
||
})
|
||
}
|
||
|
||
const updateData: any = {
|
||
templateId: targetTemplateId,
|
||
materialKind: targetTemplate.materialKind,
|
||
metadata,
|
||
...standardData,
|
||
}
|
||
clearNonTargetStandardFields(updateData, targetFieldKeys)
|
||
|
||
if (updateMaterialType && targetTemplate.materialType) {
|
||
updateData.materialType = targetTemplate.materialType
|
||
}
|
||
|
||
if ('audioDuration' in nextTemplateFieldValues) {
|
||
updateData.audioDuration = normalizeOptionalString(nextTemplateFieldValues.audioDuration)
|
||
}
|
||
if ('audioCategory' in nextTemplateFieldValues) {
|
||
updateData.audioCategory = normalizeOptionalString(nextTemplateFieldValues.audioCategory)
|
||
}
|
||
if ('audioScene' in nextTemplateFieldValues) {
|
||
updateData.audioScene = normalizeOptionalString(nextTemplateFieldValues.audioScene)
|
||
}
|
||
|
||
return {
|
||
id: material.id,
|
||
data: updateData,
|
||
}
|
||
})
|
||
|
||
if (incompatibleMaterials.length > 0) {
|
||
return NextResponse.json({
|
||
error: `目标模板“${targetTemplate.name}”存在无法从旧素材自动补齐的必填字段,请先补齐后再迁移。`,
|
||
errorCode: 'TARGET_TEMPLATE_FIELDS_UNRESOLVED',
|
||
details: {
|
||
targetTemplateId: targetTemplate.id,
|
||
targetTemplateName: targetTemplate.name,
|
||
incompatibleCount: incompatibleMaterials.length,
|
||
samples: incompatibleMaterials.slice(0, 10),
|
||
},
|
||
}, { status: 409 })
|
||
}
|
||
|
||
if (oversizedMetadataMaterials.length > 0) {
|
||
return NextResponse.json({
|
||
error: '模板迁移后 metadata 数据过大,请先减少自定义字段内容后再迁移。',
|
||
errorCode: 'METADATA_TOO_LARGE',
|
||
details: {
|
||
oversizedCount: oversizedMetadataMaterials.length,
|
||
samples: oversizedMetadataMaterials.slice(0, 10),
|
||
},
|
||
}, { status: 409 })
|
||
}
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
for (const update of preparedUpdates) {
|
||
const result = await tx.material.updateMany({
|
||
where: {
|
||
id: update.id,
|
||
templateId: sourceTemplateId,
|
||
},
|
||
data: update.data,
|
||
})
|
||
|
||
if (result.count !== 1) {
|
||
throw new Error(`素材 ${update.id} 已不属于源模板,迁移已整体回滚`)
|
||
}
|
||
}
|
||
})
|
||
|
||
console.log('[TemplateMigrate] Migration completed:', {
|
||
sourceTemplateId,
|
||
targetTemplateId,
|
||
migratedCount: preparedUpdates.length,
|
||
materialsWithDroppedFields,
|
||
droppedTemplateFieldCount,
|
||
droppedStandardFieldCount,
|
||
})
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
migratedCount: preparedUpdates.length,
|
||
message: `成功将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}"`,
|
||
sourceTemplate: {
|
||
id: sourceTemplate.id,
|
||
name: sourceTemplate.name,
|
||
},
|
||
targetTemplate: {
|
||
id: targetTemplate.id,
|
||
name: targetTemplate.name,
|
||
},
|
||
migrationBackup: {
|
||
materialsWithDroppedFields,
|
||
droppedTemplateFieldCount,
|
||
droppedStandardFieldCount,
|
||
},
|
||
})
|
||
} catch (error: any) {
|
||
console.error('[TemplateMigrate] Migration error:', {
|
||
sourceTemplateId: params.id,
|
||
error: error?.message,
|
||
stack: error?.stack,
|
||
})
|
||
|
||
return NextResponse.json({
|
||
error: error?.message || '迁移失败',
|
||
errorCode: 'MIGRATION_FAILED',
|
||
}, { status: 500 })
|
||
}
|
||
}
|