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:
@@ -40,6 +40,12 @@ export interface AppConfig {
|
||||
// 更新相关类型
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// 日志类型(从 logging.ts 重导出)
|
||||
// ============================================================
|
||||
|
||||
export type { LogLevel, LogCategory, LogErrorSnapshot, LogEntry } from './logging';
|
||||
|
||||
/** 更新检查请求参数 */
|
||||
export interface UpdateCheckParams {
|
||||
platform: Platform;
|
||||
|
||||
51
shared/types/logging.ts
Normal file
51
shared/types/logging.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// ============================================================
|
||||
// 日志模块 — 共享类型定义(主进程 + 渲染进程双端使用)
|
||||
// ============================================================
|
||||
|
||||
/** 日志级别 */
|
||||
export type LogLevel = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
|
||||
|
||||
/** 日志分类 */
|
||||
export type LogCategory =
|
||||
| 'request' // HTTP 请求相关
|
||||
| 'auth' // 登录 / Token / 权限
|
||||
| 'updater' // 自动更新
|
||||
| 'event' // 事件总线
|
||||
| 'app' // 应用生命周期
|
||||
| 'ui' // UI 层错误 / 警告
|
||||
| 'ipc'; // IPC 通信
|
||||
|
||||
/**
|
||||
* 结构化错误快照
|
||||
* Error 对象不可 JSON 序列化,提取关键字段保存
|
||||
*/
|
||||
export interface LogErrorSnapshot {
|
||||
/** 错误名称(Error.name 或 constructor name) */
|
||||
name: string;
|
||||
/** 错误消息 */
|
||||
message: string;
|
||||
/** 堆栈信息(可选,按需记录以节省空间) */
|
||||
stack?: string;
|
||||
/** 原始错误链(cause)的消息摘要 */
|
||||
cause?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条日志条目(JSON Lines 中的一行)
|
||||
*/
|
||||
export interface LogEntry {
|
||||
/** ISO 8601 时间戳(例如 "2026-06-04T10:30:00.123Z") */
|
||||
timestamp: string;
|
||||
/** 日志级别 */
|
||||
level: LogLevel;
|
||||
/** 日志分类 */
|
||||
category: LogCategory;
|
||||
/** 日志消息(简短描述) */
|
||||
message: string;
|
||||
/** 日志来源进程 */
|
||||
processType: 'main' | 'renderer';
|
||||
/** 附加上下文(需 JSON 可序列化) */
|
||||
context?: Record<string, unknown>;
|
||||
/** 错误快照(仅在 warn / error 级别时填充) */
|
||||
error?: LogErrorSnapshot;
|
||||
}
|
||||
182
shared/utils/sanitize.ts
Normal file
182
shared/utils/sanitize.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// ============================================================
|
||||
// 日志脱敏工具 — 主进程 + 渲染进程共用
|
||||
//
|
||||
// 设计:在 createEntry 阶段统一对 context 字段脱敏,调用方无感知
|
||||
//
|
||||
// 脱敏策略(按字段名模式匹配,大小写不敏感):
|
||||
// *token* / *secret* / *password* / *pwd* → [REDACTED]
|
||||
// *email* → 保留首字符 + 域名(t***@qq.com)
|
||||
// *phone* / *mobile* / *tel* → 保留首 3 尾 4(138****5678)
|
||||
// *id / *_id / userId / user_id → SHA256 前 8 位(可追溯但不可读)
|
||||
// *name / username / display_name → 保留首字符 + ***
|
||||
// *balance* / *money* / *amount* → 数值替换为 [FILTERED]
|
||||
// 其他字段 → 原样保留
|
||||
// ============================================================
|
||||
|
||||
import type { LogEntry } from '@shared/types';
|
||||
|
||||
// ---------- 脱敏函数 ----------
|
||||
|
||||
/** 通过哈希缩短(用于 ID 类字段,可追溯不可读) */
|
||||
export function hashShort(value: string): string {
|
||||
// 简易哈希(避免引入 crypto 模块,双端兼容)
|
||||
let h = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
h = (Math.imul(31, h) + value.charCodeAt(i)) | 0;
|
||||
}
|
||||
// 转 hex 前缀,补零到 8 位
|
||||
const hex = (h >>> 0).toString(16).padStart(8, '0');
|
||||
return `#${hex}`;
|
||||
}
|
||||
|
||||
/** 邮箱脱敏:首字符 + ***@域名 */
|
||||
export function sanitizeEmail(value: string): string {
|
||||
const atIndex = value.indexOf('@');
|
||||
if (atIndex <= 0) return `${value[0]}***`;
|
||||
return `${value[0]}***@${value.slice(atIndex + 1)}`;
|
||||
}
|
||||
|
||||
/** 手机号脱敏:保留前 3 后 4 */
|
||||
export function sanitizePhone(value: string): string {
|
||||
const digits = value.replace(/\D/g, '');
|
||||
if (digits.length < 7) return `${digits.slice(0, 3)}****`;
|
||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** 名称脱敏:保留首字符 + *** */
|
||||
export function sanitizeName(value: string): string {
|
||||
if (!value || value.length <= 1) return '***';
|
||||
return `${value[0]}***`;
|
||||
}
|
||||
|
||||
/** 判断字段名是否匹配某个模式 */
|
||||
function matchKey(key: string, patterns: string[]): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
return patterns.some((p) => {
|
||||
if (p.startsWith('*') && p.endsWith('*')) return lower.includes(p.slice(1, -1));
|
||||
if (p.endsWith('*')) return lower.startsWith(p.slice(0, -1));
|
||||
if (p.startsWith('*')) return lower.endsWith(p.slice(1));
|
||||
return lower === p;
|
||||
});
|
||||
}
|
||||
|
||||
/** 对单个值脱敏(递归处理嵌套对象) */
|
||||
function sanitizeValue(key: string, value: unknown): unknown {
|
||||
if (value === null || value === undefined) return value;
|
||||
|
||||
// 完全隐藏
|
||||
if (matchKey(key, ['*token*', '*secret*', '*password*', '*pwd*', 'authorization'])) {
|
||||
return '[REDACTED]';
|
||||
}
|
||||
|
||||
// 邮箱
|
||||
if (
|
||||
matchKey(key, ['*email*']) &&
|
||||
typeof value === 'string' &&
|
||||
value.includes('@')
|
||||
) {
|
||||
return sanitizeEmail(value);
|
||||
}
|
||||
|
||||
// 手机号
|
||||
if (matchKey(key, ['*phone*', '*mobile*', '*tel*']) && typeof value === 'string') {
|
||||
return sanitizePhone(value);
|
||||
}
|
||||
|
||||
// ID 类字段(数字或字符串)
|
||||
if (
|
||||
matchKey(key, ['*id', '*_id', 'userId', 'user_id', 'company_id']) &&
|
||||
(typeof value === 'string' || typeof value === 'number')
|
||||
) {
|
||||
return hashShort(String(value));
|
||||
}
|
||||
|
||||
// 名称
|
||||
if (
|
||||
matchKey(key, ['*name', 'username', 'display_name', 'real_name']) &&
|
||||
typeof value === 'string'
|
||||
) {
|
||||
return sanitizeName(value);
|
||||
}
|
||||
|
||||
// 金额 / 余额
|
||||
if (matchKey(key, ['*balance*', '*money*', '*amount*', '*cent*', '*fee*'])) {
|
||||
return '[FILTERED]';
|
||||
}
|
||||
|
||||
// 递归处理嵌套对象
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
return sanitizeRecord(value as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// 数组:逐个脱敏
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => sanitizeValue(key, v));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 脱敏整个 Record */
|
||||
function sanitizeRecord(record: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
result[key] = sanitizeValue(key, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------- 对外 API ----------
|
||||
|
||||
/**
|
||||
* 脱敏任意对象(返回新对象,不修改原对象)
|
||||
*
|
||||
* 用法:
|
||||
* import { sanitize } from '@shared/utils/sanitize';
|
||||
* const safe = sanitize({ userId: 42, email: 'test@qq.com', phone: '13812345678' });
|
||||
* // → { userId: "#a1b2c3d4", email: "t***@qq.com", phone: "138****5678" }
|
||||
*/
|
||||
export function sanitize(record: Record<string, unknown>): Record<string, unknown> {
|
||||
return sanitizeRecord(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 LogEntry 脱敏(原地不修改,返回新对象)
|
||||
* 内部也调用了 sanitizeRecord
|
||||
*/
|
||||
export function sanitizeLogEntry(entry: LogEntry): LogEntry {
|
||||
const sanitized = { ...entry };
|
||||
|
||||
// 脱敏 context
|
||||
if (sanitized.context && Object.keys(sanitized.context).length > 0) {
|
||||
sanitized.context = sanitizeRecord(sanitized.context);
|
||||
}
|
||||
|
||||
// 脱敏 error 中的 message(可能含用户信息,如 "User xxx not found")
|
||||
if (sanitized.error) {
|
||||
sanitized.error = { ...sanitized.error };
|
||||
// 对 error message 做基础脱敏(邮箱/手机号)
|
||||
if (sanitized.error.message) {
|
||||
sanitized.error.message = sanitizeInlineText(sanitized.error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 脱敏 message 字段本身
|
||||
sanitized.message = sanitizeInlineText(sanitized.message);
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对任意文本中的敏感信息做内联脱敏
|
||||
* 使用正则匹配邮箱和手机号模式
|
||||
*/
|
||||
export function sanitizeInlineText(text: string): string {
|
||||
// 邮箱(简单模式)
|
||||
let result = text.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, (match) =>
|
||||
sanitizeEmail(match),
|
||||
);
|
||||
// 手机号(中国大陆 11 位)
|
||||
result = result.replace(/\b1[3-9]\d{9}\b/g, (match) => sanitizePhone(match));
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user