feat: 日志模块 + netRequest 封装 + PROJECT.md 更新
日志模块(文件持久化 + 事件总线联动 + 脱敏)
新增:
- shared/types/logging.ts — LogLevel / LogCategory / LogEntry 类型
- electron/main/logger.ts — 主进程日志核心(JSON Lines / 每日轮转 / 7天清理)
- electron/main/log-ipc.ts — 渲染进程日志 IPC 通道接收
- src/utils/logger.ts — 渲染进程日志器(IPC 转发 / DEV 控制台 / 事件总线钩子)
- shared/utils/sanitize.ts — 脱敏工具(ID哈希 / 邮箱 / 手机 / 名称 / Token)
修改:
- electron/main.ts — initLogger + flushLogger + uncaughtException
- src/main.tsx — renderer 全局错误捕获
- src/utils/event-bus.ts — setEventLogListener 钩子,emit 自动审计
- src/services/request.ts — 拦截器结构化错误分级记录
- electron/main/updater.ts — 14处 console → logger 迁移
- src/components/AppProvider.tsx — 登录/登出/自动登录 auth 事件日志
- shared/constants/ipc-channels.ts — 新增 LOG_MESSAGE 通道
netRequest 封装(Electron net.request 的 axios 风格 API)
新增:
- electron/main/net-request.ts — netRequest.get/post/put/del 命名空间
修改:
- electron/main/updater.ts — fetchUpdateInfo 改用 netRequest(22→12行)
文档更新
- PROJECT.md — 目录结构 / 状态管理 / 日志系统 / IPC 章节同步现状
- TODO.md — 增量更新改造 + 日志模块待办
- UpdateA.md — API blockmap 字段 / 数据库字段说明
This commit is contained in:
251
electron/main/logger.ts
Normal file
251
electron/main/logger.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
// ============================================================
|
||||
// 主进程日志核心 — 文件写入 / 轮转 / 清理
|
||||
//
|
||||
// 架构:
|
||||
// - 日志写入 {userData}/logs/app-YYYY-MM-DD.log(JSON Lines 格式)
|
||||
// - 每日轮转:日期变化时自动创建新文件
|
||||
// - 保留策略:7 天前旧文件自动删除
|
||||
// - 写入队列批量冲刷(setImmediate 聚合同一 tick 的日志)
|
||||
// - 惰性初始化:app.getPath('userData') 在 ready 后才可用,
|
||||
// initLogger() 在 whenReady 中显式调用预热
|
||||
//
|
||||
// 用法(主进程):
|
||||
// import { logger, initLogger } from './main/logger';
|
||||
// logger.info('updater', '检查更新', { version: '0.1.0' });
|
||||
//
|
||||
// 渲染进程日志通过 log-ipc.ts IPC 通道写入,调用 writeRendererLog()
|
||||
// ============================================================
|
||||
|
||||
import { app } from 'electron';
|
||||
import { createWriteStream, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { WriteStream } from 'node:fs';
|
||||
import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '../../shared/types/logging';
|
||||
import { sanitizeLogEntry } from '../../shared/utils/sanitize';
|
||||
|
||||
// ---------- 内部状态 ----------
|
||||
|
||||
let writeStream: WriteStream | null = null;
|
||||
let currentDate = '';
|
||||
let logDir = '';
|
||||
const writeQueue: string[] = [];
|
||||
let isWriting = false;
|
||||
const MAX_DAYS = 7;
|
||||
|
||||
// ---------- 辅助函数 ----------
|
||||
|
||||
function getDateStr(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getLogFilePath(date: string): string {
|
||||
return join(logDir, `app-${date}.log`);
|
||||
}
|
||||
|
||||
/** 确保 logs/ 目录存在 */
|
||||
function ensureDir(): void {
|
||||
if (logDir) return;
|
||||
try {
|
||||
logDir = join(app.getPath('userData'), 'logs');
|
||||
} catch {
|
||||
// app.getPath 尚不可用(app.ready 之前),静默跳过
|
||||
return;
|
||||
}
|
||||
if (!logDir) return;
|
||||
try {
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
} catch {
|
||||
/* 目录创建失败 — 后续写入静默丢弃 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 日期轮转:日切时关闭旧流 → 创建新文件 → 清理过期文件 */
|
||||
function rotateIfNeeded(): void {
|
||||
const today = getDateStr();
|
||||
if (currentDate === today && writeStream) return;
|
||||
|
||||
if (writeStream) {
|
||||
try {
|
||||
writeStream.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
writeStream = null;
|
||||
}
|
||||
|
||||
try {
|
||||
writeStream = createWriteStream(getLogFilePath(today), { flags: 'a' });
|
||||
currentDate = today;
|
||||
} catch {
|
||||
writeStream = null;
|
||||
}
|
||||
|
||||
purgeOldLogs();
|
||||
}
|
||||
|
||||
/** 清理超过 MAX_DAYS 天的旧日志文件 */
|
||||
function purgeOldLogs(): void {
|
||||
if (!logDir) return;
|
||||
const now = Date.now();
|
||||
const maxAge = MAX_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
try {
|
||||
const files = readdirSync(logDir);
|
||||
for (const file of files) {
|
||||
if (!file.startsWith('app-') || !file.endsWith('.log')) continue;
|
||||
try {
|
||||
const stat = statSync(join(logDir, file));
|
||||
if (now - stat.mtimeMs > maxAge) {
|
||||
unlinkSync(join(logDir, file));
|
||||
}
|
||||
} catch {
|
||||
/* skip inaccessible files */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 目录读取失败 — 下次轮转再试 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 批量冲刷写入队列 */
|
||||
function processQueue(): void {
|
||||
if (isWriting || writeQueue.length === 0) return;
|
||||
isWriting = true;
|
||||
|
||||
ensureDir();
|
||||
if (logDir) {
|
||||
rotateIfNeeded();
|
||||
if (writeStream) {
|
||||
const batch = writeQueue.splice(0).join('');
|
||||
try {
|
||||
writeStream.write(batch);
|
||||
} catch {
|
||||
/* 写入失败 — 数据丢失,但日志不应阻断业务 */
|
||||
}
|
||||
} else {
|
||||
writeQueue.length = 0;
|
||||
}
|
||||
} else {
|
||||
// app ready 前尚无 userData 路径 — 丢弃(此时距 ready 极近)
|
||||
writeQueue.length = 0;
|
||||
}
|
||||
|
||||
isWriting = false;
|
||||
}
|
||||
|
||||
/** 错误对象 → 可序列化快照 */
|
||||
function errorToSnapshot(err: Error): LogErrorSnapshot {
|
||||
return {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
cause: (err as { cause?: unknown }).cause
|
||||
? String((err as { cause?: unknown }).cause)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 构建日志条目 */
|
||||
function createEntry(
|
||||
level: LogLevel,
|
||||
category: LogCategory,
|
||||
message: string,
|
||||
processType: 'main' | 'renderer',
|
||||
error?: Error,
|
||||
context?: Record<string, unknown>,
|
||||
): LogEntry {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
category,
|
||||
message,
|
||||
processType,
|
||||
};
|
||||
if (context && Object.keys(context).length > 0) {
|
||||
entry.context = context;
|
||||
}
|
||||
if (error) {
|
||||
entry.error = errorToSnapshot(error);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** 内部写入入口 */
|
||||
function write(
|
||||
level: LogLevel,
|
||||
category: LogCategory,
|
||||
message: string,
|
||||
error?: Error,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
const entry = sanitizeLogEntry(
|
||||
createEntry(level, category, message, 'main', error, context),
|
||||
);
|
||||
writeQueue.push(JSON.stringify(entry) + '\n');
|
||||
// 首个入队项触发 setImmediate 批量冲刷
|
||||
if (writeQueue.length === 1) {
|
||||
setImmediate(processQueue);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 对外 API ----------
|
||||
|
||||
/** 主进程日志器(与渲染进程 API 完全一致) */
|
||||
export const logger = {
|
||||
debug(category: LogCategory, message: string, context?: Record<string, unknown>): void {
|
||||
write('DEBUG', category, message, undefined, context);
|
||||
},
|
||||
info(category: LogCategory, message: string, context?: Record<string, unknown>): void {
|
||||
write('INFO', category, message, undefined, context);
|
||||
},
|
||||
warn(
|
||||
category: LogCategory,
|
||||
message: string,
|
||||
error?: Error,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
write('WARN', category, message, error, context);
|
||||
},
|
||||
error(
|
||||
category: LogCategory,
|
||||
message: string,
|
||||
error?: Error,
|
||||
context?: Record<string, unknown>,
|
||||
): void {
|
||||
write('ERROR', category, message, error, context);
|
||||
},
|
||||
};
|
||||
|
||||
/** 预热日志模块(在 app.whenReady() 中调用) */
|
||||
export function initLogger(): void {
|
||||
ensureDir();
|
||||
if (logDir) {
|
||||
rotateIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收渲染进程日志并写入文件
|
||||
* 由 log-ipc.ts 的 IPC 监听器调用
|
||||
*/
|
||||
export function writeRendererLog(entry: LogEntry): void {
|
||||
writeQueue.push(JSON.stringify(entry) + '\n');
|
||||
if (writeQueue.length === 1) {
|
||||
setImmediate(processQueue);
|
||||
}
|
||||
}
|
||||
|
||||
/** 冲刷所有未写入日志并关闭写入流(在 app.before-quit 中调用) */
|
||||
export function flushLogger(): void {
|
||||
if (writeQueue.length > 0) {
|
||||
processQueue();
|
||||
}
|
||||
if (writeStream) {
|
||||
try {
|
||||
writeStream.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
writeStream = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user