Compare commits

...
15 Commits
Author SHA1 Message Date
nyaKazuha 102b49b666 feat(rpc): 完成 issue #1 Go 调用边界
bat-rust / Build and test Rust (push) Failing after 3m17s
2026-07-24 17:58:34 +08:00
nyaKazuha ecda08ed97 feat(rpc): 补齐 issue #1 资源后端接口 2026-07-24 15:55:37 +08:00
nyaKazuha a729615a48 fix(sync): 移除多线程下载并补齐staging复用回归 2026-07-24 10:34:58 +08:00
nyaKazuha 3f5d2a8da7 fix(sync): 修复官方资源并发下载与媒体路径
bat-rust / Build and test Rust (push) Successful in 3m47s
daemon 子进程现会透传下载并发配置,下载进度按已完成数量单调上报。MediaCatalog 改为使用官方相对路径生成全局媒体 URL,覆盖 GameData、Prologue 等目录并补齐 jpg 资源,避免叶子文件名误拼媒体根目录导致 403。同步更新测试、smoke 断言和运行文档。
2026-07-23 11:56:34 +08:00
nyaKazuha d694100d6c fix(ci)
bat-rust / Build and test Rust (push) Successful in 3m44s
2026-07-21 22:52:39 +08:00
nyaKazuha 3add5f7327 fix(ci): 产物构建与上传 2026-07-21 22:24:17 +08:00
nyaKazuha 40bd82e227 fix(ci):
bat-rust / Build and test Rust (push) Successful in 1m45s
2026-07-21 22:05:25 +08:00
nyaKazuha 84047fbacb fix(ci): 新增同步环境
bat-rust / Build and test Rust (push) Failing after 6s
2026-07-21 21:54:51 +08:00
nyaKazuha 43e1a33b88 fix(ci): 移除 Gitea workflow 外部 action 依赖
bat-rust / Build and test Rust (push) Failing after 1s
改为在 linux runner 中手动 fetch 当前提交,避免自托管 runner 准备阶段通过 gh-proxy 克隆 actions/checkout 和 dtolnay/rust-toolchain 时被 403 拦截,并同步开发文档与缺口说明。
2026-07-21 11:53:39 +08:00
nyaKazuha 924cff5f51 ci(rust): 切换 Gitea runner 标签为 linux
bat-rust / Build and test Rust (push) Canceled after 14m5s
保留 .gitea/workflows/bat.yml 作为实际工作流入口,删除冗余副本,并同步项目计划与当前缺口文档。
2026-07-21 11:35:59 +08:00
nyaKazuha d76f6f1c88 ci(rust): 添加 Gitea host runner 工作流
补充自托管 Gitea host-runner workflow,并将项目状态、缺口、开发指南与路线图同步到当前实际情况。
2026-07-21 10:39:11 +08:00
nyaKazuhaandClaude Fable 5 0ab3f3b953 feat(download): 多线程下载 + 重试指数退避(issue #17)
下载主循环由串行改为「并发下载 + 串行簿记」三段式:
- Phase A:无网络前置校验(官方性/目标路径/建目录)+ 判定跳过/需下载,
  非官方 URL 在任何下载前 fail-fast
- Phase B:need-download 项经 scoped 线程池并发下载(默认并发 4,
  可配 1..=256)。worker 只做只读 &self 的 pull_one(各 URL 独立
  .part/目标文件),经 mpsc 把结果送回主线程;manifest/quarantine
  簿记与进度回调全在主线程串行执行,无需加锁。首个失败或 should_cancel
  置 cancel 标志,其余 worker 在任务边界停止
- Phase C:按 plan 顺序串行收尾——seed .hash 校验(顺序相关、可
  fail-fast)+ 构建有序结果

fail-fast 与「不发布不完整资源」不变量保留;进度事件按 URL 配对但
顺序不再单调(并发下天然如此)。

curl 重试加指数退避(网络类 200ms→400ms→800ms…上限 5s;ETXTBSY 仍走
极短退避),并发下对官方 CDN 更礼貌;退避基值 cfg(test) 下为 0 不拖慢
单测。

并发度经 OfficialUpdateConfig.download_concurrency 贯通,CLI
--download-concurrency 与 BAT_DOWNLOAD_CONCURRENCY 可配,.env 模板
与 USERGUIDE/CURRENT_STATUS/CHANGELOG 同步。

验证:新增并发正确性测试(并发 8:每 URL 恰一次 started+finished、
全部落盘)、并发度钳制、退避时长计算、CLI/env 解析单测;progress
排序测试改为顺序无关不变量;workspace 全测试(20 套件) + fmt +
clippy --all-targets -D warnings 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 03:25:04 -07:00
nyaKazuhaandClaude Fable 5 8efd8f36b4 feat(addressables): 提取 m_Crc 并提供 size/CRC 校验 API(issue #2)
Addressables catalog 里的 m_Crc(bundle IEEE CRC-32)此前从未解析;
声明的 size 也只作元数据、无校验能力。本次:

- ResourceEntry 新增 crc: Option<u32>(serde default 向后兼容),
  compact 与 expanded 两种 catalog 形态均解析 m_Crc/m_Crc→crc
- core 新增 crc32_ieee(IEEE CRC-32,等价 zlib/Unity m_Crc)与
  ResourceEntry::{declared_crc, verify_downloaded_bytes}:按声明的
  size/crc 校验字节,0 视为「无 CRC」跳过
- SqliteResourceRepository 持久化 crc 列,旧库经幂等 ensure_column
  迁移补列(pragma_table_info 判断后 ALTER)
- golden 投影与 fixture 补 crc 字段,验证真实形态 catalog 提取贯通

校验 API 暂不接入 import 覆盖路径(该路径按 CAS id 重写 hash/size 是
既定语义,且合成测试的声明值不匹配实际字节);接入下载/导入校验留
待 G-011。

验证:core crc32 标准向量 + verify 分支单测、expanded 形态非零 crc
提取单测、golden 端到端;core/adapters/infrastructure 全测试 + fmt +
clippy --all-targets -D warnings 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 02:41:41 -07:00
nyaKazuhaandClaude Fable 5 a150407a14 feat(unity): UnityFS 目录条目越界校验(issue #3)
解析阶段已读取 directory 的 offset/size,但从不校验其是否落在解压
数据区(各 block uncompressed_size 之和)内;截断或损坏的 bundle
会给出越界目录条目并被静默接受。新增 validate_directory_bounds:
累加数据区大小、逐条校验 offset+size 不越界且不溢出 u64,越界即
报错并带上下文,接在 parse_blocks_info 之后。

验证:新增 4 个测试(合法覆盖尾部/越界拒绝/offset+size 溢出/
端到端越界 bundle 被拒);bat-adapters 73 测试全绿,fmt + clippy
-p bat-adapters --all-targets -D warnings 通过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:07:40 -07:00
nyaKazuhaandClaude Fable 5 d9332299ef docs: G-008 并入 G-009(bat-api 仿官方 API),调整关闭顺序
- G-008(Go CLI)关闭:用户命令入口由功能完整的 Rust bat 承担,
  Go 侧转向仿官方 API 的 bat-api(issue #19)
- G-009 重定义为 bat-api:仿 BlueArchive 官方 API 的 Go HTTP 服务,
  含鉴权/签名验签,经 daemon RPC + current/ 发布布局对接
- 关闭顺序改为先完善 Rust bat 后端(issue #2/#3/#17)再做 bat-api
- PROJECT_PLAN 近期任务同步为 Rust 后端优先

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:55:03 -07:00
44 changed files with 2438 additions and 365 deletions
+163
View File
@@ -0,0 +1,163 @@
# Gitea Actions workflow for the Rust workspace.
# Self-hosted runner friendly.
# Does not use external GitHub Actions.
name: bat-rust
on:
push:
branches:
- "**"
pull_request:
workflow_dispatch:
jobs:
rust:
name: Build and test Rust
runs-on: linux
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
BAT_SKIP_ENV_FILE: "1"
steps:
- name: Checkout
shell: bash
run: |
set -euo pipefail
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
: "${GITHUB_SHA:?GITHUB_SHA is required}"
repo_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
if [ -d .git ]; then
git remote set-url origin "${repo_url}"
else
git init .
git remote add origin "${repo_url}"
fi
ref="${GITHUB_REF:-${GITHUB_SHA}}"
git fetch \
--no-tags \
--depth=1 \
origin \
"${ref}" \
|| git fetch \
--no-tags \
--depth=1 \
origin \
"${GITHUB_SHA}"
git checkout --force --detach FETCH_HEAD
git submodule update --init --recursive
- name: Show tool versions
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
command -v git
command -v rustc
command -v cargo
rustc --version
cargo --version
rustfmt --version
cargo clippy --version
- name: Check formatting
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo fmt --all -- --check
- name: Check workspace
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo check --workspace --locked
- name: Build workspace
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo build \
--workspace \
--release \
--locked
- name: Run clippy
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo clippy \
--workspace \
--all-targets \
--locked \
-- -D warnings
- name: Run tests
shell: bash
run: |
source /var/lib/act_runner/env.sh
cargo test \
--workspace \
--locked
- name: Package binary
shell: bash
run: |
set -euo pipefail
mkdir -p artifact
cp target/release/bat artifact/
tar \
-czf \
bat-linux-x86_64.tar.gz \
-C artifact \
bat
sha256sum \
bat-linux-x86_64.tar.gz \
> bat-linux-x86_64.sha256
- name: Prepare artifact
shell: bash
run: |
set -euo pipefail
mkdir upload
cp bat-linux-x86_64.tar.gz upload/
cp bat-linux-x86_64.sha256 upload/
cd upload
zip -q \
../bat-linux-x86_64.zip \
*
cd ..
ls -lh bat-linux-x86_64.zip
+1 -1
View File
@@ -5,7 +5,7 @@
*.dll *.dll
*.so *.so
*.dylib *.dylib
bat /bat
# Test binary, built with `go test -c` # Test binary, built with `go test -c`
*.test *.test
+11 -5
View File
@@ -6,14 +6,20 @@
## [未发布] ## [未发布]
### 新增
- Addressables catalog 提取 `m_Crc`bundle IEEE CRC-32):`ResourceEntry` 新增 `crc` 字段(compact/expanded 两种形态均解析),SQLite 持久化并对旧库幂等迁移补列;core 新增 `crc32_ieee``ResourceEntry::verify_downloaded_bytes`(按声明的 size/CRC 校验字节)(issue #2
- UnityFS 解析新增目录条目越界校验:directory 的 `offset+size` 必须落在解压数据区内,截断/损坏 bundle 的越界目录条目不再被静默接受(issue #3
- 官方资源下载回归顺序执行:manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17
### 修复
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s
### 计划 ### 计划
- [ ] 实现 Go CLI 最小可用入口(默认经 daemon RPC 或 `bat --json` 进程边界 - [ ] 实现 `bat-api`:仿 BlueArchive 官方 API 的 Go HTTP 服务(含鉴权/签名验签,issue #19
- [ ] 官方同步结果接入 CAS + ResourceRepository 的用户级工作流 - [ ] 官方同步结果接入 CAS + ResourceRepository 的用户级工作流
- [ ] 实现 AssetBundle 解析器(UnityFS header/block/directory 起步 - [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`
- [ ] 继续逆向 Addressables catalog 可校验字段
- [ ] 实现翻译系统 - [ ] 实现翻译系统
- [ ] 实现 Patch 引擎 - [ ] 实现 Patch 引擎
- [ ] 实现 API Server
- [ ] 实现 Web 管理后台 - [ ] 实现 Web 管理后台
## [0.2.0] - 2026-07-17 ## [0.2.0] - 2026-07-17
@@ -38,7 +44,7 @@
- 新增官方同步路径安全边界:拒绝危险输出目录和 snapshot 路径逃逸,下载目标、manifest、daemon PID/status/log/control 文件不跟随 symlinkdaemon 状态文件默认使用 `0600` 权限 - 新增官方同步路径安全边界:拒绝危险输出目录和 snapshot 路径逃逸,下载目标、manifest、daemon PID/status/log/control 文件不跟随 symlinkdaemon 状态文件默认使用 `0600` 权限
- 新增官方资源原子发布布局:非 dry-run 下载先进入 `.staging/<id>`,校验和 manifest/snapshot 写入完成后发布到 `versions/<id>`,再原子切换 `current` symlink - 新增官方资源原子发布布局:非 dry-run 下载先进入 `.staging/<id>`,校验和 manifest/snapshot 写入完成后发布到 `versions/<id>`,再原子切换 `current` symlink
- 新增 daemon 可观测性:`bat-events.jsonl` 结构化 JSONL 日志、日志轮转、status 中的当前下载进度、最后成功时间、下次检查时间和最后错误摘要 - 新增 daemon 可观测性:`bat-events.jsonl` 结构化 JSONL 日志、日志轮转、status 中的当前下载进度、最后成功时间、下次检查时间和最后错误摘要
- 新增运行时下载与校验 progress log总体下载进度、单文件开始/完成状态、官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验摘要 - 新增运行时下载与校验 progress log下载已完成计数、单文件开始/完成状态、官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验摘要
- 新增官方资源同步生产部署模板:release binary symlink 路径、systemd unit、运行用户、日志位置、升级和回滚流程 - 新增官方资源同步生产部署模板:release binary symlink 路径、systemd unit、运行用户、日志位置、升级和回滚流程
- 新增真实官方网络全量拉取 smoke:`scripts/official-full-pull-smoke.sh``make official-smoke``docs/guides/official-full-pull-smoke.md` - 新增真实官方网络全量拉取 smoke:`scripts/official-full-pull-smoke.sh``make official-smoke``docs/guides/official-full-pull-smoke.md`
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验 - 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
+1 -1
View File
@@ -39,7 +39,7 @@
```bash ```bash
cargo fmt --check cargo fmt --check
cargo test --workspace cargo test --workspace
cargo clippy --workspace -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
``` ```
如果改动只影响部分 crate,可以先跑更窄的测试,但合并前必须确保影响面被覆盖。官方资源同步、下载、daemon、status、verify 或 repair 相关改动还应运行: 如果改动只影响部分 crate,可以先跑更窄的测试,但合并前必须确保影响面被覆盖。官方资源同步、下载、daemon、status、verify 或 repair 相关改动还应运行:
+39 -37
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 当前工作区状态 # BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-07-15 - **更新时间**2026-07-20
- **状态来源**:本地工作区盘点、代码验证和最新提交 - **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment` - **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准 - **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
@@ -10,7 +10,7 @@
## 1. 总体判断 ## 1. 总体判断
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备最小生产运行形态、Go CLI/API/Web 仍未落地** 阶段。 当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go `bat-api` 已有 Rust daemon RPC client 但 CLI/API/Web 仍未形成产品入口** 阶段。
Rust 侧官方日服资源链路已经从实验验证推进到正式入口: Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
@@ -19,17 +19,17 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
3. 默认平台为 `Windows + Android` 3. 默认平台为 `Windows + Android`
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json` 4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。 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 切换。 6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。下载执行保持顺序处理;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` 会清除。 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` 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 误判为可复用。 9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>``.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。 10. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;资源目录是发布根目录,包含 `current` symlink、`versions/<id>``.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;如果上一轮同一 app version、bundle version 和 Addressables root 的 staging 失败但目录仍安全存在,下一轮会复用该 staging 并按 manifest 逐文件校验/补下载;后台状态目录包含 `bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl`、任务历史 `bat-tasks.json` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
12. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。 12. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、403/404、hash mismatch 均有离线回归 fixture。 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,AssetBundle 导入会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC 与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖
14. `bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。 14. `bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted``BAT-ERR-700005`)。
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。 仍需明确:这不是完整产品完成。Go 产品入口、完整 AssetBundle 引擎解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。
--- ---
@@ -87,17 +87,17 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
已包含: 已包含:
- Unity adapter trait、注册表、Unity 2021.3 adapter 骨架 - Unity adapter trait、注册表、Unity 2021.3 adapter 基础解析与校验
- Manifest driver trait、Addressables driver、注册表。 - Manifest driver trait、Addressables driver、注册表。
- Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。 - Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。
- 真实形态 Addressables fixture/golden 测试。 - 真实形态 Addressables fixture/golden 测试。
- 当前 catalog、上一个版本 catalog、结构变化 catalog 的离线回归 fixture。 - 当前 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 等媒体资源,避免把叶子文件名误拼到媒体根目录
待完成: 待完成:
- Unity bundle serialize 仍是后续阶段能力 - `crates/bat-assetbundle` 仍是占位 crate,完整 UnityFS/对象表/TypeTree 引擎未实现
- Addressables parser 仍需继续覆盖二进制/压缩字段组合和更细失败诊断。 - Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
- 客户端发现、备份、应用补丁流程尚未连接真实实现。 - 客户端发现、备份、应用补丁流程尚未连接真实实现。
### `bat-cas-engine` ### `bat-cas-engine`
@@ -148,9 +148,9 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
当前只有: 当前只有:
- Parser trait 占位。 - Parser trait 仍是占位。
- AssetType 占位。 - AssetType 仍是占位。
- 错误类型骨架。 - 错误类型骨架可用,但没有完整解析引擎
待完成: 待完成:
@@ -163,7 +163,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
状态:**占位** 状态:**占位**
当前 Binary Patch 和 JSON Patch 函数返回空结果,不具备真实补丁能力。 当前 Binary Patch 和 JSON Patch 函数会明确返回未实现错误,不具备真实补丁能力。
待完成: 待完成:
@@ -188,7 +188,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
- `bat-ffi` 只暴露粗粒度、无状态、一次调用一次 JSON 输入输出的 C ABI helper。 - `bat-ffi` 只暴露粗粒度、无状态、一次调用一次 JSON 输入输出的 C ABI helper。
- 它不持有 downloader、daemon、CAS handle、资源目录锁或长生命周期状态。 - 它不持有 downloader、daemon、CAS handle、资源目录锁或长生命周期状态。
- Go CLI 和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。 - 未来 Go 产品入口和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。
- FFI 仅用于需要嵌入 C ABI 的兼容场景,不能作为官方同步控制面或主集成边界。 - FFI 仅用于需要嵌入 C ABI 的兼容场景,不能作为官方同步控制面或主集成边界。
待完成: 待完成:
@@ -198,34 +198,36 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
### Go / API / Web ### Go / API / Web
状态:**CLI/API/Web 仍未实现,仅有可选 CGO 兼容包装** 状态:**Go 产品入口仍未完成;`bat-api` 已有 typed Rust daemon RPC client、可选 CGO 兼容包装和试验性 `cmd/bat` 骨架**
当前情况: 当前情况:
- `internal/backendrpc` 已提供 Go typed Unix socket JSON-RPC client,作为 `bat-api` 调用 Rust daemon 的默认路径。
- `internal/ffi/ffi.go` 已存在。 - `internal/ffi/ffi.go` 已存在。
- Go CLI 默认集成方向是调用 Rust `bat --json` 并转发结构化 report,而不是依赖 FFI - Go CLI 的稳定集成方向仍应优先通过 Rust daemon RPC 或 Rust `bat --json` one-shot 进程边界;`cmd/bat` 目前只是试验性骨架,不代表产品级 CLI 已完成
- `cmd/``pkg/``api/``web/` 仍无可用产品入口。 - `cmd/``pkg/``api/``web/` 仍无可用产品入口`cmd/bat` 目前只覆盖 `doctor``manifest inspect``sync plan` 这类最小演示能力
- `go test ./...` 在没有 Go package 时可能无测试可运行;Makefile 会清晰跳过空 Go 阶段 - `go test ./...` 目前只有空测试包结果,`go vet ./...` 可作为基础门禁
--- ---
## 4. 已验证结果 ## 4. 已验证结果
最新功能提交前已运行并通过: 本轮复核已运行并通过:
```bash ```bash
cargo test -p bat-adapters -- --nocapture cargo test --workspace --quiet
cargo test -p bat-ffi -- --nocapture cargo clippy --workspace --all-targets -- -D warnings
cargo test -p bat-infrastructure -- --nocapture go test ./...
cargo test -p bat-infrastructure --bin bat -- --nocapture go vet ./...
cargo run -p bat-infrastructure --bin bat -- --help go build -o /tmp/bat-go-cli ./cmd/bat
git diff --cached --check target/debug/bat --help
git diff --check
``` ```
提交后确认: 同步确认:
```bash ```bash
git status --short git status --short --branch
``` ```
结果:工作区干净。 结果:工作区干净。
@@ -233,14 +235,14 @@ git status --short
未执行: 未执行:
- 本次状态更新未执行一次性真实官方网络全量下载 smoke;该流程已由 `docs/guides/official-full-pull-smoke.md``scripts/official-full-pull-smoke.sh` 固化并关闭(G-018),当前处于长期运行测试阶段,运行报告将在后续提供。 - 本次状态更新未执行一次性真实官方网络全量下载 smoke;该流程已由 `docs/guides/official-full-pull-smoke.md``scripts/official-full-pull-smoke.sh` 固化并关闭(G-018),当前处于长期运行测试阶段,运行报告将在后续提供。
- Go CLI 端到端测试,因为 Go CLI 尚未实现 - Go CLI 端到端测试,因为 Go 产品入口尚未完成;`internal/backendrpc` 已有 fake transport 单测覆盖
- Web/API 测试,因为 Web/API 尚未实现。 - Web/API 测试,因为 Web/API 尚未实现。
--- ---
## 5. 当前生产运行边界 ## 5. 当前生产运行边界
当前唯一可作为 Linux 生产资源同步任务运行的入口是 Rust binary 当前唯一可作为 Linux 生产资源同步任务运行的入口是 Rust binary
```bash ```bash
cargo run -p bat-infrastructure --bin bat -- \ cargo run -p bat-infrastructure --bin bat -- \
@@ -263,12 +265,12 @@ cargo run -p bat-infrastructure --bin bat -- \
## 6. 当前阻塞项 ## 6. 当前阻塞项
GitHub issue 状态:#4#16 已全部关闭(#16 为 daemon status 版本失败输出与重复堆积 bug,已由失败版本去重和状态输出优化修复),当前 open 的是 #1P1)、#2P2)、#3P2 GitHub issue 状态:#1 已升为 P0#17 的实现已合入 HEAD,但 issue 本身尚未关闭,需在验收后再同步关闭。其他 open issue 的实时标签以 GitHub 为准
下一阶段必须优先完成: 下一阶段必须优先完成:
1. Issue #1P1,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope`ok``status``error``data``request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`status/logs/stop/reload/refresh)、`resource.*`state/sync/verify/manifest)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`resource.repair`(待引擎独立修复模式)、`task.create`(按设计由语义方法创建)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 1. Issue #1P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope`ok``status``error``data``request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`status/logs/stop/reload/refresh/doctor)、`resource.*`state/sync/verify/repair/manifest/list)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`task.create`(按设计由语义方法创建)、`daemon.restart` / `daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做自重启或在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
2. Go CLI 最小可用入口:`bat doctor`、稳定的 `bat --help` 命令结构,默认通过上述 RPC 或 `bat --json` 进程边界获取同步 report 2. `bat-api` Go 侧:`internal/backendrpc` typed RPC client 已起步,`cmd/bat` 当前只实现 `doctor``manifest inspect``sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛
3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。 3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。
4. Issue #3P2):AssetBundle UnityFS 基础解析校验。 4. Issue #3P2):AssetBundle UnityFS 基础解析校验。
5. Issue #2P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。 5. Issue #2P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
@@ -282,13 +284,13 @@ GitHub issue 状态:#4#16 已全部关闭(#16 为 daemon status 版本失
立即任务: 立即任务:
1. Issue #1 收尾:协议基础设施、最小方法集`catalog.*`/`task.*` 全量、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)与任务持久化按后续里程碑推进 1. Issue #1 收尾:协议基础设施、最小方法集`catalog.*``task.*``resource.repair`、任务持久化、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)以及 `task.create``daemon.restart``daemon.clean-stable` 的设计边界确认
2. 实现 Go CLI 最小框架和 `doctor`,通过 RPC 或 `bat --json` 边界对接 Rust backend 2. 明确 Go 产品入口的边界:是继续推进独立 `bat` CLI,还是保留当前 Rust `bat` 为用户 CLI、Go 只做服务层与 `bat-api`
3. 跟进官方同步长期运行测试,收集并归档运行报告。 3. 跟进官方同步长期运行测试,收集并归档运行报告。
4. 开始 AssetBundle parser 的 UnityFS header/block/directoryissue #3),并继续扩展 Addressables catalog 可校验字段(issue #2)。 4. 开始 AssetBundle parser 的 UnityFS header/block/directoryissue #3),并继续扩展 Addressables catalog 可校验字段(issue #2)。
--- ---
- **当前总体完成度**约 22% - **当前总体完成度**不再固定写单一百分比,以各模块状态和 issue 为准。
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;产品级 CLI/API/Web 仍未完成。 - **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go `bat-api` 已有 Rust daemon RPC client,但产品级 CLI/API、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。
- **下一工程里程碑**Rust Resource Backend RPC API 最小方法集(issue #1+ Go CLI 最小可用 + 官方同步结果接入 CAS/ResourceRepository + AssetBundle 解析起步。 - **下一工程里程碑**Rust Resource Backend RPC API 收尾、Go 产品入口收敛、官方同步结果接入 CAS/ResourceRepositoryAssetBundle 解析起步。
+15 -11
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 文档索引 # BlueArchiveToolkit 文档索引
- **更新时间**2026-07-15 - **更新时间**2026-07-24
- **说明**:本索引用于快速定位当前权威文档和历史资料。 - **说明**:本索引用于快速定位当前权威文档和历史资料。
--- ---
@@ -14,6 +14,7 @@
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。 - `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。 - `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。 - `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。
- `docs/reference/rpc-backend-api.md`Rust Resource Backend JSON-RPC 稳定 contract。
- `CHANGELOG.md`:版本变更记录。 - `CHANGELOG.md`:版本变更记录。
- `AGENTS.md`:AI agent 和自动化开发助手长期规则。 - `AGENTS.md`:AI agent 和自动化开发助手长期规则。
- `CONTRIBUTING.md`:贡献者协作、提交和验证要求。 - `CONTRIBUTING.md`:贡献者协作、提交和验证要求。
@@ -25,6 +26,7 @@
- `docs/architecture/README.md`:总体架构设计。 - `docs/architecture/README.md`:总体架构设计。
- `docs/api/README.md`API 设计入口。 - `docs/api/README.md`API 设计入口。
- `docs/reference/rpc-backend-api.md`Rust Resource Backend JSON-RPC 稳定 contract。
- `docs/guides/development.md`:开发指南。 - `docs/guides/development.md`:开发指南。
- `docs/guides/deployment.md`:部署指南。 - `docs/guides/deployment.md`:部署指南。
- `deployments/systemd/`:官方资源同步生产 systemd unit 和环境文件示例。 - `deployments/systemd/`:官方资源同步生产 systemd unit 和环境文件示例。
@@ -78,18 +80,19 @@
3. `docs/guides/official-resource-test-pull.md` 3. `docs/guides/official-resource-test-pull.md`
4. `docs/guides/official-full-pull-smoke.md` 4. `docs/guides/official-full-pull-smoke.md`
5. `docs/architecture/official-resource-backend.md` 5. `docs/architecture/official-resource-backend.md`
6. `docs/reports/CURRENT_GAPS.md` 6. `docs/reference/rpc-backend-api.md`
7. `docs/guides/baseline.md` 7. `docs/reports/CURRENT_GAPS.md`
8. `docs/architecture/README.md` 8. `docs/guides/baseline.md`
9. `docs/guides/development.md` 9. `docs/architecture/README.md`
10. `CONTRIBUTING.md` 10. `docs/guides/development.md`
11. `AGENTS.md` 11. `CONTRIBUTING.md`
12. `AGENTS.md`
--- ---
## 6. 状态摘要 ## 6. 状态摘要
当前总体完成度**22%** 当前总体完成度不再固定写单一百分比,以 `CURRENT_STATUS.md``CURRENT_GAPS.md` 的模块状态为准
已完成: 已完成:
@@ -98,13 +101,14 @@
- CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发测试和损坏检测。 - CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发测试和损坏检测。
- 文档整理和路线图重制。 - 文档整理和路线图重制。
- 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` 校验。 - 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` 校验。
- `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。 - 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
- `bat` 运行时 progress log 已覆盖总体下载进度、单文件下载进度和校验结果摘要。 - `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
- Addressables 当前真实形态 fixture/golden 覆盖。 - Addressables 当前真实形态 fixture/golden 覆盖。
- SQLite Resource Repository 和可选无状态 `bat-ffi` JSON 兼容接口。 - SQLite Resource Repository 和可选无状态 `bat-ffi` JSON 兼容接口。
优先待办: 优先待办:
- 落地 Go CLI 最小可用入口 - 收敛 Go 产品入口的最终形态,避免把试验性 `cmd/bat` 误当作完成品
- 将官方同步结果接入 CAS + ResourceRepository 的用户级流程。 - 将官方同步结果接入 CAS + ResourceRepository 的用户级流程。
- 开始 AssetBundle UnityFS 解析。 - 推进 AssetBundle UnityFS 引擎级解析。
+34 -33
View File
@@ -1,7 +1,7 @@
# BlueArchiveToolkit 完整开发计划 # BlueArchiveToolkit 完整开发计划
- **项目名称**BlueArchiveToolkit - **项目名称**BlueArchiveToolkit
- **文档版本**2026-07-06 状态收口版 - **文档版本**2026-07-20 状态收口版
- **权威状态**:以本文档和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。 - **权威状态**:以本文档和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
- **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。 - **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。
@@ -22,7 +22,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 2. 当前真实状态 ## 2. 当前真实状态
本节来自 2026-07-06 的工作区盘点、本地验证和最新功能提交。 本节来自 2026-07-20 的工作区盘点、本地验证和最新功能提交。
### 已具备 ### 已具备
@@ -32,15 +32,15 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。 4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。 5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat` 6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`
7. `bat` 支持 `--auto-discover``--watch``--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable` 运维命令 7. `bat` 支持 `--auto-discover``--watch``--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list``catalog.*``task.*`);`restart``clean-stable` 仍由 CLI 侧按进程生命周期显式执行
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
9. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。 9. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。
### 仍是骨架或占位 ### 仍是骨架或占位
1. AssetBundle 解析器仍是占位 trait,未解析 UnityFS、压缩块、TypeTree 或对象表。 1. `bat-assetbundle` 仍是占位 crate;完整 UnityFS、压缩块、TypeTree 或对象表解析未完成
2. Patch 的 Binary/JSON 模块仍返回空结果,不具备真实补丁能力。 2. `bat-patch` 的 Binary/JSON 模块仍返回明确的未实现错误,不具备真实补丁能力。
3. Go CLI/API/SDK 仍没有产品级入口;只有 `internal/ffi` 的可选兼容包装骨架 3. Go CLI/API/SDK 仍没有产品级入口;当前 `bat-api` module 只有 `internal/backendrpc` Rust daemon RPC client、`cmd/bat` 试验骨架与 `internal/ffi` 兼容包装。
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。 4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。 5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。
6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。 6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
@@ -49,12 +49,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### 已验证 ### 已验证
1. `cargo test -p bat-adapters -- --nocapture` 通过。 1. `cargo test --workspace --quiet` 通过。
2. `cargo test -p bat-ffi -- --nocapture` 通过。 2. `cargo clippy --workspace --all-targets -- -D warnings` 通过。
3. `cargo test -p bat-infrastructure -- --nocapture` 通过 3. `go test ./...` 通过,但目前没有 Go 产品级测试覆盖
4. `cargo test -p bat-infrastructure --bin bat -- --nocapture` 通过。 4. `go vet ./...` 通过。
5. `cargo run -p bat-infrastructure --bin bat -- --help` 可用 5. `go build -o /tmp/bat-go-cli ./cmd/bat` 通过
6. `go test ./...` 当前无 Go 产品 package`Makefile` 已调整为在 Go 未实现阶段明确跳过 6. `target/debug/bat --help` 可用
--- ---
@@ -84,7 +84,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
1. 无占位返回、无静默吞错、无未说明的 `TODO` 1. 无占位返回、无静默吞错、无未说明的 `TODO`
2. 公共接口具备文档、错误语义和兼容性说明。 2. 公共接口具备文档、错误语义和兼容性说明。
3. 单元测试覆盖核心分支;跨模块能力补集成测试。 3. 单元测试覆盖核心分支;跨模块能力补集成测试。
4. `cargo fmt``cargo clippy --workspace -- -D warnings``cargo test --workspace` 通过。 4. `cargo fmt``cargo clippy --workspace --all-targets -- -D warnings``cargo test --workspace` 通过。
5. Go 模块落地后,`go test ./...``go vet ./...` 通过。 5. Go 模块落地后,`go test ./...``go vet ./...` 通过。
6. 用户可见命令必须有 `doctor` 检查和失败恢复建议。 6. 用户可见命令必须有 `doctor` 检查和失败恢复建议。
@@ -141,7 +141,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
**目标**:完成可长期使用的 Content Addressable Storage。 **目标**:完成可长期使用的 Content Addressable Storage。
**当前状态**:已完成 CAS V1。Go CLI 以最小稳定入口优先,Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。 **当前状态**:已完成 CAS V1。Go CLI/API 产品入口尚未完成;`bat-api` 已有 `internal/backendrpc` typed RPC client 和 `cmd/bat` 试验骨架;Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。
交付物: 交付物:
@@ -166,7 +166,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
**目标**:能够获取、解析和同步 Blue Archive 资源清单。 **目标**:能够获取、解析和同步 Blue Archive 资源清单。
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;Go CLI、完整解析覆盖、CAS 导入编排和真实线上 smoke 仍待完成。 **当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;Go 产品入口、完整解析覆盖、CAS 导入编排和真实线上 smoke 仍待完成。
交付物: 交付物:
@@ -174,9 +174,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。 2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验和 repair。 3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验和 repair。
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch``--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。 4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch``--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。
5. Go CLI**未完成**。需要实现 `bat doctor``bat sync --help`、Rust 官方同步命令包装和 JSON/human 输出 5. Go 产品入口**未完成**。`internal/backendrpc` 已提供 Go 调 Rust daemon 的 typed RPC client;当前 `cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,尚不构成产品级 CLI;若要继续由 Go 承担用户入口,需要单独收敛命令集和调用边界
6. 用户级 `sync``manifest inspect``cache status`**未完成**。Rust `bat --json` Go CLI 默认进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。 6. 用户级 `sync``manifest inspect``cache status`**未完成**。Rust `bat --json`当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
7. 下载结果写入 CAS + ResourceRepository**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果作为用户级流程自动导入。 7. 下载结果写入 CAS + ResourceRepository**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果自动作为用户级流程导入。
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig` 8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`
9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。 9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
@@ -348,7 +348,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
交付物: 交付物:
1. 发布验证:format、lint、test、build、security audit、release artifact 由本地可重复命令与脚本承担(决策:不引入 GitHub Workflows 等托管 CI,见 `docs/reports/CURRENT_GAPS.md` G-017)。 1. 发布验证:format、lint、test、build、security audit、release artifact 由本地可重复命令、自托管 Gitea linux-runner workflow 与脚本承担(决策:不引入 GitHub Workflows 等托管 CI,见 `docs/reports/CURRENT_GAPS.md` G-017)。
2. Docker Compose:本地开发、服务端部署。 2. Docker Compose:本地开发、服务端部署。
3. 数据备份与恢复文档。 3. 数据备份与恢复文档。
4. 用户文档、开发文档、故障排查文档。 4. 用户文档、开发文档、故障排查文档。
@@ -366,7 +366,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序 ## 5. 推荐执行顺序
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go CLI 入口、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。 近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go 产品入口边界、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。
建议顺序: 建议顺序:
@@ -380,14 +380,15 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 6. 近期具体任务 ## 6. 近期具体任务
1. 落地 Go CLI 的最小生产入口:`bat doctor``bat sync --help``bat official sync --help` 优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,`bat-api/internal/backendrpc` 已提供 Go 到 Rust daemon 的本机 RPC clientGo `cmd/bat` 仍只是试验骨架,`bat-api` HTTP 服务仍是独立目标(issue #19 / G-009):
2. 让 Go CLI 默认调用 Rust `bat --json` 官方同步入口,并稳定转发结构化 report;除非有明确兼容需求,不走 FFI。
3. 记录一次真实官方网络 smoke:dry-run、首次下载、二次 up-to-date、本地损坏 repair 1. 对 issue #17 做验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open
4. 将官方同步下载结果接入 CAS + `SqliteResourceRepository` 的用户级流程 2. 继续逆向 Addressables catalog,扩大 bundle hash/size/CRC 等可校验字段覆盖(issue #2
5. 继续扩展 Addressables parser 的真实 catalog 变体覆盖和错误诊断 3. 对 AssetBundle/UnityFS 做引擎级解析:header/block/directory/metadata/object tableissue #3 / G-005
6. 开始 AssetBundle UnityFS header/block/directory 解析 4. 将官方同步下载结果接入 CAS + `SqliteResourceRepository` 的用户级流程(G-011
7. 为 CLI 和 CAS 增加 `doctor cas` 诊断入口 5. 收敛 Go 产品入口:明确继续推进最小 Go CLI,或把用户 CLI 固化为 Rust `bat` 并把 Go 侧集中到 `bat-api`
8. `bat --watch` / `bat --daemon` 持续补充发布型构建、systemd service 示例和运维检查清单;后台 live control plane 已改为 Unix socket JSON-RPC;基础生产部署模板、日志路径、权限用户、升级/回滚流程已补齐 6. 实现 `bat-api`(仿官方 API 的 Go HTTP 服务,含鉴权/签名验签,issue #19 / G-009
7. 为 CAS 增加 `doctor cas` 诊断入口。
--- ---
@@ -414,8 +415,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
处理策略: 处理策略:
1. Rust 提供稳定引擎能力,不承担 CLI 编排,但负责完整资源拉取和更新检查的核心逻辑 1. Rust 提供稳定引擎能力,并在当前阶段承担可生产运行的官方资源同步 CLI、watch 和 daemon
2. Go 负责用户命令、最小稳定 CLI、服务编排、网络和 Provider。 2. Go 的长期职责包括用户命令、最小稳定 CLI、服务编排、网络和 Provider;当前 Go 产品入口尚未完成,不能把 `cmd/bat` 试验骨架视为完成
3. 跨边界优先进程或 SDK,FFI 只作为可选的粗粒度、无状态、安全、可测试兼容 API。 3. 跨边界优先进程或 SDK,FFI 只作为可选的粗粒度、无状态、安全、可测试兼容 API。
4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。 4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。
@@ -439,11 +440,11 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 8. 当前完成度评估 ## 8. 当前完成度评估
按最终目标计算,当前总体完成度约为 **22%** 按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准
已完成的是稳定基线、架构骨架、部分接口、CAS V1 和 Rust 官方资源同步闭环,不是完整产品能力。下一阶段的关键不是继续堆目录,而是把 Go CLI 最小入口、官方同步端到端验证、CAS/ResourceRepository 编排和 AssetBundle 解析链路做实。 已完成的是稳定基线、架构骨架、部分接口、CAS V1 和 Rust 官方资源同步闭环,不是完整产品能力。下一阶段的关键不是继续堆目录,而是把 Go 产品入口边界、官方同步端到端验证、CAS/ResourceRepository 编排和 AssetBundle 解析链路做实。
--- ---
- **下一份应更新文档**:真实官方网络 smoke 记录 - **下一份应更新文档**:真实官方网络 smoke 记录
- **下一项工程任务**Go CLI 最小可用入口和官方同步端到端 smoke - **下一项工程任务**收敛 Go 产品入口边界、执行官方同步端到端 smoke,并推进 CAS/ResourceRepository 与 AssetBundle 解析
+19 -17
View File
@@ -2,7 +2,7 @@
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。 **BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。 当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go module 名为 `bat-api`,目前包含试验性的 `cmd/bat` 骨架、`internal/backendrpc` Rust daemon RPC client 和 `internal/ffi` 兼容包装;产品级 CLI、API Server、Web、完整 AssetBundle 解析、翻译系统和 Patch 系统仍在后续阶段。
--- ---
@@ -10,10 +10,11 @@
- Rust workspace 和 monorepo 结构。 - Rust workspace 和 monorepo 结构。
- `bat-core` 领域对象和仓储接口骨架。 - `bat-core` 领域对象和仓储接口骨架。
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖。 - `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 基础校验
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。 - `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。 - `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC 后台控制命令 `status/stop/restart/reload/refresh/logs/verify/repair/doctor/clean-stable` - `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、顺序下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list``catalog.*``task.*`
- `internal/backendrpc`Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。 - 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。 - 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。 - `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
@@ -21,8 +22,8 @@
仍未完成: 仍未完成:
- Go CLI 最小可用入口 - Go CLI 产品入口(当前仅有试验性 `cmd/bat` 骨架)
- 完整 UnityFS / AssetBundle 解析。 - 完整 UnityFS / AssetBundle 引擎解析。
- 真实 Patch apply/diff。 - 真实 Patch apply/diff。
- Translation Memory、Glossary、AI Provider。 - Translation Memory、Glossary、AI Provider。
- API Server、SDK、Web 管理后台。 - API Server、SDK、Web 管理后台。
@@ -48,13 +49,13 @@
- `curl` - `curl`
- `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets` - `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets`
运行当前主要测试 运行当前通用验证
```bash ```bash
cargo test -p bat-adapters -- --nocapture cargo test --workspace
cargo test -p bat-ffi -- --nocapture cargo clippy --workspace --all-targets -- -D warnings
cargo test -p bat-infrastructure -- --nocapture go test ./...
cargo test -p bat-infrastructure --bin bat -- --nocapture go vet ./...
``` ```
查看官方同步命令: 查看官方同步命令:
@@ -80,7 +81,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--error-retry 60s --error-retry 60s
``` ```
后台自动运行可以把 `--watch` 换成 `--daemon`。默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。daemon 会在状态目录下创建 `bat.sock` 作为 Unix socket JSON-RPC 控制通道,同时写入 `bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl` 和短生命周期的 `bat-control.lock``bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 用于串行化 `status/stop/restart/reload/logs/refresh` 等控制命令: 后台自动运行可以把 `--watch` 换成 `--daemon`。默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。daemon 会在状态目录下创建 `bat.sock` 作为 Unix socket JSON-RPC 控制通道,同时写入 `bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl` 和短生命周期的 `bat-control.lock``bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 用于串行化 `status/stop/restart/reload/logs/refresh/repair` 等控制命令:
```bash ```bash
cargo run -p bat-infrastructure --bin bat -- \ cargo run -p bat-infrastructure --bin bat -- \
@@ -94,7 +95,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
cargo run -p bat-infrastructure --bin bat -- stop cargo run -p bat-infrastructure --bin bat -- stop
``` ```
`status``stop``logs``reload`默认形态的 `refresh` 会优先连接 live RPC socketsocket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop` `status``stop``logs``reload`默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socketsocket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh`/`repair` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 `bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
@@ -115,7 +116,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。 下载失败会按 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>/` 隔离目录,不会写入已有客户端、生产目录或开发机人工维护资源目录: 真实官方网络全量拉取 smoke 已固化为可重复命令,默认使用 `/tmp/bat-official-smoke-<UTC timestamp>/` 隔离目录,不会写入已有客户端、生产目录或开发机人工维护资源目录:
@@ -156,8 +157,9 @@ BlueArchiveToolkit/
│ ├── bat-assetbundle/ │ ├── bat-assetbundle/
│ ├── bat-patch/ │ ├── bat-patch/
│ └── bat-ffi/ # 可选无状态 C ABI 兼容层 │ └── bat-ffi/ # 可选无状态 C ABI 兼容层
├── internal/backendrpc/ # Go -> Rust daemon 的 typed JSON-RPC client
├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径 ├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径
├── cmd/ # Go CLI 入口,尚未实现 ├── cmd/ # Go CLI 试验骨架与后续产品入口
├── pkg/ # Go SDK 包,尚未实现 ├── pkg/ # Go SDK 包,尚未实现
├── api/ # API 定义,尚未实现 ├── api/ # API 定义,尚未实现
├── web/ # Web 管理后台,尚未实现 ├── web/ # Web 管理后台,尚未实现
@@ -174,13 +176,13 @@ BlueArchiveToolkit/
近期优先级: 近期优先级:
1. 落地 Go CLI 最小可用入口:`bat doctor``bat sync --help`、通过 `bat --json` 包装 Rust 同步命令 1. 收敛 Go CLI 产品入口的最终形态:当前 `cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,不应误写成完整 CLI
2. 补齐 AssetBundle UnityFS header/block/directory 解析。 2. 补齐 AssetBundle UnityFS 引擎级解析。
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。 3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
4. 将官方同步结果接入 CAS + ResourceRepository 的用户级工作流。 4. 将官方同步结果接入 CAS + ResourceRepository 的用户级工作流。
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。 5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
不建议在 Go CLI、资源解析和文本提取基础能力完成前优先开发 Web UI。 不建议在 Go 产品入口、资源解析和文本提取基础能力完成前优先开发 Web UI。
--- ---
+14 -6
View File
@@ -166,7 +166,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
`location` 是稳定的「组件·操作」标签(跟随语义、不随行号漂移)。`retryable` 是该类错误的默认可重试性。 `location` 是稳定的「组件·操作」标签(跟随语义、不随行号漂移)。`retryable` 是该类错误的默认可重试性。
> 说明:错误码模型(`core/src/error_code.rs`)已建立并作为公共契约;将各链路的报错逐步接入该码表的工作在 issue #1 下推进。下表随码表更新 > 说明:错误码模型(`core/src/error_code.rs`)已建立并作为公共契约;下载、launcher/metadata、server-info/marker、配置校验、任务/RPC 等主要链路已接入该码表。剩余未实现命名空间和后续引擎能力继续按本表扩展
### 域一览 ### 域一览
@@ -239,7 +239,9 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
## 6. Daemon RPC 接口 ## 6. Daemon RPC 接口
`bat --daemon` 在后台状态目录下创建 `bat.sock`Unix socket),提供**换行分隔的 JSON-RPC 2.0** 控制面。CLI 的 `status`/`stop`/`logs`/`reload`/`refresh` 优先走它;Go 服务层也应通过这个进程边界调用,而非 FFI 稳定 contract 以 `docs/reference/rpc-backend-api.md` 为准,本节保留常用说明和命令行示例
`bat --daemon` 在后台状态目录下创建 `bat.sock`Unix socket),提供**换行分隔的 JSON-RPC 2.0** 控制面。CLI 的 `status`/`stop`/`logs`/`reload`/`refresh`/`repair` 优先走它;Go 服务层也应通过这个进程边界调用,而非 FFI 或执行 `bat` binary 后再解析 stdout。
### 传输与 envelope ### 传输与 envelope
@@ -271,10 +273,12 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `daemon.stop` | ✅ | 请求停止(`accepted` | | `daemon.stop` | ✅ | 请求停止(`accepted` |
| `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted` | | `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted` |
| `daemon.refresh` | ✅ | 请求刷新检查(`params.force``accepted` | | `daemon.refresh` | ✅ | 请求刷新检查(`params.force``accepted` |
| `daemon.doctor` | ✅ | 返回运行时诊断报告(只读,不清理、不重启) |
| `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 | | `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 |
| `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` | | `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` |
| `resource.verify` | ✅ | 触发校验任务(dry-run + audit),返回 `task_id` | | `resource.verify` | ✅ | 触发校验任务(dry-run + audit),返回 `task_id` |
| `resource.manifest` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000 | | `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
| `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000 |
| `catalog.status` | ✅ | 当前已发布版本的 catalog 概览(app/bundle 版本、addressables 根、端点与 marker 计数、launcher 元数据) | | `catalog.status` | ✅ | 当前已发布版本的 catalog 概览(app/bundle 版本、addressables 根、端点与 marker 计数、launcher 元数据) |
| `catalog.versions` | ✅ | 版本历史:current / in_progress / previous / failed | | `catalog.versions` | ✅ | 版本历史:current / in_progress / previous / failed |
| `catalog.diff` | ✅ | 当前 snapshot 相对上一个可用版本的差异(base_delta + extended_delta + 变更端点 URL | | `catalog.diff` | ✅ | 当前 snapshot 相对上一个可用版本的差异(base_delta + extended_delta + 变更端点 URL |
@@ -283,14 +287,14 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `task.list` | ✅ | 列出全部任务(最新在前) | | `task.list` | ✅ | 列出全部任务(最新在前) |
| `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 | | `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 |
| `task.logs` | ✅ | 返回任务的进度日志(`params.task_id`,有界) | | `task.logs` | ✅ | 返回任务的进度日志(`params.task_id`,有界) |
| `resource.repair` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`not implemented);repair 待引擎支持独立修复模式,patch/unityfs 待引擎实现 | | `daemon.restart` / `daemon.clean-stable` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`not implemented);restart/clean-stable 仍由 CLI 侧按进程生命周期显式执行,patch/unityfs 待引擎实现,task.create 暂不开放通用任务入口 |
| 未知方法 | — | `BAT-ERR-700001`unknown method | | 未知方法 | — | `BAT-ERR-700001`unknown method |
只读查询(`resource.state` / `resource.manifest` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。 只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
### 任务模型 ### 任务模型
`resource.sync` / `resource.verify` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }``status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录: `resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }``status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
```json ```json
{ "id": "task-1234-1", "kind": "resource.sync", { "id": "task-1234-1", "kind": "resource.sync",
@@ -324,4 +328,8 @@ printf '{"jsonrpc":"2.0","id":4,"method":"catalog.versions"}\n' \
# 分页读取当前版本的下载 manifest # 分页读取当前版本的下载 manifest
printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":0,"limit":50}}\n' \ printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":0,"limit":50}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
# 触发本地资源审计+修复任务
printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
``` ```
+20
View File
@@ -272,6 +272,12 @@ impl AddressablesCatalogDriver {
.unwrap_or_default(); .unwrap_or_default();
let dependencies = Self::dependencies_from_entry(value); let dependencies = Self::dependencies_from_entry(value);
let crc = value
.get("crc")
.or_else(|| value.get("Crc"))
.or_else(|| value.get("m_Crc"))
.and_then(|value| value.as_u64())
.and_then(|value| u32::try_from(value).ok());
Some(ResourceEntry { Some(ResourceEntry {
path: path.to_string(), path: path.to_string(),
@@ -280,6 +286,7 @@ impl AddressablesCatalogDriver {
resource_type: Self::resource_type_for_path(path), resource_type: Self::resource_type_for_path(path),
address, address,
dependencies, dependencies,
crc,
}) })
} }
@@ -376,6 +383,7 @@ impl AddressablesCatalogDriver {
resource_type: Self::resource_type_for_path(path), resource_type: Self::resource_type_for_path(path),
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}) })
}) })
.collect() .collect()
@@ -413,6 +421,7 @@ impl AddressablesCatalogDriver {
resource_type, resource_type,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}); });
} }
@@ -483,6 +492,7 @@ impl AddressablesCatalogDriver {
Some(primary_key) Some(primary_key)
}, },
dependencies, dependencies,
crc: extra.crc,
}) })
}) })
.collect() .collect()
@@ -619,6 +629,11 @@ impl AddressablesCatalogDriver {
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.map(ToOwned::to_owned), .map(ToOwned::to_owned),
bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()), bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()),
// m_Crc 是 bundle 的 IEEE CRC-320 表示不做 CRC 校验,忠实保留原值。
crc: json
.get("m_Crc")
.and_then(|value| value.as_u64())
.and_then(|value| u32::try_from(value).ok()),
} }
} }
@@ -821,6 +836,7 @@ struct AddressablesExtraData {
hash: Option<String>, hash: Option<String>,
bundle_name: Option<String>, bundle_name: Option<String>,
bundle_size: Option<u64>, bundle_size: Option<u64>,
crc: Option<u32>,
} }
impl Default for AddressablesCatalogDriver { impl Default for AddressablesCatalogDriver {
@@ -954,6 +970,7 @@ mod tests {
"internal_id": "synthetic/minimal.bundle", "internal_id": "synthetic/minimal.bundle",
"hash": "synthetic-entry-hash", "hash": "synthetic-entry-hash",
"size": 119, "size": 119,
"crc": 3735928559,
"address": "Character_001", "address": "Character_001",
"dependencies": ["synthetic/shared.bundle"] "dependencies": ["synthetic/shared.bundle"]
}, },
@@ -971,6 +988,9 @@ mod tests {
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle"); assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash"); assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
assert_eq!(manifest.resources[0].size, 119); assert_eq!(manifest.resources[0].size, 119);
// m_Crc(此处 0xDEADBEEF)应被提取;缺该字段的条目为 None。
assert_eq!(manifest.resources[0].crc, Some(0xDEAD_BEEF));
assert_eq!(manifest.resources[1].crc, None);
assert_eq!( assert_eq!(
manifest.resources[0].address.as_deref(), manifest.resources[0].address.as_deref(),
Some("Character_001") Some("Character_001")
+111 -23
View File
@@ -10,7 +10,7 @@ pub struct YostarJpDownloadInventory {
pub bundle_patch_pack_names: Vec<String>, pub bundle_patch_pack_names: Vec<String>,
/// Table file names from `TableCatalog.bytes`. /// Table file names from `TableCatalog.bytes`.
pub table_file_names: Vec<String>, 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>, pub media_file_names: Vec<String>,
} }
@@ -21,7 +21,7 @@ pub struct YostarJpPlatformCatalogInventory {
pub platform: PatchPlatform, pub platform: PatchPlatform,
/// Patch-pack zip names from this platform's `BundlePackingInfo.bytes`. /// Patch-pack zip names from this platform's `BundlePackingInfo.bytes`.
pub bundle_patch_pack_names: Vec<String>, 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>, pub media_file_names: Vec<String>,
} }
@@ -35,10 +35,7 @@ impl YostarJpPlatformCatalogInventory {
Self { Self {
platform, platform,
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info), bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
media_file_names: extract_file_names( media_file_names: extract_media_file_paths(media_catalog),
media_catalog,
&["zip", "mp4", "png", "ogg", "wav"],
),
} }
} }
@@ -172,10 +169,7 @@ impl YostarJpDownloadInventory {
Self { Self {
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info), bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
table_file_names: extract_table_file_names(table_catalog), table_file_names: extract_table_file_names(table_catalog),
media_file_names: extract_file_names( media_file_names: extract_media_file_paths(media_catalog),
media_catalog,
&["zip", "mp4", "png", "ogg", "wav"],
),
} }
} }
@@ -327,6 +321,20 @@ fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec<String> {
names.into_iter().collect() 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> { fn extract_printable_strings(data: &[u8], min_len: usize) -> Vec<String> {
let mut strings = Vec::new(); let mut strings = Vec::new();
let mut current = Vec::new(); let mut current = Vec::new();
@@ -375,6 +383,32 @@ fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec<String> {
names 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 { fn filename_start(bytes: &[u8], mut index: usize) -> usize {
while index > 0 { while index > 0 {
let byte = bytes[index - 1]; 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, '_' | '-' | '.')) .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> { fn unique_platforms(platforms: &[PatchPlatform]) -> Vec<PatchPlatform> {
platforms platforms
.iter() .iter()
@@ -441,7 +497,7 @@ mod tests {
let inventory = YostarJpDownloadInventory::from_catalog_bytes( let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix", 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"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!( assert_eq!(
@@ -457,7 +513,10 @@ mod tests {
); );
assert_eq!( assert_eq!(
inventory.media_file_names, 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( let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip FullPatch_001.zip", b"FullPatch_000.zip FullPatch_001.zip",
b"ExcelDB.db ExcelDB.db Battle.zip Battle.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(); let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
@@ -496,7 +555,7 @@ mod tests {
.any(|url| url.ends_with("/TableBundles/ExcelDB.db"))); .any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
assert!(urls assert!(urls
.iter() .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] #[test]
@@ -504,7 +563,7 @@ mod tests {
let inventory = YostarJpDownloadInventory::from_catalog_bytes( let inventory = YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip", b"FullPatch_000.zip",
b"ExcelDB.db ExcelDB.db", 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(); let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
@@ -524,10 +583,10 @@ mod tests {
.any(|url| url.ends_with("/TableBundles/ExcelDB.db"))); .any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
assert!(urls assert!(urls
.iter() .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 assert!(urls
.iter() .iter()
.any(|url| url.ends_with("/MediaResources/JP_Airi.zip"))); .any(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi.zip")));
} }
#[test] #[test]
@@ -538,12 +597,12 @@ mod tests {
YostarJpPlatformCatalogInventory::from_catalog_bytes( YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Windows, PatchPlatform::Windows,
b"FullPatch_000.zip", b"FullPatch_000.zip",
b"JP_Airi_Win.zip", b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip",
), ),
YostarJpPlatformCatalogInventory::from_catalog_bytes( YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Android, PatchPlatform::Android,
b"FullPatch_001.zip", 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"))); .any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip")));
assert!(urls assert!(urls
.iter() .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 assert!(urls
.iter() .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 assert!(!urls
.iter() .iter()
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip"))); .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"))); .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] #[test]
#[ignore = "requires BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO, BAT_REAL_OFFICIAL_TABLE_CATALOG, BAT_REAL_OFFICIAL_MEDIA_CATALOG"] #[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() { fn extracts_realistic_counts_from_official_shape() {
@@ -591,7 +675,7 @@ mod tests {
assert_eq!(inventory.bundle_patch_pack_names.len(), 142); assert_eq!(inventory.bundle_patch_pack_names.len(), 142);
assert!(inventory.table_file_names.len() < 1000); assert!(inventory.table_file_names.len() < 1000);
assert_eq!(inventory.media_file_names.len(), 1887); assert!(inventory.media_file_names.len() >= 4000);
assert!(inventory assert!(inventory
.bundle_patch_pack_names .bundle_patch_pack_names
.iter() .iter()
@@ -603,6 +687,10 @@ mod tests {
assert!(inventory assert!(inventory
.media_file_names .media_file_names
.iter() .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. /// Returns an official media archive URL.
/// ///
/// The argument is the `Media.FileName` field from `MediaCatalog.bytes`, /// The argument is the downloadable relative path from `MediaCatalog.bytes`,
/// for example `JP_Airi.zip`. /// for example `GameData/Audio/VOC_JP/JP_Airi.zip`.
pub fn media_file(&self, platform: PatchPlatform, file_name: &str) -> Result<String, String> { pub fn media_file(&self, platform: PatchPlatform, file_name: &str) -> Result<String, String> {
validate_relative_path(file_name, "media file")?; validate_relative_path(file_name, "media file")?;
Ok(format!( Ok(format!(
@@ -1134,8 +1134,9 @@ mod tests {
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/Catalog/MediaCatalog.bytes" "https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/Catalog/MediaCatalog.bytes"
); );
assert_eq!( assert_eq!(
root.media_file(PatchPlatform::Windows, "JP_Airi.zip").unwrap(), root.media_file(PatchPlatform::Windows, "GameData/Audio/VOC_JP/JP_Airi.zip")
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/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.table_bundle("text=jp/ExcelDB.db").is_err());
assert!(root assert!(root
.media_file(PatchPlatform::Windows, "/JP_Airi.zip") .media_file(PatchPlatform::Windows, "/GameData/Audio/VOC_JP/JP_Airi.zip")
.is_err()); .is_err());
assert!(root assert!(root
.bundle_patch_pack(PatchPlatform::Windows, "../FullPatch_000.zip") .bundle_patch_pack(PatchPlatform::Windows, "../FullPatch_000.zip")
+122
View File
@@ -86,6 +86,7 @@ impl Unity2021_3Adapter {
flags, flags,
)?; )?;
let (blocks, directories) = Self::parse_blocks_info(&block_info)?; let (blocks, directories) = Self::parse_blocks_info(&block_info)?;
validate_directory_bounds(&blocks, &directories)?;
Ok(ParsedAssetBundle { Ok(ParsedAssetBundle {
unity_version: header.unity_version.clone(), unity_version: header.unity_version.clone(),
@@ -145,6 +146,50 @@ impl Unity2021_3Adapter {
} }
} }
/// 校验目录条目落在解压数据区内。
///
/// UnityFS 的 directory 是解压后(所有 block 的 uncompressed 数据依次拼接而成的)
/// 连续数据区上的 `[offset, offset + size)` 切片。解析阶段只按结构读取这些数值,
/// 并不保证它们不越界;截断或损坏的 bundle 会给出指向数据区之外的目录条目,
/// 若不校验就静默接受,后续按 offset/size 取数据时才会出错或读到错误内容。
/// 这里把每个目录条目与「各 block 解压大小之和」比对,越界即报错并带上下文。
fn validate_directory_bounds(
blocks: &[UnityFsBlockInfo],
directories: &[UnityFsDirectoryInfo],
) -> Result<(), String> {
let mut data_region_size: u64 = 0;
for (index, block) in blocks.iter().enumerate() {
data_region_size = data_region_size
.checked_add(u64::from(block.uncompressed_size))
.ok_or_else(|| {
format!(
"UnityFS 解压数据区大小溢出:累加到第 {index} 个 blockuncompressed_size={})时超过 u64",
block.uncompressed_size
)
})?;
}
for (index, directory) in directories.iter().enumerate() {
let end = directory
.offset
.checked_add(directory.size)
.ok_or_else(|| {
format!(
"UnityFS 目录条目 {} 的 offset({}) + size({}) 溢出 u64",
directory.path, directory.offset, directory.size
)
})?;
if end > data_region_size {
return Err(format!(
"UnityFS 目录条目 {}(第 {index} 项)越界:offset({}) + size({}) = {} 超过解压数据区大小 {}",
directory.path, directory.offset, directory.size, end, data_region_size
));
}
}
Ok(())
}
fn read_blocks_info_bytes<'a>( fn read_blocks_info_bytes<'a>(
data: &'a [u8], data: &'a [u8],
reader: &mut UnityFsReader<'a>, reader: &mut UnityFsReader<'a>,
@@ -497,4 +542,81 @@ mod tests {
assert!(result.is_err()); assert!(result.is_err());
assert!(result.unwrap_err().contains("signature")); assert!(result.unwrap_err().contains("signature"));
} }
fn block(uncompressed_size: u32) -> UnityFsBlockInfo {
UnityFsBlockInfo {
uncompressed_size,
compressed_size: uncompressed_size,
flags: 0,
compression: UnityFsCompression::None,
}
}
fn directory(offset: u64, size: u64) -> UnityFsDirectoryInfo {
UnityFsDirectoryInfo {
offset,
size,
flags: 0,
path: "CAB-test".to_string(),
}
}
#[test]
fn directory_within_data_region_is_accepted() {
// 两个 block 共 12 字节解压数据区;目录条目正好覆盖尾部,合法。
let result =
validate_directory_bounds(&[block(8), block(4)], &[directory(0, 8), directory(8, 4)]);
assert!(result.is_ok(), "{result:?}");
}
#[test]
fn directory_past_data_region_is_rejected() {
// 解压数据区仅 4 字节,目录声称 [0, 8) 越界,应被拒绝并带上下文。
let error = validate_directory_bounds(&[block(4)], &[directory(0, 8)]).unwrap_err();
assert!(error.contains("越界"), "{error}");
assert!(error.contains("解压数据区大小 4"), "{error}");
}
#[test]
fn directory_offset_size_overflow_is_rejected() {
let error = validate_directory_bounds(&[block(4)], &[directory(u64::MAX, 1)]).unwrap_err();
assert!(error.contains("溢出"), "{error}");
}
#[tokio::test]
async fn test_parse_rejects_out_of_bounds_directory() {
// 构造一个 directory.size 超过 block 解压大小的 bundle,端到端验证被拒。
let mut blocks_info = Vec::new();
blocks_info.extend_from_slice(&[0; 16]);
push_i32(&mut blocks_info, 1);
push_u32(&mut blocks_info, 4); // block uncompressed_size = 4
push_u32(&mut blocks_info, 4);
push_u16(&mut blocks_info, 0);
push_i32(&mut blocks_info, 1);
push_u64(&mut blocks_info, 0);
push_u64(&mut blocks_info, 99); // directory size 99 远超数据区
push_u32(&mut blocks_info, 0);
push_c_string(&mut blocks_info, "CAB-test");
let mut data = Vec::new();
push_c_string(&mut data, "UnityFS");
push_u32(&mut data, 8);
push_c_string(&mut data, "5.x.x");
push_c_string(&mut data, "2021.3.56f2");
push_u64(&mut data, 0);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, blocks_info.len() as u32);
push_u32(&mut data, 0);
align(&mut data, UNITYFS_ALIGNMENT);
data.extend_from_slice(&blocks_info);
data.extend_from_slice(b"data");
let adapter = Unity2021_3Adapter::new();
let bundle = RawAssetBundle {
data,
path: Some("out-of-bounds.bundle".to_string()),
};
let error = adapter.parse(&bundle).await.unwrap_err();
assert!(error.contains("越界"), "{error}");
}
} }
+6 -3
View File
@@ -12,7 +12,8 @@
"address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle", "address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle",
"dependencies": [ "dependencies": [
"shared_assets_all_123.bundle" "shared_assets_all_123.bundle"
] ],
"crc": 0
}, },
{ {
"path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle", "path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
@@ -20,7 +21,8 @@
"size": 162134, "size": 162134,
"resource_type": "AssetBundle", "resource_type": "AssetBundle",
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle", "address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
"dependencies": [] "dependencies": [],
"crc": 0
}, },
{ {
"path": "shared_assets_all_123.bundle", "path": "shared_assets_all_123.bundle",
@@ -28,7 +30,8 @@
"size": 153480, "size": 153480,
"resource_type": "AssetBundle", "resource_type": "AssetBundle",
"address": "shared_assets_all_123.bundle", "address": "shared_assets_all_123.bundle",
"dependencies": [] "dependencies": [],
"crc": 0
} }
], ],
"metadata": { "metadata": {
@@ -22,6 +22,7 @@ async fn parses_real_shape_addressables_catalog_against_golden() {
"resource_type": format!("{:?}", resource.resource_type), "resource_type": format!("{:?}", resource.resource_type),
"address": resource.address, "address": resource.address,
"dependencies": resource.dependencies, "dependencies": resource.dependencies,
"crc": resource.crc,
}) })
}).collect::<Vec<_>>(), }).collect::<Vec<_>>(),
"metadata": manifest.metadata.extra, "metadata": manifest.metadata.extra,
+10
View File
@@ -0,0 +1,10 @@
package main
import (
"fmt"
)
func runDoctor() error {
fmt.Println("bat doctor: ok")
return nil
}
+15
View File
@@ -0,0 +1,15 @@
package main
import "bat-api/internal/ffi"
func InspectManifest(rawJSON string) (string, error) {
return ffi.InspectManifest(rawJSON)
}
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
return ffi.BuildSyncPlan(currentJSON, previousJSON)
}
func batVersion() (string, error) {
return ffi.Version()
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
var err error
switch os.Args[1] {
case "doctor":
err = runDoctor()
case "manifest":
err = runManifest(os.Args[2:])
case "sync":
err = runSync(os.Args[2:])
case "help", "-h", "--help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func printUsage() {
fmt.Println("bat - BlueArchiveToolkit CLI")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" bat doctor")
fmt.Println(" bat manifest inspect <file>")
fmt.Println(" bat sync plan <current-json> [previous-json]")
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
)
func runManifest(args []string) error {
if len(args) < 2 || args[0] != "inspect" {
return fmt.Errorf("usage: bat manifest inspect <file>")
}
path := args[1]
data, err := os.ReadFile(path)
if err != nil {
return err
}
result, err := InspectManifest(string(data))
if err != nil {
return err
}
fmt.Println(result)
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
)
func runSync(args []string) error {
if len(args) < 2 || args[0] != "plan" {
return fmt.Errorf("usage: bat sync plan <current-json> [previous-json]")
}
current := args[1]
previous := ""
if len(args) > 2 {
previous = args[2]
}
result, err := BuildSyncPlan(current, previous)
if err != nil {
return err
}
fmt.Fprintln(os.Stdout, result)
return nil
}
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod translation;
pub use game_client::{ClientStatus, GameClient, GameRegion}; pub use game_client::{ClientStatus, GameClient, GameRegion};
pub use game_version::{GameVersion, UnityVersion}; pub use game_version::{GameVersion, UnityVersion};
pub use resource::{Resource, ResourceEntry, ResourceType}; pub use resource::{crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceType};
pub use translation::{ pub use translation::{
ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText, ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText,
TranslationStatus, TranslationStatus,
+145 -5
View File
@@ -34,6 +34,94 @@ pub struct ResourceEntry {
pub address: Option<String>, pub address: Option<String>,
/// 该资源依赖的其他资源标识 /// 该资源依赖的其他资源标识
pub dependencies: Vec<String>, pub dependencies: Vec<String>,
/// Addressables bundle 的 CRC32catalog 中的 `m_Crc`)。
///
/// `None` 表示 catalog 未提供该字段;Unity 用 `0` 表示「不做 CRC 校验」,
/// 因此 `Some(0)` 与 `None` 在校验时同样视为「无 CRC」。为向后兼容旧的
/// 持久化数据,反序列化时缺省为 `None`。
#[serde(default)]
pub crc: Option<u32>,
}
/// 已下载字节与 catalog 声明的可校验字段不一致。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IntegrityMismatch {
/// 实际字节数与声明的 `size` 不符。
Size {
/// catalog 声明的大小。
expected: u64,
/// 实际字节数。
actual: u64,
},
/// 实际 CRC32 与声明的 `crc` 不符。
Crc {
/// catalog 声明的 CRC32。
expected: u32,
/// 实际计算出的 CRC32。
actual: u32,
},
}
impl std::fmt::Display for IntegrityMismatch {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Size { expected, actual } => {
write!(formatter, "大小不符:声明 {expected},实际 {actual}")
}
Self::Crc { expected, actual } => write!(
formatter,
"CRC32 不符:声明 {expected:#010x},实际 {actual:#010x}"
),
}
}
}
impl std::error::Error for IntegrityMismatch {}
impl ResourceEntry {
/// catalog 声明的 CRC32`m_Crc`),`0` 归一化为「无 CRC」(返回 `None`)。
pub fn declared_crc(&self) -> Option<u32> {
self.crc.filter(|value| *value != 0)
}
/// 用 catalog 声明的可校验字段(`size`、`crc`)校验已下载/已解出的字节。
///
/// - `size`:声明值为 `0` 视为未提供,跳过;否则要求与 `data.len()` 相等。
/// - `crc`:无声明(`None`/`Some(0)`)时跳过;否则按 IEEE CRC-32 计算 `data`
/// 的 CRC 并比对。Unity AssetBundle 的 `m_Crc` 即标准 IEEE CRC-32(与
/// zlib `crc32` 一致,UnityPy/AssetStudio 等生态一致采用)。
///
/// 校验通过返回 `Ok(())`;不一致返回首个失败项(先 size 后 crc)。
pub fn verify_downloaded_bytes(&self, data: &[u8]) -> Result<(), IntegrityMismatch> {
if self.size != 0 && self.size != data.len() as u64 {
return Err(IntegrityMismatch::Size {
expected: self.size,
actual: data.len() as u64,
});
}
if let Some(expected) = self.declared_crc() {
let actual = crc32_ieee(data);
if actual != expected {
return Err(IntegrityMismatch::Crc { expected, actual });
}
}
Ok(())
}
}
/// 计算 IEEE CRC-32(多项式 `0xEDB88320`,反射,初值/终值 `0xFFFFFFFF`)。
///
/// 与 zlib `crc32` 及 Unity AssetBundle `m_Crc` 使用的算法一致。
pub fn crc32_ieee(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= u32::from(byte);
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
} }
/// 资源 /// 资源
@@ -51,18 +139,70 @@ pub struct Resource {
mod tests { mod tests {
use super::*; use super::*;
#[test] fn entry_with(size: u64, crc: Option<u32>) -> ResourceEntry {
fn test_resource_entry() { ResourceEntry {
let entry = ResourceEntry {
path: "test.bundle".to_string(), path: "test.bundle".to_string(),
hash: "abc123".to_string(), hash: "abc123".to_string(),
size: 1024, size,
resource_type: ResourceType::AssetBundle, resource_type: ResourceType::AssetBundle,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
}; crc,
}
}
#[test]
fn test_resource_entry() {
let entry = entry_with(1024, None);
assert_eq!(entry.path, "test.bundle"); assert_eq!(entry.path, "test.bundle");
assert_eq!(entry.size, 1024); assert_eq!(entry.size, 1024);
assert_eq!(entry.crc, None);
}
#[test]
fn crc32_matches_known_vector() {
// 标准 IEEE CRC-32 测试向量:crc32("123456789") == 0xCBF43926。
assert_eq!(crc32_ieee(b"123456789"), 0xCBF4_3926);
assert_eq!(crc32_ieee(b""), 0);
}
#[test]
fn declared_crc_treats_zero_as_absent() {
assert_eq!(entry_with(0, None).declared_crc(), None);
assert_eq!(entry_with(0, Some(0)).declared_crc(), None);
assert_eq!(entry_with(0, Some(42)).declared_crc(), Some(42));
}
#[test]
fn verify_downloaded_bytes_checks_size_and_crc() {
let data = b"123456789";
let crc = crc32_ieee(data);
// size + crc 均匹配。
assert!(entry_with(data.len() as u64, Some(crc))
.verify_downloaded_bytes(data)
.is_ok());
// size=0 与 crc=0/None 视为未声明,跳过校验。
assert!(entry_with(0, None).verify_downloaded_bytes(data).is_ok());
assert!(entry_with(0, Some(0)).verify_downloaded_bytes(data).is_ok());
// size 不符。
assert_eq!(
entry_with(3, None).verify_downloaded_bytes(data),
Err(IntegrityMismatch::Size {
expected: 3,
actual: 9
})
);
// size 通过、crc 不符。
assert_eq!(
entry_with(data.len() as u64, Some(0xDEAD_BEEF)).verify_downloaded_bytes(data),
Err(IntegrityMismatch::Crc {
expected: 0xDEAD_BEEF,
actual: crc
})
);
} }
} }
+1 -1
View File
@@ -4,7 +4,7 @@
BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建一个可持续维护十年以上的工业级开源项目。 BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建一个可持续维护十年以上的工业级开源项目。
当前文档描述目标架构和已经落地的关键边界。它不是部署手册;当前可部署能力只有 Rust 官方资源同步任务。API Server、Web、Provider 编排和完整 Go CLI 仍未实现,实际实现状态以根目录 `CURRENT_STATUS.md``PROJECT_PLAN.md` 为准。 当前文档描述目标架构和已经落地的关键边界。它不是部署手册;当前可部署能力只有 Rust 官方资源同步任务。API Server、Web、Provider 编排和 Go 产品入口仍未完成,实际实现状态以根目录 `CURRENT_STATUS.md``PROJECT_PLAN.md` 为准。
当前已经可用的官方资源入口包括: 当前已经可用的官方资源入口包括:
@@ -65,7 +65,7 @@ CAS V1 不以“能通过简单 put/get 测试”为完成标准。必须满足
4. 并发写入相同内容测试通过。 4. 并发写入相同内容测试通过。
5. 损坏对象读取返回明确错误。 5. 损坏对象读取返回明确错误。
6. 权限或路径错误有清晰错误类型。 6. 权限或路径错误有清晰错误类型。
7. `cargo test --workspace``cargo clippy --workspace -- -D warnings` 通过。 7. `cargo test --workspace``cargo clippy --workspace --all-targets -- -D warnings` 通过。
--- ---
+16 -13
View File
@@ -68,7 +68,7 @@
### 3.2 枚举完整资源清单 ### 3.2 枚举完整资源清单
资源清单不是“猜几个文件”,而是从官方 catalog 字节里提取完整文件名列表。 资源清单不是“猜几个文件”,而是从官方 catalog 字节里提取完整文件名或相对路径列表。
当前做法: 当前做法:
@@ -77,7 +77,7 @@
3. 读取 `TableCatalog.bytes` 3. 读取 `TableCatalog.bytes`
4. 提取所有表资源名,例如 `ExcelDB.db` 4. 提取所有表资源名,例如 `ExcelDB.db`
5. 读取 `MediaCatalog.bytes` 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 集: 然后对 verified platforms 生成完整 URL 集:
@@ -148,14 +148,14 @@
### 3.5 导入到 CAS 和资源仓储 ### 3.5 导入到 CAS 和资源仓储
资源下载后,导入层会: 当前实现已经提供资源导入能力,但官方同步下载完成后尚未自动作为用户级流程触发导入。手动或上层流程调用导入层时,它会:
1. 把 bundle 原始字节写入 CAS。 1. 把 bundle 原始字节写入 CAS。
2. 解析 UnityFS 基础摘要。 2. 解析 UnityFS 基础摘要。
3. 把资源条目写入 `ResourceRepository` 3. 把资源条目写入 `ResourceRepository`
4. 记录资源路径、hash、大小和解析摘要。 4. 记录资源路径、hash、大小和解析摘要。
这层的意义是把“下载到磁盘的文件”变成“可查询、可复用、可去重”的资源对象。 这层的意义是把“下载到磁盘的文件”变成“可查询、可复用、可去重”的资源对象。把官方同步结果自动接入 CAS + `ResourceRepository` 仍属于 G-011 剩余工作。
对应实现主要在: 对应实现主要在:
@@ -189,7 +189,7 @@
集成边界: 集成边界:
1. 当前生产和 Go CLI 默认集成路径是运行 `bat --json` 并消费结构化 report。 1. 当前生产集成路径是 Rust `bat --watch` / `bat --daemon` 持久运行;Go `bat-api` 应优先通过 `internal/backendrpc` 调用 daemon RPCone-shot/fallback 场景才运行 `bat --json` 并消费结构化 report。
2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。 2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。
3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。 3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。
@@ -208,7 +208,7 @@
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot。 11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot。
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。 12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status``stop``restart``reload``logs``refresh``verify``repair``doctor``clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane`bat.pid``bat-status.json``bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json``status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status``stop``logs``reload`默认形态的 `refresh` 优先走 RPC`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00``16:00``18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry``--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output``--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。 该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status``stop``restart``reload``logs``refresh``verify``repair``doctor``clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane`bat.pid``bat-status.json``bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`bat-status.json``status` 子命令包含最后成功时间、下次检查时间、最后错误摘要、当前阶段和当前下载 URL 进度。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status``stop``logs``reload`默认形态的 `refresh` 和默认形态的 `repair` 优先走 RPC`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,默认 `repair` 会通过 `resource.repair` 入队本地 manifest 审计+修复任务,`restart` 才负责重启进程或替换启动参数;显式 `--proxy` / `--no-proxy` 会作为启动参数保存并在后台重启时复用。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新,默认形态 `repair` 会通过 RPC 入队任务。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00``16:00``18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry``--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output``--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
对应实现主要在: 对应实现主要在:
@@ -246,7 +246,7 @@ Linux 生产路径:
- pull plan 会同时包含 discovery URLs 和 content URLs - pull plan 会同时包含 discovery URLs 和 content URLs
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL - 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策 - `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 live control/backend`daemon.status/logs/stop/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list``catalog.*``task.*`);`restart``clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 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 - curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因 - `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因
- 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类 - 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类
@@ -280,6 +280,7 @@ Linux 生产路径:
daemon`bat --daemon`)在 `<state-dir>/bat.sock` 上提供 Unix socket daemon`bat --daemon`)在 `<state-dir>/bat.sock` 上提供 Unix socket
JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边界**。 JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边界**。
稳定方法、schema 和错误语义以 `docs/reference/rpc-backend-api.md` 为准。
### 7.1 协议契约 ### 7.1 协议契约
@@ -289,22 +290,24 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
- `error` 为统一 `ApiError``code``BAT-ERR-<6 位>`)、`kind` - `error` 为统一 `ApiError``code``BAT-ERR-<6 位>`)、`kind`
`domain``location``message``retryable`。码表以 `domain``location``message``retryable`。码表以
`core/src/error_code.rs` 为准。 `core/src/error_code.rs` 为准。
- 长任务(`resource.sync` / `resource.verify` / `catalog.refresh` - 长任务(`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh`
入队即返回 `task_id`,经 `task.status` / `task.list` / `task.logs` 入队即返回 `task_id`,经 `task.status` / `task.list` / `task.logs`
轮询,`task.cancel` 协作式取消。任务执行器是单 worker FIFO,与 轮询,`task.cancel` 协作式取消。任务执行器是单 worker FIFO,与
watch 循环经进程内锁互斥。任务历史持久化于 `<state-dir>/bat-tasks.json` watch 循环经进程内锁互斥。任务历史持久化于 `<state-dir>/bat-tasks.json`
(版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务 (版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务
仍可经 `task.*` 查询,中断任务标记 `task_interrupted`700005)。 仍可经 `task.*` 查询,中断任务标记 `task_interrupted`700005)。
- 方法命名空间与实现状态、请求/响应示例见 `USERGUIDE.md` §6 - 方法命名空间与实现状态、请求/响应示例见 `USERGUIDE.md` §6
`daemon.*` / `resource.*` / `catalog.*` / `task.*` 已实现; `daemon.status/logs/stop/reload/refresh/doctor``resource.state/sync/verify/repair/manifest/list`
`patch.*` / `unityfs.*` 待引擎;`task.create` / `resource.repair` `catalog.*` `task.status/list/cancel/logs` 已实现;`patch.*` / `unityfs.*`
按设计暂缓。 待引擎;`task.create` 按设计暂不开放通用任务入口;`daemon.restart` /
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
### 7.2 Go 层职责边界 ### 7.2 Go 层职责边界
- Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发, - Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发,
以及作为 RPC client 调用本机 daemon(连接 `bat.sock`,每行一个 以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon
JSON-RPC 请求/响应)。 (连接 `bat.sock`,每行一个 JSON-RPC 请求/响应)。当前 Go 产品入口
尚未完成,`cmd/bat` 仍是试验骨架。
- Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、 - Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、
版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。 版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源 - Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源
+10 -8
View File
@@ -1,6 +1,6 @@
# 稳定工程基线指南 # 稳定工程基线指南
- **更新时间**2026-07-06 - **更新时间**2026-07-20
- **目标**:让工作区处于可继续开发核心功能的可信状态。 - **目标**:让工作区处于可继续开发核心功能的可信状态。
--- ---
@@ -13,7 +13,7 @@
2. 根目录只保留入口文档和工程配置。 2. 根目录只保留入口文档和工程配置。
3. 旧报告归档,且不再和当前状态混淆。 3. 旧报告归档,且不再和当前状态混淆。
4. Rust workspace 成员显式列出。 4. Rust workspace 成员显式列出。
5. Go 尚未实现时,Makefile 不误报失败 5. Go 产品入口尚未完成时,Makefile 不把骨架包误报为完整产品
6. 当前缺口有集中清单和关闭顺序。 6. 当前缺口有集中清单和关闭顺序。
7. 架构边界有 ADR 记录。 7. 架构边界有 ADR 记录。
8. 基础验证命令通过。 8. 基础验证命令通过。
@@ -36,14 +36,16 @@ make lint
```bash ```bash
cargo test --workspace cargo test --workspace
cargo check --workspace cargo check --workspace
cargo clippy --workspace -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
go test ./...
go vet ./...
``` ```
说明: 说明:
1. 当前Go 产品入口,因此 Go build/test/check/fmt/lint 会在空 Go 阶段明确跳过 1. 当前`internal/backendrpc` fake socket 单测、`cmd/bat` 试验骨架和 `internal/ffi` 兼容包装;这些不代表 CLI/API 产品入口已完成
2. 如果后续新增 Go package,必须让 `go test ./...``go vet ./...` 纳入硬性验证。 2. 后续新增 Go 产品 package,必须让 `go test ./...``go vet ./...` 纳入硬性验证。
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入 CI 3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入本地门禁
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture` 4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`
--- ---
@@ -88,10 +90,10 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进: CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进:
1. Go CLI 的 `doctor` 和基础命令框架 1. 收敛 Go 产品入口:当前 `cmd/bat` 仅是试验骨架,不能视为完成
2.`docs/guides/official-full-pull-smoke.md` 执行真实官方网络全量下载 smoke,并保留隔离目录报告。 2.`docs/guides/official-full-pull-smoke.md` 执行真实官方网络全量下载 smoke,并保留隔离目录报告。
3. 官方同步结果接入 CAS + ResourceRepository。 3. 官方同步结果接入 CAS + ResourceRepository。
4. AssetBundle UnityFS 解析。 4. AssetBundle UnityFS 引擎级解析。
优先阅读: 优先阅读:
+2 -2
View File
@@ -250,7 +250,7 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat reload --state-dir /var/lib/bluearc
sudo -u bat /opt/bluearchive-toolkit/bin/bat stop --state-dir /var/lib/bluearchive-toolkit/daemon-state sudo -u bat /opt/bluearchive-toolkit/bin/bat stop --state-dir /var/lib/bluearchive-toolkit/daemon-state
``` ```
`--daemon` 会在 `--state-dir` 下创建 `bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl` 和短生命周期的 `bat-control.lock``bat.sock` 是 Unix socket JSON-RPC 控制通道;`status``stop``logs``reload`默认形态的 `refresh` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart` `--daemon` 会在 `--state-dir` 下创建 `bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl` 和短生命周期的 `bat-control.lock``bat.sock` 是 Unix socket JSON-RPC 控制通道;`status``stop``logs``reload`默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`
不要同时运行 systemd `--watch` 和 standalone `--daemon` 指向同一个 `--output`。二者都会被资源锁和 live daemon 互斥保护,但生产运维上应保持单一 owner。 不要同时运行 systemd `--watch` 和 standalone `--daemon` 指向同一个 `--output`。二者都会被资源锁和 live daemon 互斥保护,但生产运维上应保持单一 owner。
@@ -287,7 +287,7 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat doctor --output /var/lib/bluearchiv
sudo -u bat /opt/bluearchive-toolkit/bin/bat clean-stable --output /var/lib/bluearchive-toolkit/official --state-dir /run/bluearchive-toolkit sudo -u bat /opt/bluearchive-toolkit/bin/bat clean-stable --output /var/lib/bluearchive-toolkit/official --state-dir /run/bluearchive-toolkit
``` ```
如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;带 `--output`、server-info、connection-group、app-version、platforms、snapshot、curlunzip 等显式参数时,`refresh` 会作为一次性前台同步运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`repair` 会走官方同步链路重新下载必要文件,但同样不能和 live daemon 并行写同一资源目录;`clean-stable` 只清理 `.part``.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。 如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;默认形态的 `repair` 会通过 `resource.repair` RPC 入队本地 manifest 审计+修复任务并返回 `task_id``--output`、server-info、connection-group、app-version、platforms、snapshot、curlunzip 或其它显式同步参数时,`refresh` / `repair` 会作为一次性前台命令运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`clean-stable` 只清理 `.part``.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。
### 升级 ### 升级
+19 -3
View File
@@ -23,6 +23,20 @@ rustc --version # 验证安装
cargo --version cargo --version
``` ```
#### 自托管 Gitea runner
`.gitea/workflows/bat.yml` 使用 `runs-on: linux`,并且不依赖 `actions/checkout``dtolnay/rust-toolchain` 等外部 GitHub Action。runner 需要在执行环境中预装以下命令:
```bash
git --version
rustc --version
cargo --version
rustfmt --version
cargo clippy --version
```
该 workflow 会用 `GITHUB_SERVER_URL``GITHUB_REPOSITORY``GITHUB_REF``GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
#### Docker #### Docker
```bash ```bash
# 安装 Docker 和 Docker Compose # 安装 Docker 和 Docker Compose
@@ -127,9 +141,11 @@ git push origin feature/your-feature-name
cargo fmt --check cargo fmt --check
cargo test --workspace cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
go test ./...
go vet ./...
``` ```
Go CLI 尚未实现时,`go test ./...` 可能没有产品级 package 可运行;Makefile 会在空 Go 阶段清晰跳过 Go 产品入口尚未完成,但仓库已有 `internal/backendrpc` Rust daemon RPC client、`cmd/bat` 试验骨架与 `internal/ffi` 兼容包装。提交前应运行 `go test ./...``go vet ./...``internal/backendrpc` 使用 fake transport 覆盖 JSON-RPC envelope、错误和 typed helper,不能把这些测试误认为 Go 产品级 CLI/API 已完成
### 常用聚焦命令 ### 常用聚焦命令
@@ -144,7 +160,7 @@ cargo clippy -p bat-core -p bat-adapters -p bat-infrastructure --all-targets --
官方资源同步、下载、daemon、status、verify 或 repair 相关改动必须至少覆盖 `bat-infrastructure``bat` 二进制测试。 官方资源同步、下载、daemon、status、verify 或 repair 相关改动必须至少覆盖 `bat-infrastructure``bat` 二进制测试。
`bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 导出、JSON schema、错误返回或 `internal/ffi` CGO 包装时必须运行 `cargo test -p bat-ffi -- --nocapture`Go CLI 和生产同步默认应通过 `bat --json` 进程边界集成 `bat-ffi` 只是可选无状态 C ABI 兼容层。修改 FFI 导出、JSON schema、错误返回或 `internal/ffi` CGO 包装时必须运行 `cargo test -p bat-ffi -- --nocapture`未来 Go 产品入口和生产同步默认应通过 `internal/backendrpc` 调用 daemon RPC,或在 one-shot/fallback 场景使用 Rust `bat --json` 进程边界。
### 集成测试 ### 集成测试
@@ -215,7 +231,7 @@ cargo fetch
### 3. FFI 兼容层问题 ### 3. FFI 兼容层问题
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。默认 Go CLI 集成优先运行 Rust `bat --json` `bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。未来 Go 产品入口集成优先运行 Rust `bat --json` 或调用 daemon RPC
重新构建兼容库: 重新构建兼容库:
```bash ```bash
+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` - 二次运行 stdout JSON 包含 `update_status=up_to_date`
- repair stdout JSON 包含 `command=repair``status=completed` - repair stdout JSON 包含 `command=repair``status=completed`
- repair stderr log 包含总体下载进度、单文件进度和校验结果。 - repair stderr log 包含下载已完成计数、单文件进度和校验结果。
- repair 后 verify stdout JSON 包含 `healthy=true` - 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` - `03-second-up-to-date.stdout.json``update_status``up_to_date`
- `04-repair-after-damage.stdout.json` 中 repair 完成,且有重新下载或修复行为。 - `04-repair-after-damage.stdout.json` 中 repair 完成,且有重新下载或修复行为。
- `05-verify-after-repair.stdout.json``healthy``true` - `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` 中包含下载已完成计数、单文件进度和校验结果日志。
+4 -4
View File
@@ -45,7 +45,7 @@ target/release/bat \
--watch --watch
``` ```
默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。资源输出目录是发布根目录:非 dry-run 同步先写 `<output>/.staging/<id>`,校验完成后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应读取 `current`。后台状态目录会保存 `bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl` 和短生命周期的 `bat-control.lock`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 串行化 `status/stop/restart/reload/logs/refresh` 等控制命令。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive` 默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。资源输出目录是发布根目录:非 dry-run 同步先写 `<output>/.staging/<id>`,校验完成后移动到 `<output>/versions/<id>`,再原子切换 `<output>/current` symlink;生产读取方应读取 `current`。后台状态目录会保存 `bat.sock``bat.pid``bat-status.json``bat-daemon.log``bat-events.jsonl` 和短生命周期的 `bat-control.lock`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志,`bat-status.json` 保存最后成功时间、下次检查时间、最后错误摘要和当前下载进度,`bat-control.lock` 串行化 `status/stop/restart/reload/logs/refresh/repair` 等控制命令。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`
同步流程会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 同步流程会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
@@ -265,7 +265,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
cargo run -p bat-infrastructure --bin bat -- stop cargo run -p bat-infrastructure --bin bat -- stop
``` ```
`status``stop``logs``reload`默认形态的 `refresh` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status``stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy``--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json` `status``stop``logs``reload`默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status``stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy``--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`
如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>` 如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`
@@ -308,7 +308,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--error-retry 60s --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 还会阻止前台写命令直接修改它正在管理的同一目录。 生产可以直接运行 `--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 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. 例外输入 ## 7. 例外输入
+169
View File
@@ -0,0 +1,169 @@
# Rust Resource Backend RPC API
本文档冻结本机 Rust Resource Backend API 的稳定调用边界。Go 项目
`bat-api`、Go 服务层、运维脚本和 `bat` CLI 都应以这里的 JSON-RPC
contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主路径。
## 传输
- 传输:Unix domain socket。
- 默认 socket`/tmp/bat-pid/bat.sock`
- 协议:JSON-RPC 2.0,每行一个 request,每行一个 response。
- 编码:UTF-8 JSON。
- 访问控制:依赖本机文件权限和状态目录权限;不要把 socket 暴露到公网。
请求:
```json
{"jsonrpc":"2.0","id":1,"method":"resource.repair","params":null}
```
成功响应的 JSON-RPC 顶层 `result` 一律是应用层 envelope
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"ok": true,
"status": "accepted",
"data": {"task_id": "task-1234-1", "kind": "resource.repair"},
"request_id": "req-1234-1"
}
}
```
应用层失败也放在 `result` 的 envelope 中:
```json
{
"ok": false,
"status": "error",
"error": {
"code": "BAT-ERR-700003",
"kind": "not_implemented",
"domain": "rpc",
"location": "rpc.dispatch",
"message": "方法尚未实现:daemon.clean-stable",
"retryable": false
},
"request_id": "req-1234-2"
}
```
只有 JSON 解析失败等传输层错误使用 JSON-RPC 顶层 `error`
## Envelope
| 字段 | 类型 | 说明 |
|---|---|---|
| `ok` | bool | 应用层是否成功。 |
| `status` | string | `ok``accepted``error`。 |
| `data` | object/null | 成功结果。失败时省略。 |
| `error` | object/null | `ApiError`。成功时省略。 |
| `request_id` | string | daemon 进程内请求 ID,用于日志关联。 |
`ApiError` 结构以 `core/src/error_code.rs` 码表为准:
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | string | `BAT-ERR-<6位>`。 |
| `kind` | string | 错误类别。 |
| `domain` | string | 错误域。 |
| `location` | string | Rust 侧出错位置。 |
| `message` | string | 可诊断错误信息。 |
| `retryable` | bool | 调用方是否可以按策略重试。 |
## 方法
### daemon
| 方法 | 状态 | params | data |
|---|---|---|---|
| `daemon.status` | 已实现 | `null` | 后台状态报告。 |
| `daemon.logs` | 已实现 | `{ "tail": 200 }` | 日志尾部报告。 |
| `daemon.stop` | 已实现 | `null` | accepted ack。 |
| `daemon.reload` | 已实现 | `null` | accepted ack。 |
| `daemon.refresh` | 已实现 | `{ "force": false }` | accepted ack。 |
| `daemon.doctor` | 已实现 | `null` | 只读诊断报告。 |
| `daemon.restart` | 保留 | `null` | live RPC 不执行;由 CLI 生命周期入口处理。 |
| `daemon.clean-stable` | 保留 | `null` | live RPC 不执行;由 CLI 离线清理入口处理。 |
`bat.status``bat.stop``bat.reload``bat.refresh``bat.logs`
`bat.doctor``bat.clean-stable` 是兼容别名;新代码应使用 `daemon.*`
### resource
| 方法 | 状态 | params | data |
|---|---|---|---|
| `resource.state` | 已实现 | `null` | 资源发布根、版本状态、上次同步结果。 |
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 |
| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.manifest` 的兼容别名。 |
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`
`limit` 范围是 `1..=1000`,非法参数返回 `BAT-ERR-700002`
### catalog
| 方法 | 状态 | params | data |
|---|---|---|---|
| `catalog.status` | 已实现 | `null` | 当前已发布 catalog 概览。 |
| `catalog.versions` | 已实现 | `null` | current / in_progress / previous / failed。 |
| `catalog.diff` | 已实现 | `null` | 当前 snapshot 相对上一可用版本的差异。 |
| `catalog.refresh` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "catalog.refresh" }`。 |
只读查询在没有可用版本时返回 `ok=true``data.available=false`
### task
| 方法 | 状态 | params | data |
|---|---|---|---|
| `task.status` | 已实现 | `{ "task_id": "..." }` | 单个任务记录。 |
| `task.list` | 已实现 | `null` | `{ "tasks": [...] }`。 |
| `task.cancel` | 已实现 | `{ "task_id": "..." }` | cancel ack。 |
| `task.logs` | 已实现 | `{ "task_id": "..." }` | `{ "task_id": "...", "lines": [...] }`。 |
| `task.create` | 保留 | object | 不开放通用任务入口;由语义方法创建任务。 |
任务记录:
```json
{
"id": "task-1234-1",
"kind": "resource.repair",
"status": "queued",
"stage": null,
"message": null,
"created_at": 1780000000,
"updated_at": 1780000000,
"started_at": null,
"finished_at": null,
"error": null,
"result": null
}
```
`status` 取值:`queued``running``succeeded``failed``cancelled`
daemon 重启后仍处于 `queued``running` 的历史任务会被标记为
`failed`,错误码为 `BAT-ERR-700005`
### patch / unityfs
`patch.*``unityfs.*` 是已规划命名空间,目前返回
`BAT-ERR-700003`。它们依赖后续 `bat-patch``bat-assetbundle`
引擎,不作为 issue #1 的关闭阻塞项。
## Go 调用边界
`bat-api` 应直接调用本 RPC contract,不通过 `exec` 调用 `bat` binary。
`bat` binary 是人类 CLI 和进程生命周期工具;默认 `refresh` / `repair`
在 daemon 可用时也会作为 RPC client 调用同一个 socket。
禁止事项:
- Go 服务层不直接读写 `bat-status.json``bat-tasks.json` 等 daemon 内部状态文件。
- Go 服务层不扩展 `bat-ffi` 为主控制面。
- Go 服务层不通过 stdout 解析 `bat status --json` 作为常规调用路径。
+52 -42
View File
@@ -1,6 +1,6 @@
# 当前实现缺口清单 # 当前实现缺口清单
- **更新时间**2026-07-17 - **更新时间**2026-07-20
- **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。 - **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。
- **权威计划**`../../PROJECT_PLAN.md` - **权威计划**`../../PROJECT_PLAN.md`
@@ -107,30 +107,32 @@
- 并发写入相同内容只产生一个对象。 - 并发写入相同内容只产生一个对象。
- 读取时 Hash 不匹配会返回明确错误。 - 读取时 Hash 不匹配会返回明确错误。
### G-005AssetBundle 解析器仍是占位 ### G-005AssetBundle 引擎解析器仍未完成
现象: 现象:
- `crates/bat-assetbundle/src/parser.rs` 只有 `Parser::name` - `crates/bat-assetbundle/src/parser.rs` 只有 `Parser::name`
- `types.rs` 只有 `AssetType::TextAsset` - `types.rs` 只有 `AssetType::TextAsset`
- `adapters/src/unity/unity_2021_3.rs` 已能解析 UnityFS header、block info、directory,并校验 directory `offset+size` 不越界;这属于 adapter 层基础摘要能力,不等于 `bat-assetbundle` 引擎已完成。
- 仍没有对象表、TypeTree、TextAsset、MonoBehaviour、ScriptableObject 或可扩展提取入口。
影响: 影响:
- 无法解析真实 UnityFS。 - 可以对部分 UnityFS 样本做基础结构校验,但无法完成真实资源对象解析和文本提取
- 无法提取 TextAsset 或配置文本。 - 无法提取 TextAsset 或配置文本。
验收: 验收:
- 能解析结构化测试样本。 - `crates/bat-assetbundle` 能解析结构化测试样本和隔离真实样本
- 支持 UnityFS header、blocks、directory、metadata。 - 支持 UnityFS header、blocks、directory、metadata、object table
- 错误包含偏移和字段上下文。 - 错误包含偏移和字段上下文。
### G-006Patch 引擎仍是占位 ### G-006Patch 引擎仍是占位
现象: 现象:
- `binary::apply_patch` 返回 `Vec` - `binary::apply_patch` 明确返回 `PatchError::ApplyFailed`,提示 Binary patch 尚未实现
- `json::apply_json_patch` 返回空字符串 - `json::apply_json_patch` 明确返回 `PatchError::ApplyFailed`,提示 JSON patch 尚未实现
影响: 影响:
@@ -150,7 +152,8 @@
现象: 现象:
- `AddressablesCatalogDriver` 已能解析当前真实形态 JSON catalog fixture/golden。 - `AddressablesCatalogDriver` 已能解析当前真实形态 JSON catalog fixture/golden。
- 已输出 path、hash、size、resource_type、address、dependencies、metadata。 - 已输出 path、hash、size、resource_type、address、dependencies、metadata,并已提取 `m_Crc``crc` 字段
- `bat-core` 已提供 `crc32_ieee``ResourceEntry::verify_downloaded_bytes`SQLite `ResourceRepository` 已有 `crc` 列迁移。
- 仍需覆盖更多官方 catalog 结构变体、二进制/压缩字段组合和更明确的失败诊断。 - 仍需覆盖更多官方 catalog 结构变体、二进制/压缩字段组合和更明确的失败诊断。
影响: 影响:
@@ -160,53 +163,56 @@
验收: 验收:
- 能解析项目目标版本的真实 Catalog 样本集合。 - 能解析项目目标版本的真实 Catalog 样本集合。
- 解析结果包含资源 key、provider、dependency、hash、size、path。 - 解析结果包含资源 key、provider、dependency、hash、size、path、CRC
- 对不支持的 catalog 结构返回明确错误,而不是静默丢字段。 - 对不支持的 catalog 结构返回明确错误,而不是静默丢字段。
--- ---
## 3. 应用层缺口 ## 3. 应用层缺口
### G-008Go CLI 尚未实现 ### G-008Go CLI 产品入口尚未完成
状态:**未完成(此前“并入 G-009”只是短期跟踪调整,不代表能力完成)**
现象: 现象:
- `cmd/bat` 已有 `main.go`,但只是通过 cgo 调用 `bat-ffi` 的最小骨架(doctor/manifest inspect/sync plan),不是产品级用户入口;且默认 Go/Rust 集成边界应是 `bat --json` 进程边界,而非 FFI - 当前可用的用户同步/运维入口是 Rust `bat` binary
- `internal/ffi/ffi.go` 已存在,但只是可选 CGO 兼容包装,不是用户可运行的产品 CLI,也不是默认集成边界 - `internal/backendrpc` 已提供 Go 到 Rust daemon 的 typed JSON-RPC client`cmd/bat` 已存在,但仅有 `doctor``manifest inspect``sync plan` 试验能力;`doctor` 只输出固定 `ok``manifest`/`sync` 依赖可选 CGO/FFI helper
- `go test ./...` 当前没有产品级 Go package 覆盖 - Go 侧尚未实现通过 Rust `bat --json` 或 daemon RPC 包装官方同步命令、稳定 human/json 输出、真实 doctor 检查和端到端测试
- 如果项目决策改为“用户 CLI 永久由 Rust `bat` 承担,Go 只做 `bat-api`/服务层”,必须同步更新 `AGENTS.md``PROJECT_PLAN.md` 和 issue 跟踪;在完成该决策前,不能把 Go CLI 写成已完成。
当前进展:
- 对接边界已就绪:Rust daemon 的 `bat.sock` Unix socket JSON-RPC Backend APIissue #1 主体已完成:统一 envelope、`BAT-ERR` 错误码模型、`daemon.*`/`resource.*`/`catalog.*`/`task.*` 方法集)与 `bat --json` 进程边界均可用。Go CLI 缺的是产品级入口本身,实现时应重写 `cmd/bat` 现有 cgo 骨架为 RPC/进程边界对接。
影响:
- 用户没有统一入口。
- 同步、提取、补丁流程无法从命令行串联。
验收: 验收:
- `bat doctor` 可运行 - `cmd/bat doctor` 做真实环境诊断,而不是固定字符串
- `bat --help` 命令结构稳定 - `cmd/bat sync` 能通过 `internal/backendrpc` daemon RPC 或 Rust `bat --json` one-shot/fallback 触发/查询官方同步,不走 FFI 控制下载器或 daemon
- 命令支持默认人类可读输出和 `--json` 机器输出 - human/json 输出、退出码和错误码与 Rust `bat` 契约一致
- Go CLI 默认通过 Rust `bat --json` 进程边界获取同步 report;除非明确兼容需求,不依赖 FFI - `go test ./...``go vet ./...` 覆盖命令解析、错误输出和至少一个 mocked Rust 边界
### G-009API Server 和 OpenAPI 尚未实现 ### G-009API Server`bat-api`,仿官方 API尚未实现
现象: 现象:
- `api/` 只有目录结构。 - `api/` 只有目录结构,无 handler、service、路由
- 无 handler、service、OpenAPI schema - Go 侧尚无对接 daemon RPC / `current/` 发布布局的服务端入口
目标(对应 issue #19):
- 新建 `cmd/bat-api`**完全仿照 BlueArchive 官方 API** 的 Go HTTP 服务,把 Rust `bat` 后端发布的 `current/` release 按官方接口形态对外提供,使真实客户端/工具可将其当作官方服务端。
- 仿真面:资源 CDN 面(TableCatalog/MediaCatalog/BundlePackingInfo、bundle、seed `.hash`)、server-info 面、launcher API 面。
- 鉴权/签名**完全仿照**官方实现,服务端做验签(对齐 `adapters/src/official/launcher.rs` 的签名逻辑)。
- 版本/状态经 daemon RPC`catalog.*` / `resource.manifest`)发现,资源字节从 `current/` 读取;不读写 daemon 状态文件内部。
影响: 影响:
- Web 和第三方集成无服务端入口。 - 客户端、工具和第三方集成无服务端入口。
验收: 验收:
- `/api/v1/health` 可用 - Go 单测覆盖路由、签名验签、错误响应形态
- 统一错误结构落地 - 真机 e2edaemon 发布 fixture release → 启动 `bat-api` → 按官方 URL 与鉴权头请求 server-info / catalog / bundle / launcher 链,断言字节与形态正确、验签生效(缺签/错签被拒)
- OpenAPI 与实际路由同步 - 统一错误结构与官方响应 envelope 对齐;Makefile 增加 Go 构建/测试目标
排期:P2,排在 issue #17 验收收口以及 issue #2 / #3 的解析能力继续推进之后启动;可与 G-008 的 Go 产品入口边界收敛并行。
### G-010Web 管理后台尚未实现 ### G-010Web 管理后台尚未实现
@@ -396,7 +402,9 @@
处理结果: 处理结果:
- 明确决策:本项目不加入 GitHub Workflows,也不引入其他托管 CI。 - 明确决策:本项目不加入 GitHub Workflows,也不引入其他托管 CI。
- 质量门禁由本地默认验证命令承担:提交前执行 `cargo fmt` / `cargo clippy --workspace --all-targets -- -D warnings` / `cargo test --workspace`(见 `docs/guides/development.md``docs/guides/baseline.md` - 目前补充了自托管 Gitea linux-runner workflow`.gitea/workflows/bat.yml`),仅用于 Rust workspace 的构建和测试,不改变“不引入托管 CI”的决策
- workflow 不使用外部 GitHub Action;它通过 runner 环境变量手动 `git fetch` 当前提交,并要求 runner 预装 `git`、Rust stable、rustfmt 和 clippy,避免准备阶段因第三方 action 仓库代理或网络限制失败。
- 质量门禁由本地默认验证命令和自托管 workflow 共同承担:提交前执行 `cargo fmt` / `cargo clippy --workspace --all-targets -- -D warnings` / `cargo test --workspace`(见 `docs/guides/development.md``docs/guides/baseline.md`)。
- 发布类检查(build、smoke)由 `Makefile``scripts/` 下的可重复脚本承担(如 `make official-smoke`)。 - 发布类检查(build、smoke)由 `Makefile``scripts/` 下的可重复脚本承担(如 `make official-smoke`)。
限制: 限制:
@@ -418,7 +426,7 @@
- 新增 `make official-smoke` 统一入口。 - 新增 `make official-smoke` 统一入口。
- 新增 `docs/guides/official-full-pull-smoke.md`,记录目标、命令、输出结构、环境变量、安全边界和成功判定。 - 新增 `docs/guides/official-full-pull-smoke.md`,记录目标、命令、输出结构、环境变量、安全边界和成功判定。
- smoke 流程覆盖 dry-run plan、首次全量拉取、二次 `up_to_date`、人工破坏 active release 文件后的 `repair`、repair 后 `verify` - 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。 - 运行报告 `SMOKE_REPORT.md` 记录实际输出目录、active release、文件数量、release 大小和被破坏文件;大型官方资源文件保留在隔离输出目录,不纳入 Git。
验收: 验收:
@@ -462,11 +470,13 @@
## 6. 当前关闭顺序建议 ## 6. 当前关闭顺序建议
1. G-008 1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余。
2. G-011 2. issue #1Rust daemon/backend API 边界收口;`resource.repair``resource.list``daemon.doctor``internal/backendrpc` Go client 和稳定 RPC reference 已补齐,剩余确认 `patch.*` / `unityfs.*`(待引擎)、`task.create`(暂不开放通用入口)以及 `daemon.restart` / `daemon.clean-stable`(CLI 生命周期入口)的 issue 验收口径。
3. G-005 3. issue #17/#20/#21/#22:多线程下载入口已按最新决定移除,下载回归顺序执行并保留指数退避与单调进度上报;daemon 子进程不再透传并发参数,但 GitHub issue 仍 open。
4. G-007 4. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。
5. G-012 5. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。
6. G-006 6. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流。
7. G-008 / G-009:收敛 Go 产品入口边界,并实现 `bat-api`issue #19)。
8. G-012 / G-006:翻译系统、Patch 引擎。
这个顺序优先补齐用户入口和官方同步结果的资源索引编排,再推进解析、翻译和补丁。G-018 已固化为可重复 smoke 命令并关闭;G-017 已按"不引入托管 CI"决策关闭。 这个顺序优先把 Rust `bat` 后端做扎实(解析能力 + 下载性能),再把官方同步结果进入可查询资源库,之后收敛 Go 入口和仿官方 API 服务端,最后推进翻译和补丁。G-018 已固化为可重复 smoke 命令并关闭;G-017 已按不引入托管 CI决策关闭。
+1 -1
View File
@@ -1,3 +1,3 @@
module bluearchive-toolkit module bat-api
go 1.26.4 go 1.26.4
+267 -49
View File
@@ -641,13 +641,18 @@ struct DaemonRpcAck {
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。 // 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
const RPC_METHOD_STATUS: &str = "daemon.status"; const RPC_METHOD_STATUS: &str = "daemon.status";
const RPC_METHOD_STOP: &str = "daemon.stop"; const RPC_METHOD_STOP: &str = "daemon.stop";
const RPC_METHOD_RESTART: &str = "daemon.restart";
const RPC_METHOD_RELOAD: &str = "daemon.reload"; const RPC_METHOD_RELOAD: &str = "daemon.reload";
const RPC_METHOD_REFRESH: &str = "daemon.refresh"; const RPC_METHOD_REFRESH: &str = "daemon.refresh";
const RPC_METHOD_LOGS: &str = "daemon.logs"; const RPC_METHOD_LOGS: &str = "daemon.logs";
const RPC_METHOD_DOCTOR: &str = "daemon.doctor";
const RPC_METHOD_CLEAN_STABLE: &str = "daemon.clean-stable";
const RPC_METHOD_RESOURCE_STATE: &str = "resource.state"; const RPC_METHOD_RESOURCE_STATE: &str = "resource.state";
const RPC_METHOD_RESOURCE_SYNC: &str = "resource.sync"; const RPC_METHOD_RESOURCE_SYNC: &str = "resource.sync";
const RPC_METHOD_RESOURCE_VERIFY: &str = "resource.verify"; const RPC_METHOD_RESOURCE_VERIFY: &str = "resource.verify";
const RPC_METHOD_RESOURCE_REPAIR: &str = "resource.repair";
const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest"; const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest";
const RPC_METHOD_RESOURCE_LIST: &str = "resource.list";
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status"; const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions"; const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff"; const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
@@ -667,6 +672,7 @@ const MAX_TASK_LOG_LINES: usize = 200;
enum TaskKind { enum TaskKind {
Sync, Sync,
Verify, Verify,
Repair,
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。 /// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
Refresh, Refresh,
} }
@@ -676,6 +682,7 @@ impl TaskKind {
match self { match self {
Self::Sync => RPC_METHOD_RESOURCE_SYNC, Self::Sync => RPC_METHOD_RESOURCE_SYNC,
Self::Verify => RPC_METHOD_RESOURCE_VERIFY, Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
Self::Refresh => RPC_METHOD_CATALOG_REFRESH, Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
} }
} }
@@ -695,6 +702,12 @@ impl TaskKind {
config.repair = false; config.repair = false;
config.force = false; config.force = false;
} }
Self::Repair => {
config.dry_run = false;
config.audit_local = true;
config.repair = true;
config.force = false;
}
Self::Refresh => { Self::Refresh => {
config.dry_run = true; config.dry_run = true;
config.plan = true; config.plan = true;
@@ -800,6 +813,7 @@ fn task_kind_static(kind: &str) -> Option<&'static str> {
match kind { match kind {
RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC), RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC),
RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY), RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY),
RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR),
RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH), RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH),
_ => None, _ => None,
} }
@@ -1233,21 +1247,28 @@ fn canonical_rpc_method(method: &str) -> &str {
match method { match method {
"bat.status" => RPC_METHOD_STATUS, "bat.status" => RPC_METHOD_STATUS,
"bat.stop" => RPC_METHOD_STOP, "bat.stop" => RPC_METHOD_STOP,
"bat.restart" => RPC_METHOD_RESTART,
"bat.reload" => RPC_METHOD_RELOAD, "bat.reload" => RPC_METHOD_RELOAD,
"bat.refresh" => RPC_METHOD_REFRESH, "bat.refresh" => RPC_METHOD_REFRESH,
"bat.logs" => RPC_METHOD_LOGS, "bat.logs" => RPC_METHOD_LOGS,
"bat.doctor" => RPC_METHOD_DOCTOR,
"bat.clean-stable" => RPC_METHOD_CLEAN_STABLE,
RPC_METHOD_RESOURCE_LIST => RPC_METHOD_RESOURCE_MANIFEST,
other => other, other => other,
} }
} }
/// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。 /// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。
fn is_pending_rpc_method(method: &str) -> bool { fn is_pending_rpc_method(method: &str) -> bool {
// task.create:任务统一由 resource.sync / resource.verify / catalog.refresh // task.create:任务统一由 resource.sync / resource.verify / resource.repair / catalog.refresh
// 等语义方法创建,通用创建接口暂不开放。 // 等语义方法创建,通用创建接口暂不开放。
// resource.repair:引擎尚无独立修复模式(sync 自带审计+重下)。 // daemon.restart / daemon.clean-stableCLI 侧按进程生命周期处理;
// live RPC 内不做自重启或在线清理。
// patch.* / unityfs.*:被 bat-patch / bat-assetbundle 引擎阻塞。 // patch.* / unityfs.*:被 bat-patch / bat-assetbundle 引擎阻塞。
matches!(method, "task.create" | "resource.repair") matches!(
|| method.starts_with("patch.") method,
"task.create" | RPC_METHOD_RESTART | RPC_METHOD_CLEAN_STABLE
) || method.starts_with("patch.")
|| method.starts_with("unityfs.") || method.starts_with("unityfs.")
} }
@@ -1831,6 +1852,12 @@ fn dispatch_rpc_method(
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)), .and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
) )
} }
RPC_METHOD_DOCTOR => rpc_envelope_from_result(
request_id,
"daemon.doctor",
build_doctor_report(state_dir, &tasks.base_config)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
),
RPC_METHOD_STOP => { RPC_METHOD_STOP => {
daemon_control_mark_stop_requested(control); daemon_control_mark_stop_requested(control);
let _ = update_daemon_state_only(state_dir, "stopping"); let _ = update_daemon_state_only(state_dir, "stopping");
@@ -1879,6 +1906,9 @@ fn dispatch_rpc_method(
RPC_METHOD_RESOURCE_VERIFY => { RPC_METHOD_RESOURCE_VERIFY => {
enqueue_task_envelope(tasks, TaskKind::Verify, false, request_id) enqueue_task_envelope(tasks, TaskKind::Verify, false, request_id)
} }
RPC_METHOD_RESOURCE_REPAIR => {
enqueue_task_envelope(tasks, TaskKind::Repair, false, request_id)
}
RPC_METHOD_RESOURCE_MANIFEST => { RPC_METHOD_RESOURCE_MANIFEST => {
let (offset, limit) = match rpc_page_params(request.params.as_ref()) { let (offset, limit) = match rpc_page_params(request.params.as_ref()) {
Ok(page) => page, Ok(page) => page,
@@ -3006,19 +3036,36 @@ struct CommandReport<T> {
} }
fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> { fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> {
if refresh_should_use_daemon_rpc(options, command_name) run_sync_command_with_rpc(options, command_name, daemon_rpc_available, daemon_rpc_call)
&& daemon_rpc_available(&options.state_dir) }
fn run_sync_command_with_rpc(
options: &CliOptions,
command_name: &'static str,
rpc_available: impl Fn(&Path) -> bool,
rpc_call: impl Fn(&Path, &str, Option<serde_json::Value>) -> anyhow::Result<serde_json::Value>,
) -> anyhow::Result<()> {
if let Some(rpc_method) =
sync_command_rpc_method(options, command_name).filter(|_| rpc_available(&options.state_dir))
{ {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?; let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let report = daemon_rpc_call( let params = if rpc_method == RPC_METHOD_REFRESH {
&options.state_dir, Some(serde_json::json!({ "force": options.config.force }))
RPC_METHOD_REFRESH, } else {
Some(serde_json::json!({ "force": options.config.force })), None
)?; };
let report = rpc_call(&options.state_dir, rpc_method, params)?;
print_json_value(options.output_format, &report)?; print_json_value(options.output_format, &report)?;
return Ok(()); return Ok(());
} }
run_sync_command_foreground(options, command_name)
}
fn run_sync_command_foreground(
options: &CliOptions,
command_name: &'static str,
) -> anyhow::Result<()> {
assert_no_live_daemon_output_conflict(options, command_name)?; assert_no_live_daemon_output_conflict(options, command_name)?;
let mut config = options.config.clone(); let mut config = options.config.clone();
if command_name == "repair" { if command_name == "repair" {
@@ -3044,13 +3091,9 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow:
Ok(()) Ok(())
} }
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool { fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&'static str> {
if command_name != "refresh" {
return false;
}
let defaults = OfficialUpdateConfig::default(); let defaults = OfficialUpdateConfig::default();
options.command == CliCommand::Refresh let default_daemon_shape = !options.watch
&& !options.watch
&& !options.daemon && !options.daemon
&& !options.daemon_child && !options.daemon_child
&& !options.output_explicit && !options.output_explicit
@@ -3068,7 +3111,19 @@ fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bo
&& !options.config.dry_run && !options.config.dry_run
&& !options.config.plan && !options.config.plan
&& options.config.audit_local == defaults.audit_local && options.config.audit_local == defaults.audit_local
&& options.config.repair == defaults.repair && options.config.repair == defaults.repair;
if !default_daemon_shape {
return None;
}
match (options.command, command_name) {
(CliCommand::Refresh, "refresh") => Some(RPC_METHOD_REFRESH),
(CliCommand::Repair, "repair") if !options.config.force => Some(RPC_METHOD_RESOURCE_REPAIR),
_ => None,
}
}
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool {
sync_command_rpc_method(options, command_name) == Some(RPC_METHOD_REFRESH)
} }
fn print_report<T>(format: OutputFormat, report: &T) -> anyhow::Result<()> fn print_report<T>(format: OutputFormat, report: &T) -> anyhow::Result<()>
@@ -3806,30 +3861,29 @@ struct DoctorReport {
checks: Vec<DoctorCheck>, checks: Vec<DoctorCheck>,
} }
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> { fn build_doctor_report(
state_dir: &Path,
config: &OfficialUpdateConfig,
) -> anyhow::Result<DoctorReport> {
let mut checks = vec![ let mut checks = vec![
path_check("state_dir", &options.state_dir, "后台状态目录可用"), path_check("state_dir", state_dir, "后台状态目录可用"),
path_check( path_check("output_root", &config.output_root, "资源输出目录可用"),
"output_root",
&options.config.output_root,
"资源输出目录可用",
),
safety_check( safety_check(
"output_root_safety", "output_root_safety",
validate_output_root(&options.config.output_root), validate_output_root(&config.output_root),
"资源输出目录安全边界通过", "资源输出目录安全边界通过",
), ),
safety_check( safety_check(
"state_dir_safety", "state_dir_safety",
validate_runtime_state_dir(&options.state_dir), validate_runtime_state_dir(state_dir),
"后台状态目录安全边界通过", "后台状态目录安全边界通过",
), ),
command_check("curl", &options.config.curl_command), command_check("curl", &config.curl_command),
proxy_check(&options.config.curl_proxy), proxy_check(&config.curl_proxy),
command_check("unzip", &options.config.unzip_command), command_check("unzip", &config.unzip_command),
]; ];
let pid_path = daemon_pid_path(&options.state_dir); let pid_path = daemon_pid_path(state_dir);
let daemon_running = read_pid_file(&pid_path) let daemon_running = read_pid_file(&pid_path)
.ok() .ok()
.flatten() .flatten()
@@ -3857,9 +3911,9 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
}), }),
} }
let socket_path = daemon_socket_path(&options.state_dir); let socket_path = daemon_socket_path(state_dir);
let socket_exists = daemon_socket_path_exists(&socket_path).unwrap_or(false); let socket_exists = daemon_socket_path_exists(&socket_path).unwrap_or(false);
let socket_available = daemon_rpc_available(&options.state_dir); let socket_available = daemon_rpc_available(state_dir);
checks.push(DoctorCheck { checks.push(DoctorCheck {
name: "daemon_rpc", name: "daemon_rpc",
ok: if daemon_running { ok: if daemon_running {
@@ -3884,7 +3938,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
}, },
}); });
let lock_path = options.config.lock_path(); let lock_path = config.lock_path();
checks.push(match classify_pid_lock_file(&lock_path)? { checks.push(match classify_pid_lock_file(&lock_path)? {
PidLockState::Missing => DoctorCheck { PidLockState::Missing => DoctorCheck {
name: "resource_lock", name: "resource_lock",
@@ -3917,7 +3971,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
}, },
}); });
let control_lock_path = daemon_control_lock_path(&options.state_dir); let control_lock_path = daemon_control_lock_path(state_dir);
checks.push(match classify_pid_lock_file(&control_lock_path)? { checks.push(match classify_pid_lock_file(&control_lock_path)? {
PidLockState::Missing => DoctorCheck { PidLockState::Missing => DoctorCheck {
name: "daemon_control_lock", name: "daemon_control_lock",
@@ -3950,7 +4004,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
}, },
}); });
let status_path = daemon_status_path(&options.state_dir); let status_path = daemon_status_path(state_dir);
// ok 与 message 从同一次解析结果派生,避免“ok=false 却提示可解析”的自相矛盾。 // ok 与 message 从同一次解析结果派生,避免“ok=false 却提示可解析”的自相矛盾。
let daemon_status_result = read_daemon_status_file(&status_path); let daemon_status_result = read_daemon_status_file(&status_path);
checks.push(DoctorCheck { checks.push(DoctorCheck {
@@ -3964,7 +4018,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
}); });
let healthy = checks.iter().all(|check| check.ok); let healthy = checks.iter().all(|check| check.ok);
let report = DoctorReport { Ok(DoctorReport {
command: "doctor", command: "doctor",
status: if healthy { "ok" } else { "issues_found" }, status: if healthy { "ok" } else { "issues_found" },
message: if healthy { message: if healthy {
@@ -3974,7 +4028,12 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
}, },
healthy, healthy,
checks, checks,
}; })
}
fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
let report = build_doctor_report(&options.state_dir, &options.config)?;
let healthy = report.healthy;
print_report(options.output_format, &report)?; print_report(options.output_format, &report)?;
Ok(healthy) Ok(healthy)
} }
@@ -4957,7 +5016,6 @@ BAT_AUTO_DISCOVER=1
# #
#BAT_INTERVAL_SECONDS=3600 #BAT_INTERVAL_SECONDS=3600
#BAT_ERROR_RETRY_SECONDS=60 #BAT_ERROR_RETRY_SECONDS=60
# ---- ---- # ---- ----
# URL http/https/socks4/socks4a/socks5/socks5h # URL http/https/socks4/socks4a/socks5/socks5h
# HTTPS_PROXY / ALL_PROXY / HTTP_PROXY # HTTPS_PROXY / ALL_PROXY / HTTP_PROXY
@@ -5920,11 +5978,13 @@ mod tests {
"404", "404",
)); ));
}); });
let repair_id = registry.create(TaskKind::Repair);
registry.update(&repair_id, |record| record.status = "succeeded");
drop(registry); drop(registry);
// 重启:恢复历史;running 任务标记中断;错误码经持久化往返保留。 // 重启:恢复历史;running 任务标记中断;错误码经持久化往返保留。
let (registry, summary) = TaskRegistry::with_persistence(state_dir); let (registry, summary) = TaskRegistry::with_persistence(state_dir);
assert!(summary.contains("恢复任务历史 3"), "{summary}"); assert!(summary.contains("恢复任务历史 4"), "{summary}");
assert!(summary.contains("标记中断 1 条"), "{summary}"); assert!(summary.contains("标记中断 1 条"), "{summary}");
let finished = registry.get(&finished_id).unwrap(); let finished = registry.get(&finished_id).unwrap();
assert_eq!(finished.status, "succeeded"); assert_eq!(finished.status, "succeeded");
@@ -5945,16 +6005,19 @@ mod tests {
failed.error.as_ref().unwrap().code(), failed.error.as_ref().unwrap().code(),
ErrorCode::HTTP_NOT_FOUND ErrorCode::HTTP_NOT_FOUND
); );
let repair = registry.get(&repair_id).unwrap();
assert_eq!(repair.kind, "resource.repair");
assert_eq!(repair.status, "succeeded");
// seq 持久化:重启后(本测试内 pid 相同)新任务不与历史撞 ID。 // seq 持久化:重启后(本测试内 pid 相同)新任务不与历史撞 ID。
let new_id = registry.create(TaskKind::Sync); let new_id = registry.create(TaskKind::Sync);
assert!( assert!(
[&finished_id, &running_id, &failed_id] [&finished_id, &running_id, &failed_id, &repair_id]
.iter() .iter()
.all(|id| **id != new_id), .all(|id| **id != new_id),
"新任务 ID {new_id} 与历史撞号" "新任务 ID {new_id} 与历史撞号"
); );
assert_eq!(registry.list().len(), 4); assert_eq!(registry.list().len(), 5);
} }
#[test] #[test]
@@ -6369,8 +6432,13 @@ mod tests {
#[test] #[test]
fn canonical_rpc_method_resolves_aliases() { fn canonical_rpc_method_resolves_aliases() {
assert_eq!(canonical_rpc_method("bat.status"), RPC_METHOD_STATUS); assert_eq!(canonical_rpc_method("bat.status"), RPC_METHOD_STATUS);
assert_eq!(canonical_rpc_method("bat.doctor"), RPC_METHOD_DOCTOR);
assert_eq!(canonical_rpc_method("bat.refresh"), RPC_METHOD_REFRESH); assert_eq!(canonical_rpc_method("bat.refresh"), RPC_METHOD_REFRESH);
assert_eq!(canonical_rpc_method("daemon.status"), RPC_METHOD_STATUS); assert_eq!(canonical_rpc_method("daemon.status"), RPC_METHOD_STATUS);
assert_eq!(
canonical_rpc_method("resource.list"),
RPC_METHOD_RESOURCE_MANIFEST
);
assert_eq!(canonical_rpc_method("resource.state"), "resource.state"); assert_eq!(canonical_rpc_method("resource.state"), "resource.state");
assert_eq!(canonical_rpc_method("unknown.method"), "unknown.method"); assert_eq!(canonical_rpc_method("unknown.method"), "unknown.method");
} }
@@ -6380,17 +6448,20 @@ mod tests {
assert!(is_pending_rpc_method("patch.apply")); assert!(is_pending_rpc_method("patch.apply"));
assert!(is_pending_rpc_method("unityfs.inspect")); assert!(is_pending_rpc_method("unityfs.inspect"));
assert!(is_pending_rpc_method("task.create")); assert!(is_pending_rpc_method("task.create"));
assert!(is_pending_rpc_method("resource.repair")); assert!(is_pending_rpc_method("daemon.restart"));
// sync/verify、task.cancel/logs、catalog.* 与 resource.manifest 已实现, assert!(is_pending_rpc_method("daemon.clean-stable"));
// 不再是 pending // sync/verify/repair、task.cancel/logs、catalog.* 与 resource.manifest 已实现
assert!(!is_pending_rpc_method("resource.sync")); assert!(!is_pending_rpc_method("resource.sync"));
assert!(!is_pending_rpc_method("resource.verify")); assert!(!is_pending_rpc_method("resource.verify"));
assert!(!is_pending_rpc_method("resource.repair"));
assert!(!is_pending_rpc_method("resource.manifest")); assert!(!is_pending_rpc_method("resource.manifest"));
assert!(!is_pending_rpc_method("resource.list"));
assert!(!is_pending_rpc_method("catalog.status")); assert!(!is_pending_rpc_method("catalog.status"));
assert!(!is_pending_rpc_method("catalog.refresh")); assert!(!is_pending_rpc_method("catalog.refresh"));
assert!(!is_pending_rpc_method("task.cancel")); assert!(!is_pending_rpc_method("task.cancel"));
assert!(!is_pending_rpc_method("task.logs")); assert!(!is_pending_rpc_method("task.logs"));
assert!(!is_pending_rpc_method("daemon.status")); assert!(!is_pending_rpc_method("daemon.status"));
assert!(!is_pending_rpc_method("daemon.doctor"));
} }
#[test] #[test]
@@ -6503,6 +6574,36 @@ mod tests {
assert_eq!(value["error"]["code"], "BAT-ERR-700004"); assert_eq!(value["error"]["code"], "BAT-ERR-700004");
} }
#[test]
fn dispatch_daemon_doctor_returns_report() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
let mut base_config = OfficialUpdateConfig::default();
base_config.output_root = output_root;
let (queue, _rx) = mpsc::channel::<TaskJob>();
let context = DaemonTaskContext {
registry: TaskRegistry::new(),
queue,
base_config,
};
let envelope = dispatch_rpc_method(
&rpc_request("daemon.doctor", None),
&state_dir,
&new_daemon_control(),
&context,
"req-doctor-1".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["status"], "ok");
assert_eq!(value["data"]["command"], "doctor");
assert!(value["data"]["healthy"].is_boolean());
let checks = value["data"]["checks"].as_array().unwrap();
assert!(checks.iter().any(|check| check["name"] == "daemon_rpc"));
}
#[test] #[test]
fn dispatch_resource_sync_enqueues_task() { fn dispatch_resource_sync_enqueues_task() {
let temp = tempfile::TempDir::new().unwrap(); let temp = tempfile::TempDir::new().unwrap();
@@ -6555,6 +6656,48 @@ mod tests {
assert_eq!(status_value["data"]["status"], "queued"); assert_eq!(status_value["data"]["status"], "queued");
} }
#[test]
fn dispatch_resource_repair_enqueues_repair_task() {
let temp = tempfile::TempDir::new().unwrap();
let control = new_daemon_control();
// 保留 rx 让 send 成功(不启动 worker,任务停留在 queued)。
let (queue, rx) = mpsc::channel::<TaskJob>();
let mut base_config = OfficialUpdateConfig::default();
base_config.force = true;
base_config.dry_run = true;
base_config.audit_local = false;
base_config.repair = false;
let context = DaemonTaskContext {
registry: TaskRegistry::new(),
queue,
base_config,
};
let envelope = dispatch_rpc_method(
&rpc_request("resource.repair", None),
temp.path(),
&control,
&context,
"req-repair-1".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["status"], "accepted");
assert_eq!(value["data"]["kind"], "resource.repair");
let task_id = value["data"]["task_id"].as_str().unwrap();
let record = context.registry.get(task_id).unwrap();
assert_eq!(record.kind, "resource.repair");
assert_eq!(record.status, "queued");
// repair 任务强制开启本地审计+修复,但不会继承 force/dry-run。
let job = rx.try_recv().unwrap();
assert_eq!(job.id, task_id);
assert!(!job.config.dry_run);
assert!(job.config.audit_local);
assert!(job.config.repair);
assert!(!job.config.force);
}
#[test] #[test]
fn task_registry_create_update_and_prune() { fn task_registry_create_update_and_prune() {
let registry = TaskRegistry::new(); let registry = TaskRegistry::new();
@@ -7093,21 +7236,79 @@ mod tests {
} }
#[test] #[test]
fn refresh_rpc_selection_only_for_default_daemon_shape() { fn sync_command_rpc_selection_only_for_default_daemon_shape() {
let options = parse(&["bat", "refresh"]).unwrap(); let options = parse(&["bat", "refresh"]).unwrap();
assert!(refresh_should_use_daemon_rpc(&options, "refresh")); assert!(refresh_should_use_daemon_rpc(&options, "refresh"));
assert_eq!(
sync_command_rpc_method(&options, "refresh"),
Some(RPC_METHOD_REFRESH)
);
let options = parse(&["bat", "refresh", "--force"]).unwrap(); let options = parse(&["bat", "refresh", "--force"]).unwrap();
assert!(refresh_should_use_daemon_rpc(&options, "refresh")); assert!(refresh_should_use_daemon_rpc(&options, "refresh"));
assert_eq!(
sync_command_rpc_method(&options, "refresh"),
Some(RPC_METHOD_REFRESH)
);
let options = parse(&["bat", "refresh", "--output", "/tmp/other"]).unwrap(); let options = parse(&["bat", "refresh", "--output", "/tmp/other"]).unwrap();
assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
assert_eq!(sync_command_rpc_method(&options, "refresh"), None);
let options = parse(&["bat", "refresh", "--server-info-file", "ProdNotice.json"]).unwrap(); let options = parse(&["bat", "refresh", "--server-info-file", "ProdNotice.json"]).unwrap();
assert!(!refresh_should_use_daemon_rpc(&options, "refresh")); assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
assert_eq!(sync_command_rpc_method(&options, "refresh"), None);
let options = parse(&["bat", "repair"]).unwrap(); let options = parse(&["bat", "repair"]).unwrap();
assert!(!refresh_should_use_daemon_rpc(&options, "repair")); assert!(!refresh_should_use_daemon_rpc(&options, "repair"));
assert_eq!(
sync_command_rpc_method(&options, "repair"),
Some(RPC_METHOD_RESOURCE_REPAIR)
);
let options = parse(&["bat", "repair", "--output", "/tmp/other"]).unwrap();
assert_eq!(sync_command_rpc_method(&options, "repair"), None);
let options = parse(&["bat", "repair", "--force"]).unwrap();
assert_eq!(sync_command_rpc_method(&options, "repair"), None);
}
#[test]
fn repair_command_uses_rpc_when_default_daemon_shape() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
fs::create_dir_all(&state_dir).unwrap();
let seen = Arc::new(Mutex::new(Vec::<(String, Option<serde_json::Value>)>::new()));
let seen_calls = Arc::clone(&seen);
let options = parse(&[
"bat",
"repair",
"--json",
"--state-dir",
state_dir.to_str().unwrap(),
])
.unwrap();
run_sync_command_with_rpc(
&options,
"repair",
|_| true,
move |_state_dir, method, params| {
seen_calls
.lock()
.unwrap()
.push((method.to_string(), params.clone()));
Ok(serde_json::json!({
"task_id": "task-fake-repair",
"kind": "resource.repair",
}))
},
)
.unwrap();
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].0, RPC_METHOD_RESOURCE_REPAIR);
assert_eq!(seen[0].1, None);
} }
#[test] #[test]
@@ -7592,17 +7793,34 @@ mod tests {
let value = serde_json::to_value(&envelope).unwrap(); let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], false); assert_eq!(value["ok"], false);
assert_eq!(value["error"]["code"], "BAT-ERR-700002"); assert_eq!(value["error"]["code"], "BAT-ERR-700002");
let envelope = dispatch_rpc_method(
&rpc_request(
"resource.list",
Some(serde_json::json!({ "offset": 2, "limit": 1 })),
),
&state_dir,
&new_daemon_control(),
&test_task_context(),
"req-man-3".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["available"], true);
let entries = value["data"]["entries"].as_array().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0]["destination"], "c");
} }
#[test] #[test]
fn dispatch_resource_repair_reports_not_implemented() { fn dispatch_daemon_clean_stable_reports_not_implemented() {
let temp = tempfile::TempDir::new().unwrap(); let temp = tempfile::TempDir::new().unwrap();
let envelope = dispatch_rpc_method( let envelope = dispatch_rpc_method(
&rpc_request("resource.repair", None), &rpc_request("daemon.clean-stable", None),
temp.path(), temp.path(),
&new_daemon_control(), &new_daemon_control(),
&test_task_context(), &test_task_context(),
"req-rep-1".to_string(), "req-clean-1".to_string(),
); );
let value = serde_json::to_value(&envelope).unwrap(); let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], false); assert_eq!(value["ok"], false);
+44 -4
View File
@@ -427,6 +427,34 @@ impl std::fmt::Display for CurlRetryError {
impl std::error::Error for CurlRetryError {} impl std::error::Error for CurlRetryError {}
/// 网络类可重试失败的退避基值(毫秒)。生产 200ms、指数增长;测试下为 0
/// 以免拖慢单测(单测仍验证退避时长的计算,只是不真正 sleep)。
#[cfg(not(test))]
const RETRY_BACKOFF_BASE_MS: u64 = 200;
#[cfg(test)]
const RETRY_BACKOFF_BASE_MS: u64 = 0;
/// 退避上限(毫秒)。
const RETRY_BACKOFF_MAX_MS: u64 = 5_000;
/// 计算第 `attempt` 次失败后、下次重试前的退避时长。
fn retry_backoff(busy: bool, attempt: usize) -> std::time::Duration {
std::time::Duration::from_millis(backoff_delay_ms(RETRY_BACKOFF_BASE_MS, busy, attempt))
}
/// 退避时长(毫秒)的纯计算,便于独立于 cfg 门控的基值做单测。
///
/// - `ETXTBSY`fork/exec 竞态):极短固定退避,只为让兄弟进程完成 execve。
/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),避免
/// 连续失败后立即重发。
fn backoff_delay_ms(base: u64, busy: bool, attempt: usize) -> u64 {
if busy {
return 5 * attempt as u64;
}
let shift = attempt.saturating_sub(1).min(5) as u32;
base.saturating_mul(1u64 << shift).min(RETRY_BACKOFF_MAX_MS)
}
pub(crate) fn run_curl_with_retry_with_proxy( pub(crate) fn run_curl_with_retry_with_proxy(
curl_command: &Path, curl_command: &Path,
url: &str, url: &str,
@@ -448,8 +476,6 @@ pub(crate) fn run_curl_with_retry_with_proxy(
Err(error) => CurlFailure::process_failed(url, destination, curl_command, error), Err(error) => CurlFailure::process_failed(url, destination, curl_command, error),
}; };
let retryable = failure.retryable(); let retryable = failure.retryable();
// ETXTBSY 是 fork/exec 竞态窗口造成的瞬时忙,立刻重试往往仍落在同一窗口内。
// 让出 CPU 并做一次极短退避,使持有可写句柄的兄弟进程完成其 execve。
let busy = failure.kind == CurlFailureKind::ProcessBusy; let busy = failure.kind == CurlFailureKind::ProcessBusy;
failures.push(CurlAttemptFailure { failures.push(CurlAttemptFailure {
attempt, attempt,
@@ -459,8 +485,8 @@ pub(crate) fn run_curl_with_retry_with_proxy(
if !retryable { if !retryable {
break; break;
} }
if busy && attempt < attempts { if attempt < attempts {
std::thread::sleep(std::time::Duration::from_millis(5 * attempt as u64)); std::thread::sleep(retry_backoff(busy, attempt));
} }
} }
@@ -838,4 +864,18 @@ mod tests {
assert_eq!(command_env(&command, "ALL_PROXY"), Some(None)); assert_eq!(command_env(&command, "ALL_PROXY"), Some(None));
assert_eq!(command_env(&command, "HTTPS_PROXY"), Some(None)); assert_eq!(command_env(&command, "HTTPS_PROXY"), Some(None));
} }
#[test]
fn backoff_is_exponential_capped_and_busy_is_short() {
// 网络类失败:base·2^(attempt-1),上限 5s。
assert_eq!(backoff_delay_ms(200, false, 1), 200);
assert_eq!(backoff_delay_ms(200, false, 2), 400);
assert_eq!(backoff_delay_ms(200, false, 3), 800);
assert_eq!(backoff_delay_ms(200, false, 4), 1600);
// 高次方触顶 5s 上限。
assert_eq!(backoff_delay_ms(200, false, 10), 5000);
// ETXTBSY 走极短固定退避,与指数无关。
assert_eq!(backoff_delay_ms(200, true, 1), 5);
assert_eq!(backoff_delay_ms(200, true, 3), 15);
}
} }
+7
View File
@@ -425,6 +425,7 @@ mod tests {
resource_type: ResourceType::AssetBundle, resource_type: ResourceType::AssetBundle,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
ResourceEntry { ResourceEntry {
path: "synthetic/catalog.json".to_string(), path: "synthetic/catalog.json".to_string(),
@@ -433,6 +434,7 @@ mod tests {
resource_type: ResourceType::Manifest, resource_type: ResourceType::Manifest,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
ResourceEntry { ResourceEntry {
path: "TextAssets/dialogue.csv".to_string(), path: "TextAssets/dialogue.csv".to_string(),
@@ -441,6 +443,7 @@ mod tests {
resource_type: ResourceType::TextAsset, resource_type: ResourceType::TextAsset,
address: Some("dialogue".to_string()), address: Some("dialogue".to_string()),
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
ResourceEntry { ResourceEntry {
path: "TableBundles/ExcelDB.db".to_string(), path: "TableBundles/ExcelDB.db".to_string(),
@@ -449,6 +452,7 @@ mod tests {
resource_type: ResourceType::TableBundle, resource_type: ResourceType::TableBundle,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
ResourceEntry { ResourceEntry {
path: "MediaResources-Windows/voice/title.acb".to_string(), path: "MediaResources-Windows/voice/title.acb".to_string(),
@@ -457,6 +461,7 @@ mod tests {
resource_type: ResourceType::Media, resource_type: ResourceType::Media,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
], ],
metadata: ManifestMetadata { metadata: ManifestMetadata {
@@ -487,6 +492,7 @@ mod tests {
resource_type: ResourceType::TextAsset, resource_type: ResourceType::TextAsset,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
} }
} }
@@ -507,6 +513,7 @@ mod tests {
resource_type: ResourceType::AssetBundle, resource_type: ResourceType::AssetBundle,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
]); ]);
+226 -61
View File
@@ -111,7 +111,12 @@ impl OfficialResourcePullProgressKind {
pub struct OfficialResourcePullProgress { pub struct OfficialResourcePullProgress {
/// Progress event kind. /// Progress event kind.
pub kind: OfficialResourcePullProgressKind, 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, pub index: usize,
/// Total URL count in the pull plan. /// Total URL count in the pull plan.
pub total: usize, pub total: usize,
@@ -562,29 +567,21 @@ impl OfficialResourcePullService {
let mut manifest = self.read_download_manifest()?; let mut manifest = self.read_download_manifest()?;
let urls = plan.all_urls()?; let urls = plan.all_urls()?;
let total = urls.len(); let total = urls.len();
let mut items = Vec::new();
let mut verified_hashes = Vec::new(); // Phase A:无网络的前置校验(fail-fast)。逐个校验官方性、算目标路径、
let mut verified_hash_urls = HashSet::<String>::new(); // 建目录,并判定该 URL 是「已验证可跳过」还是「需下载」。任何非官方
let mut processed_urls = HashSet::<String>::new(); // URL 在发起任何下载前即拒绝。
for (offset, url) in urls.into_iter().enumerate() {
if should_cancel() { if should_cancel() {
return Err("官方资源拉取已被停止请求中断".to_string().into()); return Err("官方资源拉取已被停止请求中断".to_string().into());
} }
let mut planned: Vec<PlannedDownload> = Vec::with_capacity(total);
let index = offset + 1; for url in urls {
progress(OfficialResourcePullProgress::started(
index,
total,
url.clone(),
));
if !is_official_yostar_jp_url(&url) { if !is_official_yostar_jp_url(&url) {
return Err(DownloadError::new( return Err(DownloadError::new(
bat_core::ErrorCode::NON_OFFICIAL_URL, bat_core::ErrorCode::NON_OFFICIAL_URL,
format!("拒绝下载非官方 URL{url}"), format!("拒绝下载非官方 URL{url}"),
)); ));
} }
let destination = self.destination_for_url(&url)?; let destination = self.destination_for_url(&url)?;
if let Some(parent) = destination.parent() { if let Some(parent) = destination.parent() {
ensure_safe_directory_path(parent, "下载目标目录")?; ensure_safe_directory_path(parent, "下载目标目录")?;
@@ -596,50 +593,148 @@ impl OfficialResourcePullService {
ensure_safe_file_target(&self.output_root, &destination, "下载目标文件")?; ensure_safe_file_target(&self.output_root, &destination, "下载目标文件")?;
let force_refresh = force_refresh_urls.contains(&url); let force_refresh = force_refresh_urls.contains(&url);
let result = if force_refresh { let existing = if force_refresh {
None None
} else { } else {
self.validated_existing_file(&url, &destination, &manifest)? self.validated_existing_file(&url, &destination, &manifest)?
}; };
let result = if let Some(result) = result { planned.push(PlannedDownload {
self.clear_quarantine_entry(&url)?; url,
result destination,
} else { existing,
let result = match self.pull_one(&url, &destination) { });
Ok(result) => result, }
Err(error) => {
self.record_quarantine_entry(&url, &destination, &error)?; // Phase B:顺序下载 need-download 项。每个 URL 的目标和 `.part` 都是独立
progress(OfficialResourcePullProgress::failed( // 的,但这里保留单线程执行,便于维持稳定进度、稳定日志和简单的失败恢复。
index, let download_indices: Vec<usize> = planned
.iter()
.enumerate()
.filter(|(_, item)| item.existing.is_none())
.map(|(index, _)| index)
.collect();
let mut download_results: Vec<Option<Result<PullOneResult, PullOneError>>> =
(0..planned.len()).map(|_| None).collect();
let mut completed_count = 0usize;
for &plan_index in &download_indices {
let item = &planned[plan_index];
progress(OfficialResourcePullProgress::started(
completed_count,
total, total,
url.clone(), item.url.clone(),
));
if should_cancel() {
return Err("官方资源拉取已被停止请求中断".to_string().into());
}
let result = self.pull_one(&item.url, &item.destination);
match result {
Ok(pull_result) => {
if let Err(error) = self
.clear_quarantine_entry(&item.url)
.and_then(|_| {
self.record_download_manifest_entry(
&mut manifest,
&item.url,
&item.destination,
)
})
.and_then(|_| self.write_download_manifest(&manifest))
{
let error = PullOneError::plain(format!("记录下载 manifest 失败:{error}"));
self.record_quarantine_entry(&item.url, &item.destination, &error)?;
progress(OfficialResourcePullProgress::failed(
completed_count,
total,
item.url.clone(),
&error, &error,
)); ));
return Err(DownloadError::new( return Err(DownloadError::new(
error.error_code(), error.error_code(),
format!( format!(
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={url} quarantine={}{}", "官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={} quarantine={}{}",
item.url,
self.download_quarantine_path().display(), self.download_quarantine_path().display(),
error.message error.message
), ),
)); ));
} }
};
self.clear_quarantine_entry(&url)?; completed_count += 1;
self.record_download_manifest_entry(&mut manifest, &url, &destination)?; progress(OfficialResourcePullProgress::finished(
self.write_download_manifest(&manifest)?; completed_count,
result total,
item.url.clone(),
pull_result.status,
pull_result.bytes,
pull_result.transferred_bytes,
));
download_results[plan_index] = Some(Ok(pull_result));
}
Err(error) => {
self.record_quarantine_entry(&item.url, &item.destination, &error)?;
progress(OfficialResourcePullProgress::failed(
completed_count,
total,
item.url.clone(),
&error,
));
return Err(DownloadError::new(
error.error_code(),
format!(
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={} quarantine={}{}",
item.url,
self.download_quarantine_path().display(),
error.message
),
));
}
}
}
if should_cancel() {
return Err("官方资源拉取已被停止请求中断".to_string().into());
}
// Phase C:按 plan 顺序串行收尾——跳过项清 quarantine 并补发进度,
// 逐项做官方 seed `.hash` 校验(顺序相关、可 fail-fast),构建有序结果。
let mut items = Vec::with_capacity(planned.len());
let mut verified_hashes = Vec::new();
let mut verified_hash_urls = HashSet::<String>::new();
let mut processed_urls = HashSet::<String>::new();
for (plan_index, item) in planned.iter().enumerate() {
let result = if let Some(existing) = &item.existing {
self.clear_quarantine_entry(&item.url)?;
progress(OfficialResourcePullProgress::started(
completed_count,
total,
item.url.clone(),
));
completed_count += 1;
progress(OfficialResourcePullProgress::finished(
completed_count,
total,
item.url.clone(),
existing.status,
existing.bytes,
existing.transferred_bytes,
));
*existing
} else {
// Phase B 已保证需下载项此时均为 Ok(失败会在上面 fail-fast 返回)。
match download_results[plan_index].take() {
Some(Ok(result)) => result,
_ => {
return Err(DownloadError::new(
bat_core::ErrorCode::INTERNAL,
format!("内部错误:下载结果缺失 url={}", item.url),
))
}
}
}; };
progress(OfficialResourcePullProgress::finished( processed_urls.insert(item.url.clone());
index,
total,
url.clone(),
result.status,
result.bytes,
result.transferred_bytes,
));
processed_urls.insert(url.clone());
self.verify_ready_official_hashes( self.verify_ready_official_hashes(
&official_hash_pairs, &official_hash_pairs,
&processed_urls, &processed_urls,
@@ -648,17 +743,14 @@ impl OfficialResourcePullService {
&mut manifest, &mut manifest,
)?; )?;
items.push(OfficialResourcePullItem { items.push(OfficialResourcePullItem {
url, url: item.url.clone(),
destination, destination: item.destination.clone(),
bytes: result.bytes, bytes: result.bytes,
transferred_bytes: result.transferred_bytes, transferred_bytes: result.transferred_bytes,
status: result.status, status: result.status,
}); });
} }
if should_cancel() {
return Err("官方资源拉取已被停止请求中断".to_string().into());
}
self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?; self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?;
Ok(OfficialResourcePullReport { Ok(OfficialResourcePullReport {
@@ -1603,6 +1695,14 @@ fn default_download_quarantine_version() -> u32 {
DOWNLOAD_QUARANTINE_VERSION DOWNLOAD_QUARANTINE_VERSION
} }
/// Phase A 产出的单个下载计划项:URL、目标路径,以及若命中本地 manifest
/// 校验则带上「已验证可跳过」的结果(`existing`)。
struct PlannedDownload {
url: String,
destination: PathBuf,
existing: Option<PullOneResult>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PullOneResult { struct PullOneResult {
bytes: u64, bytes: u64,
@@ -1912,7 +2012,7 @@ exit 22
YostarJpDownloadInventory::from_catalog_bytes( YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip", b"FullPatch_000.zip",
b"ExcelDB.db ExcelDB.db", b"ExcelDB.db ExcelDB.db",
b"JP_Airi.zip", b"GameData\\Audio\\VOC_JP\\JP_Airi.zip",
) )
} }
@@ -1972,10 +2072,10 @@ done
printf '%s' '{android_media_hash}' > "$out" printf '%s' '{android_media_hash}' > "$out"
;; ;;
*/MediaResources-Windows/Catalog/MediaCatalog.bytes) */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) */MediaResources/Catalog/MediaCatalog.bytes)
printf '%s' 'JP_Airi_Android.zip' > "$out" printf '%s' 'GameData\Audio\VOC_JP\JP_Airi_Android.zip' > "$out"
;; ;;
*.zip) *.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" 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"
@@ -1991,8 +2091,8 @@ fi
table_catalog_hash = xxhash32(b"ExcelDB.db"), table_catalog_hash = xxhash32(b"ExcelDB.db"),
windows_bundle_hash = xxhash32(b"FullPatch_000.zip"), windows_bundle_hash = xxhash32(b"FullPatch_000.zip"),
android_bundle_hash = xxhash32(b"FullPatch_001.zip"), android_bundle_hash = xxhash32(b"FullPatch_001.zip"),
windows_media_hash = xxhash32(b"JP_Airi_Win.zip"), windows_media_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"),
android_media_hash = xxhash32(b"JP_Airi_Android.zip"), android_media_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"),
) )
} }
@@ -2697,7 +2797,10 @@ exit 22
assert_eq!(xxhash32(b""), 46947589); assert_eq!(xxhash32(b""), 46947589);
assert_eq!(xxhash32(b"ExcelDB.db"), 2044170421); assert_eq!(xxhash32(b"ExcelDB.db"), 2044170421);
assert_eq!(xxhash32(b"FullPatch_000.zip"), 4038880697); 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); assert_eq!(xxhash32(b"ExcelDB.db ExcelDB.db"), 2147704569);
} }
@@ -2853,6 +2956,8 @@ exit 22
let curl_path = bin_dir.path().join("curl"); let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path); write_fake_curl(&curl_path);
// 并发下事件顺序不固定,此处只断言与顺序无关的不变量;并发正确性另有
// downloads_run_concurrently_and_each_url_reports_once 专测。
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path); let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let plan = build_official_pull_plan_for_platforms( let plan = build_official_pull_plan_for_platforms(
discovery_plan(), discovery_plan(),
@@ -2868,19 +2973,79 @@ exit 22
assert_eq!(report.items.len(), expected_urls); assert_eq!(report.items.len(), expected_urls);
assert_eq!(events.len(), expected_urls * 2); assert_eq!(events.len(), expected_urls * 2);
assert_eq!(events[0].kind, OfficialResourcePullProgressKind::Started);
assert_eq!(events[0].index, 1); let started: Vec<_> = events
assert_eq!(events[0].total, expected_urls); .iter()
assert_eq!(events[1].kind, OfficialResourcePullProgressKind::Finished); .filter(|event| event.kind == OfficialResourcePullProgressKind::Started)
assert_eq!( .collect();
events[1].status, let finished: Vec<_> = events
Some(OfficialResourcePullStatus::Downloaded) .iter()
); .filter(|event| event.kind == OfficialResourcePullProgressKind::Finished)
.collect();
assert_eq!(started.len(), expected_urls);
assert_eq!(finished.len(), expected_urls);
// 每个 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();
let finished_urls: HashSet<_> = finished.iter().map(|event| event.url.clone()).collect();
assert_eq!(started_urls, finished_urls);
assert!(finished
.iter()
.all(|event| event.status == Some(OfficialResourcePullStatus::Downloaded)));
assert!(events assert!(events
.iter() .iter()
.any(|event| event.url.ends_with("/TableBundles/ExcelDB.db"))); .any(|event| event.url.ends_with("/TableBundles/ExcelDB.db")));
} }
#[test]
fn downloads_run_sequentially_and_each_url_reports_once() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
// 顺序下载:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let plan = build_official_pull_plan_for_platforms(
discovery_plan(),
inventory(),
&[PatchPlatform::Windows],
);
let all_urls = plan.all_urls().unwrap();
let mut events = Vec::new();
let report = service
.pull_with_progress(&plan, |event| events.push(event))
.unwrap();
assert_eq!(report.items.len(), all_urls.len());
for url in &all_urls {
let started = events
.iter()
.filter(|event| {
&event.url == url && event.kind == OfficialResourcePullProgressKind::Started
})
.count();
let finished = events
.iter()
.filter(|event| {
&event.url == url && event.kind == OfficialResourcePullProgressKind::Finished
})
.count();
assert_eq!(started, 1, "url {url} 的 started 次数");
assert_eq!(finished, 1, "url {url} 的 finished 次数");
}
// 所有下载都记入 manifest。
let manifest = service.read_download_manifest().unwrap();
assert_eq!(manifest.entries.len(), all_urls.len());
}
#[test] #[test]
fn retries_transient_download_failures() { fn retries_transient_download_failures() {
let out_dir = TempDir::new().unwrap(); let out_dir = TempDir::new().unwrap();
+5 -5
View File
@@ -186,7 +186,7 @@ mod tests {
YostarJpDownloadInventory::from_catalog_bytes( YostarJpDownloadInventory::from_catalog_bytes(
b"FullPatch_000.zip", b"FullPatch_000.zip",
b"ExcelDB.db ExcelDB.db", 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( YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Windows, PatchPlatform::Windows,
b"FullPatch_000.zip", b"FullPatch_000.zip",
b"JP_Airi_Win.zip", b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip",
), ),
YostarJpPlatformCatalogInventory::from_catalog_bytes( YostarJpPlatformCatalogInventory::from_catalog_bytes(
PatchPlatform::Android, PatchPlatform::Android,
b"FullPatch_001.zip", 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[0].ends_with("TableCatalog.bytes"));
assert!(all_urls assert!(all_urls
.iter() .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!( assert_eq!(
all_urls all_urls
.iter() .iter()
.filter(|url| url.ends_with("/MediaResources/JP_Airi.zip")) .filter(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi.zip"))
.count(), .count(),
1 1
); );
+3 -3
View File
@@ -1649,7 +1649,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new( OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
"download", "download",
format!( format!(
"下载进度:总体 {}/{} ({:.1}%);单文件开始 URL={}", "下载进度:已完成 {}/{} ({:.1}%);单文件开始 URL={}",
event.index, event.index,
event.total, event.total,
download_progress_percent(event.index, event.total), download_progress_percent(event.index, event.total),
@@ -1662,7 +1662,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
OfficialUpdateProgress::new( OfficialUpdateProgress::new(
"download", "download",
format!( format!(
"下载进度:总体 {}/{} ({:.1}%);单文件完成 状态={} 文件字节={} 本轮传输字节={} URL={}", "下载进度:已完成 {}/{} ({:.1}%);单文件完成 状态={} 文件字节={} 本轮传输字节={} URL={}",
event.index, event.index,
event.total, event.total,
download_progress_percent(event.index, event.total), download_progress_percent(event.index, event.total),
@@ -1677,7 +1677,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
OfficialResourcePullProgressKind::Failed => OfficialUpdateProgress::new( OfficialResourcePullProgressKind::Failed => OfficialUpdateProgress::new(
"download", "download",
format!( format!(
"下载中断:总体 {}/{} ({:.1}%);失败类型={} HTTP={} 可重试={} 尝试次数={} quarantine={};本轮跳过该 URL 且不会发布不完整资源 URL={}", "下载中断:已完成 {}/{} ({:.1}%);失败类型={} HTTP={} 可重试={} 尝试次数={} quarantine={};本轮跳过该 URL 且不会发布不完整资源 URL={}",
event.index, event.index,
event.total, event.total,
download_progress_percent(event.index, event.total), download_progress_percent(event.index, event.total),
+47 -9
View File
@@ -129,13 +129,18 @@ impl SqliteResourceRepository {
resource_type TEXT NOT NULL, resource_type TEXT NOT NULL,
local_path TEXT NOT NULL, local_path TEXT NOT NULL,
address TEXT, address TEXT,
dependencies_json TEXT NOT NULL DEFAULT '[]' dependencies_json TEXT NOT NULL DEFAULT '[]',
crc INTEGER
) )
"#, "#,
), ),
) )
.await?; .await?;
// 向后兼容:早于 crc 列的旧库缺少该列,按需补加(新建库已含该列,
// pragma 检查后不会重复 ALTER)。
Self::ensure_column(&self.pool, "resources", "crc", "INTEGER").await?;
Self::execute_query( Self::execute_query(
&self.pool, &self.pool,
sqlx::query( sqlx::query(
@@ -182,6 +187,33 @@ impl SqliteResourceRepository {
.map_err(|error| bat_core::Error::Other(error.into())) .map_err(|error| bat_core::Error::Other(error.into()))
} }
/// 幂等地为 `table` 补加 `column`(若尚不存在)。用于向后兼容的 schema 迁移。
async fn ensure_column(
pool: &SqlitePool,
table: &str,
column: &str,
column_type: &str,
) -> bat_core::Result<()> {
let exists: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info(?1) WHERE name = ?2")
.bind(table)
.bind(column)
.fetch_one(pool)
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
if exists == 0 {
// 表名/列名/类型均为内部常量,非用户输入,可安全内插。
Self::execute_query(
pool,
sqlx::query(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {column_type}"
)),
)
.await?;
}
Ok(())
}
fn resource_type_to_str(resource_type: ResourceType) -> &'static str { fn resource_type_to_str(resource_type: ResourceType) -> &'static str {
match resource_type { match resource_type {
ResourceType::AssetBundle => "AssetBundle", ResourceType::AssetBundle => "AssetBundle",
@@ -219,7 +251,8 @@ impl SqliteResourceRepository {
} }
fn resource_from_row(row: ResourceRow) -> bat_core::Result<Resource> { fn resource_from_row(row: ResourceRow) -> bat_core::Result<Resource> {
let (id, path, hash, size, resource_type, local_path, address, dependencies_json) = row; let (id, path, hash, size, resource_type, local_path, address, dependencies_json, crc) =
row;
Ok(Resource { Ok(Resource {
id, id,
local_path: PathBuf::from(local_path), local_path: PathBuf::from(local_path),
@@ -230,6 +263,7 @@ impl SqliteResourceRepository {
resource_type: Self::resource_type_from_str(&resource_type)?, resource_type: Self::resource_type_from_str(&resource_type)?,
address, address,
dependencies: Self::dependencies_from_json(&dependencies_json)?, dependencies: Self::dependencies_from_json(&dependencies_json)?,
crc: crc.and_then(|value| u32::try_from(value).ok()),
}, },
}) })
} }
@@ -269,7 +303,7 @@ impl SqliteResourceRepository {
limit: Option<usize>, limit: Option<usize>,
) -> bat_core::Result<Vec<Resource>> { ) -> bat_core::Result<Vec<Resource>> {
let mut builder = QueryBuilder::<Sqlite>::new( let mut builder = QueryBuilder::<Sqlite>::new(
"SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json FROM resources", "SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc FROM resources",
); );
Self::apply_filters(&mut builder, query)?; Self::apply_filters(&mut builder, query)?;
builder.push(" ORDER BY id"); builder.push(" ORDER BY id");
@@ -308,9 +342,9 @@ impl ResourceRepository for SqliteResourceRepository {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO resources ( INSERT INTO resources (
id, path, hash, size, resource_type, local_path, address, dependencies_json id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
) )
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
path = excluded.path, path = excluded.path,
hash = excluded.hash, hash = excluded.hash,
@@ -318,7 +352,8 @@ impl ResourceRepository for SqliteResourceRepository {
resource_type = excluded.resource_type, resource_type = excluded.resource_type,
local_path = excluded.local_path, local_path = excluded.local_path,
address = excluded.address, address = excluded.address,
dependencies_json = excluded.dependencies_json dependencies_json = excluded.dependencies_json,
crc = excluded.crc
"#, "#,
) )
.bind(resource.id.clone()) .bind(resource.id.clone())
@@ -328,7 +363,8 @@ impl ResourceRepository for SqliteResourceRepository {
.bind(Self::resource_type_to_str(resource.entry.resource_type)) .bind(Self::resource_type_to_str(resource.entry.resource_type))
.bind(resource.local_path.to_string_lossy().to_string()) .bind(resource.local_path.to_string_lossy().to_string())
.bind(resource.entry.address.clone()) .bind(resource.entry.address.clone())
.bind(dependencies), .bind(dependencies)
.bind(resource.entry.crc.map(i64::from)),
) )
.await?; .await?;
@@ -338,7 +374,7 @@ impl ResourceRepository for SqliteResourceRepository {
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> { async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
let row: Option<ResourceRow> = sqlx::query_as( let row: Option<ResourceRow> = sqlx::query_as(
r#" r#"
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
FROM resources FROM resources
WHERE id = ?1 WHERE id = ?1
"#, "#,
@@ -356,7 +392,7 @@ impl ResourceRepository for SqliteResourceRepository {
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> { async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
let row: Option<ResourceRow> = sqlx::query_as( let row: Option<ResourceRow> = sqlx::query_as(
r#" r#"
SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json SELECT id, path, hash, size, resource_type, local_path, address, dependencies_json, crc
FROM resources FROM resources
WHERE hash = ?1 WHERE hash = ?1
ORDER BY id ORDER BY id
@@ -418,6 +454,7 @@ type ResourceRow = (
String, String,
Option<String>, Option<String>,
String, String,
Option<i64>,
); );
fn glob_to_like(pattern: &str) -> String { fn glob_to_like(pattern: &str) -> String {
@@ -505,6 +542,7 @@ mod tests {
resource_type, resource_type,
address: None, address: None,
dependencies: Vec::new(), dependencies: Vec::new(),
crc: None,
}, },
} }
} }
@@ -231,7 +231,7 @@ fn official_update_first_run_pulls_without_pre_audit() {
.any(|event| { event.stage == "decision" && event.message.contains("首次拉取=true") })); .any(|event| { event.stage == "decision" && event.message.contains("首次拉取=true") }));
assert!(events.iter().any(|event| { assert!(events.iter().any(|event| {
event.stage == "download" event.stage == "download"
&& event.message.contains("下载进度:总体") && event.message.contains("下载进度:已完成")
&& event.message.contains("单文件") && event.message.contains("单文件")
})); }));
assert!(events.iter().any(|event| { assert!(events.iter().any(|event| {
@@ -330,6 +330,75 @@ fn official_update_second_run_audits_existing_resources_before_reuse() {
assert!(version_state.in_progress_version.is_none()); assert!(version_state.in_progress_version.is_none());
} }
#[test]
fn official_update_reuses_failed_staging_after_interrupted_download() {
let harness = TestHarness::new();
let bootstrap = harness.fetch_bootstrap();
let fetcher = harness.fetcher();
let server_info_url = bootstrap
.game_main_config
.server_info_data_url
.as_ref()
.unwrap()
.clone();
let server_info_bytes = fetcher.fetch_bytes(&server_info_url).unwrap();
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes).unwrap();
let discovery = server_info
.discovery_plan(
bootstrap
.game_main_config
.default_connection_group
.as_deref()
.unwrap(),
&bootstrap.game_config.game_latest_version,
&verified_official_platforms(),
)
.unwrap();
let inventory = fetch_platform_inventory(&fetcher, &discovery);
let plan = build_official_pull_plan_from_platform_inventory(discovery, inventory);
let all_urls = plan.all_urls().unwrap();
let resume_url = all_urls
.iter()
.find(|url| url.ends_with("/Windows_PatchPack/catalog_StandaloneWindows64.zip"))
.expect("fixture plan should contain Windows addressables catalog zip")
.clone();
let resources_assets_path = harness.temp.path().join("resources.assets");
let zip_fixture = harness.temp.path().join("downloaded.zip");
let curl_failure_state = harness.temp.path().join("curl-failure.state");
write_executable(
&harness.curl_script,
&official_curl_script(
&harness.curl_log,
&zip_fixture,
&resources_assets_path,
fs::metadata(&resources_assets_path).unwrap().len() as usize,
TEST_LAUNCHER_MANIFEST_SOURCE,
Some(&curl_failure_state),
),
);
let config = harness.sync_config("failed-staging-output");
let first_error = OfficialUpdateService::new().run(&config).unwrap_err();
assert!(first_error.to_string().contains("quarantine"));
assert!(first_error.to_string().contains("simulated failure"));
let first_log = fs::read_to_string(&harness.curl_log).unwrap();
assert!(first_log.contains(&resume_url));
let report = OfficialUpdateService::new().run(&config).unwrap();
assert_eq!(report.update_status, OfficialUpdateStatus::Downloaded);
let second_log = fs::read_to_string(&harness.curl_log).unwrap();
assert_eq!(second_log.matches(&resume_url).count(), 1);
let version_state = read_version_state(&report.version_state_path)
.unwrap()
.unwrap();
assert!(version_state.failed_versions.is_empty());
assert!(version_state.in_progress_version.is_none());
assert!(version_state.current_completed_version.is_some());
}
struct TestHarness { struct TestHarness {
temp: TempDir, temp: TempDir,
curl_script: std::path::PathBuf, curl_script: std::path::PathBuf,
@@ -362,6 +431,7 @@ impl TestHarness {
&resources_assets_path, &resources_assets_path,
resources_assets.len(), resources_assets.len(),
manifest_source, manifest_source,
None,
), ),
); );
@@ -478,11 +548,16 @@ fn official_curl_script(
resources_assets_fixture: &Path, resources_assets_fixture: &Path,
resources_assets_size: usize, resources_assets_size: usize,
manifest_source: &str, manifest_source: &str,
failure_state: Option<&Path>,
) -> String { ) -> String {
let failure_state = failure_state
.map(shell_quote)
.unwrap_or_else(|| "''".to_string());
format!( format!(
r#"#!/usr/bin/env bash r#"#!/usr/bin/env bash
set -euo pipefail set -euo pipefail
log={} log={}
failure_state={failure_state}
printf '%s\n' "$*" >> "$log" printf '%s\n' "$*" >> "$log"
output="" output=""
url="" url=""
@@ -522,6 +597,23 @@ emit_resources_assets_fixture() {{
cp {} "$output" cp {} "$output"
}} }}
maybe_fail_once() {{
if [[ -z "$failure_state" ]]; then
return 0
fi
local attempt=0
if [[ -f "$failure_state" ]]; then
attempt="$(cat "$failure_state")"
fi
attempt=$((attempt + 1))
mkdir -p "$(dirname "$failure_state")"
printf '%s' "$attempt" > "$failure_state"
if [[ "$attempt" -le 3 ]]; then
echo "simulated failure for $url attempt=$attempt" >&2
exit 22
fi
}}
if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then
cat <<'JSON' cat <<'JSON'
{{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}} {{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}}
@@ -561,7 +653,7 @@ elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.bytes
elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.hash" ]]; then elif [[ "$url" == "{addressables_root}/Windows_PatchPack/BundlePackingInfo.hash" ]]; then
emit_text "{windows_bundle_catalog_hash}" emit_text "{windows_bundle_catalog_hash}"
elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.bytes" ]]; then 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 elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.hash" ]]; then
emit_text "{windows_media_catalog_hash}" emit_text "{windows_media_catalog_hash}"
elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.bytes" ]]; then elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.bytes" ]]; then
@@ -569,10 +661,11 @@ elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.bytes
elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.hash" ]]; then elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.hash" ]]; then
emit_text "{android_bundle_catalog_hash}" emit_text "{android_bundle_catalog_hash}"
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.bytes" ]]; then 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 elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.hash" ]]; then
emit_text "{android_media_catalog_hash}" emit_text "{android_media_catalog_hash}"
elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then
maybe_fail_once
filename="${{url##*/}}" filename="${{url##*/}}"
if [[ "$filename" == *.zip ]]; then if [[ "$filename" == *.zip ]]; then
emit_zip_fixture emit_zip_fixture
@@ -601,9 +694,10 @@ fi
addressables_root = TEST_ADDRESSABLES_ROOT, addressables_root = TEST_ADDRESSABLES_ROOT,
table_catalog_hash = xxhash32(b"ExcelDB.db ExcelDB.db"), table_catalog_hash = xxhash32(b"ExcelDB.db ExcelDB.db"),
windows_bundle_catalog_hash = xxhash32(b"FullPatch_000.zip"), 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_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"),
failure_state = failure_state,
) )
} }
+423
View File
@@ -0,0 +1,423 @@
// Package backendrpc is the Go client for the local Rust Resource Backend.
//
// The package talks directly to bat.sock over newline-delimited JSON-RPC 2.0.
// It is the default Go integration path for bat-api; do not shell out to the
// bat binary for normal service calls.
package backendrpc
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"sync/atomic"
"time"
)
const jsonRPCVersion = "2.0"
// Client calls the local Rust daemon through a Unix domain socket.
type Client struct {
SocketPath string
Timeout time.Duration
// DialContext exists for tests and alternative local transports.
DialContext func(ctx context.Context, network string, address string) (net.Conn, error)
seq atomic.Uint64
}
// New returns a client for the given bat.sock path.
func New(socketPath string) *Client {
return &Client{SocketPath: socketPath, Timeout: 30 * time.Second}
}
// APIError mirrors the Rust ApiError envelope payload.
type APIError struct {
Code string `json:"code"`
Kind string `json:"kind"`
Domain string `json:"domain"`
Location string `json:"location"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
func (e *APIError) Error() string {
if e == nil {
return "<nil api error>"
}
if e.Code == "" {
return e.Message
}
return fmt.Sprintf("%s %s", e.Code, e.Message)
}
// JSONRPCError is a transport-level JSON-RPC error.
type JSONRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *JSONRPCError) Error() string {
if e == nil {
return "<nil json-rpc error>"
}
return fmt.Sprintf("json-rpc error %d: %s", e.Code, e.Message)
}
// Envelope is the application-level payload carried in JSON-RPC result.
type Envelope struct {
OK bool `json:"ok"`
Status string `json:"status"`
Data json.RawMessage `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
RequestID string `json:"request_id"`
}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID uint64 `json:"id"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID uint64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
}
// Call invokes method and decodes the envelope data into out when out is non-nil.
func (c *Client) Call(ctx context.Context, method string, params any, out any) (*Envelope, error) {
envelope, err := c.callEnvelope(ctx, method, params)
if err != nil {
return nil, err
}
if !envelope.OK {
if envelope.Error != nil {
return envelope, envelope.Error
}
return envelope, errors.New("backend rpc returned ok=false without error payload")
}
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return envelope, fmt.Errorf("decode %s response data: %w", method, err)
}
}
return envelope, nil
}
func (c *Client) callEnvelope(ctx context.Context, method string, params any) (*Envelope, error) {
if c.SocketPath == "" {
return nil, errors.New("backend rpc socket path is empty")
}
conn, err := c.dial(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
if deadline, ok := c.deadline(ctx); ok {
_ = conn.SetDeadline(deadline)
}
req := rpcRequest{
JSONRPC: jsonRPCVersion,
ID: c.seq.Add(1),
Method: method,
Params: params,
}
if err := json.NewEncoder(conn).Encode(&req); err != nil {
return nil, fmt.Errorf("write backend rpc request %s: %w", method, err)
}
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("read backend rpc response %s: %w", method, err)
}
var resp rpcResponse
if err := json.Unmarshal(line, &resp); err != nil {
return nil, fmt.Errorf("decode backend rpc response %s: %w", method, err)
}
if resp.Error != nil {
return nil, resp.Error
}
if len(resp.Result) == 0 {
return nil, fmt.Errorf("backend rpc %s response missing result", method)
}
var envelope Envelope
if err := json.Unmarshal(resp.Result, &envelope); err != nil {
return nil, fmt.Errorf("decode backend rpc envelope %s: %w", method, err)
}
return &envelope, nil
}
func (c *Client) dial(ctx context.Context) (net.Conn, error) {
if c.DialContext != nil {
return c.DialContext(ctx, "unix", c.SocketPath)
}
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", c.SocketPath)
}
func (c *Client) deadline(ctx context.Context) (time.Time, bool) {
if deadline, ok := ctx.Deadline(); ok {
return deadline, true
}
if c.Timeout > 0 {
return time.Now().Add(c.Timeout), true
}
return time.Time{}, false
}
type boolParam struct {
Force bool `json:"force"`
}
type pageParam struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
}
type tailParam struct {
Tail int `json:"tail"`
}
type taskIDParam struct {
TaskID string `json:"task_id"`
}
// Ack is returned by accepted daemon control methods.
type Ack struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
StateDir string `json:"state_dir"`
SocketPath string `json:"socket_path"`
Force *bool `json:"force,omitempty"`
}
// TaskAccepted is returned when an async backend task is queued.
type TaskAccepted struct {
TaskID string `json:"task_id"`
Kind string `json:"kind"`
}
// TaskRecord is the pollable task state.
type TaskRecord struct {
ID string `json:"id"`
Kind string `json:"kind"`
Status string `json:"status"`
Stage *string `json:"stage,omitempty"`
Message *string `json:"message,omitempty"`
CreatedAt uint64 `json:"created_at"`
UpdatedAt uint64 `json:"updated_at"`
StartedAt *uint64 `json:"started_at,omitempty"`
FinishedAt *uint64 `json:"finished_at,omitempty"`
Error *APIError `json:"error,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
}
type TaskList struct {
Tasks []TaskRecord `json:"tasks"`
}
type TaskCancelResult struct {
TaskID string `json:"task_id"`
CancelRequested bool `json:"cancel_requested"`
Note string `json:"note,omitempty"`
}
type TaskLogs struct {
TaskID string `json:"task_id"`
Lines []string `json:"lines"`
}
type DoctorCheck struct {
Name string `json:"name"`
OK bool `json:"ok"`
Message string `json:"message"`
}
type DoctorReport struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
Healthy bool `json:"healthy"`
Checks []DoctorCheck `json:"checks"`
}
type LogsReport struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
LogPath string `json:"log_path"`
Exists bool `json:"exists"`
Empty bool `json:"empty"`
Bytes int `json:"bytes"`
TotalLines int `json:"total_lines"`
ReturnedLines int `json:"returned_lines"`
Content string `json:"content"`
}
type DaemonStatusReport struct {
Status string `json:"status"`
Message string `json:"message"`
Running bool `json:"running"`
PID *int `json:"pid,omitempty"`
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
StateDir string `json:"state_dir"`
SocketPath string `json:"socket_path"`
RPCAvailable bool `json:"rpc_available"`
CurrentStage *string `json:"current_stage,omitempty"`
CurrentMessage *string `json:"current_message,omitempty"`
}
type ResourceState struct {
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
VersionState json.RawMessage `json:"version_state,omitempty"`
LastUpdateStatus *string `json:"last_update_status,omitempty"`
LastSuccessUnixSeconds *uint64 `json:"last_success_unix_seconds,omitempty"`
}
type ResourceManifestEntry struct {
URL string `json:"url"`
Destination string `json:"destination"`
Bytes *uint64 `json:"bytes,omitempty"`
BLAKE3 string `json:"blake3,omitempty"`
}
type ResourceManifestPage struct {
Available bool `json:"available"`
ResourceRoot string `json:"resource_root,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
TotalEntries int `json:"total_entries,omitempty"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
Entries []ResourceManifestEntry `json:"entries,omitempty"`
}
func (c *Client) DaemonStatus(ctx context.Context) (*DaemonStatusReport, error) {
var out DaemonStatusReport
_, err := c.Call(ctx, "daemon.status", nil, &out)
return &out, err
}
func (c *Client) DaemonLogs(ctx context.Context, tail int) (*LogsReport, error) {
var out LogsReport
_, err := c.Call(ctx, "daemon.logs", tailParam{Tail: tail}, &out)
return &out, err
}
func (c *Client) DaemonStop(ctx context.Context) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.stop", nil, &out)
return &out, err
}
func (c *Client) DaemonReload(ctx context.Context) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.reload", nil, &out)
return &out, err
}
func (c *Client) DaemonRefresh(ctx context.Context, force bool) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.refresh", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) DaemonDoctor(ctx context.Context) (*DoctorReport, error) {
var out DoctorReport
_, err := c.Call(ctx, "daemon.doctor", nil, &out)
return &out, err
}
func (c *Client) ResourceState(ctx context.Context) (*ResourceState, error) {
var out ResourceState
_, err := c.Call(ctx, "resource.state", nil, &out)
return &out, err
}
func (c *Client) ResourceSync(ctx context.Context, force bool) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.sync", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) ResourceVerify(ctx context.Context) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.verify", nil, &out)
return &out, err
}
func (c *Client) ResourceRepair(ctx context.Context) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.repair", nil, &out)
return &out, err
}
func (c *Client) ResourceManifest(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
var out ResourceManifestPage
_, err := c.Call(ctx, "resource.manifest", pageParam{Offset: offset, Limit: limit}, &out)
return &out, err
}
func (c *Client) ResourceList(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
var out ResourceManifestPage
_, err := c.Call(ctx, "resource.list", pageParam{Offset: offset, Limit: limit}, &out)
return &out, err
}
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.status", nil)
}
func (c *Client) CatalogVersions(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.versions", nil)
}
func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.diff", nil)
}
func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) TaskStatus(ctx context.Context, taskID string) (*TaskRecord, error) {
var out TaskRecord
_, err := c.Call(ctx, "task.status", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) TaskList(ctx context.Context) (*TaskList, error) {
var out TaskList
_, err := c.Call(ctx, "task.list", nil, &out)
return &out, err
}
func (c *Client) TaskCancel(ctx context.Context, taskID string) (*TaskCancelResult, error) {
var out TaskCancelResult
_, err := c.Call(ctx, "task.cancel", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) TaskLogs(ctx context.Context, taskID string) (*TaskLogs, error) {
var out TaskLogs
_, err := c.Call(ctx, "task.logs", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) rawData(ctx context.Context, method string, params any) (json.RawMessage, error) {
envelope, err := c.Call(ctx, method, params, nil)
if err != nil {
return nil, err
}
return envelope.Data, nil
}
+207
View File
@@ -0,0 +1,207 @@
package backendrpc
import (
"bufio"
"context"
"encoding/json"
"errors"
"net"
"testing"
"time"
)
type testRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type testEnvelope struct {
OK bool `json:"ok"`
Status string `json:"status"`
Data any `json:"data,omitempty"`
Error any `json:"error,omitempty"`
RequestID string `json:"request_id"`
}
type testResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
}
func newTestClient(t *testing.T, handler func(t *testing.T, req testRequest) testResponse) *Client {
t.Helper()
client := New("bat.sock")
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
clientConn, serverConn := net.Pipe()
go func(conn net.Conn) {
defer conn.Close()
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return
}
var req testRequest
if err := json.Unmarshal(line, &req); err != nil {
return
}
resp := handler(t, req)
if len(resp.ID) == 0 {
resp.ID = req.ID
}
if resp.JSONRPC == "" {
resp.JSONRPC = jsonRPCVersion
}
_ = json.NewEncoder(conn).Encode(resp)
}(serverConn)
return clientConn, nil
}
return client
}
func TestResourceRepairQueuesTask(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "resource.repair" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "accepted",
RequestID: "req-test-1",
Data: map[string]any{
"task_id": "task-1",
"kind": "resource.repair",
},
},
}
})
task, err := client.ResourceRepair(context.Background())
if err != nil {
t.Fatalf("ResourceRepair error: %v", err)
}
if task.TaskID != "task-1" || task.Kind != "resource.repair" {
t.Fatalf("unexpected task: %#v", task)
}
}
func TestResourceListSendsPagination(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "resource.list" {
t.Fatalf("method = %s", req.Method)
}
var params pageParam
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.Offset != 2 || params.Limit != 1 {
t.Fatalf("params = %#v", params)
}
bytes := uint64(42)
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-2",
Data: map[string]any{
"available": true,
"resource_root": "/tmp/resources/current",
"manifest_version": 1,
"total_entries": 3,
"offset": 2,
"limit": 1,
"entries": []map[string]any{
{
"url": "https://example.invalid/a.zip",
"destination": "a.zip",
"bytes": bytes,
"blake3": "abc",
},
},
},
},
}
})
page, err := client.ResourceList(context.Background(), 2, 1)
if err != nil {
t.Fatalf("ResourceList error: %v", err)
}
if !page.Available || page.TotalEntries != 3 || len(page.Entries) != 1 {
t.Fatalf("unexpected page: %#v", page)
}
if page.Entries[0].Bytes == nil || *page.Entries[0].Bytes != 42 {
t.Fatalf("unexpected entry bytes: %#v", page.Entries[0].Bytes)
}
}
func TestApplicationErrorReturnsAPIError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "task.status" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: false,
Status: "error",
Error: APIError{
Code: "BAT-ERR-700004",
Kind: "task_not_found",
Domain: "rpc",
Location: "task.status",
Message: "任务不存在:missing",
Retryable: false,
},
RequestID: "req-test-3",
},
}
})
_, err := client.TaskStatus(context.Background(), "missing")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T %v", err, err)
}
if apiErr.Code != "BAT-ERR-700004" {
t.Fatalf("code = %s", apiErr.Code)
}
}
func TestTransportErrorReturnsJSONRPCError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
return testResponse{
Error: &JSONRPCError{
Code: -32700,
Message: "parse error",
},
}
})
_, err := client.DaemonStatus(context.Background())
var rpcErr *JSONRPCError
if !errors.As(err, &rpcErr) {
t.Fatalf("expected JSONRPCError, got %T %v", err, err)
}
if rpcErr.Code != -32700 {
t.Fatalf("code = %d", rpcErr.Code)
}
}
func TestContextDeadlineIsApplied(t *testing.T) {
client := New("bat.sock")
client.Timeout = time.Millisecond
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
<-ctx.Done()
return nil, ctx.Err()
}
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
_, err := client.DaemonStatus(ctx)
if err == nil {
t.Fatal("expected dial error")
}
}
+2 -2
View File
@@ -85,7 +85,7 @@ run_step "02-first-full-pull" \
--progress \ --progress \
--no-banner --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 single-file download progress"
require_contains "${REPORT_DIR}/02-first-full-pull.stderr.log" "校验结果:" "first pull did not emit verification summary" 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" '"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.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 single-file download progress"
require_contains "${REPORT_DIR}/04-repair-after-damage.stderr.log" "校验结果:" "repair did not emit verification summary" require_contains "${REPORT_DIR}/04-repair-after-damage.stderr.log" "校验结果:" "repair did not emit verification summary"