fix(sync): 修复官方资源并发下载与媒体路径
bat-rust / Build and test Rust (push) Successful in 3m47s

daemon 子进程现会透传下载并发配置,下载进度按已完成数量单调上报。MediaCatalog 改为使用官方相对路径生成全局媒体 URL,覆盖 GameData、Prologue 等目录并补齐 jpg 资源,避免叶子文件名误拼媒体根目录导致 403。同步更新测试、smoke 断言和运行文档。
This commit is contained in:
2026-07-23 11:56:34 +08:00
parent d694100d6c
commit 3f5d2a8da7
16 changed files with 182 additions and 74 deletions
+1 -1
View File
@@ -44,7 +44,7 @@
- 新增官方同步路径安全边界:拒绝危险输出目录和 snapshot 路径逃逸,下载目标、manifest、daemon PID/status/log/control 文件不跟随 symlinkdaemon 状态文件默认使用 `0600` 权限
- 新增官方资源原子发布布局:非 dry-run 下载先进入 `.staging/<id>`,校验和 manifest/snapshot 写入完成后发布到 `versions/<id>`,再原子切换 `current` symlink
- 新增 daemon 可观测性:`bat-events.jsonl` 结构化 JSONL 日志、日志轮转、status 中的当前下载进度、最后成功时间、下次检查时间和最后错误摘要
- 新增运行时下载与校验 progress log总体下载进度、单文件开始/完成状态、官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验摘要
- 新增运行时下载与校验 progress log下载已完成计数、单文件开始/完成状态、官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验摘要
- 新增官方资源同步生产部署模板:release binary symlink 路径、systemd unit、运行用户、日志位置、升级和回滚流程
- 新增真实官方网络全量拉取 smoke:`scripts/official-full-pull-smoke.sh``make official-smoke``docs/guides/official-full-pull-smoke.md`
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
+2 -2
View File
@@ -19,7 +19,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
3. 默认平台为 `Windows + Android`
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。下载支持多线程:并行仅作用于实际网络下载(默认并发 4,可经 `--download-concurrency`/`BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`),manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变。
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。下载支持多线程:并行仅作用于实际网络下载(默认并发 4,可经 `--download-concurrency`/`BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`),daemon 子进程会透传该并发配置;manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。
7. 支持 curl 传输层本地代理:默认自动检测 `HTTPS_PROXY` / `ALL_PROXY` / `HTTP_PROXY` 及小写环境变量(带凭据的代理推荐用环境变量配置),也可用 `--proxy <URL>` 显式指定或 `--no-proxy` 强制直连;代理决策会写入 progress log、daemon log 和 `bat doctor` 诊断输出。代理凭据不落世界可读位置:日志/`status` 脱敏,传给 curl 经 `ALL_PROXY` 环境变量而非 argv`--daemon` 下经环境变量下传后台子进程、不进子进程 argv 或 `bat-status.json`,复用凭据存于 `bat-proxy.secret``0600`)且 `clean-stable` 会清除。
8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`
9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
@@ -92,7 +92,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
- Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。
- 真实形态 Addressables fixture/golden 测试。
- 当前 catalog、上一个版本 catalog、结构变化 catalog 的离线回归 fixture。
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举。
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举`MediaCatalog.bytes` 使用官方相对路径生成媒体 URL,覆盖 `GameData/``Prologue/` 下的 zip/mp4/png/jpg/ogg/wav 等媒体资源,避免把叶子文件名误拼到媒体根目录
待完成:
+1 -1
View File
@@ -99,7 +99,7 @@
- 文档整理和路线图重制。
- Rust 官方资源同步闭环:`bat``--auto-discover``--watch``--daemon`、Unix socket JSON-RPC 后台控制、`status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`、北京时间固定强制刷新、snapshot、manifest audit/repair、官方 seed `.hash` 校验。
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
- `bat` 运行时 progress log 已覆盖总体下载进度、单文件下载进度和校验结果摘要。
- `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
- Addressables 当前真实形态 fixture/golden 覆盖。
- SQLite Resource Repository 和可选无状态 `bat-ffi` JSON 兼容接口。
+1 -1
View File
@@ -115,7 +115,7 @@ cargo run -p bat-infrastructure --bin bat -- clean-stable
下载失败会按 curl exit 和 HTTP 状态分类:403/404/普通 4xx 视为不可重试,5xx、429、DNS、连接、超时、中断和网络类错误会按尝试次数重试。某个 URL 最终失败后会写入 `<output>/current` 或 staging 下的 `official-download-quarantine.json`stderr progress、daemon status 和 `bat-events.jsonl` 会记录失败类型、HTTP 状态、是否可重试、尝试次数和 quarantine 状态;同步会中断并阻止发布不完整资源。旧 launcher 包下载路径会在官方 primary CDN 失败后切换官方 backup CDN。
CLI 默认启动时会向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并继续把阶段进度日志写到 stderr,例如自动发现、拉取 catalog、audit、总体下载进度、单文件下载进度、校验结果摘要、snapshot 和 publish;命令结果默认以人类可读摘要写到 stdout。需要给上层程序保留稳定结构化输出时加 `--json --no-progress`,只想关闭横幅但保留日志时可加 `--no-banner`
CLI 默认启动时会向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并继续把阶段进度日志写到 stderr,例如自动发现、拉取 catalog、audit、下载已完成计数、单文件下载进度、校验结果摘要、snapshot 和 publish;命令结果默认以人类可读摘要写到 stdout。需要给上层程序保留稳定结构化输出时加 `--json --no-progress`,只想关闭横幅但保留日志时可加 `--no-banner`
真实官方网络全量拉取 smoke 已固化为可重复命令,默认使用 `/tmp/bat-official-smoke-<UTC timestamp>/` 隔离目录,不会写入已有客户端、生产目录或开发机人工维护资源目录:
+111 -23
View File
@@ -10,7 +10,7 @@ pub struct YostarJpDownloadInventory {
pub bundle_patch_pack_names: Vec<String>,
/// Table file names from `TableCatalog.bytes`.
pub table_file_names: Vec<String>,
/// Media file names from `MediaCatalog.bytes`.
/// Media file relative paths from `MediaCatalog.bytes`.
pub media_file_names: Vec<String>,
}
@@ -21,7 +21,7 @@ pub struct YostarJpPlatformCatalogInventory {
pub platform: PatchPlatform,
/// Patch-pack zip names from this platform's `BundlePackingInfo.bytes`.
pub bundle_patch_pack_names: Vec<String>,
/// Media file names from this platform's `MediaCatalog.bytes`.
/// Media file relative paths from this platform's `MediaCatalog.bytes`.
pub media_file_names: Vec<String>,
}
@@ -35,10 +35,7 @@ impl YostarJpPlatformCatalogInventory {
Self {
platform,
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
media_file_names: extract_file_names(
media_catalog,
&["zip", "mp4", "png", "ogg", "wav"],
),
media_file_names: extract_media_file_paths(media_catalog),
}
}
@@ -172,10 +169,7 @@ impl YostarJpDownloadInventory {
Self {
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
table_file_names: extract_table_file_names(table_catalog),
media_file_names: extract_file_names(
media_catalog,
&["zip", "mp4", "png", "ogg", "wav"],
),
media_file_names: extract_media_file_paths(media_catalog),
}
}
@@ -327,6 +321,20 @@ fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec<String> {
names.into_iter().collect()
}
fn extract_media_file_paths(data: &[u8]) -> Vec<String> {
let mut paths = BTreeSet::new();
for string in extract_printable_strings(data, 4) {
for path in
candidate_relative_paths(&string, &["zip", "mp4", "png", "jpg", "jpeg", "ogg", "wav"])
{
paths.insert(path);
}
}
paths.into_iter().collect()
}
fn extract_printable_strings(data: &[u8], min_len: usize) -> Vec<String> {
let mut strings = Vec::new();
let mut current = Vec::new();
@@ -375,6 +383,32 @@ fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec<String> {
names
}
fn candidate_relative_paths(value: &str, extensions: &[&str]) -> Vec<String> {
let mut paths = Vec::new();
let bytes = value.as_bytes();
for extension in extensions {
let suffix = format!(".{extension}");
let mut search_from = 0;
while let Some(relative_index) = value[search_from..].find(&suffix) {
let extension_start = search_from + relative_index;
let start = filename_start(bytes, extension_start);
let end = extension_start + suffix.len();
let candidate = &value[start..end];
let candidate = candidate.replace('\\', "/");
if is_plausible_relative_path(&candidate) {
paths.push(candidate);
}
search_from = end;
}
}
paths
}
fn filename_start(bytes: &[u8], mut index: usize) -> usize {
while index > 0 {
let byte = bytes[index - 1];
@@ -409,6 +443,28 @@ fn is_plausible_file_name(name: &str) -> bool {
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
}
fn is_plausible_relative_path(path: &str) -> bool {
if path.is_empty()
|| path.starts_with('/')
|| path.starts_with('.')
|| path.contains("..")
|| path.contains(':')
|| path.contains('=')
|| !path.contains('/')
{
return false;
}
path.split('/').all(|segment| {
!segment.is_empty()
&& segment != "."
&& segment != ".."
&& segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
})
}
fn unique_platforms(platforms: &[PatchPlatform]) -> Vec<PatchPlatform> {
platforms
.iter()
@@ -441,7 +497,7 @@ mod tests {
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix",
b"GameData\\Table\\ExcelDB.db\0ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip\0Battle.zip8",
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0JP_Akane.zip",
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0audio/voc_jp/jp_akane/jp_akane\0GameData\\Audio\\VOC_JP\\JP_Akane.zip",
);
assert_eq!(
@@ -457,7 +513,10 @@ mod tests {
);
assert_eq!(
inventory.media_file_names,
vec!["JP_Airi.zip".to_string(), "JP_Akane.zip".to_string(),]
vec![
"GameData/Audio/VOC_JP/JP_Airi.zip".to_string(),
"GameData/Audio/VOC_JP/JP_Akane.zip".to_string(),
]
);
}
@@ -482,7 +541,7 @@ mod tests {
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip FullPatch_001.zip",
b"ExcelDB.db ExcelDB.db Battle.zip Battle.zip",
b"JP_Airi.zip JP_Akane.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi.zip GameData\\Audio\\VOC_JP\\JP_Akane.zip",
);
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
@@ -496,7 +555,7 @@ mod tests {
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
.any(|url| url.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip")));
}
#[test]
@@ -504,7 +563,7 @@ mod tests {
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip",
b"ExcelDB.db ExcelDB.db",
b"JP_Airi.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi.zip",
);
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
@@ -524,10 +583,10 @@ mod tests {
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
.any(|url| url.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources/JP_Airi.zip")));
.any(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi.zip")));
}
#[test]
@@ -538,12 +597,12 @@ mod tests {
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Windows,
b"FullPatch_000.zip",
b"JP_Airi_Win.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip",
),
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Android,
b"FullPatch_001.zip",
b"JP_Airi_Android.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip",
),
],
);
@@ -561,10 +620,11 @@ mod tests {
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi_Win.zip")));
.any(|url| url
.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi_Win.zip")));
assert!(urls
.iter()
.any(|url| url.ends_with("/MediaResources/JP_Airi_Android.zip")));
.any(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi_Android.zip")));
assert!(!urls
.iter()
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip")));
@@ -573,6 +633,30 @@ mod tests {
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_001.zip")));
}
#[test]
fn media_catalog_uses_download_relative_path_not_leaf_name() {
let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes(
b"ExcelDB.db ExcelDB.db",
vec![YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Windows,
b"FullPatch_000.zip",
b"scenario/event/10000_title_sound\0Prologue\\Scenario\\Event\\10000_Title_Sound.ogg\0 10000_Title_Sound.ogg",
)],
);
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
let urls = inventory
.direct_download_urls_for_platforms(&root, &[PatchPlatform::Windows])
.unwrap();
assert!(urls.iter().any(|url| {
url.ends_with("/MediaResources-Windows/Prologue/Scenario/Event/10000_Title_Sound.ogg")
}));
assert!(!urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/10000_Title_Sound.ogg")));
}
#[test]
#[ignore = "requires BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO, BAT_REAL_OFFICIAL_TABLE_CATALOG, BAT_REAL_OFFICIAL_MEDIA_CATALOG"]
fn extracts_realistic_counts_from_official_shape() {
@@ -591,7 +675,7 @@ mod tests {
assert_eq!(inventory.bundle_patch_pack_names.len(), 142);
assert!(inventory.table_file_names.len() < 1000);
assert_eq!(inventory.media_file_names.len(), 1887);
assert!(inventory.media_file_names.len() >= 4000);
assert!(inventory
.bundle_patch_pack_names
.iter()
@@ -603,6 +687,10 @@ mod tests {
assert!(inventory
.media_file_names
.iter()
.any(|name| name == "JP_Airi.zip"));
.any(|name| name == "GameData/Audio/VOC_JP/JP_Airi.zip"));
assert!(inventory
.media_file_names
.iter()
.any(|name| name.ends_with(".jpg")));
}
}
+6 -5
View File
@@ -646,8 +646,8 @@ impl YostarJpResourceRoot {
/// Returns an official media archive URL.
///
/// The argument is the `Media.FileName` field from `MediaCatalog.bytes`,
/// for example `JP_Airi.zip`.
/// The argument is the downloadable relative path from `MediaCatalog.bytes`,
/// for example `GameData/Audio/VOC_JP/JP_Airi.zip`.
pub fn media_file(&self, platform: PatchPlatform, file_name: &str) -> Result<String, String> {
validate_relative_path(file_name, "media file")?;
Ok(format!(
@@ -1134,8 +1134,9 @@ mod tests {
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/Catalog/MediaCatalog.bytes"
);
assert_eq!(
root.media_file(PatchPlatform::Windows, "JP_Airi.zip").unwrap(),
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/JP_Airi.zip"
root.media_file(PatchPlatform::Windows, "GameData/Audio/VOC_JP/JP_Airi.zip")
.unwrap(),
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip"
);
}
@@ -1211,7 +1212,7 @@ mod tests {
);
assert!(root.table_bundle("text=jp/ExcelDB.db").is_err());
assert!(root
.media_file(PatchPlatform::Windows, "/JP_Airi.zip")
.media_file(PatchPlatform::Windows, "/GameData/Audio/VOC_JP/JP_Airi.zip")
.is_err());
assert!(root
.bundle_patch_pack(PatchPlatform::Windows, "../FullPatch_000.zip")
@@ -68,7 +68,7 @@
### 3.2 枚举完整资源清单
资源清单不是“猜几个文件”,而是从官方 catalog 字节里提取完整文件名列表。
资源清单不是“猜几个文件”,而是从官方 catalog 字节里提取完整文件名或相对路径列表。
当前做法:
@@ -77,7 +77,7 @@
3. 读取 `TableCatalog.bytes`
4. 提取所有表资源名,例如 `ExcelDB.db`
5. 读取 `MediaCatalog.bytes`
6. 提取所有媒体资源名,例如 `JP_Airi.zip`
6. 提取所有媒体下载相对路径,例如 `GameData/Audio/VOC_JP/JP_Airi.zip``Prologue/Scenario/Event/10000_Title_Sound.ogg`
然后对 verified platforms 生成完整 URL 集:
@@ -246,7 +246,7 @@ Linux 生产路径:
- pull plan 会同时包含 discovery URLs 和 content URLs
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress logstdout 默认输出人类可读摘要;progress log 覆盖代理决策、总体下载进度、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status``stop``restart``reload``logs``refresh --force``verify``repair``doctor``clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging``versions``current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress logstdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status``stop``restart``reload``logs``refresh --force``verify``repair``doctor``clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging``versions``current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因
- 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类
+3 -3
View File
@@ -69,10 +69,10 @@ cargo build --release -p bat-infrastructure --bin bat
脚本会在关键步骤后自动检查:
- 首次全量拉取 stderr log 包含总体下载进度、单文件进度和校验结果。
- 首次全量拉取 stderr log 包含下载已完成计数、单文件进度和校验结果。
- 二次运行 stdout JSON 包含 `update_status=up_to_date`
- repair stdout JSON 包含 `command=repair``status=completed`
- repair stderr log 包含总体下载进度、单文件进度和校验结果。
- repair stderr log 包含下载已完成计数、单文件进度和校验结果。
- repair 后 verify stdout JSON 包含 `healthy=true`
## 环境变量
@@ -96,4 +96,4 @@ cargo build --release -p bat-infrastructure --bin bat
- `03-second-up-to-date.stdout.json``update_status``up_to_date`
- `04-repair-after-damage.stdout.json` 中 repair 完成,且有重新下载或修复行为。
- `05-verify-after-repair.stdout.json``healthy``true`
- `02-first-full-pull.stderr.log``04-repair-after-damage.stderr.log` 中包含下载总体进度、单文件进度和校验结果日志。
- `02-first-full-pull.stderr.log``04-repair-after-damage.stderr.log` 中包含下载已完成计数、单文件进度和校验结果日志。
+2 -2
View File
@@ -308,7 +308,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--error-retry 60s
```
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`watch/daemon 模式还会在每天北京时间(UTC+8)`03:00``16:00``18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出总体下载进度和单文件开始/完成状态,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON errorwatch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75``verify``doctor` 发现问题也返回非 0。
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`watch/daemon 模式还会在每天北京时间(UTC+8)`03:00``16:00``18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,已完成计数在并发下载时保持单调不倒退,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON errorwatch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75``verify``doctor` 发现问题也返回非 0。
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取资源时应读取 `--output/current`,不要读取 `.staging``versions` 中未切换的目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
@@ -325,7 +325,7 @@ scripts/official-full-pull-smoke.sh
make official-smoke
```
默认输出在 `/tmp/bat-official-smoke-<UTC timestamp>/`,脚本会执行 dry-run plan、首次全量拉取、二次 `up_to_date`、本地文件破坏后的 `repair`、repair 后 `verify`,并检查 stderr progress log 中存在总体下载进度、单文件进度和校验结果摘要。完整说明见 `docs/guides/official-full-pull-smoke.md`
默认输出在 `/tmp/bat-official-smoke-<UTC timestamp>/`,脚本会执行 dry-run plan、首次全量拉取、二次 `up_to_date`、本地文件破坏后的 `repair`、repair 后 `verify`,并检查 stderr progress log 中存在下载已完成计数、单文件进度和校验结果摘要。完整说明见 `docs/guides/official-full-pull-smoke.md`
## 7. 例外输入
+2 -2
View File
@@ -426,7 +426,7 @@
- 新增 `make official-smoke` 统一入口。
- 新增 `docs/guides/official-full-pull-smoke.md`,记录目标、命令、输出结构、环境变量、安全边界和成功判定。
- smoke 流程覆盖 dry-run plan、首次全量拉取、二次 `up_to_date`、人工破坏 active release 文件后的 `repair`、repair 后 `verify`
- 脚本会检查二次 `up_to_date`、repair 完成、verify `healthy=true`,并检查首次拉取和 repair 的 stderr log 中存在总体下载进度、单文件进度和校验结果日志。
- 脚本会检查二次 `up_to_date`、repair 完成、verify `healthy=true`,并检查首次拉取和 repair 的 stderr log 中存在下载已完成计数、单文件进度和校验结果日志。
- 运行报告 `SMOKE_REPORT.md` 记录实际输出目录、active release、文件数量、release 大小和被破坏文件;大型官方资源文件保留在隔离输出目录,不纳入 Git。
验收:
@@ -470,7 +470,7 @@
## 6. 当前关闭顺序建议
1. issue #17 验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。
1. issue #17/#20/#21/#22 验收并关闭或更新范围:多线程下载与指数退避实现已合入,daemon 子进程并发参数透传和单调进度上报已补齐,但 GitHub issue 仍 open。
2. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。
3. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。
4. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流。
@@ -4471,6 +4471,8 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
}
args.push("--curl".to_string());
args.push(config.curl_command.to_string_lossy().to_string());
args.push("--download-concurrency".to_string());
args.push(config.download_concurrency.to_string());
match config.curl_proxy.mode() {
CurlProxyMode::Auto if options.proxy_option_explicit => {
args.push("--proxy".to_string());
@@ -6324,6 +6326,8 @@ mod tests {
"http://127.0.0.1:7890",
"--unzip",
"/usr/bin/unzip",
"--download-concurrency",
"40",
"--interval",
"30m",
"--error-retry",
@@ -6346,6 +6350,9 @@ mod tests {
assert!(args
.windows(2)
.any(|pair| pair == ["--platforms", "Windows,Android"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--download-concurrency", "40"]));
// 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。
assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string()));
assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890")));
+28 -16
View File
@@ -118,7 +118,12 @@ impl OfficialResourcePullProgressKind {
pub struct OfficialResourcePullProgress {
/// Progress event kind.
pub kind: OfficialResourcePullProgressKind,
/// One-based URL index in the pull plan.
/// Monotonic completed item count for the whole pull.
///
/// `Started` events report the currently completed count before the URL
/// finishes; `Finished` events report the count after completion. This is
/// intentionally not the URL's plan position, because concurrent downloads
/// finish out of plan order and status percentages must not move backward.
pub index: usize,
/// Total URL count in the pull plan.
pub total: usize,
@@ -633,6 +638,7 @@ impl OfficialResourcePullService {
.collect();
let mut download_results: Vec<Option<Result<PullOneResult, PullOneError>>> =
(0..planned.len()).map(|_| None).collect();
let mut completed_count = 0usize;
if !download_indices.is_empty() {
let concurrency = self.download_concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY);
@@ -680,7 +686,7 @@ impl OfficialResourcePullService {
WorkerMessage::Started { plan_index } => {
let item = &planned[plan_index];
progress(OfficialResourcePullProgress::started(
plan_index + 1,
completed_count,
total,
item.url.clone(),
));
@@ -711,8 +717,9 @@ impl OfficialResourcePullService {
)));
continue;
}
completed_count += 1;
progress(OfficialResourcePullProgress::finished(
plan_index + 1,
completed_count,
total,
item.url.clone(),
pull_result.status,
@@ -736,7 +743,7 @@ impl OfficialResourcePullService {
let item = &planned[plan_index];
self.record_quarantine_entry(&item.url, &item.destination, error)?;
progress(OfficialResourcePullProgress::failed(
plan_index + 1,
completed_count,
total,
item.url.clone(),
error,
@@ -768,12 +775,13 @@ impl OfficialResourcePullService {
let result = if let Some(existing) = &item.existing {
self.clear_quarantine_entry(&item.url)?;
progress(OfficialResourcePullProgress::started(
plan_index + 1,
completed_count,
total,
item.url.clone(),
));
completed_count += 1;
progress(OfficialResourcePullProgress::finished(
plan_index + 1,
completed_count,
total,
item.url.clone(),
existing.status,
@@ -2083,7 +2091,7 @@ exit 22
YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip",
b"ExcelDB.db ExcelDB.db",
b"JP_Airi.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi.zip",
)
}
@@ -2143,10 +2151,10 @@ done
printf '%s' '{android_media_hash}' > "$out"
;;
*/MediaResources-Windows/Catalog/MediaCatalog.bytes)
printf '%s' 'JP_Airi_Win.zip' > "$out"
printf '%s' 'GameData\Audio\VOC_JP\JP_Airi_Win.zip' > "$out"
;;
*/MediaResources/Catalog/MediaCatalog.bytes)
printf '%s' 'JP_Airi_Android.zip' > "$out"
printf '%s' 'GameData\Audio\VOC_JP\JP_Airi_Android.zip' > "$out"
;;
*.zip)
printf '\120\113\003\004\024\000\000\000\000\000\000\000\000\000\000\000\000\000\002\000\000\000\002\000\000\000\010\000\000\000file.txtok\120\113\001\002\024\000\024\000\000\000\000\000\000\000\000\000\000\000\000\000\002\000\000\000\002\000\000\000\010\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000file.txt\120\113\005\006\000\000\000\000\001\000\001\000\066\000\000\000\050\000\000\000\000\000' > "$out"
@@ -2162,8 +2170,8 @@ fi
table_catalog_hash = xxhash32(b"ExcelDB.db"),
windows_bundle_hash = xxhash32(b"FullPatch_000.zip"),
android_bundle_hash = xxhash32(b"FullPatch_001.zip"),
windows_media_hash = xxhash32(b"JP_Airi_Win.zip"),
android_media_hash = xxhash32(b"JP_Airi_Android.zip"),
windows_media_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"),
android_media_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"),
)
}
@@ -2868,7 +2876,10 @@ exit 22
assert_eq!(xxhash32(b""), 46947589);
assert_eq!(xxhash32(b"ExcelDB.db"), 2044170421);
assert_eq!(xxhash32(b"FullPatch_000.zip"), 4038880697);
assert_eq!(xxhash32(b"JP_Airi_Win.zip"), 1416778215);
assert_eq!(
xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"),
1865771294
);
assert_eq!(xxhash32(b"ExcelDB.db ExcelDB.db"), 2147704569);
}
@@ -3053,11 +3064,12 @@ exit 22
assert_eq!(started.len(), expected_urls);
assert_eq!(finished.len(), expected_urls);
// 每个 started 的 total 一致;index 覆盖 1..=N 且不重复
let mut indices: Vec<usize> = started.iter().map(|event| event.index).collect();
indices.sort_unstable();
assert_eq!(indices, (1..=expected_urls).collect::<Vec<_>>());
// 每个 started 的 total 一致;index 表示已完成数量,不能超过总数
assert!(started.iter().all(|event| event.index <= expected_urls));
assert!(started.iter().all(|event| event.total == expected_urls));
assert!(events.windows(2).all(|pair| pair[0].index <= pair[1].index));
let finished_indices: Vec<usize> = finished.iter().map(|event| event.index).collect();
assert_eq!(finished_indices, (1..=expected_urls).collect::<Vec<_>>());
// started 与 finished 覆盖同一组 URLfinished 均为已下载。
let started_urls: HashSet<_> = started.iter().map(|event| event.url.clone()).collect();
+5 -5
View File
@@ -186,7 +186,7 @@ mod tests {
YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip",
b"ExcelDB.db ExcelDB.db",
b"JP_Airi.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi.zip",
)
}
@@ -197,12 +197,12 @@ mod tests {
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Windows,
b"FullPatch_000.zip",
b"JP_Airi_Win.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip",
),
YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Android,
b"FullPatch_001.zip",
b"JP_Airi_Android.zip",
b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip",
),
],
)
@@ -229,11 +229,11 @@ mod tests {
assert!(all_urls[0].ends_with("TableCatalog.bytes"));
assert!(all_urls
.iter()
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
.any(|url| url.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip")));
assert_eq!(
all_urls
.iter()
.filter(|url| url.ends_with("/MediaResources/JP_Airi.zip"))
.filter(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi.zip"))
.count(),
1
);
+3 -3
View File
@@ -1654,7 +1654,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
"download",
format!(
"下载进度:总体 {}/{} ({:.1}%);单文件开始 URL={}",
"下载进度:已完成 {}/{} ({:.1}%);单文件开始 URL={}",
event.index,
event.total,
download_progress_percent(event.index, event.total),
@@ -1667,7 +1667,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
OfficialUpdateProgress::new(
"download",
format!(
"下载进度:总体 {}/{} ({:.1}%);单文件完成 状态={} 文件字节={} 本轮传输字节={} URL={}",
"下载进度:已完成 {}/{} ({:.1}%);单文件完成 状态={} 文件字节={} 本轮传输字节={} URL={}",
event.index,
event.total,
download_progress_percent(event.index, event.total),
@@ -1682,7 +1682,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
OfficialResourcePullProgressKind::Failed => OfficialUpdateProgress::new(
"download",
format!(
"下载中断:总体 {}/{} ({:.1}%);失败类型={} HTTP={} 可重试={} 尝试次数={} quarantine={};本轮跳过该 URL 且不会发布不完整资源 URL={}",
"下载中断:已完成 {}/{} ({:.1}%);失败类型={} HTTP={} 可重试={} 尝试次数={} quarantine={};本轮跳过该 URL 且不会发布不完整资源 URL={}",
event.index,
event.total,
download_progress_percent(event.index, event.total),
@@ -231,7 +231,7 @@ fn official_update_first_run_pulls_without_pre_audit() {
.any(|event| { event.stage == "decision" && event.message.contains("首次拉取=true") }));
assert!(events.iter().any(|event| {
event.stage == "download"
&& event.message.contains("下载进度:总体")
&& event.message.contains("下载进度:已完成")
&& event.message.contains("单文件")
}));
assert!(events.iter().any(|event| {
@@ -561,7 +561,7 @@ elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.bytes
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.hash" ]]; then
emit_text "{windows_bundle_catalog_hash}"
elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.bytes" ]]; then
emit_text "JP_Airi_Win.zip"
emit_text "GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"
elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.hash" ]]; then
emit_text "{windows_media_catalog_hash}"
elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.bytes" ]]; then
@@ -569,7 +569,7 @@ elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.bytes
elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.hash" ]]; then
emit_text "{android_bundle_catalog_hash}"
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.bytes" ]]; then
emit_text "JP_Airi_Android.zip"
emit_text "GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.hash" ]]; then
emit_text "{android_media_catalog_hash}"
elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then
@@ -601,9 +601,9 @@ fi
addressables_root = TEST_ADDRESSABLES_ROOT,
table_catalog_hash = xxhash32(b"ExcelDB.db ExcelDB.db"),
windows_bundle_catalog_hash = xxhash32(b"FullPatch_000.zip"),
windows_media_catalog_hash = xxhash32(b"JP_Airi_Win.zip"),
windows_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"),
android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"),
android_media_catalog_hash = xxhash32(b"JP_Airi_Android.zip"),
android_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"),
)
}
+2 -2
View File
@@ -85,7 +85,7 @@ run_step "02-first-full-pull" \
--progress \
--no-banner
require_contains "${REPORT_DIR}/02-first-full-pull.stderr.log" "下载进度:总体" "first pull did not emit total download progress"
require_contains "${REPORT_DIR}/02-first-full-pull.stderr.log" "下载进度:已完成" "first pull did not emit completed download progress"
require_contains "${REPORT_DIR}/02-first-full-pull.stderr.log" "单文件" "first pull did not emit single-file download progress"
require_contains "${REPORT_DIR}/02-first-full-pull.stderr.log" "校验结果:" "first pull did not emit verification summary"
@@ -129,7 +129,7 @@ run_step "04-repair-after-damage" \
require_contains "${REPORT_DIR}/04-repair-after-damage.stdout.json" '"command": "repair"' "repair command report missing"
require_contains "${REPORT_DIR}/04-repair-after-damage.stdout.json" '"status": "completed"' "repair command did not complete"
require_contains "${REPORT_DIR}/04-repair-after-damage.stderr.log" "下载进度:总体" "repair did not emit total download progress"
require_contains "${REPORT_DIR}/04-repair-after-damage.stderr.log" "下载进度:已完成" "repair did not emit completed download progress"
require_contains "${REPORT_DIR}/04-repair-after-damage.stderr.log" "单文件" "repair did not emit single-file download progress"
require_contains "${REPORT_DIR}/04-repair-after-damage.stderr.log" "校验结果:" "repair did not emit verification summary"