Compare commits
20 Commits
v1.7.0
...
24d9bfebaf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d9bfebaf | ||
|
|
ba436cf374 | ||
|
|
6413eef5b8 | ||
|
|
6a9d144bbc | ||
|
|
a2e3681c8f | ||
|
|
21359b31c6 | ||
|
|
0a1181fecd | ||
|
|
883f98065a | ||
|
|
020bbcdccc | ||
|
|
63a292c5f9 | ||
|
|
51f8e0a6e7 | ||
|
|
c8ab58d390 | ||
|
|
811361a1a3 | ||
|
|
ffbda4c618 | ||
|
|
348b02600d | ||
|
|
d004f8e9f8 | ||
|
|
3cbe9eef12 | ||
|
|
5b310d944b | ||
|
|
6e04f21b10 | ||
|
|
17fbd7d251 |
@@ -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
|
||||||
|
|||||||
752
docs/LOGGING_IMPLEMENTATION.md
Normal file
752
docs/LOGGING_IMPLEMENTATION.md
Normal 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_
|
||||||
@@ -48,13 +48,13 @@ graph TD
|
|||||||
|
|
||||||
## 文档职责一览
|
## 文档职责一览
|
||||||
|
|
||||||
| 文档 | 主要回答的问题 |
|
| 文档 | 主要回答的问题 |
|
||||||
| --- | --- |
|
| ------------------------- | -------------------------------------------------- |
|
||||||
| `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 |
|
| `overview.md` | 这个项目整体是什么、做什么、核心目录和主链路是什么 |
|
||||||
| `runtime-architecture.md` | `main / preload / renderer` 如何协作 |
|
| `runtime-architecture.md` | `main / preload / renderer` 如何协作 |
|
||||||
| `data-flow.md` | 核心业务数据如何在各层之间流动 |
|
| `data-flow.md` | 核心业务数据如何在各层之间流动 |
|
||||||
| `file-map.md` | 关键文件在哪里、应该先看哪些入口 |
|
| `file-map.md` | 关键文件在哪里、应该先看哪些入口 |
|
||||||
| `decision-log.md` | 最近几轮重要重构和架构决策是什么 |
|
| `decision-log.md` | 最近几轮重要重构和架构决策是什么 |
|
||||||
|
|
||||||
## 按问题选择阅读路径
|
## 按问题选择阅读路径
|
||||||
|
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ flowchart TD
|
|||||||
|
|
||||||
## 当前文档一览
|
## 当前文档一览
|
||||||
|
|
||||||
| 文档 | 主要内容 |
|
| 文档 | 主要内容 |
|
||||||
| --- | --- |
|
| ------------------------- | ---------------------------------------- |
|
||||||
| `local-development.md` | 环境准备、启动、构建、常用命令、本地验证 |
|
| `local-development.md` | 环境准备、启动、构建、常用命令、本地验证 |
|
||||||
| `debugging.md` | 分层调试思路、调试入口、主链路定位方法 |
|
| `debugging.md` | 分层调试思路、调试入口、主链路定位方法 |
|
||||||
| `renderer-development.md` | React 渲染层开发方式、页面/hook/组件边界 |
|
| `renderer-development.md` | React 渲染层开发方式、页面/hook/组件边界 |
|
||||||
| `ipc-development.md` | 新增或修改 IPC 能力的推荐实现路径 |
|
| `ipc-development.md` | 新增或修改 IPC 能力的推荐实现路径 |
|
||||||
| `release-process.md` | 构建、发布、更新产物与上传流程 |
|
| `release-process.md` | 构建、发布、更新产物与上传流程 |
|
||||||
|
|
||||||
## 与其他文档目录的关系
|
## 与其他文档目录的关系
|
||||||
|
|
||||||
|
|||||||
@@ -59,14 +59,14 @@ 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 数据准备 |
|
||||||
| Cleaner | `cleaner.md` | 物料校验展示、删除计划保存、ERP 清理执行、报告展示 |
|
| Cleaner | `cleaner.md` | 物料校验展示、删除计划保存、ERP 清理执行、报告展示 |
|
||||||
| Update | `update.md` | 更新目录、状态广播、下载、安装、用户/管理员更新视图 |
|
| Update | `update.md` | 更新目录、状态广播、下载、安装、用户/管理员更新视图 |
|
||||||
| Settings | `settings.md` | ERP 凭据加载与保存、当前用户配置管理 |
|
| Settings | `settings.md` | ERP 凭据加载与保存、当前用户配置管理 |
|
||||||
|
|
||||||
## 模块入口地图
|
## 模块入口地图
|
||||||
|
|
||||||
|
|||||||
6
docs/releases/1.7.1.md
Normal file
6
docs/releases/1.7.1.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 1.7.1
|
||||||
|
|
||||||
|
## 问题修复
|
||||||
|
|
||||||
|
- 修复 MySQL 数据库下操作历史查询报错问题。
|
||||||
|
- 优化历史记录数据结构,支持按订单统计记录数量。
|
||||||
6
docs/releases/1.7.2.md
Normal file
6
docs/releases/1.7.2.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 1.7.2
|
||||||
|
|
||||||
|
## 问题修复
|
||||||
|
|
||||||
|
- 修复操作历史时间显示错误(时区转换导致时间快8小时)。
|
||||||
|
- 操作历史支持一键复制总排号和订单号。
|
||||||
12
docs/releases/1.8.0.md
Normal file
12
docs/releases/1.8.0.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# 1.8.0
|
||||||
|
|
||||||
|
## 权限控制
|
||||||
|
|
||||||
|
- 操作历史删除按钮仅对管理员可见,普通用户无法删除历史记录。
|
||||||
|
- 修复用户状态传递问题,确保权限判断正确生效。
|
||||||
|
|
||||||
|
## 界面与交互
|
||||||
|
|
||||||
|
- 管理员可使用多选标签(Chip)按用户筛选操作历史。
|
||||||
|
- 支持同时选择多个用户查看记录,点击标签即可切换选中状态。
|
||||||
|
- 添加"清空筛选"按钮,一键恢复显示所有用户记录。
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.929.0",
|
"@aws-sdk/client-s3": "^3.929.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"description": "An Electron application with React and TypeScript",
|
"description": "An Electron application with React and TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "example.com",
|
"author": "example.com",
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
@@ -256,7 +260,20 @@ export function registerExtractorHandlers(): void {
|
|||||||
: result.errors.length > 0
|
: result.errors.length > 0
|
||||||
? 'failed'
|
? 'failed'
|
||||||
: 'success'
|
: 'success'
|
||||||
await historyDao.updateBatchStatus(batchId, status, result.recordCount)
|
|
||||||
|
// Write per-order record counts
|
||||||
|
for (const { orderNumber, recordCount } of result.orderRecordCounts) {
|
||||||
|
await historyDao.updateRecordStatus(
|
||||||
|
batchId,
|
||||||
|
orderNumber,
|
||||||
|
status,
|
||||||
|
undefined,
|
||||||
|
recordCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update batch status without recordCount (per-order counts are set individually)
|
||||||
|
await historyDao.updateBatchStatus(batchId, status)
|
||||||
log.info('Operation history batch status updated', { batchId, status })
|
log.info('Operation history batch status updated', { batchId, status })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,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
|
||||||
|
|||||||
@@ -68,15 +68,21 @@ export function withErrorHandling<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isBaseError(error)) {
|
if (isBaseError(error)) {
|
||||||
logError(log, `[${context}] ${error.name}`, error, {
|
logError(log, error, {
|
||||||
code,
|
message: `[${context}] ${error.name}`,
|
||||||
cause: getErrorCauseMessage(error),
|
context: {
|
||||||
handler: context
|
code,
|
||||||
|
cause: getErrorCauseMessage(error),
|
||||||
|
handler: context
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
logError(log, `[${context}] Error`, error, {
|
logError(log, error, {
|
||||||
code,
|
message: `[${context}] Error`,
|
||||||
handler: context
|
context: {
|
||||||
|
code,
|
||||||
|
handler: context
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 })
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ import type {
|
|||||||
|
|
||||||
const log = createLogger('ExtractorOperationHistoryDAO')
|
const log = createLogger('ExtractorOperationHistoryDAO')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format datetime value from database to ISO string
|
||||||
|
* mssql driver returns Date objects in UTC format
|
||||||
|
*/
|
||||||
|
function formatDateTime(value: unknown): string {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString()
|
||||||
|
}
|
||||||
|
return value ? String(value) : new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for ExtractorOperationHistory table
|
* Configuration for ExtractorOperationHistory table
|
||||||
*/
|
*/
|
||||||
@@ -160,43 +171,24 @@ export class ExtractorOperationHistoryDAO {
|
|||||||
* Update the status of all records in a batch
|
* Update the status of all records in a batch
|
||||||
* @param batchId - Batch identifier
|
* @param batchId - Batch identifier
|
||||||
* @param status - New status (success, failed, partial)
|
* @param status - New status (success, failed, partial)
|
||||||
* @param recordCount - Total record count for the batch
|
|
||||||
* @returns Update result
|
* @returns Update result
|
||||||
*/
|
*/
|
||||||
async updateBatchStatus(
|
async updateBatchStatus(batchId: string, status: string): Promise<UpdateBatchStatusResult> {
|
||||||
batchId: string,
|
|
||||||
status: string,
|
|
||||||
recordCount: number | null
|
|
||||||
): Promise<UpdateBatchStatusResult> {
|
|
||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const isSqlServer = dbService.type === 'sqlserver'
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
const placeholder = isSqlServer ? '@p0' : '?'
|
const sqlString = `
|
||||||
let sqlString: string
|
UPDATE ${tableName}
|
||||||
let params: (string | number | null)[]
|
SET Status = ${isSqlServer ? '@p0' : '?'}
|
||||||
|
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
|
||||||
if (recordCount !== null) {
|
`
|
||||||
sqlString = `
|
const params = [status, batchId]
|
||||||
UPDATE ${tableName}
|
|
||||||
SET Status = ${isSqlServer ? '@p0' : '?'},
|
|
||||||
RecordCount = ${isSqlServer ? '@p1' : '?'}
|
|
||||||
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
|
|
||||||
`
|
|
||||||
params = isSqlServer ? [status, recordCount, batchId] : [status, recordCount, batchId]
|
|
||||||
} else {
|
|
||||||
sqlString = `
|
|
||||||
UPDATE ${tableName}
|
|
||||||
SET Status = ${placeholder}
|
|
||||||
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
|
|
||||||
`
|
|
||||||
params = isSqlServer ? [status, batchId] : [status, batchId]
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbService.query(sqlString, params)
|
await dbService.query(sqlString, params)
|
||||||
|
|
||||||
log.info('Batch status updated', { batchId, status, recordCount })
|
log.info('Batch status updated', { batchId, status })
|
||||||
return { success: true, updatedCount: 1 }
|
return { success: true, updatedCount: 1 }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Update batch status error', {
|
log.error('Update batch status error', {
|
||||||
@@ -208,33 +200,51 @@ export class ExtractorOperationHistoryDAO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a single record's status and error message
|
* Update a single record's status, error message, and optional record count
|
||||||
* @param batchId - Batch identifier
|
* @param batchId - Batch identifier
|
||||||
* @param orderNumber - Order number
|
* @param orderNumber - Order number
|
||||||
* @param status - New status
|
* @param status - New status
|
||||||
* @param errorMessage - Optional error message
|
* @param errorMessage - Optional error message
|
||||||
|
* @param recordCount - Optional per-order record count
|
||||||
* @returns True if successful
|
* @returns True if successful
|
||||||
*/
|
*/
|
||||||
async updateRecordStatus(
|
async updateRecordStatus(
|
||||||
batchId: string,
|
batchId: string,
|
||||||
orderNumber: string,
|
orderNumber: string,
|
||||||
status: string,
|
status: string,
|
||||||
errorMessage?: string
|
errorMessage?: string,
|
||||||
|
recordCount?: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const isSqlServer = dbService.type === 'sqlserver'
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
const sqlString = `
|
let sqlString: string
|
||||||
UPDATE ${tableName}
|
let params: (string | number | null)[]
|
||||||
SET Status = ${isSqlServer ? '@p0' : '?'},
|
|
||||||
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
|
|
||||||
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
|
|
||||||
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
|
|
||||||
`
|
|
||||||
|
|
||||||
await dbService.query(sqlString, [status, errorMessage || null, batchId, orderNumber])
|
if (recordCount !== undefined) {
|
||||||
|
sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET Status = ${isSqlServer ? '@p0' : '?'},
|
||||||
|
ErrorMessage = ${isSqlServer ? '@p1' : '?'},
|
||||||
|
RecordCount = ${isSqlServer ? '@p2' : '?'}
|
||||||
|
WHERE BatchId = ${isSqlServer ? '@p3' : '?'}
|
||||||
|
AND OrderNumber = ${isSqlServer ? '@p4' : '?'}
|
||||||
|
`
|
||||||
|
params = [status, errorMessage || null, recordCount, batchId, orderNumber]
|
||||||
|
} else {
|
||||||
|
sqlString = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET Status = ${isSqlServer ? '@p0' : '?'},
|
||||||
|
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
|
||||||
|
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
|
||||||
|
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
|
||||||
|
`
|
||||||
|
params = [status, errorMessage || null, batchId, orderNumber]
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbService.query(sqlString, params)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -252,7 +262,7 @@ export class ExtractorOperationHistoryDAO {
|
|||||||
/**
|
/**
|
||||||
* Get batch statistics with optional user filtering
|
* Get batch statistics with optional user filtering
|
||||||
* @param userId - Optional user ID for filtering (Admin gets all, User gets own)
|
* @param userId - Optional user ID for filtering (Admin gets all, User gets own)
|
||||||
* @param options - Query options (limit, offset)
|
* @param options - Query options (limit, offset, usernames)
|
||||||
* @returns Array of batch statistics
|
* @returns Array of batch statistics
|
||||||
*/
|
*/
|
||||||
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
|
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
|
||||||
@@ -280,6 +290,11 @@ export class ExtractorOperationHistoryDAO {
|
|||||||
if (userId !== undefined) {
|
if (userId !== undefined) {
|
||||||
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
||||||
params.push(userId)
|
params.push(userId)
|
||||||
|
} else if (options?.usernames && options.usernames.length > 0) {
|
||||||
|
// Admin user filtering by multiple usernames using IN clause
|
||||||
|
const placeholders = this.buildPlaceholders(options.usernames.length, isSqlServer)
|
||||||
|
sqlString += ` WHERE Username IN (${placeholders}) `
|
||||||
|
params.push(...options.usernames)
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlString += `
|
sqlString += `
|
||||||
@@ -288,31 +303,30 @@ export class ExtractorOperationHistoryDAO {
|
|||||||
`
|
`
|
||||||
|
|
||||||
if (options?.limit) {
|
if (options?.limit) {
|
||||||
// Add pagination - track current param count before adding new params
|
const safeLimit = Math.floor(options.limit)
|
||||||
const offsetIndex = params.length
|
const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
|
||||||
const limitIndex = params.length + 1
|
|
||||||
|
|
||||||
if (options.offset !== undefined) {
|
|
||||||
params.push(options.offset)
|
|
||||||
}
|
|
||||||
params.push(options.limit)
|
|
||||||
|
|
||||||
if (isSqlServer) {
|
if (isSqlServer) {
|
||||||
if (options.offset !== undefined) {
|
// SQL Server: use parameterized OFFSET/FETCH
|
||||||
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${limitIndex} ROWS ONLY`
|
const offsetIndex = params.length
|
||||||
|
if (safeOffset !== undefined) {
|
||||||
|
params.push(safeOffset)
|
||||||
|
}
|
||||||
|
params.push(safeLimit)
|
||||||
|
|
||||||
|
if (safeOffset !== undefined) {
|
||||||
|
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${offsetIndex + 1} ROWS ONLY`
|
||||||
} else {
|
} else {
|
||||||
// When no offset, use 0 for offset and next index for limit
|
|
||||||
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
|
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (options.offset !== undefined) {
|
// MySQL: embed validated integer values directly.
|
||||||
sqlString += ` LIMIT ?`
|
// connection.execute() uses binary protocol prepared statements,
|
||||||
// For MySQL with offset, we need to modify the query
|
// which do not reliably support ? placeholders in LIMIT/OFFSET clauses.
|
||||||
// Replace LIMIT with OFFSET LIMIT
|
if (safeOffset !== undefined) {
|
||||||
const parts = sqlString.split(' LIMIT ?')
|
sqlString += ` LIMIT ${safeLimit} OFFSET ${safeOffset}`
|
||||||
sqlString = parts[0] + ` OFFSET ? LIMIT ?` + (parts[1] || '')
|
|
||||||
} else {
|
} else {
|
||||||
sqlString += ` LIMIT ?`
|
sqlString += ` LIMIT ${safeLimit}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,9 +337,7 @@ export class ExtractorOperationHistoryDAO {
|
|||||||
batchId: row.BatchId as string,
|
batchId: row.BatchId as string,
|
||||||
userId: row.UserId as number,
|
userId: row.UserId as number,
|
||||||
username: row.Username as string,
|
username: row.Username as string,
|
||||||
operationTime: row.OperationTime
|
operationTime: formatDateTime(row.OperationTime),
|
||||||
? new Date(row.OperationTime as string).toISOString()
|
|
||||||
: new Date().toISOString(),
|
|
||||||
status: row.Status as string,
|
status: row.Status as string,
|
||||||
totalOrders: row.TotalOrders as number,
|
totalOrders: row.TotalOrders as number,
|
||||||
totalRecords: (row.TotalRecords as number) || 0,
|
totalRecords: (row.TotalRecords as number) || 0,
|
||||||
@@ -431,9 +443,7 @@ export class ExtractorOperationHistoryDAO {
|
|||||||
batchId: row.BatchId as string,
|
batchId: row.BatchId as string,
|
||||||
userId: row.UserId as number,
|
userId: row.UserId as number,
|
||||||
username: row.Username as string,
|
username: row.Username as string,
|
||||||
operationTime: row.OperationTime
|
operationTime: formatDateTime(row.OperationTime),
|
||||||
? new Date(row.OperationTime as string).toISOString()
|
|
||||||
: new Date().toISOString(),
|
|
||||||
status: row.Status as string,
|
status: row.Status as string,
|
||||||
totalOrders: row.TotalOrders as number,
|
totalOrders: row.TotalOrders as number,
|
||||||
totalRecords: (row.TotalRecords as number) || 0,
|
totalRecords: (row.TotalRecords as number) || 0,
|
||||||
@@ -565,9 +575,10 @@ export class ExtractorOperationHistoryDAO {
|
|||||||
/**
|
/**
|
||||||
* Count total batches with optional user filtering
|
* Count total batches with optional user filtering
|
||||||
* @param userId - Optional user ID for filtering
|
* @param userId - Optional user ID for filtering
|
||||||
|
* @param usernames - Optional usernames filter for Admin users
|
||||||
* @returns Total number of batches
|
* @returns Total number of batches
|
||||||
*/
|
*/
|
||||||
async countBatches(userId?: number): Promise<number> {
|
async countBatches(userId?: number, usernames?: string[]): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
@@ -578,11 +589,16 @@ export class ExtractorOperationHistoryDAO {
|
|||||||
FROM ${tableName}
|
FROM ${tableName}
|
||||||
`
|
`
|
||||||
|
|
||||||
const params: number[] = []
|
const params: (number | string)[] = []
|
||||||
|
|
||||||
if (userId !== undefined) {
|
if (userId !== undefined) {
|
||||||
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
||||||
params.push(userId)
|
params.push(userId)
|
||||||
|
} else if (usernames && usernames.length > 0) {
|
||||||
|
// Admin user filtering by multiple usernames using IN clause
|
||||||
|
const placeholders = this.buildPlaceholders(usernames.length, isSqlServer)
|
||||||
|
sqlString += ` WHERE Username IN (${placeholders}) `
|
||||||
|
params.push(...usernames)
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbService.query(sqlString, params)
|
const result = await dbService.query(sqlString, params)
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export class ExtractorService {
|
|||||||
downloadedFiles: [],
|
downloadedFiles: [],
|
||||||
mergedFile: null,
|
mergedFile: null,
|
||||||
recordCount: 0,
|
recordCount: 0,
|
||||||
errors: []
|
errors: [],
|
||||||
|
orderRecordCounts: []
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -79,6 +80,7 @@ export class ExtractorService {
|
|||||||
const mergeResult = await this.mergeFiles(result.downloadedFiles)
|
const mergeResult = await this.mergeFiles(result.downloadedFiles)
|
||||||
result.mergedFile = mergeResult.mergedFile
|
result.mergedFile = mergeResult.mergedFile
|
||||||
result.recordCount = mergeResult.recordCount
|
result.recordCount = mergeResult.recordCount
|
||||||
|
result.orderRecordCounts = mergeResult.orderRecordCounts
|
||||||
|
|
||||||
// Add merge error to result if any
|
// Add merge error to result if any
|
||||||
if (mergeResult.error) {
|
if (mergeResult.error) {
|
||||||
@@ -121,11 +123,14 @@ 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[]
|
mergedFile: string | null
|
||||||
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> {
|
recordCount: number
|
||||||
|
error?: string
|
||||||
|
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
|
||||||
|
}> {
|
||||||
if (filePaths.length === 0) {
|
if (filePaths.length === 0) {
|
||||||
return { mergedFile: null, recordCount: 0 }
|
return { mergedFile: null, recordCount: 0, orderRecordCounts: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Starting merge', { fileCount: filePaths.length })
|
log.info('Starting merge', { fileCount: filePaths.length })
|
||||||
@@ -154,15 +159,21 @@ export class ExtractorService {
|
|||||||
|
|
||||||
// Calculate total record count (total material rows)
|
// Calculate total record count (total material rows)
|
||||||
let recordCount = 0
|
let recordCount = 0
|
||||||
|
const orderRecordCounts: Array<{ orderNumber: string; recordCount: number }> = []
|
||||||
for (const order of allOrders) {
|
for (const order of allOrders) {
|
||||||
recordCount += order.materials.length
|
const count = order.materials.length
|
||||||
|
recordCount += count
|
||||||
|
orderRecordCounts.push({
|
||||||
|
orderNumber: order.orderInfo.productionOrder || '',
|
||||||
|
recordCount: count
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
|
log.info('Merge summary', { orderCount: allOrders.length, recordCount })
|
||||||
|
|
||||||
if (recordCount === 0) {
|
if (recordCount === 0) {
|
||||||
log.warn('No records found in any downloaded files')
|
log.warn('No records found in any downloaded files')
|
||||||
return { mergedFile: null, recordCount: 0 }
|
return { mergedFile: null, recordCount: 0, orderRecordCounts }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate output filename with timestamp
|
// Generate output filename with timestamp
|
||||||
@@ -178,13 +189,18 @@ export class ExtractorService {
|
|||||||
log.info('Saving merged file', { outputPath })
|
log.info('Saving merged file', { outputPath })
|
||||||
await this.saveMergedOrders(allOrders, outputPath)
|
await this.saveMergedOrders(allOrders, outputPath)
|
||||||
log.info('Merged file saved successfully', { recordCount })
|
log.info('Merged file saved successfully', { recordCount })
|
||||||
return { mergedFile: outputPath, recordCount }
|
return { mergedFile: outputPath, recordCount, orderRecordCounts }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
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, error: `保存合并文件失败:${errorMsg}` }
|
return {
|
||||||
|
mergedFile: null,
|
||||||
|
recordCount,
|
||||||
|
orderRecordCounts,
|
||||||
|
error: `保存合并文件失败:${errorMsg}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
? sanitizeError(serializeError(info.error))
|
info.error = IS_PROD
|
||||||
: serializeError(info.error)
|
? 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)) {
|
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
|
||||||
logger.add(
|
* Removes existing DailyRotateFile transports and recreates them with config values
|
||||||
new DailyRotateFile({
|
*
|
||||||
filename: path.join(getLogDir(), 'error-%DATE%.log'),
|
* @param config - Logging configuration from config.yaml
|
||||||
datePattern: 'YYYY-MM-DD',
|
*/
|
||||||
zippedArchive: true,
|
export function applyLoggingConfig(config: { level: string; appRetention: number }): void {
|
||||||
maxSize: '20m',
|
// Update log level
|
||||||
maxFiles: '14d',
|
setLogLevel(config.level)
|
||||||
level: 'error',
|
|
||||||
format: fileFormat
|
// 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: retentionStr,
|
||||||
|
level: 'error',
|
||||||
|
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
|
||||||
|
|||||||
60
src/main/services/logger/shared.ts
Normal file
60
src/main/services/logger/shared.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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 []
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/main/tools/debug-env.ts
Normal file
27
src/main/tools/debug-env.ts
Normal 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()
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface ExtractorResult {
|
|||||||
errors: string[]
|
errors: string[]
|
||||||
/** Database import result (only populated if mergedFile was created) */
|
/** Database import result (only populated if mergedFile was created) */
|
||||||
importResult?: ImportResult
|
importResult?: ImportResult
|
||||||
|
/** Per-order material row counts */
|
||||||
|
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrderInfo {
|
export interface OrderInfo {
|
||||||
|
|||||||
@@ -83,4 +83,6 @@ export interface GetBatchesOptions {
|
|||||||
limit?: number
|
limit?: number
|
||||||
/** Number of batches to skip (for pagination) */
|
/** Number of batches to skip (for pagination) */
|
||||||
offset?: number
|
offset?: number
|
||||||
|
/** Optional username filter for Admin users (supports multiple) */
|
||||||
|
usernames?: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
2
src/preload/index.d.ts
vendored
2
src/preload/index.d.ts
vendored
@@ -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 {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Admin users see all users' records, regular users see only their own.
|
* Admin users see all users' records, regular users see only their own.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
import { Modal } from './ui/Modal'
|
import { Modal } from './ui/Modal'
|
||||||
import {
|
import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -14,35 +14,15 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
Clock
|
Clock,
|
||||||
|
Copy
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { UserInfo } from './UserSelectionDialog'
|
import type { UserInfo } from './UserSelectionDialog'
|
||||||
|
import type {
|
||||||
// Local type definitions matching the backend types
|
BatchStats,
|
||||||
interface BatchStats {
|
OperationHistoryRecord
|
||||||
batchId: string
|
} from '../../../main/types/operation-history.types'
|
||||||
userId: number
|
import { showSuccess, showError, showWarning } from '../stores/useAppStore'
|
||||||
username: string
|
|
||||||
operationTime: string
|
|
||||||
status: string
|
|
||||||
totalOrders: number
|
|
||||||
totalRecords: number
|
|
||||||
successCount: number
|
|
||||||
failedCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OperationHistoryRecord {
|
|
||||||
id?: number
|
|
||||||
batchId: string
|
|
||||||
userId: number
|
|
||||||
username: string
|
|
||||||
productionId: string | null
|
|
||||||
orderNumber: string
|
|
||||||
operationTime: Date
|
|
||||||
status: string
|
|
||||||
recordCount: number | null
|
|
||||||
errorMessage: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExtractorOperationHistoryModalProps {
|
interface ExtractorOperationHistoryModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -71,6 +51,24 @@ const statusIcons: Record<string, React.ReactNode> = {
|
|||||||
pending: <Clock size={16} className="text-gray-500" />
|
pending: <Clock size={16} className="text-gray-500" />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDateTime = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
|
||||||
|
// Check if the date is valid
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
return dateStr // Return original if invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use UTC methods to display the time as stored in database (without timezone conversion)
|
||||||
|
const year = date.getUTCFullYear()
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getUTCDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getUTCHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryModalProps> = ({
|
export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -82,21 +80,22 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
|||||||
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set())
|
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set())
|
||||||
const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map())
|
const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map())
|
||||||
const [deleting, setDeleting] = useState<Set<string>>(new Set())
|
const [deleting, setDeleting] = useState<Set<string>>(new Set())
|
||||||
|
const [allUsers, setAllUsers] = useState<string[]>([])
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||||
|
|
||||||
const isAdmin = user?.userType === 'Admin'
|
const isAdmin = user?.userType === 'Admin'
|
||||||
|
|
||||||
// Fetch batches when modal opens
|
const fetchBatches = useCallback(async () => {
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
void fetchBatches()
|
|
||||||
}
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
const fetchBatches = async () => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.operationHistory.getBatches({ limit: 100 })
|
// Admin user can pass usernames filter
|
||||||
|
const options =
|
||||||
|
isAdmin && selectedUsers.length > 0
|
||||||
|
? { limit: 100, usernames: selectedUsers }
|
||||||
|
: { limit: 100 }
|
||||||
|
|
||||||
|
const result = await window.electron.operationHistory.getBatches(options)
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setBatches(result.data)
|
setBatches(result.data)
|
||||||
} else {
|
} else {
|
||||||
@@ -107,23 +106,48 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [isAdmin, selectedUsers])
|
||||||
|
|
||||||
const fetchBatchDetails = async (batchId: string) => {
|
|
||||||
// If already loaded, don't fetch again
|
|
||||||
if (batchDetails.has(batchId)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const fetchAllUsers = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electron.operationHistory.getBatchDetails(batchId)
|
const result = await window.electron.auth.getAllUsers()
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setBatchDetails((prev) => new Map(prev).set(batchId, result.data!))
|
const usernames = result.data.map((u: UserInfo) => u.username)
|
||||||
|
setAllUsers(usernames)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch batch details:', err)
|
console.error('Failed to fetch users:', err)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
|
const fetchBatchDetails = useCallback(
|
||||||
|
async (batchId: string) => {
|
||||||
|
// If already loaded, don't fetch again
|
||||||
|
if (batchDetails.has(batchId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.electron.operationHistory.getBatchDetails(batchId)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setBatchDetails((prev) => new Map(prev).set(batchId, result.data!))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch batch details:', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[batchDetails]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fetch batches when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void fetchBatches()
|
||||||
|
if (isAdmin) {
|
||||||
|
void fetchAllUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, fetchBatches, fetchAllUsers, isAdmin])
|
||||||
|
|
||||||
const toggleBatchExpansion = (batchId: string) => {
|
const toggleBatchExpansion = (batchId: string) => {
|
||||||
setExpandedBatches((prev) => {
|
setExpandedBatches((prev) => {
|
||||||
@@ -175,15 +199,34 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDateTime = (dateStr: string) => {
|
const handleCopyColumn = async (field: 'productionId' | 'orderNumber', batchId: string) => {
|
||||||
const date = new Date(dateStr)
|
const details = batchDetails.get(batchId) || []
|
||||||
return date.toLocaleString('zh-CN', {
|
const values = details
|
||||||
year: 'numeric',
|
.map((d) => (field === 'productionId' ? d.productionId : d.orderNumber))
|
||||||
month: '2-digit',
|
.filter(Boolean) // 移除空值
|
||||||
day: '2-digit',
|
.join('\n') // 使用换行符分隔
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
if (!values) {
|
||||||
})
|
showWarning('没有可复制的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(values)
|
||||||
|
showSuccess(`已复制 ${values.split('\n').length} 条数据`)
|
||||||
|
} catch {
|
||||||
|
showError('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleUserFilter = (username: string) => {
|
||||||
|
setSelectedUsers((prev) =>
|
||||||
|
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearUserFilters = () => {
|
||||||
|
setSelectedUsers([])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
@@ -192,21 +235,59 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
|||||||
<Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl">
|
<Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl">
|
||||||
<div className="flex flex-col h-[70vh]">
|
<div className="flex flex-col h-[70vh]">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
|
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex-1">
|
||||||
<span className="text-sm text-gray-600">
|
{isAdmin && allUsers.length > 0 && (
|
||||||
{isAdmin ? (
|
<div className="mb-3">
|
||||||
<span className="text-amber-600 font-medium">管理员模式:显示所有用户记录</span>
|
<div className="text-xs text-gray-500 mb-2">筛选用户:</div>
|
||||||
) : (
|
<div className="flex flex-wrap gap-2">
|
||||||
<span>仅显示您的操作记录</span>
|
{allUsers.map((username) => {
|
||||||
)}
|
const isSelected = selectedUsers.includes(username)
|
||||||
</span>
|
return (
|
||||||
{batches.length > 0 && (
|
<button
|
||||||
<span className="text-sm text-gray-500">共 {batches.length} 条批次</span>
|
key={username}
|
||||||
|
onClick={() => toggleUserFilter(username)}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-blue-600 text-white shadow-sm'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{selectedUsers.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearUserFilters}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 transition-all"
|
||||||
|
>
|
||||||
|
清空筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
{isAdmin ? (
|
||||||
|
<span className="text-amber-600 font-medium">
|
||||||
|
管理员模式:
|
||||||
|
{selectedUsers.length > 0
|
||||||
|
? `已选择 ${selectedUsers.length} 个用户`
|
||||||
|
: '显示所有用户记录'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>仅显示您的操作记录</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{batches.length > 0 && (
|
||||||
|
<span className="text-sm text-gray-500">共 {batches.length} 条批次</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
onClick={() => void fetchBatches()}
|
onClick={() => void fetchBatches()}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
title="刷新"
|
title="刷新"
|
||||||
@@ -299,17 +380,19 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{isAdmin && (
|
||||||
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
|
<button
|
||||||
onClick={(e) => {
|
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
|
||||||
e.stopPropagation()
|
onClick={(e) => {
|
||||||
void handleDeleteBatch(batch.batchId)
|
e.stopPropagation()
|
||||||
}}
|
void handleDeleteBatch(batch.batchId)
|
||||||
disabled={isDeleting}
|
}}
|
||||||
title="删除批次"
|
disabled={isDeleting}
|
||||||
>
|
title="删除批次"
|
||||||
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
|
>
|
||||||
</button>
|
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Batch details */}
|
{/* Batch details */}
|
||||||
@@ -320,10 +403,38 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||||
总排号
|
<div className="flex items-center gap-2">
|
||||||
|
总排号
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
void handleCopyColumn('productionId', batch.batchId)
|
||||||
|
}
|
||||||
|
title="复制所有总排号"
|
||||||
|
>
|
||||||
|
<Copy
|
||||||
|
size={14}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||||
订单号
|
<div className="flex items-center gap-2">
|
||||||
|
订单号
|
||||||
|
<button
|
||||||
|
className="p-1 hover:bg-gray-200 rounded transition-colors"
|
||||||
|
onClick={() =>
|
||||||
|
void handleCopyColumn('orderNumber', batch.batchId)
|
||||||
|
}
|
||||||
|
title="复制所有订单号"
|
||||||
|
>
|
||||||
|
<Copy
|
||||||
|
size={14}
|
||||||
|
className="text-gray-500 hover:text-gray-700"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||||
状态
|
状态
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ export function AuthenticatedAppShell({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentPage === 'extractor' && <ExtractorPage />}
|
{currentPage === 'extractor' && <ExtractorPage currentUser={currentUser} />}
|
||||||
{currentPage === 'cleaner' && <CleanerPage />}
|
{currentPage === 'cleaner' && <CleanerPage />}
|
||||||
{currentPage === 'settings' && <SettingsPage />}
|
{currentPage === 'settings' && <SettingsPage />}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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()])
|
||||||
|
|||||||
@@ -7,12 +7,28 @@ import { useSharedProductionIds } from '../hooks/useSharedProductionIds'
|
|||||||
import LogPanel from '../components/ui/LogPanel'
|
import LogPanel from '../components/ui/LogPanel'
|
||||||
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
|
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
|
||||||
import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
|
import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
|
||||||
import { useUserStore } from '../stores/useUserStore'
|
import type { CurrentUser } from '../hooks/useAppBootstrap'
|
||||||
|
|
||||||
const ExtractorPage: React.FC = () => {
|
interface ExtractorPageProps {
|
||||||
|
currentUser: CurrentUser | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExtractorPage: React.FC<ExtractorPageProps> = ({ currentUser }) => {
|
||||||
const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers')
|
const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers')
|
||||||
const [showHistoryModal, setShowHistoryModal] = React.useState(false)
|
const [showHistoryModal, setShowHistoryModal] = React.useState(false)
|
||||||
const user = useUserStore((state) => state.user)
|
|
||||||
|
// Convert currentUser to UserInfo format for the modal
|
||||||
|
const user = React.useMemo(
|
||||||
|
() =>
|
||||||
|
currentUser
|
||||||
|
? {
|
||||||
|
id: 0, // ID is not needed for modal display logic
|
||||||
|
username: currentUser.username,
|
||||||
|
userType: currentUser.userType
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[currentUser]
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isRunning,
|
isRunning,
|
||||||
@@ -90,13 +106,13 @@ const ExtractorPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showHistoryModal && (
|
{showHistoryModal ? (
|
||||||
<ExtractorOperationHistoryModal
|
<ExtractorOperationHistoryModal
|
||||||
isOpen={showHistoryModal}
|
isOpen={showHistoryModal}
|
||||||
onClose={() => setShowHistoryModal(false)}
|
onClose={() => setShowHistoryModal(false)}
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{!isRunning && isComplete && (
|
{!isRunning && isComplete && (
|
||||||
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md">
|
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md">
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user