Compare commits

..
3 Commits
Author SHA1 Message Date
nyaKazuha 102b49b666 feat(rpc): 完成 issue #1 Go 调用边界
bat-rust / Build and test Rust (push) Failing after 3m17s
2026-07-24 17:58:34 +08:00
nyaKazuha ecda08ed97 feat(rpc): 补齐 issue #1 资源后端接口 2026-07-24 15:55:37 +08:00
nyaKazuha a729615a48 fix(sync): 移除多线程下载并补齐staging复用回归 2026-07-24 10:34:58 +08:00
27 changed files with 1427 additions and 355 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
*.dll
*.so
*.dylib
bat
/bat
# Test binary, built with `go test -c`
*.test
+2 -2
View File
@@ -9,10 +9,10 @@
### 新增
- 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
- 官方资源下载支持多线程:并行仅作用于实际网络下载(默认并发 4,可经 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`),manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17
- 官方资源下载回归顺序执行:manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变(issue #17
### 修复
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s,并发下载时对官方 CDN 更礼貌
- 官方下载失败重试之间加入指数退避(网络类失败 200ms→400ms→800ms…,上限 5s
### 计划
- [ ] 实现 `bat-api`:仿 BlueArchive 官方 API 的 Go HTTP 服务(含鉴权/签名验签,issue #19
+12 -11
View File
@@ -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,14 +19,14 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
3. 默认平台为 `Windows + Android`
4. 能生成官方全量 pull plan,执行真实下载,维护 release 内的 `official-download-manifest.json`
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;所有 `.zip` 在下载验收、复用、本地 audit/verify 时做 ZIP 结构校验;官方 seed `.hash` 使用标准 `xxHash32(seed=0)` 强校验(早期实现的非标准 avalanche 常量已修正)。
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。下载支持多线程:并行仅作用于实际网络下载(默认并发 4,可经 `--download-concurrency`/`BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`),daemon 子进程会透传该并发配置manifest/quarantine 簿记与 seed `.hash` 校验保持串行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。
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、CRC 与 UnityFS 边界校验均有离线回归 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 产品入口、完整 AssetBundle 引擎解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量拉取 smoke 已固化为可重复脚本和 runbook(G-018 已关闭),当前正在进行长期运行测试,运行报告将在后续提供;真实大文件产物与运行报告默认保存在 `/tmp` 隔离目录,不纳入 Git。
@@ -198,12 +198,13 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
### Go / API / Web
状态:**Go 产品入口仍未完成,仅有可选 CGO 兼容包装和试验性 `cmd/bat` 骨架**
状态:**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` 进程边界;`cmd/bat` 目前只是试验性骨架,不代表产品级 CLI 已完成。
- 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 ./...` 可作为基础门禁。
@@ -234,7 +235,7 @@ git status --short --branch
未执行:
- 本次状态更新未执行一次性真实官方网络全量下载 smoke;该流程已由 `docs/guides/official-full-pull-smoke.md``scripts/official-full-pull-smoke.sh` 固化并关闭(G-018),当前处于长期运行测试阶段,运行报告将在后续提供。
- Go CLI 端到端测试,因为 Go 产品入口尚未完成。
- Go CLI 端到端测试,因为 Go 产品入口尚未完成`internal/backendrpc` 已有 fake transport 单测覆盖
- Web/API 测试,因为 Web/API 尚未实现。
---
@@ -264,12 +265,12 @@ cargo run -p bat-infrastructure --bin bat -- \
## 6. 当前阻塞项
GitHub issue 状态:当前 open 的是 #1P1)、#2P2)、#3P2)、#17P2)、#19P2)。其中 #17 的实现已合入 HEAD,但 issue 本身尚未关闭,需在验收后再同步关闭。
GitHub issue 状态:#1 已升为 P0#17 的实现已合入 HEAD,但 issue 本身尚未关闭,需在验收后再同步关闭。其他 open issue 的实时标签以 GitHub 为准。
下一阶段必须优先完成:
1. Issue #1P1,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope`ok``status``error``data``request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`status/logs/stop/reload/refresh)、`resource.*`state/sync/verify/manifest)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`resource.repair`(待引擎独立修复模式)、`task.create`(按设计由语义方法创建)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
2. `cmd/bat` Go CLI 骨架:当前只实现 `doctor``manifest inspect``sync plan` 这类试验性入口,不能视作产品级 CLI;是否继续作为长期产品入口需要单独收敛。
1. Issue #1P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope`ok``status``error``data``request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`status/logs/stop/reload/refresh/doctor)、`resource.*`state/sync/verify/repair/manifest/list)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:`patch.*` / `unityfs.*`(被引擎阻塞)、`task.create`(按设计由语义方法创建)、`daemon.restart` / `daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做自重启或在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
2. `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 #3P2):AssetBundle UnityFS 基础解析校验。
5. Issue #2P2):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
@@ -283,7 +284,7 @@ GitHub issue 状态:当前 open 的是 #1P1)、#2P2)、#3P2)、
立即任务:
1. Issue #1 收尾:协议基础设施、最小方法集`catalog.*`/`task.*` 全量、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)与任务持久化按后续里程碑推进
1. Issue #1 收尾:协议基础设施、最小方法集`catalog.*``task.*``resource.repair`、任务持久化、错误码模型与文档(USERGUIDE §5/§6、架构文档 §7)均已完成;剩余 `patch.*`/`unityfs.*`(待引擎)以及 `task.create``daemon.restart``daemon.clean-stable` 的设计边界确认
2. 明确 Go 产品入口的边界:是继续推进独立 `bat` CLI,还是保留当前 Rust `bat` 为用户 CLI、Go 只做服务层与 `bat-api`
3. 跟进官方同步长期运行测试,收集并归档运行报告。
4. 开始 AssetBundle parser 的 UnityFS header/block/directoryissue #3),并继续扩展 Addressables catalog 可校验字段(issue #2)。
@@ -291,5 +292,5 @@ GitHub issue 状态:当前 open 的是 #1P1)、#2P2)、#3P2)、
---
- **当前总体完成度**:不再固定写单一百分比,以各模块状态和 issue 为准。
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go 产品入口、`bat-api`、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。
- **当前基线状态**:Rust 官方资源同步链路已具备可运行闭环;Go `bat-api` 已有 Rust daemon RPC client,但产品级 CLI/API、CAS 用户级导入和完整 AssetBundle 引擎仍未完成。
- **下一工程里程碑**Rust Resource Backend RPC API 收尾、Go 产品入口收敛、官方同步结果接入 CAS/ResourceRepository、AssetBundle 解析起步。
+11 -7
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 文档索引
- **更新时间**2026-07-20
- **更新时间**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,12 +80,13 @@
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`
---
@@ -98,6 +101,7 @@
- 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 已覆盖下载已完成计数、单文件下载进度和校验结果摘要。
- Addressables 当前真实形态 fixture/golden 覆盖。
+5 -5
View File
@@ -32,7 +32,7 @@ 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` 已合并。
@@ -40,7 +40,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
1. `bat-assetbundle` 仍是占位 crate;完整 UnityFS、压缩块、TypeTree 或对象表解析未完成。
2. `bat-patch` 的 Binary/JSON 模块仍返回明确的未实现错误,不具备真实补丁能力。
3. Go CLI/API/SDK 仍没有产品级入口;当前只有 `cmd/bat``internal/ffi` 的试验/兼容骨架
3. Go CLI/API/SDK 仍没有产品级入口;当前 `bat-api` module 只有 `internal/backendrpc` Rust daemon RPC client、`cmd/bat` 试验骨架`internal/ffi` 兼容包装
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
5. 官方同步结果尚未作为用户级流程自动导入 CAS + ResourceRepository。
6. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
@@ -141,7 +141,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
**目标**:完成可长期使用的 Content Addressable Storage。
**当前状态**:已完成 CAS V1。Go CLI 产品入口尚未完成,当前仅存在 `cmd/bat` 试验骨架;Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。
**当前状态**:已完成 CAS V1。Go CLI/API 产品入口尚未完成`bat-api` 已有 `internal/backendrpc` typed RPC client 和 `cmd/bat` 试验骨架;Rust 继续承载完整资源拉取与更新检查核心逻辑;`bat-ffi` 仅保留为可选兼容层。
交付物:
@@ -174,7 +174,7 @@ 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 产品入口:**未完成**。当前 `cmd/bat` 仅有 `doctor``manifest inspect``sync plan` 试验能力,尚不构成产品级 CLI;若要继续由 Go 承担用户入口,需要单独收敛命令集和调用边界。
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`
@@ -380,7 +380,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 6. 近期具体任务
优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,Go `cmd/bat` 仍只是试验骨架,`bat-api` 是独立的 Go HTTP 服务目标(issue #19 / G-009):
优先完善 Rust `bat` 后端,并同步收敛 Go 产品入口边界。当前事实是 Rust `bat` 已承担可用的资源同步/运维入口,`bat-api/internal/backendrpc` 已提供 Go 到 Rust daemon 的本机 RPC clientGo `cmd/bat` 仍只是试验骨架,`bat-api` HTTP 服务仍是独立目标(issue #19 / G-009):
1. 对 issue #17 做验收并关闭或更新范围:多线程下载与指数退避实现已合入,但 GitHub issue 仍 open。
2. 继续逆向 Addressables catalog,扩大 bundle hash/size/CRC 等可校验字段覆盖(issue #2)。
+6 -4
View File
@@ -2,7 +2,7 @@
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用 `--watch` 常驻定期检查更新。Go 侧目前只有试验性的 `cmd/bat` 骨架`internal/ffi` 兼容包装产品级 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 系统仍在后续阶段。
---
@@ -13,7 +13,8 @@
- `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 或生产同步的主集成边界。
@@ -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 socketsocket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`
`status``stop``logs``reload`默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socketsocket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh`/`repair` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
@@ -156,6 +157,7 @@ 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 试验骨架与后续产品入口
├── pkg/ # Go SDK 包,尚未实现
+14 -7
View File
@@ -84,7 +84,6 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
| `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h |
| `--no-proxy` | 强制直连 |
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip` |
| `--download-concurrency <N>` | 并行下载数,`1..=256`(默认 `4`)。并行仅作用于实际网络下载;manifest/quarantine 簿记与 seed `.hash` 校验仍串行,`fail-fast` 与「不发布不完整资源」不变量保留。可下载失败重试带指数退避以对官方 CDN 礼貌 |
| `--dry-run` | 不写同步状态 |
| `--plan` | dry-run 时输出计划中的 URL |
| `--force` | 强制下载/刷新 |
@@ -127,7 +126,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
- 优先级:**命令行参数 > 进程环境变量 > `.env` > 内置默认值**。
- 语法:每行 `KEY=VALUE``#` 开头为注释;值两侧成对引号会剥除;空值视为未设置。
- 支持的键:`BAT_OUTPUT``BAT_STATE_DIR``BAT_AUTO_DISCOVER``BAT_WATCH``BAT_DAEMON``BAT_PROXY``BAT_NO_PROXY``BAT_INTERVAL_SECONDS``BAT_ERROR_RETRY_SECONDS``BAT_DOWNLOAD_CONCURRENCY``BAT_APP_VERSION``BAT_CONNECTION_GROUP``BAT_LAUNCHER_VERSION``BAT_PLATFORMS``BAT_CURL``BAT_UNZIP``BAT_JSON``BAT_QUIET_UP_TO_DATE`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`
- 支持的键:`BAT_OUTPUT``BAT_STATE_DIR``BAT_AUTO_DISCOVER``BAT_WATCH``BAT_DAEMON``BAT_PROXY``BAT_NO_PROXY``BAT_INTERVAL_SECONDS``BAT_ERROR_RETRY_SECONDS``BAT_APP_VERSION``BAT_CONNECTION_GROUP``BAT_LAUNCHER_VERSION``BAT_PLATFORMS``BAT_CURL``BAT_UNZIP``BAT_JSON``BAT_QUIET_UP_TO_DATE`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run``.env` 的模式开关让位。`status` / `verify` 等子命令不受它们影响。
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`
-`BAT_SKIP_ENV_FILE=1` 可让 `bat` 完全跳过 `.env` 的生成与加载。
@@ -240,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
@@ -272,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 |
@@ -284,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",
@@ -325,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
```
+10
View File
@@ -0,0 +1,10 @@
package main
import (
"fmt"
)
func runDoctor() error {
fmt.Println("bat doctor: ok")
return nil
}
+15
View File
@@ -0,0 +1,15 @@
package main
import "bat-api/internal/ffi"
func InspectManifest(rawJSON string) (string, error) {
return ffi.InspectManifest(rawJSON)
}
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
return ffi.BuildSyncPlan(currentJSON, previousJSON)
}
func batVersion() (string, error) {
return ffi.Version()
}
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
var err error
switch os.Args[1] {
case "doctor":
err = runDoctor()
case "manifest":
err = runManifest(os.Args[2:])
case "sync":
err = runSync(os.Args[2:])
case "help", "-h", "--help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func printUsage() {
fmt.Println("bat - BlueArchiveToolkit CLI")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" bat doctor")
fmt.Println(" bat manifest inspect <file>")
fmt.Println(" bat sync plan <current-json> [previous-json]")
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
)
func runManifest(args []string) error {
if len(args) < 2 || args[0] != "inspect" {
return fmt.Errorf("usage: bat manifest inspect <file>")
}
path := args[1]
data, err := os.ReadFile(path)
if err != nil {
return err
}
result, err := InspectManifest(string(data))
if err != nil {
return err
}
fmt.Println(result)
return nil
}
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"fmt"
"os"
)
func runSync(args []string) error {
if len(args) < 2 || args[0] != "plan" {
return fmt.Errorf("usage: bat sync plan <current-json> [previous-json]")
}
current := args[1]
previous := ""
if len(args) > 2 {
previous = args[2]
}
result, err := BuildSyncPlan(current, previous)
if err != nil {
return err
}
fmt.Fprintln(os.Stdout, result)
return nil
}
+12 -9
View File
@@ -189,7 +189,7 @@
集成边界:
1. 当前生产集成路径是运行 `bat --json` 并消费结构化 report;未来 Go CLI 若继续作为产品入口,也应优先使用该进程边界或 daemon RPC
1. 当前生产集成路径是 Rust `bat --watch` / `bat --daemon` 持久运行;Go `bat-api` 应优先通过 `internal/backendrpc` 调用 daemon RPCone-shot/fallback 场景才运行 `bat --json` 并消费结构化 report。
2. systemd、容器或上层 Go 进程只负责守护 `bat --watch` / `bat --daemon`,不直接接管下载器内部状态。
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 logstdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC 控制、`status``stop``restart``reload``logs``refresh --force``verify``repair``doctor``clean-stable`,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging``versions``current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress logstdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend`daemon.status/logs/stop/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list``catalog.*``task.*`);`restart``clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging``versions``current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
- `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 请求/响应)。当前 Go 产品入口尚未完成,`cmd/bat` 仍是试验骨架。
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon
(连接 `bat.sock`,每行一个 JSON-RPC 请求/响应)。当前 Go 产品入口
尚未完成,`cmd/bat` 仍是试验骨架。
- Rust daemon 负责:官方资源自动拉取与校验、catalog 更新检查、
版本状态与发布、任务队列/日志/错误/进度管理等长期状态型工作。
- Go 层**不**直接嵌入 Rust FFI,不直接读写 daemon 的状态文件与资源
+2 -2
View File
@@ -43,8 +43,8 @@ go vet ./...
说明:
1. 当前已有 `cmd/bat``internal/ffi` 骨架,但没有 Go 产品级测试覆盖;`go test ./...` 出现 `[no test files]` 不代表 CLI/API 已完成。
2. 如果后续新增 Go 产品 package,必须让 `go test ./...``go vet ./...` 纳入硬性验证。
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`
+2 -2
View File
@@ -250,7 +250,7 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat reload --state-dir /var/lib/bluearc
sudo -u bat /opt/bluearchive-toolkit/bin/bat stop --state-dir /var/lib/bluearchive-toolkit/daemon-state
```
`--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、curlunzip 等显式参数时,`refresh` 会作为一次性前台同步运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`repair` 会走官方同步链路重新下载必要文件,但同样不能和 live daemon 并行写同一资源目录;`clean-stable` 只清理 `.part``.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。
如果后台 daemon 正在运行,并且 `refresh` 没有显式指定另一套同步参数,`refresh` / `refresh --force` 会通过 RPC 唤醒或排队后台进程;默认形态的 `repair` 会通过 `resource.repair` RPC 入队本地 manifest 审计+修复任务并返回 `task_id``--output`、server-info、connection-group、app-version、platforms、snapshot、curlunzip 或其它显式同步参数时,`refresh` / `repair` 会作为一次性前台命令运行,但不能写入 live daemon 正在管理的同一资源目录,否则会返回 locked。`verify` 发现远端变化、本地缺失或校验失败时返回非 0;`clean-stable` 只清理 `.part``.tmp`、失效或损坏的 PID/socket/锁,不删除正式资源。
### 升级
+2 -2
View File
@@ -145,7 +145,7 @@ go test ./...
go vet ./...
```
Go 产品入口尚未完成,但仓库已有 `cmd/bat``internal/ffi` 骨架。提交前应运行 `go test ./...``go vet ./...`目前输出可能只有 `[no test files]`,这代表缺少 Go 产品测试覆盖,不代表 Go CLI 已完成。
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 已完成。
### 常用聚焦命令
@@ -160,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 产品入口和生产同步默认应通过 Rust `bat --json` 或 daemon RPC 进程边界集成
`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` 进程边界。
### 集成测试
+3 -3
View File
@@ -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 errorwatch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75``verify``doctor` 发现问题也返回非 0。
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`watch/daemon 模式还会在每天北京时间(UTC+8)`03:00``16:00``18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,下载执行保持顺序处理,已完成计数保持单调不倒退,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON errorwatch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75``verify``doctor` 发现问题也返回非 0。
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取资源时应读取 `--output/current`,不要读取 `.staging``versions` 中未切换的目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
+169
View File
@@ -0,0 +1,169 @@
# Rust Resource Backend RPC API
本文档冻结本机 Rust Resource Backend API 的稳定调用边界。Go 项目
`bat-api`、Go 服务层、运维脚本和 `bat` CLI 都应以这里的 JSON-RPC
contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主路径。
## 传输
- 传输:Unix domain socket。
- 默认 socket`/tmp/bat-pid/bat.sock`
- 协议:JSON-RPC 2.0,每行一个 request,每行一个 response。
- 编码:UTF-8 JSON。
- 访问控制:依赖本机文件权限和状态目录权限;不要把 socket 暴露到公网。
请求:
```json
{"jsonrpc":"2.0","id":1,"method":"resource.repair","params":null}
```
成功响应的 JSON-RPC 顶层 `result` 一律是应用层 envelope
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"ok": true,
"status": "accepted",
"data": {"task_id": "task-1234-1", "kind": "resource.repair"},
"request_id": "req-1234-1"
}
}
```
应用层失败也放在 `result` 的 envelope 中:
```json
{
"ok": false,
"status": "error",
"error": {
"code": "BAT-ERR-700003",
"kind": "not_implemented",
"domain": "rpc",
"location": "rpc.dispatch",
"message": "方法尚未实现:daemon.clean-stable",
"retryable": false
},
"request_id": "req-1234-2"
}
```
只有 JSON 解析失败等传输层错误使用 JSON-RPC 顶层 `error`
## Envelope
| 字段 | 类型 | 说明 |
|---|---|---|
| `ok` | bool | 应用层是否成功。 |
| `status` | string | `ok``accepted``error`。 |
| `data` | object/null | 成功结果。失败时省略。 |
| `error` | object/null | `ApiError`。成功时省略。 |
| `request_id` | string | daemon 进程内请求 ID,用于日志关联。 |
`ApiError` 结构以 `core/src/error_code.rs` 码表为准:
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | string | `BAT-ERR-<6位>`。 |
| `kind` | string | 错误类别。 |
| `domain` | string | 错误域。 |
| `location` | string | Rust 侧出错位置。 |
| `message` | string | 可诊断错误信息。 |
| `retryable` | bool | 调用方是否可以按策略重试。 |
## 方法
### daemon
| 方法 | 状态 | params | data |
|---|---|---|---|
| `daemon.status` | 已实现 | `null` | 后台状态报告。 |
| `daemon.logs` | 已实现 | `{ "tail": 200 }` | 日志尾部报告。 |
| `daemon.stop` | 已实现 | `null` | accepted ack。 |
| `daemon.reload` | 已实现 | `null` | accepted ack。 |
| `daemon.refresh` | 已实现 | `{ "force": false }` | accepted ack。 |
| `daemon.doctor` | 已实现 | `null` | 只读诊断报告。 |
| `daemon.restart` | 保留 | `null` | live RPC 不执行;由 CLI 生命周期入口处理。 |
| `daemon.clean-stable` | 保留 | `null` | live RPC 不执行;由 CLI 离线清理入口处理。 |
`bat.status``bat.stop``bat.reload``bat.refresh``bat.logs`
`bat.doctor``bat.clean-stable` 是兼容别名;新代码应使用 `daemon.*`
### resource
| 方法 | 状态 | params | data |
|---|---|---|---|
| `resource.state` | 已实现 | `null` | 资源发布根、版本状态、上次同步结果。 |
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 |
| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.manifest` 的兼容别名。 |
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`
`limit` 范围是 `1..=1000`,非法参数返回 `BAT-ERR-700002`
### catalog
| 方法 | 状态 | params | data |
|---|---|---|---|
| `catalog.status` | 已实现 | `null` | 当前已发布 catalog 概览。 |
| `catalog.versions` | 已实现 | `null` | current / in_progress / previous / failed。 |
| `catalog.diff` | 已实现 | `null` | 当前 snapshot 相对上一可用版本的差异。 |
| `catalog.refresh` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "catalog.refresh" }`。 |
只读查询在没有可用版本时返回 `ok=true``data.available=false`
### task
| 方法 | 状态 | params | data |
|---|---|---|---|
| `task.status` | 已实现 | `{ "task_id": "..." }` | 单个任务记录。 |
| `task.list` | 已实现 | `null` | `{ "tasks": [...] }`。 |
| `task.cancel` | 已实现 | `{ "task_id": "..." }` | cancel ack。 |
| `task.logs` | 已实现 | `{ "task_id": "..." }` | `{ "task_id": "...", "lines": [...] }`。 |
| `task.create` | 保留 | object | 不开放通用任务入口;由语义方法创建任务。 |
任务记录:
```json
{
"id": "task-1234-1",
"kind": "resource.repair",
"status": "queued",
"stage": null,
"message": null,
"created_at": 1780000000,
"updated_at": 1780000000,
"started_at": null,
"finished_at": null,
"error": null,
"result": null
}
```
`status` 取值:`queued``running``succeeded``failed``cancelled`
daemon 重启后仍处于 `queued``running` 的历史任务会被标记为
`failed`,错误码为 `BAT-ERR-700005`
### patch / unityfs
`patch.*``unityfs.*` 是已规划命名空间,目前返回
`BAT-ERR-700003`。它们依赖后续 `bat-patch``bat-assetbundle`
引擎,不作为 issue #1 的关闭阻塞项。
## Go 调用边界
`bat-api` 应直接调用本 RPC contract,不通过 `exec` 调用 `bat` binary。
`bat` binary 是人类 CLI 和进程生命周期工具;默认 `refresh` / `repair`
在 daemon 可用时也会作为 RPC client 调用同一个 socket。
禁止事项:
- Go 服务层不直接读写 `bat-status.json``bat-tasks.json` 等 daemon 内部状态文件。
- Go 服务层不扩展 `bat-ffi` 为主控制面。
- Go 服务层不通过 stdout 解析 `bat status --json` 作为常规调用路径。
+10 -8
View File
@@ -177,14 +177,14 @@
现象:
- 当前可用的用户同步/运维入口是 Rust `bat` binary。
- `cmd/bat` 已存在,但仅有 `doctor``manifest inspect``sync plan` 试验能力;`doctor` 只输出固定 `ok``manifest`/`sync` 依赖可选 CGO/FFI helper。
- `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 写成已完成。
验收:
- `cmd/bat doctor` 做真实环境诊断,而不是固定字符串。
- `cmd/bat sync` 能通过 Rust `bat --json` 或 RPC 触发/查询官方同步,不走 FFI 控制下载器或 daemon。
- `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 边界。
@@ -470,11 +470,13 @@
## 6. 当前关闭顺序建议
1. issue #17/#20/#21/#22 验收并关闭或更新范围:多线程下载与指数退避实现已合入,daemon 子进程并发参数透传和单调进度上报已补齐,但 GitHub issue 仍 open
2. issue #2 / G-007:继续扩大 Addressables 可校验字段和结构变体覆盖
3. issue #3 / G-005:把 UnityFS 基础摘要推进到 `bat-assetbundle` 引擎级解析
4. G-011:官方同步结果接入 CAS + ResourceRepository 用户级工作流
5. G-008 / G-009:收敛 Go 产品入口边界,并实现 `bat-api`issue #19
6. G-012 / G-006:翻译系统、Patch 引擎
1. issue #24:失败 staging 复用已补离线回归;继续核对 issue 口径、状态与后续是否仍有真实场景无法复现的残余
2. issue #1Rust daemon/backend API 边界收口;`resource.repair``resource.list``daemon.doctor``internal/backendrpc` Go client 和稳定 RPC reference 已补齐,剩余确认 `patch.*` / `unityfs.*`(待引擎)、`task.create`(暂不开放通用入口)以及 `daemon.restart` / `daemon.clean-stable`(CLI 生命周期入口)的 issue 验收口径
3. 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 引擎。
这个顺序优先把 Rust `bat` 后端做扎实(解析能力 + 下载性能),再把官方同步结果进入可查询资源库,之后收敛 Go 入口和仿官方 API 服务端,最后推进翻译和补丁。G-018 已固化为可重复 smoke 命令并关闭;G-017 已按“不引入托管 CI”决策关闭。
+1 -1
View File
@@ -1,3 +1,3 @@
module bluearchive-toolkit
module bat-api
go 1.26.4
+267 -123
View File
@@ -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-stableCLI 侧按进程生命周期处理;
// 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)
}
@@ -4471,8 +4530,6 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
}
args.push("--curl".to_string());
args.push(config.curl_command.to_string_lossy().to_string());
args.push("--download-concurrency".to_string());
args.push(config.download_concurrency.to_string());
match config.curl_proxy.mode() {
CurlProxyMode::Auto if options.proxy_option_explicit => {
args.push("--proxy".to_string());
@@ -4959,9 +5016,6 @@ BAT_AUTO_DISCOVER=1
#
#BAT_INTERVAL_SECONDS=3600
#BAT_ERROR_RETRY_SECONDS=60
# 1~256 4 CDN
#BAT_DOWNLOAD_CONCURRENCY=4
# ---- ----
# URL http/https/socks4/socks4a/socks5/socks5h
# HTTPS_PROXY / ALL_PROXY / HTTP_PROXY
@@ -5145,9 +5199,6 @@ fn apply_bat_env_overrides(
if let Some(v) = value("BAT_UNZIP") {
options.config.unzip_command = PathBuf::from(v);
}
if let Some(v) = value("BAT_DOWNLOAD_CONCURRENCY") {
options.config.download_concurrency = parse_download_concurrency(&v)?;
}
if let Some(v) = value("BAT_PROXY") {
options.config.curl_proxy = parse_proxy_config(&v)?;
}
@@ -5325,11 +5376,6 @@ fn parse_args_with_env(
"--unzip" => {
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--download-concurrency" => {
let value = next_option_value(&mut args, &flag)?;
options.config.download_concurrency = parse_download_concurrency(&value)?;
options.sync_option_explicit = true;
}
"--dry-run" => {
options.config.dry_run = true;
options.sync_option_explicit = true;
@@ -5668,7 +5714,6 @@ fn print_usage(binary: &str) {
eprintln!(" --proxy <URL|auto|none> curl proxy override (default: auto from env)");
eprintln!(" --no-proxy Force direct curl connections");
eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
eprintln!(" --download-concurrency <N> Parallel downloads, 1..=256 (default: 4)");
eprintln!(" --dry-run Do not write sync state");
eprintln!(" --plan Include planned URLs in dry-run");
eprintln!(" --force Force download/refresh");
@@ -5743,17 +5788,6 @@ fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
.collect()
}
/// 解析下载并发度:正整数,钳制到 `1..=256`。
fn parse_download_concurrency(value: &str) -> anyhow::Result<usize> {
let parsed = value
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("下载并发度无效:{error}"))?;
if parsed == 0 {
return Err(anyhow::anyhow!("下载并发度必须大于 0"));
}
Ok(parsed.min(256))
}
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
match value.to_ascii_lowercase().as_str() {
"windows" | "win" => Ok(PatchPlatform::Windows),
@@ -5944,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");
@@ -5969,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]
@@ -6036,51 +6075,6 @@ mod tests {
assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded");
}
#[test]
fn parses_download_concurrency_flag_and_env() {
// 默认值(与 OfficialUpdateConfig 默认一致)。
assert_eq!(
parse(&["bat"]).unwrap().config.download_concurrency,
OfficialUpdateConfig::default().download_concurrency
);
// CLI 显式设置 + 上限钳制。
assert_eq!(
parse(&["bat", "--download-concurrency", "16"])
.unwrap()
.config
.download_concurrency,
16
);
assert_eq!(
parse(&["bat", "--download-concurrency", "9999"])
.unwrap()
.config
.download_concurrency,
256
);
// 0 与非数字报错。
assert!(parse(&["bat", "--download-concurrency", "0"]).is_err());
assert!(parse(&["bat", "--download-concurrency", "abc"]).is_err());
// 环境变量(含 .env)设置默认值;CLI 覆盖之。
assert_eq!(
parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "12")])
.unwrap()
.config
.download_concurrency,
12
);
assert_eq!(
parse_with_env(
&["bat", "--download-concurrency", "3"],
&[("BAT_DOWNLOAD_CONCURRENCY", "12")]
)
.unwrap()
.config
.download_concurrency,
3
);
}
#[test]
fn parses_auto_discover_sync_args() {
let options = parse(&[
@@ -6326,8 +6320,6 @@ mod tests {
"http://127.0.0.1:7890",
"--unzip",
"/usr/bin/unzip",
"--download-concurrency",
"40",
"--interval",
"30m",
"--error-retry",
@@ -6350,9 +6342,6 @@ mod tests {
assert!(args
.windows(2)
.any(|pair| pair == ["--platforms", "Windows,Android"]));
assert!(args
.windows(2)
.any(|pair| pair == ["--download-concurrency", "40"]));
// 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。
assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string()));
assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890")));
@@ -6443,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");
}
@@ -6454,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]
@@ -6577,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();
@@ -6629,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();
@@ -7167,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]
@@ -7666,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);
+2 -2
View File
@@ -445,8 +445,8 @@ fn retry_backoff(busy: bool, attempt: usize) -> std::time::Duration {
/// 退避时长(毫秒)的纯计算,便于独立于 cfg 门控的基值做单测。
///
/// - `ETXTBSY`fork/exec 竞态):极短固定退避,只为让兄弟进程完成 execve。
/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),并发下载
/// 时对官方 CDN 更礼貌,避免 N 个连接失败后同时立即重发。
/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),避免
/// 连续失败后立即重发。
fn backoff_delay_ms(base: u64, busy: bool, attempt: usize) -> u64 {
if busy {
return 5 * attempt as u64;
+60 -159
View File
@@ -16,9 +16,6 @@ use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
/// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。
@@ -64,10 +61,6 @@ const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
const DOWNLOAD_MANIFEST_VERSION: u32 = 1;
const DOWNLOAD_QUARANTINE_VERSION: u32 = 1;
const DEFAULT_RETRY_ATTEMPTS: usize = 3;
/// 默认下载并发度。保守取值,对官方 CDN 礼貌;可经配置调到 1..=256。
pub(crate) const DEFAULT_DOWNLOAD_CONCURRENCY: usize = 4;
/// 下载并发度上限。
const MAX_DOWNLOAD_CONCURRENCY: usize = 256;
/// Outcome for one official resource pull item.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -493,7 +486,6 @@ pub struct OfficialResourcePullService {
curl_command: PathBuf,
curl_proxy: CurlProxyConfig,
retry_attempts: usize,
download_concurrency: usize,
}
impl OfficialResourcePullService {
@@ -512,7 +504,6 @@ impl OfficialResourcePullService {
curl_command: curl_command.into(),
curl_proxy: CurlProxyConfig::default(),
retry_attempts: DEFAULT_RETRY_ATTEMPTS,
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
}
}
@@ -528,17 +519,6 @@ impl OfficialResourcePullService {
self
}
/// 设置下载并发度(并行执行的网络下载数),钳制到 `1..=256`。
pub fn with_download_concurrency(mut self, concurrency: usize) -> Self {
self.download_concurrency = concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY);
self
}
/// 返回当前下载并发度。
pub fn download_concurrency(&self) -> usize {
self.download_concurrency
}
/// Returns the output root used for downloaded files.
pub fn output_root(&self) -> &Path {
&self.output_root
@@ -625,11 +605,8 @@ impl OfficialResourcePullService {
});
}
// Phase B并发下载 need-download 项(各 URL 目标/`.part` 相互独立
// 天然可并行)。worker 只做只读 `&self` 的网络下载,经 mpsc 把结果送回
// 主线程;manifest/quarantine 簿记与进度回调全部在主线程串行完成,无需
// 加锁。首个失败或 `should_cancel` 会置 cancel 标志,其余 worker 在下一
// 个任务边界停止,保持 fail-fast 与「不发布不完整资源」不变量。
// Phase B顺序下载 need-download 项。每个 URL 目标`.part` 都是独立
// 的,但这里保留单线程执行,便于维持稳定进度、稳定日志和简单的失败恢复。
let download_indices: Vec<usize> = planned
.iter()
.enumerate()
@@ -640,113 +617,68 @@ impl OfficialResourcePullService {
(0..planned.len()).map(|_| None).collect();
let mut completed_count = 0usize;
if !download_indices.is_empty() {
let concurrency = self.download_concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY);
let cursor = AtomicUsize::new(0);
let cancel = AtomicBool::new(false);
let (sender, receiver) = mpsc::channel::<WorkerMessage>();
thread::scope(|scope| {
for _ in 0..concurrency.min(download_indices.len()) {
let sender = sender.clone();
let cursor = &cursor;
let cancel = &cancel;
let download_indices = &download_indices;
let planned = &planned;
let service = &*self;
scope.spawn(move || loop {
if cancel.load(Ordering::Relaxed) {
break;
}
let slot = cursor.fetch_add(1, Ordering::Relaxed);
let Some(&plan_index) = download_indices.get(slot) else {
break;
};
let item = &planned[plan_index];
if sender.send(WorkerMessage::Started { plan_index }).is_err() {
break;
}
let result = service.pull_one(&item.url, &item.destination);
if result.is_err() {
cancel.store(true, Ordering::Relaxed);
}
if sender
.send(WorkerMessage::Done { plan_index, result })
.is_err()
{
break;
}
});
}
// 主线程持有的 sender 副本必须丢弃,否则 receiver 永不结束。
drop(sender);
for message in receiver {
match message {
WorkerMessage::Started { plan_index } => {
let item = &planned[plan_index];
progress(OfficialResourcePullProgress::started(
completed_count,
total,
item.url.clone(),
));
// 停止请求:置 cancel,让 worker 在下个任务边界退出。
if should_cancel() {
cancel.store(true, Ordering::Relaxed);
}
}
WorkerMessage::Done { plan_index, result } => {
if let Ok(pull_result) = &result {
let item = &planned[plan_index];
// manifest/quarantine 簿记在主线程串行执行。
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))
{
// 簿记失败:记为该项错误并触发 fail-fast。
cancel.store(true, Ordering::Relaxed);
download_results[plan_index] = Some(Err(PullOneError::plain(
format!("记录下载 manifest 失败:{error}"),
)));
continue;
}
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(result);
}
}
}
});
if should_cancel() && download_results.iter().any(Option::is_none) {
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());
}
// fail-fast:按 plan 顺序取首个失败项,记 quarantine、发 failed 进度并中止。
for &plan_index in &download_indices {
if let Some(Some(Err(error))) = download_results.get(plan_index) {
let item = &planned[plan_index];
self.record_quarantine_entry(&item.url, &item.destination, error)?;
let result = self.pull_one(&item.url, &item.destination);
match result {
Ok(pull_result) => {
if let Err(error) = self
.clear_quarantine_entry(&item.url)
.and_then(|_| {
self.record_download_manifest_entry(
&mut manifest,
&item.url,
&item.destination,
)
})
.and_then(|_| self.write_download_manifest(&manifest))
{
let error = PullOneError::plain(format!("记录下载 manifest 失败:{error}"));
self.record_quarantine_entry(&item.url, &item.destination, &error)?;
progress(OfficialResourcePullProgress::failed(
completed_count,
total,
item.url.clone(),
&error,
));
return Err(DownloadError::new(
error.error_code(),
format!(
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={} quarantine={}{}",
item.url,
self.download_quarantine_path().display(),
error.message
),
));
}
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,
&error,
));
return Err(DownloadError::new(
error.error_code(),
@@ -1771,17 +1703,6 @@ struct PlannedDownload {
existing: Option<PullOneResult>,
}
/// worker 线程经 mpsc 送回主线程的消息。
enum WorkerMessage {
/// worker 已领取某计划项、即将下载(主线程据此发 started 进度)。
Started { plan_index: usize },
/// 某计划项下载结束(成功或失败)。
Done {
plan_index: usize,
result: Result<PullOneResult, PullOneError>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PullOneResult {
bytes: u64,
@@ -3084,16 +3005,14 @@ exit 22
}
#[test]
fn downloads_run_concurrently_and_each_url_reports_once() {
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);
// 并发度 8:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
.with_download_concurrency(8);
assert_eq!(service.download_concurrency(), 8);
// 顺序下载:每个 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(),
@@ -3127,24 +3046,6 @@ exit 22
assert_eq!(manifest.entries.len(), all_urls.len());
}
#[test]
fn download_concurrency_is_clamped() {
let service = OfficialResourcePullService::with_curl_command("/tmp/unused", "curl");
assert_eq!(service.download_concurrency(), DEFAULT_DOWNLOAD_CONCURRENCY);
assert_eq!(
OfficialResourcePullService::with_curl_command("/tmp/unused", "curl")
.with_download_concurrency(0)
.download_concurrency(),
1
);
assert_eq!(
OfficialResourcePullService::with_curl_command("/tmp/unused", "curl")
.with_download_concurrency(9999)
.download_concurrency(),
MAX_DOWNLOAD_CONCURRENCY
);
}
#[test]
fn retries_transient_download_failures() {
let out_dir = TempDir::new().unwrap();
+2 -7
View File
@@ -96,8 +96,6 @@ pub struct OfficialUpdateConfig {
pub audit_local: bool,
/// Repair local files when the local manifest audit fails.
pub repair: bool,
/// 下载并发度(并行执行的网络下载数)。默认 4,钳制到 `1..=256`。
pub download_concurrency: usize,
}
impl Default for OfficialUpdateConfig {
@@ -119,7 +117,6 @@ impl Default for OfficialUpdateConfig {
force: false,
audit_local: true,
repair: true,
download_concurrency: crate::official_download::DEFAULT_DOWNLOAD_CONCURRENCY,
}
}
}
@@ -901,8 +898,7 @@ impl OfficialUpdateService {
&active_resource_root,
&config.curl_command,
)
.with_proxy_config(config.curl_proxy.clone())
.with_download_concurrency(config.download_concurrency);
.with_proxy_config(config.curl_proxy.clone());
let snapshot_path = snapshot_path_for(config, &active_resource_root);
let bootstrap_cache_path = config.bootstrap_cache_path();
@@ -1341,8 +1337,7 @@ impl OfficialUpdateService {
&publish_plan.staging_path,
&config.curl_command,
)
.with_proxy_config(config.curl_proxy.clone())
.with_download_concurrency(config.download_concurrency);
.with_proxy_config(config.curl_proxy.clone());
report.staging_path = Some(publish_plan.staging_path.clone());
report.snapshot_path = staging_snapshot_path.clone();
report.download_manifest = staging_fetcher.download_manifest_path();
@@ -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}"}}}}
@@ -573,6 +665,7 @@ elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.bytes
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
@@ -604,6 +697,7 @@ fi
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"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"),
failure_state = failure_state,
)
}
+423
View File
@@ -0,0 +1,423 @@
// Package backendrpc is the Go client for the local Rust Resource Backend.
//
// The package talks directly to bat.sock over newline-delimited JSON-RPC 2.0.
// It is the default Go integration path for bat-api; do not shell out to the
// bat binary for normal service calls.
package backendrpc
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"sync/atomic"
"time"
)
const jsonRPCVersion = "2.0"
// Client calls the local Rust daemon through a Unix domain socket.
type Client struct {
SocketPath string
Timeout time.Duration
// DialContext exists for tests and alternative local transports.
DialContext func(ctx context.Context, network string, address string) (net.Conn, error)
seq atomic.Uint64
}
// New returns a client for the given bat.sock path.
func New(socketPath string) *Client {
return &Client{SocketPath: socketPath, Timeout: 30 * time.Second}
}
// APIError mirrors the Rust ApiError envelope payload.
type APIError struct {
Code string `json:"code"`
Kind string `json:"kind"`
Domain string `json:"domain"`
Location string `json:"location"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
func (e *APIError) Error() string {
if e == nil {
return "<nil api error>"
}
if e.Code == "" {
return e.Message
}
return fmt.Sprintf("%s %s", e.Code, e.Message)
}
// JSONRPCError is a transport-level JSON-RPC error.
type JSONRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (e *JSONRPCError) Error() string {
if e == nil {
return "<nil json-rpc error>"
}
return fmt.Sprintf("json-rpc error %d: %s", e.Code, e.Message)
}
// Envelope is the application-level payload carried in JSON-RPC result.
type Envelope struct {
OK bool `json:"ok"`
Status string `json:"status"`
Data json.RawMessage `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
RequestID string `json:"request_id"`
}
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID uint64 `json:"id"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID uint64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
}
// Call invokes method and decodes the envelope data into out when out is non-nil.
func (c *Client) Call(ctx context.Context, method string, params any, out any) (*Envelope, error) {
envelope, err := c.callEnvelope(ctx, method, params)
if err != nil {
return nil, err
}
if !envelope.OK {
if envelope.Error != nil {
return envelope, envelope.Error
}
return envelope, errors.New("backend rpc returned ok=false without error payload")
}
if out != nil && len(envelope.Data) > 0 && string(envelope.Data) != "null" {
if err := json.Unmarshal(envelope.Data, out); err != nil {
return envelope, fmt.Errorf("decode %s response data: %w", method, err)
}
}
return envelope, nil
}
func (c *Client) callEnvelope(ctx context.Context, method string, params any) (*Envelope, error) {
if c.SocketPath == "" {
return nil, errors.New("backend rpc socket path is empty")
}
conn, err := c.dial(ctx)
if err != nil {
return nil, err
}
defer conn.Close()
if deadline, ok := c.deadline(ctx); ok {
_ = conn.SetDeadline(deadline)
}
req := rpcRequest{
JSONRPC: jsonRPCVersion,
ID: c.seq.Add(1),
Method: method,
Params: params,
}
if err := json.NewEncoder(conn).Encode(&req); err != nil {
return nil, fmt.Errorf("write backend rpc request %s: %w", method, err)
}
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("read backend rpc response %s: %w", method, err)
}
var resp rpcResponse
if err := json.Unmarshal(line, &resp); err != nil {
return nil, fmt.Errorf("decode backend rpc response %s: %w", method, err)
}
if resp.Error != nil {
return nil, resp.Error
}
if len(resp.Result) == 0 {
return nil, fmt.Errorf("backend rpc %s response missing result", method)
}
var envelope Envelope
if err := json.Unmarshal(resp.Result, &envelope); err != nil {
return nil, fmt.Errorf("decode backend rpc envelope %s: %w", method, err)
}
return &envelope, nil
}
func (c *Client) dial(ctx context.Context) (net.Conn, error) {
if c.DialContext != nil {
return c.DialContext(ctx, "unix", c.SocketPath)
}
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", c.SocketPath)
}
func (c *Client) deadline(ctx context.Context) (time.Time, bool) {
if deadline, ok := ctx.Deadline(); ok {
return deadline, true
}
if c.Timeout > 0 {
return time.Now().Add(c.Timeout), true
}
return time.Time{}, false
}
type boolParam struct {
Force bool `json:"force"`
}
type pageParam struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
}
type tailParam struct {
Tail int `json:"tail"`
}
type taskIDParam struct {
TaskID string `json:"task_id"`
}
// Ack is returned by accepted daemon control methods.
type Ack struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
StateDir string `json:"state_dir"`
SocketPath string `json:"socket_path"`
Force *bool `json:"force,omitempty"`
}
// TaskAccepted is returned when an async backend task is queued.
type TaskAccepted struct {
TaskID string `json:"task_id"`
Kind string `json:"kind"`
}
// TaskRecord is the pollable task state.
type TaskRecord struct {
ID string `json:"id"`
Kind string `json:"kind"`
Status string `json:"status"`
Stage *string `json:"stage,omitempty"`
Message *string `json:"message,omitempty"`
CreatedAt uint64 `json:"created_at"`
UpdatedAt uint64 `json:"updated_at"`
StartedAt *uint64 `json:"started_at,omitempty"`
FinishedAt *uint64 `json:"finished_at,omitempty"`
Error *APIError `json:"error,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
}
type TaskList struct {
Tasks []TaskRecord `json:"tasks"`
}
type TaskCancelResult struct {
TaskID string `json:"task_id"`
CancelRequested bool `json:"cancel_requested"`
Note string `json:"note,omitempty"`
}
type TaskLogs struct {
TaskID string `json:"task_id"`
Lines []string `json:"lines"`
}
type DoctorCheck struct {
Name string `json:"name"`
OK bool `json:"ok"`
Message string `json:"message"`
}
type DoctorReport struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
Healthy bool `json:"healthy"`
Checks []DoctorCheck `json:"checks"`
}
type LogsReport struct {
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
LogPath string `json:"log_path"`
Exists bool `json:"exists"`
Empty bool `json:"empty"`
Bytes int `json:"bytes"`
TotalLines int `json:"total_lines"`
ReturnedLines int `json:"returned_lines"`
Content string `json:"content"`
}
type DaemonStatusReport struct {
Status string `json:"status"`
Message string `json:"message"`
Running bool `json:"running"`
PID *int `json:"pid,omitempty"`
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
StateDir string `json:"state_dir"`
SocketPath string `json:"socket_path"`
RPCAvailable bool `json:"rpc_available"`
CurrentStage *string `json:"current_stage,omitempty"`
CurrentMessage *string `json:"current_message,omitempty"`
}
type ResourceState struct {
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
VersionState json.RawMessage `json:"version_state,omitempty"`
LastUpdateStatus *string `json:"last_update_status,omitempty"`
LastSuccessUnixSeconds *uint64 `json:"last_success_unix_seconds,omitempty"`
}
type ResourceManifestEntry struct {
URL string `json:"url"`
Destination string `json:"destination"`
Bytes *uint64 `json:"bytes,omitempty"`
BLAKE3 string `json:"blake3,omitempty"`
}
type ResourceManifestPage struct {
Available bool `json:"available"`
ResourceRoot string `json:"resource_root,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
TotalEntries int `json:"total_entries,omitempty"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
Entries []ResourceManifestEntry `json:"entries,omitempty"`
}
func (c *Client) DaemonStatus(ctx context.Context) (*DaemonStatusReport, error) {
var out DaemonStatusReport
_, err := c.Call(ctx, "daemon.status", nil, &out)
return &out, err
}
func (c *Client) DaemonLogs(ctx context.Context, tail int) (*LogsReport, error) {
var out LogsReport
_, err := c.Call(ctx, "daemon.logs", tailParam{Tail: tail}, &out)
return &out, err
}
func (c *Client) DaemonStop(ctx context.Context) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.stop", nil, &out)
return &out, err
}
func (c *Client) DaemonReload(ctx context.Context) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.reload", nil, &out)
return &out, err
}
func (c *Client) DaemonRefresh(ctx context.Context, force bool) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.refresh", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) DaemonDoctor(ctx context.Context) (*DoctorReport, error) {
var out DoctorReport
_, err := c.Call(ctx, "daemon.doctor", nil, &out)
return &out, err
}
func (c *Client) ResourceState(ctx context.Context) (*ResourceState, error) {
var out ResourceState
_, err := c.Call(ctx, "resource.state", nil, &out)
return &out, err
}
func (c *Client) ResourceSync(ctx context.Context, force bool) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.sync", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) ResourceVerify(ctx context.Context) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.verify", nil, &out)
return &out, err
}
func (c *Client) ResourceRepair(ctx context.Context) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "resource.repair", nil, &out)
return &out, err
}
func (c *Client) ResourceManifest(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
var out ResourceManifestPage
_, err := c.Call(ctx, "resource.manifest", pageParam{Offset: offset, Limit: limit}, &out)
return &out, err
}
func (c *Client) ResourceList(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
var out ResourceManifestPage
_, err := c.Call(ctx, "resource.list", pageParam{Offset: offset, Limit: limit}, &out)
return &out, err
}
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.status", nil)
}
func (c *Client) CatalogVersions(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.versions", nil)
}
func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.diff", nil)
}
func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) {
var out TaskAccepted
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
return &out, err
}
func (c *Client) TaskStatus(ctx context.Context, taskID string) (*TaskRecord, error) {
var out TaskRecord
_, err := c.Call(ctx, "task.status", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) TaskList(ctx context.Context) (*TaskList, error) {
var out TaskList
_, err := c.Call(ctx, "task.list", nil, &out)
return &out, err
}
func (c *Client) TaskCancel(ctx context.Context, taskID string) (*TaskCancelResult, error) {
var out TaskCancelResult
_, err := c.Call(ctx, "task.cancel", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) TaskLogs(ctx context.Context, taskID string) (*TaskLogs, error) {
var out TaskLogs
_, err := c.Call(ctx, "task.logs", taskIDParam{TaskID: taskID}, &out)
return &out, err
}
func (c *Client) rawData(ctx context.Context, method string, params any) (json.RawMessage, error) {
envelope, err := c.Call(ctx, method, params, nil)
if err != nil {
return nil, err
}
return envelope.Data, nil
}
+207
View File
@@ -0,0 +1,207 @@
package backendrpc
import (
"bufio"
"context"
"encoding/json"
"errors"
"net"
"testing"
"time"
)
type testRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type testEnvelope struct {
OK bool `json:"ok"`
Status string `json:"status"`
Data any `json:"data,omitempty"`
Error any `json:"error,omitempty"`
RequestID string `json:"request_id"`
}
type testResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
}
func newTestClient(t *testing.T, handler func(t *testing.T, req testRequest) testResponse) *Client {
t.Helper()
client := New("bat.sock")
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
clientConn, serverConn := net.Pipe()
go func(conn net.Conn) {
defer conn.Close()
line, err := bufio.NewReader(conn).ReadBytes('\n')
if err != nil {
return
}
var req testRequest
if err := json.Unmarshal(line, &req); err != nil {
return
}
resp := handler(t, req)
if len(resp.ID) == 0 {
resp.ID = req.ID
}
if resp.JSONRPC == "" {
resp.JSONRPC = jsonRPCVersion
}
_ = json.NewEncoder(conn).Encode(resp)
}(serverConn)
return clientConn, nil
}
return client
}
func TestResourceRepairQueuesTask(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "resource.repair" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "accepted",
RequestID: "req-test-1",
Data: map[string]any{
"task_id": "task-1",
"kind": "resource.repair",
},
},
}
})
task, err := client.ResourceRepair(context.Background())
if err != nil {
t.Fatalf("ResourceRepair error: %v", err)
}
if task.TaskID != "task-1" || task.Kind != "resource.repair" {
t.Fatalf("unexpected task: %#v", task)
}
}
func TestResourceListSendsPagination(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "resource.list" {
t.Fatalf("method = %s", req.Method)
}
var params pageParam
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.Offset != 2 || params.Limit != 1 {
t.Fatalf("params = %#v", params)
}
bytes := uint64(42)
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-2",
Data: map[string]any{
"available": true,
"resource_root": "/tmp/resources/current",
"manifest_version": 1,
"total_entries": 3,
"offset": 2,
"limit": 1,
"entries": []map[string]any{
{
"url": "https://example.invalid/a.zip",
"destination": "a.zip",
"bytes": bytes,
"blake3": "abc",
},
},
},
},
}
})
page, err := client.ResourceList(context.Background(), 2, 1)
if err != nil {
t.Fatalf("ResourceList error: %v", err)
}
if !page.Available || page.TotalEntries != 3 || len(page.Entries) != 1 {
t.Fatalf("unexpected page: %#v", page)
}
if page.Entries[0].Bytes == nil || *page.Entries[0].Bytes != 42 {
t.Fatalf("unexpected entry bytes: %#v", page.Entries[0].Bytes)
}
}
func TestApplicationErrorReturnsAPIError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "task.status" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: false,
Status: "error",
Error: APIError{
Code: "BAT-ERR-700004",
Kind: "task_not_found",
Domain: "rpc",
Location: "task.status",
Message: "任务不存在:missing",
Retryable: false,
},
RequestID: "req-test-3",
},
}
})
_, err := client.TaskStatus(context.Background(), "missing")
var apiErr *APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected APIError, got %T %v", err, err)
}
if apiErr.Code != "BAT-ERR-700004" {
t.Fatalf("code = %s", apiErr.Code)
}
}
func TestTransportErrorReturnsJSONRPCError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
return testResponse{
Error: &JSONRPCError{
Code: -32700,
Message: "parse error",
},
}
})
_, err := client.DaemonStatus(context.Background())
var rpcErr *JSONRPCError
if !errors.As(err, &rpcErr) {
t.Fatalf("expected JSONRPCError, got %T %v", err, err)
}
if rpcErr.Code != -32700 {
t.Fatalf("code = %d", rpcErr.Code)
}
}
func TestContextDeadlineIsApplied(t *testing.T) {
client := New("bat.sock")
client.Timeout = time.Millisecond
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
<-ctx.Done()
return nil, ctx.Err()
}
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
_, err := client.DaemonStatus(ctx)
if err == nil {
t.Fatal("expected dial error")
}
}