fix: 完成下载并发与翻译交接链路

This commit is contained in:
2026-08-02 22:34:23 +08:00
parent d533c88108
commit 8b64cc94f3
19 changed files with 2923 additions and 138 deletions
+79 -1
View File
@@ -2,6 +2,45 @@
use async_trait::async_trait;
use bat_core::domain::{GameClient, GameRegion};
use std::path::PathBuf;
/// Linux-first client discovery backed by explicitly supplied roots.
///
/// The adapter never scans home directories implicitly and does not require
/// the official launcher. The roots are normally a staging/import directory
/// selected by the caller.
#[derive(Debug, Clone, Default)]
pub struct LinuxClientDiscovery {
roots: Vec<PathBuf>,
}
impl LinuxClientDiscovery {
/// Creates a discovery adapter for explicit candidate roots.
pub fn new(roots: Vec<PathBuf>) -> Self {
Self { roots }
}
}
#[async_trait]
impl ClientDiscovery for LinuxClientDiscovery {
async fn discover_all(&self) -> Result<Vec<GameClient>, String> {
GameClient::discover_in_roots(&self.roots).map_err(|error| error.to_string())
}
async fn verify_client(&self, path: &str) -> bool {
GameClient::new(PathBuf::from(path), GameRegion::Japan)
.verify_integrity()
.unwrap_or(false)
}
async fn detect_region(&self, path: &str) -> Result<GameRegion, String> {
if self.verify_client(path).await {
Ok(GameRegion::Japan)
} else {
Err(format!("不是有效的 Linux Blue Archive 客户端:{path}"))
}
}
}
/// 客户端发现接口
///
@@ -49,5 +88,44 @@ pub trait ClientDiscovery: Send + Sync {
#[cfg(test)]
mod tests {
// 测试将在实现时添加
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn linux_discovery_uses_explicit_roots_and_verifies_layout() {
let temp = TempDir::new().unwrap();
let client = temp.path().join("BlueArchive_JP");
fs::create_dir_all(client.join("BlueArchive_Data/StreamingAssets/AssetBundles")).unwrap();
let discovery = LinuxClientDiscovery::new(vec![temp.path().to_path_buf()]);
let clients = discovery.discover_all().await.unwrap();
assert_eq!(clients.len(), 1);
assert!(
discovery
.verify_client(clients[0].install_path.to_str().unwrap())
.await
);
assert_eq!(
discovery
.detect_region(clients[0].install_path.to_str().unwrap())
.await
.unwrap(),
GameRegion::Japan
);
}
#[tokio::test]
async fn linux_discovery_rejects_unrelated_path() {
let discovery = LinuxClientDiscovery::default();
assert!(
!discovery
.verify_client("/tmp/not-a-blue-archive-client")
.await
);
assert!(discovery
.detect_region("/tmp/not-a-blue-archive-client")
.await
.is_err());
}
}
+208
View File
@@ -0,0 +1,208 @@
//! Official resource backend seams.
//!
//! The update pipeline consumes these small contracts instead of depending on
//! one region's URL and catalog rules everywhere. The JP implementation is
//! the only production adapter today; adding another region should implement
//! this module's contracts without changing downloader orchestration.
use super::inventory::{YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory};
use super::yostar_jp::{
is_official_yostar_jp_url, server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan,
YostarJpResourceRoot, YostarJpServerInfo,
};
use std::path::{Path, PathBuf};
/// Catalog bytes required to build one platform's download inventory.
#[derive(Debug, Clone, Copy)]
pub struct PlatformCatalogInput<'a> {
/// Platform represented by the catalog.
pub platform: PatchPlatform,
/// `BundlePackingInfo.bytes` payload.
pub bundle_packing_info: &'a [u8],
/// `MediaCatalog.bytes` payload.
pub media_catalog: &'a [u8],
}
/// Region/backend contract used by official resource orchestration.
pub trait OfficialResourceBackend: Send + Sync {
/// Stable backend identifier persisted in diagnostics.
fn backend_id(&self) -> &'static str;
/// Builds the server-info URL from an official metadata file name.
fn server_info_url(&self, file_name: &str) -> Result<String, String>;
/// Selects a discovery plan from server-info and requested platforms.
fn discovery_plan(
&self,
server_info: &YostarJpServerInfo,
connection_group: &str,
app_version: &str,
platforms: &[PatchPlatform],
) -> Result<YostarJpResourceDiscoveryPlan, String>;
/// Builds platform-aware inventory from the verified seed catalog bytes.
fn inventory(
&self,
table_catalog: &[u8],
platform_catalogs: &[PlatformCatalogInput<'_>],
) -> YostarJpPlatformDownloadInventory;
/// Validates that a URL belongs to this backend's official hosts.
fn is_official_url(&self, url: &str) -> bool;
}
/// URL-to-destination mapping contract for a resource backend.
pub trait DownloadUrlMapper: Send + Sync {
/// Maps an official HTTPS URL to a relative release destination.
fn relative_destination(&self, url: &str) -> Result<PathBuf, String>;
}
/// The currently supported official Blue Archive JP backend.
#[derive(Debug, Clone, Copy, Default)]
pub struct YostarJpBackend;
impl OfficialResourceBackend for YostarJpBackend {
fn backend_id(&self) -> &'static str {
"bluearchive.yostar.jp"
}
fn server_info_url(&self, file_name: &str) -> Result<String, String> {
server_info_url(file_name)
}
fn discovery_plan(
&self,
server_info: &YostarJpServerInfo,
connection_group: &str,
app_version: &str,
platforms: &[PatchPlatform],
) -> Result<YostarJpResourceDiscoveryPlan, String> {
server_info.discovery_plan(connection_group, app_version, platforms)
}
fn inventory(
&self,
table_catalog: &[u8],
platform_catalogs: &[PlatformCatalogInput<'_>],
) -> YostarJpPlatformDownloadInventory {
let catalogs = platform_catalogs
.iter()
.map(|catalog| {
YostarJpPlatformCatalogInventory::from_catalog_bytes(
catalog.platform,
catalog.bundle_packing_info,
catalog.media_catalog,
)
})
.collect();
YostarJpPlatformDownloadInventory::from_catalog_bytes(table_catalog, catalogs)
}
fn is_official_url(&self, url: &str) -> bool {
is_official_yostar_jp_url(url)
}
}
impl DownloadUrlMapper for YostarJpBackend {
fn relative_destination(&self, url: &str) -> Result<PathBuf, String> {
let rest = url
.strip_prefix("https://")
.ok_or_else(|| format!("官方 URL 必须使用 https{url}"))?;
let (host, path) = rest
.split_once('/')
.ok_or_else(|| format!("官方 URL 缺少路径:{url}"))?;
let mut destination = PathBuf::from(sanitize_component(host, url)?);
for segment in path.split('/') {
if segment.is_empty() {
continue;
}
destination.push(sanitize_component(segment, url)?);
}
Ok(destination)
}
}
impl YostarJpBackend {
/// Returns the validated resource-root builder for an official root.
pub fn resource_root(&self, addressables_root: &str) -> Result<YostarJpResourceRoot, String> {
YostarJpResourceRoot::from_addressables_root(addressables_root)
}
}
fn sanitize_component(component: &str, url: &str) -> Result<String, String> {
if component == "." || component == ".." || component.is_empty() {
return Err(format!("官方 URL 包含不安全路径片段:{url}"));
}
if component.contains('?') || component.contains('#') || component.contains('\\') {
return Err(format!("官方资源 URL 包含不安全路径字符:{url}"));
}
Ok(component.to_string())
}
/// Joins a backend-relative destination below an output root.
pub fn destination_under_root(root: &Path, relative: &Path) -> Result<PathBuf, String> {
if relative.is_absolute() {
return Err(format!(
"backend destination must be relative: {}",
relative.display()
));
}
let destination = root.join(relative);
if destination
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(format!(
"backend destination escapes output root: {}",
relative.display()
));
}
Ok(destination)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jp_backend_keeps_discovery_and_inventory_rules_in_one_adapter() {
let backend = YostarJpBackend;
let server_info = YostarJpServerInfo::from_json(
r#"{"ConnectionGroups":[{"Name":"Prod","AddressablesCatalogUrlRoot":"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture"}]}"#,
)
.unwrap();
let plan = backend
.discovery_plan(&server_info, "Prod", "1.70.0", &[PatchPlatform::Windows])
.unwrap();
assert_eq!(backend.backend_id(), "bluearchive.yostar.jp");
assert!(backend.is_official_url(&plan.endpoints[0].url));
let inventory = backend.inventory(
b"ExcelDB.db ExcelDB.db",
&[PlatformCatalogInput {
platform: PatchPlatform::Windows,
bundle_packing_info: b"FullPatch_000.zip",
media_catalog: b"GameData/Audio/JP.zip",
}],
);
assert_eq!(inventory.table_file_names, vec!["ExcelDB.db"]);
assert_eq!(inventory.platform_catalogs.len(), 1);
}
#[test]
fn jp_backend_maps_and_rejects_unsafe_destinations() {
let backend = YostarJpBackend;
assert_eq!(
backend
.relative_destination(
"https://prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/a.bytes"
)
.unwrap(),
PathBuf::from("prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/a.bytes")
);
assert!(backend
.relative_destination("https://prod-clientpatch.bluearchiveyostar.com/r93/../secret")
.is_err());
assert!(!backend.is_official_url("https://example.invalid/a"));
}
}
+5
View File
@@ -4,11 +4,16 @@
//! client endpoints. Mirror-specific layers such as `bluearchive.cafe` or
//! `text=jp/voice=jp/media=jp` are intentionally excluded.
pub mod backend;
pub mod game_main_config;
pub mod inventory;
pub mod launcher;
pub mod yostar_jp;
pub use backend::{
destination_under_root, DownloadUrlMapper, OfficialResourceBackend, PlatformCatalogInput,
YostarJpBackend,
};
pub use game_main_config::YostarJpGameMainConfig;
pub use inventory::{
YostarJpDownloadInventory, YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,