427 lines
12 KiB
TypeScript
427 lines
12 KiB
TypeScript
/**
|
||
* 导出素材详细清单
|
||
*
|
||
* 功能:
|
||
* 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)
|
||
})
|