337 lines
9.6 KiB
TypeScript
337 lines
9.6 KiB
TypeScript
/**
|
||
* 检查素材源文件是否缺失,生成CSV报告
|
||
*
|
||
* 使用方法:
|
||
* npx tsx scripts/check-missing-source-files.ts
|
||
* npx tsx scripts/check-missing-source-files.ts --output missing-files-report.csv
|
||
*/
|
||
|
||
import { PrismaClient } from '@prisma/client'
|
||
import { exec } from 'child_process'
|
||
import { promisify } from 'util'
|
||
import { writeFile, mkdir } from 'fs/promises'
|
||
import { existsSync } from 'fs'
|
||
import path from 'path'
|
||
|
||
const execAsync = promisify(exec)
|
||
const prisma = new PrismaClient()
|
||
|
||
// 服务器配置
|
||
const SERVER_IP = process.env.SERVER_IP || '192.168.58.20'
|
||
const SERVER_USER = process.env.SERVER_USER || 'root'
|
||
// 如果在容器内运行,使用容器路径;否则使用服务器路径
|
||
const IS_IN_CONTAINER = process.env.IS_IN_CONTAINER === 'true' || process.env.NODE_ENV === 'production'
|
||
const SERVER_UPLOADS_PATH = IS_IN_CONTAINER
|
||
? '/app/uploads' // 容器内路径
|
||
: (process.env.SERVER_UPLOADS_PATH || '/mnt/PoolOne/apps/youyidian-3d/uploads') // 服务器路径
|
||
const SSH_KEY_PATH = process.env.SSH_KEY_PATH || `${process.env.USERPROFILE}\\.ssh\\id_rsa`
|
||
const SSH_PORT = process.env.SSH_PORT || '22'
|
||
|
||
// 输出文件
|
||
const args = process.argv.slice(2)
|
||
const outputIndex = args.indexOf('--output')
|
||
const outputFile = outputIndex >= 0 && args[outputIndex + 1]
|
||
? args[outputIndex + 1]
|
||
: `missing-source-files-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.csv`
|
||
|
||
/**
|
||
* 通过SSH执行命令
|
||
*/
|
||
async function execSSH(command: string): Promise<string> {
|
||
const sshOptions = [
|
||
SSH_KEY_PATH ? `-i "${SSH_KEY_PATH}"` : '',
|
||
`-p ${SSH_PORT}`,
|
||
'-o StrictHostKeyChecking=no',
|
||
'-o ConnectTimeout=10',
|
||
].filter(Boolean).join(' ')
|
||
|
||
const sshCommand = `ssh ${sshOptions} ${SERVER_USER}@${SERVER_IP} "${command.replace(/"/g, '\\"')}"`
|
||
|
||
try {
|
||
const { stdout } = await execAsync(sshCommand)
|
||
return stdout.trim()
|
||
} catch (error: any) {
|
||
// 如果命令失败,返回空字符串
|
||
return ''
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查文件是否存在
|
||
*/
|
||
async function checkFileExists(filePath: string): Promise<boolean> {
|
||
// 如果在容器内运行,直接使用fs检查
|
||
if (IS_IN_CONTAINER) {
|
||
const fs = require('fs')
|
||
const fileName = path.basename(filePath)
|
||
const localPath = path.join(SERVER_UPLOADS_PATH, fileName)
|
||
try {
|
||
fs.accessSync(localPath, fs.constants.F_OK)
|
||
return true
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 否则通过SSH检查
|
||
const fileName = path.basename(filePath)
|
||
const remotePath = `${SERVER_UPLOADS_PATH}/${fileName}`
|
||
const checkCommand = `test -f "${remotePath}" && echo "exists" || echo "not found"`
|
||
const result = await execSSH(checkCommand)
|
||
return result === 'exists'
|
||
}
|
||
|
||
/**
|
||
* 转义CSV字段
|
||
*/
|
||
function escapeCsvField(value: any): string {
|
||
if (value === null || value === undefined) {
|
||
return ''
|
||
}
|
||
const str = String(value)
|
||
// 如果包含逗号、引号或换行符,需要用引号包裹并转义引号
|
||
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||
return `"${str.replace(/"/g, '""')}"`
|
||
}
|
||
return str
|
||
}
|
||
|
||
async function main() {
|
||
console.log('='.repeat(60))
|
||
console.log('检查素材源文件缺失情况')
|
||
console.log('='.repeat(60))
|
||
console.log(`服务器: ${SERVER_USER}@${SERVER_IP}:${SSH_PORT}`)
|
||
console.log(`服务器路径: ${SERVER_UPLOADS_PATH}`)
|
||
console.log(`输出文件: ${outputFile}`)
|
||
console.log()
|
||
|
||
// 1. 测试SSH连接
|
||
console.log('🔌 测试SSH连接...')
|
||
try {
|
||
const hostname = await execSSH('hostname')
|
||
console.log(` ✅ 连接成功: ${hostname}`)
|
||
} catch (error: any) {
|
||
console.error(' ❌ SSH连接失败:', error.message)
|
||
process.exit(1)
|
||
}
|
||
console.log()
|
||
|
||
// 2. 查询所有素材
|
||
console.log('📋 查询所有素材...')
|
||
const allMaterials = await prisma.material.findMany({
|
||
where: {
|
||
deletedAt: null,
|
||
},
|
||
select: {
|
||
id: true,
|
||
materialType: true,
|
||
productModel: true,
|
||
authorName: true,
|
||
zipFilePath: true,
|
||
zipFileName: true,
|
||
zipFileSize: true,
|
||
createdAt: true,
|
||
updatedAt: true,
|
||
sceneName: true,
|
||
platformName: true,
|
||
productCategoryName: true,
|
||
renderer: true,
|
||
categoryName: true,
|
||
},
|
||
orderBy: {
|
||
createdAt: 'desc',
|
||
},
|
||
})
|
||
|
||
console.log(` 找到 ${allMaterials.length} 个素材`)
|
||
console.log()
|
||
|
||
// 3. 检查源文件
|
||
console.log('🔍 检查源文件...')
|
||
const results: Array<{
|
||
id: string
|
||
materialType: string
|
||
productModel: string
|
||
authorName: string
|
||
zipFilePath: string | null
|
||
zipFileName: string | null
|
||
zipFileSize: number | null
|
||
fileExists: boolean
|
||
createdAt: Date
|
||
updatedAt: Date
|
||
sceneName: string | null
|
||
platformName: string | null
|
||
productCategoryName: string | null
|
||
renderer: string | null
|
||
categoryName: string
|
||
}> = []
|
||
|
||
let checkedCount = 0
|
||
for (const material of allMaterials) {
|
||
checkedCount++
|
||
|
||
// 只检查本地路径的源文件
|
||
let fileExists = false
|
||
if (material.zipFilePath && (
|
||
material.zipFilePath.startsWith('uploads/') ||
|
||
material.zipFilePath.startsWith('/app/uploads/') ||
|
||
material.zipFilePath.startsWith('./uploads/')
|
||
)) {
|
||
fileExists = await checkFileExists(material.zipFilePath)
|
||
} else if (material.zipFilePath && (
|
||
material.zipFilePath.startsWith('http://') ||
|
||
material.zipFilePath.startsWith('https://')
|
||
)) {
|
||
// HTTP URL源文件,标记为存在(需要单独检查)
|
||
fileExists = true
|
||
} else {
|
||
// 没有源文件路径,标记为缺失
|
||
fileExists = false
|
||
}
|
||
|
||
results.push({
|
||
id: material.id,
|
||
materialType: material.materialType || '',
|
||
productModel: material.productModel || '',
|
||
authorName: material.authorName || '',
|
||
zipFilePath: material.zipFilePath,
|
||
zipFileName: material.zipFileName,
|
||
zipFileSize: material.zipFileSize ? Number(material.zipFileSize) : null,
|
||
fileExists,
|
||
createdAt: material.createdAt,
|
||
updatedAt: material.updatedAt,
|
||
sceneName: material.sceneName,
|
||
platformName: material.platformName,
|
||
productCategoryName: material.productCategoryName,
|
||
renderer: material.renderer,
|
||
categoryName: material.categoryName || '',
|
||
})
|
||
|
||
// 每100个显示一次进度
|
||
if (checkedCount % 100 === 0) {
|
||
console.log(` 已检查: ${checkedCount}/${allMaterials.length}`)
|
||
}
|
||
}
|
||
|
||
console.log(` 检查完成: ${checkedCount} 个素材`)
|
||
console.log()
|
||
|
||
// 4. 统计缺失文件
|
||
const missingFiles = results.filter(r => !r.fileExists)
|
||
const existingFiles = results.filter(r => r.fileExists)
|
||
|
||
console.log('📊 统计结果:')
|
||
console.log(` 总素材数: ${results.length}`)
|
||
console.log(` 源文件存在: ${existingFiles.length}`)
|
||
console.log(` 源文件缺失: ${missingFiles.length}`)
|
||
console.log()
|
||
|
||
// 5. 生成CSV
|
||
console.log('📝 生成CSV报告...')
|
||
|
||
// CSV表头
|
||
const headers = [
|
||
'素材ID',
|
||
'素材类型',
|
||
'产品型号(SPU)',
|
||
'作者',
|
||
'使用店铺',
|
||
'场景类别',
|
||
'平台分类',
|
||
'品类分类',
|
||
'渲染器',
|
||
'源文件路径',
|
||
'源文件名',
|
||
'源文件大小(字节)',
|
||
'文件是否存在',
|
||
'创建时间',
|
||
'更新时间',
|
||
]
|
||
|
||
// CSV内容
|
||
const csvRows: string[] = []
|
||
csvRows.push(headers.map(escapeCsvField).join(','))
|
||
|
||
// 先输出缺失的文件
|
||
for (const result of missingFiles) {
|
||
const row = [
|
||
result.id,
|
||
result.materialType,
|
||
result.productModel,
|
||
result.authorName,
|
||
result.categoryName,
|
||
result.sceneName || '',
|
||
result.platformName || '',
|
||
result.productCategoryName || '',
|
||
result.renderer || '',
|
||
result.zipFilePath || '',
|
||
result.zipFileName || '',
|
||
result.zipFileSize?.toString() || '',
|
||
'否',
|
||
result.createdAt.toISOString(),
|
||
result.updatedAt.toISOString(),
|
||
]
|
||
csvRows.push(row.map(escapeCsvField).join(','))
|
||
}
|
||
|
||
// 再输出存在的文件(可选,如果只需要缺失的,可以注释掉)
|
||
// for (const result of existingFiles) {
|
||
// const row = [
|
||
// result.id,
|
||
// result.materialType,
|
||
// result.productModel,
|
||
// result.author,
|
||
// result.storeName || '',
|
||
// result.sceneName || '',
|
||
// result.platformName || '',
|
||
// result.productCategoryName || '',
|
||
// result.renderer || '',
|
||
// result.zipFilePath || '',
|
||
// result.zipFileName || '',
|
||
// result.zipFileSize?.toString() || '',
|
||
// '是',
|
||
// result.createdAt.toISOString(),
|
||
// result.updatedAt.toISOString(),
|
||
// ]
|
||
// csvRows.push(row.map(escapeCsvField).join(','))
|
||
// }
|
||
|
||
// 写入文件
|
||
const csvContent = csvRows.join('\n')
|
||
await writeFile(outputFile, csvContent, 'utf-8')
|
||
|
||
console.log(` ✅ CSV报告已生成: ${outputFile}`)
|
||
console.log(` 缺失文件数: ${missingFiles.length}`)
|
||
console.log()
|
||
|
||
// 6. 按类型统计缺失文件
|
||
console.log('📊 按素材类型统计缺失文件:')
|
||
const typeStats: Record<string, number> = {}
|
||
for (const result of missingFiles) {
|
||
const type = result.materialType || '未知'
|
||
typeStats[type] = (typeStats[type] || 0) + 1
|
||
}
|
||
|
||
const sortedTypes = Object.entries(typeStats)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 10)
|
||
|
||
for (const [type, count] of sortedTypes) {
|
||
console.log(` ${type}: ${count} 个`)
|
||
}
|
||
|
||
console.log()
|
||
console.log('='.repeat(60))
|
||
console.log('检查完成')
|
||
console.log('='.repeat(60))
|
||
console.log(`CSV文件: ${outputFile}`)
|
||
console.log(`缺失文件: ${missingFiles.length} 个`)
|
||
|
||
await prisma.$disconnect()
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error('执行失败:', error)
|
||
process.exit(1)
|
||
})
|