2026-06-03 18:41:52 +08:00

482 lines
15 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 {
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 })
}
}