feat: add official resource sync pipeline

Add the production-facing official update service and bat-official-sync watch CLI for unattended resource synchronization.

Support launcher-resource discovery without installing the launcher, remote marker snapshots, local manifest audit and repair, official seed hash validation, bootstrap caching, richer Addressables coverage, SQLite resource persistence, and FFI JSON helpers.
This commit is contained in:
2026-07-05 23:49:56 +08:00
parent 99e66a48d7
commit 789402c887
35 changed files with 6262 additions and 177 deletions
+412 -1
View File
@@ -1,9 +1,13 @@
//! 内存资源仓储实现。
use async_trait::async_trait;
use bat_core::domain::Resource;
use bat_core::domain::{Resource, ResourceEntry, ResourceType};
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteQueryResult};
use sqlx::{QueryBuilder, Sqlite, SqlitePool};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use tokio::sync::RwLock;
/// 以内存 `HashMap` 保存资源索引的仓储实现。
@@ -83,6 +87,359 @@ impl ResourceRepository for InMemoryResourceRepository {
}
}
/// SQLite 资源仓储实现。
#[derive(Debug, Clone)]
pub struct SqliteResourceRepository {
pool: SqlitePool,
}
impl SqliteResourceRepository {
/// 打开或创建 SQLite 资源仓储。
pub async fn new(path: impl AsRef<Path>) -> bat_core::Result<Self> {
if let Some(parent) = path.as_ref().parent() {
tokio::fs::create_dir_all(parent).await?;
}
let options =
SqliteConnectOptions::from_str(&format!("sqlite://{}", path.as_ref().display()))
.map_err(|error| bat_core::Error::Other(error.into()))?
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
let repository = Self { pool };
repository.init_schema().await?;
Ok(repository)
}
async fn init_schema(&self) -> bat_core::Result<()> {
Self::execute_query(
&self.pool,
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS resources (
id TEXT PRIMARY KEY NOT NULL,
path TEXT NOT NULL,
hash TEXT NOT NULL,
size INTEGER NOT NULL CHECK(size >= 0),
resource_type TEXT NOT NULL,
local_path TEXT NOT NULL,
address TEXT,
dependencies_json TEXT NOT NULL DEFAULT '[]'
)
"#,
),
)
.await?;
Self::execute_query(
&self.pool,
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_resources_hash
ON resources(hash)
"#,
),
)
.await?;
Self::execute_query(
&self.pool,
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_resources_type
ON resources(resource_type)
"#,
),
)
.await?;
Self::execute_query(
&self.pool,
sqlx::query(
r#"
CREATE INDEX IF NOT EXISTS idx_resources_path
ON resources(path)
"#,
),
)
.await?;
Ok(())
}
async fn execute_query<'q>(
pool: &SqlitePool,
query: sqlx::query::Query<'q, sqlx::Sqlite, sqlx::sqlite::SqliteArguments<'q>>,
) -> bat_core::Result<SqliteQueryResult> {
query
.execute(pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))
}
fn resource_type_to_str(resource_type: ResourceType) -> &'static str {
match resource_type {
ResourceType::AssetBundle => "AssetBundle",
ResourceType::Manifest => "Manifest",
ResourceType::TableBundle => "TableBundle",
ResourceType::Other => "Other",
}
}
fn resource_type_from_str(value: &str) -> bat_core::Result<ResourceType> {
match value {
"AssetBundle" => Ok(ResourceType::AssetBundle),
"Manifest" => Ok(ResourceType::Manifest),
"TableBundle" => Ok(ResourceType::TableBundle),
"Other" => Ok(ResourceType::Other),
other => Err(bat_core::Error::Serialization(format!(
"Unknown resource type: {}",
other
))),
}
}
fn dependencies_to_json(dependencies: &[String]) -> bat_core::Result<String> {
serde_json::to_string(dependencies)
.map_err(|error| bat_core::Error::Serialization(error.to_string()))
}
fn dependencies_from_json(value: &str) -> bat_core::Result<Vec<String>> {
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) = row;
Ok(Resource {
id,
local_path: PathBuf::from(local_path),
entry: ResourceEntry {
path,
hash,
size: size as u64,
resource_type: Self::resource_type_from_str(&resource_type)?,
address,
dependencies: Self::dependencies_from_json(&dependencies_json)?,
},
})
}
fn apply_filters<'a>(
builder: &mut QueryBuilder<'a, Sqlite>,
query: &'a ResourceQuery,
) -> bat_core::Result<()> {
let mut has_where = false;
if let Some(resource_type) = query.resource_type {
push_condition_prefix(builder, &mut has_where);
builder.push("resource_type = ");
builder.push_bind(Self::resource_type_to_str(resource_type));
}
if let Some(hash) = &query.hash {
push_condition_prefix(builder, &mut has_where);
builder.push("hash = ");
builder.push_bind(hash);
}
if let Some(pattern) = &query.path_pattern {
push_condition_prefix(builder, &mut has_where);
builder.push("path LIKE ");
builder
.push_bind(glob_to_like(pattern))
.push(" ESCAPE '\\'");
}
Ok(())
}
async fn fetch_resources(
&self,
query: &ResourceQuery,
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 FROM resources",
);
Self::apply_filters(&mut builder, query)?;
builder.push(" ORDER BY id");
if let Some(limit) = limit {
builder.push(" LIMIT ").push_bind(limit as i64);
}
let rows: Vec<ResourceRow> = builder
.build_query_as()
.fetch_all(&self.pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
rows.into_iter().map(Self::resource_from_row).collect()
}
async fn count_resources(&self, query: &ResourceQuery) -> bat_core::Result<u64> {
let mut builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM resources");
Self::apply_filters(&mut builder, query)?;
let count: i64 = builder
.build_query_scalar()
.fetch_one(&self.pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
Ok(count as u64)
}
}
#[async_trait]
impl ResourceRepository for SqliteResourceRepository {
async fn add(&self, resource: Resource) -> bat_core::Result<String> {
let dependencies = Self::dependencies_to_json(&resource.entry.dependencies)?;
Self::execute_query(
&self.pool,
sqlx::query(
r#"
INSERT INTO resources (
id, path, hash, size, resource_type, local_path, address, dependencies_json
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(id) DO UPDATE SET
path = excluded.path,
hash = excluded.hash,
size = excluded.size,
resource_type = excluded.resource_type,
local_path = excluded.local_path,
address = excluded.address,
dependencies_json = excluded.dependencies_json
"#,
)
.bind(resource.id.clone())
.bind(resource.entry.path.clone())
.bind(resource.entry.hash.clone())
.bind(resource.entry.size as i64)
.bind(Self::resource_type_to_str(resource.entry.resource_type))
.bind(resource.local_path.to_string_lossy().to_string())
.bind(resource.entry.address.clone())
.bind(dependencies),
)
.await?;
Ok(resource.id)
}
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
FROM resources
WHERE id = ?1
"#,
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
row.map(Self::resource_from_row)
.transpose()?
.ok_or_else(|| bat_core::Error::NotFound(id.to_string()))
}
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
FROM resources
WHERE hash = ?1
ORDER BY id
LIMIT 1
"#,
)
.bind(hash)
.fetch_optional(&self.pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
row.map(Self::resource_from_row)
.transpose()?
.ok_or_else(|| bat_core::Error::NotFound(hash.to_string()))
}
async fn list(&self, query: ResourceQuery) -> bat_core::Result<Vec<Resource>> {
self.fetch_resources(&query, None).await
}
async fn update(&self, resource: Resource) -> bat_core::Result<()> {
let _existing = self.find_by_id(&resource.id).await?;
self.add(resource).await?;
Ok(())
}
async fn delete(&self, id: &str) -> bat_core::Result<()> {
let result = Self::execute_query(
&self.pool,
sqlx::query("DELETE FROM resources WHERE id = ?1").bind(id),
)
.await?;
if result.rows_affected() == 0 {
return Err(bat_core::Error::NotFound(id.to_string()));
}
Ok(())
}
async fn count(&self, query: ResourceQuery) -> bat_core::Result<u64> {
self.count_resources(&query).await
}
}
fn push_condition_prefix<'a>(builder: &mut QueryBuilder<'a, Sqlite>, has_where: &mut bool) {
if *has_where {
builder.push(" AND ");
} else {
builder.push(" WHERE ");
*has_where = true;
}
}
type ResourceRow = (
String,
String,
String,
i64,
String,
String,
Option<String>,
String,
);
fn glob_to_like(pattern: &str) -> String {
let mut escaped = String::new();
let mut chars = pattern.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'*' => {
if matches!(chars.peek(), Some('*')) {
chars.next();
}
escaped.push('%');
}
'?' => escaped.push('_'),
'%' | '_' | '\\' => {
escaped.push('\\');
escaped.push(ch);
}
other => escaped.push(other),
}
}
escaped
}
fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
if let Some(resource_type) = query.resource_type {
if resource.entry.resource_type != resource_type {
@@ -142,6 +499,8 @@ mod tests {
hash: hash.to_string(),
size: 7,
resource_type,
address: None,
dependencies: Vec::new(),
},
}
}
@@ -214,4 +573,56 @@ mod tests {
);
assert_eq!(repository.count(ResourceQuery::all()).await.unwrap(), 2);
}
async fn sqlite_repository() -> (tempfile::TempDir, SqliteResourceRepository) {
let temp_dir = tempfile::tempdir().unwrap();
let repository = SqliteResourceRepository::new(temp_dir.path().join("resources.sqlite"))
.await
.unwrap();
(temp_dir, repository)
}
#[tokio::test]
async fn sqlite_repository_persists_and_filters_resources() {
let (_temp_dir, repository) = sqlite_repository().await;
let mut resource = resource(
"resource/sqlite-a",
"assets/model.bundle",
"hash-sqlite-a",
ResourceType::AssetBundle,
);
resource.entry.address = Some("Character_001".to_string());
resource
.entry
.dependencies
.push("assets/shared.bundle".to_string());
repository.add(resource.clone()).await.unwrap();
let by_id = repository.find_by_id(&resource.id).await.unwrap();
assert_eq!(by_id.entry.address.as_deref(), Some("Character_001"));
assert_eq!(
by_id.entry.dependencies,
vec!["assets/shared.bundle".to_string()]
);
assert_eq!(
repository.find_by_hash("hash-sqlite-a").await.unwrap().id,
resource.id
);
let bundles = repository
.list(ResourceQuery::by_type(ResourceType::AssetBundle))
.await
.unwrap();
assert_eq!(bundles.len(), 1);
let count = repository.count(ResourceQuery::all()).await.unwrap();
assert_eq!(count, 1);
repository.delete(&resource.id).await.unwrap();
assert!(matches!(
repository.find_by_id(&resource.id).await,
Err(bat_core::Error::NotFound(_))
));
}
}