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

@@ -13,6 +13,7 @@ import type { AppContextValue } from '@/contexts/app-context';
import type { LoginResponseBody } from '@/services/modules';
import { setToken, clearToken } from '@/services/request';
import { emit, EVENTS } from '@/utils/event-bus';
import { logger } from '@/utils/logger';
import { AuthAPI } from '@/services/modules';
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/pages/login/hooks/use-auth-state';
import {
@@ -41,10 +42,15 @@ export function AppProvider({ children }: AppProviderProps) {
setStoredRefreshToken(auth.refresh_token);
setUser(auth);
setIsLoggedIn(true);
logger.info('auth', 'User logged in', {
userId: auth.user_id,
username: auth.username,
});
emit(EVENTS.LOGIN_SUCCESS, auth);
}, []);
const logout = useCallback(() => {
logger.info('auth', 'User logged out');
clearToken();
clearStoredRefreshToken();
setUser(null);
@@ -71,6 +77,7 @@ export function AppProvider({ children }: AppProviderProps) {
setToken(res.access_token);
setStoredRefreshToken(res.refresh_token);
setIsLoggedIn(true);
logger.info('auth', 'Auto-login succeeded');
// 补全用户信息
AuthAPI.getUserInfo().then((info) => {
const u: LoginResponseBody = {
@@ -97,6 +104,7 @@ export function AppProvider({ children }: AppProviderProps) {
});
})
.catch(() => {
logger.warn('auth', 'Auto-login failed, refresh_token expired');
clearStoredRefreshToken();
// 仅清除自动登录标记,保留用户名+密码供下次表单回填
clearAutoLoginFlag();
@@ -109,9 +117,7 @@ export function AppProvider({ children }: AppProviderProps) {
const handler = (_event: unknown, info: unknown) => {
const data = info as { platform?: string; edition?: string } | undefined;
if (data) {
if (isDev()) {
console.log('[AppContext] 主进程平台信息:', data);
}
logger.debug('app', 'Received platform info from main process', data as Record<string, unknown>);
}
};
safeIpcOn('platform-info', handler);

View File

@@ -32,3 +32,22 @@ import { safeIpcOn } from './utils/ipc';
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
console.log('[Main Process Message]:', message);
});
// 渲染进程全局未捕获错误(兜底记录到日志文件)
import { logger } from './utils/logger';
window.addEventListener('error', (event) => {
logger.error('ui', 'Uncaught error (renderer)', event.error, {
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
});
});
window.addEventListener('unhandledrejection', (event) => {
const error =
event.reason instanceof Error
? event.reason
: new Error(String(event.reason));
logger.error('ui', 'Unhandled promise rejection (renderer)', error);
});

View File

@@ -18,6 +18,7 @@ import axios, {
import type { ApiResponse } from './types';
import { RequestError, RequestErrorType, RefreshError, isRefreshError } from './types';
import { emit, EVENTS } from '../utils/event-bus';
import { logger } from '../utils/logger';
// ---------- 配置 ----------
@@ -187,6 +188,13 @@ instance.interceptors.response.use(
return handle401(response.config);
}
logger.warn('request', `Business error: ${data.message || '请求失败'}`, undefined, {
url: response.config.url,
method: response.config.method?.toUpperCase(),
code: data.code,
traceId: data.traceId,
});
return Promise.reject(
new RequestError(RequestErrorType.BUSINESS, data.message || '请求失败', {
code: data.code,
@@ -199,8 +207,19 @@ instance.interceptors.response.use(
return response;
},
(error) => {
// 全局捕获 RefreshError → 自动触发登录 Modal直接返回不继续走后续判断
// 提取请求上下文(所有日志共用)
const reqCtx = {
url: error.config?.url,
method: error.config?.method?.toUpperCase(),
};
// 全局捕获 RefreshError → 自动触发登录 Modal
if (isRefreshError(error)) {
logger.debug('request', 'Auth expired (RefreshError caught)', {
...reqCtx,
errorType: error.name,
errorMessage: error.message,
});
clearToken();
emit(EVENTS.AUTH_REQUIRED);
return Promise.reject(error);
@@ -208,26 +227,37 @@ instance.interceptors.response.use(
// 请求已被取消
if (axios.isCancel(error)) {
return Promise.reject(new RequestError(RequestErrorType.CANCELLED, '请求已取消'));
const cancelErr = new RequestError(RequestErrorType.CANCELLED, '请求已取消');
logger.debug('request', 'Request cancelled', reqCtx);
return Promise.reject(cancelErr);
}
// 超时
if (error.code === 'ECONNABORTED') {
return Promise.reject(new RequestError(RequestErrorType.TIMEOUT, '请求超时,请检查网络'));
const timeoutErr = new RequestError(RequestErrorType.TIMEOUT, '请求超时,请检查网络');
logger.warn('request', 'Request timeout', timeoutErr, reqCtx);
return Promise.reject(timeoutErr);
}
// 无网络
if (!error.response) {
return Promise.reject(new RequestError(RequestErrorType.NETWORK, '网络异常,请检查连接'));
const netErr = new RequestError(RequestErrorType.NETWORK, '网络异常,请检查连接');
logger.warn('request', 'Network error', netErr, reqCtx);
return Promise.reject(netErr);
}
// HTTP 状态码错误
const { status, data } = error.response;
// 401 → 尝试刷新 token 并重试(refresh 接口自身 401 必须直接拒绝,否则死循环)
// 401 → 尝试刷新 token 并重试refresh 接口自身 401 直接拒绝,死循环)
if (status === 401 && error.config) {
if (isRefreshRequest(error.config)) {
return Promise.reject(new RefreshError());
const refreshErr = new RefreshError();
logger.debug('request', 'Refresh endpoint returned 401', {
...reqCtx,
errorType: refreshErr.name,
});
return Promise.reject(refreshErr);
}
return handle401(error.config);
}
@@ -255,12 +285,19 @@ instance.interceptors.response.use(
break;
}
return Promise.reject(
new RequestError(RequestErrorType.HTTP, data?.message || errMsg, {
httpStatus: status,
code: data?.code,
}),
);
const httpErr = new RequestError(RequestErrorType.HTTP, data?.message || errMsg, {
httpStatus: status,
code: data?.code,
});
// HTTP 5xx → error 级别4xx → warn 级别
if (status >= 500) {
logger.error('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx);
} else {
logger.warn('request', `HTTP ${status}: ${errMsg}`, httpErr, reqCtx);
}
return Promise.reject(httpErr);
},
);

View File

@@ -7,6 +7,21 @@ type Listener = (...args: unknown[]) => void;
const listeners = new Map<string, Set<Listener>>();
// ---------- 日志钩子(由 logger 模块注册,避免循环依赖)----------
type EventLogListener = (event: string, ...args: unknown[]) => void;
let eventLogListener: EventLogListener | null = null;
/**
* 注册事件日志监听器(由 logger 模块调用)
* 每次 emit() 时回调,用于审计追踪
*/
export function setEventLogListener(fn: EventLogListener): void {
eventLogListener = fn;
}
// ---------- API ----------
/** 监听事件 */
export function on(event: string, fn: Listener): void {
if (!listeners.has(event)) {
@@ -22,6 +37,16 @@ export function off(event: string, fn: Listener): void {
/** 触发事件 */
export function emit(event: string, ...args: unknown[]): void {
// ① 通知日志监听器(在触发业务监听器之前记录)
if (eventLogListener) {
try {
eventLogListener(event, ...args);
} catch {
/* 日志钩子异常不影响事件投递 */
}
}
// ② 通知业务监听器
listeners.get(event)?.forEach((fn) => {
try {
fn(...args);

140
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,140 @@
// ============================================================
// 渲染进程日志器 — 通过 IPC 转发到主进程写入文件
//
// 用法:
// import { logger } from '@/utils/logger';
// logger.info('auth', 'User logged in', { userId: 42 });
// logger.warn('request', 'Request timeout', new Error('...'), { url: '...' });
//
// 行为:
// Electron 环境 → safeIpcSend('log-message', entry) 发往主进程
// 浏览器 DEV 环境 → console[level] 输出(不回退文件)
//
// 事件总线联动:
// 调用 registerEventLogHook() 后,所有 emit() 自动记录 DEBUG event 日志
// ============================================================
import { safeIpcSend, hasIpc } from './ipc';
import { RENDERER_TO_MAIN } from '@shared/constants/ipc-channels';
import { setEventLogListener } from './event-bus';
import { sanitizeLogEntry } from '@shared/utils/sanitize';
import type { LogEntry, LogLevel, LogCategory, LogErrorSnapshot } from '@shared/types/logging';
// ---------- 辅助 ----------
/** 错误对象 → 可序列化快照 */
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,
error?: Error,
context?: Record<string, unknown>,
): LogEntry {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
category,
message,
processType: 'renderer',
};
if (context && Object.keys(context).length > 0) {
entry.context = context;
}
if (error) {
entry.error = errorToSnapshot(error);
}
return entry;
}
/** 发送日志条目 */
function send(rawEntry: LogEntry): void {
const entry = sanitizeLogEntry(rawEntry);
// Electron 环境 → IPC 发往主进程写入文件
if (hasIpc()) {
safeIpcSend(RENDERER_TO_MAIN.LOG_MESSAGE, entry);
}
// DEV 模式 → 同时输出到浏览器控制台(开发者可见)
if (import.meta.env.DEV) {
const prefix = `[${entry.level}][${entry.category}]`;
const ctx = entry.context ? ` ${JSON.stringify(entry.context)}` : '';
const errMsg = entry.error ? `${entry.error.name}: ${entry.error.message}` : '';
switch (entry.level) {
case 'DEBUG':
console.debug(prefix, entry.message, ctx);
break;
case 'INFO':
console.info(prefix, entry.message, ctx);
break;
case 'WARN':
console.warn(prefix, entry.message + errMsg, ctx);
break;
case 'ERROR':
console.error(prefix, entry.message + errMsg, ctx, entry.error?.stack || '');
break;
}
}
}
// ---------- 对外 API ----------
export const logger = {
debug(category: LogCategory, message: string, context?: Record<string, unknown>): void {
send(createEntry('DEBUG', category, message, undefined, context));
},
info(category: LogCategory, message: string, context?: Record<string, unknown>): void {
send(createEntry('INFO', category, message, undefined, context));
},
warn(
category: LogCategory,
message: string,
error?: Error,
context?: Record<string, unknown>,
): void {
send(createEntry('WARN', category, message, error, context));
},
error(
category: LogCategory,
message: string,
error?: Error,
context?: Record<string, unknown>,
): void {
send(createEntry('ERROR', category, message, error, context));
},
};
// ---------- 事件总线联动 ----------
let eventHookRegistered = false;
/**
* 注册事件总线日志钩子(单次调用,重复调用无副作用)
* 注册后所有 emit() 调用自动记录为 DEBUG event 日志
*/
export function registerEventLogHook(): void {
if (eventHookRegistered) return;
eventHookRegistered = true;
setEventLogListener((event: string, ...args: unknown[]) => {
// 过滤高频事件,避免日志文件膨胀
// 仅记录非 LOGIN_SUCCESS / LOGOUT 的开销极低的事件
logger.debug('event', 'Event emitted', { event, args });
});
}
// 模块加载时自动注册事件总线钩子
registerEventLogHook();