flnxi-Wu/lib/system-integrity.ts
WuYongJie 63ec6b7dfb fix: 修复排行榜按钮消失、tag排序和输入框浅色模式问题
1. 排行榜悬浮窗关闭后小按钮消失问题
   - 修复面板居中模式关闭时按钮位置错误
   - 保存并恢复按钮的居中状态

2. 首页tag按钮按素材数量排序
   - 添加按素材数量降序排序逻辑

3. 上传弹窗输入框浅色模式文字不可见
   - 添加 text-slate-900 确保浅色模式下文字可见
   - 添加 placeholder 样式确保占位符正确显示
2025-12-14 21:41:29 +08:00

479 lines
16 KiB
TypeScript
Raw 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'
// ============================================================================
// 内置模板配置(单一数据源)
// ============================================================================
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<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] }
}
}