diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9891e7d --- /dev/null +++ b/.codegraph/.gitignore @@ -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 \ No newline at end of file diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid new file mode 100644 index 0000000..affbb7a --- /dev/null +++ b/.codegraph/daemon.pid @@ -0,0 +1,6 @@ +{ + "pid": 91784, + "version": "0.9.9", + "socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea", + "startedAt": 1780539931451 +} diff --git a/.gitignore b/.gitignore index 00800a2..5233289 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,3 @@ dist-ssr *.py *.pyi WORKLOG.md -WORKLOG.md diff --git a/CLAUDE.md b/CLAUDE.md index c45f890..4a5b3f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,4 @@ # 回答语言 - 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明 +- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录 diff --git a/PROJECT.md b/PROJECT.md index 57ec59a..b5c474e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 IPC(ipcMain/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/ # 渲染进程专用类型 -├── shared/ # 主进程 & 渲染进程共享 -│ ├── types/ # IPC 通道、载荷类型、通用接口 -│ ├── constants/ # 事件名称、配置常量 -│ └── utils/ # 通用逻辑(如版本比较) -├── package.json -├── vite.config.ts # Vite 配置(多入口) -├── tailwind.config.ts # Tailwind 主题扩展 +├── 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/ # 自定义 Hooks(useUpdater / 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/ # 类型定义(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 / useSettings),Hook 内部做 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 → debug,5xx → 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 中的安全守卫访问 IPC(hasIpc 检查 → safeIpcOn / send / invoke), + 非 Electron 环境下静默跳过,保证浏览器预览不崩溃。 - 主进程事件处理函数需做好错误捕获与日志记录,渲染进程调用 IPC 时使用 try-catch 并处理超时。 -- 窗口间通信走主进程转发:发送窗口 ipcRenderer.send,主进程接收后定位目标窗口并 webContents.send。 +- 窗口间通信走主进程转发:发送窗口 safeIpcSend,主进程接收后定位目标窗口并 webContents.send。 # 开发与构建命令 diff --git a/TODO.md b/TODO.md index d10665a..a5554fe 100644 --- a/TODO.md +++ b/TODO.md @@ -10,13 +10,14 @@ 当前所有敏感数据存储在 `localStorage` 明文/Base64: -| 数据 | 现状 | 目标方案 | -|------|------|---------| -| `access_token` | 明文 | Electron `safeStorage.encryptString()` | -| `refresh_token` | 明文 | Electron `safeStorage.encryptString()` | -| `passwordBase64` | Base64 编码 | 移除密码存储,仅靠 refresh_token 自动登录 | +| 数据 | 现状 | 目标方案 | +|------------------|-----------|----------------------------------------| +| `access_token` | 明文 | Electron `safeStorage.encryptString()` | +| `refresh_token` | 明文 | Electron `safeStorage.encryptString()` | +| `passwordBase64` | Base64 编码 | 移除密码存储,仅靠 refresh_token 自动登录 | **方案 A(推荐)**:使用 Electron `safeStorage` API + - macOS → Keychain - Windows → DPAPI - Linux → libsecret @@ -24,8 +25,8 @@ ```ts // preload.ts 暴露安全方法 contextBridge.exposeInMainWorld('safeStorage', { - encrypt: (plain: string) => ipcRenderer.invoke('safe-storage:encrypt', plain), - decrypt: (encrypted: Buffer) => ipcRenderer.invoke('safe-storage:decrypt', encrypted), + encrypt: (plain: string) => ipcRenderer.invoke('safe-storage:encrypt', plain), + decrypt: (encrypted: Buffer) => ipcRenderer.invoke('safe-storage:decrypt', encrypted), }); // 替代现有 setToken / setStoredRefreshToken @@ -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 { + // 版本检查 → 走自定义 API(保留灰度/强更控制) + async getLatestVersion(): Promise { + 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/` 类型和常量 @@ -102,12 +152,12 @@ contextBridge.exposeInMainWorld('safeStorage', { ## ⚠️ 已知技术债务 -| 问题 | 说明 | 影响 | -|------|------|------| -| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 | -| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 | -| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 | -| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 | +| 问题 | 说明 | 影响 | +|-----------------|---------------------------------------|----------------| +| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 | +| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 | +| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 | +| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 | --- diff --git a/UpdateA.md b/UpdateA.md index b44b46d..a0060bd 100644 --- a/UpdateA.md +++ b/UpdateA.md @@ -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, @@ -210,26 +227,30 @@ CSC_LINK=/path/to/cert.pfx CSC_KEY_PASSWORD=xxx npm run build:win ```sql CREATE TABLE releases ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - 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 编码', - changelog TEXT, - force_update TINYINT(1) DEFAULT 0, - min_version VARCHAR(20) DEFAULT '', - release_date DATE NOT NULL, - status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + id BIGINT PRIMARY KEY AUTO_INCREMENT, + 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 '安装包 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 '', + release_date DATE NOT NULL, + status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_platform_edition_version (platform, edition, version), INDEX idx_platform_edition_status (platform, edition, status) ); ``` +> **新字段说明**:`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 ← 发布到 API(axios) ├ release/ ← 构建产物目录(按版本分) │ └ 0.1.0/ │ ├ xxx-Setup.exe +│ ├ xxx-Setup.exe.blockmap ← 差分更新块映射文件 │ └ xxx-update-info.json ├ dist/ ← Vite 前端产物(构建输入) └ dist-electron/ ← Electron 主进程产物(构建输入) diff --git a/electron/main.ts b/electron/main.ts index 950584a..55f19d7 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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(); diff --git a/electron/main/log-ipc.ts b/electron/main/log-ipc.ts new file mode 100644 index 0000000..ce9d755 --- /dev/null +++ b/electron/main/log-ipc.ts @@ -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.on(fire-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); + }); +} diff --git a/electron/main/logger.ts b/electron/main/logger.ts new file mode 100644 index 0000000..ef23207 --- /dev/null +++ b/electron/main/logger.ts @@ -0,0 +1,251 @@ +// ============================================================ +// 主进程日志核心 — 文件写入 / 轮转 / 清理 +// +// 架构: +// - 日志写入 {userData}/logs/app-YYYY-MM-DD.log(JSON 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, +): 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, +): 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): void { + write('DEBUG', category, message, undefined, context); + }, + info(category: LogCategory, message: string, context?: Record): void { + write('INFO', category, message, undefined, context); + }, + warn( + category: LogCategory, + message: string, + error?: Error, + context?: Record, + ): void { + write('WARN', category, message, error, context); + }, + error( + category: LogCategory, + message: string, + error?: Error, + context?: Record, + ): 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; + } +} diff --git a/electron/main/net-request.ts b/electron/main/net-request.ts new file mode 100644 index 0000000..df9971c --- /dev/null +++ b/electron/main/net-request.ts @@ -0,0 +1,208 @@ +// ============================================================ +// NetRequest — Electron net.request 的 axios 风格封装 +// +// 用法: +// import { request, get, post } from './net-request'; +// const { data } = await get('/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; + /** 请求头 */ + headers?: Record; + /** 请求体(对象自动 JSON.stringify,字符串原样发送) */ + data?: unknown; + /** 响应类型(默认 json,设置 text 跳过解析) */ + responseType?: 'json' | 'text'; + /** 超时时间(毫秒,默认 30s) */ + timeout?: number; +} + +export interface RequestResponse { + /** HTTP 状态码 */ + status: number; + /** 状态文本(如 "OK") */ + statusText: string; + /** 响应头 */ + headers: Record; + /** 响应体 */ + 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; + +/** 拼接 URL(baseURL + 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( + url: string, + config: RequestConfig = {}, +): Promise> { + 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 = {}; + // Electron IncomingMessage.headers 是 Record + 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(url: string, config?: Omit) { + return request(url, { ...config, method: 'GET' }); + }, + + post(url: string, data?: unknown, config?: Omit) { + return request(url, { ...config, method: 'POST', data }); + }, + + put(url: string, data?: unknown, config?: Omit) { + return request(url, { ...config, method: 'PUT', data }); + }, + + del(url: string, config?: Omit) { + return request(url, { ...config, method: 'DELETE' }); + }, +}; diff --git a/electron/main/updater.ts b/electron/main/updater.ts index 6a43725..de9d5db 100644 --- a/electron/main/updater.ts +++ b/electron/main/updater.ts @@ -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 { - 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}`; + 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', + }, + }, + ); - console.log('[Updater] 请求版本检查 API:', apiUrl); + // 兼容两种 API 响应格式: + // 格式 A: { code: 0, data: UpdateCheckResult } + // 格式 B: UpdateCheckResult 直接返回 + const result: UpdateCheckResult = (data as { data?: UpdateCheckResult }).data ?? (data as UpdateCheckResult); - return new Promise((resolve, reject) => { - const req = net.request({ method: 'GET', url: apiUrl }); + if (!result.hasUpdate) { + logger.info('updater', '当前已是最新版本'); + return null; + } - req.on('response', (response) => { - let body = ''; - response.on('data', (chunk) => (body += chunk.toString())); - response.on('end', () => { - try { - const json = JSON.parse(body); - - // 兼容两种 API 响应格式: - // 格式 A: { code: 0, data: UpdateCheckResult } - // 格式 B: UpdateCheckResult 直接返回 - const data: UpdateCheckResult = json.data ?? json; - - if (!data.hasUpdate) { - console.log('[Updater] 当前已是最新版本'); - return resolve(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 { 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 { ); } - console.log('[Updater] 下载完成,SHA512 校验通过'); + logger.info('updater', '下载完成,SHA512 校验通过'); pendingInstallerPath = tempPath; resolve(tempPath); }); @@ -203,12 +191,12 @@ async function downloadInstaller(info: UpdateCheckResult): Promise { export async function checkForUpdates(): Promise { 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 { // 下载完成 → 通知渲染进程 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 { 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 { 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: AppImage,chmod +x 后执行(通常需要手动替换) - console.log('[Updater] Linux 更新请手动替换 AppImage 文件'); + logger.warn('updater', 'Linux 更新需手动替换 AppImage 文件'); } // 退出应用,让安装器接管 diff --git a/package.json b/package.json index 1978c62..0384b46 100644 --- a/package.json +++ b/package.json @@ -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}" }, diff --git a/shared/types/index.ts b/shared/types/index.ts index b7ef91c..6ba91c4 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -40,6 +40,12 @@ export interface AppConfig { // 更新相关类型 // ============================================================ +// ============================================================ +// 日志类型(从 logging.ts 重导出) +// ============================================================ + +export type { LogLevel, LogCategory, LogErrorSnapshot, LogEntry } from './logging'; + /** 更新检查请求参数 */ export interface UpdateCheckParams { platform: Platform; diff --git a/shared/types/logging.ts b/shared/types/logging.ts new file mode 100644 index 0000000..400cf62 --- /dev/null +++ b/shared/types/logging.ts @@ -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; + /** 错误快照(仅在 warn / error 级别时填充) */ + error?: LogErrorSnapshot; +} diff --git a/shared/utils/sanitize.ts b/shared/utils/sanitize.ts new file mode 100644 index 0000000..dccef05 --- /dev/null +++ b/shared/utils/sanitize.ts @@ -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); + } + + // 数组:逐个脱敏 + if (Array.isArray(value)) { + return value.map((v) => sanitizeValue(key, v)); + } + + return value; +} + +/** 脱敏整个 Record */ +function sanitizeRecord(record: Record): Record { + const result: Record = {}; + 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): Record { + 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; +} diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 7905eea..25945e8 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -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); } }; safeIpcOn('platform-info', handler); diff --git a/src/main.tsx b/src/main.tsx index a936af6..81622ef 100644 --- a/src/main.tsx +++ b/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); +}); diff --git a/src/services/request.ts b/src/services/request.ts index 717fce8..6d69ca0 100644 --- a/src/services/request.ts +++ b/src/services/request.ts @@ -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); }, ); diff --git a/src/utils/event-bus.ts b/src/utils/event-bus.ts index e9ada35..a7146ad 100644 --- a/src/utils/event-bus.ts +++ b/src/utils/event-bus.ts @@ -7,6 +7,21 @@ type Listener = (...args: unknown[]) => void; const listeners = new Map>(); +// ---------- 日志钩子(由 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); diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..c4fec13 --- /dev/null +++ b/src/utils/logger.ts @@ -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, +): 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): void { + send(createEntry('DEBUG', category, message, undefined, context)); + }, + info(category: LogCategory, message: string, context?: Record): void { + send(createEntry('INFO', category, message, undefined, context)); + }, + warn( + category: LogCategory, + message: string, + error?: Error, + context?: Record, + ): void { + send(createEntry('WARN', category, message, error, context)); + }, + error( + category: LogCategory, + message: string, + error?: Error, + context?: Record, + ): 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();