mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 04:06:44 +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:
+160
-14
@@ -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,10 +138,40 @@ 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 {
|
||||||
|
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 category = classify_import_category(entry.resource_type, &entry.path);
|
||||||
let Some(bundle) = find_bundle(bundles, &entry.path) else {
|
let Some(bundle) = find_bundle(bundles, &entry.path)? else {
|
||||||
if entry.resource_type == ResourceType::AssetBundle {
|
if entry.resource_type == ResourceType::AssetBundle {
|
||||||
return Err(bat_core::Error::InvalidArgument(format!(
|
return Err(bat_core::Error::InvalidArgument(format!(
|
||||||
"Missing bundle data for manifest resource: {}",
|
"Missing bundle data for manifest resource: {}",
|
||||||
@@ -149,7 +179,7 @@ impl<'a> ResourceImportService<'a> {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
report.skipped.push(entry.path.clone());
|
report.skipped.push(entry.path.clone());
|
||||||
continue;
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
let unityfs = if entry.resource_type == ResourceType::AssetBundle {
|
let unityfs = if entry.resource_type == ResourceType::AssetBundle {
|
||||||
@@ -159,6 +189,7 @@ impl<'a> ResourceImportService<'a> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let object_id = self.cas.store(&bundle.data).await?;
|
let object_id = self.cas.store(&bundle.data).await?;
|
||||||
|
stored_objects.push(object_id.clone());
|
||||||
let mut stored_entry = entry.clone();
|
let mut stored_entry = entry.clone();
|
||||||
stored_entry.hash = object_id.clone();
|
stored_entry.hash = object_id.clone();
|
||||||
stored_entry.size = bundle.data.len() as u64;
|
stored_entry.size = bundle.data.len() as u64;
|
||||||
@@ -169,6 +200,7 @@ impl<'a> ResourceImportService<'a> {
|
|||||||
entry: stored_entry,
|
entry: stored_entry,
|
||||||
};
|
};
|
||||||
let id = self.resources.add(resource).await?;
|
let id = self.resources.add(resource).await?;
|
||||||
|
added_resources.push(id.clone());
|
||||||
|
|
||||||
report.imported.push(ImportedResource {
|
report.imported.push(ImportedResource {
|
||||||
id,
|
id,
|
||||||
@@ -183,9 +215,21 @@ impl<'a> ResourceImportService<'a> {
|
|||||||
.category_counts
|
.category_counts
|
||||||
.entry(category.as_str().to_string())
|
.entry(category.as_str().to_string())
|
||||||
.or_insert(0) += 1;
|
.or_insert(0) += 1;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(report)
|
/// 回滚本次导入已写入的资源索引和 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(
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user