mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-21 22:11:26 +08:00
feat(addressables): 提取 m_Crc 并提供 size/CRC 校验 API(issue #2)
Addressables catalog 里的 m_Crc(bundle IEEE CRC-32)此前从未解析;
声明的 size 也只作元数据、无校验能力。本次:
- ResourceEntry 新增 crc: Option<u32>(serde default 向后兼容),
compact 与 expanded 两种 catalog 形态均解析 m_Crc/m_Crc→crc
- core 新增 crc32_ieee(IEEE CRC-32,等价 zlib/Unity m_Crc)与
ResourceEntry::{declared_crc, verify_downloaded_bytes}:按声明的
size/crc 校验字节,0 视为「无 CRC」跳过
- SqliteResourceRepository 持久化 crc 列,旧库经幂等 ensure_column
迁移补列(pragma_table_info 判断后 ALTER)
- golden 投影与 fixture 补 crc 字段,验证真实形态 catalog 提取贯通
校验 API 暂不接入 import 覆盖路径(该路径按 CAS id 重写 hash/size 是
既定语义,且合成测试的声明值不匹配实际字节);接入下载/导入校验留
待 G-011。
验证:core crc32 标准向量 + verify 分支单测、expanded 形态非零 crc
提取单测、golden 端到端;core/adapters/infrastructure 全测试 + fmt +
clippy --all-targets -D warnings 全绿。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -272,6 +272,12 @@ impl AddressablesCatalogDriver {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let dependencies = Self::dependencies_from_entry(value);
|
let dependencies = Self::dependencies_from_entry(value);
|
||||||
|
let crc = value
|
||||||
|
.get("crc")
|
||||||
|
.or_else(|| value.get("Crc"))
|
||||||
|
.or_else(|| value.get("m_Crc"))
|
||||||
|
.and_then(|value| value.as_u64())
|
||||||
|
.and_then(|value| u32::try_from(value).ok());
|
||||||
|
|
||||||
Some(ResourceEntry {
|
Some(ResourceEntry {
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
@@ -280,6 +286,7 @@ impl AddressablesCatalogDriver {
|
|||||||
resource_type: Self::resource_type_for_path(path),
|
resource_type: Self::resource_type_for_path(path),
|
||||||
address,
|
address,
|
||||||
dependencies,
|
dependencies,
|
||||||
|
crc,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,6 +383,7 @@ impl AddressablesCatalogDriver {
|
|||||||
resource_type: Self::resource_type_for_path(path),
|
resource_type: Self::resource_type_for_path(path),
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
crc: None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -413,6 +421,7 @@ impl AddressablesCatalogDriver {
|
|||||||
resource_type,
|
resource_type,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
crc: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,6 +492,7 @@ impl AddressablesCatalogDriver {
|
|||||||
Some(primary_key)
|
Some(primary_key)
|
||||||
},
|
},
|
||||||
dependencies,
|
dependencies,
|
||||||
|
crc: extra.crc,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -619,6 +629,11 @@ impl AddressablesCatalogDriver {
|
|||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
.map(ToOwned::to_owned),
|
.map(ToOwned::to_owned),
|
||||||
bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()),
|
bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()),
|
||||||
|
// m_Crc 是 bundle 的 IEEE CRC-32;0 表示不做 CRC 校验,忠实保留原值。
|
||||||
|
crc: json
|
||||||
|
.get("m_Crc")
|
||||||
|
.and_then(|value| value.as_u64())
|
||||||
|
.and_then(|value| u32::try_from(value).ok()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,6 +836,7 @@ struct AddressablesExtraData {
|
|||||||
hash: Option<String>,
|
hash: Option<String>,
|
||||||
bundle_name: Option<String>,
|
bundle_name: Option<String>,
|
||||||
bundle_size: Option<u64>,
|
bundle_size: Option<u64>,
|
||||||
|
crc: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AddressablesCatalogDriver {
|
impl Default for AddressablesCatalogDriver {
|
||||||
@@ -954,6 +970,7 @@ mod tests {
|
|||||||
"internal_id": "synthetic/minimal.bundle",
|
"internal_id": "synthetic/minimal.bundle",
|
||||||
"hash": "synthetic-entry-hash",
|
"hash": "synthetic-entry-hash",
|
||||||
"size": 119,
|
"size": 119,
|
||||||
|
"crc": 3735928559,
|
||||||
"address": "Character_001",
|
"address": "Character_001",
|
||||||
"dependencies": ["synthetic/shared.bundle"]
|
"dependencies": ["synthetic/shared.bundle"]
|
||||||
},
|
},
|
||||||
@@ -971,6 +988,9 @@ mod tests {
|
|||||||
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
|
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
|
||||||
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
|
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
|
||||||
assert_eq!(manifest.resources[0].size, 119);
|
assert_eq!(manifest.resources[0].size, 119);
|
||||||
|
// m_Crc(此处 0xDEADBEEF)应被提取;缺该字段的条目为 None。
|
||||||
|
assert_eq!(manifest.resources[0].crc, Some(0xDEAD_BEEF));
|
||||||
|
assert_eq!(manifest.resources[1].crc, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.resources[0].address.as_deref(),
|
manifest.resources[0].address.as_deref(),
|
||||||
Some("Character_001")
|
Some("Character_001")
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle",
|
"address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"shared_assets_all_123.bundle"
|
"shared_assets_all_123.bundle"
|
||||||
]
|
],
|
||||||
|
"crc": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
"path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
"size": 162134,
|
"size": 162134,
|
||||||
"resource_type": "AssetBundle",
|
"resource_type": "AssetBundle",
|
||||||
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
||||||
"dependencies": []
|
"dependencies": [],
|
||||||
|
"crc": 0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "shared_assets_all_123.bundle",
|
"path": "shared_assets_all_123.bundle",
|
||||||
@@ -28,7 +30,8 @@
|
|||||||
"size": 153480,
|
"size": 153480,
|
||||||
"resource_type": "AssetBundle",
|
"resource_type": "AssetBundle",
|
||||||
"address": "shared_assets_all_123.bundle",
|
"address": "shared_assets_all_123.bundle",
|
||||||
"dependencies": []
|
"dependencies": [],
|
||||||
|
"crc": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async fn parses_real_shape_addressables_catalog_against_golden() {
|
|||||||
"resource_type": format!("{:?}", resource.resource_type),
|
"resource_type": format!("{:?}", resource.resource_type),
|
||||||
"address": resource.address,
|
"address": resource.address,
|
||||||
"dependencies": resource.dependencies,
|
"dependencies": resource.dependencies,
|
||||||
|
"crc": resource.crc,
|
||||||
})
|
})
|
||||||
}).collect::<Vec<_>>(),
|
}).collect::<Vec<_>>(),
|
||||||
"metadata": manifest.metadata.extra,
|
"metadata": manifest.metadata.extra,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub mod translation;
|
|||||||
|
|
||||||
pub use game_client::{ClientStatus, GameClient, GameRegion};
|
pub use game_client::{ClientStatus, GameClient, GameRegion};
|
||||||
pub use game_version::{GameVersion, UnityVersion};
|
pub use game_version::{GameVersion, UnityVersion};
|
||||||
pub use resource::{Resource, ResourceEntry, ResourceType};
|
pub use resource::{crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceType};
|
||||||
pub use translation::{
|
pub use translation::{
|
||||||
ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText,
|
ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText,
|
||||||
TranslationStatus,
|
TranslationStatus,
|
||||||
|
|||||||
+145
-5
@@ -34,6 +34,94 @@ pub struct ResourceEntry {
|
|||||||
pub address: Option<String>,
|
pub address: Option<String>,
|
||||||
/// 该资源依赖的其他资源标识
|
/// 该资源依赖的其他资源标识
|
||||||
pub dependencies: Vec<String>,
|
pub dependencies: Vec<String>,
|
||||||
|
/// Addressables bundle 的 CRC32(catalog 中的 `m_Crc`)。
|
||||||
|
///
|
||||||
|
/// `None` 表示 catalog 未提供该字段;Unity 用 `0` 表示「不做 CRC 校验」,
|
||||||
|
/// 因此 `Some(0)` 与 `None` 在校验时同样视为「无 CRC」。为向后兼容旧的
|
||||||
|
/// 持久化数据,反序列化时缺省为 `None`。
|
||||||
|
#[serde(default)]
|
||||||
|
pub crc: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已下载字节与 catalog 声明的可校验字段不一致。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum IntegrityMismatch {
|
||||||
|
/// 实际字节数与声明的 `size` 不符。
|
||||||
|
Size {
|
||||||
|
/// catalog 声明的大小。
|
||||||
|
expected: u64,
|
||||||
|
/// 实际字节数。
|
||||||
|
actual: u64,
|
||||||
|
},
|
||||||
|
/// 实际 CRC32 与声明的 `crc` 不符。
|
||||||
|
Crc {
|
||||||
|
/// catalog 声明的 CRC32。
|
||||||
|
expected: u32,
|
||||||
|
/// 实际计算出的 CRC32。
|
||||||
|
actual: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for IntegrityMismatch {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Size { expected, actual } => {
|
||||||
|
write!(formatter, "大小不符:声明 {expected},实际 {actual}")
|
||||||
|
}
|
||||||
|
Self::Crc { expected, actual } => write!(
|
||||||
|
formatter,
|
||||||
|
"CRC32 不符:声明 {expected:#010x},实际 {actual:#010x}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for IntegrityMismatch {}
|
||||||
|
|
||||||
|
impl ResourceEntry {
|
||||||
|
/// catalog 声明的 CRC32(`m_Crc`),`0` 归一化为「无 CRC」(返回 `None`)。
|
||||||
|
pub fn declared_crc(&self) -> Option<u32> {
|
||||||
|
self.crc.filter(|value| *value != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用 catalog 声明的可校验字段(`size`、`crc`)校验已下载/已解出的字节。
|
||||||
|
///
|
||||||
|
/// - `size`:声明值为 `0` 视为未提供,跳过;否则要求与 `data.len()` 相等。
|
||||||
|
/// - `crc`:无声明(`None`/`Some(0)`)时跳过;否则按 IEEE CRC-32 计算 `data`
|
||||||
|
/// 的 CRC 并比对。Unity AssetBundle 的 `m_Crc` 即标准 IEEE CRC-32(与
|
||||||
|
/// zlib `crc32` 一致,UnityPy/AssetStudio 等生态一致采用)。
|
||||||
|
///
|
||||||
|
/// 校验通过返回 `Ok(())`;不一致返回首个失败项(先 size 后 crc)。
|
||||||
|
pub fn verify_downloaded_bytes(&self, data: &[u8]) -> Result<(), IntegrityMismatch> {
|
||||||
|
if self.size != 0 && self.size != data.len() as u64 {
|
||||||
|
return Err(IntegrityMismatch::Size {
|
||||||
|
expected: self.size,
|
||||||
|
actual: data.len() as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(expected) = self.declared_crc() {
|
||||||
|
let actual = crc32_ieee(data);
|
||||||
|
if actual != expected {
|
||||||
|
return Err(IntegrityMismatch::Crc { expected, actual });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 计算 IEEE CRC-32(多项式 `0xEDB88320`,反射,初值/终值 `0xFFFFFFFF`)。
|
||||||
|
///
|
||||||
|
/// 与 zlib `crc32` 及 Unity AssetBundle `m_Crc` 使用的算法一致。
|
||||||
|
pub fn crc32_ieee(data: &[u8]) -> u32 {
|
||||||
|
let mut crc: u32 = 0xFFFF_FFFF;
|
||||||
|
for &byte in data {
|
||||||
|
crc ^= u32::from(byte);
|
||||||
|
for _ in 0..8 {
|
||||||
|
let mask = (crc & 1).wrapping_neg();
|
||||||
|
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!crc
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 资源
|
/// 资源
|
||||||
@@ -51,18 +139,70 @@ pub struct Resource {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
fn entry_with(size: u64, crc: Option<u32>) -> ResourceEntry {
|
||||||
fn test_resource_entry() {
|
ResourceEntry {
|
||||||
let entry = ResourceEntry {
|
|
||||||
path: "test.bundle".to_string(),
|
path: "test.bundle".to_string(),
|
||||||
hash: "abc123".to_string(),
|
hash: "abc123".to_string(),
|
||||||
size: 1024,
|
size,
|
||||||
resource_type: ResourceType::AssetBundle,
|
resource_type: ResourceType::AssetBundle,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
};
|
crc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resource_entry() {
|
||||||
|
let entry = entry_with(1024, None);
|
||||||
assert_eq!(entry.path, "test.bundle");
|
assert_eq!(entry.path, "test.bundle");
|
||||||
assert_eq!(entry.size, 1024);
|
assert_eq!(entry.size, 1024);
|
||||||
|
assert_eq!(entry.crc, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crc32_matches_known_vector() {
|
||||||
|
// 标准 IEEE CRC-32 测试向量:crc32("123456789") == 0xCBF43926。
|
||||||
|
assert_eq!(crc32_ieee(b"123456789"), 0xCBF4_3926);
|
||||||
|
assert_eq!(crc32_ieee(b""), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declared_crc_treats_zero_as_absent() {
|
||||||
|
assert_eq!(entry_with(0, None).declared_crc(), None);
|
||||||
|
assert_eq!(entry_with(0, Some(0)).declared_crc(), None);
|
||||||
|
assert_eq!(entry_with(0, Some(42)).declared_crc(), Some(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_downloaded_bytes_checks_size_and_crc() {
|
||||||
|
let data = b"123456789";
|
||||||
|
let crc = crc32_ieee(data);
|
||||||
|
|
||||||
|
// size + crc 均匹配。
|
||||||
|
assert!(entry_with(data.len() as u64, Some(crc))
|
||||||
|
.verify_downloaded_bytes(data)
|
||||||
|
.is_ok());
|
||||||
|
|
||||||
|
// size=0 与 crc=0/None 视为未声明,跳过校验。
|
||||||
|
assert!(entry_with(0, None).verify_downloaded_bytes(data).is_ok());
|
||||||
|
assert!(entry_with(0, Some(0)).verify_downloaded_bytes(data).is_ok());
|
||||||
|
|
||||||
|
// size 不符。
|
||||||
|
assert_eq!(
|
||||||
|
entry_with(3, None).verify_downloaded_bytes(data),
|
||||||
|
Err(IntegrityMismatch::Size {
|
||||||
|
expected: 3,
|
||||||
|
actual: 9
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// size 通过、crc 不符。
|
||||||
|
assert_eq!(
|
||||||
|
entry_with(data.len() as u64, Some(0xDEAD_BEEF)).verify_downloaded_bytes(data),
|
||||||
|
Err(IntegrityMismatch::Crc {
|
||||||
|
expected: 0xDEAD_BEEF,
|
||||||
|
actual: crc
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,6 +425,7 @@ mod tests {
|
|||||||
resource_type: ResourceType::AssetBundle,
|
resource_type: ResourceType::AssetBundle,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
crc: None,
|
||||||
},
|
},
|
||||||
ResourceEntry {
|
ResourceEntry {
|
||||||
path: "synthetic/catalog.json".to_string(),
|
path: "synthetic/catalog.json".to_string(),
|
||||||
@@ -433,6 +434,7 @@ mod tests {
|
|||||||
resource_type: ResourceType::Manifest,
|
resource_type: ResourceType::Manifest,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
crc: None,
|
||||||
},
|
},
|
||||||
ResourceEntry {
|
ResourceEntry {
|
||||||
path: "TextAssets/dialogue.csv".to_string(),
|
path: "TextAssets/dialogue.csv".to_string(),
|
||||||
@@ -441,6 +443,7 @@ mod tests {
|
|||||||
resource_type: ResourceType::TextAsset,
|
resource_type: ResourceType::TextAsset,
|
||||||
address: Some("dialogue".to_string()),
|
address: Some("dialogue".to_string()),
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
crc: None,
|
||||||
},
|
},
|
||||||
ResourceEntry {
|
ResourceEntry {
|
||||||
path: "TableBundles/ExcelDB.db".to_string(),
|
path: "TableBundles/ExcelDB.db".to_string(),
|
||||||
@@ -449,6 +452,7 @@ mod tests {
|
|||||||
resource_type: ResourceType::TableBundle,
|
resource_type: ResourceType::TableBundle,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
crc: None,
|
||||||
},
|
},
|
||||||
ResourceEntry {
|
ResourceEntry {
|
||||||
path: "MediaResources-Windows/voice/title.acb".to_string(),
|
path: "MediaResources-Windows/voice/title.acb".to_string(),
|
||||||
@@ -457,6 +461,7 @@ mod tests {
|
|||||||
resource_type: ResourceType::Media,
|
resource_type: ResourceType::Media,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
crc: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
metadata: ManifestMetadata {
|
metadata: ManifestMetadata {
|
||||||
@@ -487,6 +492,7 @@ mod tests {
|
|||||||
resource_type: ResourceType::TextAsset,
|
resource_type: ResourceType::TextAsset,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
crc: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +513,7 @@ mod tests {
|
|||||||
resource_type: ResourceType::AssetBundle,
|
resource_type: ResourceType::AssetBundle,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
crc: None,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -129,13 +129,18 @@ impl SqliteResourceRepository {
|
|||||||
resource_type TEXT NOT NULL,
|
resource_type TEXT NOT NULL,
|
||||||
local_path TEXT NOT NULL,
|
local_path TEXT NOT NULL,
|
||||||
address TEXT,
|
address TEXT,
|
||||||
dependencies_json TEXT NOT NULL DEFAULT '[]'
|
dependencies_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
crc INTEGER
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// 向后兼容:早于 crc 列的旧库缺少该列,按需补加(新建库已含该列,
|
||||||
|
// pragma 检查后不会重复 ALTER)。
|
||||||
|
Self::ensure_column(&self.pool, "resources", "crc", "INTEGER").await?;
|
||||||
|
|
||||||
Self::execute_query(
|
Self::execute_query(
|
||||||
&self.pool,
|
&self.pool,
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -182,6 +187,33 @@ impl SqliteResourceRepository {
|
|||||||
.map_err(|error| bat_core::Error::Other(error.into()))
|
.map_err(|error| bat_core::Error::Other(error.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 幂等地为 `table` 补加 `column`(若尚不存在)。用于向后兼容的 schema 迁移。
|
||||||
|
async fn ensure_column(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
table: &str,
|
||||||
|
column: &str,
|
||||||
|
column_type: &str,
|
||||||
|
) -> bat_core::Result<()> {
|
||||||
|
let exists: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info(?1) WHERE name = ?2")
|
||||||
|
.bind(table)
|
||||||
|
.bind(column)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|error| bat_core::Error::Other(error.into()))?;
|
||||||
|
if exists == 0 {
|
||||||
|
// 表名/列名/类型均为内部常量,非用户输入,可安全内插。
|
||||||
|
Self::execute_query(
|
||||||
|
pool,
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"ALTER TABLE {table} ADD COLUMN {column} {column_type}"
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn resource_type_to_str(resource_type: ResourceType) -> &'static str {
|
fn resource_type_to_str(resource_type: ResourceType) -> &'static str {
|
||||||
match resource_type {
|
match resource_type {
|
||||||
ResourceType::AssetBundle => "AssetBundle",
|
ResourceType::AssetBundle => "AssetBundle",
|
||||||
@@ -219,7 +251,8 @@ impl SqliteResourceRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resource_from_row(row: ResourceRow) -> bat_core::Result<Resource> {
|
fn resource_from_row(row: ResourceRow) -> bat_core::Result<Resource> {
|
||||||
let (id, path, hash, size, resource_type, local_path, address, dependencies_json) = row;
|
let (id, path, hash, size, resource_type, local_path, address, dependencies_json, crc) =
|
||||||
|
row;
|
||||||
Ok(Resource {
|
Ok(Resource {
|
||||||
id,
|
id,
|
||||||
local_path: PathBuf::from(local_path),
|
local_path: PathBuf::from(local_path),
|
||||||
@@ -230,6 +263,7 @@ impl SqliteResourceRepository {
|
|||||||
resource_type: Self::resource_type_from_str(&resource_type)?,
|
resource_type: Self::resource_type_from_str(&resource_type)?,
|
||||||
address,
|
address,
|
||||||
dependencies: Self::dependencies_from_json(&dependencies_json)?,
|
dependencies: Self::dependencies_from_json(&dependencies_json)?,
|
||||||
|
crc: crc.and_then(|value| u32::try_from(value).ok()),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -269,7 +303,7 @@ impl SqliteResourceRepository {
|
|||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> bat_core::Result<Vec<Resource>> {
|
) -> bat_core::Result<Vec<Resource>> {
|
||||||
let mut builder = QueryBuilder::<Sqlite>::new(
|
let mut builder = QueryBuilder::<Sqlite>::new(
|
||||||
"SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json FROM resources",
|
"SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc FROM resources",
|
||||||
);
|
);
|
||||||
Self::apply_filters(&mut builder, query)?;
|
Self::apply_filters(&mut builder, query)?;
|
||||||
builder.push(" ORDER BY id");
|
builder.push(" ORDER BY id");
|
||||||
@@ -308,9 +342,9 @@ impl ResourceRepository for SqliteResourceRepository {
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO resources (
|
INSERT INTO resources (
|
||||||
id, path, hash, size, resource_type, local_path, address, dependencies_json
|
id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
|
||||||
)
|
)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
path = excluded.path,
|
path = excluded.path,
|
||||||
hash = excluded.hash,
|
hash = excluded.hash,
|
||||||
@@ -318,7 +352,8 @@ impl ResourceRepository for SqliteResourceRepository {
|
|||||||
resource_type = excluded.resource_type,
|
resource_type = excluded.resource_type,
|
||||||
local_path = excluded.local_path,
|
local_path = excluded.local_path,
|
||||||
address = excluded.address,
|
address = excluded.address,
|
||||||
dependencies_json = excluded.dependencies_json
|
dependencies_json = excluded.dependencies_json,
|
||||||
|
crc = excluded.crc
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(resource.id.clone())
|
.bind(resource.id.clone())
|
||||||
@@ -328,7 +363,8 @@ impl ResourceRepository for SqliteResourceRepository {
|
|||||||
.bind(Self::resource_type_to_str(resource.entry.resource_type))
|
.bind(Self::resource_type_to_str(resource.entry.resource_type))
|
||||||
.bind(resource.local_path.to_string_lossy().to_string())
|
.bind(resource.local_path.to_string_lossy().to_string())
|
||||||
.bind(resource.entry.address.clone())
|
.bind(resource.entry.address.clone())
|
||||||
.bind(dependencies),
|
.bind(dependencies)
|
||||||
|
.bind(resource.entry.crc.map(i64::from)),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -338,7 +374,7 @@ impl ResourceRepository for SqliteResourceRepository {
|
|||||||
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
|
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
|
||||||
let row: Option<ResourceRow> = sqlx::query_as(
|
let row: Option<ResourceRow> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json
|
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
|
||||||
FROM resources
|
FROM resources
|
||||||
WHERE id = ?1
|
WHERE id = ?1
|
||||||
"#,
|
"#,
|
||||||
@@ -356,7 +392,7 @@ impl ResourceRepository for SqliteResourceRepository {
|
|||||||
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
|
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
|
||||||
let row: Option<ResourceRow> = sqlx::query_as(
|
let row: Option<ResourceRow> = sqlx::query_as(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json
|
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
|
||||||
FROM resources
|
FROM resources
|
||||||
WHERE hash = ?1
|
WHERE hash = ?1
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
@@ -418,6 +454,7 @@ type ResourceRow = (
|
|||||||
String,
|
String,
|
||||||
Option<String>,
|
Option<String>,
|
||||||
String,
|
String,
|
||||||
|
Option<i64>,
|
||||||
);
|
);
|
||||||
|
|
||||||
fn glob_to_like(pattern: &str) -> String {
|
fn glob_to_like(pattern: &str) -> String {
|
||||||
@@ -505,6 +542,7 @@ mod tests {
|
|||||||
resource_type,
|
resource_type,
|
||||||
address: None,
|
address: None,
|
||||||
dependencies: Vec::new(),
|
dependencies: Vec::new(),
|
||||||
|
crc: None,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user