gongxiangp2/scripts/check-source-files-status.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

325 lines
11 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.

/**
* 检查所有素材的源文件状态
* 生成详细报告:哪些文件存在,哪些丢失
*/
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();