mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-21 22:11:26 +08:00
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:
+200
-54
@@ -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<ResourceImportReport> {
|
||||
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 {
|
||||
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<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(
|
||||
&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<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()
|
||||
.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<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]
|
||||
async fn imports_synthetic_manifest_bundles_into_cas_and_resource_repository() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user