diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index efb9e8dae..9f39a8114 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -118,7 +118,7 @@ pub(crate) fn save_ui_design_state_at( return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string()); } let current = - read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; + read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; validate_document(¤t, &expected_project_id, &asset_id)?; if current.revision != input.expected_revision { return Ok(SaveUiDesignStateResult::Conflict { @@ -152,7 +152,7 @@ pub(crate) fn save_ui_design_state_at( validate_document(&next, &expected_project_id, &asset_id)?; write_ui_design_document(root, &asset.local_path, &next)?; let installed = - read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; + read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; validate_document(&installed, &expected_project_id, &asset_id)?; if installed != next { return Err("UI 设计 State 安装后回读与待写内容不一致".to_string()); @@ -221,25 +221,47 @@ fn read_ui_design_document( asset_id: &str, ) -> Result { let primary_path = resolve_local_project_path(root, relative_path)?; - let backup_path = agent_runtime_json_sidecar_backup_path(&primary_path); - let read_candidate = |path: &Path| { - read_ui_design_document_path(path).and_then(|document| { - validate_document(&document, project_id, asset_id)?; - Ok(document) - }) - }; - match read_candidate(&primary_path) { + match read_valid_ui_design_document_path(&primary_path, project_id, asset_id) { Ok(document) => Ok(document), - Err(primary_error) => match read_candidate(&backup_path) { - Ok(document) => { - write_ui_design_document(root, relative_path, &document)?; - Ok(document) - } - Err(_) => Err(primary_error), - }, + Err(_) => { + let _lock = acquire_project_write_lock(root, "ui_design.state_recover")?; + read_ui_design_document_locked(root, relative_path, project_id, asset_id) + } } } +fn read_ui_design_document_locked( + root: &Path, + relative_path: &str, + project_id: &str, + asset_id: &str, +) -> Result { + let primary_path = resolve_local_project_path(root, relative_path)?; + let backup_path = agent_runtime_json_sidecar_backup_path(&primary_path); + match read_valid_ui_design_document_path(&primary_path, project_id, asset_id) { + Ok(document) => Ok(document), + Err(primary_error) => { + match read_valid_ui_design_document_path(&backup_path, project_id, asset_id) { + Ok(document) => { + write_ui_design_document(root, relative_path, &document)?; + Ok(document) + } + Err(_) => Err(primary_error), + } + } + } +} + +fn read_valid_ui_design_document_path( + path: &Path, + project_id: &str, + asset_id: &str, +) -> Result { + let document = read_ui_design_document_path(path)?; + validate_document(&document, project_id, asset_id)?; + Ok(document) +} + fn read_ui_design_document_path(path: &Path) -> Result { let (mut file, metadata) = open_project_snapshot_regular_file(path, "UI 设计 State")?; if metadata.len() > UI_DESIGN_STATE_MAX_BYTES as u64 { @@ -681,6 +703,43 @@ mod tests { ); } + #[test] + fn recovery_install_respects_the_project_write_lock() { + let (directory, asset_id) = fixture(); + let first = state_with_unavailable_image("assets/first-missing.png"); + let second = state_with_unavailable_image("assets/second-missing.png"); + save_ui_design_state_at(input(directory.path(), &asset_id, 0, first.clone())) + .expect("save first state"); + save_ui_design_state_at(input(directory.path(), &asset_id, 1, second)) + .expect("save second state"); + + let asset = ui_design_asset(directory.path(), PROJECT_ID, &asset_id).expect("find asset"); + let primary = resolve_local_project_path(directory.path(), &asset.local_path) + .expect("resolve primary"); + fs::write(&primary, b"{broken").expect("corrupt primary"); + + let project_lock = acquire_project_write_lock(directory.path(), "test.concurrent-save") + .expect("hold project write lock"); + let error = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .expect_err("recovery must not install while another writer holds the lock"); + assert!(error.contains("项目正在被其他写操作占用")); + assert!(read_ui_design_document_path(&primary).is_err()); + drop(project_lock); + + let recovered = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id, + }) + .expect("recover after project lock is released"); + assert_eq!(recovered.revision, 1); + assert_eq!(recovered.state, first); + } + #[test] fn rejects_legacy_empty_object_and_unknown_envelope_fields() { let (directory, asset_id) = fixture(); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 215e8fdd2..9ed60b9fa 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1078,5 +1078,5 @@ game-project/ - UI 编辑器复用现有 manifest `kind: "UI"`、`mediaType: "application/json"` 资源,不增加平行 asset kind。资源文件固定为严格 `game-creator-ui-design-state.v1` JSON envelope:`projectId`、`assetId`、每资源 `revision` 和 Rust 唯一源 `State`;旧空对象、未知字段、身份错配、超限、无效内部引用和不安全相对路径均失败关闭。 - Tauri 专用 load/save command 只接受项目路径、期望项目 ID、manifest asset ID 和(保存时)资源 revision;Rust 按 manifest 解析受控本地路径并在项目写锁内做 CAS。相同 `State` 返回 unchanged 且不推进 project revision;不同内容安装并回读一致后才推进 revision,后续推进失败返回 `reconciliation-required`,不伪装为完整保存。 -- UI State 原子安装保留最近一个有效 `.previous` 恢复副本。主文件损坏时仅在恢复副本通过同一严格校验后恢复;任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。 +- UI State 原子安装保留最近一个有效 `.previous` 恢复副本。主文件损坏时仅在恢复副本通过同一严格校验后恢复;恢复安装与保存共用项目写锁,并在持锁后重新读取主文件,已有并发保存的有效新版本时直接返回而不安装旧副本。任一候选均不可信则停在加载错误,前端禁编辑和保存。新建 UI 资源先登记并安装合法 envelope,任一步失败补偿 manifest/文件,避免把空 JSON 留给资源卡。 - 图片路径只需是安全项目相对路径,不要求外部图片仍存在或已登记为 manifest asset;缺失媒体只导致 preview 占位。`imageOrder`、当前选择、缩放、面板开关和 preview URL 不写入 State,加载后由 State 派生。保存冻结提交快照,保存期间的新编辑继续保持 dirty;AI state lock 和加载期间禁保存。