feat(official-sync): 错误码模型铺开到 launcher/metadata 拉取路径

此前统一错误码只覆盖资源下载路径,launcher/metadata 链
(fetch_game_config → manifest URL → remote manifest → resources.assets
→ GameMainConfig)整条返回 Result<_, String>,经 anyhow::Error::msg
抹成无类型消息,RPC/任务层只能标为 internal。

本次将该链整体改为 Result<_, DownloadError>:

- core 新增两个错误码:LAUNCHER_API_REJECTED(300031,官方启动器 API
  返回非 200 业务码)与 LAUNCHER_RESPONSE_INVALID(600004,响应无法
  解析或缺少必需字段),并纳入唯一性测试;
- official_launcher:curl 失败保留准确网络域码(403/404/DNS/超时/TLS/
  代理等,final_error_code()),API 非 200 → LAUNCHER_API_REJECTED,
  响应解析/缺字段 → LAUNCHER_RESPONSE_INVALID,非官方 manifest URL →
  NON_OFFICIAL_URL,manifest 无文件条目 → MANIFEST_PARSE_FAILED,
  空版本/路径入参 → INVALID_ARGUMENT;
- official_game_main_config:下载失败带网络域码(主/备 CDN 均失败时
  以最终一次为准),ZIP 结构 → ZIP_STRUCTURE_INVALID,manifest 大小
  不符 → SIZE_MISMATCH,GameMainConfig 解密/解析 →
  GAME_MAIN_CONFIG_FAILED,manifest 缺 source →
  LAUNCHER_RESPONSE_INVALID;
- official_update 两处调用改 anyhow::Error::msg → anyhow::Error::new,
  保留类型,task worker 现有 downcast 直接接住 launcher 路径错误码;
- USERGUIDE 错误码表补录 300031 与 600004。

内部工具函数(launcher_package_url / launcher_api_url /
launcher_authorization_header)仍返回 String,经 From<String> 归
INTERNAL——这些是硬编码常量上的内部不变量校验,非运行时主要失败面。

验证:新增 4 个单测断言映射(API 403 业务码 → 300031、非法 JSON →
600004、HTTP 404 → 300002、非官方 URL → 300030);全量
fmt / clippy --workspace --all-targets -D warnings / test --workspace
全绿(含 examples 编译)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 08:29:16 -07:00
co-authored by Claude Fable 5
parent d4d6d0a686
commit d2466d60db
6 changed files with 226 additions and 76 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ pub struct DownloadError {
}
impl DownloadError {
fn new(code: bat_core::ErrorCode, message: impl Into<String>) -> Self {
pub(crate) fn new(code: bat_core::ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
+30 -12
View File
@@ -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<OfficialGameMainConfigBootstrap, String> {
pub fn fetch_bootstrap(&self) -> Result<OfficialGameMainConfigBootstrap, DownloadError> {
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<YostarJpGameMainConfig, String> {
pub fn fetch_game_main_config(&self) -> Result<YostarJpGameMainConfig, DownloadError> {
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))
}
}
+184 -61
View File
@@ -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<YostarJpLauncherGameConfig, String> {
pub fn fetch_game_config(&self) -> Result<YostarJpLauncherGameConfig, DownloadError> {
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<YostarJpLauncherCdnConfig, String> {
pub fn fetch_cdn_config(&self) -> Result<YostarJpLauncherCdnConfig, DownloadError> {
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<YostarJpLauncherManifestUrl, String> {
) -> Result<YostarJpLauncherManifestUrl, DownloadError> {
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<YostarJpLauncherRemoteManifest, String> {
) -> Result<YostarJpLauncherRemoteManifest, DownloadError> {
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<LauncherEnvelope, String> {
pub fn fetch_launcher_envelope(&self, path: &str) -> Result<LauncherEnvelope, DownloadError> {
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<Output, String> {
) -> Result<Output, DownloadError> {
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();
+2 -2
View File
@@ -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(),