Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
/**
|
||
* 检查所有素材的源文件状态
|
||
* 生成详细报告:哪些文件存在,哪些丢失
|
||
*/
|
||
|
||
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();
|