From c362fd80f449ae150c12f8cd1d24413a2beaa765 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Thu, 16 Jul 2026 08:29:27 -0700 Subject: [PATCH] =?UTF-8?q?fix(import):=20=E5=AF=BC=E5=85=A5=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=9B=9E=E6=BB=9A=EF=BC=8Cfind=5Fbundle=20=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E5=90=8C=E5=90=8D=E6=AD=A7=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 非事务导入:import_manifest_bundles 中途某条目失败(如 AssetBundle 解析失败) 时,之前已写入的 CAS 对象和 ResourceRepository 行不回滚,留下部分导入状态。 改为记录本次写入的 object_id 与资源 id,任一条目失败时 best-effort 回滚 (删除资源行、对 store 增加的引用逐一 remove_reference)。 - find_bundle 跨目录同名:文件名回退无目录约束,jp/data.bundle 与 en/data.bundle 同名时可能静默取错数据。改为返回 Result:精确路径优先,文件名回退仅在唯一匹配 时采用,多个同名 bundle 时显式报错而非猜测。 新增单测:后续条目失败时回滚先前成功条目(资源行清空、CAS 引用回滚为 0)、 同名 bundle 歧义显式报错。 对应 issue #18 维护清单 2-2。 Co-Authored-By: Claude Fable 5 --- infrastructure/src/import.rs | 254 +++++++++++++++++++++++++++-------- 1 file changed, 200 insertions(+), 54 deletions(-) diff --git a/infrastructure/src/import.rs b/infrastructure/src/import.rs index 4d36673..88c6334 100644 --- a/infrastructure/src/import.rs +++ b/infrastructure/src/import.rs @@ -2,7 +2,7 @@ use bat_adapters::manifest::GenericManifest; use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry}; -use bat_core::domain::{Resource, ResourceType}; +use bat_core::domain::{Resource, ResourceEntry, ResourceType}; use bat_core::repositories::{CasRepository, ResourceRepository}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -138,56 +138,100 @@ impl<'a> ResourceImportService<'a> { bundles: &[BundleSource], ) -> bat_core::Result { let mut report = ResourceImportReport::default(); + // 记录本次已写入的 CAS 对象和资源 id;任一条目失败时回滚,避免留下部分导入状态。 + let mut stored_objects: Vec = Vec::new(); + let mut added_resources: Vec = Vec::new(); for entry in &manifest.resources { - let category = classify_import_category(entry.resource_type, &entry.path); - let Some(bundle) = find_bundle(bundles, &entry.path) else { - if entry.resource_type == ResourceType::AssetBundle { - return Err(bat_core::Error::InvalidArgument(format!( - "Missing bundle data for manifest resource: {}", - entry.path - ))); - } - report.skipped.push(entry.path.clone()); - continue; - }; - - let unityfs = if entry.resource_type == ResourceType::AssetBundle { - Some(self.parse_unityfs_summary(&entry.path, bundle).await?) - } else { - None - }; - - let object_id = self.cas.store(&bundle.data).await?; - let mut stored_entry = entry.clone(); - stored_entry.hash = object_id.clone(); - stored_entry.size = bundle.data.len() as u64; - - let resource = Resource { - id: resource_id_for_path(&entry.path), - local_path: PathBuf::from(&entry.path), - entry: stored_entry, - }; - let id = self.resources.add(resource).await?; - - report.imported.push(ImportedResource { - id, - source_path: entry.path.clone(), - resource_type: entry.resource_type, - category, - object_id, - bytes: bundle.data.len() as u64, - unityfs, - }); - *report - .category_counts - .entry(category.as_str().to_string()) - .or_insert(0) += 1; + if let Err(error) = self + .import_single_entry( + entry, + bundles, + &mut report, + &mut stored_objects, + &mut added_resources, + ) + .await + { + self.rollback_import(&stored_objects, &added_resources) + .await; + return Err(error); + } } Ok(report) } + async fn import_single_entry( + &self, + entry: &ResourceEntry, + bundles: &[BundleSource], + report: &mut ResourceImportReport, + stored_objects: &mut Vec, + added_resources: &mut Vec, + ) -> bat_core::Result<()> { + let category = classify_import_category(entry.resource_type, &entry.path); + let Some(bundle) = find_bundle(bundles, &entry.path)? else { + if entry.resource_type == ResourceType::AssetBundle { + return Err(bat_core::Error::InvalidArgument(format!( + "Missing bundle data for manifest resource: {}", + entry.path + ))); + } + report.skipped.push(entry.path.clone()); + return Ok(()); + }; + + let unityfs = if entry.resource_type == ResourceType::AssetBundle { + Some(self.parse_unityfs_summary(&entry.path, bundle).await?) + } else { + None + }; + + let object_id = self.cas.store(&bundle.data).await?; + stored_objects.push(object_id.clone()); + let mut stored_entry = entry.clone(); + stored_entry.hash = object_id.clone(); + stored_entry.size = bundle.data.len() as u64; + + let resource = Resource { + id: resource_id_for_path(&entry.path), + local_path: PathBuf::from(&entry.path), + entry: stored_entry, + }; + let id = self.resources.add(resource).await?; + added_resources.push(id.clone()); + + report.imported.push(ImportedResource { + id, + source_path: entry.path.clone(), + resource_type: entry.resource_type, + category, + object_id, + bytes: bundle.data.len() as u64, + unityfs, + }); + *report + .category_counts + .entry(category.as_str().to_string()) + .or_insert(0) += 1; + Ok(()) + } + + /// 回滚本次导入已写入的资源索引和 CAS 引用(best-effort)。 + /// + /// 撤销 `resources.add` 与 `cas.store`:删除本次新增的资源行,并对本次 + /// store 增加的引用逐一 `remove_reference`(同一对象被引用多次则递减多次), + /// 使中途失败不留下部分导入状态。 + async fn rollback_import(&self, stored_objects: &[String], added_resources: &[String]) { + for id in added_resources { + let _ = self.resources.delete(id).await; + } + for object_id in stored_objects { + let _ = self.cas.remove_reference(object_id).await; + } + } + async fn parse_unityfs_summary( &self, manifest_path: &str, @@ -230,17 +274,32 @@ fn resource_id_for_path(path: &str) -> String { format!("resource/{}", path) } -fn find_bundle<'a>(bundles: &'a [BundleSource], manifest_path: &str) -> Option<&'a BundleSource> { - bundles +fn find_bundle<'a>( + bundles: &'a [BundleSource], + manifest_path: &str, +) -> bat_core::Result> { + // 精确路径优先。 + if let Some(bundle) = bundles.iter().find(|bundle| bundle.path == manifest_path) { + return Ok(Some(bundle)); + } + + // 文件名回退:仅当恰好唯一匹配时采用,避免跨目录同名(如 en/data.bundle 与 + // jp/data.bundle)静默取错数据;多个同名 bundle 时显式报错而非猜测。 + let Some(manifest_name) = file_name(manifest_path) else { + return Ok(None); + }; + let mut matches = bundles .iter() - .find(|bundle| bundle.path == manifest_path) - .or_else(|| { - file_name(manifest_path).and_then(|manifest_name| { - bundles - .iter() - .find(|bundle| file_name(&bundle.path) == Some(manifest_name)) - }) - }) + .filter(|bundle| file_name(&bundle.path) == Some(manifest_name)); + let Some(first) = matches.next() else { + return Ok(None); + }; + if matches.next().is_some() { + return Err(bat_core::Error::InvalidArgument(format!( + "manifest 资源 {manifest_path} 存在多个同名 bundle,无法确定使用哪一个" + ))); + } + Ok(Some(first)) } fn file_name(path: &str) -> Option<&str> { @@ -408,6 +467,93 @@ mod tests { } } + fn manifest_with(resources: Vec) -> GenericManifest { + GenericManifest { + format: ManifestFormat::AddressablesCatalog, + resources, + metadata: ManifestMetadata { + locator_id: None, + cdn_prefixes: Vec::new(), + extra: HashMap::new(), + }, + } + } + + fn text_entry(path: &str) -> ResourceEntry { + ResourceEntry { + path: path.to_string(), + hash: "h".to_string(), + size: 1, + resource_type: ResourceType::TextAsset, + address: None, + dependencies: Vec::new(), + } + } + + #[tokio::test] + async fn import_rolls_back_when_a_later_entry_fails() { + let temp_dir = TempDir::new().unwrap(); + let cas = FileSystemCasRepository::new(temp_dir.path().join("cas")); + let resources = InMemoryResourceRepository::new(); + let service = ResourceImportService::new(&cas, &resources); + + // 先一个可导入的 TextAsset,再一个数据非 UnityFS 的 AssetBundle(解析失败)。 + let manifest = manifest_with(vec![ + text_entry("TextAssets/ok.csv"), + ResourceEntry { + path: "bad.bundle".to_string(), + hash: "h2".to_string(), + size: 1, + resource_type: ResourceType::AssetBundle, + address: None, + dependencies: Vec::new(), + }, + ]); + + let result = service + .import_manifest_bundles( + &manifest, + &[ + BundleSource::new("ok.csv", b"a,b".to_vec()), + BundleSource::new("bad.bundle", b"not-unityfs".to_vec()), + ], + ) + .await; + + assert!(result.is_err()); + // 回滚:先前成功的 TextAsset 资源行不应残留。 + assert!(resources + .list(ResourceQuery::all()) + .await + .unwrap() + .is_empty()); + // 其 CAS 对象引用已回滚为 0。 + let ok_object = cas.store(b"a,b").await.unwrap(); + assert_eq!(cas.get_reference_count(&ok_object).await.unwrap(), 1); + } + + #[tokio::test] + async fn import_rejects_same_name_bundle_ambiguity() { + let temp_dir = TempDir::new().unwrap(); + let cas = FileSystemCasRepository::new(temp_dir.path().join("cas")); + let resources = InMemoryResourceRepository::new(); + let service = ResourceImportService::new(&cas, &resources); + + // 精确路径 jp/data.txt 不存在,回退按文件名匹配时存在两个 data.txt。 + let manifest = manifest_with(vec![text_entry("jp/data.txt")]); + let error = service + .import_manifest_bundles( + &manifest, + &[ + BundleSource::new("en/data.txt", b"en".to_vec()), + BundleSource::new("other/data.txt", b"other".to_vec()), + ], + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("多个同名 bundle")); + } + #[tokio::test] async fn imports_synthetic_manifest_bundles_into_cas_and_resource_repository() { let temp_dir = TempDir::new().unwrap();