gongxiangp2/scripts/check-missing-source-files.ts

337 lines
9.6 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.

/**
* 检查素材源文件是否缺失,生成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)
})