flnxi-Wu/lib/system-integrity.ts
2026-05-25 17:12:11 +08:00

448 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 系统数据完整性检查和自动修复模块
*
* 设计目标:
* 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] }
}
}