9 Commits

Author SHA1 Message Date
Misaka
24d9bfebaf fix(logger): use app.isPackaged for log dir detection and add logging docs
Previously getLogDir() only checked app.isReady(), which caused
development builds to write logs to the user data directory instead
of the local project logs/ folder. Now uses app.isPackaged to
correctly distinguish production from development environments.

Also adds comprehensive logging system documentation and a debug
utility for verifying Electron environment detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 09:22:20 +08:00
Misaka
ba436cf374 feat(extraction): read headless mode from config instead of hardcoding
Add headless field to extraction config schema (default: true).
Extractor handler now reads globalConfig.extraction.headless instead
of hardcoding true. Users can set headless: false in config.yaml
to show the browser window during extraction for debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:53:21 +08:00
Misaka
6413eef5b8 fix(logger): address code review findings
- Remove misleading await from audit-logger tests (functions are sync)
- Add cleanup() to LoggerAPI type definition in index.d.ts
- Fix circular reference fallback to preserve null values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:37:59 +08:00
Misaka
6a9d144bbc fix(logger): batch fix audit-logger, circular meta, and sync callers
- Make logAudit and closeAuditLogger synchronous (were async for no reason)
- Set audit-logger silent:true initially, enable on applyAuditConfig()
- Add try-catch for circular references in consoleFormat meta JSON
- Update all callers to remove unnecessary await/.catch() on sync functions
- Add comment to shared.ts explaining acceptable sync FS usage
- Fix audit-logger test for sync closeAuditLogger

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:25:06 +08:00
Misaka
a2e3681c8f feat(logger): sync renderer log level cache and support verbose IPC
- Add LOGGER_LEVEL_CHANGED IPC channel for broadcasting level changes
- setLogLevel() now notifies all BrowserWindows when level changes
- Add verbose case in IPC forwardToWinston (was falling through to info)
- Renderer logger API listens for level changes and updates cached level
- Add cleanup() method to remove level change listener

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:18:56 +08:00
Misaka
21359b31c6 fix(logger): cache isProduction and prevent error double-serialization
Cache isProduction() result at module load to avoid repeated property
lookups. Add isSerializedError() check in format functions to skip
re-serialization when error objects have already been processed by
logError/formatErrorForLogging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:16:04 +08:00
Misaka
0a1181fecd fix(logger): use will-quit instead of before-quit and remove redundant console.error
Move logger close from before-quit to will-quit to keep the logger available
for uncaughtException handlers that may fire during shutdown. Remove 4
redundant console.error calls that duplicate Winston logger output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 21:13:36 +08:00
Misaka
883f98065a refactor(logger): optimize logging architecture with 6 improvements
1. Extract shared module: consolidate getLogDir() and isProduction()
   into shared.ts, eliminate duplication across logger modules
2. Make retention config effective: delay file transport creation
   until config is loaded, apply appRetention/auditRetention from config.yaml
3. Add before-quit log flush: close logger and audit logger on
   app exit to prevent log loss
4. Unify logError entry point: remove duplicate logError from index.ts,
   re-export from error-utils.ts with richer error context
5. Renderer log level filtering: add client-side level check in preload
   to skip IPC for filtered-out messages
6. Child logger cache + audit cleanup: cache child loggers in IPC
   handler for performance, remove redundant timestamp format in audit logger

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 20:53:53 +08:00
Misaka_Company
020bbcdccc fix(auth): retry silent login after logout to show user selection for Admin
When Admin switches user and the switched user logs out, instead of
showing the login dialog, re-run silent login to detect if the
computer belongs to an Admin user and show user selection dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 16:28:26 +08:00
29 changed files with 1212 additions and 225 deletions

View File

@@ -40,6 +40,7 @@ extraction:
autoConvert: true
mergeBatches: true
enableDbPersistence: true
headless: true # 浏览器无头模式true=后台运行false=显示浏览器窗口(调试用)
validation:
dataSource: database_full

View File

