Files
BIPMaterialManager/docs/plans/2026-03-03-settings-partial-save-design.md
Misaka_Company 79934a58d0 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 12:24:27 +08:00

12 KiB
Raw Blame History

配置保存优化设计文档

日期: 2026-03-03 分支: fix/settings-partial-save 状态: 设计阶段


问题描述

当前设置界面只能配置 3 个字段ERP URL、用户名、密码但保存后会意外覆盖 .env 文件中的其他配置项(如 DB_TYPEVALIDATION_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. 深度合并工具函数

/**
 * 深度合并两个对象,只更新 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. 字段白名单验证

/**
 * 定义 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. 部分保存方法

/**
 * 保存部分配置(只更新传入的字段)
 */
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. 备份与恢复机制

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

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

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', '保存设置时发生错误')
  }
}

测试策略

单元测试场景

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. 白名单配置化

当设置页面需要支持更多配置时:

const UI_EDITABLE_FIELDS: string[] = [
  'erp.url',
  'erp.username',
  'erp.password',
  'database.dbType',        // 新增
  'paths.dataDir',          // 新增
  'extraction.batchSize',   // 新增
  // ...
]

2. 按用户角色分级

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. 配置变更审计

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 中未包含的字段被覆盖
  • 设计原则:安全优先、最小化修改、可扩展性