mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
Compare commits
19
Commits
03021ad649
...
7c863d10d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c863d10d1
|
||
|
|
9a5b3ba39b
|
||
|
|
2b053e247d
|
||
|
|
ba28e067c9
|
||
|
|
6442c7661d
|
||
|
|
8b64cc94f3
|
||
|
|
d533c88108
|
||
|
|
12c5d365ab
|
||
|
|
a05d3ee6af
|
||
|
|
8d930bf4d7
|
||
|
|
80e6718e8a
|
||
|
|
df361cff28
|
||
|
|
1e466d3374
|
||
|
|
6af7706190
|
||
|
|
20ddd67947
|
||
|
|
4cc143f66d
|
||
|
|
99e6b3a23a
|
||
|
|
16e73327b4
|
||
|
|
8ec0e12795
|
@@ -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
|
||||
|
||||
@@ -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
@@ -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 fixture,Go 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 #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list/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,不走 FFI(FFI 降级说明见 `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 #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/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,不走 FFI(FFI 降级说明见 `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 #3(P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。
|
||||
5. Issue #2(P1):继续逆向 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 path;issue #19 剩余)。
|
||||
3. 跟进官方同步长期运行测试报告。
|
||||
4. AssetBundle / Addressables(issue #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 解析和重打包。
|
||||
|
||||
@@ -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
@@ -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 4:Unity 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. 继续 Addressables(issue #2)与 UnityFS(issue #3 / G-005)。
|
||||
5. 将 `crowdin-textunit-queue.json` 接入真实 Crowdin worker、翻译记忆和 Patch 构建。
|
||||
6. 继续扩展 G-011 剩余查询面:翻译任务状态、`doctor cas` 诊断入口和更丰富 TextUnit 查询。
|
||||
6. 继续扩展 G-011 剩余查询面:真实 worker 状态写回、`doctor cas` 诊断入口和面向大索引的查询优化。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI 和管理面板预留;`.env` 配置端口/RPC socket/刷新周期;生产资源根来自 RPC,不负责自动拉取。
|
||||
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(issue #19 / G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN 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 socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh`/`repair` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。
|
||||
`status`、`stop`、`restart`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 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
@@ -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`。
|
||||
|
||||
所有动态 JSON(bootstrap、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 暴露。
|
||||
|
||||
所有动态 JSON(bootstrap、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`(正常状态而非错误,便于调用方直接分支)。
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¤t) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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,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 plane;PID、状态和日志文件是快照与 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。
|
||||
|
||||
@@ -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),冻结期只接受稳定性、诊断、真实回归和文档一致性修复。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/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 log,stdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend(`daemon.status/logs/stop/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 层职责边界
|
||||
|
||||
|
||||
@@ -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 发布前置链路已可用。
|
||||
|
||||
优先阅读:
|
||||
|
||||
@@ -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。
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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 error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。
|
||||
|
||||
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产官方资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取原版资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。汉化 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`
|
||||
|
||||
@@ -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 reason;schema 由 `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 的隔离环境中单独验证。
|
||||
|
||||
@@ -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、rustfmt 和 clippy,避免准备阶段因第三方 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、rustfmt、clippy 和 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 #1:RPC 主体、文件级 `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-007(P1):Addressables 可校验字段。
|
||||
|
||||
@@ -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 controller,Go 不直接执行 `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-RPC;fake 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 固定 ok;manifest/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(¶ms)?;
|
||||
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(¶ms)?;
|
||||
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(¶ms)?;
|
||||
print_report(options.output_format, &report)
|
||||
}
|
||||
CliCommand::UnityFsPatchField => {
|
||||
let params = unityfs_field_params_from_options(options)?;
|
||||
let report = apply_unityfs_field_patch_file(¶ms)?;
|
||||
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(())
|
||||
}
|
||||
@@ -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(¤t.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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ¶ms); 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, ¶ms); 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" {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user