From 79934a58d0d2697fd968e8baf28c36d0bd8b2797 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 12:24:27 +0800 Subject: [PATCH 01/12] docs: add settings partial save design document - Problem: Settings page overwrites unmodified .env fields - Solution: Deep merge + whitelist validation approach - Added backup mechanism for safe rollback - Designed extensible whitelist for future UI expansion --- ...2026-03-03-settings-partial-save-design.md | 488 ++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 docs/plans/2026-03-03-settings-partial-save-design.md diff --git a/docs/plans/2026-03-03-settings-partial-save-design.md b/docs/plans/2026-03-03-settings-partial-save-design.md new file mode 100644 index 0000000..caf59d3 --- /dev/null +++ b/docs/plans/2026-03-03-settings-partial-save-design.md @@ -0,0 +1,488 @@ +# 配置保存优化设计文档 + +**日期:** 2026-03-03 +**分支:** fix/settings-partial-save +**状态:** 设计阶段 + +--- + +## 问题描述 + +当前设置界面只能配置 3 个字段(ERP URL、用户名、密码),但保存后会意外覆盖 `.env` 文件中的其他配置项(如 `DB_TYPE`、`VALIDATION_DATA_SOURCE` 等),导致这些字段被重置为默认值或丢失。 + +### 根本原因 + +在 `config-manager.ts:437-483` 中,`saveAllSettings()` 方法无条件覆盖所有配置类别。当 UI 只发送部分字段时,未包含的字段会被设置为 `undefined` 或默认值,导致原有配置丢失。 + +**数据流问题:** +``` +SettingsPage (只修改 ERP URL) + ↓ 发送完整的 settings 对象 +ConfigManager.saveAllSettings() + ↓ 覆盖所有字段到缓存 +.env 文件被完全重写(丢失未被 UI 包含的字段) +``` + +--- + +## 解决方案 + +采用 **方案 A(深度合并)+ 方案 C(字段白名单)** 的组合策略: + +### 核心策略 + +1. **部分更新**:只更新传入的字段,保留其他字段不变 +2. **白名单验证**:只允许 UI 支持的字段被修改 +3. **备份机制**:保存前备份,失败可回滚 +4. **安全日志**:记录所有配置变更操作 + +--- + +## 架构设计 + +### 数据流 + +``` +┌─────────────────┐ +│ SettingsPage │ +│ (Renderer) │ +└────────┬────────┘ + │ 只发送支持的字段 + │ { erp: { url, username, password } } + ▼ +┌─────────────────┐ +│ Settings Handler│ +│ (IPC Bridge) │ +└────────┬────────┘ + │ 传递部分配置 (Partial) + ▼ +┌─────────────────────────────┐ +│ ConfigManager │ +│ ┌─────────────────────┐ │ +│ │ 1. 验证字段白名单 │ │ +│ │ 2. 深度合并当前配置 │ │ +│ │ 3. 备份 .env 文件 │ │ +│ │ 4. 原子写入新配置 │ │ +│ └─────────────────────┘ │ +└─────────────────────────────┘ +``` + +### 改动点 + +| 文件 | 改动类型 | 说明 | +|------|---------|------| +| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 | +| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial` | +| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 | + +--- + +## 核心实现 + +### 1. 深度合并工具函数 + +```typescript +/** + * 深度合并两个对象,只更新 target 中存在的字段 + * 保留 source 中 target 没有的字段 + */ +function deepMerge(source: T, target: Partial): T { + const result = { ...source } + + for (const key in target) { + if (key in target) { + const targetValue = target[key] + const sourceValue = result[key] + + if (isObject(targetValue) && isObject(sourceValue)) { + result[key] = deepMerge(sourceValue, targetValue) + } else if (targetValue !== undefined) { + result[key] = targetValue as T[Extract] + } + } + } + + return result +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} +``` + +### 2. 字段白名单验证 + +```typescript +/** + * 定义 UI 可编辑的字段路径 + * 使用点号表示法:'section.field' + */ +const UI_EDITABLE_FIELDS: string[] = [ + 'erp.url', + 'erp.username', + 'erp.password', + // 未来扩展: + // 'database.dbType', + // 'paths.dataDir', + // ... +] + +/** + * 验证配置更新是否只包含允许的字段 + */ +function validateEditableFields(settings: Partial): { + valid: boolean + invalidFields: string[] +} { + const invalidFields: string[] = [] + + for (const [section, values] of Object.entries(settings)) { + if (values && typeof values === 'object') { + for (const field of Object.keys(values)) { + const fieldPath = `${section}.${field}` + if (!UI_EDITABLE_FIELDS.includes(fieldPath)) { + invalidFields.push(fieldPath) + } + } + } + } + + return { + valid: invalidFields.length === 0, + invalidFields + } +} +``` + +### 3. 部分保存方法 + +```typescript +/** + * 保存部分配置(只更新传入的字段) + */ +public async savePartialSettings( + settings: Partial +): Promise<{ success: boolean; error?: string }> { + try { + // 步骤 1: 验证字段白名单 + const validation = validateEditableFields(settings) + if (!validation.valid) { + log.warn('Attempted to save non-editable fields', { + invalidFields: validation.invalidFields + }) + return { + success: false, + error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}` + } + } + + // 步骤 2: 读取当前配置 + const currentSettings = this.getAllSettings() + + // 步骤 3: 深度合并 + const mergedSettings = deepMerge(currentSettings, settings) + + // 步骤 4: 备份并保存 + const backupSuccess = await this.backupEnvFile() + if (!backupSuccess) { + log.warn('Failed to backup .env file, proceeding with caution') + } + + const saveSuccess = await this.saveAllSettings(mergedSettings) + + if (!saveSuccess) { + // 保存失败,尝试恢复备份 + await this.restoreBackup() + return { + success: false, + error: '保存配置失败,已恢复原配置' + } + } + + log.info('Settings saved successfully', { + updatedFields: Object.keys(settings) + }) + + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Error in savePartialSettings', { error: message }) + await this.restoreBackup() + return { + success: false, + error: `保存配置时发生错误:${message}` + } + } +} +``` + +### 4. 备份与恢复机制 + +```typescript +private backupPath: string + +constructor() { + // ... + this.backupPath = path.resolve(__dirname, '../../.env.backup') +} + +/** + * 备份当前 .env 文件 + */ +private async backupEnvFile(): Promise { + try { + if (fs.existsSync(this.envPath)) { + fs.copyFileSync(this.envPath, this.backupPath) + log.debug('Backup created', { path: this.backupPath }) + return true + } + return false + } catch (error) { + log.error('Failed to backup .env file', { error }) + return false + } +} + +/** + * 从备份恢复 .env 文件 + */ +private async restoreBackup(): Promise { + try { + if (fs.existsSync(this.backupPath)) { + fs.copyFileSync(this.backupPath, this.envPath) + await this.loadEnvFile() // 重新加载到缓存 + log.info('Restored from backup') + return true + } + return false + } catch (error) { + log.error('Failed to restore backup', { error }) + return false + } +} +``` + +--- + +## IPC 调用链路调整 + +### settings-handler.ts + +```typescript +ipcMain.handle( + 'settings:saveSettings', + async (_event, settings: Partial): Promise => { + try { + log.info('Saving settings', { + sections: Object.keys(settings) + }) + + // 使用新的部分保存方法 + const result = await configManager.savePartialSettings(settings) + + if (result.success) { + log.info('Settings saved successfully') + return { success: true } + } else { + log.warn('Failed to save settings', { + error: result.error + }) + return { + success: false, + error: result.error || '保存设置失败' + } + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Error saving settings', { error: message }) + return { + success: false, + error: `保存设置失败:${message}` + } + } + } +) +``` + +**关键改动:** +- 参数类型从 `SettingsData` 改为 `Partial` +- 调用 `savePartialSettings()` 替代 `saveAllSettings()` + +--- + +## 前端优化(双重保险) + +### SettingsPage.tsx + +```typescript +const handleSaveSettings = async () => { + try { + // 只发送 UI 支持的字段(双重保险) + const partialSettings = { + erp: { + url: settings.erp?.url, + username: settings.erp?.username, + password: settings.erp?.password + } + } + + const result = await window.electron.settings.saveSettings(partialSettings) + + if (result.success) { + setIsModified(false) + showMessage('success', '设置保存成功') + } else { + showMessage('error', result.error || '保存失败') + } + } catch (error) { + showMessage('error', '保存设置时发生错误') + } +} +``` + +--- + +## 测试策略 + +### 单元测试场景 + +```typescript +describe('ConfigManager.savePartialSettings', () => { + it('应该只更新指定的字段,保留其他字段', async () => { + const initial = { + erp: { url: 'http://old.com', username: 'user1' }, + database: { dbType: 'mysql' } + } + + const update = { + erp: { url: 'http://new.com' } + } + + await configManager.savePartialSettings(update) + const result = configManager.getAllSettings() + + expect(result.erp.url).toBe('http://new.com') + expect(result.erp.username).toBe('user1') // 保留 + expect(result.database.dbType).toBe('mysql') // 保留 + }) + + it('应该拒绝未授权的字段更新', async () => { + const invalidUpdate = { + database: { dbType: 'postgres' } + } + + const result = await configManager.savePartialSettings(invalidUpdate) + + expect(result.success).toBe(false) + expect(result.error).toContain('不允许修改') + }) + + it('保存失败时应该恢复备份', async () => { + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => { + throw new Error('Disk full') + }) + + const result = await configManager.savePartialSettings({ erp: { url: 'x' } }) + + expect(result.success).toBe(false) + }) +}) +``` + +### 手动验证步骤 + +1. 打开 `.env`,记录所有字段值 +2. 打开设置页面,只修改 ERP URL +3. 点击保存 +4. 检查 `.env`:只有 `ERP_URL` 改变,其他字段保持原值 + +--- + +## 未来扩展性 + +### 1. 白名单配置化 + +当设置页面需要支持更多配置时: + +```typescript +const UI_EDITABLE_FIELDS: string[] = [ + 'erp.url', + 'erp.username', + 'erp.password', + 'database.dbType', // 新增 + 'paths.dataDir', // 新增 + 'extraction.batchSize', // 新增 + // ... +] +``` + +### 2. 按用户角色分级 + +```typescript +const EDITABLE_FIELDS_BY_ROLE: Record = { + Admin: ['*'], + User: ['erp.url', 'erp.username', 'erp.password'], + Guest: [] +} + +function validateEditableFields( + settings: Partial, + userType: UserType +) { + const allowed = EDITABLE_FIELDS_BY_ROLE[userType] + // 验证逻辑... +} +``` + +### 3. 配置变更审计 + +```typescript +interface ConfigChange { + timestamp: Date + user: string + field: string + oldValue: string + newValue: string +} +``` + +--- + +## 实施计划 + +下一步将创建详细的实施计划,包括: + +1. 在 ConfigManager 中添加深度合并和验证函数 +2. 实现 `savePartialSettings()` 方法 +3. 添加备份与恢复机制 +4. 更新 IPC handler 调用 +5. 前端优化(只发送必要字段) +6. 编写单元测试 +7. 集成测试和手动验证 + +--- + +## 风险与缓解 + +| 风险 | 影响 | 缓解措施 | +|------|------|---------| +| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 | +| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 | +| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 | +| 并发保存冲突 | 数据不一致 | 单实例 ConfigManager + 文件锁 | + +--- + +## 附录 + +### 相关文件 + +- `src/main/services/config/config-manager.ts` - 配置管理器 +- `src/main/ipc/settings-handler.ts` - IPC 处理器 +- `src/renderer/src/pages/SettingsPage.tsx` - 设置页面 +- `src/main/types/settings.types.ts` - 类型定义 + +### 参考 + +- 当前问题:保存设置时 `.env` 中未包含的字段被覆盖 +- 设计原则:安全优先、最小化修改、可扩展性 From 429357ae8cf0f8c6cef3668e0d0bba141a16ca5d Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 12:25:59 +0800 Subject: [PATCH 02/12] docs: add implementation plan for settings partial save feature - 8 detailed tasks with step-by-step instructions - TDD approach with failing tests first - Complete code snippets provided - Manual testing procedures included - Estimated 2-3 hours implementation time --- ...03-settings-partial-save-implementation.md | 943 ++++++++++++++++++ 1 file changed, 943 insertions(+) create mode 100644 docs/plans/2026-03-03-settings-partial-save-implementation.md diff --git a/docs/plans/2026-03-03-settings-partial-save-implementation.md b/docs/plans/2026-03-03-settings-partial-save-implementation.md new file mode 100644 index 0000000..239ae57 --- /dev/null +++ b/docs/plans/2026-03-03-settings-partial-save-implementation.md @@ -0,0 +1,943 @@ +# Settings Partial Save Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix settings save to only update modified fields, preventing unintended overwrites of unmodified .env configuration values. + +**Architecture:** Implement partial save strategy using deep merge + whitelist validation. ConfigManager validates fields, merges with current config, backs up .env, and atomically writes changes. + +**Tech Stack:** TypeScript 5.9, Electron 39, Vitest, Node.js fs module + +--- + +## Task 1: Add Utility Functions to ConfigManager + +**Files:** +- Modify: `src/main/services/config/config-manager.ts` + +**Step 1: Write failing test for deep merge** + +Create test file: `tests/main/services/config/config-manager.test.ts` + +```typescript +import { describe, it, expect } from 'vitest' +import { ConfigManager } from '@/services/config/config-manager' +import type { SettingsData } from '@/types/settings.types' + +describe('ConfigManager - deep merge utilities', () => { + it('should deep merge objects, updating only specified fields', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + // Setup initial state + const initial: SettingsData = { + erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true }, + database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' }, + paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'validation.xlsx' }, + extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, + validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' }, + ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 }, + execution: { dryRun: false } + } + + // Load initial settings + await manager.saveAllSettings(initial) + + // Partial update + const partial = { + erp: { url: 'http://new.com' } + } + + const result = await manager.savePartialSettings(partial) + + expect(result.success).toBe(true) + + const current = manager.getAllSettings() + + // Updated field + expect(current.erp.url).toBe('http://new.com') + + // Preserved fields + expect(current.erp.username).toBe('user1') + expect(current.database.dbType).toBe('mysql') + expect(current.paths.dataDir).toBe('/data') + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `cd D:/Node/ERPAuto-settings-fix && npm test -- tests/main/services/config/config-manager.test.ts` + +Expected: FAIL - "savePartialSettings is not a function" + +**Step 3: Add helper functions to ConfigManager** + +In `src/main/services/config/config-manager.ts`, add after the DEFAULT_SETTINGS constant (around line 78): + +```typescript +/** + * Check if value is a plain object + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Deep merge two objects, only updating fields present in target + * Preserves all fields from source that are not in target + */ +function deepMerge(source: T, target: Partial): T { + const result = { ...source } + + for (const key in target) { + if (key in target) { + const targetValue = target[key] + const sourceValue = result[key] + + if (isObject(targetValue) && isObject(sourceValue)) { + result[key] = deepMerge(sourceValue, targetValue) + } else if (targetValue !== undefined) { + result[key] = targetValue as T[Extract] + } + } + } + + return result +} + +/** + * UI editable field whitelist + * Fields that can be modified through the settings UI + */ +const UI_EDITABLE_FIELDS: string[] = [ + 'erp.url', + 'erp.username', + 'erp.password', + // Add more fields as UI expands +] + +/** + * Validate that settings only contain editable fields + */ +function validateEditableFields(settings: Partial): { + valid: boolean + invalidFields: string[] +} { + const invalidFields: string[] = [] + + for (const [section, values] of Object.entries(settings)) { + if (values && typeof values === 'object') { + for (const field of Object.keys(values)) { + const fieldPath = `${section}.${field}` + if (!UI_EDITABLE_FIELDS.includes(fieldPath)) { + invalidFields.push(fieldPath) + } + } + } + } + + return { + valid: invalidFields.length === 0, + invalidFields + } +} +``` + +**Step 4: Run test to verify it still fails (method not implemented yet)** + +Run: `npm test -- tests/main/services/config/config-manager.test.ts` + +Expected: FAIL - "savePartialSettings is not a function" + +**Step 5: Commit** + +```bash +cd D:/Node/ERPAuto-settings-fix +git add src/main/services/config/config-manager.ts tests/main/services/config/config-manager.test.ts +git commit -m "feat: add deep merge and validation utility functions to ConfigManager" +``` + +--- + +## Task 2: Add Backup and Restore Mechanism + +**Files:** +- Modify: `src/main/services/config/config-manager.ts` + +**Step 1: Write test for backup functionality** + +Add to `tests/main/services/config/config-manager.test.ts`: + +```typescript +describe('ConfigManager - backup and restore', () => { + it('should create backup before saving', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + const backupSuccess = await manager['backupEnvFile']() + + expect(backupSuccess).toBe(true) + + // Check backup file exists + const fs = await import('fs') + const path = await import('path') + const backupPath = path.resolve(process.cwd(), '.env.backup') + + expect(fs.existsSync(backupPath)).toBe(true) + }) + + it('should restore from backup when save fails', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + // Create initial state + const initial = manager.getAllSettings() + const originalUrl = initial.erp.url + + // Mock fs.writeFileSync to fail + const fs = await import('fs') + const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => { + throw new Error('Disk full') + }) + + const result = await manager.savePartialSettings({ + erp: { url: 'http://should-not-save.com' } + }) + + expect(result.success).toBe(false) + + // Restore should have happened + const current = manager.getAllSettings() + expect(current.erp.url).toBe(originalUrl) + + writeFileSyncSpy.mockRestore() + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- tests/main/services/config/config-manager.test.ts` + +Expected: FAIL - "backupEnvFile is not a function" + +**Step 3: Add backup path property and methods to ConfigManager class** + +In `ConfigManager` class, modify constructor (around line 88) to add backupPath: + +```typescript +export class ConfigManager { + private static instance: ConfigManager | null = null + private envPath: string + private backupPath: string // ADD THIS LINE + private configCache: Map = new Map() + private initialized: boolean = false + + private constructor() { + if (this.initialized) { + return + } + this.envPath = path.resolve(__dirname, '../../.env') + this.backupPath = path.resolve(__dirname, '../../.env.backup') // ADD THIS LINE + this.initialized = true + } +``` + +Add private methods at the end of the class (before `getInstance()`): + +```typescript + /** + * Backup current .env file + */ + private async backupEnvFile(): Promise { + try { + if (fs.existsSync(this.envPath)) { + fs.copyFileSync(this.envPath, this.backupPath) + log.debug('Backup created', { path: this.backupPath }) + return true + } + return false + } catch (error) { + log.error('Failed to backup .env file', { error }) + return false + } + } + + /** + * Restore .env file from backup + */ + private async restoreBackup(): Promise { + try { + if (fs.existsSync(this.backupPath)) { + fs.copyFileSync(this.backupPath, this.envPath) + await this.loadEnvFile() + log.info('Restored from backup') + return true + } + return false + } catch (error) { + log.error('Failed to restore backup', { error }) + return false + } + } +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- tests/main/services/config/config-manager.test.ts` + +Expected: PASS + +**Step 5: Commit** + +```bash +cd D:/Node/ERPAuto-settings-fix +git add src/main/services/config/config-manager.ts tests/main/services/config/config-manager.test.ts +git commit -m "feat: add backup and restore mechanism to ConfigManager" +``` + +--- + +## Task 3: Implement savePartialSettings Method + +**Files:** +- Modify: `src/main/services/config/config-manager.ts` + +**Step 1: Write comprehensive test for savePartialSettings** + +Add to `tests/main/services/config/config-manager.test.ts`: + +```typescript +describe('ConfigManager.savePartialSettings', () => { + it('should save only specified fields and preserve others', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + // Setup initial state with multiple categories + await manager.saveAllSettings({ + erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true }, + database: { dbType: 'mysql', server: '', mysqlHost: '192.168.1.1', mysqlPort: 3306, database: 'testdb', username: 'dbuser', password: '' }, + paths: { dataDir: '/old/path', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' }, + extraction: { batchSize: 50, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, + validation: { dataSource: 'database_full', batchSize: 1000, matchMode: 'exact', enableCrud: false, defaultManager: '' }, + ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 }, + execution: { dryRun: true } + }) + + // Update only ERP URL + const result = await manager.savePartialSettings({ + erp: { url: 'http://new.com' } + }) + + expect(result.success).toBe(true) + + const current = manager.getAllSettings() + + // Verify updated field + expect(current.erp.url).toBe('http://new.com') + + // Verify preserved ERP fields + expect(current.erp.username).toBe('user1') + expect(current.erp.password).toBe('pass1') + + // Verify preserved other categories + expect(current.database.dbType).toBe('mysql') + expect(current.database.mysqlHost).toBe('192.168.1.1') + expect(current.paths.dataDir).toBe('/old/path') + expect(current.extraction.batchSize).toBe(50) + expect(current.ui.fontFamily).toBe('Tahoma') + }) + + it('should reject updates to non-whitelisted fields', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + const result = await manager.savePartialSettings({ + database: { dbType: 'postgres' } + }) + + expect(result.success).toBe(false) + expect(result.error).toContain('不允许修改') + expect(result.error).toContain('database.dbType') + }) + + it('should handle nested object updates correctly', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + await manager.saveAllSettings({ + erp: { url: 'http://test.com', username: 'u', password: 'p', headless: false, ignoreHttpsErrors: false, autoCloseBrowser: false }, + database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' }, + paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' }, + extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, + validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' }, + ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 }, + execution: { dryRun: false } + }) + + // Update multiple ERP fields at once + const result = await manager.savePartialSettings({ + erp: { + url: 'http://updated.com', + username: 'newuser', + password: 'newpass' + } + }) + + expect(result.success).toBe(true) + + const current = manager.getAllSettings() + + expect(current.erp.url).toBe('http://updated.com') + expect(current.erp.username).toBe('newuser') + expect(current.erp.password).toBe('newpass') + expect(current.erp.headless).toBe(false) // preserved + }) + + it('should restore backup on save failure', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + const originalUrl = manager.getAllSettings().erp.url + + // Mock save to fail + const originalSave = manager.save.bind(manager) + vi.spyOn(manager, 'save').mockResolvedValueOnce(false) + + const result = await manager.savePartialSettings({ + erp: { url: 'http://should-not-apply.com' } + }) + + expect(result.success).toBe(false) + expect(result.error).toContain('保存配置失败') + + // Verify rollback + expect(manager.getAllSettings().erp.url).toBe(originalUrl) + + manager.save.mockRestore() + }) +}) +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- tests/main/services/config/config-manager.test.ts` + +Expected: FAIL - "savePartialSettings is not a function" or implementation incomplete + +**Step 3: Implement savePartialSettings method** + +Add this public method to ConfigManager class (after saveAllSettings method, around line 483): + +```typescript + /** + * Save partial settings (only update provided fields) + * Preserves all existing fields not included in the update + */ + public async savePartialSettings( + settings: Partial + ): Promise<{ success: boolean; error?: string }> { + try { + // Step 1: Validate field whitelist + const validation = validateEditableFields(settings) + if (!validation.valid) { + log.warn('Attempted to save non-editable fields', { + invalidFields: validation.invalidFields + }) + return { + success: false, + error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}` + } + } + + // Step 2: Read current settings + const currentSettings = this.getAllSettings() + + // Step 3: Deep merge + const mergedSettings = deepMerge(currentSettings, settings) + + // Step 4: Backup and save + const backupSuccess = await this.backupEnvFile() + if (!backupSuccess) { + log.warn('Failed to backup .env file, proceeding with caution') + } + + const saveSuccess = await this.saveAllSettings(mergedSettings) + + if (!saveSuccess) { + // Save failed, attempt restore + await this.restoreBackup() + return { + success: false, + error: '保存配置失败,已恢复原配置' + } + } + + log.info('Settings saved successfully', { + updatedFields: Object.keys(settings) + }) + + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Error in savePartialSettings', { error: message }) + await this.restoreBackup() + return { + success: false, + error: `保存配置时发生错误:${message}` + } + } + } +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- tests/main/services/config/config-manager.test.ts` + +Expected: PASS + +**Step 5: Run typecheck** + +Run: `cd D:/Node/ERPAuto-settings-fix && npm run typecheck:node` + +Expected: PASS (no type errors) + +**Step 6: Commit** + +```bash +cd D:/Node/ERPAuto-settings-fix +git add src/main/services/config/config-manager.ts tests/main/services/config/config-manager.test.ts +git commit -m "feat: implement savePartialSettings with validation and rollback" +``` + +--- + +## Task 4: Update IPC Handler to Use Partial Save + +**Files:** +- Modify: `src/main/ipc/settings-handler.ts` + +**Step 1: Update settings:saveSettings handler** + +Find the `settings:saveSettings` handler (around line 83) and replace it: + +```typescript + /** + * Save settings (updated to use partial save) + */ + ipcMain.handle( + 'settings:saveSettings', + async (_event, settings: Partial): Promise => { + try { + log.info('Saving settings', { + sections: Object.keys(settings) + }) + + // Use partial save method + const result = await configManager.savePartialSettings(settings) + + if (result.success) { + log.info('Settings saved successfully') + return { success: true } + } else { + log.warn('Failed to save settings', { + error: result.error + }) + return { + success: false, + error: result.error || '保存设置失败' + } + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Error saving settings', { error: message }) + return { + success: false, + error: `保存设置失败:${message}` + } + } + } + ) +``` + +**Step 2: Run typecheck** + +Run: `cd D:/Node/ERPAuto-settings-fix && npm run typecheck:node` + +Expected: PASS + +**Step 3: Commit** + +```bash +cd D:/Node/ERPAuto-settings-fix +git add src/main/ipc/settings-handler.ts +git commit -m "feat: update settings handler to use savePartialSettings" +``` + +--- + +## Task 5: Update Frontend to Send Only Necessary Fields + +**Files:** +- Modify: `src/renderer/src/pages/SettingsPage.tsx` + +**Step 1: Update handleSaveSettings to send partial settings** + +Find the `handleSaveSettings` function (around line 61) and replace it: + +```typescript + const handleSaveSettings = async () => { + try { + // Only send UI-supported fields (double safety) + const partialSettings = { + erp: { + url: settings.erp?.url, + username: settings.erp?.username, + password: settings.erp?.password + } + } + + const result = await window.electron.settings.saveSettings(partialSettings as any) + + if (result.success) { + setIsModified(false) + showMessage('success', '设置保存成功') + } else { + showMessage('error', result.error || '保存失败') + } + } catch (error) { + showMessage('error', '保存设置时发生错误') + } + } +``` + +**Step 2: Run typecheck** + +Run: `cd D:/Node/ERPAuto-settings-fix && npm run typecheck:web` + +Expected: PASS + +**Step 3: Commit** + +```bash +cd D:/Node/ERPAuto-settings-fix +git add src/renderer/src/pages/SettingsPage.tsx +git commit -m "feat: send only ERP fields from settings page (defensive programming)" +``` + +--- + +## Task 6: Manual Testing and Verification + +**Files:** +- Manual test procedure + +**Step 1: Prepare test environment** + +```bash +cd D:/Node/ERPAuto-settings-fix + +# Create a test .env file with all fields +cat > .env << 'EOF' +# Test Configuration +ERP_URL=http://original-test.com +ERP_USERNAME=testuser +ERP_PASSWORD=testpass +ERP_HEADLESS=true +ERP_IGNORE_HTTPS_ERRORS=true +ERP_AUTO_CLOSE_BROWSER=true + +DB_TYPE=mysql +DB_NAME=testdb +DB_USERNAME=dbuser +DB_PASSWORD=dbpass +DB_MYSQL_HOST=192.168.100.50 +DB_MYSQL_PORT=3306 +DB_MYSQL_CHARSET=utf8mb4 + +PATH_DATA_DIR=/test/data +PATH_DEFAULT_OUTPUT=test.xlsx +PATH_VALIDATION_OUTPUT=validation.xlsx + +EXTRACTION_BATCH_SIZE=100 +EXTRACTION_VERBOSE=true +EXTRACTION_AUTO_CONVERT=true +EXTRACTION_MERGE_BATCHES=true +EXTRACTION_ENABLE_DB_PERSISTENCE=true + +VALIDATION_DATA_SOURCE=database_full +VALIDATION_USE_DATABASE=true +VALIDATION_BATCH_SIZE=2000 +VALIDATION_ENABLE_CRUD=false +VALIDATION_DEFAULT_MANAGER=admin +VALIDATION_MATCH_MODE=substring + +UI_FONT_FAMILY=TestFont +UI_FONT_SIZE=11 +UI_PRODUCTION_ID_INPUT_WIDTH=15 + +EXECUTION_DRYRUN=false +EOF +``` + +**Step 2: Start development server** + +Run: `npm run dev` + +**Step 3: Navigate to settings page** + +1. Login to the application +2. Navigate to Settings page +3. Modify only ERP URL to `http://modified-test.com` +4. Click "保存并应用配置" + +**Step 4: Verify .env file preservation** + +Check `.env` file: + +```bash +cat .env +``` + +Expected results: +- `ERP_URL` should be `http://modified-test.com` (CHANGED) +- `DB_TYPE` should still be `mysql` (PRESERVED) +- `VALIDATION_MATCH_MODE` should still be `substring` (PRESERVED) +- All other fields should remain unchanged + +**Step 5: Test whitelist validation** + +Add test code to temporarily send invalid field: + +```typescript +// In SettingsPage.tsx handleSaveSettings, temporarily add: +const partialSettings = { + erp: { + url: settings.erp?.url, + username: settings.erp?.username, + password: settings.erp?.password + }, + database: { dbType: 'postgres' } // Should be rejected +} +``` + +Click save, should see error: "包含不允许修改的字段:database.dbType" + +Remove test code after verification. + +**Step 6: Test rollback mechanism** + +Simulate save failure by temporarily making .env read-only: + +```bash +chmod -w .env # On Linux/Mac +# or on Windows with file properties +``` + +Attempt to save settings, should see error: "保存配置失败,已恢复原配置" + +Verify .env content unchanged, then restore write permissions: + +```bash +chmod +w .env # On Linux/Mac +``` + +**Step 7: Document test results** + +Create test report: + +```bash +cat > docs/test-reports/settings-partial-save-manual-test.md << 'EOF' +# Settings Partial Save - Manual Test Report + +**Date:** 2026-03-03 +**Tester:** [Your Name] +**Branch:** fix/settings-partial-save + +## Test Results + +### Test 1: Partial Field Preservation +- [x] Modified ERP URL only +- [x] Verified DB_TYPE unchanged +- [x] Verified all other fields preserved + +### Test 2: Whitelist Validation +- [x] Attempted to modify database.dbType +- [x] Received error message about unauthorized field +- [x] No changes applied to .env + +### Test 3: Backup and Rollback +- [x] Backup file created before save +- [x] Save failure triggered rollback +- [x] Original configuration restored + +## Conclusion +All manual tests passed successfully. +EOF +``` + +**Step 8: Commit** + +```bash +cd D:/Node/ERPAuto-settings-fix +git add docs/test-reports/settings-partial-save-manual-test.md +git commit -m "test: add manual test report for settings partial save" +``` + +--- + +## Task 7: Update Documentation + +**Files:** +- Create: `docs/settings-partial-save.md` +- Update: `README.md` (if applicable) + +**Step 1: Create feature documentation** + +Create `docs/settings-partial-save.md`: + +```markdown +# Settings Partial Save Feature + +## Overview + +The settings system now implements partial save functionality to prevent unintended overwrites of configuration values. + +## How It Works + +1. **Field Whitelist**: Only fields exposed in the UI can be modified +2. **Deep Merge**: Updates are merged with existing config, preserving unmodified fields +3. **Backup & Rollback**: Config is backed up before save; failures trigger automatic rollback + +## Editable Fields + +Currently editable via UI: +- `erp.url` - ERP system URL +- `erp.username` - ERP login username +- `erp.password` - ERP login password + +## Adding New Editable Fields + +To add a new field to the UI: + +1. Add field to whitelist in `src/main/services/config/config-manager.ts`: + +```typescript +const UI_EDITABLE_FIELDS: string[] = [ + 'erp.url', + 'erp.username', + 'erp.password', + 'database.dbType', // Add new field here +] +``` + +2. Add UI input in `src/renderer/src/pages/SettingsPage.tsx` +3. Update `handleSaveSettings` to include the new field + +## API + +### savePartialSettings(settings: Partial) + +Saves only the provided fields, preserving all existing configuration. + +**Returns:** `{ success: boolean, error?: string }` + +**Validation:** +- Checks whitelist before applying changes +- Returns error for unauthorized fields + +## Error Handling + +- **Unauthorized field**: Returns error message listing invalid fields +- **Save failure**: Automatically restores from backup +- **Backup failure**: Logs warning, continues with save + +## Backup File + +Location: `.env.backup` (in project root) + +Created before every save operation. Used for rollback on failure. +``` + +**Step 2: Update CLAUDE.md if needed** + +Add to "Development Commands" or "Architecture Overview" sections if there's relevant information about config management. + +**Step 3: Commit** + +```bash +cd D:/Node/ERPAuto-settings-fix +git add docs/settings-partial-save.md +git commit -m "docs: add settings partial save feature documentation" +``` + +--- + +## Task 8: Final Verification and Cleanup + +**Files:** +- All modified files + +**Step 1: Run full test suite** + +Run: `cd D:/Node/ERPAuto-settings-fix && npm test` + +Expected: All tests pass + +**Step 2: Run type checking** + +Run: `npm run typecheck` + +Expected: No type errors + +**Step 3: Run linting** + +Run: `npm run lint` + +Expected: No linting errors (or fix if present) + +**Step 4: Build verification** + +Run: `npm run build` + +Expected: Build succeeds without errors + +**Step 5: Review all changes** + +```bash +cd D:/Node/ERPAuto-settings-fix +git diff dev --stat +``` + +Verify all changes are expected. + +**Step 6: Final commit** + +```bash +cd D:/Node/ERPAuto-settings-fix +git add -A +git commit -m "chore: final verification and cleanup for settings partial save feature" +``` + +--- + +## Summary + +This implementation plan fixes the settings save issue through: + +1. ✅ Deep merge utilities that preserve unmodified fields +2. ✅ Field whitelist validation to prevent unauthorized changes +3. ✅ Backup and rollback mechanism for safe saves +4. ✅ Updated IPC handler with partial save support +5. ✅ Frontend defensive programming (sends only necessary fields) +6. ✅ Comprehensive unit and manual testing +7. ✅ Complete documentation + +**Total estimated implementation time:** 2-3 hours + +**Key files modified:** +- `src/main/services/config/config-manager.ts` (core logic) +- `src/main/ipc/settings-handler.ts` (IPC layer) +- `src/renderer/src/pages/SettingsPage.tsx` (frontend) +- `tests/main/services/config/config-manager.test.ts` (tests) From 765fb95644ebd3f9677b7325b72fd67948e16737 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 12:42:34 +0800 Subject: [PATCH 03/12] feat: add deep merge and validation utility functions to ConfigManager This commit adds utility functions to support partial settings save functionality: - isObject: Type guard for plain objects - deepMerge: Recursively merges objects, preserving unspecified fields - validateEditableFields: Validates settings against UI editable field whitelist - UI_EDITABLE_FIELDS: Whitelist of fields modifiable through UI A failing test is included to verify the deep merge behavior. Co-Authored-By: Claude Sonnet 4.5 --- src/main/services/config/config-manager.ts | 67 +++++++++++++++++++ .../services/config/config-manager.test.ts | 43 ++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 tests/main/services/config/config-manager.test.ts diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index ced0fdc..525ee87 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -76,6 +76,73 @@ const DEFAULT_SETTINGS: SettingsData = { } } +/** + * Check if value is a plain object + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Deep merge two objects, only updating fields present in target + * Preserves all fields from source that are not in target + */ +function deepMerge(source: T, target: Partial): T { + const result = { ...source } + + for (const key in target) { + if (key in target) { + const targetValue = target[key] + const sourceValue = result[key] + + if (isObject(targetValue) && isObject(sourceValue)) { + result[key] = deepMerge(sourceValue, targetValue) + } else if (targetValue !== undefined) { + result[key] = targetValue as T[Extract] + } + } + } + + return result +} + +/** + * UI editable field whitelist + * Fields that can be modified through the settings UI + */ +const UI_EDITABLE_FIELDS: string[] = [ + 'erp.url', + 'erp.username', + 'erp.password', + // Add more fields as UI expands +] + +/** + * Validate that settings only contain editable fields + */ +function validateEditableFields(settings: Partial): { + valid: boolean + invalidFields: string[] +} { + const invalidFields: string[] = [] + + for (const [section, values] of Object.entries(settings)) { + if (values && typeof values === 'object') { + for (const field of Object.keys(values)) { + const fieldPath = `${section}.${field}` + if (!UI_EDITABLE_FIELDS.includes(fieldPath)) { + invalidFields.push(fieldPath) + } + } + } + } + + return { + valid: invalidFields.length === 0, + invalidFields + } +} + /** * Configuration Manager Class */ diff --git a/tests/main/services/config/config-manager.test.ts b/tests/main/services/config/config-manager.test.ts new file mode 100644 index 0000000..35440de --- /dev/null +++ b/tests/main/services/config/config-manager.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest' +import { ConfigManager } from '@services/config/config-manager' +import type { SettingsData } from '@types/settings.types' + +describe('ConfigManager - deep merge utilities', () => { + it('should deep merge objects, updating only specified fields', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + // Setup initial state + const initial: SettingsData = { + erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true }, + database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' }, + paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'validation.xlsx' }, + extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, + validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' }, + ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 }, + execution: { dryRun: false } + } + + // Load initial settings + await manager.saveAllSettings(initial) + + // Partial update + const partial = { + erp: { url: 'http://new.com' } + } + + const result = await manager.savePartialSettings(partial) + + expect(result.success).toBe(true) + + const current = manager.getAllSettings() + + // Updated field + expect(current.erp.url).toBe('http://new.com') + + // Preserved fields + expect(current.erp.username).toBe('user1') + expect(current.database.dbType).toBe('mysql') + expect(current.paths.dataDir).toBe('/data') + }) +}) From c06880e946246fbf167a062cec4070c1b9e0f668 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 12:53:46 +0800 Subject: [PATCH 04/12] fix: resolve code quality issues in utility functions - Fix TypeScript type error in deepMerge recursive call with proper type assertions - Remove unused type imports (ErpConfig, DatabaseConfig, PathsConfig, ExtractionConfig, ValidationConfig, UiConfig, ExecutionConfig) - Fix line endings (CRLF to LF) via Prettier format Co-Authored-By: Claude Sonnet 4.5 --- src/main/services/config/config-manager.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index 525ee87..98db245 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -11,13 +11,6 @@ import { fileURLToPath } from 'url' import { dirname } from 'path' import type { SettingsData, - ErpConfig, - DatabaseConfig, - PathsConfig, - ExtractionConfig, - ValidationConfig, - UiConfig, - ExecutionConfig, DatabaseType, MatchMode, ValidationDataSource @@ -96,7 +89,10 @@ function deepMerge(source: T, target: Partial): T { const sourceValue = result[key] if (isObject(targetValue) && isObject(sourceValue)) { - result[key] = deepMerge(sourceValue, targetValue) + result[key] = deepMerge( + sourceValue as T[Extract], + targetValue as Partial]> + ) } else if (targetValue !== undefined) { result[key] = targetValue as T[Extract] } @@ -113,7 +109,7 @@ function deepMerge(source: T, target: Partial): T { const UI_EDITABLE_FIELDS: string[] = [ 'erp.url', 'erp.username', - 'erp.password', + 'erp.password' // Add more fields as UI expands ] From b5ef38f48658b933811ad9320c56312fd02263e1 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 13:50:36 +0800 Subject: [PATCH 05/12] feat: add backup and restore mechanism to ConfigManager --- src/main/services/config/config-manager.ts | 37 +++++++++ .../services/config/config-manager.test.ts | 80 ++++++++++++++++++- 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index 98db245..24985c1 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -145,6 +145,7 @@ function validateEditableFields(settings: Partial): { export class ConfigManager { private static instance: ConfigManager | null = null private envPath: string + private backupPath: string private configCache: Map = new Map() private initialized: boolean = false @@ -153,6 +154,7 @@ export class ConfigManager { return } this.envPath = path.resolve(__dirname, '../../.env') + this.backupPath = path.resolve(__dirname, '../../.env.backup') this.initialized = true } @@ -599,4 +601,39 @@ export class ConfigManager { public getDefaultSettings(): SettingsData { return DEFAULT_SETTINGS } + + /** + * Backup current .env file + */ + private async backupEnvFile(): Promise { + try { + if (fs.existsSync(this.envPath)) { + fs.copyFileSync(this.envPath, this.backupPath) + console.log('[ConfigManager] Backup created', this.backupPath) + return true + } + return false + } catch (error) { + console.error('[ConfigManager] Failed to backup .env file:', error) + return false + } + } + + /** + * Restore .env file from backup + */ + private async restoreBackup(): Promise { + try { + if (fs.existsSync(this.backupPath)) { + fs.copyFileSync(this.backupPath, this.envPath) + await this.loadEnvFile() + console.log('[ConfigManager] Restored from backup') + return true + } + return false + } catch (error) { + console.error('[ConfigManager] Failed to restore backup:', error) + return false + } + } } diff --git a/tests/main/services/config/config-manager.test.ts b/tests/main/services/config/config-manager.test.ts index 35440de..8847760 100644 --- a/tests/main/services/config/config-manager.test.ts +++ b/tests/main/services/config/config-manager.test.ts @@ -9,11 +9,38 @@ describe('ConfigManager - deep merge utilities', () => { // Setup initial state const initial: SettingsData = { - erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true }, - database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' }, + erp: { + url: 'http://old.com', + username: 'user1', + password: 'pass1', + headless: true, + ignoreHttpsErrors: true, + autoCloseBrowser: true + }, + database: { + dbType: 'mysql', + server: '', + mysqlHost: 'localhost', + mysqlPort: 3306, + database: 'db', + username: 'user', + password: '' + }, paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'validation.xlsx' }, - extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, - validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' }, + extraction: { + batchSize: 100, + verbose: true, + autoConvert: true, + mergeBatches: true, + enableDbPersistence: true + }, + validation: { + dataSource: 'database_full', + batchSize: 2000, + matchMode: 'substring', + enableCrud: false, + defaultManager: '' + }, ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 }, execution: { dryRun: false } } @@ -41,3 +68,48 @@ describe('ConfigManager - deep merge utilities', () => { expect(current.paths.dataDir).toBe('/data') }) }) + +describe('ConfigManager - backup and restore', () => { + it('should create backup before saving', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + const backupSuccess = await manager['backupEnvFile']() + + expect(backupSuccess).toBe(true) + + // Check backup file exists (in same location as .env file) + const fs = await import('fs') + const path = await import('path') + const backupPath = path.resolve('src/main/.env.backup') + + expect(fs.existsSync(backupPath)).toBe(true) + }) + + it('should restore from backup when save fails', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + + // Create initial state + const initial = manager.getAllSettings() + const originalUrl = initial.erp.url + + // Mock fs.writeFileSync to fail + const fs = await import('fs') + const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => { + throw new Error('Disk full') + }) + + const result = await manager.savePartialSettings({ + erp: { url: 'http://should-not-save.com' } + }) + + expect(result.success).toBe(false) + + // Restore should have happened + const current = manager.getAllSettings() + expect(current.erp.url).toBe(originalUrl) + + writeFileSyncSpy.mockRestore() + }) +}) From 7e37a9b1fc9df6fa9eb66df15cb7ab7506fc9592 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 14:04:42 +0800 Subject: [PATCH 06/12] fix: use proper logger in ConfigManager backup/restore methods Replace console.log/console.error with proper logger usage in backupEnvFile and restoreBackup methods. Use the existing 'log' logger created with createLogger('ConfigManager') following the same pattern used in other methods. Changes: - Import createLogger and create log instance - Replace console.log with log.debug in backupEnvFile - Replace console.error with log.error in both methods - Pass error and path metadata as objects for structured logging - Fix test to use correct backup path (process.cwd() + src/main/.env.backup) Co-Authored-By: Claude Sonnet 4.5 --- src/main/services/config/config-manager.ts | 11 +++++++---- tests/main/services/config/config-manager.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index 24985c1..1281c7a 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -9,6 +9,7 @@ import * as fs from 'fs' import * as path from 'path' import { fileURLToPath } from 'url' import { dirname } from 'path' +import { createLogger } from '../logger' import type { SettingsData, DatabaseType, @@ -16,6 +17,8 @@ import type { ValidationDataSource } from '../../types/settings.types' +const log = createLogger('ConfigManager') + const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) @@ -609,12 +612,12 @@ export class ConfigManager { try { if (fs.existsSync(this.envPath)) { fs.copyFileSync(this.envPath, this.backupPath) - console.log('[ConfigManager] Backup created', this.backupPath) + log.debug('Backup created', { path: this.backupPath }) return true } return false } catch (error) { - console.error('[ConfigManager] Failed to backup .env file:', error) + log.error('Failed to backup .env file', { error }) return false } } @@ -627,12 +630,12 @@ export class ConfigManager { if (fs.existsSync(this.backupPath)) { fs.copyFileSync(this.backupPath, this.envPath) await this.loadEnvFile() - console.log('[ConfigManager] Restored from backup') + log.debug('Restored from backup') return true } return false } catch (error) { - console.error('[ConfigManager] Failed to restore backup:', error) + log.error('Failed to restore backup', { error }) return false } } diff --git a/tests/main/services/config/config-manager.test.ts b/tests/main/services/config/config-manager.test.ts index 8847760..37ed857 100644 --- a/tests/main/services/config/config-manager.test.ts +++ b/tests/main/services/config/config-manager.test.ts @@ -78,10 +78,10 @@ describe('ConfigManager - backup and restore', () => { expect(backupSuccess).toBe(true) - // Check backup file exists (in same location as .env file) + // Check backup file exists (in same location as .env file, which is src/main/) const fs = await import('fs') const path = await import('path') - const backupPath = path.resolve('src/main/.env.backup') + const backupPath = path.resolve(process.cwd(), 'src/main/.env.backup') expect(fs.existsSync(backupPath)).toBe(true) }) From f3be0ad01dbb4b9084e2c9060e1e95cc5077d075 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 14:36:50 +0800 Subject: [PATCH 07/12] feat: implement savePartialSettings with validation and rollback Implements the core savePartialSettings method that: - Validates fields against UI_EDITABLE_FIELDS whitelist - Deep merges partial updates with current settings - Creates backup before saving - Restores backup on save failure - Reloads .env file to populate cache with correct keys Added comprehensive tests: - Partial update preserves existing fields - Rejects non-whitelisted fields - Handles nested object updates - Restores backup on save failure Fixed cache key inconsistency bug by clearing cache in loadEnvFile() and reloading after save to ensure proper cache population. Co-Authored-By: Claude Sonnet 4.5 --- src/main/services/config/config-manager.ts | 65 +++++++++ .../services/config/config-manager.test.ts | 126 ++++++++++++++++-- 2 files changed, 179 insertions(+), 12 deletions(-) diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index 1281c7a..7432089 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -183,6 +183,9 @@ export class ConfigManager { */ private async loadEnvFile(): Promise { try { + // Clear cache before loading + this.configCache.clear() + if (fs.existsSync(this.envPath)) { const content = fs.readFileSync(this.envPath, 'utf-8') const lines = content.split('\n') @@ -550,6 +553,68 @@ export class ConfigManager { return this.save() } + /** + * Save partial settings (only update provided fields) + * Preserves all existing fields not included in the update + */ + public async savePartialSettings( + settings: Partial + ): Promise<{ success: boolean; error?: string }> { + try { + // Step 1: Validate field whitelist + const validation = validateEditableFields(settings) + if (!validation.valid) { + log.warn('Attempted to save non-editable fields', { + invalidFields: validation.invalidFields + }) + return { + success: false, + error: `包含不允许修改的字段:${validation.invalidFields.join(', ')}` + } + } + + // Step 2: Read current settings + const currentSettings = this.getAllSettings() + + // Step 3: Deep merge + const mergedSettings = deepMerge(currentSettings, settings) + + // Step 4: Backup and save + const backupSuccess = await this.backupEnvFile() + if (!backupSuccess) { + log.warn('Failed to backup .env file, proceeding with caution') + } + + const saveSuccess = await this.saveAllSettings(mergedSettings) + + if (!saveSuccess) { + // Save failed, attempt restore + await this.restoreBackup() + return { + success: false, + error: '保存配置失败,已恢复原配置' + } + } + + // Step 5: Reload from disk to populate cache with correct keys (ERP_URL instead of erp.url) + await this.loadEnvFile() + + log.info('Settings saved successfully', { + updatedFields: Object.keys(settings) + }) + + return { success: true } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + log.error('Error in savePartialSettings', { error: message }) + await this.restoreBackup() + return { + success: false, + error: `保存配置时发生错误:${message}` + } + } + } + /** * Reset to default settings */ diff --git a/tests/main/services/config/config-manager.test.ts b/tests/main/services/config/config-manager.test.ts index 37ed857..1a5f5f6 100644 --- a/tests/main/services/config/config-manager.test.ts +++ b/tests/main/services/config/config-manager.test.ts @@ -6,6 +6,8 @@ describe('ConfigManager - deep merge utilities', () => { it('should deep merge objects, updating only specified fields', async () => { const manager = ConfigManager.getInstance() await manager.initialize() + // Reload to ensure clean state from previous tests + await manager['loadEnvFile']() // Setup initial state const initial: SettingsData = { @@ -47,6 +49,8 @@ describe('ConfigManager - deep merge utilities', () => { // Load initial settings await manager.saveAllSettings(initial) + // Reload from disk to populate cache + await manager['loadEnvFile']() // Partial update const partial = { @@ -86,30 +90,128 @@ describe('ConfigManager - backup and restore', () => { expect(fs.existsSync(backupPath)).toBe(true) }) - it('should restore from backup when save fails', async () => { + // Note: Skipping fs.writeFileSync mock test due to ESM limitations in Vitest + // The restoreBackup functionality is tested indirectly through the savePartialSettings rollback test +}) + +describe('ConfigManager.savePartialSettings', () => { + it('should save only specified fields and preserve others', async () => { const manager = ConfigManager.getInstance() await manager.initialize() - // Create initial state - const initial = manager.getAllSettings() - const originalUrl = initial.erp.url + // Setup initial state with multiple categories + await manager.saveAllSettings({ + erp: { url: 'http://old.com', username: 'user1', password: 'pass1', headless: true, ignoreHttpsErrors: true, autoCloseBrowser: true }, + database: { dbType: 'mysql', server: '', mysqlHost: '192.168.1.1', mysqlPort: 3306, database: 'testdb', username: 'dbuser', password: '' }, + paths: { dataDir: '/old/path', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' }, + extraction: { batchSize: 50, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, + validation: { dataSource: 'database_full', batchSize: 1000, matchMode: 'exact', enableCrud: false, defaultManager: '' }, + ui: { fontFamily: 'Tahoma', fontSize: 14, productionIdInputWidth: 25 }, + execution: { dryRun: true } + }) + // Reload from disk to populate cache + await manager['loadEnvFile']() - // Mock fs.writeFileSync to fail - const fs = await import('fs') - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => { - throw new Error('Disk full') + // Update only ERP URL + const result = await manager.savePartialSettings({ + erp: { url: 'http://new.com' } }) + expect(result.success).toBe(true) + + const current = manager.getAllSettings() + + // Verify updated field + expect(current.erp.url).toBe('http://new.com') + + // Verify preserved ERP fields + expect(current.erp.username).toBe('user1') + expect(current.erp.password).toBe('pass1') + + // Verify preserved other categories + expect(current.database.dbType).toBe('mysql') + expect(current.database.mysqlHost).toBe('192.168.1.1') + expect(current.paths.dataDir).toBe('/old/path') + expect(current.extraction.batchSize).toBe(50) + expect(current.ui.fontFamily).toBe('Tahoma') + }) + + it('should reject updates to non-whitelisted fields', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + // Reset to ensure clean state + manager.resetToDefaults() + await manager.save() + const result = await manager.savePartialSettings({ - erp: { url: 'http://should-not-save.com' } + database: { dbType: 'postgres' } }) expect(result.success).toBe(false) + expect(result.error).toContain('不允许修改') + expect(result.error).toContain('database.dbType') + }) + + it('should handle nested object updates correctly', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + // Reset to ensure clean state + manager.resetToDefaults() + await manager.save() + + await manager.saveAllSettings({ + erp: { url: 'http://test.com', username: 'u', password: 'p', headless: false, ignoreHttpsErrors: false, autoCloseBrowser: false }, + database: { dbType: 'mysql', server: '', mysqlHost: 'localhost', mysqlPort: 3306, database: 'db', username: 'user', password: '' }, + paths: { dataDir: '/data', defaultOutput: 'out.xlsx', validationOutput: 'val.xlsx' }, + extraction: { batchSize: 100, verbose: true, autoConvert: true, mergeBatches: true, enableDbPersistence: true }, + validation: { dataSource: 'database_full', batchSize: 2000, matchMode: 'substring', enableCrud: false, defaultManager: '' }, + ui: { fontFamily: 'Arial', fontSize: 12, productionIdInputWidth: 20 }, + execution: { dryRun: false } + }) + // Reload from disk to populate cache + await manager['loadEnvFile']() + + // Update multiple ERP fields at once + const result = await manager.savePartialSettings({ + erp: { + url: 'http://updated.com', + username: 'newuser', + password: 'newpass' + } + }) + + expect(result.success).toBe(true) - // Restore should have happened const current = manager.getAllSettings() - expect(current.erp.url).toBe(originalUrl) - writeFileSyncSpy.mockRestore() + expect(current.erp.url).toBe('http://updated.com') + expect(current.erp.username).toBe('newuser') + expect(current.erp.password).toBe('newpass') + expect(current.erp.headless).toBe(false) // preserved + }) + + it('should restore backup on save failure', async () => { + const manager = ConfigManager.getInstance() + await manager.initialize() + // Reset to ensure clean state + manager.resetToDefaults() + await manager.save() + + const originalUrl = manager.getAllSettings().erp.url + + // Mock save to fail + vi.spyOn(manager, 'save').mockResolvedValueOnce(false) + + const result = await manager.savePartialSettings({ + erp: { url: 'http://should-not-apply.com' } + }) + + expect(result.success).toBe(false) + expect(result.error).toContain('保存配置失败') + + // Verify rollback + expect(manager.getAllSettings().erp.url).toBe(originalUrl) + + manager.save.mockRestore() }) }) From a9670500583550f98a95552b16ab552773096abe Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 14:57:06 +0800 Subject: [PATCH 08/12] feat: update settings handler to use savePartialSettings - Change parameter type from SettingsData to Partial - Call savePartialSettings() instead of saveAllSettings() - Return detailed error messages from savePartialSettings - Add logging for sections being saved This change enables partial settings save functionality, allowing the UI to save only specific settings sections without requiring the complete settings object. Co-Authored-By: Claude Sonnet 4.5 --- src/main/ipc/settings-handler.ts | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/settings-handler.ts b/src/main/ipc/settings-handler.ts index 08514cd..8ac2bc0 100644 --- a/src/main/ipc/settings-handler.ts +++ b/src/main/ipc/settings-handler.ts @@ -78,25 +78,38 @@ export function registerSettingsHandlers(): void { }) /** - * Save settings + * Save settings (updated to use partial save) */ ipcMain.handle( 'settings:saveSettings', - async (_event, settings: SettingsData): Promise => { + async (_event, settings: Partial): Promise => { try { - log.info('Saving settings') - const success = await configManager.saveAllSettings(settings) - if (success) { + log.info('Saving settings', { + sections: Object.keys(settings) + }) + + // Use partial save method + const result = await configManager.savePartialSettings(settings) + + if (result.success) { log.info('Settings saved successfully') return { success: true } } else { - log.warn('Failed to save settings') - return { success: false, error: '保存设置失败' } + log.warn('Failed to save settings', { + error: result.error + }) + return { + success: false, + error: result.error || '保存设置失败' + } } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' log.error('Error saving settings', { error: message }) - return { success: false, error: `保存设置失败:${message}` } + return { + success: false, + error: `保存设置失败:${message}` + } } } ) From 4d8df611877e616faefbd166d118c91b54b2e58a Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 15:05:40 +0800 Subject: [PATCH 09/12] feat: send only ERP fields from settings page (defensive programming) --- src/renderer/src/pages/SettingsPage.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index 2ef7ae5..d0b014f 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -60,7 +60,17 @@ const SettingsPage: React.FC = () => { const handleSaveSettings = async () => { try { - const result = await window.electron.settings.saveSettings(settings as any) + // Only send UI-supported fields (double safety) + const partialSettings = { + erp: { + url: settings.erp?.url, + username: settings.erp?.username, + password: settings.erp?.password + } + } + + const result = await window.electron.settings.saveSettings(partialSettings as any) + if (result.success) { setIsModified(false) showMessage('success', '设置保存成功') From 73a49f9de36170d72788a5e76deba9c8a62e40fe Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 15:12:39 +0800 Subject: [PATCH 10/12] docs: add settings partial save feature documentation Co-Authored-By: Claude Sonnet 4.5 --- docs/settings-partial-save.md | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/settings-partial-save.md diff --git a/docs/settings-partial-save.md b/docs/settings-partial-save.md new file mode 100644 index 0000000..6e745a5 --- /dev/null +++ b/docs/settings-partial-save.md @@ -0,0 +1,60 @@ +# Settings Partial Save Feature + +## Overview + +The settings system now implements partial save functionality to prevent unintended overwrites of configuration values. + +## How It Works + +1. **Field Whitelist**: Only fields exposed in the UI can be modified +2. **Deep Merge**: Updates are merged with existing config, preserving unmodified fields +3. **Backup & Rollback**: Config is backed up before save; failures trigger automatic rollback + +## Editable Fields + +Currently editable via UI: +- `erp.url` - ERP system URL +- `erp.username` - ERP login username +- `erp.password` - ERP login password + +## Adding New Editable Fields + +To add a new field to the UI: + +1. Add field to whitelist in `src/main/services/config/config-manager.ts`: + +```typescript +const UI_EDITABLE_FIELDS: string[] = [ + 'erp.url', + 'erp.username', + 'erp.password', + 'database.dbType', // Add new field here +] +``` + +2. Add UI input in `src/renderer/src/pages/SettingsPage.tsx` +3. Update `handleSaveSettings` to include the new field + +## API + +### savePartialSettings(settings: Partial) + +Saves only the provided fields, preserving all existing configuration. + +**Returns:** `{ success: boolean, error?: string }` + +**Validation:** +- Checks whitelist before applying changes +- Returns error for unauthorized fields + +## Error Handling + +- **Unauthorized field**: Returns error message listing invalid fields +- **Save failure**: Automatically restores from backup +- **Backup failure**: Logs warning, continues with save + +## Backup File + +Location: `.env.backup` (in project root) + +Created before every save operation. Used for rollback on failure. From 816060444c06d9ff0c04cd1133a41d01fbf76c79 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 15:42:40 +0800 Subject: [PATCH 11/12] fix: correct cache key format to match .env file structure The root cause of config overwrites was key mismatch: - .env file uses: ERP_URL, DB_TYPE, DB_NAME (underscore uppercase) - Code was using: erp.url, database.dbType (dot notation) Fixed in three methods: - saveAllSettings() - now sets cache with correct keys - resetToDefaults() - now uses correct keys - save() - now reads cache with correct keys This ensures partial save preserves unmodified fields. --- src/main/services/config/config-manager.ts | 198 +++++++++++---------- 1 file changed, 100 insertions(+), 98 deletions(-) diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index 7432089..abcd33a 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -256,21 +256,21 @@ export class ConfigManager { lines.push('# ===========================') lines.push('# ERP 系统配置') lines.push('# ===========================') - lines.push(`ERP_URL=${this.configCache.get('erp.url') || DEFAULT_SETTINGS.erp.url}`) + lines.push(`ERP_URL=${this.configCache.get('ERP_URL') || DEFAULT_SETTINGS.erp.url}`) lines.push( - `ERP_USERNAME=${this.configCache.get('erp.username') || DEFAULT_SETTINGS.erp.username}` + `ERP_USERNAME=${this.configCache.get('ERP_USERNAME') || DEFAULT_SETTINGS.erp.username}` ) lines.push( - `ERP_PASSWORD=${this.configCache.get('erp.password') || DEFAULT_SETTINGS.erp.password}` + `ERP_PASSWORD=${this.configCache.get('ERP_PASSWORD') || DEFAULT_SETTINGS.erp.password}` ) lines.push( - `ERP_HEADLESS=${this.configCache.get('erp.headless') || DEFAULT_SETTINGS.erp.headless}` + `ERP_HEADLESS=${this.configCache.get('ERP_HEADLESS') || DEFAULT_SETTINGS.erp.headless}` ) lines.push( - `ERP_IGNORE_HTTPS_ERRORS=${this.configCache.get('erp.ignoreHttpsErrors') || DEFAULT_SETTINGS.erp.ignoreHttpsErrors}` + `ERP_IGNORE_HTTPS_ERRORS=${this.configCache.get('ERP_IGNORE_HTTPS_ERRORS') || DEFAULT_SETTINGS.erp.ignoreHttpsErrors}` ) lines.push( - `ERP_AUTO_CLOSE_BROWSER=${this.configCache.get('erp.autoCloseBrowser') || DEFAULT_SETTINGS.erp.autoCloseBrowser}` + `ERP_AUTO_CLOSE_BROWSER=${this.configCache.get('ERP_AUTO_CLOSE_BROWSER') || DEFAULT_SETTINGS.erp.autoCloseBrowser}` ) lines.push('') @@ -279,10 +279,10 @@ export class ConfigManager { lines.push('# 数据库配置 - SQL Server') lines.push('# ===========================') lines.push(`# DB_TYPE=sqlserver`) - lines.push(`# DB_SERVER=${this.configCache.get('database.server') || ''}`) - lines.push(`# DB_NAME=${this.configCache.get('database.database') || ''}`) - lines.push(`# DB_USERNAME=${this.configCache.get('database.username') || ''}`) - lines.push(`# DB_PASSWORD=${this.configCache.get('database.password') || ''}`) + lines.push(`# DB_SERVER=${this.configCache.get('DB_SERVER') || ''}`) + lines.push(`# DB_NAME=${this.configCache.get('DB_NAME') || ''}`) + lines.push(`# DB_USERNAME=${this.configCache.get('DB_USERNAME') || ''}`) + lines.push(`# DB_PASSWORD=${this.configCache.get('DB_PASSWORD') || ''}`) lines.push(`DB_SQLSERVER_DRIVER=ODBC Driver 18 for SQL Server`) lines.push(`DB_TRUST_SERVER_CERTIFICATE=yes`) lines.push('') @@ -292,22 +292,22 @@ export class ConfigManager { lines.push('# 数据库配置 - MySQL (切换时使用)') lines.push('# ===========================') lines.push( - `DB_TYPE=${this.configCache.get('database.dbType') || DEFAULT_SETTINGS.database.dbType}` + `DB_TYPE=${this.configCache.get('DB_TYPE') || DEFAULT_SETTINGS.database.dbType}` ) lines.push( - `DB_NAME=${this.configCache.get('database.database') || DEFAULT_SETTINGS.database.database}` + `DB_NAME=${this.configCache.get('DB_NAME') || DEFAULT_SETTINGS.database.database}` ) lines.push( - `DB_USERNAME=${this.configCache.get('database.username') || DEFAULT_SETTINGS.database.username}` + `DB_USERNAME=${this.configCache.get('DB_USERNAME') || DEFAULT_SETTINGS.database.username}` ) lines.push( - `DB_PASSWORD=${this.configCache.get('database.password') || DEFAULT_SETTINGS.database.password}` + `DB_PASSWORD=${this.configCache.get('DB_PASSWORD') || DEFAULT_SETTINGS.database.password}` ) lines.push( - `DB_MYSQL_HOST=${this.configCache.get('database.mysqlHost') || DEFAULT_SETTINGS.database.mysqlHost}` + `DB_MYSQL_HOST=${this.configCache.get('DB_MYSQL_HOST') || DEFAULT_SETTINGS.database.mysqlHost}` ) lines.push( - `DB_MYSQL_PORT=${this.configCache.get('database.mysqlPort') || DEFAULT_SETTINGS.database.mysqlPort}` + `DB_MYSQL_PORT=${this.configCache.get('DB_MYSQL_PORT') || DEFAULT_SETTINGS.database.mysqlPort}` ) lines.push(`DB_MYSQL_CHARSET=utf8mb4`) lines.push('') @@ -327,14 +327,14 @@ export class ConfigManager { lines.push('# 路径配置') lines.push('# ===========================') lines.push( - `PATH_DATA_DIR=${this.configCache.get('paths.dataDir') || DEFAULT_SETTINGS.paths.dataDir}` + `PATH_DATA_DIR=${this.configCache.get('PATH_DATA_DIR') || DEFAULT_SETTINGS.paths.dataDir}` ) lines.push(`PATH_PRODUCTION_ID_FILE=ProductionID.txt`) lines.push( - `PATH_DEFAULT_OUTPUT=${this.configCache.get('paths.defaultOutput') || DEFAULT_SETTINGS.paths.defaultOutput}` + `PATH_DEFAULT_OUTPUT=${this.configCache.get('PATH_DEFAULT_OUTPUT') || DEFAULT_SETTINGS.paths.defaultOutput}` ) lines.push( - `PATH_VALIDATION_OUTPUT=${this.configCache.get('paths.validationOutput') || DEFAULT_SETTINGS.paths.validationOutput}` + `PATH_VALIDATION_OUTPUT=${this.configCache.get('PATH_VALIDATION_OUTPUT') || DEFAULT_SETTINGS.paths.validationOutput}` ) lines.push('') @@ -343,19 +343,19 @@ export class ConfigManager { lines.push('# 数据提取配置') lines.push('# ===========================') lines.push( - `EXTRACTION_BATCH_SIZE=${this.configCache.get('extraction.batchSize') || DEFAULT_SETTINGS.extraction.batchSize}` + `EXTRACTION_BATCH_SIZE=${this.configCache.get('EXTRACTION_BATCH_SIZE') || DEFAULT_SETTINGS.extraction.batchSize}` ) lines.push( - `EXTRACTION_VERBOSE=${this.configCache.get('extraction.verbose') || DEFAULT_SETTINGS.extraction.verbose}` + `EXTRACTION_VERBOSE=${this.configCache.get('EXTRACTION_VERBOSE') || DEFAULT_SETTINGS.extraction.verbose}` ) lines.push( - `EXTRACTION_AUTO_CONVERT=${this.configCache.get('extraction.autoConvert') || DEFAULT_SETTINGS.extraction.autoConvert}` + `EXTRACTION_AUTO_CONVERT=${this.configCache.get('EXTRACTION_AUTO_CONVERT') || DEFAULT_SETTINGS.extraction.autoConvert}` ) lines.push( - `EXTRACTION_MERGE_BATCHES=${this.configCache.get('extraction.mergeBatches') || DEFAULT_SETTINGS.extraction.mergeBatches}` + `EXTRACTION_MERGE_BATCHES=${this.configCache.get('EXTRACTION_MERGE_BATCHES') || DEFAULT_SETTINGS.extraction.mergeBatches}` ) lines.push( - `EXTRACTION_ENABLE_DB_PERSISTENCE=${this.configCache.get('extraction.enableDbPersistence') || DEFAULT_SETTINGS.extraction.enableDbPersistence}` + `EXTRACTION_ENABLE_DB_PERSISTENCE=${this.configCache.get('EXTRACTION_ENABLE_DB_PERSISTENCE') || DEFAULT_SETTINGS.extraction.enableDbPersistence}` ) lines.push('') @@ -364,22 +364,22 @@ export class ConfigManager { lines.push('# 校验配置') lines.push('# ===========================') lines.push( - `VALIDATION_DATA_SOURCE=${this.configCache.get('validation.dataSource') || DEFAULT_SETTINGS.validation.dataSource}` + `VALIDATION_DATA_SOURCE=${this.configCache.get('VALIDATION_DATA_SOURCE') || DEFAULT_SETTINGS.validation.dataSource}` ) lines.push( - `VALIDATION_USE_DATABASE=${this.configCache.get('validation.useDatabase') || true}` + `VALIDATION_USE_DATABASE=${this.configCache.get('VALIDATION_USE_DATABASE') || true}` ) lines.push( - `VALIDATION_BATCH_SIZE=${this.configCache.get('validation.batchSize') || DEFAULT_SETTINGS.validation.batchSize}` + `VALIDATION_BATCH_SIZE=${this.configCache.get('VALIDATION_BATCH_SIZE') || DEFAULT_SETTINGS.validation.batchSize}` ) lines.push( - `VALIDATION_ENABLE_CRUD=${this.configCache.get('validation.enableCrud') || DEFAULT_SETTINGS.validation.enableCrud}` + `VALIDATION_ENABLE_CRUD=${this.configCache.get('VALIDATION_ENABLE_CRUD') || DEFAULT_SETTINGS.validation.enableCrud}` ) lines.push( - `VALIDATION_DEFAULT_MANAGER=${this.configCache.get('validation.defaultManager') || DEFAULT_SETTINGS.validation.defaultManager}` + `VALIDATION_DEFAULT_MANAGER=${this.configCache.get('VALIDATION_DEFAULT_MANAGER') || DEFAULT_SETTINGS.validation.defaultManager}` ) lines.push( - `VALIDATION_MATCH_MODE=${this.configCache.get('validation.matchMode') || DEFAULT_SETTINGS.validation.matchMode}` + `VALIDATION_MATCH_MODE=${this.configCache.get('VALIDATION_MATCH_MODE') || DEFAULT_SETTINGS.validation.matchMode}` ) lines.push('') @@ -388,13 +388,13 @@ export class ConfigManager { lines.push('# UI 配置') lines.push('# ===========================') lines.push( - `UI_FONT_FAMILY=${this.configCache.get('ui.fontFamily') || DEFAULT_SETTINGS.ui.fontFamily}` + `UI_FONT_FAMILY=${this.configCache.get('UI_FONT_FAMILY') || DEFAULT_SETTINGS.ui.fontFamily}` ) lines.push( - `UI_FONT_SIZE=${this.configCache.get('ui.fontSize') || DEFAULT_SETTINGS.ui.fontSize}` + `UI_FONT_SIZE=${this.configCache.get('UI_FONT_SIZE') || DEFAULT_SETTINGS.ui.fontSize}` ) lines.push( - `UI_PRODUCTION_ID_INPUT_WIDTH=${this.configCache.get('ui.productionIdInputWidth') || DEFAULT_SETTINGS.ui.productionIdInputWidth}` + `UI_PRODUCTION_ID_INPUT_WIDTH=${this.configCache.get('UI_PRODUCTION_ID_INPUT_WIDTH') || DEFAULT_SETTINGS.ui.productionIdInputWidth}` ) lines.push('') @@ -403,7 +403,7 @@ export class ConfigManager { lines.push('# 执行配置') lines.push('# ===========================') lines.push( - `EXECUTION_DRYRUN=${this.configCache.get('execution.dryRun') || DEFAULT_SETTINGS.execution.dryRun}` + `EXECUTION_DRYRUN=${this.configCache.get('EXECUTION_DRYRUN') || DEFAULT_SETTINGS.execution.dryRun}` ) const content = lines.join('\n') @@ -506,49 +506,49 @@ export class ConfigManager { * Save settings from SettingsData object */ public async saveAllSettings(settings: SettingsData): Promise { - // ERP settings - this.set('erp.url', settings.erp.url) - this.set('erp.username', settings.erp.username) - this.set('erp.password', settings.erp.password) - this.set('erp.headless', settings.erp.headless) - this.set('erp.ignoreHttpsErrors', settings.erp.ignoreHttpsErrors) - this.set('erp.autoCloseBrowser', settings.erp.autoCloseBrowser) + // ERP settings - use underscore uppercase keys to match .env file + this.set('ERP_URL', settings.erp.url) + this.set('ERP_USERNAME', settings.erp.username) + this.set('ERP_PASSWORD', settings.erp.password) + this.set('ERP_HEADLESS', settings.erp.headless) + this.set('ERP_IGNORE_HTTPS_ERRORS', settings.erp.ignoreHttpsErrors) + this.set('ERP_AUTO_CLOSE_BROWSER', settings.erp.autoCloseBrowser) // Database settings - this.set('database.dbType', settings.database.dbType) - this.set('database.server', settings.database.server) - this.set('database.mysqlHost', settings.database.mysqlHost) - this.set('database.mysqlPort', settings.database.mysqlPort) - this.set('database.database', settings.database.database) - this.set('database.username', settings.database.username) - this.set('database.password', settings.database.password) + this.set('DB_TYPE', settings.database.dbType) + this.set('DB_SERVER', settings.database.server) + this.set('DB_MYSQL_HOST', settings.database.mysqlHost) + this.set('DB_MYSQL_PORT', settings.database.mysqlPort) + this.set('DB_NAME', settings.database.database) + this.set('DB_USERNAME', settings.database.username) + this.set('DB_PASSWORD', settings.database.password) // Path settings - this.set('paths.dataDir', settings.paths.dataDir) - this.set('paths.defaultOutput', settings.paths.defaultOutput) - this.set('paths.validationOutput', settings.paths.validationOutput) + this.set('PATH_DATA_DIR', settings.paths.dataDir) + this.set('PATH_DEFAULT_OUTPUT', settings.paths.defaultOutput) + this.set('PATH_VALIDATION_OUTPUT', settings.paths.validationOutput) // Extraction settings - this.set('extraction.batchSize', settings.extraction.batchSize) - this.set('extraction.verbose', settings.extraction.verbose) - this.set('extraction.autoConvert', settings.extraction.autoConvert) - this.set('extraction.mergeBatches', settings.extraction.mergeBatches) - this.set('extraction.enableDbPersistence', settings.extraction.enableDbPersistence) + this.set('EXTRACTION_BATCH_SIZE', settings.extraction.batchSize) + this.set('EXTRACTION_VERBOSE', settings.extraction.verbose) + this.set('EXTRACTION_AUTO_CONVERT', settings.extraction.autoConvert) + this.set('EXTRACTION_MERGE_BATCHES', settings.extraction.mergeBatches) + this.set('EXTRACTION_ENABLE_DB_PERSISTENCE', settings.extraction.enableDbPersistence) // Validation settings - this.set('validation.dataSource', settings.validation.dataSource) - this.set('validation.batchSize', settings.validation.batchSize) - this.set('validation.matchMode', settings.validation.matchMode) - this.set('validation.enableCrud', settings.validation.enableCrud) - this.set('validation.defaultManager', settings.validation.defaultManager) + this.set('VALIDATION_DATA_SOURCE', settings.validation.dataSource) + this.set('VALIDATION_BATCH_SIZE', settings.validation.batchSize) + this.set('VALIDATION_MATCH_MODE', settings.validation.matchMode) + this.set('VALIDATION_ENABLE_CRUD', settings.validation.enableCrud) + this.set('VALIDATION_DEFAULT_MANAGER', settings.validation.defaultManager) // UI settings - this.set('ui.fontFamily', settings.ui.fontFamily) - this.set('ui.fontSize', settings.ui.fontSize) - this.set('ui.productionIdInputWidth', settings.ui.productionIdInputWidth) + this.set('UI_FONT_FAMILY', settings.ui.fontFamily) + this.set('UI_FONT_SIZE', settings.ui.fontSize) + this.set('UI_PRODUCTION_ID_INPUT_WIDTH', settings.ui.productionIdInputWidth) // Execution settings - this.set('execution.dryRun', settings.execution.dryRun) + this.set('EXECUTION_DRYRUN', settings.execution.dryRun) return this.save() } @@ -573,10 +573,12 @@ export class ConfigManager { } } - // Step 2: Read current settings + // Step 2: Read current settings from .env file directly + // This avoids the cache key mismatch issue (ERP_URL vs erp.url) + await this.loadEnvFile() const currentSettings = this.getAllSettings() - // Step 3: Deep merge + // Step 3: Deep merge - only update provided fields const mergedSettings = deepMerge(currentSettings, settings) // Step 4: Backup and save @@ -622,43 +624,43 @@ export class ConfigManager { // Clear cache and reload from defaults this.configCache.clear() - // Set all defaults - this.set('erp.url', DEFAULT_SETTINGS.erp.url) - this.set('erp.username', DEFAULT_SETTINGS.erp.username) - this.set('erp.password', DEFAULT_SETTINGS.erp.password) - this.set('erp.headless', DEFAULT_SETTINGS.erp.headless) - this.set('erp.ignoreHttpsErrors', DEFAULT_SETTINGS.erp.ignoreHttpsErrors) - this.set('erp.autoCloseBrowser', DEFAULT_SETTINGS.erp.autoCloseBrowser) + // Set all defaults using underscore uppercase keys + this.set('ERP_URL', DEFAULT_SETTINGS.erp.url) + this.set('ERP_USERNAME', DEFAULT_SETTINGS.erp.username) + this.set('ERP_PASSWORD', DEFAULT_SETTINGS.erp.password) + this.set('ERP_HEADLESS', DEFAULT_SETTINGS.erp.headless) + this.set('ERP_IGNORE_HTTPS_ERRORS', DEFAULT_SETTINGS.erp.ignoreHttpsErrors) + this.set('ERP_AUTO_CLOSE_BROWSER', DEFAULT_SETTINGS.erp.autoCloseBrowser) - this.set('database.dbType', DEFAULT_SETTINGS.database.dbType) - this.set('database.server', DEFAULT_SETTINGS.database.server) - this.set('database.mysqlHost', DEFAULT_SETTINGS.database.mysqlHost) - this.set('database.mysqlPort', DEFAULT_SETTINGS.database.mysqlPort) - this.set('database.database', DEFAULT_SETTINGS.database.database) - this.set('database.username', DEFAULT_SETTINGS.database.username) - this.set('database.password', DEFAULT_SETTINGS.database.password) + this.set('DB_TYPE', DEFAULT_SETTINGS.database.dbType) + this.set('DB_SERVER', DEFAULT_SETTINGS.database.server) + this.set('DB_MYSQL_HOST', DEFAULT_SETTINGS.database.mysqlHost) + this.set('DB_MYSQL_PORT', DEFAULT_SETTINGS.database.mysqlPort) + this.set('DB_NAME', DEFAULT_SETTINGS.database.database) + this.set('DB_USERNAME', DEFAULT_SETTINGS.database.username) + this.set('DB_PASSWORD', DEFAULT_SETTINGS.database.password) - this.set('paths.dataDir', DEFAULT_SETTINGS.paths.dataDir) - this.set('paths.defaultOutput', DEFAULT_SETTINGS.paths.defaultOutput) - this.set('paths.validationOutput', DEFAULT_SETTINGS.paths.validationOutput) + this.set('PATH_DATA_DIR', DEFAULT_SETTINGS.paths.dataDir) + this.set('PATH_DEFAULT_OUTPUT', DEFAULT_SETTINGS.paths.defaultOutput) + this.set('PATH_VALIDATION_OUTPUT', DEFAULT_SETTINGS.paths.validationOutput) - this.set('extraction.batchSize', DEFAULT_SETTINGS.extraction.batchSize) - this.set('extraction.verbose', DEFAULT_SETTINGS.extraction.verbose) - this.set('extraction.autoConvert', DEFAULT_SETTINGS.extraction.autoConvert) - this.set('extraction.mergeBatches', DEFAULT_SETTINGS.extraction.mergeBatches) - this.set('extraction.enableDbPersistence', DEFAULT_SETTINGS.extraction.enableDbPersistence) + this.set('EXTRACTION_BATCH_SIZE', DEFAULT_SETTINGS.extraction.batchSize) + this.set('EXTRACTION_VERBOSE', DEFAULT_SETTINGS.extraction.verbose) + this.set('EXTRACTION_AUTO_CONVERT', DEFAULT_SETTINGS.extraction.autoConvert) + this.set('EXTRACTION_MERGE_BATCHES', DEFAULT_SETTINGS.extraction.mergeBatches) + this.set('EXTRACTION_ENABLE_DB_PERSISTENCE', DEFAULT_SETTINGS.extraction.enableDbPersistence) - this.set('validation.dataSource', DEFAULT_SETTINGS.validation.dataSource) - this.set('validation.batchSize', DEFAULT_SETTINGS.validation.batchSize) - this.set('validation.matchMode', DEFAULT_SETTINGS.validation.matchMode) - this.set('validation.enableCrud', DEFAULT_SETTINGS.validation.enableCrud) - this.set('validation.defaultManager', DEFAULT_SETTINGS.validation.defaultManager) + this.set('VALIDATION_DATA_SOURCE', DEFAULT_SETTINGS.validation.dataSource) + this.set('VALIDATION_BATCH_SIZE', DEFAULT_SETTINGS.validation.batchSize) + this.set('VALIDATION_MATCH_MODE', DEFAULT_SETTINGS.validation.matchMode) + this.set('VALIDATION_ENABLE_CRUD', DEFAULT_SETTINGS.validation.enableCrud) + this.set('VALIDATION_DEFAULT_MANAGER', DEFAULT_SETTINGS.validation.defaultManager) - this.set('ui.fontFamily', DEFAULT_SETTINGS.ui.fontFamily) - this.set('ui.fontSize', DEFAULT_SETTINGS.ui.fontSize) - this.set('ui.productionIdInputWidth', DEFAULT_SETTINGS.ui.productionIdInputWidth) + this.set('UI_FONT_FAMILY', DEFAULT_SETTINGS.ui.fontFamily) + this.set('UI_FONT_SIZE', DEFAULT_SETTINGS.ui.fontSize) + this.set('UI_PRODUCTION_ID_INPUT_WIDTH', DEFAULT_SETTINGS.ui.productionIdInputWidth) - this.set('execution.dryRun', DEFAULT_SETTINGS.execution.dryRun) + this.set('EXECUTION_DRYRUN', DEFAULT_SETTINGS.execution.dryRun) return DEFAULT_SETTINGS } From baaa031dac38036f5185731ff6d6085ab4d34571 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 3 Mar 2026 15:51:47 +0800 Subject: [PATCH 12/12] debug: add logging to savePartialSettings for troubleshooting --- src/main/services/config/config-manager.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/services/config/config-manager.ts b/src/main/services/config/config-manager.ts index abcd33a..c313456 100644 --- a/src/main/services/config/config-manager.ts +++ b/src/main/services/config/config-manager.ts @@ -578,9 +578,21 @@ export class ConfigManager { await this.loadEnvFile() const currentSettings = this.getAllSettings() + log.info('Current settings before merge', { + erpUrl: currentSettings.erp.url, + dbType: currentSettings.database.dbType, + dbName: currentSettings.database.database + }) + // Step 3: Deep merge - only update provided fields const mergedSettings = deepMerge(currentSettings, settings) + log.info('Settings after merge', { + erpUrl: mergedSettings.erp.url, + dbType: mergedSettings.database.dbType, + dbName: mergedSettings.database.database + }) + // Step 4: Backup and save const backupSuccess = await this.backupEnvFile() if (!backupSuccess) {