补充模板迁移预览
This commit is contained in:
parent
a2776f68ae
commit
0fcbd3bcb3
@ -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',
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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`。
|
||||
|
||||
@ -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 变更。
|
||||
|
||||
@ -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. 条件显示规则前后端都执行。(已完成第一轮)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user