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:
@@ -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);
|
||||
|
||||
19
src/main.tsx
19
src/main.tsx
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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
140
src/utils/logger.ts
Normal 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();
|
||||
Reference in New Issue
Block a user