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
+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"));
}
}