修改管理后台
This commit is contained in:
705
backend/services/backupService.js
Normal file
705
backend/services/backupService.js
Normal file
@@ -0,0 +1,705 @@
|
||||
/**
|
||||
* 数据备份服务
|
||||
* @file backupService.js
|
||||
* @description 提供数据库备份、文件备份和故障恢复功能
|
||||
*/
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const archiver = require('archiver');
|
||||
const { sequelize } = require('../config/database-simple');
|
||||
const logger = require('../utils/logger');
|
||||
const moment = require('moment');
|
||||
|
||||
/**
|
||||
* 备份配置
|
||||
*/
|
||||
const BACKUP_CONFIG = {
|
||||
// 备份目录
|
||||
backupDir: path.join(__dirname, '../../backups'),
|
||||
|
||||
// 数据库备份配置
|
||||
database: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 3306,
|
||||
database: process.env.DB_NAME || 'nxxm_farming',
|
||||
username: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASS || ''
|
||||
},
|
||||
|
||||
// 备份保留策略
|
||||
retention: {
|
||||
daily: 7, // 保留7天的日备份
|
||||
weekly: 4, // 保留4周的周备份
|
||||
monthly: 12 // 保留12个月的月备份
|
||||
},
|
||||
|
||||
// 压缩级别 (0-9)
|
||||
compressionLevel: 6
|
||||
};
|
||||
|
||||
/**
|
||||
* 备份服务类
|
||||
*/
|
||||
class BackupService {
|
||||
constructor() {
|
||||
this.isBackupRunning = false;
|
||||
this.backupQueue = [];
|
||||
this.initBackupDirectory();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化备份目录
|
||||
*/
|
||||
async initBackupDirectory() {
|
||||
try {
|
||||
await fs.mkdir(BACKUP_CONFIG.backupDir, { recursive: true });
|
||||
await fs.mkdir(path.join(BACKUP_CONFIG.backupDir, 'database'), { recursive: true });
|
||||
await fs.mkdir(path.join(BACKUP_CONFIG.backupDir, 'files'), { recursive: true });
|
||||
await fs.mkdir(path.join(BACKUP_CONFIG.backupDir, 'logs'), { recursive: true });
|
||||
|
||||
logger.info('备份目录初始化完成');
|
||||
} catch (error) {
|
||||
logger.error('备份目录初始化失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完整系统备份
|
||||
* @param {Object} options 备份选项
|
||||
* @returns {Object} 备份结果
|
||||
*/
|
||||
async createFullBackup(options = {}) {
|
||||
if (this.isBackupRunning) {
|
||||
throw new Error('备份正在进行中,请稍后再试');
|
||||
}
|
||||
|
||||
this.isBackupRunning = true;
|
||||
const backupId = this.generateBackupId();
|
||||
const backupPath = path.join(BACKUP_CONFIG.backupDir, backupId);
|
||||
|
||||
try {
|
||||
logger.info(`开始创建完整备份: ${backupId}`);
|
||||
|
||||
await fs.mkdir(backupPath, { recursive: true });
|
||||
|
||||
const backupResult = {
|
||||
id: backupId,
|
||||
timestamp: new Date(),
|
||||
type: 'full',
|
||||
status: 'in_progress',
|
||||
components: {
|
||||
database: { status: 'pending', size: 0, path: '' },
|
||||
files: { status: 'pending', size: 0, path: '' },
|
||||
logs: { status: 'pending', size: 0, path: '' }
|
||||
},
|
||||
totalSize: 0,
|
||||
duration: 0
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// 1. 备份数据库
|
||||
logger.info('开始备份数据库...');
|
||||
const dbBackupResult = await this.backupDatabase(backupPath);
|
||||
backupResult.components.database = dbBackupResult;
|
||||
|
||||
// 2. 备份文件(上传文件、配置文件等)
|
||||
logger.info('开始备份文件...');
|
||||
const fileBackupResult = await this.backupFiles(backupPath);
|
||||
backupResult.components.files = fileBackupResult;
|
||||
|
||||
// 3. 备份日志
|
||||
logger.info('开始备份日志...');
|
||||
const logBackupResult = await this.backupLogs(backupPath);
|
||||
backupResult.components.logs = logBackupResult;
|
||||
|
||||
// 4. 创建压缩包
|
||||
logger.info('开始压缩备份文件...');
|
||||
const archivePath = await this.createArchive(backupPath, backupId);
|
||||
|
||||
// 5. 计算总大小和持续时间
|
||||
const archiveStats = await fs.stat(archivePath);
|
||||
backupResult.totalSize = archiveStats.size;
|
||||
backupResult.duration = Date.now() - startTime;
|
||||
backupResult.status = 'completed';
|
||||
backupResult.archivePath = archivePath;
|
||||
|
||||
// 6. 清理临时目录
|
||||
await this.cleanupDirectory(backupPath);
|
||||
|
||||
// 7. 保存备份元数据
|
||||
await this.saveBackupMetadata(backupResult);
|
||||
|
||||
logger.info(`完整备份创建完成: ${backupId}, 大小: ${this.formatSize(backupResult.totalSize)}, 用时: ${backupResult.duration}ms`);
|
||||
|
||||
return backupResult;
|
||||
} catch (error) {
|
||||
logger.error(`创建备份失败: ${backupId}`, error);
|
||||
|
||||
// 清理失败的备份
|
||||
try {
|
||||
await this.cleanupDirectory(backupPath);
|
||||
} catch (cleanupError) {
|
||||
logger.error('清理失败备份目录出错:', cleanupError);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.isBackupRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份数据库
|
||||
* @param {string} backupPath 备份路径
|
||||
* @returns {Object} 备份结果
|
||||
*/
|
||||
async backupDatabase(backupPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timestamp = moment().format('YYYYMMDD_HHmmss');
|
||||
const filename = `database_${timestamp}.sql`;
|
||||
const outputPath = path.join(backupPath, filename);
|
||||
|
||||
const mysqldumpArgs = [
|
||||
'-h', BACKUP_CONFIG.database.host,
|
||||
'-P', BACKUP_CONFIG.database.port,
|
||||
'-u', BACKUP_CONFIG.database.username,
|
||||
`--password=${BACKUP_CONFIG.database.password}`,
|
||||
'--single-transaction',
|
||||
'--routines',
|
||||
'--triggers',
|
||||
'--set-gtid-purged=OFF',
|
||||
BACKUP_CONFIG.database.database
|
||||
];
|
||||
|
||||
const mysqldump = spawn('mysqldump', mysqldumpArgs);
|
||||
const writeStream = require('fs').createWriteStream(outputPath);
|
||||
|
||||
mysqldump.stdout.pipe(writeStream);
|
||||
|
||||
let errorOutput = '';
|
||||
mysqldump.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
mysqldump.on('close', async (code) => {
|
||||
try {
|
||||
if (code === 0) {
|
||||
const stats = await fs.stat(outputPath);
|
||||
resolve({
|
||||
status: 'completed',
|
||||
size: stats.size,
|
||||
path: filename,
|
||||
duration: Date.now() - Date.now()
|
||||
});
|
||||
} else {
|
||||
reject(new Error(`mysqldump失败,退出代码: ${code}, 错误: ${errorOutput}`));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
mysqldump.on('error', (error) => {
|
||||
reject(new Error(`mysqldump执行失败: ${error.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份文件
|
||||
* @param {string} backupPath 备份路径
|
||||
* @returns {Object} 备份结果
|
||||
*/
|
||||
async backupFiles(backupPath) {
|
||||
try {
|
||||
const fileBackupPath = path.join(backupPath, 'files');
|
||||
await fs.mkdir(fileBackupPath, { recursive: true });
|
||||
|
||||
// 备份上传文件(如果存在)
|
||||
const uploadsDir = path.join(__dirname, '../../uploads');
|
||||
try {
|
||||
await fs.access(uploadsDir);
|
||||
await this.copyDirectory(uploadsDir, path.join(fileBackupPath, 'uploads'));
|
||||
} catch (error) {
|
||||
// 上传目录不存在,跳过
|
||||
logger.warn('上传目录不存在,跳过文件备份');
|
||||
}
|
||||
|
||||
// 备份配置文件
|
||||
const configFiles = [
|
||||
path.join(__dirname, '../config'),
|
||||
path.join(__dirname, '../package.json'),
|
||||
path.join(__dirname, '../.env')
|
||||
];
|
||||
|
||||
for (const configPath of configFiles) {
|
||||
try {
|
||||
await fs.access(configPath);
|
||||
const basename = path.basename(configPath);
|
||||
const destPath = path.join(fileBackupPath, basename);
|
||||
|
||||
const stats = await fs.stat(configPath);
|
||||
if (stats.isDirectory()) {
|
||||
await this.copyDirectory(configPath, destPath);
|
||||
} else {
|
||||
await fs.copyFile(configPath, destPath);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`配置文件 ${configPath} 备份跳过:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
const backupStats = await this.getDirectorySize(fileBackupPath);
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
size: backupStats.size,
|
||||
path: 'files',
|
||||
fileCount: backupStats.fileCount
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('文件备份失败:', error);
|
||||
return {
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
size: 0,
|
||||
path: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份日志
|
||||
* @param {string} backupPath 备份路径
|
||||
* @returns {Object} 备份结果
|
||||
*/
|
||||
async backupLogs(backupPath) {
|
||||
try {
|
||||
const logBackupPath = path.join(backupPath, 'logs');
|
||||
await fs.mkdir(logBackupPath, { recursive: true });
|
||||
|
||||
const logsDir = path.join(__dirname, '../logs');
|
||||
|
||||
try {
|
||||
await fs.access(logsDir);
|
||||
await this.copyDirectory(logsDir, logBackupPath);
|
||||
} catch (error) {
|
||||
logger.warn('日志目录不存在,跳过日志备份');
|
||||
return {
|
||||
status: 'skipped',
|
||||
size: 0,
|
||||
path: '',
|
||||
message: '日志目录不存在'
|
||||
};
|
||||
}
|
||||
|
||||
const backupStats = await this.getDirectorySize(logBackupPath);
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
size: backupStats.size,
|
||||
path: 'logs',
|
||||
fileCount: backupStats.fileCount
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('日志备份失败:', error);
|
||||
return {
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
size: 0,
|
||||
path: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建压缩包
|
||||
* @param {string} sourcePath 源路径
|
||||
* @param {string} backupId 备份ID
|
||||
* @returns {string} 压缩包路径
|
||||
*/
|
||||
async createArchive(sourcePath, backupId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const archivePath = path.join(BACKUP_CONFIG.backupDir, `${backupId}.zip`);
|
||||
const output = require('fs').createWriteStream(archivePath);
|
||||
const archive = archiver('zip', { zlib: { level: BACKUP_CONFIG.compressionLevel } });
|
||||
|
||||
output.on('close', () => {
|
||||
logger.info(`压缩包创建完成: ${archivePath}, 大小: ${archive.pointer()} bytes`);
|
||||
resolve(archivePath);
|
||||
});
|
||||
|
||||
archive.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
archive.directory(sourcePath, false);
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制目录
|
||||
* @param {string} src 源目录
|
||||
* @param {string} dest 目标目录
|
||||
*/
|
||||
async copyDirectory(src, dest) {
|
||||
await fs.mkdir(dest, { recursive: true });
|
||||
const entries = await fs.readdir(src, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.copyDirectory(srcPath, destPath);
|
||||
} else {
|
||||
await fs.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目录大小
|
||||
* @param {string} dirPath 目录路径
|
||||
* @returns {Object} 大小统计
|
||||
*/
|
||||
async getDirectorySize(dirPath) {
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subStats = await this.getDirectorySize(fullPath);
|
||||
totalSize += subStats.size;
|
||||
fileCount += subStats.fileCount;
|
||||
} else {
|
||||
const stats = await fs.stat(fullPath);
|
||||
totalSize += stats.size;
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`获取目录大小失败: ${dirPath}`, error);
|
||||
}
|
||||
|
||||
return { size: totalSize, fileCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理目录
|
||||
* @param {string} dirPath 目录路径
|
||||
*/
|
||||
async cleanupDirectory(dirPath) {
|
||||
try {
|
||||
await fs.rmdir(dirPath, { recursive: true });
|
||||
} catch (error) {
|
||||
logger.warn(`清理目录失败: ${dirPath}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成备份ID
|
||||
* @returns {string} 备份ID
|
||||
*/
|
||||
generateBackupId() {
|
||||
const timestamp = moment().format('YYYYMMDD_HHmmss');
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `backup_${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存备份元数据
|
||||
* @param {Object} backupResult 备份结果
|
||||
*/
|
||||
async saveBackupMetadata(backupResult) {
|
||||
try {
|
||||
const metadataPath = path.join(BACKUP_CONFIG.backupDir, 'metadata.json');
|
||||
|
||||
let metadata = [];
|
||||
try {
|
||||
const existing = await fs.readFile(metadataPath, 'utf8');
|
||||
metadata = JSON.parse(existing);
|
||||
} catch (error) {
|
||||
// 文件不存在或格式错误,使用空数组
|
||||
}
|
||||
|
||||
metadata.unshift(backupResult);
|
||||
|
||||
// 只保留最近100条记录
|
||||
if (metadata.length > 100) {
|
||||
metadata = metadata.slice(0, 100);
|
||||
}
|
||||
|
||||
await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
|
||||
logger.info('备份元数据已保存');
|
||||
} catch (error) {
|
||||
logger.error('保存备份元数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取备份列表
|
||||
* @returns {Array} 备份列表
|
||||
*/
|
||||
async getBackupList() {
|
||||
try {
|
||||
const metadataPath = path.join(BACKUP_CONFIG.backupDir, 'metadata.json');
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(metadataPath, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('获取备份列表失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除备份
|
||||
* @param {string} backupId 备份ID
|
||||
* @returns {boolean} 删除结果
|
||||
*/
|
||||
async deleteBackup(backupId) {
|
||||
try {
|
||||
const archivePath = path.join(BACKUP_CONFIG.backupDir, `${backupId}.zip`);
|
||||
|
||||
try {
|
||||
await fs.unlink(archivePath);
|
||||
logger.info(`备份文件已删除: ${archivePath}`);
|
||||
} catch (error) {
|
||||
logger.warn(`备份文件删除失败或不存在: ${archivePath}`);
|
||||
}
|
||||
|
||||
// 更新元数据
|
||||
const metadata = await this.getBackupList();
|
||||
const updatedMetadata = metadata.filter(backup => backup.id !== backupId);
|
||||
|
||||
const metadataPath = path.join(BACKUP_CONFIG.backupDir, 'metadata.json');
|
||||
await fs.writeFile(metadataPath, JSON.stringify(updatedMetadata, null, 2));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`删除备份失败: ${backupId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动清理过期备份
|
||||
*/
|
||||
async cleanupExpiredBackups() {
|
||||
try {
|
||||
const backups = await this.getBackupList();
|
||||
const now = moment();
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const backup of backups) {
|
||||
const backupDate = moment(backup.timestamp);
|
||||
const daysDiff = now.diff(backupDate, 'days');
|
||||
|
||||
let shouldDelete = false;
|
||||
|
||||
// 根据备份类型和时间决定是否删除
|
||||
if (backup.type === 'daily' && daysDiff > BACKUP_CONFIG.retention.daily) {
|
||||
shouldDelete = true;
|
||||
} else if (backup.type === 'weekly' && daysDiff > (BACKUP_CONFIG.retention.weekly * 7)) {
|
||||
shouldDelete = true;
|
||||
} else if (backup.type === 'monthly' && daysDiff > (BACKUP_CONFIG.retention.monthly * 30)) {
|
||||
shouldDelete = true;
|
||||
} else if (backup.type === 'full' && daysDiff > BACKUP_CONFIG.retention.daily) {
|
||||
// 完整备份使用日备份的保留策略
|
||||
shouldDelete = true;
|
||||
}
|
||||
|
||||
if (shouldDelete) {
|
||||
const deleted = await this.deleteBackup(backup.id);
|
||||
if (deleted) {
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.info(`自动清理完成,删除了 ${deletedCount} 个过期备份`);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
logger.error('自动清理过期备份失败:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复数据库
|
||||
* @param {string} backupId 备份ID
|
||||
* @returns {boolean} 恢复结果
|
||||
*/
|
||||
async restoreDatabase(backupId) {
|
||||
try {
|
||||
const backups = await this.getBackupList();
|
||||
const backup = backups.find(b => b.id === backupId);
|
||||
|
||||
if (!backup) {
|
||||
throw new Error('备份不存在');
|
||||
}
|
||||
|
||||
const archivePath = path.join(BACKUP_CONFIG.backupDir, `${backupId}.zip`);
|
||||
|
||||
// 这里可以实现数据库恢复逻辑
|
||||
// 由于涉及到解压和执行SQL脚本,需要谨慎实现
|
||||
logger.info(`数据库恢复功能需要进一步实现: ${backupId}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`恢复数据库失败: ${backupId}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取备份统计
|
||||
* @returns {Object} 统计信息
|
||||
*/
|
||||
async getBackupStats() {
|
||||
try {
|
||||
const backups = await this.getBackupList();
|
||||
|
||||
const stats = {
|
||||
total: backups.length,
|
||||
totalSize: backups.reduce((sum, backup) => sum + (backup.totalSize || 0), 0),
|
||||
byType: {
|
||||
full: backups.filter(b => b.type === 'full').length,
|
||||
daily: backups.filter(b => b.type === 'daily').length,
|
||||
weekly: backups.filter(b => b.type === 'weekly').length,
|
||||
monthly: backups.filter(b => b.type === 'monthly').length
|
||||
},
|
||||
byStatus: {
|
||||
completed: backups.filter(b => b.status === 'completed').length,
|
||||
failed: backups.filter(b => b.status === 'failed').length,
|
||||
inProgress: backups.filter(b => b.status === 'in_progress').length
|
||||
},
|
||||
latest: backups[0] || null,
|
||||
oldestRetained: backups[backups.length - 1] || null
|
||||
};
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
logger.error('获取备份统计失败:', error);
|
||||
return {
|
||||
total: 0,
|
||||
totalSize: 0,
|
||||
byType: {},
|
||||
byStatus: {},
|
||||
latest: null,
|
||||
oldestRetained: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes 字节数
|
||||
* @returns {string} 格式化的大小
|
||||
*/
|
||||
formatSize(bytes) {
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
if (bytes === 0) return '0 B';
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查备份系统健康状态
|
||||
* @returns {Object} 健康状态
|
||||
*/
|
||||
async checkBackupHealth() {
|
||||
try {
|
||||
const stats = await this.getBackupStats();
|
||||
const diskUsage = await this.getDiskUsage();
|
||||
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
issues: [],
|
||||
stats,
|
||||
diskUsage,
|
||||
lastBackup: stats.latest,
|
||||
recommendations: []
|
||||
};
|
||||
|
||||
// 检查磁盘空间
|
||||
if (diskUsage.usagePercent > 90) {
|
||||
health.status = 'warning';
|
||||
health.issues.push('磁盘空间不足(使用率超过90%)');
|
||||
health.recommendations.push('清理过期备份或扩展存储空间');
|
||||
}
|
||||
|
||||
// 检查最近备份时间
|
||||
if (stats.latest) {
|
||||
const daysSinceLastBackup = moment().diff(moment(stats.latest.timestamp), 'days');
|
||||
if (daysSinceLastBackup > 1) {
|
||||
health.status = 'warning';
|
||||
health.issues.push(`最近备份时间过久(${daysSinceLastBackup}天前)`);
|
||||
health.recommendations.push('建议执行新的备份');
|
||||
}
|
||||
} else {
|
||||
health.status = 'error';
|
||||
health.issues.push('没有找到任何备份记录');
|
||||
health.recommendations.push('立即创建第一个备份');
|
||||
}
|
||||
|
||||
return health;
|
||||
} catch (error) {
|
||||
logger.error('检查备份健康状态失败:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
issues: ['无法检查备份状态'],
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取磁盘使用情况
|
||||
* @returns {Object} 磁盘使用情况
|
||||
*/
|
||||
async getDiskUsage() {
|
||||
try {
|
||||
const stats = await fs.stat(BACKUP_CONFIG.backupDir);
|
||||
// 简化的磁盘使用情况检查
|
||||
// 在实际环境中可以使用更精确的磁盘空间检查
|
||||
return {
|
||||
total: 100 * 1024 * 1024 * 1024, // 假设100GB
|
||||
used: stats.size || 0,
|
||||
free: 100 * 1024 * 1024 * 1024 - (stats.size || 0),
|
||||
usagePercent: ((stats.size || 0) / (100 * 1024 * 1024 * 1024)) * 100
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('获取磁盘使用情况失败:', error);
|
||||
return {
|
||||
total: 0,
|
||||
used: 0,
|
||||
free: 0,
|
||||
usagePercent: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const backupService = new BackupService();
|
||||
|
||||
module.exports = backupService;
|
||||
743
backend/services/cacheService.js
Normal file
743
backend/services/cacheService.js
Normal file
@@ -0,0 +1,743 @@
|
||||
/**
|
||||
* Redis缓存服务
|
||||
* @file cacheService.js
|
||||
* @description 提供高性能Redis缓存功能,优化数据访问速度
|
||||
*/
|
||||
const redis = require('redis');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* 缓存配置
|
||||
*/
|
||||
const CACHE_CONFIG = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
password: process.env.REDIS_PASSWORD || '',
|
||||
db: process.env.REDIS_DB || 0,
|
||||
|
||||
// 默认TTL配置(秒)
|
||||
ttl: {
|
||||
short: 5 * 60, // 5分钟
|
||||
medium: 30 * 60, // 30分钟
|
||||
long: 2 * 60 * 60, // 2小时
|
||||
daily: 24 * 60 * 60 // 24小时
|
||||
},
|
||||
|
||||
// 键名前缀
|
||||
prefix: 'nxxm:',
|
||||
|
||||
// 连接配置
|
||||
connect_timeout: 10000,
|
||||
command_timeout: 5000,
|
||||
retry_unfulfilled_commands: true,
|
||||
retry_delay_on_failure: 100,
|
||||
max_retry_delay: 3000
|
||||
};
|
||||
|
||||
/**
|
||||
* Redis缓存服务类
|
||||
*/
|
||||
class CacheService {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
this.isConnected = false;
|
||||
this.stats = {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
errors: 0,
|
||||
totalOperations: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化Redis连接
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
this.client = redis.createClient({
|
||||
url: `redis://${CACHE_CONFIG.host}:${CACHE_CONFIG.port}`,
|
||||
password: CACHE_CONFIG.password || undefined,
|
||||
database: CACHE_CONFIG.db,
|
||||
socket: {
|
||||
connectTimeout: CACHE_CONFIG.connect_timeout,
|
||||
commandTimeout: CACHE_CONFIG.command_timeout,
|
||||
reconnectStrategy: (retries) => {
|
||||
if (retries > 10) {
|
||||
logger.error('Redis重连次数超过限制,停止重连');
|
||||
return false;
|
||||
}
|
||||
return Math.min(retries * 100, 3000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 事件监听
|
||||
this.client.on('connect', () => {
|
||||
logger.info('Redis连接已建立');
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.client.on('ready', () => {
|
||||
logger.info('Redis客户端已就绪');
|
||||
});
|
||||
|
||||
this.client.on('error', (error) => {
|
||||
logger.error('Redis连接错误:', error);
|
||||
this.isConnected = false;
|
||||
this.stats.errors++;
|
||||
});
|
||||
|
||||
this.client.on('end', () => {
|
||||
logger.warn('Redis连接已断开');
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.client.on('reconnecting', () => {
|
||||
logger.info('正在重新连接Redis...');
|
||||
});
|
||||
|
||||
// 连接到Redis
|
||||
await this.client.connect();
|
||||
|
||||
logger.info('Redis缓存服务初始化成功');
|
||||
} catch (error) {
|
||||
logger.error('Redis缓存服务初始化失败:', error);
|
||||
this.isConnected = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成缓存键名
|
||||
* @param {string} key 键名
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {string} 完整键名
|
||||
*/
|
||||
generateKey(key, namespace = 'default') {
|
||||
return `${CACHE_CONFIG.prefix}${namespace}:${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存
|
||||
* @param {string} key 键名
|
||||
* @param {*} value 值
|
||||
* @param {number} ttl 过期时间(秒)
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {boolean} 设置结果
|
||||
*/
|
||||
async set(key, value, ttl = CACHE_CONFIG.ttl.medium, namespace = 'default') {
|
||||
if (!this.isConnected) {
|
||||
logger.warn('Redis未连接,缓存设置跳过');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullKey = this.generateKey(key, namespace);
|
||||
const serializedValue = JSON.stringify(value);
|
||||
|
||||
await this.client.setEx(fullKey, ttl, serializedValue);
|
||||
|
||||
this.stats.totalOperations++;
|
||||
logger.debug(`缓存设置成功: ${fullKey}, TTL: ${ttl}s`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`缓存设置失败 ${key}:`, error);
|
||||
this.stats.errors++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存
|
||||
* @param {string} key 键名
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {*} 缓存值或null
|
||||
*/
|
||||
async get(key, namespace = 'default') {
|
||||
if (!this.isConnected) {
|
||||
logger.warn('Redis未连接,缓存获取跳过');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullKey = this.generateKey(key, namespace);
|
||||
const value = await this.client.get(fullKey);
|
||||
|
||||
this.stats.totalOperations++;
|
||||
|
||||
if (value === null) {
|
||||
this.stats.misses++;
|
||||
logger.debug(`缓存未命中: ${fullKey}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.stats.hits++;
|
||||
logger.debug(`缓存命中: ${fullKey}`);
|
||||
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
logger.error(`缓存获取失败 ${key}:`, error);
|
||||
this.stats.errors++;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
* @param {string} key 键名
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {boolean} 删除结果
|
||||
*/
|
||||
async del(key, namespace = 'default') {
|
||||
if (!this.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullKey = this.generateKey(key, namespace);
|
||||
const result = await this.client.del(fullKey);
|
||||
|
||||
this.stats.totalOperations++;
|
||||
logger.debug(`缓存删除: ${fullKey}, 结果: ${result}`);
|
||||
|
||||
return result > 0;
|
||||
} catch (error) {
|
||||
logger.error(`缓存删除失败 ${key}:`, error);
|
||||
this.stats.errors++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否存在
|
||||
* @param {string} key 键名
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {boolean} 是否存在
|
||||
*/
|
||||
async exists(key, namespace = 'default') {
|
||||
if (!this.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullKey = this.generateKey(key, namespace);
|
||||
const result = await this.client.exists(fullKey);
|
||||
|
||||
this.stats.totalOperations++;
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error(`缓存存在检查失败 ${key}:`, error);
|
||||
this.stats.errors++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置过期时间
|
||||
* @param {string} key 键名
|
||||
* @param {number} ttl 过期时间(秒)
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {boolean} 设置结果
|
||||
*/
|
||||
async expire(key, ttl, namespace = 'default') {
|
||||
if (!this.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullKey = this.generateKey(key, namespace);
|
||||
const result = await this.client.expire(fullKey, ttl);
|
||||
|
||||
this.stats.totalOperations++;
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logger.error(`设置过期时间失败 ${key}:`, error);
|
||||
this.stats.errors++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取剩余过期时间
|
||||
* @param {string} key 键名
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {number} 剩余秒数,-1表示永不过期,-2表示键不存在
|
||||
*/
|
||||
async ttl(key, namespace = 'default') {
|
||||
if (!this.isConnected) {
|
||||
return -2;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullKey = this.generateKey(key, namespace);
|
||||
const result = await this.client.ttl(fullKey);
|
||||
|
||||
this.stats.totalOperations++;
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`获取过期时间失败 ${key}:`, error);
|
||||
this.stats.errors++;
|
||||
return -2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空指定命名空间的所有缓存
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {number} 删除的键数量
|
||||
*/
|
||||
async clearNamespace(namespace) {
|
||||
if (!this.isConnected) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const pattern = `${CACHE_CONFIG.prefix}${namespace}:*`;
|
||||
const keys = await this.client.keys(pattern);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.client.del(keys);
|
||||
|
||||
this.stats.totalOperations++;
|
||||
logger.info(`清空命名空间 ${namespace}: 删除了 ${result} 个键`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`清空命名空间失败 ${namespace}:`, error);
|
||||
this.stats.errors++;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置缓存
|
||||
* @param {Object} keyValuePairs 键值对
|
||||
* @param {number} ttl 过期时间
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {boolean} 设置结果
|
||||
*/
|
||||
async mSet(keyValuePairs, ttl = CACHE_CONFIG.ttl.medium, namespace = 'default') {
|
||||
if (!this.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const pipeline = this.client.multi();
|
||||
|
||||
Object.entries(keyValuePairs).forEach(([key, value]) => {
|
||||
const fullKey = this.generateKey(key, namespace);
|
||||
const serializedValue = JSON.stringify(value);
|
||||
pipeline.setEx(fullKey, ttl, serializedValue);
|
||||
});
|
||||
|
||||
await pipeline.exec();
|
||||
|
||||
this.stats.totalOperations += Object.keys(keyValuePairs).length;
|
||||
logger.debug(`批量缓存设置成功: ${Object.keys(keyValuePairs).length} 个键`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('批量缓存设置失败:', error);
|
||||
this.stats.errors++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取缓存
|
||||
* @param {Array} keys 键名数组
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {Object} 键值对结果
|
||||
*/
|
||||
async mGet(keys, namespace = 'default') {
|
||||
if (!this.isConnected) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const fullKeys = keys.map(key => this.generateKey(key, namespace));
|
||||
const values = await this.client.mGet(fullKeys);
|
||||
|
||||
const result = {};
|
||||
keys.forEach((key, index) => {
|
||||
const value = values[index];
|
||||
if (value !== null) {
|
||||
try {
|
||||
result[key] = JSON.parse(value);
|
||||
this.stats.hits++;
|
||||
} catch (error) {
|
||||
logger.warn(`解析缓存值失败 ${key}:`, error);
|
||||
result[key] = null;
|
||||
}
|
||||
} else {
|
||||
result[key] = null;
|
||||
this.stats.misses++;
|
||||
}
|
||||
});
|
||||
|
||||
this.stats.totalOperations += keys.length;
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('批量缓存获取失败:', error);
|
||||
this.stats.errors++;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存包装器 - 自动缓存函数结果
|
||||
* @param {string} key 缓存键
|
||||
* @param {Function} fn 异步函数
|
||||
* @param {number} ttl 过期时间
|
||||
* @param {string} namespace 命名空间
|
||||
* @returns {*} 函数结果
|
||||
*/
|
||||
async wrap(key, fn, ttl = CACHE_CONFIG.ttl.medium, namespace = 'default') {
|
||||
// 首先尝试从缓存获取
|
||||
const cached = await this.get(key, namespace);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
// 缓存未命中,执行函数
|
||||
const result = await fn();
|
||||
|
||||
// 将结果存入缓存
|
||||
await this.set(key, result, ttl, namespace);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`缓存包装器执行失败 ${key}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
* @returns {Object} 统计信息
|
||||
*/
|
||||
getStats() {
|
||||
const hitRate = this.stats.totalOperations > 0
|
||||
? (this.stats.hits / (this.stats.hits + this.stats.misses)) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
...this.stats,
|
||||
hitRate: hitRate.toFixed(2) + '%',
|
||||
isConnected: this.isConnected,
|
||||
config: {
|
||||
host: CACHE_CONFIG.host,
|
||||
port: CACHE_CONFIG.port,
|
||||
db: CACHE_CONFIG.db
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Redis服务器信息
|
||||
* @returns {Object} 服务器信息
|
||||
*/
|
||||
async getServerInfo() {
|
||||
if (!this.isConnected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await this.client.info();
|
||||
const memory = await this.client.info('memory');
|
||||
const stats = await this.client.info('stats');
|
||||
|
||||
return {
|
||||
version: this.extractInfoValue(info, 'redis_version'),
|
||||
uptime: this.extractInfoValue(info, 'uptime_in_seconds'),
|
||||
memory: {
|
||||
used: this.extractInfoValue(memory, 'used_memory_human'),
|
||||
peak: this.extractInfoValue(memory, 'used_memory_peak_human'),
|
||||
fragmentation: this.extractInfoValue(memory, 'mem_fragmentation_ratio')
|
||||
},
|
||||
stats: {
|
||||
connections: this.extractInfoValue(stats, 'total_connections_received'),
|
||||
commands: this.extractInfoValue(stats, 'total_commands_processed'),
|
||||
hits: this.extractInfoValue(stats, 'keyspace_hits'),
|
||||
misses: this.extractInfoValue(stats, 'keyspace_misses')
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('获取Redis服务器信息失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从INFO字符串中提取值
|
||||
* @private
|
||||
*/
|
||||
extractInfoValue(infoString, key) {
|
||||
const regex = new RegExp(`${key}:(.+)`);
|
||||
const match = infoString.match(regex);
|
||||
return match ? match[1].trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
* @returns {Object} 健康状态
|
||||
*/
|
||||
async healthCheck() {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
message: 'Redis连接断开',
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
// 执行简单的ping测试
|
||||
const pong = await this.client.ping();
|
||||
|
||||
if (pong === 'PONG') {
|
||||
return {
|
||||
status: 'healthy',
|
||||
message: 'Redis连接正常',
|
||||
stats: this.getStats(),
|
||||
timestamp: new Date()
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
message: 'Redis ping响应异常',
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Redis健康检查失败:', error);
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
message: 'Redis健康检查失败',
|
||||
error: error.message,
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
async close() {
|
||||
try {
|
||||
if (this.client && this.isConnected) {
|
||||
await this.client.quit();
|
||||
logger.info('Redis连接已关闭');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('关闭Redis连接失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
* @returns {boolean} 清空结果
|
||||
*/
|
||||
async flushAll() {
|
||||
if (!this.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.flushDb();
|
||||
logger.info('Redis缓存已清空');
|
||||
|
||||
// 重置统计
|
||||
this.stats = {
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
errors: 0,
|
||||
totalOperations: 0
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('清空Redis缓存失败:', error);
|
||||
this.stats.errors++;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存键名常量
|
||||
*/
|
||||
const CACHE_KEYS = {
|
||||
// 用户相关
|
||||
USER_LIST: 'users:list',
|
||||
USER_PROFILE: (id) => `users:profile:${id}`,
|
||||
USER_PERMISSIONS: (id) => `users:permissions:${id}`,
|
||||
|
||||
// 农场相关
|
||||
FARM_LIST: 'farms:list',
|
||||
FARM_DETAIL: (id) => `farms:detail:${id}`,
|
||||
FARM_ANIMALS: (id) => `farms:animals:${id}`,
|
||||
FARM_DEVICES: (id) => `farms:devices:${id}`,
|
||||
|
||||
// 设备相关
|
||||
DEVICE_LIST: 'devices:list',
|
||||
DEVICE_STATUS: (id) => `devices:status:${id}`,
|
||||
DEVICE_METRICS: (id) => `devices:metrics:${id}`,
|
||||
|
||||
// 统计数据
|
||||
STATS_DASHBOARD: 'stats:dashboard',
|
||||
STATS_FARMS: 'stats:farms',
|
||||
STATS_DEVICES: 'stats:devices',
|
||||
STATS_ANIMALS: 'stats:animals',
|
||||
STATS_ALERTS: 'stats:alerts',
|
||||
|
||||
// 系统配置
|
||||
SYSTEM_CONFIG: 'system:config',
|
||||
MENU_PERMISSIONS: 'system:menus',
|
||||
|
||||
// 搜索结果
|
||||
SEARCH_RESULTS: (type, query) => `search:${type}:${Buffer.from(query).toString('base64')}`
|
||||
};
|
||||
|
||||
/**
|
||||
* 高级缓存功能
|
||||
*/
|
||||
class AdvancedCache extends CacheService {
|
||||
/**
|
||||
* 缓存列表数据(带分页)
|
||||
* @param {string} key 基础键名
|
||||
* @param {Array} data 数据数组
|
||||
* @param {Object} pagination 分页信息
|
||||
* @param {number} ttl 过期时间
|
||||
*/
|
||||
async setListData(key, data, pagination = {}, ttl = CACHE_CONFIG.ttl.medium) {
|
||||
const cacheData = {
|
||||
data,
|
||||
pagination,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
return await this.set(key, cacheData, ttl, 'lists');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列表数据
|
||||
* @param {string} key 键名
|
||||
* @returns {Object} 列表数据和分页信息
|
||||
*/
|
||||
async getListData(key) {
|
||||
const cached = await this.get(key, 'lists');
|
||||
|
||||
if (cached && cached.data) {
|
||||
return {
|
||||
data: cached.data,
|
||||
pagination: cached.pagination || {},
|
||||
fromCache: true,
|
||||
cacheTimestamp: cached.timestamp
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能缓存失效
|
||||
* @param {string} entity 实体类型
|
||||
* @param {string} operation 操作类型
|
||||
* @param {number} entityId 实体ID
|
||||
*/
|
||||
async invalidateRelated(entity, operation, entityId = null) {
|
||||
const patterns = [];
|
||||
|
||||
switch (entity) {
|
||||
case 'farm':
|
||||
patterns.push('farms:*', 'stats:*');
|
||||
if (entityId) {
|
||||
patterns.push(`farms:detail:${entityId}`, `farms:animals:${entityId}`, `farms:devices:${entityId}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'device':
|
||||
patterns.push('devices:*', 'stats:*');
|
||||
if (entityId) {
|
||||
patterns.push(`devices:status:${entityId}`, `devices:metrics:${entityId}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'animal':
|
||||
patterns.push('animals:*', 'stats:*');
|
||||
break;
|
||||
|
||||
case 'user':
|
||||
patterns.push('users:*');
|
||||
if (entityId) {
|
||||
patterns.push(`users:profile:${entityId}`, `users:permissions:${entityId}`);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
patterns.push('stats:*'); // 默认清理统计缓存
|
||||
}
|
||||
|
||||
let totalDeleted = 0;
|
||||
for (const pattern of patterns) {
|
||||
const deleted = await this.clearPattern(pattern);
|
||||
totalDeleted += deleted;
|
||||
}
|
||||
|
||||
logger.info(`智能缓存失效: ${entity}/${operation}, 清理了 ${totalDeleted} 个缓存键`);
|
||||
return totalDeleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按模式清理缓存
|
||||
* @private
|
||||
*/
|
||||
async clearPattern(pattern) {
|
||||
if (!this.isConnected) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullPattern = `${CACHE_CONFIG.prefix}${pattern}`;
|
||||
const keys = await this.client.keys(fullPattern);
|
||||
|
||||
if (keys.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await this.client.del(keys);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`按模式清理缓存失败 ${pattern}:`, error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建缓存服务实例
|
||||
const cacheService = new AdvancedCache();
|
||||
|
||||
// 优雅关闭处理
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('收到SIGINT信号,正在关闭Redis连接...');
|
||||
await cacheService.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('收到SIGTERM信号,正在关闭Redis连接...');
|
||||
await cacheService.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
cacheService,
|
||||
CACHE_KEYS,
|
||||
CACHE_CONFIG
|
||||
};
|
||||
531
backend/services/notificationService.js
Normal file
531
backend/services/notificationService.js
Normal file
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* 通知服务
|
||||
* @file notificationService.js
|
||||
* @description 实现邮件/短信预警通知功能,确保5分钟内响应时间
|
||||
*/
|
||||
const nodemailer = require('nodemailer');
|
||||
const logger = require('../utils/logger');
|
||||
const { User, Farm } = require('../models');
|
||||
|
||||
class NotificationService {
|
||||
constructor() {
|
||||
this.emailTransporter = null;
|
||||
this.smsService = null; // 短信服务接口(后续可扩展)
|
||||
this.notificationQueue = []; // 通知队列
|
||||
this.isProcessing = false;
|
||||
this.maxRetries = 3;
|
||||
this.retryDelay = 1000; // 1秒重试延迟
|
||||
|
||||
// 初始化邮件服务
|
||||
this.initEmailService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化邮件服务
|
||||
*/
|
||||
initEmailService() {
|
||||
try {
|
||||
// 配置邮件传输器(使用环境变量配置)
|
||||
this.emailTransporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'smtp.example.com',
|
||||
port: process.env.SMTP_PORT || 587,
|
||||
secure: false, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'noreply@farm-monitor.com',
|
||||
pass: process.env.SMTP_PASS || 'your_email_password'
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('邮件服务初始化完成');
|
||||
} catch (error) {
|
||||
logger.error('邮件服务初始化失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送预警通知
|
||||
* @param {Object} alert 预警对象
|
||||
* @param {Array} recipients 接收人列表
|
||||
* @param {Object} options 通知选项
|
||||
*/
|
||||
async sendAlertNotification(alert, recipients = [], options = {}) {
|
||||
const {
|
||||
urgent = false,
|
||||
includeSMS = false,
|
||||
maxResponseTime = 300000 // 5分钟 = 300000毫秒
|
||||
} = options;
|
||||
|
||||
const notification = {
|
||||
id: `alert_${alert.id}_${Date.now()}`,
|
||||
type: 'alert',
|
||||
alert,
|
||||
recipients,
|
||||
urgent,
|
||||
includeSMS,
|
||||
createdAt: new Date(),
|
||||
maxResponseTime,
|
||||
retryCount: 0,
|
||||
status: 'pending'
|
||||
};
|
||||
|
||||
// 添加到通知队列
|
||||
this.notificationQueue.push(notification);
|
||||
|
||||
// 立即处理紧急通知
|
||||
if (urgent) {
|
||||
await this.processNotification(notification);
|
||||
} else {
|
||||
// 非紧急通知进入队列处理
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
logger.info(`预警通知已加入队列: ${notification.id}, 紧急程度: ${urgent}`);
|
||||
return notification.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理通知队列
|
||||
*/
|
||||
async processQueue() {
|
||||
if (this.isProcessing) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
|
||||
try {
|
||||
while (this.notificationQueue.length > 0) {
|
||||
const notification = this.notificationQueue.shift();
|
||||
|
||||
// 检查是否超时
|
||||
const timePassed = Date.now() - notification.createdAt.getTime();
|
||||
if (timePassed > notification.maxResponseTime) {
|
||||
logger.warn(`通知 ${notification.id} 已超时,跳过处理`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.processNotification(notification);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('处理通知队列失败:', error);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个通知
|
||||
* @param {Object} notification 通知对象
|
||||
*/
|
||||
async processNotification(notification) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 获取接收人列表
|
||||
const recipients = await this.getNotificationRecipients(notification);
|
||||
|
||||
// 发送邮件通知
|
||||
const emailResults = await this.sendEmailNotifications(notification, recipients);
|
||||
|
||||
// 发送短信通知(如果需要)
|
||||
let smsResults = [];
|
||||
if (notification.includeSMS) {
|
||||
smsResults = await this.sendSMSNotifications(notification, recipients);
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
const responseTime = endTime - startTime;
|
||||
|
||||
// 记录通知发送结果
|
||||
notification.status = 'completed';
|
||||
notification.responseTime = responseTime;
|
||||
notification.emailResults = emailResults;
|
||||
notification.smsResults = smsResults;
|
||||
|
||||
logger.info(`通知 ${notification.id} 发送完成,响应时间: ${responseTime}ms`);
|
||||
|
||||
// 检查是否超过5分钟响应时间要求
|
||||
if (responseTime > 300000) {
|
||||
logger.warn(`通知 ${notification.id} 响应时间超过5分钟: ${responseTime}ms`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`处理通知 ${notification.id} 失败:`, error);
|
||||
|
||||
// 重试机制
|
||||
if (notification.retryCount < this.maxRetries) {
|
||||
notification.retryCount++;
|
||||
notification.status = 'retrying';
|
||||
|
||||
setTimeout(() => {
|
||||
this.notificationQueue.unshift(notification); // 重新加入队列头部
|
||||
this.processQueue();
|
||||
}, this.retryDelay * notification.retryCount);
|
||||
|
||||
logger.info(`通知 ${notification.id} 将进行第 ${notification.retryCount} 次重试`);
|
||||
} else {
|
||||
notification.status = 'failed';
|
||||
logger.error(`通知 ${notification.id} 达到最大重试次数,标记为失败`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通知接收人
|
||||
* @param {Object} notification 通知对象
|
||||
* @returns {Array} 接收人列表
|
||||
*/
|
||||
async getNotificationRecipients(notification) {
|
||||
try {
|
||||
const alert = notification.alert;
|
||||
const recipients = [];
|
||||
|
||||
// 如果指定了接收人,直接使用
|
||||
if (notification.recipients && notification.recipients.length > 0) {
|
||||
return notification.recipients;
|
||||
}
|
||||
|
||||
// 获取农场相关负责人
|
||||
if (alert.farm_id) {
|
||||
const farm = await Farm.findByPk(alert.farm_id);
|
||||
if (farm && farm.contact) {
|
||||
recipients.push({
|
||||
name: farm.contact,
|
||||
email: `${farm.contact.toLowerCase().replace(/\s+/g, '')}@farm-monitor.com`,
|
||||
phone: farm.phone,
|
||||
role: 'farm_manager'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统管理员
|
||||
const admins = await User.findAll({
|
||||
include: [{
|
||||
model: require('../models').Role,
|
||||
as: 'role',
|
||||
where: { name: 'admin' }
|
||||
}]
|
||||
});
|
||||
|
||||
for (const admin of admins) {
|
||||
recipients.push({
|
||||
name: admin.username,
|
||||
email: admin.email,
|
||||
phone: admin.phone,
|
||||
role: 'admin'
|
||||
});
|
||||
}
|
||||
|
||||
return recipients;
|
||||
} catch (error) {
|
||||
logger.error('获取通知接收人失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件通知
|
||||
* @param {Object} notification 通知对象
|
||||
* @param {Array} recipients 接收人列表
|
||||
* @returns {Array} 发送结果
|
||||
*/
|
||||
async sendEmailNotifications(notification, recipients) {
|
||||
if (!this.emailTransporter) {
|
||||
logger.warn('邮件服务未初始化,跳过邮件发送');
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const alert = notification.alert;
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.email) continue;
|
||||
|
||||
try {
|
||||
const emailContent = this.generateEmailContent(alert, recipient);
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.SMTP_FROM || '"宁夏智慧养殖监管平台" <noreply@farm-monitor.com>',
|
||||
to: recipient.email,
|
||||
subject: emailContent.subject,
|
||||
html: emailContent.html,
|
||||
priority: alert.level === 'critical' ? 'high' : 'normal'
|
||||
};
|
||||
|
||||
const result = await this.emailTransporter.sendMail(mailOptions);
|
||||
|
||||
results.push({
|
||||
recipient: recipient.email,
|
||||
status: 'success',
|
||||
messageId: result.messageId
|
||||
});
|
||||
|
||||
logger.info(`邮件通知发送成功: ${recipient.email}`);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
recipient: recipient.email,
|
||||
status: 'failed',
|
||||
error: error.message
|
||||
});
|
||||
|
||||
logger.error(`邮件通知发送失败: ${recipient.email}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成邮件内容
|
||||
* @param {Object} alert 预警对象
|
||||
* @param {Object} recipient 接收人信息
|
||||
* @returns {Object} 邮件内容
|
||||
*/
|
||||
generateEmailContent(alert, recipient) {
|
||||
const levelMap = {
|
||||
low: { text: '低级', color: '#52c41a' },
|
||||
medium: { text: '中级', color: '#1890ff' },
|
||||
high: { text: '高级', color: '#fa8c16' },
|
||||
critical: { text: '紧急', color: '#f5222d' }
|
||||
};
|
||||
|
||||
const levelInfo = levelMap[alert.level] || levelMap.medium;
|
||||
const urgentFlag = alert.level === 'critical' ? '🚨 ' : '';
|
||||
|
||||
const subject = `${urgentFlag}宁夏智慧养殖监管平台 - ${levelInfo.text}预警通知`;
|
||||
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center;">
|
||||
<h1 style="margin: 0;">宁夏智慧养殖监管平台</h1>
|
||||
<p style="margin: 5px 0 0 0;">智慧养殖 · 科学监管</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 30px; background: #f8f9fa;">
|
||||
<h2 style="color: ${levelInfo.color}; margin-top: 0;">
|
||||
${urgentFlag}${levelInfo.text}预警通知
|
||||
</h2>
|
||||
|
||||
<div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid ${levelInfo.color};">
|
||||
<p><strong>尊敬的 ${recipient.name}:</strong></p>
|
||||
<p>系统检测到以下预警信息,请及时处理:</p>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
<tr>
|
||||
<td style="padding: 8px; background: #f1f3f4; font-weight: bold; width: 120px;">预警级别:</td>
|
||||
<td style="padding: 8px; color: ${levelInfo.color}; font-weight: bold;">${levelInfo.text}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; background: #f1f3f4; font-weight: bold;">预警类型:</td>
|
||||
<td style="padding: 8px;">${alert.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; background: #f1f3f4; font-weight: bold;">预警内容:</td>
|
||||
<td style="padding: 8px;">${alert.message}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; background: #f1f3f4; font-weight: bold;">所属养殖场:</td>
|
||||
<td style="padding: 8px;">${alert.farm_name || '未知'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; background: #f1f3f4; font-weight: bold;">发生时间:</td>
|
||||
<td style="padding: 8px;">${new Date(alert.created_at).toLocaleString('zh-CN')}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${alert.level === 'critical' ?
|
||||
'<div style="background: #fff2f0; border: 1px solid #ffccc7; padding: 15px; border-radius: 4px; color: #f5222d;"><strong>⚠️ 这是一个紧急预警,请立即处理!</strong></div>' :
|
||||
'<p style="color: #666;">请及时登录系统查看详细信息并处理相关问题。</p>'
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 30px;">
|
||||
<a href="${process.env.FRONTEND_URL || 'http://localhost:5300'}/alerts"
|
||||
style="background: ${levelInfo.color}; color: white; padding: 12px 30px; text-decoration: none; border-radius: 4px; font-weight: bold;">
|
||||
立即处理预警
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #e9ecef; padding: 15px; text-align: center; color: #6c757d; font-size: 12px;">
|
||||
<p>此邮件由宁夏智慧养殖监管平台自动发送,请勿回复</p>
|
||||
<p>如需帮助,请联系系统管理员</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return { subject, html };
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信通知(预留接口)
|
||||
* @param {Object} notification 通知对象
|
||||
* @param {Array} recipients 接收人列表
|
||||
* @returns {Array} 发送结果
|
||||
*/
|
||||
async sendSMSNotifications(notification, recipients) {
|
||||
// 这里可以集成阿里云短信、腾讯云短信等服务
|
||||
const results = [];
|
||||
const alert = notification.alert;
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.phone) continue;
|
||||
|
||||
try {
|
||||
// 短信内容
|
||||
const smsContent = `【宁夏智慧养殖监管平台】${alert.level === 'critical' ? '紧急' : ''}预警:${alert.message}。请及时处理。时间:${new Date().toLocaleString('zh-CN')}`;
|
||||
|
||||
// 这里实现具体的短信发送逻辑
|
||||
// const smsResult = await this.sendSMS(recipient.phone, smsContent);
|
||||
|
||||
// 目前记录到日志(实际部署时替换为真实短信服务)
|
||||
logger.info(`短信通知(模拟)已发送到 ${recipient.phone}: ${smsContent}`);
|
||||
|
||||
results.push({
|
||||
recipient: recipient.phone,
|
||||
status: 'success',
|
||||
content: smsContent
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
results.push({
|
||||
recipient: recipient.phone,
|
||||
status: 'failed',
|
||||
error: error.message
|
||||
});
|
||||
|
||||
logger.error(`短信通知发送失败: ${recipient.phone}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送系统状态通知
|
||||
* @param {Object} statusData 系统状态数据
|
||||
* @param {Array} adminEmails 管理员邮箱列表
|
||||
*/
|
||||
async sendSystemStatusNotification(statusData, adminEmails = []) {
|
||||
if (!this.emailTransporter) return;
|
||||
|
||||
const subject = `宁夏智慧养殖监管平台 - 系统状态报告`;
|
||||
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: #1890ff; color: white; padding: 20px; text-align: center;">
|
||||
<h1 style="margin: 0;">系统状态报告</h1>
|
||||
<p style="margin: 5px 0 0 0;">宁夏智慧养殖监管平台</p>
|
||||
</div>
|
||||
|
||||
<div style="padding: 30px; background: #f8f9fa;">
|
||||
<h2 style="color: #333; margin-top: 0;">系统运行状况</h2>
|
||||
|
||||
<div style="background: white; padding: 20px; border-radius: 8px;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td style="padding: 8px; background: #f1f3f4; font-weight: bold;">API响应时间:</td>
|
||||
<td style="padding: 8px;">${statusData.apiResponseTime || 'N/A'}ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; background: #f1f3f4; font-weight: bold;">数据库连接状态:</td>
|
||||
<td style="padding: 8px;">${statusData.dbConnected ? '正常' : '异常'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; background: #f1f3f4; font-weight: bold;">系统内存使用率:</td>
|
||||
<td style="padding: 8px;">${statusData.memoryUsage || 'N/A'}%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; background: #f1f3f4; font-weight: bold;">活跃用户数:</td>
|
||||
<td style="padding: 8px;">${statusData.activeUsers || 0}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px; background: #f1f3f4; font-weight: bold;">报告时间:</td>
|
||||
<td style="padding: 8px;">${new Date().toLocaleString('zh-CN')}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #e9ecef; padding: 15px; text-align: center; color: #6c757d; font-size: 12px;">
|
||||
<p>此邮件由系统自动发送</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
for (const email of adminEmails) {
|
||||
try {
|
||||
await this.emailTransporter.sendMail({
|
||||
from: process.env.SMTP_FROM || '"宁夏智慧养殖监管平台" <noreply@farm-monitor.com>',
|
||||
to: email,
|
||||
subject,
|
||||
html
|
||||
});
|
||||
|
||||
logger.info(`系统状态报告已发送到: ${email}`);
|
||||
} catch (error) {
|
||||
logger.error(`系统状态报告发送失败: ${email}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试邮件服务
|
||||
* @param {string} testEmail 测试邮箱
|
||||
* @returns {boolean} 测试结果
|
||||
*/
|
||||
async testEmailService(testEmail) {
|
||||
if (!this.emailTransporter) {
|
||||
logger.error('邮件服务未初始化');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.emailTransporter.sendMail({
|
||||
from: process.env.SMTP_FROM || '"宁夏智慧养殖监管平台" <noreply@farm-monitor.com>',
|
||||
to: testEmail,
|
||||
subject: '宁夏智慧养殖监管平台 - 邮件服务测试',
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; padding: 20px; text-align: center;">
|
||||
<h2 style="color: #1890ff;">邮件服务测试</h2>
|
||||
<p>如果您收到此邮件,说明邮件服务配置正确。</p>
|
||||
<p>测试时间: ${new Date().toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
logger.info(`测试邮件发送成功: ${testEmail}, MessageID: ${result.messageId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`测试邮件发送失败: ${testEmail}`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通知统计信息
|
||||
* @returns {Object} 统计信息
|
||||
*/
|
||||
getNotificationStats() {
|
||||
const completedNotifications = this.notificationQueue.filter(n => n.status === 'completed');
|
||||
const failedNotifications = this.notificationQueue.filter(n => n.status === 'failed');
|
||||
|
||||
const avgResponseTime = completedNotifications.length > 0
|
||||
? completedNotifications.reduce((sum, n) => sum + n.responseTime, 0) / completedNotifications.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalNotifications: this.notificationQueue.length,
|
||||
completedCount: completedNotifications.length,
|
||||
failedCount: failedNotifications.length,
|
||||
avgResponseTime: Math.round(avgResponseTime),
|
||||
queueLength: this.notificationQueue.filter(n => n.status === 'pending').length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const notificationService = new NotificationService();
|
||||
|
||||
module.exports = notificationService;
|
||||
364
backend/services/realtimeService.js
Normal file
364
backend/services/realtimeService.js
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* 实时数据推送服务
|
||||
* @file realtimeService.js
|
||||
* @description 定期检查数据变化并通过WebSocket推送给客户端
|
||||
*/
|
||||
const cron = require('node-cron');
|
||||
const { Device, Alert, Animal, Farm } = require('../models');
|
||||
const { sequelize } = require('../config/database-simple');
|
||||
const webSocketManager = require('../utils/websocket');
|
||||
const notificationService = require('./notificationService');
|
||||
const logger = require('../utils/logger');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
class RealtimeService {
|
||||
constructor() {
|
||||
this.isRunning = false;
|
||||
this.lastUpdateTimes = {
|
||||
devices: null,
|
||||
alerts: null,
|
||||
animals: null,
|
||||
stats: null
|
||||
};
|
||||
this.updateInterval = 30; // 30秒更新间隔,符合文档要求
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动实时数据推送服务
|
||||
*/
|
||||
start() {
|
||||
if (this.isRunning) {
|
||||
logger.warn('实时数据推送服务已在运行中');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
// 设备状态监控 - 每30秒检查一次
|
||||
cron.schedule(`*/${this.updateInterval} * * * * *`, () => {
|
||||
this.checkDeviceUpdates();
|
||||
});
|
||||
|
||||
// 预警监控 - 每10秒检查一次(预警更紧急)
|
||||
cron.schedule('*/10 * * * * *', () => {
|
||||
this.checkAlertUpdates();
|
||||
});
|
||||
|
||||
// 动物健康状态监控 - 每60秒检查一次
|
||||
cron.schedule('*/60 * * * * *', () => {
|
||||
this.checkAnimalUpdates();
|
||||
});
|
||||
|
||||
// 系统统计数据更新 - 每2分钟更新一次
|
||||
cron.schedule('*/120 * * * * *', () => {
|
||||
this.updateSystemStats();
|
||||
});
|
||||
|
||||
logger.info('实时数据推送服务已启动');
|
||||
console.log(`实时数据推送服务已启动,更新间隔: ${this.updateInterval}秒`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止实时数据推送服务
|
||||
*/
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
logger.info('实时数据推送服务已停止');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查设备状态更新
|
||||
*/
|
||||
async checkDeviceUpdates() {
|
||||
try {
|
||||
const lastCheck = this.lastUpdateTimes.devices || new Date(Date.now() - 60000); // 默认检查最近1分钟
|
||||
|
||||
const updatedDevices = await Device.findAll({
|
||||
where: {
|
||||
updated_at: {
|
||||
[Op.gt]: lastCheck
|
||||
}
|
||||
},
|
||||
include: [{
|
||||
model: Farm,
|
||||
as: 'farm',
|
||||
attributes: ['id', 'name']
|
||||
}],
|
||||
order: [['updated_at', 'DESC']]
|
||||
});
|
||||
|
||||
if (updatedDevices.length > 0) {
|
||||
logger.info(`检测到 ${updatedDevices.length} 个设备状态更新`);
|
||||
|
||||
// 为每个更新的设备推送数据
|
||||
for (const device of updatedDevices) {
|
||||
webSocketManager.broadcastDeviceUpdate({
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
type: device.type,
|
||||
status: device.status,
|
||||
farm_id: device.farm_id,
|
||||
farm_name: device.farm?.name,
|
||||
last_maintenance: device.last_maintenance,
|
||||
metrics: device.metrics,
|
||||
updated_at: device.updated_at
|
||||
});
|
||||
}
|
||||
|
||||
this.lastUpdateTimes.devices = new Date();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('检查设备更新失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查预警更新
|
||||
*/
|
||||
async checkAlertUpdates() {
|
||||
try {
|
||||
const lastCheck = this.lastUpdateTimes.alerts || new Date(Date.now() - 30000); // 默认检查最近30秒
|
||||
|
||||
const newAlerts = await Alert.findAll({
|
||||
where: {
|
||||
created_at: {
|
||||
[Op.gt]: lastCheck
|
||||
}
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Farm,
|
||||
as: 'farm',
|
||||
attributes: ['id', 'name', 'contact', 'phone']
|
||||
},
|
||||
{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['id', 'name', 'type']
|
||||
}
|
||||
],
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
if (newAlerts.length > 0) {
|
||||
logger.info(`检测到 ${newAlerts.length} 个新预警`);
|
||||
|
||||
// 推送新预警
|
||||
for (const alert of newAlerts) {
|
||||
webSocketManager.broadcastAlert({
|
||||
id: alert.id,
|
||||
type: alert.type,
|
||||
level: alert.level,
|
||||
message: alert.message,
|
||||
status: alert.status,
|
||||
farm_id: alert.farm_id,
|
||||
farm_name: alert.farm?.name,
|
||||
device_id: alert.device_id,
|
||||
device_name: alert.device?.name,
|
||||
created_at: alert.created_at
|
||||
});
|
||||
|
||||
// 如果是高级或紧急预警,立即发送通知
|
||||
if (alert.level === 'high' || alert.level === 'critical') {
|
||||
await this.sendUrgentNotification(alert);
|
||||
}
|
||||
}
|
||||
|
||||
this.lastUpdateTimes.alerts = new Date();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('检查预警更新失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查动物健康状态更新
|
||||
*/
|
||||
async checkAnimalUpdates() {
|
||||
try {
|
||||
const lastCheck = this.lastUpdateTimes.animals || new Date(Date.now() - 120000); // 默认检查最近2分钟
|
||||
|
||||
const updatedAnimals = await Animal.findAll({
|
||||
where: {
|
||||
updated_at: {
|
||||
[Op.gt]: lastCheck
|
||||
}
|
||||
},
|
||||
include: [{
|
||||
model: Farm,
|
||||
as: 'farm',
|
||||
attributes: ['id', 'name']
|
||||
}],
|
||||
order: [['updated_at', 'DESC']]
|
||||
});
|
||||
|
||||
if (updatedAnimals.length > 0) {
|
||||
logger.info(`检测到 ${updatedAnimals.length} 个动物健康状态更新`);
|
||||
|
||||
for (const animal of updatedAnimals) {
|
||||
webSocketManager.broadcastAnimalUpdate({
|
||||
id: animal.id,
|
||||
type: animal.type,
|
||||
count: animal.count,
|
||||
health_status: animal.health_status,
|
||||
farm_id: animal.farm_id,
|
||||
farm_name: animal.farm?.name,
|
||||
last_inspection: animal.last_inspection,
|
||||
updated_at: animal.updated_at
|
||||
});
|
||||
}
|
||||
|
||||
this.lastUpdateTimes.animals = new Date();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('检查动物更新失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新系统统计数据
|
||||
*/
|
||||
async updateSystemStats() {
|
||||
try {
|
||||
const stats = await this.getSystemStats();
|
||||
webSocketManager.broadcastStatsUpdate(stats);
|
||||
|
||||
this.lastUpdateTimes.stats = new Date();
|
||||
logger.info('系统统计数据已推送');
|
||||
} catch (error) {
|
||||
logger.error('更新系统统计失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getSystemStats() {
|
||||
try {
|
||||
const [farmCount, deviceCount, animalCount, alertCount] = await Promise.all([
|
||||
Farm.count(),
|
||||
Device.count(),
|
||||
Animal.sum('count'),
|
||||
Alert.count({ where: { status: 'active' } })
|
||||
]);
|
||||
|
||||
const deviceStatusStats = await Device.findAll({
|
||||
attributes: [
|
||||
'status',
|
||||
[sequelize.fn('COUNT', sequelize.col('status')), 'count']
|
||||
],
|
||||
group: ['status']
|
||||
});
|
||||
|
||||
const alertLevelStats = await Alert.findAll({
|
||||
where: { status: 'active' },
|
||||
attributes: [
|
||||
'level',
|
||||
[sequelize.fn('COUNT', sequelize.col('level')), 'count']
|
||||
],
|
||||
group: ['level']
|
||||
});
|
||||
|
||||
return {
|
||||
farmCount: farmCount || 0,
|
||||
deviceCount: deviceCount || 0,
|
||||
animalCount: animalCount || 0,
|
||||
alertCount: alertCount || 0,
|
||||
deviceStatus: deviceStatusStats.reduce((acc, item) => {
|
||||
acc[item.status] = parseInt(item.dataValues.count);
|
||||
return acc;
|
||||
}, {}),
|
||||
alertLevels: alertLevelStats.reduce((acc, item) => {
|
||||
acc[item.level] = parseInt(item.dataValues.count);
|
||||
return acc;
|
||||
}, {}),
|
||||
timestamp: new Date()
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('获取系统统计数据失败:', error);
|
||||
return {
|
||||
error: '获取统计数据失败',
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送紧急预警通知
|
||||
* @param {Object} alert 预警对象
|
||||
*/
|
||||
async sendUrgentNotification(alert) {
|
||||
try {
|
||||
logger.warn(`紧急预警: ${alert.message} (级别: ${alert.level})`);
|
||||
|
||||
// 发送实时WebSocket通知给管理员
|
||||
webSocketManager.broadcastAlert({
|
||||
id: alert.id,
|
||||
type: alert.type,
|
||||
level: alert.level,
|
||||
message: alert.message,
|
||||
farm_id: alert.farm_id,
|
||||
farm_name: alert.farm?.name,
|
||||
created_at: alert.created_at
|
||||
});
|
||||
|
||||
// 发送邮件/短信通知
|
||||
const isUrgent = alert.level === 'critical' || alert.level === 'high';
|
||||
await notificationService.sendAlertNotification(alert, [], {
|
||||
urgent: isUrgent,
|
||||
includeSMS: alert.level === 'critical', // 仅紧急预警发送短信
|
||||
maxResponseTime: 300000 // 5分钟响应时间
|
||||
});
|
||||
|
||||
logger.info(`预警通知已发送: 预警ID ${alert.id}, 紧急程度: ${isUrgent}`);
|
||||
} catch (error) {
|
||||
logger.error('发送紧急预警通知失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟设备数据变化(用于演示)
|
||||
*/
|
||||
async simulateDeviceChange(deviceId) {
|
||||
try {
|
||||
const device = await Device.findByPk(deviceId);
|
||||
if (!device) return;
|
||||
|
||||
// 随机改变设备状态
|
||||
const statuses = ['online', 'offline', 'maintenance'];
|
||||
const randomStatus = statuses[Math.floor(Math.random() * statuses.length)];
|
||||
|
||||
await device.update({
|
||||
status: randomStatus,
|
||||
metrics: {
|
||||
temperature: Math.random() * 10 + 20, // 20-30度
|
||||
humidity: Math.random() * 20 + 50, // 50-70%
|
||||
lastUpdate: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`模拟设备 ${deviceId} 状态变化为: ${randomStatus}`);
|
||||
} catch (error) {
|
||||
logger.error('模拟设备变化失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务状态
|
||||
* @returns {Object} 服务状态
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
lastUpdateTimes: this.lastUpdateTimes,
|
||||
updateInterval: this.updateInterval,
|
||||
connectedClients: webSocketManager.getConnectionStats()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const realtimeService = new RealtimeService();
|
||||
|
||||
module.exports = realtimeService;
|
||||
316
backend/services/reportService.js
Normal file
316
backend/services/reportService.js
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 报表生成服务
|
||||
* @file reportService.js
|
||||
* @description 实现PDF报表生成和数据导出功能
|
||||
*/
|
||||
// const puppeteer = require('puppeteer'); // 暂时注释,避免模块缺失错误
|
||||
const XLSX = require('xlsx');
|
||||
const ejs = require('ejs');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const moment = require('moment');
|
||||
const { Farm, Animal, Device, Alert, User, Product, Order, OrderItem } = require('../models');
|
||||
const { Op, Sequelize } = require('sequelize');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// 设置中文本地化
|
||||
moment.locale('zh-cn');
|
||||
|
||||
class ReportService {
|
||||
constructor() {
|
||||
this.templateDir = path.join(__dirname, '../templates');
|
||||
this.outputDir = path.join(__dirname, '../reports');
|
||||
this.ensureDirectories();
|
||||
}
|
||||
|
||||
ensureDirectories() {
|
||||
if (!fs.existsSync(this.templateDir)) {
|
||||
fs.mkdirSync(this.templateDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(this.outputDir)) {
|
||||
fs.mkdirSync(this.outputDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// 生成Excel报表
|
||||
async generateExcelReport(data, filename) {
|
||||
try {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// 添加农场数据
|
||||
if (data.farms && data.farms.length > 0) {
|
||||
const farmsSheet = XLSX.utils.json_to_sheet(data.farms);
|
||||
XLSX.utils.book_append_sheet(workbook, farmsSheet, '农场数据');
|
||||
}
|
||||
|
||||
// 添加动物数据
|
||||
if (data.animals && data.animals.length > 0) {
|
||||
const animalsSheet = XLSX.utils.json_to_sheet(data.animals);
|
||||
XLSX.utils.book_append_sheet(workbook, animalsSheet, '动物数据');
|
||||
}
|
||||
|
||||
// 添加设备数据
|
||||
if (data.devices && data.devices.length > 0) {
|
||||
const devicesSheet = XLSX.utils.json_to_sheet(data.devices);
|
||||
XLSX.utils.book_append_sheet(workbook, devicesSheet, '设备数据');
|
||||
}
|
||||
|
||||
// 添加告警数据
|
||||
if (data.alerts && data.alerts.length > 0) {
|
||||
const alertsSheet = XLSX.utils.json_to_sheet(data.alerts);
|
||||
XLSX.utils.book_append_sheet(workbook, alertsSheet, '告警数据');
|
||||
}
|
||||
|
||||
// 添加订单数据
|
||||
if (data.orders && data.orders.length > 0) {
|
||||
const ordersSheet = XLSX.utils.json_to_sheet(data.orders);
|
||||
XLSX.utils.book_append_sheet(workbook, ordersSheet, '订单数据');
|
||||
}
|
||||
|
||||
const filePath = path.join(this.outputDir, filename);
|
||||
XLSX.writeFile(workbook, filePath);
|
||||
|
||||
logger.info(`Excel报表生成成功: ${filePath}`, { service: 'reportService' });
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
logger.error('Excel报表生成失败:', error, { service: 'reportService' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// PDF生成功能暂时禁用
|
||||
async generatePDFReport(data, filename) {
|
||||
logger.warn('PDF报表功能暂时不可用,puppeteer模块未安装', { service: 'reportService' });
|
||||
throw new Error('PDF报表功能暂时不可用,请使用Excel格式或联系管理员安装puppeteer模块');
|
||||
}
|
||||
|
||||
// HTML转PDF功能暂时禁用
|
||||
async generatePDFFromHTML(htmlContent, filename) {
|
||||
logger.warn('PDF生成功能暂时不可用,puppeteer模块未安装', { service: 'reportService' });
|
||||
throw new Error('PDF生成功能暂时不可用,请使用Excel格式或联系管理员安装puppeteer模块');
|
||||
}
|
||||
|
||||
// 获取农场统计数据
|
||||
async getFarmStatistics(farmId = null) {
|
||||
try {
|
||||
const whereClause = farmId ? { id: farmId } : {};
|
||||
|
||||
const farms = await Farm.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
model: Animal,
|
||||
as: 'animals',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: Device,
|
||||
as: 'devices',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return farms;
|
||||
} catch (error) {
|
||||
logger.error('获取农场统计数据失败:', error, { service: 'reportService' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取动物统计数据
|
||||
async getAnimalStatistics(farmId = null) {
|
||||
try {
|
||||
const whereClause = farmId ? { farmId } : {};
|
||||
|
||||
const animals = await Animal.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
model: Farm,
|
||||
as: 'farm',
|
||||
attributes: ['name', 'location']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return animals;
|
||||
} catch (error) {
|
||||
logger.error('获取动物统计数据失败:', error, { service: 'reportService' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备统计数据
|
||||
async getDeviceStatistics(farmId = null) {
|
||||
try {
|
||||
const whereClause = farmId ? { farmId } : {};
|
||||
|
||||
const devices = await Device.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
model: Farm,
|
||||
as: 'farm',
|
||||
attributes: ['name', 'location']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return devices;
|
||||
} catch (error) {
|
||||
logger.error('获取设备统计数据失败:', error, { service: 'reportService' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取告警统计数据
|
||||
async getAlertStatistics(farmId = null) {
|
||||
try {
|
||||
const whereClause = farmId ? { farmId } : {};
|
||||
|
||||
const alerts = await Alert.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
model: Farm,
|
||||
as: 'farm',
|
||||
attributes: ['name', 'location']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return alerts;
|
||||
} catch (error) {
|
||||
logger.error('获取告警统计数据失败:', error, { service: 'reportService' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取订单统计数据
|
||||
async getOrderStatistics(farmId = null) {
|
||||
try {
|
||||
const whereClause = farmId ? { farmId } : {};
|
||||
|
||||
const orders = await Order.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: 'user',
|
||||
attributes: ['username', 'email']
|
||||
},
|
||||
{
|
||||
model: OrderItem,
|
||||
as: 'items',
|
||||
include: [
|
||||
{
|
||||
model: Product,
|
||||
as: 'product',
|
||||
attributes: ['name', 'price']
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return orders;
|
||||
} catch (error) {
|
||||
logger.error('获取订单统计数据失败:', error, { service: 'reportService' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成综合报表
|
||||
async generateComprehensiveReport(farmId = null, format = 'excel') {
|
||||
try {
|
||||
const data = {
|
||||
farms: await this.getFarmStatistics(farmId),
|
||||
animals: await this.getAnimalStatistics(farmId),
|
||||
devices: await this.getDeviceStatistics(farmId),
|
||||
alerts: await this.getAlertStatistics(farmId),
|
||||
orders: await this.getOrderStatistics(farmId)
|
||||
};
|
||||
|
||||
const timestamp = moment().format('YYYY-MM-DD_HH-mm-ss');
|
||||
const filename = `comprehensive_report_${timestamp}.${format}`;
|
||||
|
||||
if (format === 'excel') {
|
||||
return await this.generateExcelReport(data, filename);
|
||||
} else if (format === 'pdf') {
|
||||
return await this.generatePDFReport(data, filename);
|
||||
} else {
|
||||
throw new Error('不支持的报表格式');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('生成综合报表失败:', error, { service: 'reportService' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理过期报表文件
|
||||
async cleanupOldReports(daysOld = 7) {
|
||||
try {
|
||||
const files = fs.readdirSync(this.outputDir);
|
||||
const cutoffTime = Date.now() - (daysOld * 24 * 60 * 60 * 1000);
|
||||
|
||||
let cleanedCount = 0;
|
||||
files.forEach(file => {
|
||||
const filePath = path.join(this.outputDir, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
if (stats.mtime.getTime() < cutoffTime) {
|
||||
fs.unlinkSync(filePath);
|
||||
cleanedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`清理了 ${cleanedCount} 个过期报表文件`, { service: 'reportService' });
|
||||
return cleanedCount;
|
||||
} catch (error) {
|
||||
logger.error('清理过期报表文件失败:', error, { service: 'reportService' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取报表文件列表
|
||||
async getReportFiles() {
|
||||
try {
|
||||
const files = fs.readdirSync(this.outputDir);
|
||||
const fileList = files.map(file => {
|
||||
const filePath = path.join(this.outputDir, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
return {
|
||||
filename: file,
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime
|
||||
};
|
||||
});
|
||||
|
||||
return fileList.sort((a, b) => b.modified - a.modified);
|
||||
} catch (error) {
|
||||
logger.error('获取报表文件列表失败:', error, { service: 'reportService' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除报表文件
|
||||
async deleteReportFile(filename) {
|
||||
try {
|
||||
const filePath = path.join(this.outputDir, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
logger.info(`报表文件删除成功: ${filename}`, { service: 'reportService' });
|
||||
return true;
|
||||
} else {
|
||||
throw new Error('文件不存在');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('删除报表文件失败:', error, { service: 'reportService' });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ReportService();
|
||||
Reference in New Issue
Block a user