diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 9e0dda9..5dcf7b5 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -264,7 +264,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --watch ``` -资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`bat-api` 已补 launcher 资源引导兼容端点和玩家-facing HTTP 控制面(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh` 可经 Web 转发),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。 +资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest` 和 `official-sync-snapshot.json` contract fixture,Go mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移。`bat-api` 已补 launcher 资源引导兼容端点和玩家-facing HTTP 控制面(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh` 可经 Web 转发),响应只来自已发布 snapshot/RPC,不提供登录、网关、鉴权或完整 package update manifest。 生产要求: diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index be7dfb3..9b8fdab 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -32,7 +32,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。 5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。 6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`。 -7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`localized.status`、`catalog.*`、`task.*`);`restart` 和 `clean-stable` 仍由 CLI 侧按进程生命周期显式执行。 +7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`localized.status`、`catalog.*`、`task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行。 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布。 10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。 diff --git a/README.md b/README.md index 12aa8f6..890287a 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ cargo run -p bat-infrastructure --bin bat -- reload cargo run -p bat-infrastructure --bin bat -- stop ``` -`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh`/`repair` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。 +`status`、`stop`、`restart`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径替换后台进程;`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh`/`repair` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`。 `bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 1e0cb52..d49b4e1 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -120,7 +120,7 @@ current symlink → official-sync-snapshot.json + official-download-manifest.jso - `refresh --force` 可手动强制刷新;`verify` 只读校验当前官方计划、本地 manifest 和官方 seed hash;`repair` 尝试修复异常资源。 - 非 dry-run 同步先写 `.staging/`,校验完成后发布 `versions/` 并原子切换 `current` symlink。 - `--daemon` 使用状态目录下的 `bat.sock` 作为 Unix socket JSON-RPC live control plane;PID、状态和日志文件是快照与 fallback,`bat-events.jsonl` 是结构化轮转日志。 -- `status`、`logs`、`reload`、`stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 负责重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。 +- `status`、`logs`、`restart`、`reload`、`stop` 和默认形态的 `refresh` 优先通过 RPC 管理后台进程;控制命令通过 `bat-control.lock` 串行化;`restart` 通过 Rust lifecycle controller 复用 CLI restart 路径重启或替换启动参数;live daemon 会阻止前台写命令直接修改同一资源目录;`doctor` 做运行时诊断;`clean-stable` 清理临时文件和失效/损坏状态。 - 远端 marker 无变化且本地 manifest clean 时不下载。 - 本地文件损坏时 repair。 - 官方 seed `.hash` 强校验;Addressables `catalog_*.hash` 作为变更 marker。 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index 61dc787..7ddab34 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -253,7 +253,7 @@ sudo -u bat /opt/bluearchive-toolkit/bin/bat reload --state-dir /var/lib/bluearc sudo -u bat /opt/bluearchive-toolkit/bin/bat stop --state-dir /var/lib/bluearchive-toolkit/daemon-state ``` -`--daemon` 会在 `--state-dir` 下创建 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`。`bat.sock` 是 Unix socket JSON-RPC 控制通道;`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`。 +`--daemon` 会在 `--state-dir` 下创建 `bat.sock`、`bat.pid`、`bat-status.json`、`bat-daemon.log`、`bat-events.jsonl` 和短生命周期的 `bat-control.lock`。`bat.sock` 是 Unix socket JSON-RPC 控制通道;`status`、`stop`、`restart`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 live daemon。PID、状态和日志文件保留为快照、诊断和 socket 不可用时的兼容路径;`bat-events.jsonl` 是带轮转的结构化 JSONL 事件日志;`bat-status.json` 会暴露最后成功时间、下次检查时间、最后错误摘要和当前下载进度;`bat-control.lock` 串行化控制命令,并能在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径替换后台进程;`reload` 默认不会重启进程,而是让 watch 循环重新自动发现并强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行;需要替换启动参数或 binary 时用 `restart`。 不要同时运行 systemd `--watch` 和 standalone `--daemon` 指向同一个 `--output`。二者都会被资源锁和 live daemon 互斥保护,但生产运维上应保持单一 owner。 diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index fd9afaf..c39c0d0 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -277,7 +277,7 @@ cargo run -p bat-infrastructure --bin bat -- reload cargo run -p bat-infrastructure --bin bat -- stop ``` -`status`、`stop`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy` 或 `--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。 +`status`、`stop`、`restart`、`logs`、`reload`、默认形态的 `refresh` 和默认形态的 `repair` 会优先连接 `bat.sock`,通过 Unix socket JSON-RPC 和 live daemon 通信;socket 不可用时,`status`、`stop` 会回退到 PID/状态文件兼容路径。`status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、文本日志路径、结构化日志路径和轮转日志路径;正在重新拉取同一版本时,对应旧失败不会作为当前历史失败摘要展示;人类输出不会把完整 `official-version-state.json` 内联打印成 JSON。控制命令会通过 `bat-control.lock` 做跨进程互斥,失效或损坏的控制锁会在下次控制命令或 `clean-stable` 时恢复。`restart` 会通过 Rust lifecycle controller 复用 CLI restart 路径停止旧后台进程并按保存参数或显式参数重新启动;`reload` 在未显式传入同步参数时不会重启进程,而是唤醒或排队 watch 循环重新执行自动发现和强制刷新:空闲睡眠时立即执行,正在同步时等当前轮结束;如果显式传入 `--proxy` 或 `--no-proxy`,会按新代理配置重启后台进程。所有命令默认输出人类可读摘要,脚本集成时加 `--json`。 如果要把后台状态目录改到其他位置,使用 `--state-dir <目录>`: diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 52a64e8..59c326f 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -327,6 +327,12 @@ CLI 对应关系: `--class-id`、`--field-path` 和 `--format`;这些过滤参数不适用于 `parse-status` 或 `localized-status`。 +Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖 +`catalog.status` available/unavailable、`resource.manifest` page0 和对应 +`official-sync-snapshot.json`。这些 fixture 由 Rust 输出归一化而来,只用于 +schema / mirror 回归;live daemon socket 和完整 release 切换仍需在允许 smoke 的 +隔离环境中验证。 + 禁止事项: - Go 服务层不直接读写 `bat-status.json`、`bat-tasks.json` 等 daemon 内部状态文件。 diff --git a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md index 77c0ab3..2188488 100644 --- a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md +++ b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md @@ -1,9 +1,14 @@ # bat-api / Rust bat Contract Fixture Handoff -更新时间:2026-07-28 +更新时间:2026-07-31 本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。它只定义协作协议和验收标准,不包含已审核 fixture。 +2026-07-31 更新:已审核归一化 fixture 已落入 +`internal/api/testdata/contract/`,Go 侧通过 +`internal/api/contract_fixture_test.go` 固化 mirror struct 验证。本文件保留为 +后续重新生成或扩展 contract fixture 时的协作协议。 + ## 最小上下文包 另一个窗口不需要知道本窗口的完整对话,只需要遵守以下上下文: @@ -193,4 +198,11 @@ contract fixture 工作只有在以下条件同时满足时才算完成: - Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config_bootstrap` 的 mirror struct。 - Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理控制白名单。 -- contract fixture 尚未落仓库,等待 Rust 侧真实输出与用户审核。 +- 已归一化的 Rust contract fixture 已落仓库: + - `internal/api/testdata/contract/catalog-status.available.json` + - `internal/api/testdata/contract/catalog-status.unavailable.json` + - `internal/api/testdata/contract/resource-manifest.page0.json` + - `internal/api/testdata/contract/official-sync-snapshot.json` +- 原始交接产物仍位于 `/tmp/bat-contract-fixture/`;当前受本地 sandbox 限制,live socket daemon 无法启动,原始 JSON 通过临时 Rust 测试调用同一 dispatch/report 代码路径生成。 +- Go contract 测试读取仓库内归一化 fixture,不依赖 `/tmp/bat-contract-fixture/`、开发机资源目录或远端长期运行的 `bat`。 +- 仍未覆盖真实长期 daemon socket 的端到端调用和完整发布切换;该项需要在允许 live daemon / smoke 的隔离环境中单独验证。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 8d8d17e..cd72387 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -231,6 +231,7 @@ - **拉取归属 Rust `bat`**;`bat-api` 不做下载器。 - 发现经 `bat.sock`:先 `daemon.status`,再 `daemon.doctor`,再 `catalog.status` / `resource.manifest`。 - `bat-api` 通过 RPC 读取的 `resource.state`、`catalog.status`、`parse.status` 和 `localized.status` 会获得短状态 `status` 与稳定生命周期状态码 `status_code`;错误原因仍以 `BAT-ERR-*` 为准。 +- `bat-api` 可通过受限 Web 控制面转发 `reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`,其中 `restart`、`parse.*`、`localized.status` 和文件级 `unityfs.patch_*` 均走 Rust live RPC;`daemon.clean-stable` 等危险或离线生命周期命令不经 Web 转发。 - 生产与 Rust `bat` 同环境运行,资源根来自 RPC 返回的 `resource_root`;`--resource-root` 仅用于 fixture 或应急只读诊断。 - `.env` 配置端口 / public base / RPC socket / RPC 刷新周期;预留 database/redis。 - `/v1/bootstrap` 返回 RPC 健康、release 摘要、server-info URL、client-patch base 和改写后的 Addressables root。 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index cde595c..d37562d 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -95,7 +95,7 @@ | 组件 | 路径 | 状态 | 说明 | |---|---|---|---| | Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 | -| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPC;覆盖 daemon restart、resource/catalog/task、parse/localized 和文件级 UnityFS patch 调用;fake transport 单测 | +| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPC;覆盖 daemon restart、resource/catalog/task、parse/localized 和文件级 UnityFS patch 调用;fake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror | | 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + `.env` | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` | diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index dc22470..48cdf23 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -8951,6 +8951,36 @@ mod tests { assert_eq!(value["error"]["code"], "BAT-ERR-700002"); } + #[test] + fn dispatch_unityfs_patch_methods_are_live_handlers_and_require_params() { + let temp = tempfile::TempDir::new().unwrap(); + let control = new_daemon_control(); + for method in [ + "unityfs.patch_text_asset", + "unityfs.patch_string_field", + "unityfs.patch_field", + ] { + let envelope = dispatch_rpc_method( + &rpc_request(method, None), + temp.path(), + &control, + &test_task_context(), + format!("req-test-{method}"), + ); + let value = serde_json::to_value(&envelope).unwrap(); + assert_eq!(value["ok"], false, "method={method}"); + assert_eq!(value["error"]["code"], "BAT-ERR-700002", "method={method}"); + assert_eq!(value["error"]["location"], method, "method={method}"); + assert!( + value["error"]["message"] + .as_str() + .unwrap() + .contains("params 不能为空"), + "method={method}" + ); + } + } + #[test] fn dispatch_patch_apply_writes_text_patch_target() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/internal/api/contract_fixture_test.go b/internal/api/contract_fixture_test.go new file mode 100644 index 0000000..497bea4 --- /dev/null +++ b/internal/api/contract_fixture_test.go @@ -0,0 +1,112 @@ +package api + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "bat-api/internal/backendrpc" +) + +func readContractFixture(t *testing.T, name string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join("testdata", "contract", name)) + if err != nil { + t.Fatalf("read contract fixture %s: %v", name, err) + } + return data +} + +func TestRustContractFixturesPreserveGoMirror(t *testing.T) { + availableRaw := readContractFixture(t, "catalog-status.available.json") + unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json") + manifestRaw := readContractFixture(t, "resource-manifest.page0.json") + snapshotRaw := readContractFixture(t, "official-sync-snapshot.json") + + for name, raw := range map[string][]byte{ + "catalog available": availableRaw, + "catalog unavailable": unavailableRaw, + "resource manifest": manifestRaw, + "snapshot": snapshotRaw, + } { + if bytes.Contains(raw, []byte("/tmp/")) { + t.Fatalf("%s contains an absolute temporary path", name) + } + if bytes.Contains(raw, []byte("r93_fixture")) { + t.Fatalf("%s contains a concrete release token", name) + } + } + + summary, resourceRoot, available := parseCatalogStatus(availableRaw) + if !available || summary == nil { + t.Fatal("available catalog fixture was not accepted") + } + if summary.Status != "published" || summary.StatusCode != "official.published" { + t.Fatalf("catalog status=%+v", summary) + } + if summary.DistributionStatusCode != "distribution.ready" { + t.Fatalf("distribution status=%q", summary.DistributionStatusCode) + } + if resourceRoot != "${RESOURCE_ROOT}" || summary.VersionID != "${VERSION_ID}" { + t.Fatalf("catalog root/version=%q/%q", resourceRoot, summary.VersionID) + } + if summary.LauncherMetadata == nil || summary.GameMainConfig == nil { + t.Fatalf("catalog optional objects missing: %+v", summary) + } + if summary.LauncherMetadata.GameLowestVersion != "" { + t.Fatalf("null optional field became non-empty: %q", summary.LauncherMetadata.GameLowestVersion) + } + if summary.GameMainConfig.DefaultConnectionGroup != "${CONNECTION_GROUP}" { + t.Fatalf("game config=%+v", summary.GameMainConfig) + } + + if got, _, ok := parseCatalogStatus(unavailableRaw); got != nil || ok { + t.Fatalf("unavailable catalog parsed as available: got=%+v ok=%v", got, ok) + } + + var manifest backendrpc.ResourceManifestPage + if err := json.Unmarshal(manifestRaw, &manifest); err != nil { + t.Fatalf("decode resource manifest: %v", err) + } + if !manifest.Available || manifest.ManifestVersion != 1 || manifest.TotalEntries != 2 { + t.Fatalf("manifest header=%+v", manifest) + } + if len(manifest.Entries) != 2 { + t.Fatalf("manifest entries=%d", len(manifest.Entries)) + } + if manifest.Entries[0].Bytes == nil || *manifest.Entries[0].Bytes != 21 { + t.Fatalf("manifest first bytes=%v", manifest.Entries[0].Bytes) + } + if manifest.Entries[0].BLAKE3 == "" || !strings.Contains(manifest.Entries[0].Destination, "{addressables-root}") { + t.Fatalf("manifest first entry=%+v", manifest.Entries[0]) + } + + var snapshot struct { + AppVersion string `json:"app_version"` + BundleVersion string `json:"bundle_version"` + GameMainConfigBootstrap *GameMainConfigSummary `json:"game_main_config_bootstrap"` + LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata"` + LegacyGameMainConfig json.RawMessage `json:"game_main_config"` + } + if err := json.Unmarshal(snapshotRaw, &snapshot); err != nil { + t.Fatalf("decode snapshot: %v", err) + } + if snapshot.AppVersion != "${APP_VERSION}" || snapshot.BundleVersion != "${BUNDLE_VERSION}" { + t.Fatalf("snapshot versions=%q/%q", snapshot.AppVersion, snapshot.BundleVersion) + } + if snapshot.GameMainConfigBootstrap == nil || snapshot.GameMainConfigBootstrap.ServerInfoDataURL == "" { + t.Fatalf("snapshot game config=%+v", snapshot.GameMainConfigBootstrap) + } + if snapshot.GameMainConfigBootstrap.ServerInfoDataURL != "https://yostar-serverinfo.bluearchiveyostar.com/{server-info-path}" { + t.Fatalf("snapshot server info=%q", snapshot.GameMainConfigBootstrap.ServerInfoDataURL) + } + if snapshot.LauncherMetadata == nil || snapshot.LauncherMetadata.ManifestSource == "" { + t.Fatalf("snapshot launcher metadata=%+v", snapshot.LauncherMetadata) + } + if len(snapshot.LegacyGameMainConfig) != 0 { + t.Fatalf("legacy game_main_config field unexpectedly present: %s", snapshot.LegacyGameMainConfig) + } +} diff --git a/internal/api/testdata/contract/README.md b/internal/api/testdata/contract/README.md new file mode 100644 index 0000000..15f8ebd --- /dev/null +++ b/internal/api/testdata/contract/README.md @@ -0,0 +1,15 @@ +# Rust contract fixtures + +本目录保存 Go `bat-api` 对 Rust `bat.sock` / snapshot JSON 的跨语言 +contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本机路径、版本号、 +时间戳和 release URL token,不改字段名、层级、null 语义或数字类型。 + +覆盖范围: + +- `catalog.status` 可用与不可用响应。 +- `resource.manifest` 第一页分页响应。 +- 对应 release 的 `official-sync-snapshot.json`。 +- `launcher_metadata` 与 `game_main_config_bootstrap` 的 Go mirror 解码。 + +这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live +daemon socket 或完整发布切换验证。 diff --git a/internal/api/testdata/contract/catalog-status.available.json b/internal/api/testdata/contract/catalog-status.available.json new file mode 100644 index 0000000..2fca259 --- /dev/null +++ b/internal/api/testdata/contract/catalog-status.available.json @@ -0,0 +1,41 @@ +{ + "addressables_catalog_marker_count": 0, + "addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}", + "app_version": "${APP_VERSION}", + "available": true, + "bundle_version": "${BUNDLE_VERSION}", + "connection_group_name": "${CONNECTION_GROUP}", + "distribution_status": "ready", + "distribution_status_code": "distribution.ready", + "endpoint_count": 0, + "endpoint_marker_count": 0, + "game_main_config_bootstrap": { + "default_connection_group": "${CONNECTION_GROUP}", + "server_info_data_url": "https://yostar-serverinfo.bluearchiveyostar.com/{server-info-path}" + }, + "launcher_metadata": { + "game_latest_file_path": "${GAME_PACKAGE_PATH}", + "game_latest_version": "${APP_VERSION}", + "game_lowest_version": null, + "game_start_exe_name": "${GAME_START_EXE_NAME}", + "game_start_params": [ + "${GAME_START_PARAM}" + ], + "launcher_version": "${LAUNCHER_VERSION}", + "manifest_file_count": 2, + "manifest_source": "${LAUNCHER_MANIFEST_SOURCE}", + "manifest_url": "https://launcher-pkg-ba-jp.yo-star.com/{launcher-manifest-path}" + }, + "official_seed_hash_marker_count": 0, + "snapshot_version": 2, + "status": "published", + "status_code": "official.published", + "status_phase": "official_sync", + "status_retryable": false, + "status_terminal": true, + "version": { + "completed_unix_seconds": 1000, + "id": "${VERSION_ID}", + "resource_root": "${RESOURCE_ROOT}" + } +} diff --git a/internal/api/testdata/contract/catalog-status.unavailable.json b/internal/api/testdata/contract/catalog-status.unavailable.json new file mode 100644 index 0000000..6343684 --- /dev/null +++ b/internal/api/testdata/contract/catalog-status.unavailable.json @@ -0,0 +1,10 @@ +{ + "available": false, + "distribution_status": "blocked", + "distribution_status_code": "distribution.blocked", + "status": "unavailable", + "status_code": "official.unavailable", + "status_phase": "official_sync", + "status_retryable": false, + "status_terminal": true +} diff --git a/internal/api/testdata/contract/official-sync-snapshot.json b/internal/api/testdata/contract/official-sync-snapshot.json new file mode 100644 index 0000000..ad6d1a0 --- /dev/null +++ b/internal/api/testdata/contract/official-sync-snapshot.json @@ -0,0 +1,26 @@ +{ + "addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}", + "app_version": "${APP_VERSION}", + "bundle_version": "${BUNDLE_VERSION}", + "connection_group_name": "${CONNECTION_GROUP}", + "endpoint_markers": [], + "endpoints": [], + "game_main_config_bootstrap": { + "default_connection_group": "${CONNECTION_GROUP}", + "server_info_data_url": "https://yostar-serverinfo.bluearchiveyostar.com/{server-info-path}" + }, + "launcher_metadata": { + "game_latest_file_path": "${GAME_PACKAGE_PATH}", + "game_latest_version": "${APP_VERSION}", + "game_lowest_version": null, + "game_start_exe_name": "${GAME_START_EXE_NAME}", + "game_start_params": [ + "${GAME_START_PARAM}" + ], + "launcher_version": "${LAUNCHER_VERSION}", + "manifest_file_count": 2, + "manifest_source": "${LAUNCHER_MANIFEST_SOURCE}", + "manifest_url": "https://launcher-pkg-ba-jp.yo-star.com/{launcher-manifest-path}" + }, + "snapshot_version": 2 +} diff --git a/internal/api/testdata/contract/resource-manifest.page0.json b/internal/api/testdata/contract/resource-manifest.page0.json new file mode 100644 index 0000000..6cac3ee --- /dev/null +++ b/internal/api/testdata/contract/resource-manifest.page0.json @@ -0,0 +1,22 @@ +{ + "available": true, + "entries": [ + { + "blake3": "0000000000000000000000000000000000000000000000000000000000000000", + "bytes": 21, + "destination": "prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.bytes", + "url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.bytes" + }, + { + "blake3": "0000000000000000000000000000000000000000000000000000000000000000", + "bytes": 10, + "destination": "prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash", + "url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash" + } + ], + "limit": 2, + "manifest_version": 1, + "offset": 0, + "resource_root": "${RESOURCE_ROOT}", + "total_entries": 2 +}