2026-05-25 16:48:19 +08:00

569 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 模板素材迁移 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 })
}
}