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

182
shared/utils/sanitize.ts Normal file
View File

@@ -0,0 +1,182 @@
// ============================================================
// 日志脱敏工具 — 主进程 + 渲染进程共用
//
// 设计:在 createEntry 阶段统一对 context 字段脱敏,调用方无感知
//
// 脱敏策略(按字段名模式匹配,大小写不敏感):
// *token* / *secret* / *password* / *pwd* → [REDACTED]
// *email* → 保留首字符 + 域名t***@qq.com
// *phone* / *mobile* / *tel* → 保留首 3 尾 4138****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;
}