Compare commits

..
19 Commits
Author SHA1 Message Date
nyaKazuha 7c863d10d1 fix: 稳定并发 worker 回归测试
bat-rust / Build and test Go API (push) Canceled after 0s
bat-rust / Build and test Rust (push) Canceled after 0s
2026-08-03 00:57:29 +08:00
nyaKazuha 9a5b3ba39b fix: 拆分 bat 控制面 2026-08-03 00:43:11 +08:00
nyaKazuha 2b053e247d fix: 抽象官方库存与 sidecar hash 策略 2026-08-02 23:12:25 +08:00
nyaKazuha ba28e067c9 fix: 明确并发下载报告按计划顺序输出 2026-08-02 22:47:21 +08:00
nyaKazuha 6442c7661d fix: 保持官方资源 adapter URL 错误契约 2026-08-02 22:44:09 +08:00
nyaKazuha 8b64cc94f3 fix: 完成下载并发与翻译交接链路 2026-08-02 22:34:23 +08:00
nyaKazuha d533c88108 chore: 忽略本地 CHECK.md 2026-08-01 19:17:36 +08:00
nyaKazuha 12c5d365ab feat(translation): 增加离线任务状态查询 2026-08-01 19:04:54 +08:00
nyaKazuha a05d3ee6af fix(repository): 扩展资源索引查询面 2026-08-01 12:11:35 +08:00
nyaKazuha 8d930bf4d7 chore(docs): 增加状态一致性门禁 2026-08-01 10:55:43 +08:00
nyaKazuha 80e6718e8a fix(sync): 对齐 localized 状态契约 2026-07-31 18:28:02 +08:00
nyaKazuha df361cff28 test(api): 固化 Rust-Go RPC 契约 2026-07-31 18:20:37 +08:00
nyaKazuha 1e466d3374 fix(sync): 规范 release flow 状态码解析 2026-07-31 17:45:50 +08:00
nyaKazuha 6af7706190 fix(api): 补齐 bat-api 控制与后端 RPC 2026-07-31 17:01:03 +08:00
nyaKazuha 20ddd67947 fix(api): 统一 catalog GameMainConfig 字段 2026-07-31 16:05:17 +08:00
nyaKazuha 4cc143f66d fix(sync): 固化 release flow 状态码 2026-07-31 15:57:07 +08:00
nyaKazuha 99e6b3a23a fix(sync): 立即校验官方 hash sidecar 2026-07-31 13:12:21 +08:00
nyaKazuha 16e73327b4 fix(sync): 补齐发布清理与校验进度 2026-07-31 13:00:53 +08:00
nyaKazuha 8ec0e12795 ci(api): 增加 Go API 门禁 2026-07-31 12:55:43 +08:00
55 changed files with 17219 additions and 10898 deletions
+84
View File
@@ -161,3 +161,87 @@ jobs:
cd ..
ls -lh bat-linux-x86_64.zip
go-api:
name: Build and test Go API
runs-on: linux
env:
GOCACHE: /tmp/bat-go-cache
steps:
- name: Checkout
shell: bash
run: |
set -euo pipefail
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
: "${GITHUB_SHA:?GITHUB_SHA is required}"
repo_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
if [ -d .git ]; then
git remote set-url origin "${repo_url}"
else
git init .
git remote add origin "${repo_url}"
fi
ref="${GITHUB_REF:-${GITHUB_SHA}}"
git fetch \
--no-tags \
--depth=1 \
origin \
"${ref}" \
|| git fetch \
--no-tags \
--depth=1 \
origin \
"${GITHUB_SHA}"
git checkout --force --detach FETCH_HEAD
git submodule update --init --recursive
- name: Show Go tool version
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
command -v go
go version
- name: Run Go API tests
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
make test-go-api
- name: Run Go API vet
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
- name: Build Go API
shell: bash
run: |
set -euo pipefail
source /var/lib/act_runner/env.sh
go build -o /tmp/bat-api ./cmd/bat-api
- name: Run documentation status gate
shell: bash
run: |
set -euo pipefail
make check-docs
+1
View File
@@ -53,6 +53,7 @@ pg_log/
/docs/reports/fuck-u-code-*.md
/docs/reports/*-current.generated.md
/docs/reports/**/SMOKE_REPORT.md
CHECK.md
# Backups
/deployments/backups/
+19 -13
View File
@@ -20,18 +20,23 @@ 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 切换。启动器/server-info 先行更新但 client-patch seed marker 或必需 seed catalog 尚未开放时,会进入 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging,也不写入失败版本循环;启用 `--auto-discover` 的非 dry-run 会写入 `<output>/official-launcher-bootstrap.pending.json` 作为维护期证据。下载执行保持顺序处理;manifest/quarantine 簿记与 seed `.hash` 校验仍逐项执行,`fail-fast` 与「不发布不完整资源」不变量不变。下载进度按已完成数量单调上报,不再使用 plan 序号计算百分比。
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair、失败 staging 恢复复用、403/404/5xx 分类重试(重试带指数退避)、下载 quarantine 诊断,以及旧 launcher 包官方 primary/backup CDN 切换。启动器/server-info 先行更新但 client-patch seed marker 或必需 seed catalog 尚未开放时,会进入 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging,也不写入失败版本循环;启用 `--auto-discover` 的非 dry-run 会写入 `<output>/official-launcher-bootstrap.pending.json` 作为维护期证据。下载默认使用 8 个独立 worker,范围为 `1..=256`;每个 worker 完成当前 URL 后立即从共享计划队列领取下一个任务,进度按实际完成顺序即时上报,最终 report 资源列表仍按计划顺序输出。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 秒快速重试,官方资源端尚未开放时状态为 `waiting` 并同样按错误重试间隔探测;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`
8. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-events.jsonl` 记录带轮转的结构化事件日志,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试,官方资源端尚未开放时状态为 `waiting` 并同样按错误重试间隔探测;`resource.state` / `catalog.status` / `parse.status` / `localized.status` 会返回 `status` 与稳定 `status_code`(如 `official.up_to_date``official.published``parse.completed``translation.queued_offline``localized.published``distribution.ready`),供 `bat-api` 等读侧判断阶段、终态和重试属性;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner、progress log、失败分类和 quarantine 状态,需要机器输出时使用 `--json --no-progress`
9. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
10. 默认官方原版资源目录是 `./bat-resources`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`;官方资源目录是发布根目录,包含 `current` symlink、`versions/<id>``.staging/<id>`,非 dry-run 会先写 staging,校验完成后发布 versioned 目录并原子切换 `current`;启用 `--auto-discover` 的 release 会包含 `official-launcher-bootstrap.json`up-to-date 轮询会为旧 release 补写该产物;如果上一轮同一 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` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC 可按类型、hash、路径模式分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin 当前仅预留本地离线队列,不发网络请求;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;UnityFS TextAsset patch 发布成功后会写 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `localized``bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`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`)。
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json``crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units``parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin 当前仅预留本地离线队列,不发网络请求;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;UnityFS TextAsset patch 发布成功后会写 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published``status_code=localized.published``localized_release_status=localized``bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`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`)。
仍需明确:这不是完整产品完成。完整 AssetBundle 重打包、翻译、Web、以及 `bat-api` 的服务器联调/可选业务扩展仍是后续工作;G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`
当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker,完成后动态领取
任务,最终资源报告按 pull plan 顺序输出。
---
## 2. 权威文档入口
@@ -140,7 +145,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
待完成:
-`crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建;继续扩展 ResourceRepository 对翻译任务状态和 CAS 诊断查询面。
-`crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建;继续扩展真实 worker 状态、失败原因写回和 CAS 诊断查询面。
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`;实际运行报告由脚本写入隔离输出目录。
- 增加更多权限和极端文件系统场景测试。
@@ -218,7 +223,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
| Web | `web/` | 空(G-010 |
默认 Go 门禁:`make test-go-api``make build-go-api`(无 FFI)。
默认 Go/docs 门禁:`make test-go-api``make build-go-api``make check-docs`(无 FFI)。
---
@@ -243,6 +248,7 @@ cargo test --offline -p bat-infrastructure localized_patch --quiet
env GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache make test-go-api
env GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache make build-go-api
env GOCACHE=/tmp/bat-go-cache GOMODCACHE=/tmp/bat-go-modcache go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
make check-docs
```
未执行 / 后置:
@@ -264,7 +270,7 @@ cargo run -p bat-infrastructure --bin bat -- \
--watch
```
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`bat-api` 已补 launcher 资源引导兼容端点和玩家-facing HTTP 控制面(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理面板预留),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status``resource.manifest``official-sync-snapshot.json` contract fixtureGo mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移。`bat-api` 已补 launcher 资源引导兼容端点和玩家-facing HTTP 控制面(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh` 可经 Web 转发),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。
生产要求:
@@ -280,13 +286,13 @@ cargo run -p bat-infrastructure --bin bat -- \
## 6. 当前阻塞项
GitHub issue 状态:#1 已关闭;#17 已按 wontfix 关闭(多线程下载入口已移除,下载回归顺序执行并保留指数退避与单调进度上报,子 issue #20#23 均已关闭。其他 open issue 的实时标签以 GitHub 为准。
GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实现,现行默认并发为 8,范围 `1..=256`,每个独立 worker 完成后立即领取下一个任务,进度按完成事件即时统计并保持 report 计划顺序;子 issue #20#23 均已关闭。其他 open issue 的实时标签以 GitHub 为准。
下一阶段必须优先完成:
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/index)、`parse.*`status/text_units/errors)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)、文件级 `patch.apply``unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.restart` / `daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做自重启或在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理面板预留、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;剩余为远程服务器全量 release 联调、Rust/Go snapshot contract fixture(用户审核后)、refresh mtime/size 增量缓存和可选持久化。
3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json` 已生成并可查询 TextUnit 明细剩余为真实 Crowdin worker、翻译记忆、Patch 构建和翻译任务状态查询
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/restart/reload/refresh/doctor)、`resource.*`state/sync/verify/repair/manifest/list/index)、`parse.*`status/text_units/errors)、`translation.*`tasks)、`localized.*`status)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)、文件级 `patch.apply``unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;剩余为远程服务器全量 release 联调、Rust/Go snapshot contract fixture(用户审核后)、refresh mtime/size 增量缓存和可选持久化。
3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json` 已生成TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询。剩余为真实 Crowdin worker、翻译记忆、Patch 构建和 worker 状态写回
4. Issue #3P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。
5. Issue #2P1):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
6. 通用 Binary/JSON/Text Patch 基础已落地;复杂 AssetBundle 重打包和真实翻译系统仍应后置,UnityFS TextAsset patch 发布前置已具备回归测试。
@@ -299,7 +305,7 @@ GitHub issue 状态:#1 已关闭;#17 已按 wontfix 关闭(多线程下载
立即任务:
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*``parse.*``task.*``resource.repair`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余发布级 `patch build`/`rollback`、复杂 `unityfs.*` 语义编辑以及 `task.create``daemon.restart``daemon.clean-stable` 的设计边界确认。
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*``parse.*``localized.*``task.*``resource.repair``daemon.restart`文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余发布级 `patch build`/`rollback`、复杂 `unityfs.*` 语义编辑以及 `task.create``daemon.clean-stable` 的设计边界确认。
2. `bat-api` 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap``/v1/launcher/bootstrap`、server-info 和 CDN pathissue #19 剩余)。
3. 跟进官方同步长期运行测试报告。
4. AssetBundle / Addressablesissue #3 / #2);CAS 用户级导入(G-011)。
@@ -308,4 +314,4 @@ GitHub issue 状态:#1 已关闭;#17 已按 wontfix 关闭(多线程下载
- **当前总体完成度**:不再固定写单一百分比,以各模块状态、`GO_STATUS.md` 和 issue 为准。
- **当前基线状态**Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP + 玩家-facing HTTP 控制面 + launcher 资源引导兼容 + RPC 周期刷新/诊断 + readiness + `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础和 UnityFS TextAsset patch 发布前置可用;完整 AssetBundle 重打包未完成。
- **下一工程里程碑**bat-api 联调、真实 Crowdin worker / 翻译记忆、翻译任务状态查询、复杂 AssetBundle 解析和重打包。
- **下一工程里程碑**bat-api 联调、真实 Crowdin worker / 翻译记忆、worker 状态写回、复杂 AssetBundle 解析和重打包。
+6 -2
View File
@@ -1,4 +1,4 @@
.PHONY: help build build-ffi test clean check fmt lint install dev docker-build docker-up docker-down official-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
.PHONY: help build build-ffi test clean check check-docs fmt lint install dev docker-build docker-up docker-down official-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
# 默认目标
.DEFAULT_GOAL := help
@@ -86,7 +86,7 @@ official-smoke: ## 运行真实官方全量拉取 smoke(默认写入 /tmp 隔
# 代码质量
# ============================================================================
check: check-rust check-go ## 检查代码(不编译)
check: check-rust check-go check-docs ## 检查代码和状态文档(不编译)
check-rust: ## 检查 Rust 代码
@echo "$(BLUE)Checking Rust code...$(NC)"
@@ -100,6 +100,10 @@ check-go: ## 检查 Go 代码
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
fi
check-docs: ## 检查权威状态文档与占位目录声明
@echo "$(BLUE)Checking documentation status claims...$(NC)"
bash scripts/check-doc-status.sh
fmt: fmt-rust fmt-go ## 格式化所有代码
fmt-rust: ## 格式化 Rust 代码
+7 -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 的 live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``localized.status``catalog.*``task.*`);`restart` `clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
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/restart/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``localized.status``catalog.*``task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布。
10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json``crowdin-translation-handoff.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。
@@ -44,7 +44,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,当前发布级可用的是 `bat-assetbundle` + `LocalizedPatchService` 的 UnityFS TextAsset patch 前置链路。
3. Go 侧边界已冻结(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发 = `cmd/bat-api` MVP`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / Web / SDK 仍未完成。
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询,翻译任务状态仍需继续推进。
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,真实 worker 状态写回仍需继续推进。
6. 汉化 Patch 发布前置已具备 UnityFS TextAsset manifest/apply/diff/rollback/完整性校验和 `localized.status` 严格校验;真实 Crowdin worker、翻译记忆到完整汉化文件集合的构建仍未完成。
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
8. Web、数据库迁移、OpenAPI、插件加载机制尚未实现。
@@ -174,11 +174,11 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
1. Addressables Catalog 真实字段解析:**部分完成**。当前已覆盖 path、hash、size、address、dependencies、metadata 和真实形态 fixture/golden;仍需继续覆盖更多官方 catalog 结构变体。
2. 资源版本、区域、渠道、远端 URL、Hash、大小、依赖关系模型:**部分完成**。`Resource` 和官方 endpoint/snapshot 模型已扩展;仍需冻结 Go CLI/API 可见模型。
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验repair。
3. Rust 官方下载器:**已完成当前生产入口需要的核心能力**。包含官方 URL 校验、`.part` 续传、重试、本地 manifest size+BLAKE3 校验、官方 seed `.hash` 校验repair,以及默认 8、范围 `1..=256` 的有界并发 scheduler;worker 动态领取任务,进度按完成数单调上报,report 保持 plan 顺序
4. Rust 自动更新入口:**已完成当前生产入口**。`bat` 支持 snapshot、marker diff、bootstrap cache、one-shot、`--watch``--daemon`、默认 1 小时间隔、北京时间固定强制刷新,以及 Unix socket JSON-RPC 后台运维命令返回。
5. Go 入口边界:**已冻结**。同步命令行 = Rust `bat`G-008 关闭);资源分发 = `bat-api` MVP(G-009 部分完成)。详见 `docs/reports/GO_STATUS.md`
6. 用户级 `sync``manifest inspect``cache status`**未完成**。Rust `bat --json` 是当前稳定进程边界;`bat-ffi` 只提供可选兼容用的 Manifest inspect 和 sync plan JSON helper。
7. 下载结果写入 CAS + ResourceRepository**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index` 可查询现有索引和资源 metadata;`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误。剩余工作是翻译任务状态、CAS 诊断入口和更丰富查询
7. 下载结果写入 CAS + ResourceRepository**部分完成**。CAS 和 SQLite ResourceRepository 已存在,官方同步入口可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后导入;`resource.index`按资源级 release、平台、destination、archive entry、parse status 和 TextUnit format 查询现有索引和资源 metadata`parse.text_units` / `parse.errors` 可查询当前 release 的 TextUnit 明细与解析错误`translation.tasks` 可查询离线 TextUnit 翻译任务状态和跳过/失败原因。剩余工作是真实 worker 状态写回、CAS 诊断入口和面向大索引的查询优化
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`
9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
10. 官方发布后的增量 handoff 与解析缓存:**已完成基础入口**。新 release 发布后先生成 `official-resource-changes.json``crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译候选;`official-parse-cache.json` 基于下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;随后生成 `official-textunit-index.json``official-textunit-tasks.json``crowdin-textunit-queue.json`,本地文件未变化且缓存/索引有效时跳过重复解析。
@@ -202,6 +202,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
### Milestone 4Unity AssetBundle 解析
维护冻结细则见 [`docs/reports/PARSER_FREEZE.md`](docs/reports/PARSER_FREEZE.md);冻结期只接受稳定性、诊断、真实回归和文档一致性修复。
**目标**:建立可扩展 AssetBundle 解析框架,并首先支持文本相关资源。
交付物:
@@ -402,7 +404,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
3. G-009 / issue #19`bat-api` 资源分发 MVP 已落地;优先服务器联调;拉取仍在 Rust `bat`
4. 继续 Addressablesissue #2)与 UnityFSissue #3 / G-005)。
5.`crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
6. 继续扩展 G-011 剩余查询面:翻译任务状态`doctor cas` 诊断入口和更丰富 TextUnit 查询
6. 继续扩展 G-011 剩余查询面:真实 worker 状态写回`doctor cas` 诊断入口和面向大索引的查询优化
---
+5 -5
View File
@@ -13,13 +13,13 @@
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。
- `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、版本化 `official-launcher-bootstrap.json``--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``localized.status``catalog.*``task.*`)。
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`report 按 plan 顺序、进度按完成数单调上报)、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json``--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.status/logs/stop/restart/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``localized.status``catalog.*``task.*`)。
- `internal/backendrpc`Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVPissue #19 / G-009);`/v1/bootstrap``/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readinessCDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI 和管理面板预留`.env` 配置端口/RPC socket/刷新周期;生产资源根来自 RPC,不负责自动拉取。
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVPissue #19 / G-009);`/v1/bootstrap``/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readinessCDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI 和管理控制白名单`.env` 配置端口/RPC socket/刷新周期;生产资源根来自 RPC,不负责自动拉取。
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)G-008 已关闭:同步 CLI = Rust `bat`)。
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC 可按类型、hash、路径模式分页查询索引。
- 新 release 发布后会生成 `official-resource-changes.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-translation-handoff.json``crowdin-textunit-queue.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API。
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、archive entry、parse status 和 TextUnit format 分页查询索引。
- 新 release 发布后会生成 `official-resource-changes.json``official-parse-cache.json``official-textunit-index.json``official-textunit-tasks.json``crowdin-translation-handoff.json``crowdin-textunit-queue.json``translation-tasks.sqlite``translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接
- `LocalizedPatchService` 已具备 UnityFS TextAsset patch 发布前置能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset patch、写 `localized-patch-manifest.json`hash、size、diff、rollback)、校验后发布到 `versions/<id>` 并切换 `current`
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,汉化发布当前仍走 UnityFS TextAsset 前置链路。
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
@@ -101,7 +101,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
cargo run -p bat-infrastructure --bin bat -- 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`
`status``stop``restart``logs``reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socketsocket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径替换后台进程;`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` 权限创建。
+20 -4
View File
@@ -109,13 +109,14 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
| `GET /yostar-serverinfo.bluearchiveyostar.com/server-info.json` | 官方 host/path 形态的 server-info |
| `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 |
| `GET /openapi.yaml` | bat-api OpenAPI 文档 |
| `GET /admin/` | 管理面板预留入口;当前返回 JSON 链接,未来接入 Web UI |
| `GET /admin/` | 管理控制入口与允许操作列表 |
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` snapshot 中的 `launcher_metadata``game_main_config_bootstrap`,显式标记 `scope=resource_bootstrap_only` / `package_update_manifest=false``bat-api` 不下载 launcher 包,不生成官方 PC package update manifest,也不仿造登录、账号、网关、鉴权或游戏业务协议。
生产面对玩家分发时,应启用 HTTP token 鉴权、限流和访问日志:
- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer <token>``X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。
- `BAT_API_AUTH_TOKEN`:启用 `Authorization: Bearer <token>``X-BAT-Token` 或 query fallback 鉴权;token 推荐由 secret manager 或进程环境提供,不建议写入提交文件。`/admin/control/*` 需要此 token。
- `BAT_API_AUTH_QUERY_PARAM`query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。
- `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`
- `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。
@@ -123,7 +124,21 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
- `BAT_API_ACCESS_LOG`:结构化访问日志,记录 method/path/status/bytes/duration/client_ip/request_id/user_agent,不记录 query string。
- `BAT_API_MAX_RESOURCE_LIMIT``/v1/resources` 最大分页上限,默认 `1000`
所有动态 JSONbootstrap、health、ready、release、resources、launcher 兼容、server-info、OpenAPI、admin placeholder 和错误响应)显式返回 `Cache-Control: no-store`。资源字节 CDN path 仍返回长期 immutable cache header。
`POST /admin/control/{action}` 只转发固定白名单内的 Rust RPC,不是任意 RPC proxy
| action | Rust RPC | 参数 | 返回 |
|---|---|---|---|
| `reload` | `daemon.reload` | 无 | `202` accepted |
| `refresh` | `daemon.refresh` | 可选 `{ "force": true }` | `202` accepted |
| `restart` | `daemon.restart` | 无 | `202` accepted |
| `sync` | `resource.sync` | 可选 `{ "force": true }` | `202` + task |
| `verify` | `resource.verify` | 无 | `202` + task |
| `repair` | `resource.repair` | 无 | `202` + task |
| `catalog-refresh` | `catalog.refresh` | 可选 `{ "force": true }` | `202` + task |
`stop``clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
所有动态 JSONbootstrap、health、ready、release、resources、launcher 兼容、server-info、OpenAPI、admin 和错误响应)显式返回 `Cache-Control: no-store`。资源字节 CDN path 仍返回长期 immutable cache header。
CDN path 只服务 manifest 索引内且磁盘存在、size 匹配的文件。响应支持 `GET``HEAD``Range`、条件请求、ETag、Last-Modified、Accept-Ranges 和长期缓存头;ETag 优先使用 manifest 中的 BLAKE3。`.hash``text/plain` 返回,其它未知扩展默认为 `application/octet-stream`
@@ -360,6 +375,7 @@ curl -i -H 'Range: bytes=0-1023' \
| `daemon.stop` | ✅ | 请求停止(`accepted` |
| `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted` |
| `daemon.refresh` | ✅ | 请求刷新检查(`params.force``accepted` |
| `daemon.restart` | ✅ | 启动 Rust lifecycle controller,并在响应后停止当前 daemon(`accepted` |
| `daemon.doctor` | ✅ | 返回运行时诊断报告(只读,不清理、不重启) |
| `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 |
| `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` |
@@ -374,7 +390,7 @@ curl -i -H 'Range: bytes=0-1023' \
| `task.list` | ✅ | 列出全部任务(最新在前) |
| `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 |
| `task.logs` | ✅ | 返回任务的进度日志(`params.task_id`,有界) |
| `daemon.restart` / `daemon.clean-stable` / `patch.*` / `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`not implemented);restart/clean-stable 仍由 CLI 侧按进程生命周期显式执行,patch/unityfs 待引擎实现,task.create 暂不开放通用任务入口 |
| `daemon.clean-stable` / 发布级 `patch.*` / 未开放 `unityfs.*` / `task.create` | ⏳ | 已规划,返回 `BAT-ERR-700003`not implemented);clean-stable 仍由 CLI 侧按进程生命周期显式执行,task.create 暂不开放通用任务入口 |
| 未知方法 | — | `BAT-ERR-700001`unknown method |
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
+79 -1
View File
@@ -2,6 +2,45 @@
use async_trait::async_trait;
use bat_core::domain::{GameClient, GameRegion};
use std::path::PathBuf;
/// Linux-first client discovery backed by explicitly supplied roots.
///
/// The adapter never scans home directories implicitly and does not require
/// the official launcher. The roots are normally a staging/import directory
/// selected by the caller.
#[derive(Debug, Clone, Default)]
pub struct LinuxClientDiscovery {
roots: Vec<PathBuf>,
}
impl LinuxClientDiscovery {
/// Creates a discovery adapter for explicit candidate roots.
pub fn new(roots: Vec<PathBuf>) -> Self {
Self { roots }
}
}
#[async_trait]
impl ClientDiscovery for LinuxClientDiscovery {
async fn discover_all(&self) -> Result<Vec<GameClient>, String> {
GameClient::discover_in_roots(&self.roots).map_err(|error| error.to_string())
}
async fn verify_client(&self, path: &str) -> bool {
GameClient::new(PathBuf::from(path), GameRegion::Japan)
.verify_integrity()
.unwrap_or(false)
}
async fn detect_region(&self, path: &str) -> Result<GameRegion, String> {
if self.verify_client(path).await {
Ok(GameRegion::Japan)
} else {
Err(format!("不是有效的 Linux Blue Archive 客户端:{path}"))
}
}
}
/// 客户端发现接口
///
@@ -49,5 +88,44 @@ pub trait ClientDiscovery: Send + Sync {
#[cfg(test)]
mod tests {
// 测试将在实现时添加
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn linux_discovery_uses_explicit_roots_and_verifies_layout() {
let temp = TempDir::new().unwrap();
let client = temp.path().join("BlueArchive_JP");
fs::create_dir_all(client.join("BlueArchive_Data/StreamingAssets/AssetBundles")).unwrap();
let discovery = LinuxClientDiscovery::new(vec![temp.path().to_path_buf()]);
let clients = discovery.discover_all().await.unwrap();
assert_eq!(clients.len(), 1);
assert!(
discovery
.verify_client(clients[0].install_path.to_str().unwrap())
.await
);
assert_eq!(
discovery
.detect_region(clients[0].install_path.to_str().unwrap())
.await
.unwrap(),
GameRegion::Japan
);
}
#[tokio::test]
async fn linux_discovery_rejects_unrelated_path() {
let discovery = LinuxClientDiscovery::default();
assert!(
!discovery
.verify_client("/tmp/not-a-blue-archive-client")
.await
);
assert!(discovery
.detect_region("/tmp/not-a-blue-archive-client")
.await
.is_err());
}
}
+354
View File
@@ -0,0 +1,354 @@
//! Official resource backend seams.
//!
//! The update pipeline consumes these small contracts instead of depending on
//! one region's URL and catalog rules everywhere. The JP implementation is
//! the only production adapter today; adding another region should implement
//! this module's contracts without changing downloader orchestration.
use super::inventory::{YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory};
use super::yostar_jp::{
is_official_yostar_jp_url, server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan,
YostarJpResourceRoot, YostarJpServerInfo,
};
use std::path::{Path, PathBuf};
/// Catalog bytes required to build one platform's download inventory.
#[derive(Debug, Clone, Copy)]
pub struct PlatformCatalogInput<'a> {
/// Platform represented by the catalog.
pub platform: PatchPlatform,
/// `BundlePackingInfo.bytes` payload.
pub bundle_packing_info: &'a [u8],
/// `MediaCatalog.bytes` payload.
pub media_catalog: &'a [u8],
}
/// Platform catalog parser selected by an official resource backend.
pub trait InventoryParser: Send + Sync {
/// Parses verified seed catalog payloads into a platform-aware inventory.
fn parse_inventory(
&self,
table_catalog: &[u8],
platform_catalogs: &[PlatformCatalogInput<'_>],
) -> YostarJpPlatformDownloadInventory;
}
/// Verification result returned by a sidecar hash strategy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SidecarHashVerification {
/// Decimal or textual expected digest parsed from the sidecar.
pub expected: String,
/// Digest computed from the resource bytes.
pub actual: String,
}
/// Hash sidecar policy independent from download orchestration.
pub trait SidecarHashStrategy: Send + Sync {
/// Stable algorithm identifier used in diagnostics.
fn algorithm_id(&self) -> &'static str;
/// Parses and verifies one resource payload against a sidecar.
fn verify(&self, data: &[u8], sidecar: &[u8]) -> Result<SidecarHashVerification, String>;
}
/// Official JP decimal `xxHash32(seed=0)` sidecar strategy.
#[derive(Debug, Clone, Copy, Default)]
pub struct XxHash32DecimalSeedZero;
impl XxHash32DecimalSeedZero {
/// Computes the decimal digest used by this sidecar strategy.
pub fn digest(self, data: &[u8]) -> String {
xxhash32(data).to_string()
}
}
impl SidecarHashStrategy for XxHash32DecimalSeedZero {
fn algorithm_id(&self) -> &'static str {
"xxhash32_decimal"
}
fn verify(&self, data: &[u8], sidecar: &[u8]) -> Result<SidecarHashVerification, String> {
let expected = std::str::from_utf8(sidecar)
.map_err(|error| format!("官方 hash sidecar 不是 UTF-8{error}"))?
.trim()
.parse::<u32>()
.map_err(|error| format!("官方 hash sidecar 不是十进制 xxHash32{error}"))?;
let actual = self.digest(data);
let mismatch = expected.to_string() != actual;
let verification = SidecarHashVerification {
expected: expected.to_string(),
actual,
};
if mismatch {
return Err(format!(
"官方 hash 校验失败:期望 {},实际 {}",
verification.expected, verification.actual
));
}
Ok(verification)
}
}
/// Region/backend contract used by official resource orchestration.
pub trait OfficialResourceBackend: InventoryParser + Send + Sync {
/// Stable backend identifier persisted in diagnostics.
fn backend_id(&self) -> &'static str;
/// Builds the server-info URL from an official metadata file name.
fn server_info_url(&self, file_name: &str) -> Result<String, String>;
/// Selects a discovery plan from server-info and requested platforms.
fn discovery_plan(
&self,
server_info: &YostarJpServerInfo,
connection_group: &str,
app_version: &str,
platforms: &[PatchPlatform],
) -> Result<YostarJpResourceDiscoveryPlan, String>;
/// Validates that a URL belongs to this backend's official hosts.
fn is_official_url(&self, url: &str) -> bool;
}
/// URL-to-destination mapping contract for a resource backend.
pub trait DownloadUrlMapper: Send + Sync {
/// Maps an official HTTPS URL to a relative release destination.
fn relative_destination(&self, url: &str) -> Result<PathBuf, String>;
}
/// The currently supported official Blue Archive JP backend.
#[derive(Debug, Clone, Copy, Default)]
pub struct YostarJpBackend;
impl InventoryParser for YostarJpBackend {
fn parse_inventory(
&self,
table_catalog: &[u8],
platform_catalogs: &[PlatformCatalogInput<'_>],
) -> YostarJpPlatformDownloadInventory {
let catalogs = platform_catalogs
.iter()
.map(|catalog| {
YostarJpPlatformCatalogInventory::from_catalog_bytes(
catalog.platform,
catalog.bundle_packing_info,
catalog.media_catalog,
)
})
.collect();
YostarJpPlatformDownloadInventory::from_catalog_bytes(table_catalog, catalogs)
}
}
impl OfficialResourceBackend for YostarJpBackend {
fn backend_id(&self) -> &'static str {
"bluearchive.yostar.jp"
}
fn server_info_url(&self, file_name: &str) -> Result<String, String> {
server_info_url(file_name)
}
fn discovery_plan(
&self,
server_info: &YostarJpServerInfo,
connection_group: &str,
app_version: &str,
platforms: &[PatchPlatform],
) -> Result<YostarJpResourceDiscoveryPlan, String> {
server_info.discovery_plan(connection_group, app_version, platforms)
}
fn is_official_url(&self, url: &str) -> bool {
is_official_yostar_jp_url(url)
}
}
fn xxhash32(bytes: &[u8]) -> u32 {
const PRIME1: u32 = 0x9E37_79B1;
const PRIME2: u32 = 0x85EB_CA77;
const PRIME3: u32 = 0xC2B2_AE3D;
const PRIME4: u32 = 0x27D4_EB2F;
const PRIME5: u32 = 0x1656_67B1;
let len = bytes.len();
let mut index = 0usize;
let mut hash = if len >= 16 {
let mut v1 = PRIME1.wrapping_add(PRIME2);
let mut v2 = PRIME2;
let mut v3 = 0;
let mut v4 = 0u32.wrapping_sub(PRIME1);
while index + 16 <= len {
v1 = xxhash32_round(v1, read_u32_le(bytes, index));
v2 = xxhash32_round(v2, read_u32_le(bytes, index + 4));
v3 = xxhash32_round(v3, read_u32_le(bytes, index + 8));
v4 = xxhash32_round(v4, read_u32_le(bytes, index + 12));
index += 16;
}
v1.rotate_left(1)
.wrapping_add(v2.rotate_left(7))
.wrapping_add(v3.rotate_left(12))
.wrapping_add(v4.rotate_left(18))
} else {
PRIME5
}
.wrapping_add(len as u32);
while index + 4 <= len {
hash = hash
.wrapping_add(read_u32_le(bytes, index).wrapping_mul(PRIME3))
.rotate_left(17)
.wrapping_mul(PRIME4);
index += 4;
}
while index < len {
hash = hash
.wrapping_add((bytes[index] as u32).wrapping_mul(PRIME5))
.rotate_left(11)
.wrapping_mul(PRIME1);
index += 1;
}
hash ^= hash >> 15;
hash = hash.wrapping_mul(PRIME2);
hash ^= hash >> 13;
hash = hash.wrapping_mul(PRIME3);
hash ^ (hash >> 16)
}
fn xxhash32_round(acc: u32, input: u32) -> u32 {
acc.wrapping_add(input.wrapping_mul(0x85EB_CA77))
.rotate_left(13)
.wrapping_mul(0x9E37_79B1)
}
fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
impl DownloadUrlMapper for YostarJpBackend {
fn relative_destination(&self, url: &str) -> Result<PathBuf, String> {
let rest = url
.strip_prefix("https://")
.ok_or_else(|| format!("官方 URL 必须使用 https{url}"))?;
let (host, path) = rest
.split_once('/')
.ok_or_else(|| format!("官方 URL 缺少路径:{url}"))?;
let mut destination = PathBuf::from(sanitize_component(host, url)?);
for segment in path.split('/') {
if segment.is_empty() {
continue;
}
destination.push(sanitize_component(segment, url)?);
}
Ok(destination)
}
}
impl YostarJpBackend {
/// Returns the validated resource-root builder for an official root.
pub fn resource_root(&self, addressables_root: &str) -> Result<YostarJpResourceRoot, String> {
YostarJpResourceRoot::from_addressables_root(addressables_root)
}
}
fn sanitize_component(component: &str, url: &str) -> Result<String, String> {
if component == "." || component == ".." || component.is_empty() {
return Err(format!("官方 URL 包含不安全路径片段:{url}"));
}
if component.contains('?') || component.contains('#') {
return Err(format!(
"官方资源 URL 包含 query 或 fragment 等不安全路径字符:{url}"
));
}
if component.contains('\\') {
return Err(format!("官方资源 URL 包含不安全路径字符:{url}"));
}
Ok(component.to_string())
}
/// Joins a backend-relative destination below an output root.
pub fn destination_under_root(root: &Path, relative: &Path) -> Result<PathBuf, String> {
if relative.is_absolute() {
return Err(format!(
"backend destination must be relative: {}",
relative.display()
));
}
let destination = root.join(relative);
if destination
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(format!(
"backend destination escapes output root: {}",
relative.display()
));
}
Ok(destination)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jp_backend_keeps_discovery_and_inventory_rules_in_one_adapter() {
let backend = YostarJpBackend;
let server_info = YostarJpServerInfo::from_json(
r#"{"ConnectionGroups":[{"Name":"Prod","AddressablesCatalogUrlRoot":"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture"}]}"#,
)
.unwrap();
let plan = backend
.discovery_plan(&server_info, "Prod", "1.70.0", &[PatchPlatform::Windows])
.unwrap();
assert_eq!(backend.backend_id(), "bluearchive.yostar.jp");
assert!(backend.is_official_url(&plan.endpoints[0].url));
let inventory = backend.parse_inventory(
b"ExcelDB.db ExcelDB.db",
&[PlatformCatalogInput {
platform: PatchPlatform::Windows,
bundle_packing_info: b"FullPatch_000.zip",
media_catalog: b"GameData/Audio/JP.zip",
}],
);
assert_eq!(inventory.table_file_names, vec!["ExcelDB.db"]);
assert_eq!(inventory.platform_catalogs.len(), 1);
}
#[test]
fn jp_hash_strategy_verifies_decimal_xxhash32_sidecars() {
let strategy = XxHash32DecimalSeedZero;
assert_eq!(strategy.algorithm_id(), "xxhash32_decimal");
assert_eq!(
strategy.verify(b"", b"46947589").unwrap(),
SidecarHashVerification {
expected: "46947589".to_string(),
actual: "46947589".to_string(),
}
);
assert!(strategy.verify(b"changed", b"46947589").is_err());
}
#[test]
fn jp_backend_maps_and_rejects_unsafe_destinations() {
let backend = YostarJpBackend;
assert_eq!(
backend
.relative_destination(
"https://prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/a.bytes"
)
.unwrap(),
PathBuf::from("prod-clientpatch.bluearchiveyostar.com/r93/TableBundles/a.bytes")
);
assert!(backend
.relative_destination("https://prod-clientpatch.bluearchiveyostar.com/r93/../secret")
.is_err());
assert!(!backend.is_official_url("https://example.invalid/a"));
}
}
+6
View File
@@ -4,11 +4,17 @@
//! client endpoints. Mirror-specific layers such as `bluearchive.cafe` or
//! `text=jp/voice=jp/media=jp` are intentionally excluded.
pub mod backend;
pub mod game_main_config;
pub mod inventory;
pub mod launcher;
pub mod yostar_jp;
pub use backend::{
destination_under_root, DownloadUrlMapper, InventoryParser, OfficialResourceBackend,
PlatformCatalogInput, SidecarHashStrategy, SidecarHashVerification, XxHash32DecimalSeedZero,
YostarJpBackend,
};
pub use game_main_config::YostarJpGameMainConfig;
pub use inventory::{
YostarJpDownloadInventory, YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
+130 -19
View File
@@ -1,6 +1,11 @@
//! 游戏客户端领域对象
use std::path::PathBuf;
use std::collections::HashSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const CLIENT_ROOTS_ENV: &str = "BAT_CLIENT_ROOTS";
/// 游戏区域
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -69,18 +74,58 @@ impl GameClient {
}
}
/// 发现本地安装的客户端
/// 发现显式配置根目录下的本地客户端
///
/// # 返回
/// - 成功:返回找到的所有客户端
/// - 失败:返回错误
///
/// # 注意
/// 此功能将在 Phase 3 实现
/// 默认不扫描系统目录。调用方必须通过 `BAT_CLIENT_ROOTS` 提供一个或
/// 多个路径;路径格式使用平台原生路径分隔符。没有配置时返回空列表。
pub fn discover() -> crate::Result<Vec<GameClient>> {
Err(crate::Error::NotImplemented(
"客户端发现功能将在 Phase 3 实现".to_string(),
))
let Some(value) = env::var_os(CLIENT_ROOTS_ENV) else {
return Ok(Vec::new());
};
let roots = env::split_paths(&value).collect::<Vec<_>>();
Self::discover_in_roots(&roots)
}
/// 在调用方明确提供的隔离根目录下发现客户端。
///
/// 每个根目录只检查根本身和它的直接子目录,不递归扫描用户目录。
/// 当前核心模型的默认发现区域为日本服;其他区域应由适配器提供
/// 专用区域识别策略。
pub fn discover_in_roots(roots: &[PathBuf]) -> crate::Result<Vec<GameClient>> {
let mut candidates = Vec::new();
let mut seen = HashSet::new();
for root in roots {
if !is_real_directory(root)? || has_symlink_component(root)? {
continue;
}
if seen.insert(root.clone()) {
candidates.push(root.clone());
}
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if !is_real_directory(&path)? || has_symlink_component(&path)? {
continue;
}
if seen.insert(path.clone()) {
candidates.push(path);
}
}
}
let mut clients = Vec::new();
for path in candidates {
if client_layout_is_present(&path)? {
clients.push(GameClient::new(path, GameRegion::Japan));
}
}
Ok(clients)
}
/// 验证客户端完整性
@@ -89,12 +134,11 @@ impl GameClient {
/// - true: 客户端完整
/// - false: 客户端损坏
///
/// # 注意
/// 此功能将在 Phase 3 实现
pub fn verify_integrity(&self) -> crate::Result<bool> {
Err(crate::Error::NotImplemented(
"完整性验证将在 Phase 3 实现".to_string(),
))
if !is_real_directory(&self.install_path)? || has_symlink_component(&self.install_path)? {
return Ok(false);
}
Ok(client_layout_is_present(&self.install_path)?)
}
/// 获取 StreamingAssets 目录路径
@@ -113,6 +157,8 @@ impl GameClient {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_game_region_code() {
@@ -153,12 +199,77 @@ mod tests {
}
#[test]
fn test_discover_not_implemented() {
let result = GameClient::discover();
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
crate::Error::NotImplemented(_)
));
fn test_discover_without_explicit_roots_is_empty() {
// discover() 不得因为测试机或用户 home 中存在目录而扫描它们。
assert!(GameClient::discover_in_roots(&[]).unwrap().is_empty());
}
#[test]
fn test_discover_and_verify_isolated_client_layout() {
let temp = TempDir::new().unwrap();
let client_root = temp.path().join("BlueArchive_JP");
fs::create_dir_all(client_root.join("BlueArchive_Data/StreamingAssets/AssetBundles"))
.unwrap();
let clients = GameClient::discover_in_roots(&[temp.path().to_path_buf()]).unwrap();
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].install_path, client_root);
assert_eq!(clients[0].region, GameRegion::Japan);
assert!(clients[0].verify_integrity().unwrap());
}
#[test]
fn test_integrity_rejects_incomplete_layout() {
let temp = TempDir::new().unwrap();
let client = GameClient::new(temp.path().join("missing"), GameRegion::Japan);
assert!(!client.verify_integrity().unwrap());
}
#[cfg(unix)]
#[test]
fn test_discovery_and_integrity_reject_symlinked_client() {
use std::os::unix::fs::symlink;
let temp = TempDir::new().unwrap();
let real = temp.path().join("real");
fs::create_dir_all(real.join("BlueArchive_Data/StreamingAssets/AssetBundles")).unwrap();
let link = temp.path().join("link");
symlink(&real, &link).unwrap();
let clients = GameClient::discover_in_roots(&[temp.path().to_path_buf()]).unwrap();
assert_eq!(clients.len(), 1);
assert_eq!(clients[0].install_path, real);
assert!(!GameClient::new(link, GameRegion::Japan)
.verify_integrity()
.unwrap());
}
}
fn client_layout_is_present(path: &Path) -> crate::Result<bool> {
Ok(is_real_directory(&path.join("BlueArchive_Data"))?
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets"))?
&& is_real_directory(&path.join("BlueArchive_Data/StreamingAssets/AssetBundles"))?
&& !has_symlink_component(path)?)
}
fn is_real_directory(path: &Path) -> crate::Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(metadata.is_dir() && !metadata.file_type().is_symlink()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
fn has_symlink_component(path: &Path) -> crate::Result<bool> {
let mut current = PathBuf::new();
for component in path.components() {
current.push(component.as_os_str());
match fs::symlink_metadata(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => return Ok(true),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
}
}
Ok(false)
}
+85 -11
View File
@@ -37,7 +37,7 @@ use async_trait::async_trait;
/// 资源查询条件
///
/// 用于构建灵活的资源查询。支持按类型、Hash、路径模式过滤。
/// 用于构建灵活的资源查询。支持按类型、Hash、路径、官方 release 和解析摘要过滤。
///
/// # 示例
///
@@ -56,9 +56,10 @@ use async_trait::async_trait;
/// resource_type: Some(ResourceType::AssetBundle),
/// hash: Some("abc123".to_string()),
/// path_pattern: Some("academy-*.bundle".to_string()),
/// ..ResourceQuery::all()
/// };
/// ```
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Default)]
pub struct ResourceQuery {
/// 按资源类型过滤
///
@@ -90,6 +91,41 @@ pub struct ResourceQuery {
/// - `"**/*.json"` - 匹配所有 JSON 文件
/// - `"assets/???.png"` - 匹配三个字符的 PNG 文件
pub path_pattern: Option<String>,
/// 按官方 release ID 过滤。
///
/// 匹配 `ResourceMetadata::official_release_id`。
pub official_release_id: Option<String>,
/// 按资源平台过滤。
///
/// 匹配 `ResourceMetadata::platform`,例如 `windows` 或 `android`。
pub platform: Option<String>,
/// 按官方 destination 过滤。
///
/// 匹配 `ResourceEntry::path`,用于从 release manifest destination 反查资源。
pub destination: Option<String>,
/// 按资源或所在 bundle 的官方相对路径过滤。
///
/// 匹配 `ResourceMetadata::bundle_path`。
pub bundle_path: Option<String>,
/// 按 ZIP/archive entry 过滤。
///
/// 匹配 `ResourceMetadata::archive_entries` 中的任意一项。
pub archive_entry: Option<String>,
/// 按解析状态过滤。
///
/// 匹配 `ResourceMetadata::parse_statuses` 中的任意一项。
pub parse_status: Option<String>,
/// 按 TextUnit payload format 过滤。
///
/// 匹配 `ResourceMetadata::text_unit_formats` 中的任意一项。
pub text_unit_format: Option<String>,
}
impl ResourceQuery {
@@ -105,11 +141,7 @@ impl ResourceQuery {
/// let all_resources = repo.list(ResourceQuery::all()).await?;
/// ```
pub fn all() -> Self {
Self {
resource_type: None,
hash: None,
path_pattern: None,
}
Self::default()
}
/// 按类型查询
@@ -132,8 +164,7 @@ impl ResourceQuery {
pub fn by_type(resource_type: ResourceType) -> Self {
Self {
resource_type: Some(resource_type),
hash: None,
path_pattern: None,
..Self::default()
}
}
@@ -156,11 +187,23 @@ impl ResourceQuery {
/// ```
pub fn by_hash(hash: String) -> Self {
Self {
resource_type: None,
hash: Some(hash),
path_pattern: None,
..Self::default()
}
}
/// 是否包含需要读取完整 `Resource` 后才能判断的条件。
///
/// 基础索引可先用类型、hash、路径模式和 destination 缩小范围;这些条件
/// 需要再按 metadata 过滤,确保 `list()` 与 `count()` 的语义一致。
pub fn requires_resource_scan(&self) -> bool {
self.official_release_id.is_some()
|| self.platform.is_some()
|| self.bundle_path.is_some()
|| self.archive_entry.is_some()
|| self.parse_status.is_some()
|| self.text_unit_format.is_some()
}
}
/// 资源仓储接口
@@ -305,6 +348,7 @@ pub trait ResourceRepository: Send + Sync {
/// resource_type: Some(ResourceType::AssetBundle),
/// path_pattern: Some("academy-*.bundle".to_string()),
/// hash: None,
/// ..ResourceQuery::all()
/// };
/// let filtered = repo.list(query).await?;
/// ```
@@ -416,6 +460,13 @@ mod tests {
assert!(query.resource_type.is_none());
assert!(query.hash.is_none());
assert!(query.path_pattern.is_none());
assert!(query.official_release_id.is_none());
assert!(query.platform.is_none());
assert!(query.destination.is_none());
assert!(query.bundle_path.is_none());
assert!(query.archive_entry.is_none());
assert!(query.parse_status.is_none());
assert!(query.text_unit_format.is_none());
}
/// 测试按类型查询
@@ -425,6 +476,7 @@ mod tests {
assert_eq!(query.resource_type, Some(ResourceType::AssetBundle));
assert!(query.hash.is_none());
assert!(query.path_pattern.is_none());
assert!(!query.requires_resource_scan());
}
/// 测试按 Hash 查询
@@ -434,6 +486,7 @@ mod tests {
assert_eq!(query.hash, Some("abc123".to_string()));
assert!(query.resource_type.is_none());
assert!(query.path_pattern.is_none());
assert!(!query.requires_resource_scan());
}
/// 测试组合查询
@@ -443,11 +496,32 @@ mod tests {
resource_type: Some(ResourceType::AssetBundle),
hash: Some("hash123".to_string()),
path_pattern: Some("*.bundle".to_string()),
official_release_id: Some("v-current".to_string()),
platform: Some("windows".to_string()),
destination: Some("Bundles/academy.bundle".to_string()),
bundle_path: Some("Bundles/academy.bundle".to_string()),
archive_entry: Some("academy".to_string()),
parse_status: Some("parsed".to_string()),
text_unit_format: Some("json".to_string()),
};
assert_eq!(query.resource_type, Some(ResourceType::AssetBundle));
assert_eq!(query.hash, Some("hash123".to_string()));
assert_eq!(query.path_pattern, Some("*.bundle".to_string()));
assert_eq!(query.official_release_id, Some("v-current".to_string()));
assert_eq!(query.platform, Some("windows".to_string()));
assert_eq!(
query.destination,
Some("Bundles/academy.bundle".to_string())
);
assert_eq!(
query.bundle_path,
Some("Bundles/academy.bundle".to_string())
);
assert_eq!(query.archive_entry, Some("academy".to_string()));
assert_eq!(query.parse_status, Some("parsed".to_string()));
assert_eq!(query.text_unit_format, Some("json".to_string()));
assert!(query.requires_resource_scan());
}
/// 测试 ResourceQuery 可以被克隆
+8 -2
View File
@@ -8,7 +8,13 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建
当前已经可用的官方资源入口包括:
- `infrastructure/src/bin/bat_official_sync.rs`Linux 官方资源同步正式入口,构建为 `bat`,支持 one-shot、`--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `infrastructure/src/bin/bat_official_sync.rs`Linux 官方资源同步入口,构建为 `bat`;控制面组合与实现位于 `infrastructure/src/bin/bat/`,支持 one-shot、`--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `infrastructure/src/bin/bat/app.rs`CLI/env、daemon/watch、RPC dispatch、状态输出与顶层流程组合。
- `infrastructure/src/bin/bat/task_registry.rs`:任务注册表、任务持久化、取消和 daemon worker。
- `infrastructure/src/bin/bat/readonly_query.rs`parse/resource/translation/localized 只读查询及 RPC 选择。
- `infrastructure/src/bin/bat/translation_query.rs`:翻译任务与 handoff 查询、worker 状态更新。
- `infrastructure/src/bin/bat/patch_commands.rs`:文件 patch 与 UnityFS 写入命令参数校验和执行。
- `infrastructure/src/bin/bat/app_tests.rs`:控制面回归测试,避免测试代码继续堆积在入口实现中。
- `infrastructure/src/official_update.rs`:官方自动更新核心服务,负责 auto-discover、snapshot、marker diff、本地 audit/repair。
- `infrastructure/examples/official_pull_plan.rs`:开发/审计用 pull plan 入口。
- `infrastructure/examples/official_update_check.rs`:历史/开发入口,生产优先使用 `bat`
@@ -120,7 +126,7 @@ current symlink → official-sync-snapshot.json + official-download-manifest.jso
- `refresh --force` 可手动强制刷新;`verify` 只读校验当前官方计划、本地 manifest 和官方 seed hash`repair` 尝试修复异常资源。
- 非 dry-run 同步先写 `.staging/<id>`,校验完成后发布 `versions/<id>` 并原子切换 `current` symlink。
- `--daemon` 使用状态目录下的 `bat.sock` 作为 Unix socket JSON-RPC live control planePID、状态和日志文件是快照与 fallback,`bat-events.jsonl` 是结构化轮转日志。
- `status``logs``reload``stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 负责重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。
- `status``logs``restart``reload``stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 通过 Rust lifecycle controller 复用 CLI restart 路径重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。
- 远端 marker 无变化且本地 manifest clean 时不下载。
- 本地文件损坏时 repair。
- 官方 seed `.hash` 强校验;Addressables `catalog_*.hash` 作为变更 marker。
+1
View File
@@ -3,6 +3,7 @@
- **更新时间**2026-07-26
- **适用范围**:Rust 解析引擎、官方同步后的解析缓存、CAS/ResourceRepository 接入、后续文本提取和 Patch 发布。
- **权威关联**`PROJECT_PLAN.md` Milestone 3/4/5/8`docs/reports/CURRENT_GAPS.md` G-005/G-007/G-011/G-011D。
- **维护冻结**:解析扩展遵循 [`docs/reports/PARSER_FREEZE.md`](../reports/PARSER_FREEZE.md),冻结期只接受稳定性、诊断、真实回归和文档一致性修复。
---
+25 -9
View File
@@ -146,6 +146,12 @@
17. 旧 launcher 包或 `resources.assets` 下载使用官方 launcher CDN 配置,primary CDN 失败后切换 official backup CDN;资源 patch host 不猜测非官方镜像。
18. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态。
19. 非官方 URL 直接拒绝。
20. 下载调度默认并发数为 `8`,允许范围是 `1..=256`,由
`--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置。worker 从共享
plan 队列逐项领取任务,单个任务完成后立即领取下一个,不等待其他 worker
的当前任务;完成结果在协调线程即时更新 manifest、hash 事件和进度计数。
最终 `OfficialResourcePullReport.items` 仍按 `OfficialResourcePullPlan`
顺序排列,避免并发完成顺序泄露到发布和 API 读侧。
路径映射时会做分段清理,并在写入前做输出目录安全校验、相对路径归属校验和现有路径组件 symlink 检查,避免把不安全路径写进输出目录或通过 symlink 跳出输出目录。
@@ -171,8 +177,12 @@
这层的意义是把“下载到磁盘的文件”变成“可查询、可复用、可去重”的资源对象。
`resource.index` RPC / CLI 只读查询现有 SQLite 索引;索引不存在时返回
`available=false`,不会因为查询创建空库。G-011 剩余工作是翻译任务状态、
CAS 诊断入口和更丰富查询。
`available=false`,不会因为查询创建空库。发布后的 TextUnit 队列还会在当前
release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrations`
管理 queued/running/failed/completed/skipped 和 failure reason。`translation.tasks`
优先查询这份状态库,`translation.task.update` 供 provider worker 回写状态;
没有状态库的旧 release 才回退到 immutable JSON 队列。G-011 剩余工作是 CAS
诊断入口和面向大索引的查询优化。
对应实现主要在:
@@ -225,18 +235,22 @@ CAS 诊断入口和更丰富查询。
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot,并在 staging 中写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`)。
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json``crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。
14. 随后刷新 active release 下的 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。
14. 随后刷新 active release 下的 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;UnityFS TextAsset patch 发布成功并通过 `localized-patch-manifest.json`、current symlink 和 release ID 校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。
维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `<output>/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。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`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`,三者分别通过 `--output``--localized-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``restart``logs``reload`、默认形态的 `refresh` 和默认形态的 `repair` 优先走 RPC`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,默认 `repair` 会通过 `resource.repair` 入队本地 manifest 审计+修复任务,live RPC `restart` 会启动 Rust lifecycle controller 并复用 CLI 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`,默认汉化产物目录是 `./bat-localized`,默认后台状态目录是 `/tmp/bat-pid`,三者分别通过 `--output``--localized-output``--state-dir` 配置;官方目录和汉化目录不能相同或互相嵌套。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
对应实现主要在:
- `infrastructure/src/official_update.rs`
- `infrastructure/src/bin/bat_official_sync.rs`
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口)
- `infrastructure/src/bin/bat/app.rs`(控制面组合)
- `infrastructure/src/bin/bat/task_registry.rs`(任务注册表、持久化和 worker)
- `infrastructure/src/bin/bat/readonly_query.rs``translation_query.rs`(只读查询)
- `infrastructure/src/bin/bat/patch_commands.rs`patch 命令)
- `infrastructure/examples/official_update_check.rs`(历史/开发入口)
## 4. 官方 bootstrap 与用户流程
@@ -269,7 +283,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 live control/backend`daemon.status/logs/stop/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``localized.status``catalog.*``task.*`);`restart` `clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 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/restart/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``translation.tasks``localized.status``catalog.*``task.*`);`restart` 通过 Rust lifecycle controller 复用 CLI 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` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类;`resource.index` 可只读查询现有索引
@@ -321,11 +335,13 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
(版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务
仍可经 `task.*` 查询,中断任务标记 `task_interrupted`700005)。
- 方法命名空间与实现状态、请求/响应示例见
`docs/reference/rpc-backend-api.md``daemon.status/logs/stop/reload/refresh/doctor`
`docs/reference/rpc-backend-api.md``daemon.status/logs/stop/restart/reload/refresh/doctor`
`resource.state/sync/verify/repair/manifest/list/index``parse.status/text_units/errors`
`localized.status``catalog.*``task.status/list/cancel/logs` 已实现;
`patch.*` / `unityfs.*` 待引擎;`task.create` 按设计暂不开放通用任务入口
`daemon.restart` / `daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
文件级 `patch.apply` / `unityfs.patch_*` 已实现,发布级 patch 与复杂 UnityFS 语义编辑待引擎
`task.create` 按设计暂不开放通用任务入口;
`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径;
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
### 7.2 Go 层职责边界
+1 -1
View File
@@ -93,7 +93,7 @@ CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进:
1. 继续联调 Go `bat-api` 与 Rust daemon 的资源分发路径;Go 同步 CLI 不再作为产品目标。
2.`docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
3.`crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
4. 扩展 ResourceRepository 查询面:翻译任务状态、CAS 诊断入口和更丰富 TextUnit 查询。
4. 扩展 ResourceRepository 查询面:真实 worker 状态写回、CAS 诊断入口和更丰富 TextUnit 查询。
5. 继续完善 AssetBundle 复杂对象解析、复杂对象重打包和 Patch 发布流程统一;通用 Binary/JSON/Text Patch 基础与 UnityFS TextAsset patch 发布前置链路已可用。
优先阅读:
+1 -1
View File
@@ -253,7 +253,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` 和默认形态的 `repair` 会优先连接 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``restart``logs``reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径替换后台进程;`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`
不要同时运行 systemd `--watch` 和 standalone `--daemon` 指向同一个 `--output`。二者都会被资源锁和 live daemon 互斥保护,但生产运维上应保持单一 owner。
+25 -3
View File
@@ -33,9 +33,10 @@ rustc --version
cargo --version
rustfmt --version
cargo clippy --version
go version
```
该 workflow 会用 `GITHUB_SERVER_URL``GITHUB_REPOSITORY``GITHUB_REF``GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
该 workflow 会用 `GITHUB_SERVER_URL``GITHUB_REPOSITORY``GITHUB_REF``GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试,以及 Go API 门禁和文档状态门禁。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
#### Docker
```bash
@@ -148,6 +149,7 @@ cargo clippy --workspace --all-targets -- -D warnings
make test-go-api
make build-go-api
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
make check-docs
```
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
@@ -217,12 +219,20 @@ cargo run -p bat-infrastructure --bin bat -- \
`official-resource-changes.json``crowdin-translation-handoff.json`
`official-parse-cache.json``official-textunit-index.json`,随后从
Added/Modified 资源、parse cache 与 TextUnit 明细索引派生
`official-textunit-tasks.json``crowdin-textunit-queue.json`。本地已有旧完整
`official-textunit-tasks.json``crowdin-textunit-queue.json`
`translation-tasks.sqlite``translation-handoff.json`。本地已有旧完整
版本时,新版本发布后会先按 manifest destination 对比旧/新 release,只把新增和
内容变更的资源写入解析与 Crowdin handoff;删除资源只记录差异,不进入翻译队列。
up-to-date 轮询发现本地文件、解析缓存、TextUnit 明细索引和 TextUnit 队列未变时不会重复解析。
Crowdin 队列当前只落本地文件,不发网络请求。
官方下载服务默认使用 8 个有界 worker,`--download-concurrency` /
`BAT_DOWNLOAD_CONCURRENCY` 只接受 `1..=256`。worker 完成一个 URL 后立即从共享
队列领取下一个任务;finished 进度按实际完成顺序即时上报,完成计数单调递增,
最终 report 的资源列表仍按 pull plan 顺序。需要验证顺序模式时显式使用
`--download-concurrency 1`。本文档中的真实资源命令仅是隔离 runbook;本地轻量
验证应使用 fake-curl/fixture,不要在开发机执行真实下载或 smoke run。
需要把已校验官方 release 导入 CAS + `ResourceRepository` 时,显式启用:
```bash
@@ -240,14 +250,26 @@ cargo run -p bat-infrastructure --bin bat -- \
cargo run -p bat-infrastructure --bin bat -- parse-status
cargo run -p bat-infrastructure --bin bat -- parse-text-units --limit 50
cargo run -p bat-infrastructure --bin bat -- parse-errors --limit 50
cargo run -p bat-infrastructure --bin bat -- translation-tasks --task-status skipped_parse_failed --has-reason --limit 50
cargo run -p bat-infrastructure --bin bat -- translation-tasks --worker-status failed --has-failure-reason --limit 50
cargo run -p bat-infrastructure --bin bat -- translation-handoff
cargo run -p bat-infrastructure --bin bat -- localized-status
cargo run -p bat-infrastructure --bin bat -- resource-index --limit 50
cargo run -p bat-infrastructure --bin bat -- resource-index --release-id <ID> --platform windows --archive-entry <PATH> --format json --limit 50
```
`parse-status` 会额外显示 TextUnit 明细索引和队列摘要;`parse-text-units` /
`parse-errors` 可按 destination、archive entry、path id、class id、field path
和 format 分页查询当前官方 release 的 TextUnit 明细与解析错误;
`resource-index` 返回的资源 JSON 包含 release、平台、bundle path、TextAsset 和 TextUnit metadata
`translation-tasks` 可按 release、destination、archive entry、队列任务状态、provider
worker 状态、parse status、TextUnit format、队列 reason 和 provider failure reason
查询离线 TextUnit 翻译任务状态与跳过/失败原因;发布后的状态保存在当前 release
根目录的 `translation-tasks.sqlite`,旧 release 没有状态库时回退到 JSON 队列;
`translation-handoff` / `translation.handoff` 会动态合并版本化
`translation-handoff.json` 与 SQLite 状态,返回 job、unit、provider run 的完整交接
视图;
`resource-index` 返回的资源 JSON 包含 release、平台、bundle path、TextAsset 和 TextUnit metadata
并可按 release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 做资源级过滤;
`localized-status` 只有在 `localized-version-state.json``current` symlink 和
`localized-patch-manifest.json` 都匹配当前官方 release 时才返回 `localized`
+3 -3
View File
@@ -277,7 +277,7 @@ cargo run -p bat-infrastructure --bin bat -- reload
cargo run -p bat-infrastructure --bin bat -- stop
```
`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`
`status``stop``restart``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`通过 Rust lifecycle controller 复用 CLI restart 路径停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy``--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`
如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`
@@ -322,7 +322,7 @@ cargo run -p bat-infrastructure --bin bat -- \
默认平台是 `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` 中未切换的目录。汉化 Patch/导出应写入 `--localized-output`,并保留官方相对目录结构,不能写回 `--output/current`。发布状态分两档:`not_localized` 只发布原版资源、不发布汉化资源;`localized` 发布原版和汉化两套资源。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一官方资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。下载默认并发 8,可用 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`worker 动态领取共享 plan,finished 进度即时按完成数统计,发布 report 仍按 plan 顺序。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产官方资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取原版资源时应读取 `--output/current`,不要读取 `.staging``versions` 中未切换的目录。汉化 Patch/导出应写入 `--localized-output`,并保留官方相对目录结构,不能写回 `--output/current`。发布状态分两档:`not_localized` 只发布原版资源、不发布汉化资源;`localized` 发布原版和汉化两套资源。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一官方资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
需要只做探测时可以加 `--dry-run`。需要关闭本地 audit 或 repair 时可以显式使用 `--no-audit-local``--no-repair`,但生产同步默认应保持开启。
@@ -356,7 +356,7 @@ make official-smoke
- `infrastructure/examples/official_launcher_bootstrap.rs`
- `infrastructure/examples/official_pull_plan.rs`
- `infrastructure/src/bin/bat_official_sync.rs`
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口;控制面实现位于同目录 `bat/`
- `infrastructure/examples/official_update_check.rs`(历史/开发入口;生产优先使用 `bat`
- `adapters/examples/yostar_jp_client_bootstrap.rs`
- `adapters/examples/yostar_jp_discovery.rs`
+71 -10
View File
@@ -83,13 +83,17 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主
| `daemon.status` | 已实现 | `null` | 后台状态报告。 |
| `daemon.logs` | 已实现 | `{ "tail": 200 }` | 日志尾部报告。 |
| `daemon.stop` | 已实现 | `null` | accepted ack。 |
| `daemon.restart` | 已实现 | `null` | accepted ack;启动 Rust lifecycle controller,并在响应后停止当前 daemon。 |
| `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`
`daemon.restart` 不在 daemon 线程内手写第二套启动流程;它启动本机 Rust
`bat restart --state-dir ...` lifecycle controller,由既有 CLI restart 路径复用
保存的启动参数、代理凭据、PID/socket 替换和控制锁。
`bat.status``bat.stop``bat.restart``bat.reload``bat.refresh``bat.logs`
`bat.doctor``bat.clean-stable` 是兼容别名;新代码应使用 `daemon.*`
### resource
@@ -102,14 +106,18 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主
| `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.index` | 已实现 | `{ "offset": 0, "limit": 100, "type": "asset_bundle", "hash": "...", "path_pattern": "*" }` | 当前 `ResourceRepository` 分页/过滤查询。 |
| `resource.index` | 已实现 | `{ "offset": 0, "limit": 100, "type": "asset_bundle", "hash": "...", "path_pattern": "*", "release_id": "...", "platform": "windows", "destination": "...", "archive_entry": "...", "parse_status": "parsed", "format": "json" }` | 当前 `ResourceRepository` 分页/过滤查询。 |
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`
`resource.manifest` / `resource.list` 查询当前已发布 release 的
`official-download-manifest.json``resource.index` 查询可选导入产生的
SQLite `ResourceRepository`,索引不存在时返回 `ok=true`
`data.available=false`,不会隐式创建数据库。`limit` 范围是 `1..=1000`
非法参数返回 `BAT-ERR-700002`
`data.available=false`,不会隐式创建数据库。`resource.index` 可按
`resource_type`/`type``hash``path_pattern``official_release_id`/`release_id`
`platform``destination``bundle_path``archive_entry``parse_status`
`text_unit_format`/`format` 过滤;`path_id``class_id``field_path`
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
`1..=1000`,非法参数返回 `BAT-ERR-700002`
`resource.index``entries[]``Resource` JSON,除 `id``local_path`
`entry` 外会包含 `metadata``official_release_id``platform`
@@ -118,6 +126,15 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
`text_unit_error_count` 等字段。旧索引库会通过 `metadata_json` 迁移列得到
默认空 metadata。
`resource.state``catalog.status``parse.status``localized.status`
都会返回当前观察面的短状态 `status` 与稳定状态码 `status_code``status_code`
使用命名空间格式,例如 `official.up_to_date``official.published`
`parse.completed``translation.queued_offline``localized.published`
`distribution.ready`。这些状态码描述资源/解析/翻译 handoff/汉化/分发生命周期;
失败原因仍使用 `BAT-ERR-*` 错误码,二者不混用。响应还会包含
`status_phase``status_terminal``status_retryable`,供 `bat-api` 等读侧
决定展示、重试或 readiness。
官方资源完整新版本发布后,Rust 侧会先比较上一完整 release 与当前 release
的 download manifest,并在当前 release 根目录写出:
@@ -134,6 +151,11 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
TextUnit 明细索引派生,记录 TextUnit 任务、跳过原因和解析诊断。
- `crowdin-textunit-queue.json`:只包含已产生 TextUnit 的离线任务,预留给后续
Crowdin worker;当前不会发出网络请求。
- `translation-tasks.sqlite`:当前 release 的可变 worker 状态库,记录
queued / running / failed / completed / skipped、attempt count、provider run
ID 和 failure reasonschema 由 `schema_migrations` 版本表管理。
- `translation-handoff.json`:当前 release 的版本化 job/unit/provider run 交接
快照;worker 更新后的实时状态仍以 `translation-tasks.sqlite` 为准。
删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。
@@ -144,6 +166,9 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
| `parse.status` | 已实现 | `null` | 当前官方 release 的解析缓存状态。 |
| `parse.text_units` | 已实现 | `{ "offset": 0, "limit": 100, "destination": "*Table*", "archive_entry": "*.bytes", "path_id": 1, "class_id": 114, "field_path": "*Text*", "format": "json" }` | 当前官方 release 的 TextUnit 明细分页。 |
| `parse.errors` | 已实现 | `{ "offset": 0, "limit": 100, "destination": "*Table*", "archive_entry": "*.bytes", "path_id": 1, "class_id": 114, "field_path": "*Text*", "format": "json" }` | 当前官方 release 的解析错误分页。 |
| `translation.tasks` | 已实现 | `{ "offset": 0, "limit": 100, "task_id": "...", "release_id": "...", "destination": "...", "archive_entry": "...", "status": "skipped_parse_failed", "parse_status": "failed", "format": "json", "has_reason": true }` | 当前官方 release 的离线 TextUnit 翻译任务状态分页。 |
| `translation.handoff` | 已实现 | `null` | 当前官方 release 的 job、unit、provider run 交接视图;动态合并队列和 SQLite worker 状态。 |
| `translation.task.update` | 已实现 | `{ "task_id": "...", "status": "failed", "failure_reason": "...", "provider_run_id": "..." }` | 写入当前 release 的 provider worker 状态,返回可回查任务记录。 |
`parse.status` 是只读查询;没有当前 release 或没有解析缓存时返回
`ok=true``data.available=false`。解析缓存来自官方原版资源目录,不读取
@@ -151,7 +176,9 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
`textunit_index_available=true``textunit_index_path`
`textunit_index_summary`;存在 `official-textunit-tasks.json` 时,响应会包含
`textunit_queue_available=true``textunit_task_queue_path`
`textunit_task_summary`
`textunit_task_summary`当 TextUnit 队列存在且有离线任务时,
`translation_status_code=translation.queued_offline`;真实 Crowdin worker
尚未接入时不会返回翻译完成状态。
`parse.text_units` / `parse.errors` 是只读查询;没有当前 release 或没有
`official-textunit-index.json` 时返回 `ok=true``data.available=false`
@@ -175,6 +202,26 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
完整 managed reference registry 等暂不支持结构会进入解析错误,而不是静默降级为
低保真文本。
`translation.tasks` 优先查询当前 release 的 `translation-tasks.sqlite`,旧 release
没有该文件时回退到 `official-textunit-tasks.json`;用于查看离线 TextUnit
翻译任务候选和 provider worker 状态。没有当前 release 或没有任务队列时返回
`ok=true``data.available=false`。过滤参数包括 `task_id`
`official_release_id`/`release_id``destination``path_pattern`
`archive_entry``status`/`task_status``worker_status``parse_status`
`text_unit_format`/`format``has_reason``has_failure_reason`
`entries[]` 会包含 `official_release_id``destination``archive_entry`
`parse_status`、队列 `status``task_status``failure_reason``attempt_count`
`provider_run_id`、TextAsset/TextUnit 摘要和校验指纹。
`translation.task.update` 只更新当前 release 的 SQLite 状态库,不改写 immutable
队列文件,也不主动访问 Crowdin。`status` 支持 `queued``running``failed`
`completed``skipped`;进入 `running` 会增加 attempt count`completed`
记录完成时间,`failed` 可写入 `failure_reason`。因此 worker 消费 handoff 后,
bat-api 可通过 `translation.tasks` 查询单项任务,也可通过
`translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff`
不会触发下载或 provider 网络请求;没有当前 release 或任务队列时返回
`data.available=false`
### localized
| 方法 | 状态 | params | data |
@@ -184,9 +231,12 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
`localized.status` 严格按 daemon / `.env` 中的 `BAT_LOCALIZED_OUTPUT`
`--localized-output` 查询汉化产物目录,不把 `./bat-resources`
`./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。
返回 `localized` 的条件是:`localized-version-state.json` 的官方 release ID
匹配当前官方 release`current` symlink 指向汉化发布根下对应的
`versions/<id>`,并且该版本目录中的
`status` / `status_code` 使用生命周期短状态和稳定状态码,例如
`pending` / `localized.pending``stale` / `localized.stale``published` /
`localized.published`;旧的 `localized` / `not_localized` 业务标签放在
`localized_release_status`。返回 `localized_release_status=localized` 的条件是:
`localized-version-state.json` 的官方 release ID 匹配当前官方 release
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
`patch_manifest_path``patch_manifest_available`
`patch_manifest_matches_release``patch_file_count`
@@ -202,6 +252,9 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
| `catalog.refresh` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "catalog.refresh" }`。 |
只读查询在没有可用版本时返回 `ok=true``data.available=false`
`catalog.status` 可用时会返回 `status_code=official.published`,并用
`distribution_status_code=distribution.ready` 表示该官方 release 可被读侧分发;
不可用时对应 `official.unavailable` / `distribution.blocked`
### task
@@ -289,7 +342,9 @@ CLI 对应关系:
`bat-api` 应直接调用本 RPC contract,不通过 `exec` 调用 `bat` binary。
`bat` binary 是人类 CLI 和进程生命周期工具;默认 `refresh` / `repair`
在 daemon 可用时也会作为 RPC client 调用同一个 socket。
在 daemon 可用时也会作为 RPC client 调用同一个 socket。`daemon.restart`
会启动 Rust lifecycle controller 复用同一套 CLI restart 路径,Go 层仍不直接
`exec` 或解析 `bat` stdout。
人类 CLI 的只读查询命令与 RPC 对应关系如下:
@@ -307,6 +362,12 @@ CLI 对应关系:
`--class-id``--field-path``--format`;这些过滤参数不适用于
`parse-status``localized-status`
Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖
`catalog.status` available/unavailable、`resource.manifest` page0 和对应
`official-sync-snapshot.json`。这些 fixture 由 Rust 输出归一化而来,只用于
schema / mirror 回归;live daemon socket 和完整 release 切换仍需在允许 smoke 的
隔离环境中验证。
禁止事项:
- Go 服务层不直接读写 `bat-status.json``bat-tasks.json` 等 daemon 内部状态文件。
@@ -1,9 +1,14 @@
# bat-api / Rust bat Contract Fixture Handoff
更新时间:2026-07-28
更新时间:2026-07-31
本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。它只定义协作协议和验收标准,不包含已审核 fixture。
2026-07-31 更新:已审核归一化 fixture 已落入
`internal/api/testdata/contract/`Go 侧通过
`internal/api/contract_fixture_test.go` 固化 mirror struct 验证。本文件保留为
后续重新生成或扩展 contract fixture 时的协作协议。
## 最小上下文包
另一个窗口不需要知道本窗口的完整对话,只需要遵守以下上下文:
@@ -90,14 +95,14 @@ Go 窗口读取归一化后的 JSON,验证:
- `version.completed_unix_seconds`
- `version.resource_root`
- `launcher_metadata`
- `game_main_config`
- `game_main_config_bootstrap`
2. `parseCatalogStatus``available=false` 返回不可用而不是错误。
3. `resource.manifest` entry 字段能映射为 Go `ResourceManifestEntry`
- `url`
- `destination`
- `bytes`
- `blake3`
4. 本地 snapshot fixture 使用 `game_main_config_bootstrap`RPC `catalog.status` 使用 `game_main_config`
4. 本地 snapshot fixture RPC `catalog.status` 使用 `game_main_config_bootstrap`
5. `bat-api` bootstrap 和 launcher bootstrap 不泄露归一化前的开发机路径。
Go 侧审核通过后的落地建议:
@@ -118,8 +123,8 @@ Go 侧审核通过后的落地建议:
- `launcher_metadata.game_lowest_version`
- `launcher_metadata.game_start_exe_name`
- `launcher_metadata.manifest_source`
- `game_main_config.server_info_data_url`
- `game_main_config.default_connection_group`
- `game_main_config_bootstrap.server_info_data_url`
- `game_main_config_bootstrap.default_connection_group`
2. optional 字段为 null 或缺省:
- Go mirror 不应崩溃。
- HTTP response 中按当前 Go struct `omitempty` 策略输出。
@@ -130,7 +135,7 @@ Go 侧审核通过后的落地建议:
- 归一化是否过度改变 Rust 真实输出。
- fixture 是否意外绑定真实版本、日期、本机路径或私有部署路径。
- `game_main_config``game_main_config_bootstrap` RPC / snapshot 差异是否符合预期
- `game_main_config_bootstrap` RPC / snapshot 中是否保持同一语义
- optional 字段覆盖是否足够。
## notes.md 模板
@@ -191,6 +196,13 @@ contract fixture 工作只有在以下条件同时满足时才算完成:
## 当前状态
- Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config` 的 mirror struct。
- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理面板预留
- contract fixture 尚未落仓库,等待 Rust 侧真实输出与用户审核。
- Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config_bootstrap` 的 mirror struct。
- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理控制白名单
- 已归一化的 Rust contract fixture 落仓库
- `internal/api/testdata/contract/catalog-status.available.json`
- `internal/api/testdata/contract/catalog-status.unavailable.json`
- `internal/api/testdata/contract/resource-manifest.page0.json`
- `internal/api/testdata/contract/official-sync-snapshot.json`
- 原始交接产物仍位于 `/tmp/bat-contract-fixture/`;当前受本地 sandbox 限制,live socket daemon 无法启动,原始 JSON 通过临时 Rust 测试调用同一 dispatch/report 代码路径生成。
- Go contract 测试读取仓库内归一化 fixture,不依赖 `/tmp/bat-contract-fixture/`、开发机资源目录或远端长期运行的 `bat`
- 仍未覆盖真实长期 daemon socket 的端到端调用和完整发布切换;该项需要在允许 live daemon / smoke 的隔离环境中单独验证。
+20 -11
View File
@@ -230,6 +230,8 @@
- `cmd/bat-api`:组织 Rust `bat` 已发布 release 的启动前资源入口,并只读分发官方 CDN host/path 形态资源。
- **拉取归属 Rust `bat`**`bat-api` 不做下载器。
- 发现经 `bat.sock`:先 `daemon.status`,再 `daemon.doctor`,再 `catalog.status` / `resource.manifest`
- `bat-api` 通过 RPC 读取的 `resource.state``catalog.status``parse.status``localized.status` 会获得短状态 `status` 与稳定生命周期状态码 `status_code`;错误原因仍以 `BAT-ERR-*` 为准。
- `bat-api` 可通过受限 Web 控制面转发 `reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`,其中 `restart``parse.*``localized.status` 和文件级 `unityfs.patch_*` 均走 Rust live RPC`daemon.clean-stable` 等危险或离线生命周期命令不经 Web 转发。
- 生产与 Rust `bat` 同环境运行,资源根来自 RPC 返回的 `resource_root``--resource-root` 仅用于 fixture 或应急只读诊断。
- `.env` 配置端口 / public base / RPC socket / RPC 刷新周期;预留 database/redis。
- `/v1/bootstrap` 返回 RPC 健康、release 摘要、server-info URL、client-patch base 和改写后的 Addressables root。
@@ -241,7 +243,7 @@
已完成:
- `cmd/bat-api``internal/api``/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、HTTP token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON `no-store``/v1/resources` 分页上限、OpenAPI、`/admin/` 预留、RPC 周期刷新/诊断、`/readyz`、CDN Range/缓存头、fixture 单测、USERGUIDE 基础章节、systemd bat-api 模板、`make build-go-api` / `test-go-api`
- `cmd/bat-api``internal/api``/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、HTTP token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON `no-store``/v1/resources` 分页上限、OpenAPI、`/admin/` 管理控制白名单、RPC 周期刷新/诊断、`/readyz`、CDN Range/缓存头、fixture 单测、USERGUIDE 基础章节、systemd bat-api 模板、`make build-go-api` / `test-go-api`
- 进度权威:`docs/reports/GO_STATUS.md`
验收(剩余):
@@ -280,22 +282,27 @@
- `SqliteResourceRepository` 已存在,可按领域 repository 接口保存资源元数据。
- `ResourceImportService` 已能把 manifest 中有数据的资源写入 CAS + `ResourceRepository`AssetBundle 会记录 UnityFS 摘要,TextAsset/Table/Media 会分类索引。
- 官方同步下载结果可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后自动导入 CAS + ResourceRepository;默认 CAS 为 `<output>/.cas`,默认 SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root``--import-resource-db``BAT_IMPORT_CAS_ROOT``BAT_IMPORT_RESOURCE_DB` 覆盖。
- `resource.index` RPC 已能按资源类型、hash、路径模式分页查询现有 SQLite 索引;数据库不存在时返回 `available=false`,不会因查询创建空库。
- `resource.index` RPC/CLI 已能按资源类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有 SQLite 索引;数据库不存在时返回 `available=false`,不会因查询创建空库。
- 官方 release 发布后会写出 `official-resource-changes.json``crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。
- `Resource` metadata 已通过 SQLite `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;`resource.index` 会返回这些 metadata。
- 官方 release 发布后会持久化 `official-textunit-index.json`,记录单条 TextUnit 和解析错误;`parse.text_units` / `parse.errors` RPC 和 `parse-text-units` / `parse-errors` CLI 可按 destination、archive entry、path id、class id、field path 和 format 分页过滤。
- 官方 release 发布后会从 Added/Modified 资源、parse cache 和 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json`;删除资源不会进入队列。
- 翻译任务状态和 Crowdin worker 失败原因查询仍需补齐
- 官方 release 发布后会从 Added/Modified 资源、parse cache 和 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json``translation-tasks.sqlite`;删除资源不会进入队列。
- 官方 release 发布后还会写入版本化 `translation-handoff.json``translation.handoff` RPC/CLI 动态合并该快照与 SQLite worker 状态,暴露 job、unit、provider run、attempt 和 failure reason
- `translation-tasks.sqlite``schema_migrations` 管理 durable task state、attempt count、provider run ID 和 failure reason;重复同步会保留已有 worker 状态。
- `translation.tasks` RPC/CLI 优先查询 `translation-tasks.sqlite`,旧 release 没有状态库时回退到 `official-textunit-tasks.json`;返回队列 `status`、worker `task_status`、failure reason 和时间/尝试次数。
- `translation.task.update` RPC 已提供 queued/running/failed/completed/skipped 状态回写契约,provider worker 可在消费 handoff 后按 task_id 更新并由同一查询接口反查。
- 真实 Crowdin 网络 worker、翻译记忆和完整 localized repack 仍属于后续翻译系统工作,不在当前 Rust 离线状态仓储范围内。
验收:
- schema 和迁移可重复执行。
- 可按版本、类型、hash、路径查询资源,且能明确区分索引缺失、版本缺失和空结果。
- 可按版本、平台、类型、hash、路径、destination、archive entry、parse status 和 TextUnit format 查询资源,且能明确区分索引缺失、版本缺失和空结果。
- 官方同步后的资源可通过 CLI/RPC 查询并能追溯到 CAS 对象。
- `official-parse-cache.json` 的 bundle、zip entry、TextAsset 和 TextUnit 摘要能进入 ResourceRepository 查询面。
- `parse.status` 能报告 TextUnit 索引、TextUnit 队列路径与摘要。
- `parse.text_units` / `parse.errors` 能分页查询当前 release 的 TextUnit 明细和解析错误。
- Crowdin handoff 被后续翻译 worker 消费后,任务状态和失败原因能反查到对应官方 release资源 destination。
- 离线 TextUnit 翻译任务状态和跳过/失败 reason 可反查到对应官方 release资源 destination 和 archive entry
- Crowdin handoff 被后续翻译 worker 消费后,worker 状态、远端失败原因和完成结果能反查到对应官方 release 与资源 destination。
解析补全路线图:
@@ -357,7 +364,7 @@
- 官方同步报告新增 `localized_release_status=not_localized`,明确表示原版资源已发布、汉化资源未发布。
- `LocalizedPatchService` 已具备将给定汉化文件按官方相对路径发布到 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 指定目录下的 `.staging/<id>`、校验后移动到 `versions/<id>`、原子切换 `current` 并写入 `localized-version-state.json` 的基础能力。
- `LocalizedPatchService` 已写入结构化 `localized-patch-manifest.json`,记录 TextAsset 操作、原始/汉化 hash、size、byte delta 和 rollback 信息;发布前后会校验 manifest hash/size 与 current symlink,失败时清理 staging / 未完成 version。
- `localized.status` RPC 会读取 `.env` / daemon 配置中的汉化输出目录,校验汉化状态是否匹配当前官方 release,且要求 patch manifest 存在并匹配 release,避免写死 `./bat-localized` 或误报手工状态。
- `localized.status` RPC 会读取 `.env` / daemon 配置中的汉化输出目录,校验汉化状态是否匹配当前官方 release,且要求 patch manifest 存在并匹配 release,避免写死 `./bat-localized` 或误报手工状态;其中 `status` / `status_code` 返回生命周期状态,`localized_release_status` 保留 `localized` / `not_localized` 发布标签
仍未完成:
@@ -480,9 +487,9 @@
处理结果:
- 明确决策:本项目不加入 GitHub Workflows,也不引入其他托管 CI。
- 目前补充了自托管 Gitea linux-runner workflow`.gitea/workflows/bat.yml`),仅用于 Rust workspace 构建测试,不改变“不引入托管 CI”的决策。
- workflow 不使用外部 GitHub Action;它通过 runner 环境变量手动 `git fetch` 当前提交,并要求 runner 预装 `git`、Rust stable、rustfmtclippy,避免准备阶段因第三方 action 仓库代理或网络限制失败。
- 质量门禁由本地默认验证命令和自托管 workflow 共同承担:提交前执行 `cargo fmt` / `cargo clippy --workspace --all-targets -- -D warnings` / `cargo test --workspace`(见 `docs/guides/development.md``docs/guides/baseline.md`)。
- 目前补充了自托管 Gitea linux-runner workflow`.gitea/workflows/bat.yml`),覆盖 Rust workspace 构建/测试、Go API 门禁和文档状态门禁,不改变“不引入托管 CI”的决策。
- workflow 不使用外部 GitHub Action;它通过 runner 环境变量手动 `git fetch` 当前提交,并要求 runner 预装 `git`、Rust stable、rustfmtclippy 和 Go,避免准备阶段因第三方 action 仓库代理或网络限制失败。
- 质量门禁由本地默认验证命令和自托管 workflow 共同承担:提交前执行 `cargo fmt` / `cargo clippy --workspace --all-targets -- -D warnings` / `cargo test --workspace`、Go API 门禁和 `make check-docs`(见 `docs/guides/development.md``docs/guides/baseline.md`)。
- 发布类检查(build、smoke)由 `Makefile``scripts/` 下的可重复脚本承担(如 `make official-smoke`)。
限制:
@@ -550,7 +557,9 @@
1. issue #24:失败 staging 复用回归已补;核对残余场景。
2. issue #1RPC 主体、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已落地;剩余发布级 patch build/rollback、复杂 UnityFS 语义编辑与设计边界确认。
3. issue #17 及子 issue:已按 wontfix 关闭多线程下载(顺序下载 + 指数退避)。
3. issue #17 的历史顺序/重试契约仍保留;当前 issue #33/#35 已补有界 downloader
scheduler 和默认并发 8,范围 `1..=256`,worker 完成后立即领取下一个任务,
进度即时按完成数上报,最终 report 保持 plan 顺序。
4. **G-008:已决策关闭**(同步 CLI = Rust `bat`;见 `GO_STATUS.md`)。
5. **G-009 / issue #19**:资源 bootstrap/分发 MVP 已编码;优先服务器联调与索引实勘,非「从零实现」。
6. issue #2 / G-007P1):Addressables 可校验字段。
+9 -6
View File
@@ -32,7 +32,7 @@
| 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata |
| 下载与发布 | 下载、校验、staging、原子发布 `current -> versions/<id>`,维护 manifest/snapshot/version-state | 不下载、不写 staging、不改 version-state;生产资源根来自 RPC 返回的 `resource_root` |
| 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info` 和 CDN path,组织给客户端/补丁器使用 |
| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引,只展示 ready、RPC 健康和 release;需要拉取/修复时由外部运维调用 `bat` 或 RPC 任务 |
| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引;认证 Web 控制面仅白名单转发 reload/refresh/restart/sync/verify/repair/catalog-refresh,不持有或写入同步状态 |
这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。
@@ -65,17 +65,19 @@
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
| I | 真数据在**已全量拉取且长期运行 Rust `bat` 的远程服务器**;开发机不跑全量 `bat`,用 fixture、mock RPC 和 Go 门禁验证;远程联调等连接信息 |
| J | 索引以 **manifest + 磁盘 Present/size** 为准 |
| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`bat-api` 只读消费,不自行推导同步状态 |
### 进程配置
| ID | 约定 |
|---|---|
| K | `.env` / 环境变量 / CLI:端口、public base、RPC socket、RPC 刷新周期;**预留** database/redis |
| L | 管理面 / bootstrap`/healthz``/readyz``/v1/bootstrap``/v1/release``/v1/resources``/openapi.yaml``/admin/` 预留 |
| L | 管理面 / bootstrap`/healthz``/readyz``/v1/bootstrap``/v1/release``/v1/resources``/openapi.yaml``/admin/` 控制入口 |
| M | CDN`GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...`,支持 Range、ETag、Last-Modified、长期缓存头 |
| N | server-info 可选;**只改 AddressablesCatalogUrlRoot** |
| N2 | launcher 兼容仅限资源引导:`/v1/launcher/bootstrap``/api/launcher/...` 形状端点输出已发布 release、launcher metadata 和 GameMainConfig 摘要;不下载 launcher 包、不生成完整 PC package update manifest、不仿造登录/网关 |
| N3 | 玩家-facing HTTP 控制面:可配置 token 鉴权、进程内限流、访问日志、反代 IP 适配、动态 JSON `no-store``/v1/resources` 分页上限 |
| N4 | `/admin/control/{action}` 白名单控制面;`restart` 通过 Rust live RPC 启动 lifecycle controllerGo 不直接执行 `bat` binary |
### 工程
@@ -84,7 +86,7 @@
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
| Q | 空目录标明 reserved empty |
| R | 默认门禁:`make test-go-api` + `make build-go-api`(无 FFI |
| R | 默认门禁:`make test-go-api` + `make build-go-api` + `make check-docs`(无 FFI |
---
@@ -93,8 +95,8 @@
| 组件 | 路径 | 状态 | 说明 |
|---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPCfake transport 单测 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理面预留 + `.env` |
| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPC覆盖 daemon restart、resource/catalog/task、parse/localized 和文件级 UnityFS patch 调用;fake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + `.env` |
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
| 空骨架 | `api/``pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
@@ -117,6 +119,7 @@
make test-go-api
make build-go-api
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
make check-docs
# 可选:改 FFI 或试验 CLI 时
make build-ffi
@@ -131,7 +134,7 @@ make build-go-cli # 产出 bin/bat-go
| 项 | 状态 |
|---|---|
| G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat` |
| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理面预留和部署模板,后续远程服务器联调/可选持久化 |
| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单和部署模板,后续远程服务器联调/可选持久化 |
| issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** |
| G-010 Web | 未开始 |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
pub(super) fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> {
match options.command {
CliCommand::PatchApply => {
let params = patch_apply_params_from_options(options)?;
let report = apply_patch_file(&params)?;
print_report(options.output_format, &report)
}
CliCommand::UnityFsPatchTextAsset => {
let params = unityfs_text_asset_params_from_options(options)?;
let report = apply_unityfs_text_asset_patch_file(&params)?;
print_report(options.output_format, &report)
}
CliCommand::UnityFsPatchStringField => {
let params = unityfs_string_field_params_from_options(options)?;
let report = apply_unityfs_string_field_patch_file(&params)?;
print_report(options.output_format, &report)
}
CliCommand::UnityFsPatchField => {
let params = unityfs_field_params_from_options(options)?;
let report = apply_unityfs_field_patch_file(&params)?;
print_report(options.output_format, &report)
}
_ => Err(anyhow::anyhow!("不是写入 patch 命令")),
}
}
pub(super) fn is_write_patch_command(command: CliCommand) -> bool {
matches!(
command,
CliCommand::PatchApply
| CliCommand::UnityFsPatchTextAsset
| CliCommand::UnityFsPatchStringField
| CliCommand::UnityFsPatchField
)
}
pub(super) fn validate_write_patch_options(options: &CliOptions) -> anyhow::Result<()> {
match options.command {
CliCommand::PatchApply => {
let _ = patch_apply_params_from_options(options)?;
reject_unityfs_write_options(options, "patch-apply")?;
}
CliCommand::UnityFsPatchTextAsset => {
let _ = unityfs_text_asset_params_from_options(options)?;
reject_patch_apply_options(options, "unityfs-patch-text-asset")?;
if options.unityfs_field_path.is_some()
|| options.unityfs_replacement_text.is_some()
|| options.unityfs_expected_value.is_some()
{
return Err(anyhow::anyhow!(
"unityfs-patch-text-asset 不接受 --field-path、--string-field-path、--replacement-text 或 --expected-value"
));
}
}
CliCommand::UnityFsPatchStringField => {
let _ = unityfs_string_field_params_from_options(options)?;
reject_patch_apply_options(options, "unityfs-patch-string-field")?;
if options.unityfs_expected_name.is_some() {
return Err(anyhow::anyhow!(
"unityfs-patch-string-field 不接受 --expected-name"
));
}
}
CliCommand::UnityFsPatchField => {
let _ = unityfs_field_params_from_options(options)?;
reject_patch_apply_options(options, "unityfs-patch-field")?;
if options.unityfs_expected_name.is_some()
|| options.unityfs_replacement_text.is_some()
|| options.unityfs_expected_value.is_some()
{
return Err(anyhow::anyhow!(
"unityfs-patch-field 不接受 --expected-name、--replacement-text 或 --expected-value;请使用 --replacement-json / --expected-json"
));
}
}
_ => {}
}
Ok(())
}
fn patch_apply_params_from_options(options: &CliOptions) -> anyhow::Result<PatchApplyParams> {
Ok(PatchApplyParams {
kind: require_cli_option(options.patch_kind, "--patch-kind")?,
source_path: require_cli_option(options.patch_source_path.clone(), "--source-file")?,
patch_path: require_cli_option(options.patch_patch_path.clone(), "--patch-file")?,
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
})
}
fn unityfs_text_asset_params_from_options(
options: &CliOptions,
) -> anyhow::Result<UnityFsTextAssetPatchParams> {
Ok(UnityFsTextAssetPatchParams {
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
serialized_file_path: require_cli_option(
options.unityfs_serialized_file_path.clone(),
"--serialized-file",
)?,
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
replacement_path: require_cli_option(
options.unityfs_replacement_path.clone(),
"--replacement-file",
)?,
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
expected_name: options.unityfs_expected_name.clone(),
})
}
fn unityfs_string_field_params_from_options(
options: &CliOptions,
) -> anyhow::Result<UnityFsStringFieldPatchParams> {
let has_replacement_text = options.unityfs_replacement_text.is_some();
let has_replacement_path = options.unityfs_replacement_path.is_some();
if has_replacement_text == has_replacement_path {
return Err(anyhow::anyhow!(
"unityfs-patch-string-field 必须且只能指定 --replacement-text 或 --replacement-file 其中一个"
));
}
Ok(UnityFsStringFieldPatchParams {
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
serialized_file_path: require_cli_option(
options.unityfs_serialized_file_path.clone(),
"--serialized-file",
)?,
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
field_path: require_cli_option(
options.unityfs_field_path.clone(),
"--field-path/--string-field-path",
)?,
replacement_text: options.unityfs_replacement_text.clone(),
replacement_path: options.unityfs_replacement_path.clone(),
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
expected_value: options.unityfs_expected_value.clone(),
})
}
fn unityfs_field_params_from_options(
options: &CliOptions,
) -> anyhow::Result<UnityFsFieldPatchParams> {
if options.unityfs_replacement_path.is_some() {
return Err(anyhow::anyhow!(
"unityfs-patch-field 不接受 --replacement-file;请使用 --replacement-json"
));
}
Ok(UnityFsFieldPatchParams {
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
serialized_file_path: require_cli_option(
options.unityfs_serialized_file_path.clone(),
"--serialized-file",
)?,
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
field_path: require_cli_option(
options.unityfs_field_path.clone(),
"--field-path/--string-field-path",
)?,
replacement: require_cli_option(
options.unityfs_replacement_value.clone(),
"--replacement-json",
)?,
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
expected_value: options.unityfs_expected_semantic_value.clone(),
})
}
fn require_cli_option<T>(value: Option<T>, name: &str) -> anyhow::Result<T> {
value.ok_or_else(|| anyhow::anyhow!("缺少必要参数 {name}"))
}
fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Result<()> {
if options.patch_kind.is_some()
|| options.patch_source_path.is_some()
|| options.patch_patch_path.is_some()
{
return Err(anyhow::anyhow!(
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
));
}
Ok(())
}
fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::Result<()> {
if options.unityfs_bundle_path.is_some()
|| options.unityfs_serialized_file_path.is_some()
|| options.unityfs_path_id.is_some()
|| options.unityfs_field_path.is_some()
|| options.unityfs_replacement_path.is_some()
|| options.unityfs_replacement_text.is_some()
|| options.unityfs_expected_name.is_some()
|| options.unityfs_expected_value.is_some()
|| options.unityfs_replacement_value.is_some()
|| options.unityfs_expected_semantic_value.is_some()
{
return Err(anyhow::anyhow!(
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
));
}
Ok(())
}
use super::*;
@@ -0,0 +1,271 @@
use super::*;
pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> {
run_readonly_query_command_with_rpc(options, daemon_rpc_available, daemon_rpc_call)
}
pub(super) fn run_readonly_query_command_with_rpc(
options: &CliOptions,
rpc_available: impl Fn(&Path) -> bool,
rpc_call: impl Fn(&Path, &str, Option<serde_json::Value>) -> anyhow::Result<serde_json::Value>,
) -> anyhow::Result<()> {
let method = readonly_query_rpc_method(options.command)
.ok_or_else(|| anyhow::anyhow!("不是只读查询命令"))?;
if rpc_available(&options.state_dir) && !readonly_query_requires_local_config(options) {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let report = rpc_call(
&options.state_dir,
method,
readonly_query_rpc_params(options),
)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
let report = build_readonly_query_report(options, method)?;
print_json_value(options.output_format, &report)
}
fn readonly_query_rpc_method(command: CliCommand) -> Option<&'static str> {
match command {
CliCommand::ParseStatus => Some(RPC_METHOD_PARSE_STATUS),
CliCommand::ParseTextUnits => Some(RPC_METHOD_PARSE_TEXT_UNITS),
CliCommand::ParseErrors => Some(RPC_METHOD_PARSE_ERRORS),
CliCommand::TranslationTasks => Some(RPC_METHOD_TRANSLATION_TASKS),
CliCommand::TranslationHandoff => Some(RPC_METHOD_TRANSLATION_HANDOFF),
CliCommand::LocalizedStatus => Some(RPC_METHOD_LOCALIZED_STATUS),
CliCommand::ResourceIndex => Some(RPC_METHOD_RESOURCE_INDEX),
_ => None,
}
}
fn readonly_query_rpc_params(options: &CliOptions) -> Option<serde_json::Value> {
let mut params = serde_json::Map::new();
match options.command {
CliCommand::ResourceIndex
| CliCommand::ParseTextUnits
| CliCommand::ParseErrors
| CliCommand::TranslationTasks => {
params.insert(
"offset".to_string(),
serde_json::json!(options.query_offset),
);
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
}
_ => return None,
}
match options.command {
CliCommand::ResourceIndex => {
if let Some(resource_type) = options.query_resource_type {
params.insert(
"resource_type".to_string(),
serde_json::json!(resource_type_rpc_label(resource_type)),
);
}
if let Some(hash) = options.query_hash.as_ref() {
params.insert("hash".to_string(), serde_json::json!(hash));
}
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
}
if let Some(release_id) = options.query_official_release_id.as_ref() {
params.insert(
"official_release_id".to_string(),
serde_json::json!(release_id),
);
}
if let Some(platform) = options.query_platform.as_ref() {
params.insert("platform".to_string(), serde_json::json!(platform));
}
if let Some(destination) = options.query_destination.as_ref() {
params.insert("destination".to_string(), serde_json::json!(destination));
}
if let Some(bundle_path) = options.query_bundle_path.as_ref() {
params.insert("bundle_path".to_string(), serde_json::json!(bundle_path));
}
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
params.insert(
"archive_entry".to_string(),
serde_json::json!(archive_entry),
);
}
if let Some(parse_status) = options.query_parse_status.as_ref() {
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
}
if let Some(format) = options.query_format.as_ref() {
params.insert("text_unit_format".to_string(), serde_json::json!(format));
}
}
CliCommand::ParseTextUnits | CliCommand::ParseErrors => {
if let Some(destination) = options.query_destination.as_ref() {
params.insert("destination".to_string(), serde_json::json!(destination));
}
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
}
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
params.insert(
"archive_entry".to_string(),
serde_json::json!(archive_entry),
);
}
if let Some(path_id) = options.query_path_id {
params.insert("path_id".to_string(), serde_json::json!(path_id));
}
if let Some(class_id) = options.query_class_id {
params.insert("class_id".to_string(), serde_json::json!(class_id));
}
if let Some(field_path) = options.query_field_path.as_ref() {
params.insert("field_path".to_string(), serde_json::json!(field_path));
}
if let Some(format) = options.query_format.as_ref() {
params.insert("format".to_string(), serde_json::json!(format));
}
}
CliCommand::TranslationTasks => {
if let Some(task_id) = options.query_task_id.as_ref() {
params.insert("task_id".to_string(), serde_json::json!(task_id));
}
if let Some(release_id) = options.query_official_release_id.as_ref() {
params.insert(
"official_release_id".to_string(),
serde_json::json!(release_id),
);
}
if let Some(destination) = options.query_destination.as_ref() {
params.insert("destination".to_string(), serde_json::json!(destination));
}
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
}
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
params.insert(
"archive_entry".to_string(),
serde_json::json!(archive_entry),
);
}
if let Some(status) = options.query_task_status.as_ref() {
params.insert("status".to_string(), serde_json::json!(status));
}
if let Some(status) = options.query_worker_status.as_ref() {
params.insert("worker_status".to_string(), serde_json::json!(status));
}
if let Some(parse_status) = options.query_parse_status.as_ref() {
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
}
if let Some(format) = options.query_format.as_ref() {
params.insert("text_unit_format".to_string(), serde_json::json!(format));
}
if let Some(has_reason) = options.query_has_reason {
params.insert("has_reason".to_string(), serde_json::json!(has_reason));
}
if let Some(has_failure_reason) = options.query_has_failure_reason {
params.insert(
"has_failure_reason".to_string(),
serde_json::json!(has_failure_reason),
);
}
}
_ => {}
}
Some(serde_json::Value::Object(params))
}
fn readonly_query_requires_local_config(options: &CliOptions) -> bool {
matches!(options.command, CliCommand::ResourceIndex)
&& options.config.import_resource_repository_path.is_some()
}
fn build_readonly_query_report(
options: &CliOptions,
method: &str,
) -> anyhow::Result<serde_json::Value> {
match method {
RPC_METHOD_PARSE_STATUS => build_parse_status_report(&options.state_dir),
RPC_METHOD_PARSE_TEXT_UNITS => build_parse_text_units_report(
&options.state_dir,
textunit_query_from_options(options),
options.query_offset,
options.query_limit,
),
RPC_METHOD_PARSE_ERRORS => build_parse_errors_report(
&options.state_dir,
textunit_query_from_options(options),
options.query_offset,
options.query_limit,
),
RPC_METHOD_TRANSLATION_TASKS => build_translation_tasks_report(
&options.state_dir,
translation_task_query_from_options(options),
options.query_offset,
options.query_limit,
),
RPC_METHOD_TRANSLATION_HANDOFF => build_translation_handoff_report(&options.state_dir),
RPC_METHOD_LOCALIZED_STATUS => {
build_localized_status_report(&options.state_dir, &options.config)
}
RPC_METHOD_RESOURCE_INDEX => build_resource_index_report(
&options.state_dir,
&options.config,
resource_index_query_from_options(options),
options.query_offset,
options.query_limit,
),
_ => Err(anyhow::anyhow!("不支持的只读查询方法:{method}")),
}
}
pub(super) fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> {
let has_resource_index_only_filter = options.query_resource_type.is_some()
|| options.query_hash.is_some()
|| options.query_platform.is_some()
|| options.query_bundle_path.is_some();
let has_parse_object_filter = options.query_path_id.is_some()
|| options.query_class_id.is_some()
|| options.query_field_path.is_some();
let has_translation_task_filter = options.query_task_id.is_some()
|| options.query_task_status.is_some()
|| options.query_worker_status.is_some()
|| options.query_has_reason.is_some()
|| options.query_has_failure_reason.is_some();
match options.command {
CliCommand::ResourceIndex => {
if has_parse_object_filter || has_translation_task_filter {
return Err(anyhow::anyhow!(
"--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
));
}
}
CliCommand::ParseTextUnits | CliCommand::ParseErrors => {
if has_resource_index_only_filter
|| has_translation_task_filter
|| options.query_official_release_id.is_some()
|| options.query_parse_status.is_some()
{
return Err(anyhow::anyhow!(
"--resource-type/--hash/--release-id/--platform/--bundle-path/--parse-status 只适用于 resource-index 或 translation-tasks--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
));
}
}
CliCommand::TranslationTasks => {
if has_resource_index_only_filter || has_parse_object_filter {
return Err(anyhow::anyhow!(
"--resource-type/--hash/--platform/--bundle-path 只适用于 resource-index--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors"
));
}
}
CliCommand::TranslationHandoff if options.query_option_explicit => {
return Err(anyhow::anyhow!(
"translation-handoff 不接受查询过滤参数;请使用 translation-tasks 查询单项任务"
));
}
CliCommand::ParseStatus | CliCommand::LocalizedStatus if options.query_option_explicit => {
return Err(anyhow::anyhow!(
"查询过滤参数只适用于 resource-index、parse-text-units、parse-errors 或 translation-tasks"
));
}
_ => {}
}
Ok(())
}
+587
View File
@@ -0,0 +1,587 @@
use super::*;
pub(super) const MAX_RETAINED_TASKS: usize = 64;
/// 每个任务保留的进度日志行数上限。
pub(super) const MAX_TASK_LOG_LINES: usize = 200;
/// 任务类型:目前覆盖官方同步、校验与 catalog 更新检查。
#[derive(Debug, Clone, Copy)]
pub(super) enum TaskKind {
Sync,
Verify,
Repair,
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
Refresh,
}
impl TaskKind {
pub(super) fn method(self) -> &'static str {
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,
}
}
/// 由 daemon 基准配置派生该任务的实际同步配置。
pub(super) fn build_config(
self,
base: &OfficialUpdateConfig,
force: bool,
) -> OfficialUpdateConfig {
let mut config = base.clone();
match self {
Self::Sync => {
config.dry_run = false;
config.force = config.force || force;
}
Self::Verify => {
config.dry_run = true;
config.plan = true;
config.audit_local = true;
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;
config.audit_local = false;
config.repair = false;
config.force = force;
}
}
config
}
}
/// 请求取消任务的结果。
pub(super) enum CancelOutcome {
Requested,
AlreadyFinished,
NotFound,
}
/// 单个任务的可轮询记录。
#[derive(Debug, Clone, Serialize)]
pub(super) struct TaskRecord {
pub(super) id: String,
pub(super) kind: &'static str,
/// `queued` | `running` | `succeeded` | `failed` | `cancelled`。
pub(super) status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) stage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) message: Option<String>,
pub(super) created_at: u64,
pub(super) updated_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) started_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) finished_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) error: Option<ApiError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) result: Option<serde_json::Value>,
/// 取消标志,worker 的 should_cancel 检查它;不参与序列化。
#[serde(skip)]
pub(super) cancel: Arc<AtomicBool>,
/// 进度日志(有界),经 task.logs 返回;不参与 task.status 序列化。
#[serde(skip)]
pub(super) log: Vec<String>,
}
impl TaskRecord {
pub(super) fn is_finished(&self) -> bool {
matches!(self.status, "succeeded" | "failed" | "cancelled")
}
}
struct TaskStore {
tasks: HashMap<String, TaskRecord>,
order: Vec<String>,
seq: u64,
/// 任务历史持久化文件路径;`None` 表示纯内存(测试等非 daemon 场景)。
persist_path: Option<PathBuf>,
}
/// daemon 任务历史持久化文件名(位于 state dir 内,`0600` 原子写)。
pub(super) const TASKS_FILE_NAME: &str = "bat-tasks.json";
/// 任务历史文件结构版本。
pub(super) const TASKS_FILE_VERSION: u32 = 1;
/// 任务历史文件的持久化形态(版本化;daemon 重启后恢复任务历史用)。
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct PersistedTaskFile {
pub(super) version: u32,
/// 任务 ID 序号计数器;恢复它避免 pid 复用时新任务与历史任务撞 ID。
pub(super) seq: u64,
pub(super) tasks: Vec<PersistedTaskRecord>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct PersistedTaskRecord {
id: String,
kind: String,
pub(super) status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
stage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
message: Option<String>,
created_at: u64,
updated_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
started_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
finished_at: Option<u64>,
/// `ApiError` 的序列化形态(code/kind/domain/location/message/retryable)。
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
log: Vec<String>,
}
/// 把持久化的任务类型映射回静态字符串;未识别(如未来版本新增)返回 `None`。
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,
}
}
/// 把持久化的任务状态映射回静态字符串;未识别返回 `None`。
fn task_status_static(status: &str) -> Option<&'static str> {
match status {
"queued" => Some("queued"),
"running" => Some("running"),
"succeeded" => Some("succeeded"),
"failed" => Some("failed"),
"cancelled" => Some("cancelled"),
_ => None,
}
}
impl PersistedTaskRecord {
fn from_record(record: &TaskRecord) -> Self {
Self {
id: record.id.clone(),
kind: record.kind.to_string(),
status: record.status.to_string(),
stage: record.stage.clone(),
message: record.message.clone(),
created_at: record.created_at,
updated_at: record.updated_at,
started_at: record.started_at,
finished_at: record.finished_at,
error: record
.error
.as_ref()
.and_then(|error| serde_json::to_value(error).ok()),
result: record.result.clone(),
log: record.log.clone(),
}
}
/// 还原为内存任务记录;kind/status 未识别时返回 `None`(调用方计数跳过)。
fn into_record(self) -> Option<TaskRecord> {
let kind = task_kind_static(&self.kind)?;
let status = task_status_static(&self.status)?;
// 错误从序列化形态还原:code 经码表反查(未登记回退 internal),
// location 固定为任务执行器(当前全部任务错误的唯一来源)。
let error = self.error.as_ref().map(|value| {
let code = value
.get("code")
.and_then(serde_json::Value::as_str)
.and_then(ErrorCode::from_id)
.unwrap_or(ErrorCode::INTERNAL);
let message = value
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("<持久化错误信息缺失>")
.to_string();
ApiError::new(code, "task.executor", message)
});
Some(TaskRecord {
id: self.id,
kind,
status,
stage: self.stage,
message: self.message,
created_at: self.created_at,
updated_at: self.updated_at,
started_at: self.started_at,
finished_at: self.finished_at,
error,
result: self.result,
cancel: Arc::new(AtomicBool::new(false)),
log: self.log,
})
}
}
/// 读取任务历史文件。文件缺失返回 `Ok(None)`;symlink、解析失败或版本不支持返回 `Err`。
fn load_persisted_tasks(path: &Path) -> Result<Option<PersistedTaskFile>, String> {
let Some(bytes) = read_file_no_symlink(path, "任务历史")? else {
return Ok(None);
};
let file: PersistedTaskFile = serde_json::from_slice(&bytes)
.map_err(|error| format!("解析任务历史失败 {}{error}", path.display()))?;
if file.version != TASKS_FILE_VERSION {
return Err(format!(
"不支持的任务历史版本 {},文件 {}",
file.version,
path.display()
));
}
Ok(Some(file))
}
/// 任务注册表句柄:包住内存存储,供 RPC handler 与 worker 共享。
///
/// 通过方法访问(而非直接摸内部 map),便于将来换成 Redis 等持久化后端。
#[derive(Clone)]
pub(super) struct TaskRegistry {
inner: Arc<Mutex<TaskStore>>,
}
impl TaskRegistry {
/// 纯内存注册表(无持久化);生产 daemon 走 [`Self::with_persistence`]。
#[cfg(test)]
pub(super) fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(TaskStore {
tasks: HashMap::new(),
order: Vec::new(),
seq: 0,
persist_path: None,
})),
}
}
/// 从 state dir 恢复任务历史并启用持久化。
///
/// 中断时仍处于 queued/running 的任务标记为 `failed``TASK_INTERRUPTED`);
/// 文件缺失按空历史处理;文件损坏或版本不支持时改名 `.corrupt` 留证并从
/// 空历史开始。返回注册表与恢复摘要(供 daemon 日志记录)。
pub(super) fn with_persistence(state_dir: &Path) -> (Self, String) {
let path = state_dir.join(TASKS_FILE_NAME);
let now = unix_seconds_now();
let mut seq = 0;
let mut tasks = HashMap::new();
let mut order = Vec::new();
let summary = match load_persisted_tasks(&path) {
Ok(None) => "无历史任务文件,从空任务历史开始".to_string(),
Ok(Some(file)) => {
seq = file.seq;
let total = file.tasks.len();
let mut interrupted = 0usize;
let mut skipped = 0usize;
for persisted in file.tasks {
let Some(mut record) = persisted.into_record() else {
skipped += 1;
continue;
};
if !record.is_finished() {
interrupted += 1;
record.status = "failed";
record.finished_at = Some(now);
record.updated_at = now;
record.error = Some(ApiError::new(
ErrorCode::TASK_INTERRUPTED,
"task.executor",
"daemon 停止/重启导致任务中断",
));
record
.log
.push("[daemon] 任务因 daemon 停止/重启而中断".to_string());
}
if tasks.insert(record.id.clone(), record.clone()).is_none() {
order.push(record.id);
} else {
skipped += 1;
}
}
format!("恢复任务历史 {total} 条(标记中断 {interrupted} 条,跳过无法识别 {skipped} 条)")
}
Err(error) => {
// 保留损坏文件供诊断(改名而非覆盖),从空历史开始。
let corrupt = path.with_extension("json.corrupt");
if fs::rename(&path, &corrupt).is_ok() {
format!(
"任务历史不可用({error});原文件已改名保留为 {}",
corrupt.display()
)
} else {
format!("任务历史不可用({error});且无法改名保留原文件")
}
}
};
let registry = Self {
inner: Arc::new(Mutex::new(TaskStore {
tasks,
order,
seq,
persist_path: Some(path),
})),
};
// 把中断标记(或空历史)立即写回,保证文件与内存视图一致。
registry.lock().persist();
(registry, summary)
}
fn lock(&self) -> std::sync::MutexGuard<'_, TaskStore> {
self.inner
.lock()
.unwrap_or_else(|poison| poison.into_inner())
}
/// 创建 queued 任务并返回 task_id。
pub(super) fn create(&self, kind: TaskKind) -> String {
let now = unix_seconds_now();
let mut store = self.lock();
store.seq += 1;
let id = format!("task-{}-{}", std::process::id(), store.seq);
let record = TaskRecord {
id: id.clone(),
kind: kind.method(),
status: "queued",
stage: None,
message: None,
created_at: now,
updated_at: now,
started_at: None,
finished_at: None,
error: None,
result: None,
cancel: Arc::new(AtomicBool::new(false)),
log: Vec::new(),
};
store.tasks.insert(id.clone(), record);
store.order.push(id.clone());
store.prune();
store.persist();
id
}
pub(super) fn update<F: FnOnce(&mut TaskRecord)>(&self, id: &str, update: F) {
let mut store = self.lock();
let mut status_changed = false;
if let Some(record) = store.tasks.get_mut(id) {
let previous_status = record.status;
update(record);
record.updated_at = unix_seconds_now();
status_changed = record.status != previous_status;
}
// 只在生命周期转换时落盘;stage/message/log 的高频进度更新以内存为准,
// 随下一次转换一起写入(避免每个进度事件一次磁盘写)。
if status_changed {
store.persist();
}
}
pub(super) fn get(&self, id: &str) -> Option<TaskRecord> {
self.lock().tasks.get(id).cloned()
}
/// 返回任务的取消标志(与 worker 共享同一 Arc)。
pub(super) fn cancel_flag(&self, id: &str) -> Option<Arc<AtomicBool>> {
self.lock()
.tasks
.get(id)
.map(|record| Arc::clone(&record.cancel))
}
/// 追加一行进度日志,超出上限时丢弃最旧的。
pub(super) fn append_log(&self, id: &str, line: String) {
let mut store = self.lock();
if let Some(record) = store.tasks.get_mut(id) {
record.log.push(line);
if record.log.len() > MAX_TASK_LOG_LINES {
let overflow = record.log.len() - MAX_TASK_LOG_LINES;
record.log.drain(0..overflow);
}
}
}
/// 返回任务的进度日志。
pub(super) fn logs(&self, id: &str) -> Option<Vec<String>> {
self.lock().tasks.get(id).map(|record| record.log.clone())
}
/// 请求取消任务:未结束的置取消标志,已结束的原样返回,不存在返回 NotFound。
pub(super) fn request_cancel(&self, id: &str) -> CancelOutcome {
let store = self.lock();
match store.tasks.get(id) {
None => CancelOutcome::NotFound,
Some(record) if record.is_finished() => CancelOutcome::AlreadyFinished,
Some(record) => {
record.cancel.store(true, Ordering::Relaxed);
CancelOutcome::Requested
}
}
}
/// 返回全部任务,最新创建的在前。
pub(super) fn list(&self) -> Vec<TaskRecord> {
let store = self.lock();
store
.order
.iter()
.rev()
.filter_map(|id| store.tasks.get(id).cloned())
.collect()
}
}
impl TaskStore {
/// 把当前任务历史落盘(`0600` 原子写、不跟随 symlink)。
///
/// 持久化未启用时为 no-op;写失败只记 stderr(进 daemon 日志),
/// 不让持久化故障拖垮任务执行本身。
fn persist(&self) {
let Some(path) = &self.persist_path else {
return;
};
let file = PersistedTaskFile {
version: TASKS_FILE_VERSION,
seq: self.seq,
tasks: self
.order
.iter()
.filter_map(|id| self.tasks.get(id))
.map(PersistedTaskRecord::from_record)
.collect(),
};
match serde_json::to_vec_pretty(&file) {
Ok(bytes) => {
if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史")
{
eprintln!("[daemon] 任务历史落盘失败:{error}");
}
}
Err(error) => eprintln!("[daemon] 任务历史序列化失败:{error}"),
}
}
/// 裁剪最旧的已结束任务,把内存占用控制在上限内;运行中/排队中的任务不裁剪。
fn prune(&mut self) {
while self.order.len() > MAX_RETAINED_TASKS {
let Some(position) = self.order.iter().position(|id| {
self.tasks
.get(id)
.map(TaskRecord::is_finished)
.unwrap_or(true)
}) else {
break;
};
let id = self.order.remove(position);
self.tasks.remove(&id);
}
}
}
/// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。
pub(super) struct TaskJob {
pub(super) id: String,
pub(super) config: OfficialUpdateConfig,
/// 与任务记录共享的取消标志。
pub(super) cancel: Arc<AtomicBool>,
}
/// daemon 任务上下文:RPC handler 借它创建任务、入队和读取。
#[derive(Clone)]
pub(super) struct DaemonTaskContext {
pub(super) registry: TaskRegistry,
pub(super) queue: mpsc::Sender<TaskJob>,
pub(super) base_config: OfficialUpdateConfig,
pub(super) restart_controller: DaemonRestartController,
}
pub(super) type DaemonRestartController = fn(&Path) -> anyhow::Result<u32>;
/// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。
///
/// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败);
/// 进度写入任务记录;`should_cancel` 接 daemon 停止标志,停机时中止在途任务。
pub(super) fn run_task_worker(
receiver: mpsc::Receiver<TaskJob>,
registry: TaskRegistry,
sync_lock: Arc<Mutex<()>>,
control: DaemonControl,
) {
let service = OfficialUpdateService::new();
for job in receiver {
registry.update(&job.id, |record| {
record.status = "running";
record.started_at = Some(unix_seconds_now());
});
let cancel = Arc::clone(&job.cancel);
let run_result = {
let _sync_guard = sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let progress_registry = registry.clone();
let progress_id = job.id.clone();
let cancel_check = Arc::clone(&cancel);
let stop_control = Arc::clone(&control);
service.run_with_progress_and_cancellation(
&job.config,
|event| {
progress_registry
.append_log(&progress_id, format!("[{}] {}", event.stage, event.message));
progress_registry.update(&progress_id, |record| {
record.stage = Some(event.stage.to_string());
record.message = Some(event.message.clone());
});
},
|| {
cancel_check.load(Ordering::Relaxed)
|| daemon_control_stop_requested(Some(&stop_control))
},
)
};
match run_result {
Ok(report) => registry.update(&job.id, |record| {
record.status = "succeeded";
record.finished_at = Some(unix_seconds_now());
record.result = serde_json::to_value(&report).ok();
}),
Err(error) => {
let cancelled = cancel.load(Ordering::Relaxed);
// 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。
let code = error
.downcast_ref::<bat_infrastructure::DownloadError>()
.map(bat_infrastructure::DownloadError::code)
.unwrap_or(ErrorCode::INTERNAL);
registry.update(&job.id, |record| {
record.finished_at = Some(unix_seconds_now());
if cancelled {
record.status = "cancelled";
record.error = Some(ApiError::new(
ErrorCode::INTERNAL,
"task.executor",
"任务已取消",
));
} else {
record.status = "failed";
record.error =
Some(ApiError::new(code, "task.executor", error.to_string()));
}
});
}
}
}
}
@@ -0,0 +1,232 @@
use super::*;
pub(super) fn build_translation_tasks_report(
state_dir: &Path,
query: OfficialTextUnitTaskQuery,
offset: usize,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let Some(record) = current else {
return Ok(serde_json::json!({ "available": false }));
};
let task_queue_path = record
.resource_root
.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
let task_repository_path =
SqliteTranslationTaskRepository::repository_path(&record.resource_root);
let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(&record.resource_root)
.map_err(anyhow::Error::msg)?
else {
return Ok(serde_json::json!({
"available": false,
"current_version_id": record.id,
"resource_root": record.resource_root,
"textunit_task_queue_path": task_queue_path,
"task_repository_path": task_repository_path,
"task_repository_available": false,
}));
};
let task_repository_available =
sqlite_file_exists_no_symlink(&task_repository_path, "翻译任务状态数据库")?;
let (total_entries, entries) = if task_repository_available {
query_translation_task_repository(&task_repository_path, &query, offset, limit)?
} else {
let mut queue_query = query.clone();
queue_query.task_status = None;
queue_query.has_failure_reason = None;
let matches = bat_infrastructure::query_textunit_tasks(&queue, &queue_query);
let persisted = matches
.into_iter()
.cloned()
.map(|task| {
bat_infrastructure::PersistedTranslationTask::from_queued_task(
task,
queue.generated_unix_seconds,
)
})
.filter(|task| {
query
.task_status
.as_ref()
.is_none_or(|status| task.task_status.as_str() == status)
})
.filter(|task| {
query
.has_failure_reason
.is_none_or(|has_reason| task.failure_reason.is_some() == has_reason)
})
.collect::<Vec<_>>();
let total_entries = persisted.len();
let entries = persisted
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
(total_entries as u64, entries)
};
Ok(serde_json::json!({
"available": true,
"current_version_id": record.id,
"resource_root": record.resource_root,
"textunit_task_queue_path": task_queue_path,
"task_repository_path": task_repository_path,
"task_repository_available": task_repository_available,
"task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION,
"summary": queue.summary,
"total_entries": total_entries,
"offset": offset,
"limit": limit,
"query": translation_task_query_json(&query),
"entries": entries,
}))
}
pub(super) fn build_translation_handoff_report(
state_dir: &Path,
) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let Some(record) = current else {
return Ok(serde_json::json!({ "available": false }));
};
let resource_root = &record.resource_root;
let task_queue_path = resource_root.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
let handoff_path = resource_root.join(bat_infrastructure::TRANSLATION_HANDOFF_FILE);
let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root);
let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(resource_root)
.map_err(anyhow::Error::msg)?
else {
return Ok(serde_json::json!({
"available": false,
"current_version_id": record.id,
"resource_root": resource_root,
"textunit_task_queue_path": task_queue_path,
"translation_handoff_path": handoff_path,
"task_repository_path": repository_path,
}));
};
let task_repository_available =
sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")?;
let tasks = if task_repository_available {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
let repository = SqliteTranslationTaskRepository::open(&repository_path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.list(&OfficialTextUnitTaskQuery::default())
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?
} else {
queue
.tasks
.iter()
.cloned()
.map(|task| {
bat_infrastructure::PersistedTranslationTask::from_queued_task(
task,
queue.generated_unix_seconds,
)
})
.collect::<Vec<_>>()
};
let handoff = bat_infrastructure::build_translation_handoff(&queue, &tasks);
let handoff_file_available = sqlite_file_exists_no_symlink(&handoff_path, "翻译 handoff")?;
Ok(serde_json::json!({
"available": true,
"current_version_id": record.id,
"resource_root": resource_root,
"textunit_task_queue_path": task_queue_path,
"translation_handoff_path": handoff_path,
"translation_handoff_file_available": handoff_file_available,
"task_repository_path": repository_path,
"task_repository_available": task_repository_available,
"task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION,
"handoff_schema_version": bat_infrastructure::TRANSLATION_HANDOFF_SCHEMA_VERSION,
"handoff": handoff,
}))
}
pub(super) fn update_translation_task_status_report(
state_dir: &Path,
params: Option<&serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
let task_id = rpc_string_param(params, "task_id")
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
let status_label = rpc_string_param(params, "status")
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 status"))?;
let status = TranslationTaskStatus::parse(status_label)
.ok_or_else(|| anyhow::anyhow!("不支持的翻译任务 worker 状态:{status_label}"))?;
let failure_reason = rpc_string_param(params, "failure_reason")
.or_else(|| rpc_string_param(params, "reason"))
.map(str::to_string);
let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string);
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
.ok_or_else(|| anyhow::anyhow!("没有可更新翻译任务的当前官方 release"))?;
let repository_path = SqliteTranslationTaskRepository::repository_path(&current.resource_root);
if !sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")? {
return Err(anyhow::anyhow!(
"翻译任务状态数据库不存在:{}",
repository_path.display()
));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let task = runtime.block_on(async {
let repository = SqliteTranslationTaskRepository::open(&repository_path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.update_status(task_id, status, failure_reason, provider_run_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"current_version_id": current.id,
"task_repository_path": repository_path,
"entry": task,
}))
}
pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::Value {
serde_json::json!({
"destination": query.destination.clone(),
"path_pattern": query.path_pattern.clone(),
"archive_entry": query.archive_entry.clone(),
"path_id": query.path_id,
"class_id": query.class_id,
"field_path": query.field_path.clone(),
"format": query.format.clone(),
})
}
pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value {
serde_json::json!({
"task_id": query.task_id.clone(),
"official_release_id": query.official_release_id.clone(),
"destination": query.destination.clone(),
"path_pattern": query.path_pattern.clone(),
"archive_entry": query.archive_entry.clone(),
"status": query.status.clone(),
"task_status": query.task_status.clone(),
"parse_status": query.parse_status.clone(),
"text_unit_format": query.text_unit_format.clone(),
"has_reason": query.has_reason,
"has_failure_reason": query.has_failure_reason,
})
}
File diff suppressed because it is too large Load Diff
+265
View File
@@ -0,0 +1,265 @@
//! Downloader backend and bounded scheduling contracts.
//!
//! The scheduler is deliberately independent from curl, manifests, and
//! official URL rules. Those concerns belong to a backend and the caller,
//! which keeps retry, proxy, and verification policy composable.
use std::sync::{mpsc, Arc, Mutex};
/// Lowest supported download concurrency.
pub const MIN_DOWNLOAD_CONCURRENCY: usize = 1;
/// Highest supported download concurrency.
pub const MAX_DOWNLOAD_CONCURRENCY: usize = 256;
/// Default official download concurrency.
pub const DEFAULT_DOWNLOAD_CONCURRENCY: usize = 8;
/// A backend that executes one already-planned download task.
pub trait DownloaderBackend<T>: Send + Sync {
/// Successful result returned for one task.
type Output: Send;
/// Failure returned for one task.
type Error: Send;
/// Executes one task. The scheduler owns ordering and concurrency only.
fn download(&self, task: T) -> Result<Self::Output, Self::Error>;
}
/// A bounded worker scheduler.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DownloadScheduler {
max_concurrency: usize,
}
impl DownloadScheduler {
/// Creates a scheduler with the supported bounded range.
///
/// The official CLI validates input and reports out-of-range values.
/// This lower-level constructor remains total for library callers and
/// clamps values to the same safety bounds.
pub fn new(max_concurrency: usize) -> Self {
Self {
max_concurrency: max_concurrency
.clamp(MIN_DOWNLOAD_CONCURRENCY, MAX_DOWNLOAD_CONCURRENCY),
}
}
/// Returns the configured upper bound.
pub fn max_concurrency(self) -> usize {
self.max_concurrency
}
/// Executes tasks with a bounded number of workers.
///
/// Results are returned in input order even when workers finish out of
/// order. A failed task does not cause additional tasks to be scheduled
/// after it, because already-started bounded work must be joined cleanly;
/// callers decide whether a failed result invalidates the whole release.
pub fn execute<T, B>(self, backend: &B, tasks: Vec<T>) -> Vec<Result<B::Output, B::Error>>
where
T: Send + 'static,
B: DownloaderBackend<T>,
{
self.execute_with_observer(
backend,
tasks,
|_, _| Ok::<(), std::convert::Infallible>(()),
)
.expect("infallible download observer cannot fail")
}
/// Executes tasks and observes each result as soon as a worker returns it.
///
/// The observer runs on the coordinator thread, while worker threads
/// immediately take another pending task after sending their result. An
/// observer error stops further observation but still drains and joins all
/// workers before returning, so no background transfer is left detached.
pub fn execute_with_observer<T, B, F, E>(
self,
backend: &B,
tasks: Vec<T>,
mut observer: F,
) -> Result<Vec<Result<B::Output, B::Error>>, E>
where
T: Send + 'static,
B: DownloaderBackend<T>,
F: FnMut(usize, &Result<B::Output, B::Error>) -> Result<(), E>,
{
if tasks.is_empty() {
return Ok(Vec::new());
}
if self.max_concurrency == 1 {
let mut results = Vec::with_capacity(tasks.len());
for (index, task) in tasks.into_iter().enumerate() {
let result = backend.download(task);
observer(index, &result)?;
results.push(result);
}
return Ok(results);
}
let total = tasks.len();
let worker_count = self.max_concurrency.min(total);
let pending = Arc::new(Mutex::new(tasks.into_iter().enumerate()));
let (result_sender, result_receiver) = mpsc::channel();
std::thread::scope(|scope| {
for _ in 0..worker_count {
let pending = Arc::clone(&pending);
let result_sender = result_sender.clone();
scope.spawn(move || loop {
let task = pending
.lock()
.expect("download scheduler task queue poisoned")
.next();
let Some((index, task)) = task else {
break;
};
let result = backend.download(task);
if result_sender.send((index, result)).is_err() {
break;
}
});
}
drop(result_sender);
let mut results = std::iter::repeat_with(|| None)
.take(total)
.collect::<Vec<_>>();
let mut observer_error = None;
for (index, result) in result_receiver {
if observer_error.is_none() {
if let Err(error) = observer(index, &result) {
observer_error = Some(error);
}
}
results[index] = Some(result);
}
let results = results
.into_iter()
.map(|result| result.expect("download scheduler lost a task result"))
.collect();
match observer_error {
Some(error) => Err(error),
None => Ok(results),
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
use std::time::Duration;
struct TestBackend {
active: AtomicUsize,
max_active: AtomicUsize,
}
impl DownloaderBackend<usize> for TestBackend {
type Output = usize;
type Error = String;
fn download(&self, task: usize) -> Result<Self::Output, Self::Error> {
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
self.max_active.fetch_max(active, Ordering::SeqCst);
thread::sleep(Duration::from_millis(2));
self.active.fetch_sub(1, Ordering::SeqCst);
Ok(task * 2)
}
}
#[test]
fn scheduler_preserves_result_order_and_respects_bound() {
let backend = TestBackend {
active: AtomicUsize::new(0),
max_active: AtomicUsize::new(0),
};
let results = DownloadScheduler::new(2).execute(&backend, (0..8).collect());
assert_eq!(
results.into_iter().map(Result::unwrap).collect::<Vec<_>>(),
(0..8).map(|value| value * 2).collect::<Vec<_>>()
);
assert!(backend.max_active.load(Ordering::SeqCst) <= 2);
assert!(backend.max_active.load(Ordering::SeqCst) >= 2);
}
#[test]
fn zero_concurrency_is_conservative() {
assert_eq!(
DownloadScheduler::new(0).max_concurrency(),
MIN_DOWNLOAD_CONCURRENCY
);
}
#[test]
fn scheduler_caps_untrusted_upper_bound() {
assert_eq!(
DownloadScheduler::new(usize::MAX).max_concurrency(),
MAX_DOWNLOAD_CONCURRENCY
);
}
#[test]
fn observer_receives_completion_without_a_global_barrier() {
struct UnevenBackend {
active: AtomicUsize,
task_two_started_while_task_zero_active: AtomicUsize,
}
impl DownloaderBackend<usize> for UnevenBackend {
type Output = usize;
type Error = String;
fn download(&self, task: usize) -> Result<Self::Output, Self::Error> {
if task == 0 {
self.active.fetch_add(1, Ordering::SeqCst);
thread::sleep(Duration::from_millis(20));
self.active.fetch_sub(1, Ordering::SeqCst);
} else {
if task == 1 {
while self.active.load(Ordering::SeqCst) == 0 {
thread::yield_now();
}
}
if task == 2 && self.active.load(Ordering::SeqCst) > 0 {
self.task_two_started_while_task_zero_active
.fetch_add(1, Ordering::SeqCst);
}
thread::sleep(Duration::from_millis(if task == 1 { 1 } else { 5 }));
}
Ok(task)
}
}
let backend = UnevenBackend {
active: AtomicUsize::new(0),
task_two_started_while_task_zero_active: AtomicUsize::new(0),
};
let mut completed = Vec::new();
let results = DownloadScheduler::new(2)
.execute_with_observer(&backend, vec![0, 1, 2], |index, _| {
completed.push(index);
Ok::<(), ()>(())
})
.unwrap();
assert_eq!(
results.into_iter().map(Result::unwrap).collect::<Vec<_>>(),
vec![0, 1, 2]
);
assert_eq!(completed.len(), 3);
assert!(completed[0] == 1, "短任务应在长任务之前回传:{completed:?}");
assert_eq!(
backend
.task_two_started_while_task_zero_active
.load(Ordering::SeqCst),
1,
"worker 完成 task 1 后应立即领取 task 2"
);
}
}
+25 -8
View File
@@ -12,6 +12,7 @@
pub mod cas;
mod curl_transfer;
pub mod downloader;
pub mod import;
pub mod localized_patch;
pub mod official_changes;
@@ -26,13 +27,19 @@ pub mod official_textunit_queue;
pub mod official_update;
pub mod patch_ops;
pub mod path_security;
pub mod release_flow;
pub mod resources;
pub mod translation_tasks;
mod zip_validation;
pub use cas::FileSystemCasRepository;
pub use curl_transfer::{
redact_proxy_url, resolve_curl_proxy, CurlProxyConfig, CurlProxyMode, ResolvedCurlProxy,
};
pub use downloader::{
DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY, MAX_DOWNLOAD_CONCURRENCY,
MIN_DOWNLOAD_CONCURRENCY,
};
pub use import::{
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
ResourceImportService,
@@ -59,9 +66,10 @@ pub use official_download::{
read_download_manifest_at, DownloadError, OfficialDownloadManifest,
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
OfficialLocalVerificationReport, OfficialResourcePullItem, OfficialResourcePullProgress,
OfficialLocalVerificationReport, OfficialResourceHashAlgorithm,
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
OfficialResourcePullStatus,
OfficialResourcePullStatus, OfficialResourceVerification,
};
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
pub use official_launcher::{
@@ -92,12 +100,12 @@ pub use official_sync::{
default_official_platforms, OfficialSyncDecision, OfficialSyncPlan,
};
pub use official_textunit_queue::{
read_textunit_task_queue_at, write_crowdin_textunit_queue_at, write_official_textunit_queues,
write_textunit_task_queue_at, CrowdinTextUnitQueue, CrowdinTextUnitQueueItem,
OfficialTextUnitQueueReport, OfficialTextUnitTask, OfficialTextUnitTaskQueue,
OfficialTextUnitTaskStatus, OfficialTextUnitTaskSummary, CROWDIN_TEXTUNIT_QUEUE_FILE,
CROWDIN_TEXTUNIT_QUEUE_VERSION, OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE,
OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION,
query_textunit_tasks, read_textunit_task_queue_at, write_crowdin_textunit_queue_at,
write_official_textunit_queues, write_textunit_task_queue_at, CrowdinTextUnitQueue,
CrowdinTextUnitQueueItem, OfficialTextUnitQueueReport, OfficialTextUnitTask,
OfficialTextUnitTaskQuery, OfficialTextUnitTaskQueue, OfficialTextUnitTaskStatus,
OfficialTextUnitTaskSummary, CROWDIN_TEXTUNIT_QUEUE_FILE, CROWDIN_TEXTUNIT_QUEUE_VERSION,
OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE, OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION,
};
pub use official_update::{
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
@@ -120,7 +128,16 @@ pub use path_security::{
open_append_file, read_file_no_symlink, set_file_mode, validate_output_root,
validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE,
};
pub use release_flow::ReleaseFlowStatusCode;
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
pub use translation_tasks::{
build_translation_handoff, read_translation_handoff_at, sync_translation_task_repository_at,
write_translation_handoff_at, PersistedTranslationTask, ProviderRun, ProviderRunStatus,
SqliteTranslationTaskRepository, TranslationHandoff, TranslationJob, TranslationJobStatus,
TranslationTaskStatus, TranslationTaskSyncReport, TranslationUnit, TranslationUnitStatus,
TRANSLATION_HANDOFF_FILE, TRANSLATION_HANDOFF_SCHEMA_VERSION, TRANSLATION_TASK_REPOSITORY_FILE,
TRANSLATION_TASK_SCHEMA_VERSION,
};
/// Infrastructure 版本号
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
File diff suppressed because it is too large Load Diff
@@ -16,6 +16,7 @@ use crate::path_security::{
ensure_path_within_root, ensure_safe_file_target, read_file_no_symlink, write_file_atomic,
STATE_FILE_MODE,
};
use crate::sync_translation_task_repository_at;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
@@ -100,6 +101,33 @@ pub struct OfficialTextUnitTask {
pub reason: Option<String>,
}
/// Query filters for incremental TextUnit translation tasks.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct OfficialTextUnitTaskQuery {
/// Filter by stable task ID.
pub task_id: Option<String>,
/// Filter by official release ID.
pub official_release_id: Option<String>,
/// Filter by resource destination.
pub destination: Option<String>,
/// Filter by destination glob pattern.
pub path_pattern: Option<String>,
/// Filter by ZIP/archive entry.
pub archive_entry: Option<String>,
/// Filter by task status, for example `queued_offline` or `skipped_parse_failed`.
pub status: Option<String>,
/// Filter by parse status, for example `parsed`, `failed`, or `skipped_unsupported`.
pub parse_status: Option<String>,
/// Filter by TextUnit format.
pub text_unit_format: Option<String>,
/// Filter tasks by whether a diagnostic reason is present.
pub has_reason: Option<bool>,
/// Filter tasks by whether a provider failure reason is present.
pub has_failure_reason: Option<bool>,
/// Filter by mutable provider-worker status.
pub task_status: Option<String>,
}
/// Aggregate counters for an incremental TextUnit task queue.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialTextUnitTaskSummary {
@@ -332,6 +360,16 @@ pub fn write_official_textunit_queues(
write_textunit_task_queue_at(resource_root, &task_queue)?;
let task_queue_path = resource_root.join(OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("构建官方 TextUnit 任务状态同步运行时失败:{error}"))?;
runtime
.block_on(sync_translation_task_repository_at(
resource_root,
&task_queue,
))
.map_err(|error| format!("同步官方 TextUnit 任务状态到 SQLite 失败:{error}"))?;
let crowdin_queue =
CrowdinTextUnitQueue::from_textunit_task_queue(task_queue_path.clone(), &task_queue);
write_crowdin_textunit_queue_at(resource_root, &crowdin_queue)?;
@@ -363,6 +401,18 @@ pub fn read_textunit_task_queue_at(
Ok(Some(queue))
}
/// Returns TextUnit translation tasks matching a query.
pub fn query_textunit_tasks<'a>(
queue: &'a OfficialTextUnitTaskQueue,
query: &OfficialTextUnitTaskQuery,
) -> Vec<&'a OfficialTextUnitTask> {
queue
.tasks
.iter()
.filter(|task| textunit_task_matches(task, query))
.collect()
}
/// Returns whether the persisted TextUnit queue still matches current inputs.
pub fn is_textunit_task_queue_current(
resource_root: &Path,
@@ -457,6 +507,100 @@ fn parse_entries_by_destination(
by_destination
}
pub(crate) fn textunit_task_matches(
task: &OfficialTextUnitTask,
query: &OfficialTextUnitTaskQuery,
) -> bool {
if query
.task_id
.as_ref()
.is_some_and(|task_id| &task.task_id != task_id)
{
return false;
}
if query
.official_release_id
.as_ref()
.is_some_and(|release_id| &task.official_release_id != release_id)
{
return false;
}
if query
.destination
.as_ref()
.is_some_and(|destination| &task.destination != destination)
{
return false;
}
if query
.path_pattern
.as_ref()
.is_some_and(|pattern| !glob_matches(pattern, &task.destination))
{
return false;
}
if query
.archive_entry
.as_ref()
.is_some_and(|archive_entry| task.archive_entry.as_ref() != Some(archive_entry))
{
return false;
}
if query
.status
.as_ref()
.is_some_and(|status| task.status.as_str() != status)
{
return false;
}
if let Some(status) = &query.parse_status {
if task.parse_status.map(parse_status_label) != Some(status.as_str()) {
return false;
}
}
if query.text_unit_format.as_ref().is_some_and(|format| {
!task
.text_unit_formats
.iter()
.any(|task_format| task_format == format)
}) {
return false;
}
if query
.has_reason
.is_some_and(|has_reason| task.reason.is_some() != has_reason)
{
return false;
}
true
}
fn parse_status_label(status: OfficialParseStatus) -> &'static str {
match status {
OfficialParseStatus::Parsed => "parsed",
OfficialParseStatus::SkippedUnsupported => "skipped_unsupported",
OfficialParseStatus::Failed => "failed",
}
}
fn glob_matches(pattern: &str, value: &str) -> bool {
glob_matches_bytes(pattern.as_bytes(), value.as_bytes())
}
fn glob_matches_bytes(pattern: &[u8], value: &[u8]) -> bool {
match pattern.split_first() {
None => value.is_empty(),
Some((&b'*', rest)) => {
glob_matches_bytes(rest, value)
|| (!value.is_empty() && glob_matches_bytes(pattern, &value[1..]))
}
Some((&b'?', rest)) => !value.is_empty() && glob_matches_bytes(rest, &value[1..]),
Some((&literal, rest)) => value
.split_first()
.is_some_and(|(&head, tail)| head == literal && glob_matches_bytes(rest, tail)),
}
}
fn skipped_no_parse_entry_task(
change_set: &OfficialResourceChangeSet,
change: &OfficialResourceChange,
@@ -772,6 +916,61 @@ mod tests {
assert_eq!(crowdin.items[0].destination, "Bundles/a.bundle");
}
#[test]
fn query_textunit_tasks_filters_status_reason_and_format() {
let temp = tempfile::TempDir::new().unwrap();
let queue = OfficialTextUnitTaskQueue::from_change_set_and_parse_cache(
&change_set(temp.path()),
&parse_cache(),
);
let queued = query_textunit_tasks(
&queue,
&OfficialTextUnitTaskQuery {
official_release_id: Some("release-new".to_string()),
path_pattern: Some("Bundles/*.bundle".to_string()),
status: Some("queued_offline".to_string()),
parse_status: Some("parsed".to_string()),
text_unit_format: Some("plain".to_string()),
has_reason: Some(false),
..OfficialTextUnitTaskQuery::default()
},
);
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].destination, "Bundles/a.bundle");
assert_eq!(queued[0].reason, None);
let skipped_with_reason = query_textunit_tasks(
&queue,
&OfficialTextUnitTaskQuery {
status: Some("skipped_unsupported".to_string()),
has_reason: Some(true),
..OfficialTextUnitTaskQuery::default()
},
);
assert_eq!(skipped_with_reason.len(), 1);
assert_eq!(skipped_with_reason[0].destination, "Bundles/b.bundle");
assert_eq!(
skipped_with_reason[0].reason.as_deref(),
Some("unsupported")
);
let by_task_id = query_textunit_tasks(
&queue,
&OfficialTextUnitTaskQuery {
task_id: Some("textunit/release-new/Bundles/c.bundle".to_string()),
status: Some("skipped_no_parse_entry".to_string()),
has_reason: Some(true),
..OfficialTextUnitTaskQuery::default()
},
);
assert_eq!(by_task_id.len(), 1);
assert_eq!(
by_task_id[0].reason.as_deref(),
Some("parse cache entry not found for changed resource")
);
}
#[test]
fn write_textunit_queues_persists_files() {
let temp = tempfile::TempDir::new().unwrap();
+172 -20
View File
@@ -28,27 +28,34 @@ use crate::path_security::{
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE,
};
use crate::release_flow::ReleaseFlowStatusCode;
use crate::translation_tasks::{
build_translation_handoff, sync_translation_task_repository_at, write_translation_handoff_at,
};
use crate::{
build_official_pull_plan_for_platform_inventory, build_official_sync_plan,
changed_endpoint_urls, default_official_platforms, DownloadError,
OfficialGameMainConfigBootstrapService, OfficialLauncherBootstrapService,
OfficialResourcePullPlan, OfficialResourcePullProgress, OfficialResourcePullProgressKind,
OfficialResourcePullService, YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig,
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
OfficialResourceHashVerification, OfficialResourcePullPlan, OfficialResourcePullProgress,
OfficialResourcePullProgressKind, OfficialResourcePullService, OfficialResourceVerification,
YostarJpLauncherCdnConfig, YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl,
YostarJpLauncherRemoteManifest,
};
use crate::{
read_parse_cache_at, OfficialParseCacheService, OfficialParseConfig, OfficialParseSummary,
};
use crate::{FileSystemCasRepository, SqliteResourceRepository};
use crate::{DEFAULT_DOWNLOAD_CONCURRENCY, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY};
use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use bat_adapters::official::inventory::{
YostarJpPlatformCatalogInventory, YostarJpPlatformDownloadInventory,
};
use bat_adapters::official::launcher::YostarJpLauncherManifestFile;
use bat_adapters::official::yostar_jp::{
server_info_url, PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint,
PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint,
YostarJpResourceEndpointKind, YostarJpServerInfo, YostarJpSyncSnapshot,
};
use bat_adapters::official::{
InventoryParser, OfficialResourceBackend, PlatformCatalogInput, YostarJpBackend,
YostarJpPlatformDownloadInventory,
};
use bat_core::ErrorCode;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -113,6 +120,10 @@ pub struct OfficialUpdateConfig {
pub curl_command: PathBuf,
/// Proxy selection used by all official `curl` transfers.
pub curl_proxy: CurlProxyConfig,
/// Maximum number of resource downloads executed concurrently.
///
/// `8` is the default; values are accepted only in `1..=256`.
pub download_concurrency: usize,
/// Unzip command used when a metadata change requires GameMainConfig parsing.
pub unzip_command: PathBuf,
/// Dry run reports decisions and optional plan URLs without writing sync state.
@@ -149,6 +160,7 @@ impl Default for OfficialUpdateConfig {
snapshot_path: None,
curl_command: PathBuf::from("curl"),
curl_proxy: CurlProxyConfig::default(),
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
unzip_command: PathBuf::from("unzip"),
dry_run: false,
plan: false,
@@ -226,6 +238,16 @@ impl OfficialUpdateStatus {
Self::Downloaded => "downloaded",
}
}
/// Returns the stable cross-module flow status code.
pub const fn flow_status_code(self) -> ReleaseFlowStatusCode {
match self {
Self::UpToDate => ReleaseFlowStatusCode::OfficialUpToDate,
Self::WouldDownload => ReleaseFlowStatusCode::OfficialUpdateAvailable,
Self::WaitingForOfficialResources => ReleaseFlowStatusCode::OfficialWaitingForResources,
Self::Downloaded => ReleaseFlowStatusCode::OfficialPublished,
}
}
}
/// Publication state for localized resources associated with an official release.
@@ -246,6 +268,15 @@ impl LocalizedReleaseStatus {
Self::Localized => "localized",
}
}
/// Returns the stable status code for an official release that is known
/// to match the currently selected localized state.
pub const fn flow_status_code(self) -> ReleaseFlowStatusCode {
match self {
Self::NotLocalized => ReleaseFlowStatusCode::LocalizedPending,
Self::Localized => ReleaseFlowStatusCode::LocalizedPublished,
}
}
}
/// Snapshot of the official JP update state observed at a point in time.
@@ -711,6 +742,8 @@ impl OfficialVerificationSummary {
pub struct OfficialUpdateReport {
/// Final update status.
pub update_status: OfficialUpdateStatus,
/// Stable cross-module flow status code.
pub status_code: ReleaseFlowStatusCode,
/// Selected connection group.
pub connection_group: String,
/// Selected app version.
@@ -821,6 +854,8 @@ pub struct OfficialUpdateReport {
pub crowdin_textunit_queue_path: Option<PathBuf>,
/// Incremental TextUnit task queue summary.
pub textunit_task_summary: Option<OfficialTextUnitTaskSummary>,
/// Versioned translation worker handoff view.
pub translation_handoff_path: Option<PathBuf>,
/// Snapshot path written after success.
pub snapshot_written: Option<PathBuf>,
/// Launcher bootstrap artifact written for this run.
@@ -841,6 +876,8 @@ pub struct OfficialUpdateReport {
pub struct OfficialUpdateProgress {
/// Stable progress stage label.
pub stage: &'static str,
/// Stable cross-module flow status code derived from `stage`.
pub status_code: ReleaseFlowStatusCode,
/// Human-readable status line.
pub message: String,
/// One-based download index when the event represents URL download work.
@@ -865,6 +902,10 @@ pub struct OfficialUpdateProgress {
pub download_failure_attempts: Option<usize>,
/// Whether the URL was recorded in the quarantine manifest.
pub download_quarantined: Option<bool>,
/// Local size/BLAKE3/ZIP verification for a completed URL.
pub download_verification: Option<OfficialResourceVerification>,
/// Official `.hash` sidecar verification when a seed catalog pair passes.
pub official_hash_verification: Option<OfficialResourceHashVerification>,
}
impl OfficialUpdateProgress {
@@ -872,6 +913,7 @@ impl OfficialUpdateProgress {
pub fn new(stage: &'static str, message: impl Into<String>) -> Self {
Self {
stage,
status_code: ReleaseFlowStatusCode::from_progress_stage(stage),
message: message.into(),
download_index: None,
download_total: None,
@@ -884,6 +926,8 @@ impl OfficialUpdateProgress {
download_failure_retryable: None,
download_failure_attempts: None,
download_quarantined: None,
download_verification: None,
official_hash_verification: None,
}
}
@@ -906,6 +950,8 @@ impl OfficialUpdateProgress {
self.download_failure_attempts = event.failure_attempts;
self.download_quarantined =
(event.kind == OfficialResourcePullProgressKind::Failed).then_some(event.quarantined);
self.download_verification = event.verification.clone();
self.official_hash_verification = event.official_hash.clone();
self
}
}
@@ -1187,7 +1233,8 @@ impl OfficialUpdateService {
&active_resource_root,
&config.curl_command,
)
.with_proxy_config(config.curl_proxy.clone());
.with_proxy_config(config.curl_proxy.clone())
.with_max_concurrency(config.download_concurrency);
let snapshot_path = snapshot_path_for(config, &active_resource_root);
let bootstrap_cache_path = config.bootstrap_cache_path();
@@ -1570,6 +1617,11 @@ impl OfficialUpdateService {
} else {
OfficialUpdateStatus::UpToDate
},
status_code: if should_download {
ReleaseFlowStatusCode::OfficialUpdateAvailable
} else {
ReleaseFlowStatusCode::OfficialUpToDate
},
connection_group: current_snapshot.connection_group_name.clone(),
app_version: current_snapshot.app_version.clone(),
bundle_version: current_snapshot.bundle_version.clone(),
@@ -1629,6 +1681,7 @@ impl OfficialUpdateService {
textunit_task_queue_path: None,
crowdin_textunit_queue_path: None,
textunit_task_summary: None,
translation_handoff_path: None,
snapshot_written: None,
launcher_bootstrap_artifact_path: None,
repository_import_enabled: config.import_repository,
@@ -1807,7 +1860,20 @@ impl OfficialUpdateService {
&publish_plan.staging_path,
&config.curl_command,
)
.with_proxy_config(config.curl_proxy.clone());
.with_proxy_config(config.curl_proxy.clone())
.with_max_concurrency(config.download_concurrency);
let pruned_stale_resource_count = staging_fetcher
.prune_stale_manifest_entries(&pull_plan)
.map_err(anyhow::Error::msg)?;
if pruned_stale_resource_count > 0 {
progress(OfficialUpdateProgress::new(
"publish",
format!(
"已清理新 manifest 删除的旧官方资源:{} 项",
pruned_stale_resource_count
),
));
}
report.staging_path = Some(publish_plan.staging_path.clone());
report.snapshot_path = staging_snapshot_path.clone();
report.download_manifest = staging_fetcher.download_manifest_path();
@@ -1921,6 +1987,7 @@ impl OfficialUpdateService {
};
report.update_status = OfficialUpdateStatus::Downloaded;
report.status_code = report.update_status.flow_status_code();
report.active_resource_root = published_version_path.clone();
report.published_version_path = Some(published_version_path.clone());
report.snapshot_path = final_snapshot_path.clone();
@@ -2149,6 +2216,7 @@ fn run_post_sync_textunit_queue_if_needed(
&& is_crowdin_textunit_queue_current(resource_root, &queue)
.map_err(anyhow::Error::msg)?
{
sync_translation_task_repository(resource_root, &queue)?;
progress(OfficialUpdateProgress::new(
"textunit",
format!(
@@ -2160,7 +2228,8 @@ fn run_post_sync_textunit_queue_if_needed(
));
report.textunit_task_queue_path = Some(task_queue_path);
report.crowdin_textunit_queue_path = Some(crowdin_queue_path);
report.textunit_task_summary = Some(queue.summary);
report.textunit_task_summary = Some(queue.summary.clone());
write_translation_handoff_for_release(resource_root, &queue, report, progress)?;
return Ok(());
}
progress(OfficialUpdateProgress::new(
@@ -2175,6 +2244,19 @@ fn run_post_sync_textunit_queue_if_needed(
run_post_sync_textunit_queue(resource_root, report, progress, should_cancel)
}
fn sync_translation_task_repository(
resource_root: &Path,
queue: &crate::official_textunit_queue::OfficialTextUnitTaskQueue,
) -> anyhow::Result<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime
.block_on(sync_translation_task_repository_at(resource_root, queue))
.map_err(|error| anyhow::anyhow!("{error}"))?;
Ok(())
}
fn run_post_sync_textunit_queue(
resource_root: &Path,
report: &mut OfficialUpdateReport,
@@ -2205,6 +2287,11 @@ fn run_post_sync_textunit_queue(
),
));
apply_textunit_queue_report(report, queue_report);
if let Some(queue) =
read_textunit_task_queue_at(resource_root).map_err(anyhow::Error::msg)?
{
write_translation_handoff_for_release(resource_root, &queue, report, progress)?;
}
}
Err(error) => {
progress(OfficialUpdateProgress::new(
@@ -2225,6 +2312,48 @@ fn apply_textunit_queue_report(
report.textunit_task_summary = Some(queue_report.summary);
}
fn write_translation_handoff_for_release(
resource_root: &Path,
queue: &crate::official_textunit_queue::OfficialTextUnitTaskQueue,
report: &mut OfficialUpdateReport,
progress: &mut dyn FnMut(OfficialUpdateProgress),
) -> anyhow::Result<()> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let tasks = runtime.block_on(async {
let repository = crate::translation_tasks::SqliteTranslationTaskRepository::new(
crate::translation_tasks::SqliteTranslationTaskRepository::repository_path(
resource_root,
),
)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.sync_queue(queue)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.list(&crate::official_textunit_queue::OfficialTextUnitTaskQuery::default())
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
let handoff = build_translation_handoff(queue, &tasks);
write_translation_handoff_at(resource_root, &handoff).map_err(anyhow::Error::msg)?;
let path = resource_root.join(crate::translation_tasks::TRANSLATION_HANDOFF_FILE);
report.translation_handoff_path = Some(path.clone());
progress(OfficialUpdateProgress::new(
"textunit",
format!(
"翻译 handoff 已发布:任务={} provider_run={} {}",
handoff.units.len(),
handoff.provider_runs.len(),
path.display()
),
));
Ok(())
}
fn run_post_sync_repository_import(
config: &OfficialUpdateConfig,
resource_root: &Path,
@@ -2318,8 +2447,9 @@ fn build_pull_plan(
should_cancel: &mut dyn FnMut() -> bool,
) -> anyhow::Result<OfficialResourcePullPlan> {
check_shutdown_requested(should_cancel)?;
let discovery = server_info
.discovery_plan(connection_group, app_version, platforms)
let backend = YostarJpBackend;
let discovery = backend
.discovery_plan(server_info, connection_group, app_version, platforms)
.map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new(
"plan",
@@ -2350,7 +2480,9 @@ fn load_server_info(
match source {
OfficialServerInfoSource::LocalPath(path) => Ok(fs::read(path)?),
OfficialServerInfoSource::OfficialFile(file_name) => {
let url = server_info_url(file_name).map_err(anyhow::Error::msg)?;
let url = YostarJpBackend
.server_info_url(file_name)
.map_err(anyhow::Error::msg)?;
fetcher.fetch_bytes(&url).map_err(anyhow::Error::new)
}
OfficialServerInfoSource::OfficialUrl(url) => {
@@ -2383,6 +2515,7 @@ fn waiting_for_official_resources_report(
let localized_info = localized_release_info_for(config, Some(active_release_id.as_str()));
OfficialUpdateReport {
update_status: OfficialUpdateStatus::WaitingForOfficialResources,
status_code: ReleaseFlowStatusCode::OfficialWaitingForResources,
connection_group: base_snapshot.connection_group_name.clone(),
app_version: base_snapshot.app_version.clone(),
bundle_version: base_snapshot.bundle_version.clone(),
@@ -2436,6 +2569,7 @@ fn waiting_for_official_resources_report(
textunit_task_queue_path: None,
crowdin_textunit_queue_path: None,
textunit_task_summary: None,
translation_handoff_path: None,
snapshot_written: None,
launcher_bootstrap_artifact_path: None,
repository_import_enabled: config.import_repository,
@@ -2701,6 +2835,21 @@ fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpda
),
)
.with_download_progress(&event),
OfficialResourcePullProgressKind::Verification => {
let hash = event.official_hash.as_ref();
OfficialUpdateProgress::new(
"verify",
format!(
"官方 hash 校验通过:算法={} 期望={} 实际={} 数据 URL={} hash URL={}",
hash.map(|value| value.algorithm.as_str()).unwrap_or("unknown"),
hash.map(|value| value.expected.as_str()).unwrap_or("unknown"),
hash.map(|value| value.actual.as_str()).unwrap_or("unknown"),
hash.map(|value| value.data_url.as_str()).unwrap_or(event.url.as_str()),
hash.map(|value| value.hash_url.as_str()).unwrap_or("unknown")
),
)
.with_download_progress(&event)
}
}
}
@@ -2826,6 +2975,12 @@ pub fn diff_extended_snapshot(
}
fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
if !(MIN_DOWNLOAD_CONCURRENCY..=MAX_DOWNLOAD_CONCURRENCY).contains(&config.download_concurrency)
{
return Err(format!(
"下载并发数必须在 {MIN_DOWNLOAD_CONCURRENCY}..={MAX_DOWNLOAD_CONCURRENCY} 范围内"
));
}
validate_output_root(&config.output_root)?;
validate_output_root(&config.localized_output_root)?;
validate_separate_output_roots(&config.output_root, &config.localized_output_root)?;
@@ -3979,17 +4134,14 @@ fn build_inventory_from_seed_catalogs(
anyhow::anyhow!("官方发现结果缺少 {} MediaCatalog.bytes", platform.as_str())
})?;
platform_catalogs.push(YostarJpPlatformCatalogInventory::from_catalog_bytes(
*platform,
platform_catalogs.push(PlatformCatalogInput {
platform: *platform,
bundle_packing_info,
media_catalog,
));
});
}
Ok(YostarJpPlatformDownloadInventory::from_catalog_bytes(
table_catalog,
platform_catalogs,
))
Ok(YostarJpBackend.parse_inventory(table_catalog, &platform_catalogs))
}
#[derive(Debug, Default)]
+310
View File
@@ -0,0 +1,310 @@
//! Stable status codes for the official-resource to localized-release flow.
//!
//! The codes describe observable lifecycle state. They are deliberately
//! separate from `BAT-ERR-*`: an error code explains why an operation failed,
//! while a flow status code explains what a caller can do next.
use serde::{Deserialize, Serialize};
/// Stable status code shared by Rust reports and read-only RPC data.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReleaseFlowStatusCode {
/// No published official release is currently available.
#[serde(rename = "official.unavailable")]
OfficialUnavailable,
/// The producer is discovering remote and local official-resource state.
#[serde(rename = "official.checking")]
OfficialChecking,
/// The producer determined that a new or repaired official release is needed.
#[serde(rename = "official.update_available")]
OfficialUpdateAvailable,
/// Official resources are being downloaded or reused into staging.
#[serde(rename = "official.downloading")]
OfficialDownloading,
/// Downloaded official resources are being verified.
#[serde(rename = "official.validating")]
OfficialValidating,
/// A verified official release is being staged or atomically published.
#[serde(rename = "official.publishing")]
OfficialPublishing,
/// A verified official release has been published.
#[serde(rename = "official.published")]
OfficialPublished,
/// The published official release already matches the observed remote state.
#[serde(rename = "official.up_to_date")]
OfficialUpToDate,
/// Launcher/server-info has advanced before required CDN resources are readable.
#[serde(rename = "official.waiting_for_resources")]
OfficialWaitingForResources,
/// Official-resource production failed before a publishable state was reached.
#[serde(rename = "official.failed")]
OfficialFailed,
/// Parsing is blocked because there is no published official release.
#[serde(rename = "parse.blocked_official")]
ParseBlockedOfficial,
/// A published official release exists but parse cache is not present yet.
#[serde(rename = "parse.pending")]
ParsePending,
/// Parse cache or TextUnit index generation is running.
#[serde(rename = "parse.running")]
ParseRunning,
/// Parse cache and TextUnit indexes are present without recorded parse failures.
#[serde(rename = "parse.completed")]
ParseCompleted,
/// Parse cache exists but contains parser or extraction failures.
#[serde(rename = "parse.completed_with_errors")]
ParseCompletedWithErrors,
/// Translation worker integration is not available for the observed release.
#[serde(rename = "translation.unavailable")]
TranslationUnavailable,
/// Translation handoff files are being prepared from the official change set.
#[serde(rename = "translation.handoff_preparing")]
TranslationHandoffPreparing,
/// Translation tasks have been queued to local offline handoff files.
#[serde(rename = "translation.queued_offline")]
TranslationQueuedOffline,
/// Localized publication is blocked because there is no official release.
#[serde(rename = "localized.blocked_official")]
LocalizedBlockedOfficial,
/// The current official release has no matching localized publication yet.
#[serde(rename = "localized.pending")]
LocalizedPending,
/// A localized state exists but it does not match the current official release.
#[serde(rename = "localized.stale")]
LocalizedStale,
/// A localized release is published and matches the current official release.
#[serde(rename = "localized.published")]
LocalizedPublished,
/// Distribution cannot serve a usable release for the observed channel.
#[serde(rename = "distribution.blocked")]
DistributionBlocked,
/// Distribution can serve the published release.
#[serde(rename = "distribution.ready")]
DistributionReady,
}
impl ReleaseFlowStatusCode {
/// Returns the stable wire label.
pub const fn as_str(self) -> &'static str {
match self {
Self::OfficialUnavailable => "official.unavailable",
Self::OfficialChecking => "official.checking",
Self::OfficialUpdateAvailable => "official.update_available",
Self::OfficialDownloading => "official.downloading",
Self::OfficialValidating => "official.validating",
Self::OfficialPublishing => "official.publishing",
Self::OfficialPublished => "official.published",
Self::OfficialUpToDate => "official.up_to_date",
Self::OfficialWaitingForResources => "official.waiting_for_resources",
Self::OfficialFailed => "official.failed",
Self::ParseBlockedOfficial => "parse.blocked_official",
Self::ParsePending => "parse.pending",
Self::ParseRunning => "parse.running",
Self::ParseCompleted => "parse.completed",
Self::ParseCompletedWithErrors => "parse.completed_with_errors",
Self::TranslationUnavailable => "translation.unavailable",
Self::TranslationHandoffPreparing => "translation.handoff_preparing",
Self::TranslationQueuedOffline => "translation.queued_offline",
Self::LocalizedBlockedOfficial => "localized.blocked_official",
Self::LocalizedPending => "localized.pending",
Self::LocalizedStale => "localized.stale",
Self::LocalizedPublished => "localized.published",
Self::DistributionBlocked => "distribution.blocked",
Self::DistributionReady => "distribution.ready",
}
}
/// Returns the short status value used alongside `status_code` in RPC
/// payloads. This keeps existing human-facing labels independent from the
/// namespaced wire code.
pub const fn status(self) -> &'static str {
match self {
Self::OfficialUnavailable => "unavailable",
Self::OfficialChecking => "checking",
Self::OfficialUpdateAvailable => "update_available",
Self::OfficialDownloading => "downloading",
Self::OfficialValidating => "validating",
Self::OfficialPublishing => "publishing",
Self::OfficialPublished => "published",
Self::OfficialUpToDate => "up_to_date",
Self::OfficialWaitingForResources => "waiting_for_resources",
Self::OfficialFailed => "failed",
Self::ParseBlockedOfficial => "blocked_official",
Self::ParsePending => "pending",
Self::ParseRunning => "running",
Self::ParseCompleted => "completed",
Self::ParseCompletedWithErrors => "completed_with_errors",
Self::TranslationUnavailable => "unavailable",
Self::TranslationHandoffPreparing => "handoff_preparing",
Self::TranslationQueuedOffline => "queued_offline",
Self::LocalizedBlockedOfficial => "blocked_official",
Self::LocalizedPending => "pending",
Self::LocalizedStale => "stale",
Self::LocalizedPublished => "published",
Self::DistributionBlocked => "blocked",
Self::DistributionReady => "ready",
}
}
/// Parses a status code read from a persisted daemon/RPC snapshot.
fn parse_wire_code(code: &str) -> Option<Self> {
Some(match code {
"official.unavailable" => Self::OfficialUnavailable,
"official.checking" => Self::OfficialChecking,
"official.update_available" => Self::OfficialUpdateAvailable,
"official.downloading" => Self::OfficialDownloading,
"official.validating" => Self::OfficialValidating,
"official.publishing" => Self::OfficialPublishing,
"official.published" => Self::OfficialPublished,
"official.up_to_date" => Self::OfficialUpToDate,
"official.waiting_for_resources" => Self::OfficialWaitingForResources,
"official.failed" => Self::OfficialFailed,
"parse.blocked_official" => Self::ParseBlockedOfficial,
"parse.pending" => Self::ParsePending,
"parse.running" => Self::ParseRunning,
"parse.completed" => Self::ParseCompleted,
"parse.completed_with_errors" => Self::ParseCompletedWithErrors,
"translation.unavailable" => Self::TranslationUnavailable,
"translation.handoff_preparing" => Self::TranslationHandoffPreparing,
"translation.queued_offline" => Self::TranslationQueuedOffline,
"localized.blocked_official" => Self::LocalizedBlockedOfficial,
"localized.pending" => Self::LocalizedPending,
"localized.stale" => Self::LocalizedStale,
"localized.published" => Self::LocalizedPublished,
"distribution.blocked" => Self::DistributionBlocked,
"distribution.ready" => Self::DistributionReady,
_ => return None,
})
}
/// Returns the broad flow phase represented by this code.
pub const fn phase(self) -> &'static str {
match self {
Self::OfficialUnavailable
| Self::OfficialChecking
| Self::OfficialUpdateAvailable
| Self::OfficialDownloading
| Self::OfficialValidating
| Self::OfficialPublishing
| Self::OfficialPublished
| Self::OfficialUpToDate
| Self::OfficialWaitingForResources
| Self::OfficialFailed => "official_sync",
Self::ParseBlockedOfficial
| Self::ParsePending
| Self::ParseRunning
| Self::ParseCompleted
| Self::ParseCompletedWithErrors => "parse",
Self::TranslationUnavailable
| Self::TranslationHandoffPreparing
| Self::TranslationQueuedOffline => "translation",
Self::LocalizedBlockedOfficial
| Self::LocalizedPending
| Self::LocalizedStale
| Self::LocalizedPublished => "localized_publish",
Self::DistributionBlocked | Self::DistributionReady => "distribution",
}
}
/// Returns whether the state is stable for the current observation.
pub const fn terminal(self) -> bool {
!matches!(
self,
Self::OfficialChecking
| Self::OfficialDownloading
| Self::OfficialValidating
| Self::OfficialPublishing
| Self::ParseRunning
| Self::TranslationHandoffPreparing
)
}
/// Returns whether the producer may retry the operation automatically.
pub const fn retryable(self) -> bool {
matches!(
self,
Self::OfficialWaitingForResources
| Self::OfficialFailed
| Self::ParsePending
| Self::ParseCompletedWithErrors
)
}
/// Maps an existing official update result to the stable flow code.
pub fn from_update_status(status: &str) -> Self {
match status {
"would_download" => Self::OfficialUpdateAvailable,
"waiting_for_official_resources" => Self::OfficialWaitingForResources,
"downloaded" => Self::OfficialPublished,
"up_to_date" => Self::OfficialUpToDate,
_ => Self::OfficialFailed,
}
}
/// Maps an existing progress stage to the stable flow code.
pub fn from_progress_stage(stage: &str) -> Self {
match stage {
"download" => Self::OfficialDownloading,
"audit" | "snapshot" => Self::OfficialValidating,
"publish" | "launcher-bootstrap" => Self::OfficialPublishing,
"parse" => Self::ParseRunning,
"changes" => Self::TranslationHandoffPreparing,
"finish" => Self::OfficialPublished,
_ => Self::OfficialChecking,
}
}
}
impl std::str::FromStr for ReleaseFlowStatusCode {
type Err = ();
fn from_str(code: &str) -> Result<Self, Self::Err> {
Self::parse_wire_code(code).ok_or(())
}
}
#[cfg(test)]
mod tests {
use super::ReleaseFlowStatusCode;
#[test]
fn status_codes_are_stable_and_namespaced() {
let codes = [
ReleaseFlowStatusCode::OfficialUnavailable,
ReleaseFlowStatusCode::OfficialChecking,
ReleaseFlowStatusCode::OfficialUpdateAvailable,
ReleaseFlowStatusCode::OfficialDownloading,
ReleaseFlowStatusCode::OfficialValidating,
ReleaseFlowStatusCode::OfficialPublishing,
ReleaseFlowStatusCode::OfficialPublished,
ReleaseFlowStatusCode::OfficialUpToDate,
ReleaseFlowStatusCode::OfficialWaitingForResources,
ReleaseFlowStatusCode::OfficialFailed,
ReleaseFlowStatusCode::ParseBlockedOfficial,
ReleaseFlowStatusCode::ParsePending,
ReleaseFlowStatusCode::ParseRunning,
ReleaseFlowStatusCode::ParseCompleted,
ReleaseFlowStatusCode::ParseCompletedWithErrors,
ReleaseFlowStatusCode::TranslationUnavailable,
ReleaseFlowStatusCode::TranslationHandoffPreparing,
ReleaseFlowStatusCode::TranslationQueuedOffline,
ReleaseFlowStatusCode::LocalizedBlockedOfficial,
ReleaseFlowStatusCode::LocalizedPending,
ReleaseFlowStatusCode::LocalizedStale,
ReleaseFlowStatusCode::LocalizedPublished,
ReleaseFlowStatusCode::DistributionBlocked,
ReleaseFlowStatusCode::DistributionReady,
];
let labels = codes.iter().map(|code| code.as_str()).collect::<Vec<_>>();
let unique = labels.iter().collect::<std::collections::BTreeSet<_>>();
assert_eq!(labels.len(), unique.len());
assert!(labels.iter().all(|label| label.contains('.')));
}
#[test]
fn status_codes_round_trip_through_from_str() {
let code = ReleaseFlowStatusCode::LocalizedPublished;
assert_eq!(code.as_str().parse::<ReleaseFlowStatusCode>(), Ok(code));
assert!("unknown.status".parse::<ReleaseFlowStatusCode>().is_err());
}
}
+158 -1
View File
@@ -326,6 +326,12 @@ impl SqliteResourceRepository {
.push(" ESCAPE '\\'");
}
if let Some(destination) = &query.destination {
push_condition_prefix(builder, &mut has_where);
builder.push("path = ");
builder.push_bind(destination);
}
Ok(())
}
@@ -349,10 +355,21 @@ impl SqliteResourceRepository {
.await
.map_err(|error| bat_core::Error::Other(error.into()))?;
rows.into_iter().map(Self::resource_from_row).collect()
let resources = rows
.into_iter()
.map(Self::resource_from_row)
.collect::<bat_core::Result<Vec<_>>>()?;
Ok(resources
.into_iter()
.filter(|resource| query_matches(query, resource))
.collect())
}
async fn count_resources(&self, query: &ResourceQuery) -> bat_core::Result<u64> {
if query.requires_resource_scan() {
return Ok(self.fetch_resources(query, None).await?.len() as u64);
}
let mut builder = QueryBuilder::<Sqlite>::new("SELECT COUNT(*) FROM resources");
Self::apply_filters(&mut builder, query)?;
@@ -536,6 +553,63 @@ fn query_matches(query: &ResourceQuery, resource: &Resource) -> bool {
}
}
if let Some(release_id) = &query.official_release_id {
if resource.metadata.official_release_id.as_deref() != Some(release_id.as_str()) {
return false;
}
}
if let Some(platform) = &query.platform {
if resource.metadata.platform.as_deref() != Some(platform.as_str()) {
return false;
}
}
if let Some(destination) = &query.destination {
if resource.entry.path != *destination {
return false;
}
}
if let Some(bundle_path) = &query.bundle_path {
if resource.metadata.bundle_path.as_deref() != Some(bundle_path.as_str()) {
return false;
}
}
if let Some(archive_entry) = &query.archive_entry {
if !resource
.metadata
.archive_entries
.iter()
.any(|entry| entry == archive_entry)
{
return false;
}
}
if let Some(parse_status) = &query.parse_status {
if !resource
.metadata
.parse_statuses
.iter()
.any(|status| status == parse_status)
{
return false;
}
}
if let Some(text_unit_format) = &query.text_unit_format {
if !resource
.metadata
.text_unit_formats
.iter()
.any(|format| format == text_unit_format)
{
return false;
}
}
true
}
@@ -641,6 +715,7 @@ mod tests {
resource_type: Some(ResourceType::AssetBundle),
hash: Some("hash-a".to_string()),
path_pattern: Some("synthetic-*.bundle".to_string()),
..ResourceQuery::all()
};
let results = repository.list(query).await.unwrap();
@@ -653,6 +728,52 @@ mod tests {
assert_eq!(repository.count(ResourceQuery::all()).await.unwrap(), 2);
}
#[tokio::test]
async fn list_filters_by_release_parse_and_textunit_metadata() {
let repository = InMemoryResourceRepository::new();
let mut matching = resource(
"resource/text-a",
"TextAssets/a.json",
"hash-a",
ResourceType::TextAsset,
);
matching.metadata.official_release_id = Some("v-current".to_string());
matching.metadata.platform = Some("windows".to_string());
matching.metadata.bundle_path = Some("Bundles/story.bundle".to_string());
matching.metadata.archive_entries = vec!["story/Scenario.json".to_string()];
matching.metadata.parse_statuses = vec!["parsed".to_string()];
matching.metadata.text_unit_formats = vec!["json".to_string()];
repository.add(matching).await.unwrap();
let mut stale = resource(
"resource/text-b",
"TextAssets/b.json",
"hash-b",
ResourceType::TextAsset,
);
stale.metadata.official_release_id = Some("v-old".to_string());
stale.metadata.platform = Some("android".to_string());
stale.metadata.parse_statuses = vec!["failed".to_string()];
stale.metadata.text_unit_formats = vec!["plain".to_string()];
repository.add(stale).await.unwrap();
let query = ResourceQuery {
official_release_id: Some("v-current".to_string()),
platform: Some("windows".to_string()),
destination: Some("TextAssets/a.json".to_string()),
bundle_path: Some("Bundles/story.bundle".to_string()),
archive_entry: Some("story/Scenario.json".to_string()),
parse_status: Some("parsed".to_string()),
text_unit_format: Some("json".to_string()),
..ResourceQuery::all()
};
let results = repository.list(query.clone()).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "resource/text-a");
assert_eq!(repository.count(query).await.unwrap(), 1);
}
async fn sqlite_repository() -> (tempfile::TempDir, SqliteResourceRepository) {
let temp_dir = tempfile::tempdir().unwrap();
let repository = SqliteResourceRepository::new(temp_dir.path().join("resources.sqlite"))
@@ -677,8 +798,12 @@ mod tests {
.push("assets/shared.bundle".to_string());
resource.metadata.official_release_id = Some("release-1".to_string());
resource.metadata.platform = Some("windows".to_string());
resource.metadata.bundle_path = Some("assets/model.bundle".to_string());
resource.metadata.archive_entries = vec!["serialized/Scenario".to_string()];
resource.metadata.parse_statuses = vec!["parsed".to_string()];
resource.metadata.text_assets = vec!["Scenario".to_string()];
resource.metadata.text_unit_count = 3;
resource.metadata.text_unit_formats = vec!["json".to_string()];
repository.add(resource.clone()).await.unwrap();
@@ -693,8 +818,18 @@ mod tests {
Some("release-1")
);
assert_eq!(by_id.metadata.platform.as_deref(), Some("windows"));
assert_eq!(
by_id.metadata.bundle_path.as_deref(),
Some("assets/model.bundle")
);
assert_eq!(
by_id.metadata.archive_entries,
vec!["serialized/Scenario".to_string()]
);
assert_eq!(by_id.metadata.parse_statuses, vec!["parsed".to_string()]);
assert_eq!(by_id.metadata.text_assets, vec!["Scenario".to_string()]);
assert_eq!(by_id.metadata.text_unit_count, 3);
assert_eq!(by_id.metadata.text_unit_formats, vec!["json".to_string()]);
assert_eq!(
repository.find_by_hash("hash-sqlite-a").await.unwrap().id,
resource.id
@@ -709,6 +844,28 @@ mod tests {
let count = repository.count(ResourceQuery::all()).await.unwrap();
assert_eq!(count, 1);
let query = ResourceQuery {
official_release_id: Some("release-1".to_string()),
platform: Some("windows".to_string()),
destination: Some("assets/model.bundle".to_string()),
bundle_path: Some("assets/model.bundle".to_string()),
archive_entry: Some("serialized/Scenario".to_string()),
parse_status: Some("parsed".to_string()),
text_unit_format: Some("json".to_string()),
..ResourceQuery::all()
};
let filtered = repository.list(query.clone()).await.unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id, resource.id);
assert_eq!(repository.count(query).await.unwrap(), 1);
let missing = ResourceQuery {
official_release_id: Some("release-missing".to_string()),
..ResourceQuery::all()
};
assert!(repository.list(missing.clone()).await.unwrap().is_empty());
assert_eq!(repository.count(missing).await.unwrap(), 0);
repository.delete(&resource.id).await.unwrap();
assert!(matches!(
repository.find_by_id(&resource.id).await,
File diff suppressed because it is too large Load Diff
+171 -2
View File
@@ -1,6 +1,21 @@
package api
import "net/http"
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"bat-api/internal/backendrpc"
)
const adminControlMaxBodyBytes = 1024
type adminControlRequest struct {
Force bool `json:"force"`
}
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
@@ -10,7 +25,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
body := AdminIndexResponse{
Service: "bat-api",
Panel: "admin",
Status: "reserved",
Status: "available",
Links: []string{
"/healthz",
"/readyz",
@@ -19,6 +34,15 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/v1/resources",
"/openapi.yaml",
},
Controls: []string{
"/admin/control/reload",
"/admin/control/refresh",
"/admin/control/restart",
"/admin/control/sync",
"/admin/control/verify",
"/admin/control/repair",
"/admin/control/catalog-refresh",
},
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
@@ -27,3 +51,148 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
}
writeNoStoreJSON(w, http.StatusOK, body)
}
func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
action := strings.TrimPrefix(r.URL.Path, "/admin/control/")
if action == "" || strings.Contains(action, "/") {
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action")
return
}
request, ok := decodeAdminControlRequest(w, r)
if !ok {
return
}
backend, ok := s.backend.(ControlBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "control_backend_unavailable", "Rust bat control backend is unavailable")
return
}
var (
method string
result any
err error
)
switch action {
case "reload":
if request.Force {
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "reload does not accept force")
return
}
method = "daemon.reload"
result, err = backend.DaemonReload(r.Context())
case "refresh":
method = "daemon.refresh"
result, err = backend.DaemonRefresh(r.Context(), request.Force)
case "restart":
if request.Force {
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "restart does not accept force")
return
}
method = "daemon.restart"
result, err = backend.DaemonRestart(r.Context())
case "sync":
method = "resource.sync"
result, err = backend.ResourceSync(r.Context(), request.Force)
case "verify":
if request.Force {
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "verify does not accept force")
return
}
method = "resource.verify"
result, err = backend.ResourceVerify(r.Context())
case "repair":
if request.Force {
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "repair does not accept force")
return
}
method = "resource.repair"
result, err = backend.ResourceRepair(r.Context())
case "catalog-refresh":
method = "catalog.refresh"
result, err = backend.CatalogRefresh(r.Context(), request.Force)
case "stop", "clean-stable":
writeErrorJSON(w, http.StatusForbidden, "control_not_allowed", "control action is not exposed by bat-api")
return
default:
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action")
return
}
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: method,
Status: "accepted",
Result: result,
})
}
func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool {
if s.cfg.AuthToken == "" {
writeErrorJSON(w, http.StatusForbidden, "admin_auth_required", "admin controls require BAT_API_AUTH_TOKEN")
return false
}
if !constantTimeTokenEqual(s.requestToken(r), s.cfg.AuthToken) {
w.Header().Set("WWW-Authenticate", `Bearer realm="bat-api-admin"`)
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized", "missing or invalid access token")
return false
}
return true
}
func decodeAdminControlRequest(w http.ResponseWriter, r *http.Request) (adminControlRequest, bool) {
var request adminControlRequest
if r.Body == nil {
return request, true
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, adminControlMaxBodyBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
if errors.Is(err, io.EOF) {
return request, true
}
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must be a JSON object with an optional force boolean")
return adminControlRequest{}, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must contain exactly one JSON object")
return adminControlRequest{}, false
}
return request, true
}
func (s *Server) writeControlBackendError(w http.ResponseWriter, action string, err error) {
status := http.StatusBadGateway
code := "control_backend_failed"
message := "Rust bat rejected the control request"
switch {
case errors.Is(err, context.DeadlineExceeded):
status = http.StatusGatewayTimeout
code = "control_backend_timeout"
message = "Rust bat control request timed out"
case errors.Is(err, context.Canceled):
status = http.StatusRequestTimeout
code = "control_request_canceled"
message = "control request was canceled"
default:
var apiErr *backendrpc.APIError
if errors.As(err, &apiErr) && apiErr.Kind == "not_implemented" {
status = http.StatusNotImplemented
code = "control_not_implemented"
message = "Rust bat does not implement this control action"
}
}
s.logger.Printf("bat-api control action=%s error=%v", action, err)
writeErrorJSON(w, status, code, message)
}
+160 -6
View File
@@ -548,6 +548,46 @@ func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit in
return f.manifest, nil
}
type controlBackend struct {
*fakeBackend
calls []string
}
func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
b.calls = append(b.calls, "daemon.reload")
return &backendrpc.Ack{Command: "reload", Status: "accepted"}, nil
}
func (b *controlBackend) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
b.calls = append(b.calls, "daemon.restart")
return &backendrpc.Ack{Command: "restart", Status: "accepted"}, nil
}
func (b *controlBackend) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) {
b.calls = append(b.calls, "daemon.refresh")
return &backendrpc.Ack{Command: "refresh", Status: "accepted", Force: &force}, nil
}
func (b *controlBackend) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
b.calls = append(b.calls, "resource.sync")
return &backendrpc.TaskAccepted{TaskID: "task-sync-1", Kind: "resource.sync"}, nil
}
func (b *controlBackend) ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) {
b.calls = append(b.calls, "resource.verify")
return &backendrpc.TaskAccepted{TaskID: "task-verify-1", Kind: "resource.verify"}, nil
}
func (b *controlBackend) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) {
b.calls = append(b.calls, "resource.repair")
return &backendrpc.TaskAccepted{TaskID: "task-repair-1", Kind: "resource.repair"}, nil
}
func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
b.calls = append(b.calls, "catalog.refresh")
return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil
}
func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
root := fixtureRoot(t)
bytes := uint64(20)
@@ -558,11 +598,19 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
b := uint64(info.Size())
_ = bytes
catalogObj := map[string]any{
"available": true,
"app_version": "1.70.0",
"bundle_version": "s8tloc7lo3",
"connection_group_name": "Prod",
"addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture",
"available": true,
"status": "published",
"status_code": "official.published",
"distribution_status": "ready",
"distribution_status_code": "distribution.ready",
"app_version": "1.70.0",
"bundle_version": "s8tloc7lo3",
"connection_group_name": "Prod",
"addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture",
"game_main_config_bootstrap": map[string]any{
"server_info_data_url": "https://prod-serverinfo.bluearchiveyostar.com/server-info.json",
"default_connection_group": "Prod",
},
"version": map[string]any{
"id": "v1",
"resource_root": root,
@@ -603,6 +651,16 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
if result.DoctorHealthy == nil || !*result.DoctorHealthy {
t.Fatal("doctor healthy expected")
}
summary := result.Index.Summary()
if summary.Snapshot.Status != "published" || summary.Snapshot.StatusCode != "official.published" {
t.Fatalf("snapshot status=%q code=%q", summary.Snapshot.Status, summary.Snapshot.StatusCode)
}
if summary.Snapshot.DistributionStatusCode != "distribution.ready" {
t.Fatalf("distribution status code=%q", summary.Snapshot.DistributionStatusCode)
}
if summary.Snapshot.GameMainConfig == nil || summary.Snapshot.GameMainConfig.DefaultConnectionGroup != "Prod" {
t.Fatalf("game main config=%+v", summary.Snapshot.GameMainConfig)
}
}
func TestLoadEnvFileDoesNotOverride(t *testing.T) {
@@ -956,7 +1014,103 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
if err := json.Unmarshal(rr.Body.Bytes(), &admin); err != nil {
t.Fatal(err)
}
if admin.Status != "reserved" {
if admin.Status != "available" {
t.Fatalf("admin=%+v", admin)
}
if len(admin.Controls) == 0 || admin.Controls[0] != "/admin/control/reload" {
t.Fatalf("admin controls=%v", admin.Controls)
}
}
func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
s := NewServer(cfg, backend, nil)
tests := []struct {
name string
action string
body string
rpcMethod string
call string
}{
{name: "reload", action: "reload", rpcMethod: "daemon.reload", call: "daemon.reload"},
{name: "restart", action: "restart", rpcMethod: "daemon.restart", call: "daemon.restart"},
{name: "force sync", action: "sync", body: `{"force":true}`, rpcMethod: "resource.sync", call: "resource.sync"},
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/admin/control/"+tc.action, strings.NewReader(tc.body))
request.Header.Set("Authorization", "Bearer control-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var response AdminControlResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Action != tc.action || response.RPCMethod != tc.rpcMethod || response.Status != "accepted" {
t.Fatalf("response=%+v", response)
}
if len(backend.calls) == 0 || backend.calls[len(backend.calls)-1] != tc.call {
t.Fatalf("calls=%v", backend.calls)
}
})
}
}
func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
s := NewServer(cfg, backend, nil)
tests := []struct {
name string
action string
token string
body string
wantStatus int
wantCode string
}{
{name: "missing token", action: "repair", wantStatus: http.StatusUnauthorized, wantCode: "unauthorized"},
{name: "dangerous stop", action: "stop", token: "control-token", wantStatus: http.StatusForbidden, wantCode: "control_not_allowed"},
{name: "unknown action", action: "arbitrary-rpc", token: "control-token", wantStatus: http.StatusNotFound, wantCode: "control_not_found"},
{name: "invalid parameters", action: "repair", token: "control-token", body: `{"force":true}`, wantStatus: http.StatusBadRequest, wantCode: "invalid_control_params"},
{name: "restart invalid parameters", action: "restart", token: "control-token", body: `{"force":true}`, wantStatus: http.StatusBadRequest, wantCode: "invalid_control_params"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/admin/control/"+tc.action, strings.NewReader(tc.body))
if tc.token != "" {
request.Header.Set("Authorization", "Bearer "+tc.token)
}
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != tc.wantStatus {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var response ErrorResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Error.Code != tc.wantCode {
t.Fatalf("error=%+v", response.Error)
}
})
}
if len(backend.calls) != 0 {
t.Fatalf("rejected actions reached backend: %v", backend.calls)
}
}
+112
View File
@@ -0,0 +1,112 @@
package api
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"bat-api/internal/backendrpc"
)
func readContractFixture(t *testing.T, name string) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join("testdata", "contract", name))
if err != nil {
t.Fatalf("read contract fixture %s: %v", name, err)
}
return data
}
func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
availableRaw := readContractFixture(t, "catalog-status.available.json")
unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json")
manifestRaw := readContractFixture(t, "resource-manifest.page0.json")
snapshotRaw := readContractFixture(t, "official-sync-snapshot.json")
for name, raw := range map[string][]byte{
"catalog available": availableRaw,
"catalog unavailable": unavailableRaw,
"resource manifest": manifestRaw,
"snapshot": snapshotRaw,
} {
if bytes.Contains(raw, []byte("/tmp/")) {
t.Fatalf("%s contains an absolute temporary path", name)
}
if bytes.Contains(raw, []byte("r93_fixture")) {
t.Fatalf("%s contains a concrete release token", name)
}
}
summary, resourceRoot, available := parseCatalogStatus(availableRaw)
if !available || summary == nil {
t.Fatal("available catalog fixture was not accepted")
}
if summary.Status != "published" || summary.StatusCode != "official.published" {
t.Fatalf("catalog status=%+v", summary)
}
if summary.DistributionStatusCode != "distribution.ready" {
t.Fatalf("distribution status=%q", summary.DistributionStatusCode)
}
if resourceRoot != "${RESOURCE_ROOT}" || summary.VersionID != "${VERSION_ID}" {
t.Fatalf("catalog root/version=%q/%q", resourceRoot, summary.VersionID)
}
if summary.LauncherMetadata == nil || summary.GameMainConfig == nil {
t.Fatalf("catalog optional objects missing: %+v", summary)
}
if summary.LauncherMetadata.GameLowestVersion != "" {
t.Fatalf("null optional field became non-empty: %q", summary.LauncherMetadata.GameLowestVersion)
}
if summary.GameMainConfig.DefaultConnectionGroup != "${CONNECTION_GROUP}" {
t.Fatalf("game config=%+v", summary.GameMainConfig)
}
if got, _, ok := parseCatalogStatus(unavailableRaw); got != nil || ok {
t.Fatalf("unavailable catalog parsed as available: got=%+v ok=%v", got, ok)
}
var manifest backendrpc.ResourceManifestPage
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
t.Fatalf("decode resource manifest: %v", err)
}
if !manifest.Available || manifest.ManifestVersion != 1 || manifest.TotalEntries != 2 {
t.Fatalf("manifest header=%+v", manifest)
}
if len(manifest.Entries) != 2 {
t.Fatalf("manifest entries=%d", len(manifest.Entries))
}
if manifest.Entries[0].Bytes == nil || *manifest.Entries[0].Bytes != 21 {
t.Fatalf("manifest first bytes=%v", manifest.Entries[0].Bytes)
}
if manifest.Entries[0].BLAKE3 == "" || !strings.Contains(manifest.Entries[0].Destination, "{addressables-root}") {
t.Fatalf("manifest first entry=%+v", manifest.Entries[0])
}
var snapshot struct {
AppVersion string `json:"app_version"`
BundleVersion string `json:"bundle_version"`
GameMainConfigBootstrap *GameMainConfigSummary `json:"game_main_config_bootstrap"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata"`
LegacyGameMainConfig json.RawMessage `json:"game_main_config"`
}
if err := json.Unmarshal(snapshotRaw, &snapshot); err != nil {
t.Fatalf("decode snapshot: %v", err)
}
if snapshot.AppVersion != "${APP_VERSION}" || snapshot.BundleVersion != "${BUNDLE_VERSION}" {
t.Fatalf("snapshot versions=%q/%q", snapshot.AppVersion, snapshot.BundleVersion)
}
if snapshot.GameMainConfigBootstrap == nil || snapshot.GameMainConfigBootstrap.ServerInfoDataURL == "" {
t.Fatalf("snapshot game config=%+v", snapshot.GameMainConfigBootstrap)
}
if snapshot.GameMainConfigBootstrap.ServerInfoDataURL != "https://yostar-serverinfo.bluearchiveyostar.com/{server-info-path}" {
t.Fatalf("snapshot server info=%q", snapshot.GameMainConfigBootstrap.ServerInfoDataURL)
}
if snapshot.LauncherMetadata == nil || snapshot.LauncherMetadata.ManifestSource == "" {
t.Fatalf("snapshot launcher metadata=%+v", snapshot.LauncherMetadata)
}
if len(snapshot.LegacyGameMainConfig) != 0 {
t.Fatalf("legacy game_main_config field unexpectedly present: %s", snapshot.LegacyGameMainConfig)
}
}
+36 -3
View File
@@ -9,7 +9,7 @@ const openAPISpecYAML = `openapi: 3.0.3
info:
title: BlueArchive Toolkit bat-api
version: 0.1.0
description: Resource bootstrap and read-only distribution API.
description: Resource bootstrap, read-only distribution, and authenticated Rust bat control proxy.
servers:
- url: http://127.0.0.1:18080
security:
@@ -111,10 +111,43 @@ paths:
description: OpenAPI YAML.
/admin/:
get:
summary: Reserved admin panel entry
summary: Admin control entry
responses:
"200":
description: Admin panel placeholder and links.
description: Admin links and allowlisted control actions.
/admin/control/{action}:
post:
summary: Forward an allowlisted control action to Rust bat
parameters:
- name: action
in: path
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh]
requestBody:
required: false
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
force:
type: boolean
responses:
"202":
description: Rust bat accepted the control request.
"400":
description: Invalid action parameters.
"401":
description: Missing or invalid admin token.
"403":
description: Control is not exposed or no admin token is configured.
"501":
description: Rust bat does not implement the requested control action.
"502":
description: Rust bat rejected the control request.
/prod-clientpatch.bluearchiveyostar.com/{path}:
get:
summary: CDN-shaped resource bytes
+12 -8
View File
@@ -22,14 +22,18 @@ type ResourceEntry struct {
// SnapshotSummary is a subset of official-sync-snapshot.json / catalog.status.
type SnapshotSummary struct {
AppVersion string `json:"app_version,omitempty"`
BundleVersion string `json:"bundle_version,omitempty"`
ConnectionGroupName string `json:"connection_group_name,omitempty"`
AddressablesRoot string `json:"addressables_root,omitempty"`
VersionID string `json:"version_id,omitempty"`
CompletedUnixSeconds *uint64 `json:"completed_unix_seconds,omitempty"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"`
GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"`
Status string `json:"status,omitempty"`
StatusCode string `json:"status_code,omitempty"`
DistributionStatus string `json:"distribution_status,omitempty"`
DistributionStatusCode string `json:"distribution_status_code,omitempty"`
AppVersion string `json:"app_version,omitempty"`
BundleVersion string `json:"bundle_version,omitempty"`
ConnectionGroupName string `json:"connection_group_name,omitempty"`
AddressablesRoot string `json:"addressables_root,omitempty"`
VersionID string `json:"version_id,omitempty"`
CompletedUnixSeconds *uint64 `json:"completed_unix_seconds,omitempty"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"`
GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"`
}
// LauncherMetadataSummary mirrors the resource-relevant part of Rust's launcher metadata snapshot.
+13 -4
View File
@@ -162,10 +162,19 @@ type LauncherEnvelope[T any] struct {
}
type AdminIndexResponse struct {
Service string `json:"service"`
Panel string `json:"panel"`
Status string `json:"status"`
Links []string `json:"links"`
Service string `json:"service"`
Panel string `json:"panel"`
Status string `json:"status"`
Links []string `json:"links"`
Controls []string `json:"controls"`
}
type AdminControlResponse struct {
Service string `json:"service"`
Action string `json:"action"`
RPCMethod string `json:"rpc_method"`
Status string `json:"status"`
Result any `json:"result"`
}
func writeNoStoreJSON(w http.ResponseWriter, status int, body any) {
+79 -14
View File
@@ -23,6 +23,21 @@ type Backend interface {
ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error)
}
// ControlBackend is the explicitly allowlisted mutation subset exposed through
// the authenticated bat-api admin control surface.
//
// It intentionally does not include daemon.stop, cleanup, or generic RPC calls.
// Restart is forwarded only to Rust's lifecycle RPC; Go never execs bat itself.
type ControlBackend interface {
DaemonRestart(ctx context.Context) (*backendrpc.Ack, error)
DaemonReload(ctx context.Context) (*backendrpc.Ack, error)
DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error)
ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error)
ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error)
ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error)
CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error)
}
// RPCClient adapts *backendrpc.Client to Backend.
type RPCClient struct {
Client *backendrpc.Client
@@ -43,6 +58,48 @@ func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
return r.Client.ResourceManifest(ctx, offset, limit)
}
func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
return r.Client.DaemonRestart(ctx)
}
func (r RPCClient) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
return r.Client.DaemonReload(ctx)
}
func (r RPCClient) DaemonRefresh(ctx context.Context, force bool) (*backendrpc.Ack, error) {
return r.Client.DaemonRefresh(ctx, force)
}
func (r RPCClient) ResourceSync(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
return r.Client.ResourceSync(ctx, force)
}
func (r RPCClient) ResourceVerify(ctx context.Context) (*backendrpc.TaskAccepted, error) {
return r.Client.ResourceVerify(ctx)
}
func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted, error) {
return r.Client.ResourceRepair(ctx)
}
func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
return r.Client.CatalogRefresh(ctx, force)
}
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.ParseStatus(ctx)
}
func (r RPCClient) ParseTextUnits(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
return r.Client.ParseTextUnits(ctx, query)
}
func (r RPCClient) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
return r.Client.ParseErrors(ctx, query)
}
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.LocalizedStatus(ctx)
}
func (r RPCClient) UnityFSPatchTextAsset(ctx context.Context, params backendrpc.UnityFSTextAssetPatchParams) (json.RawMessage, error) {
return r.Client.UnityFSPatchTextAsset(ctx, params)
}
func (r RPCClient) UnityFSPatchStringField(ctx context.Context, params backendrpc.UnityFSStringFieldPatchParams) (json.RawMessage, error) {
return r.Client.UnityFSPatchStringField(ctx, params)
}
func (r RPCClient) UnityFSPatchField(ctx context.Context, params backendrpc.UnityFSFieldPatchParams) (json.RawMessage, error) {
return r.Client.UnityFSPatchField(ctx, params)
}
// DiscoverResult is the outcome of talking to the bat daemon.
type DiscoverResult struct {
@@ -195,14 +252,18 @@ func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
return nil, "", false
}
var payload struct {
Available bool `json:"available"`
AppVersion string `json:"app_version"`
BundleVersion string `json:"bundle_version"`
ConnectionGroupName string `json:"connection_group_name"`
AddressablesRoot string `json:"addressables_root"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata"`
GameMainConfig *GameMainConfigSummary `json:"game_main_config"`
Version *struct {
Available bool `json:"available"`
Status string `json:"status"`
StatusCode string `json:"status_code"`
DistributionStatus string `json:"distribution_status"`
DistributionStatusCode string `json:"distribution_status_code"`
AppVersion string `json:"app_version"`
BundleVersion string `json:"bundle_version"`
ConnectionGroupName string `json:"connection_group_name"`
AddressablesRoot string `json:"addressables_root"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata"`
GameMainConfigBootstrap *GameMainConfigSummary `json:"game_main_config_bootstrap"`
Version *struct {
ID string `json:"id"`
CompletedUnixSeconds *uint64 `json:"completed_unix_seconds"`
ResourceRoot string `json:"resource_root"`
@@ -212,12 +273,16 @@ func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
return nil, "", false
}
snap := &SnapshotSummary{
AppVersion: payload.AppVersion,
BundleVersion: payload.BundleVersion,
ConnectionGroupName: payload.ConnectionGroupName,
AddressablesRoot: payload.AddressablesRoot,
LauncherMetadata: payload.LauncherMetadata,
GameMainConfig: payload.GameMainConfig,
Status: payload.Status,
StatusCode: payload.StatusCode,
DistributionStatus: payload.DistributionStatus,
DistributionStatusCode: payload.DistributionStatusCode,
AppVersion: payload.AppVersion,
BundleVersion: payload.BundleVersion,
ConnectionGroupName: payload.ConnectionGroupName,
AddressablesRoot: payload.AddressablesRoot,
LauncherMetadata: payload.LauncherMetadata,
GameMainConfig: payload.GameMainConfigBootstrap,
}
root := ""
if payload.Version != nil {
+2
View File
@@ -61,6 +61,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig)
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
mux.HandleFunc("/admin/control/", s.handleAdminControl)
mux.HandleFunc("/admin/", s.handleAdminIndex)
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
mux.HandleFunc("/"+ClientPatchHost+"/", s.serveCDN)
@@ -124,6 +125,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/" + ServerInfoHost + "/...",
"/openapi.yaml",
"/admin/",
"/admin/control/{action}",
},
})
return
+15
View File
@@ -0,0 +1,15 @@
# Rust contract fixtures
本目录保存 Go `bat-api` 对 Rust `bat.sock` / snapshot JSON 的跨语言
contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本机路径、版本号、
时间戳和 release URL token,不改字段名、层级、null 语义或数字类型。
覆盖范围:
- `catalog.status` 可用与不可用响应。
- `resource.manifest` 第一页分页响应。
- 对应 release 的 `official-sync-snapshot.json`
- `launcher_metadata``game_main_config_bootstrap` 的 Go mirror 解码。
这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live
daemon socket 或完整发布切换验证。
@@ -0,0 +1,41 @@
{
"addressables_catalog_marker_count": 0,
"addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}",
"app_version": "${APP_VERSION}",
"available": true,
"bundle_version": "${BUNDLE_VERSION}",
"connection_group_name": "${CONNECTION_GROUP}",
"distribution_status": "ready",
"distribution_status_code": "distribution.ready",
"endpoint_count": 0,
"endpoint_marker_count": 0,
"game_main_config_bootstrap": {
"default_connection_group": "${CONNECTION_GROUP}",
"server_info_data_url": "https://yostar-serverinfo.bluearchiveyostar.com/{server-info-path}"
},
"launcher_metadata": {
"game_latest_file_path": "${GAME_PACKAGE_PATH}",
"game_latest_version": "${APP_VERSION}",
"game_lowest_version": null,
"game_start_exe_name": "${GAME_START_EXE_NAME}",
"game_start_params": [
"${GAME_START_PARAM}"
],
"launcher_version": "${LAUNCHER_VERSION}",
"manifest_file_count": 2,
"manifest_source": "${LAUNCHER_MANIFEST_SOURCE}",
"manifest_url": "https://launcher-pkg-ba-jp.yo-star.com/{launcher-manifest-path}"
},
"official_seed_hash_marker_count": 0,
"snapshot_version": 2,
"status": "published",
"status_code": "official.published",
"status_phase": "official_sync",
"status_retryable": false,
"status_terminal": true,
"version": {
"completed_unix_seconds": 1000,
"id": "${VERSION_ID}",
"resource_root": "${RESOURCE_ROOT}"
}
}
@@ -0,0 +1,10 @@
{
"available": false,
"distribution_status": "blocked",
"distribution_status_code": "distribution.blocked",
"status": "unavailable",
"status_code": "official.unavailable",
"status_phase": "official_sync",
"status_retryable": false,
"status_terminal": true
}
@@ -0,0 +1,26 @@
{
"addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}",
"app_version": "${APP_VERSION}",
"bundle_version": "${BUNDLE_VERSION}",
"connection_group_name": "${CONNECTION_GROUP}",
"endpoint_markers": [],
"endpoints": [],
"game_main_config_bootstrap": {
"default_connection_group": "${CONNECTION_GROUP}",
"server_info_data_url": "https://yostar-serverinfo.bluearchiveyostar.com/{server-info-path}"
},
"launcher_metadata": {
"game_latest_file_path": "${GAME_PACKAGE_PATH}",
"game_latest_version": "${APP_VERSION}",
"game_lowest_version": null,
"game_start_exe_name": "${GAME_START_EXE_NAME}",
"game_start_params": [
"${GAME_START_PARAM}"
],
"launcher_version": "${LAUNCHER_VERSION}",
"manifest_file_count": 2,
"manifest_source": "${LAUNCHER_MANIFEST_SOURCE}",
"manifest_url": "https://launcher-pkg-ba-jp.yo-star.com/{launcher-manifest-path}"
},
"snapshot_version": 2
}
@@ -0,0 +1,22 @@
{
"available": true,
"entries": [
{
"blake3": "0000000000000000000000000000000000000000000000000000000000000000",
"bytes": 21,
"destination": "prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.bytes",
"url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.bytes"
},
{
"blake3": "0000000000000000000000000000000000000000000000000000000000000000",
"bytes": 10,
"destination": "prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash",
"url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash"
}
],
"limit": 2,
"manifest_version": 1,
"offset": 0,
"resource_root": "${RESOURCE_ROOT}",
"total_entries": 2
}
+89 -6
View File
@@ -190,14 +190,57 @@ type taskIDParam struct {
TaskID string `json:"task_id"`
}
type TextUnitQueryParams struct {
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
Destination string `json:"destination,omitempty"`
PathPattern string `json:"path_pattern,omitempty"`
ArchiveEntry string `json:"archive_entry,omitempty"`
PathID *int64 `json:"path_id,omitempty"`
ClassID *int `json:"class_id,omitempty"`
FieldPath string `json:"field_path,omitempty"`
Format string `json:"format,omitempty"`
}
type UnityFSTextAssetPatchParams struct {
BundlePath string `json:"bundle_path"`
SerializedFilePath string `json:"serialized_file_path"`
PathID int64 `json:"path_id"`
ReplacementPath string `json:"replacement_path"`
TargetPath string `json:"target_path"`
ExpectedName *string `json:"expected_name,omitempty"`
}
type UnityFSStringFieldPatchParams struct {
BundlePath string `json:"bundle_path"`
SerializedFilePath string `json:"serialized_file_path"`
PathID int64 `json:"path_id"`
FieldPath string `json:"field_path"`
ReplacementText *string `json:"replacement_text,omitempty"`
ReplacementPath string `json:"replacement_path,omitempty"`
TargetPath string `json:"target_path"`
ExpectedValue *string `json:"expected_value,omitempty"`
}
type UnityFSFieldPatchParams struct {
BundlePath string `json:"bundle_path"`
SerializedFilePath string `json:"serialized_file_path"`
PathID int64 `json:"path_id"`
FieldPath string `json:"field_path"`
Replacement json.RawMessage `json:"replacement"`
TargetPath string `json:"target_path"`
ExpectedValue json.RawMessage `json:"expected_value,omitempty"`
}
// 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"`
Command string `json:"command"`
Status string `json:"status"`
Message string `json:"message"`
StateDir string `json:"state_dir"`
SocketPath string `json:"socket_path"`
ControllerPID *int `json:"controller_pid,omitempty"`
Force *bool `json:"force,omitempty"`
}
// TaskAccepted is returned when an async backend task is queued.
@@ -265,6 +308,7 @@ type LogsReport struct {
type DaemonStatusReport struct {
Status string `json:"status"`
StatusCode *string `json:"status_code,omitempty"`
Message string `json:"message"`
Running bool `json:"running"`
PID *int `json:"pid,omitempty"`
@@ -277,6 +321,11 @@ type DaemonStatusReport struct {
}
type ResourceState struct {
Status string `json:"status,omitempty"`
StatusCode string `json:"status_code,omitempty"`
StatusPhase string `json:"status_phase,omitempty"`
StatusTerminal bool `json:"status_terminal,omitempty"`
StatusRetryable bool `json:"status_retryable,omitempty"`
ResourceOutputRoot *string `json:"resource_output_root,omitempty"`
VersionState json.RawMessage `json:"version_state,omitempty"`
LastUpdateStatus *string `json:"last_update_status,omitempty"`
@@ -318,6 +367,12 @@ func (c *Client) DaemonStop(ctx context.Context) (*Ack, error) {
return &out, err
}
func (c *Client) DaemonRestart(ctx context.Context) (*Ack, error) {
var out Ack
_, err := c.Call(ctx, "daemon.restart", 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)
@@ -390,6 +445,34 @@ func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted,
return &out, err
}
func (c *Client) ParseStatus(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "parse.status", nil)
}
func (c *Client) ParseTextUnits(ctx context.Context, query TextUnitQueryParams) (json.RawMessage, error) {
return c.rawData(ctx, "parse.text_units", query)
}
func (c *Client) ParseErrors(ctx context.Context, query TextUnitQueryParams) (json.RawMessage, error) {
return c.rawData(ctx, "parse.errors", query)
}
func (c *Client) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "localized.status", nil)
}
func (c *Client) UnityFSPatchTextAsset(ctx context.Context, params UnityFSTextAssetPatchParams) (json.RawMessage, error) {
return c.rawData(ctx, "unityfs.patch_text_asset", params)
}
func (c *Client) UnityFSPatchStringField(ctx context.Context, params UnityFSStringFieldPatchParams) (json.RawMessage, error) {
return c.rawData(ctx, "unityfs.patch_string_field", params)
}
func (c *Client) UnityFSPatchField(ctx context.Context, params UnityFSFieldPatchParams) (json.RawMessage, error) {
return c.rawData(ctx, "unityfs.patch_field", params)
}
func (c *Client) TaskStatus(ctx context.Context, taskID string) (*TaskRecord, error) {
var out TaskRecord
_, err := c.Call(ctx, "task.status", taskIDParam{TaskID: taskID}, &out)
+114
View File
@@ -88,6 +88,120 @@ func TestResourceRepairQueuesTask(t *testing.T) {
}
}
func TestDaemonRestartSendsControlMethod(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "daemon.restart" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "accepted",
RequestID: "req-test-restart",
Data: map[string]any{
"command": "restart",
"status": "accepted",
"message": "restart accepted",
"state_dir": "/tmp/bat-pid",
"socket_path": "/tmp/bat-pid/bat.sock",
"controller_pid": 4242,
},
},
}
})
ack, err := client.DaemonRestart(context.Background())
if err != nil {
t.Fatalf("DaemonRestart error: %v", err)
}
if ack.Command != "restart" || ack.ControllerPID == nil || *ack.ControllerPID != 4242 {
t.Fatalf("unexpected ack: %#v", ack)
}
}
func TestParseTextUnitsSendsQuery(t *testing.T) {
pathID := int64(7)
classID := 114
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "parse.text_units" {
t.Fatalf("method = %s", req.Method)
}
var params TextUnitQueryParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.Offset != 3 || params.Limit != 5 || params.Destination != "*Table*" || params.PathID == nil || *params.PathID != pathID || params.ClassID == nil || *params.ClassID != classID {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-parse",
Data: map[string]any{
"available": true,
"entries": []any{},
},
},
}
})
raw, err := client.ParseTextUnits(context.Background(), TextUnitQueryParams{
Offset: 3,
Limit: 5,
Destination: "*Table*",
PathID: &pathID,
ClassID: &classID,
})
if err != nil {
t.Fatalf("ParseTextUnits error: %v", err)
}
if !json.Valid(raw) {
t.Fatalf("invalid raw JSON: %s", string(raw))
}
}
func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "unityfs.patch_field" {
t.Fatalf("method = %s", req.Method)
}
var params UnityFSFieldPatchParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.BundlePath != "/tmp/source.bundle" || params.FieldPath != "m_Name" || string(params.Replacement) != `{"kind":"string","value":"new text"}` {
t.Fatalf("params = %#v replacement=%s", params, string(params.Replacement))
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-unityfs",
Data: map[string]any{
"command": "unityfs.patch_field",
"status": "completed",
},
},
}
})
raw, err := client.UnityFSPatchField(context.Background(), UnityFSFieldPatchParams{
BundlePath: "/tmp/source.bundle",
SerializedFilePath: "CAB-test",
PathID: 1,
FieldPath: "m_Name",
Replacement: json.RawMessage(`{"kind":"string","value":"new text"}`),
TargetPath: "/tmp/target.bundle",
})
if err != nil {
t.Fatalf("UnityFSPatchField error: %v", err)
}
if !json.Valid(raw) {
t.Fatalf("invalid raw JSON: %s", string(raw))
}
}
func TestResourceListSendsPagination(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "resource.list" {
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${repo_root}"
fail() {
printf 'doc status check failed: %s\n' "$*" >&2
exit 1
}
require_file() {
local file="$1"
[[ -f "${file}" ]] || fail "missing file: ${file}"
}
require_contains() {
local file="$1"
local needle="$2"
grep -Fq -- "${needle}" "${file}" || fail "${file} missing required text: ${needle}"
}
require_regex() {
local file="$1"
local pattern="$2"
grep -Eiq -- "${pattern}" "${file}" || fail "${file} missing required pattern: ${pattern}"
}
required_docs=(
"CURRENT_STATUS.md"
"USERGUIDE.md"
"PROJECT_PLAN.md"
"docs/architecture/assetbundle.md"
"docs/guides/development.md"
"docs/reference/rpc-backend-api.md"
"docs/reports/CURRENT_GAPS.md"
"docs/reports/GO_STATUS.md"
"docs/reports/PARSER_FREEZE.md"
"internal/api/testdata/contract/README.md"
)
for file in "${required_docs[@]}"; do
require_file "${file}"
done
placeholder_readmes=(
"api/README.md"
"api/proto/README.md"
"internal/config/README.md"
"internal/downloader/README.md"
"internal/extractor/README.md"
"internal/manifest/README.md"
"internal/storage/README.md"
"pkg/README.md"
"pkg/cas/README.md"
"pkg/translator/README.md"
"pkg/types/README.md"
"web/README.md"
"web/admin/README.md"
"web/shared/README.md"
)
for file in "${placeholder_readmes[@]}"; do
require_file "${file}"
require_regex "${file}" "not implemented"
require_contains "${file}" "docs/reports/GO_STATUS.md"
done
parser_freeze_docs=(
"CURRENT_STATUS.md"
"PROJECT_PLAN.md"
"docs/architecture/assetbundle.md"
"docs/guides/development.md"
"docs/reports/CURRENT_GAPS.md"
)
for file in "${parser_freeze_docs[@]}"; do
require_contains "${file}" "docs/reports/PARSER_FREEZE.md"
done
require_contains "USERGUIDE.md" "scope=resource_bootstrap_only"
require_contains "USERGUIDE.md" "package_update_manifest=false"
require_contains "docs/reports/GO_STATUS.md" "resource bootstrap + CDN path"
require_contains "docs/reports/GO_STATUS.md" "internal/api/testdata/contract/"
require_contains "CURRENT_STATUS.md" "cmd/bat-api"
require_contains "CURRENT_STATUS.md" "internal/api/testdata/contract/"
require_contains "docs/reports/CURRENT_GAPS.md" "非完整官方游戏 API"
require_contains "docs/reports/CURRENT_GAPS.md" "daemon.clean-stable"
require_contains "docs/reference/rpc-backend-api.md" "daemon.restart"
require_contains "docs/reference/rpc-backend-api.md" "daemon.clean-stable"
require_contains "docs/reference/rpc-backend-api.md" "localized_release_status"
require_contains "Makefile" "check-docs:"
require_contains ".gitea/workflows/bat.yml" "make check-docs"
require_contains ".gitea/workflows/bat.yml" "make test-go-api"
require_contains ".gitea/workflows/bat.yml" "go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/..."
require_contains ".gitea/workflows/bat.yml" "go build -o /tmp/bat-api ./cmd/bat-api"
printf 'doc status check ok\n'
+2 -1
View File
@@ -7,4 +7,5 @@ and OpenAPI.
The production panel must reuse the same HTTP authentication, rate limiting,
access logging, reverse-proxy handling, and no-store dynamic response policy as
the resource API. Static frontend assets are not implemented yet.
the resource API. Static frontend assets are not implemented yet. See
`docs/reports/GO_STATUS.md` for the current component boundary and status.