feat(logging): Wave 1 - logging infrastructure complete

- Add logging config to config.yaml with level, auditRetention, appRetention
- Add 4 global exception handlers (uncaughtException, unhandledRejection, render-process-gone, child-process-gone)
- Create audit-logger.ts with JSONL format and 30-day rotation
- Define IPC logger channels (LOGGER_FORWARD) and preload API
- Define audit types (AuditAction enum, AuditEntry interface, AuditStatus enum)
- Add unit tests for audit logger

All typechecks passing. Wave 1 complete.
This commit is contained in:
test
2026-03-08 13:49:31 +08:00
parent f45d3df385
commit 5e6898fb40
9 changed files with 450 additions and 18 deletions

View File

@@ -4,6 +4,8 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset' import icon from '../../resources/icon.png?asset'
import { registerIpcHandlers } from './ipc' import { registerIpcHandlers } from './ipc'
import { ConfigManager } from './services/config/config-manager' import { ConfigManager } from './services/config/config-manager'
import logger from './services/logger/index'
import { logAudit } from './services/logger/audit-logger'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import { dirname } from 'path' import { dirname } from 'path'
import fs from 'fs' import fs from 'fs'
@@ -147,5 +149,41 @@ app.on('window-all-closed', () => {
} }
}) })
// Global exception handlers to prevent crashes without logging
process.on('uncaughtException', async (err) => {
logger.error('Uncaught exception', { error: err })
await logAudit('SYSTEM_CRASH', 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
metadata: { error: err.message, stack: err.stack }
})
console.error('Uncaught exception:', err)
setTimeout(() => process.exit(1), 1000)
})
process.on('unhandledRejection', async (reason, promise) => {
logger.error('Unhandled Rejection', { reason: String(reason) })
await logAudit('SYSTEM_ERROR', 'system', {
username: 'system',
computerName: process.env.COMPUTERNAME || 'unknown',
resource: 'main-process',
status: 'failure',
metadata: { reason: String(reason) }
})
console.error('Unhandled Rejection:', reason)
})
app.on('render-process-gone', (_, webContents, details) => {
logger.error('Render process gone', { details, webContentsId: webContents.id })
console.error('Render process gone:', details)
})
app.on('child-process-gone', (_, details) => {
logger.error('Child process gone', { details })
console.error('Child process gone:', details)
})
// In this file you can include the rest of your app's specific main process // In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here. // code. You can also put them in separate files and require them here.

View File

@@ -20,13 +20,14 @@ 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 } from '../logger' import { createLogger, setLogLevel } from '../logger'
import { import {
fullConfigSchema, fullConfigSchema,
type FullConfig, type FullConfig,
type DatabaseType, type DatabaseType,
type MySqlConfig, type MySqlConfig,
type SqlServerConfig type SqlServerConfig,
type LoggingConfig
} from '../../types/config.schema' } from '../../types/config.schema'
const log = createLogger('ConfigManager') const log = createLogger('ConfigManager')
@@ -84,6 +85,11 @@ const DEFAULT_CONFIG: FullConfig = {
tableName: '', tableName: '',
productionIdField: '', productionIdField: '',
orderNumberField: '' orderNumberField: ''
},
logging: {
level: 'info',
auditRetention: 30,
appRetention: 14
} }
} }
@@ -134,6 +140,8 @@ export class ConfigManager {
log.info('Config file not found, creating default config.yaml') log.info('Config file not found, creating default config.yaml')
await this.saveConfig(DEFAULT_CONFIG) await this.saveConfig(DEFAULT_CONFIG)
this.config = DEFAULT_CONFIG this.config = DEFAULT_CONFIG
// Apply logging configuration from default config
setLogLevel(DEFAULT_CONFIG.logging.level)
return return
} }
@@ -152,6 +160,9 @@ export class ConfigManager {
const validated = fullConfigSchema.parse(parsed) const validated = fullConfigSchema.parse(parsed)
this.config = validated this.config = validated
// Apply logging configuration
setLogLevel(validated.logging.level)
log.info('Configuration loaded and validated successfully') log.info('Configuration loaded and validated successfully')
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
@@ -230,6 +241,16 @@ export class ConfigManager {
return this.config.database.activeType return this.config.database.activeType
} }
/**
* 获取日志配置
*/
public getLoggingConfig(): LoggingConfig {
if (!this.config) {
throw new Error('Configuration not initialized')
}
return this.config.logging
}
/** /**
* 更新部分配置(深合并) * 更新部分配置(深合并)
*/ */

