/** * 检查所有素材的源文件状态 * 生成详细报告:哪些文件存在,哪些丢失 */ import { PrismaClient } from '@prisma/client'; import { access } from 'fs/promises'; import path from 'path'; import { execFile } from 'child_process'; import { promisify } from 'util'; const execFileAsync = promisify(execFile); const prisma = new PrismaClient(); interface MaterialStatus { id: string; materialType: string; productModel: string; authorName: string; createdAt: Date; zipFilePath: string; zipFileName: string; videoSourceUrl: string | null; fileStatus: 'exists' | 'missing' | 'unknown'; fileLocation: string; fileSize: string; } // 检查生产服务器文件是否存在(192.168.58.20) async function checkProductionFile(filePath: string): Promise<{ exists: boolean; size: string }> { try { if (!filePath || filePath === '') { return { exists: false, size: '0' }; } // 构建完整的服务器路径 let serverPath = filePath; // 处理不同的路径格式 if (filePath.startsWith('/uploads/')) { // 格式: /uploads/video-xxx.mp4 或 /uploads/audio-source-xxx.mp3 serverPath = `/mnt/PoolOne/apps/youyidian-3d${filePath}`; } else if (filePath.startsWith('uploads/')) { // 格式: uploads/material-xxx.zip 或 uploads/product-package/xxx/source.zip serverPath = `/mnt/PoolOne/apps/youyidian-3d/${filePath}`; } else if (filePath.startsWith('/mnt/')) { // 已经是完整路径,不需要处理 serverPath = filePath; } else { // 其他相对路径,假设在 uploads 目录下 serverPath = `/mnt/PoolOne/youyidian-uploads/${filePath}`; } // 转义路径中的特殊字符 serverPath = serverPath.replace(/'/g, "'\\''"); const password = process.env.PRODUCTION_SERVER_PASSWORD; if (!password) { throw new Error('必须设置 PRODUCTION_SERVER_PASSWORD'); } const remoteCommand = `if [ -f '${serverPath}' ]; then stat -c '%s' '${serverPath}'; else echo 'NOT_FOUND'; fi`; const { stdout } = await execFileAsync( 'C:\\Program Files\\PuTTY\\plink.exe', ['-batch', '-no-antispoof', '-pw', password, 'root@192.168.58.20', remoteCommand], ); const output = stdout.trim(); if (output === 'NOT_FOUND' || output === '' || output.includes('not found') || output.includes('No such file')) { return { exists: false, size: '0' }; } const sizeBytes = parseInt(output); if (isNaN(sizeBytes)) { return { exists: false, size: '0' }; } const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2); return { exists: true, size: `${sizeMB} MB` }; } catch (error) { console.error(`检查文件失败: ${filePath}`, error); return { exists: false, size: '0' }; } } // 检查远程文件是否存在(103.118.245.6) async function checkRemoteFile(filePath: string): Promise<{ exists: boolean; size: string }> { try { if (!filePath || filePath === '') { return { exists: false, size: '0' }; } // 提取文件路径(去掉 http://103.118.245.6 前缀) let remotePath = filePath; if (filePath.includes('103.118.245.6')) { remotePath = filePath.replace(/^https?:\/\/103\.118\.245\.6/, ''); } // 转义路径中的特殊字符 remotePath = remotePath.replace(/'/g, "'\\''"); // 使用 plink 检查外网服务器上的文件 const command = `& "C:\\Program Files\\PuTTY\\plink.exe" -batch -no-antispoof -pw "peATjCBUn2Js" root@103.118.245.6 "if [ -f '${remotePath}' ]; then stat -c '%s' '${remotePath}'; else echo 'NOT_FOUND'; fi"`; const { stdout } = await execAsync(command, { shell: 'powershell.exe' }); const output = stdout.trim(); if (output === 'NOT_FOUND' || output === '') { return { exists: false, size: '0' }; } const sizeBytes = parseInt(output); const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2); return { exists: true, size: `${sizeMB} MB` }; } catch (error) { return { exists: false, size: '0' }; } } async function analyzeMaterials() { try { console.log('开始检查所有素材的源文件状态...\n'); console.log('这可能需要几分钟时间,请耐心等待...\n'); // 查询所有未删除的素材 const materials = await prisma.material.findMany({ where: { deletedAt: null, }, select: { id: true, materialType: true, productModel: true, authorName: true, createdAt: true, zipFilePath: true, zipFileName: true, videoSourceUrl: true, }, orderBy: { createdAt: 'desc', }, }); console.log(`共找到 ${materials.length} 个素材\n`); const results: MaterialStatus[] = []; // 并发处理,每批处理 20 个素材 const BATCH_SIZE = 20; const batches = []; for (let i = 0; i < materials.length; i += BATCH_SIZE) { batches.push(materials.slice(i, i + BATCH_SIZE)); } console.log(`将分 ${batches.length} 批处理,每批 ${BATCH_SIZE} 个素材\n`); let processedCount = 0; for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) { const batch = batches[batchIndex]; // 并发处理当前批次 const batchResults = await Promise.all( batch.map(async (material) => { let fileStatus: 'exists' | 'missing' | 'unknown' = 'unknown'; let fileLocation = ''; let fileSize = '0'; // 确定源文件路径 let sourceFilePath = ''; // 视频类型优先检查 videoSourceUrl if (material.videoSourceUrl) { sourceFilePath = material.videoSourceUrl; } else if (material.zipFilePath) { sourceFilePath = material.zipFilePath; } if (sourceFilePath) { // 判断文件位置 if (sourceFilePath.includes('103.118.245.6') || sourceFilePath.startsWith('http')) { // 远程文件 fileLocation = '外网服务器 (103.118.245.6)'; const { exists, size } = await checkRemoteFile(sourceFilePath); fileStatus = exists ? 'exists' : 'missing'; fileSize = size; } else if (sourceFilePath.startsWith('uploads/') || sourceFilePath.startsWith('/uploads/')) { // 生产服务器文件 fileLocation = '生产服务器 (192.168.58.20)'; const { exists, size } = await checkProductionFile(sourceFilePath); fileStatus = exists ? 'exists' : 'missing'; fileSize = size; } else { fileLocation = '未知位置'; fileStatus = 'unknown'; } } else { // 没有源文件路径(可能是只有预览图的素材) fileLocation = '无源文件'; fileStatus = 'missing'; } return { id: material.id, materialType: material.materialType, productModel: material.productModel, authorName: material.authorName, createdAt: material.createdAt, zipFilePath: material.zipFilePath || '', zipFileName: material.zipFileName || '', videoSourceUrl: material.videoSourceUrl, fileStatus, fileLocation, fileSize, }; }) ); results.push(...batchResults); processedCount += batch.length; // 显示进度 console.log(`已处理 ${processedCount}/${materials.length} 个素材 (${Math.round(processedCount/materials.length*100)}%)...`); } console.log('\n检查完成!正在生成报告...\n'); // 统计 const existsCount = results.filter(r => r.fileStatus === 'exists').length; const missingCount = results.filter(r => r.fileStatus === 'missing').length; const unknownCount = results.filter(r => r.fileStatus === 'unknown').length; console.log('统计结果:'); console.log(` 源文件存在: ${existsCount} 个`); console.log(` 源文件丢失: ${missingCount} 个`); console.log(` 状态未知: ${unknownCount} 个`); console.log(''); // 按状态分组 const existsMaterials = results.filter(r => r.fileStatus === 'exists'); const missingMaterials = results.filter(r => r.fileStatus === 'missing'); // 生成 CSV 报告 const csvLines = [ 'ID,素材类型,SPU型号,作者,创建时间,文件路径,文件名,文件状态,文件位置,文件大小', ]; results.forEach(r => { const line = [ r.id, r.materialType, r.productModel, r.authorName, r.createdAt.toISOString(), r.videoSourceUrl || r.zipFilePath, r.zipFileName, r.fileStatus === 'exists' ? '存在' : r.fileStatus === 'missing' ? '丢失' : '未知', r.fileLocation, r.fileSize, ].map(v => `"${v}"`).join(','); csvLines.push(line); }); const csvContent = csvLines.join('\n'); // 保存报告 const fs = require('fs'); const reportPath = 'source-files-status-report.csv'; fs.writeFileSync(reportPath, '\uFEFF' + csvContent, 'utf-8'); // 添加 BOM 以支持 Excel 打开中文 console.log(`详细报告已保存到: ${reportPath}`); console.log('可以用 Excel 打开查看\n'); // 显示丢失文件的摘要 if (missingCount > 0) { console.log('源文件丢失的素材(前20个):'); console.log('-'.repeat(100)); missingMaterials.slice(0, 20).forEach((m, index) => { console.log(`${index + 1}. ${m.materialType} - ${m.productModel} (${m.authorName})`); console.log(` 创建时间: ${m.createdAt.toISOString()}`); console.log(` 文件路径: ${m.videoSourceUrl || m.zipFilePath || '无'}`); console.log(` 位置: ${m.fileLocation}`); console.log(''); }); if (missingCount > 20) { console.log(` ... 还有 ${missingCount - 20} 个素材源文件丢失,详见 CSV 报告\n`); } } // 显示存在文件的摘要 if (existsCount > 0) { console.log('源文件存在的素材(前10个):'); console.log('-'.repeat(100)); existsMaterials.slice(0, 10).forEach((m, index) => { console.log(`${index + 1}. ${m.materialType} - ${m.productModel} (${m.authorName})`); console.log(` 创建时间: ${m.createdAt.toISOString()}`); console.log(` 文件大小: ${m.fileSize}`); console.log(` 位置: ${m.fileLocation}`); console.log(''); }); if (existsCount > 10) { console.log(` ... 还有 ${existsCount - 10} 个素材源文件存在,详见 CSV 报告\n`); } } console.log('分析完成!'); console.log('\n下一步建议:'); console.log(' 1. 打开 source-files-status-report.csv 查看完整报告'); console.log(' 2. 确认哪些素材需要保留、哪些可以清理'); console.log(' 3. 决定是否需要添加"源文件丢失"标记'); } catch (error) { console.error('分析失败:', error); } finally { await prisma.$disconnect(); } } analyzeMaterials();