feat(release):完成双 release 运维闭环
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-12 11:08:04 +08:00
parent 8a77502272
commit 8d57a63697
27 changed files with 3635 additions and 222 deletions
+15 -6
View File
@@ -46,6 +46,12 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
按当前 QA 精确校验 identity。`translation.glossary.*` 已通过 按当前 QA 精确校验 identity。`translation.glossary.*` 已通过
`bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。 `bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。
`localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity
分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回
`localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的
`release.status/list/distribution/cleanup` 已由 Rust 从既有状态、manifest、文件系统和
CAS/reference 元数据统一生成,Go 仅 typed 转发。
--- ---
## 2. 权威文档入口 ## 2. 权威文档入口
@@ -152,11 +158,14 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
- `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。 - `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。
- `OfficialUpdateService`:官方 metadata auto-discover、bootstrap cache、snapshot diff、marker diff、本地 audit/repair、失败 staging 恢复。 - `OfficialUpdateService`:官方 metadata auto-discover、bootstrap cache、snapshot diff、marker diff、本地 audit/repair、失败 staging 恢复。
- `bat`:正式 CLI binary,支持 one-shot、`--proxy` / `--no-proxy``--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable` - `bat`:正式 CLI binary,支持 one-shot、`--proxy` / `--no-proxy``--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `release_ops.rs`:从既有 official/localized state、manifest、filesystem 和 CAS reference
元数据生成双 release `status/list/distribution/cleanup`;默认 official 分发,localized
和历史 release 仅在 Rust 完整性验证通过后可选,cleanup 使用 dry-run `plan_id` 和执行前重验证。
- `report_output.rs``terminal_output.rs`:分别负责结果报告渲染和前台终端诊断、帮助、进度及结构化日志输出。 - `report_output.rs``terminal_output.rs`:分别负责结果报告渲染和前台终端诊断、帮助、进度及结构化日志输出。
待完成: 待完成:
- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary复杂 AssetBundle fixturegeneric manifest 发布资源视图 - 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary复杂 AssetBundle fixturegeneric manifest V1 与双 release 运维 V1 已完成
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`;实际运行报告由脚本写入隔离输出目录。 - 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh``make official-smoke`;实际运行报告由脚本写入隔离输出目录。
- 增加更多权限和极端文件系统场景测试。 - 增加更多权限和极端文件系统场景测试。
@@ -194,7 +203,7 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
待完成: 待完成:
- 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。 - 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。
- 未见样本驱动的复杂 AssetBundle 重打包、更多双 release 查询/分发视图和清理策略;当前 generic manifest 只承诺已验证的 Binary/JSON/TextUnityFS 结构,不等价于任意整体 AssetBundle 重打包。 - 未见样本驱动的复杂 AssetBundle 重打包;当前 generic manifest 和双 release 运维 V1 只承诺已验证的 Binary/JSON/TextUnityFS 结构及 Rust-owned release 查询/分发/安全清理,不等价于任意整体 AssetBundle 重打包。
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。 - `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
### `bat-ffi` ### `bat-ffi`
@@ -228,7 +237,7 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
| 角色 | 所有者 | 状态 | | 角色 | 所有者 | 状态 |
|---|---|---| |---|---|---|
| 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 | | 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 |
| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + CDN MVP + RPC 周期刷新/诊断 + readiness + 内嵌 dashboard | | 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + official/localized/historical verified CDN MVP + RPC 周期刷新/诊断 + readiness + release 管理转发 + 内嵌 dashboard |
| daemon RPC client | `internal/backendrpc` | 完成 | | daemon RPC client | `internal/backendrpc` | 完成 |
| 试验 CLI | `cmd/bat``bin/bat-go` | 非产品 | | 试验 CLI | `cmd/bat``bin/bat-go` | 非产品 |
| FFI | `internal/ffi` | 可选 | | FFI | `internal/ffi` | 可选 |
@@ -305,7 +314,7 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
后续工程顺序: 后续工程顺序:
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。 1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
2. 继续通用 Patchmanifest 驱动、双 release 查询和清理策略 2. 继续通用 Patch真实样本驱动的复杂 AssetBundle 兼容;双 release 查询、分发、rollback 边界和安全清理 V1 已完成
3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider 3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider
扩展体系。 扩展体系。
4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。 4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
@@ -317,5 +326,5 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
`backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
通用 Binary/JSON/Text Patch 基础、generic manifest 和受支持 localized patch 发布/rollback 可用; 通用 Binary/JSON/Text Patch 基础、generic manifest 和受支持 localized patch 发布/rollback 可用;
复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更双 release 运维视图未完成。 复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更高阶 release retention 未完成;双 release 运维 V1 已完成。
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、双 release 运维视图,以及真实官方资源长期运行验证。 - **下一工程里程碑**:复杂 AssetBundle 解析和重打包,以及真实官方资源长期运行验证。
+5 -4
View File
@@ -32,7 +32,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。 4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。 5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat` 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/restart/reload/refresh/doctor``resource.sync/verify/repair/state/manifest/list/index``parse.status/text_units/errors``translation.*``localized.status``catalog.*``task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI 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``translation.*``localized.status``release.status/list/distribution/cleanup``catalog.*``task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release。 9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release。
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 明细索引和队列时只读取摘要,不重复解析。 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 明细索引和队列时只读取摘要,不重复解析。
@@ -306,7 +306,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
3. 实现客户端发现、路径校验、备份、应用、回滚。 3. 实现客户端发现、路径校验、备份、应用、回滚。
4. 实现 `patch build``patch apply``patch rollback``verify` 4. 实现 `patch build``patch apply``patch rollback``verify`
5. 实现 dry-run 和安全检查。 5. 实现 dry-run 和安全检查。
6. generic Patch manifest 已统一当前支持类型后,继续补复杂 AssetBundle 兼容和双 release 运维视图 6. generic Patch manifest V1 已统一当前支持类型;双 release 的查询、分发选择和安全 cleanup V1 已由 Rust `bat` 持有,复杂 AssetBundle 兼容继续由真实样本驱动
7. `release.status` / `release.list` 提供 official/localized current 与历史 release 统一视图;`release.distribution` 只选择已验证资源,默认 official;`release.cleanup` 采用 dry-run `plan_id`、执行前重验证和 CAS/reference 保护,rollback 保持独立。
验收标准: 验收标准:
@@ -387,7 +388,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
## 5. 推荐执行顺序 ## 5. 推荐执行顺序
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包、双 release 运维视图和真实官方资源长期运行验证。 近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
建议顺序: 建议顺序:
@@ -461,7 +462,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。 按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、generic manifest、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容、双 release 运维视图和官方资源长期运行报告。 已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary V1、通用 Binary/JSON/Text Patch 基础、generic manifest V1、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback、Rust-owned 双 release 查询/分发/cleanup V1,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容和官方资源长期运行报告。
--- ---
+30 -1
View File
@@ -501,6 +501,10 @@ Glossary 相关定义变化会使受影响 override 失效,无关术语变化
| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` | | `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
| `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000 | | `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000 |
| `resource.index` | ✅ | 查询现有 SQLite ResourceRepository 索引,支持资源类型、hash、路径模式、release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 过滤 | | `resource.index` | ✅ | 查询现有 SQLite ResourceRepository 索引,支持资源类型、hash、路径模式、release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 过滤 |
| `release.status` | ✅ | 查询 official/localized 当前与历史 release 的统一状态、来源关系和完整性 |
| `release.list` | ✅ | 按 namespace 查询历史 release 摘要,识别 current、legacy、stale 和 damaged |
| `release.distribution` | ✅ | 选择已验证的 official 或 localized 当前/历史 release;默认 official,不跨 channel fallback |
| `release.cleanup` | ✅ | 先 dry-run 生成 `plan_id`,再由 Rust 重验证引用后清理无引用历史 release |
| `parse.status` | ✅ | 查询当前 release 的解析缓存、TextUnit 索引和队列摘要 | | `parse.status` | ✅ | 查询当前 release 的解析缓存、TextUnit 索引和队列摘要 |
| `parse.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 | | `parse.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 |
| `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 | | `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 |
@@ -523,10 +527,23 @@ Glossary 相关定义变化会使受影响 override 失效,无关术语变化
| 未知方法 | — | `BAT-ERR-700001`unknown method | | 未知方法 | — | `BAT-ERR-700001`unknown method |
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` / 只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` /
`resource.index` / `parse.*` / `translation.tasks` / `translation.handoff` / `resource.index` / `release.status` / `release.list` / `release.distribution` / `parse.*` / `translation.tasks` / `translation.handoff` /
`localized.status` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本 `localized.status` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本
或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。 或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
`localized.status` 会分别返回 `patch_manifest_contract_status` 和
`artifact_integrity_status`。current 仍存在但 release 文件被截断或手工修改时,
状态为 `degraded` / `localized.degraded`,不会自动回滚、删除或修复。`release.distribution`
默认选择 official;只有 Rust 已验证的当前或显式历史 release 可分发,localized、
staging、损坏或路径不安全的 release 不会回退到另一 channel。
`release.list` 的每项摘要还会返回 `rollback_available`、`stale`、`damaged`、
`referenced` 和 `unknown`,便于区分可回滚、损坏和证据不足的历史 release。
双 release cleanup 不是独立 CLI:通过 `release.cleanup` 先执行 dry-run,再把返回的
`plan_id` 传给 `execute=true`。Rust 会保护 current、rollback、staging、source、
状态/manifest/CAS 引用和未知归属对象;rollback 仍使用独立的 `localized.rollback`。
### 任务模型 ### 任务模型
`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }``status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录: `resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }``status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
@@ -567,4 +584,16 @@ printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":
# 触发本地资源审计+修复任务 # 触发本地资源审计+修复任务
printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \ printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock | socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
# 查询双 release 状态,并选择已验证的 localized release
printf '{"jsonrpc":"2.0","id":7,"method":"release.status"}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
printf '{"jsonrpc":"2.0","id":8,"method":"release.distribution","params":{"channel":"localized"}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
# cleanup 必须先 dry-run,再使用同一 plan_id 执行
printf '{"jsonrpc":"2.0","id":9,"method":"release.cleanup","params":{"execute":false}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
printf '{"jsonrpc":"2.0","id":10,"method":"release.cleanup","params":{"execute":true,"plan_id":"<plan-id>"}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
``` ```
+73 -1
View File
@@ -72,6 +72,52 @@ paths:
responses: responses:
"200": "200":
description: Release summary. description: Release summary.
/v1/releases:
get:
summary: Rust-owned official and localized release history
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
responses:
"200":
description: Release history and manifest/artifact integrity summaries.
"503":
description: Rust bat release backend is unavailable.
/v1/distribution:
get:
summary: Select a verified official or localized release for distribution
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
default: official
- name: release_id
in: query
schema:
type: string
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
responses:
"200":
description: Rust-verified selected release and resource manifest page.
"409":
description: Selected release is missing, stale, damaged, or not distributable.
"503":
description: Rust bat release backend is unavailable.
/v1/resources: /v1/resources:
get: get:
summary: Paginated resource manifest entries summary: Paginated resource manifest entries
@@ -544,6 +590,32 @@ paths:
description: Missing or invalid admin token. description: Missing or invalid admin token.
"503": "503":
description: Rust bat localized backend is unavailable. description: Rust bat localized backend is unavailable.
/admin/releases/status:
get:
summary: Read the unified Rust-owned release status view
responses:
"200":
description: Official/localized current relation and integrity status.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat release backend is unavailable.
/admin/releases:
get:
summary: Read Rust-owned historical release summaries
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
responses:
"200":
description: Historical release summaries.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat release backend is unavailable.
/admin/control/{action}: /admin/control/{action}:
post: post:
summary: Forward an allowlisted control or schedule action to Rust bat summary: Forward an allowlisted control or schedule action to Rust bat
@@ -553,7 +625,7 @@ paths:
required: true required: true
schema: schema:
type: string type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback] enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup]
requestBody: requestBody:
required: false required: false
content: content:
+315 -87
View File
@@ -347,7 +347,11 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
file.path.display() file.path.display()
))); )));
} }
let mut targets = std::collections::BTreeSet::new(); let unity_operations = file
.operations
.iter()
.filter_map(unity_operation_target)
.collect::<Vec<_>>();
for (expected_sequence, operation) in file.operations.iter().enumerate() { for (expected_sequence, operation) in file.operations.iter().enumerate() {
operation.payload.validate()?; operation.payload.validate()?;
if operation.sequence != expected_sequence as u32 { if operation.sequence != expected_sequence as u32 {
@@ -391,15 +395,6 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
operation.sequence operation.sequence
))); )));
} }
if let Some(target) = unity_operation_target(operation) {
if !targets.insert(target) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest repeats an incompatible UnityFS target: {} operation {}",
file.path.display(),
operation.sequence
)));
}
}
if file.patch_kind != PatchKind::Mixed && file.patch_kind != operation.patch_kind() { if file.patch_kind != PatchKind::Mixed && file.patch_kind != operation.patch_kind() {
return Err(PatchError::ApplyFailed(format!( return Err(PatchError::ApplyFailed(format!(
"patch manifest kind mismatch for {} operation {}", "patch manifest kind mismatch for {} operation {}",
@@ -408,34 +403,153 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
))); )));
} }
} }
for (index, left) in unity_operations.iter().enumerate() {
for right in unity_operations.iter().skip(index + 1) {
if unity_operation_targets_conflict(left, right) {
return Err(PatchError::ApplyFailed(format!(
"patch manifest contains overlapping UnityFS targets in {}: {} and {}",
file.path.display(),
left.describe(),
right.describe()
)));
}
}
}
} }
Ok(()) Ok(())
} }
fn unity_operation_target(operation: &PatchManifestOperation) -> Option<(Option<&str>, &str, i64)> { #[derive(Debug, Clone, Copy)]
let (serialized_file_path, path_id) = match &operation.payload { enum UnityOperationTarget<'a> {
WholeObject {
archive_entry: Option<&'a str>,
serialized_file_path: &'a str,
path_id: i64,
},
Field {
archive_entry: Option<&'a str>,
serialized_file_path: &'a str,
path_id: i64,
field_path: &'a str,
},
}
impl UnityOperationTarget<'_> {
fn archive_entry(&self) -> Option<&str> {
match self {
Self::WholeObject { archive_entry, .. } | Self::Field { archive_entry, .. } => {
*archive_entry
}
}
}
fn serialized_file_path(&self) -> &str {
match self {
Self::WholeObject {
serialized_file_path,
..
}
| Self::Field {
serialized_file_path,
..
} => serialized_file_path,
}
}
fn path_id(&self) -> i64 {
match self {
Self::WholeObject { path_id, .. } | Self::Field { path_id, .. } => *path_id,
}
}
fn describe(self) -> String {
match self {
Self::WholeObject {
archive_entry,
serialized_file_path,
path_id,
} => format!(
"archive={archive_entry:?}, serialized_file={serialized_file_path}, path_id={path_id}, object"
),
Self::Field {
archive_entry,
serialized_file_path,
path_id,
field_path,
} => format!(
"archive={archive_entry:?}, serialized_file={serialized_file_path}, path_id={path_id}, field={field_path}"
),
}
}
}
fn unity_operation_target(operation: &PatchManifestOperation) -> Option<UnityOperationTarget<'_>> {
let archive_entry = operation.archive_entry.as_deref();
match &operation.payload {
PatchManifestOperationPayload::UnityFsTextAsset { PatchManifestOperationPayload::UnityFsTextAsset {
serialized_file_path, serialized_file_path,
path_id, path_id,
.. ..
} => (serialized_file_path, *path_id), } => Some(UnityOperationTarget::WholeObject {
archive_entry,
serialized_file_path,
path_id: *path_id,
}),
PatchManifestOperationPayload::UnityFsStringField { PatchManifestOperationPayload::UnityFsStringField {
serialized_file_path, serialized_file_path,
path_id, path_id,
field_path,
.. ..
} }
| PatchManifestOperationPayload::UnityFsField { | PatchManifestOperationPayload::UnityFsField {
serialized_file_path, serialized_file_path,
path_id, path_id,
field_path,
.. ..
} => (serialized_file_path, *path_id), } => Some(UnityOperationTarget::Field {
_ => return None, archive_entry,
}; serialized_file_path,
Some(( path_id: *path_id,
operation.archive_entry.as_deref(), field_path,
serialized_file_path.as_str(), }),
path_id, _ => None,
)) }
}
fn unity_operation_targets_conflict(
left: &UnityOperationTarget<'_>,
right: &UnityOperationTarget<'_>,
) -> bool {
if left.archive_entry() != right.archive_entry()
|| left.serialized_file_path() != right.serialized_file_path()
|| left.path_id() != right.path_id()
{
return false;
}
match (left, right) {
(UnityOperationTarget::WholeObject { .. }, _)
| (_, UnityOperationTarget::WholeObject { .. }) => true,
(
UnityOperationTarget::Field {
field_path: left_path,
..
},
UnityOperationTarget::Field {
field_path: right_path,
..
},
) => field_paths_overlap(left_path, right_path),
}
}
fn field_paths_overlap(left: &str, right: &str) -> bool {
left == right || is_field_path_parent(left, right) || is_field_path_parent(right, left)
}
fn is_field_path_parent(parent: &str, child: &str) -> bool {
child
.strip_prefix(parent)
.is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('['))
} }
/// Builds a manifest from release-root bytes and ordered operation payloads. /// Builds a manifest from release-root bytes and ordered operation payloads.
@@ -477,13 +591,8 @@ pub fn build_patch_manifest(
file.path.display() file.path.display()
))); )));
} }
if file.operations.len() > 1 && has_unity_operation {
return Err(PatchError::ApplyFailed(format!(
"manifest builder requires one UnityFS operation per file; use adapter-supplied manifest operations for multiple UnityFS targets: {}",
file.path.display()
)));
}
let operation_count = file.operations.len(); let operation_count = file.operations.len();
let direct_operations = !has_unity_operation && !has_archive_operation;
let mut current = source.clone(); let mut current = source.clone();
let operations = file let operations = file
.operations .operations
@@ -491,39 +600,58 @@ pub fn build_patch_manifest(
.enumerate() .enumerate()
.map(|(sequence, mut operation)| { .map(|(sequence, mut operation)| {
operation.payload.validate()?; operation.payload.validate()?;
let source_blake3 = (operation_count > 1).then(|| blake3_hex(&current)); if direct_operations && operation.source_blake3.is_none() && operation_count > 1 {
let source_size = (operation_count > 1).then_some(current.len() as u64); operation.source_blake3 = Some(blake3_hex(&current));
if operation.source_blake3.is_none() && operation_count > 1 { operation.source_size = Some(current.len() as u64);
if has_archive_operation || has_unity_operation { } else if !direct_operations
return Err(PatchError::ApplyFailed(format!( && operation_count > 1
"manifest builder requires source preconditions for multiple non-direct operations: {}", && operation.source_blake3.is_none()
file.path.display()
)));
}
operation.source_blake3 = source_blake3;
operation.source_size = source_size;
}
if let (Some(expected_hash), Some(expected_size)) =
(operation.source_blake3.as_deref(), operation.source_size)
{ {
if expected_hash != blake3_hex(&current) return Err(PatchError::ApplyFailed(format!(
|| expected_size != current.len() as u64 "manifest builder requires source preconditions for multiple non-direct operations: {}",
file.path.display()
)));
}
if direct_operations {
if let (Some(expected_hash), Some(expected_size)) =
(operation.source_blake3.as_deref(), operation.source_size)
{ {
return Err(PatchError::ApplyFailed(format!( if expected_hash != blake3_hex(&current)
"manifest builder operation source precondition mismatch: {} operation {}", || expected_size != current.len() as u64
file.path.display(), {
sequence return Err(PatchError::ApplyFailed(format!(
))); "manifest builder operation source precondition mismatch: {} operation {}",
file.path.display(),
sequence
)));
}
}
current = apply_direct_payload(&current, &operation.payload)?;
} else if operation_count == 1 {
if let (Some(expected_hash), Some(expected_size)) =
(operation.source_blake3.as_deref(), operation.source_size)
{
if expected_hash != blake3_hex(&source)
|| expected_size != source.len() as u64
{
return Err(PatchError::ApplyFailed(format!(
"manifest builder operation source precondition mismatch: {} operation {}",
file.path.display(),
sequence
)));
}
} }
} }
if !has_unity_operation && !has_archive_operation { /*
current = apply_direct_payload(&current, &operation.payload)?; * Non-direct operations are produced by the owning adapter.
} * Their source preconditions describe adapter-produced
* intermediate bytes, which this crate cannot reconstruct.
*/
operation.sequence = sequence as u32; operation.sequence = sequence as u32;
Ok(operation) Ok(operation)
}) })
.collect::<crate::Result<Vec<_>>>()?; .collect::<crate::Result<Vec<_>>>()?;
if !has_unity_operation && !has_archive_operation && current != target { if direct_operations && current != target {
return Err(PatchError::ApplyFailed(format!( return Err(PatchError::ApplyFailed(format!(
"manifest builder operations do not produce target bytes: {}", "manifest builder operations do not produce target bytes: {}",
file.path.display() file.path.display()
@@ -852,45 +980,145 @@ mod tests {
} }
#[test] #[test]
fn validate_patch_manifest_rejects_repeated_unity_object_target() { fn validate_patch_manifest_allows_sibling_fields_on_one_unity_object() {
let source = b"source"; let manifest = unity_manifest(vec![
let target = b"target"; unity_string_operation(None, "first", 0),
let mut manifest = manifest_for("bundle", source, target); unity_field_operation(None, "second", 1),
manifest.files[0].patch_kind = PatchKind::Mixed; ]);
manifest.files[0].operations = vec![
PatchManifestOperation { validate_patch_manifest(&manifest).unwrap();
sequence: 0, }
source_blake3: Some(blake3_hex(source)),
source_size: Some(source.len() as u64), #[test]
archive_entry: None, fn validate_patch_manifest_rejects_duplicate_unity_field() {
payload: PatchManifestOperationPayload::UnityFsStringField { let manifest = unity_manifest(vec![
serialized_file_path: "CAB-one".to_string(), unity_string_operation(None, "first", 0),
path_id: 7, unity_string_operation(None, "first", 1),
field_path: "first".to_string(), ]);
expected_value: None,
replacement: "one".to_string(),
},
provenance: None,
},
PatchManifestOperation {
sequence: 1,
source_blake3: Some(blake3_hex(target)),
source_size: Some(target.len() as u64),
archive_entry: None,
payload: PatchManifestOperationPayload::UnityFsField {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
field_path: "second".to_string(),
expected_value: None,
replacement: serde_json::json!({"kind": "string", "value": "two"}),
},
provenance: None,
},
];
assert!(validate_patch_manifest(&manifest).is_err()); assert!(validate_patch_manifest(&manifest).is_err());
} }
#[test]
fn validate_patch_manifest_rejects_whole_object_and_field_overlap() {
let manifest = unity_manifest(vec![
unity_text_asset_operation(None, 0),
unity_string_operation(None, "first", 1),
]);
assert!(validate_patch_manifest(&manifest).is_err());
}
#[test]
fn validate_patch_manifest_rejects_parent_child_field_overlap() {
let manifest = unity_manifest(vec![
unity_string_operation(None, "root", 0),
unity_field_operation(None, "root.child", 1),
]);
assert!(validate_patch_manifest(&manifest).is_err());
}
#[test]
fn validate_patch_manifest_applies_the_same_identity_rules_inside_zip_entries() {
validate_patch_manifest(&unity_manifest(vec![
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
unity_field_operation(Some("bundles/one.bundle"), "second", 1),
]))
.unwrap();
for operations in [
vec![
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
unity_string_operation(Some("bundles/one.bundle"), "first", 1),
],
vec![
unity_text_asset_operation(Some("bundles/one.bundle"), 0),
unity_string_operation(Some("bundles/one.bundle"), "first", 1),
],
vec![
unity_string_operation(Some("bundles/one.bundle"), "root", 0),
unity_field_operation(Some("bundles/one.bundle"), "root.child", 1),
],
] {
assert!(validate_patch_manifest(&unity_manifest(operations)).is_err());
}
validate_patch_manifest(&unity_manifest(vec![
unity_string_operation(Some("bundles/one.bundle"), "first", 0),
unity_string_operation(Some("bundles/two.bundle"), "first", 1),
]))
.unwrap();
}
fn unity_manifest(operations: Vec<PatchManifestOperation>) -> PatchManifest {
let mut manifest = manifest_for("bundle", b"source", b"target");
manifest.files[0].patch_kind = PatchKind::Mixed;
manifest.files[0].operations = operations;
manifest
}
fn unity_string_operation(
archive_entry: Option<&str>,
field_path: &str,
sequence: u32,
) -> PatchManifestOperation {
PatchManifestOperation {
sequence,
source_blake3: Some(blake3_hex(b"source")),
source_size: Some(6),
archive_entry: archive_entry.map(str::to_string),
payload: PatchManifestOperationPayload::UnityFsStringField {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
field_path: field_path.to_string(),
expected_value: None,
replacement: "replacement".to_string(),
},
provenance: None,
}
}
fn unity_field_operation(
archive_entry: Option<&str>,
field_path: &str,
sequence: u32,
) -> PatchManifestOperation {
PatchManifestOperation {
sequence,
source_blake3: Some(blake3_hex(b"source")),
source_size: Some(6),
archive_entry: archive_entry.map(str::to_string),
payload: PatchManifestOperationPayload::UnityFsField {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
field_path: field_path.to_string(),
expected_value: None,
replacement: serde_json::json!({"kind": "string", "value": "replacement"}),
},
provenance: None,
}
}
fn unity_text_asset_operation(
archive_entry: Option<&str>,
sequence: u32,
) -> PatchManifestOperation {
PatchManifestOperation {
sequence,
source_blake3: Some(blake3_hex(b"source")),
source_size: Some(6),
archive_entry: archive_entry.map(str::to_string),
payload: PatchManifestOperationPayload::UnityFsTextAsset {
serialized_file_path: "CAB-one".to_string(),
path_id: 7,
expected_name: None,
replacement: b"replacement".to_vec(),
},
provenance: None,
}
}
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest { fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
PatchManifest { PatchManifest {
version: PATCH_MANIFEST_VERSION, version: PATCH_MANIFEST_VERSION,
+2 -2
View File
@@ -156,7 +156,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。 1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。
2. ResourceRepository 已保存官方 manifest 资源的类型、路径、hash、size 和 metadatametadata 包含 release、平台、bundle path、parse status、TextAsset 名称、TextUnit 数量/格式。 2. ResourceRepository 已保存官方 manifest 资源的类型、路径、hash、size 和 metadatametadata 包含 release、平台、bundle path、parse status、TextAsset 名称、TextUnit 数量/格式。
3. 支持 RPC/CLI 查询资源、bundle、TextAsset、解析错误和缓存状态;当前 `resource.index` 会返回资源 metadata`parse-status` 会返回 TextUnit 索引和队列摘要,`parse-text-units` / `parse-errors` 会按当前 release 查询明细,`localized-status`校验 patch manifest。 3. 支持 RPC/CLI 查询资源、bundle、TextAsset、解析错误和缓存状态;当前 `resource.index` 会返回资源 metadata`parse-status` 会返回 TextUnit 索引和队列摘要,`parse-text-units` / `parse-errors` 会按当前 release 查询明细,`localized-status`区分 patch manifest contract 与 artifact integrity`release.status/list/distribution/cleanup` 提供 Rust-owned 双 release 视图和安全运维操作
4. schema 迁移可重复执行;当前 SQLite 已有 `crc``metadata_json` 兼容迁移。 4. schema 迁移可重复执行;当前 SQLite 已有 `crc``metadata_json` 兼容迁移。
验收: 验收:
@@ -175,7 +175,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作;普通 ZIP 条目在 `archive_entry` 可验证、内层可重解析时会解包、重建并重写外层 ZIP,路径穿越、symlink、混合直接/ZIP patch 和无效内层 bundle 明确失败。 2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作;普通 ZIP 条目在 `archive_entry` 可验证、内层可重解析时会解包、重建并重写外层 ZIP,路径穿越、symlink、混合直接/ZIP patch 和无效内层 bundle 明确失败。
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果;generic manifest 不把 UnityFS 定位信息扁平化。 3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果;generic manifest 不把 UnityFS 定位信息扁平化。
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`rollback 按 manifest 恢复上一 release。 4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`rollback 按 manifest 恢复上一 release。
5. 成功后发布状态从 `not_localized` 切到 `localized``localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 releaseZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验 5. 成功后发布状态从 `not_localized` 切到 `localized``localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release并分别报告 schema/contract 与 artifact integrityZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验;current 仍存在但产物损坏时返回 `localized.degraded`,不自动修复
验收: 验收:
+10 -4
View File
@@ -244,7 +244,7 @@ trusted 和 release/TextUnit/provider/run provenance`translation.tasks` 优
13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json``crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。 13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json``crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。
14. 随后刷新 active release 下的 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。 14. 随后刷新 active release 下的 `official-parse-cache.json``official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json``crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用,staging/release 清理时递减,失败则回退网络并保留诊断。 15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用,staging/release 清理时递减,失败则回退网络并保留诊断。
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;generic manifest 驱动的 Binary/JSON/Text 以及当前支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 及 ZIP 内层最终重解析校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。 16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;generic manifest 驱动的 Binary/JSON/Text 以及当前支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 及 ZIP 内层最终重解析校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`localized.status` 分开返回 `patch_manifest_contract_status``artifact_integrity_status`state/current/identity 存在但文件被截断或手工修改时返回 `localized.degraded`,只读检查不回滚、不删除、不修复。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。
维护期特殊分支:如果官方 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 证据,供后续排障和自研客户端开发使用。
@@ -345,8 +345,9 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
- 方法命名空间与实现状态、请求/响应示例见 - 方法命名空间与实现状态、请求/响应示例见
`docs/reference/rpc-backend-api.md``daemon.status/logs/stop/restart/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`
`translation.tasks/handoff/task.update/proofread``localized.status``catalog.*` `translation.tasks/handoff/task.update/proofread``localized.status`
`task.status/list/cancel/logs` 已实现;文件级 `patch.apply` / `unityfs.patch_*` `release.status/list/distribution/cleanup``catalog.*` `task.status/list/cancel/logs`
已实现;文件级 `patch.apply` / `unityfs.patch_*`
和受支持 localized publish/rollback 已实现,`archive_entry` 可验证时会重写 和受支持 localized publish/rollback 已实现,`archive_entry` 可验证时会重写
外层 ZIP;通用发布级 patch 与复杂 UnityFS 语义编辑仍待后续; 外层 ZIP;通用发布级 patch 与复杂 UnityFS 语义编辑仍待后续;
`task.create` 按设计暂不开放通用任务入口; `task.create` 按设计暂不开放通用任务入口;
@@ -357,7 +358,7 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
- Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、 - Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、
bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和 bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和
`resource_root` 中已发布的只读文件。 Rust 选择后返回的 `resource_root` 中已发布的只读文件。
- Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、 - Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接 以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接
`bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。 `bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。
@@ -371,6 +372,11 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。 manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再 - 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再
`catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。
- `/v1/releases``/v1/distribution` 和受保护的 `/admin/releases/status`
`/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed
结果;localized 或历史分发不会绕过 Rust 完整性判断。
- 受保护的 `/admin/control/release-cleanup` 只转发 Rust `release.cleanup`
先 dry-run 获取 `plan_id`,执行时由 Rust 重验证引用、路径和 current 保护。
- 支持 `.env` / 环境变量配置监听端口、public base URL、RPC socket 和 RPC - 支持 `.env` / 环境变量配置监听端口、public base URL、RPC socket 和 RPC
刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。 刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。
- 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装 - 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装
+29 -4
View File
@@ -8,6 +8,7 @@
- inventory 抽取:`adapters/src/official/inventory.rs` - inventory 抽取:`adapters/src/official/inventory.rs`
- 落盘与 manifest`infrastructure/src/official_download.rs``destination_for_url` - 落盘与 manifest`infrastructure/src/official_download.rs``destination_for_url`
- 发布布局:`infrastructure/src/official_update.rs` - 发布布局:`infrastructure/src/official_update.rs`
- 双 release 视图、分发选择与清理:`infrastructure/src/release_ops.rs`
- 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md` - 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md`
--- ---
@@ -17,7 +18,7 @@
| 角色 | 组件 | 职责 | | 角色 | 组件 | 职责 |
|---|---|---| |---|---|---|
| 同步 / 运维(近乎全自动) | Rust `bat` | auto-discover、拉取、校验、发布、watch/daemon、RPC 后端 | | 同步 / 运维(近乎全自动) | Rust `bat` | auto-discover、拉取、校验、发布、watch/daemon、RPC 后端 |
| 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 发现已发布版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写官方 CDN path 字节 | | 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 读取 Rust 选择的已验证版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写官方/localized CDN path 字节和 release 管理转发 |
| 试验 CLI | Go `cmd/bat``bin/bat-go` | 非产品;禁止与 Rust `bat` 重名 | | 试验 CLI | Go `cmd/bat``bin/bat-go` | 非产品;禁止与 Rust `bat` 重名 |
**禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。 **禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。
@@ -85,13 +86,34 @@
- `not_localized`:官方原版资源已经完成下载、校验和发布,汉化资源尚未发布;这是官方同步完成后的默认状态。 - `not_localized`:官方原版资源已经完成下载、校验和发布,汉化资源尚未发布;这是官方同步完成后的默认状态。
- `localized`:同一官方版本的原版资源和汉化资源都已发布,生产侧可以同时提供两套资源。 - `localized`:同一官方版本的原版资源和汉化资源都已发布,生产侧可以同时提供两套资源。
### 2.1 读侧 vs 写侧 ### 2.1 双 release 读写边界
Rust `bat``release.status` 是 official/localized 的统一只读视图,基于既有
version state、current symlink、release manifest、文件系统和必要的 CAS/reference
元数据计算,不建立第二个 release 数据库。`release.list` 返回两个 namespace 的当前
与历史 release,包含稳定 ID、created/published、source official relation、生命周期、
`rollback_available`、manifest contract、artifact/distribution integrity、
`stale`/`damaged`/`referenced`/`unknown`、rollback previous 和诊断;缺少 generic manifest 的
旧 localized release 保留为 `legacy`/`unknown`,不自动改写。
`release.distribution` 的默认 channel 是 `official`。只有当前或显式历史、路径归属安全、
source relation 正确且 manifest/artifact integrity 通过的 release 才能被选择;staging、
损坏、缺失、symlink/path escape 或未验证历史项不会回退到另一 channel。返回的
`resource_root` 和 manifest entry 由 Rust 决定,Go 只做 typed forwarding。
`release.cleanup` 先生成 dry-run 计划和 `plan_id`,执行时重新计算并比对计划。current、
rollback previous、active/in-progress、localized source official、state/manifest/CAS
reference、无法确认 ownership 的对象均保留;只删除重新验证后仍为普通目录且确定无引用的
历史 release。它不改变 current,不执行 rollback,也不负责自动 repairstaging 默认保留
以避免删除未持久化任务。
### 2.2 读侧 vs 写侧
| 阶段 | 根目录 | | 阶段 | 根目录 |
|---|---| |---|---|
| 下载写入 | `<output>/.staging/<id>` | | 下载写入 | `<output>/.staging/<id>` |
| 发布完成 | rename 到 `versions/<id>`,再切换 `current` | | 发布完成 | rename 到 `versions/<id>`,再切换 `current` |
| 生产读取 / bat-api | RPC 给出的 `version.resource_root`通常等价于 `current` 解析后的 versioned 目录 | | 生产读取 / bat-api | RPC 给出的已验证 `resource_root`默认等价于 official `current` 解析后的 versioned 目录localized 必须显式选择 |
每个 release 的 `official-download-manifest.json` 是历史复用的索引。新 staging 每个 release 的 `official-download-manifest.json` 是历史复用的索引。新 staging
按规范化 destination 查找候选,并重新验证 manifest 中的 size、BLAKE3 和 ZIP 按规范化 destination 查找候选,并重新验证 manifest 中的 size、BLAKE3 和 ZIP
@@ -138,7 +160,10 @@ GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
≡ 磁盘 <resource_root>/prod-clientpatch.bluearchiveyostar.com/<root_token>/... ≡ 磁盘 <resource_root>/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
``` ```
默认仅服务 **download manifest 索引内且 Present + size 匹配** 的文件。 默认仅服务 **official download manifest 索引内且 Present + size 匹配** 的文件。需要
localized 或历史 release 时,调用 `release.distribution` 选择 Rust 已验证的
`resource_root`,再由 `/v1/distribution` 或带 `channel`/`release_id` 的 CDN path
转发;Go 不在本地判断健康度,也不回退到 official。
### 3.3 launcher 资源引导兼容 ### 3.3 launcher 资源引导兼容
+36
View File
@@ -34,6 +34,42 @@ bat res pull --auto-discover --watch --interval 1h \
资源下载默认使用 8 个独立 worker,允许范围为 `1..=256`。worker 完成当前 URL 后立即领取共享队列中的下一个任务,进度按完成顺序统计,最终报告仍按计划顺序输出。 资源下载默认使用 8 个独立 worker,允许范围为 `1..=256`。worker 完成当前 URL 后立即领取共享队列中的下一个任务,进度按完成顺序统计,最终报告仍按计划顺序输出。
## Release 查询与清理
双 release 运维由 Rust `bat` 通过 `bat.sock` 提供,不新增平行顶层 CLI
```bash
# 查看 official/localized 当前、历史、source relation 和完整性
printf '{"jsonrpc":"2.0","id":1,"method":"release.status"}\n' \
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
printf '{"jsonrpc":"2.0","id":2,"method":"release.list","params":{"channel":"localized"}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
# 选择已验证的 localized 当前 release,默认 channel 仍是 official
printf '{"jsonrpc":"2.0","id":3,"method":"release.distribution","params":{"channel":"localized"}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
```
`release.distribution` 只返回 Rust 已验证的当前或显式历史 release。localized 必须
同时满足 source official、current、manifest identity、source/target hash/size 和
UnityFS/ZIP 最终语义校验;staging、损坏、缺失或路径不安全的 release 不会跨 channel
fallback。`localized.status``patch_manifest_contract_status`
`artifact_integrity_status` 分开表示 schema 和产物健康度,产物损坏时为
`localized.degraded`,检查不会自动修复。
清理必须先 dry-run,再使用同一 `plan_id` 执行:
```bash
printf '{"jsonrpc":"2.0","id":4,"method":"release.cleanup","params":{"execute":false}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
printf '{"jsonrpc":"2.0","id":5,"method":"release.cleanup","params":{"execute":true,"plan_id":"<plan-id>"}}\n' \
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
```
Rust 会在执行前重算计划,保护 current、rollback previous、active/in-progress、
localized source official、state/manifest/CAS/reference 和未知归属对象。cleanup 不改变
current、不执行 rollback,也不删除 staging;回滚仍使用独立的 `localized.rollback`
## 解析与重打包 ## 解析与重打包
解析当前已发布 release 解析当前已发布 release
+24 -3
View File
@@ -172,6 +172,23 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。 删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。
### release
| 方法 | 状态 | params | data |
|---|---|---|---|
| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 统一视图。 |
| `release.list` | 已实现 | `{ "channel": "official" }``{ "channel": "localized" }`,可省略 | 对应 namespace 的历史 release 摘要,包含 stable ID、created/published、current pointer、`rollback_available`、lifecycle、`stale`/`damaged`/`referenced`/`unknown`、legacy 和诊断。 |
| `release.distribution` | 已实现 | `{ "channel": "official", "release_id": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 选择的 verified `resource_root` 和 download manifest entries;默认 channel 为 official,选择失败返回 `available=false`,不跨 channel fallback。 |
| `release.cleanup` | 已实现 | dry-run `{ "execute": false }`;执行 `{ "execute": true, "plan_id": "..." }` | cleanup plan、candidate/retain reasons、blocking references 和 removed paths;执行前会重新生成并比对 `plan_id`。 |
`release.status``release.list``release.distribution` 只读现有 official/localized
state、current、manifest、文件系统和 CAS/reference 元数据,不创建第二套 release 状态。
localized 只有在 source official、current pointer、manifest identity、source/target
hash/size 以及 UnityFS/ZIP 最终语义校验全部通过时才可分发;默认官方分发行为不变。
`release.cleanup` 只删除 Rust 能证明是普通目录且未被 current、rollback、staging、
source、state、manifest、CAS 或未知 ownership 引用的历史项,不修改 current,也不承担
rollback 或 repair。
### schedule ### schedule
调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的 调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的
@@ -345,7 +362,7 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
`./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。 `./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。
`status` / `status_code` 使用生命周期短状态和稳定状态码,例如 `status` / `status_code` 使用生命周期短状态和稳定状态码,例如
`pending` / `localized.pending``stale` / `localized.stale``published` / `pending` / `localized.pending``stale` / `localized.stale``published` /
`localized.published`;旧的 `localized` / `not_localized` 业务标签放在 `localized.published``localized.degraded`;旧的 `localized` / `not_localized` 业务标签放在
`localized_release_status``translation_workflow_status` / `translation_workflow_status_code` `localized_release_status``translation_workflow_status` / `translation_workflow_status_code`
用于表示汉化工作流的人工校对状态,例如 `manual_proofreading` / 用于表示汉化工作流的人工校对状态,例如 `manual_proofreading` /
`translation.manual_proofreading`。返回 `localized_release_status=localized` 的条件是: `translation.manual_proofreading`。返回 `localized_release_status=localized` 的条件是:
@@ -353,7 +370,10 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的 `current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回 `localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
`patch_manifest_path``patch_manifest_available` `patch_manifest_path``patch_manifest_available`
`patch_manifest_matches_release``patch_manifest_integrity_status` `patch_manifest_matches_release``patch_manifest_contract_status`
`patch_manifest_integrity_status`(兼容别名)、`artifact_integrity_status`
`artifact_integrity_verified``artifact_integrity_error`
`artifact_integrity_diagnostics`
`patch_manifest_source_version``patch_manifest_target_version` `patch_manifest_source_version``patch_manifest_target_version`
`patch_file_count``patch_operation_count``patch_kind_counts` `patch_file_count``patch_operation_count``patch_kind_counts`
`patch_text_asset_operation_count``rollback_previous_current_target` `patch_text_asset_operation_count``rollback_previous_current_target`
@@ -525,7 +545,7 @@ CLI 对应关系:
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。 envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、 - typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
`resource.state/sync/verify/repair/manifest/list``schedule.list/add/update/remove/run` `resource.state/sync/verify/repair/manifest/list``schedule.list/add/update/remove/run`
`catalog.*``parse.*` `catalog.*``parse.*``release.status/list/distribution/cleanup`
`localized.status``localized.publish``localized.rollback` `localized.status``localized.publish``localized.rollback`
`translation.tasks``translation.handoff``translation.task.update` `translation.tasks``translation.handoff``translation.task.update`
`translation.worker.run``translation.proofread``translation.memory.summary` `translation.worker.run``translation.proofread``translation.memory.summary`
@@ -551,6 +571,7 @@ CLI 对应关系:
| `TranslationMemoryBackend` | `translation.memory.summary``translation.memory.query``translation.memory.confirm` | 鉴权后的 TM 摘要、source/context 查询和显式 candidate 确认;Go 只转发,不持有 TM 状态 | | `TranslationMemoryBackend` | `translation.memory.summary``translation.memory.query``translation.memory.confirm` | 鉴权后的 TM 摘要、source/context 查询和显式 candidate 确认;Go 只转发,不持有 TM 状态 |
| `GlossaryBackend` | `translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核/删除 mutationGo 只转发,不持有 Glossary 状态 | | `GlossaryBackend` | `translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核/删除 mutationGo 只转发,不持有 Glossary 状态 |
| `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 | | `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
| `ReleaseBackend` | `release.status``release.list``release.distribution``release.cleanup` | 鉴权后的双 release 查询、验证分发选择和 dry-run/execute cleanupGo 不持有 release 状态 |
`daemon.stop``daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。 `daemon.stop``daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是 Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
+19 -8
View File
@@ -32,7 +32,7 @@ directory 形态和未修改对象/字段,并明确拒绝未知压缩或无法
回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须 回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须
同时补真实 fixture、回归测试和文档。 同时补真实 fixture、回归测试和文档。
### G-006:通用 Patch 的复杂格式和运维视图仍未完成 ### G-006:通用 Patch 的复杂格式和运维扩展仍未完成
状态:**V1 已完成(当前支持类型),复杂格式和运维扩展继续推进** 状态:**V1 已完成(当前支持类型),复杂格式和运维扩展继续推进**
@@ -41,13 +41,16 @@ rollback 元数据;`LocalizedPatchService` 已使用同一有序 generic manif
Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field
写入及 localized publish/rollback。发布会在独立 staging 中校验 source/target identity、 写入及 localized publish/rollback。发布会在独立 staging 中校验 source/target identity、
逐操作 precondition、ZIP 内层重解析和实际字段替换,并保留 TextUnit/TM/Glossary/review 逐操作 precondition、ZIP 内层重解析和实际字段替换,并保留 TextUnit/TM/Glossary/review
provenance`localized.publish``i18n publish` 和 bat-api typed forwarding 均已接入。 provenanceUnityFS 目标身份至少包含 archive entry、serialized file、path ID 和实际
field path,同对象 sibling field 可并存,重复、父子、whole-object/field 结构重叠会拒绝;
`localized.publish``i18n publish` 和 bat-api typed forwarding 均已接入。
仍需完成: 仍需完成:
- 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持 - 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持
可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围。 可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围。
- 原版 release 与 localized release 双发布后的更丰富查询、分发和清理策略。 - generic manifest 已冻结为当前支持类型的 V1;复杂 AssetBundle 结构仍需真实样本驱动,
不在本项中扩展 Patch 格式。
所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变 所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变
已发布的 `bat-resources/current``bat-localized/current` 已发布的 `bat-resources/current``bat-localized/current`
@@ -106,14 +109,21 @@ format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务
- 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。 - 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。
- 更多 schema 迁移、权限、并发和损坏恢复场景验证。 - 更多 schema 迁移、权限、并发和损坏恢复场景验证。
### G-011D:双 release 的完整查询与运维策略仍未完成 ### G-011D:双 release 查询、分发与安全清理
状态:**发布闭环完成,查询/清理扩展继续推进** 状态:**V1 已完成**
官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和 官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和
rollback`localized.status` 能校验当前官方 release 与 patch manifest 的一致性。 rollback。Rust `release.status` 提供统一 current/source/matchmanifest contract、
artifact/distribution integrity、历史 release、legacy/stale/damaged 摘要;
`release.list` 查询两个 namespace`release.distribution` 只允许当前或显式历史且已
验证的 official/localized release,默认仍为 official`release.cleanup` 提供 dry-run
`plan_id` 和执行前重验证,只删除确定未被 current、rollback、staging、source、状态、
manifest、CAS 或未知归属引用的普通目录。`localized.status` 还区分 schema/contract 与
artifact integrity,损坏产物返回 degraded/corrupt,不自动回滚或删除。
仍需完成复杂重打包、双 release 查询/分发视图和清理策略。 rollback 与 cleanup 保持独立;缺少 generic manifest 的旧 localized release 仍可读,
明确标记 `legacy`/`unknown`,不会被自动重写。
### G-012Translation Memory V1 已实现,扩展能力仍缺失 ### G-012Translation Memory V1 已实现,扩展能力仍缺失
@@ -148,7 +158,8 @@ Rust `bat` 已提供独立项目级 `glossary.sqlite`term/alias/recommended/a
## 3. 后续推进顺序 ## 3. 后续推进顺序
1. 继续 G-005:更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。 1. 继续 G-005:更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。
2. 继续 G-006/G-011D:复杂 AssetBundle 兼容和双 release 查询/清理策略。 2. 继续 G-006:复杂 AssetBundle 兼容和真实样本覆盖;G-011D 的双 release 运维 V1
已完成,后续 retention scheduler 不属于本次闭环。
3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider 3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider
扩展体系。 扩展体系。
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。 4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
+3 -3
View File
@@ -95,8 +95,8 @@
| 组件 | 路径 | 状态 | 说明 | | 组件 | 路径 | 状态 | 说明 |
|---|---|---|---| |---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 | | Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`localized.publish` 的 typed params 支持 `translation_file``from_worker``patch_manifest` 三选一;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | | RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list``release.status/list/distribution/cleanup``catalog.*``parse.*``localized.status/publish/rollback``task.*``translation.tasks``translation.handoff``translation.task.update``translation.worker.run``translation.proofread``translation.memory.summary/query/confirm``translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`localized.publish` 的 typed params 支持 `translation_file``from_worker``patch_manifest` 三选一;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` | | 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + `/v1/releases` + `/v1/distribution` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + release/localized/TM/Glossary admin forwarding + 内嵌 dashboard + `.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 |
@@ -134,7 +134,7 @@ make build-go-cli # 产出 bin/bat-go
| 项 | 状态 | | 项 | 状态 |
|---|---| |---|---|
| Go 同步 CLI | **边界已确定**(正式同步 CLI = Rust `bat` | | Go 同步 CLI | **边界已确定**(正式同步 CLI = Rust `bat` |
| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*``task.*``parse.*`、翻译任务/TM 状态查询与显式确认代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 | | G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*``task.*``parse.*``release.*` 双 release 查询/分发/cleanup 转发、翻译任务/TM 状态查询与显式确认代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 |
| G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 | | G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
--- ---
+281 -74
View File
@@ -7,30 +7,33 @@ use bat_core::{ApiError, ErrorCode};
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY; use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
use bat_infrastructure::{ use bat_infrastructure::{
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
apply_unityfs_text_asset_patch_file, changed_endpoint_urls, apply_unityfs_text_asset_patch_file, build_release_list, build_release_status,
completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench, changed_endpoint_urls, cleanup_releases, completed_worker_translation_workbench,
gc_orphan_staging_with_cas_root, get_translation_entry, lexical_absolute, diff_extended_snapshot, export_translation_workbench, gc_orphan_staging_with_cas_root,
get_translation_entry, inspect_localized_release_artifact, lexical_absolute,
localized_patch_operations_with_glossary_path, open_append_file, read_download_manifest_at, localized_patch_operations_with_glossary_path, open_append_file, read_download_manifest_at,
read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state,
read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench, read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench,
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy,
set_translation_checked_with_glossary_path, unset_translation, validate_output_root, select_release_distribution, set_translation, set_translation_checked_with_glossary_path,
validate_runtime_state_dir, validate_translation_workbench_with_glossary_path, unset_translation, validate_output_root, validate_runtime_state_dir,
write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, validate_translation_workbench_with_glossary_path, write_file_atomic,
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig,
LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService, OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
OfficialParseConfig, OfficialResourceHashVerification, OfficialResourceVerification, OfficialParseConfig, OfficialResourceHashVerification, OfficialResourceVerification,
OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery, OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams, OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams,
PatchApplyReport, ReleaseFlowStatusCode, RepackReport, SqliteResourceRepository, PatchApplyReport, ReleaseCleanupParams, ReleaseDistributionParams, ReleaseFlowStatusCode,
SqliteTranslationTaskRepository, TranslationProviderKind, TranslationTaskStatus, ReleaseListParams, RepackReport, SqliteResourceRepository, SqliteTranslationTaskRepository,
TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport, TranslationProviderKind, TranslationTaskStatus, TranslationWorkerConfig,
UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams,
DEFAULT_TRANSLATION_CONCURRENCY, DEFAULT_TRANSLATION_LEASE_SECONDS, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, DEFAULT_TRANSLATION_CONCURRENCY,
DEFAULT_TRANSLATION_MAX_ATTEMPTS, DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK, DEFAULT_TRANSLATION_LEASE_SECONDS, DEFAULT_TRANSLATION_MAX_ATTEMPTS,
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING, DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE,
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MAX_TRANSLATION_CONCURRENCY, LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MAX_TRANSLATION_CONCURRENCY,
MIN_DOWNLOAD_CONCURRENCY, MIN_TRANSLATION_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE, MIN_DOWNLOAD_CONCURRENCY, MIN_TRANSLATION_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE,
@@ -1195,6 +1198,10 @@ const RPC_METHOD_GLOSSARY_DIAGNOSE: &str = "translation.glossary.diagnose";
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status"; const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish"; const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish";
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback"; const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
const RPC_METHOD_RELEASE_STATUS: &str = "release.status";
const RPC_METHOD_RELEASE_LIST: &str = "release.list";
const RPC_METHOD_RELEASE_DISTRIBUTION: &str = "release.distribution";
const RPC_METHOD_RELEASE_CLEANUP: &str = "release.cleanup";
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status"; const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions"; const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff"; const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
@@ -2371,6 +2378,94 @@ fn dispatch_rpc_method(
Err(error) => rpc_envelope_error(request_id, error), Err(error) => rpc_envelope_error(request_id, error),
} }
} }
RPC_METHOD_RELEASE_STATUS => {
let _sync_guard = tasks
.sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
rpc_envelope_from_result(
request_id,
RPC_METHOD_RELEASE_STATUS,
build_release_status(
&tasks.base_config.output_root,
&tasks.base_config.localized_output_root,
&tasks.base_config.unzip_command,
)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
)
}
RPC_METHOD_RELEASE_LIST => {
let params = match rpc_optional_struct_params::<ReleaseListParams>(
request.params.as_ref(),
RPC_METHOD_RELEASE_LIST,
) {
Ok(params) => params,
Err(error) => return rpc_envelope_error(request_id, error),
};
let _sync_guard = tasks
.sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
rpc_envelope_from_result(
request_id,
RPC_METHOD_RELEASE_LIST,
build_release_list(
&tasks.base_config.output_root,
&tasks.base_config.localized_output_root,
&params,
&tasks.base_config.unzip_command,
),
)
}
RPC_METHOD_RELEASE_DISTRIBUTION => {
let params = match rpc_optional_struct_params::<ReleaseDistributionParams>(
request.params.as_ref(),
RPC_METHOD_RELEASE_DISTRIBUTION,
) {
Ok(params) => params,
Err(error) => return rpc_envelope_error(request_id, error),
};
let _sync_guard = tasks
.sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
rpc_envelope_from_result(
request_id,
RPC_METHOD_RELEASE_DISTRIBUTION,
select_release_distribution(
&tasks.base_config.output_root,
&tasks.base_config.localized_output_root,
&params,
&tasks.base_config.unzip_command,
)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
)
}
RPC_METHOD_RELEASE_CLEANUP => {
let params = match rpc_optional_struct_params::<ReleaseCleanupParams>(
request.params.as_ref(),
RPC_METHOD_RELEASE_CLEANUP,
) {
Ok(params) => params,
Err(error) => return rpc_envelope_error(request_id, error),
};
let _sync_guard = tasks
.sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
rpc_envelope_from_result(
request_id,
RPC_METHOD_RELEASE_CLEANUP,
cleanup_releases(
&tasks.base_config.output_root,
&tasks.base_config.localized_output_root,
&tasks.base_config.effective_import_cas_root(),
&params,
&tasks.base_config.unzip_command,
)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
)
}
RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result( RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result(
request_id, request_id,
"catalog.status", "catalog.status",
@@ -3281,7 +3376,12 @@ fn build_localized_status_report(
let mut patch_manifest_path = None; let mut patch_manifest_path = None;
let mut patch_manifest_available = false; let mut patch_manifest_available = false;
let mut patch_manifest_matches_release = false; let mut patch_manifest_matches_release = false;
let mut patch_manifest_integrity_status = None; let mut patch_manifest_contract_status: Option<String> = None;
let mut patch_manifest_integrity_status: Option<String> = None;
let mut artifact_integrity_status = None;
let mut artifact_integrity_verified = false;
let mut artifact_integrity_error = None;
let mut artifact_integrity_diagnostics = Vec::new();
let mut patch_manifest_source_version = None; let mut patch_manifest_source_version = None;
let mut patch_manifest_target_version = None; let mut patch_manifest_target_version = None;
let mut patch_file_count = None; let mut patch_file_count = None;
@@ -3321,69 +3421,161 @@ fn build_localized_status_report(
} }
if localized_state.status == "localized" && matches_current_official_release { if localized_state.status == "localized" && matches_current_official_release {
if let Some(release_id) = localized_state.current_release_id.as_deref() { if let Some(release_id) = localized_state.current_release_id.as_deref() {
let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id); let official_id_is_safe = official_version_id
current_points_to_published_version = .as_deref()
localized_current_points_to(&current_path, &candidate); .is_some_and(is_safe_release_id);
let manifest_path = candidate.join(LOCALIZED_PATCH_MANIFEST_FILE); if !is_safe_release_id(release_id) || !official_id_is_safe {
patch_manifest_path = Some(manifest_path); let diagnostic = if !is_safe_release_id(release_id) {
if let Some(manifest) = read_localized_patch_manifest_at(&candidate)? { format!("localized current release identity 不安全:{release_id}")
patch_manifest_available = true; } else {
patch_manifest_matches_release = official_version_id format!(
.as_deref() "official current release identity 不安全:{}",
.is_some_and(|id| manifest.official_release_id == id) official_version_id.as_deref().unwrap_or_default()
&& manifest.localized_release_id == release_id; )
patch_file_count = Some(manifest.file_count); };
patch_text_asset_operation_count = Some(manifest.text_asset_operation_count); localized_release_status = "degraded";
rollback_previous_current_target = manifest.rollback.previous_current_target; flow_status_code = ReleaseFlowStatusCode::LocalizedDegraded;
if let Some(generic_manifest) = manifest.patch_manifest.as_ref() { patch_manifest_contract_status = Some("invalid".to_string());
patch_manifest_source_version = patch_manifest_integrity_status = Some("invalid".to_string());
Some(generic_manifest.source_version.clone()); artifact_integrity_status = Some("invalid".to_string());
patch_manifest_target_version = artifact_integrity_error = Some(diagnostic.clone());
Some(generic_manifest.target_version.clone()); artifact_integrity_diagnostics.push(diagnostic);
let mut operation_count = 0usize; } else {
for file in &generic_manifest.files { let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id);
for operation in &file.operations { current_points_to_published_version =
operation_count += 1; localized_current_points_to(&current_path, &candidate);
let key = match operation.patch_kind() { let manifest_path = candidate.join(LOCALIZED_PATCH_MANIFEST_FILE);
bat_patch::PatchKind::Binary => "binary", patch_manifest_path = Some(manifest_path.clone());
bat_patch::PatchKind::Json => "json", match read_localized_patch_manifest_at(&candidate) {
bat_patch::PatchKind::Text => "text", Ok(Some(manifest)) => {
bat_patch::PatchKind::UnityFsTextAsset => "unityfs_text_asset", patch_manifest_available = true;
bat_patch::PatchKind::UnityFsStringField => { patch_manifest_matches_release = official_version_id
"unityfs_string_field" .as_deref()
.is_some_and(|id| manifest.official_release_id == id)
&& manifest.localized_release_id == release_id;
patch_file_count = Some(manifest.file_count);
patch_text_asset_operation_count =
Some(manifest.text_asset_operation_count);
rollback_previous_current_target =
manifest.rollback.previous_current_target;
if let Some(generic_manifest) = manifest.patch_manifest.as_ref() {
patch_manifest_source_version =
Some(generic_manifest.source_version.clone());
patch_manifest_target_version =
Some(generic_manifest.target_version.clone());
let mut operation_count = 0usize;
for file in &generic_manifest.files {
for operation in &file.operations {
operation_count += 1;
let key = match operation.patch_kind() {
bat_patch::PatchKind::Binary => "binary",
bat_patch::PatchKind::Json => "json",
bat_patch::PatchKind::Text => "text",
bat_patch::PatchKind::UnityFsTextAsset => {
"unityfs_text_asset"
}
bat_patch::PatchKind::UnityFsStringField => {
"unityfs_string_field"
}
bat_patch::PatchKind::UnityFsField => "unityfs_field",
bat_patch::PatchKind::Mixed => "mixed",
}
.to_string();
let count = patch_kind_counts
.entry(key)
.or_insert_with(|| serde_json::json!(0));
*count = serde_json::json!(
count.as_u64().unwrap_or_default() + 1
);
} }
bat_patch::PatchKind::UnityFsField => "unityfs_field",
bat_patch::PatchKind::Mixed => "mixed",
} }
.to_string(); patch_operation_count = Some(operation_count);
let count = patch_kind_counts patch_manifest_contract_status = Some(
.entry(key) if bat_patch::validate_patch_manifest(generic_manifest).is_ok()
.or_insert_with(|| serde_json::json!(0)); {
*count = serde_json::json!(count.as_u64().unwrap_or_default() + 1); "valid"
} else {
"invalid"
}
.to_string(),
);
patch_manifest_integrity_status =
patch_manifest_contract_status.clone();
patch_manifest_matches_release &= generic_manifest.source_version
== manifest.official_release_id
&& generic_manifest.target_version
== manifest.localized_release_id;
} else {
patch_manifest_contract_status = Some("legacy".to_string());
patch_manifest_integrity_status = Some("legacy".to_string());
} }
} }
patch_operation_count = Some(operation_count); Ok(None) => {
patch_manifest_integrity_status = Some( patch_manifest_contract_status = Some("missing".to_string());
if bat_patch::validate_patch_manifest(generic_manifest).is_ok() { patch_manifest_integrity_status = Some("missing".to_string());
"valid" }
} else { Err(error) => {
"invalid" patch_manifest_available = fs::symlink_metadata(&manifest_path).is_ok();
}, patch_manifest_contract_status = Some("invalid".to_string());
); patch_manifest_integrity_status = Some("invalid".to_string());
patch_manifest_matches_release &= generic_manifest.source_version artifact_integrity_error = Some(error.to_string());
== manifest.official_release_id artifact_integrity_diagnostics.push(error.to_string());
&& generic_manifest.target_version == manifest.localized_release_id; }
} else { }
patch_manifest_integrity_status = Some("legacy"); if let Some(official_root) = status_file
.as_ref()
.map(|status| {
status
.resource_output_root
.join("versions")
.join(official_version_id.as_deref().unwrap_or_default())
})
.or_else(|| {
Some(
base_config
.output_root
.join("versions")
.join(official_version_id.as_deref().unwrap_or_default()),
)
})
{
let artifact = inspect_localized_release_artifact(
&official_root,
&localized_root,
release_id,
official_version_id.as_deref().unwrap_or_default(),
&base_config.unzip_command,
);
artifact_integrity_status =
Some(artifact.artifact_integrity_status.clone());
artifact_integrity_verified = artifact.verified;
artifact_integrity_error = artifact.error.clone();
artifact_integrity_diagnostics = artifact.diagnostics.clone();
current_points_to_published_version = artifact.current_points_to_release;
if artifact.manifest_available {
patch_manifest_available = true;
}
if !artifact.manifest_contract_status.is_empty() {
patch_manifest_contract_status =
Some(artifact.manifest_contract_status);
patch_manifest_integrity_status =
patch_manifest_contract_status.clone();
}
patch_manifest_matches_release = artifact.manifest_matches_release;
}
if candidate.is_dir()
&& current_points_to_published_version
&& patch_manifest_matches_release
&& artifact_integrity_verified
{
localized_release_status = "localized";
flow_status_code = ReleaseFlowStatusCode::LocalizedPublished;
published_version_path = Some(candidate);
} else if candidate.is_dir() && current_points_to_published_version {
localized_release_status = "degraded";
flow_status_code = ReleaseFlowStatusCode::LocalizedDegraded;
published_version_path = Some(candidate);
} }
}
if candidate.is_dir()
&& current_points_to_published_version
&& patch_manifest_matches_release
{
localized_release_status = "localized";
flow_status_code = ReleaseFlowStatusCode::LocalizedPublished;
published_version_path = Some(candidate);
} }
} }
} }
@@ -3413,7 +3605,12 @@ fn build_localized_status_report(
"patch_manifest_path": patch_manifest_path, "patch_manifest_path": patch_manifest_path,
"patch_manifest_available": patch_manifest_available, "patch_manifest_available": patch_manifest_available,
"patch_manifest_matches_release": patch_manifest_matches_release, "patch_manifest_matches_release": patch_manifest_matches_release,
"patch_manifest_contract_status": patch_manifest_contract_status,
"patch_manifest_integrity_status": patch_manifest_integrity_status, "patch_manifest_integrity_status": patch_manifest_integrity_status,
"artifact_integrity_status": artifact_integrity_status,
"artifact_integrity_verified": artifact_integrity_verified,
"artifact_integrity_error": artifact_integrity_error,
"artifact_integrity_diagnostics": artifact_integrity_diagnostics,
"patch_manifest_source_version": patch_manifest_source_version, "patch_manifest_source_version": patch_manifest_source_version,
"patch_manifest_target_version": patch_manifest_target_version, "patch_manifest_target_version": patch_manifest_target_version,
"patch_file_count": patch_file_count, "patch_file_count": patch_file_count,
@@ -3442,6 +3639,16 @@ fn localized_current_points_to(current_path: &Path, version_path: &Path) -> bool
resolved == version_path resolved == version_path
} }
fn is_safe_release_id(value: &str) -> bool {
!value.is_empty()
&& value != "."
&& value != ".."
&& !value.contains('/')
&& !value.contains('\\')
&& !value.contains(':')
&& !value.contains('\0')
}
#[cfg(unix)] #[cfg(unix)]
fn write_json_rpc_response( fn write_json_rpc_response(
stream: &mut UnixStream, stream: &mut UnixStream,
+101 -4
View File
@@ -4226,6 +4226,75 @@ fn dispatch_catalog_versions_lists_history() {
assert!(value["data"]["failed"].as_array().unwrap().is_empty()); assert!(value["data"]["failed"].as_array().unwrap().is_empty());
} }
#[cfg(unix)]
#[test]
fn dispatch_release_rpc_exposes_dual_release_queries_and_safe_cleanup_plan() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
let localized_root = temp.path().join("localized");
let tasks = test_task_context_with_config(OfficialUpdateConfig {
output_root: output_root.clone(),
localized_output_root: localized_root.clone(),
..OfficialUpdateConfig::default()
});
let status = dispatch_rpc_method(
&rpc_request("release.status", None),
&state_dir,
&new_daemon_control(),
&tasks,
"req-release-status".to_string(),
);
let status = serde_json::to_value(status).unwrap();
assert_eq!(status["ok"], true);
assert_eq!(status["data"]["default_distribution_channel"], "official");
assert_eq!(status["data"]["official_current_release_id"], "v-current");
let list = dispatch_rpc_method(
&rpc_request(
"release.list",
Some(serde_json::json!({"channel": "official"})),
),
&state_dir,
&new_daemon_control(),
&tasks,
"req-release-list".to_string(),
);
let list = serde_json::to_value(list).unwrap();
assert_eq!(list["ok"], true);
assert_eq!(list["data"]["channel"], "official");
assert_eq!(list["data"]["releases"][0]["channel"], "official");
let distribution = dispatch_rpc_method(
&rpc_request(
"release.distribution",
Some(serde_json::json!({"channel": "localized"})),
),
&state_dir,
&new_daemon_control(),
&tasks,
"req-release-distribution".to_string(),
);
let distribution = serde_json::to_value(distribution).unwrap();
assert_eq!(distribution["ok"], true);
assert_eq!(distribution["data"]["available"], false);
assert_eq!(distribution["data"]["channel"], "localized");
let cleanup = dispatch_rpc_method(
&rpc_request("release.cleanup", None),
&state_dir,
&new_daemon_control(),
&tasks,
"req-release-cleanup".to_string(),
);
let cleanup = serde_json::to_value(cleanup).unwrap();
assert_eq!(cleanup["ok"], true);
assert_eq!(cleanup["data"]["execute"], false);
assert!(cleanup["data"]["plan_id"].as_str().is_some());
}
#[test] #[test]
fn dispatch_catalog_diff_reports_bundle_change() { fn dispatch_catalog_diff_reports_bundle_change() {
let temp = tempfile::TempDir::new().unwrap(); let temp = tempfile::TempDir::new().unwrap();
@@ -5086,7 +5155,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
let temp = tempfile::TempDir::new().unwrap(); let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state"); let state_dir = temp.path().join("state");
let output_root = temp.path().join("output"); let output_root = temp.path().join("output");
write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None); let official_version = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
let localized_root = temp.path().join("localized"); let localized_root = temp.path().join("localized");
let localized_version = localized_root let localized_version = localized_root
.join(LOCALIZED_VERSIONS_DIR) .join(LOCALIZED_VERSIONS_DIR)
@@ -5097,6 +5166,8 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
localized_root.join(LOCALIZED_CURRENT_LINK), localized_root.join(LOCALIZED_CURRENT_LINK),
) )
.unwrap(); .unwrap();
fs::write(official_version.join("data.bin"), b"official").unwrap();
fs::write(localized_version.join("data.bin"), b"localized").unwrap();
fs::write( fs::write(
localized_root.join(LOCALIZED_VERSION_STATE_FILE), localized_root.join(LOCALIZED_VERSION_STATE_FILE),
serde_json::to_vec(&bat_infrastructure::LocalizedVersionState { serde_json::to_vec(&bat_infrastructure::LocalizedVersionState {
@@ -5117,9 +5188,18 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
official_release_id: "v-current".to_string(), official_release_id: "v-current".to_string(),
localized_release_id: "v-current".to_string(), localized_release_id: "v-current".to_string(),
generated_unix_seconds: 124, generated_unix_seconds: 124,
file_count: 0, file_count: 1,
text_asset_operation_count: 0, text_asset_operation_count: 0,
files: Vec::new(), files: vec![bat_infrastructure::LocalizedPatchFile {
path: "data.bin".to_string(),
original_blake3: blake3::hash(b"official").to_hex().to_string(),
localized_blake3: blake3::hash(b"localized").to_hex().to_string(),
original_bytes: 8,
localized_bytes: 9,
byte_delta: 1,
text_asset_operations: Vec::new(),
operations: Vec::new(),
}],
patch_manifest: None, patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo { rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None, previous_current_target: None,
@@ -5152,7 +5232,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
assert_eq!(value["data"]["current_points_to_published_version"], true); assert_eq!(value["data"]["current_points_to_published_version"], true);
assert_eq!(value["data"]["patch_manifest_available"], true); assert_eq!(value["data"]["patch_manifest_available"], true);
assert_eq!(value["data"]["patch_manifest_matches_release"], true); assert_eq!(value["data"]["patch_manifest_matches_release"], true);
assert_eq!(value["data"]["patch_file_count"], 0); assert_eq!(value["data"]["patch_file_count"], 1);
assert_eq!(value["data"]["patch_text_asset_operation_count"], 0); assert_eq!(value["data"]["patch_text_asset_operation_count"], 0);
assert_eq!( assert_eq!(
value["data"]["translation_workflow_status"], value["data"]["translation_workflow_status"],
@@ -5162,6 +5242,23 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
value["data"]["published_version_path"].as_str().unwrap(), value["data"]["published_version_path"].as_str().unwrap(),
localized_version.to_string_lossy() localized_version.to_string_lossy()
); );
assert_eq!(value["data"]["patch_manifest_contract_status"], "legacy");
assert_eq!(value["data"]["artifact_integrity_status"], "valid");
fs::write(localized_version.join("data.bin"), b"corrupt").unwrap();
let envelope = dispatch_rpc_method(
&rpc_request("localized.status", None),
&state_dir,
&new_daemon_control(),
&tasks,
"req-loc-corrupt".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["status_code"], "localized.degraded");
assert_eq!(value["data"]["localized_release_status"], "degraded");
assert_eq!(value["data"]["artifact_integrity_status"], "invalid");
assert_eq!(value["data"]["artifact_integrity_verified"], false);
} }
#[test] #[test]
+15 -8
View File
@@ -29,6 +29,7 @@ pub mod official_update;
pub mod patch_ops; pub mod patch_ops;
pub mod path_security; pub mod path_security;
pub mod release_flow; pub mod release_flow;
pub mod release_ops;
pub mod resources; pub mod resources;
pub mod translation_memory; pub mod translation_memory;
pub mod translation_tasks; pub mod translation_tasks;
@@ -53,15 +54,16 @@ pub use import::{
ResourceImportService, ResourceImportService,
}; };
pub use localized_patch::{ pub use localized_patch::{
inspect_localized_release_artifact, inspect_localized_release_artifact_at,
mark_localized_manual_proofreading, read_localized_patch_manifest_at, mark_localized_manual_proofreading, read_localized_patch_manifest_at,
read_localized_version_state, write_localized_version_state, LocalizedFieldPatch, read_localized_version_state, write_localized_version_state, LocalizedArtifactIntegrityReport,
LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity, LocalizedFieldPatch, LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput,
LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata, LocalizedPatchIntegrity, LocalizedPatchManifest, LocalizedPatchOperation,
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService, LocalizedPatchOperationMetadata, LocalizedPatchReport, LocalizedPatchRollbackInfo,
LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch, LocalizedPatchService, LocalizedRollbackReport, LocalizedStringFieldPatch,
LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK, LocalizedTextAssetPatch, LocalizedTranslationWorkflowReport, LocalizedVersionState,
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR, LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION,
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING, LOCALIZED_STAGING_DIR, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION, LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION,
}; };
@@ -145,6 +147,11 @@ pub use path_security::{
validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE, validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE,
}; };
pub use release_flow::ReleaseFlowStatusCode; pub use release_flow::ReleaseFlowStatusCode;
pub use release_ops::{
build_release_list, build_release_status, cleanup_releases, select_release_distribution,
ReleaseCleanupParams, ReleaseCleanupReport, ReleaseDistributionEntry, ReleaseDistributionPage,
ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary,
};
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository}; pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
pub use translation_memory::{ pub use translation_memory::{
translation_memory_context, translation_memory_repository_path, translation_memory_context, translation_memory_repository_path,
+337 -2
View File
@@ -6,7 +6,7 @@ use bat_assetbundle::{
UnitySerializedValue, UnitySerializedValue,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
@@ -446,6 +446,30 @@ pub struct LocalizedPatchIntegrity {
pub current_points_to_release: bool, pub current_points_to_release: bool,
} }
/// Read-only contract and artifact verification result for one localized
/// release. Contract validity covers the manifest schema and release identity;
/// artifact integrity additionally verifies recorded files and UnityFS/ZIP
/// semantic replacements.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LocalizedArtifactIntegrityReport {
/// `valid`, `legacy`, `invalid` or `missing`.
pub manifest_contract_status: String,
/// `valid`, `invalid` or `unavailable`.
pub artifact_integrity_status: String,
/// Whether all read-only checks passed.
pub verified: bool,
/// Whether `current` points to this release.
pub current_points_to_release: bool,
/// Whether the manifest file could be read.
pub manifest_available: bool,
/// Whether manifest release IDs match the observed state.
pub manifest_matches_release: bool,
/// First diagnostic, retained for compact callers.
pub error: Option<String>,
/// All diagnostics from the read-only inspection.
pub diagnostics: Vec<String>,
}
/// Persisted manifest for one localized release. /// Persisted manifest for one localized release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchManifest { pub struct LocalizedPatchManifest {
@@ -1932,6 +1956,292 @@ fn verify_published_localized_release(
Ok(integrity) Ok(integrity)
} }
/// Inspects one localized release without changing state, staging, current or
/// any repair target.
pub fn inspect_localized_release_artifact(
official_release_root: &Path,
localized_output_root: &Path,
localized_release_id: &str,
expected_official_release_id: &str,
unzip_command: &Path,
) -> LocalizedArtifactIntegrityReport {
inspect_localized_release_artifact_inner(
official_release_root,
localized_output_root,
localized_release_id,
expected_official_release_id,
unzip_command,
true,
)
}
/// Inspects a historical localized release without requiring the channel
/// `current` pointer to select it.
pub fn inspect_localized_release_artifact_at(
official_release_root: &Path,
localized_output_root: &Path,
localized_release_id: &str,
expected_official_release_id: &str,
unzip_command: &Path,
) -> LocalizedArtifactIntegrityReport {
inspect_localized_release_artifact_inner(
official_release_root,
localized_output_root,
localized_release_id,
expected_official_release_id,
unzip_command,
false,
)
}
fn is_safe_release_id(value: &str) -> bool {
!value.is_empty()
&& value != "."
&& value != ".."
&& !value.contains('/')
&& !value.contains('\\')
&& !value.contains(':')
&& !value.contains('\0')
}
fn inspect_localized_release_artifact_inner(
official_release_root: &Path,
localized_output_root: &Path,
localized_release_id: &str,
expected_official_release_id: &str,
unzip_command: &Path,
require_current_pointer: bool,
) -> LocalizedArtifactIntegrityReport {
let mut diagnostics = Vec::new();
if !is_safe_release_id(localized_release_id) {
diagnostics.push(format!(
"localized release identity 不安全:{localized_release_id}"
));
}
if !is_safe_release_id(expected_official_release_id) {
diagnostics.push(format!(
"official release identity 不安全:{expected_official_release_id}"
));
}
if !diagnostics.is_empty() {
return LocalizedArtifactIntegrityReport {
manifest_contract_status: "invalid".to_string(),
artifact_integrity_status: "invalid".to_string(),
verified: false,
current_points_to_release: false,
manifest_available: false,
manifest_matches_release: false,
error: diagnostics.first().cloned(),
diagnostics,
};
}
let version_path = localized_output_root
.join(LOCALIZED_VERSIONS_DIR)
.join(localized_release_id);
let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK);
let current_points_to_release =
current_points_to_version(&current_path, &version_path).unwrap_or(false);
let candidate_exists = fs::symlink_metadata(&version_path)
.map(|metadata| metadata.is_dir())
.unwrap_or(false);
if !candidate_exists {
diagnostics.push(format!(
"localized release 目录不存在:{}",
version_path.display()
));
return LocalizedArtifactIntegrityReport {
manifest_contract_status: "missing".to_string(),
artifact_integrity_status: "unavailable".to_string(),
verified: false,
current_points_to_release,
manifest_available: false,
manifest_matches_release: false,
error: diagnostics.first().cloned(),
diagnostics,
};
}
if require_current_pointer && !current_points_to_release {
diagnostics.push(format!(
"localized current 未指向 releasecurrent={} version={}",
current_path.display(),
version_path.display()
));
}
if let Err(error) = ensure_safe_directory_path(&version_path, "汉化 release") {
diagnostics.push(error.to_string());
}
let manifest_available = fs::symlink_metadata(version_path.join(LOCALIZED_PATCH_MANIFEST_FILE))
.map(|metadata| !metadata.file_type().is_symlink())
.unwrap_or(false);
let manifest = match read_localized_patch_manifest_at(&version_path) {
Ok(Some(manifest)) => manifest,
Ok(None) => {
diagnostics.push(format!(
"缺少汉化 patch manifest{}",
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
));
return LocalizedArtifactIntegrityReport {
manifest_contract_status: "missing".to_string(),
artifact_integrity_status: "invalid".to_string(),
verified: false,
current_points_to_release,
manifest_available,
manifest_matches_release: false,
error: diagnostics.first().cloned(),
diagnostics,
};
}
Err(error) => {
diagnostics.push(error.to_string());
return LocalizedArtifactIntegrityReport {
manifest_contract_status: "invalid".to_string(),
artifact_integrity_status: "invalid".to_string(),
verified: false,
current_points_to_release,
manifest_available,
manifest_matches_release: false,
error: diagnostics.first().cloned(),
diagnostics,
};
}
};
let wrapper_matches_release = manifest.official_release_id == expected_official_release_id
&& manifest.localized_release_id == localized_release_id;
if !wrapper_matches_release {
diagnostics.push(format!(
"localized manifest identity 不匹配:official={} localized={}",
manifest.official_release_id, manifest.localized_release_id
));
}
let generic_matches_wrapper = manifest.patch_manifest.as_ref().is_none_or(|generic| {
generic.source_version == manifest.official_release_id
&& generic.target_version == manifest.localized_release_id
});
if !generic_matches_wrapper {
diagnostics.push(
"generic manifest source_version/target_version 与 localized wrapper 不一致"
.to_string(),
);
}
let manifest_matches_release = wrapper_matches_release && generic_matches_wrapper;
let manifest_contract_status = if let Some(generic) = manifest.patch_manifest.as_ref() {
match bat_patch::validate_patch_manifest(generic) {
Ok(()) if manifest_matches_release => "valid",
Ok(()) => "invalid",
Err(error) => {
diagnostics.push(format!("generic manifest schema 无效:{error}"));
"invalid"
}
}
} else {
"legacy"
};
if manifest_contract_status == "invalid" {
return LocalizedArtifactIntegrityReport {
manifest_contract_status: manifest_contract_status.to_string(),
artifact_integrity_status: "invalid".to_string(),
verified: false,
current_points_to_release,
manifest_available,
manifest_matches_release,
error: diagnostics.first().cloned(),
diagnostics,
};
}
if let Err(error) = verify_patch_manifest_files(
official_release_root,
&version_path,
&manifest,
unzip_command,
) {
diagnostics.push(error.to_string());
}
if manifest.patch_manifest.is_some() {
match crate::official_download::read_download_manifest_at(official_release_root) {
Ok(Some(download_manifest)) => {
if let Err(error) = verify_localized_resource_root(
official_release_root,
&version_path,
&manifest,
&download_manifest,
) {
diagnostics.push(error.to_string());
}
}
Ok(None) => diagnostics.push(format!(
"缺少官方 download manifest,无法完成 generic release 全量完整性检查:{}",
official_release_root.display()
)),
Err(error) => diagnostics.push(error),
}
}
let verified = diagnostics.is_empty()
&& (!require_current_pointer || current_points_to_release)
&& manifest_matches_release;
LocalizedArtifactIntegrityReport {
manifest_contract_status: manifest_contract_status.to_string(),
artifact_integrity_status: if verified {
"valid".to_string()
} else {
"invalid".to_string()
},
verified,
current_points_to_release,
manifest_available,
manifest_matches_release,
error: diagnostics.first().cloned(),
diagnostics,
}
}
fn verify_localized_resource_root(
official_release_root: &Path,
localized_release_root: &Path,
localized_manifest: &LocalizedPatchManifest,
download_manifest: &crate::official_download::OfficialDownloadManifest,
) -> anyhow::Result<()> {
let changed_paths = localized_manifest
.files
.iter()
.map(|file| file.path.as_str())
.collect::<BTreeSet<_>>();
for entry in download_manifest.entries.values() {
let official_path = official_release_root.join(&entry.destination);
let localized_path = localized_release_root.join(&entry.destination);
ensure_path_within_root(official_release_root, &official_path)
.map_err(anyhow::Error::msg)?;
ensure_path_within_root(localized_release_root, &localized_path)
.map_err(anyhow::Error::msg)?;
ensure_safe_file_target(official_release_root, &official_path, "官方 release 文件")
.map_err(anyhow::Error::msg)?;
ensure_safe_file_target(localized_release_root, &localized_path, "汉化 release 文件")
.map_err(anyhow::Error::msg)?;
let official = fs::read(&official_path)?;
if official.len() as u64 != entry.bytes
|| blake3::hash(&official).to_hex().to_string() != entry.blake3
{
return Err(anyhow::anyhow!(
"官方 source 文件完整性失败:{}",
entry.destination
));
}
let localized = fs::read(&localized_path)?;
if changed_paths.contains(entry.destination.as_str()) {
continue;
}
if localized.len() as u64 != entry.bytes
|| blake3::hash(&localized).to_hex().to_string() != entry.blake3
{
return Err(anyhow::anyhow!(
"汉化 release 未变更文件完整性失败:{}",
entry.destination
));
}
}
Ok(())
}
fn verify_patch_manifest_files( fn verify_patch_manifest_files(
official_release_root: &Path, official_release_root: &Path,
localized_release_root: &Path, localized_release_root: &Path,
@@ -2463,7 +2773,10 @@ fn release_id_from_current_target(target: &Path) -> Option<String> {
Some(std::path::Component::Normal(root)), Some(std::path::Component::Normal(root)),
Some(std::path::Component::Normal(release_id)), Some(std::path::Component::Normal(release_id)),
None, None,
) if root == LOCALIZED_VERSIONS_DIR => release_id.to_str().map(str::to_string), ) if root == LOCALIZED_VERSIONS_DIR => {
let release_id = release_id.to_str()?;
is_safe_release_id(release_id).then_some(release_id.to_string())
}
_ => None, _ => None,
} }
} }
@@ -2567,6 +2880,8 @@ fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> {
if release_id.is_empty() if release_id.is_empty()
|| release_id.contains('/') || release_id.contains('/')
|| release_id.contains('\\') || release_id.contains('\\')
|| release_id.contains(':')
|| release_id.contains('\0')
|| release_id == "." || release_id == "."
|| release_id == ".." || release_id == ".."
{ {
@@ -3440,6 +3755,26 @@ mod tests {
assert!(report.publish_allowed); assert!(report.publish_allowed);
} }
#[test]
fn artifact_inspection_rejects_unsafe_release_identity_before_reading_paths() {
let temp = TempDir::new().unwrap();
let report = inspect_localized_release_artifact_at(
&temp.path().join("official"),
&temp.path().join("localized"),
"../outside",
"official-1",
Path::new("unzip"),
);
assert_eq!(report.manifest_contract_status, "invalid");
assert_eq!(report.artifact_integrity_status, "invalid");
assert!(!report.verified);
assert!(report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.contains("identity 不安全")));
}
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn failed_patch_publish_cleans_staging_and_unpublished_version() { fn failed_patch_publish_cleans_staging_and_unpublished_version() {
+10 -1
View File
@@ -78,6 +78,10 @@ pub enum ReleaseFlowStatusCode {
/// A localized release is published and matches the current official release. /// A localized release is published and matches the current official release.
#[serde(rename = "localized.published")] #[serde(rename = "localized.published")]
LocalizedPublished, LocalizedPublished,
/// A localized current release exists but its manifest or published bytes
/// fail read-only integrity verification.
#[serde(rename = "localized.degraded")]
LocalizedDegraded,
/// Distribution cannot serve a usable release for the observed channel. /// Distribution cannot serve a usable release for the observed channel.
#[serde(rename = "distribution.blocked")] #[serde(rename = "distribution.blocked")]
DistributionBlocked, DistributionBlocked,
@@ -113,6 +117,7 @@ impl ReleaseFlowStatusCode {
Self::LocalizedPending => "localized.pending", Self::LocalizedPending => "localized.pending",
Self::LocalizedStale => "localized.stale", Self::LocalizedStale => "localized.stale",
Self::LocalizedPublished => "localized.published", Self::LocalizedPublished => "localized.published",
Self::LocalizedDegraded => "localized.degraded",
Self::DistributionBlocked => "distribution.blocked", Self::DistributionBlocked => "distribution.blocked",
Self::DistributionReady => "distribution.ready", Self::DistributionReady => "distribution.ready",
} }
@@ -146,6 +151,7 @@ impl ReleaseFlowStatusCode {
Self::LocalizedPending => "pending", Self::LocalizedPending => "pending",
Self::LocalizedStale => "stale", Self::LocalizedStale => "stale",
Self::LocalizedPublished => "published", Self::LocalizedPublished => "published",
Self::LocalizedDegraded => "degraded",
Self::DistributionBlocked => "blocked", Self::DistributionBlocked => "blocked",
Self::DistributionReady => "ready", Self::DistributionReady => "ready",
} }
@@ -177,6 +183,7 @@ impl ReleaseFlowStatusCode {
"localized.pending" => Self::LocalizedPending, "localized.pending" => Self::LocalizedPending,
"localized.stale" => Self::LocalizedStale, "localized.stale" => Self::LocalizedStale,
"localized.published" => Self::LocalizedPublished, "localized.published" => Self::LocalizedPublished,
"localized.degraded" => Self::LocalizedDegraded,
"distribution.blocked" => Self::DistributionBlocked, "distribution.blocked" => Self::DistributionBlocked,
"distribution.ready" => Self::DistributionReady, "distribution.ready" => Self::DistributionReady,
_ => return None, _ => return None,
@@ -208,7 +215,8 @@ impl ReleaseFlowStatusCode {
Self::LocalizedBlockedOfficial Self::LocalizedBlockedOfficial
| Self::LocalizedPending | Self::LocalizedPending
| Self::LocalizedStale | Self::LocalizedStale
| Self::LocalizedPublished => "localized_publish", | Self::LocalizedPublished
| Self::LocalizedDegraded => "localized_publish",
Self::DistributionBlocked | Self::DistributionReady => "distribution", Self::DistributionBlocked | Self::DistributionReady => "distribution",
} }
} }
@@ -300,6 +308,7 @@ mod tests {
ReleaseFlowStatusCode::LocalizedPending, ReleaseFlowStatusCode::LocalizedPending,
ReleaseFlowStatusCode::LocalizedStale, ReleaseFlowStatusCode::LocalizedStale,
ReleaseFlowStatusCode::LocalizedPublished, ReleaseFlowStatusCode::LocalizedPublished,
ReleaseFlowStatusCode::LocalizedDegraded,
ReleaseFlowStatusCode::DistributionBlocked, ReleaseFlowStatusCode::DistributionBlocked,
ReleaseFlowStatusCode::DistributionReady, ReleaseFlowStatusCode::DistributionReady,
]; ];
File diff suppressed because it is too large Load Diff
+33
View File
@@ -85,6 +85,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/translation-glossary-delete", "/admin/control/translation-glossary-delete",
"/admin/control/localized-publish", "/admin/control/localized-publish",
"/admin/control/localized-rollback", "/admin/control/localized-rollback",
"/admin/control/release-cleanup",
}, },
} }
if r.Method == http.MethodHead { if r.Method == http.MethodHead {
@@ -144,6 +145,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
s.handleAdminLocalizedRollback(w, r) s.handleAdminLocalizedRollback(w, r)
return return
} }
if action == "release-cleanup" {
s.handleAdminReleaseCleanup(w, r)
return
}
request, ok := decodeAdminControlRequest(w, r) request, ok := decodeAdminControlRequest(w, r)
if !ok { if !ok {
return return
@@ -619,6 +624,34 @@ func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Reque
writeNoStoreJSON(w, http.StatusOK, result) writeNoStoreJSON(w, http.StatusOK, result)
} }
func (s *Server) handleAdminReleaseCleanup(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
return
}
var params backendrpc.ReleaseCleanupParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if params.Execute && strings.TrimSpace(params.PlanID) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_release_cleanup_params", "execute cleanup requires plan_id from a dry run")
return
}
result, err := backend.ReleaseCleanup(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "release-cleanup", err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: "release-cleanup",
RPCMethod: "release.cleanup",
Status: "accepted",
Result: result,
})
}
func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
+36 -9
View File
@@ -14,11 +14,6 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
} }
idx := s.index()
if idx == nil || idx.ResourceRoot == "" {
http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
return
}
_, rel, err := SplitCDNPath(r.URL.Path) _, rel, err := SplitCDNPath(r.URL.Path)
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
@@ -27,15 +22,47 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
var entry ResourceEntry var entry ResourceEntry
var hasEntry bool var hasEntry bool
if s.cfg.RequireIndexed { resourceRoot := ""
entry, hasEntry = idx.Lookup(rel) explicitRelease := false
channel := r.URL.Query().Get("channel")
releaseID := r.URL.Query().Get("release_id")
if channel != "" || releaseID != "" {
explicitRelease = true
channel, releaseID, selectorErr := releaseSelector(r)
if selectorErr != nil {
http.Error(w, selectorErr.Error(), http.StatusBadRequest)
return
}
selected, selectErr := s.loadReleaseDistribution(r, channel, releaseID)
if selectErr != nil {
http.Error(w, selectErr.Error(), http.StatusServiceUnavailable)
return
}
if selected == nil || !selected.Available || selected.ResourceRoot == "" {
http.Error(w, "selected release is not distributable", http.StatusConflict)
return
}
resourceRoot = selected.ResourceRoot
entry, hasEntry = releaseDistributionEntry(selected, rel)
} else {
idx := s.index()
if idx == nil || idx.ResourceRoot == "" {
http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
return
}
resourceRoot = idx.ResourceRoot
if s.cfg.RequireIndexed {
entry, hasEntry = idx.Lookup(rel)
}
}
if explicitRelease || s.cfg.RequireIndexed {
if !hasEntry || !entry.Present || !entry.SizeMatch { if !hasEntry || !entry.Present || !entry.SizeMatch {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
} }
abs, err := ResolveUnderRoot(idx.ResourceRoot, rel, true) abs, err := ResolveUnderRoot(resourceRoot, rel, true)
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -45,7 +72,7 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if s.cfg.RequireIndexed && s.cfg.VerifySize { if (explicitRelease || s.cfg.RequireIndexed) && s.cfg.VerifySize {
if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes { if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
http.Error(w, "size mismatch with release index", http.StatusConflict) http.Error(w, "size mismatch with release index", http.StatusConflict)
return return
+73 -1
View File
@@ -79,6 +79,52 @@ paths:
responses: responses:
"200": "200":
description: Release summary. description: Release summary.
/v1/releases:
get:
summary: Rust-owned official and localized release history
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
responses:
"200":
description: Release history and manifest/artifact integrity summaries.
"503":
description: Rust bat release backend is unavailable.
/v1/distribution:
get:
summary: Select a verified official or localized release for distribution
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
default: official
- name: release_id
in: query
schema:
type: string
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
responses:
"200":
description: Rust-verified selected release and resource manifest page.
"409":
description: Selected release is missing, stale, damaged, or not distributable.
"503":
description: Rust bat release backend is unavailable.
/v1/resources: /v1/resources:
get: get:
summary: Paginated resource manifest entries summary: Paginated resource manifest entries
@@ -551,6 +597,32 @@ paths:
description: Missing or invalid admin token. description: Missing or invalid admin token.
"503": "503":
description: Rust bat localized backend is unavailable. description: Rust bat localized backend is unavailable.
/admin/releases/status:
get:
summary: Read the unified Rust-owned release status view
responses:
"200":
description: Official/localized current relation and integrity status.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat release backend is unavailable.
/admin/releases:
get:
summary: Read Rust-owned historical release summaries
parameters:
- name: channel
in: query
schema:
type: string
enum: [official, localized]
responses:
"200":
description: Historical release summaries.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat release backend is unavailable.
/admin/control/{action}: /admin/control/{action}:
post: post:
summary: Forward an allowlisted control or schedule action to Rust bat summary: Forward an allowlisted control or schedule action to Rust bat
@@ -560,7 +632,7 @@ paths:
required: true required: true
schema: schema:
type: string type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback] enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup]
requestBody: requestBody:
required: false required: false
content: content:
+239
View File
@@ -0,0 +1,239 @@
package api
import (
"net/http"
"strconv"
"strings"
"bat-api/internal/backendrpc"
)
func (s *Server) handleReleaseList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
return
}
channel := r.URL.Query().Get("channel")
result, err := backend.ReleaseList(r.Context(), backendrpc.ReleaseListParams{Channel: channel})
if err != nil {
s.writeControlBackendError(w, "release-list", err)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleReleaseDistribution(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
channel, releaseID, err := releaseSelector(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_release_selector", err.Error())
return
}
params, err := releaseDistributionParams(r, channel, releaseID)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_release_query", err.Error())
return
}
page, err := s.requestReleaseDistribution(r, params)
if err != nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", err.Error())
return
}
if page == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust release distribution returned no result")
return
}
status := http.StatusOK
if !page.Available {
status = http.StatusConflict
}
writeNoStoreJSON(w, status, page)
}
func (s *Server) handleAdminReleaseStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
return
}
result, err := backend.ReleaseStatus(r.Context())
if err != nil {
s.writeControlBackendError(w, "release-status", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminReleaseList(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
return
}
result, err := backend.ReleaseList(r.Context(), backendrpc.ReleaseListParams{
Channel: r.URL.Query().Get("channel"),
})
if err != nil {
s.writeControlBackendError(w, "release-list", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func releaseSelector(r *http.Request) (string, string, error) {
channel := strings.TrimSpace(r.URL.Query().Get("channel"))
releaseID := strings.TrimSpace(r.URL.Query().Get("release_id"))
if channel == "" {
channel = "official"
}
if channel != "official" && channel != "localized" {
return "", "", &releaseSelectorError{message: "channel must be official or localized"}
}
if releaseID == "." || releaseID == ".." ||
strings.Contains(releaseID, "/") ||
strings.Contains(releaseID, "\\") ||
strings.Contains(releaseID, ":") ||
strings.ContainsRune(releaseID, 0) {
return "", "", &releaseSelectorError{message: "release_id contains an unsafe path character"}
}
return channel, releaseID, nil
}
type releaseSelectorError struct {
message string
}
func (e *releaseSelectorError) Error() string {
return e.message
}
func (s *Server) loadReleaseDistribution(r *http.Request, channel, releaseID string) (*backendrpc.ReleaseDistributionPage, error) {
return s.loadReleaseDistributionFrom(r, backendrpc.ReleaseDistributionParams{
Channel: channel,
ReleaseID: releaseID,
Offset: 0,
Limit: 1000,
})
}
func releaseDistributionParams(r *http.Request, channel, releaseID string) (backendrpc.ReleaseDistributionParams, error) {
params := backendrpc.ReleaseDistributionParams{
Channel: channel,
ReleaseID: releaseID,
}
query := r.URL.Query()
if raw := strings.TrimSpace(query.Get("offset")); raw != "" {
offset, err := strconv.ParseUint(raw, 10, 64)
if err != nil || uint64(int(^uint(0)>>1)) < offset {
return backendrpc.ReleaseDistributionParams{}, &releaseSelectorError{
message: "offset must be a non-negative integer",
}
}
params.Offset = int(offset)
}
if raw := strings.TrimSpace(query.Get("limit")); raw != "" {
limit, err := strconv.ParseUint(raw, 10, 64)
if err != nil || limit == 0 || limit > 1000 {
return backendrpc.ReleaseDistributionParams{}, &releaseSelectorError{
message: "limit must be in 1..=1000",
}
}
params.Limit = int(limit)
}
return params, nil
}
func (s *Server) requestReleaseDistribution(r *http.Request, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
return nil, &releaseSelectorError{message: "Rust bat release backend is unavailable"}
}
return backend.ReleaseDistribution(r.Context(), params)
}
func (s *Server) loadReleaseDistributionFrom(r *http.Request, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
backend, ok := s.backend.(ReleaseBackend)
if !ok || backend == nil {
return nil, &releaseSelectorError{message: "Rust bat release backend is unavailable"}
}
pageSize := 1000
result, err := backend.ReleaseDistribution(r.Context(), params)
if err != nil || result == nil || !result.Available || result.Total <= len(result.Entries) {
return result, err
}
all := append([]backendrpc.ReleaseDistributionEntry(nil), result.Entries...)
for offset := len(all); offset < result.Total; {
next, nextErr := backend.ReleaseDistribution(r.Context(), backendrpc.ReleaseDistributionParams{
Channel: params.Channel,
ReleaseID: params.ReleaseID,
Offset: offset,
Limit: pageSize,
})
if nextErr != nil {
return nil, nextErr
}
if next == nil || !next.Available || len(next.Entries) == 0 {
return nil, &releaseSelectorError{message: "Rust release distribution page is incomplete"}
}
all = append(all, next.Entries...)
offset = len(all)
if len(all) > result.Total {
all = all[:result.Total]
break
}
}
result.Entries = all
result.Offset = 0
result.Limit = len(all)
return result, nil
}
func releaseDistributionEntry(page *backendrpc.ReleaseDistributionPage, rel string) (ResourceEntry, bool) {
rel = strings.TrimPrefix(strings.ReplaceAll(rel, "\\", "/"), "/")
for _, entry := range page.Entries {
destination := strings.TrimPrefix(strings.ReplaceAll(entry.Destination, "\\", "/"), "/")
if destination == rel {
return ResourceEntry{
URL: entry.URL,
RelativePath: destination,
Bytes: entry.Bytes,
BLAKE3: entry.BLAKE3,
Present: true,
SizeMatch: true,
}, true
}
}
return ResourceEntry{}, false
}
+175
View File
@@ -0,0 +1,175 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"bat-api/internal/backendrpc"
)
type releaseBackendStub struct {
*fakeBackend
root string
available bool
distributionParams []backendrpc.ReleaseDistributionParams
}
func (b *controlBackend) ReleaseStatus(context.Context) (*backendrpc.ReleaseStatusReport, error) {
b.calls = append(b.calls, "release.status")
return &backendrpc.ReleaseStatusReport{Status: "ready", StatusCode: "distribution.ready"}, nil
}
func (b *controlBackend) ReleaseList(context.Context, backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
b.calls = append(b.calls, "release.list")
return &backendrpc.ReleaseListReport{Status: "ready", StatusCode: "distribution.ready"}, nil
}
func (b *controlBackend) ReleaseDistribution(context.Context, backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
b.calls = append(b.calls, "release.distribution")
return &backendrpc.ReleaseDistributionPage{Available: false, StatusCode: "distribution.blocked"}, nil
}
func (b *controlBackend) ReleaseCleanup(context.Context, backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
b.calls = append(b.calls, "release.cleanup")
return &backendrpc.ReleaseCleanupReport{PlanID: "plan-1"}, nil
}
func (b *releaseBackendStub) ReleaseStatus(context.Context) (*backendrpc.ReleaseStatusReport, error) {
return &backendrpc.ReleaseStatusReport{
Status: "ready",
StatusCode: "distribution.ready",
OfficialCurrentReleaseID: "official-1",
DefaultDistributionChannel: "official",
OfficialDistributionReady: true,
}, nil
}
func (b *releaseBackendStub) ReleaseList(context.Context, backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
return &backendrpc.ReleaseListReport{
Status: "ready",
StatusCode: "distribution.ready",
Releases: []backendrpc.ReleaseSummary{{
Channel: "localized",
ID: "localized-1",
ManifestContractStatus: "valid",
ArtifactIntegrityStatus: "valid",
DistributionIntegrityStatus: "valid",
}},
}, nil
}
func (b *releaseBackendStub) ReleaseDistribution(_ context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
b.distributionParams = append(b.distributionParams, params)
return &backendrpc.ReleaseDistributionPage{
Available: b.available,
Channel: params.Channel,
ReleaseID: params.ReleaseID,
ResourceRoot: b.root,
Status: "ready",
StatusCode: "distribution.ready",
ArtifactIntegrityStatus: "valid",
Total: 1,
Limit: 1000,
Entries: []backendrpc.ReleaseDistributionEntry{{
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Bytes: 21,
BLAKE3: "not-used-by-http-index",
}},
}, nil
}
func (*releaseBackendStub) ReleaseCleanup(context.Context, backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
return &backendrpc.ReleaseCleanupReport{PlanID: "plan-1"}, nil
}
func TestReleaseHTTPForwardsTypedSelectionAndDoesNotFallback(t *testing.T) {
cfg := DefaultConfig()
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &releaseBackendStub{fakeBackend: &fakeBackend{}, root: fixtureRoot(t), available: true}
server := NewServer(cfg, backend, nil)
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/releases?channel=localized", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("release list status=%d body=%s", recorder.Code, recorder.Body.String())
}
recorder = httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/distribution?channel=localized&release_id=localized-1&offset=2&limit=10", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("distribution status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.distributionParams) != 1 ||
backend.distributionParams[0].Channel != "localized" ||
backend.distributionParams[0].ReleaseID != "localized-1" ||
backend.distributionParams[0].Offset != 2 ||
backend.distributionParams[0].Limit != 10 {
t.Fatalf("distribution params=%#v", backend.distributionParams)
}
recorder = httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized&release_id=localized-1", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("localized CDN status=%d body=%s", recorder.Code, recorder.Body.String())
}
if recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("localized CDN body=%q", recorder.Body.String())
}
cfg.RequireIndexed = false
unindexedServer := NewServer(cfg, backend, nil)
recorder = httptest.NewRecorder()
unindexedServer.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/not-listed.bytes?channel=localized&release_id=localized-1", nil))
if recorder.Code != http.StatusNotFound {
t.Fatalf("unlisted localized CDN status=%d body=%s", recorder.Code, recorder.Body.String())
}
blocked := &releaseBackendStub{fakeBackend: &fakeBackend{}, root: fixtureRoot(t), available: false}
blockedServer := NewServer(cfg, blocked, nil)
recorder = httptest.NewRecorder()
blockedServer.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized", nil))
if recorder.Code != http.StatusConflict {
t.Fatalf("blocked localized CDN status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminReleaseCleanupRequiresAuthAndForwards(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
server := NewServer(cfg, backend, nil)
recorder := httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", nil))
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated cleanup status=%d", recorder.Code)
}
request := httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", strings.NewReader(`{"execute":false}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted {
t.Fatalf("dry-run cleanup status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.calls) != 1 || backend.calls[0] != "release.cleanup" {
t.Fatalf("calls=%v", backend.calls)
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/release-cleanup", strings.NewReader(`{"execute":true}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
server.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("missing plan cleanup status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
+25
View File
@@ -113,6 +113,15 @@ type LocalizedBackend interface {
LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error)
} }
// ReleaseBackend exposes Rust-owned dual-release queries, distribution
// selection and the explicit cleanup operation.
type ReleaseBackend interface {
ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error)
ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error)
ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error)
ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, 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
@@ -253,6 +262,22 @@ func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.Loca
return r.Client.LocalizedRollback(ctx, params) return r.Client.LocalizedRollback(ctx, params)
} }
func (r RPCClient) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) {
return r.Client.ReleaseStatus(ctx)
}
func (r RPCClient) ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
return r.Client.ReleaseList(ctx, params)
}
func (r RPCClient) ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
return r.Client.ReleaseDistribution(ctx, params)
}
func (r RPCClient) ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
return r.Client.ReleaseCleanup(ctx, params)
}
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) { func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.ParseStatus(ctx) return r.Client.ParseStatus(ctx)
} }
+8
View File
@@ -51,6 +51,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/v1/bootstrap", s.handleBootstrap) mux.HandleFunc("/v1/bootstrap", s.handleBootstrap)
mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap) mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap)
mux.HandleFunc("/v1/release", s.handleRelease) mux.HandleFunc("/v1/release", s.handleRelease)
mux.HandleFunc("/v1/releases", s.handleReleaseList)
mux.HandleFunc("/v1/distribution", s.handleReleaseDistribution)
mux.HandleFunc("/v1/resources", s.handleResources) mux.HandleFunc("/v1/resources", s.handleResources)
mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug) mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug)
mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig) mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig)
@@ -80,6 +82,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/admin/translation/glossary/query", s.handleAdminGlossaryQuery) mux.HandleFunc("/admin/translation/glossary/query", s.handleAdminGlossaryQuery)
mux.HandleFunc("/admin/translation/glossary/diagnose", s.handleAdminGlossaryDiagnose) mux.HandleFunc("/admin/translation/glossary/diagnose", s.handleAdminGlossaryDiagnose)
mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus) mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus)
mux.HandleFunc("/admin/releases/status", s.handleAdminReleaseStatus)
mux.HandleFunc("/admin/releases", s.handleAdminReleaseList)
mux.HandleFunc("/admin/control/", s.handleAdminControl) 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)
@@ -132,6 +136,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/v1/bootstrap", "/v1/bootstrap",
"/v1/launcher/bootstrap", "/v1/launcher/bootstrap",
"/v1/release", "/v1/release",
"/v1/releases",
"/v1/distribution",
"/v1/resources", "/v1/resources",
"/v1/server-info", "/v1/server-info",
"/api/launcher/game/config", "/api/launcher/game/config",
@@ -162,6 +168,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/admin/translation/glossary/query", "/admin/translation/glossary/query",
"/admin/translation/glossary/diagnose", "/admin/translation/glossary/diagnose",
"/admin/translation/status", "/admin/translation/status",
"/admin/releases/status",
"/admin/releases",
"/admin/control/{action}", "/admin/control/{action}",
}, },
}) })
+135
View File
@@ -182,6 +182,117 @@ type pageParam struct {
Limit int `json:"limit"` Limit int `json:"limit"`
} }
// ReleaseListParams selects one Rust-owned release namespace.
type ReleaseListParams struct {
Channel string `json:"channel,omitempty"`
}
// ReleaseDistributionParams selects a verified release for distribution.
type ReleaseDistributionParams struct {
Channel string `json:"channel,omitempty"`
ReleaseID string `json:"release_id,omitempty"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
}
// ReleaseCleanupParams controls the dry-run/execute cleanup pair.
type ReleaseCleanupParams struct {
Execute bool `json:"execute,omitempty"`
PlanID string `json:"plan_id,omitempty"`
}
// ReleaseSummary mirrors Rust's dual-release historical summary.
type ReleaseSummary struct {
Channel string `json:"channel"`
ID string `json:"id"`
Path string `json:"path"`
SourceOfficialReleaseID string `json:"source_official_release_id,omitempty"`
CreatedUnixSeconds *uint64 `json:"created_unix_seconds,omitempty"`
PublishedUnixSeconds *uint64 `json:"published_unix_seconds,omitempty"`
Current bool `json:"current"`
CurrentPointerValid bool `json:"current_pointer_valid"`
RollbackAvailable bool `json:"rollback_available"`
Stale bool `json:"stale"`
Damaged bool `json:"damaged"`
Referenced bool `json:"referenced"`
Unknown bool `json:"unknown"`
Lifecycle string `json:"lifecycle"`
ManifestContractStatus string `json:"manifest_contract_status"`
ArtifactIntegrityStatus string `json:"artifact_integrity_status"`
DistributionIntegrityStatus string `json:"distribution_integrity_status"`
Legacy bool `json:"legacy"`
RollbackPreviousReleaseID string `json:"rollback_previous_release_id,omitempty"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
// ReleaseStatusReport is the unified official/localized release view.
type ReleaseStatusReport struct {
Status string `json:"status"`
StatusCode string `json:"status_code"`
OfficialCurrentReleaseID string `json:"official_current_release_id,omitempty"`
LocalizedCurrentReleaseID string `json:"localized_current_release_id,omitempty"`
LocalizedSourceOfficialID string `json:"localized_source_official_release_id,omitempty"`
CurrentReleasesMatch bool `json:"current_releases_match"`
DefaultDistributionChannel string `json:"default_distribution_channel"`
OfficialDistributionReady bool `json:"official_distribution_ready"`
LocalizedDistributionReady bool `json:"localized_distribution_ready"`
Releases []ReleaseSummary `json:"releases"`
}
// ReleaseListReport is the filtered historical release response.
type ReleaseListReport struct {
Status string `json:"status"`
StatusCode string `json:"status_code"`
Channel string `json:"channel,omitempty"`
Releases []ReleaseSummary `json:"releases"`
}
// ReleaseDistributionEntry is one Rust-verified resource manifest entry.
type ReleaseDistributionEntry struct {
URL string `json:"url"`
Destination string `json:"destination"`
Bytes uint64 `json:"bytes"`
BLAKE3 string `json:"blake3"`
}
// ReleaseDistributionPage is a typed page for one selected release.
type ReleaseDistributionPage struct {
Available bool `json:"available"`
Channel string `json:"channel"`
ReleaseID string `json:"release_id,omitempty"`
ResourceRoot string `json:"resource_root,omitempty"`
SourceOfficialReleaseID string `json:"source_official_release_id,omitempty"`
Current bool `json:"current"`
Status string `json:"status"`
StatusCode string `json:"status_code"`
ArtifactIntegrityStatus string `json:"artifact_integrity_status"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Entries []ReleaseDistributionEntry `json:"entries"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
// ReleaseCleanupEntry is one retained or removable cleanup observation.
type ReleaseCleanupEntry struct {
Channel string `json:"channel"`
ID string `json:"id"`
Path string `json:"path"`
Candidate bool `json:"candidate"`
RetainReasons []string `json:"retain_reasons,omitempty"`
BlockingReferences []string `json:"blocking_references,omitempty"`
}
// ReleaseCleanupReport is the dry-run or execute result.
type ReleaseCleanupReport struct {
Execute bool `json:"execute"`
PlanID string `json:"plan_id"`
Revalidated bool `json:"revalidated"`
Entries []ReleaseCleanupEntry `json:"entries"`
Removed []string `json:"removed"`
Diagnostics []string `json:"diagnostics,omitempty"`
}
type tailParam struct { type tailParam struct {
Tail int `json:"tail"` Tail int `json:"tail"`
} }
@@ -981,6 +1092,30 @@ func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.diff", nil) return c.rawData(ctx, "catalog.diff", nil)
} }
func (c *Client) ReleaseStatus(ctx context.Context) (*ReleaseStatusReport, error) {
var out ReleaseStatusReport
_, err := c.Call(ctx, "release.status", nil, &out)
return &out, err
}
func (c *Client) ReleaseList(ctx context.Context, params ReleaseListParams) (*ReleaseListReport, error) {
var out ReleaseListReport
_, err := c.Call(ctx, "release.list", params, &out)
return &out, err
}
func (c *Client) ReleaseDistribution(ctx context.Context, params ReleaseDistributionParams) (*ReleaseDistributionPage, error) {
var out ReleaseDistributionPage
_, err := c.Call(ctx, "release.distribution", params, &out)
return &out, err
}
func (c *Client) ReleaseCleanup(ctx context.Context, params ReleaseCleanupParams) (*ReleaseCleanupReport, error) {
var out ReleaseCleanupReport
_, err := c.Call(ctx, "release.cleanup", params, &out)
return &out, err
}
func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) { func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) {
var out TaskAccepted var out TaskAccepted
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out) _, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
+49
View File
@@ -88,6 +88,55 @@ func TestResourceRepairQueuesTask(t *testing.T) {
} }
} }
func TestReleaseDistributionUsesTypedRPCContract(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "release.distribution" {
t.Fatalf("method = %s", req.Method)
}
var params ReleaseDistributionParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.Channel != "localized" || params.ReleaseID != "localized-1" || params.Offset != 2 || params.Limit != 10 {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
Data: map[string]any{
"available": true,
"channel": "localized",
"release_id": "localized-1",
"resource_root": "/tmp/localized",
"total": 3,
"offset": 2,
"limit": 10,
"entries": []any{map[string]any{
"url": "https://example.invalid/data.bin",
"destination": "host/data.bin",
"bytes": 4,
"blake3": "abcd",
}},
},
},
}
})
page, err := client.ReleaseDistribution(context.Background(), ReleaseDistributionParams{
Channel: "localized",
ReleaseID: "localized-1",
Offset: 2,
Limit: 10,
})
if err != nil {
t.Fatalf("ReleaseDistribution error: %v", err)
}
if !page.Available || page.ResourceRoot != "/tmp/localized" || len(page.Entries) != 1 {
t.Fatalf("page = %#v", page)
}
}
func TestDaemonRestartSendsControlMethod(t *testing.T) { func TestDaemonRestartSendsControlMethod(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 != "daemon.restart" { if req.Method != "daemon.restart" {