mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
fix(api): 补齐 bat-api 控制与后端 RPC
This commit is contained in:
+4
-4
@@ -264,7 +264,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
--watch
|
--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 门禁验证。`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。
|
||||||
|
|
||||||
生产要求:
|
生产要求:
|
||||||
|
|
||||||
@@ -284,8 +284,8 @@ GitHub issue 状态:#1 已关闭;#17 已按 wontfix 关闭(多线程下载
|
|||||||
|
|
||||||
下一阶段必须优先完成:
|
下一阶段必须优先完成:
|
||||||
|
|
||||||
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)。
|
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)、`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 增量缓存和可选持久化。
|
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 构建和翻译任务状态查询。
|
3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成并可查询 TextUnit 明细;剩余为真实 Crowdin worker、翻译记忆、Patch 构建和翻译任务状态查询。
|
||||||
4. Issue #3(P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。
|
4. Issue #3(P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。
|
||||||
5. Issue #2(P1):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
|
5. Issue #2(P1):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。
|
||||||
@@ -299,7 +299,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 剩余)。
|
2. `bat-api` 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、server-info 和 CDN path;issue #19 剩余)。
|
||||||
3. 跟进官方同步长期运行测试报告。
|
3. 跟进官方同步长期运行测试报告。
|
||||||
4. AssetBundle / Addressables(issue #3 / #2);CAS 用户级导入(G-011)。
|
4. AssetBundle / Addressables(issue #3 / #2);CAS 用户级导入(G-011)。
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。
|
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。
|
||||||
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
||||||
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
|
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
|
||||||
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、顺序下载、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `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 分类重试、指数退避、顺序下载、下载 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 的默认路径。
|
- `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`)。
|
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(G-008 已关闭:同步 CLI = Rust `bat`)。
|
||||||
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||||
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC 可按类型、hash、路径模式分页查询索引。
|
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC 可按类型、hash、路径模式分页查询索引。
|
||||||
|
|||||||
+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 /yostar-serverinfo.bluearchiveyostar.com/server-info.json` | 官方 host/path 形态的 server-info |
|
||||||
| `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 |
|
| `GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...` | 官方 CDN path 形态资源字节 |
|
||||||
| `GET /openapi.yaml` | bat-api OpenAPI 文档 |
|
| `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,也不仿造登录、账号、网关、鉴权或游戏业务协议。
|
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 鉴权、限流和访问日志:
|
生产面对玩家分发时,应启用 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_QUERY_PARAM`:query fallback 参数名,默认 `bat_token`;兼容不能写 header 的客户端,访问日志不会记录 query。
|
||||||
- `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`。
|
- `BAT_API_AUTH_EXEMPT_PATHS`:逗号分隔的免鉴权 path 或 slash-prefix,例如 `/healthz,/readyz`。
|
||||||
- `BAT_API_RATE_LIMIT_RPS` / `BAT_API_RATE_LIMIT_BURST`:按客户端 IP 的进程内 token bucket 限流;边缘反代/CDN 仍应配置独立限流。
|
- `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_ACCESS_LOG`:结构化访问日志,记录 method/path/status/bytes/duration/client_ip/request_id/user_agent,不记录 query string。
|
||||||
- `BAT_API_MAX_RESOURCE_LIMIT`:`/v1/resources` 最大分页上限,默认 `1000`。
|
- `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`。
|
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.stop` | ✅ | 请求停止(`accepted`) |
|
||||||
| `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted`) |
|
| `daemon.reload` | ✅ | 请求重新发现并强制刷新(`accepted`) |
|
||||||
| `daemon.refresh` | ✅ | 请求刷新检查(`params.force`,`accepted`) |
|
| `daemon.refresh` | ✅ | 请求刷新检查(`params.force`,`accepted`) |
|
||||||
|
| `daemon.restart` | ✅ | 启动 Rust lifecycle controller,并在响应后停止当前 daemon(`accepted`) |
|
||||||
| `daemon.doctor` | ✅ | 返回运行时诊断报告(只读,不清理、不重启) |
|
| `daemon.doctor` | ✅ | 返回运行时诊断报告(只读,不清理、不重启) |
|
||||||
| `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 |
|
| `resource.state` | ✅ | 资源发布根 + 版本状态 + 上次同步结果 |
|
||||||
| `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` |
|
| `resource.sync` | ✅ | 触发同步任务(`params.force`),返回 `task_id` |
|
||||||
@@ -374,7 +390,7 @@ curl -i -H 'Range: bytes=0-1023' \
|
|||||||
| `task.list` | ✅ | 列出全部任务(最新在前) |
|
| `task.list` | ✅ | 列出全部任务(最新在前) |
|
||||||
| `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 |
|
| `task.cancel` | ✅ | 请求取消任务(`params.task_id`);协作式,在同步检查点生效 |
|
||||||
| `task.logs` | ✅ | 返回任务的进度日志(`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) |
|
| 未知方法 | — | `BAT-ERR-700001`(unknown method) |
|
||||||
|
|
||||||
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
|
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ CAS 诊断入口和更丰富查询。
|
|||||||
|
|
||||||
维护期特殊分支:如果官方 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 证据,供后续排障和自研客户端开发使用。
|
维护期特殊分支:如果官方 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 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
|
||||||
|
|
||||||
对应实现主要在:
|
对应实现主要在:
|
||||||
|
|
||||||
@@ -269,7 +269,7 @@ Linux 生产路径:
|
|||||||
- pull plan 会同时包含 discovery URLs 和 content URLs
|
- pull plan 会同时包含 discovery URLs 和 content URLs
|
||||||
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
|
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
|
||||||
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
|
||||||
- `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress 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`、`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
|
- curl 失败分类和重试策略已覆盖 404 不重试、5xx 重试耗尽后 quarantine、launcher primary CDN 失败后切换 official backup CDN
|
||||||
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因
|
- `official-version-state.json` 已覆盖当前完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,重新拉取或成功发布后清理同版本失败记录,同版本失败 staging 会在路径安全且未发布时复用,`bat status` 会暴露版本状态摘要和最近历史失败原因
|
||||||
- 资源导入链路已覆盖可配置 CAS 写入、`ResourceRepository` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类;`resource.index` 可只读查询现有索引
|
- 资源导入链路已覆盖可配置 CAS 写入、`ResourceRepository` 索引、`metadata_json` release/平台/bundle/TextAsset/TextUnit 摘要,以及 TextAsset/Table/Media 分类;`resource.index` 可只读查询现有索引
|
||||||
@@ -321,11 +321,13 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
|||||||
(版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务
|
(版本化、`0600` 原子写,生命周期转换时落盘),daemon 重启后历史任务
|
||||||
仍可经 `task.*` 查询,中断任务标记 `task_interrupted`(700005)。
|
仍可经 `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`、
|
`resource.state/sync/verify/repair/manifest/list/index`、`parse.status/text_units/errors`、
|
||||||
`localized.status`、`catalog.*` 与 `task.status/list/cancel/logs` 已实现;
|
`localized.status`、`catalog.*` 与 `task.status/list/cancel/logs` 已实现;
|
||||||
`patch.*` / `unityfs.*` 待引擎;`task.create` 按设计暂不开放通用任务入口;
|
文件级 `patch.apply` / `unityfs.patch_*` 已实现,发布级 patch 与复杂 UnityFS 语义编辑待引擎;
|
||||||
`daemon.restart` / `daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
`task.create` 按设计暂不开放通用任务入口;
|
||||||
|
`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径;
|
||||||
|
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||||
|
|
||||||
### 7.2 Go 层职责边界
|
### 7.2 Go 层职责边界
|
||||||
|
|
||||||
|
|||||||
@@ -83,13 +83,17 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主
|
|||||||
| `daemon.status` | 已实现 | `null` | 后台状态报告。 |
|
| `daemon.status` | 已实现 | `null` | 后台状态报告。 |
|
||||||
| `daemon.logs` | 已实现 | `{ "tail": 200 }` | 日志尾部报告。 |
|
| `daemon.logs` | 已实现 | `{ "tail": 200 }` | 日志尾部报告。 |
|
||||||
| `daemon.stop` | 已实现 | `null` | accepted ack。 |
|
| `daemon.stop` | 已实现 | `null` | accepted ack。 |
|
||||||
|
| `daemon.restart` | 已实现 | `null` | accepted ack;启动 Rust lifecycle controller,并在响应后停止当前 daemon。 |
|
||||||
| `daemon.reload` | 已实现 | `null` | accepted ack。 |
|
| `daemon.reload` | 已实现 | `null` | accepted ack。 |
|
||||||
| `daemon.refresh` | 已实现 | `{ "force": false }` | accepted ack。 |
|
| `daemon.refresh` | 已实现 | `{ "force": false }` | accepted ack。 |
|
||||||
| `daemon.doctor` | 已实现 | `null` | 只读诊断报告。 |
|
| `daemon.doctor` | 已实现 | `null` | 只读诊断报告。 |
|
||||||
| `daemon.restart` | 保留 | `null` | live RPC 不执行;由 CLI 生命周期入口处理。 |
|
|
||||||
| `daemon.clean-stable` | 保留 | `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.*`。
|
`bat.doctor`、`bat.clean-stable` 是兼容别名;新代码应使用 `daemon.*`。
|
||||||
|
|
||||||
### resource
|
### resource
|
||||||
@@ -303,7 +307,9 @@ CLI 对应关系:
|
|||||||
|
|
||||||
`bat-api` 应直接调用本 RPC contract,不通过 `exec` 调用 `bat` binary。
|
`bat-api` 应直接调用本 RPC contract,不通过 `exec` 调用 `bat` binary。
|
||||||
`bat` binary 是人类 CLI 和进程生命周期工具;默认 `refresh` / `repair`
|
`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 对应关系如下:
|
人类 CLI 的只读查询命令与 RPC 对应关系如下:
|
||||||
|
|
||||||
|
|||||||
@@ -192,5 +192,5 @@ contract fixture 工作只有在以下条件同时满足时才算完成:
|
|||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
- Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config_bootstrap` 的 mirror struct。
|
- Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config_bootstrap` 的 mirror struct。
|
||||||
- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理面板预留。
|
- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理控制白名单。
|
||||||
- contract fixture 尚未落仓库,等待 Rust 侧真实输出与用户审核。
|
- contract fixture 尚未落仓库,等待 Rust 侧真实输出与用户审核。
|
||||||
|
|||||||
@@ -242,7 +242,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`
|
- 进度权威:`docs/reports/GO_STATUS.md`
|
||||||
|
|
||||||
验收(剩余):
|
验收(剩余):
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
| 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata |
|
| 资源发现 | 读取官方 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` |
|
| 下载与发布 | 下载、校验、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,组织给客户端/补丁器使用 |
|
| 启动前资源入口 | 暴露 `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 下载器或伪装完整游戏业务服务。
|
这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。
|
||||||
|
|
||||||
@@ -72,11 +72,12 @@
|
|||||||
| ID | 约定 |
|
| ID | 约定 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| K | `.env` / 环境变量 / CLI:端口、public base、RPC socket、RPC 刷新周期;**预留** database/redis |
|
| 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、长期缓存头 |
|
| M | CDN:`GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...`,支持 Range、ETag、Last-Modified、长期缓存头 |
|
||||||
| N | server-info 可选;**只改 AddressablesCatalogUrlRoot** |
|
| N | server-info 可选;**只改 AddressablesCatalogUrlRoot** |
|
||||||
| N2 | launcher 兼容仅限资源引导:`/v1/launcher/bootstrap` 与 `/api/launcher/...` 形状端点输出已发布 release、launcher metadata 和 GameMainConfig 摘要;不下载 launcher 包、不生成完整 PC package update manifest、不仿造登录/网关 |
|
| 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` 分页上限 |
|
| N3 | 玩家-facing HTTP 控制面:可配置 token 鉴权、进程内限流、访问日志、反代 IP 适配、动态 JSON `no-store`、`/v1/resources` 分页上限 |
|
||||||
|
| N4 | `/admin/control/{action}` 白名单控制面;`restart` 通过 Rust live RPC 启动 lifecycle controller,Go 不直接执行 `bat` binary |
|
||||||
|
|
||||||
### 工程
|
### 工程
|
||||||
|
|
||||||
@@ -94,8 +95,8 @@
|
|||||||
| 组件 | 路径 | 状态 | 说明 |
|
| 组件 | 路径 | 状态 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
||||||
| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPC;fake transport 单测 |
|
| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPC;覆盖 daemon restart、resource/catalog/task、parse/localized 和文件级 UnityFS patch 调用;fake transport 单测 |
|
||||||
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理面预留 + `.env` |
|
| 资源 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 |
|
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
||||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||||
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
||||||
@@ -132,7 +133,7 @@ make build-go-cli # 产出 bin/bat-go
|
|||||||
| 项 | 状态 |
|
| 项 | 状态 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat`) |
|
| 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** |
|
| issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** |
|
||||||
| G-010 Web | 未开始 |
|
| G-010 Web | 未开始 |
|
||||||
|
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
registry,
|
registry,
|
||||||
queue: task_tx,
|
queue: task_tx,
|
||||||
base_config: options.config.clone(),
|
base_config: options.config.clone(),
|
||||||
|
restart_controller: spawn_daemon_restart_controller,
|
||||||
};
|
};
|
||||||
let server =
|
let server =
|
||||||
start_daemon_rpc_server(&daemon_state_dir, Arc::clone(control), context.clone())?;
|
start_daemon_rpc_server(&daemon_state_dir, Arc::clone(control), context.clone())?;
|
||||||
@@ -757,6 +758,8 @@ struct DaemonRpcAck {
|
|||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
socket_path: PathBuf,
|
socket_path: PathBuf,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
controller_pid: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
force: Option<bool>,
|
force: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1295,8 +1298,11 @@ struct DaemonTaskContext {
|
|||||||
registry: TaskRegistry,
|
registry: TaskRegistry,
|
||||||
queue: mpsc::Sender<TaskJob>,
|
queue: mpsc::Sender<TaskJob>,
|
||||||
base_config: OfficialUpdateConfig,
|
base_config: OfficialUpdateConfig,
|
||||||
|
restart_controller: DaemonRestartController,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DaemonRestartController = fn(&Path) -> anyhow::Result<u32>;
|
||||||
|
|
||||||
/// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。
|
/// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。
|
||||||
///
|
///
|
||||||
/// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败);
|
/// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败);
|
||||||
@@ -1393,14 +1399,12 @@ fn canonical_rpc_method(method: &str) -> &str {
|
|||||||
fn is_pending_rpc_method(method: &str) -> bool {
|
fn is_pending_rpc_method(method: &str) -> bool {
|
||||||
// task.create:任务统一由 resource.sync / resource.verify / resource.repair / catalog.refresh
|
// task.create:任务统一由 resource.sync / resource.verify / resource.repair / catalog.refresh
|
||||||
// 等语义方法创建,通用创建接口暂不开放。
|
// 等语义方法创建,通用创建接口暂不开放。
|
||||||
// daemon.restart / daemon.clean-stable:CLI 侧按进程生命周期处理;
|
// daemon.clean-stable:CLI 侧按进程生命周期处理;
|
||||||
// live RPC 内不做自重启或在线清理。
|
// live RPC 内不做在线清理。
|
||||||
// patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建、复杂
|
// patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建、复杂
|
||||||
// UnityFS 语义编辑和 inspect 等子命令仍未开放。
|
// UnityFS 语义编辑和 inspect 等子命令仍未开放。
|
||||||
matches!(
|
matches!(method, "task.create" | RPC_METHOD_CLEAN_STABLE)
|
||||||
method,
|
|| (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY)
|
||||||
"task.create" | RPC_METHOD_RESTART | RPC_METHOD_CLEAN_STABLE
|
|
||||||
) || (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY)
|
|
||||||
|| (method.starts_with("unityfs.")
|
|| (method.starts_with("unityfs.")
|
||||||
&& method != RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET
|
&& method != RPC_METHOD_UNITYFS_PATCH_TEXT_ASSET
|
||||||
&& method != RPC_METHOD_UNITYFS_PATCH_STRING_FIELD
|
&& method != RPC_METHOD_UNITYFS_PATCH_STRING_FIELD
|
||||||
@@ -1997,7 +2001,10 @@ fn handle_daemon_rpc_client(
|
|||||||
let mut notify_stop_after_response = false;
|
let mut notify_stop_after_response = false;
|
||||||
let response = match serde_json::from_str::<JsonRpcRequest>(&line) {
|
let response = match serde_json::from_str::<JsonRpcRequest>(&line) {
|
||||||
Ok(request) => {
|
Ok(request) => {
|
||||||
notify_stop_after_response = request.method == RPC_METHOD_STOP;
|
notify_stop_after_response = matches!(
|
||||||
|
canonical_rpc_method(&request.method),
|
||||||
|
RPC_METHOD_STOP | RPC_METHOD_RESTART
|
||||||
|
);
|
||||||
handle_daemon_rpc_request(request, &state_dir, &control, &tasks)
|
handle_daemon_rpc_request(request, &state_dir, &control, &tasks)
|
||||||
}
|
}
|
||||||
Err(error) => json_rpc_error(None, -32700, format!("JSON-RPC 请求解析失败:{error}")),
|
Err(error) => json_rpc_error(None, -32700, format!("JSON-RPC 请求解析失败:{error}")),
|
||||||
@@ -2081,6 +2088,30 @@ fn dispatch_rpc_method(
|
|||||||
rpc_ack_value("stop", "后台停止请求已发送", state_dir, None),
|
rpc_ack_value("stop", "后台停止请求已发送", state_dir, None),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
RPC_METHOD_RESTART => match (tasks.restart_controller)(state_dir) {
|
||||||
|
Ok(controller_pid) => {
|
||||||
|
daemon_control_mark_stop_requested(control);
|
||||||
|
let _ = update_daemon_state_only(state_dir, "restarting");
|
||||||
|
rpc_envelope_ok(
|
||||||
|
request_id,
|
||||||
|
"accepted",
|
||||||
|
rpc_restart_ack_value(
|
||||||
|
"restart",
|
||||||
|
"后台重启控制进程已启动;当前 daemon 会在响应后停止并由 Rust 生命周期入口重启",
|
||||||
|
state_dir,
|
||||||
|
controller_pid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(error) => rpc_envelope_error(
|
||||||
|
request_id,
|
||||||
|
ApiError::new(
|
||||||
|
ErrorCode::INTERNAL,
|
||||||
|
"daemon.restart",
|
||||||
|
format!("启动后台重启控制进程失败:{error}"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
RPC_METHOD_RELOAD => {
|
RPC_METHOD_RELOAD => {
|
||||||
daemon_control_request_reload(control);
|
daemon_control_request_reload(control);
|
||||||
rpc_envelope_ok(
|
rpc_envelope_ok(
|
||||||
@@ -2430,11 +2461,30 @@ fn rpc_ack_value(
|
|||||||
message,
|
message,
|
||||||
state_dir: state_dir.to_path_buf(),
|
state_dir: state_dir.to_path_buf(),
|
||||||
socket_path: daemon_socket_path(state_dir),
|
socket_path: daemon_socket_path(state_dir),
|
||||||
|
controller_pid: None,
|
||||||
force,
|
force,
|
||||||
})
|
})
|
||||||
.unwrap_or(serde_json::Value::Null)
|
.unwrap_or(serde_json::Value::Null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rpc_restart_ack_value(
|
||||||
|
command: &'static str,
|
||||||
|
message: &'static str,
|
||||||
|
state_dir: &Path,
|
||||||
|
controller_pid: u32,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
serde_json::to_value(DaemonRpcAck {
|
||||||
|
command,
|
||||||
|
status: "accepted",
|
||||||
|
message,
|
||||||
|
state_dir: state_dir.to_path_buf(),
|
||||||
|
socket_path: daemon_socket_path(state_dir),
|
||||||
|
controller_pid: Some(controller_pid),
|
||||||
|
force: None,
|
||||||
|
})
|
||||||
|
.unwrap_or(serde_json::Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
/// 构建 `resource.state` 数据:资源发布根、版本状态、上次同步结果。
|
/// 构建 `resource.state` 数据:资源发布根、版本状态、上次同步结果。
|
||||||
/// 读取 daemon 状态文件与资源根目录的版本状态(resource/catalog 只读查询共用)。
|
/// 读取 daemon 状态文件与资源根目录的版本状态(resource/catalog 只读查询共用)。
|
||||||
fn read_daemon_resource_state(
|
fn read_daemon_resource_state(
|
||||||
@@ -2613,6 +2663,15 @@ fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result<serde_json::Val
|
|||||||
.and_then(|state| state.previous_available_version.as_ref());
|
.and_then(|state| state.previous_available_version.as_ref());
|
||||||
let previous_snapshot =
|
let previous_snapshot =
|
||||||
previous_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten());
|
previous_record.and_then(|record| read_snapshot(&record.snapshot_path).ok().flatten());
|
||||||
|
let translation_status_code = if previous_record.is_some() {
|
||||||
|
ReleaseFlowStatusCode::TranslationQueuedOffline
|
||||||
|
} else {
|
||||||
|
ReleaseFlowStatusCode::TranslationUnavailable
|
||||||
|
};
|
||||||
|
let (status, status_code, status_phase, status_terminal, status_retryable) =
|
||||||
|
flow_status_fields(ReleaseFlowStatusCode::ParseCompleted);
|
||||||
|
let (translation_status, translation_status_code, _, _, _) =
|
||||||
|
flow_status_fields(translation_status_code);
|
||||||
|
|
||||||
let current_base = current_snapshot.base_snapshot();
|
let current_base = current_snapshot.base_snapshot();
|
||||||
let previous_base = previous_snapshot
|
let previous_base = previous_snapshot
|
||||||
@@ -2623,6 +2682,13 @@ fn build_catalog_diff_report(state_dir: &Path) -> anyhow::Result<serde_json::Val
|
|||||||
let changed_urls = changed_endpoint_urls(&base_delta);
|
let changed_urls = changed_endpoint_urls(&base_delta);
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"available": true,
|
"available": true,
|
||||||
|
"status": status,
|
||||||
|
"status_code": status_code,
|
||||||
|
"status_phase": status_phase,
|
||||||
|
"status_terminal": status_terminal,
|
||||||
|
"status_retryable": status_retryable,
|
||||||
|
"translation_status": translation_status,
|
||||||
|
"translation_status_code": translation_status_code,
|
||||||
"current_version_id": current_record.id,
|
"current_version_id": current_record.id,
|
||||||
"previous_version_id": previous_record.map(|record| record.id.clone()),
|
"previous_version_id": previous_record.map(|record| record.id.clone()),
|
||||||
"previous_snapshot_missing": previous_record.is_some() && previous_snapshot.is_none(),
|
"previous_snapshot_missing": previous_record.is_some() && previous_snapshot.is_none(),
|
||||||
@@ -3500,6 +3566,29 @@ fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result<DaemonStart
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_daemon_restart_controller(state_dir: &Path) -> anyhow::Result<u32> {
|
||||||
|
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
|
||||||
|
fs::create_dir_all(state_dir)?;
|
||||||
|
let executable = env::current_exe()?;
|
||||||
|
let log = open_append_file(&daemon_log_path(state_dir), PRIVATE_FILE_MODE, "后台日志")
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
let log_for_stdout = log.try_clone()?;
|
||||||
|
let mut command = Command::new(executable);
|
||||||
|
command
|
||||||
|
.arg("restart")
|
||||||
|
.arg("--state-dir")
|
||||||
|
.arg(state_dir)
|
||||||
|
.arg("--json")
|
||||||
|
.arg("--no-progress")
|
||||||
|
.arg("--no-banner")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::from(log_for_stdout))
|
||||||
|
.stderr(Stdio::from(log));
|
||||||
|
configure_daemon_command(&mut command);
|
||||||
|
let child = command.spawn()?;
|
||||||
|
Ok(child.id())
|
||||||
|
}
|
||||||
|
|
||||||
fn start_daemon_with_args(
|
fn start_daemon_with_args(
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
resource_output_root: PathBuf,
|
resource_output_root: PathBuf,
|
||||||
@@ -8713,10 +8802,10 @@ mod tests {
|
|||||||
assert!(is_pending_rpc_method("patch.build"));
|
assert!(is_pending_rpc_method("patch.build"));
|
||||||
assert!(is_pending_rpc_method("unityfs.inspect"));
|
assert!(is_pending_rpc_method("unityfs.inspect"));
|
||||||
assert!(is_pending_rpc_method("task.create"));
|
assert!(is_pending_rpc_method("task.create"));
|
||||||
assert!(is_pending_rpc_method("daemon.restart"));
|
|
||||||
assert!(is_pending_rpc_method("daemon.clean-stable"));
|
assert!(is_pending_rpc_method("daemon.clean-stable"));
|
||||||
// sync/verify/repair、task.cancel/logs、catalog.*、resource.manifest 和
|
// restart、sync/verify/repair、task.cancel/logs、catalog.*、
|
||||||
// 文件级 patch/unityfs 写入方法已实现。
|
// resource.manifest 和文件级 patch/unityfs 写入方法已实现。
|
||||||
|
assert!(!is_pending_rpc_method("daemon.restart"));
|
||||||
assert!(!is_pending_rpc_method("patch.apply"));
|
assert!(!is_pending_rpc_method("patch.apply"));
|
||||||
assert!(!is_pending_rpc_method("unityfs.patch_text_asset"));
|
assert!(!is_pending_rpc_method("unityfs.patch_text_asset"));
|
||||||
assert!(!is_pending_rpc_method("unityfs.patch_string_field"));
|
assert!(!is_pending_rpc_method("unityfs.patch_string_field"));
|
||||||
@@ -8738,6 +8827,28 @@ mod tests {
|
|||||||
assert!(!is_pending_rpc_method("daemon.doctor"));
|
assert!(!is_pending_rpc_method("daemon.doctor"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_daemon_restart_starts_controller_and_requests_stop() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let control = new_daemon_control();
|
||||||
|
let envelope = dispatch_rpc_method(
|
||||||
|
&rpc_request("daemon.restart", None),
|
||||||
|
temp.path(),
|
||||||
|
&control,
|
||||||
|
&test_task_context(),
|
||||||
|
"req-restart-1".to_string(),
|
||||||
|
);
|
||||||
|
let value = serde_json::to_value(&envelope).unwrap();
|
||||||
|
assert_eq!(value["ok"], true);
|
||||||
|
assert_eq!(value["status"], "accepted");
|
||||||
|
assert_eq!(value["data"]["command"], "restart");
|
||||||
|
assert_eq!(value["data"]["controller_pid"], 4242);
|
||||||
|
assert_eq!(
|
||||||
|
wait_for_daemon_wake(Some(&control), Duration::from_millis(1)),
|
||||||
|
DaemonWake::Stop
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn task_registry_cancel_and_logs() {
|
fn task_registry_cancel_and_logs() {
|
||||||
let registry = TaskRegistry::new();
|
let registry = TaskRegistry::new();
|
||||||
@@ -8783,9 +8894,14 @@ mod tests {
|
|||||||
registry: TaskRegistry::new(),
|
registry: TaskRegistry::new(),
|
||||||
queue,
|
queue,
|
||||||
base_config,
|
base_config,
|
||||||
|
restart_controller: test_restart_controller,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_restart_controller(_state_dir: &Path) -> anyhow::Result<u32> {
|
||||||
|
Ok(4242)
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dispatch_unknown_method_returns_unknown_error_envelope() {
|
fn dispatch_unknown_method_returns_unknown_error_envelope() {
|
||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
@@ -8917,6 +9033,7 @@ mod tests {
|
|||||||
registry: TaskRegistry::new(),
|
registry: TaskRegistry::new(),
|
||||||
queue,
|
queue,
|
||||||
base_config,
|
base_config,
|
||||||
|
restart_controller: test_restart_controller,
|
||||||
};
|
};
|
||||||
|
|
||||||
let envelope = dispatch_rpc_method(
|
let envelope = dispatch_rpc_method(
|
||||||
@@ -8945,6 +9062,7 @@ mod tests {
|
|||||||
registry: TaskRegistry::new(),
|
registry: TaskRegistry::new(),
|
||||||
queue,
|
queue,
|
||||||
base_config: OfficialUpdateConfig::default(),
|
base_config: OfficialUpdateConfig::default(),
|
||||||
|
restart_controller: test_restart_controller,
|
||||||
};
|
};
|
||||||
|
|
||||||
let envelope = dispatch_rpc_method(
|
let envelope = dispatch_rpc_method(
|
||||||
@@ -9004,6 +9122,7 @@ mod tests {
|
|||||||
registry: TaskRegistry::new(),
|
registry: TaskRegistry::new(),
|
||||||
queue,
|
queue,
|
||||||
base_config,
|
base_config,
|
||||||
|
restart_controller: test_restart_controller,
|
||||||
};
|
};
|
||||||
|
|
||||||
let envelope = dispatch_rpc_method(
|
let envelope = dispatch_rpc_method(
|
||||||
@@ -10197,6 +10316,7 @@ mod tests {
|
|||||||
registry: TaskRegistry::new(),
|
registry: TaskRegistry::new(),
|
||||||
queue,
|
queue,
|
||||||
base_config: OfficialUpdateConfig::default(),
|
base_config: OfficialUpdateConfig::default(),
|
||||||
|
restart_controller: test_restart_controller,
|
||||||
};
|
};
|
||||||
let envelope = dispatch_rpc_method(
|
let envelope = dispatch_rpc_method(
|
||||||
&rpc_request("catalog.refresh", None),
|
&rpc_request("catalog.refresh", None),
|
||||||
|
|||||||
+171
-2
@@ -1,6 +1,21 @@
|
|||||||
package api
|
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) {
|
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
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{
|
body := AdminIndexResponse{
|
||||||
Service: "bat-api",
|
Service: "bat-api",
|
||||||
Panel: "admin",
|
Panel: "admin",
|
||||||
Status: "reserved",
|
Status: "available",
|
||||||
Links: []string{
|
Links: []string{
|
||||||
"/healthz",
|
"/healthz",
|
||||||
"/readyz",
|
"/readyz",
|
||||||
@@ -19,6 +34,15 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/v1/resources",
|
"/v1/resources",
|
||||||
"/openapi.yaml",
|
"/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 {
|
if r.Method == http.MethodHead {
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+137
-1
@@ -548,6 +548,46 @@ func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit in
|
|||||||
return f.manifest, nil
|
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) {
|
func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
|
||||||
root := fixtureRoot(t)
|
root := fixtureRoot(t)
|
||||||
bytes := uint64(20)
|
bytes := uint64(20)
|
||||||
@@ -974,7 +1014,103 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
|
|||||||
if err := json.Unmarshal(rr.Body.Bytes(), &admin); err != nil {
|
if err := json.Unmarshal(rr.Body.Bytes(), &admin); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if admin.Status != "reserved" {
|
if admin.Status != "available" {
|
||||||
t.Fatalf("admin=%+v", admin)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-3
@@ -9,7 +9,7 @@ const openAPISpecYAML = `openapi: 3.0.3
|
|||||||
info:
|
info:
|
||||||
title: BlueArchive Toolkit bat-api
|
title: BlueArchive Toolkit bat-api
|
||||||
version: 0.1.0
|
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:
|
servers:
|
||||||
- url: http://127.0.0.1:18080
|
- url: http://127.0.0.1:18080
|
||||||
security:
|
security:
|
||||||
@@ -111,10 +111,43 @@ paths:
|
|||||||
description: OpenAPI YAML.
|
description: OpenAPI YAML.
|
||||||
/admin/:
|
/admin/:
|
||||||
get:
|
get:
|
||||||
summary: Reserved admin panel entry
|
summary: Admin control entry
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"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}:
|
/prod-clientpatch.bluearchiveyostar.com/{path}:
|
||||||
get:
|
get:
|
||||||
summary: CDN-shaped resource bytes
|
summary: CDN-shaped resource bytes
|
||||||
|
|||||||
@@ -166,6 +166,15 @@ type AdminIndexResponse struct {
|
|||||||
Panel string `json:"panel"`
|
Panel string `json:"panel"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Links []string `json:"links"`
|
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) {
|
func writeNoStoreJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ type Backend interface {
|
|||||||
ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error)
|
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.
|
// RPCClient adapts *backendrpc.Client to Backend.
|
||||||
type RPCClient struct {
|
type RPCClient struct {
|
||||||
Client *backendrpc.Client
|
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) {
|
func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
|
||||||
return r.Client.ResourceManifest(ctx, offset, limit)
|
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.
|
// DiscoverResult is the outcome of talking to the bat daemon.
|
||||||
type DiscoverResult struct {
|
type DiscoverResult struct {
|
||||||
|
|||||||
@@ -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/advanced/game/download/cdn"), s.handleLauncherCdnConfig)
|
||||||
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
|
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
|
||||||
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
|
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
|
||||||
|
mux.HandleFunc("/admin/control/", s.handleAdminControl)
|
||||||
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
||||||
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
||||||
mux.HandleFunc("/"+ClientPatchHost+"/", s.serveCDN)
|
mux.HandleFunc("/"+ClientPatchHost+"/", s.serveCDN)
|
||||||
@@ -124,6 +125,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/" + ServerInfoHost + "/...",
|
"/" + ServerInfoHost + "/...",
|
||||||
"/openapi.yaml",
|
"/openapi.yaml",
|
||||||
"/admin/",
|
"/admin/",
|
||||||
|
"/admin/control/{action}",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -190,6 +190,48 @@ type taskIDParam struct {
|
|||||||
TaskID string `json:"task_id"`
|
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.
|
// Ack is returned by accepted daemon control methods.
|
||||||
type Ack struct {
|
type Ack struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
@@ -197,6 +239,7 @@ type Ack struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
StateDir string `json:"state_dir"`
|
StateDir string `json:"state_dir"`
|
||||||
SocketPath string `json:"socket_path"`
|
SocketPath string `json:"socket_path"`
|
||||||
|
ControllerPID *int `json:"controller_pid,omitempty"`
|
||||||
Force *bool `json:"force,omitempty"`
|
Force *bool `json:"force,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +367,12 @@ func (c *Client) DaemonStop(ctx context.Context) (*Ack, error) {
|
|||||||
return &out, err
|
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) {
|
func (c *Client) DaemonReload(ctx context.Context) (*Ack, error) {
|
||||||
var out Ack
|
var out Ack
|
||||||
_, err := c.Call(ctx, "daemon.reload", nil, &out)
|
_, err := c.Call(ctx, "daemon.reload", nil, &out)
|
||||||
@@ -396,6 +445,34 @@ func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted,
|
|||||||
return &out, err
|
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) {
|
func (c *Client) TaskStatus(ctx context.Context, taskID string) (*TaskRecord, error) {
|
||||||
var out TaskRecord
|
var out TaskRecord
|
||||||
_, err := c.Call(ctx, "task.status", taskIDParam{TaskID: taskID}, &out)
|
_, 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) {
|
func TestResourceListSendsPagination(t *testing.T) {
|
||||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
if req.Method != "resource.list" {
|
if req.Method != "resource.list" {
|
||||||
|
|||||||
Reference in New Issue
Block a user