fix(api): 补齐 bat-api 控制与后端 RPC

This commit is contained in:
2026-07-31 17:01:03 +08:00
parent 20ddd67947
commit 6af7706190
17 changed files with 793 additions and 51 deletions
+4 -4
View File
@@ -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 #1P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope`ok``status``error``data``request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`status/logs/stop/reload/refresh/doctor)、`resource.*`state/sync/verify/repair/manifest/list/index)、`parse.*`status/text_units/errors)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)、文件级 `patch.apply``unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.restart` / `daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做自重启或在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。 1. Issue #1P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope`ok``status``error``data``request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`status/logs/stop/restart/reload/refresh/doctor)、`resource.*`state/sync/verify/repair/manifest/list/index)、`parse.*`status/text_units/errors)、`localized.*`status)、`catalog.*`status/refresh/diff/versions)、`task.*`status/list/cancel/logs)、文件级 `patch.apply``unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFIFFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理面板预留、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;剩余为远程服务器全量 release 联调、Rust/Go snapshot contract fixture(用户审核后)、refresh mtime/size 增量缓存和可选持久化。 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 #3P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。 4. Issue #3P1):AssetBundle UnityFS 基础解析校验已具备离线和隔离真实样本覆盖;对象级解析继续跟踪 G-005。
5. Issue #2P1):继续逆向 Addressables catalog,提取 bundle hash/size/CRC 等可校验字段。 5. Issue #2P1):继续逆向 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 pathissue #19 剩余)。 2. `bat-api` 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap``/v1/launcher/bootstrap`、server-info 和 CDN pathissue #19 剩余)。
3. 跟进官方同步长期运行测试报告。 3. 跟进官方同步长期运行测试报告。
4. AssetBundle / Addressablesissue #3 / #2);CAS 用户级导入(G-011)。 4. AssetBundle / Addressablesissue #3 / #2);CAS 用户级导入(G-011)。
+2 -2
View File
@@ -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 MVPissue #19 / G-009);`/v1/bootstrap``/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readinessCDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI 和管理面板预留`.env` 配置端口/RPC socket/刷新周期;生产资源根来自 RPC,不负责自动拉取。 - `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVPissue #19 / G-009);`/v1/bootstrap``/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readinessCDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI 和管理控制白名单`.env` 配置端口/RPC socket/刷新周期;生产资源根来自 RPC,不负责自动拉取。
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)G-008 已关闭:同步 CLI = Rust `bat`)。 - 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
View File
@@ -109,13 +109,14 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
| `GET /yostar-serverinfo.bluearchiveyostar.com/server-info.json` | 官方 host/path 形态的 server-info | | `GET /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`
所有动态 JSONbootstrap、health、ready、release、resources、launcher 兼容、server-info、OpenAPI、admin placeholder 和错误响应)显式返回 `Cache-Control: no-store`。资源字节 CDN path 仍返回长期 immutable cache header。 `POST /admin/control/{action}` 只转发固定白名单内的 Rust RPC,不是任意 RPC proxy
| action | Rust RPC | 参数 | 返回 |
|---|---|---|---|
| `reload` | `daemon.reload` | 无 | `202` accepted |
| `refresh` | `daemon.refresh` | 可选 `{ "force": true }` | `202` accepted |
| `restart` | `daemon.restart` | 无 | `202` accepted |
| `sync` | `resource.sync` | 可选 `{ "force": true }` | `202` + task |
| `verify` | `resource.verify` | 无 | `202` + task |
| `repair` | `resource.repair` | 无 | `202` + task |
| `catalog-refresh` | `catalog.refresh` | 可选 `{ "force": true }` | `202` + task |
`stop``clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
所有动态 JSONbootstrap、health、ready、release、resources、launcher 兼容、server-info、OpenAPI、admin 和错误响应)显式返回 `Cache-Control: no-store`。资源字节 CDN path 仍返回长期 immutable cache header。
CDN path 只服务 manifest 索引内且磁盘存在、size 匹配的文件。响应支持 `GET``HEAD``Range`、条件请求、ETag、Last-Modified、Accept-Ranges 和长期缓存头;ETag 优先使用 manifest 中的 BLAKE3。`.hash``text/plain` 返回,其它未知扩展默认为 `application/octet-stream` 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 logstdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend`daemon.status/logs/stop/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``localized.status``catalog.*``task.*`);`restart` `clean-stable` 仍由 CLI 侧按进程生命周期显式执行,非 dry-run 使用 `.official-sync.lock` 防止并发写资源目录,控制命令使用 `bat-control.lock` 防止并发状态修改,资源发布使用 `.staging``versions``current` 原子切换,daemon 写 `bat-events.jsonl` 结构化日志并在 `status` 中暴露下载进度、失败类型、HTTP 状态和调度状态 - `bat` 默认向 stderr 输出 `BlueArchiveToolkit` ASCII banner 和 progress logstdout 默认输出人类可读摘要;progress log 覆盖代理决策、下载已完成计数、单文件开始/完成状态、下载中断失败分类和校验结果摘要;支持 `--proxy` / `--no-proxy` 控制 curl 传输代理,支持 `--json` 输出稳定 JSON,支持 `--no-progress` 关闭进度日志,支持 `--no-banner` 只关闭横幅,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,支持 `--daemon` Unix socket JSON-RPC live control/backend`daemon.status/logs/stop/restart/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``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 层职责边界
+9 -3
View File
@@ -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 侧真实输出与用户审核。
+1 -1
View File
@@ -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`
验收(剩余): 验收(剩余):
+6 -5
View File
@@ -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 controllerGo 不直接执行 `bat` binary |
### 工程 ### 工程
@@ -94,8 +95,8 @@
| 组件 | 路径 | 状态 | 说明 | | 组件 | 路径 | 状态 | 说明 |
|---|---|---|---| |---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 | | Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPCfake 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 固定 okmanifest/sync 走 FFI | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/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 | 未开始 |
+130 -10
View File
@@ -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-stableCLI 侧按进程生命周期处理; // daemon.clean-stableCLI 侧按进程生命周期处理;
// 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+13 -4
View File
@@ -162,10 +162,19 @@ type LauncherEnvelope[T any] struct {
} }
type AdminIndexResponse struct { type AdminIndexResponse struct {
Service string `json:"service"` Service string `json:"service"`
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) {
+57
View File
@@ -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 {
+2
View File
@@ -61,6 +61,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig) mux.HandleFunc(launcherHostPath("/api/launcher/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
+83 -6
View File
@@ -190,14 +190,57 @@ 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"`
Status string `json:"status"` Status string `json:"status"`
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"`
Force *bool `json:"force,omitempty"` ControllerPID *int `json:"controller_pid,omitempty"`
Force *bool `json:"force,omitempty"`
} }
// TaskAccepted is returned when an async backend task is queued. // TaskAccepted is returned when an async backend task is queued.
@@ -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)
+114
View File
@@ -88,6 +88,120 @@ func TestResourceRepairQueuesTask(t *testing.T) {
} }
} }
func TestDaemonRestartSendsControlMethod(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "daemon.restart" {
t.Fatalf("method = %s", req.Method)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "accepted",
RequestID: "req-test-restart",
Data: map[string]any{
"command": "restart",
"status": "accepted",
"message": "restart accepted",
"state_dir": "/tmp/bat-pid",
"socket_path": "/tmp/bat-pid/bat.sock",
"controller_pid": 4242,
},
},
}
})
ack, err := client.DaemonRestart(context.Background())
if err != nil {
t.Fatalf("DaemonRestart error: %v", err)
}
if ack.Command != "restart" || ack.ControllerPID == nil || *ack.ControllerPID != 4242 {
t.Fatalf("unexpected ack: %#v", ack)
}
}
func TestParseTextUnitsSendsQuery(t *testing.T) {
pathID := int64(7)
classID := 114
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "parse.text_units" {
t.Fatalf("method = %s", req.Method)
}
var params TextUnitQueryParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.Offset != 3 || params.Limit != 5 || params.Destination != "*Table*" || params.PathID == nil || *params.PathID != pathID || params.ClassID == nil || *params.ClassID != classID {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-parse",
Data: map[string]any{
"available": true,
"entries": []any{},
},
},
}
})
raw, err := client.ParseTextUnits(context.Background(), TextUnitQueryParams{
Offset: 3,
Limit: 5,
Destination: "*Table*",
PathID: &pathID,
ClassID: &classID,
})
if err != nil {
t.Fatalf("ParseTextUnits error: %v", err)
}
if !json.Valid(raw) {
t.Fatalf("invalid raw JSON: %s", string(raw))
}
}
func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "unityfs.patch_field" {
t.Fatalf("method = %s", req.Method)
}
var params UnityFSFieldPatchParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.BundlePath != "/tmp/source.bundle" || params.FieldPath != "m_Name" || string(params.Replacement) != `{"kind":"string","value":"new text"}` {
t.Fatalf("params = %#v replacement=%s", params, string(params.Replacement))
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-unityfs",
Data: map[string]any{
"command": "unityfs.patch_field",
"status": "completed",
},
},
}
})
raw, err := client.UnityFSPatchField(context.Background(), UnityFSFieldPatchParams{
BundlePath: "/tmp/source.bundle",
SerializedFilePath: "CAB-test",
PathID: 1,
FieldPath: "m_Name",
Replacement: json.RawMessage(`{"kind":"string","value":"new text"}`),
TargetPath: "/tmp/target.bundle",
})
if err != nil {
t.Fatalf("UnityFSPatchField error: %v", err)
}
if !json.Valid(raw) {
t.Fatalf("invalid raw JSON: %s", string(raw))
}
}
func TestResourceListSendsPagination(t *testing.T) { 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" {