From 9c2001b9704f741beaac7b99b47b284341447130 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sat, 22 Aug 2026 17:10:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B2=BE=E4=BF=AE=E7=B4=A0?= =?UTF-8?q?=E6=9D=90=E7=A8=B3=E5=AE=9A=E8=BF=90=E8=A1=8C=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提交精修后刷新游戏引用的稳定入口字节 记录运行入口并在幂等重放与恢复中修复 补充稳定入口回归测试与技术合同 --- .../src-tauri/src/project/asset_canvas.rs | 156 +++++++++++++++++- .../src/project/asset_canvas_tests.rs | 37 +++++ ...客户端素材创作无限画布阶段一合同-2026-08-05.md | 1 + 3 files changed, 193 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index a3b4142db..97de94b9f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -2579,6 +2579,10 @@ struct AssetCanvasTransactionJournal { final_image_relative_path: String, final_image_sha256: String, final_image_existed_before: bool, + #[serde(default)] + runtime_entry_relative_path: Option, + #[serde(default)] + runtime_entry_refreshed: bool, manifest_before_sha256: String, manifest_after_sha256: String, project_revision_before_sha256: Option, @@ -2960,6 +2964,143 @@ fn asset_canvas_conflict( } } +fn replace_asset_canvas_runtime_entry( + path: &Path, + bytes: &[u8], + label: &str, +) -> Result<(), String> { + let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?; + fs::create_dir_all(parent).map_err(|_| format!("创建 {label} 目录失败"))?; + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("{label} 必须是普通文件")); + } + } + let temp_path = path.with_file_name(format!(".asset-canvas-runtime-{}.tmp", Uuid::new_v4())); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600).custom_flags(libc::O_NOFOLLOW); + } + let mut file = options + .open(&temp_path) + .map_err(|_| format!("创建 {label} 临时文件失败"))?; + if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) { + let _ = fs::remove_file(&temp_path); + return Err(format!("写入 {label} 临时文件失败:{error}")); + } + drop(file); + fs::rename(&temp_path, path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!("安装 {label} 失败:{error}") + })?; + let (_, metadata) = open_project_snapshot_regular_file(path, label)?; + if metadata.len() != bytes.len() as u64 { + return Err(format!("{label} 安装后大小不一致")); + } + #[cfg(unix)] + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("同步 {label} 目录失败:{error}"))?; + Ok(()) +} + +fn asset_canvas_runtime_entry_path( + root: &Path, + manifest_before: &GameCreationAppManifest, + asset_id: &str, +) -> Result, String> { + let mut historical = None; + for journal in read_asset_canvas_transaction_journals(root)? { + if journal.asset_id != asset_id { + continue; + } + let Some(path) = journal.runtime_entry_relative_path else { + continue; + }; + let is_newer = historical + .as_ref() + .is_none_or(|(_, revision): &(String, u64)| { + journal.target_project_revision > *revision + }); + if is_newer { + historical = Some((path, journal.target_project_revision)); + } + } + if let Some((path, _)) = historical { + return Ok(Some(path)); + } + + let Some(source) = manifest_before + .assets + .iter() + .find(|asset| asset.id == asset_id) + else { + return Ok(None); + }; + if source.local_path.starts_with("assets/canvas/") { + return Ok(None); + } + let path = resolve_local_project_path(root, &source.local_path)?; + if !path.is_file() { + return Ok(None); + } + Ok(Some(source.local_path.clone())) +} + +fn runtime_entry_matches_journal( + root: &Path, + journal: &AssetCanvasTransactionJournal, +) -> Result { + let Some(relative) = journal.runtime_entry_relative_path.as_deref() else { + return Ok(true); + }; + let path = resolve_local_project_path(root, relative)?; + if !path.is_file() { + return Ok(false); + } + Ok(open_and_validate_image_file( + &path, + &journal.staged_image.media_type, + Some(&journal.final_image_sha256), + ) + .is_ok_and(|(bytes, width, height)| { + bytes.len() as u64 == journal.staged_image.byte_length + && width == journal.staged_image.pixel_width + && height == journal.staged_image.pixel_height + })) +} + +fn refresh_runtime_entry_for_journal( + root: &Path, + journal: &mut AssetCanvasTransactionJournal, +) -> Result<(), String> { + let Some(relative) = journal.runtime_entry_relative_path.as_deref() else { + return Ok(()); + }; + if runtime_entry_matches_journal(root, journal)? { + if journal.runtime_entry_refreshed { + return Ok(()); + } + journal.runtime_entry_refreshed = true; + journal.updated_at = asset_canvas_now(); + return write_asset_canvas_journal(root, journal); + } + let final_path = resolve_local_project_path(root, &journal.final_image_relative_path)?; + let (bytes, _, _) = open_and_validate_image_file( + &final_path, + &journal.staged_image.media_type, + Some(&journal.final_image_sha256), + )?; + let runtime_path = resolve_local_project_path(root, relative)?; + replace_asset_canvas_runtime_entry(&runtime_path, &bytes, "素材运行入口")?; + journal.runtime_entry_refreshed = true; + journal.updated_at = asset_canvas_now(); + write_asset_canvas_journal(root, journal) +} + fn verify_committed_asset_canvas_state( root: &Path, journal: &AssetCanvasTransactionJournal, @@ -3233,6 +3374,9 @@ fn commit_asset_canvas_at_internal( return Err("commitId 或 idempotencyKey 已绑定到不同提交请求".to_string()); } if ledger.status == AssetCanvasLedgerStatus::Committed { + if let Some(mut journal) = read_asset_canvas_journal(root, &input.commit_id)? { + refresh_runtime_entry_for_journal(root, &mut journal)?; + } return committed_result_from_ledger(root, ledger, manifest, current_revision.revision); } if matches!( @@ -3376,6 +3520,11 @@ fn commit_asset_canvas_at_internal( if fs::symlink_metadata(&final_path).is_ok() { return Err("正式素材目标路径已存在".to_string()); } + let runtime_entry_relative_path = if input.intent == AssetCanvasIntent::Refine { + asset_canvas_runtime_entry_path(root, &manifest_before, &asset_id)? + } else { + None + }; let asset = if input.intent == AssetCanvasIntent::Refine { let source = manifest .assets @@ -3484,6 +3633,8 @@ fn commit_asset_canvas_at_internal( final_image_relative_path: final_relative_path.clone(), final_image_sha256: staged.sha256.clone(), final_image_existed_before: false, + runtime_entry_relative_path: runtime_entry_relative_path.clone(), + runtime_entry_refreshed: false, manifest_before_sha256: asset_canvas_sha256(&manifest_before_bytes), manifest_after_sha256: asset_canvas_sha256(&manifest_after_bytes), project_revision_before_sha256: Some(asset_canvas_sha256(&revision_before_bytes)), @@ -3576,6 +3727,7 @@ fn commit_asset_canvas_at_internal( journal.stage = AssetCanvasTransactionStage::Committed; journal.updated_at = asset_canvas_now(); write_asset_canvas_journal(root, &journal)?; + refresh_runtime_entry_for_journal(root, &mut journal)?; Ok(CommitAssetCanvasExecution { result: CommitAssetCanvasResult::Committed { @@ -4073,6 +4225,7 @@ fn finish_recovered_asset_canvas_commit_locked( draft.updated_at = asset_canvas_now(); write_asset_canvas_draft_locked(root, &draft)?; } + refresh_runtime_entry_for_journal(root, &mut journal)?; journal.stage = AssetCanvasTransactionStage::Committed; journal.updated_at = asset_canvas_now(); write_asset_canvas_journal(root, &journal)?; @@ -4089,7 +4242,7 @@ fn finish_recovered_asset_canvas_commit_locked( fn recover_asset_canvas_transaction_locked( root: &Path, - journal: AssetCanvasTransactionJournal, + mut journal: AssetCanvasTransactionJournal, ) -> Result<(RecoverAssetCanvasOutcome, Option), String> { if journal.schema_version != ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION { return Err("不支持的素材画布 transaction schema".to_string()); @@ -4158,6 +4311,7 @@ fn recover_asset_canvas_transaction_locked( && asset_present && current_revision.revision >= journal.target_project_revision { + refresh_runtime_entry_for_journal(root, &mut journal)?; let mut draft = read_asset_canvas_draft_locked(root, &journal.project_id, &journal.draft_id)? .ok_or_else(|| "已提交事务缺少草稿".to_string())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs index 0a74b6c93..cd7b2f529 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -521,7 +521,14 @@ fn refine_preserves_source_and_records_non_destructive_lineage() { Uuid::new_v4().to_string(), Uuid::new_v4().to_string(), ); + fs::create_dir_all(fixture.root().join("game")).expect("create game directory"); + fs::write( + fixture.root().join("game/game.js"), + "const backgroundImage = '../assets/source.png';\n", + ) + .expect("write stable game resource reference"); let execution = commit_asset_canvas_at(fixture.root(), &input).expect("commit refine asset"); + assert!(fixture.root().join("game/game.js").is_file()); let committed_asset = match execution.result { CommitAssetCanvasResult::Committed { asset, .. } => asset, other => panic!("unexpected refine result: {other:?}"), @@ -541,6 +548,36 @@ fn refine_preserves_source_and_records_non_destructive_lineage() { fs::read(fixture.root().join(&source.local_path)).expect("read committed source"), fixture.png ); + assert_eq!( + fs::read(fixture.root().join("assets/source.png")).expect("read stable runtime entry"), + fixture.png + ); + assert_eq!( + fs::read_to_string(fixture.root().join("game/game.js")) + .expect("read stable game resource reference"), + "const backgroundImage = '../assets/source.png';\n" + ); + let journal = read_asset_canvas_journal(fixture.root(), &input.commit_id) + .expect("read runtime entry journal") + .expect("runtime entry journal retained"); + assert_eq!( + journal.runtime_entry_relative_path.as_deref(), + Some("assets/source.png") + ); + assert!(journal.runtime_entry_refreshed); + + fs::write( + fixture.root().join("assets/source.png"), + png_bytes([90, 120, 150, 255]), + ) + .expect("corrupt stable runtime entry"); + recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("repair stable runtime entry"); + assert_eq!( + fs::read(fixture.root().join("assets/source.png")) + .expect("read repaired stable runtime entry"), + fixture.png + ); assert_eq!( source.source.resource_id.as_deref(), Some("local-asset:source-asset") diff --git a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md index af5ec8273..d517c091d 100644 --- a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md +++ b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md @@ -911,6 +911,7 @@ cancelling | A35 | 精修文件名包含历史提交后缀 | 后续精修重新打开当前 `localPath`,或再次生成 / 设为最终图 | 统一剥离文件名末尾一个或多个 `--` 后缀并规范化为合法 1..=80 字符显示名;生成与最终提交使用同一结果 | | A36 | 确定性提交参数无效 | 候选提交名称或用途在校验阶段失败 | 在读取候选、staging、transaction 或 ledger 写入前零副作用失败;UI 作为输入校验错误允许继续编辑,不触发安全恢复 | | A37 | 候选首次确认 | 生成完成后与旧 autosave 并发 | 前端先同步 authoritative layers 并保存确认;确认前后端把未确认候选层合回旧保存,重启恢复可从私有 ledger 重建候选层,确认后的显式删除仍允许 | +| A38 | 稳定运行入口 | 精修替换已在游戏源码中引用的图片 | manifest 指向不可变正式版本,同时原稳定入口路径不变并刷新为新版本字节;幂等重放和事务恢复会修复缺失或不匹配入口,游戏源码不需要改路径 | 阶段一至五最终审计只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。