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 autoConvert: true
mergeBatches: true mergeBatches: true
enableDbPersistence: true enableDbPersistence: true
headless: true # 浏览器无头模式true=后台运行false=显示浏览器窗口(调试用)
validation: validation:
dataSource: database_full 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` | 这个项目整体是什么、做什么、核心目录和主链路是什么 | | `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 |
| `runtime-architecture.md` | `main / preload / renderer` 如何协作 | | `runtime-architecture.md` | `main / preload / renderer` 如何协作 |
| `data-flow.md` | 核心业务数据如何在各层之间流动 | | `data-flow.md` | 核心业务数据如何在各层之间流动 |

View File

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

View File

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

View File

@@ -1,40 +1,45 @@
import { app } from 'electron' import { app } from 'electron'
import logger from '../services/logger/index' 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 { export function setupProcessGuards(): void {
process.on('uncaughtException', async (err) => { process.on('uncaughtException', (err) => {
logger.error('Uncaught exception', { error: err }) logger.error('Uncaught exception', { error: err })
await logAudit('SYSTEM_CRASH', 'system', { logAudit('SYSTEM_CRASH', 'system', {
username: 'system', username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown', computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process', resource: 'main-process',
status: 'failure', status: 'failure',
metadata: { error: err.message, stack: err.stack } metadata: { error: err.message, stack: err.stack }
}) })
console.error('Uncaught exception:', err)
setTimeout(() => process.exit(1), 1000) setTimeout(() => process.exit(1), 1000)
}) })
process.on('unhandledRejection', async (reason) => { process.on('unhandledRejection', (reason) => {
logger.error('Unhandled Rejection', { reason: String(reason) }) logger.error('Unhandled Rejection', { reason: String(reason) })
await logAudit('SYSTEM_ERROR', 'system', { logAudit('SYSTEM_ERROR', 'system', {
username: 'system', username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown', computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process', resource: 'main-process',
status: 'failure', status: 'failure',
metadata: { reason: String(reason) } metadata: { reason: String(reason) }
}) })
console.error('Unhandled Rejection:', reason)
}) })
app.on('render-process-gone', (_, webContents, details) => { app.on('render-process-gone', (_, webContents, details) => {
logger.error('Render process gone', { details, webContentsId: webContents.id }) logger.error('Render process gone', { details, webContentsId: webContents.id })
console.error('Render process gone:', details)
}) })
app.on('child-process-gone', (_, details) => { app.on('child-process-gone', (_, details) => {
logger.error('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...') log.info('Fetching ERP configuration from database...')
const erpConfig = await getErpConfig() const erpConfig = await getErpConfig()
// Read headless setting from global config
const configManager = ConfigManager.getInstance()
const globalConfig = configManager.getConfig()
log.info('ERP config retrieved', { log.info('ERP config retrieved', {
url: erpConfig.url ? 'configured' : 'EMPTY', url: erpConfig.url ? 'configured' : 'EMPTY',
username: erpConfig.username ? 'configured' : 'EMPTY' username: erpConfig.username ? 'configured' : 'EMPTY'
@@ -188,7 +192,7 @@ export function registerExtractorHandlers(): void {
url: erpConfig.url, url: erpConfig.url,
username: erpConfig.username, username: erpConfig.username,
password: erpConfig.password, password: erpConfig.password,
headless: true headless: globalConfig.extraction.headless
}) })
sendProgress(sender, '登录 ERP 系统...', 9.99, { sendProgress(sender, '登录 ERP 系统...', 9.99, {
@@ -259,7 +263,13 @@ export function registerExtractorHandlers(): void {
// Write per-order record counts // Write per-order record counts
for (const { orderNumber, recordCount } of result.orderRecordCounts) { 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) // Update batch status without recordCount (per-order counts are set individually)
@@ -286,7 +296,7 @@ export function registerExtractorHandlers(): void {
recordCount: result.recordCount, recordCount: result.recordCount,
errorCount: result.errors.length errorCount: result.errors.length
} }
}).catch((err) => log.warn('Failed to write audit log', { err })) })
} }
return result return result

View File

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

View File

@@ -10,7 +10,9 @@
*/ */
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import winston from 'winston'
import { createLogger } from '../services/logger' import { createLogger } from '../services/logger'
import logger from '../services/logger'
import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels' import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels'
const log = createLogger('LoggerHandler') const log = createLogger('LoggerHandler')
@@ -41,6 +43,7 @@ class LoggerHandlerState {
private buffer: LogEntry[] = [] private buffer: LogEntry[] = []
private debounceTimer: NodeJS.Timeout | null = null private debounceTimer: NodeJS.Timeout | null = null
private discardedCount = 0 private discardedCount = 0
private childLoggerCache = new Map<string, winston.Logger>()
/** /**
* Add log entry to buffer * 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 * Forward a single log entry to Winston logger
* @param entry - Log entry to forward * @param entry - Log entry to forward
*/ */
private forwardToWinston(entry: LogEntry): void { private forwardToWinston(entry: LogEntry): void {
const context = (entry.context?.component as string) || 'renderer' const context = (entry.context?.component as string) || 'renderer'
const childLogger = log.child({ const childLogger = this.getChildLogger(context)
source: 'renderer',
component: context
})
const message = entry.context?.message const message = entry.context?.message
? `[${entry.context.message}] ${entry.message}` ? `[${entry.context.message}] ${entry.message}`
: entry.message : entry.message
switch (entry.level) { switch (entry.level) {
case 'verbose':
childLogger.verbose(message, entry.context)
break
case 'debug': case 'debug':
childLogger.debug(message, entry.context) childLogger.debug(message, entry.context)
break break
@@ -187,6 +204,7 @@ class LoggerHandlerState {
} }
this.buffer = [] this.buffer = []
this.discardedCount = 0 this.discardedCount = 0
this.childLoggerCache.clear()
} }
} }
@@ -197,6 +215,11 @@ const state = new LoggerHandlerState()
* Register IPC handlers for logger * Register IPC handlers for logger
*/ */
export function registerLoggerHandlers(): void { 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 // Use ipcMain.on with send() - fire-and-forget, non-blocking
ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => { ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => {
// Validate entry // Validate entry

View File

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

View File

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

View File

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

View File

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

View File

@@ -123,9 +123,7 @@ export class ExtractorService {
* @param filePaths - Array of downloaded Excel file paths * @param filePaths - Array of downloaded Excel file paths
* @returns Merged file path, total record count, and optional error message * @returns Merged file path, total record count, and optional error message
*/ */
private async mergeFiles( private async mergeFiles(filePaths: string[]): Promise<{
filePaths: string[]
): Promise<{
mergedFile: string | null mergedFile: string | null
recordCount: number recordCount: number
error?: string error?: string
@@ -197,7 +195,12 @@ export class ExtractorService {
const errorStack = error instanceof Error ? error.stack : '' const errorStack = error instanceof Error ? error.stack : ''
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack }) log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
// Return parsed record count and error info even if save fails // 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 winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file' import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path' import path from 'path'
import { app } from 'electron' import { getLogDir } from './shared'
import fs from 'fs'
/** /**
* Audit log entry structure * Audit log entry structure
@@ -32,22 +31,6 @@ export interface AuditEntry {
metadata: Record<string, unknown> 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 * JSONL formatter - outputs one JSON object per line
* This is the key difference from the standard JSON formatter * 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 * 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({ const auditLogger = winston.createLogger({
level: 'info', level: 'info',
silent: false, silent: true,
transports: [ 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({ new DailyRotateFile({
filename: path.join(getLogDir(), 'audit-%DATE%.jsonl'), filename: path.join(getLogDir(), 'audit-%DATE%.jsonl'),
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
zippedArchive: true, zippedArchive: true,
maxSize: '20m', maxSize: '20m',
maxFiles: '30d', // 30-day retention maxFiles: `${retentionDays}d`,
level: 'info', level: 'info',
format: winston.format.combine( format: jsonlFormat
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
jsonlFormat
)
}) })
] )
}) }
/** /**
* Log an audit event * Log an audit event
@@ -86,9 +86,8 @@ const auditLogger = winston.createLogger({
* @param action - The action that was performed * @param action - The action that was performed
* @param userId - User ID who performed the action * @param userId - User ID who performed the action
* @param details - Additional details including username, computerName, resource, status, and optional metadata * @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, action: string,
userId: string, userId: string,
details: { details: {
@@ -98,7 +97,7 @@ export async function logAudit(
status: 'success' | 'failure' | 'partial' status: 'success' | 'failure' | 'partial'
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
} }
): Promise<void> { ): void {
const entry: AuditEntry = { const entry: AuditEntry = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
action, action,
@@ -118,8 +117,7 @@ export async function logAudit(
/** /**
* Flush and close the audit logger (call on app shutdown) * Flush and close the audit logger (call on app shutdown)
*/ */
export async function closeAuditLogger(): Promise<void> { export function closeAuditLogger(): void {
// Winston logger.close() is synchronous
auditLogger.close() auditLogger.close()
} }

View File

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

View File

@@ -11,26 +11,28 @@
import winston from 'winston' import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file' import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path' import path from 'path'
import { app } from 'electron' import { BrowserWindow } from 'electron'
import fs from 'fs'
import { serializeError, sanitizeError } from './error-utils' 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 // Cache isProduction() at module load — app.isPackaged never changes at runtime
function getLogDir(): string { const IS_PROD = isProduction()
if (app && app.isReady()) {
return app.getPath('logs') /**
} * Check if an error has already been serialized (plain object with name/message but not an Error instance).
// Fallback for development or before app is ready * Prevents double-serialization when logError() output passes through the format pipeline.
const devLogDir = path.join(process.cwd(), 'logs') */
if (!fs.existsSync(devLogDir)) { function isSerializedError(value: unknown): boolean {
fs.mkdirSync(devLogDir, { recursive: true }) return (
} typeof value === 'object' &&
return devLogDir 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 // Custom format for console output - includes full error details
const consoleFormat = winston.format.combine( const consoleFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), 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 // Format error with full stack trace
let errorStr = '' let errorStr = ''
if (error) { 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) { if (serialized.stack) {
errorStr = `\n${serialized.stack}` errorStr = `\n${serialized.stack}`
} else { } 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}` return `${timestamp} [${level}]${contextStr} ${message}${errorStr}${metaStr}`
}) })
) )
@@ -58,19 +82,19 @@ const consoleFormat = winston.format.combine(
const fileFormat = winston.format.combine( const fileFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format((info) => { winston.format((info) => {
// Serialize errors in metadata // Serialize errors in metadata (skip if already serialized)
if (info.error) { if (info.error) {
info.error = isProduction if (!isSerializedError(info.error)) {
info.error = IS_PROD
? sanitizeError(serializeError(info.error)) ? sanitizeError(serializeError(info.error))
: 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)) { for (const key of Object.keys(info)) {
if (key !== 'error' && info[key] instanceof Error) { if (key !== 'error' && info[key] instanceof Error) {
info[key] = isProduction info[key] = IS_PROD ? sanitizeError(serializeError(info[key])) : serializeError(info[key])
? sanitizeError(serializeError(info[key]))
: serializeError(info[key])
} }
} }
@@ -80,19 +104,20 @@ const fileFormat = winston.format.combine(
) )
// Daily rotate file transport configuration // Daily rotate file transport configuration
const createFileTransport = (level?: string): DailyRotateFile => { const createFileTransport = (level?: string, maxFiles?: string): DailyRotateFile => {
return new DailyRotateFile({ return new DailyRotateFile({
filename: path.join(getLogDir(), 'app-%DATE%.log'), filename: path.join(getLogDir(), 'app-%DATE%.log'),
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
zippedArchive: true, zippedArchive: true,
maxSize: '20m', maxSize: '20m',
maxFiles: '14d', maxFiles: maxFiles || '14d',
level, level,
format: fileFormat 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({ const logger = winston.createLogger({
level: 'info', // Default level, can be updated via setLogLevel() level: 'info', // Default level, can be updated via setLogLevel()
defaultMeta: { service: 'erpauto' }, defaultMeta: { service: 'erpauto' },
@@ -100,33 +125,59 @@ const logger = winston.createLogger({
// Console transport - always enabled // Console transport - always enabled
new winston.transports.Console({ new winston.transports.Console({
format: consoleFormat 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 * @param level - The new log level
*/ */
export function setLogLevel(level: string): void { export function setLogLevel(level: string): void {
logger.level = level 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( logger.add(
new DailyRotateFile({ new DailyRotateFile({
filename: path.join(getLogDir(), 'error-%DATE%.log'), filename: path.join(getLogDir(), 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
zippedArchive: true, zippedArchive: true,
maxSize: '20m', maxSize: '20m',
maxFiles: '14d', maxFiles: retentionStr,
level: 'error', level: 'error',
format: fileFormat format: fileFormat
}) })
) )
}
} }
/** /**
@@ -138,23 +189,8 @@ export function createLogger(context: string): winston.Logger {
return logger.child({ context }) return logger.child({ context })
} }
/** // Re-export error utilities for convenience
* Log an error with full context and stack trace export { logError, formatErrorForLogging, serializeError, extractErrorContext } from './error-utils'
* 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 })
}
// Export the main logger for direct use // Export the main logger for direct use
export default logger 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 return null
} }
} catch (error) { } catch (error) {
logError(log, 'Authenticate failed', error, { logError(log, error, {
message: 'Authenticate failed',
operation: 'authenticate', operation: 'authenticate',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return null return null
} }
@@ -222,10 +222,10 @@ export class BIPUsersDAO {
return null return null
} }
} catch (error) { } catch (error) {
logError(log, 'Silent login failed', error, { logError(log, error, {
message: 'Silent login failed',
operation: 'authenticateByComputerName', operation: 'authenticateByComputerName',
computerName, context: { computerName, dbType: this.dbType }
dbType: this.dbType
}) })
return null return null
} }
@@ -258,9 +258,10 @@ export class BIPUsersDAO {
createTime: row.CreateTime as Date | undefined createTime: row.CreateTime as Date | undefined
})) }))
} catch (error) { } catch (error) {
logError(log, 'Get all users failed', error, { logError(log, error, {
message: 'Get all users failed',
operation: 'getAllUsers', operation: 'getAllUsers',
dbType: this.dbType context: { dbType: this.dbType }
}) })
return [] return []
} }
@@ -345,11 +346,10 @@ export class BIPUsersDAO {
return true return true
} }
} catch (error) { } catch (error) {
logError(log, 'Create user failed', error, { logError(log, error, {
message: 'Create user failed',
operation: 'createUser', operation: 'createUser',
username, context: { username, userType, dbType: this.dbType }
userType,
dbType: this.dbType
}) })
return false return false
} }
@@ -389,11 +389,10 @@ export class BIPUsersDAO {
return true return true
} }
} catch (error) { } catch (error) {
logError(log, 'Update user type failed', error, { logError(log, error, {
message: 'Update user type failed',
operation: 'updateUserType', operation: 'updateUserType',
username, context: { username, userType, dbType: this.dbType }
userType,
dbType: this.dbType
}) })
return false return false
} }
@@ -433,10 +432,10 @@ export class BIPUsersDAO {
return true return true
} }
} catch (error) { } catch (error) {
logError(log, 'Update password failed', error, { logError(log, error, {
message: 'Update password failed',
operation: 'updatePassword', operation: 'updatePassword',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return false return false
} }
@@ -472,10 +471,10 @@ export class BIPUsersDAO {
return true return true
} }
} catch (error) { } catch (error) {
logError(log, 'Delete user failed', error, { logError(log, error, {
message: 'Delete user failed',
operation: 'deleteUser', operation: 'deleteUser',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return false return false
} }
@@ -513,10 +512,10 @@ export class BIPUsersDAO {
return result.rows.length > 0 && (result.rows[0].count as number) > 0 return result.rows.length > 0 && (result.rows[0].count as number) > 0
} }
} catch (error) { } catch (error) {
logError(log, 'Check user exists failed', error, { logError(log, error, {
message: 'Check user exists failed',
operation: 'userExists', operation: 'userExists',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return false return false
} }
@@ -574,10 +573,10 @@ export class BIPUsersDAO {
return null return null
} }
} catch (error) { } catch (error) {
logError(log, 'Get user ERP credentials failed', error, { logError(log, error, {
message: 'Get user ERP credentials failed',
operation: 'getUserErpCredentials', operation: 'getUserErpCredentials',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return null return null
} }
@@ -626,10 +625,10 @@ export class BIPUsersDAO {
return true return true
} }
} catch (error) { } catch (error) {
logError(log, 'Update user ERP credentials failed', error, { logError(log, error, {
message: 'Update user ERP credentials failed',
operation: 'updateUserErpCredentials', operation: 'updateUserErpCredentials',
username, context: { username, dbType: this.dbType }
dbType: this.dbType
}) })
return false return false
} }
@@ -668,9 +667,10 @@ export class BIPUsersDAO {
erpUsername: (row[cols.ERP_USERNAME] as string) || '' erpUsername: (row[cols.ERP_USERNAME] as string) || ''
})) }))
} catch (error) { } catch (error) {
logError(log, 'Get all users ERP config failed', error, { logError(log, error, {
message: 'Get all users ERP config failed',
operation: 'getAllUsersErpConfig', operation: 'getAllUsersErpConfig',
dbType: this.dbType context: { dbType: this.dbType }
}) })
return [] 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), verbose: z.boolean().default(true),
autoConvert: z.boolean().default(true), autoConvert: z.boolean().default(true),
mergeBatches: 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 { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ipcRenderer } from '../lib/ipc' 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 = { export const loggerApi = {
log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => { 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, { ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, {
level, level,
message, message,
context, context,
timestamp: Date.now() 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 } as const

View File

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

View File

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

View File

@@ -47,7 +47,10 @@ export const ComparisonTooltip = React.memo(
{user || '未分配'}: {user || '未分配'}:
</span> </span>
<span className="font-medium text-slate-900"> <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> </span>
</div> </div>
) )

View File

@@ -40,7 +40,10 @@ export const CustomTooltip = React.memo(
{entry.name}: {entry.name}:
</span> </span>
<span className="font-medium text-slate-900"> <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> </span>
</div> </div>
))} ))}

View File

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

View File

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