新增素材删除能力与运行槽位绑定判定

- 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 与跨项目身份被拒且状态不变
This commit is contained in:
2026-09-10 10:42:02 +08:00
parent d526439508
commit 69741e5e13
5 changed files with 384 additions and 2 deletions
@@ -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<DeleteLocalProjectAssetResult, String> {
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::*;
@@ -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<DeleteLocalProjectAssetResult, String> {
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,
@@ -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,
@@ -304,6 +304,28 @@ fn acquire_asset_usage_write_lock(root: &Path) -> Result<AssetUsageWriteLock, St
Err("资源使用清单正在被另一个写入方占用,请稍后重试".to_string())
}
/// 就地改写清单的引用与诊断,保持身份与 revision 不变。
///
/// 供素材删除等"登记被摘掉"的路径清理悬空归属使用;不改变 `projectRevision`,因此不会
/// 干扰 revision CAS 语义。
pub(crate) fn write_local_project_asset_usage_at(
root: &Path,
usage: &LocalProjectAssetUsage,
references: Vec<AssetUsageReference>,
diagnostics: Vec<AssetUsageDiagnostic>,
) -> 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()))?;
@@ -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<DeleteLocalProjectAssetResult, String> {
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() {