import { WriteStream } from "fs"; import * as fs from "node:fs"; import * as path from "node:path"; import { randomUUID } from "node:crypto"; import {log, font_style} from "../types/utils/logger"; export interface LogHistoryRecord { id: string; timestamp: number; log: log[]; } export interface _Logger { info(msg: string): void; success(msg: string): void; error(msg: string): void; warning(msg: string): void; tip(msg: string): void; custom(msg: string, font_color: string, font_style: font_style[]): void; log(log: log[]): void; get_recent_log(count: number): log[][]; read_recent_log(count: number): Promise; close(): Promise; } export class Logger implements _Logger { // 单个日志文件的最大字节数(50M) private static readonly MAX_FILE_SIZE = 50 * 1024 * 1024; // 同一目录下保留的最大日志文件数 private static readonly MAX_FILES = 10; // 日志文件名格式:year-month-day_log.log / year-month-day_log_{index}.log private static readonly LOG_FILE_REGEX = /^\d{4}-\d{2}-\d{2}_log(_\d+)?\.log$/; // 重建写入流的最大失败次数与重试延时 private static readonly MAX_RECREATE_FAILURES = 10; private static readonly RECREATE_DELAY = 1000; private readonly log_dir: string; private writer!: WriteStream; // 当前日志所属日期(year-month-day) private day = ''; // 当天的文件序号,0 为当天首个文件 private index = 0; private size = 0; private write_queue: string[] = []; private in_flight_chunk: string | undefined; private writing = false; private recreate_failures = 0; private recreate_timer: NodeJS.Timeout | null = null; private stopped = false; private close_task: Promise | undefined; constructor(log_dir: string) { this.log_dir = log_dir; this.day = this.format_day(new Date()); this.open_writer(); } private bind_writer_error(): void { this.writer.on('error', (err: NodeJS.ErrnoException) => { this.writing = false; // 仅处理文件不存在,重建写入流;其他错误交由后续写入重试 if (err.code === 'ENOENT') { this.schedule_recreate(); } }); } // 合并并发错误,避免同一时刻重复创建 private schedule_recreate(): void { if (this.stopped || this.recreate_timer !== null) { return; } this.recreate_timer = setTimeout(() => { this.recreate_timer = null; this.recreate_writer(); }, Logger.RECREATE_DELAY); this.recreate_timer.unref(); } // 重新创建写入流;累计失败达到上限后停止创建 private recreate_writer(): void { if (this.stopped) { return; } this.recreate_failures++; if (this.recreate_failures >= Logger.MAX_RECREATE_FAILURES) { this.stopped = true; return; } try { this.open_writer(); this.recreate_failures = 0; this.flush(); } catch { // 创建失败,排到下一次重试 this.schedule_recreate(); } } private format_day(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } // 当天首个文件为 year-month-day_log.log,超出大小后追加序号 private build_filename(day: string, index: number): string { return index === 0 ? `${day}_log.log` : `${day}_log_${index}.log`; } // 打开(或创建)当前应写入的文件,并接管已有大小 private open_writer(): void { if (!fs.existsSync(this.log_dir)) { fs.mkdirSync(this.log_dir, { recursive: true }); } const filename = this.build_filename(this.day, this.index); const file_path = path.join(this.log_dir, filename); try { this.size = fs.statSync(file_path).size; } catch { this.size = 0; } const broken = this.writer; if (broken) { try { broken.destroy(); } catch { // 忽略已销毁流抛出的异常 } } this.writer = fs.createWriteStream(file_path, {flags: 'a'}); this.bind_writer_error(); this.prune_files(filename); } // 超出保留数量时删除最旧的日志文件(文件名按时间递增,保留当前文件) private prune_files(keep_filename: string): void { let entries: string[]; try { entries = fs.readdirSync(this.log_dir) .filter(name => Logger.LOG_FILE_REGEX.test(name)) .sort(); } catch { return; } let excess = entries.length - Logger.MAX_FILES; for (const name of entries) { if (excess <= 0) break; if (name === keep_filename) continue; try { fs.rmSync(path.join(this.log_dir, name)); excess--; } catch { // 删除失败则跳过,保留文件 } } } // 跨天或写满时切换到新文件 private ensure_writer(): void { const today = this.format_day(new Date()); if (today !== this.day) { this.day = today; this.index = 0; this.open_writer(); return; } if (this.size >= Logger.MAX_FILE_SIZE) { this.index++; this.open_writer(); } } public info(message: any): log { // 白色 return { message, font_color: '#ffffff', font_style: ['normal'] }; } public success(message: any): log { // 柔和绿 return { message, font_color: '#8fb98f', font_style: ['normal'] }; } public warning(message: any): log { // 柔和沙黄 return { message, font_color: '#d1b478', font_style: ['normal'] }; } public error(message: any): log { // 柔和玫红 return { message, font_color: '#cf8e8e', font_style: ['normal'] }; } public tip(message: any): log { // 柔和钢蓝 return { message, font_color: '#8fb0c9', font_style: ['normal'] }; } public custom(message: any, font_color: string, font_style: font_style[]): log { return { message, font_color, font_style }; } public log(log: log[]): void { if (this.stopped || log.length === 0) { return; } const data = { id: randomUUID(), timestamp: Date.now(), log } this.write_queue.push(`${JSON.stringify(data)}\n`); this.flush(); } // 串行写入:上一条落盘后才写下一批,保证大量日志的顺序 private flush(): void { if (this.writing || this.stopped) { return; } const chunk = this.write_queue.shift(); if (chunk === undefined) { return; } this.ensure_writer(); this.writing = true; this.in_flight_chunk = chunk; this.writer.write(chunk, (err) => { if (this.in_flight_chunk === chunk) this.in_flight_chunk = undefined; this.writing = false; if (err) { if (this.stopped) return; // 写入失败则回队,等待重建写入流后重试,避免丢日志 this.write_queue.unshift(chunk); return; } this.size += Buffer.byteLength(chunk); this.flush(); }); } public close(): Promise { if (this.close_task) return this.close_task; this.stopped = true; if (this.recreate_timer) clearTimeout(this.recreate_timer); this.recreate_timer = null; this.close_task = new Promise(resolve => { if (this.writer.closed) { resolve(); return; } this.writer.once('close', () => { this.write_queue.length = 0; resolve(); }); this.writer.end(this.write_queue.join('')); }); return this.close_task; } private static parse_history_record(line: string): LogHistoryRecord | undefined { try { const record = JSON.parse(line); if (record && typeof record.id === 'string' && typeof record.timestamp === 'number' && Number.isFinite(record.timestamp) && Array.isArray(record.log)) { return record; } } catch { // Ignore corrupt or incomplete records. } return undefined; } public static async read_recent_log(log_dir: string, count: number): Promise { if (!Number.isFinite(count) || count < 1) return []; count = Math.floor(count); let names: string[]; try { names = (await fs.promises.readdir(log_dir)) .filter(name => Logger.LOG_FILE_REGEX.test(name)) .sort((a, b) => { const date_order = a.slice(0, 10).localeCompare(b.slice(0, 10)); return date_order || Number(a.match(/_(\d+)\.log$/)?.[1] ?? 0) - Number(b.match(/_(\d+)\.log$/)?.[1] ?? 0); }).reverse(); } catch { return []; } const records: LogHistoryRecord[] = []; const max_line_bytes = 1024 * 1024; let budget = 16 * max_line_bytes; for (const name of names) { if (records.length >= count || budget <= 0) break; let file: fs.promises.FileHandle | undefined; try { file = await fs.promises.open(path.join(log_dir, name), 'r'); let position = (await file.stat()).size; let pending = Buffer.alloc(0); let skipping = false; const accept = (line: Buffer) => { if (line.length > max_line_bytes) return; const record = Logger.parse_history_record(line.toString('utf8')); if (record) records.push(record); }; while (position > 0 && records.length < count && budget > 0) { const length = Math.min(position, 64 * 1024, budget); position -= length; budget -= length; const chunk = Buffer.allocUnsafe(length); const {bytesRead} = await file.read(chunk, 0, length, position); const data = Buffer.concat([chunk.subarray(0, bytesRead), pending]); let end = data.length; let newline: number; while ((newline = data.lastIndexOf(10, end - 1)) >= 0 && end > 0) { if (!skipping) accept(data.subarray(newline + 1, end)); skipping = false; end = newline; if (records.length >= count) break; } // Discard oversized lines until their preceding newline is found. if (skipping || end > max_line_bytes) { pending = Buffer.alloc(0); skipping = true; } else { pending = Buffer.from(data.subarray(0, end)); } } if (position === 0 && !skipping && records.length < count) accept(pending); } catch { // Rotation or removal can race a history read; continue with older files. } finally { if (file) await file.close(); } } return records.reverse(); } public async read_recent_log(count: number): Promise { if (!Number.isFinite(count) || count < 1) return []; count = Math.floor(count); const snapshot = [this.in_flight_chunk, ...this.write_queue] .filter((chunk): chunk is string => chunk !== undefined) .map(chunk => Logger.parse_history_record(chunk)) .filter((record): record is LogHistoryRecord => record !== undefined); const snapshot_ids = new Set(snapshot.map(record => record.id)); const records = (await Logger.read_recent_log(this.log_dir, count)) .filter(record => !snapshot_ids.has(record.id)); const ids = new Set(records.map(record => record.id)); for (const record of snapshot) { if (ids.has(record.id)) continue; ids.add(record.id); records.push(record); } return records.slice(-count); } // 读取最近的 count 条日志,跨文件时自动按时间顺序拼接 public get_recent_log(count: number): log[][] { if (count <= 0) { return []; } let names: string[]; try { names = fs.readdirSync(this.log_dir) .filter(name => Logger.LOG_FILE_REGEX.test(name)) .sort() .reverse(); } catch { return []; } const lines: string[] = []; for (const name of names) { let content: string; try { content = fs.readFileSync(path.join(this.log_dir, name), 'utf-8'); } catch { continue; } const file_lines = content.split('\n').filter(line => line.length > 0); for (let i = file_lines.length - 1; i >= 0 && lines.length < count; i--) { lines.push(file_lines[i]); } if (lines.length >= count) { break; } } // 收集顺序为从新到旧,反转恢复为时间正序 lines.reverse(); return lines.map(line => { try { return JSON.parse(line); } catch { return line; } }); } }