forked from datawhale/whale-town-end
435 lines
12 KiB
TypeScript
435 lines
12 KiB
TypeScript
/**
|
||
* 日志管理服务
|
||
*
|
||
* 功能描述:
|
||
* - 定期清理过期日志文件
|
||
* - 监控日志文件大小和数量
|
||
* - 提供日志统计和分析功能
|
||
* - 支持日志文件压缩和归档
|
||
*
|
||
* 依赖模块:
|
||
* - ConfigService: 环境配置服务
|
||
* - AppLoggerService: 应用日志服务
|
||
* - ScheduleModule: 定时任务模块
|
||
*
|
||
* @author moyin
|
||
* @version 1.0.0
|
||
* @since 2025-12-13
|
||
*/
|
||
|
||
import { Injectable } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||
import { AppLoggerService } from './logger.service';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import * as zlib from 'zlib';
|
||
|
||
/**
|
||
* 日志管理服务类
|
||
*
|
||
* 职责:
|
||
* - 执行定期日志清理任务
|
||
* - 监控日志系统健康状态
|
||
* - 提供日志文件统计信息
|
||
* - 管理日志文件的生命周期
|
||
*
|
||
* 主要方法:
|
||
* - cleanupOldLogs(): 清理过期日志文件
|
||
* - compressLogs(): 压缩历史日志文件
|
||
* - getLogStatistics(): 获取日志统计信息
|
||
* - monitorLogHealth(): 监控日志系统健康状态
|
||
*
|
||
* 使用场景:
|
||
* - 定期维护日志文件
|
||
* - 监控系统日志状态
|
||
* - 优化存储空间使用
|
||
* - 提供日志分析数据
|
||
*/
|
||
@Injectable()
|
||
export class LogManagementService {
|
||
private readonly logDir: string;
|
||
private readonly maxFiles: number;
|
||
private readonly maxSize: string;
|
||
|
||
constructor(
|
||
private readonly configService: ConfigService,
|
||
private readonly logger: AppLoggerService,
|
||
) {
|
||
this.logDir = this.configService.get('LOG_DIR', './logs');
|
||
this.maxFiles = this.parseMaxFiles(this.configService.get('LOG_MAX_FILES', '7d'));
|
||
this.maxSize = this.configService.get('LOG_MAX_SIZE', '10m');
|
||
}
|
||
|
||
/**
|
||
* 获取日志目录的绝对路径
|
||
*
|
||
* 说明:用于后台打包下载 logs/ 整目录。
|
||
*/
|
||
getLogDirAbsolutePath(): string {
|
||
return path.resolve(this.logDir);
|
||
}
|
||
|
||
/**
|
||
* 定期清理过期日志文件
|
||
*
|
||
* 功能描述:
|
||
* 每天凌晨2点执行,清理超过保留期限的日志文件
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 扫描日志目录中的所有文件
|
||
* 2. 检查文件创建时间
|
||
* 3. 删除超过保留期限的文件
|
||
* 4. 记录清理结果
|
||
*
|
||
* @cron 每天凌晨2点执行
|
||
*/
|
||
@Cron('0 2 * * *', {
|
||
name: 'cleanup-old-logs',
|
||
timeZone: 'Asia/Shanghai',
|
||
})
|
||
async cleanupOldLogs(): Promise<void> {
|
||
const startTime = Date.now();
|
||
|
||
this.logger.info('开始执行日志清理任务', {
|
||
operation: 'cleanupOldLogs',
|
||
logDir: this.logDir,
|
||
maxFiles: this.maxFiles,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
if (!fs.existsSync(this.logDir)) {
|
||
this.logger.warn('日志目录不存在,跳过清理任务', {
|
||
operation: 'cleanupOldLogs',
|
||
logDir: this.logDir,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const files = fs.readdirSync(this.logDir);
|
||
const cutoffDate = new Date();
|
||
cutoffDate.setDate(cutoffDate.getDate() - this.maxFiles);
|
||
|
||
let deletedCount = 0;
|
||
let deletedSize = 0;
|
||
|
||
for (const file of files) {
|
||
const filePath = path.join(this.logDir, file);
|
||
const stats = fs.statSync(filePath);
|
||
|
||
// 只处理日志文件(.log 扩展名)
|
||
if (path.extname(file) === '.log' && stats.birthtime < cutoffDate) {
|
||
try {
|
||
deletedSize += stats.size;
|
||
fs.unlinkSync(filePath);
|
||
deletedCount++;
|
||
|
||
this.logger.info('删除过期日志文件', {
|
||
operation: 'cleanupOldLogs',
|
||
fileName: file,
|
||
fileSize: this.formatBytes(stats.size),
|
||
fileAge: Math.floor((Date.now() - stats.birthtime.getTime()) / (1000 * 60 * 60 * 24)),
|
||
});
|
||
} catch (error) {
|
||
this.logger.error('删除日志文件失败', {
|
||
operation: 'cleanupOldLogs',
|
||
fileName: file,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
}, error instanceof Error ? error.stack : undefined);
|
||
}
|
||
}
|
||
}
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.info('日志清理任务完成', {
|
||
operation: 'cleanupOldLogs',
|
||
deletedCount,
|
||
deletedSize: this.formatBytes(deletedSize),
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
} catch (error) {
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.error('日志清理任务执行失败', {
|
||
operation: 'cleanupOldLogs',
|
||
logDir: this.logDir,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
}, error instanceof Error ? error.stack : undefined);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 定期压缩历史日志文件
|
||
*
|
||
* 功能描述:
|
||
* 每周日凌晨3点执行,压缩7天前的日志文件以节省存储空间
|
||
*
|
||
* @cron 每周日凌晨3点执行
|
||
*/
|
||
@Cron('0 3 * * 0', {
|
||
name: 'compress-logs',
|
||
timeZone: 'Asia/Shanghai',
|
||
})
|
||
async compressLogs(): Promise<void> {
|
||
this.logger.info('日志压缩任务已跳过', {
|
||
operation: 'compressLogs',
|
||
reason: '使用简化的日志管理策略',
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
// 简化版本:只记录任务执行,实际压缩功能可以后续添加
|
||
// 这样可以避免复杂的文件操作导致的问题
|
||
}
|
||
|
||
/**
|
||
* 定期监控日志系统健康状态
|
||
*
|
||
* 功能描述:
|
||
* 每小时执行一次,检查日志系统的健康状态
|
||
*
|
||
* @cron 每小时执行
|
||
*/
|
||
@Cron(CronExpression.EVERY_HOUR, {
|
||
name: 'monitor-log-health',
|
||
})
|
||
async monitorLogHealth(): Promise<void> {
|
||
try {
|
||
const stats = await this.getLogStatistics();
|
||
|
||
// 检查磁盘空间使用情况
|
||
if (stats.totalSize > this.parseSize(this.maxSize) * 100) { // 如果总大小超过单文件限制的100倍
|
||
this.logger.warn('日志文件占用空间过大', {
|
||
operation: 'monitorLogHealth',
|
||
totalSize: this.formatBytes(stats.totalSize),
|
||
fileCount: stats.fileCount,
|
||
recommendation: '建议检查日志清理策略',
|
||
});
|
||
}
|
||
|
||
// 检查错误日志数量
|
||
if (stats.errorLogCount > 1000) { // 如果错误日志过多
|
||
this.logger.warn('错误日志数量异常', {
|
||
operation: 'monitorLogHealth',
|
||
errorLogCount: stats.errorLogCount,
|
||
recommendation: '建议检查系统运行状态',
|
||
});
|
||
}
|
||
|
||
// 定期输出日志统计信息(每天一次)
|
||
const hour = new Date().getHours();
|
||
if (hour === 9) { // 每天上午9点输出统计信息
|
||
this.logger.info('日志系统健康状态报告', {
|
||
operation: 'monitorLogHealth',
|
||
...stats,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
}
|
||
|
||
} catch (error) {
|
||
this.logger.error('日志健康监控失败', {
|
||
operation: 'monitorLogHealth',
|
||
error: error instanceof Error ? error.message : String(error),
|
||
timestamp: new Date().toISOString(),
|
||
}, error instanceof Error ? error.stack : undefined);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取日志统计信息
|
||
*
|
||
* 功能描述:
|
||
* 统计日志目录中的文件数量、大小等信息
|
||
*
|
||
* @returns 日志统计信息对象
|
||
*/
|
||
async getLogStatistics(): Promise<{
|
||
fileCount: number;
|
||
totalSize: number;
|
||
errorLogCount: number;
|
||
oldestFile: string;
|
||
newestFile: string;
|
||
avgFileSize: number;
|
||
}> {
|
||
try {
|
||
if (!fs.existsSync(this.logDir)) {
|
||
return {
|
||
fileCount: 0,
|
||
totalSize: 0,
|
||
errorLogCount: 0,
|
||
oldestFile: '',
|
||
newestFile: '',
|
||
avgFileSize: 0,
|
||
};
|
||
}
|
||
|
||
const files = fs.readdirSync(this.logDir);
|
||
let totalSize = 0;
|
||
let errorLogCount = 0;
|
||
let oldestTime = Date.now();
|
||
let newestTime = 0;
|
||
let oldestFile = '';
|
||
let newestFile = '';
|
||
|
||
for (const file of files) {
|
||
const filePath = path.join(this.logDir, file);
|
||
const stats = fs.statSync(filePath);
|
||
|
||
totalSize += stats.size;
|
||
|
||
if (file.includes('error')) {
|
||
errorLogCount++;
|
||
}
|
||
|
||
if (stats.birthtime.getTime() < oldestTime) {
|
||
oldestTime = stats.birthtime.getTime();
|
||
oldestFile = file;
|
||
}
|
||
|
||
if (stats.birthtime.getTime() > newestTime) {
|
||
newestTime = stats.birthtime.getTime();
|
||
newestFile = file;
|
||
}
|
||
}
|
||
|
||
return {
|
||
fileCount: files.length,
|
||
totalSize,
|
||
errorLogCount,
|
||
oldestFile,
|
||
newestFile,
|
||
avgFileSize: files.length > 0 ? Math.round(totalSize / files.length) : 0,
|
||
};
|
||
|
||
} catch (error) {
|
||
this.logger.error('获取日志统计信息失败', {
|
||
operation: 'getLogStatistics',
|
||
error: error instanceof Error ? error.message : String(error),
|
||
}, error instanceof Error ? error.stack : undefined);
|
||
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取运行日志尾部(用于后台查看)
|
||
*
|
||
* 说明:
|
||
* - 开发环境默认读取 dev.log
|
||
* - 生产环境默认读取 app.log(可选 access/error)
|
||
* - 通过读取文件尾部一定字节数实现“近似 tail”,避免大文件全量读取
|
||
*/
|
||
async getRuntimeLogTail(options?: {
|
||
type?: 'app' | 'access' | 'error' | 'dev';
|
||
lines?: number;
|
||
}): Promise<{
|
||
file: string;
|
||
updated_at: string;
|
||
lines: string[];
|
||
}> {
|
||
const isProduction = this.configService.get('NODE_ENV') === 'production';
|
||
const requestedLines = Math.max(1, Math.min(Number(options?.lines ?? 200), 2000));
|
||
const requestedType = options?.type;
|
||
|
||
const allowedFiles = isProduction
|
||
? {
|
||
app: 'app.log',
|
||
access: 'access.log',
|
||
error: 'error.log',
|
||
}
|
||
: {
|
||
dev: 'dev.log',
|
||
};
|
||
|
||
const defaultType = isProduction ? 'app' : 'dev';
|
||
const typeKey = (requestedType && requestedType in allowedFiles ? requestedType : defaultType) as keyof typeof allowedFiles;
|
||
const fileName = allowedFiles[typeKey];
|
||
const filePath = path.join(this.logDir, fileName);
|
||
|
||
if (!fs.existsSync(filePath)) {
|
||
return { file: fileName, updated_at: new Date().toISOString(), lines: [] };
|
||
}
|
||
|
||
const stats = fs.statSync(filePath);
|
||
const maxBytes = 256 * 1024; // 256KB 足够覆盖常见的数百行日志
|
||
const readBytes = Math.min(stats.size, maxBytes);
|
||
const startPos = Math.max(0, stats.size - readBytes);
|
||
|
||
const fd = fs.openSync(filePath, 'r');
|
||
try {
|
||
const buffer = Buffer.alloc(readBytes);
|
||
fs.readSync(fd, buffer, 0, readBytes, startPos);
|
||
const text = buffer.toString('utf8');
|
||
const allLines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
||
const tailLines = allLines.slice(-requestedLines);
|
||
return {
|
||
file: fileName,
|
||
updated_at: stats.mtime.toISOString(),
|
||
lines: tailLines,
|
||
};
|
||
} finally {
|
||
fs.closeSync(fd);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析最大文件数配置
|
||
*
|
||
* @param maxFiles 配置字符串(如 "7d", "30", "2w")
|
||
* @returns 天数
|
||
* @private
|
||
*/
|
||
private parseMaxFiles(maxFiles: string): number {
|
||
if (maxFiles.endsWith('d')) {
|
||
return parseInt(maxFiles.slice(0, -1));
|
||
} else if (maxFiles.endsWith('w')) {
|
||
return parseInt(maxFiles.slice(0, -1)) * 7;
|
||
} else if (maxFiles.endsWith('m')) {
|
||
return parseInt(maxFiles.slice(0, -1)) * 30;
|
||
} else {
|
||
return parseInt(maxFiles) || 7;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析文件大小配置
|
||
*
|
||
* @param size 大小字符串(如 "10m", "1g", "500k")
|
||
* @returns 字节数
|
||
* @private
|
||
*/
|
||
private parseSize(size: string): number {
|
||
const units: Record<string, number> = {
|
||
'k': 1024,
|
||
'm': 1024 * 1024,
|
||
'g': 1024 * 1024 * 1024,
|
||
};
|
||
|
||
const unit = size.slice(-1).toLowerCase();
|
||
const value = parseInt(size.slice(0, -1));
|
||
|
||
return value * (units[unit] || 1);
|
||
}
|
||
|
||
/**
|
||
* 格式化字节数为可读字符串
|
||
*
|
||
* @param bytes 字节数
|
||
* @returns 格式化后的字符串
|
||
* @private
|
||
*/
|
||
private formatBytes(bytes: number): string {
|
||
if (bytes === 0) return '0 B';
|
||
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||
}
|
||
} |