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 { registerIpcHandlers } from './ipc'
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 { dirname } from 'path'
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
// 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 yaml from 'js-yaml'
import { z } from 'zod'
import { createLogger } from '../logger'
import { createLogger, setLogLevel } from '../logger'
import {
fullConfigSchema,
type FullConfig,
type DatabaseType,
type MySqlConfig,
type SqlServerConfig
type SqlServerConfig,
type LoggingConfig
} from '../../types/config.schema'
const log = createLogger('ConfigManager')
@@ -84,6 +85,11 @@ const DEFAULT_CONFIG: FullConfig = {
tableName: '',
productionIdField: '',
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')
await this.saveConfig(DEFAULT_CONFIG)
this.config = DEFAULT_CONFIG
// Apply logging configuration from default config
setLogLevel(DEFAULT_CONFIG.logging.level)
return
}
@@ -152,6 +160,9 @@ export class ConfigManager {
const validated = fullConfigSchema.parse(parsed)
this.config = validated
// Apply logging configuration
setLogLevel(validated.logging.level)
log.info('Configuration loaded and validated successfully')
} catch (error) {
if (error instanceof z.ZodError) {
@@ -230,6 +241,16 @@ export class ConfigManager {
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({
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' },
transports: [
// 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
if (app.isPackaged) {
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')
})
/**
* 日志配置 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
*/
@@ -122,7 +131,8 @@ export const fullConfigSchema = z.object({
paths: pathsConfigSchema,
extraction: extractionConfigSchema,
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 SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
/**
* 验证并解析配置

View File

@@ -11,7 +11,7 @@ import type {
MaterialTypeBatchRequest
} from '../main/types/validation.types'
import type { IpcResult } from '../main/ipc'
import { IPC_CHANNELS } from '../shared/ipc-channels'
import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
type ErpSettingsPayload = {
erp?: {
@@ -104,8 +104,7 @@ const api = {
},
auth: {
getComputerName: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME),
getComputerName: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_GET_COMPUTER_NAME),
silentLogin: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.AUTH_SILENT_LOGIN),
login: (request: LoginRequest): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.AUTH_LOGIN, request),
@@ -121,8 +120,7 @@ const api = {
connectMySql: (config: MySqlConfig): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_CONNECT, config),
disconnectMySql: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_DISCONNECT),
isMySqlConnected: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_IS_CONNECTED),
isMySqlConnected: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_IS_CONNECTED),
queryMySql: (sql: string, params?: any[]): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.DATABASE_MYSQL_QUERY, sql, params),
connectSqlServer: (config: SqlServerConfig): Promise<IpcResult> =>
@@ -142,8 +140,7 @@ const api = {
invokeIpc(IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS, productionIds),
getSharedProductionIds: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS),
getCleanerData: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA)
getCleanerData: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA)
},
materials: {
@@ -166,8 +163,7 @@ const api = {
saveSettings: (settings: ErpSettingsPayload): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.SETTINGS_SAVE_SETTINGS, settings),
resetDefaults: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.SETTINGS_RESET_DEFAULTS),
testDbConnection: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION)
testDbConnection: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.SETTINGS_TEST_DB_CONNECTION)
},
materialType: {
@@ -189,9 +185,23 @@ const api = {
getCurrent: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_GET_CURRENT),
update: (config: { url: string; username: string; password: string }): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_UPDATE, config),
testConnection: (config: { url: string; username: string; password: string }): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.USER_ERP_CONFIG_TEST_CONNECTION, config),
testConnection: (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)
},
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
@@ -208,4 +218,3 @@ if (process.contextIsolated) {
// @ts-ignore (define in dts)
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_UPDATE: 'user-erp-config:update',
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
/**
* Log level for logger service
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'