gongxiangp2/scripts/export-material-inventory.ts

427 lines
12 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. 检查uploads目录中的实际文件
* 3. 生成JSON和CSV格式的报告
* 4. 支持恢复前后的对比(通过时间戳文件名区分)
*
* 使用方法:
* npx tsx scripts/export-material-inventory.ts
*
* 环境变量:
* PROD_DATABASE_URL - 正式数据库连接(可选,默认使用.env中的DATABASE_URL)
* UPLOAD_DIR - uploads目录路径(可选,默认./uploads)
*/
import { PrismaClient } from '@prisma/client'
import { writeFileSync } from 'fs'
import path from 'path'
const PROD_DB_URL = process.env.PROD_DATABASE_URL || process.env.DATABASE_URL || 'postgresql://gongxiangpan:gongxiangpan123@192.168.58.20:5432/lobechat'
const prisma = new PrismaClient({
datasources: {
db: {
url: PROD_DB_URL,
},
},
})
interface MaterialInventoryItem {
// 基本信息
materialId: string
materialType: string
productModel: string
categoryName: string
sceneName: string
platformName: string
productCategoryName: string
renderer: string
authorName: string
// 文件信息
zipFileName: string
zipFilePath: string
zipFileSize: number | string
videoSourceUrl: string | null
videoPreview540Url: string | null
videoPreviewClipUrl: string | null
videoCoverUrl: string | null
// URL信息
previewUrl: string
displayImages: string[]
// 视频处理信息
videoProcessingStatus: string | null
videoDuration: number | null
transcodeLastError: string | null
transcodeAttempts: number | null
// 任务信息
taskNo: string | null
taskStatus: string | null
taskFetchedAt: Date | null
// 统计信息
downloadCount: number
likeCount: number
// 时间信息
createdAt: Date
updatedAt: Date
deletedAt: Date | null
// 用户信息
userId: string
originalOwnerId: string | null
transferredAt: Date | null
transferredBy: string | null
transferNote: string | null
// 上传信息
uploadId: string | null
// SKU信息(标准模型)
skuCount: number
skus: Array<{
id: string
skuName: string
filePath: string
fileName: string | null
fileSize: number | string
previewUrl: string | null
}>
// 资产信息(任务-详情)
assetCount: number
assets: Array<{
id: string
assetType: string
fileUrl: string
fileName: string | null
fileSize: number | string
}>
}
// 注意:不扫描本地文件系统,只导出数据库中的素材信息
// 文件存在性检查在恢复后通过对比脚本来完成
/**
* 导出素材清单
*/
async function exportMaterialInventory() {
console.log('========================================')
console.log('素材详细清单导出工具')
console.log('========================================')
console.log(`数据库: ${PROD_DB_URL.split('@')[1] || '未知'}`)
console.log('')
try {
// 连接数据库
await prisma.$connect()
console.log('✅ 数据库连接成功')
console.log('')
// 查询所有素材(包括已删除的)
console.log('📊 查询素材数据...')
const materials = await prisma.material.findMany({
include: {
user: {
select: {
id: true,
username: true,
email: true,
},
},
skus: {
where: {
deletedAt: null,
},
orderBy: {
sortOrder: 'asc',
},
},
assets: {
orderBy: {
sortOrder: 'asc',
},
},
},
orderBy: {
createdAt: 'desc',
},
})
console.log(` 找到 ${materials.length} 条素材记录`)
console.log('')
// 处理每个素材
console.log('🔄 处理素材数据...')
const inventory: MaterialInventoryItem[] = []
for (let i = 0; i < materials.length; i++) {
const material = materials[i]
if ((i + 1) % 100 === 0) {
console.log(` 进度: ${i + 1}/${materials.length}`)
}
// 处理SKU文件(不检查文件存在性,只导出路径信息)
const skus = material.skus.map(sku => {
return {
id: sku.id,
skuName: sku.skuName,
filePath: sku.filePath,
fileName: sku.fileName || null,
fileSize: sku.fileSize.toString(),
previewUrl: sku.previewUrl || null,
}
})
// 处理资产文件
const assets = material.assets.map(asset => ({
id: asset.id,
assetType: asset.assetType,
fileUrl: asset.fileUrl,
fileName: asset.fileName,
fileSize: asset.fileSize.toString(),
}))
const item: MaterialInventoryItem = {
// 基本信息
materialId: material.id,
materialType: material.materialType,
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
platformName: material.platformName || '',
productCategoryName: material.productCategoryName || '',
renderer: material.renderer,
authorName: material.authorName,
// 文件信息
zipFileName: material.zipFileName || '',
zipFilePath: material.zipFilePath || '',
zipFileSize: material.zipFileSize.toString(),
videoSourceUrl: material.videoSourceUrl,
videoPreview540Url: material.videoPreview540Url,
videoPreviewClipUrl: material.videoPreviewClipUrl,
videoCoverUrl: material.videoCoverUrl,
// URL信息
previewUrl: material.previewUrl,
displayImages: material.displayImages || [],
// 视频处理信息
videoProcessingStatus: material.videoProcessingStatus,
videoDuration: material.videoDuration,
transcodeLastError: material.transcodeLastError,
transcodeAttempts: material.transcodeAttempts,
// 任务信息
taskNo: material.taskNo,
taskStatus: material.taskStatus,
taskFetchedAt: material.taskFetchedAt,
// 统计信息
downloadCount: material.downloadCount,
likeCount: material.likeCount,
// 时间信息
createdAt: material.createdAt,
updatedAt: material.updatedAt,
deletedAt: material.deletedAt,
// 用户信息
userId: material.userId,
originalOwnerId: material.originalOwnerId,
transferredAt: material.transferredAt,
transferredBy: material.transferredBy || null,
transferNote: material.transferNote || null,
// 上传信息
uploadId: material.uploadId || null,
// SKU信息
skuCount: skus.length,
skus,
// 资产信息
assetCount: assets.length,
assets,
}
inventory.push(item)
}
console.log(`✅ 处理完成,共 ${inventory.length} 条记录`)
console.log('')
// 生成时间戳
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
// 生成JSON报告
const jsonFile = `material-inventory-${timestamp}.json`
const jsonPath = path.join(process.cwd(), jsonFile)
writeFileSync(jsonPath, JSON.stringify(inventory, null, 2), 'utf-8')
console.log(`✅ JSON报告已保存: ${jsonFile}`)
// 生成CSV报告(简化版,包含主要字段)
const csvFile = `material-inventory-${timestamp}.csv`
const csvPath = path.join(process.cwd(), csvFile)
const csvHeaders = [
'素材ID',
'素材类型',
'产品型号',
'使用店铺',
'场景类别',
'平台分类',
'品类分类',
'渲染器',
'作者',
'压缩包文件名',
'压缩包路径',
'压缩包大小(字节)',
'视频源URL',
'预览图URL',
'视频处理状态',
'视频时长(秒)',
'下载次数',
'点赞次数',
'创建时间',
'更新时间',
'删除时间',
'主文件是否存在',
'主文件路径',
'视频源文件是否存在',
'视频源文件路径',
'SKU数量',
'资产数量',
'任务号',
'任务状态',
]
const csvRows = inventory.map(item => [
item.materialId,
item.materialType,
item.productModel,
item.categoryName,
item.sceneName,
item.platformName,
item.productCategoryName,
item.renderer,
item.authorName,
item.zipFileName,
item.zipFilePath,
item.zipFileSize,
item.videoSourceUrl || '',
item.previewUrl,
item.videoProcessingStatus || '',
item.videoDuration?.toString() || '',
item.downloadCount.toString(),
item.likeCount.toString(),
item.createdAt.toISOString(),
item.updatedAt.toISOString(),
item.deletedAt?.toISOString() || '',
item.skuCount.toString(),
item.assetCount.toString(),
item.taskNo || '',
item.taskStatus || '',
])
const csvContent = [
csvHeaders.join(','),
...csvRows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')),
].join('\n')
writeFileSync(csvPath, '\ufeff' + csvContent, 'utf-8') // 添加BOM以支持Excel中文
console.log(`✅ CSV报告已保存: ${csvFile}`)
// 生成统计报告
const statsFile = `material-inventory-stats-${timestamp}.txt`
const statsPath = path.join(process.cwd(), statsFile)
const totalMaterials = inventory.length
const activeMaterials = inventory.filter(m => !m.deletedAt).length
const deletedMaterials = inventory.filter(m => m.deletedAt).length
const materialsWithMainFile = inventory.filter(m => m.zipFilePath || m.videoSourceUrl).length
const materialsWithVideoSource = inventory.filter(m => m.videoSourceUrl).length
const totalSkus = inventory.reduce((sum, m) => sum + m.skuCount, 0)
const totalAssets = inventory.reduce((sum, m) => sum + m.assetCount, 0)
const stats = [
'========================================',
'素材清单统计报告',
'========================================',
`导出时间: ${new Date().toLocaleString('zh-CN')}`,
'',
'=== 素材统计 ===',
`总素材数: ${totalMaterials}`,
`有效素材数: ${activeMaterials}`,
`已删除素材数: ${deletedMaterials}`,
'',
'=== 文件路径统计 ===',
`有文件路径的素材数: ${materialsWithMainFile}`,
`有视频源URL的素材数: ${materialsWithVideoSource}`,
'',
'=== 关联数据统计 ===',
`总SKU数: ${totalSkus}`,
`总资产数: ${totalAssets}`,
'',
'=== 按素材类型统计 ===',
]
const typeStats = inventory.reduce((acc, m) => {
acc[m.materialType] = (acc[m.materialType] || 0) + 1
return acc
}, {} as Record<string, number>)
for (const [type, count] of Object.entries(typeStats).sort((a, b) => b[1] - a[1])) {
stats.push(`${type}: ${count}`)
}
stats.push('')
stats.push('========================================')
writeFileSync(statsPath, stats.join('\n'), 'utf-8')
console.log(`✅ 统计报告已保存: ${statsFile}`)
console.log('')
console.log('========================================')
console.log('导出完成!')
console.log('========================================')
console.log(`JSON报告: ${jsonFile}`)
console.log(`CSV报告: ${csvFile}`)
console.log(`统计报告: ${statsFile}`)
console.log('')
console.log('📌 提示:')
console.log(' - JSON报告包含完整的素材信息(包括SKU和资产详情)')
console.log(' - CSV报告包含主要字段,方便在Excel中查看')
console.log(' - 恢复数据后,可以再次运行此脚本进行对比')
} catch (error) {
console.error('❌ 导出失败:', error)
throw error
} finally {
await prisma.$disconnect()
}
}
// 运行导出
exportMaterialInventory()
.then(() => {
console.log('✅ 脚本执行完成')
process.exit(0)
})
.catch((error) => {
console.error('❌ 脚本执行失败:', error)
process.exit(1)
})