View File

@@ -0,0 +1,126 @@
/**
* Audit Logger Service
* Writes audit logs in JSONL format with 30-day rotation using winston-daily-rotate-file
*/
import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path'
import { app } from 'electron'
import fs from 'fs'
/**
* Audit log entry structure
* All 8 required fields for comprehensive audit tracking
*/
export interface AuditEntry {
/** ISO 8601 timestamp of the audit event */
timestamp: string
/** The action that was performed (e.g., 'LOGIN', 'EXTRACT', 'DELETE') */
action: string
/** User ID who performed the action */
userId: string
/** Username of the user who performed the action */
username: string
/** Computer name from which the action was performed */
computerName: string
/** The resource that was affected (e.g., table name, file path) */
resource: string
/** Status of the action: 'success' | 'failure' | 'partial' */
status: 'success' | 'failure' | 'partial'
/** Additional metadata about the audit event */
metadata: Record<string, unknown>
}
/**
* Get the log directory for audit logs
* Uses app.getPath('logs') in production, local logs dir in development
*/
function getLogDir(): string {
if (app && app.isReady()) {
return app.getPath('logs')
}
// Fallback for development or before app is ready
const devLogDir = path.join(process.cwd(), 'logs')
if (!fs.existsSync(devLogDir)) {
fs.mkdirSync(devLogDir, { recursive: true })
}
return devLogDir
}
/**
* JSONL formatter - outputs one JSON object per line
* This is the key difference from the standard JSON formatter
*/
const jsonlFormat = winston.format.printf(({ message }) => {
// Message should already be a JSON string
return typeof message === 'string' ? message : JSON.stringify(message)
})
/**
* Create the audit logger instance with daily rotation
* Configured for 30-day retention as per requirements
*/
const auditLogger = winston.createLogger({
level: 'info',
silent: false,
transports: [
new DailyRotateFile({
filename: path.join(getLogDir(), 'audit-%DATE%.jsonl'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '30d', // 30-day retention
level: 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DDTHH:mm:ss.SSSZ' }),
jsonlFormat
)
})
]
})
/**
* Log an audit event
*
* @param action - The action that was performed
* @param userId - User ID who performed the action
* @param details - Additional details including username, computerName, resource, status, and optional metadata
* @returns Promise that resolves when the log is written (non-blocking)
*/
export async function logAudit(
action: string,
userId: string,
details: {
username: string
computerName: string
resource: string
status: 'success' | 'failure' | 'partial'
metadata?: Record<string, unknown>
}
): Promise<void> {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action,
userId,
username: details.username,
computerName: details.computerName,
resource: details.resource,
status: details.status,
metadata: details.metadata || {}
}
// Write as JSONL - one JSON object per line
// Using info level with the entry stringified as the message
auditLogger.info(JSON.stringify(entry))
}
/**
* Flush and close the audit logger (call on app shutdown)
*/
export async function closeAuditLogger(): Promise<void> {
// Winston logger.close() is synchronous
auditLogger.close()
}
export default auditLogger

View File

