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

17
.codegraph/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
codegraph-commands.md

6
.codegraph/daemon.pid Normal file
View File

@@ -0,0 +1,6 @@
{
"pid": 91784,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
"startedAt": 1780539931451
}

1
.gitignore vendored
View File

@@ -36,4 +36,3 @@ dist-ssr
*.py
*.pyi
WORKLOG.md
WORKLOG.md

View File

@@ -1,3 +1,4 @@
# 回答语言
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录

View File

@@ -30,17 +30,21 @@
## 前端架构原则
- 组件化:以函数组件 + Hooks 为唯一组件形式UI 拆分为展示组件与容器组件(若无特殊需求,可使用 Hooks 直接连接 Redux
- 组件化:以函数组件 + Hooks 为唯一组件形式UI 拆分为展示组件与容器组件。
- 单向数据流:全局状态使用 Redux Toolkit严格遵循 Action → Reducer → Store → View 单向流动。组件内部状态使用
useState/useReducer不污染全局 Store
- 单向数据流:全局状态使用 Context + Provider 模式AppContext / ThemeContext / SettingsContext
遵循 Provider → Context → Comsumer 单向流动。组件内部状态使用 useState/useReducer不污染全局 Context
- 事件驱动EDA
1. 主进程与渲染进程通信基于 Electron IPCipcMain/ipcRenderer定义明确的事件通道与载荷类型。
2. 渲染进程内跨组件通信优先使用 Redux;需要解耦的场景可借助自定义事件总线(如 mitt但严禁滥用。
2. 渲染进程内跨组件通信优先使用 Context;需要解耦的场景使用自定义事件总线(src/utils/event-bus.ts
所有 emit 调用自动记录到日志文件用于审计追踪。
3. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。
3. 自定义错误体系RequestError / RefreshError通过事件总线解耦请求拦截器抛出自定义错误 →
全局捕获 → emit 事件 → UI 层响应。
4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。
- 路由与导航:主窗口为 单页应用,使用 React Router v7+ 管理页面切换,支持嵌套路由。
@@ -50,50 +54,62 @@
# 目录结构
```md
├── electron/ # Electron 主进程与预加载脚本
│ ├── main/ # 主进程代码
├── index.ts # 入口,初始化应用
│ ├── windowManager.ts # 多窗口管理器(创建、销毁、聚焦)
│ ├── ipc/ # IPC 事件注册与处理
│ ├── menu/ # 系统菜单模板(按平台拆分
│ └── utils/ # 平台判断、路径处理等
└── preload/ # 预加载脚本
├── index.ts # 主窗口预加载
└── subWindow.ts # 辅助窗口预加载
├── src/ # 渲染进程React 应用
│ ├── main/ # 主窗口 SPA 入口
├── index.html # HTML 入口
├── main.tsx # React 挂载点
│ └── App.tsx # 根组件(路由、主题
├── sub/ # 辅助窗口独立入口(一个窗口对应一个子目录
├── settings/ # 设置窗口示例
│ ├── index.html
│ └── main.tsx
│ └── preview/ # 预览窗口示例
│ ├── components/ # 通用 UI 组件
│ ├── containers/ # 业务容器组件(连接 Redux
├── store/ # Redux Toolkit 配置
│ ├── index.ts # Store 创建
│ ├── slices/ # 按功能拆分 Slice
│ └── hooks.ts # 类型化 useAppSelector/useAppDispatch
│ ├── hooks/ # 自定义 Hooks
├── theme/ # 主题适配器与定制
│ ├── adapters/ # Ant Design 主题配置、Tailwind 扩展
│ ├── tokens.ts # 设计令牌(颜色、圆角、阴影等)
│ └── globals.css # Tailwind 指令 + 全局样式
│ ├── router/ # 路由配置(主窗口)
├── utils/ # 纯函数工具
│ └── platform.ts # 平台判定工具process.platform 封装)
│ └── types/ # 渲染进程专用类型
├── electron/ # Electron 主进程
├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期
├── preload.ts # 预加载脚本contextBridge
└── main/
├── updater.ts # 自定义更新API 版本检查 + OSS 下载 + NSIS 安装)
├── logger.ts # 主进程日志核心JSON Lines 写入 / 轮转 / 清理
├── log-ipc.ts # 渲染进程日志 IPC 通道接收
├── net-request.ts # Electron net.request 的 axios 风格封装
├── menu/index.ts # 系统菜单
└── utils/ # 平台判断 / 图标路径
├── src/ # 渲染进程React 19 SPA
├── main.tsx # React 挂载点 + 全局错误捕获
├── App.tsx # 根组件(路由 + 登录弹窗)
├── components/ # 通用 UI 组件 + Provider
│ ├── AppProvider.tsx # 全局状态platform / edition / login
│ ├── ThemeProvider.tsx # 主题状态light / dark
├── SettingsProvider.tsx # 设置状态(存储路径等)
├── Layout.tsx # 页面壳NavBar + Outlet
└── navbar/ # 导航栏 + 公告抽屉
├── contexts/ # Context 定义app / settings / theme
├── hooks/ # 自定义 HooksuseUpdater / useTheme
├── pages/ # 页面组件
│ ├── home/ # 首页仪表盘
│ ├── login/ # 登录 / 注册页
│ └── settings/ # 设置面板(主题 / 存储 / 更新)
├── router/index.tsx # HashRouter 路由配置
├── services/ # HTTP 请求封装
│ ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
│ ├── types.ts # 自定义错误类型RequestError / RefreshError
│ ├── auth-token.ts # refresh_token 管理
│ └── modules/ # API 模块auth / announcement
├── theme/ # 主题系统
│ ├── tokens.ts # 设计令牌
│ └── globals.css # Tailwind + 全局样式
└── utils/ # 纯函数工具
│ ├── event-bus.ts # 事件总线on / off / emit + 日志钩子)
│ ├── logger.ts # 渲染进程日志器IPC 转发 / DEV 控制台)
│ ├── ipc.ts # 安全 IPC 守卫safeIpcOn / send / invoke
│ ├── platform.ts # 平台判定
│ └── device.ts # 设备 ID 生成
├── shared/ # 主进程 & 渲染进程共享
│ ├── types/ # IPC 通道、载荷类型、通用接口
│ ├── constants/ # 事件名称、配置常量
│ └── utils/ # 通用逻辑(如版本比较)
├── package.json
├── vite.config.ts # Vite 配置(多入口
├── tailwind.config.ts # Tailwind 主题扩展
├── types/ # 类型定义API / IPC / update / logging
├── constants/ # 常量IPC 通道 / version / app
└── utils/ # 共享工具
│ ├── index.ts # debounce / throttle / compareVersions
│ └── sanitize.ts # 日志脱敏工具ID / 邮箱 / 手机 / 名称
├── scripts/ # 构建脚本
│ ├── build.mjs # 构建总管
│ ├── clean.mjs # 清理产物
│ ├── upload-oss.mjs # OSS 上传
│ ├── publish-release.mjs # 发布到 API
│ └── generate-update-info.mjs # 生成更新元信息
├── package.json # 含 electron-builder 打包配置
├── vite.config.ts
├── tsconfig.json
└── electron-builder.yml # 打包配置(跨平台适配)
└── tailwind.config.ts
```
# 多窗口与平台判定
@@ -104,7 +120,7 @@
- src/utils/platform.ts 提供 isMacOS、isWindows 等函数(基于 navigator.userAgent 或 preload 暴露的 process.platform。在主进程的
electron/main/utils/platform.ts 中直接使用 Node.js API。
- 打包配置 (electron-builder.yml) 须针对平台设置不同的图标、签名策略、安装包格式。
- 打包配置 (package.json build 字段) 须针对平台设置不同的图标、签名策略、安装包格式。
# 代码风格
@@ -131,13 +147,29 @@
- 使用 React.memo、useMemo、useCallback 优化性能,但不要过早优化。
## Redux
## 状态管理
- 使用 Redux Toolkit 的 createSlice 管理状态,异步逻辑使用 createAsyncThunk。
- 使用 React Context + Provider 模式管理全局状态按领域拆分AppContext平台/登录、ThemeContext主题
SettingsContext设置项。避免引入 Redux 等重量级方案。
- Slice 按业务领域划分,每个 Slice 文件导出 actions 与 reducer。
- Provider 组件在 main.tsx 中按层级嵌套挂载:
AppProvider → ThemeProvider → SettingsProvider → AntdApp → App
- 组件中通过自定义 Hooks (useAppSelector, useAppDispatch) 访问 Store避免直接使用 useDispatch/useSelector
- 各模块通过自定义 Hook 消费(useAppContext / useTheme / useSettingsHook 内部做 null 检查
## 日志系统
- 双端日志主进程直接写文件electron/main/logger.ts渲染进程通过 IPC send 上报src/utils/logger.ts
两端暴露一致的 API`logger.debug / info / warn / error(category, message, error?, context?)`
- 文件存储JSON Lines 格式(每行一个 JSON路径 `{userData}/logs/app-YYYY-MM-DD.log`
每日轮转 + 7 天自动清理。
- 事件总线联动event-bus.ts 暴露 `setEventLogListener` 钩子logger 注册后所有 emit 自动记录 DEBUG 日志。
- 错误日志request.ts 拦截器根据错误类型自动分级记录AUTH_EXPIRED → debug5xx → error其余 → warn
- 脱敏shared/utils/sanitize.ts 在日志写入前自动处理 ID哈希、邮箱/手机/名称/Token 等字段。
# 样式与主题
@@ -153,11 +185,15 @@
# 事件与 IPC
- 所有 IPC 通道名称定义在 shared/constants/ipc-channels.ts 中,避免魔法字符串。
- 所有 IPC 通道名称定义在 shared/constants/ipc-channels.ts 中,分为三类:
MAIN_TO_RENDERER / RENDERER_TO_MAIN / BIDIRECTIONAL。
- 渲染进程通过 src/utils/ipc.ts 中的安全守卫访问 IPChasIpc 检查 → safeIpcOn / send / invoke
非 Electron 环境下静默跳过,保证浏览器预览不崩溃。
- 主进程事件处理函数需做好错误捕获与日志记录,渲染进程调用 IPC 时使用 try-catch 并处理超时。
- 窗口间通信走主进程转发:发送窗口 ipcRenderer.send主进程接收后定位目标窗口并 webContents.send。
- 窗口间通信走主进程转发:发送窗口 safeIpcSend主进程接收后定位目标窗口并 webContents.send。
# 开发与构建命令

54
TODO.md
View File

@@ -11,12 +11,13 @@
当前所有敏感数据存储在 `localStorage` 明文/Base64
| 数据 | 现状 | 目标方案 |
|------|------|---------|
|------------------|-----------|----------------------------------------|
| `access_token` | 明文 | Electron `safeStorage.encryptString()` |
| `refresh_token` | 明文 | Electron `safeStorage.encryptString()` |
| `passwordBase64` | Base64 编码 | 移除密码存储,仅靠 refresh_token 自动登录 |
**方案 A推荐**:使用 Electron `safeStorage` API
- macOS → Keychain
- Windows → DPAPI
- Linux → libsecret
@@ -33,6 +34,7 @@ contextBridge.exposeInMainWorld('safeStorage', {
```
**方案 B**:敏感操作下沉到主进程
- `access_token` / `refresh_token` 由主进程管理
- 渲染进程通过 IPC 发起请求时由主进程追加 Token
- 渲染进程永不持有明文 Token
@@ -85,6 +87,54 @@ contextBridge.exposeInMainWorld('safeStorage', {
- [ ] 单元测试覆盖Vitest
- [ ] Bundle 体积分析优化rollup-plugin-visualizer 已有,定期检查)
### 增量更新改造
当前状态:
- `electron-builder` 已配置 `"differential": true`,打包时已生成 `.exe.blockmap` 文件 ✅
- `electron/main/updater.ts` 为**手写全量下载**`net.request`),未使用 `electron-updater` npm 包 ❌
- blockmap 文件生成了但从未被消费,每次更新都是完整下载
目标方案 — 自定义 Provider + electron-updater
```ts
// electron/main/updater-v2.ts
import { autoUpdater } from 'electron-updater';
import { Provider } from 'electron-updater/out/providers/Provider';
class HeiXiuProvider extends Provider<UpdateInfo> {
// 版本检查 → 走自定义 API保留灰度/强更控制)
async getLatestVersion(): Promise<UpdateInfo> {
const res = await fetch(`${API_BASE}/api/v1/update/check?...`);
const data = await res.json();
return {
version: data.latestVersion,
files: [{
url: data.downloadUrl,
sha512: data.sha512,
size: data.fileSize,
blockMapSize: data.blockMapSize, // 后端新增字段
}],
path: data.downloadUrl,
sha512: data.sha512,
releaseDate: data.releaseDate,
};
}
}
```
改造内容:
- [ ] 安装 `electron-updater` npm 包
- [ ] 重写 `electron/main/updater.ts` 为自定义 Provider 模式
- [ ] 发布脚本 `upload-oss.mjs` 增加 blockmap 文件上传
- [ ] `generate-update-info.mjs` 增加 blockmap 字段(`blockMapUrl``blockMapSize`
- [ ] 后端 API `/api/v1/update/check` 响应增加 `blockMapUrl``blockMapSize` 字段
- [ ] 后端 `releases` 表增加 `blockmap_url``blockmap_size`
- [ ] OSS 上传每次版本需同时上传 `.exe` + `.exe.blockmap` 两个文件
增量效果预估:日常更新下载量从完整安装包 → 仅变化的块(预计节省 60%~90% 流量)。
### 代码质量
- [ ] 移除未使用的 `shared/` 类型和常量
@@ -103,7 +153,7 @@ contextBridge.exposeInMainWorld('safeStorage', {
## ⚠️ 已知技术债务
| 问题 | 说明 | 影响 |
|------|------|------|
|-----------------|---------------------------------------|----------------|
| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 |
| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 |
| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 |

View File

@@ -13,20 +13,25 @@
│ 版本检查 ─────────┼────────▶│ /api/v1/update/ │ │ │
│ │ │ check │ │ │
│ │◀────────│ 返回 JSON │ │ │
│ │ │ (含 blockmap 信息) │ │ │
│ │ │ │ │ │
下载安装包 ───────┼─────────┼──────────────────┼────────▶│ 静态文件托管 │
│◀────────┼──────────────────┼─────────│ .exe / .dmg
增量下载(未来)────┼─────────┼──────────────────┼────────▶│ 静态文件托管 │
仅下载变化块 │◀────────┼──────────────────┼─────────│ .exe + .blockmap
│ │ │ │ │ │
安装 (spawn) │ │ │ │ │
本地重建安装包 │ │ │ │ │
│ 校验 SHA512 │ │ │ │ │
│ NSIS /S 静默安装 │ │ │ │ │
│ → app.quit() │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
> **当前状态**:客户端为全量下载(`net.request` 下载完整 .exe。`electron-builder` 打包已生成 `.exe.blockmap` 文件(`"differential": true`),但客户端尚未使用。**增量下载**electron-updater 自定义 Provider为规划中的优化。
| 角色 | 技术 | 职责 |
|-----|--------------------|---------------------------------|
| 客户端 | Electron + `axios` | 调用 API → 下载安装包 → 校验 SHA512 → 安装 |
| ECS | 任意后端语言 | 提供版本检查 API |
| OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage |
| 客户端 | Electron + electron-updater | 调用 API → 下载 blockmap → 差分下载 → 重建 → 校验 → 安装 |
| ECS | 任意后端语言 | 提供版本检查 API(含 blockmap 信息) |
| OSS | 阿里云对象存储 | 托管 .exe / .dmg / .AppImage + .blockmap |
---
@@ -87,9 +92,10 @@ npm run build:win && npm run build:win:enterprise
# 4. 产物在 release/0.1.0/ 下:
# - xxx-Setup.exe安装包
# - xxx-Setup.exe.blockmap块映射增量更新用
# - xxx-update-info.json所有 API 字段downloadUrl 为预判 URL
# 5. 手动上传 exe 到 OSS releases/0.1.0/
# 5. 手动上传 exe + .blockmap 到 OSS releases/0.1.0/
# 6. 将 update-info.json 内容录入数据库 releases 表
```
@@ -165,6 +171,8 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
"downloadUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe",
"fileSize": 98765432,
"sha512": "Base64...",
"blockMapUrl": "https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases/0.1.0/xxx.exe.blockmap",
"blockMapSize": 152340,
"changelog": "更新日志...",
"releaseDate": "2026-06-03",
"forceUpdate": false,
@@ -173,6 +181,13 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|:--:|------|
| `blockMapUrl` | string | 否 | blockmap 文件 OSS 地址(客户端未支持增量时可不传,传了也不影响) |
| `blockMapSize` | number | 否 | blockmap 文件字节数 |
> **向后兼容**`blockMapUrl` / `blockMapSize` 为可选字段。旧版客户端不关心增量更新;新版客户端检测到有 `blockMapUrl` 时启用增量下载,缺失时回退全量下载。
**无更新**
```json
@@ -194,6 +209,8 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win
"downloadUrl": "https://...",
"fileSize": 112910987,
"sha512": "Base64...",
"blockMapUrl": "https://...",
"blockMapSize": 152340,
"changelog": "...",
"releaseDate": "2026-06-03",
"forceUpdate": false,
@@ -214,9 +231,11 @@ CREATE TABLE releases
platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux',
edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise',
version VARCHAR(20) NOT NULL COMMENT '三段式 semver',
file_url VARCHAR(500) NOT NULL COMMENT 'OSS HTTPS 地址',
file_size BIGINT NOT NULL COMMENT '字节数',
sha512 VARCHAR(128) NOT NULL COMMENT 'Base64 编码',
file_url VARCHAR(500) NOT NULL COMMENT '安装包 OSS HTTPS 地址',
file_size BIGINT NOT NULL COMMENT '安装包字节数',
sha512 VARCHAR(128) NOT NULL COMMENT '安装包 SHA512 Base64',
blockmap_url VARCHAR(500) COMMENT 'blockmap 文件 OSS 地址(增量更新用,可为空)',
blockmap_size BIGINT COMMENT 'blockmap 文件字节数',
changelog TEXT,
force_update TINYINT(1) DEFAULT 0,
min_version VARCHAR(20) DEFAULT '',
@@ -230,6 +249,8 @@ CREATE TABLE releases
);
```
> **新字段说明**`blockmap_url` 和 `blockmap_size` 为可选字段DEFAULT NULL不影响现有数据。首次发版时无需回填历史数据——客户端检测到字段为空时自动回退全量下载。
---
## 七、客户端行为
@@ -274,12 +295,13 @@ ele-heixiu/
├ package.json ← 仅保留简短的 npm 快捷命令
├ scripts/
│ ├ clean.mjs ← 清理产物
│ ├ generate-update-info.mjs ← 生成 API 信息 JSON
│ ├ upload-oss.mjs ← 上传 OSS
│ ├ generate-update-info.mjs ← 生成 API 信息 JSON(含 blockmap 信息)
│ ├ upload-oss.mjs ← 上传 OSS(安装包 + blockmap
│ └ publish-release.mjs ← 发布到 APIaxios
├ release/ ← 构建产物目录(按版本分)
│ └ 0.1.0/
│ ├ xxx-Setup.exe
│ ├ xxx-Setup.exe.blockmap ← 差分更新块映射文件
│ └ xxx-update-info.json
├ dist/ ← Vite 前端产物(构建输入)
└ dist-electron/ ← Electron 主进程产物(构建输入)

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}`;
console.log('[Updater] 请求版本检查 API:', apiUrl);
return new Promise((resolve, reject) => {
const req = net.request({ method: 'GET', url: apiUrl });
req.on('response', (response) => {
let body = '';
response.on('data', (chunk) => (body += chunk.toString()));
response.on('end', () => {
try {
const json = JSON.parse(body);
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',
},
},
);
// 兼容两种 API 响应格式:
// 格式 A: { code: 0, data: UpdateCheckResult }
// 格式 B: UpdateCheckResult 直接返回
const data: UpdateCheckResult = json.data ?? json;
const result: UpdateCheckResult = (data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult);
if (!data.hasUpdate) {
console.log('[Updater] 当前已是最新版本');
return resolve(null);
if (!result.hasUpdate) {
logger.info('updater', '当前已是最新版本');
return 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 文件');
}
// 退出应用,让安装器接管

View File

@@ -23,7 +23,7 @@
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"electron/**/*.ts\" \"shared/**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
"test:e2e": "playwright test.json"
},
"dependencies": {
"antd": "^6.4.3",
@@ -36,6 +36,7 @@
"appId": "com.heixiu.electron",
"productName": "船长·HeiXiu",
"asar": true,
"differential": true,
"directories": {
"output": "release/${version}"
},

View File

@@ -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
View 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
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;
}

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, {
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();