From 69741e5e13f5ebc79ab51a4669cd743365920504 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 10 Sep 2026 10:42:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=B4=A0=E6=9D=90=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E8=83=BD=E5=8A=9B=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=A7=BD?= =?UTF-8?q?=E4=BD=8D=E7=BB=91=E5=AE=9A=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project/asset_usage.rs 增加 write_local_project_asset_usage_at:就地改写引用与诊断,保持身份与 revision 不变 - assets.rs 新增 delete_manifest_asset_at:素材不可变,只摘掉 manifest 登记、保留磁盘文件,并清理使用清单里指向它的悬空引用 - 删除判定只承认真实运行槽位绑定:恒等绑定是版本创建时的兼容兜底,若把它也算作被引用,任何在版本创建前登记过的素材都将永远删不掉 - CAS 口径与资源分类更新一致:持项目写锁后校验 expectedProjectId 与当前 revision,失败时 manifest 与 revision 均不变;成功后 revision 推进一格 - 新增 delete_local_project_asset 命令并注册;内联测试换成带 CAS 的公开入口 - 补 4 条删除测试:恒等绑定不阻止删除、真实槽位绑定拒绝删除并报出版本与槽位、删除清理悬空引用、陈旧 revision 与跨项目身份被拒且状态不变 --- .../src-tauri/src/assets.rs | 151 ++++++++++++++ .../src-tauri/src/commands.rs | 18 ++ .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project/asset_usage.rs | 25 ++- .../src-tauri/src/tests/asset_usage.rs | 191 ++++++++++++++++++ 5 files changed, 384 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 9bc2923d7..9501b79e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1795,6 +1795,157 @@ pub(crate) fn register_local_asset_entry( }) } +/// 恒等绑定槽位前缀:`asset:{assetId}`。 +/// +/// 版本创建时会给当时 manifest 里的每个资源补一条恒等绑定,作为"未被运行槽位覆盖"的 +/// 兼容兜底(历史版本与既有校验语义依赖它)。因此恒等绑定**只表达登记关系**,不表达 +/// "这个素材真的被游戏用到了"。 +pub(crate) const GAME_ITERATION_IDENTITY_BINDING_PREFIX: &str = "asset:"; + +/// 一个素材是否被任意版本的**真实运行槽位**绑定过。 +/// +/// C5 删除规则读作"从未被任何 slot 绑定过的素材才可删"。这个判断只看真实槽位绑定: +/// 恒等绑定是版本创建时的兼容兜底,若把它也算作"被引用",那么任何在某个版本创建之前 +/// 登记过的素材都永远删不掉,删除规则会完全失效。历史版本本身仍然不可变、不影响回放。 +fn asset_is_bound_to_runtime_slot( + versions: &[GameIterationVersion], + asset_id: &str, +) -> Option<(String, String)> { + for version in versions { + for binding in &version.resource_bindings { + if binding.resource_id != asset_id { + continue; + } + if binding.slot_id.starts_with(GAME_ITERATION_IDENTITY_BINDING_PREFIX) { + continue; + } + return Some((version.version_id.clone(), binding.slot_id.clone())); + } + } + None +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct DeleteLocalProjectAssetInput { + pub project_path: String, + pub expected_project_id: String, + pub expected_project_revision: u64, + pub asset_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DeleteLocalProjectAssetResult { + pub asset_id: String, + pub local_path: String, + pub committed_project_revision: u64, + /// 只删除 manifest 登记,磁盘文件保留;调用方据此提示用户。 + pub file_retained: bool, +} + +/// 删除一个从未被真实运行槽位绑定过的素材登记。 +/// +/// 语义: +/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记; +/// - 被任意版本的真实运行槽位绑定过 → 拒绝,并在错误里给出版本与槽位; +/// - 删除同时清理运行期资源使用清单里指向它的引用,避免留下悬空归属; +/// - 已完成的版本记录本身不改动,历史回放不受影响; +/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS, +/// 失败时 manifest 与 revision 都不变。 +pub(crate) fn delete_manifest_asset_at( + root: &Path, + expected_project_id: &str, + expected_project_revision: u64, + asset_id: &str, +) -> Result { + if expected_project_revision + > shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + { + return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); + } + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("素材删除 expectedProjectId 不能为空".to_string()); + } + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + return Err("素材删除 assetId 不能为空".to_string()); + } + + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let _lock = acquire_project_write_lock(root, "asset.register")?; + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + + let mut local_path = String::new(); + mutate_manifest_at(root, |manifest| { + let Some(index) = manifest + .assets + .iter() + .position(|asset| asset.id == asset_id) + else { + return Err(format!("项目资源不存在:{asset_id}")); + }; + if let Some((version_id, slot_id)) = + asset_is_bound_to_runtime_slot(&manifest.versions, asset_id) + { + return Err(format!( + "素材 {asset_id} 已被版本 {version_id} 的运行槽位 {slot_id} 绑定,不能删除" + )); + } + local_path = manifest.assets[index].local_path.clone(); + manifest.assets.remove(index); + Ok(()) + })?; + + // 清理使用清单里指向该素材的引用;清单缺失或读取失败不阻塞删除结果。 + let _ = remove_asset_usage_references(root, asset_id); + + let committed_project_revision = advance_agent_runtime_project_revision_locked(root) + .map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?; + + Ok(DeleteLocalProjectAssetResult { + asset_id: asset_id.to_string(), + local_path, + committed_project_revision, + file_retained: true, + }) +} + +/// 从运行期资源使用清单里摘掉指向某素材的引用。 +/// +/// 这些引用记录的是"代码曾经引用了它"这一历史事实,素材登记被删除后它们不再有可解析的 +/// 资源身份,继续留着会产出悬空归属,因此一并清理。 +fn remove_asset_usage_references(root: &Path, asset_id: &str) -> Result<(), String> { + let Some(usage) = read_local_project_asset_usage(root, None)? else { + return Ok(()); + }; + if !usage + .references + .iter() + .any(|reference| reference.resource_id.as_deref() == Some(asset_id)) + { + return Ok(()); + } + let mut references = usage.references.clone(); + references.retain(|reference| reference.resource_id.as_deref() != Some(asset_id)); + let diagnostics = usage + .diagnostics + .iter() + .filter(|diagnostic| diagnostic.resource_id.as_deref() != Some(asset_id)) + .cloned() + .collect(); + write_local_project_asset_usage_at(root, &usage, references, diagnostics) +} + #[cfg(test)] mod tests { use super::*; 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 fdb27d0e7..079946a99 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -848,6 +848,24 @@ pub(crate) fn update_local_project_resource_canvas_layout( ) } +/// 删除一个从未被真实运行槽位绑定过的素材登记。 +/// +/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。被任意版本的真实运行槽位绑定过时拒绝, +/// 错误里给出版本与槽位。 +#[tauri::command] +pub(crate) fn delete_local_project_asset( + input: DeleteLocalProjectAssetInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + delete_manifest_asset_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + ) +} + #[tauri::command] pub(crate) async fn control_agent_run( app: tauri::AppHandle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index dab175a08..d6d3b37f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2683,6 +2683,7 @@ fn main() { read_local_project_resource_canvas_layout, read_local_project_resource_graph, update_local_project_resource_canvas_layout, + delete_local_project_asset, get_local_game_project_revision, get_local_game_manifest, download_agc_update, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_usage.rs index 73c026194..a78b96d92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_usage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_usage.rs @@ -304,6 +304,28 @@ fn acquire_asset_usage_write_lock(root: &Path) -> Result, + diagnostics: Vec, +) -> Result<(), String> { + let _lock = acquire_asset_usage_write_lock(root)?; + let updated = LocalProjectAssetUsage { + schema_version: ASSET_USAGE_SCHEMA_VERSION.to_string(), + project_id: usage.project_id.clone(), + project_revision: usage.project_revision, + updated_at_ms: unix_timestamp_millis(), + references, + diagnostics, + }; + write_local_project_asset_usage(root, &updated) +} + /// 读取清单。文件缺失、schema 不符或 projectId 不匹配时返回 `None`(等价于"尚未登记")。 pub(crate) fn read_local_project_asset_usage( root: &Path, @@ -335,8 +357,7 @@ pub(crate) fn read_local_project_asset_usage( fn write_local_project_asset_usage( root: &Path, usage: &LocalProjectAssetUsage, -) -> Result<(), String> { - let path = asset_usage_path(root)?; +) -> Result<(), String> { let path = asset_usage_path(root)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("创建资源使用清单目录 {} 失败:{error}", parent.display()))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs index 5cf35c52b..1982a5292 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/asset_usage.rs @@ -472,6 +472,197 @@ fn generated_asset_source(task_id: &str) -> GameCreationAppAssetSource { } } +/// 建一个项目并登记一个可删除的素材,返回(root, assetId)。 +fn asset_usage_deletable_fixture() -> (PathBuf, String) { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "素材删除规则测试").expect("init project"); + write_asset_usage_asset(&root, "assets/deletable.png", b"deletable"); + register_local_asset_at( + &root, + "assets/deletable.png", + "character", + "image/png", + "generated", + generated_asset_source("art-asset-plan"), + ) + .expect("register deletable asset"); + let manifest = read_manifest_for_project(&root).expect("read manifest"); + let asset_id = manifest + .assets + .iter() + .find(|asset| asset.local_path == "assets/deletable.png") + .expect("deletable asset registered") + .id + .clone(); + (root, asset_id) +} + +/// 用当前项目 revision 调一次删除,模拟前端拿最新 revision 发起的正常调用。 +fn delete_asset_at_current_revision( + root: &Path, + asset_id: &str, +) -> Result { + let revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision before delete") + .revision; + delete_manifest_asset_at(root, "project-1", revision, asset_id) +} + +/// 恒等绑定只是版本创建时的兼容兜底,不阻止删除;删除只摘掉登记、保留磁盘文件。 +#[test] +fn asset_delete_allows_asset_bound_only_by_identity_fallback() { + let (root, asset_id) = asset_usage_deletable_fixture(); + // 先造一个版本:它会给当时所有资源补恒等绑定,这正是要验证"不阻止删除"的场景。 + assert!( + ensure_initial_game_iteration_version_at(&root, 1).expect("create initial version"), + "initial version should be appended" + ); + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before delete") + .revision; + + let result = delete_asset_at_current_revision(&root, &asset_id).expect("delete asset"); + assert_eq!(result.asset_id, asset_id); + assert_eq!(result.local_path, "assets/deletable.png"); + assert!(result.file_retained, "素材不可变,磁盘文件必须保留"); + assert_eq!( + result.committed_project_revision, + revision_before + 1, + "删除成功后项目 revision 必须推进一格" + ); + assert!( + root.join("assets/deletable.png").is_file(), + "删除登记不得删除磁盘文件" + ); + let manifest = read_manifest_for_project(&root).expect("read manifest after delete"); + assert!( + manifest.assets.iter().all(|asset| asset.id != asset_id), + "素材登记应已摘掉" + ); + // 历史版本记录本身不变,回放语义不受影响。 + assert_eq!(manifest.versions.len(), 1); + assert!(manifest.versions[0] + .resource_bindings + .iter() + .any(|binding| binding.resource_id == asset_id)); + + fs::remove_dir_all(root).ok(); +} + +/// 被真实运行槽位绑定过的素材禁止删除,错误里要能看出是哪个版本、哪个槽位。 +#[test] +fn asset_delete_rejects_asset_bound_to_runtime_slot() { + let (root, asset_id) = asset_usage_deletable_fixture(); + submit_local_project_asset_usage_at( + &root, + "project-1", + &[LocalProjectAssetFacts { + asset_id: asset_id.clone(), + local_path: "assets/deletable.png".to_string(), + }], + &asset_usage_submit( + 1, + vec![asset_usage_reference(Some("hero"), Some(&asset_id), None)], + ), + ) + .expect("register runtime slot"); + assert!( + ensure_initial_game_iteration_version_at(&root, 1).expect("create initial version"), + "initial version should be appended" + ); + + let error = delete_asset_at_current_revision(&root, &asset_id) + .expect_err("asset bound to a runtime slot must not be deleted"); + assert!(error.contains("运行槽位"), "unexpected error: {error}"); + assert!(error.contains("hero"), "error must name the slot: {error}"); + assert!( + read_manifest_for_project(&root) + .expect("read manifest after rejected delete") + .assets + .iter() + .any(|asset| asset.id == asset_id), + "被拒绝时素材登记必须原样保留" + ); + + fs::remove_dir_all(root).ok(); +} + +/// 删除素材时同步清理使用清单里指向它的悬空引用。 +#[test] +fn asset_delete_cleans_usage_references_to_removed_asset() { + let (root, asset_id) = asset_usage_deletable_fixture(); + // 无槽位引用(unattributed)不阻止删除,但删除后必须被清理。 + submit_local_project_asset_usage_at( + &root, + "project-1", + &[LocalProjectAssetFacts { + asset_id: asset_id.clone(), + local_path: "assets/deletable.png".to_string(), + }], + &asset_usage_submit( + 1, + vec![asset_usage_reference(None, Some(&asset_id), None)], + ), + ) + .expect("register unattributed reference"); + + delete_asset_at_current_revision(&root, &asset_id).expect("delete asset"); + let usage = read_local_project_asset_usage(&root, Some("project-1")) + .expect("read usage after delete") + .expect("usage file should remain"); + assert!( + usage + .references + .iter() + .all(|reference| reference.resource_id.as_deref() != Some(asset_id.as_str())), + "指向已删除素材的引用必须清理:{:?}", + usage.references + ); + + fs::remove_dir_all(root).ok(); +} + +/// 删除同样走 CAS:陈旧 revision 与跨项目身份都被拒绝,且 manifest 与 revision 原样不变。 +#[test] +fn asset_delete_enforces_revision_and_identity_cas() { + let (root, asset_id) = asset_usage_deletable_fixture(); + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before failed delete") + .revision; + + let revision_error = + delete_manifest_asset_at(&root, "project-1", revision_before + 1, &asset_id) + .expect_err("stale revision must be rejected"); + assert_eq!(revision_error, "project-revision-conflict"); + + let identity_error = + delete_manifest_asset_at(&root, "project-2", revision_before, &asset_id) + .expect_err("cross-project identity must be rejected"); + assert_eq!(identity_error, "project-identity-conflict"); + + let missing_error = delete_manifest_asset_at(&root, "project-1", revision_before, "absent") + .expect_err("missing asset must be rejected"); + assert!( + missing_error.contains("项目资源不存在"), + "unexpected error: {missing_error}" + ); + + let manifest = read_manifest_for_project(&root).expect("read manifest after rejected deletes"); + assert!( + manifest.assets.iter().any(|asset| asset.id == asset_id), + "失败的删除不得改动 manifest" + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after rejected deletes") + .revision, + revision_before, + "失败的删除不得推进 revision" + ); + + fs::remove_dir_all(root).ok(); +} + /// 落盘正文只包含引用与诊断,不含 manifest 已有的资源元数据。 #[test] fn asset_usage_persisted_payload_omits_manifest_owned_metadata() {