refactor(logger): optimize logging architecture with 6 improvements

1. Extract shared module: consolidate getLogDir() and isProduction()
   into shared.ts, eliminate duplication across logger modules
2. Make retention config effective: delay file transport creation
   until config is loaded, apply appRetention/auditRetention from config.yaml
3. Add before-quit log flush: close logger and audit logger on
   app exit to prevent log loss
4. Unify logError entry point: remove duplicate logError from index.ts,
   re-export from error-utils.ts with richer error context
5. Renderer log level filtering: add client-side level check in preload
   to skip IPC for filtered-out messages
6. Child logger cache + audit cleanup: cache child loggers in IPC
   handler for performance, remove redundant timestamp format in audit logger

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-04-03 20:53:53 +08:00
parent 020bbcdccc
commit 883f98065a
22 changed files with 300 additions and 176 deletions

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
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', async (err) => {
@@ -37,4 +37,10 @@ export function setupProcessGuards(): void {
logger.error('Child process gone', { details }) logger.error('Child process gone', { details })
console.error('Child process gone:', details) console.error('Child process gone:', details)
}) })
// Flush and close loggers before quit to prevent log loss
app.on('before-quit', () => {
logger.close()
closeAuditLogger()
})
} }

View File

@@ -259,7 +259,13 @@ export function registerExtractorHandlers(): void {
// Write per-order record counts // Write per-order record counts
for (const { orderNumber, recordCount } of result.orderRecordCounts) { for (const { orderNumber, recordCount } of result.orderRecordCounts) {
await historyDao.updateRecordStatus(batchId, orderNumber, status, undefined, recordCount) await historyDao.updateRecordStatus(
batchId,
orderNumber,
status,
undefined,
recordCount
)
} }
// Update batch status without recordCount (per-order counts are set individually) // Update batch status without recordCount (per-order counts are set individually)

View File

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

View File

@@ -10,7 +10,9 @@
*/ */
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import winston from 'winston'
import { createLogger } from '../services/logger' import { createLogger } from '../services/logger'
import logger from '../services/logger'
import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels' import { IPC_CHANNELS, type LogLevel } from '../../shared/ipc-channels'
const log = createLogger('LoggerHandler') const log = createLogger('LoggerHandler')
@@ -41,6 +43,7 @@ class LoggerHandlerState {
private buffer: LogEntry[] = [] private buffer: LogEntry[] = []
private debounceTimer: NodeJS.Timeout | null = null private debounceTimer: NodeJS.Timeout | null = null
private discardedCount = 0 private discardedCount = 0
private childLoggerCache = new Map<string, winston.Logger>()
/** /**
* Add log entry to buffer * Add log entry to buffer
@@ -131,16 +134,27 @@ 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}`
@@ -187,6 +201,7 @@ class LoggerHandlerState {
} }
this.buffer = [] this.buffer = []
this.discardedCount = 0 this.discardedCount = 0
this.childLoggerCache.clear()
} }
} }
@@ -197,6 +212,11 @@ const state = new LoggerHandlerState()
* Register IPC handlers for logger * Register IPC handlers for logger
*/ */
export function registerLoggerHandlers(): void { export function registerLoggerHandlers(): void {
// Return current log level to preload for client-side filtering
ipcMain.handle(IPC_CHANNELS.LOGGER_GET_LEVEL, () => {
return logger.level as LogLevel
})
// Use ipcMain.on with send() - fire-and-forget, non-blocking // Use ipcMain.on with send() - fire-and-forget, non-blocking
ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => { ipcMain.on(IPC_CHANNELS.LOGGER_FORWARD, (_event, entry: LogEntry) => {
// Validate entry // Validate entry

View File

@@ -27,16 +27,13 @@ export function registerSettingsHandlers(): void {
const erpConfigService = UserErpConfigService.getInstance() const erpConfigService = UserErpConfigService.getInstance()
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => { ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
return withErrorHandling( return withErrorHandling(async () => {
async () => {
const userType = sessionManager.getUserType() const userType = sessionManager.getUserType()
if (!userType) { if (!userType) {
throw new ValidationError('未找到用户类型', 'VAL_INVALID_INPUT') throw new ValidationError('未找到用户类型', 'VAL_INVALID_INPUT')
} }
return userType as UserType return userType as UserType
}, }, 'settings:getUserType')
'settings:getUserType'
)
}) })
ipcMain.handle( ipcMain.handle(

View File

@@ -20,7 +20,8 @@ import { dirname } from 'path'
import { app } from 'electron' import { app } from 'electron'
import yaml from 'js-yaml' import yaml from 'js-yaml'
import { z } from 'zod' import { z } from 'zod'
import { createLogger, setLogLevel } from '../logger' import { createLogger, applyLoggingConfig } from '../logger'
import { applyAuditConfig } from '../logger/audit-logger'
import { import {
fullConfigSchema, fullConfigSchema,
type FullConfig, type FullConfig,
@@ -170,7 +171,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 +192,8 @@ export class ConfigManager {
this.config = validated this.config = validated
// Apply logging configuration // Apply logging configuration
setLogLevel(validated.logging.level) applyLoggingConfig(validated.logging)
applyAuditConfig(validated.logging.auditRetention)
log.info('Configuration loaded and validated successfully') log.info('Configuration loaded and validated successfully')
} catch (error) { } catch (error) {

View File

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

View File

@@ -6,8 +6,7 @@
import winston from 'winston' import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file' import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path' import path from 'path'
import { app } from 'electron' import { getLogDir } from './shared'
import fs from 'fs'
/** /**
* Audit log entry structure * Audit log entry structure
@@ -32,22 +31,6 @@ export interface AuditEntry {
metadata: Record<string, unknown> metadata: Record<string, unknown>
} }
/**
* Get the log directory for audit logs
* Uses app.getPath('logs') in production, local logs dir in development
*/
function getLogDir(): string {
if (app && app.isReady()) {
return app.getPath('logs')
}
// Fallback for development or before app is ready
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
}
/** /**
* JSONL formatter - outputs one JSON object per line * JSONL formatter - outputs one JSON object per line
* This is the key difference from the standard JSON formatter * This is the key difference from the standard JSON formatter
@@ -59,26 +42,40 @@ 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: false,
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 {
// 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

View File

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

View File

@@ -11,25 +11,8 @@
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 fs from 'fs'
import { serializeError, sanitizeError } from './error-utils' import { serializeError, sanitizeError } from './error-utils'
import { getLogDir, isProduction } from './shared'
// Get log directory - use app.getPath('logs') in production, or local logs dir in development
function getLogDir(): string {
if (app && app.isReady()) {
return app.getPath('logs')
}
// Fallback for development or before app is ready
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
}
// 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(
@@ -41,7 +24,9 @@ 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) const serialized = isProduction()
? sanitizeError(serializeError(error))
: serializeError(error)
if (serialized.stack) { if (serialized.stack) {
errorStr = `\n${serialized.stack}` errorStr = `\n${serialized.stack}`
} else { } else {
@@ -60,7 +45,7 @@ const fileFormat = winston.format.combine(
winston.format((info) => { winston.format((info) => {
// Serialize errors in metadata // Serialize errors in metadata
if (info.error) { if (info.error) {
info.error = isProduction info.error = isProduction()
? sanitizeError(serializeError(info.error)) ? sanitizeError(serializeError(info.error))
: serializeError(info.error) : serializeError(info.error)
} }
@@ -68,7 +53,7 @@ const fileFormat = winston.format.combine(
// Serialize any error in meta fields // Serialize any error in meta fields
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] = isProduction()
? sanitizeError(serializeError(info[key])) ? sanitizeError(serializeError(info[key]))
: serializeError(info[key]) : serializeError(info[key])
} }
@@ -80,19 +65,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,9 +86,7 @@ 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()
] ]
}) })
@@ -114,19 +98,40 @@ export function setLogLevel(level: string): void {
logger.level = level logger.level = level
} }
// Add error-specific file transport in production /**
if (app?.isPackaged) { * Apply logging configuration from config file
* Removes existing DailyRotateFile transports and recreates them with config values
*
* @param config - Logging configuration from config.yaml
*/
export function applyLoggingConfig(config: { level: string; appRetention: number }): void {
// Update log level
setLogLevel(config.level)
// Remove existing DailyRotateFile transports
const existingFileTransports = logger.transports.filter((t) => t instanceof DailyRotateFile)
for (const transport of existingFileTransports) {
logger.remove(transport)
}
// Add app log transport with configured retention
const retentionStr = `${config.appRetention}d`
logger.add(createFileTransport(undefined, retentionStr))
// Add error-specific file transport in production
if (isProduction()) {
logger.add( logger.add(
new DailyRotateFile({ new DailyRotateFile({
filename: path.join(getLogDir(), 'error-%DATE%.log'), filename: path.join(getLogDir(), 'error-%DATE%.log'),
datePattern: 'YYYY-MM-DD', datePattern: 'YYYY-MM-DD',
zippedArchive: true, zippedArchive: true,
maxSize: '20m', maxSize: '20m',
maxFiles: '14d', maxFiles: retentionStr,
level: 'error', level: 'error',
format: fileFormat format: fileFormat
}) })
) )
}
} }
/** /**
@@ -138,23 +143,8 @@ export function createLogger(context: string): winston.Logger {
return logger.child({ context }) return logger.child({ context })
} }
/** // Re-export error utilities for convenience
* Log an error with full context and stack trace export { logError, formatErrorForLogging, serializeError, extractErrorContext } from './error-utils'
* This is the recommended way to log errors in the application
*
* @param log - Logger instance
* @param message - Error message
* @param error - The error object (Error, BaseError, or any)
* @param meta - Additional metadata to include
*/
export function logError(
log: winston.Logger,
message: string,
error: unknown,
meta?: Record<string, unknown>
): void {
log.error(message, { error, ...meta })
}
// Export the main logger for direct use // Export the main logger for direct use
export default logger export default logger

View File

@@ -0,0 +1,53 @@
/**
* 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
*/
export 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
}
/**
* Check if running in production environment
* Uses app.isPackaged as the single source of truth
*/
export function isProduction(): boolean {
return app?.isPackaged ?? false
}
/**
* Log level priority mapping (higher number = more severe)
*/
export const LOG_LEVEL_PRIORITY: Record<string, number> = {
verbose: 0,
debug: 1,
info: 2,
warn: 3,
error: 4
}
/**
* Check if a log level should be logged given a threshold
* @param level - The log level of the message
* @param threshold - The minimum log level threshold
* @returns true if the message should be logged
*/
export function isLoggable(level: string, threshold: string): boolean {
return (LOG_LEVEL_PRIORITY[level] ?? 0) >= (LOG_LEVEL_PRIORITY[threshold] ?? 2)
}

View File

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

View File

@@ -2,13 +2,42 @@ 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)
}
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
* Should be called early in renderer initialization
*/
fetchLevel: async (): Promise<void> => {
cachedLevel = (await ipcRenderer.invoke(IPC_CHANNELS.LOGGER_GET_LEVEL)) as LogLevel
} }
} as const } as const

View File

@@ -129,6 +129,7 @@ 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>
} }
export interface UpdateAPI { export interface UpdateAPI {

View File

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

View File

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

View File

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

View File

@@ -68,6 +68,10 @@ export function useAppBootstrap() {
const initializeAuth = useCallback(async () => { const initializeAuth = useCallback(async () => {
logger.info('=== Starting initializeAuth ===') logger.info('=== Starting initializeAuth ===')
// Fetch log level early so client-side filtering takes effect
await window.electron.logger.fetchLevel()
try { try {
logger.debug('Getting computer name...') logger.debug('Getting computer name...')
const computerNameResult = await window.electron.auth.getComputerName() const computerNameResult = await window.electron.auth.getComputerName()

View File

@@ -92,6 +92,7 @@ export const IPC_CHANNELS = {
// Logger // Logger
LOGGER_FORWARD: 'logger:forward', LOGGER_FORWARD: 'logger:forward',
LOGGER_GET_LEVEL: 'logger:getLevel',
// Report // Report
REPORT_LIST_ALL: 'report:listAll', REPORT_LIST_ALL: 'report:listAll',
@@ -122,4 +123,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'