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

@@ -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(