feat(sync): 接入解析缓存与汉化发布前置

补齐官方 release 解析缓存、TextUnit 明细索引、资源变更集、Crowdin handoff 预留、ResourceRepository 导入元数据和 localized release patch 前置链路。

同时开放文件级 patch.apply 与 UnityFS TextAsset/string/semantic field patch CLI/RPC 入口,并保留官方原版资源与汉化产物双目录发布状态。

验证:cargo test -p bat-assetbundle --locked;cargo clippy -p bat-assetbundle --all-targets --locked -- -D warnings;cargo test -p bat-infrastructure --locked。
This commit is contained in:
2026-07-31 00:38:45 +08:00
parent f4880a71bd
commit 2079c6a307
25 changed files with 17287 additions and 225 deletions
+59 -11
View File
@@ -1,7 +1,7 @@
//! 内存资源仓储实现。
use async_trait::async_trait;
use bat_core::domain::{Resource, ResourceEntry, ResourceType};
use bat_core::domain::{Resource, ResourceEntry, ResourceMetadata, ResourceType};
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
use sqlx::{QueryBuilder, Sqlite, SqlitePool};
@@ -130,7 +130,8 @@ impl SqliteResourceRepository {
local_path TEXT NOT NULL,
address TEXT,
dependencies_json TEXT NOT NULL DEFAULT '[]',
crc INTEGER
crc INTEGER,
metadata_json TEXT NOT NULL DEFAULT '{}'
)
"#,
),
@@ -140,6 +141,13 @@ impl SqliteResourceRepository {
// 向后兼容:早于 crc 列的旧库缺少该列,按需补加(新建库已含该列,
// pragma 检查后不会重复 ALTER)。
Self::ensure_column(&self.pool, "resources", "crc", "INTEGER").await?;
Self::ensure_column(
&self.pool,
"resources",
"metadata_json",
"TEXT NOT NULL DEFAULT '{}'",
)
.await?;
Self::execute_query(
&self.pool,
@@ -250,9 +258,32 @@ impl SqliteResourceRepository {
.map_err(|error| bat_core::Error::Serialization(error.to_string()))
}
fn metadata_to_json(metadata: &ResourceMetadata) -> bat_core::Result<String> {
serde_json::to_string(metadata)
.map_err(|error| bat_core::Error::Serialization(error.to_string()))
}
fn metadata_from_json(value: &str) -> bat_core::Result<ResourceMetadata> {
if value.trim().is_empty() {
return Ok(ResourceMetadata::default());
}
serde_json::from_str(value)
.map_err(|error| bat_core::Error::Serialization(error.to_string()))
}
fn resource_from_row(row: ResourceRow) -> bat_core::Result<Resource> {
let (id, path, hash, size, resource_type, local_path, address, dependencies_json, crc) =
row;
let (
id,
path,
hash,
size,
resource_type,
local_path,
address,
dependencies_json,
crc,
metadata_json,
) = row;
Ok(Resource {
id,
local_path: PathBuf::from(local_path),
@@ -265,6 +296,7 @@ impl SqliteResourceRepository {
dependencies: Self::dependencies_from_json(&dependencies_json)?,
crc: crc.and_then(|value| u32::try_from(value).ok()),
},
metadata: Self::metadata_from_json(&metadata_json)?,
})
}
@@ -303,7 +335,7 @@ impl SqliteResourceRepository {
limit: Option<usize>,
) -> bat_core::Result<Vec<Resource>> {
let mut builder = QueryBuilder::<Sqlite>::new(
"SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc FROM resources",
"SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json FROM resources",
);
Self::apply_filters(&mut builder, query)?;
builder.push(" ORDER BY id");
@@ -337,14 +369,15 @@ impl SqliteResourceRepository {
impl ResourceRepository for SqliteResourceRepository {
async fn add(&self, resource: Resource) -> bat_core::Result<String> {
let dependencies = Self::dependencies_to_json(&resource.entry.dependencies)?;
let metadata = Self::metadata_to_json(&resource.metadata)?;
Self::execute_query(
&self.pool,
sqlx::query(
r#"
INSERT INTO resources (
id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(id) DO UPDATE SET
path = excluded.path,
hash = excluded.hash,
@@ -353,7 +386,8 @@ impl ResourceRepository for SqliteResourceRepository {
local_path = excluded.local_path,
address = excluded.address,
dependencies_json = excluded.dependencies_json,
crc = excluded.crc
crc = excluded.crc,
metadata_json = excluded.metadata_json
"#,
)
.bind(resource.id.clone())
@@ -364,7 +398,8 @@ impl ResourceRepository for SqliteResourceRepository {
.bind(resource.local_path.to_string_lossy().to_string())
.bind(resource.entry.address.clone())
.bind(dependencies)
.bind(resource.entry.crc.map(i64::from)),
.bind(resource.entry.crc.map(i64::from))
.bind(metadata),
)
.await?;
@@ -374,7 +409,7 @@ impl ResourceRepository for SqliteResourceRepository {
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
let row: Option<ResourceRow> = sqlx::query_as(
r#"
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json
FROM resources
WHERE id = ?1
"#,
@@ -392,7 +427,7 @@ impl ResourceRepository for SqliteResourceRepository {
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
let row: Option<ResourceRow> = sqlx::query_as(
r#"
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc, metadata_json
FROM resources
WHERE hash = ?1
ORDER BY id
@@ -455,6 +490,7 @@ type ResourceRow = (
Option<String>,
String,
Option<i64>,
String,
);
fn glob_to_like(pattern: &str) -> String {
@@ -544,6 +580,7 @@ mod tests {
dependencies: Vec::new(),
crc: None,
},
metadata: ResourceMetadata::default(),
}
}
@@ -638,6 +675,10 @@ mod tests {
.entry
.dependencies
.push("assets/shared.bundle".to_string());
resource.metadata.official_release_id = Some("release-1".to_string());
resource.metadata.platform = Some("windows".to_string());
resource.metadata.text_assets = vec!["Scenario".to_string()];
resource.metadata.text_unit_count = 3;
repository.add(resource.clone()).await.unwrap();
@@ -647,6 +688,13 @@ mod tests {
by_id.entry.dependencies,
vec!["assets/shared.bundle".to_string()]
);
assert_eq!(
by_id.metadata.official_release_id.as_deref(),
Some("release-1")
);
assert_eq!(by_id.metadata.platform.as_deref(), Some("windows"));
assert_eq!(by_id.metadata.text_assets, vec!["Scenario".to_string()]);
assert_eq!(by_id.metadata.text_unit_count, 3);
assert_eq!(
repository.find_by_hash("hash-sqlite-a").await.unwrap().id,
resource.id