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:
2026-06-04 11:03:25 +08:00
parent 337234443a
commit ae733016bb
21 changed files with 1245 additions and 154 deletions

View File

@@ -8,6 +8,8 @@ import { getWindowIconPath } from './main/utils/logo';
import { buildWindowTitle, parseEdition } from '../shared/constants/app';
import { initUpdater, registerUpdateIpcHandlers } from './main/updater';
import { setupAppMenu } from './main/menu';
import { initLogger, flushLogger, logger } from './main/logger';
import { registerLogIpcHandlers } from './main/log-ipc';
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels';
createRequire(import.meta.url);
@@ -28,6 +30,27 @@ const currentPlatform = getPlatform();
const currentEdition = parseEdition(process.env.EDITION);
const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition);
// ============================================================
// 全局未捕获异常(主进程兜底记录)
// ============================================================
process.on('uncaughtException', (error) => {
try {
logger.error('app', 'Uncaught exception (main process)', error);
} catch {
/* logger 自身异常 — 最后防线 */
}
});
process.on('unhandledRejection', (reason) => {
try {
const error = reason instanceof Error ? reason : new Error(String(reason));
logger.error('app', 'Unhandled rejection (main process)', error);
} catch {
/* logger 自身异常 — 最后防线 */
}
});
// ============================================================
// 窗口引用
// ============================================================
@@ -112,10 +135,12 @@ app.on('activate', () => {
});
app.on('before-quit', () => {
// 清理工作
flushLogger();
});
app.whenReady().then(() => {
initLogger();
registerLogIpcHandlers();
setupAppMenu();
app.setName(WINDOW_TITLE);
registerUpdateIpcHandlers();

21
electron/main/log-ipc.ts Normal file
View File

@@ -0,0 +1,21 @@
// ============================================================
// 日志 IPC 通道注册 — 主进程接收渲染进程日志
// ============================================================
import { ipcMain } from 'electron';
import { RENDERER_TO_MAIN } from '../../shared/constants/ipc-channels';
import { writeRendererLog } from './logger';
import type { LogEntry } from '../../shared/types/logging';
/**
* 注册日志 IPC 监听器
* 使用 ipcMain.onfire-and-forget而非 handle无需响应
* 在 app.whenReady() 中调用
*/
export function registerLogIpcHandlers(): void {
ipcMain.on(RENDERER_TO_MAIN.LOG_MESSAGE, (_event, entry: LogEntry) => {
// 基础校验:防御渲染进程异常数据
if (!entry || !entry.level || !entry.category || !entry.message) return;
writeRendererLog(entry);
});
}

251
electron/main/logger.ts Normal file
View File

@@ -0,0 +1,251 @@
// ============================================================
// 主进程日志核心 — 文件写入 / 轮转 / 清理
//
// 架构:
// - 日志写入 {userData}/logs/app-YYYY-MM-DD.logJSON 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;
}
}

View File

