/** * Configuration Manager (YAML Version) * * Manages application configuration using YAML format * Provides type-safe access with Zod validation * * Note: ERP configuration is stored in database (dbo_BIPUsers table) * and managed per-user, not in this config file. * * Configuration File Location: * - Development: Project root directory (config.yaml) * - Production (Installed & Portable): User data directory (AppData) * This ensures config persists across app updates and is not exposed */ import * as fs from 'fs' import * as path from 'path' import { fileURLToPath } from 'url' import { dirname } from 'path' import { app } from 'electron' import yaml from 'js-yaml' import { z } from 'zod' import { createLogger, applyLoggingConfig } from '../logger' import { applyAuditConfig } from '../logger/audit-logger' import { fullConfigSchema, type FullConfig, type DatabaseType, type MySqlConfig, type SqlServerConfig, type PostgreSqlConfig, type LoggingConfig } from '../../types/config.schema' const log = createLogger('ConfigManager') type DeepPartialRecord = Record function formatZodIssue(issue: { path: PropertyKey[]; message: string }): string { return `${issue.path.map((segment) => String(segment)).join('.')}: ${issue.message}` } const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) /** * 默认配置 */ const DEFAULT_CONFIG: FullConfig = { erp: { url: 'https://68.11.34.30:8082' }, database: { activeType: 'mysql', mysql: { host: 'localhost', port: 3306, database: 'erp_db', username: 'root', password: '', charset: 'utf8mb4' }, sqlserver: { server: 'localhost', port: 1433, database: 'erp_db', username: 'sa', password: '', driver: 'ODBC Driver 18 for SQL Server', trustServerCertificate: true }, postgresql: { host: 'localhost', port: 5432, database: 'erp_db', username: 'postgres', password: '', maxPoolSize: 10 } }, paths: { dataDir: './data/', defaultOutput: 'output.xlsx', validationOutput: 'validation-result.xlsx' }, extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true, headless: true }, validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' }, cleaner: { queryBatchSize: 100, processConcurrency: 1 }, orderResolution: { tableName: '', productionIdField: '', orderNumberField: '' }, logging: { level: 'info', auditRetention: 30, appRetention: 14 }, seq: { enabled: false, serverUrl: '', apiKey: '', batchPostingLimit: 50, period: 2000, queueLimit: 10000, maxRetries: 3 }, rustfs: { enabled: false, endpoint: '', accessKey: '', secretKey: '', bucket: 'erpauto', region: 'us-east-1' }, update: { enabled: false, allowDevMode: false, endpoint: '', accessKey: '', secretKey: '', bucket: '', region: 'us-east-1', basePrefix: 'updates/win-portable', checkIntervalMinutes: 30, maxAdminHistoryPerChannel: 10 } } export class ConfigManager { private static instance: ConfigManager | null = null private configPath!: string private backupPath!: string private config: FullConfig | null = null private initialized: boolean = false private constructor() { if (this.initialized) return // 检测是否为开发环境 const isDev = process.env.NODE_ENV === 'development' || !(app?.isPackaged ?? false) if (isDev) { // 开发环境:配置文件放在项目根目录,方便编辑和调试 this.configPath = path.resolve(__dirname, '../../config.yaml') this.backupPath = path.resolve(__dirname, '../../config.yaml.backup') log.info('Running in development mode', { configPath: this.configPath, isDev: true, environment: process.env.NODE_ENV || 'not-set' }) } else { // 生产环境(包括安装版和便携版):配置文件放在用户数据目录 // Windows: C:\Users\\AppData\Roaming\erpauto\config.yaml // 这样配置会在应用升级时保留,且不会暴露在应用目录中 this.configPath = path.join(app.getPath('userData'), 'config.yaml') this.backupPath = path.join(app.getPath('userData'), 'config.yaml.backup') log.info('Running in production mode', { configPath: this.configPath, isDev: false, userDataPath: app.getPath('userData') }) } this.initialized = true } public static getInstance(): ConfigManager { if (ConfigManager.instance === null) { ConfigManager.instance = new ConfigManager() } return ConfigManager.instance } /** * 初始化配置 * - 如果 config.yaml 不存在,创建默认配置 * - 加载并验证配置 */ public async initialize(): Promise { if (!fs.existsSync(this.configPath)) { log.info('Config file not found, creating default config.yaml', { configPath: this.configPath }) await this.saveConfig(DEFAULT_CONFIG) this.config = DEFAULT_CONFIG // Apply logging configuration from default config applyLoggingConfig(DEFAULT_CONFIG.logging) applyAuditConfig(DEFAULT_CONFIG.logging.auditRetention) log.info('Default configuration created and applied', { configPath: this.configPath, logLevel: DEFAULT_CONFIG.logging.level }) return } await this.loadConfig() } /** * 加载并验证 YAML 配置 */ private async loadConfig(): Promise { try { const content = fs.readFileSync(this.configPath, 'utf-8') const parsed = yaml.load(content) as Record // Zod 验证 const validated = fullConfigSchema.parse(parsed) this.config = validated // Apply logging configuration applyLoggingConfig(validated.logging, validated.seq) applyAuditConfig(validated.logging.auditRetention) log.info('Configuration loaded and validated successfully', { configPath: this.configPath, logLevel: validated.logging.level, auditRetention: validated.logging.auditRetention, appRetention: validated.logging.appRetention, isDev: process.env.NODE_ENV === 'development' || !(app?.isPackaged ?? false) }) } catch (error) { if (error instanceof z.ZodError) { const messages = error.issues.map(formatZodIssue) log.error('Configuration validation failed', { configPath: this.configPath, errors: messages }) throw new Error(`配置文件验证失败:\n${messages.join('\n')}`) } log.error('Failed to load configuration', { configPath: this.configPath, error }) throw error } } /** * 保存配置到 YAML 文件 */ private async saveConfig(config: FullConfig): Promise { try { // 备份现有配置 if (fs.existsSync(this.configPath)) { fs.copyFileSync(this.configPath, this.backupPath) log.debug('Config backup created', { backupPath: this.backupPath }) } // 转换为 YAML const content = yaml.dump(config, { indent: 2, lineWidth: -1, // 不自动换行 noRefs: true, // 不使用引用 quotingType: '"', forceQuotes: false }) fs.writeFileSync(this.configPath, content, 'utf-8') this.config = config log.info('Configuration saved successfully', { configPath: this.configPath, logLevel: config.logging.level, auditRetention: config.logging.auditRetention }) return true } catch (error) { log.error('Failed to save configuration', { configPath: this.configPath, error }) // 恢复备份 if (fs.existsSync(this.backupPath)) { fs.copyFileSync(this.backupPath, this.configPath) log.warn('Configuration restored from backup', { backupPath: this.backupPath }) } return false } } /** * 获取完整配置 */ public getConfig(): FullConfig { if (!this.config) { throw new Error('Configuration not initialized. Call initialize() first.') } return this.config } /** * 获取当前激活的数据库配置 */ public getActiveDatabaseConfig(): MySqlConfig | SqlServerConfig | PostgreSqlConfig { if (!this.config) { throw new Error('Configuration not initialized') } const { activeType, mysql, sqlserver, postgresql } = this.config.database switch (activeType) { case 'postgresql': return postgresql case 'sqlserver': return sqlserver default: return mysql } } /** * 获取数据库类型 */ public getDatabaseType(): DatabaseType { if (!this.config) { throw new Error('Configuration not initialized') } return this.config.database.activeType } /** * 获取日志配置 */ public getLoggingConfig(): LoggingConfig { if (!this.config) { throw new Error('Configuration not initialized') } return this.config.logging } /** * 更新部分配置(深合并) */ public async updateConfig( updates: Partial ): Promise<{ success: boolean; error?: string }> { try { if (!this.config) { await this.loadConfig() } log.info('Updating configuration', { configPath: this.configPath, updateKeys: Object.keys(updates) }) // 深合并 const merged = this.deepMerge(this.config!, updates) // 验证合并后的配置 const validated = fullConfigSchema.parse(merged) const success = await this.saveConfig(validated) if (!success) { return { success: false, error: '保存配置失败' } } log.info('Configuration update completed', { configPath: this.configPath, updatedKeys: Object.keys(updates) }) return { success: true } } catch (error) { if (error instanceof z.ZodError) { const messages = error.issues.map(formatZodIssue) log.error('Configuration update validation failed', { configPath: this.configPath, errors: messages }) return { success: false, error: `配置验证失败:\n${messages.join('\n')}` } } log.error('Failed to update configuration', { configPath: this.configPath, error }) return { success: false, error: error instanceof Error ? error.message : '未知错误' } } } /** * 深合并工具函数 */ private deepMerge(source: T, target: Partial): T { const result: T = { ...source } for (const key in target) { const sourceValue = result[key] const targetValue = target[key] if (targetValue !== undefined) { if ( typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue) && typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue) ) { result[key] = this.deepMerge( sourceValue as DeepPartialRecord, targetValue as Partial ) as T[Extract] } else { result[key] = targetValue as T[Extract] } } } return result } /** * 重置为默认配置 */ public async resetToDefaults(): Promise { return this.saveConfig(DEFAULT_CONFIG) } /** * 获取默认配置 */ public getDefaultConfig(): FullConfig { return DEFAULT_CONFIG } /** * 导出配置为 YAML 字符串(用于 UI 显示或导出) */ public exportToYaml(): string { if (!this.config) { throw new Error('Configuration not initialized') } return yaml.dump(this.config, { indent: 2, lineWidth: -1, noRefs: true }) } }