feat: merge settings partial save feature

Features:
- Partial settings save with deep merge
- Backup and rollback mechanism
- UI field whitelist validation
- Defensive programming in settings page

Safety:
- Auto-backup before save
- Automatic rollback on failure
- Field validation at both client and server
This commit is contained in:
Misaka_Company
2026-03-03 17:15:03 +08:00
7 changed files with 2025 additions and 112 deletions

View File

@@ -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<SettingsData>)
┌─────────────────────────────┐
│ ConfigManager │
│ ┌─────────────────────┐ │
│ │ 1. 验证字段白名单 │ │
│ │ 2. 深度合并当前配置 │ │
│ │ 3. 备份 .env 文件 │ │
│ │ 4. 原子写入新配置 │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
```
### 改动点
| 文件 | 改动类型 | 说明 |
|------|---------|------|
| `src/main/services/config/config-manager.ts` | 核心 | 新增 `savePartialSettings()`、深度合并、备份机制 |
| `src/main/ipc/settings-handler.ts` | 调整 | IPC 参数改为 `Partial<SettingsData>` |
| `src/renderer/src/pages/SettingsPage.tsx` | 优化 | 只发送 UI 支持的字段 |
---
## 核心实现
### 1. 深度合并工具函数
```typescript
/**
* 深度合并两个对象,只更新 target 中存在的字段
* 保留 source 中 target 没有的字段
*/
function deepMerge<T>(source: T, target: Partial<T>): 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<keyof T, string>]
}
}
}
return result
}
function isObject(value: unknown): value is Record<string, unknown> {
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<SettingsData>): {
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<SettingsData>
): 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<boolean> {
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<boolean> {
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<SettingsData>): Promise<SaveSettingsResult> => {
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<SettingsData>`
- 调用 `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<UserType, string[]> = {
Admin: ['*'],
User: ['erp.url', 'erp.username', 'erp.password'],
Guest: []
}
function validateEditableFields(
settings: Partial<SettingsData>,
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` 中未包含的字段被覆盖
- 设计原则:安全优先、最小化修改、可扩展性

View File

@@ -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<string, unknown> {
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<T>(source: T, target: Partial<T>): 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<keyof T, string>]
}
}
}
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<SettingsData>): {
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<string, string> = 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<boolean> {
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<boolean> {
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<SettingsData>
): 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<SettingsData>): Promise<SaveSettingsResult> => {
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<SettingsData>)
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)

View File

@@ -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<SettingsData>)
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.

View File

@@ -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<SaveSettingsResult> => {
async (_event, settings: Partial<SettingsData>): Promise<SaveSettingsResult> => {
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}`
}
}
}
)

View File

@@ -9,20 +9,16 @@ 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,
ErpConfig,
DatabaseConfig,
PathsConfig,
ExtractionConfig,
ValidationConfig,
UiConfig,
ExecutionConfig,
DatabaseType,
MatchMode,
ValidationDataSource
} from '../../types/settings.types'
const log = createLogger('ConfigManager')
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
@@ -76,12 +72,83 @@ const DEFAULT_SETTINGS: SettingsData = {
}
}
/**
* Check if value is a plain object
*/
function isObject(value: unknown): value is Record<string, unknown> {
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<T>(source: T, target: Partial<T>): 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 as T[Extract<keyof T, string>],
targetValue as Partial<T[Extract<keyof T, string>]>
)
} else if (targetValue !== undefined) {
result[key] = targetValue as T[Extract<keyof T, string>]
}
}
}
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<SettingsData>): {
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
*/
export class ConfigManager {
private static instance: ConfigManager | null = null
private envPath: string
private backupPath: string
private configCache: Map<string, string> = new Map()
private initialized: boolean = false
@@ -90,6 +157,7 @@ export class ConfigManager {
return
}
this.envPath = path.resolve(__dirname, '../../.env')
this.backupPath = path.resolve(__dirname, '../../.env.backup')
this.initialized = true
}
@@ -115,6 +183,9 @@ export class ConfigManager {
*/
private async loadEnvFile(): Promise<void> {
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')
@@ -185,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('')
@@ -208,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('')
@@ -221,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('')
@@ -256,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('')
@@ -272,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('')
@@ -293,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('')
@@ -317,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('')
@@ -332,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')
@@ -435,53 +506,129 @@ export class ConfigManager {
* Save settings from SettingsData object
*/
public async saveAllSettings(settings: SettingsData): Promise<boolean> {
// 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()
}
/**
* Save partial settings (only update provided fields)
* Preserves all existing fields not included in the update
*/
public async savePartialSettings(
settings: Partial<SettingsData>
): 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 from .env file directly
// This avoids the cache key mismatch issue (ERP_URL vs erp.url)
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) {
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
*/
@@ -489,43 +636,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
}
@@ -536,4 +683,39 @@ export class ConfigManager {
public getDefaultSettings(): SettingsData {
return DEFAULT_SETTINGS
}
/**
* Backup current .env file
*/
private async backupEnvFile(): Promise<boolean> {
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<boolean> {
try {
if (fs.existsSync(this.backupPath)) {
fs.copyFileSync(this.backupPath, this.envPath)
await this.loadEnvFile()
log.debug('Restored from backup')
return true
}
return false
} catch (error) {
log.error('Failed to restore backup', { error })
return false
}
}
}

View File

@@ -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', '设置保存成功')

View File

@@ -0,0 +1,217 @@
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()
// Reload to ensure clean state from previous tests
await manager['loadEnvFile']()
// 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)
// Reload from disk to populate cache
await manager['loadEnvFile']()
// 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')
})
})
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, which is src/main/)
const fs = await import('fs')
const path = await import('path')
const backupPath = path.resolve(process.cwd(), 'src/main/.env.backup')
expect(fs.existsSync(backupPath)).toBe(true)
})
// 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()
// 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']()
// 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({
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)
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()
// 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()
})
})