@@ -0,0 +1,208 @@
// ============================================================
// NetRequest — Electron net.request 的 axios 风格封装
//
// 用法:
// import { request, get, post } from './net-request';
// const { data } = await get<UpdateCheckResult>('/api/v1/update/check', {
// baseURL: 'https://www.heixiu.com',
// params: { platform: 'win32', version: '1.0.0' },
// });
//
// 设计:
// - 底层使用 Electron net.request不引入额外依赖
// - Promise 封装,支持 async/await
// - JSON 自动解析、查询参数自动拼接
// - 流式下载场景仍直接用 net.request本模块不封装 stream
// ============================================================
import { net } from 'electron';
// ---------- 类型 ----------
export interface RequestConfig {
/** 请求方法(默认 GET */
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
/** 基础 URL可选方便切换环境 */
baseURL?: string;
/** 查询参数(自动拼接 ?key=value&... */
params?: Record<string, string | number | undefined>;
/** 请求头 */
headers?: Record<string, string>;
/** 请求体(对象自动 JSON.stringify字符串原样发送 */
data?: unknown;
/** 响应类型(默认 json设置 text 跳过解析) */
responseType?: 'json' | 'text';
/** 超时时间(毫秒,默认 30s */
timeout?: number;
}
export interface RequestResponse<T = unknown> {
/** HTTP 状态码 */
status: number;
/** 状态文本(如 "OK" */
statusText: string;
/** 响应头 */
headers: Record<string, string | string[]>;
/** 响应体 */
data: T;
}
/** 网络请求错误 */
export class RequestError extends Error {
status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'NetRequestError';
this.status = status;
}
}
// ---------- 工具 ----------
const DEFAULT_TIMEOUT = 30_000;
/** 拼接 URLbaseURL + path + query */
function buildUrl(url: string, config: RequestConfig): string {
let fullUrl = url;
if (config.baseURL) {
// url 以 / 开头时为绝对路径,否则拼接
const base = config.baseURL.replace(/\/+$/, '');
const path = url.startsWith('/') ? url : `/${url}`;
fullUrl = `${base}${path}`;
}
if (config.params) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(config.params)) {
if (value !== undefined) {
search.append(key, String(value));
}
}
const qs = search.toString();
if (qs) {
fullUrl += (fullUrl.includes('?') ? '&' : '?') + qs;
}
}
return fullUrl;
}
/** 序列化请求体 */
function serializeBody(data: unknown): { body: string; contentType: string } {
if (typeof data === 'string') {
return { body: data, contentType: 'text/plain' };
}
return { body: JSON.stringify(data), contentType: 'application/json' };
}
// ---------- 核心 ----------
/**
* 发起请求axios 风格 API
*
* 流式下载场景请直接使用 Electron net.request本模块不封装
*/
export async function request<T = unknown>(
url: string,
config: RequestConfig = {},
): Promise<RequestResponse<T>> {
const fullUrl = buildUrl(url, config);
const method = (config.method || 'GET').toUpperCase();
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
return new Promise((resolve, reject) => {
const req = net.request({
method,
url: fullUrl,
});
// 超时处理
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
req.abort();
reject(new RequestError(`请求超时 (${timeout}ms)`));
}, timeout);
// 请求头
if (config.headers) {
for (const [key, value] of Object.entries(config.headers)) {
req.setHeader(key, value);
}
}
// 请求体
if (config.data !== undefined) {
const { body, contentType } = serializeBody(config.data);
req.setHeader('Content-Type', contentType);
req.write(body);
}
req.on('response', (response) => {
clearTimeout(timer);
const status = response.statusCode;
const statusText = response.statusMessage ?? '';
// 读取响应头
const headers: Record<string, string | string[]> = {};
// Electron IncomingMessage.headers 是 Record<string, string[]>
const rawHeaders = response.headers;
if (rawHeaders) {
for (const [key, value] of Object.entries(rawHeaders)) {
headers[key] = value.length === 1 ? value[0] : value;
}
}
// 读取响应体
let body = '';
response.on('data', (chunk: Buffer) => (body += chunk.toString()));
response.on('end', () => {
try {
const asJson = config.responseType !== 'text';
const data = asJson ? JSON.parse(body) : body;
resolve({ status, statusText, headers, data: data as T });
} catch {
// JSON 解析失败 → 返回原始文本
resolve({ status, statusText, headers, data: body as T });
}
});
response.on('error', (err) => {
clearTimeout(timer);
reject(new RequestError(`响应读取失败: ${err.message}`));
});
});
req.on('error', (err) => {
clearTimeout(timer);
if (timedOut) return;
reject(new RequestError(`网络请求失败: ${err.message}`));
});
req.end();
});
}
// ---------- 默认导出:类 axios 命名空间 ----------
export const netRequest = {
request,
get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'GET' });
},
post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'POST', data });
},
put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'PUT', data });
},
del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
return request<T>(url, { ...config, method: 'DELETE' });
},
};

View File

