补充模板迁移预览

This commit is contained in:
WuYongJie 2026-05-28 17:12:32 +08:00
parent a2776f68ae
commit 0fcbd3bcb3
5 changed files with 276 additions and 9 deletions

View File

@ -661,6 +661,106 @@ describe('模板素材迁移 API 测试', () => {
expect(updateCall.data.platformName).toBe('')
})
test('previewOnly 应返回迁移前字段预览且不写库', async () => {
const sourceTemplate = createTemplate({
id: 'template-source',
name: '旧模板',
fields: [
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
{ key: 'keepCustom', label: '保留字段', type: 'text', required: false },
{ key: 'oldOnlyField', label: '旧模板独有字段', type: 'text', required: false },
],
_count: { materials: 1 },
})
const targetTemplate = createTemplate({
id: 'template-target',
name: '新模板',
fields: [
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
{ key: 'keepCustom', label: '保留字段', type: 'text', required: false },
],
})
prisma.uploadTemplate.findUnique
.mockResolvedValueOnce(sourceTemplate)
.mockResolvedValueOnce(targetTemplate)
prisma.material.findMany.mockResolvedValue(
createMaterials(1, {
metadata: JSON.stringify({
templateId: 'template-source',
templateFields: {
keepCustom: '目标模板接收的值',
oldOnlyField: '旧模板独有的值',
},
}),
})
)
const request = new NextRequest(
'http://localhost/api/admin/upload-templates/template-source/migrate',
{
method: 'POST',
body: JSON.stringify({
targetTemplateId: 'template-target',
previewOnly: true,
}),
}
)
const response = await migrateTemplate(request, {
params: { id: 'template-source' },
})
const data = await response.json()
expect(response.status).toBe(200)
expect(data.success).toBe(true)
expect(data.previewOnly).toBe(true)
expect(data.migratableCount).toBe(1)
expect(data.migrationBackup).toEqual({
materialsWithDroppedFields: 1,
droppedTemplateFieldCount: 1,
droppedStandardFieldCount: 6,
})
expect(data.migrationPreview.targetCurrentFields).toEqual([
{ key: 'productModel', label: '产品型号', type: 'text', required: false },
{ key: 'keepCustom', label: '保留字段', type: 'text', required: false },
])
expect(data.migrationPreview.backupFields.templateFields).toEqual([
{
key: 'oldOnlyField',
label: '旧模板独有字段',
type: 'text',
source: 'template',
affectedCount: 1,
},
])
expect(data.migrationPreview.backupFields.standardFields).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'categoryName', label: '分类', affectedCount: 1 }),
expect.objectContaining({ key: 'sceneName', label: '场景', affectedCount: 1 }),
expect.objectContaining({ key: 'materialType', label: '素材类型', affectedCount: 1 }),
])
)
expect(data.migrationPreview.samples[0]).toEqual(
expect.objectContaining({
materialId: 'mat-1',
productModel: '产品1',
droppedTemplateFields: [
{
key: 'oldOnlyField',
label: '旧模板独有字段',
type: 'text',
source: 'template',
},
],
})
)
expect(prisma.$transaction).not.toHaveBeenCalled()
expect(prisma.material.updateMany).not.toHaveBeenCalled()
})
test('迁移后metadata超限时应拒绝并且不写库', async () => {
const sourceTemplate = createTemplate({
id: 'template-source',

View File

@ -36,6 +36,17 @@ type TemplateMigrationBackup = {
droppedStandardFields?: Record<string, unknown>
}
type MigrationPreviewFieldSummary = {
key: string
label: string
type?: string
source: 'template' | 'standard'
}
type MigrationPreviewFieldImpact = MigrationPreviewFieldSummary & {
affectedCount: number
}
const CLEARABLE_NON_TARGET_FIELD_DEFAULTS: Record<string, string | null> = {
brandName: null,
seriesName: null,
@ -71,6 +82,27 @@ const MIGRATION_BACKUP_STANDARD_FIELDS = [
'audioScene',
] as const
const STANDARD_FIELD_LABELS: Record<string, string> = {
productModel: '产品型号',
brandName: '品牌',
seriesName: '系列',
skuCode: 'SKU',
materialTexture: '材质纹理',
categoryName: '分类',
sceneName: '场景',
materialType: '素材类型',
materialKind: '素材大类',
renderer: '渲染器',
renderTime: '渲染时间',
imageResolution: '图片分辨率',
colorMode: '色彩模式',
authorName: '作者',
platformName: '平台',
audioDuration: '音频时长',
audioCategory: '音频分类',
audioScene: '音频场景',
}
function parseMetadataObject(metadata: string | null | undefined): Record<string, unknown> {
if (!metadata) {
return {}
@ -105,6 +137,37 @@ function getExistingMigrationBackups(metadata: Record<string, unknown>): unknown
: []
}
function buildFieldSummary(
key: string,
sourceTemplateFieldsByKey: Map<string, TemplateFieldSummary>,
source: 'template' | 'standard'
): MigrationPreviewFieldSummary {
const templateField = sourceTemplateFieldsByKey.get(key)
return {
key,
label: templateField?.label || STANDARD_FIELD_LABELS[key] || key,
type: templateField?.type,
source,
}
}
function addPreviewFieldImpact(
impacts: Map<string, MigrationPreviewFieldImpact>,
field: MigrationPreviewFieldSummary
) {
const mapKey = `${field.source}:${field.key}`
const existing = impacts.get(mapKey)
if (existing) {
existing.affectedCount += 1
return
}
impacts.set(mapKey, {
...field,
affectedCount: 1,
})
}
function buildMigrationMetadata(params: {
existingMetadata: string | null
targetMetadata: string | null
@ -115,7 +178,13 @@ function buildMigrationMetadata(params: {
targetTemplateId: string
targetTemplateName: string
migratedAt: string
}): { metadata: string | null; droppedTemplateFieldCount: number; droppedStandardFieldCount: number } {
}): {
metadata: string | null
droppedTemplateFieldCount: number
droppedStandardFieldCount: number
droppedTemplateFields: Record<string, unknown>
droppedStandardFields: Record<string, unknown>
} {
const existingMetadataObject = parseMetadataObject(params.existingMetadata)
const targetMetadataObject = parseMetadataObject(params.targetMetadata)
const existingTemplateFields = getRecordValue(existingMetadataObject.templateFields)
@ -170,6 +239,8 @@ function buildMigrationMetadata(params: {
metadata: JSON.stringify(nextMetadata),
droppedTemplateFieldCount,
droppedStandardFieldCount,
droppedTemplateFields,
droppedStandardFields,
}
}
@ -233,6 +304,7 @@ async function ensureAdmin() {
* Body:
* - targetTemplateId: 目标模板ID
* - updateMaterialType: 是否同时更新素材的 materialType 字段(可选,默认 true)
* - previewOnly: 是否只返回迁移预览、不实际写库(可选,默认 false)
*/
export async function POST(
req: NextRequest,
@ -256,7 +328,7 @@ export async function POST(
const sourceTemplateId = params.id
const body = await req.json()
const { targetTemplateId, updateMaterialType = true } = body
const { targetTemplateId, updateMaterialType = true, previewOnly = false } = body
if (!targetTemplateId) {
return NextResponse.json({
@ -389,10 +461,20 @@ export async function POST(
const targetFieldKeys = new Set(
(targetTemplate.fields as TemplateFieldSummary[]).map((field) => field.key)
)
const sourceTemplateFieldsByKey = new Map(
(sourceTemplate.fields as TemplateFieldSummary[]).map((field) => [field.key, field])
)
const migratedAt = new Date().toISOString()
let materialsWithDroppedFields = 0
let droppedTemplateFieldCount = 0
let droppedStandardFieldCount = 0
const previewFieldImpacts = new Map<string, MigrationPreviewFieldImpact>()
const previewSamples: Array<{
materialId: string
productModel: string | null
droppedTemplateFields: MigrationPreviewFieldSummary[]
droppedStandardFields: MigrationPreviewFieldSummary[]
}> = []
const preparedUpdates = materials.map((material) => {
const allValues = buildMaterialFieldValues(material as Record<string, unknown>)
@ -447,6 +529,25 @@ export async function POST(
materialsWithDroppedFields += 1
droppedTemplateFieldCount += migrationMetadata.droppedTemplateFieldCount
droppedStandardFieldCount += migrationMetadata.droppedStandardFieldCount
const droppedTemplateFields = Object.keys(migrationMetadata.droppedTemplateFields).map((key) =>
buildFieldSummary(key, sourceTemplateFieldsByKey, 'template')
)
const droppedStandardFields = Object.keys(migrationMetadata.droppedStandardFields).map((key) =>
buildFieldSummary(key, sourceTemplateFieldsByKey, 'standard')
)
droppedTemplateFields.forEach((field) => addPreviewFieldImpact(previewFieldImpacts, field))
droppedStandardFields.forEach((field) => addPreviewFieldImpact(previewFieldImpacts, field))
if (previewSamples.length < 10) {
previewSamples.push({
materialId: material.id,
productModel: material.productModel,
droppedTemplateFields,
droppedStandardFields,
})
}
}
if (!metadataSize.ok) {
@ -510,6 +611,44 @@ export async function POST(
}, { status: 409 })
}
const migrationPreview = {
totalMaterials: preparedUpdates.length,
targetCurrentFields: (targetTemplate.fields as TemplateFieldSummary[]).map((field) => ({
key: field.key,
label: field.label || field.key,
type: field.type,
required: field.required,
})),
backupFields: {
templateFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'template'),
standardFields: Array.from(previewFieldImpacts.values()).filter((field) => field.source === 'standard'),
},
samples: previewSamples,
}
if (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: {
materialsWithDroppedFields,
droppedTemplateFieldCount,
droppedStandardFieldCount,
},
migrationPreview,
})
}
await prisma.$transaction(async (tx) => {
for (const update of preparedUpdates) {
const result = await tx.material.updateMany({

View File

@ -179,6 +179,22 @@ POST /api/admin/upload-templates/:id/migrate
该接口用于把素材迁移到目标模板。它同样要求管理员权限和自定义模板许可证能力。
请求体:
```json
{
"targetTemplateId": "目标模板ID",
"updateMaterialType": true,
"previewOnly": false
}
```
字段说明:
- `targetTemplateId`:必填,目标模板 ID。
- `updateMaterialType`:可选,默认 `true`;为 `false` 时不自动改素材类型字段。
- `previewOnly`:可选,默认 `false`;为 `true` 时只返回迁移预览,不写数据库。
当前行为:
- 迁移前会检查目标模板必填字段是否能从旧素材自动补齐;不能补齐时返回 `TARGET_TEMPLATE_FIELDS_UNRESOLVED`,不会写库。
@ -187,7 +203,15 @@ POST /api/admin/upload-templates/:id/migrate
- 可安全清空的旧标准字段会从素材主表清理,避免继续污染新模板字段。
- 迁移生成的 metadata 超限时返回 `METADATA_TOO_LARGE`,不会进入事务。
具体请求体以当前代码 `app/api/admin/upload-templates/[id]/migrate/route.ts` 为准,执行前必须先在测试环境核对目标模板和素材范围。
预览返回会包含:
- `migratableCount`:可迁移素材数量。
- `migrationBackup`:会产生备份的素材数、旧自定义字段数量、旧标准字段数量。
- `migrationPreview.targetCurrentFields`:迁移后会成为当前模板字段的目标字段。
- `migrationPreview.backupFields`:会进入备份区的旧字段,按旧自定义字段和旧标准字段分组,并统计影响素材数。
- `migrationPreview.samples`:最多 10 条受影响素材样例,用于管理员确认迁移影响。
执行真实迁移前,建议先用 `previewOnly: true` 预览字段差异和备份影响。
## 3. 普通读取接口
@ -373,7 +397,7 @@ PUT /api/template-categories/:fieldKey
1. 更新模板前先读取详情,带上最新 `version` 再保存。
2. 已被素材使用的模板不要直接改结构;新建模板后迁移更稳。
3. 迁移前仍建议先在测试环境核对字段差异;系统会保留旧字段备份,但管理员仍应确认目标模板字段设计。
3. 迁移前先调用 `previewOnly: true` 核对字段差异;系统会保留旧字段备份,但管理员仍应确认目标模板字段设计。
4. 字段 key 虽然当前后端只强校验非空和唯一,但团队约定仍建议使用英文、数字、下划线,避免后续搜索、导出和脚本处理出问题。
5. 分类管理只适合稳定、选项可控的下拉字段;不要把高频变化的大字段放到首页筛选。
6. 不要直接调用不存在的字段池 API;需要刷新字段池时,优先通过后台分类管理里的“同步字段”维护动作触发,也就是最高权限账号调用 `POST /api/category-fields`。

View File

@ -1,6 +1,6 @@
# 上传模板系统用户手册
更新时间:2026-05-13
更新时间:2026-05-28
本文档面向管理员,说明当前后台模板系统的真实使用方式。模板管理入口在主应用后台 `/admin` 中,不存在单独的 `/admin/templates` 路由。
@ -51,7 +51,7 @@
- 新模板先用草稿状态验证。
- 已投入使用的模板不要直接改字段 key、字段类型、素材类型或素材大类。
- 需要大改时,优先新建模板,再迁移素材。
- 迁移前先用少量素材测试目标模板效果;系统会尽量保留旧字段备份,但管理员仍要确认字段设计是否合适。
- 迁移前先查看迁移预览,确认哪些字段会成为新模板当前字段、哪些旧字段会进入备份区;系统会尽量保留旧字段备份,但管理员仍要确认字段设计是否合适。
## 4. 字段配置
@ -170,6 +170,8 @@
新建模板后迁移素材时,系统会事务化处理;失败时不会只迁移一部分素材。目标模板不接收的旧字段会保留到备份区,方便后续追溯。
正式迁移前应先查看迁移预览。预览会显示可迁移素材数量、目标模板当前字段、会进入备份区的旧字段,以及最多 10 条受影响素材样例。预览不会写数据库,适合管理员确认字段设计后再执行迁移。
### 字段 key 能不能改?
未使用前可以谨慎改。模板已有素材后不建议改,当前后端也会拦截已有素材模板的字段 key 变更。

View File

@ -669,12 +669,14 @@ NAS 断电、容器重启、升级重启后,正在处理的视频可能永久
- `app/api/admin/upload-templates/[id]/migrate/route.ts`:迁移时只把目标模板字段写入当前 `templateFields`。
- `app/api/admin/upload-templates/[id]/migrate/route.ts`:目标模板不接收的旧字段进入 `templateMigrationBackups`。
- `app/api/admin/upload-templates/[id]/migrate/route.ts`:旧 `previewUrls` 继续保留。
- `app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts`:新增旧自定义字段、旧标准字段备份和可清空字段清理测试。
- `app/api/admin/upload-templates/[id]/migrate/route.ts`:新增 `previewOnly: true` 迁移预览模式,复用真实迁移前的同一套字段计算和校验,但不进入事务、不写数据库;返回目标模板当前字段、会进入备份区的旧字段统计和受影响素材样例。
- `app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts`:新增旧自定义字段、旧标准字段备份、可清空字段清理和迁移前预览测试。
建议动作:
- 已完成:默认保留旧字段到备份命名空间,避免历史字段无声丢失。
- 仍需继续:迁移前给预览报告,列出哪些字段会进入备份区,哪些字段会成为目标模板当前字段。
- 已完成:迁移前可通过 `previewOnly: true` 获取预览报告,列出哪些字段会进入备份区,哪些字段会成为目标模板当前字段。
- 仍需继续:前端模板管理页还需要把迁移预览接入正式确认流程,避免管理员直接盲点迁移按钮。
### P1-14 上传会截断 metadata,可能截坏 JSON(已修复上传、编辑和模板迁移入口)
@ -1236,7 +1238,7 @@ NAS 私有化系统可能在平板或窄屏浏览器里使用。模板编辑器
1. 决定自定义字段搜索和筛选的底层方案。
2. 修 `isSearchable`、首页筛选计数、动态筛选查询。
3. 模板迁移小批量事务化已完成;大批量任务化迁移仍待做。
4. 旧字段默认备份保留已完成;迁移前字段差异预览仍待做。
4. 旧字段默认备份保留和迁移前字段差异预览已完成;前端确认流程仍待接入。
5. 上传、编辑和模板迁移 metadata 超限已直接报错。
6. 条件显示规则前后端都执行。(已完成第一轮)