@@ -0,0 +1,752 @@
# ERPAuto 日志系统实现文档
## 概述
ERPAuto 使用 **Winston** 作为核心日志库,实现了统一的主进程 - 渲染进程日志系统。系统支持日志级别管理、文件轮转、审计日志、错误全链路追踪等功能。
---
## 架构总览
```mermaid
graph TB
subgraph Renderer Process
RC[React Components]
UH[useLogger Hook]
LA[Logger API]
end
subgraph Preload Layer
PL[Preload Bridge]
LC[Level Cache]
end
subgraph Main Process
LH[Logger Handler]
IL[IPC Router]
WL[Winston Logger]
FT[File Transports]
CT[Console Transport]
AL[Audit Logger]
end
subgraph Storage
ALF[app-YYYY-MM-DD.log]
ELF[error-YYYY-MM-DD.log]
AUF[audit-YYYY-MM-DD.jsonl]
end
RC --> UH
UH --> LA
LA --> LC
LC -->|IPC Send| PL
PL -->|logger:forward| IL
IL --> LH
LH --> WL
WL --> CT
WL --> FT
FT --> ALF
FT --> ELF
AL --> AUF
style WL fill:#f9f,stroke:#333
style LH fill:#bbf,stroke:#333
style AL fill:#bfb,stroke:#333
```
---
## 核心组件
### 1. 主进程日志服务 (`src/main/services/logger/`)
#### 1.1 核心日志器 (`index.ts`)
```typescript
// 日志器创建与配置
import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
const logger = winston.createLogger({
level: 'info',
defaultMeta: { service: 'erpauto' },
transports: [new winston.transports.Console({ format: consoleFormat })]
})
```
**关键特性:**
- **双格式输出**:控制台(彩色文本)+ 文件JSON
- **每日轮转**:日志文件按日期拆分,自动压缩归档
- **错误序列化**:完整捕获 stack trace 和自定义属性
- **环境感知**:生产环境自动脱敏敏感信息
#### 1.2 日志级别与优先级
```typescript
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose'
export const LOG_LEVEL_PRIORITY: Record<string, number> = {
verbose: 0,
debug: 1,
info: 2,
warn: 3,
error: 4
}
```
#### 1.3 错误工具类 (`error-utils.ts`)
```mermaid
flowchart LR
A[Error Occurs] --> B{Error Type?}
B -->|Error Instance| C[serializeError]
B -->|Error-like| C
B -->|Other| D[Wrap as UnknownError]
C --> E{Production?}
D --> E
E -->|Yes| F[sanitizeError]
E -->|No| G[Keep Full Details]
F --> H[Redact Sensitive Keys]
G --> I[Preserve Stack Trace]
H --> J[Log Output]
I --> J
```
**序列化流程:**
1. 捕获所有 enumerable 和 non-enumerable 属性
2. 递归处理 error cause 链
3. 生产环境脱敏 password/token/secret 等敏感字段
4. 提取堆栈中的文件/行号/列号信息
---
### 2. 审计日志服务 (`audit-logger.ts`)
**用途**:记录用户操作审计日志,满足合规要求
```typescript
interface AuditEntry {
timestamp: string // ISO 8601 时间戳
action: string // 操作类型LOGIN, EXTRACT, DELETE
userId: string // 用户 ID
username: string // 用户名
computerName: string // 计算机名
resource: string // 受影响的资源
status: 'success' | 'failure' | 'partial'
metadata: Record<string, unknown>
}
```
**格式特点:**
- **JSONL 格式**:每行一个 JSON 对象,便于流式解析
- **30 天轮转**:默认保留 30 天审计日志
- **独立文件**`audit-YYYY-MM-DD.jsonl`
---
### 3. IPC 日志处理器 (`src/main/ipc/logger-handler.ts`)
```mermaid
sequenceDiagram
participant R as Renderer
participant B as Buffer State
participant W as Winston
participant F as File
R->>B: Send Log Entry
Note over B: Circuit Breaker Check
alt Error Level
B->>B: Always Buffer
else Non-Error & Buffer < 500
B->>B: Buffer Entry
else Buffer >= 500
B->>B: Discard + Count
end
Note over B: Batch Processing
B->>B: 100ms Debounce OR 50 entries
B->>W: Flush Batch
W->>F: Write to File
```
**批处理策略:**
| 参数 | 值 | 说明 |
|------|-----|------|
| `DEBOUNCE_MS` | 100ms | 防抖等待时间 |
| `MAX_BATCH_SIZE` | 50 | 最大批次大小 |
| `CIRCUIT_BREAKER_THRESHOLD` | 500 | 熔断阈值 |
**熔断机制:**
- 当缓冲区 > 500 条时,丢弃非错误日志
- 错误日志始终绕过熔断器
- 每丢弃 100 条记录一次警告
---
### 4. 渲染进程日志 Hook (`src/renderer/src/hooks/useLogger.ts`)
```typescript
// 使用示例
function MyComponent() {
const logger = useLogger('MyComponent')
const handleClick = () => {
logger.info('User clicked button', { buttonId: 'submit' })
}
const handleError = (err: Error) => {
logger.error('Operation failed', { error: err.message })
}
}
```
**客户端级别过滤:**
```typescript
// 在发送 IPC 前检查日志级别,避免无效 IPC 调用
if (!shouldLog(level)) return
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, { ... })
```
**FPS 监控:**
- 检测因过度日志导致的 UI 卡顿
- 当 FPS < 30 时发出警告
- 5 秒冷却期避免重复警告
---
### 5. 预加载层 API (`src/preload/api/logger.ts`)
```typescript
// 级别缓存机制
let cachedLevel: LogLevel = 'info'
// 监听主进程级别变更广播
ipcRenderer.on(IPC_CHANNELS.LOGGER_LEVEL_CHANGED, (level) => {
cachedLevel = level
})
// 客户端过滤
function shouldLog(level: LogLevel): boolean {
return priorities[level] >= priorities[cachedLevel]
}
```
---
### 6. 配置管理 (`src/main/services/config/config-manager.ts`)
```yaml
# config.yaml 配置示例
logging:
level: info # 日志级别
auditRetention: 30 # 审计日志保留天数
appRetention: 14 # 应用日志保留天数
```
**配置加载时机:**
1. 应用启动时加载 `config.yaml`
2. 调用 `applyLoggingConfig()` 配置 Winston
3. 调用 `applyAuditConfig()` 配置审计日志
---
## 日志数据流
```mermaid
flowchart TD
subgraph 渲染进程
A[Component] --> B[useLogger Hook]
B --> C{Level Check}
C -->|Pass| D[loggerApi.log]
C -->|Skip| E[Drop]
end
subgraph IPC 传输
D --> F[logger:forward]
F --> G[Context Bridge]
end
subgraph 主进程
G --> H[Logger Handler]
H --> I{Circuit Breaker}
I -->|Pass| J[Batch Buffer]
I -->|Block| K[Discard Counter]
J --> L{Debounce Timer}
L -->|100ms| M[Flush to Winston]
J -->|50 entries| M
end
subgraph Winston
M --> N[Console Transport]
M --> O[File Transport]
O --> P{Error Level?}
P -->|Yes| Q[error-DATE.log]
P -->|All| R[app-DATE.log]
end
subgraph 审计日志
S[logAudit] --> T[Audit Logger]
T --> U[audit-DATE.jsonl]
end
```
---
## 日志文件组织
### 目录结构
```
AppData/Roaming/erpauto/logs/
├── app-2024-04-01.log
├── app-2024-04-01.log.gz # 压缩归档
├── app-2024-04-02.log
├── error-2024-04-01.log # 仅错误级别
├── error-2024-04-01.log.gz
├── audit-2024-04-01.jsonl # 审计日志
└── audit-2024-04-01.jsonl.gz
```
### 文件格式
**应用日志 (JSON 格式):**
```json
{
"level": "info",
"message": "Extractor started",
"timestamp": "2024-04-01 10:30:00",
"service": "erpauto",
"context": "Extractor",
"orders": ["SO001", "SO002"]
}
```
**错误日志 (含堆栈):**
```json
{
"level": "error",
"message": "Database connection failed",
"timestamp": "2024-04-01 10:31:00",
"error": {
"name": "ConnectionError",
"message": "ECONNREFUSED",
"stack": "ConnectionError: ECONNREFUSED\n at TCP.connectWrap (...)",
"code": "ECONNREFUSED"
}
}
```
**审计日志 (JSONL 格式):**
```jsonl
{"timestamp":"2024-04-01T10:30:00Z","action":"LOGIN","userId":"1","username":"admin","computerName":"DESKTOP-001","resource":"/auth","status":"success","metadata":{}}
{"timestamp":"2024-04-01T10:35:00Z","action":"EXTRACT","userId":"1","username":"admin","computerName":"DESKTOP-001","resource":"orders","status":"success","metadata":{"orderCount":50}}
```
---
## IPC 通道定义
```typescript
// src/shared/ipc-channels.ts
export const IPC_CHANNELS = {
// 日志转发renderer → main
LOGGER_FORWARD: 'logger:forward',
// 获取当前日志级别
LOGGER_GET_LEVEL: 'logger:getLevel',
// 级别变更广播main → renderer
LOGGER_LEVEL_CHANGED: 'logger:levelChanged'
}
```
---
## 使用指南
### 在主进程中记录日志
```typescript
import { createLogger } from '@/main/services/logger'
const log = createLogger('MyService')
// 基础用法
log.info('Operation started')
log.warn('Disk space low')
log.error('Failed to connect', { error: err })
// 带上下文的日志
log.info('Processing batch', {
batchId: 'B001',
itemCount: 100,
estimatedTime: '5min'
})
// 错误日志(自动序列化堆栈)
try {
await riskyOperation()
} catch (error) {
log.error('Operation failed', { error })
}
```
### 在渲染进程中记录日志
```typescript
import { useLogger } from '@/renderer/src/hooks/useLogger'
function MyComponent() {
const logger = useLogger('MyComponent')
useEffect(() => {
logger.info('Component mounted')
return () => logger.debug('Component unmounted')
}, [])
const handleAction = async () => {
try {
await api.doSomething()
logger.info('Action succeeded')
} catch (err) {
logger.error('Action failed', { error: err.message })
}
}
}
```
### 记录审计日志
```typescript
import { logAudit } from '@/main/services/logger/audit-logger'
// 用户登录审计
logAudit('LOGIN', userId, {
username: 'admin',
computerName: 'DESKTOP-001',
resource: '/auth',
status: 'success',
metadata: { loginMethod: 'password' }
})
// 数据提取审计
logAudit('EXTRACT', userId, {
username: 'user1',
computerName: 'DESKTOP-002',
resource: 'materials',
status: 'success',
metadata: { orderCount: 50, materialCount: 1200 }
})
```
---
## 高级功能
### 1. 日志级别动态切换
```mermaid
sequenceDiagram
participant U as User (UI)
participant C as ConfigManager
participant M as Main Logger
participant R as Renderer
participant L as Level Cache
U->>C: Update logging.level
C->>M: applyLoggingConfig(newLevel)
M->>M: logger.level = newLevel
M->>R: Broadcast levelChanged
R->>L: cachedLevel = newLevel
Note over L: Future logs filtered at client
```
**代码示例:**
```typescript
// 主进程设置级别
import { setLogLevel } from '@/main/services/logger'
setLogLevel('debug')
// 渲染进程自动同步
// useLogger Hook 会自动接收级别变更广播
// 客户端过滤自动生效
```
### 2. 生产环境错误脱敏
```typescript
// 自动脱敏以下关键字段
const sensitiveKeys = [
'password', 'secret', 'token', 'apiKey',
'credentials', 'authorization', 'privateKey'
]
// 生产环境错误消息
{
"name": "AuthError",
"message": "An error occurred due to invalid credentials or configuration"
// 原始错误消息被脱敏
}
```
### 3. 错误上下文提取
```typescript
// 从堆栈跟踪提取位置信息
const errorContext = extractErrorContext(serializedError)
// 输出:
{
fileName: 'extractor.ts',
lineNumber: 142,
columnName: 15,
functionName: 'runExtraction'
}
```
---
## 最佳实践
### ✅ 推荐做法
```typescript
// 1. 使用 createLogger 创建带上下文的子日志器
const log = createLogger('DatabaseService')
// 2. 记录错误时传递完整 Error 对象
log.error('Query failed', { error })
// 3. 使用结构化元数据
log.info('Batch processed', {
batchId: 'B001',
duration: 1250,
itemCount: 100
})
// 4. 渲染进程使用 useLogger Hook
const logger = useLogger('LoginForm')
// 5. 敏感信息使用审计日志
logAudit('DELETE', userId, { ... })
```
### ❌ 避免的做法
```typescript
// 1. 避免直接 console.log
console.log('debug') // ❌ 不会被 Winston 捕获
// 2. 避免只记录错误消息
log.error(err.message) // ❌ 丢失堆栈和类型
// 3. 避免循环引用元数据
const obj: any = {}
obj.self = obj
log.info('test', { obj }) // ❌ 序列化失败
// 4. 避免过度日志
for (let i = 0; i < 1000; i++) {
logger.info(`Item ${i}`) // ❌ 触发熔断
}
```
---
## 故障排查
### 问题:日志文件不生成
**检查清单:**
1. 确认 `config.yaml` 中 logging 配置正确
2. 检查日志目录权限
3. 查看控制台输出是否有 Winston 错误
4. 验证 `applyLoggingConfig()` 是否被调用
### 问题:渲染进程日志未到达主进程
**调试步骤:**
```typescript
// 1. 检查 IPC 通道是否注册
// src/main/ipc/index.ts 应包含:
registerLoggerHandlers()
// 2. 检查 preload 暴露
// src/preload/index.ts 应暴露:
contextBridge.exposeInMainWorld('electron', api)
// 3. 检查级别过滤
console.log(window.electron.logger) // 应存在
```
### 问题:生产环境错误信息不完整
**原因**:生产环境自动脱敏
**解决方案**
- 查看 `error-DATE.log` 获取完整错误
- 开发环境禁用脱敏:设置开发模式构建
---
## 测试支持
### 单元测试示例
```typescript
import { createLogger } from '@/main/services/logger'
describe('Logger', () => {
it('should log with context', () => {
const log = createLogger('TestService')
// 测试逻辑...
expect(log).toBeDefined()
})
})
```
### 集成测试
```typescript
// tests/integration/ipc-logging.test.ts
import { loggerApi } from '@/preload/api/logger'
test('Renderer logs should reach Winston', async () => {
// Mock Winston transport
// Send log via IPC
// Assert log appears in main process
})
```
---
## 配置参考
### config.yaml 完整配置
```yaml
logging:
# 日志级别error | warn | info | debug | verbose
level: info
# 审计日志保留天数
auditRetention: 30
# 应用日志保留天数
appRetention: 14
```
### 日志级别说明
| 级别 | 使用场景 | 示例 |
| --------- | -------------- | ---------------------------- |
| `error` | 系统错误、异常 | 数据库连接失败、文件写入错误 |
| `warn` | 可恢复的警告 | 磁盘空间不足、重试操作 |
| `info` | 业务操作记录 | 用户登录、提取开始/结束 |
| `debug` | 技术调试信息 | API 请求参数、SQL 语句 |
| `verbose` | 详细跟踪 | 循环迭代、中间状态 |
---
## 相关文件索引
| 文件路径 | 职责 |
| -------------------------------------------- | ------------------ |
| `src/main/services/logger/index.ts` | Winston 日志器核心 |
| `src/main/services/logger/shared.ts` | 共享工具函数 |
| `src/main/services/logger/error-utils.ts` | 错误序列化/脱敏 |
| `src/main/services/logger/audit-logger.ts` | 审计日志服务 |
| `src/main/ipc/logger-handler.ts` | IPC 批处理与熔断 |
| `src/renderer/src/hooks/useLogger.ts` | React Hook |
| `src/preload/api/logger.ts` | Preload API |
| `src/shared/ipc-channels.ts` | IPC 通道定义 |
| `src/main/services/config/config-manager.ts` | 配置管理 |
---
## 架构图附录
### 完整日志系统架构
```mermaid
graph TB
subgraph 渲染进程 Renderer
UI[UI Components]
HL[useLogger Hook]
CF[Client Filter]
LC[Level Cache]
end
subgraph 预加载层 Preload
CB[Context Bridge]
IR[IPC Renderer]
LA[Logger API]
end
subgraph 主进程 Main
IH[IPC Handler]
BB[Batch Buffer]
CB2[Circuit Breaker]
WL[Winston Logger]
AC[Audit Logger]
CM[Config Manager]
end
subgraph 传输层 Transports
CT[Console]
AFT[App File]
EFT[Error File]
ATF[Audit File]
end
subgraph 文件系统 File System
ALF[app-DATE.log]
ELF[error-DATE.log]
AUF[audit-DATE.jsonl]
GZ[.gz Archive]
end
UI --> HL
HL --> CF
CF --> LC
LC --> LA
LA --> IR
IR --> CB
CB --> IH
IH --> CB2
CB2 --> BB
BB --> WL
WL --> CT
WL --> AFT
WL --> EFT
AC --> ATF
CM --> WL
AFT --> ALF
EFT --> ELF
ATF --> AUF
ALF --> GZ
ELF --> GZ
AUF --> GZ
style WL fill:#f9f,stroke:#333
style BB fill:#bbf,stroke:#333
style CB2 fill:#fbb,stroke:#333
style AC fill:#bfb,stroke:#333
```
---
_文档生成日期2026-04-04_
_项目版本ERPAuto v1.x_

