From 0fcbd3bcb34945862afc04bc91432c731382cdaa Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Thu, 28 May 2026 17:12:32 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=A8=A1=E6=9D=BF=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/template-migrate.test.ts | 100 ++++++++++++ .../upload-templates/[id]/migrate/route.ts | 143 +++++++++++++++++- docs/api/模板系统API文档.md | 28 +++- docs/user-guide/模板系统用户手册.md | 6 +- docs/商业化地基排查问题清单_20260524.md | 8 +- 5 files changed, 276 insertions(+), 9 deletions(-) diff --git a/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts b/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts index 244f122..bcd4c1a 100644 --- a/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts +++ b/app/api/admin/upload-templates/[id]/migrate/__tests__/template-migrate.test.ts @@ -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', diff --git a/app/api/admin/upload-templates/[id]/migrate/route.ts b/app/api/admin/upload-templates/[id]/migrate/route.ts index 7f08f96..07ae0e0 100644 --- a/app/api/admin/upload-templates/[id]/migrate/route.ts +++ b/app/api/admin/upload-templates/[id]/migrate/route.ts @@ -36,6 +36,17 @@ type TemplateMigrationBackup = { droppedStandardFields?: Record } +type MigrationPreviewFieldSummary = { + key: string + label: string + type?: string + source: 'template' | 'standard' +} + +type MigrationPreviewFieldImpact = MigrationPreviewFieldSummary & { + affectedCount: number +} + const CLEARABLE_NON_TARGET_FIELD_DEFAULTS: Record = { brandName: null, seriesName: null, @@ -71,6 +82,27 @@ const MIGRATION_BACKUP_STANDARD_FIELDS = [ 'audioScene', ] as const +const STANDARD_FIELD_LABELS: Record = { + 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 { if (!metadata) { return {} @@ -105,6 +137,37 @@ function getExistingMigrationBackups(metadata: Record): unknown : [] } +function buildFieldSummary( + key: string, + sourceTemplateFieldsByKey: Map, + 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, + 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 + droppedStandardFields: Record +} { 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() + const previewSamples: Array<{ + materialId: string + productModel: string | null + droppedTemplateFields: MigrationPreviewFieldSummary[] + droppedStandardFields: MigrationPreviewFieldSummary[] + }> = [] const preparedUpdates = materials.map((material) => { const allValues = buildMaterialFieldValues(material as Record) @@ -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({ diff --git a/docs/api/模板系统API文档.md b/docs/api/模板系统API文档.md index 383d9a4..bc13e14 100644 --- a/docs/api/模板系统API文档.md +++ b/docs/api/模板系统API文档.md @@ -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`。 diff --git a/docs/user-guide/模板系统用户手册.md b/docs/user-guide/模板系统用户手册.md index e613eef..be38cfa 100644 --- a/docs/user-guide/模板系统用户手册.md +++ b/docs/user-guide/模板系统用户手册.md @@ -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 变更。 diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index b5225a9..cd1663d 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -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. 条件显示规则前后端都执行。(已完成第一轮)