diff --git a/USERGUIDE.md b/USERGUIDE.md index b5d3f17..2d326ae 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -197,6 +197,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon | `BAT-ERR-300020` | retry_exhausted | 否 | 下载重试次数耗尽 | | `BAT-ERR-300021` | quarantined | 否 | URL 因反复失败进入 quarantine | | `BAT-ERR-300030` | non_official_url | 否 | URL 不是官方 host(被拒绝) | +| `BAT-ERR-300031` | launcher_api_rejected | 否 | 官方启动器 API 返回非 200 业务码(版本/鉴权被拒等) | | `BAT-ERR-310001` | proxy_failure | 否 | 代理自身故障(认证/解析/连接代理失败) | | `BAT-ERR-400001` | blake3_mismatch | 否 | 本地 BLAKE3 与 manifest 不符 | | `BAT-ERR-400002` | official_hash_mismatch | 否 | 官方 seed `.hash`(xxHash32)校验不符 | @@ -213,6 +214,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon | `BAT-ERR-600001` | manifest_parse_failed | 否 | Manifest / Addressables catalog 解析失败 | | `BAT-ERR-600002` | unityfs_parse_failed | 否 | UnityFS 解析失败 | | `BAT-ERR-600003` | game_main_config_failed | 否 | GameMainConfig 解密/解析失败 | +| `BAT-ERR-600004` | launcher_response_invalid | 否 | 官方启动器 API 响应无法解析或缺少必需字段 | | `BAT-ERR-700001` | rpc_unknown_method | 否 | 未知 RPC 方法 | | `BAT-ERR-700002` | rpc_invalid_params | 否 | RPC 参数无效 | | `BAT-ERR-700003` | rpc_not_implemented | 否 | 方法/命名空间尚未实现 | diff --git a/core/src/error_code.rs b/core/src/error_code.rs index ced72d9..0e427e8 100644 --- a/core/src/error_code.rs +++ b/core/src/error_code.rs @@ -165,6 +165,8 @@ impl ErrorCode { pub const QUARANTINED: Self = Self::new(300_021, "quarantined", false); /// URL 不是官方 host(被拒绝)。 pub const NON_OFFICIAL_URL: Self = Self::new(300_030, "non_official_url", false); + /// 官方启动器 API 返回非 200 业务码(版本/鉴权被拒等)。 + pub const LAUNCHER_API_REJECTED: Self = Self::new(300_031, "launcher_api_rejected", false); /// 代理自身故障(认证/解析/连接代理失败)。 pub const PROXY_FAILURE: Self = Self::new(310_001, "proxy_failure", false); @@ -204,6 +206,9 @@ impl ErrorCode { pub const UNITYFS_PARSE_FAILED: Self = Self::new(600_002, "unityfs_parse_failed", false); /// GameMainConfig 解密/解析失败。 pub const GAME_MAIN_CONFIG_FAILED: Self = Self::new(600_003, "game_main_config_failed", false); + /// 官方启动器 API 响应无法解析或缺少必需字段(版本/路径/URL 等)。 + pub const LAUNCHER_RESPONSE_INVALID: Self = + Self::new(600_004, "launcher_response_invalid", false); // ---- 700xxx 任务/RPC ---- /// 未知 RPC 方法。 @@ -366,6 +371,7 @@ mod tests { ErrorCode::RETRY_EXHAUSTED, ErrorCode::QUARANTINED, ErrorCode::NON_OFFICIAL_URL, + ErrorCode::LAUNCHER_API_REJECTED, ErrorCode::PROXY_FAILURE, ErrorCode::BLAKE3_MISMATCH, ErrorCode::OFFICIAL_HASH_MISMATCH, @@ -382,6 +388,7 @@ mod tests { ErrorCode::MANIFEST_PARSE_FAILED, ErrorCode::UNITYFS_PARSE_FAILED, ErrorCode::GAME_MAIN_CONFIG_FAILED, + ErrorCode::LAUNCHER_RESPONSE_INVALID, ErrorCode::RPC_UNKNOWN_METHOD, ErrorCode::RPC_INVALID_PARAMS, ErrorCode::RPC_NOT_IMPLEMENTED, diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index 5dac1fe..a858c85 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -29,7 +29,7 @@ pub struct DownloadError { } impl DownloadError { - fn new(code: bat_core::ErrorCode, message: impl Into) -> Self { + pub(crate) fn new(code: bat_core::ErrorCode, message: impl Into) -> Self { Self { code, message: message.into(), diff --git a/infrastructure/src/official_game_main_config.rs b/infrastructure/src/official_game_main_config.rs index 6551b2f..b3f0cef 100644 --- a/infrastructure/src/official_game_main_config.rs +++ b/infrastructure/src/official_game_main_config.rs @@ -5,6 +5,7 @@ //! `GameMainConfig`, and does not install or execute the official launcher. use crate::curl_transfer::{run_curl_with_retry_with_proxy, CurlProxyConfig}; +use crate::official_download::DownloadError; use crate::official_launcher::launcher_package_url; use crate::official_launcher::OfficialLauncherBootstrapService; use crate::official_launcher::{ @@ -13,6 +14,7 @@ use crate::official_launcher::{ use crate::zip_validation::validate_zip_structure; use bat_adapters::official::game_main_config::YostarJpGameMainConfig; use bat_adapters::official::launcher::YostarJpLauncherManifestFile; +use bat_core::ErrorCode; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -88,13 +90,18 @@ impl OfficialGameMainConfigBootstrapService { /// Fetches the latest official game ZIP by re-querying the official /// launcher API, extracts `resources.assets`, and decrypts `GameMainConfig`. - pub fn fetch_bootstrap(&self) -> Result { + pub fn fetch_bootstrap(&self) -> Result { let (game_config, manifest_url, manifest) = self.launcher.fetch_latest_remote_manifest()?; let manifest_source = manifest .source .clone() .filter(|value| !value.is_empty()) - .ok_or_else(|| "官方启动器远端 manifest 缺少 source".to_string())?; + .ok_or_else(|| { + DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + "官方启动器远端 manifest 缺少 source", + ) + })?; let cdn_config = self.launcher.fetch_cdn_config()?; let temp_dir = TempDir::new().map_err(|error| format!("创建临时目录失败:{error}"))?; let (game_zip_url, resources_assets) = self.fetch_resources_assets( @@ -104,7 +111,8 @@ impl OfficialGameMainConfigBootstrapService { &cdn_config, temp_dir.path(), )?; - let game_main_config = YostarJpGameMainConfig::from_resources_assets(resources_assets)?; + let game_main_config = YostarJpGameMainConfig::from_resources_assets(resources_assets) + .map_err(|error| DownloadError::new(ErrorCode::GAME_MAIN_CONFIG_FAILED, error))?; Ok(OfficialGameMainConfigBootstrap { game_config, @@ -119,12 +127,12 @@ impl OfficialGameMainConfigBootstrapService { /// Fetches the latest official game ZIP, extracts `resources.assets`, and /// decrypts `GameMainConfig`. - pub fn fetch_game_main_config(&self) -> Result { + pub fn fetch_game_main_config(&self) -> Result { self.fetch_bootstrap() .map(|bootstrap| bootstrap.game_main_config) } - fn download_file(&self, url: &str, destination: &Path) -> Result<(), String> { + fn download_file(&self, url: &str, destination: &Path) -> Result<(), DownloadError> { run_curl_with_retry_with_proxy( &self.curl_command, url, @@ -146,7 +154,11 @@ impl OfficialGameMainConfigBootstrapService { }, ) .map(|_| ()) - .map_err(|error| format!("curl 拉取失败 {url}:{error}")) + .map_err(|error| { + // 保留 curl 失败的准确网络域码;进程类失败归 INTERNAL。 + let code = error.final_error_code().unwrap_or(ErrorCode::INTERNAL); + DownloadError::new(code, format!("curl 拉取失败 {url}:{error}")) + }) } fn download_file_with_fallback( @@ -155,14 +167,18 @@ impl OfficialGameMainConfigBootstrapService { backup_cdn_root: &str, relative_path: &str, destination: &Path, - ) -> Result<(), String> { + ) -> Result<(), DownloadError> { match self.download_file(primary_url, destination) { Ok(()) => Ok(()), Err(primary_error) => { let backup_url = launcher_package_url(backup_cdn_root, relative_path)?; self.download_file(&backup_url, destination).map_err(|backup_error| { - format!( - "下载官方游戏资源失败:主 CDN 失败后已切换官方备用 CDN;主地址失败({primary_error}),备用地址也失败({backup_error})" + // 保留备用 CDN 失败的错误码(两次都失败时以最终一次为准)。 + DownloadError::new( + backup_error.code(), + format!( + "下载官方游戏资源失败:主 CDN 失败后已切换官方备用 CDN;主地址失败({primary_error}),备用地址也失败({backup_error})" + ), ) }) } @@ -176,7 +192,7 @@ impl OfficialGameMainConfigBootstrapService { manifest_source: &str, cdn_config: &YostarJpLauncherCdnConfig, temp_root: &Path, - ) -> Result<(String, PathBuf), String> { + ) -> Result<(String, PathBuf), DownloadError> { match select_game_main_config_source(game_config, manifest_source, manifest)? { GameMainConfigSource::Archive(package_path) => { let game_zip_url = launcher_package_url(&cdn_config.primary_cdn, package_path)?; @@ -187,7 +203,8 @@ impl OfficialGameMainConfigBootstrapService { package_path, &archive_path, )?; - validate_zip_structure(&archive_path)?; + validate_zip_structure(&archive_path) + .map_err(|error| DownloadError::new(ErrorCode::ZIP_STRUCTURE_INVALID, error))?; let extract_root = temp_root.join("extract"); fs::create_dir_all(&extract_root) .map_err(|error| format!("创建解压目录失败:{error}"))?; @@ -207,7 +224,8 @@ impl OfficialGameMainConfigBootstrapService { &relative_path, &resources_assets, )?; - verify_manifest_file_size(&resources_assets, file)?; + verify_manifest_file_size(&resources_assets, file) + .map_err(|error| DownloadError::new(ErrorCode::SIZE_MISMATCH, error))?; Ok((resources_assets_url, resources_assets)) } } diff --git a/infrastructure/src/official_launcher.rs b/infrastructure/src/official_launcher.rs index 2e5eae0..193f012 100644 --- a/infrastructure/src/official_launcher.rs +++ b/infrastructure/src/official_launcher.rs @@ -8,7 +8,9 @@ use crate::curl_transfer::{ run_curl_with_retry_with_proxy as run_curl_command_with_retry, CurlProxyConfig, }; +use crate::official_download::DownloadError; use bat_adapters::official::launcher::{YostarJpLauncherManifestFile, YOSTAR_JP_GAME_TAG}; +use bat_core::ErrorCode; use md5::{Digest, Md5}; use serde::Deserialize; use std::path::Path; @@ -119,23 +121,35 @@ impl OfficialLauncherBootstrapService { } /// Fetches latest official PC game config. - pub fn fetch_game_config(&self) -> Result { + pub fn fetch_game_config(&self) -> Result { let envelope = self.fetch_launcher_envelope("/api/launcher/game/config")?; - let config: YostarJpLauncherGameConfig = serde_json::from_value(envelope.data) - .map_err(|error| format!("解析官方启动器 game config 数据失败:{error}"))?; + let config: YostarJpLauncherGameConfig = + serde_json::from_value(envelope.data).map_err(|error| { + DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + format!("解析官方启动器 game config 数据失败:{error}"), + ) + })?; if config.game_latest_version.is_empty() || config.game_latest_file_path.is_empty() { - return Err("官方启动器 game config 缺少最新版本或基准路径".into()); + return Err(DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + "官方启动器 game config 缺少最新版本或基准路径", + )); } Ok(config) } /// Fetches official CDN config for PC client package downloads. - pub fn fetch_cdn_config(&self) -> Result { + pub fn fetch_cdn_config(&self) -> Result { let envelope = self.fetch_launcher_envelope("/api/launcher/advanced/game/download/cdn")?; - serde_json::from_value(envelope.data) - .map_err(|error| format!("解析官方启动器 CDN config 数据失败:{error}")) + serde_json::from_value(envelope.data).map_err(|error| { + DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + format!("解析官方启动器 CDN config 数据失败:{error}"), + ) + }) } /// Fetches the official remote manifest URL for a PC client version and @@ -144,9 +158,12 @@ impl OfficialLauncherBootstrapService { &self, version: &str, file_path: &str, - ) -> Result { + ) -> Result { if version.is_empty() || file_path.is_empty() { - return Err("启动器 manifest 版本和文件路径不能为空".into()); + return Err(DownloadError::new( + ErrorCode::INVALID_ARGUMENT, + "启动器 manifest 版本和文件路径不能为空", + )); } let path = format!( @@ -156,12 +173,20 @@ impl OfficialLauncherBootstrapService { ); let envelope = self.fetch_launcher_envelope(&path)?; let manifest_url: YostarJpLauncherManifestUrl = serde_json::from_value(envelope.data) - .map_err(|error| format!("解析官方启动器 manifest URL 数据失败:{error}"))?; + .map_err(|error| { + DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + format!("解析官方启动器 manifest URL 数据失败:{error}"), + ) + })?; if !is_official_launcher_package_url(&manifest_url.url) { - return Err(format!( - "官方启动器 API 返回了非官方 manifest URL:{}", - manifest_url.url + return Err(DownloadError::new( + ErrorCode::NON_OFFICIAL_URL, + format!( + "官方启动器 API 返回了非官方 manifest URL:{}", + manifest_url.url + ), )); } @@ -172,29 +197,38 @@ impl OfficialLauncherBootstrapService { pub fn fetch_remote_manifest( &self, manifest_url: &str, - ) -> Result { + ) -> Result { if !is_official_launcher_package_url(manifest_url) { - return Err(format!("拒绝拉取非官方启动器 manifest URL:{manifest_url}")); + return Err(DownloadError::new( + ErrorCode::NON_OFFICIAL_URL, + format!("拒绝拉取非官方启动器 manifest URL:{manifest_url}"), + )); } - let output = self - .run_curl_with_retry(manifest_url, || { - let mut command = Command::new(&self.curl_command); - command - .arg("--fail") - .arg("--location") - .arg("--silent") - .arg("--show-error") - .arg("--url") - .arg(manifest_url); - command - }) - .map_err(|error| format!("curl 拉取启动器 manifest 失败 {manifest_url}:{error}"))?; + let output = self.run_curl_with_retry(manifest_url, || { + let mut command = Command::new(&self.curl_command); + command + .arg("--fail") + .arg("--location") + .arg("--silent") + .arg("--show-error") + .arg("--url") + .arg(manifest_url); + command + })?; let manifest: YostarJpLauncherRemoteManifest = serde_json::from_slice(&output.stdout) - .map_err(|error| format!("解析官方启动器 manifest 失败:{error}"))?; + .map_err(|error| { + DownloadError::new( + ErrorCode::MANIFEST_PARSE_FAILED, + format!("解析官方启动器 manifest 失败:{error}"), + ) + })?; if manifest.files.is_empty() { - return Err("官方启动器远端 manifest 没有文件条目".into()); + return Err(DownloadError::new( + ErrorCode::MANIFEST_PARSE_FAILED, + "官方启动器远端 manifest 没有文件条目", + )); } Ok(manifest) @@ -209,7 +243,7 @@ impl OfficialLauncherBootstrapService { YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest, ), - String, + DownloadError, > { let game_config = self.fetch_game_config()?; let manifest_url = self.fetch_manifest_url( @@ -223,40 +257,46 @@ impl OfficialLauncherBootstrapService { /// Fetches a signed official launcher API endpoint and parses the generic /// response envelope. - pub fn fetch_launcher_envelope(&self, path: &str) -> Result { + pub fn fetch_launcher_envelope(&self, path: &str) -> Result { let url = launcher_api_url(path)?; let authorization = launcher_authorization_header(&self.launcher_version, "", None)?; - let output = self - .run_curl_with_retry(&url, || { - let mut command = Command::new(&self.curl_command); - command - .arg("--fail") - .arg("--location") - .arg("--silent") - .arg("--show-error") - .arg("--header") - .arg("Content-Type: application/json;charset=UTF-8") - .arg("--header") - .arg(format!("Authorization: {authorization}")) - .arg("--url") - .arg(&url); - command - }) - .map_err(|error| format!("curl 拉取失败 {url}:{error}"))?; + let output = self.run_curl_with_retry(&url, || { + let mut command = Command::new(&self.curl_command); + command + .arg("--fail") + .arg("--location") + .arg("--silent") + .arg("--show-error") + .arg("--header") + .arg("Content-Type: application/json;charset=UTF-8") + .arg("--header") + .arg(format!("Authorization: {authorization}")) + .arg("--url") + .arg(&url); + command + })?; - let envelope: LauncherEnvelope = serde_json::from_slice(&output.stdout) - .map_err(|error| format!("解析官方启动器 API 响应失败:{error}"))?; + let envelope: LauncherEnvelope = + serde_json::from_slice(&output.stdout).map_err(|error| { + DownloadError::new( + ErrorCode::LAUNCHER_RESPONSE_INVALID, + format!("解析官方启动器 API 响应失败:{error}"), + ) + })?; if envelope.code != 200 { - return Err(format!( - "官方启动器 API 返回错误码 {}:{}", - envelope.code, - envelope - .message - .as_deref() - .or(envelope.msg.as_deref()) - .unwrap_or("<无消息>") + return Err(DownloadError::new( + ErrorCode::LAUNCHER_API_REJECTED, + format!( + "官方启动器 API 返回错误码 {}:{}", + envelope.code, + envelope + .message + .as_deref() + .or(envelope.msg.as_deref()) + .unwrap_or("<无消息>") + ), )); } @@ -267,7 +307,7 @@ impl OfficialLauncherBootstrapService { &self, url: &str, mut build_command: impl FnMut() -> Command, - ) -> Result { + ) -> Result { run_curl_command_with_retry( Path::new(&self.curl_command), url, @@ -276,7 +316,12 @@ impl OfficialLauncherBootstrapService { &self.curl_proxy, &mut build_command, ) - .map_err(|error| error.to_string()) + .map_err(|error| { + // 保留 curl 失败的准确网络域码(403/404/DNS/连接/超时/TLS/代理等), + // 进程类失败归 INTERNAL。 + let code = error.final_error_code().unwrap_or(ErrorCode::INTERNAL); + DownloadError::new(code, format!("curl 拉取失败 {url}:{error}")) + }) } } @@ -580,6 +625,84 @@ esac assert_eq!(manifest.files[0].path, "/BlueArchive.exe"); } + fn fake_launcher_config_response_script(payload: &str) -> String { + format!( + r#"#!/bin/sh +set -eu +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + --url) url="$2"; shift 2 ;; + *) shift ;; + esac +done +case "$url" in + */api/launcher/game/config) + printf '%s' '{payload}' + ;; + *) + printf '%s\n' "unexpected url: $url" >&2 + exit 22 + ;; +esac +"#, + payload = payload + ) + } + + fn fake_launcher_http_404_script() -> &'static str { + r#"#!/bin/sh +set -eu +printf 'curl: (22) The requested URL returned error: 404\n' >&2 +exit 22 +"# + } + + fn launcher_service_with_script(script: &str) -> (TempDir, OfficialLauncherBootstrapService) { + let bin_dir = TempDir::new().unwrap(); + let curl_path = bin_dir.path().join("curl"); + write_shell_script(&curl_path, script); + let service = OfficialLauncherBootstrapService::with_curl_command( + "1.7.2", + curl_path.to_string_lossy().to_string(), + ) + .with_retry_attempts(1); + (bin_dir, service) + } + + #[test] + fn launcher_api_non_200_maps_to_launcher_api_rejected() { + let (_bin, service) = launcher_service_with_script(&fake_launcher_config_response_script( + r#"{"code":403,"message":"version rejected","data":{}}"#, + )); + let error = service.fetch_game_config().unwrap_err(); + assert_eq!(error.code().id(), ErrorCode::LAUNCHER_API_REJECTED.id()); + } + + #[test] + fn launcher_api_bad_json_maps_to_launcher_response_invalid() { + let (_bin, service) = + launcher_service_with_script(&fake_launcher_config_response_script("not-json")); + let error = service.fetch_game_config().unwrap_err(); + assert_eq!(error.code().id(), ErrorCode::LAUNCHER_RESPONSE_INVALID.id()); + } + + #[test] + fn launcher_http_404_preserves_network_error_code() { + let (_bin, service) = launcher_service_with_script(fake_launcher_http_404_script()); + let error = service.fetch_game_config().unwrap_err(); + assert_eq!(error.code().id(), ErrorCode::HTTP_NOT_FOUND.id()); + } + + #[test] + fn non_official_manifest_url_maps_to_non_official_url() { + let (_bin, service) = launcher_service_with_script(fake_launcher_http_404_script()); + let error = service + .fetch_remote_manifest("https://launcher-pkg-ba-jp.bluearchive.cafe/prod/manifest.json") + .unwrap_err(); + assert_eq!(error.code().id(), ErrorCode::NON_OFFICIAL_URL.id()); + } + #[test] fn retries_transient_launcher_api_failures() { let bin_dir = TempDir::new().unwrap(); diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index 989fab4..3f95843 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -2480,7 +2480,7 @@ fn resolve_bootstrap( )); let (game_config, manifest_url, manifest) = launcher .fetch_latest_remote_manifest() - .map_err(anyhow::Error::msg)?; + .map_err(anyhow::Error::new)?; check_shutdown_requested(should_cancel)?; let launcher_metadata = launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest); @@ -2523,7 +2523,7 @@ fn resolve_bootstrap( tools.unzip_command.to_path_buf(), ) .with_proxy_config(tools.curl_proxy.clone()); - let bootstrap = bootstrapper.fetch_bootstrap().map_err(anyhow::Error::msg)?; + let bootstrap = bootstrapper.fetch_bootstrap().map_err(anyhow::Error::new)?; check_shutdown_requested(should_cancel)?; let launcher_metadata = LauncherMetadataSnapshot { launcher_version: launcher_version.to_string(),