/** * 系统数据完整性检查和自动修复模块 * * 设计目标: * 1. 客户重新安装时自动初始化必要数据 * 2. 系统更新时自动补全新增的内置数据 * 3. 不影响客户已有的自定义数据 * 4. 提供健康检查API供监控使用 * * @module system-integrity * @created 2025-12-13 */ import { prisma } from '@/lib/prisma' // ============================================================================ // 内置模板配置(单一数据源) // ============================================================================ export const BUILTIN_TEMPLATE_KEYS = [ '3dmax-material', 'psd-material', 'audio-material', 'video-material', 'model-material', 'other-material', ] as const export type BuiltinTemplateKey = (typeof BUILTIN_TEMPLATE_KEYS)[number] export const BUILTIN_TEMPLATE_CONFIG: Record< BuiltinTemplateKey, { name: string description: string materialType: string materialKind: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER' fields: Array<{ key: string label: string type: string required: boolean layoutWidth: string visibleOnHomeFilter: boolean isSearchable: boolean addToCategory?: boolean }> } > = { '3dmax-material': { name: '3DMAX 素材模板', description: '适用于 3D 模型类素材', materialType: '3DMAX', materialKind: 'IMAGE', fields: [ { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, { key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, { key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, { key: 'renderer', label: '渲染器', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true, addToCategory: true }, ], }, 'psd-material': { name: 'PSD 素材模板', description: '针对平面设计 PSD 素材', materialType: 'PSD', materialKind: 'IMAGE', fields: [ { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, { key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, { key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, ], }, 'audio-material': { name: '音频素材模板', description: '适用于音频文件素材,支持 MP3、WAV、FLAC、AAC 等格式', materialType: '音频', materialKind: 'AUDIO', fields: [ { key: 'productModel', label: '音频名称', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, { key: 'audioCategory', label: '音频分类', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, { key: 'audioScene', label: '使用场景', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, { key: 'authorName', label: '作者', type: 'text', required: false, layoutWidth: 'half', visibleOnHomeFilter: false, isSearchable: true }, ], }, 'video-material': { name: '视频素材模板', description: '适用于视频文件素材', materialType: '视频', materialKind: 'VIDEO', fields: [ { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, { key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, { key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, ], }, 'model-material': { name: '模特素材模板', description: '用于模特/人像素材', materialType: '模特', materialKind: 'IMAGE', fields: [ { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, { key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, { key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, ], }, 'other-material': { name: '其他素材模板', description: '适用于其他类型的素材', materialType: '其他', materialKind: 'OTHER', fields: [ { key: 'productModel', label: 'SPU型号', type: 'text', required: true, layoutWidth: 'full', visibleOnHomeFilter: false, isSearchable: true }, { key: 'categoryName', label: '使用店铺', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, { key: 'sceneName', label: '场景类别', type: 'select', required: true, layoutWidth: 'full', visibleOnHomeFilter: true, isSearchable: true, addToCategory: true }, ], }, } // ============================================================================ // 数据完整性检查结果类型 // ============================================================================ export interface IntegrityCheckResult { healthy: boolean timestamp: string checks: { builtinTemplates: { healthy: boolean total: number existing: number missing: string[] } orphanedMaterials: { healthy: boolean count: number templateIds: string[] } } recommendations: string[] } export interface IntegrityRepairResult { success: boolean timestamp: string repairs: { templatesCreated: string[] templatesFailed: string[] orphanedMaterialsFixed: number } errors: string[] } // ============================================================================ // 核心功能函数 // ============================================================================ /** * 检查系统数据完整性 * * 检查项目: * 1. 内置模板是否完整 * 2. 是否有素材引用了不存在的模板(孤儿素材) */ export async function checkSystemIntegrity(): Promise { const timestamp = new Date().toISOString() const recommendations: string[] = [] // 1. 检查内置模板 const expectedTemplateIds = BUILTIN_TEMPLATE_KEYS.map((key) => `builtin-${key}`) const existingTemplates = await prisma.uploadTemplate.findMany({ where: { id: { in: expectedTemplateIds } }, select: { id: true }, }) const existingIds = new Set(existingTemplates.map((t) => t.id)) const missingTemplates = expectedTemplateIds.filter((id) => !existingIds.has(id)) if (missingTemplates.length > 0) { recommendations.push(`缺少 ${missingTemplates.length} 个内置模板,建议运行修复`) } // 2. 检查孤儿素材(引用了不存在模板的素材) const materialsWithTemplate = await prisma.material.findMany({ where: { templateId: { not: null }, deletedAt: null, }, select: { templateId: true }, distinct: ['templateId'], }) const referencedTemplateIds = materialsWithTemplate .map((m) => m.templateId) .filter((id): id is string => id !== null) // 检查这些模板是否存在 const existingReferencedTemplates = await prisma.uploadTemplate.findMany({ where: { id: { in: referencedTemplateIds } }, select: { id: true }, }) const existingReferencedIds = new Set(existingReferencedTemplates.map((t) => t.id)) const orphanedTemplateIds = referencedTemplateIds.filter((id) => !existingReferencedIds.has(id)) // 统计孤儿素材数量 let orphanedMaterialCount = 0 if (orphanedTemplateIds.length > 0) { const count = await prisma.material.count({ where: { templateId: { in: orphanedTemplateIds }, deletedAt: null, }, }) orphanedMaterialCount = count recommendations.push( `有 ${orphanedMaterialCount} 个素材引用了不存在的模板,建议运行修复` ) } const builtinHealthy = missingTemplates.length === 0 const orphanedHealthy = orphanedTemplateIds.length === 0 return { healthy: builtinHealthy && orphanedHealthy, timestamp, checks: { builtinTemplates: { healthy: builtinHealthy, total: expectedTemplateIds.length, existing: existingIds.size, missing: missingTemplates, }, orphanedMaterials: { healthy: orphanedHealthy, count: orphanedMaterialCount, templateIds: orphanedTemplateIds, }, }, recommendations, } } /** * 修复系统数据完整性问题 * * 修复策略: * 1. 创建缺失的内置模板(使用 upsert 避免重复) * 2. 为孤儿素材创建占位模板或清除无效引用 */ export async function repairSystemIntegrity( options: { createMissingBuiltinTemplates?: boolean fixOrphanedMaterials?: boolean } = {} ): Promise { const { createMissingBuiltinTemplates = true, fixOrphanedMaterials = true, } = options const timestamp = new Date().toISOString() const templatesCreated: string[] = [] const templatesFailed: string[] = [] const errors: string[] = [] let orphanedMaterialsFixed = 0 // 1. 创建缺失的内置模板 if (createMissingBuiltinTemplates) { for (const key of BUILTIN_TEMPLATE_KEYS) { const templateId = `builtin-${key}` const config = BUILTIN_TEMPLATE_CONFIG[key] try { // 使用 upsert 确保幂等性 await prisma.$transaction(async (tx) => { // 检查模板是否存在 const existing = await tx.uploadTemplate.findUnique({ where: { id: templateId }, }) if (existing) { // 模板已存在,跳过 return } // 创建模板 await tx.uploadTemplate.create({ data: { id: templateId, name: config.name, description: config.description, status: 'active', materialType: config.materialType, materialKind: config.materialKind, version: 1, }, }) // 创建字段 for (let i = 0; i < config.fields.length; i++) { const field = config.fields[i] await tx.uploadField.create({ data: { templateId, key: field.key, label: field.label, type: field.type, required: field.required, orderIndex: i, layoutWidth: field.layoutWidth, visibleOnHomeFilter: field.visibleOnHomeFilter, isSearchable: field.isSearchable, addToCategory: field.addToCategory || false, }, }) } templatesCreated.push(templateId) }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) errors.push(`创建模板 ${templateId} 失败: ${errorMsg}`) templatesFailed.push(templateId) } } } // 2. 修复孤儿素材 if (fixOrphanedMaterials) { try { // 获取所有被素材引用但不存在的模板ID const materialsWithTemplate = await prisma.material.findMany({ where: { templateId: { not: null }, deletedAt: null, }, select: { templateId: true }, distinct: ['templateId'], }) const referencedTemplateIds = materialsWithTemplate .map((m) => m.templateId) .filter((id): id is string => id !== null) const existingTemplates = await prisma.uploadTemplate.findMany({ where: { id: { in: referencedTemplateIds } }, select: { id: true }, }) const existingIds = new Set(existingTemplates.map((t) => t.id)) const orphanedTemplateIds = referencedTemplateIds.filter((id) => !existingIds.has(id)) // 对于孤儿素材,根据模板ID类型处理 for (const orphanedId of orphanedTemplateIds) { if (orphanedId.startsWith('builtin-')) { // 内置模板应该已经在上一步创建了,这里不需要额外处理 continue } // 自定义模板不存在,清除素材的模板引用 // 这样素材仍然可以正常显示,只是没有关联模板 const result = await prisma.material.updateMany({ where: { templateId: orphanedId, deletedAt: null, }, data: { templateId: null, }, }) orphanedMaterialsFixed += result.count } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) errors.push(`修复孤儿素材失败: ${errorMsg}`) } } return { success: errors.length === 0, timestamp, repairs: { templatesCreated, templatesFailed, orphanedMaterialsFixed, }, errors, } } /** * 确保内置模板存在(用于应用启动时调用) * * 这是一个轻量级的检查,只在缺少模板时才执行创建 * 设计为可以安全地多次调用(幂等性) */ export async function ensureBuiltinTemplates(): Promise<{ checked: boolean created: string[] errors: string[] }> { const created: string[] = [] const errors: string[] = [] try { // 快速检查:获取现有的内置模板ID const expectedIds = BUILTIN_TEMPLATE_KEYS.map((key) => `builtin-${key}`) const existing = await prisma.uploadTemplate.findMany({ where: { id: { in: expectedIds } }, select: { id: true }, }) const existingIds = new Set(existing.map((t) => t.id)) // 只创建缺失的模板 for (const key of BUILTIN_TEMPLATE_KEYS) { const templateId = `builtin-${key}` if (existingIds.has(templateId)) continue const config = BUILTIN_TEMPLATE_CONFIG[key] try { await prisma.$transaction(async (tx) => { // 再次检查(防止并发) const check = await tx.uploadTemplate.findUnique({ where: { id: templateId }, }) if (check) return await tx.uploadTemplate.create({ data: { id: templateId, name: config.name, description: config.description, status: 'active', materialType: config.materialType, materialKind: config.materialKind, version: 1, }, }) for (let i = 0; i < config.fields.length; i++) { const field = config.fields[i] await tx.uploadField.create({ data: { templateId, key: field.key, label: field.label, type: field.type, required: field.required, orderIndex: i, layoutWidth: field.layoutWidth, visibleOnHomeFilter: field.visibleOnHomeFilter, isSearchable: field.isSearchable, addToCategory: field.addToCategory || false, }, }) } created.push(templateId) }) } catch (error) { // 忽略唯一约束冲突(可能是并发创建) const errorMsg = error instanceof Error ? error.message : String(error) if (!errorMsg.includes('Unique constraint')) { errors.push(`${templateId}: ${errorMsg}`) } } } if (created.length > 0) { console.log(`[SystemIntegrity] 创建了 ${created.length} 个内置模板:`, created) } return { checked: true, created, errors } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) console.error('[SystemIntegrity] 检查内置模板失败:', errorMsg) return { checked: false, created, errors: [errorMsg] } } }