fix(import): 导入失败回滚,find_bundle 拒绝同名歧义

- 非事务导入: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 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 08:29:27 -07:00
co-authored by Claude Fable 5
parent b570f37359
commit c362fd80f4
+200 -54
View File
@@ -2,7 +2,7 @@
use bat_adapters::manifest::GenericManifest; use bat_adapters::manifest::GenericManifest;
use bat_adapters::unity::{RawAssetBundle, UnityAdapterRegistry}; 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 bat_core::repositories::{CasRepository, ResourceRepository};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -138,56 +138,100 @@ impl<'a> ResourceImportService<'a> {
bundles: &[BundleSource], bundles: &[BundleSource],
) -> bat_core::Result<ResourceImportReport> { ) -> bat_core::Result<ResourceImportReport> {
let mut report = ResourceImportReport::default(); let mut report = ResourceImportReport::default();
// 记录本次已写入的 CAS 对象和资源 id;任一条目失败时回滚,避免留下部分导入状态。
let mut stored_objects: Vec<String> = Vec::new();
let mut added_resources: Vec<String> = Vec::new();
for entry in &manifest.resources { for entry in &manifest.resources {
let category = classify_import_category(entry.resource_type, &entry.path); if let Err(error) = self
let Some(bundle) = find_bundle(bundles, &entry.path) else { .import_single_entry(
if entry.resource_type == ResourceType::AssetBundle { entry,
return Err(bat_core::Error::InvalidArgument(format!( bundles,
"Missing bundle data for manifest resource: {}", &mut report,
entry.path &mut stored_objects,
))); &mut added_resources,
} )
report.skipped.push(entry.path.clone()); .await
continue; {
}; self.rollback_import(&stored_objects, &added_resources)
.await;
let unityfs = if entry.resource_type == ResourceType::AssetBundle { return Err(error);
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;
} }
Ok(report) Ok(report)
} }
async fn import_single_entry(
&self,
entry: &ResourceEntry,
bundles: &[BundleSource],
report: &mut ResourceImportReport,
stored_objects: &mut Vec<String>,
added_resources: &mut Vec<String>,
) -> 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( async fn parse_unityfs_summary(
&self, &self,
manifest_path: &str, manifest_path: &str,
@@ -230,17 +274,32 @@ fn resource_id_for_path(path: &str) -> String {
format!("resource/{}", path) format!("resource/{}", path)
} }
fn find_bundle<'a>(bundles: &'a [BundleSource], manifest_path: &str) -> Option<&'a BundleSource> { fn find_bundle<'a>(
bundles bundles: &'a [BundleSource],
manifest_path: &str,
) -> bat_core::Result<Option<&'a BundleSource>> {
// 精确路径优先。
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() .iter()
.find(|bundle| bundle.path == manifest_path) .filter(|bundle| file_name(&bundle.path) == Some(manifest_name));
.or_else(|| { let Some(first) = matches.next() else {
file_name(manifest_path).and_then(|manifest_name| { return Ok(None);
bundles };
.iter() if matches.next().is_some() {
.find(|bundle| file_name(&bundle.path) == Some(manifest_name)) return Err(bat_core::Error::InvalidArgument(format!(
}) "manifest 资源 {manifest_path} 存在多个同名 bundle,无法确定使用哪一个"
}) )));
}
Ok(Some(first))
} }
fn file_name(path: &str) -> Option<&str> { fn file_name(path: &str) -> Option<&str> {
@@ -408,6 +467,93 @@ mod tests {
} }
} }
fn manifest_with(resources: Vec<ResourceEntry>) -> 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] #[tokio::test]
async fn imports_synthetic_manifest_bundles_into_cas_and_resource_repository() { async fn imports_synthetic_manifest_bundles_into_cas_and_resource_repository() {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();