1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
479 lines
16 KiB
TypeScript
479 lines
16 KiB
TypeScript
/**
|
||
* 系统数据完整性检查和自动修复模块
|
||
*
|
||
* 设计目标:
|
||
* 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] }
|
||
}
|
||
}
|