448 lines
13 KiB
TypeScript
448 lines
13 KiB
TypeScript
/**
|
||
* 系统数据完整性检查和自动修复模块
|
||
*
|
||
* 设计目标:
|
||
* 1. 客户重新安装时自动初始化必要数据
|
||
* 2. 系统更新时自动补全新增的内置数据
|
||
* 3. 不影响客户已有的自定义数据
|
||
* 4. 提供健康检查API供监控使用
|
||
*
|
||
* @module system-integrity
|
||
* @created 2025-12-13
|
||
*/
|
||
|
||
import { prisma } from '@/lib/prisma'
|
||
import {
|
||
BUILTIN_TEMPLATE_KEYS as BUILTIN_TEMPLATE_KEYS_SOURCE,
|
||
createBuiltinTemplate,
|
||
} from './upload-template-presets'
|
||
|
||
// ============================================================================
|
||
// 内置模板配置(单一数据源)
|
||
// ============================================================================
|
||
|
||
export const BUILTIN_TEMPLATE_KEYS = BUILTIN_TEMPLATE_KEYS_SOURCE
|
||
|
||
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
|
||
}>
|
||
}
|
||
> = Object.fromEntries(
|
||
BUILTIN_TEMPLATE_KEYS.map((key) => {
|
||
const template = createBuiltinTemplate(key)
|
||
return [
|
||
key,
|
||
{
|
||
name: template.name,
|
||
description: template.description,
|
||
materialType: template.materialType,
|
||
materialKind: template.materialKind,
|
||
fields: template.fields.map((field) => ({
|
||
key: field.key,
|
||
label: field.label,
|
||
type: field.type,
|
||
required: field.required,
|
||
layoutWidth: field.layoutWidth || 'full',
|
||
visibleOnHomeFilter: field.visibleOnHomeFilter,
|
||
isSearchable: field.isSearchable,
|
||
addToCategory: field.addToCategory,
|
||
})),
|
||
},
|
||
]
|
||
})
|
||
) as 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
|
||
}>
|
||
}
|
||
>
|
||
|
||
// ============================================================================
|
||
// 数据完整性检查结果类型
|
||
// ============================================================================
|
||
|
||
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<IntegrityCheckResult> {
|
||
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<IntegrityRepairResult> {
|
||
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] }
|
||
}
|
||
}
|