@@ -25,6 +25,8 @@ import { unlink } from 'node:fs/promises';
import type { UpdateCheckResult } from '../../shared/types';
import { APP_VERSION } from '../../shared/constants/version';
import { logger } from './logger';
import { netRequest } from './net-request';
// ---------- IPC 通道(对渲染进程暴露,与旧版兼容)----------
@@ -59,7 +61,7 @@ export function initUpdater(mainWindow: BrowserWindow): void {
updateWindow = mainWindow;
if (import.meta.env.DEV) {
console.log('[Updater] 开发模式,跳过自动检查更新。API:', getApiBaseUrl());
logger.info('updater', '开发模式,跳过自动检查更新', { apiUrl: getApiBaseUrl() });
return;
}
@@ -74,43 +76,30 @@ export function initUpdater(mainWindow: BrowserWindow): void {
// ============================================================
async function fetchUpdateInfo(): Promise<UpdateCheckResult | null> {
const platform = process.platform; // 'win32' | 'darwin' | 'linux'
const edition = process.env.EDITION || 'personal';
const apiUrl = `${getApiBaseUrl()}/api/v1/update/check?platform=${platform}&version=${APP_VERSION}&edition=${edition}`;
const { data } = await netRequest.get<{ code: number; data?: UpdateCheckResult } & UpdateCheckResult>(
'/api/v1/update/check',
{
baseURL: getApiBaseUrl(),
params: {
platform: process.platform,
version: APP_VERSION,
edition: process.env.EDITION || 'personal',
},
},
);
console.log('[Updater] 请求版本检查 API:', apiUrl);
// 兼容两种 API 响应格式:
// 格式 A: { code: 0, data: UpdateCheckResult }
// 格式 B: UpdateCheckResult 直接返回
const result: UpdateCheckResult = (data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
return new Promise((resolve, reject) => {
const req = net.request({ method: 'GET', url: apiUrl });
if (!result.hasUpdate) {
logger.info('updater', '当前已是最新版本');
return null;
}
req.on('response', (response) => {
let body = '';
response.on('data', (chunk) => (body += chunk.toString()));
response.on('end', () => {
try {
const json = JSON.parse(body);
// 兼容两种 API 响应格式:
// 格式 A: { code: 0, data: UpdateCheckResult }
// 格式 B: UpdateCheckResult 直接返回
const data: UpdateCheckResult = json.data ?? json;
if (!data.hasUpdate) {
console.log('[Updater] 当前已是最新版本');
return resolve(null);
}
console.log('[Updater] 发现新版本:', data.latestVersion);
resolve(data);
} catch (err) {
reject(new Error(`API 响应解析失败: ${(err as Error).message}`));
}
});
});
req.on('error', (err) => reject(new Error(`API 请求失败: ${err.message}`)));
req.end();
});
logger.info('updater', '发现新版本', { latestVersion: result.latestVersion });
return result;
}
// ============================================================
@@ -122,8 +111,7 @@ async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
process.platform === 'win32' ? '.exe' : process.platform === 'darwin' ? '.dmg' : '.AppImage';
const tempPath = join(tmpdir(), `HeiXiu-${info.latestVersion}-update${ext}`);
console.log('[Updater] 开始下载:', info.downloadUrl);
console.log('[Updater] 临时文件:', tempPath);
logger.info('updater', '开始下载', { downloadUrl: info.downloadUrl, tempPath });
return new Promise((resolve, reject) => {
const req = net.request({ method: 'GET', url: info.downloadUrl });
@@ -174,7 +162,7 @@ async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
);
}
console.log('[Updater] 下载完成SHA512 校验通过');
logger.info('updater', '下载完成SHA512 校验通过');
pendingInstallerPath = tempPath;
resolve(tempPath);
});
@@ -203,12 +191,12 @@ async function downloadInstaller(info: UpdateCheckResult): Promise<string> {
export async function checkForUpdates(): Promise<void> {
if (updateChecked) {
console.log('[Updater] 已检查过更新,跳过');
logger.debug('updater', '已检查过更新,跳过');
return;
}
if (import.meta.env.DEV) {
console.log('[Updater] 开发模式,跳过更新检查');
logger.debug('updater', '开发模式,跳过更新检查');
return;
}
@@ -235,7 +223,7 @@ export async function checkForUpdates(): Promise<void> {
// 下载完成 → 通知渲染进程
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
} catch (error) {
console.error('[Updater] 更新流程失败:', (error as Error).message);
logger.error('updater', '更新流程失败', error as Error, { apiUrl: getApiBaseUrl() });
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '更新检查失败',
});
@@ -263,7 +251,7 @@ export async function checkForUpdatesManual(): Promise<void> {
await downloadInstaller(info);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_DOWNLOADED);
} catch (error) {
console.error('[Updater] 手动更新失败:', (error as Error).message);
logger.error('updater', '手动更新失败', error as Error);
updateWindow?.webContents.send(UPDATE_CHANNELS.UPDATE_ERROR, {
message: (error as Error).message || '检查更新失败',
});
@@ -272,11 +260,11 @@ export async function checkForUpdatesManual(): Promise<void> {
export function installUpdateNow(): void {
if (!pendingInstallerPath) {
console.error('[Updater] 没有待安装的更新文件');
logger.warn('updater', '没有待安装的更新文件');
return;
}
console.log('[Updater] 开始安装更新:', pendingInstallerPath);
logger.info('updater', '开始安装更新', { installerPath: pendingInstallerPath });
if (process.platform === 'win32') {
// Windows: NSIS 安装器,/S 静默安装
@@ -292,7 +280,7 @@ export function installUpdateNow(): void {
});
} else {
// Linux: AppImagechmod +x 后执行(通常需要手动替换)
console.log('[Updater] Linux 更新手动替换 AppImage 文件');
logger.warn('updater', 'Linux 更新手动替换 AppImage 文件');
}
// 退出应用,让安装器接管