新增素材删除能力与运行槽位绑定判定
- 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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user