mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 11:56:23 +08:00
Compare commits
15
Commits
v0.2.0
..
102b49b666
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
102b49b666
|
||
|
|
ecda08ed97
|
||
|
|
a729615a48
|
||
|
|
3f5d2a8da7
|
||
|
|
d694100d6c | ||
|
|
3add5f7327 | ||
|
|
40bd82e227 | ||
|
|
84047fbacb | ||
|
|
43e1a33b88
|
||
|
|
924cff5f51
|
||
|
|
d76f6f1c88
|
||
|
|
0ab3f3b953
|
||
|
|
8efd8f36b4
|
||
|
|
a150407a14
|
||
|
|
d9332299ef
|
@@ -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
@@ -5,7 +5,7 @@
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
bat
|
||||
/bat
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
+11
-5
@@ -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 的用户级工作流
|
||||
- [ ] 实现 AssetBundle 解析器(UnityFS header/block/directory 起步)
|
||||
- [ ] 继续逆向 Addressables catalog 可校验字段
|
||||
- [ ] 官方下载/导入路径接入 CRC/size 校验(复用 `verify_downloaded_bytes`)
|
||||
- [ ] 实现翻译系统
|
||||
- [ ] 实现 Patch 引擎
|
||||
- [ ] 实现 API Server
|
||||
- [ ] 实现 Web 管理后台
|
||||
|
||||
## [0.2.0] - 2026-07-17
|
||||
@@ -38,7 +44,7 @@
|
||||
- 新增官方同步路径安全边界:拒绝危险输出目录和 snapshot 路径逃逸,下载目标、manifest、daemon PID/status/log/control 文件不跟随 symlink,daemon 状态文件默认使用 `0600` 权限
|
||||
- 新增官方资源原子发布布局:非 dry-run 下载先进入 `.staging/<id>`,校验和 manifest/snapshot 写入完成后发布到 `versions/<id>`,再原子切换 `current` symlink
|
||||
- 新增 daemon 可观测性:`bat-events.jsonl` 结构化 JSONL 日志、日志轮转、status 中的当前下载进度、最后成功时间、下次检查时间和最后错误摘要
|
||||
- 新增运行时下载与校验 progress log:总体下载进度、单文件开始/完成状态、官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验摘要
|
||||
- 新增运行时下载与校验 progress log:下载已完成计数、单文件开始/完成状态、官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验摘要
|
||||
- 新增官方资源同步生产部署模板:release binary symlink 路径、systemd unit、运行用户、日志位置、升级和回滚流程
|
||||
- 新增真实官方网络全量拉取 smoke:`scripts/official-full-pull-smoke.sh`、`make official-smoke` 和 `docs/guides/official-full-pull-smoke.md`
|
||||
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
如果改动只影响部分 crate,可以先跑更窄的测试,但合并前必须确保影响面被覆盖。官方资源同步、下载、daemon、status、verify 或 repair 相关改动还应运行:
|
||||
|
||||
+39
-37
@@ -1,6 +1,6 @@
|
||||
# BlueArchiveToolkit 当前工作区状态
|
||||
|
||||
- **更新时间**:2026-07-15
|
||||
- **更新时间**:2026-07-20
|
||||
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
||||
- **状态分支**:`experiment`
|
||||
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## 1. 总体判断
|
||||
|
||||
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备最小生产运行形态、Go CLI/API/Web 仍未落地** 阶段。
|
||||
当前项目处于 **稳定基线完成、CAS V1 已落地、Rust 官方资源同步链路已具备可持续生产运行形态、Go `bat-api` 已有 Rust daemon RPC client 但 CLI/API/Web 仍未形成产品入口** 阶段。
|
||||
|
||||
Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
@@ -19,17 +19,17 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
3. 默认平台为 `Windows + Android`。
|
||||
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`。
|
||||
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。
|
||||
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。
|
||||
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` 会清除。
|
||||
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 误判为可复用。
|
||||
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` 权限创建。
|
||||
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`)。
|
||||
|
||||
仍需明确:这不是完整产品完成。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、注册表。
|
||||
- Addressables JSON catalog 的 path、hash、size、address、dependencies、metadata 解析。
|
||||
- 真实形态 Addressables fixture/golden 测试。
|
||||
- 当前 catalog、上一个版本 catalog、结构变化 catalog 的离线回归 fixture。
|
||||
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举。
|
||||
- 官方日服 `server-info`、URL 规则、平台 discovery 和 inventory 枚举;`MediaCatalog.bytes` 使用官方相对路径生成媒体 URL,覆盖 `GameData/`、`Prologue/` 下的 zip/mp4/png/jpg/ogg/wav 等媒体资源,避免把叶子文件名误拼到媒体根目录。
|
||||
|
||||
待完成:
|
||||
|
||||
- Unity bundle serialize 仍是后续阶段能力。
|
||||
- Addressables parser 仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
||||
- `crates/bat-assetbundle` 仍是占位 crate,完整 UnityFS/对象表/TypeTree 引擎未实现。
|
||||
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
||||
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
||||
|
||||
### `bat-cas-engine`
|
||||
@@ -148,9 +148,9 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
当前只有:
|
||||
|
||||
- Parser trait 占位。
|
||||
- AssetType 占位。
|
||||
- 错误类型骨架。
|
||||
- Parser trait 仍是占位。
|
||||
- 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。
|
||||
- 它不持有 downloader、daemon、CAS handle、资源目录锁或长生命周期状态。
|
||||
- Go CLI 和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。
|
||||
- 未来 Go 产品入口和生产运维默认应调用 `bat --json` 进程边界;未来稳定 SDK 也优先于 FFI。
|
||||
- FFI 仅用于需要嵌入 C ABI 的兼容场景,不能作为官方同步控制面或主集成边界。
|
||||
|
||||
待完成:
|
||||
@@ -198,34 +198,36 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
||||
|
||||
### 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` 已存在。
|
||||
- Go CLI 默认集成方向是调用 Rust `bat --json` 并转发结构化 report,而不是依赖 FFI。
|
||||
- `cmd/`、`pkg/`、`api/`、`web/` 仍无可用产品入口。
|
||||
- `go test ./...` 在没有 Go package 时可能无测试可运行;Makefile 会清晰跳过空 Go 阶段。
|
||||
- Go CLI 的稳定集成方向仍应优先通过 Rust daemon RPC 或 Rust `bat --json` one-shot 进程边界;`cmd/bat` 目前只是试验性骨架,不代表产品级 CLI 已完成。
|
||||
- `cmd/`、`pkg/`、`api/`、`web/` 仍无可用产品入口,`cmd/bat` 目前只覆盖 `doctor`、`manifest inspect`、`sync plan` 这类最小演示能力。
|
||||
- `go test ./...` 目前只有空测试包结果,`go vet ./...` 可作为基础门禁。
|
||||
|
||||
---
|
||||
|
||||
## 4. 已验证结果
|
||||
|
||||
最新功能提交前已运行并通过:
|
||||
本轮复核已运行并通过:
|
||||
|
||||
```bash
|
||||
cargo test -p bat-adapters -- --nocapture
|
||||
cargo test -p bat-ffi -- --nocapture
|
||||
cargo test -p bat-infrastructure -- --nocapture
|
||||
cargo test -p bat-infrastructure --bin bat -- --nocapture
|
||||
cargo run -p bat-infrastructure --bin bat -- --help
|
||||
git diff --cached --check
|
||||
cargo test --workspace --quiet
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build -o /tmp/bat-go-cli ./cmd/bat
|
||||
target/debug/bat --help
|
||||
git diff --check
|
||||
```
|
||||
|
||||
提交后确认:
|
||||
同步确认:
|
||||
|
||||
```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),当前处于长期运行测试阶段,运行报告将在后续提供。
|
||||
- Go CLI 端到端测试,因为 Go CLI 尚未实现。
|
||||
- Go CLI 端到端测试,因为 Go 产品入口尚未完成;`internal/backendrpc` 已有 fake transport 单测覆盖。
|
||||
- Web/API 测试,因为 Web/API 尚未实现。
|
||||
|
||||
---
|
||||
|
||||
## 5. 当前生产运行边界
|
||||
|
||||
当前唯一可作为 Linux 生产资源同步任务运行的入口是 Rust binary:
|
||||
当前唯一可作为 Linux 生产资源同步任务运行的入口仍是 Rust binary:
|
||||
|
||||
```bash
|
||||
cargo run -p bat-infrastructure --bin bat -- \
|
||||
@@ -263,12 +265,12 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
|
||||
## 6. 当前阻塞项
|
||||
|
||||
GitHub issue 状态:#4–#16 已全部关闭(#16 为 daemon status 版本失败输出与重复堆积 bug,已由失败版本去重和状态输出优化修复),当前 open 的是 #1(P1)、#2(P2)、#3(P2)。
|
||||
GitHub issue 状态:#1 已升为 P0;#17 的实现已合入 HEAD,但 issue 本身尚未关闭,需在验收后再同步关闭。其他 open issue 的实时标签以 GitHub 为准。
|
||||
|
||||
下一阶段必须优先完成:
|
||||
|
||||
1. Issue #1(P1,主体已实现):`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,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
|
||||
2. Go CLI 最小可用入口:`bat doctor`、稳定的 `bat --help` 命令结构,默认通过上述 RPC 或 `bat --json` 进程边界获取同步 report。
|
||||
1. Issue #1(P0,主体已实现):`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,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
|
||||
2. `bat-api` Go 侧:`internal/backendrpc` typed RPC client 已起步,`cmd/bat` 当前只实现 `doctor`、`manifest inspect` 和 `sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛。
|
||||
3. 官方同步结果接入 CAS + ResourceRepository 的用户级工作流(G-011 剩余部分:自动导入触发、schema 迁移、CLI 查询)。
|
||||
4. Issue #3(P2):AssetBundle UnityFS 基础解析校验。
|
||||
5. Issue #2(P2):继续逆向 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.*`(待引擎)与任务持久化按后续里程碑推进。
|
||||
2. 实现 Go CLI 最小框架和 `doctor`,通过 RPC 或 `bat --json` 边界对接 Rust backend。
|
||||
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`task.*`、`resource.repair`、任务持久化、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)以及 `task.create`、`daemon.restart`、`daemon.clean-stable` 的设计边界确认。
|
||||
2. 明确 Go 产品入口的边界:是继续推进独立 `bat` CLI,还是保留当前 Rust `bat` 为用户 CLI、Go 只做服务层与 `bat-api`。
|
||||
3. 跟进官方同步长期运行测试,收集并归档运行报告。
|
||||
4. 开始 AssetBundle parser 的 UnityFS header/block/directory(issue #3),并继续扩展 Addressables catalog 可校验字段(issue #2)。
|
||||
|
||||
---
|
||||
|
||||
- **当前总体完成度**:约 22%
|
||||
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;产品级 CLI/API/Web 仍未完成。
|
||||
- **下一工程里程碑**:Rust Resource Backend RPC API 最小方法集(issue #1)+ Go CLI 最小可用 + 官方同步结果接入 CAS/ResourceRepository + AssetBundle 解析起步。
|
||||
- **当前总体完成度**:不再固定写单一百分比,以各模块状态和 issue 为准。
|
||||
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go `bat-api` 已有 Rust daemon RPC client,但产品级 CLI/API、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。
|
||||
- **下一工程里程碑**:Rust Resource Backend RPC API 收尾、Go 产品入口收敛、官方同步结果接入 CAS/ResourceRepository、AssetBundle 解析起步。
|
||||
|
||||
+15
-11
@@ -1,6 +1,6 @@
|
||||
# BlueArchiveToolkit 文档索引
|
||||
|
||||
- **更新时间**:2026-07-15
|
||||
- **更新时间**:2026-07-24
|
||||
- **说明**:本索引用于快速定位当前权威文档和历史资料。
|
||||
|
||||
---
|
||||
@@ -14,6 +14,7 @@
|
||||
- `docs/guides/official-resource-test-pull.md`:官方资源拉取与自动更新用户指南。
|
||||
- `docs/guides/official-full-pull-smoke.md`:真实官方全量拉取 smoke runbook 和可重复命令。
|
||||
- `docs/architecture/official-resource-backend.md`:官方资源后端职责、工作原理和审核说明。
|
||||
- `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。
|
||||
- `CHANGELOG.md`:版本变更记录。
|
||||
- `AGENTS.md`:AI agent 和自动化开发助手长期规则。
|
||||
- `CONTRIBUTING.md`:贡献者协作、提交和验证要求。
|
||||
@@ -25,6 +26,7 @@
|
||||
|
||||
- `docs/architecture/README.md`:总体架构设计。
|
||||
- `docs/api/README.md`:API 设计入口。
|
||||
- `docs/reference/rpc-backend-api.md`:Rust Resource Backend JSON-RPC 稳定 contract。
|
||||
- `docs/guides/development.md`:开发指南。
|
||||
- `docs/guides/deployment.md`:部署指南。
|
||||
- `deployments/systemd/`:官方资源同步生产 systemd unit 和环境文件示例。
|
||||
@@ -78,18 +80,19 @@
|
||||
3. `docs/guides/official-resource-test-pull.md`
|
||||
4. `docs/guides/official-full-pull-smoke.md`
|
||||
5. `docs/architecture/official-resource-backend.md`
|
||||
6. `docs/reports/CURRENT_GAPS.md`
|
||||
7. `docs/guides/baseline.md`
|
||||
8. `docs/architecture/README.md`
|
||||
9. `docs/guides/development.md`
|
||||
10. `CONTRIBUTING.md`
|
||||
11. `AGENTS.md`
|
||||
6. `docs/reference/rpc-backend-api.md`
|
||||
7. `docs/reports/CURRENT_GAPS.md`
|
||||
8. `docs/guides/baseline.md`
|
||||
9. `docs/architecture/README.md`
|
||||
10. `docs/guides/development.md`
|
||||
11. `CONTRIBUTING.md`
|
||||
12. `AGENTS.md`
|
||||
|
||||
---
|
||||
|
||||
## 6. 状态摘要
|
||||
|
||||
当前总体完成度约 **22%**。
|
||||
当前总体完成度不再固定写单一百分比,以 `CURRENT_STATUS.md` 和 `CURRENT_GAPS.md` 的模块状态为准。
|
||||
|
||||
已完成:
|
||||
|
||||
@@ -98,13 +101,14 @@
|
||||
- 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` 校验。
|
||||
- `bat-api/internal/backendrpc` typed Unix socket JSON-RPC client。
|
||||
- 真实官方网络全量拉取 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`,默认写入 `/tmp` 隔离目录并输出本地运行报告。
|
||||
- `bat` 运行时 progress log 已覆盖总体下载进度、单文件下载进度和校验结果摘要。
|
||||
- `bat` 运行时 progress log 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
|
||||
- Addressables 当前真实形态 fixture/golden 覆盖。
|
||||
- SQLite Resource Repository 和可选无状态 `bat-ffi` JSON 兼容接口。
|
||||
|
||||
优先待办:
|
||||
|
||||
- 落地 Go CLI 最小可用入口。
|
||||
- 收敛 Go 产品入口的最终形态,避免把试验性 `cmd/bat` 误当作完成品。
|
||||
- 将官方同步结果接入 CAS + ResourceRepository 的用户级流程。
|
||||
- 开始 AssetBundle UnityFS 解析。
|
||||
- 推进 AssetBundle UnityFS 引擎级解析。
|
||||
|
||||
+34
-33
@@ -1,7 +1,7 @@
|
||||
# BlueArchiveToolkit 完整开发计划
|
||||
|
||||
- **项目名称**:BlueArchiveToolkit
|
||||
- **文档版本**:2026-07-06 状态收口版
|
||||
- **文档版本**:2026-07-20 状态收口版
|
||||
- **权威状态**:以本文档和 `CURRENT_STATUS.md` 为准,旧阶段报告仅作历史参考。
|
||||
- **最终目标**:构建一个可长期维护、可扩展、可审计的 Blue Archive 资源管理、文本提取、翻译和补丁平台。
|
||||
|
||||
@@ -22,7 +22,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 2. 当前真实状态
|
||||
|
||||
本节来自 2026-07-06 的工作区盘点、本地验证和最新功能提交。
|
||||
本节来自 2026-07-20 的工作区盘点、本地验证和最新功能提交。
|
||||
|
||||
### 已具备
|
||||
|
||||
@@ -32,15 +32,15 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
|
||||
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
|
||||
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。
|
||||
9. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。
|
||||
|
||||
### 仍是骨架或占位
|
||||
|
||||
1. AssetBundle 解析器仍是占位 trait,未解析 UnityFS、压缩块、TypeTree 或对象表。
|
||||
2. Patch 的 Binary/JSON 模块仍返回空结果,不具备真实补丁能力。
|
||||
3. Go CLI/API/SDK 仍没有产品级入口;只有 `internal/ffi` 的可选兼容包装骨架。
|
||||
1. `bat-assetbundle` 仍是占位 crate;完整 UnityFS、压缩块、TypeTree 或对象表解析未完成。
|
||||
2. `bat-patch` 的 Binary/JSON 模块仍返回明确的未实现错误,不具备真实补丁能力。
|
||||
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 兼容层。
|
||||
5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。
|
||||
6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
|
||||
@@ -49,12 +49,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
### 已验证
|
||||
|
||||
1. `cargo test -p bat-adapters -- --nocapture` 通过。
|
||||
2. `cargo test -p bat-ffi -- --nocapture` 通过。
|
||||
3. `cargo test -p bat-infrastructure -- --nocapture` 通过。
|
||||
4. `cargo test -p bat-infrastructure --bin bat -- --nocapture` 通过。
|
||||
5. `cargo run -p bat-infrastructure --bin bat -- --help` 可用。
|
||||
6. `go test ./...` 当前无 Go 产品 package;`Makefile` 已调整为在 Go 未实现阶段明确跳过。
|
||||
1. `cargo test --workspace --quiet` 通过。
|
||||
2. `cargo clippy --workspace --all-targets -- -D warnings` 通过。
|
||||
3. `go test ./...` 通过,但目前没有 Go 产品级测试覆盖。
|
||||
4. `go vet ./...` 通过。
|
||||
5. `go build -o /tmp/bat-go-cli ./cmd/bat` 通过。
|
||||
6. `target/debug/bat --help` 可用。
|
||||
|
||||
---
|
||||
|
||||
@@ -84,7 +84,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
1. 无占位返回、无静默吞错、无未说明的 `TODO`。
|
||||
2. 公共接口具备文档、错误语义和兼容性说明。
|
||||
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 ./...` 通过。
|
||||
6. 用户可见命令必须有 `doctor` 检查和失败恢复建议。
|
||||
|
||||
@@ -141,7 +141,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
**目标**:完成可长期使用的 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 资源清单。
|
||||
|
||||
**当前状态**:部分完成。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 可见模型。
|
||||
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 后台运维命令返回。
|
||||
5. Go CLI:**未完成**。需要实现 `bat doctor`、`bat sync --help`、Rust 官方同步命令包装和 JSON/human 输出。
|
||||
6. 用户级 `sync`、`manifest inspect`、`cache status`:**未完成**。Rust `bat --json` 是 Go CLI 默认进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
|
||||
7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果作为用户级流程自动导入。
|
||||
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` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
|
||||
7. 下载结果写入 CAS + ResourceRepository:**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口尚未把完整下载结果自动作为用户级流程导入。
|
||||
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 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
|
||||
|
||||
@@ -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:本地开发、服务端部署。
|
||||
3. 数据备份与恢复文档。
|
||||
4. 用户文档、开发文档、故障排查文档。
|
||||
@@ -366,7 +366,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 5. 推荐执行顺序
|
||||
|
||||
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go CLI 入口、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。
|
||||
近期不要直接跳到 Web 或 AI Provider。项目当前的真实瓶颈是 Go 产品入口边界、资源解析、同步结果进入 CAS/ResourceRepository,以及真实端到端验证。
|
||||
|
||||
建议顺序:
|
||||
|
||||
@@ -380,14 +380,15 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 6. 近期具体任务
|
||||
|
||||
1. 落地 Go CLI 的最小生产入口:`bat doctor`、`bat sync --help`、`bat official sync --help`。
|
||||
2. 让 Go CLI 默认调用 Rust `bat --json` 官方同步入口,并稳定转发结构化 report;除非有明确兼容需求,不走 FFI。
|
||||
3. 记录一次真实官方网络 smoke:dry-run、首次下载、二次 up-to-date、本地损坏 repair。
|
||||
4. 将官方同步下载结果接入 CAS + `SqliteResourceRepository` 的用户级流程。
|
||||
5. 继续扩展 Addressables parser 的真实 catalog 变体覆盖和错误诊断。
|
||||
6. 开始 AssetBundle UnityFS header/block/directory 解析。
|
||||
7. 为 CLI 和 CAS 增加 `doctor cas` 诊断入口。
|
||||
8. 为 `bat --watch` / `bat --daemon` 持续补充发布型构建、systemd service 示例和运维检查清单;后台 live control plane 已改为 Unix socket JSON-RPC;基础生产部署模板、日志路径、权限用户、升级/回滚流程已补齐。
|
||||
优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,`bat-api/internal/backendrpc` 已提供 Go 到 Rust daemon 的本机 RPC client,Go `cmd/bat` 仍只是试验骨架,`bat-api` HTTP 服务仍是独立目标(issue #19 / G-009):
|
||||
|
||||
1. 对 issue #17 做验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。
|
||||
2. 继续逆向 Addressables catalog,扩大 bundle hash/size/CRC 等可校验字段覆盖(issue #2)。
|
||||
3. 对 AssetBundle/UnityFS 做引擎级解析:header/block/directory/metadata/object table(issue #3 / G-005)。
|
||||
4. 将官方同步下载结果接入 CAS + `SqliteResourceRepository` 的用户级流程(G-011)。
|
||||
5. 收敛 Go 产品入口:明确继续推进最小 Go CLI,或把用户 CLI 固化为 Rust `bat` 并把 Go 侧集中到 `bat-api`。
|
||||
6. 实现 `bat-api`(仿官方 API 的 Go HTTP 服务,含鉴权/签名验签,issue #19 / G-009)。
|
||||
7. 为 CAS 增加 `doctor cas` 诊断入口。
|
||||
|
||||
---
|
||||
|
||||
@@ -414,8 +415,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
处理策略:
|
||||
|
||||
1. Rust 提供稳定引擎能力,不承担 CLI 编排,但负责完整资源拉取和更新检查的核心逻辑。
|
||||
2. Go 负责用户命令、最小稳定 CLI、服务编排、网络和 Provider。
|
||||
1. Rust 提供稳定引擎能力,并在当前阶段承担可生产运行的官方资源同步 CLI、watch 和 daemon。
|
||||
2. Go 的长期职责包括用户命令、最小稳定 CLI、服务编排、网络和 Provider;当前 Go 产品入口尚未完成,不能把 `cmd/bat` 试验骨架视为完成。
|
||||
3. 跨边界优先进程或 SDK,FFI 只作为可选的粗粒度、无状态、安全、可测试兼容 API。
|
||||
4. Rust 不需要被强制写成 Go 调用库;当前 `bat --watch` / `bat --daemon` 是允许长期运行的 Rust 生产任务。
|
||||
|
||||
@@ -439,11 +440,11 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
|
||||
## 8. 当前完成度评估
|
||||
|
||||
按最终目标计算,当前总体完成度约为 **22%**。
|
||||
按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。
|
||||
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1 和 Rust 官方资源同步闭环,不是完整产品能力。下一阶段的关键不是继续堆目录,而是把 Go CLI 最小入口、官方同步端到端验证、CAS/ResourceRepository 编排和 AssetBundle 解析链路做实。
|
||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1 和 Rust 官方资源同步闭环,不是完整产品能力。下一阶段的关键不是继续堆目录,而是把 Go 产品入口边界、官方同步端到端验证、CAS/ResourceRepository 编排和 AssetBundle 解析链路做实。
|
||||
|
||||
---
|
||||
|
||||
- **下一份应更新文档**:真实官方网络 smoke 记录
|
||||
- **下一项工程任务**:Go CLI 最小可用入口和官方同步端到端 smoke。
|
||||
- **下一项工程任务**:收敛 Go 产品入口边界、执行官方同步端到端 smoke,并推进 CAS/ResourceRepository 与 AssetBundle 解析。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**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 结构。
|
||||
- `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-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`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||
- 资源导入链路可将 manifest 条目写入 CAS + `ResourceRepository`,AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会按类型分类索引。
|
||||
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
|
||||
@@ -21,8 +22,8 @@
|
||||
|
||||
仍未完成:
|
||||
|
||||
- Go CLI 最小可用入口。
|
||||
- 完整 UnityFS / AssetBundle 解析。
|
||||
- Go CLI 产品入口(当前仅有试验性 `cmd/bat` 骨架)。
|
||||
- 完整 UnityFS / AssetBundle 引擎解析。
|
||||
- 真实 Patch apply/diff。
|
||||
- Translation Memory、Glossary、AI Provider。
|
||||
- API Server、SDK、Web 管理后台。
|
||||
@@ -48,13 +49,13 @@
|
||||
- `curl`
|
||||
- `unzip`,仅旧版 launcher manifest 指向整包 ZIP 且 `--auto-discover` 需要从 ZIP 解析 `GameMainConfig` 时使用;当前目录型 manifest 会直接下载 `resources.assets`
|
||||
|
||||
运行当前主要测试:
|
||||
运行当前通用验证:
|
||||
|
||||
```bash
|
||||
cargo test -p bat-adapters -- --nocapture
|
||||
cargo test -p bat-ffi -- --nocapture
|
||||
cargo test -p bat-infrastructure -- --nocapture
|
||||
cargo test -p bat-infrastructure --bin bat -- --nocapture
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace --all-targets -- -D warnings
|
||||
go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
查看官方同步命令:
|
||||
@@ -80,7 +81,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--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
|
||||
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
|
||||
```
|
||||
|
||||
`status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。
|
||||
`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 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` 权限创建。
|
||||
|
||||
@@ -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。
|
||||
|
||||
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>/` 隔离目录,不会写入已有客户端、生产目录或开发机人工维护资源目录:
|
||||
|
||||
@@ -156,8 +157,9 @@ BlueArchiveToolkit/
|
||||
│ ├── bat-assetbundle/
|
||||
│ ├── bat-patch/
|
||||
│ └── bat-ffi/ # 可选无状态 C ABI 兼容层
|
||||
├── internal/backendrpc/ # Go -> Rust daemon 的 typed JSON-RPC client
|
||||
├── internal/ffi/ # 可选 CGO 兼容包装,不是 Go CLI 主路径
|
||||
├── cmd/ # Go CLI 入口,尚未实现
|
||||
├── cmd/ # Go CLI 试验骨架与后续产品入口
|
||||
├── pkg/ # Go SDK 包,尚未实现
|
||||
├── api/ # API 定义,尚未实现
|
||||
├── web/ # Web 管理后台,尚未实现
|
||||
@@ -174,13 +176,13 @@ BlueArchiveToolkit/
|
||||
|
||||
近期优先级:
|
||||
|
||||
1. 落地 Go CLI 最小可用入口:`bat doctor`、`bat sync --help`、通过 `bat --json` 包装 Rust 同步命令。
|
||||
2. 补齐 AssetBundle UnityFS header/block/directory 解析。
|
||||
1. 收敛 Go CLI 产品入口的最终形态:当前 `cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,不应误写成完整 CLI。
|
||||
2. 补齐 AssetBundle UnityFS 引擎级解析。
|
||||
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
|
||||
4. 将官方同步结果接入 CAS + ResourceRepository 的用户级工作流。
|
||||
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
|
||||
|
||||
不建议在 Go CLI、资源解析和文本提取基础能力完成前优先开发 Web UI。
|
||||
不建议在 Go 产品入口、资源解析和文本提取基础能力完成前优先开发 Web UI。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-6
@@ -166,7 +166,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
|
||||
`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 接口
|
||||
|
||||
`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
|
||||
|
||||
@@ -271,10 +273,12 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
||||
| `daemon.stop` | ✅ | 请求停止(`accepted`) |
|
||||
| `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted`) |
|
||||
| `daemon.refresh` | ✅ | 请求刷新检查(`params.force`,`accepted`) |
|
||||
| `daemon.doctor` | ✅ | 返回运行时诊断报告(只读,不清理、不重启) |
|
||||
| `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 |
|
||||
| `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `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.versions` | ✅ | 版本历史:current / in_progress / previous / failed |
|
||||
| `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.cancel` | ✅ | 请求取消任务(`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) |
|
||||
|
||||
只读查询(`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
|
||||
{ "id": "task-1234-1", "kind": "resource.sync",
|
||||
@@ -324,4 +328,8 @@ printf '{"jsonrpc":"2.0","id":4,"method":"catalog.versions"}\n' \
|
||||
# 分页读取当前版本的下载 manifest
|
||||
printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":0,"limit":50}}\n' \
|
||||
| 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
|
||||
```
|
||||
|
||||
@@ -272,6 +272,12 @@ impl AddressablesCatalogDriver {
|
||||
.unwrap_or_default();
|
||||
|
||||
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 {
|
||||
path: path.to_string(),
|
||||
@@ -280,6 +286,7 @@ impl AddressablesCatalogDriver {
|
||||
resource_type: Self::resource_type_for_path(path),
|
||||
address,
|
||||
dependencies,
|
||||
crc,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -376,6 +383,7 @@ impl AddressablesCatalogDriver {
|
||||
resource_type: Self::resource_type_for_path(path),
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -413,6 +421,7 @@ impl AddressablesCatalogDriver {
|
||||
resource_type,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -483,6 +492,7 @@ impl AddressablesCatalogDriver {
|
||||
Some(primary_key)
|
||||
},
|
||||
dependencies,
|
||||
crc: extra.crc,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -619,6 +629,11 @@ impl AddressablesCatalogDriver {
|
||||
.and_then(|value| value.as_str())
|
||||
.map(ToOwned::to_owned),
|
||||
bundle_size: json.get("m_BundleSize").and_then(|value| value.as_u64()),
|
||||
// m_Crc 是 bundle 的 IEEE CRC-32;0 表示不做 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>,
|
||||
bundle_name: Option<String>,
|
||||
bundle_size: Option<u64>,
|
||||
crc: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for AddressablesCatalogDriver {
|
||||
@@ -954,6 +970,7 @@ mod tests {
|
||||
"internal_id": "synthetic/minimal.bundle",
|
||||
"hash": "synthetic-entry-hash",
|
||||
"size": 119,
|
||||
"crc": 3735928559,
|
||||
"address": "Character_001",
|
||||
"dependencies": ["synthetic/shared.bundle"]
|
||||
},
|
||||
@@ -971,6 +988,9 @@ mod tests {
|
||||
assert_eq!(manifest.resources[0].path, "synthetic/minimal.bundle");
|
||||
assert_eq!(manifest.resources[0].hash, "synthetic-entry-hash");
|
||||
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!(
|
||||
manifest.resources[0].address.as_deref(),
|
||||
Some("Character_001")
|
||||
|
||||
@@ -10,7 +10,7 @@ pub struct YostarJpDownloadInventory {
|
||||
pub bundle_patch_pack_names: Vec<String>,
|
||||
/// Table file names from `TableCatalog.bytes`.
|
||||
pub table_file_names: Vec<String>,
|
||||
/// Media file names from `MediaCatalog.bytes`.
|
||||
/// Media file relative paths from `MediaCatalog.bytes`.
|
||||
pub media_file_names: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub struct YostarJpPlatformCatalogInventory {
|
||||
pub platform: PatchPlatform,
|
||||
/// Patch-pack zip names from this platform's `BundlePackingInfo.bytes`.
|
||||
pub bundle_patch_pack_names: Vec<String>,
|
||||
/// Media file names from this platform's `MediaCatalog.bytes`.
|
||||
/// Media file relative paths from this platform's `MediaCatalog.bytes`.
|
||||
pub media_file_names: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -35,10 +35,7 @@ impl YostarJpPlatformCatalogInventory {
|
||||
Self {
|
||||
platform,
|
||||
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
|
||||
media_file_names: extract_file_names(
|
||||
media_catalog,
|
||||
&["zip", "mp4", "png", "ogg", "wav"],
|
||||
),
|
||||
media_file_names: extract_media_file_paths(media_catalog),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,10 +169,7 @@ impl YostarJpDownloadInventory {
|
||||
Self {
|
||||
bundle_patch_pack_names: extract_full_patch_pack_names(bundle_packing_info),
|
||||
table_file_names: extract_table_file_names(table_catalog),
|
||||
media_file_names: extract_file_names(
|
||||
media_catalog,
|
||||
&["zip", "mp4", "png", "ogg", "wav"],
|
||||
),
|
||||
media_file_names: extract_media_file_paths(media_catalog),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +321,20 @@ fn extract_file_names(data: &[u8], extensions: &[&str]) -> Vec<String> {
|
||||
names.into_iter().collect()
|
||||
}
|
||||
|
||||
fn extract_media_file_paths(data: &[u8]) -> Vec<String> {
|
||||
let mut paths = BTreeSet::new();
|
||||
|
||||
for string in extract_printable_strings(data, 4) {
|
||||
for path in
|
||||
candidate_relative_paths(&string, &["zip", "mp4", "png", "jpg", "jpeg", "ogg", "wav"])
|
||||
{
|
||||
paths.insert(path);
|
||||
}
|
||||
}
|
||||
|
||||
paths.into_iter().collect()
|
||||
}
|
||||
|
||||
fn extract_printable_strings(data: &[u8], min_len: usize) -> Vec<String> {
|
||||
let mut strings = Vec::new();
|
||||
let mut current = Vec::new();
|
||||
@@ -375,6 +383,32 @@ fn candidate_file_names(value: &str, extensions: &[&str]) -> Vec<String> {
|
||||
names
|
||||
}
|
||||
|
||||
fn candidate_relative_paths(value: &str, extensions: &[&str]) -> Vec<String> {
|
||||
let mut paths = Vec::new();
|
||||
let bytes = value.as_bytes();
|
||||
|
||||
for extension in extensions {
|
||||
let suffix = format!(".{extension}");
|
||||
let mut search_from = 0;
|
||||
|
||||
while let Some(relative_index) = value[search_from..].find(&suffix) {
|
||||
let extension_start = search_from + relative_index;
|
||||
let start = filename_start(bytes, extension_start);
|
||||
let end = extension_start + suffix.len();
|
||||
let candidate = &value[start..end];
|
||||
let candidate = candidate.replace('\\', "/");
|
||||
|
||||
if is_plausible_relative_path(&candidate) {
|
||||
paths.push(candidate);
|
||||
}
|
||||
|
||||
search_from = end;
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn filename_start(bytes: &[u8], mut index: usize) -> usize {
|
||||
while index > 0 {
|
||||
let byte = bytes[index - 1];
|
||||
@@ -409,6 +443,28 @@ fn is_plausible_file_name(name: &str) -> bool {
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
||||
}
|
||||
|
||||
fn is_plausible_relative_path(path: &str) -> bool {
|
||||
if path.is_empty()
|
||||
|| path.starts_with('/')
|
||||
|| path.starts_with('.')
|
||||
|| path.contains("..")
|
||||
|| path.contains(':')
|
||||
|| path.contains('=')
|
||||
|| !path.contains('/')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
path.split('/').all(|segment| {
|
||||
!segment.is_empty()
|
||||
&& segment != "."
|
||||
&& segment != ".."
|
||||
&& segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
|
||||
})
|
||||
}
|
||||
|
||||
fn unique_platforms(platforms: &[PatchPlatform]) -> Vec<PatchPlatform> {
|
||||
platforms
|
||||
.iter()
|
||||
@@ -441,7 +497,7 @@ mod tests {
|
||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||
b"prefix FullPatch_000.zip noise FullPatch_114.zip suffix",
|
||||
b"GameData\\Table\\ExcelDB.db\0ExcelDB.db\0rawdata/table/excel/ignored.bytes\0Battle.zip\0Battle.zip8",
|
||||
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0JP_Akane.zip",
|
||||
b"audio/voc_jp/jp_airi/jp_airi\0GameData\\Audio\\VOC_JP\\JP_Airi.zip8\0audio/voc_jp/jp_akane/jp_akane\0GameData\\Audio\\VOC_JP\\JP_Akane.zip",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -457,7 +513,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
inventory.media_file_names,
|
||||
vec!["JP_Airi.zip".to_string(), "JP_Akane.zip".to_string(),]
|
||||
vec![
|
||||
"GameData/Audio/VOC_JP/JP_Airi.zip".to_string(),
|
||||
"GameData/Audio/VOC_JP/JP_Akane.zip".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -482,7 +541,7 @@ mod tests {
|
||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||
b"FullPatch_000.zip FullPatch_001.zip",
|
||||
b"ExcelDB.db ExcelDB.db Battle.zip Battle.zip",
|
||||
b"JP_Airi.zip JP_Akane.zip",
|
||||
b"GameData\\Audio\\VOC_JP\\JP_Airi.zip GameData\\Audio\\VOC_JP\\JP_Akane.zip",
|
||||
);
|
||||
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
|
||||
|
||||
@@ -496,7 +555,7 @@ mod tests {
|
||||
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -504,7 +563,7 @@ mod tests {
|
||||
let inventory = YostarJpDownloadInventory::from_catalog_bytes(
|
||||
b"FullPatch_000.zip",
|
||||
b"ExcelDB.db ExcelDB.db",
|
||||
b"JP_Airi.zip",
|
||||
b"GameData\\Audio\\VOC_JP\\JP_Airi.zip",
|
||||
);
|
||||
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
|
||||
|
||||
@@ -524,10 +583,10 @@ mod tests {
|
||||
.any(|url| url.ends_with("/TableBundles/ExcelDB.db")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources/JP_Airi.zip")));
|
||||
.any(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi.zip")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -538,12 +597,12 @@ mod tests {
|
||||
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
||||
PatchPlatform::Windows,
|
||||
b"FullPatch_000.zip",
|
||||
b"JP_Airi_Win.zip",
|
||||
b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip",
|
||||
),
|
||||
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
||||
PatchPlatform::Android,
|
||||
b"FullPatch_001.zip",
|
||||
b"JP_Airi_Android.zip",
|
||||
b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip",
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -561,10 +620,11 @@ mod tests {
|
||||
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_001.zip")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi_Win.zip")));
|
||||
.any(|url| url
|
||||
.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi_Win.zip")));
|
||||
assert!(urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources/JP_Airi_Android.zip")));
|
||||
.any(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi_Android.zip")));
|
||||
assert!(!urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/Android_PatchPack/FullPatch_000.zip")));
|
||||
@@ -573,6 +633,30 @@ mod tests {
|
||||
.any(|url| url.ends_with("/Windows_PatchPack/FullPatch_001.zip")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_catalog_uses_download_relative_path_not_leaf_name() {
|
||||
let inventory = YostarJpPlatformDownloadInventory::from_catalog_bytes(
|
||||
b"ExcelDB.db ExcelDB.db",
|
||||
vec![YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
||||
PatchPlatform::Windows,
|
||||
b"FullPatch_000.zip",
|
||||
b"scenario/event/10000_title_sound\0Prologue\\Scenario\\Event\\10000_Title_Sound.ogg\0 10000_Title_Sound.ogg",
|
||||
)],
|
||||
);
|
||||
let root = YostarJpResourceRoot::from_root_token(ROOT).unwrap();
|
||||
|
||||
let urls = inventory
|
||||
.direct_download_urls_for_platforms(&root, &[PatchPlatform::Windows])
|
||||
.unwrap();
|
||||
|
||||
assert!(urls.iter().any(|url| {
|
||||
url.ends_with("/MediaResources-Windows/Prologue/Scenario/Event/10000_Title_Sound.ogg")
|
||||
}));
|
||||
assert!(!urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/10000_Title_Sound.ogg")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires BAT_REAL_OFFICIAL_BUNDLE_PACKING_INFO, BAT_REAL_OFFICIAL_TABLE_CATALOG, BAT_REAL_OFFICIAL_MEDIA_CATALOG"]
|
||||
fn extracts_realistic_counts_from_official_shape() {
|
||||
@@ -591,7 +675,7 @@ mod tests {
|
||||
|
||||
assert_eq!(inventory.bundle_patch_pack_names.len(), 142);
|
||||
assert!(inventory.table_file_names.len() < 1000);
|
||||
assert_eq!(inventory.media_file_names.len(), 1887);
|
||||
assert!(inventory.media_file_names.len() >= 4000);
|
||||
assert!(inventory
|
||||
.bundle_patch_pack_names
|
||||
.iter()
|
||||
@@ -603,6 +687,10 @@ mod tests {
|
||||
assert!(inventory
|
||||
.media_file_names
|
||||
.iter()
|
||||
.any(|name| name == "JP_Airi.zip"));
|
||||
.any(|name| name == "GameData/Audio/VOC_JP/JP_Airi.zip"));
|
||||
assert!(inventory
|
||||
.media_file_names
|
||||
.iter()
|
||||
.any(|name| name.ends_with(".jpg")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,8 +646,8 @@ impl YostarJpResourceRoot {
|
||||
|
||||
/// Returns an official media archive URL.
|
||||
///
|
||||
/// The argument is the `Media.FileName` field from `MediaCatalog.bytes`,
|
||||
/// for example `JP_Airi.zip`.
|
||||
/// The argument is the downloadable relative path from `MediaCatalog.bytes`,
|
||||
/// for example `GameData/Audio/VOC_JP/JP_Airi.zip`.
|
||||
pub fn media_file(&self, platform: PatchPlatform, file_name: &str) -> Result<String, String> {
|
||||
validate_relative_path(file_name, "media file")?;
|
||||
Ok(format!(
|
||||
@@ -1134,8 +1134,9 @@ mod tests {
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/Catalog/MediaCatalog.bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
root.media_file(PatchPlatform::Windows, "JP_Airi.zip").unwrap(),
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/JP_Airi.zip"
|
||||
root.media_file(PatchPlatform::Windows, "GameData/Audio/VOC_JP/JP_Airi.zip")
|
||||
.unwrap(),
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93_dctuo3tcd029wwxnvb55/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1211,7 +1212,7 @@ mod tests {
|
||||
);
|
||||
assert!(root.table_bundle("text=jp/ExcelDB.db").is_err());
|
||||
assert!(root
|
||||
.media_file(PatchPlatform::Windows, "/JP_Airi.zip")
|
||||
.media_file(PatchPlatform::Windows, "/GameData/Audio/VOC_JP/JP_Airi.zip")
|
||||
.is_err());
|
||||
assert!(root
|
||||
.bundle_patch_pack(PatchPlatform::Windows, "../FullPatch_000.zip")
|
||||
|
||||
@@ -86,6 +86,7 @@ impl Unity2021_3Adapter {
|
||||
flags,
|
||||
)?;
|
||||
let (blocks, directories) = Self::parse_blocks_info(&block_info)?;
|
||||
validate_directory_bounds(&blocks, &directories)?;
|
||||
|
||||
Ok(ParsedAssetBundle {
|
||||
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} 个 block(uncompressed_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>(
|
||||
data: &'a [u8],
|
||||
reader: &mut UnityFsReader<'a>,
|
||||
@@ -497,4 +542,81 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"address": "academy-_mxload-prefabs-2025-07-02_assets_all_638981069.bundle",
|
||||
"dependencies": [
|
||||
"shared_assets_all_123.bundle"
|
||||
]
|
||||
],
|
||||
"crc": 0
|
||||
},
|
||||
{
|
||||
"path": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
||||
@@ -20,7 +21,8 @@
|
||||
"size": 162134,
|
||||
"resource_type": "AssetBundle",
|
||||
"address": "academy-_mxload-prefabs-2025-08-26_assets_all_1581352935.bundle",
|
||||
"dependencies": []
|
||||
"dependencies": [],
|
||||
"crc": 0
|
||||
},
|
||||
{
|
||||
"path": "shared_assets_all_123.bundle",
|
||||
@@ -28,7 +30,8 @@
|
||||
"size": 153480,
|
||||
"resource_type": "AssetBundle",
|
||||
"address": "shared_assets_all_123.bundle",
|
||||
"dependencies": []
|
||||
"dependencies": [],
|
||||
"crc": 0
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
|
||||
@@ -22,6 +22,7 @@ async fn parses_real_shape_addressables_catalog_against_golden() {
|
||||
"resource_type": format!("{:?}", resource.resource_type),
|
||||
"address": resource.address,
|
||||
"dependencies": resource.dependencies,
|
||||
"crc": resource.crc,
|
||||
})
|
||||
}).collect::<Vec<_>>(),
|
||||
"metadata": manifest.metadata.extra,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func runDoctor() error {
|
||||
fmt.Println("bat doctor: ok")
|
||||
return nil
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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]")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,7 +7,7 @@ pub mod translation;
|
||||
|
||||
pub use game_client::{ClientStatus, GameClient, GameRegion};
|
||||
pub use game_version::{GameVersion, UnityVersion};
|
||||
pub use resource::{Resource, ResourceEntry, ResourceType};
|
||||
pub use resource::{crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceType};
|
||||
pub use translation::{
|
||||
ExtractedText, SourceText, TextContext, TextMetadata, TextSource, TranslatedText,
|
||||
TranslationStatus,
|
||||
|
||||
+145
-5
@@ -34,6 +34,94 @@ pub struct ResourceEntry {
|
||||
pub address: Option<String>,
|
||||
/// 该资源依赖的其他资源标识
|
||||
pub dependencies: Vec<String>,
|
||||
/// Addressables bundle 的 CRC32(catalog 中的 `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 {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resource_entry() {
|
||||
let entry = ResourceEntry {
|
||||
fn entry_with(size: u64, crc: Option<u32>) -> ResourceEntry {
|
||||
ResourceEntry {
|
||||
path: "test.bundle".to_string(),
|
||||
hash: "abc123".to_string(),
|
||||
size: 1024,
|
||||
size,
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
};
|
||||
crc,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resource_entry() {
|
||||
let entry = entry_with(1024, None);
|
||||
assert_eq!(entry.path, "test.bundle");
|
||||
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
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
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. 并发写入相同内容测试通过。
|
||||
5. 损坏对象读取返回明确错误。
|
||||
6. 权限或路径错误有清晰错误类型。
|
||||
7. `cargo test --workspace` 和 `cargo clippy --workspace -- -D warnings` 通过。
|
||||
7. `cargo test --workspace` 和 `cargo clippy --workspace --all-targets -- -D warnings` 通过。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
### 3.2 枚举完整资源清单
|
||||
|
||||
资源清单不是“猜几个文件”,而是从官方 catalog 字节里提取完整文件名列表。
|
||||
资源清单不是“猜几个文件”,而是从官方 catalog 字节里提取完整文件名或相对路径列表。
|
||||
|
||||
当前做法:
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
3. 读取 `TableCatalog.bytes`。
|
||||
4. 提取所有表资源名,例如 `ExcelDB.db`。
|
||||
5. 读取 `MediaCatalog.bytes`。
|
||||
6. 提取所有媒体资源名,例如 `JP_Airi.zip`。
|
||||
6. 提取所有媒体下载相对路径,例如 `GameData/Audio/VOC_JP/JP_Airi.zip`、`Prologue/Scenario/Event/10000_Title_Sound.ogg`。
|
||||
|
||||
然后对 verified platforms 生成完整 URL 集:
|
||||
|
||||
@@ -148,14 +148,14 @@
|
||||
|
||||
### 3.5 导入到 CAS 和资源仓储
|
||||
|
||||
资源下载后,导入层会:
|
||||
当前实现已经提供资源导入能力,但官方同步下载完成后尚未自动作为用户级流程触发导入。手动或上层流程调用导入层时,它会:
|
||||
|
||||
1. 把 bundle 原始字节写入 CAS。
|
||||
2. 解析 UnityFS 基础摘要。
|
||||
3. 把资源条目写入 `ResourceRepository`。
|
||||
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 RPC,one-shot/fallback 场景才运行 `bat --json` 并消费结构化 report。
|
||||
2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。
|
||||
3. `bat-ffi` 只允许作为可选无状态 C ABI 兼容层,用于 Manifest inspect 和 sync plan 这类一次性 JSON helper;它不是官方同步 daemon、下载器、资源锁、CAS handle 或主控制面的承载位置。
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot。
|
||||
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
|
||||
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
|
||||
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
||||
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress log,stdout 默认输出人类可读摘要;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 log,stdout 默认输出人类可读摘要;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
|
||||
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因
|
||||
- 资源导入链路已覆盖 CAS 写入、`ResourceRepository` 索引、AssetBundle UnityFS 摘要,以及 TextAsset/Table/Media 分类
|
||||
@@ -280,6 +280,7 @@ Linux 生产路径:
|
||||
|
||||
daemon(`bat --daemon`)在 `<state-dir>/bat.sock` 上提供 Unix socket
|
||||
JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边界**。
|
||||
稳定方法、schema 和错误语义以 `docs/reference/rpc-backend-api.md` 为准。
|
||||
|
||||
### 7.1 协议契约
|
||||
|
||||
@@ -289,22 +290,24 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
||||
- `error` 为统一 `ApiError`:`code`(`BAT-ERR-<6 位>`)、`kind`、
|
||||
`domain`、`location`、`message`、`retryable`。码表以
|
||||
`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.cancel` 协作式取消。任务执行器是单 worker FIFO,与
|
||||
watch 循环经进程内锁互斥。任务历史持久化于 `<state-dir>/bat-tasks.json`
|
||||
(版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务
|
||||
仍可经 `task.*` 查询,中断任务标记 `task_interrupted`(700005)。
|
||||
- 方法命名空间与实现状态、请求/响应示例见 `USERGUIDE.md` §6:
|
||||
`daemon.*` / `resource.*` / `catalog.*` / `task.*` 已实现;
|
||||
`patch.*` / `unityfs.*` 待引擎;`task.create` / `resource.repair`
|
||||
按设计暂缓。
|
||||
`daemon.status/logs/stop/reload/refresh/doctor`、`resource.state/sync/verify/repair/manifest/list`、
|
||||
`catalog.*` 与 `task.status/list/cancel/logs` 已实现;`patch.*` / `unityfs.*`
|
||||
待引擎;`task.create` 按设计暂不开放通用任务入口;`daemon.restart` /
|
||||
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||
|
||||
### 7.2 Go 层职责边界
|
||||
|
||||
- Go 层负责:BlueArchive 客户端请求处理、HTTP API、鉴权、内容分发,
|
||||
以及作为 RPC client 调用本机 daemon(连接 `bat.sock`,每行一个
|
||||
JSON-RPC 请求/响应)。
|
||||
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon
|
||||
(连接 `bat.sock`,每行一个 JSON-RPC 请求/响应)。当前 Go 产品入口
|
||||
尚未完成,`cmd/bat` 仍是试验骨架。
|
||||
- Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、
|
||||
版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。
|
||||
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源
|
||||
|
||||
+10
-8
@@ -1,6 +1,6 @@
|
||||
# 稳定工程基线指南
|
||||
|
||||
- **更新时间**:2026-07-06
|
||||
- **更新时间**:2026-07-20
|
||||
- **目标**:让工作区处于可继续开发核心功能的可信状态。
|
||||
|
||||
---
|
||||
@@ -13,7 +13,7 @@
|
||||
2. 根目录只保留入口文档和工程配置。
|
||||
3. 旧报告归档,且不再和当前状态混淆。
|
||||
4. Rust workspace 成员显式列出。
|
||||
5. Go 尚未实现时,Makefile 不误报失败。
|
||||
5. Go 产品入口尚未完成时,Makefile 不把骨架包误报为完整产品。
|
||||
6. 当前缺口有集中清单和关闭顺序。
|
||||
7. 架构边界有 ADR 记录。
|
||||
8. 基础验证命令通过。
|
||||
@@ -36,14 +36,16 @@ make lint
|
||||
```bash
|
||||
cargo test --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 阶段明确跳过。
|
||||
2. 如果后续新增 Go package,必须让 `go test ./...` 和 `go vet ./...` 纳入硬性验证。
|
||||
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入 CI。
|
||||
1. 当前已有 `internal/backendrpc` fake socket 单测、`cmd/bat` 试验骨架和 `internal/ffi` 兼容包装;这些不代表 CLI/API 产品入口已完成。
|
||||
2. 后续新增 Go 产品 package,必须让 `go test ./...` 和 `go vet ./...` 纳入硬性验证。
|
||||
3. 当前 `golangci-lint` 可选;当 Go 代码进入主要开发阶段后,应纳入本地门禁。
|
||||
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 官方同步闭环完成后,下一阶段优先推进:
|
||||
|
||||
1. Go CLI 的 `doctor` 和基础命令框架。
|
||||
1. 收敛 Go 产品入口:当前 `cmd/bat` 仅是试验骨架,不能视为完成。
|
||||
2. 按 `docs/guides/official-full-pull-smoke.md` 执行真实官方网络全量下载 smoke,并保留隔离目录报告。
|
||||
3. 官方同步结果接入 CAS + ResourceRepository。
|
||||
4. AssetBundle UnityFS 解析。
|
||||
4. AssetBundle UnityFS 引擎级解析。
|
||||
|
||||
优先阅读:
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
`--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。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;带 `--output`、server-info、connection-group、app-version、platforms、snapshot、curl 或 unzip 等显式参数时,`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、curl、unzip 或其它显式同步参数时,`refresh` / `repair` 会作为一次性前台命令运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`clean-stable` 只清理 `.part`、`.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。
|
||||
|
||||
### 升级
|
||||
|
||||
|
||||
@@ -23,6 +23,20 @@ rustc --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
|
||||
```bash
|
||||
# 安装 Docker 和 Docker Compose
|
||||
@@ -127,9 +141,11 @@ git push origin feature/your-feature-name
|
||||
cargo fmt --check
|
||||
cargo test --workspace
|
||||
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` 二进制测试。
|
||||
|
||||
`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 兼容层问题
|
||||
|
||||
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。默认 Go CLI 集成优先运行 Rust `bat --json`。
|
||||
`bat-ffi` 不是主集成边界,只用于需要 C ABI 的兼容场景。未来 Go 产品入口集成优先运行 Rust `bat --json` 或调用 daemon RPC。
|
||||
|
||||
重新构建兼容库:
|
||||
```bash
|
||||
|
||||
@@ -69,10 +69,10 @@ cargo build --release -p bat-infrastructure --bin bat
|
||||
|
||||
脚本会在关键步骤后自动检查:
|
||||
|
||||
- 首次全量拉取 stderr log 包含总体下载进度、单文件进度和校验结果。
|
||||
- 首次全量拉取 stderr log 包含下载已完成计数、单文件进度和校验结果。
|
||||
- 二次运行 stdout JSON 包含 `update_status=up_to_date`。
|
||||
- repair stdout JSON 包含 `command=repair` 和 `status=completed`。
|
||||
- repair stderr log 包含总体下载进度、单文件进度和校验结果。
|
||||
- repair stderr log 包含下载已完成计数、单文件进度和校验结果。
|
||||
- repair 后 verify stdout JSON 包含 `healthy=true`。
|
||||
|
||||
## 环境变量
|
||||
@@ -96,4 +96,4 @@ cargo build --release -p bat-infrastructure --bin bat
|
||||
- `03-second-up-to-date.stdout.json` 中 `update_status` 为 `up_to_date`。
|
||||
- `04-repair-after-damage.stdout.json` 中 repair 完成,且有重新下载或修复行为。
|
||||
- `05-verify-after-repair.stdout.json` 中 `healthy` 为 `true`。
|
||||
- `02-first-full-pull.stderr.log` 和 `04-repair-after-damage.stderr.log` 中包含下载总体进度、单文件进度和校验结果日志。
|
||||
- `02-first-full-pull.stderr.log` 和 `04-repair-after-damage.stderr.log` 中包含下载已完成计数、单文件进度和校验结果日志。
|
||||
|
||||
@@ -45,7 +45,7 @@ target/release/bat \
|
||||
--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` 权限创建。
|
||||
|
||||
@@ -265,7 +265,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
|
||||
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 <目录>`:
|
||||
|
||||
@@ -308,7 +308,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
||||
--error-retry 60s
|
||||
```
|
||||
|
||||
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出总体下载进度和单文件开始/完成状态,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 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 error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。
|
||||
|
||||
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
||||
|
||||
@@ -325,7 +325,7 @@ scripts/official-full-pull-smoke.sh
|
||||
make official-smoke
|
||||
```
|
||||
|
||||
默认输出在 `/tmp/bat-official-smoke-<UTC timestamp>/`,脚本会执行 dry-run plan、首次全量拉取、二次 `up_to_date`、本地文件破坏后的 `repair`、repair 后 `verify`,并检查 stderr progress log 中存在总体下载进度、单文件进度和校验结果摘要。完整说明见 `docs/guides/official-full-pull-smoke.md`。
|
||||
默认输出在 `/tmp/bat-official-smoke-<UTC timestamp>/`,脚本会执行 dry-run plan、首次全量拉取、二次 `up_to_date`、本地文件破坏后的 `repair`、repair 后 `verify`,并检查 stderr progress log 中存在下载已完成计数、单文件进度和校验结果摘要。完整说明见 `docs/guides/official-full-pull-smoke.md`。
|
||||
|
||||
## 7. 例外输入
|
||||
|
||||
|
||||
@@ -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` 作为常规调用路径。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 当前实现缺口清单
|
||||
|
||||
- **更新时间**:2026-07-17
|
||||
- **更新时间**:2026-07-20
|
||||
- **用途**:集中跟踪当前代码中的占位实现、设计缺口和下一步验收项。
|
||||
- **权威计划**:`../../PROJECT_PLAN.md`
|
||||
|
||||
@@ -107,30 +107,32 @@
|
||||
- 并发写入相同内容只产生一个对象。
|
||||
- 读取时 Hash 不匹配会返回明确错误。
|
||||
|
||||
### G-005:AssetBundle 解析器仍是占位
|
||||
### G-005:AssetBundle 引擎解析器仍未完成
|
||||
|
||||
现象:
|
||||
|
||||
- `crates/bat-assetbundle/src/parser.rs` 只有 `Parser::name`。
|
||||
- `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 或配置文本。
|
||||
|
||||
验收:
|
||||
|
||||
- 能解析结构化测试样本。
|
||||
- 支持 UnityFS header、blocks、directory、metadata。
|
||||
- `crates/bat-assetbundle` 能解析结构化测试样本和隔离真实样本。
|
||||
- 支持 UnityFS header、blocks、directory、metadata、object table。
|
||||
- 错误包含偏移和字段上下文。
|
||||
|
||||
### G-006:Patch 引擎仍是占位
|
||||
|
||||
现象:
|
||||
|
||||
- `binary::apply_patch` 返回空 `Vec`。
|
||||
- `json::apply_json_patch` 返回空字符串。
|
||||
- `binary::apply_patch` 明确返回 `PatchError::ApplyFailed`,提示 Binary patch 尚未实现。
|
||||
- `json::apply_json_patch` 明确返回 `PatchError::ApplyFailed`,提示 JSON patch 尚未实现。
|
||||
|
||||
影响:
|
||||
|
||||
@@ -150,7 +152,8 @@
|
||||
现象:
|
||||
|
||||
- `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 结构变体、二进制/压缩字段组合和更明确的失败诊断。
|
||||
|
||||
影响:
|
||||
@@ -160,53 +163,56 @@
|
||||
验收:
|
||||
|
||||
- 能解析项目目标版本的真实 Catalog 样本集合。
|
||||
- 解析结果包含资源 key、provider、dependency、hash、size、path。
|
||||
- 解析结果包含资源 key、provider、dependency、hash、size、path、CRC。
|
||||
- 对不支持的 catalog 结构返回明确错误,而不是静默丢字段。
|
||||
|
||||
---
|
||||
|
||||
## 3. 应用层缺口
|
||||
|
||||
### G-008:Go CLI 尚未实现
|
||||
### G-008:Go CLI 产品入口尚未完成
|
||||
|
||||
状态:**未完成(此前“并入 G-009”只是短期跟踪调整,不代表能力完成)**
|
||||
|
||||
现象:
|
||||
|
||||
- `cmd/bat` 已有 `main.go`,但只是通过 cgo 调用 `bat-ffi` 的最小骨架(doctor/manifest inspect/sync plan),不是产品级用户入口;且默认 Go/Rust 集成边界应是 `bat --json` 进程边界,而非 FFI。
|
||||
- `internal/ffi/ffi.go` 已存在,但只是可选 CGO 兼容包装,不是用户可运行的产品 CLI,也不是默认集成边界。
|
||||
- `go test ./...` 当前没有产品级 Go package 覆盖。
|
||||
|
||||
当前进展:
|
||||
|
||||
- 对接边界已就绪:Rust daemon 的 `bat.sock` Unix socket JSON-RPC Backend API(issue #1 主体已完成:统一 envelope、`BAT-ERR` 错误码模型、`daemon.*`/`resource.*`/`catalog.*`/`task.*` 方法集)与 `bat --json` 进程边界均可用。Go CLI 缺的是产品级入口本身,实现时应重写 `cmd/bat` 现有 cgo 骨架为 RPC/进程边界对接。
|
||||
|
||||
影响:
|
||||
|
||||
- 用户没有统一入口。
|
||||
- 同步、提取、补丁流程无法从命令行串联。
|
||||
- 当前可用的用户同步/运维入口是 Rust `bat` binary。
|
||||
- `internal/backendrpc` 已提供 Go 到 Rust daemon 的 typed JSON-RPC client;`cmd/bat` 已存在,但仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力;`doctor` 只输出固定 `ok`,`manifest`/`sync` 依赖可选 CGO/FFI helper。
|
||||
- Go 侧尚未实现通过 Rust `bat --json` 或 daemon RPC 包装官方同步命令、稳定 human/json 输出、真实 doctor 检查和端到端测试。
|
||||
- 如果项目决策改为“用户 CLI 永久由 Rust `bat` 承担,Go 只做 `bat-api`/服务层”,必须同步更新 `AGENTS.md`、`PROJECT_PLAN.md` 和 issue 跟踪;在完成该决策前,不能把 Go CLI 写成已完成。
|
||||
|
||||
验收:
|
||||
|
||||
- `bat doctor` 可运行。
|
||||
- `bat --help` 命令结构稳定。
|
||||
- 命令支持默认人类可读输出和 `--json` 机器输出。
|
||||
- Go CLI 默认通过 Rust `bat --json` 进程边界获取同步 report;除非明确兼容需求,不依赖 FFI。
|
||||
- `cmd/bat doctor` 做真实环境诊断,而不是固定字符串。
|
||||
- `cmd/bat sync` 能通过 `internal/backendrpc` daemon RPC 或 Rust `bat --json` one-shot/fallback 触发/查询官方同步,不走 FFI 控制下载器或 daemon。
|
||||
- human/json 输出、退出码和错误码与 Rust `bat` 契约一致。
|
||||
- `go test ./...`、`go vet ./...` 覆盖命令解析、错误输出和至少一个 mocked Rust 边界。
|
||||
|
||||
### G-009:API Server 和 OpenAPI 尚未实现
|
||||
### G-009:API Server(`bat-api`,仿官方 API)尚未实现
|
||||
|
||||
现象:
|
||||
|
||||
- `api/` 只有目录结构。
|
||||
- 无 handler、service、OpenAPI schema。
|
||||
- `api/` 只有目录结构,无 handler、service、路由。
|
||||
- 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` 可用。
|
||||
- 统一错误结构落地。
|
||||
- OpenAPI 与实际路由同步。
|
||||
- Go 单测覆盖路由、签名验签、错误响应形态。
|
||||
- 真机 e2e:daemon 发布 fixture release → 启动 `bat-api` → 按官方 URL 与鉴权头请求 server-info / catalog / bundle / launcher 链,断言字节与形态正确、验签生效(缺签/错签被拒)。
|
||||
- 统一错误结构与官方响应 envelope 对齐;Makefile 增加 Go 构建/测试目标。
|
||||
|
||||
排期:P2,排在 issue #17 验收收口以及 issue #2 / #3 的解析能力继续推进之后启动;可与 G-008 的 Go 产品入口边界收敛并行。
|
||||
|
||||
### G-010:Web 管理后台尚未实现
|
||||
|
||||
@@ -396,7 +402,9 @@
|
||||
处理结果:
|
||||
|
||||
- 明确决策:本项目不加入 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`)。
|
||||
|
||||
限制:
|
||||
@@ -418,7 +426,7 @@
|
||||
- 新增 `make official-smoke` 统一入口。
|
||||
- 新增 `docs/guides/official-full-pull-smoke.md`,记录目标、命令、输出结构、环境变量、安全边界和成功判定。
|
||||
- smoke 流程覆盖 dry-run plan、首次全量拉取、二次 `up_to_date`、人工破坏 active release 文件后的 `repair`、repair 后 `verify`。
|
||||
- 脚本会检查二次 `up_to_date`、repair 完成、verify `healthy=true`,并检查首次拉取和 repair 的 stderr log 中存在总体下载进度、单文件进度和校验结果日志。
|
||||
- 脚本会检查二次 `up_to_date`、repair 完成、verify `healthy=true`,并检查首次拉取和 repair 的 stderr log 中存在下载已完成计数、单文件进度和校验结果日志。
|
||||
- 运行报告 `SMOKE_REPORT.md` 记录实际输出目录、active release、文件数量、release 大小和被破坏文件;大型官方资源文件保留在隔离输出目录,不纳入 Git。
|
||||
|
||||
验收:
|
||||
@@ -462,11 +470,13 @@
|
||||
|
||||
## 6. 当前关闭顺序建议
|
||||
|
||||
1. G-008
|
||||
2. G-011
|
||||
3. G-005
|
||||
4. G-007
|
||||
5. G-012
|
||||
6. G-006
|
||||
1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余。
|
||||
2. issue #1:Rust 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. issue #17/#20/#21/#22:多线程下载入口已按最新决定移除,下载回归顺序执行并保留指数退避与单调进度上报;daemon 子进程不再透传并发参数,但 GitHub issue 仍 open。
|
||||
4. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖。
|
||||
5. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析。
|
||||
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”决策关闭。
|
||||
|
||||
@@ -641,13 +641,18 @@ struct DaemonRpcAck {
|
||||
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
|
||||
const RPC_METHOD_STATUS: &str = "daemon.status";
|
||||
const RPC_METHOD_STOP: &str = "daemon.stop";
|
||||
const RPC_METHOD_RESTART: &str = "daemon.restart";
|
||||
const RPC_METHOD_RELOAD: &str = "daemon.reload";
|
||||
const RPC_METHOD_REFRESH: &str = "daemon.refresh";
|
||||
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_SYNC: &str = "resource.sync";
|
||||
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_LIST: &str = "resource.list";
|
||||
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
|
||||
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
|
||||
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
|
||||
@@ -667,6 +672,7 @@ const MAX_TASK_LOG_LINES: usize = 200;
|
||||
enum TaskKind {
|
||||
Sync,
|
||||
Verify,
|
||||
Repair,
|
||||
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
|
||||
Refresh,
|
||||
}
|
||||
@@ -676,6 +682,7 @@ impl TaskKind {
|
||||
match self {
|
||||
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
|
||||
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
|
||||
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
|
||||
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
|
||||
}
|
||||
}
|
||||
@@ -695,6 +702,12 @@ impl TaskKind {
|
||||
config.repair = false;
|
||||
config.force = false;
|
||||
}
|
||||
Self::Repair => {
|
||||
config.dry_run = false;
|
||||
config.audit_local = true;
|
||||
config.repair = true;
|
||||
config.force = false;
|
||||
}
|
||||
Self::Refresh => {
|
||||
config.dry_run = true;
|
||||
config.plan = true;
|
||||
@@ -800,6 +813,7 @@ fn task_kind_static(kind: &str) -> Option<&'static str> {
|
||||
match kind {
|
||||
RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC),
|
||||
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),
|
||||
_ => None,
|
||||
}
|
||||
@@ -1233,21 +1247,28 @@ fn canonical_rpc_method(method: &str) -> &str {
|
||||
match method {
|
||||
"bat.status" => RPC_METHOD_STATUS,
|
||||
"bat.stop" => RPC_METHOD_STOP,
|
||||
"bat.restart" => RPC_METHOD_RESTART,
|
||||
"bat.reload" => RPC_METHOD_RELOAD,
|
||||
"bat.refresh" => RPC_METHOD_REFRESH,
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断方法是否属于已规划但尚未实现的命名空间/动作(返回 not_implemented 而非 unknown)。
|
||||
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-stable:CLI 侧按进程生命周期处理;
|
||||
// live RPC 内不做自重启或在线清理。
|
||||
// patch.* / unityfs.*:被 bat-patch / bat-assetbundle 引擎阻塞。
|
||||
matches!(method, "task.create" | "resource.repair")
|
||||
|| method.starts_with("patch.")
|
||||
matches!(
|
||||
method,
|
||||
"task.create" | RPC_METHOD_RESTART | RPC_METHOD_CLEAN_STABLE
|
||||
) || method.starts_with("patch.")
|
||||
|| 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)),
|
||||
)
|
||||
}
|
||||
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 => {
|
||||
daemon_control_mark_stop_requested(control);
|
||||
let _ = update_daemon_state_only(state_dir, "stopping");
|
||||
@@ -1879,6 +1906,9 @@ fn dispatch_rpc_method(
|
||||
RPC_METHOD_RESOURCE_VERIFY => {
|
||||
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 => {
|
||||
let (offset, limit) = match rpc_page_params(request.params.as_ref()) {
|
||||
Ok(page) => page,
|
||||
@@ -3006,19 +3036,36 @@ struct CommandReport<T> {
|
||||
}
|
||||
|
||||
fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow::Result<()> {
|
||||
if refresh_should_use_daemon_rpc(options, command_name)
|
||||
&& daemon_rpc_available(&options.state_dir)
|
||||
run_sync_command_with_rpc(options, command_name, daemon_rpc_available, daemon_rpc_call)
|
||||
}
|
||||
|
||||
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 report = daemon_rpc_call(
|
||||
&options.state_dir,
|
||||
RPC_METHOD_REFRESH,
|
||||
Some(serde_json::json!({ "force": options.config.force })),
|
||||
)?;
|
||||
let params = if rpc_method == RPC_METHOD_REFRESH {
|
||||
Some(serde_json::json!({ "force": options.config.force }))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let report = rpc_call(&options.state_dir, rpc_method, params)?;
|
||||
print_json_value(options.output_format, &report)?;
|
||||
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)?;
|
||||
let mut config = options.config.clone();
|
||||
if command_name == "repair" {
|
||||
@@ -3044,13 +3091,9 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn refresh_should_use_daemon_rpc(options: &CliOptions, command_name: &str) -> bool {
|
||||
if command_name != "refresh" {
|
||||
return false;
|
||||
}
|
||||
fn sync_command_rpc_method(options: &CliOptions, command_name: &str) -> Option<&'static str> {
|
||||
let defaults = OfficialUpdateConfig::default();
|
||||
options.command == CliCommand::Refresh
|
||||
&& !options.watch
|
||||
let default_daemon_shape = !options.watch
|
||||
&& !options.daemon
|
||||
&& !options.daemon_child
|
||||
&& !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.plan
|
||||
&& 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<()>
|
||||
@@ -3806,30 +3861,29 @@ struct DoctorReport {
|
||||
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![
|
||||
path_check("state_dir", &options.state_dir, "后台状态目录可用"),
|
||||
path_check(
|
||||
"output_root",
|
||||
&options.config.output_root,
|
||||
"资源输出目录可用",
|
||||
),
|
||||
path_check("state_dir", state_dir, "后台状态目录可用"),
|
||||
path_check("output_root", &config.output_root, "资源输出目录可用"),
|
||||
safety_check(
|
||||
"output_root_safety",
|
||||
validate_output_root(&options.config.output_root),
|
||||
validate_output_root(&config.output_root),
|
||||
"资源输出目录安全边界通过",
|
||||
),
|
||||
safety_check(
|
||||
"state_dir_safety",
|
||||
validate_runtime_state_dir(&options.state_dir),
|
||||
validate_runtime_state_dir(state_dir),
|
||||
"后台状态目录安全边界通过",
|
||||
),
|
||||
command_check("curl", &options.config.curl_command),
|
||||
proxy_check(&options.config.curl_proxy),
|
||||
command_check("unzip", &options.config.unzip_command),
|
||||
command_check("curl", &config.curl_command),
|
||||
proxy_check(&config.curl_proxy),
|
||||
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)
|
||||
.ok()
|
||||
.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_available = daemon_rpc_available(&options.state_dir);
|
||||
let socket_available = daemon_rpc_available(state_dir);
|
||||
checks.push(DoctorCheck {
|
||||
name: "daemon_rpc",
|
||||
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)? {
|
||||
PidLockState::Missing => DoctorCheck {
|
||||
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)? {
|
||||
PidLockState::Missing => DoctorCheck {
|
||||
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 却提示可解析”的自相矛盾。
|
||||
let daemon_status_result = read_daemon_status_file(&status_path);
|
||||
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 report = DoctorReport {
|
||||
Ok(DoctorReport {
|
||||
command: "doctor",
|
||||
status: if healthy { "ok" } else { "issues_found" },
|
||||
message: if healthy {
|
||||
@@ -3974,7 +4028,12 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
},
|
||||
healthy,
|
||||
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)?;
|
||||
Ok(healthy)
|
||||
}
|
||||
@@ -4957,7 +5016,6 @@ BAT_AUTO_DISCOVER=1
|
||||
# 正常检查间隔与失败重试间隔(秒)
|
||||
#BAT_INTERVAL_SECONDS=3600
|
||||
#BAT_ERROR_RETRY_SECONDS=60
|
||||
|
||||
# ---- 网络 ----
|
||||
# 显式代理 URL(支持 http/https/socks4/socks4a/socks5/socks5h)。
|
||||
# 不设则自动检测 HTTPS_PROXY / ALL_PROXY / HTTP_PROXY(也可写在本文件里)。
|
||||
@@ -5920,11 +5978,13 @@ mod tests {
|
||||
"404",
|
||||
));
|
||||
});
|
||||
let repair_id = registry.create(TaskKind::Repair);
|
||||
registry.update(&repair_id, |record| record.status = "succeeded");
|
||||
drop(registry);
|
||||
|
||||
// 重启:恢复历史;running 任务标记中断;错误码经持久化往返保留。
|
||||
let (registry, summary) = TaskRegistry::with_persistence(state_dir);
|
||||
assert!(summary.contains("恢复任务历史 3 条"), "{summary}");
|
||||
assert!(summary.contains("恢复任务历史 4 条"), "{summary}");
|
||||
assert!(summary.contains("标记中断 1 条"), "{summary}");
|
||||
let finished = registry.get(&finished_id).unwrap();
|
||||
assert_eq!(finished.status, "succeeded");
|
||||
@@ -5945,16 +6005,19 @@ mod tests {
|
||||
failed.error.as_ref().unwrap().code(),
|
||||
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。
|
||||
let new_id = registry.create(TaskKind::Sync);
|
||||
assert!(
|
||||
[&finished_id, &running_id, &failed_id]
|
||||
[&finished_id, &running_id, &failed_id, &repair_id]
|
||||
.iter()
|
||||
.all(|id| **id != new_id),
|
||||
"新任务 ID {new_id} 与历史撞号"
|
||||
);
|
||||
assert_eq!(registry.list().len(), 4);
|
||||
assert_eq!(registry.list().len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6369,8 +6432,13 @@ mod tests {
|
||||
#[test]
|
||||
fn canonical_rpc_method_resolves_aliases() {
|
||||
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("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("unknown.method"), "unknown.method");
|
||||
}
|
||||
@@ -6380,17 +6448,20 @@ mod tests {
|
||||
assert!(is_pending_rpc_method("patch.apply"));
|
||||
assert!(is_pending_rpc_method("unityfs.inspect"));
|
||||
assert!(is_pending_rpc_method("task.create"));
|
||||
assert!(is_pending_rpc_method("resource.repair"));
|
||||
// sync/verify、task.cancel/logs、catalog.* 与 resource.manifest 已实现,
|
||||
// 不再是 pending。
|
||||
assert!(is_pending_rpc_method("daemon.restart"));
|
||||
assert!(is_pending_rpc_method("daemon.clean-stable"));
|
||||
// sync/verify/repair、task.cancel/logs、catalog.* 与 resource.manifest 已实现。
|
||||
assert!(!is_pending_rpc_method("resource.sync"));
|
||||
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.list"));
|
||||
assert!(!is_pending_rpc_method("catalog.status"));
|
||||
assert!(!is_pending_rpc_method("catalog.refresh"));
|
||||
assert!(!is_pending_rpc_method("task.cancel"));
|
||||
assert!(!is_pending_rpc_method("task.logs"));
|
||||
assert!(!is_pending_rpc_method("daemon.status"));
|
||||
assert!(!is_pending_rpc_method("daemon.doctor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -6503,6 +6574,36 @@ mod tests {
|
||||
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]
|
||||
fn dispatch_resource_sync_enqueues_task() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
@@ -6555,6 +6656,48 @@ mod tests {
|
||||
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]
|
||||
fn task_registry_create_update_and_prune() {
|
||||
let registry = TaskRegistry::new();
|
||||
@@ -7093,21 +7236,79 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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();
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
assert!(!refresh_should_use_daemon_rpc(&options, "refresh"));
|
||||
assert_eq!(sync_command_rpc_method(&options, "refresh"), None);
|
||||
|
||||
let options = parse(&["bat", "repair"]).unwrap();
|
||||
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]
|
||||
@@ -7592,17 +7793,34 @@ mod tests {
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], false);
|
||||
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]
|
||||
fn dispatch_resource_repair_reports_not_implemented() {
|
||||
fn dispatch_daemon_clean_stable_reports_not_implemented() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request("resource.repair", None),
|
||||
&rpc_request("daemon.clean-stable", None),
|
||||
temp.path(),
|
||||
&new_daemon_control(),
|
||||
&test_task_context(),
|
||||
"req-rep-1".to_string(),
|
||||
"req-clean-1".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], false);
|
||||
|
||||
@@ -427,6 +427,34 @@ impl std::fmt::Display 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(
|
||||
curl_command: &Path,
|
||||
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),
|
||||
};
|
||||
let retryable = failure.retryable();
|
||||
// ETXTBSY 是 fork/exec 竞态窗口造成的瞬时忙,立刻重试往往仍落在同一窗口内。
|
||||
// 让出 CPU 并做一次极短退避,使持有可写句柄的兄弟进程完成其 execve。
|
||||
let busy = failure.kind == CurlFailureKind::ProcessBusy;
|
||||
failures.push(CurlAttemptFailure {
|
||||
attempt,
|
||||
@@ -459,8 +485,8 @@ pub(crate) fn run_curl_with_retry_with_proxy(
|
||||
if !retryable {
|
||||
break;
|
||||
}
|
||||
if busy && attempt < attempts {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5 * attempt as u64));
|
||||
if attempt < attempts {
|
||||
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, "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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +425,7 @@ mod tests {
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "synthetic/catalog.json".to_string(),
|
||||
@@ -433,6 +434,7 @@ mod tests {
|
||||
resource_type: ResourceType::Manifest,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "TextAssets/dialogue.csv".to_string(),
|
||||
@@ -441,6 +443,7 @@ mod tests {
|
||||
resource_type: ResourceType::TextAsset,
|
||||
address: Some("dialogue".to_string()),
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "TableBundles/ExcelDB.db".to_string(),
|
||||
@@ -449,6 +452,7 @@ mod tests {
|
||||
resource_type: ResourceType::TableBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
ResourceEntry {
|
||||
path: "MediaResources-Windows/voice/title.acb".to_string(),
|
||||
@@ -457,6 +461,7 @@ mod tests {
|
||||
resource_type: ResourceType::Media,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
],
|
||||
metadata: ManifestMetadata {
|
||||
@@ -487,6 +492,7 @@ mod tests {
|
||||
resource_type: ResourceType::TextAsset,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +513,7 @@ mod tests {
|
||||
resource_type: ResourceType::AssetBundle,
|
||||
address: None,
|
||||
dependencies: Vec::new(),
|
||||
crc: None,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -111,7 +111,12 @@ impl OfficialResourcePullProgressKind {
|
||||
pub struct OfficialResourcePullProgress {
|
||||
/// Progress event kind.
|
||||
pub kind: OfficialResourcePullProgressKind,
|
||||
/// One-based URL index in the pull plan.
|
||||
/// Monotonic completed item count for the whole pull.
|
||||
///
|
||||
/// `Started` events report the currently completed count before the URL
|
||||
/// finishes; `Finished` events report the count after completion. This is
|
||||
/// intentionally not the URL's plan position, because concurrent downloads
|
||||
/// finish out of plan order and status percentages must not move backward.
|
||||
pub index: usize,
|
||||
/// Total URL count in the pull plan.
|
||||
pub total: usize,
|
||||
@@ -562,29 +567,21 @@ impl OfficialResourcePullService {
|
||||
let mut manifest = self.read_download_manifest()?;
|
||||
let urls = plan.all_urls()?;
|
||||
let total = urls.len();
|
||||
let mut items = Vec::new();
|
||||
let mut verified_hashes = Vec::new();
|
||||
let mut verified_hash_urls = HashSet::<String>::new();
|
||||
let mut processed_urls = HashSet::<String>::new();
|
||||
for (offset, url) in urls.into_iter().enumerate() {
|
||||
if should_cancel() {
|
||||
return Err("官方资源拉取已被停止请求中断".to_string().into());
|
||||
}
|
||||
|
||||
let index = offset + 1;
|
||||
progress(OfficialResourcePullProgress::started(
|
||||
index,
|
||||
total,
|
||||
url.clone(),
|
||||
));
|
||||
|
||||
// Phase A:无网络的前置校验(fail-fast)。逐个校验官方性、算目标路径、
|
||||
// 建目录,并判定该 URL 是「已验证可跳过」还是「需下载」。任何非官方
|
||||
// URL 在发起任何下载前即拒绝。
|
||||
if should_cancel() {
|
||||
return Err("官方资源拉取已被停止请求中断".to_string().into());
|
||||
}
|
||||
let mut planned: Vec<PlannedDownload> = Vec::with_capacity(total);
|
||||
for url in urls {
|
||||
if !is_official_yostar_jp_url(&url) {
|
||||
return Err(DownloadError::new(
|
||||
bat_core::ErrorCode::NON_OFFICIAL_URL,
|
||||
format!("拒绝下载非官方 URL:{url}"),
|
||||
));
|
||||
}
|
||||
|
||||
let destination = self.destination_for_url(&url)?;
|
||||
if let Some(parent) = destination.parent() {
|
||||
ensure_safe_directory_path(parent, "下载目标目录")?;
|
||||
@@ -596,50 +593,148 @@ impl OfficialResourcePullService {
|
||||
ensure_safe_file_target(&self.output_root, &destination, "下载目标文件")?;
|
||||
|
||||
let force_refresh = force_refresh_urls.contains(&url);
|
||||
let result = if force_refresh {
|
||||
let existing = if force_refresh {
|
||||
None
|
||||
} else {
|
||||
self.validated_existing_file(&url, &destination, &manifest)?
|
||||
};
|
||||
let result = if let Some(result) = result {
|
||||
self.clear_quarantine_entry(&url)?;
|
||||
result
|
||||
} else {
|
||||
let result = match self.pull_one(&url, &destination) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
self.record_quarantine_entry(&url, &destination, &error)?;
|
||||
planned.push(PlannedDownload {
|
||||
url,
|
||||
destination,
|
||||
existing,
|
||||
});
|
||||
}
|
||||
|
||||
// Phase B:顺序下载 need-download 项。每个 URL 的目标和 `.part` 都是独立
|
||||
// 的,但这里保留单线程执行,便于维持稳定进度、稳定日志和简单的失败恢复。
|
||||
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,
|
||||
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(
|
||||
index,
|
||||
completed_count,
|
||||
total,
|
||||
url.clone(),
|
||||
item.url.clone(),
|
||||
&error,
|
||||
));
|
||||
return Err(DownloadError::new(
|
||||
error.error_code(),
|
||||
format!(
|
||||
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={url} quarantine={};{}",
|
||||
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={} quarantine={};{}",
|
||||
item.url,
|
||||
self.download_quarantine_path().display(),
|
||||
error.message
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
self.clear_quarantine_entry(&url)?;
|
||||
self.record_download_manifest_entry(&mut manifest, &url, &destination)?;
|
||||
self.write_download_manifest(&manifest)?;
|
||||
result
|
||||
|
||||
completed_count += 1;
|
||||
progress(OfficialResourcePullProgress::finished(
|
||||
completed_count,
|
||||
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(
|
||||
index,
|
||||
total,
|
||||
url.clone(),
|
||||
result.status,
|
||||
result.bytes,
|
||||
result.transferred_bytes,
|
||||
));
|
||||
processed_urls.insert(url.clone());
|
||||
processed_urls.insert(item.url.clone());
|
||||
self.verify_ready_official_hashes(
|
||||
&official_hash_pairs,
|
||||
&processed_urls,
|
||||
@@ -648,17 +743,14 @@ impl OfficialResourcePullService {
|
||||
&mut manifest,
|
||||
)?;
|
||||
items.push(OfficialResourcePullItem {
|
||||
url,
|
||||
destination,
|
||||
url: item.url.clone(),
|
||||
destination: item.destination.clone(),
|
||||
bytes: result.bytes,
|
||||
transferred_bytes: result.transferred_bytes,
|
||||
status: result.status,
|
||||
});
|
||||
}
|
||||
|
||||
if should_cancel() {
|
||||
return Err("官方资源拉取已被停止请求中断".to_string().into());
|
||||
}
|
||||
self.verify_all_official_hashes_are_complete(&official_hash_pairs, &verified_hash_urls)?;
|
||||
|
||||
Ok(OfficialResourcePullReport {
|
||||
@@ -1603,6 +1695,14 @@ fn default_download_quarantine_version() -> u32 {
|
||||
DOWNLOAD_QUARANTINE_VERSION
|
||||
}
|
||||
|
||||
/// Phase A 产出的单个下载计划项:URL、目标路径,以及若命中本地 manifest
|
||||
/// 校验则带上「已验证可跳过」的结果(`existing`)。
|
||||
struct PlannedDownload {
|
||||
url: String,
|
||||
destination: PathBuf,
|
||||
existing: Option<PullOneResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct PullOneResult {
|
||||
bytes: u64,
|
||||
@@ -1912,7 +2012,7 @@ exit 22
|
||||
YostarJpDownloadInventory::from_catalog_bytes(
|
||||
b"FullPatch_000.zip",
|
||||
b"ExcelDB.db ExcelDB.db",
|
||||
b"JP_Airi.zip",
|
||||
b"GameData\\Audio\\VOC_JP\\JP_Airi.zip",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1972,10 +2072,10 @@ done
|
||||
printf '%s' '{android_media_hash}' > "$out"
|
||||
;;
|
||||
*/MediaResources-Windows/Catalog/MediaCatalog.bytes)
|
||||
printf '%s' 'JP_Airi_Win.zip' > "$out"
|
||||
printf '%s' 'GameData\Audio\VOC_JP\JP_Airi_Win.zip' > "$out"
|
||||
;;
|
||||
*/MediaResources/Catalog/MediaCatalog.bytes)
|
||||
printf '%s' 'JP_Airi_Android.zip' > "$out"
|
||||
printf '%s' 'GameData\Audio\VOC_JP\JP_Airi_Android.zip' > "$out"
|
||||
;;
|
||||
*.zip)
|
||||
printf '\120\113\003\004\024\000\000\000\000\000\000\000\000\000\000\000\000\000\002\000\000\000\002\000\000\000\010\000\000\000file.txtok\120\113\001\002\024\000\024\000\000\000\000\000\000\000\000\000\000\000\000\000\002\000\000\000\002\000\000\000\010\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000file.txt\120\113\005\006\000\000\000\000\001\000\001\000\066\000\000\000\050\000\000\000\000\000' > "$out"
|
||||
@@ -1991,8 +2091,8 @@ fi
|
||||
table_catalog_hash = xxhash32(b"ExcelDB.db"),
|
||||
windows_bundle_hash = xxhash32(b"FullPatch_000.zip"),
|
||||
android_bundle_hash = xxhash32(b"FullPatch_001.zip"),
|
||||
windows_media_hash = xxhash32(b"JP_Airi_Win.zip"),
|
||||
android_media_hash = xxhash32(b"JP_Airi_Android.zip"),
|
||||
windows_media_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"),
|
||||
android_media_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2697,7 +2797,10 @@ exit 22
|
||||
assert_eq!(xxhash32(b""), 46947589);
|
||||
assert_eq!(xxhash32(b"ExcelDB.db"), 2044170421);
|
||||
assert_eq!(xxhash32(b"FullPatch_000.zip"), 4038880697);
|
||||
assert_eq!(xxhash32(b"JP_Airi_Win.zip"), 1416778215);
|
||||
assert_eq!(
|
||||
xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"),
|
||||
1865771294
|
||||
);
|
||||
assert_eq!(xxhash32(b"ExcelDB.db ExcelDB.db"), 2147704569);
|
||||
}
|
||||
|
||||
@@ -2853,6 +2956,8 @@ exit 22
|
||||
let curl_path = bin_dir.path().join("curl");
|
||||
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 plan = build_official_pull_plan_for_platforms(
|
||||
discovery_plan(),
|
||||
@@ -2868,19 +2973,79 @@ exit 22
|
||||
|
||||
assert_eq!(report.items.len(), expected_urls);
|
||||
assert_eq!(events.len(), expected_urls * 2);
|
||||
assert_eq!(events[0].kind, OfficialResourcePullProgressKind::Started);
|
||||
assert_eq!(events[0].index, 1);
|
||||
assert_eq!(events[0].total, expected_urls);
|
||||
assert_eq!(events[1].kind, OfficialResourcePullProgressKind::Finished);
|
||||
assert_eq!(
|
||||
events[1].status,
|
||||
Some(OfficialResourcePullStatus::Downloaded)
|
||||
);
|
||||
|
||||
let started: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| event.kind == OfficialResourcePullProgressKind::Started)
|
||||
.collect();
|
||||
let finished: Vec<_> = events
|
||||
.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 覆盖同一组 URL;finished 均为已下载。
|
||||
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
|
||||
.iter()
|
||||
.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]
|
||||
fn retries_transient_download_failures() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -186,7 +186,7 @@ mod tests {
|
||||
YostarJpDownloadInventory::from_catalog_bytes(
|
||||
b"FullPatch_000.zip",
|
||||
b"ExcelDB.db ExcelDB.db",
|
||||
b"JP_Airi.zip",
|
||||
b"GameData\\Audio\\VOC_JP\\JP_Airi.zip",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -197,12 +197,12 @@ mod tests {
|
||||
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
||||
PatchPlatform::Windows,
|
||||
b"FullPatch_000.zip",
|
||||
b"JP_Airi_Win.zip",
|
||||
b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip",
|
||||
),
|
||||
YostarJpPlatformCatalogInventory::from_catalog_bytes(
|
||||
PatchPlatform::Android,
|
||||
b"FullPatch_001.zip",
|
||||
b"JP_Airi_Android.zip",
|
||||
b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip",
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -229,11 +229,11 @@ mod tests {
|
||||
assert!(all_urls[0].ends_with("TableCatalog.bytes"));
|
||||
assert!(all_urls
|
||||
.iter()
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/JP_Airi.zip")));
|
||||
.any(|url| url.ends_with("/MediaResources-Windows/GameData/Audio/VOC_JP/JP_Airi.zip")));
|
||||
assert_eq!(
|
||||
all_urls
|
||||
.iter()
|
||||
.filter(|url| url.ends_with("/MediaResources/JP_Airi.zip"))
|
||||
.filter(|url| url.ends_with("/MediaResources/GameData/Audio/VOC_JP/JP_Airi.zip"))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
@@ -1649,7 +1649,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
|
||||
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
|
||||
"download",
|
||||
format!(
|
||||
"下载进度:总体 {}/{} ({:.1}%);单文件开始 URL={}",
|
||||
"下载进度:已完成 {}/{} ({:.1}%);单文件开始 URL={}",
|
||||
event.index,
|
||||
event.total,
|
||||
download_progress_percent(event.index, event.total),
|
||||
@@ -1662,7 +1662,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
|
||||
OfficialUpdateProgress::new(
|
||||
"download",
|
||||
format!(
|
||||
"下载进度:总体 {}/{} ({:.1}%);单文件完成 状态={} 文件字节={} 本轮传输字节={} URL={}",
|
||||
"下载进度:已完成 {}/{} ({:.1}%);单文件完成 状态={} 文件字节={} 本轮传输字节={} URL={}",
|
||||
event.index,
|
||||
event.total,
|
||||
download_progress_percent(event.index, event.total),
|
||||
@@ -1677,7 +1677,7 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
|
||||
OfficialResourcePullProgressKind::Failed => OfficialUpdateProgress::new(
|
||||
"download",
|
||||
format!(
|
||||
"下载中断:总体 {}/{} ({:.1}%);失败类型={} HTTP={} 可重试={} 尝试次数={} quarantine={};本轮跳过该 URL 且不会发布不完整资源 URL={}",
|
||||
"下载中断:已完成 {}/{} ({:.1}%);失败类型={} HTTP={} 可重试={} 尝试次数={} quarantine={};本轮跳过该 URL 且不会发布不完整资源 URL={}",
|
||||
event.index,
|
||||
event.total,
|
||||
download_progress_percent(event.index, event.total),
|
||||
|
||||
@@ -129,13 +129,18 @@ impl SqliteResourceRepository {
|
||||
resource_type TEXT NOT NULL,
|
||||
local_path TEXT NOT NULL,
|
||||
address TEXT,
|
||||
dependencies_json TEXT NOT NULL DEFAULT '[]'
|
||||
dependencies_json TEXT NOT NULL DEFAULT '[]',
|
||||
crc INTEGER
|
||||
)
|
||||
"#,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 向后兼容:早于 crc 列的旧库缺少该列,按需补加(新建库已含该列,
|
||||
// pragma 检查后不会重复 ALTER)。
|
||||
Self::ensure_column(&self.pool, "resources", "crc", "INTEGER").await?;
|
||||
|
||||
Self::execute_query(
|
||||
&self.pool,
|
||||
sqlx::query(
|
||||
@@ -182,6 +187,33 @@ impl SqliteResourceRepository {
|
||||
.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 {
|
||||
match resource_type {
|
||||
ResourceType::AssetBundle => "AssetBundle",
|
||||
@@ -219,7 +251,8 @@ impl SqliteResourceRepository {
|
||||
}
|
||||
|
||||
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 {
|
||||
id,
|
||||
local_path: PathBuf::from(local_path),
|
||||
@@ -230,6 +263,7 @@ impl SqliteResourceRepository {
|
||||
resource_type: Self::resource_type_from_str(&resource_type)?,
|
||||
address,
|
||||
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>,
|
||||
) -> bat_core::Result<Vec<Resource>> {
|
||||
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)?;
|
||||
builder.push(" ORDER BY id");
|
||||
@@ -308,9 +342,9 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
sqlx::query(
|
||||
r#"
|
||||
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
|
||||
path = excluded.path,
|
||||
hash = excluded.hash,
|
||||
@@ -318,7 +352,8 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
resource_type = excluded.resource_type,
|
||||
local_path = excluded.local_path,
|
||||
address = excluded.address,
|
||||
dependencies_json = excluded.dependencies_json
|
||||
dependencies_json = excluded.dependencies_json,
|
||||
crc = excluded.crc
|
||||
"#,
|
||||
)
|
||||
.bind(resource.id.clone())
|
||||
@@ -328,7 +363,8 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
.bind(Self::resource_type_to_str(resource.entry.resource_type))
|
||||
.bind(resource.local_path.to_string_lossy().to_string())
|
||||
.bind(resource.entry.address.clone())
|
||||
.bind(dependencies),
|
||||
.bind(dependencies)
|
||||
.bind(resource.entry.crc.map(i64::from)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -338,7 +374,7 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
async fn find_by_id(&self, id: &str) -> bat_core::Result<Resource> {
|
||||
let row: Option<ResourceRow> = sqlx::query_as(
|
||||
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
|
||||
WHERE id = ?1
|
||||
"#,
|
||||
@@ -356,7 +392,7 @@ impl ResourceRepository for SqliteResourceRepository {
|
||||
async fn find_by_hash(&self, hash: &str) -> bat_core::Result<Resource> {
|
||||
let row: Option<ResourceRow> = sqlx::query_as(
|
||||
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
|
||||
WHERE hash = ?1
|
||||
ORDER BY id
|
||||
@@ -418,6 +454,7 @@ type ResourceRow = (
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<i64>,
|
||||
);
|
||||
|
||||
fn glob_to_like(pattern: &str) -> String {
|
||||
@@ -505,6 +542,7 @@ mod tests {
|
||||
resource_type,
|
||||
address: None,
|
||||
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") }));
|
||||
assert!(events.iter().any(|event| {
|
||||
event.stage == "download"
|
||||
&& event.message.contains("下载进度:总体")
|
||||
&& event.message.contains("下载进度:已完成")
|
||||
&& event.message.contains("单文件")
|
||||
}));
|
||||
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());
|
||||
}
|
||||
|
||||
#[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 {
|
||||
temp: TempDir,
|
||||
curl_script: std::path::PathBuf,
|
||||
@@ -362,6 +431,7 @@ impl TestHarness {
|
||||
&resources_assets_path,
|
||||
resources_assets.len(),
|
||||
manifest_source,
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -478,11 +548,16 @@ fn official_curl_script(
|
||||
resources_assets_fixture: &Path,
|
||||
resources_assets_size: usize,
|
||||
manifest_source: &str,
|
||||
failure_state: Option<&Path>,
|
||||
) -> String {
|
||||
let failure_state = failure_state
|
||||
.map(shell_quote)
|
||||
.unwrap_or_else(|| "''".to_string());
|
||||
format!(
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
log={}
|
||||
failure_state={failure_state}
|
||||
printf '%s\n' "$*" >> "$log"
|
||||
output=""
|
||||
url=""
|
||||
@@ -522,6 +597,23 @@ emit_resources_assets_fixture() {{
|
||||
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
|
||||
cat <<'JSON'
|
||||
{{"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
|
||||
emit_text "{windows_bundle_catalog_hash}"
|
||||
elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.bytes" ]]; then
|
||||
emit_text "JP_Airi_Win.zip"
|
||||
emit_text "GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"
|
||||
elif [[ "$url" == "{addressables_root}/MediaResources-Windows/Catalog/MediaCatalog.hash" ]]; then
|
||||
emit_text "{windows_media_catalog_hash}"
|
||||
elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.bytes" ]]; then
|
||||
@@ -569,10 +661,11 @@ elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.bytes
|
||||
elif [[ "$url" == "{addressables_root}/Android_PatchPack/BundlePackingInfo.hash" ]]; then
|
||||
emit_text "{android_bundle_catalog_hash}"
|
||||
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.bytes" ]]; then
|
||||
emit_text "JP_Airi_Android.zip"
|
||||
emit_text "GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"
|
||||
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.hash" ]]; then
|
||||
emit_text "{android_media_catalog_hash}"
|
||||
elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then
|
||||
maybe_fail_once
|
||||
filename="${{url##*/}}"
|
||||
if [[ "$filename" == *.zip ]]; then
|
||||
emit_zip_fixture
|
||||
@@ -601,9 +694,10 @@ fi
|
||||
addressables_root = TEST_ADDRESSABLES_ROOT,
|
||||
table_catalog_hash = xxhash32(b"ExcelDB.db ExcelDB.db"),
|
||||
windows_bundle_catalog_hash = xxhash32(b"FullPatch_000.zip"),
|
||||
windows_media_catalog_hash = xxhash32(b"JP_Airi_Win.zip"),
|
||||
windows_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"),
|
||||
android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"),
|
||||
android_media_catalog_hash = xxhash32(b"JP_Airi_Android.zip"),
|
||||
android_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"),
|
||||
failure_state = failure_state,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¶ms); 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")
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ run_step "02-first-full-pull" \
|
||||
--progress \
|
||||
--no-banner
|
||||
|
||||
require_contains "${REPORT_DIR}/02-first-full-pull.stderr.log" "下载进度:总体" "first pull did not emit total download progress"
|
||||
require_contains "${REPORT_DIR}/02-first-full-pull.stderr.log" "下载进度:已完成" "first pull did not emit completed download progress"
|
||||
require_contains "${REPORT_DIR}/02-first-full-pull.stderr.log" "单文件" "first pull did not emit single-file download progress"
|
||||
require_contains "${REPORT_DIR}/02-first-full-pull.stderr.log" "校验结果:" "first pull did not emit verification summary"
|
||||
|
||||
@@ -129,7 +129,7 @@ run_step "04-repair-after-damage" \
|
||||
|
||||
require_contains "${REPORT_DIR}/04-repair-after-damage.stdout.json" '"command": "repair"' "repair command report missing"
|
||||
require_contains "${REPORT_DIR}/04-repair-after-damage.stdout.json" '"status": "completed"' "repair command did not complete"
|
||||
require_contains "${REPORT_DIR}/04-repair-after-damage.stderr.log" "下载进度:总体" "repair did not emit total download progress"
|
||||
require_contains "${REPORT_DIR}/04-repair-after-damage.stderr.log" "下载进度:已完成" "repair did not emit completed download progress"
|
||||
require_contains "${REPORT_DIR}/04-repair-after-damage.stderr.log" "单文件" "repair did not emit single-file download progress"
|
||||
require_contains "${REPORT_DIR}/04-repair-after-damage.stderr.log" "校验结果:" "repair did not emit verification summary"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user