@@ -52,9 +52,9 @@ const createFileTransport = (level?: string): DailyRotateFile => {
}) })
} }
// Create the logger instance // Create the logger instance with default level
const logger = winston.createLogger({ const logger = winston.createLogger({
level: 'info', // Log level is now hardcoded, can be moved to config.yaml if needed level: 'info', // Default level, can be updated via setLogLevel()
defaultMeta: { service: 'erpauto' }, defaultMeta: { service: 'erpauto' },
transports: [ transports: [
// Console transport - always enabled // Console transport - always enabled
@@ -66,6 +66,14 @@ const logger = winston.createLogger({
] ]
}) })
/**
* Update the logger level dynamically
* @param level - The new log level
*/
export function setLogLevel(level: string): void {
logger.level = level
}
// Add error-specific file transport in production // Add error-specific file transport in production
if (app.isPackaged) { if (app.isPackaged) {
logger.add( logger.add(

View File

@@ -0,0 +1,44 @@
/**
* Audit log types and interfaces
*/
/**
* Audit action enumeration
*/
export enum AuditAction {
LOGIN = 'LOGIN',
LOGOUT = 'LOGOUT',
EXTRACT = 'EXTRACT',
CLEAN = 'CLEAN',
SETTINGS_CHANGE = 'SETTINGS_CHANGE'
}
/**
* Audit status enumeration
*/
export enum AuditStatus {
SUCCESS = 'SUCCESS',
FAILURE = 'FAILURE'
}
/**
* Audit entry interface
*/
export interface AuditEntry {
/** Timestamp of the action */
timestamp: Date
/** Action performed */
action: AuditAction
/** User ID who performed the action */
userId: string
/** Username who performed the action */
username: string
/** Computer name where action was performed */
computerName: string
/** Resource affected by the action */
resource?: string
/** Status of the action */
status: AuditStatus
/** Additional metadata in JSON format */
metadata?: string
}

View File

@@ -113,6 +113,15 @@ export const erpSystemConfigSchema = z.object({
url: z.string().url('ERP URL must be a valid URL') url: z.string().url('ERP URL must be a valid URL')
}) })
/**
* 日志配置 Schema
*/
export const loggingConfigSchema = z.object({
level: z.enum(['error', 'warn', 'info', 'debug', 'verbose']).default('info'),
auditRetention: z.number().int().min(1).max(365).default(30),
appRetention: z.number().int().min(1).max(365).default(14)
})
/** /**
* 完整应用配置 Schema * 完整应用配置 Schema
*/ */
@@ -122,7 +131,8 @@ export const fullConfigSchema = z.object({
paths: pathsConfigSchema, paths: pathsConfigSchema,
extraction: extractionConfigSchema, extraction: extractionConfigSchema,
validation: validationConfigSchema, validation: validationConfigSchema,
orderResolution: orderResolutionSchema orderResolution: orderResolutionSchema,
logging: loggingConfigSchema
}) })
/** /**
@@ -133,6 +143,7 @@ export type DatabaseConfig = z.infer<typeof databaseConfigSchema>
export type MySqlConfig = z.infer<typeof mysqlConfigSchema> export type MySqlConfig = z.infer<typeof mysqlConfigSchema>
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema> export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema> export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
/** /**
* 验证并解析配置 * 验证并解析配置

View File

@@ -11,7 +11,7 @@ import type {
MaterialTypeBatchRequest MaterialTypeBatchRequest
} from '../main/types/validation.types' } from '../main/types/validation.types'
import type { IpcResult } from '../main/ipc' import type { IpcResult } from '../main/ipc'
import { IPC_CHANNELS } from '../shared/ipc-channels' import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
type ErpSettingsPayload = { type ErpSettingsPayload = {
erp?: { erp?: {
@@ -104,8 +104,7 @@ const api = {
}, },
auth: { auth: {
getComputerName: (): Promise<IpcResult> => getComputerName: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME),
invokeIpc(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME),
silentLogin: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_SILENT_LOGIN), silentLogin: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_SILENT_LOGIN),
login: (request: LoginRequest): Promise<IpcResult> => login: (request: LoginRequest): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.AUTH_LOGIN, request), invokeIpc(IPC_CHANNELS.AUTH_LOGIN, request),
@@ -121,8 +120,7 @@ const api = {
connectMySql: (config: MySqlConfig): Promise<IpcResult> => connectMySql: (config: MySqlConfig): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_CONNECT, config), invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_CONNECT, config),
disconnectMySql: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_DISCONNECT), disconnectMySql: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_DISCONNECT),
isMySqlConnected: (): Promise<IpcResult> => isMySqlConnected: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_IS_CONNECTED),
invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_IS_CONNECTED),
queryMySql: (sql: string, params?: any[]): Promise<IpcResult> => queryMySql: (sql: string, params?: any[]): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_QUERY, sql, params), invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_QUERY, sql, params),
connectSqlServer: (config: SqlServerConfig): Promise<IpcResult> => connectSqlServer: (config: SqlServerConfig): Promise<IpcResult> =>
@@ -142,8 +140,7 @@ const api = {
invokeIpc(IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS, productionIds), invokeIpc(IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS, productionIds),
getSharedProductionIds: (): Promise<IpcResult> => getSharedProductionIds: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS), invokeIpc(IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS),
getCleanerData: (): Promise<IpcResult> => getCleanerData: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA)
invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA)
}, },
materials: { materials: {
@@ -166,8 +163,7 @@ const api = {
saveSettings: (settings: ErpSettingsPayload): Promise<IpcResult> => saveSettings: (settings: ErpSettingsPayload): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.SETTINGS_SAVE_SETTINGS, settings), invokeIpc(IPC_CHANNELS.SETTINGS_SAVE_SETTINGS, settings),
resetDefaults: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.SETTINGS_RESET_DEFAULTS), resetDefaults: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.SETTINGS_RESET_DEFAULTS),
testDbConnection: (): Promise<IpcResult> => testDbConnection: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION)
invokeIpc(IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION)
}, },
materialType: { materialType: {
@@ -189,9 +185,23 @@ const api = {
getCurrent: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_CURRENT), getCurrent: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_CURRENT),
update: (config: { url: string; username: string; password: string }): Promise<IpcResult> => update: (config: { url: string; username: string; password: string }): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_UPDATE, config), invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_UPDATE, config),
testConnection: (config: { url: string; username: string; password: string }): Promise<IpcResult> => testConnection: (config: {
invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_TEST_CONNECTION, config), url: string
username: string
password: string
}): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_TEST_CONNECTION, config),
getAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL) getAll: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_ALL)
},
logger: {
log: (level: LogLevel, message: string, context?: Record<string, unknown>): void => {
ipcRenderer.send(IPC_CHANNELS.LOGGER_FORWARD, {
level,
message,
context,
timestamp: Date.now()
})
}
} }
} as const } as const
@@ -208,4 +218,3 @@ if (process.contextIsolated) {
// @ts-ignore (define in dts) // @ts-ignore (define in dts)
window.api = api window.api = api
} }

View File

@@ -81,5 +81,13 @@ export const IPC_CHANNELS = {
USER_ERP_CONFIG_GET_CURRENT: 'user-erp-config:getCurrent', USER_ERP_CONFIG_GET_CURRENT: 'user-erp-config:getCurrent',
USER_ERP_CONFIG_UPDATE: 'user-erp-config:update', USER_ERP_CONFIG_UPDATE: 'user-erp-config:update',
USER_ERP_CONFIG_TEST_CONNECTION: 'user-erp-config:testConnection', USER_ERP_CONFIG_TEST_CONNECTION: 'user-erp-config:testConnection',
USER_ERP_CONFIG_GET_ALL: 'user-erp-config:getAll' USER_ERP_CONFIG_GET_ALL: 'user-erp-config:getAll',
// Logger
LOGGER_FORWARD: 'logger:forward'
} as const } as const
/**
* Log level for logger service
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'

View File

@@ -0,0 +1,167 @@
/**
* Audit Logger Unit Tests
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import fs from 'fs'
import path from 'path'
// Mock fs for file operations
vi.mock('fs', () => ({
default: {
existsSync: vi.fn(() => false),
mkdirSync: vi.fn()
},
existsSync: vi.fn(() => false),
mkdirSync: vi.fn()
}))
// Mock electron app
vi.mock('electron', () => ({
app: {
isReady: vi.fn(() => false),
getPath: vi.fn(() => './logs'),
isPackaged: false
}
}))
// Track calls to winston
interface WinstonCall {
level: string
message: string
}
const winstonCalls: WinstonCall[] = []
// Mock winston
vi.mock('winston', () => ({
default: {
createLogger: vi.fn(() => ({
info: vi.fn((message) => {
winstonCalls.push({ level: 'info', message })
}),
close: vi.fn()
})),
format: {
combine: vi.fn((...args) => args),
timestamp: vi.fn(() => ({ type: 'timestamp' })),
printf: vi.fn((fn) => fn)
}
}
}))
// Mock winston-daily-rotate-file
vi.mock('winston-daily-rotate-file', () => ({
default: vi.fn()
}))
describe('Audit Logger', () => {
beforeEach(() => {
vi.clearAllMocks()
winstonCalls.length = 0
})
afterEach(() => {
vi.resetModules()
})
it('should export logAudit function', async () => {
const { logAudit } = await import('../../src/main/services/logger/audit-logger')
expect(logAudit).toBeDefined()
expect(typeof logAudit).toBe('function')
})
it('should log audit entry with all required fields', async () => {
const { logAudit } = await import('../../src/main/services/logger/audit-logger')
await logAudit('LOGIN', 'user123', {
username: 'john.doe',
computerName: 'DESKTOP-001',
resource: 'ERP_SYSTEM',
status: 'success',
metadata: { sessionId: 'abc-123' }
})
expect(winstonCalls.length).toBe(1)
const call = winstonCalls[0]
expect(call.level).toBe('info')
// Parse the JSONL message
const entry = JSON.parse(call.message as string)
expect(entry.action).toBe('LOGIN')
expect(entry.userId).toBe('user123')
expect(entry.username).toBe('john.doe')
expect(entry.computerName).toBe('DESKTOP-001')
expect(entry.resource).toBe('ERP_SYSTEM')
expect(entry.status).toBe('success')
expect(entry.metadata).toEqual({ sessionId: 'abc-123' })
expect(entry.timestamp).toBeDefined()
})
it('should log audit entry without optional metadata', async () => {
const { logAudit } = await import('../../src/main/services/logger/audit-logger')
await logAudit('LOGOUT', 'user456', {
username: 'jane.smith',
computerName: 'DESKTOP-002',
resource: 'ERP_SYSTEM',
status: 'success'
})
expect(winstonCalls.length).toBe(1)
const call = winstonCalls[0]
const entry = JSON.parse(call.message as string)
expect(entry.action).toBe('LOGOUT')
expect(entry.userId).toBe('user456')
expect(entry.username).toBe('jane.smith')
expect(entry.computerName).toBe('DESKTOP-002')
expect(entry.resource).toBe('ERP_SYSTEM')
expect(entry.status).toBe('success')
expect(entry.metadata).toEqual({}) // Empty object when not provided
})
it('should handle different status values', async () => {
const { logAudit } = await import('../../src/main/services/logger/audit-logger')
// Test failure status
await logAudit('EXTRACT', 'user789', {
username: 'test.user',
computerName: 'DESKTOP-003',
resource: 'materials_table',
status: 'failure',
metadata: { error: 'Connection timeout' }
})
const call = winstonCalls[0]
const entry = JSON.parse(call.message as string)
expect(entry.status).toBe('failure')
})
it('should log with partial status', async () => {
const { logAudit } = await import('../../src/main/services/logger/audit-logger')
await logAudit('DELETE', 'user999', {
username: 'admin',
computerName: 'DESKTOP-004',
resource: 'temp_files',
status: 'partial',
metadata: { deleted: 5, failed: 2 }
})
const call = winstonCalls[0]
const entry = JSON.parse(call.message as string)
expect(entry.status).toBe('partial')
})
it('should export closeAuditLogger function', async () => {
const { closeAuditLogger } = await import('../../src/main/services/logger/audit-logger')
expect(closeAuditLogger).toBeDefined()
expect(typeof closeAuditLogger).toBe('function')
})
it('should close audit logger without errors', async () => {
const { closeAuditLogger } = await import('../../src/main/services/logger/audit-logger')
await expect(closeAuditLogger()).resolves.toBeUndefined()
})
})