diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 7ee5a55f9..4ca6d448a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -2040,7 +2040,7 @@ fn persist_game_creator_app_config( let config = normalize_game_creator_app_config(config)?; let path = writable_game_creator_config_path()?; let content = serialize_game_creator_app_config_for_renderer_write(&config)?; - write_game_creator_config_atomically(&path, &format!("{content}\n"))?; + let mut writes = vec![(path, format!("{content}\n"))]; let saved: serde_json::Value = serde_json::from_str(&content) .map_err(|error| format!("解析已序列化客户端配置失败:{error}"))?; for (overlay_path, mut overlay) in overlays { @@ -2062,9 +2062,10 @@ fn persist_game_creator_app_config( if overlay != previous { let content = serde_json::to_string_pretty(&overlay) .map_err(|error| format!("序列化客户端覆盖配置失败:{error}"))?; - write_game_creator_config_atomically(&overlay_path, &format!("{content}\n"))?; + writes.push((overlay_path, format!("{content}\n"))); } } + crate::config::write_game_creator_config_batch(&writes)?; // `config` is already normalized and is exactly what was persisted. // Avoid reloading it here: a reload repeats the Windows private-path and // ACL checks and made saving the settings panel appear to hang. diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 9eaf82727..8f586dbde 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -3678,6 +3678,45 @@ fn game_creator_config_backup_path(path: &Path) -> PathBuf { )) } +pub(crate) fn write_game_creator_config_batch(writes: &[(PathBuf, String)]) -> Result<(), String> { + if writes.len() == 1 { + return write_game_creator_config_atomically(&writes[0].0, &writes[0].1); + } + let originals = writes + .iter() + .map(|(path, _)| read_game_creator_config_file(path)) + .collect::, _>>()?; + for (index, (path, content)) in writes.iter().enumerate() { + if let Err(mut error) = write_game_creator_config_atomically(path, content) { + // 写入可能在替换后的权限检查失败,因此失败目标也需要核对并恢复。 + for rollback_index in (0..=index).rev() { + let path = &writes[rollback_index].0; + let original = &originals[rollback_index]; + if read_game_creator_config_file(path).ok().as_ref() == Some(original) { + continue; + } + let restored = match original { + Some(content) => write_game_creator_config_atomically(path, content), + None => fs::remove_file(path) + .or_else(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + Ok(()) + } else { + Err(error) + } + }) + .map_err(|error| error.to_string()), + }; + if let Err(restore_error) = restored { + error.push_str(&format!(";恢复配置失败:{}: {restore_error}", path.display())); + } + } + return Err(error); + } + } + Ok(()) +} + pub(crate) fn write_game_creator_config_atomically( path: &Path, content: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index f5be621e0..65e8520b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -807,6 +807,36 @@ fn app_config_commands_write_runtime_config_file() { fs::remove_dir_all(root).expect("cleanup runtime config dir"); } +#[test] +fn app_config_batch_restores_main_when_overlay_write_fails() { + for main_exists in [false, true] { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("config dir"); + let main = root.join(GAME_CREATOR_CONFIG_FILE_NAME); + let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + let original = "{\"llm\":{\"stream\":false}}\n"; + if main_exists { + fs::write(&main, original).expect("main config"); + } + fs::write(&overlay, original).expect("overlay config"); + // 普通目录占据备份路径,让覆盖文件在替换前失败。 + fs::create_dir(root.join(format!(".{}.previous", GAME_CREATOR_LOCAL_CONFIG_FILE_NAME))) + .expect("block overlay replacement"); + crate::config::write_game_creator_config_batch(&[ + (main.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()), + (overlay.clone(), "{\"llm\":{\"stream\":true}}\n".to_string()), + ]) + .expect_err("overlay replacement must fail"); + if main_exists { + assert_eq!(fs::read_to_string(&main).expect("restored main"), original); + } else { + assert!(!main.exists()); + } + assert_eq!(fs::read_to_string(&overlay).expect("unchanged overlay"), original); + fs::remove_dir_all(root).expect("cleanup config dir"); + } +} + #[test] fn app_config_save_updates_conflicting_local_overlay() { let root = unique_project_path(); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 763d0eca0..4e77a0b27 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,10 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-09-09 常用设置跨文件保存失败 + +主配置与 local overlay 的单文件原子写入不能保证整体成功;覆盖层写入失败会留下混合配置。保存前先序列化全部变更,多文件保存保留原内容,错误时逆序恢复并报告回滚失败;单文件保持原写入路径,成功后不回读、不触发外部诊断。此回滚仅处理可捕获错误,不承诺进程崩溃下的事务恢复。 + ## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe - **现象**:Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a8ec1b2ce..1c395dd49 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -12,7 +12,7 @@ 常用设置负责运行参数的读取、编辑和保存,配置读写独立于账号权限诊断。账号权限由登录会话与实际智能服务请求链路处理,设置面板只维护配置草稿与读写反馈。 -保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,模型选择仅同步 `selectedModelId`;无冲突时不写覆盖文件。主配置和需要更新的覆盖文件均写入成功后返回规范化配置,写入失败则报告错误,不执行保存后回读或外部诊断。各文件沿用现有原子写入;跨文件失败可能已保存部分内容,用户可重新保存。 +保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,模型选择仅同步 `selectedModelId`;无冲突时不写覆盖文件。所有内容先完成序列化,多文件写入前保存原始内容,任一写入失败时逆序恢复已变更文件,回滚失败须明确报告。各文件沿用现有原子写入,不提供断电或进程崩溃下的多文件事务保证。全部成功后直接返回规范化配置,不执行保存后回读或外部诊断;单文件保存保持原路径。 ## 2026-09-08 Web 游戏 npm 与 Phaser 4 产物合同