View File

@@ -49,7 +49,7 @@ graph TD
## 文档职责一览
| 文档 | 主要回答的问题 |
| --- | --- |
| ------------------------- | -------------------------------------------------- |
| `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 |
| `runtime-architecture.md` | `main / preload / renderer` 如何协作 |
| `data-flow.md` | 核心业务数据如何在各层之间流动 |

View File

@@ -67,7 +67,7 @@ flowchart TD
## 当前文档一览
| 文档 | 主要内容 |
| --- | --- |
| ------------------------- | ---------------------------------------- |
| `local-development.md` | 环境准备、启动、构建、常用命令、本地验证 |
| `debugging.md` | 分层调试思路、调试入口、主链路定位方法 |
| `renderer-development.md` | React 渲染层开发方式、页面/hook/组件边界 |

View File

@@ -60,7 +60,7 @@ graph TD
## 模块目录一览
| 模块 | 文档 | 核心职责 |
| --- | --- | --- |
| ---------- | --------------- | --------------------------------------------------------- |
| Auth | `auth.md` | 登录、silent login、管理员代切用户、用户上下文同步 |
| Extractor | `extractor.md` | 订单号输入、提取执行、日志与共享订单号同步 |
| Validation | `validation.md` | 共享 Production IDs、校验查询、结果富化、Cleaner 数据准备 |

View File

@@ -1,40 +1,45 @@
import { app } from 'electron'
import logger from '../services/logger/index'
import { logAudit } from '../services/logger/audit-logger'
import { logAudit, closeAuditLogger } from '../services/logger/audit-logger'
export function setupProcessGuards(): void {
process.on('uncaughtException', async (err) => {
process.on('uncaughtException', (err) => {
logger.error('Uncaught exception', { error: err })
await logAudit('SYSTEM_CRASH', 'system', {
logAudit('SYSTEM_CRASH', 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
metadata: { error: err.message, stack: err.stack }
})
console.error('Uncaught exception:', err)
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', async (reason) => {
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled Rejection', { reason: String(reason) })
await logAudit('SYSTEM_ERROR', 'system', {
logAudit('SYSTEM_ERROR', 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
metadata: { reason: String(reason) }
})
console.error('Unhandled Rejection:', reason)
})
app.on('render-process-gone', (_, webContents, details) => {
logger.error('Render process gone', { details, webContentsId: webContents.id })
console.error('Render process gone:', details)
})
app.on('child-process-gone', (_, details) => {
logger.error('Child process gone', { details })
console.error('Child process gone:', details)
})
// Flush and close loggers on will-quit (fires after all windows are closed,
// but before the event loop stops). Using will-quit instead of before-quit
// ensures the logger remains available for uncaughtException handlers that
// may fire between before-quit and actual process exit.
app.on('will-quit', () => {
logger.close()
closeAuditLogger()
})
}

View File

@@ -90,6 +90,10 @@ export function registerExtractorHandlers(): void {
log.info('Fetching ERP configuration from database...')
const erpConfig = await getErpConfig()
// Read headless setting from global config
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
log.info('ERP config retrieved', {
url: erpConfig.url ? 'configured' : 'EMPTY',
username: erpConfig.username ? 'configured' : 'EMPTY'
@@ -188,7 +192,7 @@ export function registerExtractorHandlers(): void {
url: erpConfig.url,
username: erpConfig.username,
password: erpConfig.password,
headless: true
headless: globalConfig.extraction.headless
})
sendProgress(sender, '登录 ERP 系统...', 9.99, {
@@ -259,7 +263,13 @@ export function registerExtractorHandlers(): void {
// Write per-order record counts
for (const { orderNumber, recordCount } of result.orderRecordCounts) {
await historyDao.updateRecordStatus(batchId, orderNumber, status, undefined, recordCount)
await historyDao.updateRecordStatus(
batchId,
orderNumber,
status,
undefined,
recordCount
)
}
// Update batch status without recordCount (per-order counts are set individually)
@@ -286,7 +296,7 @@ export function registerExtractorHandlers(): void {
recordCount: result.recordCount,
errorCount: result.errors.length
}
}).catch((err) => log.warn('Failed to write audit log', { err }))
})
}
return result

View File

@@ -68,15 +68,21 @@ export function withErrorHandling<T>(
}
if (isBaseError(error)) {
logError(log, `[${context}] ${error.name}`, error, {
logError(log, error, {
message: `[${context}] ${error.name}`,
context: {
code,
cause: getErrorCauseMessage(error),
handler: context
}
})
} else {
logError(log, `[${context}] Error`, error, {
logError(log, error, {
message: `[${context}] Error`,
context: {
code,
handler: context
}
})
}

View File

@@ -10,7 +10,9 @@
*/
import { ipcMain } from 'electron'
import winston from 'winston'
import { createLogger } from '../services/logger'
import logger from '../services/logger'
import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels'
const log = createLogger('LoggerHandler')
@@ -41,6 +43,7 @@ class LoggerHandlerState {
private buffer: LogEntry[] = []
private debounceTimer: NodeJS.Timeout | null = null
private discardedCount = 0
private childLoggerCache = new Map<string, winston.Logger>()
/**
* Add log entry to buffer
@@ -131,22 +134,36 @@ class LoggerHandlerState {
}
}
/**
* Get or create a cached child logger for a component
* Avoids creating a new child logger for every log entry
* @param component - Component name for the child logger
*/
private getChildLogger(component: string): winston.Logger {
let child = this.childLoggerCache.get(component)
if (!child) {
child = log.child({ source: 'renderer', component })
this.childLoggerCache.set(component, child)
}
return child
}
/**
* Forward a single log entry to Winston logger
* @param entry - Log entry to forward
*/
private forwardToWinston(entry: LogEntry): void {
const context = (entry.context?.component as string) || 'renderer'
const childLogger = log.child({
source: 'renderer',
component: context
})
const childLogger = this.getChildLogger(context)
const message = entry.context?.message
? `[${entry.context.message}] ${entry.message}`
: entry.message
switch (entry.level) {
case 'verbose':
childLogger.verbose(message, entry.context)
break
case 'debug':
childLogger.debug(message, entry.context)
break
@@ -187,6 +204,7 @@ class LoggerHandlerState {
}
this.buffer = []
this.discardedCount = 0
this.childLoggerCache.clear()
}
}
@@ -197,6 +215,11 @@ const state = new LoggerHandlerState()
* Register IPC handlers for logger
*/
export function registerLoggerHandlers(): void {
// Return current log level to preload for client-side filtering
ipcMain.handle(IPC_CHANNELS.LOGGER_GET_LEVEL, () => {
return logger.level as LogLevel
})
// Use ipcMain.on with send() - fire-and-forget, non-blocking
ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => {
// Validate entry

View File

@@ -27,16 +27,13 @@ export function registerSettingsHandlers(): void {
const erpConfigService = UserErpConfigService.getInstance()
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
return withErrorHandling(
async () => {
return withErrorHandling(async () => {
const userType = sessionManager.getUserType()
if (!userType) {
throw new ValidationError('未找到用户类型', 'VAL_INVALID_INPUT')
}
return userType as UserType
},
'settings:getUserType'
)
}, 'settings:getUserType')
})
ipcMain.handle(
@@ -77,7 +74,7 @@ export function registerSettingsHandlers(): void {
resource: 'ERP_CONFIG',
status: 'success',
metadata: { changeType: 'erp_credentials', usernameChanged: !!settings.erp.username }
}).catch((err) => log.warn('Failed to write audit log', { err }))
})
}
return { success: true }

View File

@@ -176,8 +176,6 @@ export class AuthApplicationService {
actorId: string,
payload: Parameters<typeof logAudit>[2]
): void {
logAudit(action, actorId, payload).catch((err) =>
log.warn('Failed to write audit log', { err })
)
logAudit(action, actorId, payload)
}
}

View File

@@ -274,7 +274,7 @@ export class CleanerApplicationService {
? 'failure'
: 'success'
await logAudit('CLEAN', String(currentUser.id), {
logAudit('CLEAN', String(currentUser.id), {
username: currentUser.username,
computerName: (await import('os')).hostname(),
resource: 'MATERIAL_PLAN',
@@ -288,7 +288,7 @@ export class CleanerApplicationService {
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
}
}).catch((err) => log.warn('Failed to write audit log', { err }))
})
}
private async generateAndUploadReport(

View File

@@ -20,7 +20,8 @@ import { dirname } from 'path'
import { app } from 'electron'
import yaml from 'js-yaml'
import { z } from 'zod'
import { createLogger, setLogLevel } from '../logger'
import { createLogger, applyLoggingConfig } from '../logger'
import { applyAuditConfig } from '../logger/audit-logger'
import {
fullConfigSchema,
type FullConfig,
@@ -77,7 +78,8 @@ const DEFAULT_CONFIG: FullConfig = {
verbose: true,
autoConvert: true,
mergeBatches: true,
enableDbPersistence: true
enableDbPersistence: true,
headless: true
},
validation: {
dataSource: 'database_full',
@@ -170,7 +172,8 @@ export class ConfigManager {
await this.saveConfig(DEFAULT_CONFIG)
this.config = DEFAULT_CONFIG
// Apply logging configuration from default config
setLogLevel(DEFAULT_CONFIG.logging.level)
applyLoggingConfig(DEFAULT_CONFIG.logging)
applyAuditConfig(DEFAULT_CONFIG.logging.auditRetention)
return
}
@@ -190,7 +193,8 @@ export class ConfigManager {
this.config = validated
// Apply logging configuration
setLogLevel(validated.logging.level)
applyLoggingConfig(validated.logging)
applyAuditConfig(validated.logging.auditRetention)
log.info('Configuration loaded and validated successfully')
} catch (error) {

View File

@@ -123,9 +123,7 @@ export class ExtractorService {
* @param filePaths - Array of downloaded Excel file paths
* @returns Merged file path, total record count, and optional error message
*/
private async mergeFiles(
filePaths: string[]
): Promise<{
private async mergeFiles(filePaths: string[]): Promise<{
mergedFile: string | null
recordCount: number
error?: string
@@ -197,7 +195,12 @@ export class ExtractorService {
const errorStack = error instanceof Error ? error.stack : ''
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
// Return parsed record count and error info even if save fails
return { mergedFile: null, recordCount, orderRecordCounts, error: `保存合并文件失败:${errorMsg}` }
return {
mergedFile: null,
recordCount,
orderRecordCounts,
error: `保存合并文件失败:${errorMsg}`
}
}
}

View File

@@ -6,8 +6,7 @@
import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { getLogDir } from './shared'
/**
* Audit log entry structure
@@ -32,22 +31,6 @@ export interface AuditEntry {
metadata: Record<string, unknown>
}
/**
* Get the log directory for audit logs
* Uses app.getPath('logs') in production, local logs dir in development
*/
function getLogDir(): string {
if (app && app.isReady()) {
return app.getPath('logs')
}
// Fallback for development or before app is ready
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
}
/**
* JSONL formatter - outputs one JSON object per line
* This is the key difference from the standard JSON formatter
@@ -59,26 +42,43 @@ const jsonlFormat = winston.format.printf(({ message }) => {
/**
* Create the audit logger instance with daily rotation
* Configured for 30-day retention as per requirements
* Initially silent (no transports). Call applyAuditConfig() after config is loaded.
*/
const auditLogger = winston.createLogger({
level: 'info',
silent: false,
transports: [
silent: true,
transports: []
})
/**
* Apply audit log retention configuration
* Creates the DailyRotateFile transport with the configured retention period
*
* @param retentionDays - Number of days to retain audit logs
*/
export function applyAuditConfig(retentionDays: number): void {
// Enable logging now that config is loaded
auditLogger.silent = false
// Remove existing DailyRotateFile transports
const existingTransports = auditLogger.transports.filter((t) => t instanceof DailyRotateFile)
for (const transport of existingTransports) {
auditLogger.remove(transport)
}
// Add audit transport with configured retention
auditLogger.add(
new DailyRotateFile({
filename: path.join(getLogDir(), 'audit-%DATE%.jsonl'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '30d', // 30-day retention
maxFiles: `${retentionDays}d`,
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
jsonlFormat
)
format: jsonlFormat
})
]
})
)
}
/**
* Log an audit event
@@ -86,9 +86,8 @@ const auditLogger = winston.createLogger({
* @param action - The action that was performed
* @param userId - User ID who performed the action
* @param details - Additional details including username, computerName, resource, status, and optional metadata
* @returns Promise that resolves when the log is written (non-blocking)
*/
export async function logAudit(
export function logAudit(
action: string,
userId: string,
details: {
@@ -98,7 +97,7 @@ export async function logAudit(
status: 'success' | 'failure' | 'partial'
metadata?: Record<string, unknown>
}
): Promise<void> {
): void {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action,
@@ -118,8 +117,7 @@ export async function logAudit(
/**
* Flush and close the audit logger (call on app shutdown)
*/
export async function closeAuditLogger(): Promise<void> {
// Winston logger.close() is synchronous
export function closeAuditLogger(): void {
auditLogger.close()
}

View File

@@ -6,6 +6,7 @@
*/
import type { ErrorLike, SerializedError } from '../../types/errors'
import { isProduction } from './shared'
/**
* Check if value is an Error or Error-like object
@@ -84,7 +85,7 @@ export function sanitizeError(error: SerializedError): SerializedError {
const sanitized: SerializedError = { ...error }
// Sanitize message in production
if (process.env.NODE_ENV === 'production') {
if (isProduction()) {
// Keep error name and structure, but sanitize message
if (sensitiveKeys.some((key) => error.message?.toLowerCase().includes(key))) {
sanitized.message = 'An error occurred due to invalid credentials or configuration'
@@ -165,7 +166,7 @@ export function formatErrorForLogging(
metadata: Record<string, unknown>
} {
const serialized = serializeError(error)
const isProd = process.env.NODE_ENV === 'production'
const isProd = isProduction()
const errorToLog = isProd ? sanitizeError(serialized) : serialized
const errorContext = extractErrorContext(errorToLog)

View File

@@ -11,26 +11,28 @@
import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
import { BrowserWindow } from 'electron'
import { serializeError, sanitizeError } from './error-utils'
import { getLogDir, isProduction } from './shared'
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
// Get log directory - use app.getPath('logs') in production, or local logs dir in development
function getLogDir(): string {
if (app && app.isReady()) {
return app.getPath('logs')
}
// Fallback for development or before app is ready
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
// Cache isProduction() at module load — app.isPackaged never changes at runtime
const IS_PROD = isProduction()
/**
* Check if an error has already been serialized (plain object with name/message but not an Error instance).
* Prevents double-serialization when logError() output passes through the format pipeline.
*/
function isSerializedError(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
!(value instanceof Error) &&
'name' in value &&
'message' in value
)
}
// Check if running in production
const isProduction = app?.isPackaged ?? process.env.NODE_ENV === 'production'
// Custom format for console output - includes full error details
const consoleFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
@@ -41,7 +43,12 @@ const consoleFormat = winston.format.combine(
// Format error with full stack trace
let errorStr = ''
if (error) {
const serialized = isProduction ? sanitizeError(serializeError(error)) : serializeError(error)
// Skip re-serialization if already a serialized error object
const serialized: { stack?: string; message: string } = isSerializedError(error)
? (error as { stack?: string; message: string })
: IS_PROD
? sanitizeError(serializeError(error))
: serializeError(error)
if (serialized.stack) {
errorStr = `\n${serialized.stack}`
} else {
@@ -49,7 +56,24 @@ const consoleFormat = winston.format.combine(
}
}
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta, null, 2)}` : ''
let metaStr = ''
if (Object.keys(meta).length > 0) {
try {
metaStr = ` ${JSON.stringify(meta, null, 2)}`
} catch {
// Fallback for circular references: stringify primitives, replace complex objects with placeholder
metaStr = ` ${JSON.stringify(
Object.fromEntries(
Object.entries(meta).map(([k, v]) => [
k,
v !== null && typeof v === 'object' ? `[Object]` : v
])
),
null,
2
)}`
}
}
return `${timestamp} [${level}]${contextStr} ${message}${errorStr}${metaStr}`
})
)
@@ -58,19 +82,19 @@ const consoleFormat = winston.format.combine(
const fileFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format((info) => {
// Serialize errors in metadata
// Serialize errors in metadata (skip if already serialized)
if (info.error) {
info.error = isProduction
if (!isSerializedError(info.error)) {
info.error = IS_PROD
? sanitizeError(serializeError(info.error))
: serializeError(info.error)
}
}
// Serialize any error in meta fields
// Serialize any error in meta fields (skip if already serialized)
for (const key of Object.keys(info)) {
if (key !== 'error' && info[key] instanceof Error) {
info[key] = isProduction
? sanitizeError(serializeError(info[key]))
: serializeError(info[key])
info[key] = IS_PROD ? sanitizeError(serializeError(info[key])) : serializeError(info[key])
}
}
@@ -80,19 +104,20 @@ const fileFormat = winston.format.combine(
)
// Daily rotate file transport configuration
const createFileTransport = (level?: string): DailyRotateFile => {
const createFileTransport = (level?: string, maxFiles?: string): DailyRotateFile => {
return new DailyRotateFile({
filename: path.join(getLogDir(), 'app-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
maxFiles: maxFiles || '14d',
level,
format: fileFormat
})
}
// Create the logger instance with default level
// Create the logger instance with default level - Console only initially
// File transports are added after config is loaded via applyLoggingConfig()
const logger = winston.createLogger({
level: 'info', // Default level, can be updated via setLogLevel()
defaultMeta: { service: 'erpauto' },
@@ -100,33 +125,59 @@ const logger = winston.createLogger({
// Console transport - always enabled
new winston.transports.Console({
format: consoleFormat
}),
// File transport for all levels
createFileTransport()
})
]
})
/**
* Update the logger level dynamically
* Update the logger level dynamically and notify renderer processes
* @param level - The new log level
*/
export function setLogLevel(level: string): void {
logger.level = level
// Broadcast level change to all renderer windows so they update their cached level
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send(IPC_CHANNELS.LOGGER_LEVEL_CHANGED, level)
}
}
}
// Add error-specific file transport in production
if (app?.isPackaged) {
/**
* Apply logging configuration from config file
* Removes existing DailyRotateFile transports and recreates them with config values
*
* @param config - Logging configuration from config.yaml
*/
export function applyLoggingConfig(config: { level: string; appRetention: number }): void {
// Update log level
setLogLevel(config.level)
// Remove existing DailyRotateFile transports
const existingFileTransports = logger.transports.filter((t) => t instanceof DailyRotateFile)
for (const transport of existingFileTransports) {
logger.remove(transport)
}
// Add app log transport with configured retention
const retentionStr = `${config.appRetention}d`
logger.add(createFileTransport(undefined, retentionStr))
// Add error-specific file transport in production
if (IS_PROD) {
logger.add(
new DailyRotateFile({
filename: path.join(getLogDir(), 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
maxFiles: retentionStr,
level: 'error',
format: fileFormat
})
)
}
}
/**
@@ -138,23 +189,8 @@ export function createLogger(context: string): winston.Logger {
return logger.child({ context })
}
/**
* Log an error with full context and stack trace
* This is the recommended way to log errors in the application
*
* @param log - Logger instance
* @param message - Error message
* @param error - The error object (Error, BaseError, or any)
* @param meta - Additional metadata to include
*/
export function logError(
log: winston.Logger,
message: string,
error: unknown,
meta?: Record<string, unknown>
): void {
log.error(message, { error, ...meta })
}
// Re-export error utilities for convenience
export { logError, formatErrorForLogging, serializeError, extractErrorContext } from './error-utils'
// Export the main logger for direct use
export default logger

View File

@@ -0,0 +1,60 @@
/**
* Shared Logger Utilities
* Common functions used across logger modules
*/
import path from 'path'
import fs from 'fs'
import { app } from 'electron'
/**
* Get log directory path
* Uses app.getPath('logs') in production, local logs dir in development
* Production = app.isPackaged === true
*/
export function getLogDir(): string {
// Check if running in production (packed app)
// This must be checked BEFORE app.getPath('logs') because Electron
// always returns the user data logs path regardless of environment
if (app && app.isReady() && app.isPackaged) {
return app.getPath('logs')
}
// Development environment: use logs directory in project root
// Note: synchronous FS calls are acceptable here because this branch
// executes in dev environments or before app is ready.
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
}
/**
* Check if running in production environment
* Uses app.isPackaged as the single source of truth
*/
export function isProduction(): boolean {
return app?.isPackaged ?? false
}
/**
* Log level priority mapping (higher number = more severe)
*/
export const LOG_LEVEL_PRIORITY: Record<string, number> = {
verbose: 0,
debug: 1,
info: 2,
warn: 3,
error: 4
}
/**
* Check if a log level should be logged given a threshold
* @param level - The log level of the message
* @param threshold - The minimum log level threshold
* @returns true if the message should be logged
*/
export function isLoggable(level: string, threshold: string): boolean {
return (LOG_LEVEL_PRIORITY[level] ?? 0) >= (LOG_LEVEL_PRIORITY[threshold] ?? 2)
}

View File

@@ -163,10 +163,10 @@ export class BIPUsersDAO {
return null
}
} catch (error) {
logError(log, 'Authenticate failed', error, {
logError(log, error, {
message: 'Authenticate failed',
operation: 'authenticate',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return null
}
@@ -222,10 +222,10 @@ export class BIPUsersDAO {
return null
}
} catch (error) {
logError(log, 'Silent login failed', error, {
logError(log, error, {
message: 'Silent login failed',
operation: 'authenticateByComputerName',
computerName,
dbType: this.dbType
context: { computerName, dbType: this.dbType }
})
return null
}
@@ -258,9 +258,10 @@ export class BIPUsersDAO {
createTime: row.CreateTime as Date | undefined
}))
} catch (error) {
logError(log, 'Get all users failed', error, {
logError(log, error, {
message: 'Get all users failed',
operation: 'getAllUsers',
dbType: this.dbType
context: { dbType: this.dbType }
})
return []
}
@@ -345,11 +346,10 @@ export class BIPUsersDAO {
return true
}
} catch (error) {
logError(log, 'Create user failed', error, {
logError(log, error, {
message: 'Create user failed',
operation: 'createUser',
username,
userType,
dbType: this.dbType
context: { username, userType, dbType: this.dbType }
})
return false
}
@@ -389,11 +389,10 @@ export class BIPUsersDAO {
return true
}
} catch (error) {
logError(log, 'Update user type failed', error, {
logError(log, error, {
message: 'Update user type failed',
operation: 'updateUserType',
username,
userType,
dbType: this.dbType
context: { username, userType, dbType: this.dbType }
})
return false
}
@@ -433,10 +432,10 @@ export class BIPUsersDAO {
return true
}
} catch (error) {
logError(log, 'Update password failed', error, {
logError(log, error, {
message: 'Update password failed',
operation: 'updatePassword',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return false
}
@@ -472,10 +471,10 @@ export class BIPUsersDAO {
return true
}
} catch (error) {
logError(log, 'Delete user failed', error, {
logError(log, error, {
message: 'Delete user failed',
operation: 'deleteUser',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return false
}
@@ -513,10 +512,10 @@ export class BIPUsersDAO {
return result.rows.length > 0 && (result.rows[0].count as number) > 0
}
} catch (error) {
logError(log, 'Check user exists failed', error, {
logError(log, error, {
message: 'Check user exists failed',
operation: 'userExists',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return false
}
@@ -574,10 +573,10 @@ export class BIPUsersDAO {
return null
}
} catch (error) {
logError(log, 'Get user ERP credentials failed', error, {
logError(log, error, {
message: 'Get user ERP credentials failed',
operation: 'getUserErpCredentials',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return null
}
@@ -626,10 +625,10 @@ export class BIPUsersDAO {
return true
}
} catch (error) {
logError(log, 'Update user ERP credentials failed', error, {
logError(log, error, {
message: 'Update user ERP credentials failed',
operation: 'updateUserErpCredentials',
username,
dbType: this.dbType
context: { username, dbType: this.dbType }
})
return false
}
@@ -668,9 +667,10 @@ export class BIPUsersDAO {
erpUsername: (row[cols.ERP_USERNAME] as string) || ''
}))
} catch (error) {
logError(log, 'Get all users ERP config failed', error, {
logError(log, error, {
message: 'Get all users ERP config failed',
operation: 'getAllUsersErpConfig',
dbType: this.dbType
context: { dbType: this.dbType }
})
return []
}

View File

@@ -0,0 +1,27 @@
/**
* Debug script to verify Electron environment detection
*/
import { app } from 'electron'
console.log('=== Electron Environment Debug ===\n')
console.log('1. app.isPackaged:', app.isPackaged)
console.log('2. app.getPath("userData"):', app.getPath('userData'))
console.log('3. app.getPath("logs"):', app.getPath('logs'))
console.log('4. NODE_ENV:', process.env.NODE_ENV)
console.log('5. process.cwd():', process.cwd())
console.log('6. __dirname:', __dirname)
// Predict log dir
function getLogDir(): string {
if (app && app.isReady()) {
return app.getPath('logs')
}
const devLogDir = `${process.cwd()}\\logs`
return devLogDir
}
console.log('\n7. Predicted log dir:', getLogDir())
console.log('\n=== END DEBUG ===')
app.quit()

View File

@@ -83,7 +83,8 @@ export const extractionConfigSchema = z.object({
verbose: z.boolean().default(true),
autoConvert: z.boolean().default(true),
mergeBatches: z.boolean().default(true),
enableDbPersistence: z.boolean().default(true)
enableDbPersistence: z.boolean().default(true),
headless: z.boolean().default(true)
})
/**

View File

@@ -2,13 +2,56 @@ import type { LogLevel } from '../../shared/ipc-channels'
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ipcRenderer } from '../lib/ipc'
// Cached log level for client-side filtering (avoids IPC for filtered-out messages)
let cachedLevel: LogLevel = 'info'
/**
* Check if a message at the given level should be logged
* Based on level priority: error > warn > info > debug > verbose
*/
function shouldLog(level: LogLevel): boolean {
const priorities: Record<LogLevel, number> = {
verbose: 0,
debug: 1,
info: 2,
warn: 3,
error: 4
}
return (priorities[level] ?? 0) >= (priorities[cachedLevel] ?? 2)
}
// Listener for level change broadcasts from main process
function onLevelChanged(_event: Electron.IpcRendererEvent, level: LogLevel): void {
cachedLevel = level
}
export const loggerApi = {
log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
// Drop messages below the configured log level
if (!shouldLog(level)) return
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, {
level,
message,
context,
timestamp: Date.now()
})
},
/**
* Fetch the current log level from main process and cache it.
* Also registers a listener for future level changes.
* Should be called early in renderer initialization.
*/
fetchLevel: async (): Promise<void> => {
cachedLevel = (await ipcRenderer.invoke(IPC_CHANNELS.LOGGER_GET_LEVEL)) as LogLevel
ipcRenderer.on(IPC_CHANNELS.LOGGER_LEVEL_CHANGED, onLevelChanged)
},
/**
* Remove the level change listener (call on cleanup/unmount)
*/
cleanup: (): void => {
ipcRenderer.removeListener(IPC_CHANNELS.LOGGER_LEVEL_CHANGED, onLevelChanged)
}
} as const

View File

@@ -129,6 +129,8 @@ export interface ConfigAPI {
export interface LoggerAPI {
log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void
fetchLevel: () => Promise<void>
cleanup: () => void
}
export interface UpdateAPI {

View File

@@ -221,9 +221,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
const toggleUserFilter = (username: string) => {
setSelectedUsers((prev) =>
prev.includes(username)
? prev.filter((u) => u !== username)
: [...prev, username]
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
)
}
@@ -274,7 +272,10 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
<span className="text-sm text-gray-600">
{isAdmin ? (
<span className="text-amber-600 font-medium">
{selectedUsers.length > 0 ? `已选择 ${selectedUsers.length} 个用户` : '显示所有用户记录'}
{selectedUsers.length > 0
? `已选择 ${selectedUsers.length} 个用户`
: '显示所有用户记录'}
</span>
) : (
<span></span>

View File

@@ -47,7 +47,10 @@ export const ComparisonTooltip = React.memo(
{user || '未分配'}:
</span>
<span className="font-medium text-slate-900">
{firstMetric === 'executionTimeSecs' ? Number(userEntry.value).toFixed(1) : userEntry.value} {firstMetric === 'executionTimeSecs' ? '秒' : ''}
{firstMetric === 'executionTimeSecs'
? Number(userEntry.value).toFixed(1)
: userEntry.value}{' '}
{firstMetric === 'executionTimeSecs' ? '秒' : ''}
</span>
</div>
)

View File

@@ -40,7 +40,10 @@ export const CustomTooltip = React.memo(
{entry.name}:
</span>
<span className="font-medium text-slate-900">
{entry.dataKey === 'executionTimeSecs' ? Number(entry.value).toFixed(1) : entry.value} {entry.dataKey === 'executionTimeSecs' ? '秒' : ''}
{entry.dataKey === 'executionTimeSecs'
? Number(entry.value).toFixed(1)
: entry.value}{' '}
{entry.dataKey === 'executionTimeSecs' ? '秒' : ''}
</span>
</div>
))}

View File

@@ -68,6 +68,10 @@ export function useAppBootstrap() {
const initializeAuth = useCallback(async () => {
logger.info('=== Starting initializeAuth ===')
// Fetch log level early so client-side filtering takes effect
await window.electron.logger.fetchLevel()
try {
logger.debug('Getting computer name...')
const computerNameResult = await window.electron.auth.getComputerName()
@@ -246,12 +250,19 @@ export function useAppBootstrap() {
}, [])
const handleLogout = useCallback(async () => {
// 退出登录,清空后端状态
await window.electron.auth.logout()
// 清空前端状态
setIsAuthenticated(false)
setCurrentUser(null)
setIsSwitchedByAdmin(false)
setShowLoginDialog(true)
}, [])
setShowUserSelection(false)
setShowLoginDialog(false)
// 重新进行静默登录,如果是 Admin 会自动弹出用户选择界面
await initializeAuth()
}, [initializeAuth])
const openUpdateDialog = useCallback(async () => {
await Promise.all([refreshUpdateCatalog(), refreshUpdateState()])

View File

@@ -92,6 +92,8 @@ export const IPC_CHANNELS = {
// Logger
LOGGER_FORWARD: 'logger:forward',
LOGGER_GET_LEVEL: 'logger:getLevel',
LOGGER_LEVEL_CHANGED: 'logger:levelChanged',
// Report
REPORT_LIST_ALL: 'report:listAll',
@@ -122,4 +124,4 @@ export const IPC_CHANNELS = {
/**
* Log level for logger service
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'verbose'

View File

@@ -92,7 +92,7 @@ describe('Audit Logger - Real File Integration', () => {
const entry = createTestEntry()
await logAudit(entry.action as string, entry.userId as string, {
logAudit(entry.action as string, entry.userId as string, {
username: entry.username as string,
computerName: entry.computerName as string,
resource: entry.resource as string,
@@ -101,7 +101,7 @@ describe('Audit Logger - Real File Integration', () => {
})
// Close logger to flush writes
await closeAuditLogger()
closeAuditLogger()
// Find the audit log file (should be today's file)
const today = new Date().toISOString().split('T')[0]
@@ -122,7 +122,7 @@ describe('Audit Logger - Real File Integration', () => {
await import('../../src/main/services/logger/audit-logger')
// Test success status
await logAudit('EXTRACT', 'user1', {
logAudit('EXTRACT', 'user1', {
username: 'extractor',
computerName: 'PC-001',
resource: 'materials',
@@ -130,7 +130,7 @@ describe('Audit Logger - Real File Integration', () => {
})
// Test failure status
await logAudit('DELETE', 'user2', {
logAudit('DELETE', 'user2', {
username: 'cleaner',
computerName: 'PC-002',
resource: 'temp_files',
@@ -139,7 +139,7 @@ describe('Audit Logger - Real File Integration', () => {
})
// Test partial status
await logAudit('UPDATE', 'user3', {
logAudit('UPDATE', 'user3', {
username: 'updater',
computerName: 'PC-003',
resource: 'config',
@@ -147,7 +147,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: { updated: 5, failed: 2 }
})
await closeAuditLogger()
closeAuditLogger()
// Verify all entries were processed
expect(true).toBe(true) // Logger accepted all status types without error
@@ -158,7 +158,7 @@ describe('Audit Logger - Real File Integration', () => {
await import('../../src/main/services/logger/audit-logger')
// Without metadata
await logAudit('LOGIN', 'user-no-meta', {
logAudit('LOGIN', 'user-no-meta', {
username: 'no.meta',
computerName: 'PC-001',
resource: 'ERP',
@@ -166,7 +166,7 @@ describe('Audit Logger - Real File Integration', () => {
})
// With metadata
await logAudit('LOGOUT', 'user-with-meta', {
logAudit('LOGOUT', 'user-with-meta', {
username: 'with.meta',
computerName: 'PC-002',
resource: 'ERP',
@@ -174,7 +174,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: { sessionDuration: 3600, actionsPerformed: 15 }
})
await closeAuditLogger()
closeAuditLogger()
// Both entries should be processed successfully
expect(true).toBe(true)
@@ -186,14 +186,14 @@ describe('Audit Logger - Real File Integration', () => {
const beforeLog = Date.now()
await logAudit('TEST', 'timestamp-user', {
logAudit('TEST', 'timestamp-user', {
username: 'timestamp.test',
computerName: 'PC-TS',
resource: 'test_resource',
status: 'success'
})
await closeAuditLogger()
closeAuditLogger()
const afterLog = Date.now()
@@ -204,15 +204,15 @@ describe('Audit Logger - Real File Integration', () => {
it('should close audit logger without errors', async () => {
const { closeAuditLogger } = await import('../../src/main/services/logger/audit-logger')
// Should resolve without throwing
await expect(closeAuditLogger()).resolves.toBeUndefined()
// Should complete without throwing
expect(() => closeAuditLogger()).not.toThrow()
})
it('should handle special characters in fields', async () => {
const { logAudit, closeAuditLogger } =
await import('../../src/main/services/logger/audit-logger')
await logAudit('LOGIN_ATTEMPT', 'user-special', {
logAudit('LOGIN_ATTEMPT', 'user-special', {
username: 'user.name+test@example.com',
computerName: 'DESKTOP-特殊字符-001',
resource: 'ERP/子系统',
@@ -220,7 +220,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: { reason: '密码错误', attempt: 3 }
})
await closeAuditLogger()
closeAuditLogger()
// Should handle without errors
expect(true).toBe(true)
@@ -230,7 +230,7 @@ describe('Audit Logger - Real File Integration', () => {
const { logAudit, closeAuditLogger } =
await import('../../src/main/services/logger/audit-logger')
await logAudit('PING', 'ping-user', {
logAudit('PING', 'ping-user', {
username: 'pinger',
computerName: 'PC-PING',
resource: 'health_check',
@@ -238,7 +238,7 @@ describe('Audit Logger - Real File Integration', () => {
metadata: {}
})
await closeAuditLogger()
closeAuditLogger()
// Should handle empty metadata
expect(true).toBe(true)