482 lines
15 KiB
TypeScript
482 lines
15 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 {
|
||
buildTemplateMigrationPreviewWithPaidCore,
|
||
canUseCustomTemplates,
|
||
planTemplateMigrationBatchWithPaidCore,
|
||
prepareTemplateMigrationMetadataWithPaidCore,
|
||
} from '@/lib/license'
|
||
import { isCurrentSuperAdmin } from '@/lib/admin-permissions'
|
||
import {
|
||
parseMetadata,
|
||
readAllFieldValues,
|
||
} from '@/lib/field-data-storage'
|
||
|
||
type TemplateFieldSummary = {
|
||
key: string
|
||
label: string
|
||
type: string
|
||
required: boolean
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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)
|
||
* - previewOnly: 是否只返回迁移预览、不实际写库(可选,默认 false)
|
||
* - forceLargeMigration: 超过安全数量时是否确认执行(可选,默认 false)
|
||
* - migrationLimit: 本批处理的最大素材数(可选,默认 200,上限 1000)
|
||
*/
|
||
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,
|
||
} = 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 activeMaterialCount = await prisma.material.count({
|
||
where: {
|
||
templateId: sourceTemplateId,
|
||
deletedAt: null,
|
||
},
|
||
})
|
||
const totalMaterialCount = Number.isFinite(activeMaterialCount)
|
||
? activeMaterialCount
|
||
: sourceTemplate._count.materials
|
||
|
||
const batchPlan = await planTemplateMigrationBatchWithPaidCore({
|
||
sourceTemplateId,
|
||
targetTemplateId,
|
||
sourceTemplateName: sourceTemplate.name,
|
||
targetTemplateName: targetTemplate.name,
|
||
totalMaterialCount,
|
||
request: {
|
||
updateMaterialType: body?.updateMaterialType,
|
||
previewOnly: body?.previewOnly,
|
||
forceLargeMigration: body?.forceLargeMigration,
|
||
migrationLimit: body?.migrationLimit,
|
||
},
|
||
})
|
||
|
||
if (!batchPlan.valid) {
|
||
return NextResponse.json({
|
||
error: batchPlan.error,
|
||
errorCode: batchPlan.errorCode,
|
||
details: batchPlan.details,
|
||
}, { status: batchPlan.statusCode || 503 })
|
||
}
|
||
|
||
if (!batchPlan.allowed) {
|
||
if (batchPlan.response) {
|
||
return NextResponse.json(batchPlan.response, { status: batchPlan.statusCode })
|
||
}
|
||
|
||
return NextResponse.json({
|
||
error: batchPlan.error,
|
||
errorCode: batchPlan.errorCode,
|
||
details: batchPlan.details,
|
||
}, { status: batchPlan.statusCode })
|
||
}
|
||
|
||
const materials = await prisma.material.findMany({
|
||
where: {
|
||
templateId: sourceTemplateId,
|
||
deletedAt: null,
|
||
},
|
||
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,
|
||
},
|
||
orderBy: [
|
||
{ createdAt: 'asc' },
|
||
{ id: 'asc' },
|
||
],
|
||
take: batchPlan.materialTake ?? undefined,
|
||
})
|
||
|
||
const materialCount = materials.length
|
||
|
||
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,
|
||
totalMaterialCount,
|
||
migrationLimit: batchPlan.migrationLimit,
|
||
previewOnly: batchPlan.previewOnly,
|
||
forceLargeMigration: batchPlan.forceLargeMigration,
|
||
updateMaterialType: batchPlan.updateMaterialType,
|
||
})
|
||
|
||
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 = batchPlan.migratedAt
|
||
const migrationPreviewImpacts: Array<{
|
||
materialId: string
|
||
productModel: string | null
|
||
droppedTemplateFieldKeys: string[]
|
||
droppedStandardFieldKeys: string[]
|
||
}> = []
|
||
|
||
const preparedUpdates: Array<{
|
||
id: string
|
||
data: Record<string, unknown>
|
||
}> = []
|
||
|
||
for (const material of materials) {
|
||
const allValues = buildMaterialFieldValues(material as Record<string, unknown>)
|
||
|
||
const parsedMetadata = parseMetadata(material.metadata)
|
||
const migrationMetadata = await prepareTemplateMigrationMetadataWithPaidCore({
|
||
existingMetadata: material.metadata,
|
||
materialFieldValues: allValues,
|
||
materialStandardFields: material as Record<string, unknown>,
|
||
previewUrls: parsedMetadata.previewUrls,
|
||
targetFieldKeys: Array.from(targetFieldKeys),
|
||
targetFields: targetTemplate.fields as TemplateFieldSummary[],
|
||
sourceTemplateId,
|
||
sourceTemplateName: sourceTemplate.name,
|
||
targetTemplateId,
|
||
targetTemplateName: targetTemplate.name,
|
||
migratedAt,
|
||
})
|
||
|
||
if (!migrationMetadata.valid) {
|
||
if (migrationMetadata.errorCode === 'TARGET_TEMPLATE_FIELDS_UNRESOLVED') {
|
||
const missingRequiredFields = Array.isArray(migrationMetadata.details?.missingRequiredFields)
|
||
? migrationMetadata.details.missingRequiredFields.map((field) => String(field))
|
||
: []
|
||
incompatibleMaterials.push({
|
||
materialId: material.id,
|
||
productModel: material.productModel,
|
||
missingRequiredFields,
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (migrationMetadata.errorCode === 'METADATA_TOO_LARGE') {
|
||
oversizedMetadataMaterials.push({
|
||
materialId: material.id,
|
||
productModel: material.productModel,
|
||
size: Number(migrationMetadata.details?.size || 0),
|
||
maxSize: Number(migrationMetadata.details?.maxSize || 0),
|
||
})
|
||
continue
|
||
}
|
||
|
||
return NextResponse.json({
|
||
error: migrationMetadata.error || '付费增强包无法准备模板迁移数据',
|
||
errorCode: migrationMetadata.errorCode || 'TEMPLATE_MIGRATION_METADATA_PREPARE_FAILED',
|
||
details: migrationMetadata.details,
|
||
}, { status: 503 })
|
||
}
|
||
|
||
if (migrationMetadata.droppedTemplateFieldCount > 0 || migrationMetadata.droppedStandardFieldCount > 0) {
|
||
migrationPreviewImpacts.push({
|
||
materialId: material.id,
|
||
productModel: material.productModel,
|
||
droppedTemplateFieldKeys: Object.keys(migrationMetadata.droppedTemplateFields),
|
||
droppedStandardFieldKeys: Object.keys(migrationMetadata.droppedStandardFields),
|
||
})
|
||
}
|
||
|
||
const updateData: any = {
|
||
templateId: targetTemplateId,
|
||
materialKind: targetTemplate.materialKind,
|
||
metadata: migrationMetadata.metadata,
|
||
...migrationMetadata.standardData,
|
||
}
|
||
|
||
if (batchPlan.updateMaterialType && targetTemplate.materialType) {
|
||
updateData.materialType = targetTemplate.materialType
|
||
}
|
||
|
||
preparedUpdates.push({
|
||
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 })
|
||
}
|
||
|
||
const migrationPreviewResult = await buildTemplateMigrationPreviewWithPaidCore({
|
||
totalMaterialCount,
|
||
migratableCount: preparedUpdates.length,
|
||
previewOnly: batchPlan.previewOnly,
|
||
previewLimit: batchPlan.previewOnly ? batchPlan.migrationLimit : undefined,
|
||
targetFields: targetTemplate.fields as TemplateFieldSummary[],
|
||
sourceTemplateFields: sourceTemplate.fields as TemplateFieldSummary[],
|
||
impacts: migrationPreviewImpacts,
|
||
})
|
||
|
||
if (!migrationPreviewResult.valid) {
|
||
return NextResponse.json({
|
||
error: migrationPreviewResult.error || '付费增强包无法生成模板迁移预览',
|
||
errorCode: migrationPreviewResult.errorCode || 'TEMPLATE_MIGRATION_PREVIEW_BUILD_FAILED',
|
||
details: migrationPreviewResult.details,
|
||
}, { status: 503 })
|
||
}
|
||
const { migrationBackup, migrationPreview } = migrationPreviewResult
|
||
|
||
if (batchPlan.previewOnly) {
|
||
return NextResponse.json({
|
||
success: true,
|
||
previewOnly: true,
|
||
migratableCount: preparedUpdates.length,
|
||
message: `预览:将 ${preparedUpdates.length} 个素材从"${sourceTemplate.name}"迁移到"${targetTemplate.name}"`,
|
||
sourceTemplate: {
|
||
id: sourceTemplate.id,
|
||
name: sourceTemplate.name,
|
||
},
|
||
targetTemplate: {
|
||
id: targetTemplate.id,
|
||
name: targetTemplate.name,
|
||
},
|
||
migrationBackup,
|
||
migrationPreview,
|
||
})
|
||
}
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
for (const update of preparedUpdates) {
|
||
const result = await tx.material.updateMany({
|
||
where: {
|
||
id: update.id,
|
||
templateId: sourceTemplateId,
|
||
deletedAt: null,
|
||
},
|
||
data: update.data,
|
||
})
|
||
|
||
if (result.count !== 1) {
|
||
throw new Error(`素材 ${update.id} 已不属于源模板或已删除,迁移已整体回滚`)
|
||
}
|
||
}
|
||
})
|
||
|
||
console.log('[TemplateMigrate] Migration completed:', {
|
||
sourceTemplateId,
|
||
targetTemplateId,
|
||
migratedCount: preparedUpdates.length,
|
||
...migrationBackup,
|
||
})
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
migratedCount: preparedUpdates.length,
|
||
totalMaterialCount,
|
||
remainingMaterialCount: Math.max(totalMaterialCount - preparedUpdates.length, 0),
|
||
batchLimited: batchPlan.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,
|
||
},
|
||
targetTemplate: {
|
||
id: targetTemplate.id,
|
||
name: targetTemplate.name,
|
||
},
|
||
migrationBackup,
|
||
})
|
||
} 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 })
|
||
}
|
||
}
|