- Add MaterialTypeManagementDialog component for managing material type keywords - Add MaterialsTypeToBeDeletedDAO for database operations - Add material-type-handler IPC handlers - Update CleanerPage with type management button - Add database fix scripts for AUTO_INCREMENT - Update documentation for settings partial save and validation flow Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
12 KiB
12 KiB
配置保存优化设计文档
日期: 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(字段白名单) 的组合策略:
核心策略
- 部分更新:只更新传入的字段,保留其他字段不变
- 白名单验证:只允许 UI 支持的字段被修改
- 备份机制:保存前备份,失败可回滚
- 安全日志:记录所有配置变更操作
架构设计
数据流
┌─────────────────┐
│ 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)
})
})
手动验证步骤
- 打开
.env,记录所有字段值 - 打开设置页面,只修改 ERP URL
- 点击保存
- 检查
.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
}
实施计划
下一步将创建详细的实施计划,包括:
- 在 ConfigManager 中添加深度合并和验证函数
- 实现
savePartialSettings()方法 - 添加备份与恢复机制
- 更新 IPC handler 调用
- 前端优化(只发送必要字段)
- 编写单元测试
- 集成测试和手动验证
风险与缓解
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| 深度合并逻辑错误 | 配置错误 | 完善单元测试覆盖 |
| 备份文件权限问题 | 无法恢复 | 错误处理 + 日志 |
| 白名单漏配置 | 功能受限 | 清晰的文档 + 代码注释 |
| 并发保存冲突 | 数据不一致 | 单实例 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中未包含的字段被覆盖 - 设计原则:安全优先、最小化修改、可扩展性