mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
feat(release):完成双 release 运维闭环
This commit is contained in:
+15
-6
@@ -46,6 +46,12 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
|
||||
按当前 QA 精确校验 identity。`translation.glossary.*` 已通过
|
||||
`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. 权威文档入口
|
||||
@@ -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。
|
||||
- `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`。
|
||||
- `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`:分别负责结果报告渲染和前台终端诊断、帮助、进度及结构化日志输出。
|
||||
|
||||
待完成:
|
||||
|
||||
- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary、复杂 AssetBundle fixture 和 generic manifest 发布资源视图。
|
||||
- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary 和复杂 AssetBundle fixture;generic manifest V1 与双 release 运维 V1 已完成。
|
||||
- 真实线上全量下载 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 变体驱动字段修改后的语义重打包。
|
||||
- 未见样本驱动的复杂 AssetBundle 重打包、更多双 release 查询/分发视图和清理策略;当前 generic manifest 只承诺已验证的 Binary/JSON/Text 与 UnityFS 结构,不等价于任意整体 AssetBundle 重打包。
|
||||
- 未见样本驱动的复杂 AssetBundle 重打包;当前 generic manifest 和双 release 运维 V1 只承诺已验证的 Binary/JSON/Text、UnityFS 结构及 Rust-owned release 查询/分发/安全清理,不等价于任意整体 AssetBundle 重打包。
|
||||
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
|
||||
|
||||
### `bat-ffi`
|
||||
@@ -228,7 +237,7 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人
|
||||
| 角色 | 所有者 | 状态 |
|
||||
|---|---|---|
|
||||
| 同步/运维命令行(近乎全自动) | 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` | 完成 |
|
||||
| 试验 CLI | `cmd/bat` → `bin/bat-go` | 非产品 |
|
||||
| FFI | `internal/ffi` | 可选 |
|
||||
@@ -305,7 +314,7 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
|
||||
后续工程顺序:
|
||||
|
||||
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
|
||||
2. 继续通用 Patch:manifest 驱动、双 release 查询和清理策略。
|
||||
2. 继续通用 Patch:真实样本驱动的复杂 AssetBundle 兼容;双 release 查询、分发、rollback 边界和安全清理 V1 已完成。
|
||||
3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider
|
||||
扩展体系。
|
||||
4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
|
||||
@@ -317,5 +326,5 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
|
||||
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
|
||||
和 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
|
||||
通用 Binary/JSON/Text Patch 基础、generic manifest 和受支持 localized patch 发布/rollback 可用;
|
||||
复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更多双 release 运维视图未完成。
|
||||
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、双 release 运维视图,以及真实官方资源长期运行验证。
|
||||
复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更高阶 release retention 未完成;双 release 运维 V1 已完成。
|
||||
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包,以及真实官方资源长期运行验证。
|
||||
|
||||
+5
-4
@@ -32,7 +32,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
|
||||
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
|
||||
6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`。
|
||||
7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/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。
|
||||
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 明细索引和队列时只读取摘要,不重复解析。
|
||||
@@ -306,7 +306,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
||||
3. 实现客户端发现、路径校验、备份、应用、回滚。
|
||||
4. 实现 `patch build`、`patch apply`、`patch rollback`、`verify`。
|
||||
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. 推荐执行顺序
|
||||
|
||||
近期不要把内嵌 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
@@ -501,6 +501,10 @@ Glossary 相关定义变化会使受影响 override 失效,无关术语变化
|
||||
| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
|
||||
| `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 过滤 |
|
||||
| `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.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 |
|
||||
| `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 |
|
||||
@@ -523,10 +527,23 @@ Glossary 相关定义变化会使受影响 override 失效,无关术语变化
|
||||
| 未知方法 | — | `BAT-ERR-700001`(unknown method) |
|
||||
|
||||
只读查询(`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`)在尚无已发布版本
|
||||
或对应文件不存在时返回 `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` 轮询。任务记录:
|
||||
@@ -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' \
|
||||
| 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
|
||||
```
|
||||
|
||||
@@ -72,6 +72,52 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
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:
|
||||
get:
|
||||
summary: Paginated resource manifest entries
|
||||
@@ -544,6 +590,32 @@ paths:
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
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}:
|
||||
post:
|
||||
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||
@@ -553,7 +625,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
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:
|
||||
required: false
|
||||
content:
|
||||
|
||||
@@ -347,7 +347,11 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
|
||||
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() {
|
||||
operation.payload.validate()?;
|
||||
if operation.sequence != expected_sequence as u32 {
|
||||
@@ -391,15 +395,6 @@ pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
|
||||
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() {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"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(())
|
||||
}
|
||||
|
||||
fn unity_operation_target(operation: &PatchManifestOperation) -> Option<(Option<&str>, &str, i64)> {
|
||||
let (serialized_file_path, path_id) = match &operation.payload {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
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 {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
..
|
||||
} => (serialized_file_path, *path_id),
|
||||
} => Some(UnityOperationTarget::WholeObject {
|
||||
archive_entry,
|
||||
serialized_file_path,
|
||||
path_id: *path_id,
|
||||
}),
|
||||
PatchManifestOperationPayload::UnityFsStringField {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
field_path,
|
||||
..
|
||||
}
|
||||
| PatchManifestOperationPayload::UnityFsField {
|
||||
serialized_file_path,
|
||||
path_id,
|
||||
field_path,
|
||||
..
|
||||
} => (serialized_file_path, *path_id),
|
||||
_ => return None,
|
||||
};
|
||||
Some((
|
||||
operation.archive_entry.as_deref(),
|
||||
serialized_file_path.as_str(),
|
||||
path_id,
|
||||
))
|
||||
} => Some(UnityOperationTarget::Field {
|
||||
archive_entry,
|
||||
serialized_file_path,
|
||||
path_id: *path_id,
|
||||
field_path,
|
||||
}),
|
||||
_ => 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.
|
||||
@@ -477,13 +591,8 @@ pub fn build_patch_manifest(
|
||||
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 direct_operations = !has_unity_operation && !has_archive_operation;
|
||||
let mut current = source.clone();
|
||||
let operations = file
|
||||
.operations
|
||||
@@ -491,39 +600,58 @@ pub fn build_patch_manifest(
|
||||
.enumerate()
|
||||
.map(|(sequence, mut operation)| {
|
||||
operation.payload.validate()?;
|
||||
let source_blake3 = (operation_count > 1).then(|| blake3_hex(¤t));
|
||||
let source_size = (operation_count > 1).then_some(current.len() as u64);
|
||||
if operation.source_blake3.is_none() && operation_count > 1 {
|
||||
if has_archive_operation || has_unity_operation {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder requires source preconditions for multiple non-direct operations: {}",
|
||||
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 direct_operations && operation.source_blake3.is_none() && operation_count > 1 {
|
||||
operation.source_blake3 = Some(blake3_hex(¤t));
|
||||
operation.source_size = Some(current.len() as u64);
|
||||
} else if !direct_operations
|
||||
&& operation_count > 1
|
||||
&& operation.source_blake3.is_none()
|
||||
{
|
||||
if expected_hash != blake3_hex(¤t)
|
||||
|| expected_size != current.len() as u64
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"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!(
|
||||
"manifest builder operation source precondition mismatch: {} operation {}",
|
||||
file.path.display(),
|
||||
sequence
|
||||
)));
|
||||
if expected_hash != blake3_hex(¤t)
|
||||
|| expected_size != current.len() as u64
|
||||
{
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder operation source precondition mismatch: {} operation {}",
|
||||
file.path.display(),
|
||||
sequence
|
||||
)));
|
||||
}
|
||||
}
|
||||
current = apply_direct_payload(¤t, &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(¤t, &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;
|
||||
Ok(operation)
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>()?;
|
||||
if !has_unity_operation && !has_archive_operation && current != target {
|
||||
if direct_operations && current != target {
|
||||
return Err(PatchError::ApplyFailed(format!(
|
||||
"manifest builder operations do not produce target bytes: {}",
|
||||
file.path.display()
|
||||
@@ -852,45 +980,145 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_rejects_repeated_unity_object_target() {
|
||||
let source = b"source";
|
||||
let target = b"target";
|
||||
let mut manifest = manifest_for("bundle", source, target);
|
||||
manifest.files[0].patch_kind = PatchKind::Mixed;
|
||||
manifest.files[0].operations = vec![
|
||||
PatchManifestOperation {
|
||||
sequence: 0,
|
||||
source_blake3: Some(blake3_hex(source)),
|
||||
source_size: Some(source.len() as u64),
|
||||
archive_entry: None,
|
||||
payload: PatchManifestOperationPayload::UnityFsStringField {
|
||||
serialized_file_path: "CAB-one".to_string(),
|
||||
path_id: 7,
|
||||
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,
|
||||
},
|
||||
];
|
||||
fn validate_patch_manifest_allows_sibling_fields_on_one_unity_object() {
|
||||
let manifest = unity_manifest(vec![
|
||||
unity_string_operation(None, "first", 0),
|
||||
unity_field_operation(None, "second", 1),
|
||||
]);
|
||||
|
||||
validate_patch_manifest(&manifest).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_patch_manifest_rejects_duplicate_unity_field() {
|
||||
let manifest = unity_manifest(vec![
|
||||
unity_string_operation(None, "first", 0),
|
||||
unity_string_operation(None, "first", 1),
|
||||
]);
|
||||
|
||||
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 {
|
||||
PatchManifest {
|
||||
version: PATCH_MANIFEST_VERSION,
|
||||
|
||||
@@ -156,7 +156,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
|
||||
|
||||
1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。
|
||||
2. ResourceRepository 已保存官方 manifest 资源的类型、路径、hash、size 和 metadata;metadata 包含 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` 兼容迁移。
|
||||
|
||||
验收:
|
||||
@@ -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 明确失败。
|
||||
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果;generic manifest 不把 UnityFS 定位信息扁平化。
|
||||
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`;rollback 按 manifest 恢复上一 release。
|
||||
5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release,ZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验。
|
||||
5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release,并分别报告 schema/contract 与 artifact integrity,ZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验;current 仍存在但产物损坏时返回 `localized.degraded`,不自动修复。
|
||||
|
||||
验收:
|
||||
|
||||
|
||||
@@ -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 请求。
|
||||
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 清理时递减,失败则回退网络并保留诊断。
|
||||
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 证据,供后续排障和自研客户端开发使用。
|
||||
|
||||
@@ -345,8 +345,9 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
||||
- 方法命名空间与实现状态、请求/响应示例见
|
||||
`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`、
|
||||
`translation.tasks/handoff/task.update/proofread`、`localized.status`、`catalog.*` 与
|
||||
`task.status/list/cancel/logs` 已实现;文件级 `patch.apply` / `unityfs.patch_*`
|
||||
`translation.tasks/handoff/task.update/proofread`、`localized.status`、
|
||||
`release.status/list/distribution/cleanup`、`catalog.*` 与 `task.status/list/cancel/logs`
|
||||
已实现;文件级 `patch.apply` / `unityfs.patch_*`
|
||||
和受支持 localized publish/rollback 已实现,`archive_entry` 可验证时会重写
|
||||
外层 ZIP;通用发布级 patch 与复杂 UnityFS 语义编辑仍待后续;
|
||||
`task.create` 按设计暂不开放通用任务入口;
|
||||
@@ -357,7 +358,7 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
||||
|
||||
- Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、
|
||||
bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和
|
||||
`resource_root` 中已发布的只读文件。
|
||||
Rust 选择后返回的 `resource_root` 中已发布的只读文件。
|
||||
- Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、
|
||||
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接
|
||||
`bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。
|
||||
@@ -371,6 +372,11 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
||||
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
|
||||
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再
|
||||
`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
|
||||
刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。
|
||||
- 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
- inventory 抽取:`adapters/src/official/inventory.rs`
|
||||
- 落盘与 manifest:`infrastructure/src/official_download.rs`(`destination_for_url`)
|
||||
- 发布布局:`infrastructure/src/official_update.rs`
|
||||
- 双 release 视图、分发选择与清理:`infrastructure/src/release_ops.rs`
|
||||
- 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md`)
|
||||
|
||||
---
|
||||
@@ -17,7 +18,7 @@
|
||||
| 角色 | 组件 | 职责 |
|
||||
|---|---|---|
|
||||
| 同步 / 运维(近乎全自动) | 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` 重名 |
|
||||
|
||||
**禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。
|
||||
@@ -85,13 +86,34 @@
|
||||
- `not_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,也不负责自动 repair;staging 默认保留
|
||||
以避免删除未持久化任务。
|
||||
|
||||
### 2.2 读侧 vs 写侧
|
||||
|
||||
| 阶段 | 根目录 |
|
||||
|---|---|
|
||||
| 下载写入 | `<output>/.staging/<id>` |
|
||||
| 发布完成 | 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
|
||||
按规范化 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>/...
|
||||
```
|
||||
|
||||
默认仅服务 **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 资源引导兼容
|
||||
|
||||
|
||||
@@ -34,6 +34,42 @@ bat res pull --auto-discover --watch --interval 1h \
|
||||
|
||||
资源下载默认使用 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:
|
||||
|
||||
@@ -172,6 +172,23 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
||||
|
||||
删除资源只进入 `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
|
||||
|
||||
调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的
|
||||
@@ -345,7 +362,7 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
|
||||
`./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。
|
||||
`status` / `status_code` 使用生命周期短状态和稳定状态码,例如
|
||||
`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`
|
||||
用于表示汉化工作流的人工校对状态,例如 `manual_proofreading` /
|
||||
`translation.manual_proofreading`。返回 `localized_release_status=localized` 的条件是:
|
||||
@@ -353,7 +370,10 @@ Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 t
|
||||
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
|
||||
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
|
||||
`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_file_count`、`patch_operation_count`、`patch_kind_counts`、
|
||||
`patch_text_asset_operation_count` 和 `rollback_previous_current_target`。
|
||||
@@ -525,7 +545,7 @@ CLI 对应关系:
|
||||
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
|
||||
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
|
||||
`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`、
|
||||
`translation.tasks`、`translation.handoff`、`translation.task.update`、
|
||||
`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 状态 |
|
||||
| `GlossaryBackend` | `translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核/删除 mutation;Go 只转发,不持有 Glossary 状态 |
|
||||
| `LocalizedBackend` | `localized.status`、`localized.publish`、`localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
|
||||
| `ReleaseBackend` | `release.status`、`release.list`、`release.distribution`、`release.cleanup` | 鉴权后的双 release 查询、验证分发选择和 dry-run/execute cleanup;Go 不持有 release 状态 |
|
||||
|
||||
`daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
|
||||
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
||||
|
||||
@@ -32,7 +32,7 @@ directory 形态和未修改对象/字段,并明确拒绝未知压缩或无法
|
||||
回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须
|
||||
同时补真实 fixture、回归测试和文档。
|
||||
|
||||
### G-006:通用 Patch 的复杂格式和运维视图仍未完成
|
||||
### G-006:通用 Patch 的复杂格式和运维扩展仍未完成
|
||||
|
||||
状态:**V1 已完成(当前支持类型),复杂格式和运维扩展继续推进**
|
||||
|
||||
@@ -41,13 +41,16 @@ rollback 元数据;`LocalizedPatchService` 已使用同一有序 generic manif
|
||||
Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field
|
||||
写入及 localized publish/rollback。发布会在独立 staging 中校验 source/target identity、
|
||||
逐操作 precondition、ZIP 内层重解析和实际字段替换,并保留 TextUnit/TM/Glossary/review
|
||||
provenance;`localized.publish`、`i18n publish` 和 bat-api typed forwarding 均已接入。
|
||||
provenance;UnityFS 目标身份至少包含 archive entry、serialized file、path ID 和实际
|
||||
field path,同对象 sibling field 可并存,重复、父子、whole-object/field 结构重叠会拒绝;
|
||||
`localized.publish`、`i18n publish` 和 bat-api typed forwarding 均已接入。
|
||||
|
||||
仍需完成:
|
||||
|
||||
- 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持
|
||||
可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围。
|
||||
- 原版 release 与 localized release 双发布后的更丰富查询、分发和清理策略。
|
||||
- generic manifest 已冻结为当前支持类型的 V1;复杂 AssetBundle 结构仍需真实样本驱动,
|
||||
不在本项中扩展 Patch 格式。
|
||||
|
||||
所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变
|
||||
已发布的 `bat-resources/current` 或 `bat-localized/current`。
|
||||
@@ -106,14 +109,21 @@ format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务
|
||||
- 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。
|
||||
- 更多 schema 迁移、权限、并发和损坏恢复场景验证。
|
||||
|
||||
### G-011D:双 release 的完整查询与运维策略仍未完成
|
||||
### G-011D:双 release 查询、分发与安全清理
|
||||
|
||||
状态:**发布闭环完成,查询/清理扩展继续推进**
|
||||
状态:**V1 已完成**
|
||||
|
||||
官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和
|
||||
rollback,`localized.status` 能校验当前官方 release 与 patch manifest 的一致性。
|
||||
rollback。Rust `release.status` 提供统一 current/source/match、manifest 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-012:Translation Memory V1 已实现,扩展能力仍缺失
|
||||
|
||||
@@ -148,7 +158,8 @@ Rust `bat` 已提供独立项目级 `glossary.sqlite`:term/alias/recommended/a
|
||||
## 3. 后续推进顺序
|
||||
|
||||
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
|
||||
扩展体系。
|
||||
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
|
||||
|
||||
@@ -95,8 +95,8 @@
|
||||
| 组件 | 路径 | 状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
||||
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed 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` 走同一 contract;fake 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` |
|
||||
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed 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` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
|
||||
| 资源 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 固定 ok;manifest/sync 走 FFI |
|
||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
||||
@@ -134,7 +134,7 @@ make build-go-cli # 产出 bin/bat-go
|
||||
| 项 | 状态 |
|
||||
|---|---|
|
||||
| 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 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
|
||||
|
||||
---
|
||||
|
||||
@@ -7,30 +7,33 @@ use bat_core::{ApiError, ErrorCode};
|
||||
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
|
||||
use bat_infrastructure::{
|
||||
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
|
||||
apply_unityfs_text_asset_patch_file, changed_endpoint_urls,
|
||||
completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench,
|
||||
gc_orphan_staging_with_cas_root, get_translation_entry, lexical_absolute,
|
||||
apply_unityfs_text_asset_patch_file, build_release_list, build_release_status,
|
||||
changed_endpoint_urls, cleanup_releases, completed_worker_translation_workbench,
|
||||
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,
|
||||
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_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
|
||||
set_translation_checked_with_glossary_path, unset_translation, validate_output_root,
|
||||
validate_runtime_state_dir, validate_translation_workbench_with_glossary_path,
|
||||
write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
|
||||
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
|
||||
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy,
|
||||
select_release_distribution, set_translation, set_translation_checked_with_glossary_path,
|
||||
unset_translation, validate_output_root, validate_runtime_state_dir,
|
||||
validate_translation_workbench_with_glossary_path, write_file_atomic,
|
||||
write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig,
|
||||
LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
|
||||
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
|
||||
OfficialParseConfig, OfficialResourceHashVerification, OfficialResourceVerification,
|
||||
OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery,
|
||||
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||
OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams,
|
||||
PatchApplyReport, ReleaseFlowStatusCode, RepackReport, SqliteResourceRepository,
|
||||
SqliteTranslationTaskRepository, TranslationProviderKind, TranslationTaskStatus,
|
||||
TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport,
|
||||
UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE,
|
||||
DEFAULT_TRANSLATION_CONCURRENCY, DEFAULT_TRANSLATION_LEASE_SECONDS,
|
||||
DEFAULT_TRANSLATION_MAX_ATTEMPTS, DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK,
|
||||
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||
PatchApplyReport, ReleaseCleanupParams, ReleaseDistributionParams, ReleaseFlowStatusCode,
|
||||
ReleaseListParams, RepackReport, SqliteResourceRepository, SqliteTranslationTaskRepository,
|
||||
TranslationProviderKind, TranslationTaskStatus, TranslationWorkerConfig,
|
||||
UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams,
|
||||
UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, DEFAULT_TRANSLATION_CONCURRENCY,
|
||||
DEFAULT_TRANSLATION_LEASE_SECONDS, DEFAULT_TRANSLATION_MAX_ATTEMPTS,
|
||||
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_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MAX_TRANSLATION_CONCURRENCY,
|
||||
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_PUBLISH: &str = "localized.publish";
|
||||
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_VERSIONS: &str = "catalog.versions";
|
||||
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
|
||||
@@ -2371,6 +2378,94 @@ fn dispatch_rpc_method(
|
||||
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,
|
||||
¶ms,
|
||||
&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,
|
||||
¶ms,
|
||||
&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(),
|
||||
¶ms,
|
||||
&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(
|
||||
request_id,
|
||||
"catalog.status",
|
||||
@@ -3281,7 +3376,12 @@ fn build_localized_status_report(
|
||||
let mut patch_manifest_path = None;
|
||||
let mut patch_manifest_available = 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_target_version = 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 let Some(release_id) = localized_state.current_release_id.as_deref() {
|
||||
let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id);
|
||||
current_points_to_published_version =
|
||||
localized_current_points_to(¤t_path, &candidate);
|
||||
let manifest_path = candidate.join(LOCALIZED_PATCH_MANIFEST_FILE);
|
||||
patch_manifest_path = Some(manifest_path);
|
||||
if let Some(manifest) = read_localized_patch_manifest_at(&candidate)? {
|
||||
patch_manifest_available = true;
|
||||
patch_manifest_matches_release = official_version_id
|
||||
.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"
|
||||
let official_id_is_safe = official_version_id
|
||||
.as_deref()
|
||||
.is_some_and(is_safe_release_id);
|
||||
if !is_safe_release_id(release_id) || !official_id_is_safe {
|
||||
let diagnostic = if !is_safe_release_id(release_id) {
|
||||
format!("localized current release identity 不安全:{release_id}")
|
||||
} else {
|
||||
format!(
|
||||
"official current release identity 不安全:{}",
|
||||
official_version_id.as_deref().unwrap_or_default()
|
||||
)
|
||||
};
|
||||
localized_release_status = "degraded";
|
||||
flow_status_code = ReleaseFlowStatusCode::LocalizedDegraded;
|
||||
patch_manifest_contract_status = Some("invalid".to_string());
|
||||
patch_manifest_integrity_status = Some("invalid".to_string());
|
||||
artifact_integrity_status = Some("invalid".to_string());
|
||||
artifact_integrity_error = Some(diagnostic.clone());
|
||||
artifact_integrity_diagnostics.push(diagnostic);
|
||||
} else {
|
||||
let candidate = localized_root.join(LOCALIZED_VERSIONS_DIR).join(release_id);
|
||||
current_points_to_published_version =
|
||||
localized_current_points_to(¤t_path, &candidate);
|
||||
let manifest_path = candidate.join(LOCALIZED_PATCH_MANIFEST_FILE);
|
||||
patch_manifest_path = Some(manifest_path.clone());
|
||||
match read_localized_patch_manifest_at(&candidate) {
|
||||
Ok(Some(manifest)) => {
|
||||
patch_manifest_available = true;
|
||||
patch_manifest_matches_release = official_version_id
|
||||
.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();
|
||||
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);
|
||||
patch_operation_count = Some(operation_count);
|
||||
patch_manifest_contract_status = Some(
|
||||
if bat_patch::validate_patch_manifest(generic_manifest).is_ok()
|
||||
{
|
||||
"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);
|
||||
patch_manifest_integrity_status = Some(
|
||||
if bat_patch::validate_patch_manifest(generic_manifest).is_ok() {
|
||||
"valid"
|
||||
} else {
|
||||
"invalid"
|
||||
},
|
||||
);
|
||||
patch_manifest_matches_release &= generic_manifest.source_version
|
||||
== manifest.official_release_id
|
||||
&& generic_manifest.target_version == manifest.localized_release_id;
|
||||
} else {
|
||||
patch_manifest_integrity_status = Some("legacy");
|
||||
Ok(None) => {
|
||||
patch_manifest_contract_status = Some("missing".to_string());
|
||||
patch_manifest_integrity_status = Some("missing".to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
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());
|
||||
artifact_integrity_error = Some(error.to_string());
|
||||
artifact_integrity_diagnostics.push(error.to_string());
|
||||
}
|
||||
}
|
||||
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_available": patch_manifest_available,
|
||||
"patch_manifest_matches_release": patch_manifest_matches_release,
|
||||
"patch_manifest_contract_status": patch_manifest_contract_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_target_version": patch_manifest_target_version,
|
||||
"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
|
||||
}
|
||||
|
||||
fn is_safe_release_id(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value != "."
|
||||
&& value != ".."
|
||||
&& !value.contains('/')
|
||||
&& !value.contains('\\')
|
||||
&& !value.contains(':')
|
||||
&& !value.contains('\0')
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_json_rpc_response(
|
||||
stream: &mut UnixStream,
|
||||
|
||||
@@ -4226,6 +4226,75 @@ fn dispatch_catalog_versions_lists_history() {
|
||||
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]
|
||||
fn dispatch_catalog_diff_reports_bundle_change() {
|
||||
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 state_dir = temp.path().join("state");
|
||||
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_version = localized_root
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
@@ -5097,6 +5166,8 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
||||
localized_root.join(LOCALIZED_CURRENT_LINK),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(official_version.join("data.bin"), b"official").unwrap();
|
||||
fs::write(localized_version.join("data.bin"), b"localized").unwrap();
|
||||
fs::write(
|
||||
localized_root.join(LOCALIZED_VERSION_STATE_FILE),
|
||||
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(),
|
||||
localized_release_id: "v-current".to_string(),
|
||||
generated_unix_seconds: 124,
|
||||
file_count: 0,
|
||||
file_count: 1,
|
||||
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,
|
||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||
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"]["patch_manifest_available"], 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"]["translation_workflow_status"],
|
||||
@@ -5162,6 +5242,23 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
||||
value["data"]["published_version_path"].as_str().unwrap(),
|
||||
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]
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod official_update;
|
||||
pub mod patch_ops;
|
||||
pub mod path_security;
|
||||
pub mod release_flow;
|
||||
pub mod release_ops;
|
||||
pub mod resources;
|
||||
pub mod translation_memory;
|
||||
pub mod translation_tasks;
|
||||
@@ -53,15 +54,16 @@ pub use import::{
|
||||
ResourceImportService,
|
||||
};
|
||||
pub use localized_patch::{
|
||||
inspect_localized_release_artifact, inspect_localized_release_artifact_at,
|
||||
mark_localized_manual_proofreading, read_localized_patch_manifest_at,
|
||||
read_localized_version_state, write_localized_version_state, LocalizedFieldPatch,
|
||||
LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity,
|
||||
LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata,
|
||||
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService,
|
||||
LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch,
|
||||
LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
|
||||
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR,
|
||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||
read_localized_version_state, write_localized_version_state, LocalizedArtifactIntegrityReport,
|
||||
LocalizedFieldPatch, LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput,
|
||||
LocalizedPatchIntegrity, LocalizedPatchManifest, LocalizedPatchOperation,
|
||||
LocalizedPatchOperationMetadata, LocalizedPatchReport, LocalizedPatchRollbackInfo,
|
||||
LocalizedPatchService, LocalizedRollbackReport, LocalizedStringFieldPatch,
|
||||
LocalizedTextAssetPatch, LocalizedTranslationWorkflowReport, LocalizedVersionState,
|
||||
LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION,
|
||||
LOCALIZED_STAGING_DIR, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
|
||||
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,
|
||||
};
|
||||
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 translation_memory::{
|
||||
translation_memory_context, translation_memory_repository_path,
|
||||
|
||||
@@ -6,7 +6,7 @@ use bat_assetbundle::{
|
||||
UnitySerializedValue,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@@ -446,6 +446,30 @@ pub struct LocalizedPatchIntegrity {
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedPatchManifest {
|
||||
@@ -1932,6 +1956,292 @@ fn verify_published_localized_release(
|
||||
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(¤t_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 未指向 release:current={} 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(
|
||||
official_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(release_id)),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -2567,6 +2880,8 @@ fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> {
|
||||
if release_id.is_empty()
|
||||
|| release_id.contains('/')
|
||||
|| release_id.contains('\\')
|
||||
|| release_id.contains(':')
|
||||
|| release_id.contains('\0')
|
||||
|| release_id == "."
|
||||
|| release_id == ".."
|
||||
{
|
||||
@@ -3440,6 +3755,26 @@ mod tests {
|
||||
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)]
|
||||
#[test]
|
||||
fn failed_patch_publish_cleans_staging_and_unpublished_version() {
|
||||
|
||||
@@ -78,6 +78,10 @@ pub enum ReleaseFlowStatusCode {
|
||||
/// A localized release is published and matches the current official release.
|
||||
#[serde(rename = "localized.published")]
|
||||
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.
|
||||
#[serde(rename = "distribution.blocked")]
|
||||
DistributionBlocked,
|
||||
@@ -113,6 +117,7 @@ impl ReleaseFlowStatusCode {
|
||||
Self::LocalizedPending => "localized.pending",
|
||||
Self::LocalizedStale => "localized.stale",
|
||||
Self::LocalizedPublished => "localized.published",
|
||||
Self::LocalizedDegraded => "localized.degraded",
|
||||
Self::DistributionBlocked => "distribution.blocked",
|
||||
Self::DistributionReady => "distribution.ready",
|
||||
}
|
||||
@@ -146,6 +151,7 @@ impl ReleaseFlowStatusCode {
|
||||
Self::LocalizedPending => "pending",
|
||||
Self::LocalizedStale => "stale",
|
||||
Self::LocalizedPublished => "published",
|
||||
Self::LocalizedDegraded => "degraded",
|
||||
Self::DistributionBlocked => "blocked",
|
||||
Self::DistributionReady => "ready",
|
||||
}
|
||||
@@ -177,6 +183,7 @@ impl ReleaseFlowStatusCode {
|
||||
"localized.pending" => Self::LocalizedPending,
|
||||
"localized.stale" => Self::LocalizedStale,
|
||||
"localized.published" => Self::LocalizedPublished,
|
||||
"localized.degraded" => Self::LocalizedDegraded,
|
||||
"distribution.blocked" => Self::DistributionBlocked,
|
||||
"distribution.ready" => Self::DistributionReady,
|
||||
_ => return None,
|
||||
@@ -208,7 +215,8 @@ impl ReleaseFlowStatusCode {
|
||||
Self::LocalizedBlockedOfficial
|
||||
| Self::LocalizedPending
|
||||
| Self::LocalizedStale
|
||||
| Self::LocalizedPublished => "localized_publish",
|
||||
| Self::LocalizedPublished
|
||||
| Self::LocalizedDegraded => "localized_publish",
|
||||
Self::DistributionBlocked | Self::DistributionReady => "distribution",
|
||||
}
|
||||
}
|
||||
@@ -300,6 +308,7 @@ mod tests {
|
||||
ReleaseFlowStatusCode::LocalizedPending,
|
||||
ReleaseFlowStatusCode::LocalizedStale,
|
||||
ReleaseFlowStatusCode::LocalizedPublished,
|
||||
ReleaseFlowStatusCode::LocalizedDegraded,
|
||||
ReleaseFlowStatusCode::DistributionBlocked,
|
||||
ReleaseFlowStatusCode::DistributionReady,
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
||||
"/admin/control/translation-glossary-delete",
|
||||
"/admin/control/localized-publish",
|
||||
"/admin/control/localized-rollback",
|
||||
"/admin/control/release-cleanup",
|
||||
},
|
||||
}
|
||||
if r.Method == http.MethodHead {
|
||||
@@ -144,6 +145,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleAdminLocalizedRollback(w, r)
|
||||
return
|
||||
}
|
||||
if action == "release-cleanup" {
|
||||
s.handleAdminReleaseCleanup(w, r)
|
||||
return
|
||||
}
|
||||
request, ok := decodeAdminControlRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
@@ -619,6 +624,34 @@ func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Reque
|
||||
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, ¶ms) {
|
||||
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) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||
|
||||
+36
-9
@@ -14,11 +14,6 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
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)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
@@ -27,15 +22,47 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var entry ResourceEntry
|
||||
var hasEntry bool
|
||||
if s.cfg.RequireIndexed {
|
||||
entry, hasEntry = idx.Lookup(rel)
|
||||
resourceRoot := ""
|
||||
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 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
abs, err := ResolveUnderRoot(idx.ResourceRoot, rel, true)
|
||||
abs, err := ResolveUnderRoot(resourceRoot, rel, true)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -45,7 +72,7 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
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 {
|
||||
http.Error(w, "size mismatch with release index", http.StatusConflict)
|
||||
return
|
||||
|
||||
+73
-1
@@ -79,6 +79,52 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
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:
|
||||
get:
|
||||
summary: Paginated resource manifest entries
|
||||
@@ -551,6 +597,32 @@ paths:
|
||||
description: Missing or invalid admin token.
|
||||
"503":
|
||||
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}:
|
||||
post:
|
||||
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||
@@ -560,7 +632,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
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:
|
||||
required: false
|
||||
content:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,15 @@ type LocalizedBackend interface {
|
||||
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.
|
||||
type RPCClient struct {
|
||||
Client *backendrpc.Client
|
||||
@@ -253,6 +262,22 @@ func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.Loca
|
||||
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) {
|
||||
return r.Client.ParseStatus(ctx)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("/v1/bootstrap", s.handleBootstrap)
|
||||
mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap)
|
||||
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/server-info", s.handleServerInfoDebug)
|
||||
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/diagnose", s.handleAdminGlossaryDiagnose)
|
||||
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/", s.handleAdminIndex)
|
||||
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
||||
@@ -132,6 +136,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
"/v1/bootstrap",
|
||||
"/v1/launcher/bootstrap",
|
||||
"/v1/release",
|
||||
"/v1/releases",
|
||||
"/v1/distribution",
|
||||
"/v1/resources",
|
||||
"/v1/server-info",
|
||||
"/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/diagnose",
|
||||
"/admin/translation/status",
|
||||
"/admin/releases/status",
|
||||
"/admin/releases",
|
||||
"/admin/control/{action}",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -182,6 +182,117 @@ type pageParam struct {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
var out TaskAccepted
|
||||
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
|
||||
|
||||
@@ -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, ¶ms); 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) {
|
||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||
if req.Method != "daemon.restart" {
|
||||
|
||||
Reference in New Issue
Block a user