feat(assetbundle): 完善官方资源解析与双目录发布

This commit is contained in:
2026-07-25 20:51:01 +08:00
parent 102b49b666
commit 3e9bb20d79
37 changed files with 4663 additions and 1333 deletions
+151 -1
View File
@@ -7,7 +7,7 @@
use crate::curl_transfer::{resolve_curl_proxy, CurlProxyConfig};
use crate::path_security::{
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target,
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE,
};
use crate::{
@@ -18,6 +18,7 @@ use crate::{
OfficialResourcePullService, YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl,
YostarJpLauncherRemoteManifest,
};
use crate::{OfficialParseCacheService, OfficialParseConfig, OfficialParseSummary};
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use bat_adapters::official::inventory::{
YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
@@ -78,6 +79,8 @@ pub struct OfficialUpdateConfig {
pub platforms: Option<Vec<PatchPlatform>>,
/// Output root for resources and state files.
pub output_root: PathBuf,
/// Output root reserved for localized resources generated from official data.
pub localized_output_root: PathBuf,
/// Optional explicit sync snapshot path.
pub snapshot_path: Option<PathBuf>,
/// Curl command used by the current infrastructure downloader.
@@ -108,6 +111,7 @@ impl Default for OfficialUpdateConfig {
auto_discover: false,
platforms: None,
output_root: PathBuf::from("./bat-resources"),
localized_output_root: PathBuf::from("./bat-localized"),
snapshot_path: None,
curl_command: PathBuf::from("curl"),
curl_proxy: CurlProxyConfig::default(),
@@ -168,6 +172,26 @@ impl OfficialUpdateStatus {
}
}
/// Publication state for localized resources associated with an official release.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LocalizedReleaseStatus {
/// Official resources are published, but localized resources are not.
NotLocalized,
/// Official resources and localized resources are both published.
Localized,
}
impl LocalizedReleaseStatus {
/// Returns a stable string label for CLI and JSON callers.
pub fn as_str(self) -> &'static str {
match self {
Self::NotLocalized => "not_localized",
Self::Localized => "localized",
}
}
}
/// Snapshot of the official JP update state observed at a point in time.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialUpdateSnapshot {
@@ -477,6 +501,14 @@ pub struct OfficialUpdateReport {
pub platforms: Vec<PatchPlatform>,
/// Managed publish root containing `current`, `versions`, and staging.
pub output_root: PathBuf,
/// Separate root reserved for localized resources.
pub localized_output_root: PathBuf,
/// Localized publication state for this official resource version.
pub localized_release_status: LocalizedReleaseStatus,
/// Future localized `current` pointer path under `localized_output_root`.
pub localized_current_path: PathBuf,
/// Versioned localized release directory when localized resources are published.
pub localized_published_version_path: Option<PathBuf>,
/// Active resource root used for local audit before this run.
pub active_resource_root: PathBuf,
/// Atomic `current` pointer path.
@@ -543,6 +575,10 @@ pub struct OfficialUpdateReport {
pub verification_summary: OfficialVerificationSummary,
/// Download manifest path.
pub download_manifest: PathBuf,
/// Parse-cache path written or refreshed after successful verification.
pub parse_cache_path: Option<PathBuf>,
/// Post-sync parse-cache summary.
pub parse_summary: Option<OfficialParseSummary>,
/// Snapshot path written after success.
pub snapshot_written: Option<PathBuf>,
}
@@ -1175,6 +1211,10 @@ impl OfficialUpdateService {
addressables_root: current_snapshot.addressables_root.clone(),
platforms: platforms.to_vec(),
output_root: config.output_root.clone(),
localized_output_root: config.localized_output_root.clone(),
localized_release_status: LocalizedReleaseStatus::NotLocalized,
localized_current_path: config.localized_output_root.join(OFFICIAL_CURRENT_LINK),
localized_published_version_path: None,
active_resource_root: active_resource_root.clone(),
current_path: publish_layout.current_path.clone(),
version_state_path: version_state_path.clone(),
@@ -1214,6 +1254,8 @@ impl OfficialUpdateService {
local_zip_structure_verified_count,
),
download_manifest: fetcher.download_manifest_path(),
parse_cache_path: None,
parse_summary: None,
snapshot_written: None,
};
@@ -1242,6 +1284,13 @@ impl OfficialUpdateService {
&active_resource_root,
&snapshot_path,
)?;
run_post_sync_parse_cache(
config,
&active_resource_root,
&mut report,
&mut progress,
&mut should_cancel,
)?;
}
progress(OfficialUpdateProgress::new(
"finish",
@@ -1454,6 +1503,13 @@ impl OfficialUpdateService {
format!("资源已发布,但写入版本状态失败(下轮可重试):{error}"),
));
}
run_post_sync_parse_cache(
config,
&published_version_path,
&mut report,
&mut progress,
&mut should_cancel,
)?;
// 清理未被最新版本状态引用的孤儿 staging 目录(GC 失败仅告警,不影响发布结果)。
match read_version_state(&version_state_path) {
@@ -1487,6 +1543,46 @@ impl OfficialUpdateService {
}
}
fn run_post_sync_parse_cache(
config: &OfficialUpdateConfig,
resource_root: &Path,
report: &mut OfficialUpdateReport,
progress: &mut dyn FnMut(OfficialUpdateProgress),
should_cancel: &mut dyn FnMut() -> bool,
) -> anyhow::Result<()> {
check_shutdown_requested(should_cancel)?;
progress(OfficialUpdateProgress::new(
"parse",
format!("更新官方资源解析缓存 {}", resource_root.display()),
));
let parse_config = OfficialParseConfig::new(resource_root, &config.unzip_command);
match OfficialParseCacheService::new().run(&parse_config) {
Ok(parse_report) => {
progress(OfficialUpdateProgress::new(
"parse",
format!(
"解析缓存完成:条目={} 已解析={} 复用={} 不支持={} 失败={} TextAsset={}",
parse_report.summary.cache_entry_count,
parse_report.summary.parsed_bundle_count,
parse_report.summary.skipped_unchanged_count,
parse_report.summary.unsupported_count,
parse_report.summary.failed_count,
parse_report.summary.text_asset_count
),
));
report.parse_cache_path = Some(parse_report.cache_path);
report.parse_summary = Some(parse_report.summary);
}
Err(error) => {
progress(OfficialUpdateProgress::new(
"parse",
format!("解析缓存更新失败(不影响已校验官方资源):{error}"),
));
}
}
Ok(())
}
fn build_pull_plan(
server_info: &YostarJpServerInfo,
connection_group: &str,
@@ -1751,7 +1847,10 @@ pub fn diff_extended_snapshot(
fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
validate_output_root(&config.output_root)?;
validate_output_root(&config.localized_output_root)?;
validate_separate_output_roots(&config.output_root, &config.localized_output_root)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")?;
ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?;
if let Some(snapshot_path) = config.snapshot_path.as_ref() {
ensure_path_within_root(&config.output_root, snapshot_path)?;
ensure_safe_file_target(&config.output_root, snapshot_path, "官方更新快照")?;
@@ -1770,6 +1869,28 @@ fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
Ok(())
}
fn validate_separate_output_roots(
output_root: &Path,
localized_output_root: &Path,
) -> Result<(), String> {
let official = lexical_absolute(output_root)?;
let localized = lexical_absolute(localized_output_root)?;
if official == localized {
return Err(format!(
"官方资源目录和汉化输出目录不能相同:{}",
official.display()
));
}
if localized.starts_with(&official) || official.starts_with(&localized) {
return Err(format!(
"官方资源目录和汉化输出目录不能互相嵌套:官方={} 汉化={}",
official.display(),
localized.display()
));
}
Ok(())
}
fn snapshot_path_for(config: &OfficialUpdateConfig, resource_root: &Path) -> PathBuf {
config
.snapshot_path
@@ -3412,6 +3533,35 @@ mod tests {
assert!(error.to_string().contains("危险路径"));
}
#[test]
fn update_rejects_same_official_and_localized_output_root() {
let temp = tempfile::TempDir::new().unwrap();
let root = temp.path().join("resources");
let config = OfficialUpdateConfig {
output_root: root.clone(),
localized_output_root: root,
dry_run: true,
..OfficialUpdateConfig::default()
};
let error = OfficialUpdateService::new().run(&config).unwrap_err();
assert!(error.to_string().contains("不能相同"));
}
#[test]
fn update_rejects_nested_official_and_localized_output_roots() {
let temp = tempfile::TempDir::new().unwrap();
let config = OfficialUpdateConfig {
output_root: temp.path().join("resources"),
localized_output_root: temp.path().join("resources/localized"),
dry_run: true,
..OfficialUpdateConfig::default()
};
let error = OfficialUpdateService::new().run(&config).unwrap_err();
assert!(error.to_string().contains("不能互相嵌套"));
}
#[test]
fn update_rejects_snapshot_path_escape() {
let temp = tempfile::TempDir::new().unwrap();