mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 10:54:55 +08:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff1adb91ee
|
||
|
|
e486f1aaaa
|
||
|
|
7f7d757f15
|
||
|
|
99355effe4
|
||
|
|
13b0bd5b45
|
||
|
|
c17904ee1c
|
||
|
|
32fc64fa83
|
||
|
|
37d49c9793
|
||
|
|
5bae90cb14
|
||
|
|
786b739f99
|
||
|
|
f2c20367a6
|
||
|
|
30d1cd77e8
|
||
|
|
8d57a63697
|
||
|
|
8a77502272
|
||
|
|
69b6e36bf0
|
||
|
|
68c6c91b1e
|
||
|
|
0275a890bc
|
||
|
|
94483ff14d
|
@@ -1,247 +0,0 @@
|
|||||||
# Gitea Actions workflow for the Rust workspace.
|
|
||||||
# Self-hosted runner friendly.
|
|
||||||
# Does not use external GitHub Actions.
|
|
||||||
|
|
||||||
name: bat-rust
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "**"
|
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
rust:
|
|
||||||
name: Build and test Rust
|
|
||||||
runs-on: linux
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUST_BACKTRACE: 1
|
|
||||||
BAT_SKIP_ENV_FILE: "1"
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
|
|
||||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
|
||||||
: "${GITHUB_SHA:?GITHUB_SHA is required}"
|
|
||||||
|
|
||||||
repo_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
|
|
||||||
|
|
||||||
if [ -d .git ]; then
|
|
||||||
git remote set-url origin "${repo_url}"
|
|
||||||
else
|
|
||||||
git init .
|
|
||||||
git remote add origin "${repo_url}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ref="${GITHUB_REF:-${GITHUB_SHA}}"
|
|
||||||
|
|
||||||
git fetch \
|
|
||||||
--no-tags \
|
|
||||||
--depth=1 \
|
|
||||||
origin \
|
|
||||||
"${ref}" \
|
|
||||||
|| git fetch \
|
|
||||||
--no-tags \
|
|
||||||
--depth=1 \
|
|
||||||
origin \
|
|
||||||
"${GITHUB_SHA}"
|
|
||||||
|
|
||||||
git checkout --force --detach FETCH_HEAD
|
|
||||||
|
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
|
|
||||||
- name: Show tool versions
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
|
|
||||||
command -v git
|
|
||||||
command -v rustc
|
|
||||||
command -v cargo
|
|
||||||
|
|
||||||
rustc --version
|
|
||||||
cargo --version
|
|
||||||
rustfmt --version
|
|
||||||
cargo clippy --version
|
|
||||||
|
|
||||||
|
|
||||||
- name: Check formatting
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
cargo fmt --all -- --check
|
|
||||||
|
|
||||||
|
|
||||||
- name: Check workspace
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
cargo check --workspace --locked
|
|
||||||
|
|
||||||
|
|
||||||
- name: Build workspace
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
|
|
||||||
cargo build \
|
|
||||||
--workspace \
|
|
||||||
--release \
|
|
||||||
--locked
|
|
||||||
|
|
||||||
|
|
||||||
- name: Run clippy
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
|
|
||||||
cargo clippy \
|
|
||||||
--workspace \
|
|
||||||
--all-targets \
|
|
||||||
--locked \
|
|
||||||
-- -D warnings
|
|
||||||
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
|
|
||||||
cargo test \
|
|
||||||
--workspace \
|
|
||||||
--locked
|
|
||||||
|
|
||||||
|
|
||||||
- name: Package binary
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
mkdir -p artifact
|
|
||||||
|
|
||||||
cp target/release/bat artifact/
|
|
||||||
|
|
||||||
tar \
|
|
||||||
-czf \
|
|
||||||
bat-linux-x86_64.tar.gz \
|
|
||||||
-C artifact \
|
|
||||||
bat
|
|
||||||
|
|
||||||
sha256sum \
|
|
||||||
bat-linux-x86_64.tar.gz \
|
|
||||||
> bat-linux-x86_64.sha256
|
|
||||||
|
|
||||||
|
|
||||||
- name: Prepare artifact
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
mkdir upload
|
|
||||||
|
|
||||||
cp bat-linux-x86_64.tar.gz upload/
|
|
||||||
cp bat-linux-x86_64.sha256 upload/
|
|
||||||
|
|
||||||
cd upload
|
|
||||||
|
|
||||||
zip -q \
|
|
||||||
../bat-linux-x86_64.zip \
|
|
||||||
*
|
|
||||||
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
ls -lh bat-linux-x86_64.zip
|
|
||||||
|
|
||||||
go-api:
|
|
||||||
name: Build and test Go API
|
|
||||||
runs-on: linux
|
|
||||||
|
|
||||||
env:
|
|
||||||
GOCACHE: /tmp/bat-go-cache
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
: "${GITHUB_SERVER_URL:?GITHUB_SERVER_URL is required}"
|
|
||||||
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
|
||||||
: "${GITHUB_SHA:?GITHUB_SHA is required}"
|
|
||||||
|
|
||||||
repo_url="${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
|
|
||||||
|
|
||||||
if [ -d .git ]; then
|
|
||||||
git remote set-url origin "${repo_url}"
|
|
||||||
else
|
|
||||||
git init .
|
|
||||||
git remote add origin "${repo_url}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ref="${GITHUB_REF:-${GITHUB_SHA}}"
|
|
||||||
|
|
||||||
git fetch \
|
|
||||||
--no-tags \
|
|
||||||
--depth=1 \
|
|
||||||
origin \
|
|
||||||
"${ref}" \
|
|
||||||
|| git fetch \
|
|
||||||
--no-tags \
|
|
||||||
--depth=1 \
|
|
||||||
origin \
|
|
||||||
"${GITHUB_SHA}"
|
|
||||||
|
|
||||||
git checkout --force --detach FETCH_HEAD
|
|
||||||
|
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
- name: Show Go tool version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
|
|
||||||
command -v go
|
|
||||||
go version
|
|
||||||
|
|
||||||
- name: Run Go API tests
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
make test-go-api
|
|
||||||
|
|
||||||
- name: Run Go API vet
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
|
||||||
|
|
||||||
- name: Build Go API
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
source /var/lib/act_runner/env.sh
|
|
||||||
go build -o /tmp/bat-api ./cmd/bat-api
|
|
||||||
|
|
||||||
- name: Run documentation status gate
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
make check-docs
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本文件用于约束在 BlueArchiveToolkit 中工作的 AI Agent。
|
本文件用于约束在 BlueArchiveToolkit 中工作的 AI Agent。
|
||||||
|
|
||||||
具体开发进度看 `CURRENT_STATUS.md`,开发计划看 `PROJECT_PLAN.md`,当前缺口看 `docs/reports/CURRENT_GAPS.md`。这里不记录具体任务和阶段待办。
|
具体开发进度看 `CURRENT_STATUS.md`,开发计划看 `PROJECT_PLAN.md`,当前能力缺口看 `docs/reports/CURRENT_GAPS.md`,具体工程任务、优先级和依赖看根目录 `TODO.md`。这里不记录具体任务和阶段待办。
|
||||||
|
|
||||||
## 基本要求
|
## 基本要求
|
||||||
|
|
||||||
@@ -14,6 +14,18 @@ BlueArchiveToolkit 是长期维护项目。不要为了尽快完成当前任务
|
|||||||
|
|
||||||
如果发现用户提出的方案、现有代码或文档本身有问题,直接指出。不要为了迎合要求保留明显不合理的设计。
|
如果发现用户提出的方案、现有代码或文档本身有问题,直接指出。不要为了迎合要求保留明显不合理的设计。
|
||||||
|
|
||||||
|
## 工程修改原则
|
||||||
|
|
||||||
|
BlueArchiveToolkit 不以“最小修复”为工程目标。不要为了让单个 testcase 通过、暂时消除表面症状或缩小 diff,而留下已经能够确认的同根因问题。
|
||||||
|
|
||||||
|
处理问题时优先保证长期可维护性、可用性、安全性、明确契约、恢复能力和回归覆盖。进入一个工程边界后,应根据实际相关性检查正常路径、异常路径、并发、重试、恢复、兼容、持久化和资源限制,并把属于同一 root cause 或同一 contract 的问题完整收口。
|
||||||
|
|
||||||
|
这不意味着无边界重构。不要为了架构形式、代码行数或“以后也许会用”扩大修改范围;与当前 contract 无关的问题应记录到 `TODO.md`,留给后续独立处理。
|
||||||
|
|
||||||
|
跨模块问题必须沿真实状态所有权和调用链检查。例如 Rust 状态经 RPC 暴露给 Go,再由 HTTP 或 Web 消费时,不能只修改其中一层而让其他层继续保持矛盾语义。
|
||||||
|
|
||||||
|
持久化和状态机修改应考虑 schema/version、transaction、crash consistency、retry、recovery 与兼容读取;解析器、压缩包和其他外部输入应考虑 size/count/depth 等资源边界以及 malformed input 的确定性失败。
|
||||||
|
|
||||||
## 以什么为准
|
## 以什么为准
|
||||||
|
|
||||||
仓库里有不少历史文档,不能混着看。
|
仓库里有不少历史文档,不能混着看。
|
||||||
@@ -71,6 +83,96 @@ Go `bat-api` 是资源 bootstrap、只读分发和管理入口。它通过 `bat.
|
|||||||
|
|
||||||
不要静默改变已有字段的含义。确实需要破坏性修改时,先考虑版本号、迁移或兼容读取。
|
不要静默改变已有字段的含义。确实需要破坏性修改时,先考虑版本号、迁移或兼容读取。
|
||||||
|
|
||||||
|
## Dashboard 开发与设计
|
||||||
|
|
||||||
|
BlueArchiveToolkit 包含两个面向不同使用者的 Dashboard:用户 Dashboard 与运营 Dashboard。两者属于同一产品,应共享基础视觉语言、组件风格和交互一致性,但不得因为复用组件而混淆产品职责、信息层级或权限边界。
|
||||||
|
|
||||||
|
涉及 Dashboard、Web UI、页面布局、视觉样式、组件设计或交互体验的任务,在开始设计和修改前必须阅读仓库根目录的 `DESIGN.md`。
|
||||||
|
|
||||||
|
`DESIGN.md` 是 Dashboard 的主要视觉参考与设计灵感来源。应理解并延续其中的色彩关系、排版、空间、边框、层级、组件形态和交互气质,但不得机械复制其来源产品的页面结构、品牌内容或不适合 BlueArchiveToolkit 的设计。实际页面的信息架构始终由 BlueArchiveToolkit 当前功能、真实数据结构和使用场景决定。
|
||||||
|
|
||||||
|
### 用户 Dashboard
|
||||||
|
|
||||||
|
用户 Dashboard 面向普通 BlueArchiveToolkit 用户,目标是以尽可能低的认知负担完成与汉化相关的用户操作。
|
||||||
|
|
||||||
|
当前用户可控制的核心能力仅包括:
|
||||||
|
|
||||||
|
* 文字汉化是否启用;
|
||||||
|
* 图像汉化是否启用。
|
||||||
|
|
||||||
|
用户端可以展示与这些操作直接相关的必要信息,例如汉化状态、当前可用版本、更新状态、操作反馈或用户需要处理的异常,但不得暴露内部运维实现。
|
||||||
|
|
||||||
|
除非未来产品需求明确改变,否则用户 Dashboard 不应展示或要求用户理解:
|
||||||
|
|
||||||
|
* `bat` / `bat-api` 内部状态;
|
||||||
|
* RPC、daemon、worker;
|
||||||
|
* CAS;
|
||||||
|
* Provider / provider run;
|
||||||
|
* Translation Memory 内部记录;
|
||||||
|
* translation task;
|
||||||
|
* Parser;
|
||||||
|
* official/localized release 的内部实现细节;
|
||||||
|
* 服务端日志、内部错误栈和运维指标。
|
||||||
|
|
||||||
|
用户端优先保证清晰、简洁、可信和易操作。不要为了表现“Dashboard 感”堆积 KPI 卡片、图表、技术指标或无实际用途的信息。
|
||||||
|
|
||||||
|
### 运营 Dashboard
|
||||||
|
|
||||||
|
运营 Dashboard 面向项目运营和维护者,用于观察和管理 BlueArchiveToolkit 的真实运行状态。
|
||||||
|
|
||||||
|
运营端可以根据当前后端实际提供的 contract 展示和组织:
|
||||||
|
|
||||||
|
* `bat` 与 `bat-api` 运行状态;
|
||||||
|
* official resource / official release;
|
||||||
|
* localized resource / localized release;
|
||||||
|
* 资源同步与更新状态;
|
||||||
|
* Translation / Translation Memory;
|
||||||
|
* Provider 与 worker;
|
||||||
|
* task / job;
|
||||||
|
* daemon/runtime;
|
||||||
|
* CAS;
|
||||||
|
* 错误、诊断与日志;
|
||||||
|
* 配置和必要的运营操作。
|
||||||
|
|
||||||
|
运营 Dashboard 是高信息密度的 developer/operations interface。优先使用结构化列表、表格、紧凑状态信息、清晰的主次层级和按需 drill-down,而不是将所有数据做成大型 Card。
|
||||||
|
|
||||||
|
首页应帮助运营者快速回答“系统是否正常、哪里需要处理、最近发生了什么”,而不是简单罗列所有可获得的指标。
|
||||||
|
|
||||||
|
### 两个 Dashboard 的关系
|
||||||
|
|
||||||
|
两个 Dashboard 应共享:
|
||||||
|
|
||||||
|
* 基础 Design Token;
|
||||||
|
* Typography;
|
||||||
|
* Color System;
|
||||||
|
* Button、Input、Switch、Dialog 等基础组件;
|
||||||
|
* Loading、Empty、Error、Warning、Success 等状态语言;
|
||||||
|
* Motion 与交互反馈原则;
|
||||||
|
* 品牌识别。
|
||||||
|
|
||||||
|
但可以拥有不同的:
|
||||||
|
|
||||||
|
* Navigation;
|
||||||
|
* 页面结构;
|
||||||
|
* 信息密度;
|
||||||
|
* 内容层级;
|
||||||
|
* 默认组件尺寸;
|
||||||
|
* 数据展示方式。
|
||||||
|
|
||||||
|
不要把运营 Dashboard 简单裁剪几个菜单后作为用户 Dashboard,也不要为了用户端的简洁限制运营端所需的信息密度。
|
||||||
|
|
||||||
|
### 设计实现原则
|
||||||
|
|
||||||
|
Dashboard 设计必须以真实接口和真实状态为依据。不得为了视觉完整性伪造后端不存在的数据、指标、趋势、操作或状态。
|
||||||
|
|
||||||
|
如果设计需要当前 API/RPC 尚未提供的信息,应明确指出缺失 contract,而不是在前端维护第二份业务状态或通过猜测拼接数据。
|
||||||
|
|
||||||
|
优先复用项目现有前端组件和设计基础。引入新组件模式前先确认现有组件无法合理满足需求,避免同一项目逐步形成多套 Card、Table、Badge、Button 或状态展示体系。
|
||||||
|
|
||||||
|
`DESIGN.md` 是视觉方向,不高于项目稳定架构与产品事实。发生冲突时按以下优先级处理:
|
||||||
|
|
||||||
|
`AGENTS.md` 与稳定产品/接口契约 > 当前明确任务需求 > `DESIGN.md` > Agent 自身设计偏好。
|
||||||
|
|
||||||
## 代码修改
|
## 代码修改
|
||||||
|
|
||||||
先弄清楚代码为什么放在当前位置,再决定是继续修改还是拆模块。
|
先弄清楚代码为什么放在当前位置,再决定是继续修改还是拆模块。
|
||||||
@@ -86,9 +188,19 @@ Go `bat-api` 是资源 bootstrap、只读分发和管理入口。它通过 `bat.
|
|||||||
* 无说明的硬编码;
|
* 无说明的硬编码;
|
||||||
* 魔法数字;
|
* 魔法数字;
|
||||||
* 假实现、空实现冒充完成功能;
|
* 假实现、空实现冒充完成功能;
|
||||||
* 用 `TODO` / `FIXME` 代替正式的缺口记录。
|
* 用代码内 `TODO` / `FIXME` 代替根目录 `TODO.md`、`CURRENT_GAPS.md` 或其他正式缺口记录。
|
||||||
|
|
||||||
如果当前任务确实无法完成某一部分,应明确限制实现范围,并把剩余问题记录到对应的状态、缺口或 Issue 中。
|
如果当前任务确实无法完成某一部分,应明确限制实现范围;具体后续工程任务记录到根目录 `TODO.md`,能力缺口同步到 `CURRENT_GAPS.md`,需要外部协作时再使用 Issue。
|
||||||
|
|
||||||
|
## TODO 任务治理
|
||||||
|
|
||||||
|
根目录 `TODO.md` 是具体工程任务、优先级、依赖关系和完成条件的仓库内任务账本。开始具体开发前,应读取与当前工作相关的 TODO;完成任务或发现独立新问题后,应同步更新其状态和依赖。
|
||||||
|
|
||||||
|
`TODO.md` 不是当前实现事实来源。源码和测试、`CURRENT_STATUS.md`、专项 current-status 文档以及稳定 contract 的优先级高于 TODO 描述。若 TODO 与当前实现冲突,应先核对事实并更新过时 TODO,不要按照旧条目重新实现已经完成的能力。
|
||||||
|
|
||||||
|
属于当前任务同一 root cause 或同一 contract 的已确认问题,不得仅为了缩小 patch 而登记 TODO 后绕过;应在当前工程边界内一起收口。明显独立的问题应记录到 `TODO.md`,避免当前修改无限扩张。
|
||||||
|
|
||||||
|
`docs/reports/CURRENT_GAPS.md` 用于记录产品或工程能力层面的当前缺口;`PROJECT_PLAN.md` 用于长期路线;`TODO.md` 用于可执行任务追踪。不要把这些职责混在一起。
|
||||||
|
|
||||||
## 文件、网络和发布安全
|
## 文件、网络和发布安全
|
||||||
|
|
||||||
@@ -145,7 +257,9 @@ make check-docs
|
|||||||
|
|
||||||
不要把具体任务、临时优先级或某次实现方案写进本文件。
|
不要把具体任务、临时优先级或某次实现方案写进本文件。
|
||||||
|
|
||||||
新的长期架构决策应该进入 ADR 或对应架构文档;开发路线进入 `PROJECT_PLAN.md`;实际进度进入 `CURRENT_STATUS.md`;未完成内容进入 `CURRENT_GAPS.md` 或 Issue。
|
新的长期架构决策应该进入 ADR 或对应架构文档;开发路线进入 `PROJECT_PLAN.md`;实际进度进入 `CURRENT_STATUS.md`;能力缺口进入 `docs/reports/CURRENT_GAPS.md`;具体工程任务、依赖和完成条件进入根目录 `TODO.md`;需要外部协作时再使用 Issue。
|
||||||
|
|
||||||
|
Dashboard 的视觉方向与设计灵感进入根目录 `DESIGN.md`;Dashboard 的产品职责、状态所有权和接口事实仍以本文件与稳定产品/接口契约为准。
|
||||||
|
|
||||||
## 工作方式
|
## 工作方式
|
||||||
|
|
||||||
|
|||||||
+74
-23
@@ -1,6 +1,6 @@
|
|||||||
# BlueArchiveToolkit 当前工作区状态
|
# BlueArchiveToolkit 当前工作区状态
|
||||||
|
|
||||||
- **更新时间**:2026-09-06
|
- **更新时间**:2026-09-13
|
||||||
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
- **状态来源**:本地工作区盘点、代码验证和最新提交
|
||||||
- **状态分支**:`experiment`
|
- **状态分支**:`experiment`
|
||||||
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
|
||||||
@@ -30,14 +30,61 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
|||||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite,数据库不存在时返回 `available=false` 且不会创建空库;`bat doctor cas` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计,不会因诊断创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
|
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,release、平台、bundle path 和常用数组 metadata 过滤已下推到 SQLite,数据库不存在时返回 `available=false` 且不会创建空库;`bat doctor cas` 可只读检查既有 CAS 根目录、对象目录、元数据库文件和对象统计,不会因诊断创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。
|
||||||
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units` 和 `parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `config.toml.example` 配置模板(`0600`),`config.toml` 存在且 Unix 权限为 `0600` 或更严格时读取并使用它;`config.toml` 不存在时仅保留模板,不自动读取 example,运行时继续使用环境变量和内置默认值。优先级为命令行参数 > 进程环境变量 > `config.toml` > 内置默认值,`BAT_SKIP_ENV_FILE` 已废弃且不再影响启动;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。官方同步报告还分别统计当前 manifest 复用、历史 release 复用、CAS 复用、网络传输字节和复用回退诊断,下载事件状态使用 `release_reused`、`cas_reused`、`downloaded` 等稳定值。
|
14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units` 和 `parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`bat` 首次启动会在二进制所在目录释放 `config.toml.example` 配置模板(`0600`),`config.toml` 存在且 Unix 权限为 `0600` 或更严格时读取并使用它;`config.toml` 不存在时仅保留模板,不自动读取 example,运行时继续使用环境变量和内置默认值。优先级为命令行参数 > 进程环境变量 > `config.toml` > 内置默认值,`BAT_SKIP_ENV_FILE` 已废弃且不再影响启动;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。官方同步报告还分别统计当前 manifest 复用、历史 release 复用、CAS 复用、网络传输字节和复用回退诊断,下载事件状态使用 `release_reused`、`cas_reused`、`downloaded` 等稳定值。
|
||||||
|
|
||||||
15. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。`translation.worker.run` 已接入 provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。`bat-api` 已提供内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该工作流只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。真实官方网络全量拉取 smoke 已固化,真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。
|
15. Rust `bat` 已提供 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、generic manifest 驱动的 Binary/JSON/Text/受支持 UnityFS 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。`translation.worker.run` 已接入 provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。`bat-api` 已提供内嵌 dashboard MVP,静态资产由 Go embed 暴露在 `/admin/dashboard/`,页面直接调用已有鉴权接口控制资源、调度、翻译、任务、日志、parse TextUnit 查询和 localized 发布/回滚。该工作流只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和完整 Web 协作后台仍是后续工作。真实官方网络全量拉取 smoke 已固化,真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。
|
||||||
|
|
||||||
当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`;跨 release 的 Translation Memory V1 位于 `<output>/translation-memory.sqlite`,不放在 `versions/<id>` 或 release task 库中;
|
当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`;跨 release 的 Translation Memory SQLite persistence schema V2 位于 `<output>/translation-memory.sqlite`,不放在 `versions/<id>` 或 release task 库中;
|
||||||
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
|
`translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整
|
||||||
job/unit/provider run 状态;`translation.memory.summary/query/confirm` 提供
|
job/unit/provider run 状态;`translation.memory.summary/query/confirm/conflicts/resolve_conflict`
|
||||||
Rust-owned TM 的摘要、source/context 查询和显式 trusted 确认,`bat-api` 仅作
|
提供 Rust-owned TM 的摘要、source/context 查询、显式 trusted 确认和冲突治理,
|
||||||
typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取
|
`bat-api` 仅作 typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取
|
||||||
任务,最终资源报告按 pull plan 顺序输出。
|
任务,最终资源报告按 pull plan 顺序输出。
|
||||||
|
项目级 Glossary 使用 Glossary domain/feature contract V1,由 SQLite persistence schema V2 承载,位于 `<output>/glossary.sqlite`,独立于 release task 和 TM;
|
||||||
|
Rust `bat` 持有 term/alias/recommended/allowed/category/priority、全局或
|
||||||
|
TextUnit scope、source history 和 approved review。worker、TM 复用、人工 task
|
||||||
|
结果和 workbench publish 都执行确定性 QA;blocking deviation 必须携带
|
||||||
|
稳定 `qa_identity` 以及 reviewer/reason/provenance 的显式 override,所有接受路径都会
|
||||||
|
按当前 QA 精确校验 identity。`translation.glossary.*` 已通过
|
||||||
|
`bat.sock` 暴露,Go `bat-api` 仅做鉴权 typed forwarding。
|
||||||
|
|
||||||
|
三个长期 SQLite owner 现在统一使用只读 schema preflight、精确 component
|
||||||
|
fingerprint 和 `BEGIN IMMEDIATE` writer transaction:Translation Tasks 从 V1
|
||||||
|
按显式 `v1 -> v2` step 迁移,当前版本为 V2;Translation Memory persistence schema
|
||||||
|
当前版本为 V2,Glossary domain/feature contract 为 V1、persistence schema 为 V2。
|
||||||
|
Translation Memory V2 正式建立
|
||||||
|
current Trusted 唯一性、显式 supersede、冲突只读诊断、resolve_conflict 和 audit event;
|
||||||
|
Glossary persistence schema V2 正式吸收历史上未升版本的
|
||||||
|
`glossary_term_deletions` schema drift:原始 V1-A 会在事务内创建 deletion audit
|
||||||
|
表,带 deletion audit 的 V1-B 只提升 bookkeeping version。future、未知或版本与结构
|
||||||
|
不一致的数据库在任何 schema/data mutation 前 fail closed;migration 失败会 rollback,
|
||||||
|
已知无 `schema_migrations` 表的历史 fingerprint 可安全补建版本表后重试。正式 schema
|
||||||
|
路径不再使用 `ensure_column` 隐式补列。
|
||||||
|
|
||||||
|
`localized.status` 现将 generic manifest schema/contract 与已发布 artifact integrity
|
||||||
|
分开报告;current、state 和 identity 存在但文件被截断或手工修改时返回
|
||||||
|
`localized.degraded`,只读检查不会自动回滚、删除或修复。双 release 的
|
||||||
|
`release.attestation` 已由 Rust 从 current、canonical versioned resource root、publication
|
||||||
|
anchor、manifest identity 和 verification generation/freshness 生成,供 Go current
|
||||||
|
readiness 使用;`resource.manifest` 请求必须绑定同一 generation,`release.status/list/distribution/cleanup`
|
||||||
|
仍由 Rust 从既有状态、manifest、文件系统和 CAS/reference 元数据统一生成,Go 仅 typed 转发。
|
||||||
|
CAS repository 的对象文件、引用计数
|
||||||
|
和 GC 通过跨进程操作锁协调,release-local CAS 引用以 `(ownership_id, ordinal)` ownership
|
||||||
|
记录幂等释放;新清单持久化 `ownership_id`,旧清单按 output-root scope、稳定 source
|
||||||
|
identity 和 generation-aware legacy cleanup path 迁移;localized publish/rollback 通过
|
||||||
|
output-root 单写者锁和事务日志恢复
|
||||||
|
current、version-state、version 目录,publish 只有最终 `verified` phase 才能恢复为已提交。
|
||||||
|
localized release 还写入实际 bytes/BLAKE3、完整 source mapping identity 和 destination
|
||||||
|
index 的 `localized-distribution-manifest.json`;official download manifest 同样持久化
|
||||||
|
canonical mapping identity 和 destination index。新 official release 在完整发布验证后
|
||||||
|
额外写入独立的 `official-distribution-publication.json`,关联 release ID、mapping
|
||||||
|
identity、manifest content identity 和 entry count。`release.distribution` 只选择
|
||||||
|
publication identity 与当前 official manifest 一致的 release;`destination=...` 时
|
||||||
|
比较 persisted identity、查目标索引并校验单文件,返回 exactly one entry 且不在分发热路径
|
||||||
|
执行完整 release audit;缺少 publication metadata 的 legacy release 仍可读和清理,但不
|
||||||
|
满足 distribution-ready。`release.cleanup execute` 与 official sync 共用同一个
|
||||||
|
`.official-sync.lock`,localized cleanup 继续使用 `.localized-release.lock`。legacy CAS
|
||||||
|
manifest 首次 cleanup 时按 output-root scope、release generation 和稳定 source identity
|
||||||
|
迁移;已有 basename ledger 的部分 cleanup 只有在 output-root 持久化的显式 compatibility
|
||||||
|
marker 存在时才保持兼容 key,无法证明归属时拒绝 cleanup;完成后同名新 generation 不再复用。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -88,7 +135,7 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成
|
|||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- 领域服务模块仍为空。
|
- 领域服务模块仍为空。
|
||||||
- Glossary、Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
|
- Provider、Patch、Manifest 等后续仓储/服务接口需要补齐。
|
||||||
- 公共错误模型需要与 CLI/API 错误码统一。
|
- 公共错误模型需要与 CLI/API 错误码统一。
|
||||||
|
|
||||||
### `bat-adapters`
|
### `bat-adapters`
|
||||||
@@ -106,7 +153,7 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成
|
|||||||
|
|
||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归;已有 TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 发布闭环,真实复杂版本差异、整体 AssetBundle 重打包和通用 Patch 仍未实现。
|
- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归。对当前真实/合成回归覆盖的结构,TextAsset、TypeTree string field、managed-reference string field 和语义字段已形成 parse→modify→rebuild→reparse 闭环,重建保留已识别压缩/对齐/目录形态并校验未修改对象/字段;整体任意 AssetBundle、未知结构和全部真实版本差异仍未实现。
|
||||||
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
||||||
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
||||||
|
|
||||||
@@ -145,17 +192,20 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成
|
|||||||
- `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。
|
- `OfficialResourcePullService`:官方 URL 拒绝策略、目标路径映射、下载 manifest、下载 quarantine、`.part` 续传、curl 代理配置、403/404/5xx 分类重试、ZIP 结构校验、官方 seed `.hash` 校验、本地全量 verify。
|
||||||
- `OfficialUpdateService`:官方 metadata auto-discover、bootstrap cache、snapshot diff、marker diff、本地 audit/repair、失败 staging 恢复。
|
- `OfficialUpdateService`:官方 metadata auto-discover、bootstrap cache、snapshot diff、marker diff、本地 audit/repair、失败 staging 恢复。
|
||||||
- `bat`:正式 CLI binary,支持 one-shot、`--proxy` / `--no-proxy`、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。
|
- `bat`:正式 CLI binary,支持 one-shot、`--proxy` / `--no-proxy`、`--watch`、`--daemon`、`status`、`stop`、`restart`、`reload`、`refresh`、`logs`、`verify`、`repair`、`doctor` 和 `clean-stable`。
|
||||||
|
- `release_ops.rs`:从既有 official/localized state、manifest、filesystem 和 CAS reference
|
||||||
|
元数据生成双 release `status/list/distribution/cleanup`;默认 official 分发,localized
|
||||||
|
和历史 release 仅在 Rust 完整性验证通过后可选,cleanup 使用 dry-run `plan_id` 和执行前重验证。
|
||||||
- `report_output.rs`、`terminal_output.rs`:分别负责结果报告渲染和前台终端诊断、帮助、进度及结构化日志输出。
|
- `report_output.rs`、`terminal_output.rs`:分别负责结果报告渲染和前台终端诊断、帮助、进度及结构化日志输出。
|
||||||
|
|
||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- 基于已接入的 `translation.worker.run` 继续推进 Glossary、完整 Patch 构建/rollback;继续扩展更丰富的 TextUnit/TM 查询和通用 Patch 发布资源视图。
|
- 基于已接入的 `translation.worker.run` 继续扩展 TM/Glossary 和复杂 AssetBundle fixture;generic manifest V1 与双 release 运维 V1 已完成。
|
||||||
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。
|
- 真实线上全量下载 smoke 已固化为 `scripts/official-full-pull-smoke.sh` 和 `make official-smoke`;实际运行报告由脚本写入隔离输出目录。
|
||||||
- 增加更多权限和极端文件系统场景测试。
|
- 增加更多权限和极端文件系统场景测试。
|
||||||
|
|
||||||
### `bat-assetbundle`
|
### `bat-assetbundle`
|
||||||
|
|
||||||
状态:**UnityFS 解包、TypeTree 字段读取、TextUnit 提取和受支持 localized patch 发布已可用;复杂结构覆盖与整体 AssetBundle 重打包仍待继续补齐**
|
状态:**已验证 UnityFS 结构的解析、变长修改、重建、重解析和受支持 localized 发布可用;任意复杂结构兼容仍待继续补齐**
|
||||||
|
|
||||||
解析扩展当前按路线图和真实 fixture 验收推进。
|
解析扩展当前按路线图和真实 fixture 验收推进。
|
||||||
|
|
||||||
@@ -174,20 +224,20 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成
|
|||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- 真实 MonoBehaviour、ScriptableObject 版本差异、复杂容器结构调整、unknown 字段结构语义和未见样本驱动的完整 managed reference registry / map entry 变体覆盖;TypeTree-covered managed reference 字段与 registry 记录已可结构化解码,常见 full typename 可拆解为 assembly/namespace/class,不做低保真猜测。
|
- 真实 MonoBehaviour、ScriptableObject 版本差异、复杂容器结构调整、unknown 字段结构语义和未见样本驱动的完整 managed reference registry / map entry 变体覆盖;TypeTree-covered managed reference 字段与 registry 记录已可结构化解码,常见 full typename 可拆解为 assembly/namespace/class,不做低保真猜测。
|
||||||
- 复杂对象整体结构修改后的发布级 AssetBundle 重打包;UnityFS TextAsset、TypeTree string 字段、managed-reference registry payload 字符串、基础语义字段、enum、bit_field、object 字段组合和 TypeTree schema 支撑的 array/vector/map 整体替换的文件级链路已具备重建后校验,受支持 localized patch 已有独立 staging、manifest、current、状态校验和显式 rollback;整体 AssetBundle 发布仍未完成。
|
- 任意复杂对象整体结构和所有真实版本差异的发布级 AssetBundle 重打包;当前已验证的 UnityFS TextAsset、TypeTree string 字段、managed-reference registry payload 字符串、基础语义字段、enum、bit_field、object 字段组合和 TypeTree schema 支撑的 array/vector/map 整体替换已具备变长重建、压缩/对齐保留、未修改对象/字段校验、受支持 localized staging/manifest/current/rollback;带 `archive_entry` 的可验证 ZIP 内 bundle 也会重写外层 ZIP。
|
||||||
- 真实资源 fixture 覆盖对象级解析和文本提取。
|
- 真实资源 fixture 覆盖对象级解析和文本提取。
|
||||||
- 详细补全顺序见 `docs/architecture/assetbundle.md`。
|
- 详细补全顺序见 `docs/architecture/assetbundle.md`。
|
||||||
|
|
||||||
### `bat-patch`
|
### `bat-patch`
|
||||||
|
|
||||||
状态:**通用 Binary/JSON/Text Patch 基础可用;受支持 localized patch 发布/rollback 已完成,通用 Patch 发布仍未完成**
|
状态:**通用 manifest 驱动的受支持 Patch 发布/rollback 已完成;复杂 AssetBundle 兼容仍未完成**
|
||||||
|
|
||||||
当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 已可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和 `unityfs-patch-text-asset` / `unityfs-patch-string-field` / `unityfs-patch-field` CLI 已可对显式 UnityFS bundle 输出目标文件。`unityfs.patch_field` 支持 bool、signed/unsigned integer、float raw bits、string、bytes、enum、bit_field、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换语义 JSON 值;array/vector/List/HashSet/map 扩容会复用当前首个元素或 TypeTree data node 的编码 schema,空容器扩容已用合成 fixture 覆盖,嵌套 vector `Array`、`List<T>` 和 `HashSet<T>` 形态、enum、bit_field、unknown fixed-size raw bytes、managed-reference registry `data` 和 `managedReferenceData` payload 字符串已有重建后重解析 fixture。`bat-assetbundle` + `LocalizedPatchService` 已能对 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 执行替换,写入带 TextUnit/provider/review/rollback trace 的 localized patch manifest,在独立 staging 校验后发布汉化 release,并通过 `localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI 和 bat-api 控制面暴露;`LocalizedPatchManifest` 可转换为通用 `bat_patch::PatchManifest`,通用 manifest 驱动发布仍未迁移。
|
当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、manifest builder、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和对应 CLI 可对显式 UnityFS bundle 输出目标文件。`bat-assetbundle` + `LocalizedPatchService` 已把 Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field 操作统一到有序 generic manifest,在独立 staging 中逐操作校验 source precondition、最终 hash/size 和 ZIP 内层重解析结果,发布后保留 TextUnit/provider/TM/Glossary/review/rollback provenance,并通过 `localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI 和 bat-api 控制面暴露。
|
||||||
|
|
||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。
|
- 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。
|
||||||
- 通用 manifest 驱动的跨类型 patch build/apply/diff 发布;当前 localized 发布仅接受已验证 TextUnit 对应的受支持 UnityFS 文本字段,并不等价于整体 AssetBundle 重打包。
|
- 未见样本驱动的复杂 AssetBundle 重打包;当前 generic manifest 和双 release 运维 V1 只承诺已验证的 Binary/JSON/Text、UnityFS 结构及 Rust-owned release 查询/分发/安全清理,不等价于任意整体 AssetBundle 重打包。
|
||||||
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
|
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
|
||||||
|
|
||||||
### `bat-ffi`
|
### `bat-ffi`
|
||||||
@@ -221,14 +271,16 @@ typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成
|
|||||||
| 角色 | 所有者 | 状态 |
|
| 角色 | 所有者 | 状态 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 |
|
| 同步/运维命令行(近乎全自动) | Rust `bat` | 产品入口 |
|
||||||
| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + CDN MVP + RPC 周期刷新/诊断 + readiness + 内嵌 dashboard |
|
| 资源 bootstrap / 分发 HTTP | Go `cmd/bat-api` | bootstrap + official/localized/historical verified CDN MVP + RPC 周期刷新/诊断 + readiness + release 管理转发 + 内嵌 dashboard |
|
||||||
| daemon RPC client | `internal/backendrpc` | 完成 |
|
| daemon RPC client | `internal/backendrpc` | 完成 |
|
||||||
| 试验 CLI | `cmd/bat` → `bin/bat-go` | 非产品 |
|
| 试验 CLI | `cmd/bat` → `bin/bat-go` | 非产品 |
|
||||||
| FFI | `internal/ffi` | 可选 |
|
| FFI | `internal/ffi` | 可选 |
|
||||||
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
|
| 空目录 `api/` `pkg/` 等 | 占位 | 无实现 |
|
||||||
| Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 |
|
| Web | `web/` | 内嵌 dashboard MVP;完整协作后台仍未完成 |
|
||||||
|
|
||||||
默认 Go/docs 门禁:`make test-go-api`、`make build-go-api`、`make check-docs`(无 FFI)。
|
默认 Go/docs 只读门禁:`make ci-check`(Rust fmt/check/build/clippy/test、Go API
|
||||||
|
format/test/vet/build、固定版本 `golangci-lint 2.12.2`、docs/OpenAPI/RPC contract;
|
||||||
|
无 FFI)。`make format` / `make fmt` 才会修改源码;required 工具缺失或版本不匹配直接失败。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -272,7 +324,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
--watch
|
--watch
|
||||||
```
|
```
|
||||||
|
|
||||||
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取当前 `resource_root`,不在配置里写死资源目录;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest` 和 `official-sync-snapshot.json` contract fixture,Go mirror 测试会防止字段名、null 语义和 `game_main_config_bootstrap` 再次漂移;TM 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text 和 provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
|
资源 HTTP bootstrap / 只读分发入口是 Go `cmd/bat-api`。生产拓扑下它与 Rust `bat` 同环境运行,经 `bat.sock` RPC 获取 Rust 当前 official `release.attestation`,再按 release/publication/mapping/manifest identity 和 verification generation 绑定读取 `resource.manifest`,不在配置里写死资源目录;轻量 attestation 只读取 current、canonical versioned root、publication anchor、manifest 元数据和 freshness,不遍历历史 release 或计算资源文件 BLAKE3。Rust watch 周期负责 current 本地 manifest 验证并更新 attestation,默认 freshness window 为 `2 * 3600 + 60 = 7260` 秒;HTTP readiness 还要求 Go 分页快照完整且本地路径安全;本地开发不能全量跑 `bat` 时用 fixture 和 Go 门禁验证。`internal/api/testdata/contract/` 已固化来自 Rust 输出并经归一化的 `catalog.status`、`resource.manifest`、`official-sync-snapshot.json` 和 Glossary query contract fixture,Go mirror 测试会防止字段名、null 语义和 provenance 再次漂移;TM/Glossary 另有 Rust/Go 字段镜像测试覆盖 match、trust、translated text、term history 和 source provenance。`bat-api` 已补 launcher 资源引导兼容端点、玩家-facing HTTP 控制面和鉴权调度/translation/TM/Glossary 管理接口(token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单;`reload` / `refresh` / `restart` / `sync` / `verify` / `repair` / `catalog-refresh`、`schedule.*`、`task.*` 查询/取消、`daemon.logs`、`parse.*` 查询、`translation.tasks` / `translation.handoff` 查询、`translation.task.update`、`translation.worker.run`、`translation.proofread`、`translation.memory.summary/query/confirm/conflicts/resolve_conflict`、`translation.glossary.*`、`localized.publish` 和 `localized.rollback` 可经 dashboard/API 转发),响应只来自已发布 snapshot/RPC,不提供官方账号登录、游戏网关协议或完整 package update manifest。
|
||||||
|
|
||||||
生产要求:
|
生产要求:
|
||||||
|
|
||||||
@@ -298,8 +350,8 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
|
|||||||
后续工程顺序:
|
后续工程顺序:
|
||||||
|
|
||||||
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
|
1. 继续复杂 AssetBundle:真实样本、复杂字段解析和发布级重打包。
|
||||||
2. 继续通用 Patch:manifest 驱动、双 release 查询和清理策略。
|
2. 继续通用 Patch:真实样本驱动的复杂 AssetBundle 兼容;双 release 查询、分发、rollback 边界和安全清理 V1 已完成。
|
||||||
3. 继续资源查询和翻译基础设施:更丰富的查询、Glossary 和 Provider
|
3. 继续资源查询和翻译基础设施:更丰富的查询和 Provider
|
||||||
扩展体系。
|
扩展体系。
|
||||||
4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
|
4. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
|
||||||
|
|
||||||
@@ -309,7 +361,6 @@ Issue 状态不作为本地实现状态的权威来源;本次复核未把远
|
|||||||
- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP、
|
- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP、
|
||||||
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
|
HTTP 控制面、launcher 资源引导兼容、RPC 周期刷新/诊断、readiness、内嵌 dashboard
|
||||||
和 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
|
和 `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量离线队列、
|
||||||
通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;
|
通用 Binary/JSON/Text Patch 基础、generic manifest 和受支持 localized patch 发布/rollback 可用;
|
||||||
完整 AssetBundle 重打包、完整 Web 协作后台、Glossary、模糊 TM 匹配和通用 manifest 发布未完成。
|
复杂 AssetBundle 重打包、完整 Web 协作后台、模糊 TM 匹配和更高阶 release retention 未完成;双 release 运维 V1 已完成。
|
||||||
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包、Glossary、通用 manifest Patch
|
- **下一工程里程碑**:复杂 AssetBundle 解析和重打包,以及真实官方资源长期运行验证。
|
||||||
构建,以及真实官方资源长期运行验证。
|
|
||||||
|
|||||||
Generated
+2
@@ -103,6 +103,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
"hex",
|
"hex",
|
||||||
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -118,6 +119,7 @@ version = "1.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"blake3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
# Design System Inspiration of Linear
|
||||||
|
|
||||||
|
## 1. Visual Theme & Atmosphere
|
||||||
|
|
||||||
|
Linear's website is a masterclass in dark-mode-first product design — a near-black canvas (`#08090a`) where content emerges from darkness like starlight. The overall impression is one of extreme precision engineering: every element exists in a carefully calibrated hierarchy of luminance, from barely-visible borders (`rgba(255,255,255,0.05)`) to soft, luminous text (`#f7f8f8`). This is not a dark theme applied to a light design — it is darkness as the native medium, where information density is managed through subtle gradations of white opacity rather than color variation.
|
||||||
|
|
||||||
|
The typography system is built entirely on Inter Variable with OpenType features `"cv01"` and `"ss03"` enabled globally, giving the typeface a cleaner, more geometric character. Inter is used at a remarkable range of weights — from 300 (light body) through 510 (medium, Linear's signature weight) to 590 (semibold emphasis). The 510 weight is particularly distinctive: it sits between regular and medium, creating a subtle emphasis that doesn't shout. At display sizes (72px, 64px, 48px), Inter uses aggressive negative letter-spacing (-1.584px to -1.056px), creating compressed, authoritative headlines that feel engineered rather than designed. Berkeley Mono serves as the monospace companion for code and technical labels, with fallbacks to ui-monospace, SF Mono, and Menlo.
|
||||||
|
|
||||||
|
The color system is almost entirely achromatic — dark backgrounds with white/gray text — punctuated by a single brand accent: Linear's signature indigo-violet (`#5e6ad2` for backgrounds, `#7170ff` for interactive accents). This accent color is used sparingly and intentionally, appearing only on CTAs, active states, and brand elements. The border system uses ultra-thin, semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) that create structure without visual noise, like wireframes drawn in moonlight.
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- Dark-mode-native: `#08090a` marketing background, `#0f1011` panel background, `#191a1b` elevated surfaces
|
||||||
|
- Inter Variable with `"cv01", "ss03"` globally — geometric alternates for a cleaner aesthetic
|
||||||
|
- Signature weight 510 (between regular and medium) for most UI text
|
||||||
|
- Aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px)
|
||||||
|
- Brand indigo-violet: `#5e6ad2` (bg) / `#7170ff` (accent) / `#828fff` (hover) — the only chromatic color in the system
|
||||||
|
- Semi-transparent white borders throughout: `rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`
|
||||||
|
- Button backgrounds at near-zero opacity: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)`
|
||||||
|
- Multi-layered shadows with inset variants for depth on dark surfaces
|
||||||
|
- Radix UI primitives as the component foundation (6 detected primitives)
|
||||||
|
- Success green (`#27a644`, `#10b981`) used only for status indicators
|
||||||
|
|
||||||
|
## 2. Color Palette & Roles
|
||||||
|
|
||||||
|
### Background Surfaces
|
||||||
|
- **Marketing Black** (`#010102` / `#08090a`): The deepest background — the canvas for hero sections and marketing pages. Near-pure black with an imperceptible blue-cool undertone.
|
||||||
|
- **Panel Dark** (`#0f1011`): Sidebar and panel backgrounds. One step up from the marketing black.
|
||||||
|
- **Level 3 Surface** (`#191a1b`): Elevated surface areas, card backgrounds, dropdowns.
|
||||||
|
- **Secondary Surface** (`#28282c`): The lightest dark surface — used for hover states and slightly elevated components.
|
||||||
|
|
||||||
|
### Text & Content
|
||||||
|
- **Primary Text** (`#f7f8f8`): Near-white with a barely-warm cast. The default text color — not pure white, preventing eye strain on dark backgrounds.
|
||||||
|
- **Secondary Text** (`#d0d6e0`): Cool silver-gray for body text, descriptions, and secondary content.
|
||||||
|
- **Tertiary Text** (`#8a8f98`): Muted gray for placeholders, metadata, and de-emphasized content.
|
||||||
|
- **Quaternary Text** (`#62666d`): The most subdued text — timestamps, disabled states, subtle labels.
|
||||||
|
|
||||||
|
### Brand & Accent
|
||||||
|
- **Brand Indigo** (`#5e6ad2`): Primary brand color — used for CTA button backgrounds, brand marks, and key interactive surfaces.
|
||||||
|
- **Accent Violet** (`#7170ff`): Brighter variant for interactive elements — links, active states, selected items.
|
||||||
|
- **Accent Hover** (`#828fff`): Lighter, more saturated variant for hover states on accent elements.
|
||||||
|
- **Security Lavender** (`#7a7fad`): Muted indigo used specifically for security-related UI elements.
|
||||||
|
|
||||||
|
### Status Colors
|
||||||
|
- **Green** (`#27a644`): Primary success/active status. Used for "in progress" indicators.
|
||||||
|
- **Emerald** (`#10b981`): Secondary success — pill badges, completion states.
|
||||||
|
|
||||||
|
### Border & Divider
|
||||||
|
- **Border Primary** (`#23252a`): Solid dark border for prominent separations.
|
||||||
|
- **Border Secondary** (`#34343a`): Slightly lighter solid border.
|
||||||
|
- **Border Tertiary** (`#3e3e44`): Lightest solid border variant.
|
||||||
|
- **Border Subtle** (`rgba(255,255,255,0.05)`): Ultra-subtle semi-transparent border — the default.
|
||||||
|
- **Border Standard** (`rgba(255,255,255,0.08)`): Standard semi-transparent border for cards, inputs, code blocks.
|
||||||
|
- **Line Tint** (`#141516`): Nearly invisible line for the subtlest divisions.
|
||||||
|
- **Line Tertiary** (`#18191a`): Slightly more visible divider line.
|
||||||
|
|
||||||
|
### Light Mode Neutrals (for light theme contexts)
|
||||||
|
- **Light Background** (`#f7f8f8`): Page background in light mode.
|
||||||
|
- **Light Surface** (`#f3f4f5` / `#f5f6f7`): Subtle surface tinting.
|
||||||
|
- **Light Border** (`#d0d6e0`): Visible border in light contexts.
|
||||||
|
- **Light Border Alt** (`#e6e6e6`): Alternative lighter border.
|
||||||
|
- **Pure White** (`#ffffff`): Card surfaces, highlights.
|
||||||
|
|
||||||
|
### Overlay
|
||||||
|
- **Overlay Primary** (`rgba(0,0,0,0.85)`): Modal/dialog backdrop — extremely dark for focus isolation.
|
||||||
|
|
||||||
|
## 3. Typography Rules
|
||||||
|
|
||||||
|
### Font Family
|
||||||
|
- **Primary**: `Inter Variable`, with fallbacks: `SF Pro Display, -apple-system, system-ui, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Open Sans, Helvetica Neue`
|
||||||
|
- **Monospace**: `Berkeley Mono`, with fallbacks: `ui-monospace, SF Mono, Menlo`
|
||||||
|
- **OpenType Features**: `"cv01", "ss03"` enabled globally — cv01 provides an alternate lowercase 'a' (single-story), ss03 adjusts specific letterforms for a cleaner geometric appearance.
|
||||||
|
|
||||||
|
### Hierarchy
|
||||||
|
|
||||||
|
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|
||||||
|
|------|------|------|--------|-------------|----------------|-------|
|
||||||
|
| Display XL | Inter Variable | 72px (4.50rem) | 510 | 1.00 (tight) | -1.584px | Hero headlines, maximum impact |
|
||||||
|
| Display Large | Inter Variable | 64px (4.00rem) | 510 | 1.00 (tight) | -1.408px | Secondary hero text |
|
||||||
|
| Display | Inter Variable | 48px (3.00rem) | 510 | 1.00 (tight) | -1.056px | Section headlines |
|
||||||
|
| Heading 1 | Inter Variable | 32px (2.00rem) | 400 | 1.13 (tight) | -0.704px | Major section titles |
|
||||||
|
| Heading 2 | Inter Variable | 24px (1.50rem) | 400 | 1.33 | -0.288px | Sub-section headings |
|
||||||
|
| Heading 3 | Inter Variable | 20px (1.25rem) | 590 | 1.33 | -0.24px | Feature titles, card headers |
|
||||||
|
| Body Large | Inter Variable | 18px (1.13rem) | 400 | 1.60 (relaxed) | -0.165px | Introduction text, feature descriptions |
|
||||||
|
| Body Emphasis | Inter Variable | 17px (1.06rem) | 590 | 1.60 (relaxed) | normal | Emphasized body, sub-headings in content |
|
||||||
|
| Body | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard reading text |
|
||||||
|
| Body Medium | Inter Variable | 16px (1.00rem) | 510 | 1.50 | normal | Navigation, labels |
|
||||||
|
| Body Semibold | Inter Variable | 16px (1.00rem) | 590 | 1.50 | normal | Strong emphasis |
|
||||||
|
| Small | Inter Variable | 15px (0.94rem) | 400 | 1.60 (relaxed) | -0.165px | Secondary body text |
|
||||||
|
| Small Medium | Inter Variable | 15px (0.94rem) | 510 | 1.60 (relaxed) | -0.165px | Emphasized small text |
|
||||||
|
| Small Semibold | Inter Variable | 15px (0.94rem) | 590 | 1.60 (relaxed) | -0.165px | Strong small text |
|
||||||
|
| Small Light | Inter Variable | 15px (0.94rem) | 300 | 1.47 | -0.165px | De-emphasized body |
|
||||||
|
| Caption Large | Inter Variable | 14px (0.88rem) | 510–590 | 1.50 | -0.182px | Sub-labels, category headers |
|
||||||
|
| Caption | Inter Variable | 13px (0.81rem) | 400–510 | 1.50 | -0.13px | Metadata, timestamps |
|
||||||
|
| Label | Inter Variable | 12px (0.75rem) | 400–590 | 1.40 | normal | Button text, small labels |
|
||||||
|
| Micro | Inter Variable | 11px (0.69rem) | 510 | 1.40 | normal | Tiny labels |
|
||||||
|
| Tiny | Inter Variable | 10px (0.63rem) | 400–510 | 1.50 | -0.15px | Overline text, sometimes uppercase |
|
||||||
|
| Link Large | Inter Variable | 16px (1.00rem) | 400 | 1.50 | normal | Standard links |
|
||||||
|
| Link Medium | Inter Variable | 15px (0.94rem) | 510 | 2.67 | normal | Spaced navigation links |
|
||||||
|
| Link Small | Inter Variable | 14px (0.88rem) | 510 | 1.50 | normal | Compact links |
|
||||||
|
| Link Caption | Inter Variable | 13px (0.81rem) | 400–510 | 1.50 | -0.13px | Footer, metadata links |
|
||||||
|
| Mono Body | Berkeley Mono | 14px (0.88rem) | 400 | 1.50 | normal | Code blocks |
|
||||||
|
| Mono Caption | Berkeley Mono | 13px (0.81rem) | 400 | 1.50 | normal | Code labels |
|
||||||
|
| Mono Label | Berkeley Mono | 12px (0.75rem) | 400 | 1.40 | normal | Code metadata, sometimes uppercase |
|
||||||
|
|
||||||
|
### Principles
|
||||||
|
- **510 is the signature weight**: Linear uses Inter Variable's 510 weight (between regular 400 and medium 500) as its default emphasis weight. This creates a subtly bolded feel without the heaviness of traditional medium or semibold.
|
||||||
|
- **Compression at scale**: Display sizes use progressively tighter letter-spacing — -1.584px at 72px, -1.408px at 64px, -1.056px at 48px, -0.704px at 32px. Below 24px, spacing relaxes toward normal.
|
||||||
|
- **OpenType as identity**: `"cv01", "ss03"` aren't decorative — they transform Inter into Linear's distinctive typeface, giving it a more geometric, purposeful character.
|
||||||
|
- **Three-tier weight system**: 400 (reading), 510 (emphasis/UI), 590 (strong emphasis). The 300 weight appears only in deliberately de-emphasized contexts.
|
||||||
|
|
||||||
|
## 4. Component Stylings
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
**Ghost Button (Default)**
|
||||||
|
- Background: `rgba(255,255,255,0.02)`
|
||||||
|
- Text: `#e2e4e7` (near-white)
|
||||||
|
- Padding: comfortable
|
||||||
|
- Radius: 6px
|
||||||
|
- Border: `1px solid rgb(36, 40, 44)`
|
||||||
|
- Outline: none
|
||||||
|
- Focus shadow: `rgba(0,0,0,0.1) 0px 4px 12px`
|
||||||
|
- Use: Standard actions, secondary CTAs
|
||||||
|
|
||||||
|
**Subtle Button**
|
||||||
|
- Background: `rgba(255,255,255,0.04)`
|
||||||
|
- Text: `#d0d6e0` (silver-gray)
|
||||||
|
- Padding: 0px 6px
|
||||||
|
- Radius: 6px
|
||||||
|
- Use: Toolbar actions, contextual buttons
|
||||||
|
|
||||||
|
**Primary Brand Button (Inferred)**
|
||||||
|
- Background: `#5e6ad2` (brand indigo)
|
||||||
|
- Text: `#ffffff`
|
||||||
|
- Padding: 8px 16px
|
||||||
|
- Radius: 6px
|
||||||
|
- Hover: `#828fff` shift
|
||||||
|
- Use: Primary CTAs ("Start building", "Sign up")
|
||||||
|
|
||||||
|
**Icon Button (Circle)**
|
||||||
|
- Background: `rgba(255,255,255,0.03)` or `rgba(255,255,255,0.05)`
|
||||||
|
- Text: `#f7f8f8` or `#ffffff`
|
||||||
|
- Radius: 50%
|
||||||
|
- Border: `1px solid rgba(255,255,255,0.08)`
|
||||||
|
- Use: Close, menu toggle, icon-only actions
|
||||||
|
|
||||||
|
**Pill Button**
|
||||||
|
- Background: transparent
|
||||||
|
- Text: `#d0d6e0`
|
||||||
|
- Padding: 0px 10px 0px 5px
|
||||||
|
- Radius: 9999px
|
||||||
|
- Border: `1px solid rgb(35, 37, 42)`
|
||||||
|
- Use: Filter chips, tags, status indicators
|
||||||
|
|
||||||
|
**Small Toolbar Button**
|
||||||
|
- Background: `rgba(255,255,255,0.05)`
|
||||||
|
- Text: `#62666d` (muted)
|
||||||
|
- Radius: 2px
|
||||||
|
- Border: `1px solid rgba(255,255,255,0.05)`
|
||||||
|
- Shadow: `rgba(0,0,0,0.03) 0px 1.2px 0px 0px`
|
||||||
|
- Font: 12px weight 510
|
||||||
|
- Use: Toolbar actions, quick-access controls
|
||||||
|
|
||||||
|
### Cards & Containers
|
||||||
|
- Background: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)` (never solid — always translucent)
|
||||||
|
- Border: `1px solid rgba(255,255,255,0.08)` (standard) or `1px solid rgba(255,255,255,0.05)` (subtle)
|
||||||
|
- Radius: 8px (standard), 12px (featured), 22px (large panels)
|
||||||
|
- Shadow: `rgba(0,0,0,0.2) 0px 0px 0px 1px` or layered multi-shadow stacks
|
||||||
|
- Hover: subtle background opacity increase
|
||||||
|
|
||||||
|
### Inputs & Forms
|
||||||
|
|
||||||
|
**Text Area**
|
||||||
|
- Background: `rgba(255,255,255,0.02)`
|
||||||
|
- Text: `#d0d6e0`
|
||||||
|
- Border: `1px solid rgba(255,255,255,0.08)`
|
||||||
|
- Padding: 12px 14px
|
||||||
|
- Radius: 6px
|
||||||
|
|
||||||
|
**Search Input**
|
||||||
|
- Background: transparent
|
||||||
|
- Text: `#f7f8f8`
|
||||||
|
- Padding: 1px 32px (icon-aware)
|
||||||
|
|
||||||
|
**Button-style Input**
|
||||||
|
- Text: `#8a8f98`
|
||||||
|
- Padding: 1px 6px
|
||||||
|
- Radius: 5px
|
||||||
|
- Focus shadow: multi-layer stack
|
||||||
|
|
||||||
|
### Badges & Pills
|
||||||
|
|
||||||
|
**Success Pill**
|
||||||
|
- Background: `#10b981`
|
||||||
|
- Text: `#f7f8f8`
|
||||||
|
- Radius: 50% (circular)
|
||||||
|
- Font: 10px weight 510
|
||||||
|
- Use: Status dots, completion indicators
|
||||||
|
|
||||||
|
**Neutral Pill**
|
||||||
|
- Background: transparent
|
||||||
|
- Text: `#d0d6e0`
|
||||||
|
- Padding: 0px 10px 0px 5px
|
||||||
|
- Radius: 9999px
|
||||||
|
- Border: `1px solid rgb(35, 37, 42)`
|
||||||
|
- Font: 12px weight 510
|
||||||
|
- Use: Tags, filter chips, category labels
|
||||||
|
|
||||||
|
**Subtle Badge**
|
||||||
|
- Background: `rgba(255,255,255,0.05)`
|
||||||
|
- Text: `#f7f8f8`
|
||||||
|
- Padding: 0px 8px 0px 2px
|
||||||
|
- Radius: 2px
|
||||||
|
- Border: `1px solid rgba(255,255,255,0.05)`
|
||||||
|
- Font: 10px weight 510
|
||||||
|
- Use: Inline labels, version tags
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- Dark sticky header on near-black background
|
||||||
|
- Linear logomark left-aligned (SVG icon)
|
||||||
|
- Links: Inter Variable 13–14px weight 510, `#d0d6e0` text
|
||||||
|
- Active/hover: text lightens to `#f7f8f8`
|
||||||
|
- CTA: Brand indigo button or ghost button
|
||||||
|
- Mobile: hamburger collapse
|
||||||
|
- Search: command palette trigger (`/` or `Cmd+K`)
|
||||||
|
|
||||||
|
### Image Treatment
|
||||||
|
- Product screenshots on dark backgrounds with subtle border (`rgba(255,255,255,0.08)`)
|
||||||
|
- Top-rounded images: `12px 12px 0px 0px` radius
|
||||||
|
- Dashboard/issue previews dominate feature sections
|
||||||
|
- Subtle shadow beneath screenshots: `rgba(0,0,0,0.4) 0px 2px 4px`
|
||||||
|
|
||||||
|
## 5. Layout Principles
|
||||||
|
|
||||||
|
### Spacing System
|
||||||
|
- Base unit: 8px
|
||||||
|
- Scale: 1px, 4px, 7px, 8px, 11px, 12px, 16px, 19px, 20px, 22px, 24px, 28px, 32px, 35px
|
||||||
|
- The 7px and 11px values suggest micro-adjustments for optical alignment
|
||||||
|
- Primary rhythm: 8px, 16px, 24px, 32px (standard 8px grid)
|
||||||
|
|
||||||
|
### Grid & Container
|
||||||
|
- Max content width: approximately 1200px
|
||||||
|
- Hero: centered single-column with generous vertical padding
|
||||||
|
- Feature sections: 2–3 column grids for feature cards
|
||||||
|
- Full-width dark sections with internal max-width constraints
|
||||||
|
- Changelog: single-column timeline layout
|
||||||
|
|
||||||
|
### Whitespace Philosophy
|
||||||
|
- **Darkness as space**: On Linear's dark canvas, empty space isn't white — it's absence. The near-black background IS the whitespace, and content emerges from it.
|
||||||
|
- **Compressed headlines, expanded surroundings**: Display text at 72px with -1.584px tracking is dense and compressed, but sits within vast dark padding. The contrast between typographic density and spatial generosity creates tension.
|
||||||
|
- **Section isolation**: Each feature section is separated by generous vertical padding (80px+) with no visible dividers — the dark background provides natural separation.
|
||||||
|
|
||||||
|
### Border Radius Scale
|
||||||
|
- Micro (2px): Inline badges, toolbar buttons, subtle tags
|
||||||
|
- Standard (4px): Small containers, list items
|
||||||
|
- Comfortable (6px): Buttons, inputs, functional elements
|
||||||
|
- Card (8px): Cards, dropdowns, popovers
|
||||||
|
- Panel (12px): Panels, featured cards, section containers
|
||||||
|
- Large (22px): Large panel elements
|
||||||
|
- Full Pill (9999px): Chips, filter pills, status tags
|
||||||
|
- Circle (50%): Icon buttons, avatars, status dots
|
||||||
|
|
||||||
|
## 6. Depth & Elevation
|
||||||
|
|
||||||
|
| Level | Treatment | Use |
|
||||||
|
|-------|-----------|-----|
|
||||||
|
| Flat (Level 0) | No shadow, `#010102` bg | Page background, deepest canvas |
|
||||||
|
| Subtle (Level 1) | `rgba(0,0,0,0.03) 0px 1.2px 0px` | Toolbar buttons, micro-elevation |
|
||||||
|
| Surface (Level 2) | `rgba(255,255,255,0.05)` bg + `1px solid rgba(255,255,255,0.08)` border | Cards, input fields, containers |
|
||||||
|
| Inset (Level 2b) | `rgba(0,0,0,0.2) 0px 0px 12px 0px inset` | Recessed panels, inner shadows |
|
||||||
|
| Ring (Level 3) | `rgba(0,0,0,0.2) 0px 0px 0px 1px` | Border-as-shadow technique |
|
||||||
|
| Elevated (Level 4) | `rgba(0,0,0,0.4) 0px 2px 4px` | Floating elements, dropdowns |
|
||||||
|
| Dialog (Level 5) | Multi-layer stack: `rgba(0,0,0,0) 0px 8px 2px, rgba(0,0,0,0.01) 0px 5px 2px, rgba(0,0,0,0.04) 0px 3px 2px, rgba(0,0,0,0.07) 0px 1px 1px, rgba(0,0,0,0.08) 0px 0px 1px` | Popovers, command palette, modals |
|
||||||
|
| Focus | `rgba(0,0,0,0.1) 0px 4px 12px` + additional layers | Keyboard focus on interactive elements |
|
||||||
|
|
||||||
|
**Shadow Philosophy**: On dark surfaces, traditional shadows (dark on dark) are nearly invisible. Linear solves this by using semi-transparent white borders as the primary depth indicator. Elevation isn't communicated through shadow darkness but through background luminance steps — each level slightly increases the white opacity of the surface background (`0.02` → `0.04` → `0.05`), creating a subtle stacking effect. The inset shadow technique (`rgba(0,0,0,0.2) 0px 0px 12px 0px inset`) creates a unique "sunken" effect for recessed panels, adding dimensional depth that traditional dark themes lack.
|
||||||
|
|
||||||
|
## 7. Do's and Don'ts
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- Use Inter Variable with `"cv01", "ss03"` on ALL text — these features are fundamental to Linear's typeface identity
|
||||||
|
- Use weight 510 as your default emphasis weight — it's Linear's signature between-weight
|
||||||
|
- Apply aggressive negative letter-spacing at display sizes (-1.584px at 72px, -1.056px at 48px)
|
||||||
|
- Build on near-black backgrounds: `#08090a` for marketing, `#0f1011` for panels, `#191a1b` for elevated surfaces
|
||||||
|
- Use semi-transparent white borders (`rgba(255,255,255,0.05)` to `rgba(255,255,255,0.08)`) instead of solid dark borders
|
||||||
|
- Keep button backgrounds nearly transparent: `rgba(255,255,255,0.02)` to `rgba(255,255,255,0.05)`
|
||||||
|
- Reserve brand indigo (`#5e6ad2` / `#7170ff`) for primary CTAs and interactive accents only
|
||||||
|
- Use `#f7f8f8` for primary text — not pure `#ffffff`, which would be too harsh
|
||||||
|
- Apply the luminance stacking model: deeper = darker bg, elevated = slightly lighter bg
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
- Don't use pure white (`#ffffff`) as primary text — `#f7f8f8` prevents eye strain
|
||||||
|
- Don't use solid colored backgrounds for buttons — transparency is the system (rgba white at 0.02–0.05)
|
||||||
|
- Don't apply the brand indigo decoratively — it's reserved for interactive/CTA elements only
|
||||||
|
- Don't use positive letter-spacing on display text — Inter at large sizes always runs negative
|
||||||
|
- Don't use visible/opaque borders on dark backgrounds — borders should be whisper-thin semi-transparent white
|
||||||
|
- Don't skip the OpenType features (`"cv01", "ss03"`) — without them, it's generic Inter, not Linear's Inter
|
||||||
|
- Don't use weight 700 (bold) — Linear's maximum weight is 590, with 510 as the workhorse
|
||||||
|
- Don't introduce warm colors into the UI chrome — the palette is cool gray with blue-violet accent only
|
||||||
|
- Don't use drop shadows for elevation on dark surfaces — use background luminance stepping instead
|
||||||
|
|
||||||
|
## 8. Responsive Behavior
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
| Name | Width | Key Changes |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| Mobile Small | <600px | Single column, compact padding |
|
||||||
|
| Mobile | 600–640px | Standard mobile layout |
|
||||||
|
| Tablet | 640–768px | Two-column grids begin |
|
||||||
|
| Desktop Small | 768–1024px | Full card grids, expanded padding |
|
||||||
|
| Desktop | 1024–1280px | Standard desktop, full navigation |
|
||||||
|
| Large Desktop | >1280px | Full layout, generous margins |
|
||||||
|
|
||||||
|
### Touch Targets
|
||||||
|
- Buttons use comfortable padding with 6px radius minimum
|
||||||
|
- Navigation links at 13–14px with adequate spacing
|
||||||
|
- Pill tags have 10px horizontal padding for touch accessibility
|
||||||
|
- Icon buttons at 50% radius ensure circular, easy-to-tap targets
|
||||||
|
- Search trigger is prominently placed with generous hit area
|
||||||
|
|
||||||
|
### Collapsing Strategy
|
||||||
|
- Hero: 72px → 48px → 32px display text, tracking adjusts proportionally
|
||||||
|
- Navigation: horizontal links + CTAs → hamburger menu at 768px
|
||||||
|
- Feature cards: 3-column → 2-column → single column stacked
|
||||||
|
- Product screenshots: maintain aspect ratio, may reduce padding
|
||||||
|
- Changelog: timeline maintains single-column through all sizes
|
||||||
|
- Footer: multi-column → stacked single column
|
||||||
|
- Section spacing: 80px+ → 48px on mobile
|
||||||
|
|
||||||
|
### Image Behavior
|
||||||
|
- Dashboard screenshots maintain border treatment at all sizes
|
||||||
|
- Hero visuals simplify on mobile (fewer floating UI elements)
|
||||||
|
- Product screenshots use responsive sizing with consistent radius
|
||||||
|
- Dark background ensures screenshots blend naturally at any viewport
|
||||||
|
|
||||||
|
## 9. Agent Prompt Guide
|
||||||
|
|
||||||
|
### Quick Color Reference
|
||||||
|
- Primary CTA: Brand Indigo (`#5e6ad2`)
|
||||||
|
- Page Background: Marketing Black (`#08090a`)
|
||||||
|
- Panel Background: Panel Dark (`#0f1011`)
|
||||||
|
- Surface: Level 3 (`#191a1b`)
|
||||||
|
- Heading text: Primary White (`#f7f8f8`)
|
||||||
|
- Body text: Silver Gray (`#d0d6e0`)
|
||||||
|
- Muted text: Tertiary Gray (`#8a8f98`)
|
||||||
|
- Subtle text: Quaternary Gray (`#62666d`)
|
||||||
|
- Accent: Violet (`#7170ff`)
|
||||||
|
- Accent Hover: Light Violet (`#828fff`)
|
||||||
|
- Border (default): `rgba(255,255,255,0.08)`
|
||||||
|
- Border (subtle): `rgba(255,255,255,0.05)`
|
||||||
|
- Focus ring: Multi-layer shadow stack
|
||||||
|
|
||||||
|
### Example Component Prompts
|
||||||
|
- "Create a hero section on `#08090a` background. Headline at 48px Inter Variable weight 510, line-height 1.00, letter-spacing -1.056px, color `#f7f8f8`, font-feature-settings `'cv01', 'ss03'`. Subtitle at 18px weight 400, line-height 1.60, color `#8a8f98`. Brand CTA button (`#5e6ad2`, 6px radius, 8px 16px padding) and ghost button (`rgba(255,255,255,0.02)` bg, `1px solid rgba(255,255,255,0.08)` border, 6px radius)."
|
||||||
|
- "Design a card on dark background: `rgba(255,255,255,0.02)` background, `1px solid rgba(255,255,255,0.08)` border, 8px radius. Title at 20px Inter Variable weight 590, letter-spacing -0.24px, color `#f7f8f8`. Body at 15px weight 400, color `#8a8f98`, letter-spacing -0.165px."
|
||||||
|
- "Build a pill badge: transparent background, `#d0d6e0` text, 9999px radius, 0px 10px padding, `1px solid #23252a` border, 12px Inter Variable weight 510."
|
||||||
|
- "Create navigation: dark sticky header on `#0f1011`. Inter Variable 13px weight 510 for links, `#d0d6e0` text. Brand indigo CTA `#5e6ad2` right-aligned with 6px radius. Bottom border: `1px solid rgba(255,255,255,0.05)`."
|
||||||
|
- "Design a command palette: `#191a1b` background, `1px solid rgba(255,255,255,0.08)` border, 12px radius, multi-layer shadow stack. Input at 16px Inter Variable weight 400, `#f7f8f8` text. Results list with 13px weight 510 labels in `#d0d6e0` and 12px metadata in `#62666d`."
|
||||||
|
|
||||||
|
### Iteration Guide
|
||||||
|
1. Always set font-feature-settings `"cv01", "ss03"` on all Inter text — this is non-negotiable for Linear's look
|
||||||
|
2. Letter-spacing scales with font size: -1.584px at 72px, -1.056px at 48px, -0.704px at 32px, normal below 16px
|
||||||
|
3. Three weights: 400 (read), 510 (emphasize/navigate), 590 (announce)
|
||||||
|
4. Surface elevation via background opacity: `rgba(255,255,255, 0.02 → 0.04 → 0.05)` — never solid backgrounds on dark
|
||||||
|
5. Brand indigo (`#5e6ad2` / `#7170ff`) is the only chromatic color — everything else is grayscale
|
||||||
|
6. Borders are always semi-transparent white, never solid dark colors on dark backgrounds
|
||||||
|
7. Berkeley Mono for any code or technical content, Inter Variable for everything else
|
||||||
+29
-2
@@ -1,6 +1,6 @@
|
|||||||
# BlueArchive Toolkit 文档分类索引
|
# BlueArchive Toolkit 文档分类索引
|
||||||
|
|
||||||
- **更新时间**:2026-09-04
|
- **更新时间**:2026-09-13
|
||||||
- **用途**:按用途、时效性和权威级别定位文档。
|
- **用途**:按用途、时效性和权威级别定位文档。
|
||||||
- **原则**:目录是物理归档方式,不能单独代表文档权威性;当前源码、测试和下列当前文档优先于历史报告。
|
- **原则**:目录是物理归档方式,不能单独代表文档权威性;当前源码、测试和下列当前文档优先于历史报告。
|
||||||
|
|
||||||
@@ -16,6 +16,8 @@
|
|||||||
- `CHANGELOG.md`:版本变更记录,不作为当前实现的唯一依据。
|
- `CHANGELOG.md`:版本变更记录,不作为当前实现的唯一依据。
|
||||||
- `CLAUDE.md`:旧工具兼容入口,不承载独立规则。
|
- `CLAUDE.md`:旧工具兼容入口,不承载独立规则。
|
||||||
- `AGENTS.md`:AI agent 长期协作规则。
|
- `AGENTS.md`:AI agent 长期协作规则。
|
||||||
|
- `TODO.md`:具体工程任务、优先级、依赖与完成条件的仓库内任务账本;不作为当前实现事实来源。
|
||||||
|
- `DESIGN.md`:Dashboard 的主要视觉参考与设计灵感来源;涉及 Dashboard/Web UI/布局/视觉/组件/交互任务时必须先阅读。
|
||||||
|
|
||||||
## 2. 当前状态、计划与缺口
|
## 2. 当前状态、计划与缺口
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
- `docs/reports/GO_STATUS.md`:Go `bat-api` 边界和组件进度的权威文档。
|
- `docs/reports/GO_STATUS.md`:Go `bat-api` 边界和组件进度的权威文档。
|
||||||
- `docs/reports/CURRENT_GAPS.md`:当前缺口、影响和推进顺序。
|
- `docs/reports/CURRENT_GAPS.md`:当前缺口、影响和推进顺序。
|
||||||
- `PROJECT_PLAN.md`:目标和路线图;其中的计划项不等于已实现。
|
- `PROJECT_PLAN.md`:目标和路线图;其中的计划项不等于已实现。
|
||||||
|
- `TODO.md`:当前可执行工程任务、优先级、依赖与验收条件;条目状态不高于源码、测试和 current-status 文档。
|
||||||
- `docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md`:Rust 输出、Go contract fixture 和联调的当前交接说明。
|
- `docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md`:Rust 输出、Go contract fixture 和联调的当前交接说明。
|
||||||
|
|
||||||
## 3. 架构、决策与稳定契约
|
## 3. 架构、决策与稳定契约
|
||||||
@@ -51,6 +54,15 @@
|
|||||||
|
|
||||||
契约文档涉及字段、状态码、错误码、release layout 或路径语义时,必须与源码测试和 `internal/api/testdata/contract/` 一起复核。
|
契约文档涉及字段、状态码、错误码、release layout 或路径语义时,必须与源码测试和 `internal/api/testdata/contract/` 一起复核。
|
||||||
|
|
||||||
|
|
||||||
|
### 3.4 Dashboard 设计参考
|
||||||
|
|
||||||
|
- `DESIGN.md`:用户 Dashboard 与运营 Dashboard 的主要视觉参考和设计灵感来源,描述应延续的色彩关系、排版、空间、边框、层级、组件形态和交互气质。它不定义后端事实、权限或业务状态,也不要求复制参考来源的页面结构或品牌内容。
|
||||||
|
- Dashboard 的稳定产品职责、信息边界和设计执行规则见 `AGENTS.md` 的“Dashboard 开发与设计”。用户 Dashboard 与运营 Dashboard 共享基础视觉语言和组件体系,但拥有不同的信息架构、信息密度和权限边界。
|
||||||
|
- Dashboard 设计必须以当前真实 API/RPC contract 和数据结构为依据。若所需信息尚无后端 contract,应记录缺口,而不是在前端维护第二份业务状态或伪造指标。
|
||||||
|
|
||||||
|
发生冲突时遵循:`AGENTS.md` 与稳定产品/接口契约 > 当前明确任务需求 > `DESIGN.md` > Agent 自身设计偏好。
|
||||||
|
|
||||||
## 4. 用户、开发与运维指南
|
## 4. 用户、开发与运维指南
|
||||||
|
|
||||||
这些文件描述如何使用或验证已经存在的能力:
|
这些文件描述如何使用或验证已经存在的能力:
|
||||||
@@ -118,6 +130,8 @@
|
|||||||
|
|
||||||
## 8. 推荐阅读顺序
|
## 8. 推荐阅读顺序
|
||||||
|
|
||||||
|
### 8.1 项目与开发者通用阅读顺序
|
||||||
|
|
||||||
1. `README.md`
|
1. `README.md`
|
||||||
2. `CURRENT_STATUS.md`
|
2. `CURRENT_STATUS.md`
|
||||||
3. `PROJECT_PLAN.md`
|
3. `PROJECT_PLAN.md`
|
||||||
@@ -133,4 +147,17 @@
|
|||||||
13. `CONTRIBUTING.md`
|
13. `CONTRIBUTING.md`
|
||||||
14. `AGENTS.md`
|
14. `AGENTS.md`
|
||||||
|
|
||||||
阅读顺序中的状态和契约结论必须回到当前源码、测试和实际命令验证;历史报告只用于解释演进过程。
|
### 8.2 AI / Agent 开发接管顺序
|
||||||
|
|
||||||
|
Agent 进入仓库进行开发时优先按以下顺序建立上下文:
|
||||||
|
|
||||||
|
1. `AGENTS.md`:先确定长期规则、状态所有权和开发边界;
|
||||||
|
2. `DOCS_INDEX.md`:确认当前任务应阅读的权威文档;
|
||||||
|
3. `CURRENT_STATUS.md` 与对应专项状态文档:确认当前已经实现的事实;
|
||||||
|
4. `TODO.md`:确认当前具体任务、优先级、依赖和完成条件;
|
||||||
|
5. 当前任务直接相关的源码、tests、稳定 contract 和架构文档;
|
||||||
|
6. `docs/reports/CURRENT_GAPS.md` / `PROJECT_PLAN.md`:需要判断能力缺口或后续路线时再读取。
|
||||||
|
|
||||||
|
涉及 Dashboard、Web UI、页面布局、视觉样式、组件或交互体验时,在设计或修改前额外必须阅读 `DESIGN.md`。
|
||||||
|
|
||||||
|
阅读顺序中的状态和契约结论必须回到当前源码、测试和实际命令验证;`TODO.md`、`CURRENT_GAPS.md` 和 `PROJECT_PLAN.md` 均不能把计划项提升为已实现事实;历史报告只用于解释演进过程。
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help build build-ffi test clean check check-docs fmt lint install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
|
.PHONY: help build build-ffi test clean check check-docs check-go-format format fmt lint ci ci-check install dev docker-build docker-up docker-down official-smoke bat-api-local-live-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
|
||||||
|
|
||||||
# 默认目标
|
# 默认目标
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
@@ -96,12 +96,18 @@ check-go: ## 检查 Go 代码
|
|||||||
@echo "$(BLUE)Checking Go code...$(NC)"
|
@echo "$(BLUE)Checking Go code...$(NC)"
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
|
check-go-format: ## 检查 Go 格式(只读)
|
||||||
|
@echo "$(BLUE)Checking Go formatting...$(NC)"
|
||||||
|
bash scripts/check-go-format.sh
|
||||||
|
|
||||||
check-docs: ## 检查权威状态文档与占位目录声明
|
check-docs: ## 检查权威状态文档与占位目录声明
|
||||||
@echo "$(BLUE)Checking documentation status claims...$(NC)"
|
@echo "$(BLUE)Checking documentation status claims...$(NC)"
|
||||||
bash scripts/check-doc-status.sh
|
bash scripts/check-doc-status.sh
|
||||||
|
|
||||||
fmt: fmt-rust fmt-go ## 格式化所有代码
|
fmt: fmt-rust fmt-go ## 格式化所有代码
|
||||||
|
|
||||||
|
format: fmt ## 格式化所有代码(会修改工作树)
|
||||||
|
|
||||||
fmt-rust: ## 格式化 Rust 代码
|
fmt-rust: ## 格式化 Rust 代码
|
||||||
@echo "$(BLUE)Formatting Rust code...$(NC)"
|
@echo "$(BLUE)Formatting Rust code...$(NC)"
|
||||||
cargo fmt --all
|
cargo fmt --all
|
||||||
@@ -116,14 +122,19 @@ lint-rust: ## Rust Clippy 检查
|
|||||||
@echo "$(BLUE)Running Clippy...$(NC)"
|
@echo "$(BLUE)Running Clippy...$(NC)"
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
lint-go: ## Go Linter 检查
|
lint-go: ## Go Linter 检查(required)
|
||||||
@echo "$(BLUE)Running golangci-lint...$(NC)"
|
@echo "$(BLUE)Running golangci-lint...$(NC)"
|
||||||
@command -v golangci-lint >/dev/null 2>&1 || { echo "$(YELLOW)golangci-lint not installed, skipping...$(NC)"; exit 0; }
|
@. scripts/ci-versions.sh; \
|
||||||
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
|
command -v golangci-lint >/dev/null 2>&1 || { \
|
||||||
golangci-lint run ./...; \
|
echo "$(YELLOW)required gate failed: golangci-lint $${GOLANGCI_LINT_VERSION} is not installed$(NC)"; \
|
||||||
else \
|
exit 1; \
|
||||||
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
|
}; \
|
||||||
fi
|
actual="$$(golangci_lint_actual_version)"; \
|
||||||
|
test "$${actual}" = "$${GOLANGCI_LINT_VERSION}" || { \
|
||||||
|
echo "$(YELLOW)required gate failed: golangci-lint version required=$${GOLANGCI_LINT_VERSION} actual=$${actual:-unknown}$(NC)"; \
|
||||||
|
exit 1; \
|
||||||
|
}; \
|
||||||
|
XDG_CACHE_HOME="$${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" golangci-lint run ./...
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# 清理
|
# 清理
|
||||||
@@ -189,5 +200,7 @@ docs: ## 生成文档
|
|||||||
# CI/CD
|
# CI/CD
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
ci: fmt lint test ## 运行 CI 检查(本地模拟)
|
ci-check: ## 运行只读 required CI 门禁(含固定版本 Go lint)
|
||||||
@echo "$(GREEN)✓ All CI checks passed!$(NC)"
|
@bash scripts/ci-check.sh
|
||||||
|
|
||||||
|
ci: ci-check ## 运行只读 CI 检查(兼容旧命令名)
|
||||||
|
|||||||
+17
-16
@@ -32,7 +32,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
|
4. `bat-cas-engine` 已完成 CAS V1:原子写入、BLAKE3 Hash、SQLite 引用计数、GC、并发测试、损坏检测。
|
||||||
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
|
5. `bat-infrastructure` 已改为 CAS 仓储适配层,不再重复实现对象存储。
|
||||||
6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`。
|
6. `bat-infrastructure` 已提供官方资源 pull/update 服务,正式入口是 Rust binary `bat`。
|
||||||
7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`translation.*`、`localized.status`、`catalog.*`、`task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
7. `bat` 支持 `--auto-discover`、`--watch`、`--daemon`、默认 1 小时间隔、本地 manifest audit/repair、官方 seed `.hash` 校验、snapshot/cache,以及基于 Unix socket JSON-RPC 的 live control/backend 方法(`daemon.status/logs/stop/restart/reload/refresh/doctor`、`resource.sync/verify/repair/state/manifest/list/index`、`parse.status/text_units/errors`、`translation.*`、`localized.status`、`release.status/list/distribution/cleanup`、`catalog.*`、`task.*`);`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径,`clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||||
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
|
8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。
|
||||||
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release。
|
9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;`translation.proofread` 可把汉化 workflow 标记为人工校对中,但不会覆盖已发布的汉化 release。
|
||||||
10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。
|
10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。
|
||||||
@@ -41,12 +41,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
### 仍是骨架或占位
|
### 仍是骨架或占位
|
||||||
|
|
||||||
1. `bat-assetbundle` 已具备 UnityFS 解包和 TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes、TypeTree-covered managed reference payload TextUnit 上下文),并已有受支持 localized patch 发布能力;MonoBehaviour/ScriptableObject 复杂字段级解析、整体重打包和通用 Patch 仍未完成。
|
1. `bat-assetbundle` 已具备 UnityFS 解包、TextAsset/TypeTree 字段读取和 TextUnit 提取;对当前真实/合成回归覆盖的结构,UnityFS TextAsset、TypeTree string、managed-reference string 和语义字段已形成 parse→modify→rebuild→reparse 闭环,保留已识别压缩/对齐/目录形态并校验未修改对象/字段;所有真实版本差异、未知字段语义和任意复杂 AssetBundle 兼容仍未完成。
|
||||||
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService` 已完成受支持 TextUnit 到 localized patch manifest、独立 staging、发布和 rollback 闭环,通用 manifest 发布与整体 AssetBundle 重打包仍后置。
|
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest/builder、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService` 已用同一 generic manifest 完成受支持 Binary/JSON/Text/UnityFS 操作的独立 staging、发布和 rollback 闭环;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP,并在最终发布校验中重新解析和核对实际字段值,任意 AssetBundle 重打包仍后置。
|
||||||
3. Go 侧边界已确定(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发和内嵌 dashboard = `cmd/bat-api` MVP;`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / 完整 Web 协作后台 / SDK 仍未完成。
|
3. Go 侧边界已确定(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发和内嵌 dashboard = `cmd/bat-api` MVP;`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / 完整 Web 协作后台 / SDK 仍未完成。
|
||||||
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
|
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
|
||||||
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract,`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract,`translation.proofread` 已提供汉化 workflow 人工校对标记 contract,`translation.memory.*` 已提供 Rust-owned TM 摘要、raw source/context 查询、provenance 和显式 confirm contract,Go 侧仅代理。
|
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract,`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract,`translation.proofread` 已提供汉化 workflow 人工校对标记 contract,`translation.memory.*` 已提供 Rust-owned TM 摘要、raw source/context 查询、provenance 和显式 confirm contract,Go 侧仅代理。
|
||||||
6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 与项目级 Translation Memory V1 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。
|
6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP。真实 provider worker 与项目级 Translation Memory persistence schema V2 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。
|
||||||
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook;真实运行记录处于长期运行测试阶段,报告待后续提供。
|
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook;真实运行记录处于长期运行测试阶段,报告待后续提供。
|
||||||
8. 内嵌 dashboard MVP 已实现;完整 Web 协作后台、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发/OpenAPI/管理控制面已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。
|
8. 内嵌 dashboard MVP 已实现;完整 Web 协作后台、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发/OpenAPI/管理控制面已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。
|
||||||
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
|
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
|
||||||
@@ -169,7 +169,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
**目标**:能够获取、解析和同步 Blue Archive 资源清单。
|
**目标**:能够获取、解析和同步 Blue Archive 资源清单。
|
||||||
|
|
||||||
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;`bat-api` 资源 bootstrap/分发入口已落地,CAS + ResourceRepository 导入、历史 release/CAS 复用和 Translation Memory V1 已可用,但完整解析覆盖、丰富查询扩展和真实线上 smoke 仍待完成。
|
**当前状态**:部分完成。Rust 官方日服资源同步链路已经具备正式 one-shot 和 `--watch` 常驻入口;`bat-api` 资源 bootstrap/分发入口已落地,CAS + ResourceRepository 导入、历史 release/CAS 复用和 Translation Memory persistence schema V2 已可用,但完整解析覆盖、丰富查询扩展和真实线上 smoke 仍待完成。
|
||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
1. **解析缓存闭环**:官方同步发布后生成 `official-parse-cache.json`,覆盖 manifest 全部条目、直接 bundle、zip 内 bundle、非候选资源和解析失败诊断;未变化文件按 URL、相对路径、size 和 BLAKE3 复用解析结果。
|
1. **解析缓存闭环**:官方同步发布后生成 `official-parse-cache.json`,覆盖 manifest 全部条目、直接 bundle、zip 内 bundle、非候选资源和解析失败诊断;未变化文件按 URL、相对路径、size 和 BLAKE3 复用解析结果。
|
||||||
2. **Addressables 完整化**:覆盖 Windows/Android JSON、compact JSON 和后续二进制 catalog 入口,解析 provider、internal id、primary key、dependency、bundle name、hash、size、CRC 和资源类型。
|
2. **Addressables 完整化**:覆盖 Windows/Android JSON、compact JSON 和后续二进制 catalog 入口,解析 provider、internal id、primary key、dependency、bundle name、hash、size、CRC 和资源类型。
|
||||||
3. **UnityFS 容器层**:基础目标已完成 header、block info、directory、data block、LZ4/LZMA、alignment、总大小/计数/路径/边界错误、directory 文件提取和 UnityPy 真实样本回归;复杂版本差异和发布级重打包另行推进。
|
3. **UnityFS 容器层**:基础目标已完成 header、block info、directory、data block、LZ4/LZMA、alignment、总大小/计数/路径/边界错误、directory 文件提取和 UnityPy 真实样本回归;当前已验证结构另有压缩/对齐保留的变长发布级重建闭环,复杂版本差异和任意结构重打包另行推进。
|
||||||
4. **Serialized file 层**:稳定 Unity serialized file header、type table、TypeTree node、object table、path id、class id 和 raw object bytes 表示。
|
4. **Serialized file 层**:稳定 Unity serialized file header、type table、TypeTree node、object table、path id、class id 和 raw object bytes 表示。
|
||||||
5. **字段级解析层**:实现 TypeTree 字段 reader,支持 bool、integer、float、string、bytes、array、vector/staticvector 嵌套 `Array`、`List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField` 的 `m_Bits` backing field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 保留和同长度替换、TypeTree-covered managed reference / `SerializedReference` alias 和 TypeTree-covered managed reference registry 记录;managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` metadata 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,TextUnit 只提取 payload 字符串并按结构化 record、`RefIds[n]` 等记录前缀或子字段保留类型上下文;array/vector/List/HashSet/map 元素与 registry payload 字段保留独立 field path、offset 和 byte size,可支撑字符串元素、managed-reference registry payload 字段、基础语义字段 patch、enum/bit_field 语义 patch、固定值类型 patch、unknown fixed-size bytes patch、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体变长替换,`first/second` 与 `key/value` map entry schema 已有回归覆盖;解析模块当前仍有真实版本差异、未见样本驱动的完整 managed reference registry / map entry 变体和 unknown 字段结构语义需要继续推进。
|
5. **字段级解析层**:实现 TypeTree 字段 reader,支持 bool、integer、float、string、bytes、array、vector/staticvector 嵌套 `Array`、`List<T>` / `HashSet<T>` 集合 alias、map、PPtr、enum `value__` backing field、`LayerMask` / `BitField` 的 `m_Bits` backing field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 保留和同长度替换、TypeTree-covered managed reference / `SerializedReference` alias 和 TypeTree-covered managed reference registry 记录;managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` metadata 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,TextUnit 只提取 payload 字符串并按结构化 record、`RefIds[n]` 等记录前缀或子字段保留类型上下文;array/vector/List/HashSet/map 元素与 registry payload 字段保留独立 field path、offset 和 byte size,可支撑字符串元素、managed-reference registry payload 字段、基础语义字段 patch、enum/bit_field 语义 patch、固定值类型 patch、unknown fixed-size bytes patch、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体变长替换,`first/second` 与 `key/value` map entry schema 已有回归覆盖;解析模块当前仍有真实版本差异、未见样本驱动的完整 managed reference registry / map entry 变体和 unknown 字段结构语义需要继续推进。
|
||||||
6. **文本对象入口**:实现 TextAsset、MonoBehaviour、ScriptableObject 的可扩展提取入口,输出可追溯到 bundle、serialized file、path id 和 field path 的文本定位。
|
6. **文本对象入口**:实现 TextAsset、MonoBehaviour、ScriptableObject 的可扩展提取入口,输出可追溯到 bundle、serialized file、path id 和 field path 的文本定位。
|
||||||
@@ -260,10 +260,10 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
1. Translation Memory V1 已使用项目级 SQLite schema:source raw/hash、translation、完整 context、candidate/trusted 和 provenance。
|
1. Translation Memory persistence schema V2 已使用项目级 SQLite schema:source raw/hash、translation、完整 context、candidate/trusted、provenance、supersede 关系和 audit event。
|
||||||
2. 已实现 raw source + 完整 context exact match;模糊匹配、Glossary 联动和完整导入导出仍待实现。
|
2. 已实现 raw source + 完整 context exact match、current Trusted 唯一性和冲突诊断;模糊匹配和完整导入导出仍待实现。Glossary domain/feature contract V1 已由 SQLite persistence schema V2 承载,接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override。
|
||||||
3. 已实现显式 per-record confirm;术语优先级、别名、分类、冲突检测和审核队列仍待实现。
|
3. 已实现显式 per-record confirm、supersede 和历史冲突 resolve;Glossary 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。
|
||||||
4. 已实现 `bat i18n memory summary|query|confirm` 与对应 Rust RPC。
|
4. 已实现 `bat i18n memory summary|query|confirm|conflicts|resolve-conflict` 与对应 Rust RPC。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
@@ -306,7 +306,8 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
3. 实现客户端发现、路径校验、备份、应用、回滚。
|
3. 实现客户端发现、路径校验、备份、应用、回滚。
|
||||||
4. 实现 `patch build`、`patch apply`、`patch rollback`、`verify`。
|
4. 实现 `patch build`、`patch apply`、`patch rollback`、`verify`。
|
||||||
5. 实现 dry-run 和安全检查。
|
5. 实现 dry-run 和安全检查。
|
||||||
6. 将通用 Patch manifest 与汉化发布流程进一步统一。
|
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 保持独立。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
@@ -369,7 +370,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
交付物:
|
交付物:
|
||||||
|
|
||||||
1. 发布验证:format、lint、test、build、security audit、release artifact 由本地可重复命令、自托管 Gitea linux-runner workflow 与脚本承担;当前不引入托管 CI。
|
1. 发布验证:format、lint、test、build、security audit、release artifact 由本地可重复命令和脚本承担;项目以本地 `make ci-check` 作为唯一完整 required quality gate,当前不依赖 Gitea、GitHub Actions 或其它远端 CI runner。
|
||||||
2. Docker Compose:本地开发、服务端部署。
|
2. Docker Compose:本地开发、服务端部署。
|
||||||
3. 数据备份与恢复文档。
|
3. 数据备份与恢复文档。
|
||||||
4. 用户文档、开发文档、故障排查文档。
|
4. 用户文档、开发文档、故障排查文档。
|
||||||
@@ -387,7 +388,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
## 5. 推荐执行顺序
|
## 5. 推荐执行顺序
|
||||||
|
|
||||||
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是 Glossary、通用 manifest 发布、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
|
近期不要把内嵌 dashboard MVP 扩成完整协作后台或过早扩展 AI Provider。项目当前的真实瓶颈仍是完整 Web 术语协作视图、复杂 AssetBundle 重打包和真实官方资源长期运行验证。
|
||||||
|
|
||||||
建议顺序:
|
建议顺序:
|
||||||
|
|
||||||
@@ -405,7 +406,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
`docs/reports/GO_STATUS.md`:
|
`docs/reports/GO_STATUS.md`:
|
||||||
|
|
||||||
1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。
|
1. 继续 Addressables 结构变体与 UnityFS 复杂对象能力。
|
||||||
2. 基于 `translation.worker.run` 继续推进 Glossary 和 Patch 构建。
|
2. 基于 `translation.worker.run` 继续补充复杂 AssetBundle 的 Patch 构建与发布验证。
|
||||||
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
|
3. 继续扩展资源库剩余查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
|
||||||
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。
|
4. 在隔离环境执行真实官方网络长期运行 smoke,并保留运行报告。
|
||||||
|
|
||||||
@@ -461,9 +462,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。
|
按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。
|
||||||
|
|
||||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM 扩展、Glossary、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。
|
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory persistence schema V2、Glossary domain/feature contract V1(SQLite persistence schema V2)、通用 Binary/JSON/Text Patch 基础、generic manifest V1、已验证结构的 AssetBundle 变长重建、受支持 localized patch 发布/rollback、Rust-owned 双 release 查询/分发/cleanup V1,以及 Go `bat-api` 资源分发、内嵌 dashboard 和同机 live 联调。下一阶段的关键是 TM/Glossary 扩展、真实版本与复杂 AssetBundle 兼容和官方资源长期运行报告。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- **下一份应补充的验证材料**:真实官方网络 smoke 运行记录
|
- **下一份应补充的验证材料**:真实官方网络 smoke 运行记录
|
||||||
- **下一项工程任务**:推进 TM 扩展、Glossary、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
|
- **下一项工程任务**:推进 TM/Glossary 扩展、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
|
**BlueArchiveToolkit** 是一个面向长期维护的 Blue Archive 资源管理、解析、翻译和补丁工具套件。
|
||||||
|
|
||||||
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、CDN path 只读分发和内嵌管理 dashboard);`internal/backendrpc` 为 RPC client;`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、完整 Web 协作后台、复杂 AssetBundle 重打包和通用 Patch 发布仍在后续阶段。
|
当前仓库仍不是完整产品,但 Rust 侧已经具备一条可运行的官方日服资源同步链路:可以在 Linux 上通过官方 HTTP metadata 自动发现资源入口,拉取 Windows + Android 官方资源,保存同步 snapshot,校验本地下载清单,并用近乎全自动的 `--watch` / `--daemon` 常驻更新。Go module 名为 `bat-api`:正式 Go 入口是资源 bootstrap + 分发服务 `cmd/bat-api`(与 Rust `bat` 同环境运行,经 `bat.sock` RPC 周期发现 release 和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、CDN path 只读分发和内嵌管理 dashboard);`internal/backendrpc` 为 RPC client;`cmd/bat` 仅为试验骨架(产物 `bin/bat-go`,不是产品 CLI)。边界与进度见 [`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)。完整游戏业务 API、完整 Web 协作后台和复杂 AssetBundle 重打包仍在后续阶段。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,14 +13,14 @@
|
|||||||
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。
|
- `bat-adapters` Unity、Manifest、Client 集成框架,以及当前真实形态 Addressables catalog 解析覆盖,含 `m_Crc` 提取和 UnityFS 解包/TextAsset 提取基础校验。
|
||||||
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
- `bat-cas-engine` CAS V1:原子写入、BLAKE3 校验、引用计数、GC、并发写入测试、损坏检测。
|
||||||
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
|
- `bat-infrastructure` CAS 适配层、SQLite Resource Repository、资源导入服务、官方资源 pull/update 服务。
|
||||||
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`,report 按 plan 顺序、进度按完成数单调上报)、已发布历史 release 与 CAS 复用、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json`、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.*`、`resource.*`、`parse.*`、`translation.tasks/handoff/task.update/worker.run`、`translation.memory.*`、`localized.status`、`catalog.*`、`task.*`、`patch.apply`、`unityfs.patch_*`)。
|
- `bat`:官方资源自动发现、全量拉取、原子发布到 `current -> versions/<id>`、本地 manifest audit/repair、`.part` 断点续传、403/404/5xx 分类重试、指数退避、默认并发 8(可配置 `1..=256`,report 按 plan 顺序、进度按完成数单调上报)、已发布历史 release 与 CAS 复用、下载 quarantine 诊断、ZIP 结构校验、官方 seed `.hash` 校验、snapshot/cache、版本化 `official-launcher-bootstrap.json`、`--watch` 常驻更新、`--daemon` 后台运行,以及 Unix socket JSON-RPC live control/backend 方法(`daemon.*`、`resource.*`、`parse.*`、`translation.tasks/handoff/task.update/worker.run`、`translation.memory.*`、`translation.glossary.*`、`localized.status`、`catalog.*`、`task.*`、`patch.apply`、`unityfs.patch_*`)。
|
||||||
- `internal/backendrpc`:Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
|
- `internal/backendrpc`:Go 侧 typed Unix socket JSON-RPC client,是 `bat-api` 调用 Rust daemon 的默认路径。
|
||||||
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation/TM admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。
|
- `cmd/bat-api`:资源 bootstrap + 分发 HTTP MVP(G-009);`/v1/bootstrap` 和 `/v1/launcher/bootstrap` 组织 `bat` 已发布 release 的启动前资源入口,launcher 形状兼容端点仅输出资源 metadata / GameMainConfig 引导,`/healthz` 暴露 RPC refresh 诊断,`/readyz` 做 release readiness,CDN path 支持 `GET`/`HEAD`/`Range`、ETag、Last-Modified 和缓存头;玩家-facing 控制面已具备 token 鉴权、限流、访问日志、反代 IP 适配、动态 JSON no-store、OpenAPI、管理控制白名单、task/log/parse/translation/TM admin 查询控制入口和无构建内嵌 dashboard;`.env` 配置端口/RPC socket/刷新周期;生产资源根和长期状态来自 RPC,不负责自动拉取。
|
||||||
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(同步 CLI = Rust `bat`)。
|
- Go 边界权威说明:[`docs/reports/GO_STATUS.md`](docs/reports/GO_STATUS.md)(同步 CLI = Rust `bat`)。
|
||||||
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||||
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询索引,常用 metadata 过滤会下推到 SQLite;历史 release 复用会重新校验 size、BLAKE3 和 ZIP 结构,失败时按历史 release、CAS、网络顺序回退,CAS 引用记录在 `official-cas-reuse-references.json` 中;`bat doctor cas` 可只读诊断既有 CAS 目录、对象数、对象字节数和元数据库文件状态。
|
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询索引,常用 metadata 过滤会下推到 SQLite;历史 release 复用会重新校验 size、BLAKE3 和 ZIP 结构,失败时按历史 release、CAS、网络顺序回退,CAS 引用记录在 `official-cas-reuse-references.json` 中;`bat doctor cas` 可只读诊断既有 CAS 目录、对象数、对象字节数和元数据库文件状态。
|
||||||
- 新 release 发布后会生成 `official-resource-changes.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json`、`crowdin-translation-handoff.json`、`crowdin-textunit-queue.json`、`translation-tasks.sqlite` 和 `translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。
|
- 新 release 发布后会生成 `official-resource-changes.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json`、`crowdin-translation-handoff.json`、`crowdin-textunit-queue.json`、`translation-tasks.sqlite` 和 `translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。
|
||||||
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库和 Translation Memory V1;TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm 和 provenance 查询。Glossary、模糊匹配和完整 Provider 扩展体系仍待实现。
|
- `translation.worker.run` 已提供 Rust `bat` 的 mock/Crowdin provider worker,支持 lease、失败重试、TextUnit 译文结果落库、Translation Memory persistence schema V2 和 Glossary domain/feature contract V1(SQLite persistence schema V2);Glossary 独立于 release task/TM,支持全局与 TextUnit scope、alias、priority、approved review、冲突诊断、provider constraints、deletion audit 和确定性 QA。TM 独立于 release task 库,支持 candidate/trusted、完整 context exact match、显式 confirm、supersede、冲突诊断/解决和 provenance 查询。模糊匹配和完整 Provider 扩展体系仍待实现。
|
||||||
- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/<id>` 并切换 `current`,也可显式 rollback。
|
- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/<id>` 并切换 `current`,也可显式 rollback。
|
||||||
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,受支持 localized 发布通过独立 manifest/staging/current 流程完成。
|
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,受支持 localized 发布通过独立 manifest/staging/current 流程完成。
|
||||||
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
|
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
|
|
||||||
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。
|
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。
|
||||||
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
|
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
|
||||||
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段。
|
- 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前 generic manifest 已驱动已验证的 Binary/JSON/Text 与 UnityFS localized 操作,未知结构仍明确拒绝。
|
||||||
- Translation Memory、Glossary 和完整 Provider 扩展体系:其中 Translation Memory V1 已由 Rust `bat` 持有;仍未实现的是 Glossary、模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
|
- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory persistence schema V2 与 Glossary domain/feature contract V1(SQLite persistence schema V2)已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 Provider 扩展体系和完整 Web 协作后台。
|
||||||
- SDK、完整 Web 协作后台。
|
- SDK、完整 Web 协作后台。
|
||||||
|
|
||||||
详细状态见:
|
详细状态见:
|
||||||
@@ -60,16 +60,13 @@
|
|||||||
运行当前通用验证:
|
运行当前通用验证:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all -- --check
|
make ci-check
|
||||||
cargo check --workspace
|
|
||||||
cargo test --workspace
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
make test-go-api
|
|
||||||
make build-go-api
|
|
||||||
go vet ./...
|
|
||||||
make check-docs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make ci-check` 是只读 required 门禁;`make format` / `make fmt` 才会格式化源码。
|
||||||
|
Go lint 是 required gate,使用 `scripts/ci-versions.sh` 固定的
|
||||||
|
`golangci-lint 2.12.2`;工具缺失或版本不匹配都会失败。
|
||||||
|
|
||||||
查看官方同步命令:
|
查看官方同步命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -191,7 +188,7 @@ BlueArchiveToolkit/
|
|||||||
1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口和内嵌 dashboard;`cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,不应误写成完整产品 CLI。
|
1. 维护并联调 Go `bat-api` 资源 bootstrap/分发入口和内嵌 dashboard;`cmd/bat` 仅有 `doctor`、`manifest inspect`、`sync plan` 试验能力,不应误写成完整产品 CLI。
|
||||||
2. 补齐 AssetBundle UnityFS 引擎级解析。
|
2. 补齐 AssetBundle UnityFS 引擎级解析。
|
||||||
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
|
3. 扩展 Addressables catalog 解析覆盖,继续用真实形态 fixture/golden 锁定行为。
|
||||||
4. 基于 `translation.worker.run` provider worker 扩展 Glossary、完整 Patch 构建和发布/回滚闭环。
|
4. 基于 `translation.worker.run` provider worker 继续推进完整 Patch 构建和发布/回滚闭环。
|
||||||
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
|
5. 按 smoke runbook 在具备网络和磁盘窗口的环境中执行真实官方全量拉取,并保留本地报告。
|
||||||
|
|
||||||
当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在 TM 扩展、权限模型和持久化 API 明确后推进。
|
当前已提供直接调用 bat-api 鉴权接口的内嵌 dashboard;完整 Web 协作后台仍应在 TM 扩展、权限模型和持久化 API 明确后推进。
|
||||||
|
|||||||
+90
-13
@@ -59,7 +59,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon
|
|||||||
| `i18n status` | 显示当前汉化 release 状态 |
|
| `i18n status` | 显示当前汉化 release 状态 |
|
||||||
| `i18n task update` | 回写 provider worker 任务状态 |
|
| `i18n task update` | 回写 provider worker 任务状态 |
|
||||||
| `i18n worker run` | 运行真实 provider worker;支持单次、限定次数和周期执行 |
|
| `i18n worker run` | 运行真实 provider worker;支持单次、限定次数和周期执行 |
|
||||||
| `i18n publish` | 校验工作台并发布独立汉化 release;`--force` 使用新的手动 release ID |
|
| `i18n publish` | 按工作台或 `--patch-manifest` 发布独立汉化 release;`--force` 使用新的手动 release ID |
|
||||||
| `i18n schedule` | 管理翻译和汉化发布计划 |
|
| `i18n schedule` | 管理翻译和汉化发布计划 |
|
||||||
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
|
| `refresh` | 执行一次更新检查;若有 live daemon,则通过 RPC 请求其刷新 |
|
||||||
| `verify` | 校验远端计划、本地 manifest 和官方 seed hash(dry-run + 审计当前 release) |
|
| `verify` | 校验远端计划、本地 manifest 和官方 seed hash(dry-run + 审计当前 release) |
|
||||||
@@ -152,6 +152,9 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
|||||||
| `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token |
|
| `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token |
|
||||||
| `GET /admin/translation/memory/summary` | 读取 Rust TM schema、记录总数及 candidate/trusted 等状态计数;需要管理 token |
|
| `GET /admin/translation/memory/summary` | 读取 Rust TM schema、记录总数及 candidate/trusted 等状态计数;需要管理 token |
|
||||||
| `GET /admin/translation/memory/query?source_text=...&source_context=...&limit=100` | 按 raw source/context 查询 Rust TM 记录、复用判定和 provenance;需要管理 token |
|
| `GET /admin/translation/memory/query?source_text=...&source_context=...&limit=100` | 按 raw source/context 查询 Rust TM 记录、复用判定和 provenance;需要管理 token |
|
||||||
|
| `GET /admin/translation/glossary/summary` | 读取 Rust Glossary schema 和 review-state 计数;需要管理 token |
|
||||||
|
| `GET /admin/translation/glossary/query?source_text=...&review_status=approved&limit=100` | 查询 Rust term、scope、source provenance 和 history;需要管理 token |
|
||||||
|
| `GET /admin/translation/glossary/diagnose?source_text=...&context=...` | 执行 deterministic Glossary constraints/diagnostics QA 并返回 `qa_identity`;需要管理 token |
|
||||||
| `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token |
|
| `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token |
|
||||||
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
|
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
|
||||||
|
|
||||||
@@ -183,11 +186,17 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
|
|||||||
| `schedule-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report |
|
| `schedule-remove` | `schedule.remove` | `{ "id": "..." }` | `202` + Rust schedule report |
|
||||||
| `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 |
|
| `schedule-run` | `schedule.run` | 可选 `{ "id": "...", "force": true }` | `202` + 执行报告 |
|
||||||
| `task-cancel` | `task.cancel` | `{ "task_id": "..." }` | `202` + 取消请求结果 |
|
| `task-cancel` | `task.cancel` | `{ "task_id": "..." }` | `202` + 取消请求结果 |
|
||||||
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "completed", "provider": "manual", "provider_run_id": "...", "translation_results": [{ "unit_id": "...", "source_text": "...", "translated_text": "..." }] }` | `202` + 当前任务记录 |
|
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "completed", "provider": "manual", "provider_run_id": "...", "translation_results": [{ "unit_id": "...", "source_text": "...", "translated_text": "...", "glossary_override": { "qa_identity": "...", "reviewer": "...", "reason": "...", "provenance": "...", "confirmed_unix_seconds": 1 } }] }` | `202` + 当前任务记录 |
|
||||||
| `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task |
|
| `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task |
|
||||||
| `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 |
|
| `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 |
|
||||||
| `translation-memory-confirm` | `translation.memory.confirm` | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | `202` + 已确认的 TM 记录 |
|
| `translation-memory-confirm` | `translation.memory.confirm` | `{ "record_id": "...", "reviewer": "...", "reason": "...", "supersede_record_id": "..." }` | `202` + 已确认的 TM 记录 |
|
||||||
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }` 或 `{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest |
|
| `translation-memory-resolve-conflict` | `translation.memory.resolve_conflict` | `{ "winner_record_id": "...", "expected_trusted_record_ids": ["..."], "reviewer": "...", "reason": "..." }` | `202` + 冲突解决报告 |
|
||||||
|
| `translation-glossary-add` | `translation.glossary.add` | term draft JSON | `202` + Glossary term |
|
||||||
|
| `translation-glossary-update` | `translation.glossary.update` | term draft + `reviewer` | `202` + Glossary term |
|
||||||
|
| `translation-glossary-approve` | `translation.glossary.approve` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + approved term |
|
||||||
|
| `translation-glossary-deprecate` | `translation.glossary.deprecate` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deprecated term |
|
||||||
|
| `translation-glossary-delete` | `translation.glossary.delete` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deleted term snapshot |
|
||||||
|
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }`、`{ "from_worker": true, "localized_release_id": "..." }` 或 `{ "patch_manifest": "...", "localized_release_id": "..." }` | `202` + localized release manifest |
|
||||||
| `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report |
|
| `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report |
|
||||||
|
|
||||||
`stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
|
`stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
|
||||||
@@ -240,6 +249,7 @@ curl -i -H 'Range: bytes=0-1023' \
|
|||||||
| `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h |
|
| `--proxy <URL\|auto\|none>` | curl 代理覆盖(默认 `auto`,从环境变量检测)。scheme 支持 http/https/socks4/socks4a/socks5/socks5h |
|
||||||
| `--no-proxy` | 强制直连 |
|
| `--no-proxy` | 强制直连 |
|
||||||
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip`) |
|
| `--unzip <PATH>` | unzip 可执行文件(默认 `unzip`) |
|
||||||
|
| `--zip <PATH>` | zip 可执行文件(默认 `zip`) |
|
||||||
| `--dry-run` | 不写同步状态 |
|
| `--dry-run` | 不写同步状态 |
|
||||||
| `--plan` | dry-run 时输出计划中的 URL |
|
| `--plan` | dry-run 时输出计划中的 URL |
|
||||||
| `--force` | 强制下载/刷新 |
|
| `--force` | 强制下载/刷新 |
|
||||||
@@ -286,12 +296,12 @@ curl -i -H 'Range: bytes=0-1023' \
|
|||||||
- `config.toml` 的字段按职责分组:`[runtime]`、`[resource]`、`[localized]`、`[repository]`、`[network]`、`[translation.worker]`。
|
- `config.toml` 的字段按职责分组:`[runtime]`、`[resource]`、`[localized]`、`[repository]`、`[network]`、`[translation.worker]`。
|
||||||
- 现有 `BAT_*` 环境变量仍然有效,可继续覆盖 `config.toml` 中的同名配置。
|
- 现有 `BAT_*` 环境变量仍然有效,可继续覆盖 `config.toml` 中的同名配置。
|
||||||
- `BAT_SKIP_ENV_FILE` 已废弃且不再影响启动。
|
- `BAT_SKIP_ENV_FILE` 已废弃且不再影响启动。
|
||||||
- 支持的环境变量:`BAT_OUTPUT`、`BAT_LOCALIZED_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_IMPORT_REPOSITORY`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_DOWNLOAD_CONCURRENCY`、`BAT_UNZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`、`BAT_TRANSLATION_PROVIDER`、`BAT_TRANSLATION_FIXTURE`、`BAT_TRANSLATION_MEMORY_PATH`、`BAT_TRANSLATION_CONCURRENCY`、`BAT_TRANSLATION_MAX_ATTEMPTS`、`BAT_TRANSLATION_LEASE_SECONDS`、`BAT_TRANSLATION_RETRY_BACKOFF_SECONDS`、`BAT_TRANSLATION_MAX_TASKS`、`BAT_TRANSLATION_WORKER_ID`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。
|
- 支持的环境变量:`BAT_OUTPUT`、`BAT_LOCALIZED_OUTPUT`、`BAT_STATE_DIR`、`BAT_AUTO_DISCOVER`、`BAT_WATCH`、`BAT_DAEMON`、`BAT_IMPORT_REPOSITORY`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB`、`BAT_PROXY`、`BAT_NO_PROXY`、`BAT_INTERVAL_SECONDS`、`BAT_ERROR_RETRY_SECONDS`、`BAT_APP_VERSION`、`BAT_CONNECTION_GROUP`、`BAT_LAUNCHER_VERSION`、`BAT_PLATFORMS`、`BAT_CURL`、`BAT_DOWNLOAD_CONCURRENCY`、`BAT_UNZIP`、`BAT_ZIP`、`BAT_JSON`、`BAT_QUIET_UP_TO_DATE`、`BAT_TRANSLATION_PROVIDER`、`BAT_TRANSLATION_FIXTURE`、`BAT_TRANSLATION_MEMORY_PATH`、`BAT_GLOSSARY_PATH`、`BAT_TRANSLATION_CONCURRENCY`、`BAT_TRANSLATION_MAX_ATTEMPTS`、`BAT_TRANSLATION_LEASE_SECONDS`、`BAT_TRANSLATION_RETRY_BACKOFF_SECONDS`、`BAT_TRANSLATION_MAX_TASKS`、`BAT_TRANSLATION_WORKER_ID`;也可以直接写 `HTTPS_PROXY` 等通用环境变量(走现有代理自动检测)。布尔值支持 `1/0/true/false/yes/no/on/off`。
|
||||||
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时运行模式设置让位。`status` / `verify` 等子命令不受它们影响。
|
- `BAT_WATCH` / `BAT_DAEMON` 只对无子命令的 `bat` 生效(两者同时为 `1` 时 daemon 优先);命令行显式传入 `--watch` / `--daemon` / `--dry-run` 时运行模式设置让位。`status` / `verify` 等子命令不受它们影响。
|
||||||
- 已运行的 daemon 不会热读 `config.toml`;默认 `reload` 只唤醒后台重新发现和刷新。需要应用配置文件变更时,使用带显式启动参数的 `restart`/`reload`,或先 `stop` 再重新启动 daemon。
|
- 已运行的 daemon 不会热读 `config.toml`;默认 `reload` 只唤醒后台重新发现和刷新。需要应用配置文件变更时,使用带显式启动参数的 `restart`/`reload`,或先 `stop` 再重新启动 daemon。
|
||||||
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`。
|
- `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `<state-dir>/bat-tasks.json`。
|
||||||
|
|
||||||
### Translation Memory V1
|
### Translation Memory persistence schema V2
|
||||||
|
|
||||||
Translation Memory 由 Rust `bat` 独立持有,默认路径为
|
Translation Memory 由 Rust `bat` 独立持有,默认路径为
|
||||||
`<output>/translation-memory.sqlite`,不在 `versions/<id>` 内,也不使用当前 release
|
`<output>/translation-memory.sqlite`,不在 `versions/<id>` 内,也不使用当前 release
|
||||||
@@ -302,14 +312,52 @@ Translation Memory 由 Rust `bat` 独立持有,默认路径为
|
|||||||
bat i18n memory summary
|
bat i18n memory summary
|
||||||
bat i18n memory query --tm-source-text '原始文本' --tm-context-json '{"destination":"Table.bytes","archive_entry":"","field_path":"Text"}'
|
bat i18n memory query --tm-source-text '原始文本' --tm-context-json '{"destination":"Table.bytes","archive_entry":"","field_path":"Text"}'
|
||||||
bat i18n memory confirm --tm-record-id 'tm-...' --tm-reviewer 'operator' --tm-reason '人工校对通过'
|
bat i18n memory confirm --tm-record-id 'tm-...' --tm-reviewer 'operator' --tm-reason '人工校对通过'
|
||||||
|
bat i18n memory conflicts --tm-limit 100
|
||||||
|
bat i18n memory resolve-conflict --tm-record-id 'tm-winner-...' \
|
||||||
|
--tm-expected-trusted-record-ids-json '["tm-winner-...","tm-loser-..."]' \
|
||||||
|
--tm-reviewer 'operator' --tm-reason '确认唯一译文'
|
||||||
```
|
```
|
||||||
|
|
||||||
只有 raw source 完全相同、完整 context 完全相同且状态为 `trusted` 的记录会被 worker
|
只有 raw source 完全相同、完整 context 完全相同且状态为 current `trusted` 的单条记录会被
|
||||||
自动复用。provider 输出写入先是 `candidate`;manual task result 即使 completed 也不会自动
|
worker 自动复用。provider 输出写入先是 `candidate`;manual task result 即使 completed
|
||||||
建立 TM 或 trusted。查询、诊断和显式 confirm 对应 Rust
|
也不会自动建立 TM 或 trusted。查询、诊断、确认和冲突治理对应 Rust
|
||||||
RPC `translation.memory.summary`、`translation.memory.query`、`translation.memory.confirm`。
|
RPC `translation.memory.summary`、`translation.memory.query`、
|
||||||
context 不完整或不一致、normalized source 辅助命中和 workflow `proofread` 都不会自动
|
`translation.memory.confirm`、`translation.memory.conflicts` 和
|
||||||
建立 trusted 记录。
|
`translation.memory.resolve_conflict`。同一 identity 存在多个 current Trusted 时,
|
||||||
|
查询返回 `trusted_conflict`,worker 禁止自动复用;确认不同译文必须显式指定 supersede
|
||||||
|
目标,历史冲突必须通过 resolve_conflict 选择稳定 record ID。context 不完整或不一致、
|
||||||
|
normalized source 辅助命中和 workflow `proofread` 都不会自动建立 trusted 记录。
|
||||||
|
|
||||||
|
### Glossary domain/feature contract V1,SQLite persistence schema V2
|
||||||
|
|
||||||
|
Glossary 由 Rust `bat` 独立持有,默认路径为 `<output>/glossary.sqlite`,不位于
|
||||||
|
`versions/<id>`,也不与 TM 或当前 release 的 task 库共用。配置覆盖方式为
|
||||||
|
`[translation.worker].glossary_path`、`BAT_GLOSSARY_PATH` 或
|
||||||
|
`--glossary-path`。缺少数据库时 summary/query/diagnose 返回 `available=false`,
|
||||||
|
不会因只读查询创建空库。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bat i18n glossary summary
|
||||||
|
bat i18n glossary add --glossary-term-id term-sensei \
|
||||||
|
--glossary-source-term Sensei \
|
||||||
|
--glossary-recommended-translation 老师 \
|
||||||
|
--glossary-allowed-translations-json '["老师大人"]' \
|
||||||
|
--glossary-source-kind manual --glossary-source-ref issue-123
|
||||||
|
bat i18n glossary approve --glossary-term-id term-sensei \
|
||||||
|
--glossary-reviewer operator --glossary-reason '术语审校通过'
|
||||||
|
bat i18n glossary diagnose --glossary-source-text 'Sensei' \
|
||||||
|
--glossary-context-json '{"destination":"Table.bytes"}'
|
||||||
|
bat i18n glossary delete --glossary-term-id term-sensei \
|
||||||
|
--glossary-reviewer operator --glossary-reason '重复术语'
|
||||||
|
```
|
||||||
|
|
||||||
|
只有 `approved` term 会进入 provider constraints 和 TM 自动复用前的 QA。
|
||||||
|
scope 为空表示全局;同一 TextUnit 内冲突会 blocked,priority、scope specificity、
|
||||||
|
匹配长度和 term ID 使用确定性排序。允许但非推荐译法只产生 warning,系统不会在译文
|
||||||
|
生成后自动替换文本。provider、TM、人工 task update、workbench 和 localized publish
|
||||||
|
均执行相同 QA;每个具体 QA 都有稳定的 `qa_identity`。blocking deviation 需要
|
||||||
|
`qa_identity`、`reviewer`、`reason`、`provenance` 和确认时间组成显式 override;
|
||||||
|
Glossary 相关定义变化会使受影响 override 失效,无关术语变化不会使其失效。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -461,6 +509,10 @@ context 不完整或不一致、normalized source 辅助命中和 workflow `proo
|
|||||||
| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
|
| `resource.repair` | ✅ | 触发本地 manifest 审计 + 修复任务,返回 `task_id`;不继承 `force` |
|
||||||
| `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000) |
|
| `resource.manifest` / `resource.list` | ✅ | 当前版本下载 manifest 分页查询(`params.offset` 默认 0、`params.limit` 默认 100/上限 1000) |
|
||||||
| `resource.index` | ✅ | 查询现有 SQLite ResourceRepository 索引,支持资源类型、hash、路径模式、release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 过滤 |
|
| `resource.index` | ✅ | 查询现有 SQLite ResourceRepository 索引,支持资源类型、hash、路径模式、release、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 过滤 |
|
||||||
|
| `release.status` | ✅ | 查询 official/localized 当前与历史 release 的统一状态、来源关系和完整性 |
|
||||||
|
| `release.list` | ✅ | 按 namespace 查询历史 release 摘要,识别 current、legacy、stale 和 damaged |
|
||||||
|
| `release.distribution` | ✅ | 选择已验证的 official 或 localized 当前/历史 release;默认 official,不跨 channel fallback |
|
||||||
|
| `release.cleanup` | ✅ | 先 dry-run 生成 `plan_id`,再由 Rust 重验证引用后清理无引用历史 release |
|
||||||
| `parse.status` | ✅ | 查询当前 release 的解析缓存、TextUnit 索引和队列摘要 |
|
| `parse.status` | ✅ | 查询当前 release 的解析缓存、TextUnit 索引和队列摘要 |
|
||||||
| `parse.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 |
|
| `parse.text_units` / `parse.errors` | ✅ | 查询当前 release 的 TextUnit 明细和解析错误 |
|
||||||
| `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 |
|
| `translation.tasks` | ✅ | 查询离线 TextUnit 翻译任务及 worker 状态 |
|
||||||
@@ -483,10 +535,23 @@ context 不完整或不一致、normalized source 辅助命中和 workflow `proo
|
|||||||
| 未知方法 | — | `BAT-ERR-700001`(unknown method) |
|
| 未知方法 | — | `BAT-ERR-700001`(unknown method) |
|
||||||
|
|
||||||
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` /
|
只读查询(`daemon.doctor` / `resource.state` / `resource.manifest` / `resource.list` /
|
||||||
`resource.index` / `parse.*` / `translation.tasks` / `translation.handoff` /
|
`resource.index` / `release.status` / `release.list` / `release.distribution` / `parse.*` / `translation.tasks` / `translation.handoff` /
|
||||||
`localized.status` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本
|
`localized.status` / `catalog.status` / `catalog.versions` / `catalog.diff`)在尚无已发布版本
|
||||||
或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
|
或对应文件不存在时返回 `ok: true` 且 `data.available: false`(正常状态而非错误,便于调用方直接分支)。
|
||||||
|
|
||||||
|
`localized.status` 会分别返回 `patch_manifest_contract_status` 和
|
||||||
|
`artifact_integrity_status`。current 仍存在但 release 文件被截断或手工修改时,
|
||||||
|
状态为 `degraded` / `localized.degraded`,不会自动回滚、删除或修复。`release.distribution`
|
||||||
|
默认选择 official;只有 Rust 已验证的当前或显式历史 release 可分发,localized、
|
||||||
|
staging、损坏或路径不安全的 release 不会回退到另一 channel。
|
||||||
|
|
||||||
|
`release.list` 的每项摘要还会返回 `rollback_available`、`stale`、`damaged`、
|
||||||
|
`referenced` 和 `unknown`,便于区分可回滚、损坏和证据不足的历史 release。
|
||||||
|
|
||||||
|
双 release cleanup 不是独立 CLI:通过 `release.cleanup` 先执行 dry-run,再把返回的
|
||||||
|
`plan_id` 传给 `execute=true`。Rust 会保护 current、rollback、staging、source、
|
||||||
|
状态/manifest/CAS 引用和未知归属对象;rollback 仍使用独立的 `localized.rollback`。
|
||||||
|
|
||||||
### 任务模型
|
### 任务模型
|
||||||
|
|
||||||
`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
|
`resource.sync` / `resource.verify` / `resource.repair` / `catalog.refresh` 是**异步任务**:入队即返回 `{ "task_id": "task-<pid>-<seq>", "kind": "resource.sync" }`(`status: "accepted"`),实际执行由后台任务 worker 串行完成,通过 `task.status` / `task.list` 轮询。任务记录:
|
||||||
@@ -527,4 +592,16 @@ printf '{"jsonrpc":"2.0","id":5,"method":"resource.manifest","params":{"offset":
|
|||||||
# 触发本地资源审计+修复任务
|
# 触发本地资源审计+修复任务
|
||||||
printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \
|
printf '{"jsonrpc":"2.0","id":6,"method":"resource.repair"}\n' \
|
||||||
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||||
|
|
||||||
|
# 查询双 release 状态,并选择已验证的 localized release
|
||||||
|
printf '{"jsonrpc":"2.0","id":7,"method":"release.status"}\n' \
|
||||||
|
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||||
|
printf '{"jsonrpc":"2.0","id":8,"method":"release.distribution","params":{"channel":"localized"}}\n' \
|
||||||
|
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||||
|
|
||||||
|
# cleanup 必须先 dry-run,再使用同一 plan_id 执行
|
||||||
|
printf '{"jsonrpc":"2.0","id":9,"method":"release.cleanup","params":{"execute":false}}\n' \
|
||||||
|
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||||
|
printf '{"jsonrpc":"2.0","id":10,"method":"release.cleanup","params":{"execute":true,"plan_id":"<plan-id>"}}\n' \
|
||||||
|
| socat - UNIX-CONNECT:/tmp/bat-pid/bat.sock
|
||||||
```
|
```
|
||||||
|
|||||||
+271
-8
@@ -20,25 +20,25 @@ paths:
|
|||||||
summary: Release readiness
|
summary: Release readiness
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: A distributable release is available.
|
description: A current official release authorized by Rust release.attestation and fully represented by the bound local read snapshot is available.
|
||||||
"503":
|
"503":
|
||||||
description: No distributable release is available.
|
description: The Rust current attestation is unavailable, stale, invalid, or the bound local read snapshot is not distributable.
|
||||||
/v1/bootstrap:
|
/v1/bootstrap:
|
||||||
get:
|
get:
|
||||||
summary: Startup resource bootstrap
|
summary: Startup resource bootstrap
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Resource bootstrap response.
|
description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving.
|
||||||
"503":
|
"503":
|
||||||
description: Release is not ready.
|
description: The current release is not distributable.
|
||||||
/v1/launcher/bootstrap:
|
/v1/launcher/bootstrap:
|
||||||
get:
|
get:
|
||||||
summary: Launcher-shaped resource bootstrap
|
summary: Launcher-shaped resource bootstrap
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Launcher bootstrap response.
|
description: Launcher bootstrap response with the current release distribution health.
|
||||||
"503":
|
"503":
|
||||||
description: Release is not ready.
|
description: The current release is not distributable.
|
||||||
/api/launcher/game/config:
|
/api/launcher/game/config:
|
||||||
get:
|
get:
|
||||||
summary: Resource-only launcher game config compatibility
|
summary: Resource-only launcher game config compatibility
|
||||||
@@ -71,7 +71,58 @@ paths:
|
|||||||
summary: Current release summary
|
summary: Current release summary
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Release summary.
|
description: Release summary including Rust-owned whole-release distribution health.
|
||||||
|
/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: destination
|
||||||
|
in: query
|
||||||
|
description: Optional release-relative path for single-entry lookup; Rust returns exactly one entry and revalidates the selected channel's actual bytes and BLAKE3.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 1000
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Rust-verified selected release and resource manifest page.
|
||||||
|
"409":
|
||||||
|
description: Selected release is missing, stale, damaged, or not distributable.
|
||||||
|
"503":
|
||||||
|
description: Rust bat release backend is unavailable.
|
||||||
/v1/resources:
|
/v1/resources:
|
||||||
get:
|
get:
|
||||||
summary: Paginated resource manifest entries
|
summary: Paginated resource manifest entries
|
||||||
@@ -454,6 +505,111 @@ paths:
|
|||||||
description: Missing or invalid admin token.
|
description: Missing or invalid admin token.
|
||||||
"503":
|
"503":
|
||||||
description: Rust bat Translation Memory backend is unavailable.
|
description: Rust bat Translation Memory backend is unavailable.
|
||||||
|
/admin/translation/memory/conflicts:
|
||||||
|
get:
|
||||||
|
summary: List Rust-owned Translation Memory Trusted conflicts
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 1
|
||||||
|
maximum: 1000
|
||||||
|
default: 100
|
||||||
|
- name: translation_memory_path
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Translation Memory exact-identity Trusted conflict groups.
|
||||||
|
"400":
|
||||||
|
description: Invalid conflict list limit.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat Translation Memory backend is unavailable.
|
||||||
|
/admin/translation/glossary/summary:
|
||||||
|
get:
|
||||||
|
summary: Read Rust-owned Glossary summary
|
||||||
|
parameters:
|
||||||
|
- name: glossary_path
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Glossary availability and review-state counts.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat Glossary backend is unavailable.
|
||||||
|
/admin/translation/glossary/query:
|
||||||
|
get:
|
||||||
|
summary: Query Rust-owned Glossary terms
|
||||||
|
parameters:
|
||||||
|
- name: source_text
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: category
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: review_status
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [draft, approved, deprecated, rejected]
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 1
|
||||||
|
maximum: 1000
|
||||||
|
default: 100
|
||||||
|
- name: glossary_path
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Glossary terms with source and review history.
|
||||||
|
"400":
|
||||||
|
description: Invalid Glossary query.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat Glossary backend is unavailable.
|
||||||
|
/admin/translation/glossary/diagnose:
|
||||||
|
get:
|
||||||
|
summary: Run deterministic Glossary diagnostics
|
||||||
|
parameters:
|
||||||
|
- name: source_text
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: context
|
||||||
|
in: query
|
||||||
|
description: JSON object whose values are strings.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: glossary_path
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Glossary constraints, diagnostics, blocked decision, and stable qa_identity.
|
||||||
|
"400":
|
||||||
|
description: Missing source text or invalid context.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat Glossary backend is unavailable.
|
||||||
/admin/translation/status:
|
/admin/translation/status:
|
||||||
get:
|
get:
|
||||||
summary: Read Rust-owned localized release status
|
summary: Read Rust-owned localized release status
|
||||||
@@ -464,6 +620,32 @@ paths:
|
|||||||
description: Missing or invalid admin token.
|
description: Missing or invalid admin token.
|
||||||
"503":
|
"503":
|
||||||
description: Rust bat localized backend is unavailable.
|
description: Rust bat localized backend is unavailable.
|
||||||
|
/admin/releases/status:
|
||||||
|
get:
|
||||||
|
summary: Read the unified Rust-owned release status view
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Official/localized current relation and integrity status.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat release backend is unavailable.
|
||||||
|
/admin/releases:
|
||||||
|
get:
|
||||||
|
summary: Read Rust-owned historical release summaries
|
||||||
|
parameters:
|
||||||
|
- name: channel
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [official, localized]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Historical release summaries.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat release backend is unavailable.
|
||||||
/admin/control/{action}:
|
/admin/control/{action}:
|
||||||
post:
|
post:
|
||||||
summary: Forward an allowlisted control or schedule action to Rust bat
|
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||||
@@ -473,7 +655,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, 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-memory-resolve-conflict, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: false
|
required: false
|
||||||
content:
|
content:
|
||||||
@@ -539,6 +721,24 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
translated_text:
|
translated_text:
|
||||||
type: string
|
type: string
|
||||||
|
glossary_override:
|
||||||
|
type: object
|
||||||
|
required: [qa_identity, reviewer, reason, provenance, confirmed_unix_seconds]
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
qa_identity:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
reviewer:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
provenance:
|
||||||
|
type: string
|
||||||
|
confirmed_unix_seconds:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 1
|
||||||
fixture_path:
|
fixture_path:
|
||||||
type: string
|
type: string
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -566,8 +766,69 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
translation_memory_path:
|
translation_memory_path:
|
||||||
type: string
|
type: string
|
||||||
|
glossary_path:
|
||||||
|
type: string
|
||||||
record_id:
|
record_id:
|
||||||
type: string
|
type: string
|
||||||
|
winner_record_id:
|
||||||
|
type: string
|
||||||
|
expected_trusted_record_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
supersede_record_id:
|
||||||
|
type: string
|
||||||
|
reviewer:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
term_id:
|
||||||
|
type: string
|
||||||
|
source_term:
|
||||||
|
type: string
|
||||||
|
aliases:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
recommended_translation:
|
||||||
|
type: string
|
||||||
|
allowed_translations:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
source_language:
|
||||||
|
type: string
|
||||||
|
target_language:
|
||||||
|
type: string
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
priority:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
scope:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
review_status:
|
||||||
|
type: string
|
||||||
|
enum: [draft, approved, deprecated, rejected]
|
||||||
|
source:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [source_kind, observed_unix_seconds]
|
||||||
|
properties:
|
||||||
|
source_kind:
|
||||||
|
type: string
|
||||||
|
enum: [manual, imported]
|
||||||
|
source_ref:
|
||||||
|
type: string
|
||||||
|
source_author:
|
||||||
|
type: string
|
||||||
|
source_note:
|
||||||
|
type: string
|
||||||
|
observed_unix_seconds:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
reviewer:
|
reviewer:
|
||||||
type: string
|
type: string
|
||||||
reason:
|
reason:
|
||||||
@@ -576,6 +837,8 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
from_worker:
|
from_worker:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
patch_manifest:
|
||||||
|
type: string
|
||||||
localized_release_id:
|
localized_release_id:
|
||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@@ -9,7 +9,3 @@ func InspectManifest(rawJSON string) (string, error) {
|
|||||||
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
|
func BuildSyncPlan(currentJSON, previousJSON string) (string, error) {
|
||||||
return ffi.BuildSyncPlan(currentJSON, previousJSON)
|
return ffi.BuildSyncPlan(currentJSON, previousJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
func batVersion() (string, error) {
|
|
||||||
return ffi.Version()
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-2
@@ -21,6 +21,6 @@ func runSync(args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stdout, result)
|
_, err = fmt.Fprintln(os.Stdout, result)
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
blake3.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+11
-3
@@ -2,12 +2,19 @@
|
|||||||
|
|
||||||
pub mod game_client;
|
pub mod game_client;
|
||||||
pub mod game_version;
|
pub mod game_version;
|
||||||
|
pub mod glossary;
|
||||||
pub mod resource;
|
pub mod resource;
|
||||||
pub mod translation;
|
pub mod translation;
|
||||||
pub mod translation_memory;
|
pub mod translation_memory;
|
||||||
|
|
||||||
pub use game_client::{ClientStatus, GameClient, GameRegion};
|
pub use game_client::{ClientStatus, GameClient, GameRegion};
|
||||||
pub use game_version::{GameVersion, UnityVersion};
|
pub use game_version::{GameVersion, UnityVersion};
|
||||||
|
pub use glossary::{
|
||||||
|
evaluate_glossary, validate_glossary_draft, validate_glossary_override, GlossaryConstraint,
|
||||||
|
GlossaryDiagnostic, GlossaryDiagnosticKind, GlossaryEvaluation, GlossaryHistoryRecord,
|
||||||
|
GlossaryOverride, GlossaryQaReport, GlossaryQaStatus, GlossaryReviewStatus, GlossarySourceKind,
|
||||||
|
GlossarySourceRecord, GlossarySummary, GlossaryTerm, GlossaryTermDraft, GlossaryTermSnapshot,
|
||||||
|
};
|
||||||
pub use resource::{
|
pub use resource::{
|
||||||
crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType,
|
crc32_ieee, IntegrityMismatch, Resource, ResourceEntry, ResourceMetadata, ResourceType,
|
||||||
};
|
};
|
||||||
@@ -16,7 +23,8 @@ pub use translation::{
|
|||||||
TranslationStatus,
|
TranslationStatus,
|
||||||
};
|
};
|
||||||
pub use translation_memory::{
|
pub use translation_memory::{
|
||||||
TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry,
|
TranslationMemoryConflict, TranslationMemoryContext, TranslationMemoryDraft,
|
||||||
TranslationMemoryMatch, TranslationMemoryMatchKind, TranslationMemorySourceKind,
|
TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemoryMatchKind,
|
||||||
TranslationMemorySourceTrace, TranslationMemorySummary, TranslationMemoryTrustStatus,
|
TranslationMemorySourceKind, TranslationMemorySourceTrace, TranslationMemorySummary,
|
||||||
|
TranslationMemoryTrustStatus,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ impl TranslationMemoryTrustStatus {
|
|||||||
pub enum TranslationMemoryMatchKind {
|
pub enum TranslationMemoryMatchKind {
|
||||||
/// 原始 source 和上下文都完全匹配,且记录可信,可自动复用。
|
/// 原始 source 和上下文都完全匹配,且记录可信,可自动复用。
|
||||||
StrongExact,
|
StrongExact,
|
||||||
|
/// 同一 exact identity 存在多个 Trusted,必须人工治理。
|
||||||
|
TrustedConflict,
|
||||||
/// 原始 source 完全匹配,但上下文不同或不足,不能自动复用。
|
/// 原始 source 完全匹配,但上下文不同或不足,不能自动复用。
|
||||||
CandidateExact,
|
CandidateExact,
|
||||||
/// 原始 source 匹配,但上下文不兼容,不能自动复用。
|
/// 原始 source 匹配,但上下文不兼容,不能自动复用。
|
||||||
@@ -68,6 +70,7 @@ impl TranslationMemoryMatchKind {
|
|||||||
pub const fn as_str(&self) -> &'static str {
|
pub const fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::StrongExact => "strong_exact",
|
Self::StrongExact => "strong_exact",
|
||||||
|
Self::TrustedConflict => "trusted_conflict",
|
||||||
Self::CandidateExact => "candidate_exact",
|
Self::CandidateExact => "candidate_exact",
|
||||||
Self::SourceOnly => "source_only",
|
Self::SourceOnly => "source_only",
|
||||||
}
|
}
|
||||||
@@ -210,6 +213,23 @@ pub struct TranslationMemoryMatch {
|
|||||||
pub can_auto_reuse: bool,
|
pub can_auto_reuse: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 一个 exact identity 的历史多 Trusted 冲突组。
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct TranslationMemoryConflict {
|
||||||
|
/// 原始 source text。
|
||||||
|
pub source_text: String,
|
||||||
|
/// source text hash,仅用于稳定定位和辅助查询。
|
||||||
|
pub source_hash: String,
|
||||||
|
/// 完整 source context。
|
||||||
|
pub source_context: TranslationMemoryContext,
|
||||||
|
/// source context hash,仅用于稳定定位和辅助查询。
|
||||||
|
pub source_context_hash: String,
|
||||||
|
/// 当前数据库中属于该冲突组的 Trusted record ID。
|
||||||
|
pub trusted_record_ids: Vec<String>,
|
||||||
|
/// 冲突组记录及其原始 trust provenance。
|
||||||
|
pub records: Vec<TranslationMemoryEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
/// TM 仓储摘要。
|
/// TM 仓储摘要。
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct TranslationMemorySummary {
|
pub struct TranslationMemorySummary {
|
||||||
@@ -225,4 +245,8 @@ pub struct TranslationMemorySummary {
|
|||||||
pub superseded_count: u64,
|
pub superseded_count: u64,
|
||||||
/// 已拒绝记录数。
|
/// 已拒绝记录数。
|
||||||
pub rejected_count: u64,
|
pub rejected_count: u64,
|
||||||
|
/// exact identity 的 Trusted 冲突组数量。
|
||||||
|
pub trusted_conflict_group_count: u64,
|
||||||
|
/// 具备 current Trusted authorization 的 exact identity 数量。
|
||||||
|
pub current_trusted_count: u64,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Glossary repository boundary.
|
||||||
|
|
||||||
|
use crate::domain::{GlossaryEvaluation, TranslationMemoryContext};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
/// Read-only matching boundary consumed by translation workers.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait GlossaryRepository: Send + Sync {
|
||||||
|
/// Evaluates approved terms against one source TextUnit.
|
||||||
|
async fn evaluate(
|
||||||
|
&self,
|
||||||
|
source_text: &str,
|
||||||
|
context: &TranslationMemoryContext,
|
||||||
|
) -> crate::Result<GlossaryEvaluation>;
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@
|
|||||||
//! 定义所有数据访问接口
|
//! 定义所有数据访问接口
|
||||||
|
|
||||||
pub mod cas_repository;
|
pub mod cas_repository;
|
||||||
|
pub mod glossary_repository;
|
||||||
pub mod resource_repository;
|
pub mod resource_repository;
|
||||||
pub mod translation_memory_repository;
|
pub mod translation_memory_repository;
|
||||||
pub mod translation_repository;
|
pub mod translation_repository;
|
||||||
|
|
||||||
pub use cas_repository::CasRepository;
|
pub use cas_repository::CasRepository;
|
||||||
|
pub use glossary_repository::GlossaryRepository;
|
||||||
pub use resource_repository::ResourceRepository;
|
pub use resource_repository::ResourceRepository;
|
||||||
pub use translation_memory_repository::TranslationMemoryRepository;
|
pub use translation_memory_repository::TranslationMemoryRepository;
|
||||||
pub use translation_repository::TranslationRepository;
|
pub use translation_repository::TranslationRepository;
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
//! Translation Memory 仓储契约。
|
//! Translation Memory 仓储契约。
|
||||||
|
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry,
|
TranslationMemoryConflict, TranslationMemoryContext, TranslationMemoryDraft,
|
||||||
TranslationMemoryMatch, TranslationMemorySummary,
|
TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemorySummary,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
/// 跨 official release 持久化的 Translation Memory 仓储。
|
/// 跨 official release 持久化的 Translation Memory 仓储。
|
||||||
///
|
///
|
||||||
/// 该契约只描述 V1 的精确查询和明确人工确认。仓储实现不得把
|
/// 该契约描述 exact-match TM 和明确人工 Trusted 治理。仓储实现不得把
|
||||||
/// `TranslationTaskStatus::Completed` 或 provider 成功隐式解释为 trusted。
|
/// `TranslationTaskStatus::Completed` 或 provider 成功隐式解释为 trusted。
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait TranslationMemoryRepository: Send + Sync {
|
pub trait TranslationMemoryRepository: Send + Sync {
|
||||||
@@ -39,6 +39,27 @@ pub trait TranslationMemoryRepository: Send + Sync {
|
|||||||
reason: Option<String>,
|
reason: Option<String>,
|
||||||
) -> crate::Result<TranslationMemoryEntry>;
|
) -> crate::Result<TranslationMemoryEntry>;
|
||||||
|
|
||||||
|
/// 确认记录,并在需要时显式 supersede 当前唯一 Trusted。
|
||||||
|
async fn confirm_with_supersede(
|
||||||
|
&self,
|
||||||
|
record_id: &str,
|
||||||
|
reviewer: &str,
|
||||||
|
reason: Option<String>,
|
||||||
|
supersede_record_id: Option<&str>,
|
||||||
|
) -> crate::Result<TranslationMemoryEntry>;
|
||||||
|
|
||||||
|
/// 列出历史上存在多个 Trusted 的 exact identity 冲突组。
|
||||||
|
async fn list_conflicts(&self, limit: usize) -> crate::Result<Vec<TranslationMemoryConflict>>;
|
||||||
|
|
||||||
|
/// 使用事务内精确的 expected set 显式解决一个 Trusted 冲突组。
|
||||||
|
async fn resolve_conflict(
|
||||||
|
&self,
|
||||||
|
winner_record_id: &str,
|
||||||
|
expected_trusted_record_ids: &[String],
|
||||||
|
reviewer: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> crate::Result<TranslationMemoryEntry>;
|
||||||
|
|
||||||
/// 按稳定记录 ID 读取一条 TM 记录。
|
/// 按稳定记录 ID 读取一条 TM 记录。
|
||||||
async fn find(&self, record_id: &str) -> crate::Result<TranslationMemoryEntry>;
|
async fn find(&self, record_id: &str) -> crate::Result<TranslationMemoryEntry>;
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ pub mod types;
|
|||||||
pub use error::{AssetBundleError, Result};
|
pub use error::{AssetBundleError, Result};
|
||||||
pub use parser::{compression_from_flags, Parser, UnityFsParser};
|
pub use parser::{compression_from_flags, Parser, UnityFsParser};
|
||||||
pub use patch::{
|
pub use patch::{
|
||||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset,
|
||||||
StringFieldPatch, TextAssetPatch,
|
rebuild_unityfs_bundle, FieldPatch, StringFieldPatch, TextAssetPatch,
|
||||||
};
|
};
|
||||||
pub use serialized::{
|
pub use serialized::{
|
||||||
UnityManagedReferenceMetadata, UnityManagedReferenceRecord, UnitySerializedField,
|
UnityManagedReferenceMetadata, UnityManagedReferenceRecord, UnitySerializedField,
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ fn parse_unityfs(data: &[u8]) -> Result<UnityFsBundle> {
|
|||||||
compressed_data_size,
|
compressed_data_size,
|
||||||
uncompressed_data_size,
|
uncompressed_data_size,
|
||||||
raw_data: data.to_vec(),
|
raw_data: data.to_vec(),
|
||||||
|
uncompressed_data,
|
||||||
files,
|
files,
|
||||||
serialized_files,
|
serialized_files,
|
||||||
text_assets,
|
text_assets,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::parser::UnityFsParser;
|
|||||||
use crate::serialized::{
|
use crate::serialized::{
|
||||||
UnitySerializedField, UnitySerializedReplacementValue, UnitySerializedValue,
|
UnitySerializedField, UnitySerializedReplacementValue, UnitySerializedValue,
|
||||||
};
|
};
|
||||||
use crate::types::UnityFsBundle;
|
use crate::types::{UnityFsBundle, UnityFsCompression};
|
||||||
use md5::{Digest, Md5};
|
use md5::{Digest, Md5};
|
||||||
|
|
||||||
/// One TextAsset replacement inside a serialized UnityFS directory file.
|
/// One TextAsset replacement inside a serialized UnityFS directory file.
|
||||||
@@ -105,9 +105,9 @@ impl FieldPatch {
|
|||||||
|
|
||||||
/// Patches one TextAsset and rebuilds the UnityFS container.
|
/// Patches one TextAsset and rebuilds the UnityFS container.
|
||||||
///
|
///
|
||||||
/// The rebuilt bundle uses a single uncompressed data block. This keeps the
|
/// The rebuilt bundle retains the parsed block count, compression modes,
|
||||||
/// patch path deterministic and avoids relying on a compressor-specific
|
/// alignment flags and directory metadata while recalculating all variable
|
||||||
/// implementation while preserving all directory file paths and metadata.
|
/// offsets and sizes.
|
||||||
pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<Vec<u8>> {
|
pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<Vec<u8>> {
|
||||||
let parser = UnityFsParser::new();
|
let parser = UnityFsParser::new();
|
||||||
let mut bundle = parser.parse_bytes(data)?;
|
let mut bundle = parser.parse_bytes(data)?;
|
||||||
@@ -142,8 +142,25 @@ pub fn patch_unityfs_text_asset(data: &[u8], patch: &TextAssetPatch) -> Result<V
|
|||||||
|
|
||||||
let rebuilt = rebuild_unityfs(&bundle)?;
|
let rebuilt = rebuild_unityfs(&bundle)?;
|
||||||
let verified = parser.parse_bytes(&rebuilt)?;
|
let verified = parser.parse_bytes(&rebuilt)?;
|
||||||
let asset = verified
|
verify_rebuild_preserves_unmodified_content(
|
||||||
.text_assets
|
&bundle,
|
||||||
|
&verified,
|
||||||
|
&patch.serialized_file_path,
|
||||||
|
patch.path_id,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
let verified_serialized = verified
|
||||||
|
.serialized_files
|
||||||
|
.iter()
|
||||||
|
.find(|file| file.source_path.as_deref() == Some(patch.serialized_file_path.as_str()))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AssetBundleError::Parse(format!(
|
||||||
|
"patched serialized file {} was not found after rebuild",
|
||||||
|
patch.serialized_file_path
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let asset = verified_serialized
|
||||||
|
.text_assets()
|
||||||
.iter()
|
.iter()
|
||||||
.find(|asset| asset.path_id == patch.path_id)
|
.find(|asset| asset.path_id == patch.path_id)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -197,6 +214,13 @@ pub fn patch_unityfs_string_field(data: &[u8], patch: &StringFieldPatch) -> Resu
|
|||||||
|
|
||||||
let rebuilt = rebuild_unityfs(&bundle)?;
|
let rebuilt = rebuild_unityfs(&bundle)?;
|
||||||
let verified = parser.parse_bytes(&rebuilt)?;
|
let verified = parser.parse_bytes(&rebuilt)?;
|
||||||
|
verify_rebuild_preserves_unmodified_content(
|
||||||
|
&bundle,
|
||||||
|
&verified,
|
||||||
|
&patch.serialized_file_path,
|
||||||
|
patch.path_id,
|
||||||
|
Some(&patch.field_path),
|
||||||
|
)?;
|
||||||
let serialized = verified
|
let serialized = verified
|
||||||
.serialized_files
|
.serialized_files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -259,6 +283,13 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
|
|||||||
|
|
||||||
let rebuilt = rebuild_unityfs(&bundle)?;
|
let rebuilt = rebuild_unityfs(&bundle)?;
|
||||||
let verified = parser.parse_bytes(&rebuilt)?;
|
let verified = parser.parse_bytes(&rebuilt)?;
|
||||||
|
verify_rebuild_preserves_unmodified_content(
|
||||||
|
&bundle,
|
||||||
|
&verified,
|
||||||
|
&patch.serialized_file_path,
|
||||||
|
patch.path_id,
|
||||||
|
Some(&patch.field_path),
|
||||||
|
)?;
|
||||||
let serialized = verified
|
let serialized = verified
|
||||||
.serialized_files
|
.serialized_files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -285,7 +316,12 @@ pub fn patch_unityfs_field(data: &[u8], patch: &FieldPatch) -> Result<Vec<u8>> {
|
|||||||
Ok(rebuilt)
|
Ok(rebuilt)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
/// Rebuilds a parsed UnityFS bundle while preserving its container shape.
|
||||||
|
///
|
||||||
|
/// The directory order, path, flags, header version and block compression
|
||||||
|
/// modes are retained. Variable-length file changes are reflected in
|
||||||
|
/// directory offsets and block sizes; no raw offset is patched blindly.
|
||||||
|
pub fn rebuild_unityfs_bundle(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
||||||
if bundle.files.len() != bundle.directories.len() {
|
if bundle.files.len() != bundle.directories.len() {
|
||||||
return Err(AssetBundleError::Parse(format!(
|
return Err(AssetBundleError::Parse(format!(
|
||||||
"UnityFS file/directory count mismatch: files={}, directories={}",
|
"UnityFS file/directory count mismatch: files={}, directories={}",
|
||||||
@@ -293,24 +329,123 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|||||||
bundle.directories.len()
|
bundle.directories.len()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
let block_uncompressed_size = bundle
|
||||||
let mut uncompressed_data = Vec::new();
|
.blocks
|
||||||
let mut directory_offsets = Vec::with_capacity(bundle.files.len());
|
.iter()
|
||||||
for file in &bundle.files {
|
.try_fold(0u64, |total, block| {
|
||||||
let offset = u64::try_from(uncompressed_data.len())
|
total.checked_add(u64::from(block.uncompressed_size))
|
||||||
.map_err(|_| AssetBundleError::Parse("UnityFS data offset overflow".to_string()))?;
|
})
|
||||||
directory_offsets.push(offset);
|
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
|
||||||
uncompressed_data.extend_from_slice(&file.data);
|
let retained_uncompressed_size = u64::try_from(bundle.uncompressed_data.len())
|
||||||
|
.map_err(|_| AssetBundleError::Parse("UnityFS data size does not fit u64".to_string()))?;
|
||||||
|
if block_uncompressed_size != retained_uncompressed_size
|
||||||
|
|| bundle.uncompressed_data_size != retained_uncompressed_size
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS block table/data size mismatch: blocks={}, retained={}, declared={}",
|
||||||
|
block_uncompressed_size, retained_uncompressed_size, bundle.uncompressed_data_size
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ordered_directories = bundle.directories.iter().enumerate().collect::<Vec<_>>();
|
||||||
|
ordered_directories.sort_by_key(|(_, directory)| directory.offset);
|
||||||
|
let mut uncompressed_data = Vec::with_capacity(bundle.uncompressed_data.len());
|
||||||
|
let mut directory_offsets = vec![0u64; bundle.files.len()];
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
for (index, directory) in ordered_directories {
|
||||||
|
let file = bundle.files.get(index).ok_or_else(|| {
|
||||||
|
AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS directory {} has no corresponding file",
|
||||||
|
directory.path
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if file.path != directory.path {
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS file/directory path mismatch: file={:?}, directory={:?}",
|
||||||
|
file.path, directory.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let start = usize::try_from(directory.offset).map_err(|_| {
|
||||||
|
AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS directory {} offset does not fit usize",
|
||||||
|
directory.path
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let end = start
|
||||||
|
.checked_add(usize::try_from(directory.size).map_err(|_| {
|
||||||
|
AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS directory {} size does not fit usize",
|
||||||
|
directory.path
|
||||||
|
))
|
||||||
|
})?)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS directory {} range overflows usize",
|
||||||
|
directory.path
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if start < cursor || end > bundle.uncompressed_data.len() {
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS directory {} overlaps or exceeds the original data region",
|
||||||
|
directory.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
uncompressed_data.extend_from_slice(&bundle.uncompressed_data[cursor..start]);
|
||||||
|
directory_offsets[index] = u64::try_from(uncompressed_data.len()).map_err(|_| {
|
||||||
|
AssetBundleError::Parse("UnityFS directory offset overflow".to_string())
|
||||||
|
})?;
|
||||||
|
uncompressed_data.extend_from_slice(&file.data);
|
||||||
|
cursor = end;
|
||||||
|
}
|
||||||
|
uncompressed_data.extend_from_slice(&bundle.uncompressed_data[cursor..]);
|
||||||
|
|
||||||
|
let block_sizes = repartition_blocks(uncompressed_data.len(), &bundle.blocks)?;
|
||||||
|
let mut compressed_blocks = Vec::with_capacity(bundle.blocks.len());
|
||||||
|
for (index, (block, size)) in bundle.blocks.iter().zip(&block_sizes).enumerate() {
|
||||||
|
let start = block_sizes[..index].iter().sum::<usize>();
|
||||||
|
let end = start.checked_add(*size).ok_or_else(|| {
|
||||||
|
AssetBundleError::Parse(format!("UnityFS rebuilt block {index} range overflows"))
|
||||||
|
})?;
|
||||||
|
let bytes = uncompressed_data.get(start..end).ok_or_else(|| {
|
||||||
|
AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS rebuilt block {index} range {}..{} exceeds data {}",
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
uncompressed_data.len()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
compressed_blocks.push(compress_unityfs_bytes(
|
||||||
|
bytes,
|
||||||
|
block.compression,
|
||||||
|
&format!("UnityFS data block {index}"),
|
||||||
|
)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
let data_size = u32::try_from(uncompressed_data.len()).map_err(|_| {
|
|
||||||
AssetBundleError::Parse("UnityFS rebuilt data exceeds u32 size".to_string())
|
|
||||||
})?;
|
|
||||||
let mut blocks_info_body = Vec::new();
|
let mut blocks_info_body = Vec::new();
|
||||||
push_i32_be(&mut blocks_info_body, 1);
|
push_i32_be(
|
||||||
push_u32_be(&mut blocks_info_body, data_size);
|
&mut blocks_info_body,
|
||||||
push_u32_be(&mut blocks_info_body, data_size);
|
i32::try_from(bundle.blocks.len())
|
||||||
push_u16_be(&mut blocks_info_body, 0);
|
.map_err(|_| AssetBundleError::Parse("UnityFS block count exceeds i32".to_string()))?,
|
||||||
|
);
|
||||||
|
for (block, (bytes, uncompressed_size)) in bundle
|
||||||
|
.blocks
|
||||||
|
.iter()
|
||||||
|
.zip(compressed_blocks.iter().zip(block_sizes.iter()))
|
||||||
|
{
|
||||||
|
push_u32_be(
|
||||||
|
&mut blocks_info_body,
|
||||||
|
u32::try_from(*uncompressed_size).map_err(|_| {
|
||||||
|
AssetBundleError::Parse("UnityFS rebuilt block exceeds u32 size".to_string())
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
push_u32_be(
|
||||||
|
&mut blocks_info_body,
|
||||||
|
u32::try_from(bytes.len()).map_err(|_| {
|
||||||
|
AssetBundleError::Parse("UnityFS compressed block exceeds u32 size".to_string())
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
push_u16_be(&mut blocks_info_body, block.flags);
|
||||||
|
}
|
||||||
push_i32_be(
|
push_i32_be(
|
||||||
&mut blocks_info_body,
|
&mut blocks_info_body,
|
||||||
i32::try_from(bundle.files.len()).map_err(|_| {
|
i32::try_from(bundle.files.len()).map_err(|_| {
|
||||||
@@ -328,9 +463,14 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|||||||
push_c_string(&mut blocks_info_body, &file.path);
|
push_c_string(&mut blocks_info_body, &file.path);
|
||||||
}
|
}
|
||||||
let digest = Md5::digest(&blocks_info_body);
|
let digest = Md5::digest(&blocks_info_body);
|
||||||
let mut blocks_info = Vec::with_capacity(16 + blocks_info_body.len());
|
let mut blocks_info_uncompressed = Vec::with_capacity(16 + blocks_info_body.len());
|
||||||
blocks_info.extend_from_slice(&digest);
|
blocks_info_uncompressed.extend_from_slice(&digest);
|
||||||
blocks_info.extend_from_slice(&blocks_info_body);
|
blocks_info_uncompressed.extend_from_slice(&blocks_info_body);
|
||||||
|
let blocks_info = compress_unityfs_bytes(
|
||||||
|
&blocks_info_uncompressed,
|
||||||
|
compression_from_header_flags(bundle.header.flags),
|
||||||
|
"UnityFS block info",
|
||||||
|
)?;
|
||||||
|
|
||||||
let mut output = Vec::new();
|
let mut output = Vec::new();
|
||||||
push_c_string(&mut output, "UnityFS");
|
push_c_string(&mut output, "UnityFS");
|
||||||
@@ -342,27 +482,395 @@ fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
|||||||
push_u32_be(
|
push_u32_be(
|
||||||
&mut output,
|
&mut output,
|
||||||
u32::try_from(blocks_info.len()).map_err(|_| {
|
u32::try_from(blocks_info.len()).map_err(|_| {
|
||||||
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
|
AssetBundleError::Parse("UnityFS compressed block info exceeds u32 size".to_string())
|
||||||
})?,
|
})?,
|
||||||
);
|
);
|
||||||
push_u32_be(
|
push_u32_be(
|
||||||
&mut output,
|
&mut output,
|
||||||
u32::try_from(blocks_info.len()).map_err(|_| {
|
u32::try_from(blocks_info_uncompressed.len()).map_err(|_| {
|
||||||
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
|
AssetBundleError::Parse("UnityFS block info exceeds u32 size".to_string())
|
||||||
})?,
|
})?,
|
||||||
);
|
);
|
||||||
push_u32_be(&mut output, 0);
|
push_u32_be(&mut output, bundle.header.flags);
|
||||||
if bundle.header.format_version >= 7 {
|
if bundle.header.format_version >= 7 {
|
||||||
align_vec(&mut output, 16);
|
align_vec(&mut output, 16);
|
||||||
}
|
}
|
||||||
output.extend_from_slice(&blocks_info);
|
if block_info_at_end(bundle.header.flags) {
|
||||||
output.extend_from_slice(&uncompressed_data);
|
if block_data_aligned(bundle.header.flags) {
|
||||||
|
align_vec(&mut output, 16);
|
||||||
|
}
|
||||||
|
for bytes in &compressed_blocks {
|
||||||
|
output.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
output.extend_from_slice(&blocks_info);
|
||||||
|
} else {
|
||||||
|
output.extend_from_slice(&blocks_info);
|
||||||
|
if block_data_aligned(bundle.header.flags) {
|
||||||
|
align_vec(&mut output, 16);
|
||||||
|
}
|
||||||
|
for bytes in &compressed_blocks {
|
||||||
|
output.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
let total_size = u64::try_from(output.len())
|
let total_size = u64::try_from(output.len())
|
||||||
.map_err(|_| AssetBundleError::Parse("UnityFS rebuilt size overflow".to_string()))?;
|
.map_err(|_| AssetBundleError::Parse("UnityFS rebuilt size overflow".to_string()))?;
|
||||||
output[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
output[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
||||||
Ok(output)
|
Ok(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rebuild_unityfs(bundle: &UnityFsBundle) -> Result<Vec<u8>> {
|
||||||
|
rebuild_unityfs_bundle(bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compression_from_header_flags(flags: u32) -> UnityFsCompression {
|
||||||
|
crate::parser::compression_from_flags((flags & 0x3f) as u16)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_info_at_end(flags: u32) -> bool {
|
||||||
|
flags & 0x80 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_data_aligned(flags: u32) -> bool {
|
||||||
|
flags & 0x200 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repartition_blocks(
|
||||||
|
total_size: usize,
|
||||||
|
blocks: &[crate::types::UnityFsBlockInfo],
|
||||||
|
) -> Result<Vec<usize>> {
|
||||||
|
if blocks.is_empty() {
|
||||||
|
return Err(AssetBundleError::Parse(
|
||||||
|
"UnityFS bundle has no blocks to rebuild".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let original_total = blocks
|
||||||
|
.iter()
|
||||||
|
.try_fold(0u64, |total, block| {
|
||||||
|
total.checked_add(u64::from(block.uncompressed_size))
|
||||||
|
})
|
||||||
|
.ok_or_else(|| AssetBundleError::Parse("UnityFS block size overflow".to_string()))?;
|
||||||
|
if original_total == 0 {
|
||||||
|
return Ok(vec![0; blocks.len()]);
|
||||||
|
}
|
||||||
|
let total = u64::try_from(total_size)
|
||||||
|
.map_err(|_| AssetBundleError::Parse("UnityFS data size does not fit u64".to_string()))?;
|
||||||
|
let mut result = Vec::with_capacity(blocks.len());
|
||||||
|
let mut previous = 0u64;
|
||||||
|
let mut cumulative = 0u64;
|
||||||
|
for block in blocks {
|
||||||
|
cumulative = cumulative
|
||||||
|
.checked_add(u64::from(block.uncompressed_size))
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AssetBundleError::Parse("UnityFS block boundary overflow".to_string())
|
||||||
|
})?;
|
||||||
|
let boundary = total.checked_mul(cumulative).ok_or_else(|| {
|
||||||
|
AssetBundleError::Parse("UnityFS block boundary overflow".to_string())
|
||||||
|
})? / original_total;
|
||||||
|
result.push(usize::try_from(boundary - previous).map_err(|_| {
|
||||||
|
AssetBundleError::Parse("UnityFS rebuilt block size does not fit usize".to_string())
|
||||||
|
})?);
|
||||||
|
previous = boundary;
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compress_unityfs_bytes(
|
||||||
|
data: &[u8],
|
||||||
|
compression: UnityFsCompression,
|
||||||
|
context: &str,
|
||||||
|
) -> Result<Vec<u8>> {
|
||||||
|
match compression {
|
||||||
|
UnityFsCompression::None => Ok(data.to_vec()),
|
||||||
|
UnityFsCompression::Lz4 => {
|
||||||
|
lz4::block::compress(data, Some(lz4::block::CompressionMode::FAST(1)), false).map_err(
|
||||||
|
|error| {
|
||||||
|
AssetBundleError::Parse(format!("Failed to compress {context} as LZ4: {error}"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
UnityFsCompression::Lz4Hc => lz4::block::compress(
|
||||||
|
data,
|
||||||
|
Some(lz4::block::CompressionMode::HIGHCOMPRESSION(9)),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.map_err(|error| {
|
||||||
|
AssetBundleError::Parse(format!("Failed to compress {context} as LZ4HC: {error}"))
|
||||||
|
}),
|
||||||
|
UnityFsCompression::Lzma => {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
lzma_rs::lzma_compress(&mut std::io::Cursor::new(data), &mut output).map_err(
|
||||||
|
|error| {
|
||||||
|
AssetBundleError::Parse(format!(
|
||||||
|
"Failed to compress {context} as LZMA: {error}"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
UnityFsCompression::Unknown(value) => Err(AssetBundleError::UnsupportedFormat(format!(
|
||||||
|
"unsupported {context} compression flag: {value}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_rebuild_preserves_unmodified_content(
|
||||||
|
original: &UnityFsBundle,
|
||||||
|
rebuilt_bytes: &UnityFsBundle,
|
||||||
|
modified_serialized_path: &str,
|
||||||
|
modified_path_id: i64,
|
||||||
|
modified_field_path: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
if original.header.format_version != rebuilt_bytes.header.format_version
|
||||||
|
|| original.header.target_version != rebuilt_bytes.header.target_version
|
||||||
|
|| original.header.unity_version != rebuilt_bytes.header.unity_version
|
||||||
|
|| original.header.flags != rebuilt_bytes.header.flags
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(
|
||||||
|
"UnityFS rebuild changed immutable header metadata".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if original.blocks.len() != rebuilt_bytes.blocks.len()
|
||||||
|
|| original.directories.len() != rebuilt_bytes.directories.len()
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(
|
||||||
|
"UnityFS rebuild changed block or directory count".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if original.serialized_files.len() != rebuilt_bytes.serialized_files.len() {
|
||||||
|
return Err(AssetBundleError::Parse(
|
||||||
|
"UnityFS rebuild changed serialized-file count".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if original.serialized_parse_errors != rebuilt_bytes.serialized_parse_errors {
|
||||||
|
return Err(AssetBundleError::Parse(
|
||||||
|
"UnityFS rebuild changed serialized-file diagnostics".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (original, rebuilt) in original.blocks.iter().zip(&rebuilt_bytes.blocks) {
|
||||||
|
if original.flags != rebuilt.flags || original.compression != rebuilt.compression {
|
||||||
|
return Err(AssetBundleError::Parse(
|
||||||
|
"UnityFS rebuild changed block compression metadata".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (original, rebuilt) in original.directories.iter().zip(&rebuilt_bytes.directories) {
|
||||||
|
if original.path != rebuilt.path || original.flags != rebuilt.flags {
|
||||||
|
return Err(AssetBundleError::Parse(
|
||||||
|
"UnityFS rebuild changed directory path or flags".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (original_file, rebuilt_file) in original.files.iter().zip(&rebuilt_bytes.files) {
|
||||||
|
if original_file.path != modified_serialized_path
|
||||||
|
&& (original_file.path != rebuilt_file.path || original_file.data != rebuilt_file.data)
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS rebuild changed an unmodified directory file: {}",
|
||||||
|
original_file.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for original_file in &original.serialized_files {
|
||||||
|
let rebuilt_file = rebuilt_bytes
|
||||||
|
.serialized_files
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.source_path == original_file.source_path)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS rebuild lost serialized file {:?}",
|
||||||
|
original_file.source_path
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if original_file.source_path != rebuilt_file.source_path
|
||||||
|
|| original_file.version != rebuilt_file.version
|
||||||
|
|| original_file.unity_version != rebuilt_file.unity_version
|
||||||
|
|| original_file.platform != rebuilt_file.platform
|
||||||
|
|| original_file.types != rebuilt_file.types
|
||||||
|
|| original_file.objects.len() != rebuilt_file.objects.len()
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(
|
||||||
|
"UnityFS rebuild changed serialized-file metadata".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for (original_object, rebuilt_object) in
|
||||||
|
original_file.objects.iter().zip(&rebuilt_file.objects)
|
||||||
|
{
|
||||||
|
if original_object.path_id != rebuilt_object.path_id
|
||||||
|
|| original_object.type_index != rebuilt_object.type_index
|
||||||
|
|| original_object.class_id != rebuilt_object.class_id
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS rebuild changed object table entry path_id {}",
|
||||||
|
original_object.path_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let is_modified_object = original_file.source_path.as_deref()
|
||||||
|
== Some(modified_serialized_path)
|
||||||
|
&& original_object.path_id == modified_path_id;
|
||||||
|
if !is_modified_object && original_object.byte_size != rebuilt_object.byte_size {
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS rebuild changed unmodified object byte size path_id {}",
|
||||||
|
original_object.path_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if is_modified_object {
|
||||||
|
if let Some(field_path) = modified_field_path {
|
||||||
|
let original_fields = original_file.fields_for_object_entry(original_object)?;
|
||||||
|
let rebuilt_fields = rebuilt_file.fields_for_object_entry(rebuilt_object)?;
|
||||||
|
verify_unmodified_fields(
|
||||||
|
&original_fields,
|
||||||
|
&rebuilt_fields,
|
||||||
|
field_path,
|
||||||
|
original_object.path_id,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
let original_asset = original_file
|
||||||
|
.text_assets()
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.path_id == modified_path_id);
|
||||||
|
let rebuilt_asset = rebuilt_file
|
||||||
|
.text_assets()
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.path_id == modified_path_id);
|
||||||
|
if original_asset.map(|asset| &asset.name)
|
||||||
|
!= rebuilt_asset.map(|asset| &asset.name)
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"TextAsset path_id {} name changed while rebuilding",
|
||||||
|
modified_path_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if original_file.object_bytes(original_object)?
|
||||||
|
!= rebuilt_file.object_bytes(rebuilt_object)?
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"UnityFS rebuild changed unmodified object path_id {}",
|
||||||
|
original_object.path_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_unmodified_fields(
|
||||||
|
original: &[UnitySerializedField],
|
||||||
|
rebuilt: &[UnitySerializedField],
|
||||||
|
modified_field_path: &str,
|
||||||
|
path_id: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
if original.len() != rebuilt.len() {
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"Unity object path_id {path_id} changed field count while rebuilding"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for (original, rebuilt) in original.iter().zip(rebuilt) {
|
||||||
|
if original.path != rebuilt.path
|
||||||
|
|| original.name != rebuilt.name
|
||||||
|
|| original.type_name != rebuilt.type_name
|
||||||
|
|| original.type_tree_node_index != rebuilt.type_tree_node_index
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"Unity object path_id {path_id} changed field metadata at {}",
|
||||||
|
original.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !field_path_contains(&original.path, modified_field_path)
|
||||||
|
&& original.byte_size != rebuilt.byte_size
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"Unity object path_id {path_id} changed unmodified field byte size at {}",
|
||||||
|
original.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if original.path == modified_field_path {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
verify_unmodified_values(
|
||||||
|
&original.value,
|
||||||
|
&rebuilt.value,
|
||||||
|
modified_field_path,
|
||||||
|
path_id,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn field_path_contains(parent: &str, child: &str) -> bool {
|
||||||
|
child == parent
|
||||||
|
|| child
|
||||||
|
.strip_prefix(parent)
|
||||||
|
.is_some_and(|suffix| suffix.starts_with('.') || suffix.starts_with('['))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_unmodified_values(
|
||||||
|
original: &UnitySerializedValue,
|
||||||
|
rebuilt: &UnitySerializedValue,
|
||||||
|
modified_field_path: &str,
|
||||||
|
path_id: i64,
|
||||||
|
) -> Result<()> {
|
||||||
|
match (original, rebuilt) {
|
||||||
|
(UnitySerializedValue::Object(original), UnitySerializedValue::Object(rebuilt))
|
||||||
|
| (UnitySerializedValue::Array(original), UnitySerializedValue::Array(rebuilt))
|
||||||
|
| (UnitySerializedValue::Map(original), UnitySerializedValue::Map(rebuilt)) => {
|
||||||
|
verify_unmodified_fields(original, rebuilt, modified_field_path, path_id)
|
||||||
|
}
|
||||||
|
(
|
||||||
|
UnitySerializedValue::ManagedReference {
|
||||||
|
type_name: original_type,
|
||||||
|
metadata: original_metadata,
|
||||||
|
fields: original_fields,
|
||||||
|
bytes: original_bytes,
|
||||||
|
},
|
||||||
|
UnitySerializedValue::ManagedReference {
|
||||||
|
type_name: rebuilt_type,
|
||||||
|
metadata: rebuilt_metadata,
|
||||||
|
fields: rebuilt_fields,
|
||||||
|
bytes: rebuilt_bytes,
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if original_type != rebuilt_type
|
||||||
|
|| original_metadata != rebuilt_metadata
|
||||||
|
|| original_bytes != rebuilt_bytes
|
||||||
|
{
|
||||||
|
return Err(AssetBundleError::Parse(format!(
|
||||||
|
"Unity object path_id {path_id} changed managed-reference metadata"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
verify_unmodified_fields(
|
||||||
|
original_fields,
|
||||||
|
rebuilt_fields,
|
||||||
|
modified_field_path,
|
||||||
|
path_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(
|
||||||
|
UnitySerializedValue::ManagedReferenceRegistry {
|
||||||
|
references: _,
|
||||||
|
fields: original_fields,
|
||||||
|
},
|
||||||
|
UnitySerializedValue::ManagedReferenceRegistry {
|
||||||
|
references: _,
|
||||||
|
fields: rebuilt_fields,
|
||||||
|
},
|
||||||
|
) => verify_unmodified_fields(
|
||||||
|
original_fields,
|
||||||
|
rebuilt_fields,
|
||||||
|
modified_field_path,
|
||||||
|
path_id,
|
||||||
|
),
|
||||||
|
(original, rebuilt) if original != rebuilt => Err(AssetBundleError::Parse(format!(
|
||||||
|
"Unity object path_id {path_id} changed an unmodified field value"
|
||||||
|
))),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn find_field_value<'a>(
|
fn find_field_value<'a>(
|
||||||
fields: &'a [UnitySerializedField],
|
fields: &'a [UnitySerializedField],
|
||||||
field_path: &str,
|
field_path: &str,
|
||||||
@@ -489,6 +997,99 @@ mod tests {
|
|||||||
data
|
data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn synthetic_bundle_with_compressed_blocks(
|
||||||
|
files: &[(&str, &[u8])],
|
||||||
|
block_flags: u16,
|
||||||
|
header_flags: u32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut uncompressed = Vec::new();
|
||||||
|
let mut directory_offsets = Vec::with_capacity(files.len());
|
||||||
|
for (_, bytes) in files {
|
||||||
|
directory_offsets.push(uncompressed.len() as u64);
|
||||||
|
uncompressed.extend_from_slice(bytes);
|
||||||
|
}
|
||||||
|
let split = uncompressed.len() / 2;
|
||||||
|
let split = if uncompressed.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
split.max(1).min(uncompressed.len())
|
||||||
|
};
|
||||||
|
let chunks = [&uncompressed[..split], &uncompressed[split..]];
|
||||||
|
let compressed_chunks = chunks
|
||||||
|
.iter()
|
||||||
|
.map(|chunk| match block_flags & 0x3f {
|
||||||
|
0 => chunk.to_vec(),
|
||||||
|
1 => {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
lzma_rs::lzma_compress(&mut std::io::Cursor::new(chunk), &mut output).unwrap();
|
||||||
|
output
|
||||||
|
}
|
||||||
|
2..=4 => lz4::block::compress(chunk, None, false).unwrap(),
|
||||||
|
value => panic!("unsupported fixture compression {value}"),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut block_info_body = Vec::new();
|
||||||
|
push_i32_be(&mut block_info_body, chunks.len() as i32);
|
||||||
|
for (chunk, compressed) in chunks.iter().zip(&compressed_chunks) {
|
||||||
|
push_u32_be(&mut block_info_body, chunk.len() as u32);
|
||||||
|
push_u32_be(&mut block_info_body, compressed.len() as u32);
|
||||||
|
push_u16_be(&mut block_info_body, block_flags);
|
||||||
|
}
|
||||||
|
push_i32_be(&mut block_info_body, files.len() as i32);
|
||||||
|
for ((path, bytes), offset) in files.iter().zip(directory_offsets) {
|
||||||
|
push_u64_be(&mut block_info_body, offset);
|
||||||
|
push_u64_be(&mut block_info_body, bytes.len() as u64);
|
||||||
|
push_u32_be(&mut block_info_body, 0);
|
||||||
|
push_c_string(&mut block_info_body, path);
|
||||||
|
}
|
||||||
|
let mut block_info = vec![0; 16];
|
||||||
|
block_info.extend_from_slice(&block_info_body);
|
||||||
|
let block_info = match header_flags & 0x3f {
|
||||||
|
0 => block_info,
|
||||||
|
1 => {
|
||||||
|
let mut output = Vec::new();
|
||||||
|
lzma_rs::lzma_compress(&mut std::io::Cursor::new(&block_info), &mut output)
|
||||||
|
.unwrap();
|
||||||
|
output
|
||||||
|
}
|
||||||
|
2..=4 => lz4::block::compress(&block_info, None, false).unwrap(),
|
||||||
|
value => panic!("unsupported fixture block-info compression {value}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut data = Vec::new();
|
||||||
|
push_c_string(&mut data, "UnityFS");
|
||||||
|
push_u32_be(&mut data, 8);
|
||||||
|
push_c_string(&mut data, "5.x.x");
|
||||||
|
push_c_string(&mut data, "2021.3.56f2");
|
||||||
|
let total_size_offset = data.len();
|
||||||
|
push_u64_be(&mut data, 0);
|
||||||
|
push_u32_be(&mut data, block_info.len() as u32);
|
||||||
|
push_u32_be(&mut data, (16 + block_info_body.len()) as u32);
|
||||||
|
push_u32_be(&mut data, header_flags);
|
||||||
|
align_vec(&mut data, 16);
|
||||||
|
if header_flags & 0x80 != 0 {
|
||||||
|
if header_flags & 0x200 != 0 {
|
||||||
|
align_vec(&mut data, 16);
|
||||||
|
}
|
||||||
|
for compressed in &compressed_chunks {
|
||||||
|
data.extend_from_slice(compressed);
|
||||||
|
}
|
||||||
|
data.extend_from_slice(&block_info);
|
||||||
|
} else {
|
||||||
|
data.extend_from_slice(&block_info);
|
||||||
|
if header_flags & 0x200 != 0 {
|
||||||
|
align_vec(&mut data, 16);
|
||||||
|
}
|
||||||
|
for compressed in &compressed_chunks {
|
||||||
|
data.extend_from_slice(compressed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total_size = data.len() as u64;
|
||||||
|
data[total_size_offset..total_size_offset + 8].copy_from_slice(&total_size.to_be_bytes());
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
|
||||||
data.extend_from_slice(&value.to_le_bytes());
|
data.extend_from_slice(&value.to_le_bytes());
|
||||||
}
|
}
|
||||||
@@ -549,6 +1150,66 @@ mod tests {
|
|||||||
file
|
file
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn synthetic_serialized_two_text_assets(first: &[u8], second: &[u8]) -> Vec<u8> {
|
||||||
|
fn text_asset_object(bytes: &[u8]) -> Vec<u8> {
|
||||||
|
let mut object = Vec::new();
|
||||||
|
push_u32_le(&mut object, 8);
|
||||||
|
object.extend_from_slice(b"Scenario");
|
||||||
|
align_vec(&mut object, 4);
|
||||||
|
push_u32_le(&mut object, bytes.len() as u32);
|
||||||
|
object.extend_from_slice(bytes);
|
||||||
|
object
|
||||||
|
}
|
||||||
|
|
||||||
|
let first_object = text_asset_object(first);
|
||||||
|
let second_object = text_asset_object(second);
|
||||||
|
let mut object_data = Vec::new();
|
||||||
|
let first_offset = object_data.len() as u64;
|
||||||
|
object_data.extend_from_slice(&first_object);
|
||||||
|
let first_size = object_data.len() as u64 - first_offset;
|
||||||
|
let second_offset = object_data.len() as u64;
|
||||||
|
object_data.extend_from_slice(&second_object);
|
||||||
|
let second_size = object_data.len() as u64 - second_offset;
|
||||||
|
|
||||||
|
let mut metadata = Vec::new();
|
||||||
|
metadata.extend_from_slice(b"2021.3.56f2\0");
|
||||||
|
push_i32_le(&mut metadata, 19);
|
||||||
|
metadata.push(0);
|
||||||
|
push_i32_le(&mut metadata, 1);
|
||||||
|
push_i32_le(&mut metadata, 49);
|
||||||
|
metadata.push(0);
|
||||||
|
push_i16_le(&mut metadata, 0);
|
||||||
|
metadata.extend_from_slice(&[0; 16]);
|
||||||
|
push_i32_le(&mut metadata, 2);
|
||||||
|
align_vec(&mut metadata, 4);
|
||||||
|
for (path_id, offset, size) in [
|
||||||
|
(1i64, first_offset, first_size),
|
||||||
|
(2, second_offset, second_size),
|
||||||
|
] {
|
||||||
|
metadata.extend_from_slice(&path_id.to_le_bytes());
|
||||||
|
push_u64_le(&mut metadata, offset);
|
||||||
|
push_u32_le(&mut metadata, size as u32);
|
||||||
|
push_i32_le(&mut metadata, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let header_len = 48usize;
|
||||||
|
let data_offset = header_len + metadata.len();
|
||||||
|
let file_size = data_offset + object_data.len();
|
||||||
|
let mut file = Vec::new();
|
||||||
|
push_u32_be(&mut file, metadata.len() as u32);
|
||||||
|
push_u32_be(&mut file, file_size as u32);
|
||||||
|
push_u32_be(&mut file, 22);
|
||||||
|
push_u32_be(&mut file, 0);
|
||||||
|
file.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
push_u32_be(&mut file, metadata.len() as u32);
|
||||||
|
push_u64_be(&mut file, file_size as u64);
|
||||||
|
push_u64_be(&mut file, data_offset as u64);
|
||||||
|
push_u64_be(&mut file, 0);
|
||||||
|
file.extend_from_slice(&metadata);
|
||||||
|
file.extend_from_slice(&object_data);
|
||||||
|
file
|
||||||
|
}
|
||||||
|
|
||||||
fn synthetic_serialized_monobehaviour() -> Vec<u8> {
|
fn synthetic_serialized_monobehaviour() -> Vec<u8> {
|
||||||
let mut object_data = Vec::new();
|
let mut object_data = Vec::new();
|
||||||
push_u32_le(&mut object_data, 5);
|
push_u32_le(&mut object_data, 5);
|
||||||
@@ -1391,6 +2052,179 @@ mod tests {
|
|||||||
assert_eq!(parsed.unity_version, reparsed.unity_version);
|
assert_eq!(parsed.unity_version, reparsed.unity_version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebuild_preserves_compressed_blocks_alignment_and_untouched_files() {
|
||||||
|
let serialized = synthetic_serialized_text_asset(b"old");
|
||||||
|
let source = synthetic_bundle_with_compressed_blocks(
|
||||||
|
&[("CAB-scenario", &serialized), ("CAB-untouched", b"keep")],
|
||||||
|
2,
|
||||||
|
2 | 0x200,
|
||||||
|
);
|
||||||
|
let parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
|
||||||
|
assert_eq!(parsed.blocks.len(), 2);
|
||||||
|
assert_eq!(parsed.blocks[0].compression, UnityFsCompression::Lz4);
|
||||||
|
assert_eq!(parsed.header.flags, 2 | 0x200);
|
||||||
|
|
||||||
|
let patched = patch_unityfs_text_asset(
|
||||||
|
&source,
|
||||||
|
&TextAssetPatch {
|
||||||
|
serialized_file_path: "CAB-scenario".to_string(),
|
||||||
|
path_id: 1,
|
||||||
|
expected_name: Some("Scenario".to_string()),
|
||||||
|
replacement: b"a longer localized payload".to_vec(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(reparsed.blocks.len(), 2);
|
||||||
|
assert_eq!(reparsed.blocks[0].compression, UnityFsCompression::Lz4);
|
||||||
|
assert_eq!(reparsed.blocks[1].compression, UnityFsCompression::Lz4);
|
||||||
|
assert_eq!(reparsed.header.flags, parsed.header.flags);
|
||||||
|
assert_eq!(
|
||||||
|
reparsed
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.find(|file| file.path == "CAB-untouched")
|
||||||
|
.unwrap()
|
||||||
|
.data,
|
||||||
|
b"keep"
|
||||||
|
);
|
||||||
|
assert_eq!(reparsed.text_assets[0].bytes, b"a longer localized payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebuild_preserves_unmodified_serialized_objects() {
|
||||||
|
let serialized = synthetic_serialized_two_text_assets(b"old", b"keep-object");
|
||||||
|
let source = synthetic_bundle_with_path(&serialized, "CAB-scenario");
|
||||||
|
let patched = patch_unityfs_text_asset(
|
||||||
|
&source,
|
||||||
|
&TextAssetPatch {
|
||||||
|
serialized_file_path: "CAB-scenario".to_string(),
|
||||||
|
path_id: 1,
|
||||||
|
expected_name: Some("Scenario".to_string()),
|
||||||
|
replacement: b"localized".to_vec(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
reparsed
|
||||||
|
.text_assets
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.path_id == 1)
|
||||||
|
.unwrap()
|
||||||
|
.bytes,
|
||||||
|
b"localized"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reparsed
|
||||||
|
.text_assets
|
||||||
|
.iter()
|
||||||
|
.find(|asset| asset.path_id == 2)
|
||||||
|
.unwrap()
|
||||||
|
.bytes,
|
||||||
|
b"keep-object"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_rebuild_text_asset_verification_scopes_duplicate_path_id_to_serialized_file() {
|
||||||
|
let first = synthetic_serialized_text_asset(b"first");
|
||||||
|
let second = synthetic_serialized_text_asset(b"second");
|
||||||
|
let source = synthetic_bundle_with_compressed_blocks(
|
||||||
|
&[("CAB-first", &first), ("CAB-second", &second)],
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
let patched = patch_unityfs_text_asset(
|
||||||
|
&source,
|
||||||
|
&TextAssetPatch {
|
||||||
|
serialized_file_path: "CAB-second".to_string(),
|
||||||
|
path_id: 1,
|
||||||
|
expected_name: Some("Scenario".to_string()),
|
||||||
|
replacement: b"localized-second".to_vec(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
reparsed
|
||||||
|
.serialized_files
|
||||||
|
.iter()
|
||||||
|
.find(|file| file.source_path.as_deref() == Some("CAB-first"))
|
||||||
|
.unwrap()
|
||||||
|
.text_assets()[0]
|
||||||
|
.bytes,
|
||||||
|
b"first"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
reparsed
|
||||||
|
.serialized_files
|
||||||
|
.iter()
|
||||||
|
.find(|file| file.source_path.as_deref() == Some("CAB-second"))
|
||||||
|
.unwrap()
|
||||||
|
.text_assets()[0]
|
||||||
|
.bytes,
|
||||||
|
b"localized-second"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebuild_preserves_block_info_at_end_and_lzma_compression() {
|
||||||
|
let serialized = synthetic_serialized_text_asset(b"old");
|
||||||
|
let source = synthetic_bundle_with_compressed_blocks(
|
||||||
|
&[("CAB-scenario", &serialized)],
|
||||||
|
1,
|
||||||
|
1 | 0x80 | 0x200,
|
||||||
|
);
|
||||||
|
let patched = patch_unityfs_text_asset(
|
||||||
|
&source,
|
||||||
|
&TextAssetPatch {
|
||||||
|
serialized_file_path: "CAB-scenario".to_string(),
|
||||||
|
path_id: 1,
|
||||||
|
expected_name: None,
|
||||||
|
replacement: b"localized".to_vec(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let reparsed = UnityFsParser::new().parse_bytes(&patched).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(reparsed.header.flags, 1 | 0x80 | 0x200);
|
||||||
|
assert_eq!(reparsed.blocks[0].compression, UnityFsCompression::Lzma);
|
||||||
|
assert_eq!(reparsed.blocks[1].compression, UnityFsCompression::Lzma);
|
||||||
|
assert_eq!(reparsed.text_assets[0].bytes, b"localized");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebuild_rejects_unknown_compression_without_guessing() {
|
||||||
|
let source = synthetic_bundle(b"payload");
|
||||||
|
let mut parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
|
||||||
|
parsed.blocks[0].compression = UnityFsCompression::Unknown(63);
|
||||||
|
|
||||||
|
let error = rebuild_unityfs_bundle(&parsed).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
AssetBundleError::UnsupportedFormat(message)
|
||||||
|
if message.contains("unsupported UnityFS data block 0 compression flag: 63")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebuild_rejects_inconsistent_uncompressed_block_table() {
|
||||||
|
let source = synthetic_bundle(b"payload");
|
||||||
|
let mut parsed = UnityFsParser::new().parse_bytes(&source).unwrap();
|
||||||
|
parsed.blocks[0].uncompressed_size += 1;
|
||||||
|
|
||||||
|
let error = rebuild_unityfs_bundle(&parsed).unwrap_err().to_string();
|
||||||
|
|
||||||
|
assert!(error.contains("block table/data size mismatch"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn patches_text_asset_and_verifies_reparsed_payload() {
|
fn patches_text_asset_and_verifies_reparsed_payload() {
|
||||||
let original_text = "こんにちは".as_bytes();
|
let original_text = "こんにちは".as_bytes();
|
||||||
|
|||||||
@@ -1088,6 +1088,34 @@ impl UnitySerializedFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the raw payload bytes for one object table entry.
|
||||||
|
///
|
||||||
|
/// The returned slice is still owned by the parsed serialized file. It is
|
||||||
|
/// useful to compare untouched objects across a variable-length rebuild.
|
||||||
|
pub fn object_bytes(&self, object: &UnitySerializedObject) -> Result<&[u8]> {
|
||||||
|
let object_start = self
|
||||||
|
.data_offset
|
||||||
|
.checked_add(usize::try_from(object.byte_start).map_err(|_| {
|
||||||
|
AssetBundleError::Parse(format!(
|
||||||
|
"Unity object path_id {} byte_start does not fit usize",
|
||||||
|
object.path_id
|
||||||
|
))
|
||||||
|
})?)
|
||||||
|
.ok_or_else(|| AssetBundleError::Parse("Unity object offset overflow".to_string()))?;
|
||||||
|
let object_end = object_start
|
||||||
|
.checked_add(object.byte_size as usize)
|
||||||
|
.ok_or_else(|| AssetBundleError::Parse("Unity object size overflow".to_string()))?;
|
||||||
|
self.raw_data.get(object_start..object_end).ok_or_else(|| {
|
||||||
|
AssetBundleError::Parse(format!(
|
||||||
|
"Unity object path_id {} byte range {}..{} exceeds file size {}",
|
||||||
|
object.path_id,
|
||||||
|
object_start,
|
||||||
|
object_end,
|
||||||
|
self.raw_data.len()
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn rewrite_object_payload(
|
fn rewrite_object_payload(
|
||||||
&self,
|
&self,
|
||||||
target_index: usize,
|
target_index: usize,
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ pub struct UnityFsBundle {
|
|||||||
pub uncompressed_data_size: u64,
|
pub uncompressed_data_size: u64,
|
||||||
/// Original bytes retained for future extraction/serialization.
|
/// Original bytes retained for future extraction/serialization.
|
||||||
pub raw_data: Vec<u8>,
|
pub raw_data: Vec<u8>,
|
||||||
|
/// Uncompressed data region retained so rebuilds can preserve gaps and
|
||||||
|
/// trailing bytes that are not represented by directory entries.
|
||||||
|
pub(crate) uncompressed_data: Vec<u8>,
|
||||||
/// Files extracted from the UnityFS uncompressed data region.
|
/// Files extracted from the UnityFS uncompressed data region.
|
||||||
pub files: Vec<UnityFsFile>,
|
pub files: Vec<UnityFsFile>,
|
||||||
/// Serialized files parsed from UnityFS directory files.
|
/// Serialized files parsed from UnityFS directory files.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ serde.workspace = true
|
|||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
# 文件系统操作
|
# 文件系统操作
|
||||||
tokio = { workspace = true, features = ["fs", "io-util"] }
|
tokio = { workspace = true, features = ["fs", "io-util"] }
|
||||||
|
|||||||
@@ -84,6 +84,22 @@ impl SqliteRefCounter {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let _release_reference_table = Self::execute_query(
|
||||||
|
&self.pool,
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS cas_release_references (
|
||||||
|
release_id TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL CHECK(ordinal >= 0),
|
||||||
|
object_id TEXT NOT NULL,
|
||||||
|
released INTEGER NOT NULL CHECK(released IN (0, 1)),
|
||||||
|
PRIMARY KEY(release_id, ordinal)
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +299,107 @@ impl SqliteRefCounter {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically releases one durable release ownership record.
|
||||||
|
///
|
||||||
|
/// The ownership row and the reference decrement are committed in the
|
||||||
|
/// same SQLite transaction. Retrying the same `(ownership_id, ordinal)` is
|
||||||
|
/// therefore idempotent, while a different ownership keeps its own row and
|
||||||
|
/// reference count. The legacy SQL column name is retained for schema
|
||||||
|
/// compatibility.
|
||||||
|
pub async fn release_reference_once(
|
||||||
|
&self,
|
||||||
|
ownership_id: &str,
|
||||||
|
ordinal: u64,
|
||||||
|
hash: &Hash,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let mut transaction = self.pool.begin().await?;
|
||||||
|
let existing: Option<(String, i64)> = sqlx::query_as(
|
||||||
|
r#"
|
||||||
|
SELECT object_id, released
|
||||||
|
FROM cas_release_references
|
||||||
|
WHERE release_id = ?1 AND ordinal = ?2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(ownership_id)
|
||||||
|
.bind(ordinal as i64)
|
||||||
|
.fetch_optional(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some((object_id, released)) = existing {
|
||||||
|
if object_id != hash.to_string() {
|
||||||
|
return Err(CasError::Other(anyhow::anyhow!(
|
||||||
|
"CAS release ownership mismatch: ownership_id={} ordinal={} expected={} actual={}",
|
||||||
|
ownership_id,
|
||||||
|
ordinal,
|
||||||
|
object_id,
|
||||||
|
hash
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if released != 0 {
|
||||||
|
transaction.commit().await?;
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
return Err(CasError::Other(anyhow::anyhow!(
|
||||||
|
"CAS release ownership record is not in a retryable state: ownership_id={} ordinal={}",
|
||||||
|
ownership_id,
|
||||||
|
ordinal
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Self::now();
|
||||||
|
let updated: Option<i64> = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
UPDATE cas_objects
|
||||||
|
SET ref_count = ref_count - 1,
|
||||||
|
updated_at = ?1,
|
||||||
|
zero_ref_at = CASE WHEN ref_count = 1 THEN ?1 ELSE zero_ref_at END
|
||||||
|
WHERE hash = ?2 AND ref_count > 0
|
||||||
|
RETURNING ref_count
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.bind(hash.to_string())
|
||||||
|
.fetch_optional(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if updated.is_none() {
|
||||||
|
let exists: Option<i64> =
|
||||||
|
sqlx::query_scalar("SELECT ref_count FROM cas_objects WHERE hash = ?1")
|
||||||
|
.bind(hash.to_string())
|
||||||
|
.fetch_optional(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
if exists.is_some() {
|
||||||
|
return Err(CasError::ReferenceUnderflow(hash.to_string()));
|
||||||
|
}
|
||||||
|
return Err(CasError::ObjectNotFound(hash.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO cas_release_references(release_id, ordinal, object_id, released)
|
||||||
|
VALUES(?1, ?2, ?3, 1)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(ownership_id)
|
||||||
|
.bind(ordinal as i64)
|
||||||
|
.bind(hash.to_string())
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await?;
|
||||||
|
transaction.commit().await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the durable ledger contains any row for an ownership.
|
||||||
|
pub async fn has_release_ownership(&self, ownership_id: &str) -> Result<bool> {
|
||||||
|
let exists: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM cas_release_references WHERE release_id = ?1)",
|
||||||
|
)
|
||||||
|
.bind(ownership_id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(exists != 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -4,7 +4,25 @@ use crate::error::{CasError, Result};
|
|||||||
use crate::hash::{compute_hash, Hash};
|
use crate::hash::{compute_hash, Hash};
|
||||||
use crate::refcount::SqliteRefCounter;
|
use crate::refcount::SqliteRefCounter;
|
||||||
use crate::storage::{FileSystemStorage, Storage, StorageStats};
|
use crate::storage::{FileSystemStorage, Storage, StorageStats};
|
||||||
|
use std::fs::OpenOptions;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
struct CasOperationLock {
|
||||||
|
file: std::fs::File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CasOperationLock {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::flock(
|
||||||
|
std::os::unix::io::AsRawFd::as_raw_fd(&self.file),
|
||||||
|
libc::LOCK_UN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 文件系统 CAS repository。
|
/// 文件系统 CAS repository。
|
||||||
///
|
///
|
||||||
@@ -34,8 +52,16 @@ impl FileSystemCasRepository {
|
|||||||
&self.storage
|
&self.storage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn acquire_operation_lock(&self) -> Result<CasOperationLock> {
|
||||||
|
let path = self.storage.root().join(".cas-operation.lock");
|
||||||
|
tokio::task::spawn_blocking(move || acquire_operation_lock_sync(path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| CasError::Other(anyhow::anyhow!("CAS lock task failed: {error}")))?
|
||||||
|
}
|
||||||
|
|
||||||
/// 存储对象并增加引用计数。
|
/// 存储对象并增加引用计数。
|
||||||
pub async fn store(&self, data: &[u8]) -> Result<Hash> {
|
pub async fn store(&self, data: &[u8]) -> Result<Hash> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
let hash = compute_hash(data);
|
let hash = compute_hash(data);
|
||||||
let existed = self.storage.exists(&hash).await?;
|
let existed = self.storage.exists(&hash).await?;
|
||||||
let stored_hash = self.storage.put(data).await?;
|
let stored_hash = self.storage.put(data).await?;
|
||||||
@@ -70,17 +96,20 @@ impl FileSystemCasRepository {
|
|||||||
|
|
||||||
/// 读取对象并验证 Hash。
|
/// 读取对象并验证 Hash。
|
||||||
pub async fn get(&self, hash: &Hash) -> Result<Vec<u8>> {
|
pub async fn get(&self, hash: &Hash) -> Result<Vec<u8>> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
let data = self.storage.get(hash).await?;
|
let data = self.storage.get(hash).await?;
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查对象是否存在。
|
/// 检查对象是否存在。
|
||||||
pub async fn exists(&self, hash: &Hash) -> Result<bool> {
|
pub async fn exists(&self, hash: &Hash) -> Result<bool> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
self.storage.exists(hash).await
|
self.storage.exists(hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 增加引用计数。
|
/// 增加引用计数。
|
||||||
pub async fn add_reference(&self, hash: &Hash) -> Result<u64> {
|
pub async fn add_reference(&self, hash: &Hash) -> Result<u64> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
if !self.storage.exists(hash).await? {
|
if !self.storage.exists(hash).await? {
|
||||||
return Err(CasError::ObjectNotFound(hash.to_string()));
|
return Err(CasError::ObjectNotFound(hash.to_string()));
|
||||||
}
|
}
|
||||||
@@ -94,22 +123,26 @@ impl FileSystemCasRepository {
|
|||||||
|
|
||||||
/// 减少引用计数。
|
/// 减少引用计数。
|
||||||
pub async fn remove_reference(&self, hash: &Hash) -> Result<u64> {
|
pub async fn remove_reference(&self, hash: &Hash) -> Result<u64> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
self.ref_counter.remove_reference(hash).await
|
self.ref_counter.remove_reference(hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取引用计数。
|
/// 获取引用计数。
|
||||||
pub async fn get_reference_count(&self, hash: &Hash) -> Result<u64> {
|
pub async fn get_reference_count(&self, hash: &Hash) -> Result<u64> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
self.ref_counter.get_reference_count(hash).await
|
self.ref_counter.get_reference_count(hash).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回当前 GC 候选对象。
|
/// 返回当前 GC 候选对象。
|
||||||
pub async fn gc_candidates(&self) -> Result<Vec<Hash>> {
|
pub async fn gc_candidates(&self) -> Result<Vec<Hash>> {
|
||||||
self.ref_counter.zero_ref_objects().await
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
|
self.gc_candidates_unlocked().await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除引用计数为 0 的对象。
|
/// 删除引用计数为 0 的对象。
|
||||||
pub async fn gc(&self) -> Result<u64> {
|
pub async fn gc(&self) -> Result<u64> {
|
||||||
let candidates = self.gc_candidates().await?;
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
|
let candidates = self.gc_candidates_unlocked().await?;
|
||||||
let mut deleted = 0u64;
|
let mut deleted = 0u64;
|
||||||
|
|
||||||
for hash in candidates {
|
for hash in candidates {
|
||||||
@@ -130,10 +163,52 @@ impl FileSystemCasRepository {
|
|||||||
Ok(deleted)
|
Ok(deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Releases one release-owned reference exactly once.
|
||||||
|
pub async fn release_reference_once(
|
||||||
|
&self,
|
||||||
|
ownership_id: &str,
|
||||||
|
ordinal: u64,
|
||||||
|
hash: &Hash,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
|
self.ref_counter
|
||||||
|
.release_reference_once(ownership_id, ordinal, hash)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the durable release ownership ledger has any row.
|
||||||
|
pub async fn has_release_ownership(&self, ownership_id: &str) -> Result<bool> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
|
self.ref_counter.has_release_ownership(ownership_id).await
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取存储统计信息。
|
/// 获取存储统计信息。
|
||||||
pub async fn stats(&self) -> Result<StorageStats> {
|
pub async fn stats(&self) -> Result<StorageStats> {
|
||||||
|
let _lock = self.acquire_operation_lock().await?;
|
||||||
self.storage.stats().await
|
self.storage.stats().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn gc_candidates_unlocked(&self) -> Result<Vec<Hash>> {
|
||||||
|
self.ref_counter.zero_ref_objects().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acquire_operation_lock_sync(path: PathBuf) -> Result<CasOperationLock> {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(path)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let result =
|
||||||
|
unsafe { libc::flock(std::os::unix::io::AsRawFd::as_raw_fd(&file), libc::LOCK_EX) };
|
||||||
|
if result != 0 {
|
||||||
|
return Err(CasError::Io(std::io::Error::last_os_error()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(CasOperationLock { file })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -228,6 +303,53 @@ mod tests {
|
|||||||
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 1);
|
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cross_repository_gc_and_store_preserve_object_lifetime() {
|
||||||
|
let (temp_dir, repo) = temp_repo().await;
|
||||||
|
let hash = repo.store(b"cross-process lifetime").await.unwrap();
|
||||||
|
assert_eq!(repo.remove_reference(&hash).await.unwrap(), 0);
|
||||||
|
|
||||||
|
let other = FileSystemCasRepository::new(temp_dir.path()).await.unwrap();
|
||||||
|
let (gc_result, store_result) =
|
||||||
|
tokio::join!(repo.gc(), other.store(b"cross-process lifetime"));
|
||||||
|
|
||||||
|
gc_result.unwrap();
|
||||||
|
assert_eq!(store_result.unwrap(), hash);
|
||||||
|
assert_eq!(other.get_reference_count(&hash).await.unwrap(), 1);
|
||||||
|
assert_eq!(other.get(&hash).await.unwrap(), b"cross-process lifetime");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn release_reference_is_idempotent_after_retry() {
|
||||||
|
let (_temp_dir, repo) = temp_repo().await;
|
||||||
|
let hash = repo.store(b"owned").await.unwrap();
|
||||||
|
assert!(repo
|
||||||
|
.release_reference_once("release-a", 0, &hash)
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
assert!(!repo
|
||||||
|
.release_reference_once("release-a", 0, &hash)
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn release_reference_ownership_is_scoped_per_release() {
|
||||||
|
let (_temp_dir, repo) = temp_repo().await;
|
||||||
|
let hash = repo.store(b"shared ownership").await.unwrap();
|
||||||
|
assert_eq!(repo.add_reference(&hash).await.unwrap(), 2);
|
||||||
|
assert!(repo
|
||||||
|
.release_reference_once("release-a", 0, &hash)
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
assert!(repo
|
||||||
|
.release_reference_once("release-b", 0, &hash)
|
||||||
|
.await
|
||||||
|
.unwrap());
|
||||||
|
assert_eq!(repo.get_reference_count(&hash).await.unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn corrupted_object_is_detected_through_repository() {
|
async fn corrupted_object_is_detected_through_repository() {
|
||||||
let (_temp_dir, repo) = temp_repo().await;
|
let (_temp_dir, repo) = temp_repo().await;
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ pub mod text;
|
|||||||
|
|
||||||
pub use error::{PatchError, Result};
|
pub use error::{PatchError, Result};
|
||||||
pub use manifest::{
|
pub use manifest::{
|
||||||
PatchIntegrity, PatchKind, PatchManifest, PatchManifestFile, PatchRollback,
|
build_patch_manifest, validate_patch_manifest, verify_patch_file_bytes, PatchIntegrity,
|
||||||
PATCH_MANIFEST_VERSION,
|
PatchKind, PatchManifest, PatchManifestBuildFile, PatchManifestFile, PatchManifestOperation,
|
||||||
|
PatchManifestOperationPayload, PatchManifestProvenance, PatchRollback, PATCH_MANIFEST_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Patch 引擎版本号
|
/// Patch 引擎版本号
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Patch manifest, integrity and rollback primitives.
|
//! Patch manifest, integrity and rollback primitives.
|
||||||
|
|
||||||
use crate::PatchError;
|
use crate::{binary::BinaryPatch, json::JsonPatchOperation, text::TextPatch, PatchError};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
@@ -52,10 +53,30 @@ pub struct PatchManifestFile {
|
|||||||
pub source_size: u64,
|
pub source_size: u64,
|
||||||
/// Expected target byte length.
|
/// Expected target byte length.
|
||||||
pub target_size: u64,
|
pub target_size: u64,
|
||||||
|
/// Ordered operations that produce the target bytes.
|
||||||
|
#[serde(default)]
|
||||||
|
pub operations: Vec<PatchManifestOperation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input specification for building one manifest file from verified source and
|
||||||
|
/// target release roots.
|
||||||
|
///
|
||||||
|
/// Operation payloads and provenance are deliberately independent from
|
||||||
|
/// filesystem metadata. This keeps the manifest builder usable for UnityFS operations,
|
||||||
|
/// whose bytes are produced by `bat-assetbundle`, while still requiring the
|
||||||
|
/// resulting source and target files to exist and match the recorded manifest.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PatchManifestBuildFile {
|
||||||
|
/// Release-relative path.
|
||||||
|
pub path: PathBuf,
|
||||||
|
/// Patch kind declared for this file.
|
||||||
|
pub patch_kind: PatchKind,
|
||||||
|
/// Ordered operations, including archive and provenance metadata.
|
||||||
|
pub operations: Vec<PatchManifestOperation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Patch algorithm family used by one manifest file.
|
/// Patch algorithm family used by one manifest file.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum PatchKind {
|
pub enum PatchKind {
|
||||||
/// Deterministic binary hunk patch.
|
/// Deterministic binary hunk patch.
|
||||||
@@ -65,7 +86,185 @@ pub enum PatchKind {
|
|||||||
/// UTF-8 text patch.
|
/// UTF-8 text patch.
|
||||||
Text,
|
Text,
|
||||||
/// UnityFS TextAsset replacement patch.
|
/// UnityFS TextAsset replacement patch.
|
||||||
|
#[serde(rename = "unityfs_text_asset")]
|
||||||
UnityFsTextAsset,
|
UnityFsTextAsset,
|
||||||
|
/// UnityFS TypeTree string-field replacement patch.
|
||||||
|
#[serde(rename = "unityfs_string_field")]
|
||||||
|
UnityFsStringField,
|
||||||
|
/// UnityFS semantic TypeTree field replacement patch.
|
||||||
|
#[serde(rename = "unityfs_field")]
|
||||||
|
UnityFsField,
|
||||||
|
/// A file containing more than one supported operation kind.
|
||||||
|
Mixed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One ordered, auditable operation in a manifest file.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PatchManifestOperation {
|
||||||
|
/// Stable zero-based order within the file.
|
||||||
|
pub sequence: u32,
|
||||||
|
/// Optional BLAKE3 hash of the bytes immediately before this operation.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_blake3: Option<String>,
|
||||||
|
/// Optional size of the bytes immediately before this operation.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_size: Option<u64>,
|
||||||
|
/// Optional archive entry for a UnityFS bundle nested in a ZIP.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub archive_entry: Option<String>,
|
||||||
|
/// Algorithm payload and UnityFS target location.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub payload: PatchManifestOperationPayload,
|
||||||
|
/// Translation and review provenance, when this operation came from a
|
||||||
|
/// localized workflow.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provenance: Option<PatchManifestProvenance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported operation payloads in the generic manifest.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum PatchManifestOperationPayload {
|
||||||
|
/// Deterministic binary hunk patch.
|
||||||
|
Binary {
|
||||||
|
/// Complete binary patch document.
|
||||||
|
patch: BinaryPatch,
|
||||||
|
},
|
||||||
|
/// RFC 6902 JSON Patch document.
|
||||||
|
Json {
|
||||||
|
/// JSON Patch array. It is retained as JSON to preserve the wire
|
||||||
|
/// contract while the algorithm crate validates each operation.
|
||||||
|
patch: Value,
|
||||||
|
},
|
||||||
|
/// UTF-8 text patch.
|
||||||
|
Text {
|
||||||
|
/// Complete text patch document.
|
||||||
|
patch: TextPatch,
|
||||||
|
},
|
||||||
|
/// UnityFS TextAsset replacement.
|
||||||
|
#[serde(rename = "unityfs_text_asset")]
|
||||||
|
UnityFsTextAsset {
|
||||||
|
/// Serialized file path in the UnityFS directory table.
|
||||||
|
serialized_file_path: String,
|
||||||
|
/// Unity object path ID.
|
||||||
|
path_id: i64,
|
||||||
|
/// Optional expected TextAsset name.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
expected_name: Option<String>,
|
||||||
|
/// Replacement bytes.
|
||||||
|
replacement: Vec<u8>,
|
||||||
|
},
|
||||||
|
/// UnityFS TypeTree string field replacement.
|
||||||
|
#[serde(rename = "unityfs_string_field")]
|
||||||
|
UnityFsStringField {
|
||||||
|
/// Serialized file path in the UnityFS directory table.
|
||||||
|
serialized_file_path: String,
|
||||||
|
/// Unity object path ID.
|
||||||
|
path_id: i64,
|
||||||
|
/// TypeTree field path.
|
||||||
|
field_path: String,
|
||||||
|
/// Optional expected source string.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
expected_value: Option<String>,
|
||||||
|
/// Replacement string.
|
||||||
|
replacement: String,
|
||||||
|
},
|
||||||
|
/// UnityFS semantic TypeTree field replacement.
|
||||||
|
#[serde(rename = "unityfs_field")]
|
||||||
|
UnityFsField {
|
||||||
|
/// Serialized file path in the UnityFS directory table.
|
||||||
|
serialized_file_path: String,
|
||||||
|
/// Unity object path ID.
|
||||||
|
path_id: i64,
|
||||||
|
/// TypeTree field path.
|
||||||
|
field_path: String,
|
||||||
|
/// Optional expected semantic source value.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
expected_value: Option<Value>,
|
||||||
|
/// Replacement semantic value using the Unity serialized value schema.
|
||||||
|
replacement: Value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PatchManifestOperationPayload {
|
||||||
|
/// Returns the file-level patch kind represented by this payload.
|
||||||
|
pub fn patch_kind(&self) -> PatchKind {
|
||||||
|
match self {
|
||||||
|
Self::Binary { .. } => PatchKind::Binary,
|
||||||
|
Self::Json { .. } => PatchKind::Json,
|
||||||
|
Self::Text { .. } => PatchKind::Text,
|
||||||
|
Self::UnityFsTextAsset { .. } => PatchKind::UnityFsTextAsset,
|
||||||
|
Self::UnityFsStringField { .. } => PatchKind::UnityFsStringField,
|
||||||
|
Self::UnityFsField { .. } => PatchKind::UnityFsField,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> crate::Result<()> {
|
||||||
|
match self {
|
||||||
|
Self::Binary { patch } if patch.version != crate::binary::BINARY_PATCH_VERSION => {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"unsupported binary patch version {}",
|
||||||
|
patch.version
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Self::Text { patch } if patch.version != crate::text::TEXT_PATCH_VERSION => {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"unsupported text patch version {}",
|
||||||
|
patch.version
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
Self::Json { patch } => {
|
||||||
|
serde_json::from_value::<Vec<JsonPatchOperation>>(patch.clone()).map_err(
|
||||||
|
|error| {
|
||||||
|
PatchError::ApplyFailed(format!(
|
||||||
|
"invalid JSON patch operation list: {error}"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provenance retained for a localized operation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PatchManifestProvenance {
|
||||||
|
/// Stable TextUnit identifier.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text_unit_id: Option<String>,
|
||||||
|
/// BLAKE3 of the validated source text.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_text_blake3: Option<String>,
|
||||||
|
/// Translation provider.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub translation_provider: Option<String>,
|
||||||
|
/// Provider run identifier.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider_run_id: Option<String>,
|
||||||
|
/// Translation source kind.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub translation_source_kind: Option<String>,
|
||||||
|
/// Trusted Translation Memory record identifier.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub translation_memory_record_id: Option<String>,
|
||||||
|
/// Review status.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub review_status: Option<String>,
|
||||||
|
/// Deterministic Glossary QA report.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub glossary_qa: Option<Value>,
|
||||||
|
/// Explicit Glossary QA override.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub glossary_override: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PatchManifestOperation {
|
||||||
|
/// Returns the operation kind.
|
||||||
|
pub fn patch_kind(&self) -> PatchKind {
|
||||||
|
self.payload.patch_kind()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rollback metadata owned by higher-level publication code.
|
/// Rollback metadata owned by higher-level publication code.
|
||||||
@@ -94,12 +293,7 @@ pub fn verify_patch_manifest_files(
|
|||||||
target_root: &Path,
|
target_root: &Path,
|
||||||
manifest: &PatchManifest,
|
manifest: &PatchManifest,
|
||||||
) -> crate::Result<PatchIntegrity> {
|
) -> crate::Result<PatchIntegrity> {
|
||||||
if manifest.version != PATCH_MANIFEST_VERSION {
|
validate_patch_manifest(manifest)?;
|
||||||
return Err(PatchError::ApplyFailed(format!(
|
|
||||||
"unsupported patch manifest version {}",
|
|
||||||
manifest.version
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut integrity = PatchIntegrity {
|
let mut integrity = PatchIntegrity {
|
||||||
file_count: 0,
|
file_count: 0,
|
||||||
@@ -119,6 +313,403 @@ pub fn verify_patch_manifest_files(
|
|||||||
Ok(integrity)
|
Ok(integrity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates manifest schema, paths, operation order and kind compatibility.
|
||||||
|
pub fn validate_patch_manifest(manifest: &PatchManifest) -> crate::Result<()> {
|
||||||
|
if manifest.version != PATCH_MANIFEST_VERSION {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"unsupported patch manifest version {}",
|
||||||
|
manifest.version
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for (label, value) in [
|
||||||
|
("patch_id", manifest.patch_id.as_str()),
|
||||||
|
("source_version", manifest.source_version.as_str()),
|
||||||
|
("target_version", manifest.target_version.as_str()),
|
||||||
|
] {
|
||||||
|
if value.is_empty()
|
||||||
|
|| value.contains('\0')
|
||||||
|
|| value.contains('/')
|
||||||
|
|| value.contains('\\')
|
||||||
|
|| value == "."
|
||||||
|
|| value == ".."
|
||||||
|
{
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"invalid patch manifest {label}: {value}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut paths = std::collections::BTreeSet::new();
|
||||||
|
for file in &manifest.files {
|
||||||
|
resolve_manifest_path(Path::new("."), &file.path)?;
|
||||||
|
if !paths.insert(file.path.clone()) {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"patch manifest contains duplicate file path: {}",
|
||||||
|
file.path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"patch manifest operation order is not contiguous for {}: expected {}, got {}",
|
||||||
|
file.path.display(),
|
||||||
|
expected_sequence,
|
||||||
|
operation.sequence
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(archive_entry) = operation.archive_entry.as_deref() {
|
||||||
|
validate_archive_entry(archive_entry)?;
|
||||||
|
}
|
||||||
|
if operation.source_blake3.is_some() != operation.source_size.is_some() {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"patch manifest operation source precondition must include hash and size: {} operation {}",
|
||||||
|
file.path.display(),
|
||||||
|
operation.sequence
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if file.operations.len() > 1
|
||||||
|
&& (operation.source_blake3.is_none() || operation.source_size.is_none())
|
||||||
|
{
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"multiple operations require source preconditions: {} operation {}",
|
||||||
|
file.path.display(),
|
||||||
|
operation.sequence
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if operation.archive_entry.is_some()
|
||||||
|
&& !matches!(
|
||||||
|
operation.payload,
|
||||||
|
PatchManifestOperationPayload::UnityFsTextAsset { .. }
|
||||||
|
| PatchManifestOperationPayload::UnityFsStringField { .. }
|
||||||
|
| PatchManifestOperationPayload::UnityFsField { .. }
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"archive_entry is only supported for UnityFS operations: {} 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 {}",
|
||||||
|
file.path.display(),
|
||||||
|
operation.sequence
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
..
|
||||||
|
} => 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,
|
||||||
|
..
|
||||||
|
} => 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.
|
||||||
|
///
|
||||||
|
/// This function does not infer or apply operations. Callers construct target
|
||||||
|
/// bytes with the owning algorithm/adapter first, then this builder binds the
|
||||||
|
/// actual source and target hash/size to the auditable manifest. Operation
|
||||||
|
/// sequence numbers are assigned from the supplied vector order.
|
||||||
|
pub fn build_patch_manifest(
|
||||||
|
source_root: &Path,
|
||||||
|
target_root: &Path,
|
||||||
|
patch_id: impl Into<String>,
|
||||||
|
source_version: impl Into<String>,
|
||||||
|
target_version: impl Into<String>,
|
||||||
|
files: Vec<PatchManifestBuildFile>,
|
||||||
|
rollback: PatchRollback,
|
||||||
|
) -> crate::Result<PatchManifest> {
|
||||||
|
let mut manifest_files = Vec::with_capacity(files.len());
|
||||||
|
for file in files {
|
||||||
|
let source_path = resolve_manifest_path(source_root, &file.path)?;
|
||||||
|
let target_path = resolve_manifest_path(target_root, &file.path)?;
|
||||||
|
let source = read_manifest_file(&source_path, "source")?;
|
||||||
|
let target = read_manifest_file(&target_path, "target")?;
|
||||||
|
let has_unity_operation = file.operations.iter().any(|operation| {
|
||||||
|
matches!(
|
||||||
|
&operation.payload,
|
||||||
|
PatchManifestOperationPayload::UnityFsTextAsset { .. }
|
||||||
|
| PatchManifestOperationPayload::UnityFsStringField { .. }
|
||||||
|
| PatchManifestOperationPayload::UnityFsField { .. }
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let has_archive_operation = file
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.any(|operation| operation.archive_entry.is_some());
|
||||||
|
if has_archive_operation && !has_unity_operation {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"manifest builder archive operations must be UnityFS operations: {}",
|
||||||
|
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
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(sequence, mut operation)| {
|
||||||
|
operation.payload.validate()?;
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
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
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* 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 direct_operations && current != target {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"manifest builder operations do not produce target bytes: {}",
|
||||||
|
file.path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
manifest_files.push(PatchManifestFile {
|
||||||
|
path: file.path,
|
||||||
|
patch_kind: file.patch_kind,
|
||||||
|
source_blake3: blake3_hex(&source),
|
||||||
|
target_blake3: blake3_hex(&target),
|
||||||
|
source_size: source.len() as u64,
|
||||||
|
target_size: target.len() as u64,
|
||||||
|
operations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let manifest = PatchManifest {
|
||||||
|
version: PATCH_MANIFEST_VERSION,
|
||||||
|
patch_id: patch_id.into(),
|
||||||
|
source_version: source_version.into(),
|
||||||
|
target_version: target_version.into(),
|
||||||
|
files: manifest_files,
|
||||||
|
rollback,
|
||||||
|
};
|
||||||
|
validate_patch_manifest(&manifest)?;
|
||||||
|
Ok(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_direct_payload(
|
||||||
|
source: &[u8],
|
||||||
|
payload: &PatchManifestOperationPayload,
|
||||||
|
) -> crate::Result<Vec<u8>> {
|
||||||
|
match payload {
|
||||||
|
PatchManifestOperationPayload::Binary { patch } => {
|
||||||
|
crate::binary::apply_binary_patch(source, patch)
|
||||||
|
}
|
||||||
|
PatchManifestOperationPayload::Json { patch } => {
|
||||||
|
let source = std::str::from_utf8(source).map_err(|error| {
|
||||||
|
PatchError::ApplyFailed(format!("JSON patch source is not UTF-8: {error}"))
|
||||||
|
})?;
|
||||||
|
let patch = serde_json::to_string(patch).map_err(|error| {
|
||||||
|
PatchError::ApplyFailed(format!("failed to serialize JSON patch: {error}"))
|
||||||
|
})?;
|
||||||
|
crate::json::apply_json_patch(source, &patch).map(|value| value.into_bytes())
|
||||||
|
}
|
||||||
|
PatchManifestOperationPayload::Text { patch } => {
|
||||||
|
let source = std::str::from_utf8(source).map_err(|error| {
|
||||||
|
PatchError::ApplyFailed(format!("text patch source is not UTF-8: {error}"))
|
||||||
|
})?;
|
||||||
|
crate::text::apply_text_patch(source, patch).map(|value| value.into_bytes())
|
||||||
|
}
|
||||||
|
PatchManifestOperationPayload::UnityFsTextAsset { .. }
|
||||||
|
| PatchManifestOperationPayload::UnityFsStringField { .. }
|
||||||
|
| PatchManifestOperationPayload::UnityFsField { .. } => Err(PatchError::ApplyFailed(
|
||||||
|
"manifest builder cannot apply UnityFS payload without bat-assetbundle".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Verifies one manifest file entry against source and target bytes.
|
/// Verifies one manifest file entry against source and target bytes.
|
||||||
pub fn verify_patch_file_bytes(
|
pub fn verify_patch_file_bytes(
|
||||||
source: &[u8],
|
source: &[u8],
|
||||||
@@ -151,7 +742,14 @@ pub fn verify_patch_file_bytes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf> {
|
fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf> {
|
||||||
if relative.is_absolute() {
|
if relative.is_absolute()
|
||||||
|
|| relative.as_os_str().is_empty()
|
||||||
|
|| relative.to_string_lossy().contains('\0')
|
||||||
|
|| relative.to_string_lossy().contains('\\')
|
||||||
|
|| relative == Path::new(".")
|
||||||
|
|| relative.components().count() == 0
|
||||||
|
|| relative.to_string_lossy().as_bytes().get(1) == Some(&b':')
|
||||||
|
{
|
||||||
return Err(PatchError::ApplyFailed(format!(
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
"patch manifest path must be relative: {}",
|
"patch manifest path must be relative: {}",
|
||||||
relative.display()
|
relative.display()
|
||||||
@@ -171,6 +769,26 @@ fn resolve_manifest_path(root: &Path, relative: &Path) -> crate::Result<PathBuf>
|
|||||||
Ok(root.join(relative))
|
Ok(root.join(relative))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_archive_entry(entry: &str) -> crate::Result<()> {
|
||||||
|
if entry.is_empty()
|
||||||
|
|| entry.contains('\0')
|
||||||
|
|| entry.contains('\\')
|
||||||
|
|| entry.as_bytes().get(1) == Some(&b':')
|
||||||
|
{
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"patch manifest archive entry is unsafe: {entry}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
for component in Path::new(entry).components() {
|
||||||
|
if !matches!(component, Component::Normal(_) | Component::CurDir) {
|
||||||
|
return Err(PatchError::ApplyFailed(format!(
|
||||||
|
"patch manifest archive entry escapes archive root: {entry}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn read_manifest_file(path: &Path, label: &str) -> crate::Result<Vec<u8>> {
|
fn read_manifest_file(path: &Path, label: &str) -> crate::Result<Vec<u8>> {
|
||||||
fs::read(path).map_err(|error| {
|
fs::read(path).map_err(|error| {
|
||||||
PatchError::ApplyFailed(format!(
|
PatchError::ApplyFailed(format!(
|
||||||
@@ -238,6 +856,269 @@ mod tests {
|
|||||||
assert!(matches!(error, PatchError::ApplyFailed(_)));
|
assert!(matches!(error, PatchError::ApplyFailed(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_patch_manifest_binds_release_metadata_and_operation_order() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let source_root = temp.path().join("source");
|
||||||
|
let target_root = temp.path().join("target");
|
||||||
|
fs::create_dir_all(&source_root).unwrap();
|
||||||
|
fs::create_dir_all(&target_root).unwrap();
|
||||||
|
let source = b"before";
|
||||||
|
let target = b"after";
|
||||||
|
fs::write(source_root.join("file.bin"), source).unwrap();
|
||||||
|
fs::write(target_root.join("file.bin"), target).unwrap();
|
||||||
|
|
||||||
|
let manifest = build_patch_manifest(
|
||||||
|
&source_root,
|
||||||
|
&target_root,
|
||||||
|
"localized-v1",
|
||||||
|
"official-v1",
|
||||||
|
"localized-v1",
|
||||||
|
vec![PatchManifestBuildFile {
|
||||||
|
path: PathBuf::from("file.bin"),
|
||||||
|
patch_kind: PatchKind::Binary,
|
||||||
|
operations: vec![PatchManifestOperation {
|
||||||
|
sequence: 99,
|
||||||
|
source_blake3: None,
|
||||||
|
source_size: None,
|
||||||
|
archive_entry: None,
|
||||||
|
payload: PatchManifestOperationPayload::Binary {
|
||||||
|
patch: crate::binary::diff(source, target),
|
||||||
|
},
|
||||||
|
provenance: None,
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
PatchRollback {
|
||||||
|
previous_current_target: None,
|
||||||
|
remove_target_path: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(manifest.source_version, "official-v1");
|
||||||
|
assert_eq!(manifest.files[0].source_blake3, blake3_hex(source));
|
||||||
|
assert_eq!(manifest.files[0].target_blake3, blake3_hex(target));
|
||||||
|
assert_eq!(manifest.files[0].operations[0].sequence, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_patch_manifest_records_each_direct_operation_source_precondition() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let source_root = temp.path().join("source");
|
||||||
|
let target_root = temp.path().join("target");
|
||||||
|
fs::create_dir_all(&source_root).unwrap();
|
||||||
|
fs::create_dir_all(&target_root).unwrap();
|
||||||
|
let source = b"before";
|
||||||
|
let intermediate = b"middle";
|
||||||
|
let target = b"after";
|
||||||
|
fs::write(source_root.join("file.bin"), source).unwrap();
|
||||||
|
fs::write(target_root.join("file.bin"), target).unwrap();
|
||||||
|
let first = crate::binary::diff(source, intermediate);
|
||||||
|
let second = crate::binary::diff(intermediate, target);
|
||||||
|
|
||||||
|
let manifest = build_patch_manifest(
|
||||||
|
&source_root,
|
||||||
|
&target_root,
|
||||||
|
"localized-v1",
|
||||||
|
"official-v1",
|
||||||
|
"localized-v1",
|
||||||
|
vec![PatchManifestBuildFile {
|
||||||
|
path: PathBuf::from("file.bin"),
|
||||||
|
patch_kind: PatchKind::Binary,
|
||||||
|
operations: vec![
|
||||||
|
PatchManifestOperation {
|
||||||
|
sequence: 20,
|
||||||
|
source_blake3: None,
|
||||||
|
source_size: None,
|
||||||
|
archive_entry: None,
|
||||||
|
payload: PatchManifestOperationPayload::Binary { patch: first },
|
||||||
|
provenance: None,
|
||||||
|
},
|
||||||
|
PatchManifestOperation {
|
||||||
|
sequence: 21,
|
||||||
|
source_blake3: None,
|
||||||
|
source_size: None,
|
||||||
|
archive_entry: None,
|
||||||
|
payload: PatchManifestOperationPayload::Binary { patch: second },
|
||||||
|
provenance: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
PatchRollback {
|
||||||
|
previous_current_target: None,
|
||||||
|
remove_target_path: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
manifest.files[0].operations[0].source_blake3,
|
||||||
|
Some(blake3_hex(source))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.files[0].operations[1].source_blake3,
|
||||||
|
Some(blake3_hex(intermediate))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_patch_manifest_rejects_invalid_json_operation_payload() {
|
||||||
|
let mut manifest = manifest_for("file.json", b"{}", b"{\"value\":1}");
|
||||||
|
manifest.files[0].patch_kind = PatchKind::Json;
|
||||||
|
manifest.files[0].operations = vec![PatchManifestOperation {
|
||||||
|
sequence: 0,
|
||||||
|
source_blake3: None,
|
||||||
|
source_size: None,
|
||||||
|
archive_entry: None,
|
||||||
|
payload: PatchManifestOperationPayload::Json {
|
||||||
|
patch: serde_json::json!({"op": "replace", "path": "/value", "value": 1}),
|
||||||
|
},
|
||||||
|
provenance: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
assert!(validate_patch_manifest(&manifest).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
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 {
|
fn manifest_for(path: &str, source: &[u8], target: &[u8]) -> PatchManifest {
|
||||||
PatchManifest {
|
PatchManifest {
|
||||||
version: PATCH_MANIFEST_VERSION,
|
version: PATCH_MANIFEST_VERSION,
|
||||||
@@ -251,6 +1132,7 @@ mod tests {
|
|||||||
target_blake3: blake3_hex(target),
|
target_blake3: blake3_hex(target),
|
||||||
source_size: source.len() as u64,
|
source_size: source.len() as u64,
|
||||||
target_size: target.len() as u64,
|
target_size: target.len() as u64,
|
||||||
|
operations: Vec::new(),
|
||||||
}],
|
}],
|
||||||
rollback: PatchRollback {
|
rollback: PatchRollback {
|
||||||
previous_current_target: None,
|
previous_current_target: None,
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ Go `cmd/bat-api` 是资源 bootstrap、已发布资源分发和鉴权控制服
|
|||||||
- `/admin/` 与白名单 `/admin/control/{action}`;其中翻译管理面包含
|
- `/admin/` 与白名单 `/admin/control/{action}`;其中翻译管理面包含
|
||||||
`/admin/translation/tasks`、`/admin/translation/handoff`、
|
`/admin/translation/tasks`、`/admin/translation/handoff`、
|
||||||
`/admin/translation/memory/summary`、`/admin/translation/memory/query` 和
|
`/admin/translation/memory/summary`、`/admin/translation/memory/query` 和
|
||||||
`translation-memory-confirm` 转发
|
`translation-memory-confirm`、`translation-memory-resolve-conflict` 转发
|
||||||
- `/openapi.yaml`
|
- `/openapi.yaml`
|
||||||
|
|
||||||
HTTP 路由的 OpenAPI 文本由 `internal/api/openapi.go` 提供,运行中的服务也可
|
HTTP 路由的 OpenAPI 文本由 `internal/api/openapi.go` 提供,运行中的服务也可
|
||||||
|
|||||||
@@ -196,13 +196,16 @@ pub struct ParserRegistry {
|
|||||||
### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担)
|
### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担)
|
||||||
|
|
||||||
当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、
|
当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、
|
||||||
lease/retry、结果落库和项目级 Translation Memory V1。TM 位于独立 SQLite,按 raw
|
lease/retry、结果落库、项目级 Translation Memory persistence schema V2 和独立 Glossary
|
||||||
source + 完整 context 做 trusted exact reuse,candidate 必须显式 confirm;Glossary、
|
domain/feature contract V1(SQLite persistence schema V2)。TM 位于独立 SQLite,按 raw
|
||||||
模糊匹配和完整 Provider 体系仍属后续缺口。
|
source + 完整 context 做 current Trusted exact reuse,candidate 必须显式 confirm;
|
||||||
|
同一 identity 的不同译文必须显式 supersede,历史 Trusted 冲突必须显式 resolve;
|
||||||
|
Glossary 只有 approved term 进入 provider/TM 自动流程,并在结果上执行确定性 QA;模糊
|
||||||
|
匹配和完整 Provider 体系仍属后续缺口。
|
||||||
|
|
||||||
**架构**:
|
**架构**:
|
||||||
```
|
```
|
||||||
Text Extractor → TM exact query → AI Provider → Glossary (后续) → Output
|
Text Extractor → Glossary constraints + TM exact query → AI Provider → Glossary QA → Output
|
||||||
↓ ↓
|
↓ ↓
|
||||||
PostgreSQL 审核队列
|
PostgreSQL 审核队列
|
||||||
```
|
```
|
||||||
@@ -228,7 +231,7 @@ type TranslationProvider interface {
|
|||||||
- Azure Translator Provider
|
- Azure Translator Provider
|
||||||
|
|
||||||
**翻译记忆库**:
|
**翻译记忆库**:
|
||||||
- 当前 V1:raw source 完全相同、完整 context 完全相同且记录为 trusted 时自动复用。
|
- 当前规则:raw source 完全相同、完整 context 完全相同且只有一条 current Trusted 时自动复用。
|
||||||
- provider 输出写入先是 candidate;manual task result 不会自动建立 TM 或 trusted。`bat i18n memory confirm` 显式确认单条记录后才可自动复用。
|
- provider 输出写入先是 candidate;manual task result 不会自动建立 TM 或 trusted。`bat i18n memory confirm` 显式确认单条记录后才可自动复用。
|
||||||
- source、context、release、TextUnit、provider 和 run provenance 保存在 Rust TM SQLite 中。
|
- source、context、release、TextUnit、provider 和 run provenance 保存在 Rust TM SQLite 中。
|
||||||
- 模糊匹配、术语优先级和 PostgreSQL 服务化仍不是当前实现。
|
- 模糊匹配、术语优先级和 PostgreSQL 服务化仍不是当前实现。
|
||||||
@@ -295,7 +298,8 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
|
|||||||
|
|
||||||
### 7. Web 后台 (Vue 3,目标设计)
|
### 7. Web 后台 (Vue 3,目标设计)
|
||||||
|
|
||||||
当前只有 `bat-api` 内嵌 dashboard MVP;登录、角色、术语管理和完整协作审核仍未实现。
|
当前只有 `bat-api` 内嵌 dashboard MVP;Rust `bat` 的 Glossary domain/feature contract V1
|
||||||
|
及 SQLite persistence schema V2 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。
|
||||||
|
|
||||||
**技术栈**:
|
**技术栈**:
|
||||||
- Vue 3 + Composition API
|
- Vue 3 + Composition API
|
||||||
@@ -308,7 +312,7 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep
|
|||||||
**模块**:
|
**模块**:
|
||||||
- Dashboard(统计概览)
|
- Dashboard(统计概览)
|
||||||
- 翻译审核(Translation Review)
|
- 翻译审核(Translation Review)
|
||||||
- 术语管理(Glossary Manager)
|
- Web 术语管理(Glossary Manager)
|
||||||
- 资源浏览(Asset Browser)
|
- 资源浏览(Asset Browser)
|
||||||
- 用户管理(User Management)
|
- 用户管理(User Management)
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,10 @@
|
|||||||
- 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由
|
- 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由
|
||||||
`internal/backendrpc` 消费。
|
`internal/backendrpc` 消费。
|
||||||
|
|
||||||
4. **完整游戏业务 API、完整 Web 协作后台、Glossary 和 Provider 扩展体系仍是后续目标**;
|
4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标;Rust `bat` 已持有
|
||||||
Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。
|
Glossary domain/feature contract V1(SQLite persistence schema V2),Web 术语协作视图仍待建设**;
|
||||||
|
Translation Memory persistence schema V2 已由 Rust `bat` 持有,不能从目标架构图推断 Go
|
||||||
|
侧拥有第二份状态。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# AssetBundle 与资源解析路线图
|
# AssetBundle 与资源解析路线图
|
||||||
|
|
||||||
- **更新时间**:2026-09-04
|
- **更新时间**:2026-09-12
|
||||||
- **适用范围**:Rust 解析引擎、官方同步后的解析缓存、CAS/ResourceRepository 接入、后续文本提取和 Patch 发布。
|
- **适用范围**:Rust 解析引擎、官方同步后的解析缓存、CAS/ResourceRepository 接入、后续文本提取和 Patch 发布。
|
||||||
- **权威关联**:`PROJECT_PLAN.md` Milestone 3/4/5/8,`docs/reports/CURRENT_GAPS.md` G-005/G-007/G-011/G-011D。
|
- **权威关联**:`PROJECT_PLAN.md` Milestone 3/4/5/8,`docs/reports/CURRENT_GAPS.md` G-005/G-007/G-011/G-011D。
|
||||||
- **开发状态**:解析扩展当前按路线图和真实回归继续推进。
|
- **开发状态**:解析扩展当前按路线图和真实回归继续推进。
|
||||||
@@ -29,10 +29,10 @@
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 官方 seed manifest | `TableCatalog.bytes`、`BundlePackingInfo.bytes`、`MediaCatalog.bytes` | 完整下载 URL、相对路径、hash 校验边界 | 已用于下载计划,仍需沉淀更多结构化字段 |
|
| 官方 seed manifest | `TableCatalog.bytes`、`BundlePackingInfo.bytes`、`MediaCatalog.bytes` | 完整下载 URL、相对路径、hash 校验边界 | 已用于下载计划,仍需沉淀更多结构化字段 |
|
||||||
| Addressables catalog | `catalog_*.zip` 内 JSON/bin catalog、`catalog_*.hash` | asset path、provider、dependencies、size、CRC、bundle name | JSON/compact 当前目标字段已覆盖;未知结构返回明确错误 |
|
| Addressables catalog | `catalog_*.zip` 内 JSON/bin catalog、`catalog_*.hash` | asset path、provider、dependencies、size、CRC、bundle name | JSON/compact 当前目标字段已覆盖;未知结构返回明确错误 |
|
||||||
| UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、alignment、大小/计数/路径/边界校验 |
|
| UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、alignment、大小/计数/路径/边界校验;受支持 patch 可保留容器形态重建 |
|
||||||
| Serialized file | UnityFS directory 文件 | header、type table、TypeTree node、object table、TextAsset bytes | 已支持基础表结构和 TextAsset bytes |
|
| Serialized file | UnityFS directory 文件 | header、type table、TypeTree node、object table、TextAsset bytes | 已支持基础表结构和 TextAsset bytes |
|
||||||
| Unity 对象字段 | TextAsset、MonoBehaviour、ScriptableObject | 可翻译文本单元、上下文、资源定位 | TypeTree 基础字段读取、`SerializedReference` / prefixed managed-reference metadata alias、payload 提取和字符串提取已落地,真实结构覆盖继续扩大 |
|
| Unity 对象字段 | TextAsset、MonoBehaviour、ScriptableObject | 可翻译文本单元、上下文、资源定位 | TypeTree 基础字段读取、`SerializedReference` / prefixed managed-reference metadata alias、payload 提取和字符串提取已落地,真实结构覆盖继续扩大 |
|
||||||
| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | TextAsset、TypeTree string field 和 managed-reference string field 的 localized publish/rollback 已落地;整体 AssetBundle 重打包与通用 manifest 发布仍未完成 |
|
| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | generic manifest 已驱动 Binary/JSON/Text 与当前支持的 UnityFS 操作;可验证 ZIP 内 bundle 时会在外层重写后重新读取、重解析并校验定位字段/替换值 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -51,13 +51,15 @@
|
|||||||
9. `TextUnitExtractor` 已把 JSON/CSV/TSV/plain TextAsset、TypeTree 字符串字段和 TypeTree-covered managed reference payload 字符串输出为可序列化 TextUnit/JSONL;zip 场景保留 archive entry,TextUnit 明细包含 serialized file、path id、class id、field path、字段 offset/byte size、format、asset name 和上下文。managed-reference 类型元数据保留为 payload context,不进入翻译文本队列;即使 registry 暂时只能走 fallback 字段遍历,`RefIds`、`className`、`namespaceName`、`asmName` 等元数据别名也会被跳过,payload/value/object 家族和 `managedReferenceData` / `referenceData` / `serializedData` 仍按 payload 处理,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata,避免多条 fallback record 混用 managed-reference context。
|
9. `TextUnitExtractor` 已把 JSON/CSV/TSV/plain TextAsset、TypeTree 字符串字段和 TypeTree-covered managed reference payload 字符串输出为可序列化 TextUnit/JSONL;zip 场景保留 archive entry,TextUnit 明细包含 serialized file、path id、class id、field path、字段 offset/byte size、format、asset name 和上下文。managed-reference 类型元数据保留为 payload context,不进入翻译文本队列;即使 registry 暂时只能走 fallback 字段遍历,`RefIds`、`className`、`namespaceName`、`asmName` 等元数据别名也会被跳过,payload/value/object 家族和 `managedReferenceData` / `referenceData` / `serializedData` 仍按 payload 处理,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata,避免多条 fallback record 混用 managed-reference context。
|
||||||
10. `ResourceImportService` 能把 AssetBundle 摘要、TextAsset/Table/Media 分类和 TextUnit 摘要写入导入报告。
|
10. `ResourceImportService` 能把 AssetBundle 摘要、TextAsset/Table/Media 分类和 TextUnit 摘要写入导入报告。
|
||||||
11. 官方同步后 `OfficialParseCacheService` 能从 `official-download-manifest.json` 遍历所有资源,解析直接 bundle 和 zip 内条目,非候选资源记录为 unsupported,并缓存 TextUnit 数量/格式/诊断摘要,同时写出 `official-textunit-index.json` 供 `parse.text_units` / `parse.errors` 查询。
|
11. 官方同步后 `OfficialParseCacheService` 能从 `official-download-manifest.json` 遍历所有资源,解析直接 bundle 和 zip 内条目,非候选资源记录为 unsupported,并缓存 TextUnit 数量/格式/诊断摘要,同时写出 `official-textunit-index.json` 供 `parse.text_units` / `parse.errors` 查询。
|
||||||
|
12. `rebuild_unityfs_bundle` 保留当前已验证 bundle 的 header 版本、directory 顺序/路径/flags、block 数量/压缩模式、block-info-at-end 和 block-data alignment;变长目录文件会重新计算目录 offset、block size、block-info hash 和总大小,未知压缩模式明确拒绝。
|
||||||
|
13. 重建后会重解析并校验未修改 directory 文件、serialized object table、未修改 object raw bytes,以及目标对象的未修改 TypeTree 字段;不能证明保真的结构不会发布。
|
||||||
|
|
||||||
当前还不能宣称完整:
|
当前还不能宣称完整:
|
||||||
|
|
||||||
1. TypeTree-covered managed reference 字段和 registry 记录已可结构化解码并参与文本提取,常见 registry 命名别名(含 `m_ManagedReferences`、`RefIds`、`m_RefIds`、verbose type 字段)、metadata 命名别名(含 `id`、`typeInfo`)、payload 命名别名(含 `data`、`value`、`payload`、`object`、`managedReferencePayload`、`referencePayload`、`serializedReferencePayload`、`managedReferenceValue`、`referenceValue`、`serializedReferenceValue`、`managedReferenceObject`、`referenceObject`、`serializedReferenceObject`、`managedReferenceData`、`referenceData`、`serializedData`)、full typename 拆解和 payload-only TextUnit 提取已有回归覆盖,多记录 registry 聚合也已有单元回归;fallback 字段遍历会跳过常见 registry 元数据字符串,避免误入翻译队列,并按记录前缀或子字段可推导 metadata 保留 managed-reference TextUnit context。enum `value__` backing field 和 `LayerMask` / `BitField` 的 `m_Bits` backing field 已可语义化解码和替换;`Vector2f/3f/4f`、`Quaternionf`、`ColorRGBA`、`Rectf`、`AABB/Bounds/Ray`、`Matrix4x4f`、`Vector2Int/Vector3Int`、`RectInt`、`BoundsInt`、`RangeInt`、`GUID`、`Hash128` 等固定 Unity 值类型的 leaf 和 direct child TypeTree 形态已可结构化解码和语义替换;array/vector/staticvector/List/HashSet/map 元素与 registry payload 字段已保留独立 field path、offset 和 byte size,可用于字符串元素 patch,managed-reference registry payload 字符串、enum、bit_field、unknown fixed-size raw bytes、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 已可整体变长替换,`first/second` 与 `key/value` map entry schema 已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归,且嵌套 vector `Array`、`List<T>` / `HashSet<T>` 集合 alias、enum、bit_field、unknown fixed-size raw bytes 与 managed-reference payload 字段已有重建回归覆盖;后续仍需继续补齐真实样本驱动的完整 managed reference registry / map entry 变体、unknown 字段结构语义和版本差异。
|
1. TypeTree-covered managed reference 字段和 registry 记录已可结构化解码并参与文本提取,常见 registry 命名别名(含 `m_ManagedReferences`、`RefIds`、`m_RefIds`、verbose type 字段)、metadata 命名别名(含 `id`、`typeInfo`)、payload 命名别名(含 `data`、`value`、`payload`、`object`、`managedReferencePayload`、`referencePayload`、`serializedReferencePayload`、`managedReferenceValue`、`referenceValue`、`serializedReferenceValue`、`managedReferenceObject`、`referenceObject`、`serializedReferenceObject`、`managedReferenceData`、`referenceData`、`serializedData`)、full typename 拆解和 payload-only TextUnit 提取已有回归覆盖,多记录 registry 聚合也已有单元回归;fallback 字段遍历会跳过常见 registry 元数据字符串,避免误入翻译队列,并按记录前缀或子字段可推导 metadata 保留 managed-reference TextUnit context。enum `value__` backing field 和 `LayerMask` / `BitField` 的 `m_Bits` backing field 已可语义化解码和替换;`Vector2f/3f/4f`、`Quaternionf`、`ColorRGBA`、`Rectf`、`AABB/Bounds/Ray`、`Matrix4x4f`、`Vector2Int/Vector3Int`、`RectInt`、`BoundsInt`、`RangeInt`、`GUID`、`Hash128` 等固定 Unity 值类型的 leaf 和 direct child TypeTree 形态已可结构化解码和语义替换;array/vector/staticvector/List/HashSet/map 元素与 registry payload 字段已保留独立 field path、offset 和 byte size,可用于字符串元素 patch,managed-reference registry payload 字符串、enum、bit_field、unknown fixed-size raw bytes、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 已可整体变长替换,`first/second` 与 `key/value` map entry schema 已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归,且嵌套 vector `Array`、`List<T>` / `HashSet<T>` 集合 alias、enum、bit_field、unknown fixed-size raw bytes 与 managed-reference payload 字段已有重建回归覆盖;后续仍需继续补齐真实样本驱动的完整 managed reference registry / map entry 变体、unknown 字段结构语义和版本差异。
|
||||||
2. Addressables 当前目标 JSON/compact 字段链已补齐;未识别的独立二进制格式仍返回明确错误,不静默降级。
|
2. Addressables 当前目标 JSON/compact 字段链已补齐;未识别的独立二进制格式仍返回明确错误,不静默降级。
|
||||||
3. 官方 release 已可配置导入 CAS + ResourceRepository,并可通过 `resource.index` 查询现有资源索引;Resource metadata 已记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要。
|
3. 官方 release 已可配置导入 CAS + ResourceRepository,并可通过 `resource.index` 查询现有资源索引;Resource metadata 已记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要。
|
||||||
4. 不能完成复杂对象字段重打包,也不能从真实 Crowdin 结果自动生成完整汉化文件集合。
|
4. 尚未覆盖所有真实 Unity 版本、未知字段语义和任意复杂 AssetBundle 结构,也不能从真实 Crowdin 结果自动生成完整汉化文件集合;当前仅对已有真实/合成回归覆盖的结构宣称支持。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -154,7 +156,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
|
|||||||
|
|
||||||
1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。
|
1. 官方同步完成后可配置触发导入 CAS + ResourceRepository(已具备 `--import-repository` / `BAT_IMPORT_REPOSITORY=1`)。
|
||||||
2. ResourceRepository 已保存官方 manifest 资源的类型、路径、hash、size 和 metadata;metadata 包含 release、平台、bundle path、parse status、TextAsset 名称、TextUnit 数量/格式。
|
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` 兼容迁移。
|
4. schema 迁移可重复执行;当前 SQLite 已有 `crc` 和 `metadata_json` 兼容迁移。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
@@ -169,17 +171,17 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
|
|||||||
|
|
||||||
交付:
|
交付:
|
||||||
|
|
||||||
1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextUnit/provider/review trace 和回滚信息。
|
1. 已定义并接入 generic `PatchManifest`:目标官方版本、localized release、输出文件、hash、size、按序 operation、算法载荷、UnityFS 定位、TextUnit/provider/review trace 和回滚信息。
|
||||||
2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作。
|
2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作;普通 ZIP 条目在 `archive_entry` 可验证、内层可重解析时会解包、重建并重写外层 ZIP,路径穿越、symlink、混合直接/ZIP patch 和无效内层 bundle 明确失败。
|
||||||
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。
|
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果;generic manifest 不把 UnityFS 定位信息扁平化。
|
||||||
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`;rollback 按 manifest 恢复上一 release。
|
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`;rollback 按 manifest 恢复上一 release。
|
||||||
5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release。
|
5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release,并分别报告 schema/contract 与 artifact integrity,ZIP 外层文件和内层 UnityFS 也必须通过发布后重解析校验;current 仍存在但产物损坏时返回 `localized.degraded`,不自动修复。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|
||||||
1. Patch 失败不影响 `bat-resources/current`。
|
1. Patch 失败不影响 `bat-resources/current`。
|
||||||
2. 汉化 release 保留官方相对目录结构。
|
2. 汉化 release 保留官方相对目录结构。
|
||||||
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 patch manifest 可验证。
|
3. `localized` 状态能证明原版和汉化两套资源都已发布,且 generic/localized patch manifest 可验证。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -208,7 +210,7 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
|
|||||||
|
|
||||||
1. 继续补充 Addressables Windows/Android 真实 catalog 样本和独立二进制格式诊断。
|
1. 继续补充 Addressables Windows/Android 真实 catalog 样本和独立二进制格式诊断。
|
||||||
2. 继续补充 TypeTree 字段 reader、MonoBehaviour/ScriptableObject 遍历和真实版本差异。
|
2. 继续补充 TypeTree 字段 reader、MonoBehaviour/ScriptableObject 遍历和真实版本差异。
|
||||||
3. 基于 `translation.worker.run` 扩展 TM/Glossary 和通用 manifest Patch 构建。
|
3. 基于 `translation.worker.run` 继续扩展 TM/Glossary provenance 和真实资源发布样本。
|
||||||
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引。
|
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引。
|
||||||
5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和通用
|
5. 在 generic Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包,但只在
|
||||||
Patch 发布流程统一,保留当前受支持 localized patch 发布/rollback 链路。
|
新结构有真实 fixture 和完整重建验证时接入;不扩大当前 UnityFS V1 的宣称范围。
|
||||||
|
|||||||
@@ -179,10 +179,10 @@
|
|||||||
`resource.index` RPC / CLI 只读查询现有 SQLite 索引;索引不存在时返回
|
`resource.index` RPC / CLI 只读查询现有 SQLite 索引;索引不存在时返回
|
||||||
`available=false`,不会因为查询创建空库。发布后的 TextUnit 队列还会在当前
|
`available=false`,不会因为查询创建空库。发布后的 TextUnit 队列还会在当前
|
||||||
release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrations`
|
release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrations`
|
||||||
管理 queued/running/failed/completed/skipped、provider run、lease、失败分类、
|
管理 V2 queued/running/failed/completed/skipped、provider run、lease、失败分类、
|
||||||
重试计划和 TextUnit 级译文结果。跨 release 的 Translation Memory V1 独立存储在
|
重试计划和 TextUnit 级译文结果。跨 release 的 Translation Memory persistence schema V2
|
||||||
`<output>/translation-memory.sqlite`,记录 raw source/hash、完整 context、candidate/
|
独立存储在 `<output>/translation-memory.sqlite`,记录 raw source/hash、完整 context、
|
||||||
trusted 和 release/TextUnit/provider/run provenance;`translation.tasks` 优先查询这份状态库,
|
candidate/trusted 和 release/TextUnit/provider/run provenance;`translation.tasks` 优先查询这份状态库,
|
||||||
`translation.worker.run` 由 Rust worker 回写状态;`translation.task.update` 仍供外部 provider 流程回写状态;
|
`translation.worker.run` 由 Rust worker 回写状态;`translation.task.update` 仍供外部 provider 流程回写状态;
|
||||||
没有状态库的旧 release 才回退到 immutable JSON 队列。`bat doctor cas`
|
没有状态库的旧 release 才回退到 immutable JSON 队列。`bat doctor cas`
|
||||||
已提供只读 CAS 根目录、对象目录、元数据库文件和对象统计诊断;`resource.index`
|
已提供只读 CAS 根目录、对象目录、元数据库文件和对象统计诊断;`resource.index`
|
||||||
@@ -239,12 +239,12 @@ trusted 和 release/TextUnit/provider/run provenance;`translation.tasks` 优
|
|||||||
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
|
8. 远端无变化且本地已有资源时执行 download manifest audit,检查路径、size、BLAKE3 和 ZIP 结构。
|
||||||
9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。
|
9. 远端变化、本地 audit 发现 repair_needed,首次空目录运行,或缺少 `current` 原子发布指针时,进入下载/发布流程。
|
||||||
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。新 staging 还会扫描已发布 release 的下载 manifest,按规范化 destination 查找候选并重新验证 size、BLAKE3 和 ZIP 结构;硬链接失败时回退到临时文件复制和原子 rename,历史 release 保持不可变。
|
10. 下载先写入 `<output>/.staging/<id>`;若已有 active release,会先 seed staging 以复用已验证文件;若 version-state 中存在同一版本的失败 staging,则优先复用该 staging 并跳过 active seed,避免旧 active 覆盖已下载的新文件。新 staging 还会扫描已发布 release 的下载 manifest,按规范化 destination 查找候选并重新验证 size、BLAKE3 和 ZIP 结构;硬链接失败时回退到临时文件复制和原子 rename,历史 release 保持不可变。
|
||||||
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后写入新的 snapshot,并在 staging 中写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`)。
|
11. 下载、manifest、本地 BLAKE3、ZIP 和官方 `.hash` 校验完成后,在 staging 中写入独立的 `official-distribution-publication.json` 发布事实和新的 snapshot,并写入 `official-launcher-bootstrap.json`(若本轮启用 `--auto-discover`)。publication 文件关联 official release ID、完整 mapping identity、manifest content identity 和 entry count;缺少或不匹配时 release 不可作为 distribution-ready。
|
||||||
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
|
12. 将 staging rename 为 `<output>/versions/<id>`,再原子替换 `<output>/current` symlink 指向该 versioned 目录。
|
||||||
13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。
|
13. 发布完成后先对比上一完整 release 和当前 release 的 `official-download-manifest.json`,写出 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`。同一 destination 只有 size 或 BLAKE3 变化才算 modified;新增+变更资源进入解析/翻译 handoff,删除资源只进入差异记录。当前只预留 Crowdin 本地 handoff,不发外部 API 请求。
|
||||||
14. 随后刷新 active release 下的 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json`、`crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。
|
14. 随后刷新 active release 下的 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json`、`crowdin-textunit-queue.json` 和版本化的 `translation-tasks.sqlite`;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析,重新同步队列时保留已有 worker 状态。历史 release 复用只允许不可变资源 payload/sidecar 硬链接;download manifest、snapshot、parse/textunit cache、queue、handoff、bootstrap、CAS reuse references 以及 `translation-tasks.sqlite`、WAL/SHM 都必须独立复制,不能共享可变 inode。
|
||||||
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用,staging/release 清理时递减,失败则回退网络并保留诊断。
|
15. 若启用 `--import-repository`,已校验 release 会被导入 CAS + `ResourceRepository`,并可经 `resource.index` 查询。历史 release 候选失效时,已有 CAS 对象会先经过完整性和元数据校验,再增加 release 引用并原子物化;当前 release 在 `official-cas-reuse-references.json` 中记录引用和首次生成后持久化的 `ownership_id`,staging/release 清理时按 `ownership_id + ordinal` 递减。旧无 identity 清单按 output-root scope、稳定 source mapping identity 和 generation counter 迁移;已有 basename ledger 的部分 cleanup 继续使用兼容 key,直到该 generation 完成,后续同名 generation 不复用该 key,失败则回退网络并保留诊断。
|
||||||
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;UnityFS TextAsset patch 发布成功并通过 `localized-patch-manifest.json`、current symlink 和 release ID 校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。
|
16. 官方同步报告默认给出 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布;generic manifest 驱动的 Binary/JSON/Text 以及当前支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 及 ZIP 内层最终重解析校验后,`localized.status` 才返回 `localized`,表示原版和汉化两套资源都已发布。`localized.status` 分开返回 `patch_manifest_contract_status` 与 `artifact_integrity_status`;state/current/identity 存在但文件被截断或手工修改时返回 `localized.degraded`,只读检查不回滚、不删除、不修复。`translation.proofread` 只会把 workflow 标记成 `manual_proofreading` / `translation.manual_proofreading`,不会回退已发布汉化 release 的发布状态。
|
||||||
|
|
||||||
维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `<output>/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。
|
维护期特殊分支:如果官方 launcher/server-info 已经指向新资源根,但 client-patch seed marker 或必需 seed catalog 仍返回 403/404 等未开放状态,`bat` 返回 `waiting_for_official_resources`,保留现有 `current`,不创建失败 staging;若本轮启用 `--auto-discover`,会在 `<output>/official-launcher-bootstrap.pending.json` 写入待处理 launcher bootstrap 证据,供后续排障和自研客户端开发使用。
|
||||||
|
|
||||||
@@ -345,9 +345,11 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
|||||||
- 方法命名空间与实现状态、请求/响应示例见
|
- 方法命名空间与实现状态、请求/响应示例见
|
||||||
`docs/reference/rpc-backend-api.md`:`daemon.status/logs/stop/restart/reload/refresh/doctor`、
|
`docs/reference/rpc-backend-api.md`:`daemon.status/logs/stop/restart/reload/refresh/doctor`、
|
||||||
`resource.state/sync/verify/repair/manifest/list/index`、`parse.status/text_units/errors`、
|
`resource.state/sync/verify/repair/manifest/list/index`、`parse.status/text_units/errors`、
|
||||||
`translation.tasks/handoff/task.update/proofread`、`localized.status`、`catalog.*` 与
|
`translation.tasks/handoff/task.update/proofread`、`localized.status`、
|
||||||
`task.status/list/cancel/logs` 已实现;文件级 `patch.apply` / `unityfs.patch_*`
|
`release.status/list/distribution/cleanup`、`catalog.*` 与 `task.status/list/cancel/logs`
|
||||||
已实现,发布级 patch 与复杂 UnityFS 语义编辑待引擎;
|
已实现;文件级 `patch.apply` / `unityfs.patch_*`
|
||||||
|
和受支持 localized publish/rollback 已实现,`archive_entry` 可验证时会重写
|
||||||
|
外层 ZIP;通用发布级 patch 与复杂 UnityFS 语义编辑仍待后续;
|
||||||
`task.create` 按设计暂不开放通用任务入口;
|
`task.create` 按设计暂不开放通用任务入口;
|
||||||
`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径;
|
`daemon.restart` 通过 Rust lifecycle controller 复用 CLI restart 路径;
|
||||||
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
`daemon.clean-stable` 仍由 CLI 侧按进程生命周期显式执行。
|
||||||
@@ -356,7 +358,7 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
|||||||
|
|
||||||
- Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、
|
- Rust `bat` / daemon 是资源生产者和状态拥有者;Go `bat-api` 是资源读侧、
|
||||||
bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和
|
bootstrap 和 HTTP 分发入口。二者之间的稳定边界是 `bat.sock` RPC 和
|
||||||
`resource_root` 中已发布的只读文件。
|
Rust 选择后返回的 `resource_root` 中已发布的只读文件。
|
||||||
- Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、
|
- Go 层负责:资源 bootstrap、资源内容分发(`cmd/bat-api`)、HTTP API 进程配置、
|
||||||
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接
|
以及通过 `internal/backendrpc` 作为 RPC client 调用本机 daemon(连接
|
||||||
`bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。
|
`bat.sock`,每行一个 JSON-RPC 请求/响应)。`cmd/bat` 仍是试验骨架,不是产品级用户 CLI。
|
||||||
@@ -368,8 +370,16 @@ JSON-RPC 2.0 服务,是面向上层服务(Go 层)的**主要跨语言边
|
|||||||
- 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。
|
- 只读提供 Rust `bat` 已发布 release 中的资源字节(官方 CDN host/path 形态)。
|
||||||
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
|
- CDN path 支持 `GET` / `HEAD` / Range / 条件请求;ETag 优先使用 download
|
||||||
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
|
manifest 中的 BLAKE3,响应包含 Last-Modified、Accept-Ranges 和长期缓存头。
|
||||||
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再
|
- 版本/清单发现优先走 RPC:先 `daemon.status`,再 `daemon.doctor`,再读取
|
||||||
`catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket 文件)。
|
Rust 轻量 `release.attestation`,最后按 attested release/publication/mapping/manifest
|
||||||
|
identity 和 verification generation 读取 `catalog.status` / `resource.manifest`(可用 `--socket` 指定 socket
|
||||||
|
文件)。Go 不重新实现 release verifier;普通 current CDN 只有在 attestation
|
||||||
|
fresh/ready、分页快照完整且本地只读路径检查都允许时才分发。
|
||||||
|
- `/v1/releases`、`/v1/distribution` 和受保护的 `/admin/releases/status`、
|
||||||
|
`/admin/releases` 只转发 `release.status/list/distribution` 的 Rust typed
|
||||||
|
结果;localized 或历史分发不会绕过 Rust 完整性判断。
|
||||||
|
- 受保护的 `/admin/control/release-cleanup` 只转发 Rust `release.cleanup`;
|
||||||
|
先 dry-run 获取 `plan_id`,执行时由 Rust 重验证引用、路径和 current 保护。
|
||||||
- 支持 `.env` / 环境变量配置监听端口、public base URL、RPC socket 和 RPC
|
- 支持 `.env` / 环境变量配置监听端口、public base URL、RPC socket 和 RPC
|
||||||
刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。
|
刷新周期,并预留 database/redis 键供后续 API 持久化;**不**负责资源自动拉取。
|
||||||
- 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装
|
- 可选改写 server-info 中的 `AddressablesCatalogUrlRoot` 指向自身;不伪装
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 官方资源 Release 布局与资源侧契约
|
# 官方资源 Release 布局与资源侧契约
|
||||||
|
|
||||||
- **更新时间**:2026-09-04
|
- **更新时间**:2026-09-12
|
||||||
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。
|
- **用途**:冻结日服官方资源在本地发布根上的布局、URL 映射、seed 规则、`bat`/`bat-api` 关系,以及 `bat-api` 分发 path 的 1:1 对应关系。
|
||||||
- **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。
|
- **范围**:资源发现 / 清单 / 落盘 / 只读分发(**不是**完整游戏业务 API)。
|
||||||
- **权威代码**:
|
- **权威代码**:
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
- inventory 抽取:`adapters/src/official/inventory.rs`
|
- inventory 抽取:`adapters/src/official/inventory.rs`
|
||||||
- 落盘与 manifest:`infrastructure/src/official_download.rs`(`destination_for_url`)
|
- 落盘与 manifest:`infrastructure/src/official_download.rs`(`destination_for_url`)
|
||||||
- 发布布局:`infrastructure/src/official_update.rs`
|
- 发布布局:`infrastructure/src/official_update.rs`
|
||||||
|
- 双 release 视图、分发选择与清理:`infrastructure/src/release_ops.rs`
|
||||||
- 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md`)
|
- 分发:`cmd/bat-api` + `internal/api`(见 `docs/reports/GO_STATUS.md`)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
| 角色 | 组件 | 职责 |
|
| 角色 | 组件 | 职责 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 同步 / 运维(近乎全自动) | Rust `bat` | auto-discover、拉取、校验、发布、watch/daemon、RPC 后端 |
|
| 同步 / 运维(近乎全自动) | Rust `bat` | auto-discover、拉取、校验、发布、watch/daemon、RPC 后端 |
|
||||||
| 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 发现已发布版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写和官方 CDN path 字节 |
|
| 资源 bootstrap / 只读分发 | Go `bat-api` | 同环境经 `bat.sock` 读取 Rust 选择的已验证版本和 `resource_root`,提供 `/v1/bootstrap`、server-info 改写、官方/localized CDN path 字节和 release 管理转发 |
|
||||||
| 试验 CLI | Go `cmd/bat` → `bin/bat-go` | 非产品;禁止与 Rust `bat` 重名 |
|
| 试验 CLI | Go `cmd/bat` → `bin/bat-go` | 非产品;禁止与 Rust `bat` 重名 |
|
||||||
|
|
||||||
**禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。
|
**禁止**:把已安装客户端目录或 `/home/wanye/D/BlueArchive` 当作生产输入;真实全量样本优先服务器 release 或 `/tmp` 隔离目录。
|
||||||
@@ -31,13 +32,14 @@
|
|||||||
current -> versions/<id> # 原子 symlink,生产读侧
|
current -> versions/<id> # 原子 symlink,生产读侧
|
||||||
versions/<id>/ # 已发布 versioned release(= resource_root)
|
versions/<id>/ # 已发布 versioned release(= resource_root)
|
||||||
official-download-manifest.json
|
official-download-manifest.json
|
||||||
|
official-distribution-publication.json # 独立发布事实:release、mapping、manifest identity、entry count
|
||||||
official-parse-cache.json # 校验后派生解析缓存,不是汉化产物
|
official-parse-cache.json # 校验后派生解析缓存,不是汉化产物
|
||||||
official-textunit-index.json # TextUnit 明细与解析错误索引,不是汉化产物
|
official-textunit-index.json # TextUnit 明细与解析错误索引,不是汉化产物
|
||||||
official-textunit-tasks.json # 翻译任务候选派生队列,不发 Crowdin 网络请求
|
official-textunit-tasks.json # 翻译任务候选派生队列,不发 Crowdin 网络请求
|
||||||
crowdin-textunit-queue.json # Crowdin worker 离线输入队列
|
crowdin-textunit-queue.json # Crowdin worker 离线输入队列
|
||||||
official-sync-snapshot.json # 常在 active root / current 下
|
official-sync-snapshot.json # 常在 active root / current 下
|
||||||
official-launcher-bootstrap.json # 官方 launcher 引导链版本化产物
|
official-launcher-bootstrap.json # 官方 launcher 引导链版本化产物
|
||||||
official-cas-reuse-references.json # 当前 release 获取的 CAS 引用
|
official-cas-reuse-references.json # 当前 release 获取的 CAS 引用和 ownership_id
|
||||||
prod-clientpatch.bluearchiveyostar.com/
|
prod-clientpatch.bluearchiveyostar.com/
|
||||||
<root_token>/
|
<root_token>/
|
||||||
TableBundles/
|
TableBundles/
|
||||||
@@ -75,7 +77,12 @@
|
|||||||
<localized-output>/ # 汉化产物发布根(--localized-output / BAT_LOCALIZED_OUTPUT)
|
<localized-output>/ # 汉化产物发布根(--localized-output / BAT_LOCALIZED_OUTPUT)
|
||||||
current -> versions/<id> # 已汉化后才切换;未汉化状态不发布
|
current -> versions/<id> # 已汉化后才切换;未汉化状态不发布
|
||||||
versions/<id>/ # 与官方相对路径一致的汉化资源
|
versions/<id>/ # 与官方相对路径一致的汉化资源
|
||||||
localized-version-state.json # 预留:后续 Patch 发布阶段维护,官方同步阶段不写入
|
localized-patch-manifest.json # localized wrapper + generic PatchManifest 审计输入/结果
|
||||||
|
localized-distribution-manifest.json # 实际 localized bytes/hash 的轻量分发索引
|
||||||
|
.staging/<id>/ # generic/translation patch 未发布写侧
|
||||||
|
localized-version-state.json # localized current、官方 source release 和 workflow 状态
|
||||||
|
.localized-release.lock # 跨进程单写者锁
|
||||||
|
.localized-transaction.json # publish/rollback 崩溃恢复日志
|
||||||
```
|
```
|
||||||
|
|
||||||
官方资源发布和汉化发布是两个独立状态:
|
官方资源发布和汉化发布是两个独立状态:
|
||||||
@@ -83,13 +90,47 @@
|
|||||||
- `not_localized`:官方原版资源已经完成下载、校验和发布,汉化资源尚未发布;这是官方同步完成后的默认状态。
|
- `not_localized`:官方原版资源已经完成下载、校验和发布,汉化资源尚未发布;这是官方同步完成后的默认状态。
|
||||||
- `localized`:同一官方版本的原版资源和汉化资源都已发布,生产侧可以同时提供两套资源。
|
- `localized`:同一官方版本的原版资源和汉化资源都已发布,生产侧可以同时提供两套资源。
|
||||||
|
|
||||||
### 2.1 读侧 vs 写侧
|
### 2.1 双 release 读写边界
|
||||||
|
|
||||||
|
Rust `bat` 的 `release.status` 是 official/localized 的统一重型只读视图,基于既有
|
||||||
|
version state、current symlink、release manifest、文件系统和必要的 CAS/reference
|
||||||
|
元数据计算,不建立第二个 release 数据库。`release.list` 返回两个 namespace 的当前
|
||||||
|
与历史 release,包含稳定 ID、created/published、source official relation、生命周期、
|
||||||
|
`rollback_available`、manifest contract、artifact/distribution integrity、
|
||||||
|
`stale`/`damaged`/`referenced`/`unknown`、rollback previous 和诊断;缺少 generic manifest 的
|
||||||
|
旧 localized release 保留为 `legacy`/`unknown`,不自动改写。
|
||||||
|
|
||||||
|
`release.distribution` 的默认 channel 是 `official`。只有当前或显式历史、路径归属安全、
|
||||||
|
source relation 正确且 manifest/artifact integrity 通过的 release 才能被选择;staging、
|
||||||
|
损坏、缺失、symlink/path escape 或未验证历史项不会回退到另一 channel。Rust 使用已发布
|
||||||
|
manifest 做轻量选择,HTTP 热路径不重新执行完整 release audit;localized 必须额外满足
|
||||||
|
`localized-distribution-manifest.json` 与 source official manifest 的 destination/URL
|
||||||
|
集合及 deterministic source mapping identity 一致,并返回实际 localized bytes/hash。
|
||||||
|
manifest 同时保存 localized mapping identity 和 destination index;返回的 `resource_root` 和 manifest entry
|
||||||
|
由 Rust 决定,Go 只做 typed forwarding;传入 `destination` 时 Rust 会重新校验该文件的
|
||||||
|
实际 bytes/BLAKE3。单条请求只比较发布时持久化的 identity、通过 destination index
|
||||||
|
定位 entry,不重新遍历全量映射或资源文件。
|
||||||
|
|
||||||
|
localized publish/rollback 先取得 `.localized-release.lock`,并在 output root 下记录
|
||||||
|
`.localized-transaction.json`。current、version-state 和 version 目录的切换按日志阶段
|
||||||
|
推进;publish 只有最终 `verified` phase 才能 roll-forward,下一次写操作会先恢复或完成
|
||||||
|
未决事务,避免跨进程并发写入和中断后留下半发布状态。`release.cleanup execute` 先取得
|
||||||
|
同一 official `.official-sync.lock`,再按固定顺序取得 localized 锁并在持锁状态下重建
|
||||||
|
计划;dry-run 不占用 official mutation lock。
|
||||||
|
|
||||||
|
`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>` |
|
| 下载写入 | `<output>/.staging/<id>` |
|
||||||
| 发布完成 | rename 到 `versions/<id>`,再切换 `current` |
|
| 发布完成 | rename 到 `versions/<id>`,再切换 `current` |
|
||||||
| 生产读取 / bat-api | RPC 给出的 `version.resource_root`;通常等价于 `current` 解析后的 versioned 目录 |
|
| 生产读取 / bat-api | RPC 给出的已验证 `resource_root`;默认等价于 official `current` 解析后的 versioned 目录,localized 必须显式选择 |
|
||||||
|
|
||||||
每个 release 的 `official-download-manifest.json` 是历史复用的索引。新 staging
|
每个 release 的 `official-download-manifest.json` 是历史复用的索引。新 staging
|
||||||
按规范化 destination 查找候选,并重新验证 manifest 中的 size、BLAKE3 和 ZIP
|
按规范化 destination 查找候选,并重新验证 manifest 中的 size、BLAKE3 和 ZIP
|
||||||
@@ -97,9 +138,25 @@
|
|||||||
跨文件系统时复制到 staging 内的临时文件并原子 rename,旧 `versions/<id>` 目录
|
跨文件系统时复制到 staging 内的临时文件并原子 rename,旧 `versions/<id>` 目录
|
||||||
保持不可变。
|
保持不可变。
|
||||||
|
|
||||||
从 CAS 物化资源时,`official-cas-reuse-references.json` 记录每个获取的对象引用,
|
新 official release 在完整下载、manifest、文件和 ZIP 校验完成后,才会在 versioned
|
||||||
文件带版本字段且允许重复 object ID。孤儿 staging 或显式 release 清理必须先按
|
目录中原子写入 `official-distribution-publication.json`。该文件独立记录
|
||||||
清单减少 CAS 引用,再删除目录;CAS 对象损坏、缺失或元数据不一致时只产生诊断,
|
`official_release_id`、完整 distribution mapping identity、manifest content identity
|
||||||
|
(manifest 文件 BLAKE3)和 `entry_count`。普通 manifest 读写、release status 查询和
|
||||||
|
`release.distribution` 不会重建或刷新它;如果 manifest 在发布后变化、publication
|
||||||
|
文件缺失或两者 identity 不一致,该 release 的 `distribution_integrity_status` 不是
|
||||||
|
`valid`,不能被 distribution 读侧选择。没有该文件的历史 release 仍可被状态/清理逻辑
|
||||||
|
识别为 `legacy`,但不会被当作 distribution-ready。
|
||||||
|
|
||||||
|
从 CAS 物化资源时,`official-cas-reuse-references.json` 首次创建时生成并持久化
|
||||||
|
`ownership_id`,记录每个获取的对象引用;文件带版本字段且允许重复 object ID。
|
||||||
|
没有 `ownership_id` 的旧清单首次 cleanup 按 output-root scope、release ID、稳定 source
|
||||||
|
mapping identity 和 generation counter 建立 persistent legacy generation identity;
|
||||||
|
若已存在 basename ledger,则在该 generation 完成前继续使用 basename compatibility key,
|
||||||
|
完成后同名新 generation 使用新的 ownership。`.cas-owner-scope` 是 output-root 私有状态,
|
||||||
|
不会复制到另一个 release 或 staging。
|
||||||
|
孤儿 staging 或显式 release 清理必须先按
|
||||||
|
清单减少 CAS 引用,再删除目录;cleanup execute 与官方同步共用
|
||||||
|
`.official-sync.lock`,localized cleanup 使用 `.localized-release.lock`。CAS 对象损坏、缺失或元数据不一致时只产生诊断,
|
||||||
回退网络下载,不发布未经校验的文件。
|
回退网络下载,不发布未经校验的文件。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -136,7 +193,11 @@ GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
|
|||||||
≡ 磁盘 <resource_root>/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
|
≡ 磁盘 <resource_root>/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
|
||||||
```
|
```
|
||||||
|
|
||||||
默认仅服务 **download manifest 索引内且 Present + size 匹配** 的文件。
|
默认仅服务 **official download manifest 索引内且 Present + size 匹配** 的文件。需要
|
||||||
|
localized 或历史 release 时,调用 `release.distribution` 选择 Rust 已验证的
|
||||||
|
`resource_root`,再由 `/v1/distribution` 或带 `channel`/`release_id` 的 CDN path
|
||||||
|
转发;localized CDN 使用 Rust 返回的实际 bytes/hash 生成 ETag,并在显式请求时校验
|
||||||
|
实际文件长度;Go 不在本地判断健康度,也不回退到 official。
|
||||||
|
|
||||||
### 3.3 launcher 资源引导兼容
|
### 3.3 launcher 资源引导兼容
|
||||||
|
|
||||||
@@ -195,8 +256,28 @@ GET {public-base-url}/prod-clientpatch.bluearchiveyostar.com/<root_token>/...
|
|||||||
| `bytes` | 文件大小 |
|
| `bytes` | 文件大小 |
|
||||||
| `blake3` | 本地 BLAKE3 hex |
|
| `blake3` | 本地 BLAKE3 hex |
|
||||||
|
|
||||||
|
manifest 还持久化以下 distribution 查询字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `distribution_mapping_identity` | string | 完整 URL、destination、size、BLAKE3 映射的确定性 identity |
|
||||||
|
| `destination_index` | map destination → URL | 单 destination 分发的轻量定位索引 |
|
||||||
|
|
||||||
**权威清单**:拉取闭环写入的 manifest;`bat-api` / RPC `resource.manifest` 以此为应有集合,再以磁盘校验 Present。
|
**权威清单**:拉取闭环写入的 manifest;`bat-api` / RPC `resource.manifest` 以此为应有集合,再以磁盘校验 Present。
|
||||||
|
|
||||||
|
`official-distribution-publication.json` 是发布事实而不是 manifest 派生缓存。单条
|
||||||
|
distribution 查询只读取该文件、当前 manifest 的内容 identity 和
|
||||||
|
`destination_index`,再校验目标文件的 size/BLAKE3;不会为了定位一个 destination
|
||||||
|
重做完整 mapping canonicalization 或遍历其他资源。
|
||||||
|
|
||||||
|
`official-distribution-attestation.json` 是 Rust full local verification 的结果。发布时
|
||||||
|
文件先写入 `.staging/<id>`,但其中的 `resource_root` 永远记录最终的
|
||||||
|
`versions/<id>` canonical root;随后 staging 目录原子重命名并切换 `current`,不会因为
|
||||||
|
重命名再次增加 verification generation。周期性 current 验证、显式 verify/repair 和新
|
||||||
|
release 发布都会写入新的 generation;失败会写入 `ready=false`、`integrity_status=invalid`
|
||||||
|
且 `verified_at=null` 的新结果。`max_age_seconds` 由 Rust watch 的验证周期和失败重试
|
||||||
|
周期计算,缺失或为 0 的旧结果直接视为不可用,不使用固定兼容 fallback。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 发现与 seed URL 规则(L2)
|
## 5. 发现与 seed URL 规则(L2)
|
||||||
@@ -309,9 +390,13 @@ Addressables 改写后客户端拼接:
|
|||||||
|
|
||||||
1. `daemon.status`
|
1. `daemon.status`
|
||||||
2. `daemon.doctor`
|
2. `daemon.doctor`
|
||||||
3. `catalog.status`(`version.resource_root`、`addressables_root`、app/bundle)
|
3. `release.attestation`,消费 Rust 当前 official 的 `ready`、release/publication/
|
||||||
4. `resource.manifest` 分页(url / destination / bytes / blake3)
|
manifest identity、verification generation、freshness 和 integrity 事实
|
||||||
5. 在 `resource_root` 上 Lstat 校验 Present / size
|
4. `catalog.status`(`version.resource_root`、`addressables_root`、app/bundle)
|
||||||
|
5. `resource.manifest` 分页(请求携带 release/publication/manifest identity;响应每页
|
||||||
|
返回同一组 identity、generation、total、offset、limit)
|
||||||
|
6. 在 `resource_root` 上 Lstat 校验 Present / size;该检查只验证 Go 读快照,
|
||||||
|
不替代 Rust release verifier
|
||||||
|
|
||||||
**不读** `bat-status.json` / `bat-tasks.json` 作为常规路径。
|
**不读** `bat-status.json` / `bat-tasks.json` 作为常规路径。
|
||||||
|
|
||||||
|
|||||||
+15
-14
@@ -23,29 +23,30 @@
|
|||||||
|
|
||||||
## 2. 当前验证命令
|
## 2. 当前验证命令
|
||||||
|
|
||||||
必须通过:
|
提交前的只读统一门禁必须通过:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test
|
make ci-check
|
||||||
make check
|
|
||||||
make lint
|
|
||||||
```
|
```
|
||||||
|
|
||||||
等价底层命令:
|
该命令等价覆盖:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo test --workspace
|
cargo fmt --all -- --check
|
||||||
cargo check --workspace
|
cargo check --workspace --locked
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
go test ./internal/api/... ./internal/backendrpc/...
|
cargo test --workspace --locked
|
||||||
go vet ./...
|
go test ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||||
|
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||||
|
go build -o /tmp/bat-api ./cmd/bat-api
|
||||||
|
make check-docs
|
||||||
```
|
```
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
|
|
||||||
1. 默认 Go 测试只覆盖正式 `bat-api` 依赖的纯 Go 包:`internal/api` 和 `internal/backendrpc`;`make test-go-ffi` / `make test-go-all` 才会包含 FFI 和试验 CLI。
|
1. 默认 Go 测试只覆盖正式 `bat-api` 依赖的纯 Go 包:`internal/api` 和 `internal/backendrpc`;`make test-go-ffi` / `make test-go-all` 才会包含 FFI 和试验 CLI。
|
||||||
2. `make check` 当前直接执行 `go vet ./...`,因此会检查所有已存在的 Go 包;新增 Go 产品 package 后,必须同时纳入默认测试门禁。
|
2. `golangci-lint 2.12.2` 是 required gate;版本由 `scripts/ci-versions.sh` 固定,工具缺失或版本不匹配直接失败。
|
||||||
3. `golangci-lint` 当前仍是可选补充门禁;Go 的硬性验证是默认 API 测试、全量 `go vet` 和 `bat-api` 构建。
|
3. `make format` / `make fmt` 会修改工作树;`make ci-check`、`make check`、`make test` 和 `make lint` 不应格式化源码。
|
||||||
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`。
|
4. 官方同步相关修改必须额外运行 `cargo test -p bat-infrastructure --bin bat -- --nocapture`。
|
||||||
|
|
||||||
如果构建环境的默认 Go cache 不可写,可将 `GOCACHE` 指向工作区外的临时目录,例如
|
如果构建环境的默认 Go cache 不可写,可将 `GOCACHE` 指向工作区外的临时目录,例如
|
||||||
@@ -89,8 +90,8 @@ git check-ignore -v Cargo.lock CLAUDE.md AGENTS.md CONTRIBUTING.md
|
|||||||
当前开发优先推进:
|
当前开发优先推进:
|
||||||
|
|
||||||
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。
|
1. 继续 AssetBundle 复杂对象解析、真实 fixture 和发布级重打包。
|
||||||
2. 基于 `translation.worker.run` 扩展 TM/Glossary 和通用 manifest Patch 构建。
|
2. 基于 `translation.worker.run` 继续扩展 TM/Glossary,并补充复杂 AssetBundle 的真实 fixture 与发布验证。
|
||||||
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和通用 Patch 发布所需资源视图。
|
3. 扩展 ResourceRepository 查询面:更丰富的 TextUnit/TM 查询和 generic manifest 发布所需资源视图。
|
||||||
4. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
|
4. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
|
||||||
5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
|
5. 在资源和翻译契约稳定后推进完整 Web 协作后台和完整游戏业务 API。
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,42 @@ bat res pull --auto-discover --watch --interval 1h \
|
|||||||
|
|
||||||
资源下载默认使用 8 个独立 worker,允许范围为 `1..=256`。worker 完成当前 URL 后立即领取共享队列中的下一个任务,进度按完成顺序统计,最终报告仍按计划顺序输出。
|
资源下载默认使用 8 个独立 worker,允许范围为 `1..=256`。worker 完成当前 URL 后立即领取共享队列中的下一个任务,进度按完成顺序统计,最终报告仍按计划顺序输出。
|
||||||
|
|
||||||
|
## Release 查询与清理
|
||||||
|
|
||||||
|
双 release 运维由 Rust `bat` 通过 `bat.sock` 提供,不新增平行顶层 CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看 official/localized 当前、历史、source relation 和完整性
|
||||||
|
printf '{"jsonrpc":"2.0","id":1,"method":"release.status"}\n' \
|
||||||
|
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
|
||||||
|
printf '{"jsonrpc":"2.0","id":2,"method":"release.list","params":{"channel":"localized"}}\n' \
|
||||||
|
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
|
||||||
|
|
||||||
|
# 选择已验证的 localized 当前 release,默认 channel 仍是 official
|
||||||
|
printf '{"jsonrpc":"2.0","id":3,"method":"release.distribution","params":{"channel":"localized"}}\n' \
|
||||||
|
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
`release.distribution` 只返回 Rust 已验证的当前或显式历史 release。localized 必须
|
||||||
|
同时满足 source official、current、manifest identity、source/target hash/size 和
|
||||||
|
UnityFS/ZIP 最终语义校验;staging、损坏、缺失或路径不安全的 release 不会跨 channel
|
||||||
|
fallback。`localized.status` 的 `patch_manifest_contract_status` 与
|
||||||
|
`artifact_integrity_status` 分开表示 schema 和产物健康度,产物损坏时为
|
||||||
|
`localized.degraded`,检查不会自动修复。
|
||||||
|
|
||||||
|
清理必须先 dry-run,再使用同一 `plan_id` 执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '{"jsonrpc":"2.0","id":4,"method":"release.cleanup","params":{"execute":false}}\n' \
|
||||||
|
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
|
||||||
|
printf '{"jsonrpc":"2.0","id":5,"method":"release.cleanup","params":{"execute":true,"plan_id":"<plan-id>"}}\n' \
|
||||||
|
| socat - UNIX-CONNECT:/tmp/bat-state/bat.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust 会在执行前重算计划,保护 current、rollback previous、active/in-progress、
|
||||||
|
localized source official、state/manifest/CAS/reference 和未知归属对象。cleanup 不改变
|
||||||
|
current、不执行 rollback,也不删除 staging;回滚仍使用独立的 `localized.rollback`。
|
||||||
|
|
||||||
## 解析与重打包
|
## 解析与重打包
|
||||||
|
|
||||||
解析当前已发布 release:
|
解析当前已发布 release:
|
||||||
@@ -68,7 +104,7 @@ bat parse clear-cache \
|
|||||||
bat parse repack --repack-spec /tmp/bat-repack.json
|
bat parse repack --repack-spec /tmp/bat-repack.json
|
||||||
```
|
```
|
||||||
|
|
||||||
重打包写入独立的 `target_bundle`,逐个操作后由底层 UnityFS patch 实现重建并校验,不允许 source 和 target 相同。
|
重打包写入独立的 `target_bundle`,逐个操作后由底层 UnityFS patch 实现重建并校验,不允许 source 和 target 相同。重建会保留已识别的 block 压缩、alignment、目录和未修改对象内容;未知压缩模式或无法证明保真的结构会失败。
|
||||||
|
|
||||||
## 翻译工作台与发布
|
## 翻译工作台与发布
|
||||||
|
|
||||||
@@ -89,7 +125,23 @@ bat i18n set \
|
|||||||
--translated-text '中文文本'
|
--translated-text '中文文本'
|
||||||
```
|
```
|
||||||
|
|
||||||
也可以使用 `--translated-file` 读取 UTF-8 文本。需要复核单条内容时:
|
也可以使用 `--translated-file` 读取 UTF-8 文本。
|
||||||
|
如果译文触发 blocking Glossary QA,需先从 diagnose/任务结果取得当前
|
||||||
|
`qa_identity`,并与 reviewer、reason、provenance 一起提交;系统不会根据 workbench
|
||||||
|
中旧的 QA 自动补填:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bat i18n set \
|
||||||
|
--translation-file /tmp/bat-workbench.json \
|
||||||
|
--translation-id <text-unit-id> \
|
||||||
|
--translated-text '人工确认的译文' \
|
||||||
|
--glossary-qa-identity <qa-identity> \
|
||||||
|
--glossary-reviewer operator \
|
||||||
|
--glossary-reason '人工确认术语偏离' \
|
||||||
|
--glossary-provenance workbench
|
||||||
|
```
|
||||||
|
|
||||||
|
需要复核单条内容时:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bat i18n get \
|
bat i18n get \
|
||||||
@@ -161,6 +213,24 @@ bat i18n worker run \
|
|||||||
`--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须
|
`--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须
|
||||||
显式指定 `--interval`。
|
显式指定 `--interval`。
|
||||||
|
|
||||||
|
Glossary domain/feature contract V1 由 Rust `bat` 持有,并由 SQLite persistence schema V2
|
||||||
|
承载,默认位于 `<output>/glossary.sqlite`;V2 正式吸收历史上的 `glossary_term_deletions`
|
||||||
|
schema drift;也可以用 `--glossary-path`、
|
||||||
|
`BAT_GLOSSARY_PATH` 或 `[translation.worker].glossary_path` 指定。worker 只把
|
||||||
|
`approved` term 转成 provider-neutral constraints,并在 TM 复用、provider 返回
|
||||||
|
和人工工作台/任务回写时执行相同的确定性 QA。冲突或不符合推荐/允许译法的结果会
|
||||||
|
阻止自动完成;必须提交带当前 `qa_identity`、reviewer、reason 和 provenance 的显式
|
||||||
|
override。
|
||||||
|
|
||||||
|
常用 Glossary 操作:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bat i18n glossary summary --output /tmp/bat-resources
|
||||||
|
bat i18n glossary query --glossary-source-text 'Sensei' --glossary-review-status approved
|
||||||
|
bat i18n glossary diagnose --glossary-source-text 'Sensei' \
|
||||||
|
--glossary-context-json '{"destination":"Table.bytes"}'
|
||||||
|
```
|
||||||
|
|
||||||
外部 provider 或人工流程也可以用 `i18n task update` 回写当前 release 的任务状态:
|
外部 provider 或人工流程也可以用 `i18n task update` 回写当前 release 的任务状态:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -287,7 +357,9 @@ Rust `translation.tasks` / `translation.handoff`,不在 Go 侧维护状态。
|
|||||||
`translation.task.update`。人工校对流程提交译文时必须使用 `status=completed`,
|
`translation.task.update`。人工校对流程提交译文时必须使用 `status=completed`,
|
||||||
并为每个 `translation_results[]` 提供 `unit_id`、`source_text` 和
|
并为每个 `translation_results[]` 提供 `unit_id`、`source_text` 和
|
||||||
`translated_text`,Rust 会用当前 `official-textunit-index.json` 校验 unit、
|
`translated_text`,Rust 会用当前 `official-textunit-index.json` 校验 unit、
|
||||||
source text、destination 和 archive entry 后再落库。
|
source text、destination 和 archive entry 后再落库。blocking Glossary QA 还必须提交
|
||||||
|
与当前 QA 完全相等的 `glossary_override.qa_identity`;旧或缺少 identity 的 override
|
||||||
|
不会授权。
|
||||||
|
|
||||||
`POST /admin/control/translation-worker-run` 会触发 Rust 侧
|
`POST /admin/control/translation-worker-run` 会触发 Rust 侧
|
||||||
`translation.worker.run`,请求字段为 `provider`、`fixture_path`、
|
`translation.worker.run`,请求字段为 `provider`、`fixture_path`、
|
||||||
@@ -301,11 +373,13 @@ source text、destination 和 archive entry 后再落库。
|
|||||||
## localized patch 发布与回滚
|
## localized patch 发布与回滚
|
||||||
|
|
||||||
`i18n publish` 会在独立的 `.staging/<localized-release-id>` 中复制当前官方
|
`i18n publish` 会在独立的 `.staging/<localized-release-id>` 中复制当前官方
|
||||||
release,校验工作台与当前 TextUnit 索引的 source/location 一致后,写入已有支持
|
release,校验工作台或 generic patch manifest 与当前官方 release 的 source identity
|
||||||
|
一致后,按确定的 operation sequence 写入 Binary、JSON、UTF-8 Text,以及已有支持
|
||||||
范围内的 TextAsset、TypeTree string field 和 managed-reference string field
|
范围内的 TextAsset、TypeTree string field 和 managed-reference string field
|
||||||
patch。校验通过后才原子切换 `localized/current`,并在 release manifest 中记录
|
patch。校验通过后才原子切换 `localized/current`,并在 release manifest 中记录
|
||||||
源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review 和 rollback
|
源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review、发布时重新计算的
|
||||||
信息。ZIP 内 bundle 不会被静默改写。
|
Glossary QA/override 和 rollback 信息。若 TextUnit 带有 `archive_entry`,发布会在 staging 内验证 ZIP 条目路径,修改并重解析内层 UnityFS 后重写外层 ZIP;路径不安全、内层结构无效或重打包工具失败时不会发布不完整结果。可通过
|
||||||
|
`--unzip <PATH>`、`--zip <PATH>` 或对应的 `BAT_UNZIP`、`BAT_ZIP` 配置工具路径。
|
||||||
|
|
||||||
使用人工编辑的工作台发布:
|
使用人工编辑的工作台发布:
|
||||||
|
|
||||||
@@ -323,6 +397,21 @@ bat i18n publish \
|
|||||||
--localized-release-id release-worker-1
|
--localized-release-id release-worker-1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
已有 generic manifest 时直接发布:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bat i18n publish \
|
||||||
|
--patch-manifest /tmp/bat-patch-manifest.json \
|
||||||
|
--localized-release-id localized-v1
|
||||||
|
```
|
||||||
|
|
||||||
|
manifest 必须声明当前官方 `source_version` 和目标 localized `target_version`,
|
||||||
|
文件 hash/size、操作顺序和算法载荷;直接文件支持 Binary/JSON/Text,UnityFS 操作
|
||||||
|
必须保留 serialized file、path ID、field path 或 TextAsset 定位,ZIP 内 UnityFS
|
||||||
|
还必须保留 `archive_entry`。Rust 会在 staging 中逐操作验证 source precondition、
|
||||||
|
最终 hash/size,并在 ZIP 外层重写后重新读取条目、重解析 UnityFS 和校验实际替换值。
|
||||||
|
其他未支持的 UnityFS 结构仍明确拒绝。
|
||||||
|
|
||||||
发布失败会清理 staging,不切换 `current`。当前 release 的 rollback 目标由
|
发布失败会清理 staging,不切换 `current`。当前 release 的 rollback 目标由
|
||||||
manifest 记录,执行后删除本次版本目录并恢复上一版本;没有上一版本时移除
|
manifest 记录,执行后删除本次版本目录并恢复上一版本;没有上一版本时移除
|
||||||
`current`:
|
`current`:
|
||||||
@@ -334,7 +423,7 @@ bat i18n rollback --localized-release-id release-worker-1
|
|||||||
Rust RPC 方法为 `localized.publish` 和 `localized.rollback`;bat-api 对应为
|
Rust RPC 方法为 `localized.publish` 和 `localized.rollback`;bat-api 对应为
|
||||||
`POST /admin/control/localized-publish`、`POST /admin/control/localized-rollback`
|
`POST /admin/control/localized-publish`、`POST /admin/control/localized-rollback`
|
||||||
以及鉴权的 `GET /admin/translation/status`。发布请求必须且只能包含
|
以及鉴权的 `GET /admin/translation/status`。发布请求必须且只能包含
|
||||||
`translation_file` 或 `from_worker=true`;rollback 可省略 release ID 以操作当前
|
`translation_file`、`from_worker=true` 或 `patch_manifest`;rollback 可省略 release ID 以操作当前
|
||||||
release。Go 只做鉴权、参数校验和转发,状态与产物仍由 Rust 持有。
|
release。Go 只做鉴权、参数校验和转发,状态与产物仍由 Rust 持有。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ BlueArchive Toolkit 的部署文档分为当前可用模式和目标模式:
|
|||||||
本地资源状态使用文件和 SQLite。
|
本地资源状态使用文件和 SQLite。
|
||||||
2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。
|
2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。
|
||||||
3. **bat-api 资源 bootstrap / 分发服务**:当前可用,和 Rust `bat` 在同一服务器/容器环境运行,经 `bat.sock` RPC 获取当前 `resource_root`。
|
3. **bat-api 资源 bootstrap / 分发服务**:当前可用,和 Rust `bat` 在同一服务器/容器环境运行,经 `bat.sock` RPC 获取当前 `resource_root`。
|
||||||
4. **可选数据库开发环境**:PostgreSQL/Redis 只服务于未来的 Go 服务层、Glossary 和完整
|
4. **可选数据库开发环境**:PostgreSQL/Redis 只服务于未来的 Go 服务层、完整 Web 协作后台和
|
||||||
Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory V1
|
Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory
|
||||||
使用 `<output>/translation-memory.sqlite`。
|
persistence schema V2 使用 `<output>/translation-memory.sqlite`。
|
||||||
5. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。
|
5. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -39,7 +39,7 @@ docker compose -f deployments/docker-compose.dev.yml --profile local-db up -d
|
|||||||
## 模式 2:可选数据库开发环境(目标能力)
|
## 模式 2:可选数据库开发环境(目标能力)
|
||||||
|
|
||||||
PostgreSQL 和 Redis 不是当前 `bat` / `bat-api` 的生产运行依赖。本模式只用于未来
|
PostgreSQL 和 Redis 不是当前 `bat` / `bat-api` 的生产运行依赖。本模式只用于未来
|
||||||
服务层、Glossary 或 Provider 扩展的开发验证,不能作为当前
|
服务层、Web 协作视图或 Provider 扩展的开发验证,不能作为当前
|
||||||
资源同步或资源分发的部署前置条件。
|
资源同步或资源分发的部署前置条件。
|
||||||
|
|
||||||
### 远程开发连接
|
### 远程开发连接
|
||||||
@@ -439,7 +439,7 @@ BAT_API_TRUST_PROXY_HEADERS=true
|
|||||||
|
|
||||||
否则保持默认 `false`,`bat-api` 会按 TCP peer IP 做限流和日志归因。应用层访问日志只记录 path,不记录 query string,避免 query token 进入日志。动态 JSON 响应使用 `Cache-Control: no-store`;CDN 字节路径仍使用长期 immutable 缓存。
|
否则保持默认 `false`,`bat-api` 会按 TCP peer IP 做限流和日志归因。应用层访问日志只记录 path,不记录 query string,避免 query token 进入日志。动态 JSON 响应使用 `Cache-Control: no-store`;CDN 字节路径仍使用长期 immutable 缓存。
|
||||||
|
|
||||||
不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT`。`bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 重新读取 `catalog.status` / `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/<id>`。
|
不要在生产 env 里设置 `BAT_API_RESOURCE_ROOT`。`bat-api` 会按 `BAT_API_REFRESH_INTERVAL` 周期通过 RPC 读取当前 official `release.attestation`,再以同一 release/publication/mapping/manifest identity 和 verification generation 请求 `resource.manifest`,从而跟随 Rust `bat` 切换 `current -> versions/<id>`;attestation 过期、generation 变化或分页不一致时 fail closed。
|
||||||
|
|
||||||
### 健康检查
|
### 健康检查
|
||||||
|
|
||||||
|
|||||||
+20
-18
@@ -23,9 +23,11 @@ rustc --version # 验证安装
|
|||||||
cargo --version
|
cargo --version
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 自托管 Gitea runner
|
#### 本地完整质量门禁
|
||||||
|
|
||||||
`.gitea/workflows/bat.yml` 使用 `runs-on: linux`,并且不依赖 `actions/checkout`、`dtolnay/rust-toolchain` 等外部 GitHub Action。runner 需要在执行环境中预装以下命令:
|
项目以本地 `make ci-check` 作为唯一完整 required quality gate。开发过程中可运行 focused
|
||||||
|
checks 以快速反馈,但提交前完整 gate 不得省略;仓库不依赖 Gitea、GitHub Actions 或其它
|
||||||
|
远端 CI runner。执行环境需要预装以下命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git --version
|
git --version
|
||||||
@@ -34,9 +36,13 @@ cargo --version
|
|||||||
rustfmt --version
|
rustfmt --version
|
||||||
cargo clippy --version
|
cargo clippy --version
|
||||||
go version
|
go version
|
||||||
|
golangci-lint --version # 必须为 2.12.2
|
||||||
```
|
```
|
||||||
|
|
||||||
该 workflow 会用 `GITHUB_SERVER_URL`、`GITHUB_REPOSITORY`、`GITHUB_REF` 和 `GITHUB_SHA` 手动 `git fetch` 当前提交,再执行 Rust workspace 的格式化、检查、构建、clippy 和测试,以及 Go API 门禁和文档状态门禁。这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。
|
缺少上述命令、版本不匹配或 `golangci-lint` 不是 2.12.2 都会使 required gate 失败;
|
||||||
|
`golangci-lint 2.12.2` 是 required gate,不是可选检查。`make ci-check` 会执行 Rust
|
||||||
|
workspace 的只读格式检查、检查、release build、clippy 和测试,以及通过
|
||||||
|
`make check-go-format` 执行的 Go 格式、测试、vet、构建、2.12.2 lint 和文档状态门禁。
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
```bash
|
```bash
|
||||||
@@ -66,14 +72,11 @@ git checkout -b feature/your-feature-name
|
|||||||
### 2. 开发
|
### 2. 开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 实时编译检查
|
# 运行只读 required 门禁(不会格式化或修改源码)
|
||||||
make check
|
make ci-check
|
||||||
|
|
||||||
# 运行测试
|
# 需要格式化时才修改工作树
|
||||||
make test
|
make format
|
||||||
|
|
||||||
# 格式化代码
|
|
||||||
make fmt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
开发约束:
|
开发约束:
|
||||||
@@ -143,20 +146,19 @@ UnityFS / AssetBundle / Addressables / TypeTree 解析当前按路线图继续
|
|||||||
### 合并前通用门禁
|
### 合并前通用门禁
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo fmt --all -- --check
|
make ci-check
|
||||||
cargo test --workspace
|
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
|
||||||
make test-go-api
|
|
||||||
make build-go-api
|
|
||||||
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
|
||||||
make check-docs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`make ci-check` 是只读门禁入口;`make format` / `make fmt` 才会修改源码。
|
||||||
|
required 的 `golangci-lint 2.12.2` 由 `scripts/ci-versions.sh` 固定,缺失或版本不匹配
|
||||||
|
都会失败,不会伪报全部门禁通过。
|
||||||
|
|
||||||
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
|
Go 边界与进度以 `docs/reports/GO_STATUS.md` 为准:
|
||||||
|
|
||||||
- **同步/运维命令行** = Rust `bat`(近乎全自动)
|
- **同步/运维命令行** = Rust `bat`(近乎全自动)
|
||||||
- **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api`(`make build-go-api`)
|
- **资源 bootstrap/分发服务与内嵌 dashboard** = `cmd/bat-api`(`make build-go-api`)
|
||||||
- **默认 Go 门禁** = `make test-go-api`(无 FFI)
|
- **默认 Go 门禁** = `make ci-check` 中的纯 Go API test/vet/build 和
|
||||||
|
`golangci-lint 2.12.2`(无 FFI)
|
||||||
- 试验 CLI 产物为 `bin/bat-go`(`make build-go-cli`),**禁止**与 Rust `bat` 重名
|
- 试验 CLI 产物为 `bin/bat-go`(`make build-go-cli`),**禁止**与 Rust `bat` 重名
|
||||||
- 修改 FFI 时再跑 `make test-go-ffi`
|
- 修改 FFI 时再跑 `make test-go-ffi`
|
||||||
|
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
|||||||
- 新下载先写 `.part`,成功并通过必要校验后再替换为最终文件;如果断点续传后的 `.zip` 结构校验失败,会删除 `.part` 并重新全量下载
|
- 新下载先写 `.part`,成功并通过必要校验后再替换为最终文件;如果断点续传后的 `.zip` 结构校验失败,会删除 `.part` 并重新全量下载
|
||||||
- 如果上一轮非 dry-run 已进入 staging 但未发布成功,下一轮会优先查找 `<output>/official-version-state.json` 中同一 app version、bundle version 和 Addressables root 的失败版本;只有对应 `<output>/.staging/<id>` 仍存在、路径安全且 `versions/<id>` 尚未发布时,才复用该 staging,并继续按 manifest 校验复用或重下单个 URL
|
- 如果上一轮非 dry-run 已进入 staging 但未发布成功,下一轮会优先查找 `<output>/official-version-state.json` 中同一 app version、bundle version 和 Addressables root 的失败版本;只有对应 `<output>/.staging/<id>` 仍存在、路径安全且 `versions/<id>` 尚未发布时,才复用该 staging,并继续按 manifest 校验复用或重下单个 URL
|
||||||
- 新 release 的 staging 在访问网络前会扫描已发布 release 的 `official-download-manifest.json`。候选必须同时满足 manifest 记录的 destination、size、BLAKE3 和适用的 ZIP 结构校验;URL、CDN 根和 release ID 的变化本身不会阻止复用。命中后优先用硬链接,跨文件系统时回退为临时文件复制并原子 rename,旧 release 不会被修改
|
- 新 release 的 staging 在访问网络前会扫描已发布 release 的 `official-download-manifest.json`。候选必须同时满足 manifest 记录的 destination、size、BLAKE3 和适用的 ZIP 结构校验;URL、CDN 根和 release ID 的变化本身不会阻止复用。命中后优先用硬链接,跨文件系统时回退为临时文件复制并原子 rename,旧 release 不会被修改
|
||||||
- 历史 release 候选失效时,如果配置的 CAS 根已有对应 BLAKE3 对象,会先通过 CAS 读取完整性和元数据,再增加当前 release 的引用并原子物化;当前 release 会写 `official-cas-reuse-references.json`,清理孤儿 staging 或显式清理 release 时递减这些引用。CAS 损坏、缺对象或元数据不一致会写入复用诊断并继续走网络下载,不会静默使用缓存
|
- 历史 release 候选失效时,如果配置的 CAS 根已有对应 BLAKE3 对象,会先通过 CAS 读取完整性和元数据,再增加当前 release 的引用并原子物化;当前 release 会写带持久化 `ownership_id` 的 `official-cas-reuse-references.json`,清理孤儿 staging 或显式清理 release 时按 ownership 和 ordinal 递减这些引用;旧无 identity 清单保留 legacy cleanup key。CAS 损坏、缺对象或元数据不一致会写入复用诊断并继续走网络下载,不会静默使用缓存
|
||||||
- 把结果发布到 `--output/current`
|
- 把结果发布到 `--output/current`
|
||||||
|
|
||||||
## 5. 自动更新检查
|
## 5. 自动更新检查
|
||||||
@@ -228,8 +228,9 @@ cargo run -p bat-infrastructure --example official_pull_plan -- \
|
|||||||
- `<output>/official-launcher-bootstrap.pending.json`:官方 launcher/server-info 已前进但 client-patch seed marker 或必需 seed catalog 尚未开放时写入的待处理 bootstrap 证据;它不代表资源已发布,也不会改变 `current`。
|
- `<output>/official-launcher-bootstrap.pending.json`:官方 launcher/server-info 已前进但 client-patch seed marker 或必需 seed catalog 尚未开放时写入的待处理 bootstrap 证据;它不代表资源已发布,也不会改变 `current`。
|
||||||
- `<output>/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 与 remote manifest 文件列表 digest 都未变时复用缓存;任一变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
- `<output>/official-bootstrap-cache.json`:`--auto-discover` 的 `GameMainConfig` 解析缓存。launcher metadata 与 remote manifest 文件列表 digest 都未变时复用缓存;任一变化时才通过官方 HTTP 按 manifest 下载必要 `resources.assets` 或旧版 game zip 到临时目录解析。
|
||||||
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
- `<output>/official-version-state.json`:资源发布根目录的持久版本状态,包含当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||||
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size 和 BLAKE3。
|
- `<output>/current/official-download-manifest.json`:本地下载强校验清单,记录 URL、相对路径、size、BLAKE3、deterministic distribution mapping identity 和 destination index。
|
||||||
- `<output>/current/official-cas-reuse-references.json`:当前 release 获取的 CAS 引用清单;每个复用项占一条记录,release 清理或孤儿 staging GC 时据此递减引用。
|
- `<output>/current/official-distribution-publication.json`:新 official release 完整校验后写入的独立发布事实,记录 official release ID、mapping identity、manifest content identity 和 entry count;它缺失或与 manifest 不一致时 release 不可分发。普通查询不会自动补写该文件。
|
||||||
|
- `<output>/current/official-cas-reuse-references.json`:当前 release 获取的 CAS 引用清单,首次创建时包含持久化 `ownership_id`;每个复用项占一条记录,release 清理或孤儿 staging GC 时据此按 ownership/ordinal 递减引用。旧无 identity 清单使用 output-root 的 `.cas-owner-scope` 完成 generation-aware legacy cleanup;该文件不会被复制到新 release。
|
||||||
- `<output>/current/official-resource-changes.json`:当前 release 相对上一完整 release 的资源差异,记录新增、变更、删除以及解析/翻译候选计数。
|
- `<output>/current/official-resource-changes.json`:当前 release 相对上一完整 release 的资源差异,记录新增、变更、删除以及解析/翻译候选计数。
|
||||||
- `<output>/current/crowdin-translation-handoff.json`:为后续 Crowdin worker 预留的本地队列,只包含新增+变更资源;它不是 Crowdin API 调用结果。
|
- `<output>/current/crowdin-translation-handoff.json`:为后续 Crowdin worker 预留的本地队列,只包含新增+变更资源;它不是 Crowdin API 调用结果。
|
||||||
- `<output>/current/official-parse-cache.json`:官方资源发布后的派生解析缓存,记录 bundle/zip 条目解析摘要和缓存复用情况;它不是汉化产物。
|
- `<output>/current/official-parse-cache.json`:官方资源发布后的派生解析缓存,记录 bundle/zip 条目解析摘要和缓存复用情况;它不是汉化产物。
|
||||||
@@ -326,7 +327,7 @@ cargo run -p bat-infrastructure --bin bat -- \
|
|||||||
|
|
||||||
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,worker 从共享队列独立领取任务并在完成后立即领取下一项,完成计数保持单调不倒退,最终 report 的 `items` 仍按 pull plan 顺序排列,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。
|
默认平台是 `Windows,Android`,无需显式传 `--platforms`;只有要覆盖默认平台时才传。`--interval` 是正常检查周期,默认 `1h`;watch/daemon 模式还会在每天北京时间(UTC+8)`03:00`、`16:00`、`18:00` 强制执行一次自动刷新,该轮会注入 `force=true`,并且会中断普通 interval 的 sleep。`--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`。CLI 默认启动时向 stderr 打印 `BlueArchiveToolkit` ASCII banner,并把阶段进度日志写到 stderr,包括自动发现、proxy、server-info、marker、catalog、audit、download、snapshot 和 publish 阶段;download 阶段会输出已完成计数和单文件开始/完成状态,worker 从共享队列独立领取任务并在完成后立即领取下一项,完成计数保持单调不倒退,最终 report 的 `items` 仍按 pull plan 顺序排列,audit 阶段会输出官方 `.hash`、本地 BLAKE3、需修复项和 ZIP 结构校验结果摘要。daemon 还会写 `bat-events.jsonl` 结构化日志并按大小轮转。命令结果默认以人类可读摘要写到 stdout。需要纯机器输出时加 `--json --no-progress`,需要显式开启进度日志则用 `--progress`;只想关闭横幅但保留日志时可加 `--no-banner`。错误时 stderr 输出 JSON error,watch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有 banner 和进度日志。普通错误 exit `1`,资源目录锁冲突 exit `75`,`verify` 或 `doctor` 发现问题也返回非 0。
|
||||||
|
|
||||||
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。下载默认并发 8,可用 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`;worker 动态领取共享 plan,finished 进度即时按完成数统计,发布 report 仍按 plan 顺序。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产官方资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取原版资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。汉化 Patch/导出应写入 `--localized-output`,并保留官方相对目录结构,不能写回 `--output/current`。发布状态分两档:`not_localized` 只发布原版资源、不发布汉化资源;`localized` 发布原版和汉化两套资源。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一官方资源目录;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
生产可以直接运行 `--watch`,也可以用 `--daemon` 后台运行,或者用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。下载默认并发 8,可用 `--download-concurrency` / `BAT_DOWNLOAD_CONCURRENCY` 配置为 `1..=256`;worker 动态领取共享 plan,finished 进度即时按完成数统计,发布 report 仍按 plan 顺序。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产官方资源目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录;上层读取原版资源时应读取 `--output/current`,不要读取 `.staging` 或 `versions` 中未切换的目录。汉化 Patch/导出应写入 `--localized-output`,并保留官方相对目录结构,不能写回 `--output/current`。发布状态分两档:`not_localized` 只发布原版资源、不发布汉化资源;`localized` 发布原版和汉化两套资源。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一官方资源目录;`release.cleanup` execute 使用同一个锁并在锁内重新生成/校验 `plan_id`,localized cleanup 使用 `.localized-release.lock`;live daemon 还会阻止前台写命令直接修改它正在管理的同一目录。
|
||||||
|
|
||||||
需要只做探测时可以加 `--dry-run`。需要关闭本地 audit 或 repair 时可以显式使用 `--no-audit-local` 或 `--no-repair`,但生产同步默认应保持开启。
|
需要只做探测时可以加 `--dry-run`。需要关闭本地 audit 或 repair 时可以显式使用 `--no-audit-local` 或 `--no-repair`,但生产同步默认应保持开启。
|
||||||
|
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ contract 为准,不应绕过 daemon 状态文件或扩展 `bat-ffi` 作为主
|
|||||||
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
|
| `resource.sync` | 已实现 | `{ "force": false }` | `{ "task_id": "...", "kind": "resource.sync" }`。 |
|
||||||
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
|
| `resource.verify` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.verify" }`。 |
|
||||||
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
|
| `resource.repair` | 已实现 | `null` | `{ "task_id": "...", "kind": "resource.repair" }`。 |
|
||||||
| `resource.manifest` | 已实现 | `{ "offset": 0, "limit": 100 }` | 当前 download manifest 分页。 |
|
| `resource.manifest` | 已实现 | `{ "release_id": "...", "expected_publication_identity": "...", "expected_manifest_identity": "...", "expected_verification_generation": 7, "offset": 0, "limit": 100 }` | 绑定一个 Rust attested official generation 的 download manifest 分页;generation 为必需绑定条件,`0` 也不能省略或忽略。 |
|
||||||
| `resource.list` | 已实现 | `{ "offset": 0, "limit": 100 }` | `resource.manifest` 的兼容别名。 |
|
| `resource.list` | 已实现 | 同 `resource.manifest` | `resource.manifest` 的兼容别名。 |
|
||||||
| `resource.index` | 已实现 | `{ "offset": 0, "limit": 100, "type": "asset_bundle", "hash": "...", "path_pattern": "*", "release_id": "...", "platform": "windows", "destination": "...", "archive_entry": "...", "parse_status": "parsed", "format": "json" }` | 当前 `ResourceRepository` 分页/过滤查询。 |
|
| `resource.index` | 已实现 | `{ "offset": 0, "limit": 100, "type": "asset_bundle", "hash": "...", "path_pattern": "*", "release_id": "...", "platform": "windows", "destination": "...", "archive_entry": "...", "parse_status": "parsed", "format": "json" }` | 当前 `ResourceRepository` 分页/过滤查询。 |
|
||||||
|
|
||||||
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`。
|
`resource.repair` 会开启本地 manifest audit + repair,不继承 `force`。
|
||||||
@@ -119,6 +119,16 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
|
属于 `parse.text_units` / `parse.errors` 的对象级查询。`limit` 范围是
|
||||||
`1..=1000`,非法参数返回 `BAT-ERR-700002`。
|
`1..=1000`,非法参数返回 `BAT-ERR-700002`。
|
||||||
|
|
||||||
|
`resource.manifest` 的请求必须携带由 `release.attestation` 返回的
|
||||||
|
`release_id`、`expected_publication_identity`、`expected_manifest_identity` 和
|
||||||
|
`expected_verification_generation`。
|
||||||
|
每一页返回 `release_id`、`resource_root`、`manifest_version`、
|
||||||
|
`publication_identity`、`mapping_identity`、`manifest_identity`、`generation`、
|
||||||
|
`total_entries`、`offset`、`limit` 和 `entries`。Rust 在当前 release 切换或 identity
|
||||||
|
不匹配、attestation 不可用或 generation 改变时拒绝请求;Go 会逐页验证 channel、
|
||||||
|
这些 identity、generation、manifest version、页 offset/limit、total 和最终 entry
|
||||||
|
count,任何一页不一致都会丢弃整个候选快照。
|
||||||
|
|
||||||
`resource.index` 的 `entries[]` 是 `Resource` JSON,除 `id`、`local_path`、
|
`resource.index` 的 `entries[]` 是 `Resource` JSON,除 `id`、`local_path`、
|
||||||
`entry` 外会包含 `metadata`:`official_release_id`、`platform`、
|
`entry` 外会包含 `metadata`:`official_release_id`、`platform`、
|
||||||
`bundle_path`、`archive_entries`、`parse_statuses`、`unity_versions`、
|
`bundle_path`、`archive_entries`、`parse_statuses`、`unity_versions`、
|
||||||
@@ -155,18 +165,54 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
- `translation-tasks.sqlite`:当前 release 的可变 worker 状态库,记录
|
- `translation-tasks.sqlite`:当前 release 的可变 worker 状态库,记录
|
||||||
queued / running / failed / completed / skipped、attempt count、provider run
|
queued / running / failed / completed / skipped、attempt count、provider run
|
||||||
ID、provider、TextUnit 级译文结果、lease、失败分类、可重试标记和
|
ID、provider、TextUnit 级译文结果、lease、失败分类、可重试标记和
|
||||||
next attempt;schema 由 `schema_migrations` 版本表管理。
|
next attempt;当前 schema version 为 V2,schema 由 `schema_migrations` 版本表
|
||||||
|
管理,并通过只读 fingerprint、`BEGIN IMMEDIATE` 和显式 V1 → V2 migration
|
||||||
|
保证 future/未知 schema fail closed。
|
||||||
- `translation-handoff.json`:当前 release 的版本化 job/unit/provider run 交接
|
- `translation-handoff.json`:当前 release 的版本化 job/unit/provider run 交接
|
||||||
快照;worker 更新后的实时状态仍以 `translation-tasks.sqlite` 为准。
|
快照;worker 更新后的实时状态仍以 `translation-tasks.sqlite` 为准。
|
||||||
- `translation-memory.sqlite`:跨 release 的项目级 Translation Memory,不位于
|
- `translation-memory.sqlite`:跨 release 的项目级 Translation Memory,不位于
|
||||||
`versions/<id>`,也不与 `translation-tasks.sqlite` 共用;记录 raw source/hash、完整
|
`versions/<id>`,也不与 `translation-tasks.sqlite` 共用;记录 raw source/hash、完整
|
||||||
TextUnit context、candidate/trusted、translation 和 release/TextUnit/provider/run
|
TextUnit context、candidate/trusted、translation 和 release/TextUnit/provider/run
|
||||||
provenance。默认路径为 `<output>/translation-memory.sqlite`,可由
|
provenance;当前 schema version 为 V1,打开时先进行只读 fingerprint preflight,
|
||||||
|
再在 writer transaction 内补齐 `schema_migrations` 版本记录。默认路径为
|
||||||
|
`<output>/translation-memory.sqlite`,可由
|
||||||
`BAT_TRANSLATION_MEMORY_PATH`、`[translation.worker].translation_memory_path` 或 CLI
|
`BAT_TRANSLATION_MEMORY_PATH`、`[translation.worker].translation_memory_path` 或 CLI
|
||||||
覆盖。
|
覆盖。
|
||||||
|
- `glossary.sqlite`:跨 release 的项目级 Glossary,不位于 `versions/<id>`,也不与
|
||||||
|
`translation-tasks.sqlite` 或 TM 共用;记录 term、alias、推荐/允许译法、scope、
|
||||||
|
priority、review 状态、source provenance、完整 source/review history 和 deletion
|
||||||
|
audit;当前 schema version 为 V2。V2 正式吸收历史上未升版本的
|
||||||
|
`glossary_term_deletions` drift,打开时通过 fingerprint 和 writer transaction
|
||||||
|
区分 V1-A/V1-B 并 fail closed;正式 schema evolution 不再通过 `ensure_column`
|
||||||
|
隐式修复。
|
||||||
|
默认路径为
|
||||||
|
`<output>/glossary.sqlite`,可由 `BAT_GLOSSARY_PATH`、`[translation.worker].glossary_path`
|
||||||
|
或 CLI 覆盖。
|
||||||
|
|
||||||
删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。
|
删除资源只进入 `official-resource-changes.json`,不进入 Crowdin handoff。
|
||||||
|
|
||||||
|
### release
|
||||||
|
|
||||||
|
| 方法 | 状态 | params | data |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `release.attestation` | 已实现 | `null` | 当前 official 的轻量 health/publication proof:`available`、`ready`、`channel`、`release_id`、`resource_root`、`publication_identity`、`mapping_identity`、`manifest_identity`、`entry_count`、`integrity_status`、`status`、`status_code`、`verification_generation`、`verified_at`、`max_age_seconds` 和 diagnostics。只读取 current、publication anchor、manifest 元数据与 freshness,不扫描历史 release 或计算资源文件 BLAKE3。 |
|
||||||
|
| `release.status` | 已实现 | `null` | official/localized current、source relation、match、历史 release 和 manifest/artifact/distribution integrity 的重型管理诊断,仍返回管理侧 `official_distribution_ready`;普通 current bat-api readiness 使用 `release.attestation`。 |
|
||||||
|
| `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": "...", "destination": "...", "offset": 0, "limit": 1000 }`,均可省略 | Rust 只选择具有独立 `official-distribution-publication.json` 且 identity 与当前 manifest 一致的 verified `resource_root`;有 `destination` 时是 single-entry lookup,响应固定 `total=1, offset=0, limit=1, entries.length=1`,使用 published mapping identity/destination index,只校验该实际文件的 bytes/BLAKE3,不重新执行全量映射或资源 audit;无 `destination` 时保留管理查询分页语义。localized 还必须匹配 source official 的 published identity,并使用发布时生成的实际字节 metadata,不复用 official size/hash;默认 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 状态。
|
||||||
|
分发选择使用发布后的轻量 manifest 和文件 size/单文件 BLAKE3 校验,不在 HTTP 热路径
|
||||||
|
重新执行完整 release audit;localized 还要求 distribution manifest 的 destination/URL
|
||||||
|
集合与 source official manifest 一致,并使用发布时记录的实际 localized bytes/hash。
|
||||||
|
publication 文件缺失、manifest content identity 变化或 source mapping identity 不一致时,
|
||||||
|
即使单个目标文件本身完整,也返回 `available=false`;旧 release 可被列出并标记
|
||||||
|
`legacy`,但不会被 distribution 读侧自动重建 publication metadata。默认官方分发行为不变。
|
||||||
|
`release.cleanup` 只删除 Rust 能证明是普通目录且未被 current、rollback、staging、
|
||||||
|
source、state、manifest、CAS 或未知 ownership 引用的历史项,不修改 current,也不承担
|
||||||
|
rollback 或 repair。
|
||||||
|
|
||||||
### schedule
|
### schedule
|
||||||
|
|
||||||
调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的
|
调度计划由 Rust `bat` 持有,状态文件为 daemon `state_dir` 下的
|
||||||
@@ -215,9 +261,19 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
|
|||||||
| `translation.task.update` | 已实现 | `{ "task_id": "...", "status": "failed", "failure_reason": "...", "provider_run_id": "..." }` | 写入当前 release 的 provider worker 状态,返回可回查任务记录。 |
|
| `translation.task.update` | 已实现 | `{ "task_id": "...", "status": "failed", "failure_reason": "...", "provider_run_id": "..." }` | 写入当前 release 的 provider worker 状态,返回可回查任务记录。 |
|
||||||
| `translation.worker.run` | 已实现 | provider worker 参数 | 异步触发 Rust provider worker,返回 `{ "task_id": "...", "kind": "translation.worker.run", "worker": {...} }`。 |
|
| `translation.worker.run` | 已实现 | provider worker 参数 | 异步触发 Rust provider worker,返回 `{ "task_id": "...", "kind": "translation.worker.run", "worker": {...} }`。 |
|
||||||
| `translation.proofread` | 已实现 | `null` | 将当前汉化 workflow 标记为人工校对中,返回工作流状态报告。 |
|
| `translation.proofread` | 已实现 | `null` | 将当前汉化 workflow 标记为人工校对中,返回工作流状态报告。 |
|
||||||
| `translation.memory.summary` | 已实现 | 可选 `{ "translation_memory_path": "..." }` | 返回 TM schema 版本、总记录数及 candidate/trusted/rejected/superseded 状态计数。 |
|
| `translation.memory.summary` | 已实现 | 可选 `{ "translation_memory_path": "..." }` | 返回 TM persistence schema 版本、总记录数、candidate/trusted/rejected/superseded 状态计数和 trusted 冲突组计数。 |
|
||||||
| `translation.memory.query` | 已实现 | `{ "source_text": "...", "source_context": {...}, "limit": 100 }` | 按 raw source 查询记录,返回 match kind、trust、translation 和 provenance。 |
|
| `translation.memory.query` | 已实现 | `{ "source_text": "...", "source_context": {...}, "limit": 100 }` | 按 raw source 查询记录,返回 match kind、trust、translation 和 provenance;conflict 结果不可自动复用。 |
|
||||||
| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | 显式确认一条 candidate 为 trusted;worker 之后才可自动复用。 |
|
| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "...", "supersede_record_id": "..." }` | 显式确认 candidate 为 trusted;已有不同 current Trusted 时必须显式 supersede,worker 之后才可自动复用。 |
|
||||||
|
| `translation.memory.conflicts` | 已实现 | 可选 `{ "translation_memory_path": "...", "limit": 100 }` | 只读列出 exact source/context 下存在多个 current Trusted 的冲突组。 |
|
||||||
|
| `translation.memory.resolve_conflict` | 已实现 | `{ "winner_record_id": "...", "expected_trusted_record_ids": ["..."], "reviewer": "...", "reason": "..." }` | 使用稳定 record ID 原子解决历史 Trusted 冲突,保留 supersede 历史并写入 audit event。 |
|
||||||
|
| `translation.glossary.summary` | 已实现 | 可选 `{ "glossary_path": "..." }` | 返回 Glossary schema 版本和 draft/approved/deprecated/rejected 计数;缺库只返回 `available=false`,不会创建空库。 |
|
||||||
|
| `translation.glossary.query` | 已实现 | `{ "source_text": "...", "category": "...", "review_status": "approved", "limit": 100 }` | 查询 term、alias、scope、source provenance 和完整 source/review history。 |
|
||||||
|
| `translation.glossary.diagnose` | 已实现 | `{ "source_text": "...", "context": {...} }` | 只对 approved term 生成 provider-neutral constraints,并返回冲突/覆盖诊断和 blocked 决策。 |
|
||||||
|
| `translation.glossary.add` | 已实现 | Glossary term draft,包含 `term_id`、`source_term`、`recommended_translation`、`source` 等 | Rust 创建 draft/import term 并记录 source history。 |
|
||||||
|
| `translation.glossary.update` | 已实现 | term draft + `reviewer`,可选 `reason` | Rust 替换 term definition,并记录 source/review history。 |
|
||||||
|
| `translation.glossary.approve` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 将 term 明确置为 approved;只有 approved term 进入 worker/TM 自动流程。 |
|
||||||
|
| `translation.glossary.deprecate` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 保留历史但停止自动应用。 |
|
||||||
|
| `translation.glossary.delete` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 显式删除当前 term;需要 reviewer/reason,Rust 另保留删除审计快照,返回删除前快照。 |
|
||||||
|
|
||||||
TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted`;
|
TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted`;
|
||||||
context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会
|
context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会
|
||||||
@@ -273,7 +329,11 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
|
|||||||
记录完成时间,`failed` 可写入 `failure_reason`。人工校对流程可以在
|
记录完成时间,`failed` 可写入 `failure_reason`。人工校对流程可以在
|
||||||
`status=completed` 时额外提交 `provider`、`provider_run_id` 和
|
`status=completed` 时额外提交 `provider`、`provider_run_id` 和
|
||||||
`translation_results[]`,每个结果必须包含 `unit_id`、`source_text` 和
|
`translation_results[]`,每个结果必须包含 `unit_id`、`source_text` 和
|
||||||
`translated_text`;Rust 会用当前 `official-textunit-index.json` 校验 unit、
|
`translated_text`;结果也可以提交完整的 `glossary_override`(`reviewer`、
|
||||||
|
`reason`、`provenance`、`confirmed_unix_seconds` 和当前 blocking QA 的
|
||||||
|
`qa_identity`),用于人工确认 Glossary blocking deviation。`qa_identity` 必须与
|
||||||
|
Rust 重新计算的当前 QA 完全相等;缺失或过期的 override 不授权。Rust 会用当前
|
||||||
|
`official-textunit-index.json` 校验 unit、
|
||||||
source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程
|
source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程
|
||||||
消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过
|
消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过
|
||||||
`translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff`
|
`translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff`
|
||||||
@@ -299,6 +359,7 @@ provider worker 参数:
|
|||||||
| `max_tasks` | uint/null | `null` | 本轮最多 claim 的任务数,设置时必须大于 0。 |
|
| `max_tasks` | uint/null | `null` | 本轮最多 claim 的任务数,设置时必须大于 0。 |
|
||||||
| `worker_id` | string | `bat-rpc-worker` | lease 诊断用 worker ID 前缀。 |
|
| `worker_id` | string | `bat-rpc-worker` | lease 诊断用 worker ID 前缀。 |
|
||||||
| `translation_memory_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 TM 数据库路径;未指定时使用 worker 配置或 `<output>/translation-memory.sqlite`。 |
|
| `translation_memory_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 TM 数据库路径;未指定时使用 worker 配置或 `<output>/translation-memory.sqlite`。 |
|
||||||
|
| `glossary_path` | string/null | 按配置推导 | 覆盖 Rust worker 使用的项目级 Glossary 数据库路径;未指定时使用 worker 配置或 `<output>/glossary.sqlite`。存在 Glossary 但无法打开时 worker fail-closed,不自动绕过 QA。 |
|
||||||
|
|
||||||
数字字段必须是 JSON number;字符串数字、负数和越界值会返回
|
数字字段必须是 JSON number;字符串数字、负数和越界值会返回
|
||||||
`BAT-ERR-700002`。`mock` provider 在没有 fixture 时把 source text 写成可诊断的
|
`BAT-ERR-700002`。`mock` provider 在没有 fixture 时把 source text 写成可诊断的
|
||||||
@@ -306,12 +367,20 @@ mock 译文;`crowdin` provider 从 `CROWDIN_PROJECT_ID`、`CROWDIN_LANGUAGE_ID
|
|||||||
`CROWDIN_API_TOKEN` 读取配置,可选 `CROWDIN_API_BASE_URL` 和 `BAT_CURL`。
|
`CROWDIN_API_TOKEN` 读取配置,可选 `CROWDIN_API_BASE_URL` 和 `BAT_CURL`。
|
||||||
token 不会进入报告、任务记录或调试输出。
|
token 不会进入报告、任务记录或调试输出。
|
||||||
|
|
||||||
|
Glossary 只把 `approved` term 发送为 provider constraints。worker 会在 trusted TM
|
||||||
|
复用前、provider 返回后、人工 `translation.task.update` 和 workbench publish 前执行
|
||||||
|
同一套确定性 QA;冲突或未使用推荐/允许译法的结果不会自动完成或发布。允许但非推荐译法
|
||||||
|
产生 warning;blocking deviation 必须在对应结果中提交 `glossary_override`,并包含
|
||||||
|
`qa_identity`、`reviewer`、`reason`、`provenance` 和确认时间。Glossary 定义变化会
|
||||||
|
只使受影响 QA 的旧 override 失效;无关术语变化不会改变该 QA identity。系统不会在
|
||||||
|
译文生成后做静默字符串替换。
|
||||||
|
|
||||||
### localized
|
### localized
|
||||||
|
|
||||||
| 方法 | 状态 | params | data |
|
| 方法 | 状态 | params | data |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 |
|
| `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 |
|
||||||
| `localized.publish` | 已实现 | `{ "translation_file": "...", "localized_release_id": "...", "force": false }` 或 `{ "from_worker": true, "localized_release_id": "...", "force": false }` | 已校验并发布的汉化 release、manifest 和完整性报告。 |
|
| `localized.publish` | 已实现 | `{ "translation_file": "...", "localized_release_id": "...", "force": false }`、`{ "from_worker": true, "localized_release_id": "...", "force": false }` 或 `{ "patch_manifest": "...", "localized_release_id": "...", "force": false }`;三者只能选一个 | 已校验并发布的汉化 release、generic/localized manifest 和完整性报告。 |
|
||||||
| `localized.rollback` | 已实现 | `{ "localized_release_id": "..." }`,可省略 | 删除当前 release、恢复 manifest 记录的上一 release 和新状态。 |
|
| `localized.rollback` | 已实现 | `{ "localized_release_id": "..." }`,可省略 | 删除当前 release、恢复 manifest 记录的上一 release 和新状态。 |
|
||||||
|
|
||||||
`localized.status` 严格按 daemon / `config.toml` 或环境变量中的 `BAT_LOCALIZED_OUTPUT` 或
|
`localized.status` 严格按 daemon / `config.toml` 或环境变量中的 `BAT_LOCALIZED_OUTPUT` 或
|
||||||
@@ -319,7 +388,7 @@ token 不会进入报告、任务记录或调试输出。
|
|||||||
`./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。
|
`./bat-localized` 混用。当前支持未汉化发布状态和已汉化发布状态的只读报告。
|
||||||
`status` / `status_code` 使用生命周期短状态和稳定状态码,例如
|
`status` / `status_code` 使用生命周期短状态和稳定状态码,例如
|
||||||
`pending` / `localized.pending`、`stale` / `localized.stale`、`published` /
|
`pending` / `localized.pending`、`stale` / `localized.stale`、`published` /
|
||||||
`localized.published`;旧的 `localized` / `not_localized` 业务标签放在
|
`localized.published`、`localized.degraded`;旧的 `localized` / `not_localized` 业务标签放在
|
||||||
`localized_release_status`。`translation_workflow_status` / `translation_workflow_status_code`
|
`localized_release_status`。`translation_workflow_status` / `translation_workflow_status_code`
|
||||||
用于表示汉化工作流的人工校对状态,例如 `manual_proofreading` /
|
用于表示汉化工作流的人工校对状态,例如 `manual_proofreading` /
|
||||||
`translation.manual_proofreading`。返回 `localized_release_status=localized` 的条件是:
|
`translation.manual_proofreading`。返回 `localized_release_status=localized` 的条件是:
|
||||||
@@ -327,8 +396,22 @@ token 不会进入报告、任务记录或调试输出。
|
|||||||
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
|
`current` symlink 指向汉化发布根下对应的 `versions/<id>`,并且该版本目录中的
|
||||||
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
|
`localized-patch-manifest.json` 存在且 release ID 匹配。响应会返回
|
||||||
`patch_manifest_path`、`patch_manifest_available`、
|
`patch_manifest_path`、`patch_manifest_available`、
|
||||||
`patch_manifest_matches_release`、`patch_file_count`、
|
`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`。
|
`patch_text_asset_operation_count` 和 `rollback_previous_current_target`。
|
||||||
|
每个 localized patch operation 的 manifest metadata 记录发布时重新计算的
|
||||||
|
`glossary_qa`(包括 `qa_identity`)及对应 `glossary_override`,不会复用 workbench
|
||||||
|
中已经过期的 QA 快照。
|
||||||
|
|
||||||
|
`localized.publish` 也可直接接收由 Rust `bat-patch` 构建的 generic manifest。
|
||||||
|
Rust 会把其 source version 绑定当前官方 release,在独立 staging 中按 manifest
|
||||||
|
顺序执行 Binary、JSON、UTF-8 Text 和当前支持的 UnityFS TextAsset/TypeTree 字段
|
||||||
|
操作,并保留实际操作载荷、hash/size、定位信息和 TextUnit/TM/Glossary/review
|
||||||
|
provenance。Go 只做鉴权、typed 参数校验和 RPC 转发。
|
||||||
|
|
||||||
### catalog
|
### catalog
|
||||||
|
|
||||||
@@ -413,6 +496,12 @@ daemon 重启后仍处于 `queued` 或 `running` 的历史任务会被标记为
|
|||||||
`data` 会返回 source / patch 或 replacement / target 的 size 与 BLAKE3。`target_path`
|
`data` 会返回 source / patch 或 replacement / target 的 size 与 BLAKE3。`target_path`
|
||||||
不能与输入文件相同。
|
不能与输入文件相同。
|
||||||
|
|
||||||
|
`localized.publish` 从当前官方 TextUnit/工作台生成受支持的 UnityFS patch
|
||||||
|
operation;当 TextUnit 带有 `archive_entry` 时,发布 manifest 的 operation
|
||||||
|
会记录该可选字段,Rust 发布器会在独立 staging 中校验、重建内层 UnityFS 并
|
||||||
|
重写外层 ZIP。ZIP 路径、内层解析或重打包校验失败时整个发布失败,不会只发布
|
||||||
|
部分结果。
|
||||||
|
|
||||||
仍关闭的范围:通用 manifest 驱动的发布级 `patch build` / `patch rollback`、复杂 UnityFS 语义编辑、
|
仍关闭的范围:通用 manifest 驱动的发布级 `patch build` / `patch rollback`、复杂 UnityFS 语义编辑、
|
||||||
`unityfs.inspect`、通用 manifest 驱动 release 切换。调用这些规划方法仍返回
|
`unityfs.inspect`、通用 manifest 驱动 release 切换。调用这些规划方法仍返回
|
||||||
`BAT-ERR-700003`。
|
`BAT-ERR-700003`。
|
||||||
@@ -448,7 +537,13 @@ CLI 对应关系:
|
|||||||
| `bat i18n worker run` | `translation.worker.run` |
|
| `bat i18n worker run` | `translation.worker.run` |
|
||||||
| `bat i18n proofread` | `translation.proofread` |
|
| `bat i18n proofread` | `translation.proofread` |
|
||||||
| `bat i18n memory summary` / `bat i18n memory query` | `translation.memory.summary` / `translation.memory.query` |
|
| `bat i18n memory summary` / `bat i18n memory query` | `translation.memory.summary` / `translation.memory.query` |
|
||||||
| `bat i18n memory confirm` | `translation.memory.confirm` |
|
| `bat i18n memory confirm` / `bat i18n memory conflicts` | `translation.memory.confirm` / `translation.memory.conflicts` |
|
||||||
|
| `bat i18n memory resolve-conflict` | `translation.memory.resolve_conflict` |
|
||||||
|
| `bat i18n glossary summary` / `bat i18n glossary query` | `translation.glossary.summary` / `translation.glossary.query` |
|
||||||
|
| `bat i18n glossary diagnose` | `translation.glossary.diagnose` |
|
||||||
|
| `bat i18n glossary add/update` | `translation.glossary.add` / `translation.glossary.update` |
|
||||||
|
| `bat i18n glossary approve/deprecate` | `translation.glossary.approve` / `translation.glossary.deprecate` |
|
||||||
|
| `bat i18n glossary delete` | `translation.glossary.delete` |
|
||||||
| `bat localized-status` | `localized.status` |
|
| `bat localized-status` | `localized.status` |
|
||||||
| `bat resource-index` | `resource.index` |
|
| `bat resource-index` | `resource.index` |
|
||||||
|
|
||||||
@@ -477,11 +572,15 @@ CLI 对应关系:
|
|||||||
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
|
envelope 和 `ApiError` 解码;它不是 bat-api 的 HTTP 任意 RPC proxy。
|
||||||
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
|
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
|
||||||
`resource.state/sync/verify/repair/manifest/list`、`schedule.list/add/update/remove/run`、
|
`resource.state/sync/verify/repair/manifest/list`、`schedule.list/add/update/remove/run`、
|
||||||
`catalog.*`、`parse.*`、
|
`catalog.*`、`parse.*`、`release.status/list/distribution/cleanup`、
|
||||||
`localized.status`、`localized.publish`、`localized.rollback`、
|
`localized.status`、`localized.publish`、`localized.rollback`、
|
||||||
`translation.tasks`、`translation.handoff`、`translation.task.update`、
|
`translation.tasks`、`translation.handoff`、`translation.task.update`、
|
||||||
`translation.worker.run`、`translation.proofread`、`translation.memory.summary`、
|
`translation.worker.run`、`translation.proofread`、`translation.memory.summary`、
|
||||||
`translation.memory.query`、`translation.memory.confirm`、
|
`translation.memory.query`、`translation.memory.confirm`、`translation.memory.conflicts`、
|
||||||
|
`translation.memory.resolve_conflict`、`translation.glossary.summary`、
|
||||||
|
`translation.glossary.query`、`translation.glossary.diagnose`、`translation.glossary.add`、
|
||||||
|
`translation.glossary.update`、`translation.glossary.approve`、`translation.glossary.deprecate`、
|
||||||
|
`translation.glossary.delete`、
|
||||||
`task.*` 和三个 `unityfs.patch_*` 方法。
|
`task.*` 和三个 `unityfs.patch_*` 方法。
|
||||||
- `resource.index` 和 `patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守
|
- `resource.index` 和 `patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守
|
||||||
本契约的参数和响应定义。
|
本契约的参数和响应定义。
|
||||||
@@ -490,15 +589,19 @@ CLI 对应关系:
|
|||||||
|
|
||||||
| Go 接口 | 允许调用的 RPC | 用途 |
|
| Go 接口 | 允许调用的 RPC | 用途 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`resource.manifest` | 启动发现、周期刷新和资源分发 |
|
| `Backend` | `daemon.status`、`daemon.doctor`、`resource.state`、`catalog.status`、`release.attestation`、绑定后的 `resource.manifest` | 启动发现、周期刷新和资源分发基础数据 |
|
||||||
|
| `AttestationBackend` | `release.attestation` | current official 轻量 health/publication proof;不触发历史 release 扫描 |
|
||||||
|
| `ReleaseStatusBackend` | `release.status` | 鉴权管理面的 official/localized 重型 release 诊断;Go 不重新实现 verifier |
|
||||||
| `ControlBackend` | `daemon.restart`、`daemon.reload`、`daemon.refresh`、`resource.sync`、`resource.verify`、`resource.repair`、`catalog.refresh` | 鉴权后的管理控制白名单 |
|
| `ControlBackend` | `daemon.restart`、`daemon.reload`、`daemon.refresh`、`resource.sync`、`resource.verify`、`resource.repair`、`catalog.refresh` | 鉴权后的管理控制白名单 |
|
||||||
| `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 |
|
| `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 |
|
||||||
| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 |
|
| `DaemonLogsBackend` | `daemon.logs` | 鉴权后的 daemon 日志尾部查询 |
|
||||||
| `TaskBackend` | `task.list`、`task.status`、`task.logs`、`task.cancel` | 鉴权后的 daemon 任务查询和取消 |
|
| `TaskBackend` | `task.list`、`task.status`、`task.logs`、`task.cancel` | 鉴权后的 daemon 任务查询和取消 |
|
||||||
| `ParseBackend` | `parse.status`、`parse.text_units`、`parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 |
|
| `ParseBackend` | `parse.status`、`parse.text_units`、`parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 |
|
||||||
| `TranslationBackend` | `translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 |
|
| `TranslationBackend` | `translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 |
|
||||||
| `TranslationMemoryBackend` | `translation.memory.summary`、`translation.memory.query`、`translation.memory.confirm` | 鉴权后的 TM 摘要、source/context 查询和显式 candidate 确认;Go 只转发,不持有 TM 状态 |
|
| `TranslationMemoryBackend` | `translation.memory.summary`、`translation.memory.query`、`translation.memory.confirm`、`translation.memory.conflicts`、`translation.memory.resolve_conflict` | 鉴权后的 TM 摘要、source/context 查询和 Trusted 冲突治理;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 状态、发布与显式回滚 |
|
| `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 管理控制面。
|
`daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
|
||||||
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
||||||
@@ -507,7 +610,8 @@ Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
|||||||
|
|
||||||
Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖
|
Go mirror contract fixture 固化在 `internal/api/testdata/contract/`,覆盖
|
||||||
`catalog.status` available/unavailable、`resource.manifest` page0、对应
|
`catalog.status` available/unavailable、`resource.manifest` page0、对应
|
||||||
`official-sync-snapshot.json` 以及 Translation Memory query/缺库 mirror。
|
`official-sync-snapshot.json`、Translation Memory query/缺库 mirror 和 Glossary
|
||||||
|
query/source-history mirror。
|
||||||
这些 fixture/mirror 由 Rust 输出形状归一化而来,只用于
|
这些 fixture/mirror 由 Rust 输出形状归一化而来,只用于
|
||||||
schema / mirror 回归;live daemon socket 和完整 fixture release 切换由
|
schema / mirror 回归;live daemon socket 和完整 fixture release 切换由
|
||||||
`make bat-api-local-live-smoke` 在同机 `/tmp` 隔离环境中验证。该 smoke 不替代
|
`make bat-api-local-live-smoke` 在同机 `/tmp` 隔离环境中验证。该 smoke 不替代
|
||||||
|
|||||||
@@ -50,11 +50,13 @@
|
|||||||
catalog-status.unavailable.raw.json
|
catalog-status.unavailable.raw.json
|
||||||
resource-manifest.page0.raw.json
|
resource-manifest.page0.raw.json
|
||||||
official-sync-snapshot.raw.json
|
official-sync-snapshot.raw.json
|
||||||
|
glossary-query.raw.json
|
||||||
normalized/
|
normalized/
|
||||||
catalog-status.available.json
|
catalog-status.available.json
|
||||||
catalog-status.unavailable.json
|
catalog-status.unavailable.json
|
||||||
resource-manifest.page0.json
|
resource-manifest.page0.json
|
||||||
official-sync-snapshot.json
|
official-sync-snapshot.json
|
||||||
|
glossary-query.json
|
||||||
notes.md
|
notes.md
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -72,6 +74,9 @@ Rust 窗口请基于当前真实代码生成或导出以下 JSON:
|
|||||||
2. `catalog.status` available=false 响应。
|
2. `catalog.status` available=false 响应。
|
||||||
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
|
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
|
||||||
4. 对应 release 的 `official-sync-snapshot.json`。
|
4. 对应 release 的 `official-sync-snapshot.json`。
|
||||||
|
5. Rust Glossary domain/feature contract V1、SQLite persistence schema V2 的
|
||||||
|
`translation.glossary.query` 响应,至少包含 alias、approved
|
||||||
|
review、source provenance 和 created/approved history。
|
||||||
|
|
||||||
输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。
|
输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 当前实现缺口清单
|
# 当前实现缺口清单
|
||||||
|
|
||||||
- **更新时间**:2026-09-04
|
- **更新时间**:2026-09-13
|
||||||
- **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。
|
- **文档角色**:只记录尚未完成、仍需验证或仍需设计的工作,不重复维护完整实现状态。
|
||||||
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
|
- **当前事实**:以源码、测试、稳定契约和 `CURRENT_STATUS.md` 为准。
|
||||||
- **Go 进度**:`GO_STATUS.md`
|
- **Go 进度**:`GO_STATUS.md`
|
||||||
@@ -10,40 +10,47 @@
|
|||||||
|
|
||||||
## 1. 当前工程缺口
|
## 1. 当前工程缺口
|
||||||
|
|
||||||
### G-005:AssetBundle 复杂解析仍未完成
|
### G-005:AssetBundle V1 已完成,完整兼容仍未完成
|
||||||
|
|
||||||
状态:**部分完成,继续推进**
|
状态:**V1 已完成(仅限已验证结构),继续推进真实版本和复杂结构**
|
||||||
|
|
||||||
当前已具备 UnityFS 容器校验、directory 文件提取、serialized file
|
当前已具备 UnityFS 容器校验、directory 文件提取、serialized file
|
||||||
object/type table/TypeTree 元数据、TextAsset、基础 MonoBehaviour 和
|
object/type table/TypeTree 元数据、TextAsset、基础 MonoBehaviour 和
|
||||||
ScriptableObject 字段读取、TextUnit 提取,以及受支持字段的文件级重建。
|
ScriptableObject 字段读取、TextUnit 提取,以及受支持字段的文件级
|
||||||
|
parse→modify→rebuild→reparse。重建会保留已识别的 block 压缩、alignment、
|
||||||
|
directory 形态和未修改对象/字段,并明确拒绝未知压缩或无法证明保真的输入。
|
||||||
|
|
||||||
仍需完成:
|
仍需完成:
|
||||||
|
|
||||||
- 用真实资源 fixture 覆盖更多 MonoBehaviour、ScriptableObject、Unity 版本差异、
|
- 用真实资源 fixture 覆盖更多 MonoBehaviour、ScriptableObject、Unity 版本差异、
|
||||||
复杂容器和 managed reference registry/map entry 变体。
|
复杂容器和 managed reference registry/map entry 变体。
|
||||||
- 为未知字段补充结构语义;不能把低保真猜测当作已支持格式。
|
- 为未知字段补充结构语义;不能把低保真猜测当作已支持格式。
|
||||||
- 完成发布级复杂对象重打包,并把 bundle、serialized file、path id、class id、
|
- 扩大真实 Unity 版本、复杂容器、未知字段和 managed-reference/map 变体覆盖;
|
||||||
field path、offset 和 byte size 的定位信息贯通到稳定发布流程。
|
当前 V1 不等价于任意 AssetBundle 结构的通用重打包。
|
||||||
|
|
||||||
现有证据:`crates/bat-assetbundle` 的单元/重建测试、隔离真实 UnityFS 回归和
|
现有证据:`crates/bat-assetbundle` 的单元/压缩/对齐/变长重建测试、隔离真实 UnityFS
|
||||||
`bat-infrastructure` 的解析缓存测试。新增格式覆盖必须同时补真实 fixture、回归测试
|
回归和 `bat-infrastructure` 的解析缓存、ZIP 内 bundle 发布测试。新增格式覆盖必须
|
||||||
和文档。
|
同时补真实 fixture、回归测试和文档。
|
||||||
|
|
||||||
### G-006:通用 Patch 发布仍未完成
|
### G-006:通用 Patch 的复杂格式和运维扩展仍未完成
|
||||||
|
|
||||||
状态:**基础完成,发布流程部分完成**
|
状态:**V1 已完成(当前支持类型),复杂格式和运维扩展继续推进**
|
||||||
|
|
||||||
`bat-patch` 已提供 Binary/JSON/Text Patch、manifest、BLAKE3/size 校验和
|
`bat-patch` 已提供 Binary/JSON/Text Patch、manifest builder、BLAKE3/size 校验和
|
||||||
rollback 元数据;文件级 `patch.apply` 与受支持的 UnityFS TextAsset、TypeTree
|
rollback 元数据;`LocalizedPatchService` 已使用同一有序 generic manifest 驱动
|
||||||
string field、managed-reference string field 写入及 localized publish/rollback
|
Binary/JSON/Text 与当前支持的 UnityFS TextAsset、TypeTree string/semantic field
|
||||||
已可用。
|
写入及 localized publish/rollback。发布会在独立 staging 中校验 source/target identity、
|
||||||
|
逐操作 precondition、ZIP 内层重解析和实际字段替换,并保留 TextUnit/TM/Glossary/review
|
||||||
|
provenance;UnityFS 目标身份至少包含 archive entry、serialized file、path ID 和实际
|
||||||
|
field path,同对象 sibling field 可并存,重复、父子、whole-object/field 结构重叠会拒绝;
|
||||||
|
`localized.publish`、`i18n publish` 和 bat-api typed forwarding 均已接入。
|
||||||
|
|
||||||
仍需完成:
|
仍需完成:
|
||||||
|
|
||||||
- 通用 manifest 驱动的跨类型 patch build/apply/publish/rollback。
|
- 任意复杂 AssetBundle 重打包和完整翻译文件集合构建;当前 localized publish 已支持
|
||||||
- 复杂 AssetBundle 重打包和完整翻译文件集合构建。
|
可验证 ZIP 内 bundle 的外层 ZIP 重写,但不扩大 UnityFS 结构支持范围。
|
||||||
- 原版 release 与 localized release 双发布后的查询、分发和清理策略。
|
- generic manifest 已冻结为当前支持类型的 V1;复杂 AssetBundle 结构仍需真实样本驱动,
|
||||||
|
不在本项中扩展 Patch 格式。
|
||||||
|
|
||||||
所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变
|
所有发布产物必须先进入独立 staging,通过完整性校验后再原子发布;失败不得改变
|
||||||
已发布的 `bat-resources/current` 或 `bat-localized/current`。
|
已发布的 `bat-resources/current` 或 `bat-localized/current`。
|
||||||
@@ -68,6 +75,12 @@ provider、bundle name、resource type 和 CRC,并有 fixture/golden 回归。
|
|||||||
launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管理入口和内嵌
|
launcher 资源引导兼容、只读 CDN path、readiness、OpenAPI、鉴权管理入口和内嵌
|
||||||
dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC
|
dashboard;翻译任务和 Rust-owned TM 的 summary/query/confirm 也通过 typed RPC
|
||||||
转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。
|
转发。Rust `bat` 继续拥有资源发现、下载、校验、staging、发布、任务和长期状态。
|
||||||
|
普通 current release 的 readiness、bootstrap、release summary 和 CDN 共用
|
||||||
|
Rust `release.attestation` 是 current official 的轻量 health/publication proof,带
|
||||||
|
release/publication/manifest identity、verification generation、freshness 和诊断;Go
|
||||||
|
只建立绑定同一代际的 manifest 读快照,不复制 Rust verifier。`release.status` 仍保留
|
||||||
|
为重型管理诊断。`make ci-check` 是只读统一门禁,required `golangci-lint 2.12.2`
|
||||||
|
缺失或版本不匹配直接失败。
|
||||||
|
|
||||||
仍需完成:
|
仍需完成:
|
||||||
|
|
||||||
@@ -102,22 +115,68 @@ format 等资源级过滤,`parse.text_units` / `parse.errors` 和翻译任务
|
|||||||
- 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。
|
- 从同一 manifest fingerprint 追溯资源、解析缓存、翻译任务和发布产物。
|
||||||
- 更多 schema 迁移、权限、并发和损坏恢复场景验证。
|
- 更多 schema 迁移、权限、并发和损坏恢复场景验证。
|
||||||
|
|
||||||
### G-011D:双 release 的完整查询与发布策略仍未完成
|
### G-011D:双 release 查询、分发与安全清理
|
||||||
|
|
||||||
状态:**受支持范围完成,通用范围部分完成**
|
状态:**V1 已完成**
|
||||||
|
|
||||||
官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和
|
官方原版和 localized release 已分离,受支持 patch 可独立 staging、校验、发布和
|
||||||
rollback,`localized.status` 能校验当前官方 release 与 patch manifest 的一致性。
|
rollback。Rust `release.status` 提供统一 current/source/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,不自动回滚或删除。
|
||||||
|
|
||||||
仍需完成通用 patch 发布、复杂重打包、双 release 查询/分发视图和清理策略。
|
rollback 与 cleanup 保持独立;缺少 generic manifest 的旧 localized release 仍可读,
|
||||||
|
明确标记 `legacy`/`unknown`,不会被自动重写。
|
||||||
|
|
||||||
### G-012:Translation Memory V1 已实现,扩展能力仍缺失
|
本轮 P1 一致性修复已完成:CAS repository 的 store/get/reference/GC 使用跨进程操作锁,
|
||||||
|
release-local CAS 引用通过持久化 `ownership_id + ordinal` ledger 幂等释放;没有
|
||||||
|
`ownership_id` 的旧清单按持久化 output-root scope、source mapping 和 generation identity
|
||||||
|
迁移,已有 basename ledger 的部分 cleanup 保持 legacy compatibility key,完成后同名新
|
||||||
|
generation 使用新的 ownership,不随机迁移已开始的 cleanup。官方历史复用只对不可变文件使用 hard link,`translation-tasks.sqlite` 及 WAL/SHM 始终独立复制;
|
||||||
|
localized output 使用单写者锁和事务日志恢复 publish/rollback,publish 只有最终
|
||||||
|
`verified` phase 才能 roll-forward,并在发布时写入实际 localized bytes/BLAKE3 的
|
||||||
|
distribution manifest。official/localized distribution manifest 还持久化 deterministic
|
||||||
|
source/localized mapping identity 和 destination index;`release.distribution(destination=...)`
|
||||||
|
是单条 lookup,返回 exactly one entry,source identity mismatch 会阻断 localized
|
||||||
|
distribution,完整 identity 校验只在 publish/status/audit 路径执行;分发读取保留 path
|
||||||
|
ownership、symlink 和文件完整性检查;`release.cleanup execute` 与 official sync 共用
|
||||||
|
`.official-sync.lock`。新 official release 还持久化独立的
|
||||||
|
`official-distribution-publication.json`,把 release ID、完整 mapping identity、manifest
|
||||||
|
content identity 和 entry count 绑定到发布事实;publication 缺失或 manifest 变化时
|
||||||
|
official/localized distribution 均被阻断,普通查询不会自动重建。legacy release 仍可
|
||||||
|
列出和清理,但不视为 distribution-ready。本轮已关闭两个 Release/CAS P2。其他 P2
|
||||||
|
尚未由本轮处理:ResourceRepository
|
||||||
|
更完整的查询/权限/损坏恢复、模糊 TM、bat.sock peer credential/perms、FFI 生命周期、
|
||||||
|
资源大小/限额与更强的持久化 fsync 语义仍按后续专项推进。
|
||||||
|
|
||||||
Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整 context、release/TextUnit/provider/run provenance,区分 candidate/trusted,只有显式 confirm 才能建立 trusted 记录;worker 只自动复用 trusted 的 raw source + 完整 context exact match。Go `bat-api` 已提供鉴权的 summary/query 只读接口和 confirm 转发,但 Go 不持有 TM 状态。仍缺少模糊匹配、Glossary 联动和更丰富的导入导出历史能力。
|
### G-012:Translation Memory persistence schema V2 已实现,扩展能力仍缺失
|
||||||
|
|
||||||
### G-013:Glossary 未实现
|
Rust `bat` 已提供独立项目级 SQLite TM,当前 persistence schema version 为 V2;schema 打开遵守
|
||||||
|
只读 preflight、fingerprint、transaction rollback 和 future/unknown fail-closed
|
||||||
|
契约。它记录 raw source/hash、完整 context、release/TextUnit/provider/run provenance,
|
||||||
|
区分 candidate/trusted,只有显式 confirm 或 conflict resolve 才能建立唯一 current
|
||||||
|
trusted 记录;worker 只自动复用 raw source + 完整 context exact match 的 current
|
||||||
|
trusted,并在复用前执行已批准 Glossary 的确定性 QA。Go `bat-api` 已提供鉴权的
|
||||||
|
summary/query/conflicts 只读接口和 confirm/resolve_conflict 转发,但 Go 不持有 TM 状态。
|
||||||
|
仍缺少模糊匹配和更丰富的导入导出历史能力。
|
||||||
|
|
||||||
需要支持术语优先级、别名、分类、冲突检测和审核。
|
### G-013:Glossary domain/feature contract V1、persistence schema V2 已实现,协作视图仍缺失
|
||||||
|
|
||||||
|
Rust `bat` 已提供独立项目级 `glossary.sqlite`,当前 schema version 为 V2;V2 正式
|
||||||
|
吸收历史上未升版本的 `glossary_term_deletions` drift,并将历史 V1-A(无 deletion
|
||||||
|
audit)和 V1-B(已有 deletion audit)分别纳入显式迁移。打开遵守只读 fingerprint
|
||||||
|
preflight、writer transaction、rollback 和 future/unknown fail-closed 契约,不再
|
||||||
|
使用隐式 `ensure_column` 修复结构。term/alias/recommended/
|
||||||
|
allowed/category/priority、全局与 TextUnit scope、source history、approved review、
|
||||||
|
冲突诊断、provider-neutral constraints 和确定性 QA 均由 Rust 持有。trusted TM 复用会
|
||||||
|
先经过 Glossary QA;provider、TM、人工 task/workbench 结果都记录 QA,blocking
|
||||||
|
deviation 必须显式提交与当前 QA 精确绑定的 `qa_identity` 及 reviewer/reason/provenance。
|
||||||
|
localized publish 会把发布时重算的 QA 写入 manifest。`translation.glossary.*` 已通过
|
||||||
|
`bat.sock` 暴露,Go 仅提供鉴权后的 typed forwarding。剩余缺口是完整 Web 术语协作视图
|
||||||
|
和更丰富的导入/搜索能力。
|
||||||
|
|
||||||
### G-014:完整 Provider 扩展体系未实现
|
### G-014:完整 Provider 扩展体系未实现
|
||||||
|
|
||||||
@@ -143,9 +202,10 @@ Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整
|
|||||||
|
|
||||||
## 3. 后续推进顺序
|
## 3. 后续推进顺序
|
||||||
|
|
||||||
1. 继续 G-005:真实 AssetBundle 样本、复杂字段解析和发布级重打包。
|
1. 继续 G-005:更多真实 AssetBundle 样本、复杂字段解析、版本差异和任意结构重打包。
|
||||||
2. 继续 G-006/G-011D:通用 manifest Patch 和双 release 查询/清理策略。
|
2. 继续 G-006:复杂 AssetBundle 兼容和真实样本覆盖;G-011D 的双 release 运维 V1
|
||||||
3. 继续 G-011/G-012/G-013/G-014:资源查询、TM 扩展、Glossary 和 Provider
|
已完成,后续 retention scheduler 不属于本次闭环。
|
||||||
|
3. 继续 G-011/G-012/G-013/G-014:资源查询、TM/Glossary 扩展和 Provider
|
||||||
扩展体系。
|
扩展体系。
|
||||||
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
|
4. 在隔离环境执行 `make official-smoke`,补充真实网络长期运行报告。
|
||||||
5. 最后推进完整 Web 协作后台和完整游戏业务 API。
|
5. 最后推进完整 Web 协作后台和完整游戏业务 API。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Go 侧进度与边界(权威)
|
# Go 侧进度与边界(权威)
|
||||||
|
|
||||||
- **更新时间**:2026-09-04
|
- **更新时间**:2026-09-12
|
||||||
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
|
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
|
||||||
- **关联缺口**:G-009(资源 bootstrap/分发);相关契约见 `docs/architecture/official-resource-backend.md` §7 和 `docs/guides/bat-api-local-live-smoke.md`
|
- **关联缺口**:G-009(资源 bootstrap/分发);相关契约见 `docs/architecture/official-resource-backend.md` §7 和 `docs/guides/bat-api-local-live-smoke.md`
|
||||||
|
|
||||||
@@ -61,11 +61,11 @@
|
|||||||
| ID | 约定 |
|
| ID | 约定 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| F | 版本/清单经 **`bat.sock` JSON-RPC**(`--socket`);不读 daemon 内部状态文件 |
|
| F | 版本/清单经 **`bat.sock` JSON-RPC**(`--socket`);不读 daemon 内部状态文件 |
|
||||||
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 catalog/manifest |
|
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再轻量 **`release.attestation`**,再 catalog/manifest;manifest 请求绑定 attested release/publication/mapping/manifest identity 和 verification generation |
|
||||||
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
|
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
|
||||||
| I | 生产中 Rust `bat` 与 `bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
|
| I | 生产中 Rust `bat` 与 `bat-api` 在同一主机/容器/共享文件系统;开发用 `/tmp` fixture 和真实本地 `bat.sock` smoke,不依赖远程连接 |
|
||||||
| J | 索引以 **manifest + 磁盘 Present/size** 为准 |
|
| J | Go 索引以 **manifest + 磁盘 Present/size** 建立可读快照,但不将其当作 release integrity |
|
||||||
| J2 | RPC 状态以 Rust 返回的 `status` / `status_code` 为准;`bat-api` 只读消费,不自行推导同步状态 |
|
| J2 | 普通 current 分发以 Rust `release.attestation` 的 `ready`、identity、freshness 和 integrity/status code 为准;`bat-api` 只读消费,不自行推导 verifier;`release.status` 保留为重型管理诊断 |
|
||||||
|
|
||||||
### 进程配置
|
### 进程配置
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
|
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
|
||||||
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
|
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
|
||||||
| Q | 空目录标明 reserved empty |
|
| Q | 空目录标明 reserved empty |
|
||||||
| R | 默认门禁:`make test-go-api` + `make build-go-api` + `make check-docs`(无 FFI) |
|
| R | 默认门禁:`make ci-check`;其中 Go 使用纯 API test/vet/build、required `golangci-lint 2.12.2`(无 FFI),缺失或版本不匹配失败 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,8 +95,8 @@
|
|||||||
| 组件 | 路径 | 状态 | 说明 |
|
| 组件 | 路径 | 状态 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
| 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` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
|
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`release.attestation/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/conflicts/resolve_conflict`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.manifest` typed params 固定 release/publication/manifest identity 和 verification generation;`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 admin forwarding + 内嵌 dashboard + `.env` |
|
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + `/v1/releases` + `/v1/distribution` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + release/localized/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
|
||||||
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
||||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||||
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
| 空骨架 | `api/`、`pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
|
||||||
@@ -116,10 +116,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 默认(提交前 / CI 建议)
|
# 默认(提交前 / CI 建议)
|
||||||
make test-go-api
|
make ci-check
|
||||||
make build-go-api
|
|
||||||
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
|
||||||
make check-docs
|
|
||||||
|
|
||||||
# 可选:改 FFI 或试验 CLI 时
|
# 可选:改 FFI 或试验 CLI 时
|
||||||
make build-ffi
|
make build-ffi
|
||||||
@@ -134,7 +131,7 @@ make build-go-cli # 产出 bin/bat-go
|
|||||||
| 项 | 状态 |
|
| 项 | 状态 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Go 同步 CLI | **边界已确定**(正式同步 CLI = Rust `bat`) |
|
| Go 同步 CLI | **边界已确定**(正式同步 CLI = Rust `bat`) |
|
||||||
| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*`、`task.*`、`parse.*`、翻译任务/TM 状态查询与显式确认代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 |
|
| G-009 bat-api 资源 bootstrap/分发 | **资源面完成(非完整官方游戏 API)**;已含资源 bootstrap、launcher resource metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理控制白名单、Rust-owned `schedule.*`、`task.*`、`parse.*`、`release.*` 双 release 查询/分发/cleanup 转发、翻译任务/TM 状态查询与显式确认代理、内嵌 dashboard、同机 live smoke 和部署模板;持久化仍另议 |
|
||||||
| G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
|
| G-010 Web | 内嵌 dashboard MVP 已完成;完整协作后台、登录/角色、术语管理和构建型前端未开始 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1205
-115
File diff suppressed because it is too large
Load Diff
@@ -260,6 +260,47 @@ fn translation_memory_commands_parse_and_validate() {
|
|||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(confirm.command, CliCommand::TranslationMemoryConfirm);
|
assert_eq!(confirm.command, CliCommand::TranslationMemoryConfirm);
|
||||||
|
let supersede = parse(&[
|
||||||
|
"bat",
|
||||||
|
"i18n",
|
||||||
|
"memory",
|
||||||
|
"confirm",
|
||||||
|
"--tm-record-id",
|
||||||
|
"tm-new",
|
||||||
|
"--tm-supersede-record-id",
|
||||||
|
"tm-old",
|
||||||
|
"--tm-reviewer",
|
||||||
|
"reviewer",
|
||||||
|
"--tm-reason",
|
||||||
|
"replacement",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(supersede.command, CliCommand::TranslationMemoryConfirm);
|
||||||
|
assert_eq!(
|
||||||
|
supersede.translation_memory_supersede_record_id.as_deref(),
|
||||||
|
Some("tm-old")
|
||||||
|
);
|
||||||
|
let conflicts = parse(&["bat", "i18n", "memory", "conflicts"]).unwrap();
|
||||||
|
assert_eq!(conflicts.command, CliCommand::TranslationMemoryConflicts);
|
||||||
|
let resolve = parse(&[
|
||||||
|
"bat",
|
||||||
|
"i18n",
|
||||||
|
"memory",
|
||||||
|
"resolve-conflict",
|
||||||
|
"--tm-record-id",
|
||||||
|
"tm-winner",
|
||||||
|
"--tm-expected-trusted-record-ids-json",
|
||||||
|
r#"["tm-old","tm-other"]"#,
|
||||||
|
"--tm-reviewer",
|
||||||
|
"reviewer",
|
||||||
|
"--tm-reason",
|
||||||
|
"selected",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
resolve.command,
|
||||||
|
CliCommand::TranslationMemoryResolveConflict
|
||||||
|
);
|
||||||
assert!(parse(&["bat", "i18n", "memory", "query"]).is_err());
|
assert!(parse(&["bat", "i18n", "memory", "query"]).is_err());
|
||||||
assert!(parse(&[
|
assert!(parse(&[
|
||||||
"bat",
|
"bat",
|
||||||
@@ -366,6 +407,91 @@ fn translation_memory_subcommands_reject_irrelevant_options() {
|
|||||||
assert!(error.to_string().contains("query 参数"));
|
assert!(error.to_string().contains("query 参数"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn glossary_commands_parse_and_validate() {
|
||||||
|
let query = parse(&[
|
||||||
|
"bat",
|
||||||
|
"i18n",
|
||||||
|
"glossary",
|
||||||
|
"query",
|
||||||
|
"--glossary-source-text",
|
||||||
|
"Sensei",
|
||||||
|
"--glossary-review-status",
|
||||||
|
"approved",
|
||||||
|
"--limit",
|
||||||
|
"5",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(query.command, CliCommand::GlossaryQuery);
|
||||||
|
assert_eq!(query.glossary_source_text.as_deref(), Some("Sensei"));
|
||||||
|
assert_eq!(query.glossary_review_status.as_deref(), Some("approved"));
|
||||||
|
assert_eq!(query.query_limit, 5);
|
||||||
|
|
||||||
|
let add = parse(&[
|
||||||
|
"bat",
|
||||||
|
"i18n",
|
||||||
|
"glossary",
|
||||||
|
"add",
|
||||||
|
"--glossary-term-id",
|
||||||
|
"term-sensei",
|
||||||
|
"--glossary-source-term",
|
||||||
|
"Sensei",
|
||||||
|
"--glossary-recommended-translation",
|
||||||
|
"老师",
|
||||||
|
"--glossary-source-kind",
|
||||||
|
"manual",
|
||||||
|
"--glossary-scope-json",
|
||||||
|
r#"{"destination":"story.bundle"}"#,
|
||||||
|
"--glossary-path",
|
||||||
|
"/tmp/project-glossary.sqlite",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(add.command, CliCommand::GlossaryAdd);
|
||||||
|
assert_eq!(add.glossary_term_id.as_deref(), Some("term-sensei"));
|
||||||
|
assert_eq!(add.glossary_priority, 0);
|
||||||
|
assert_eq!(
|
||||||
|
add.glossary_path,
|
||||||
|
Some(PathBuf::from("/tmp/project-glossary.sqlite"))
|
||||||
|
);
|
||||||
|
|
||||||
|
let diagnose = parse(&[
|
||||||
|
"bat",
|
||||||
|
"translation",
|
||||||
|
"glossary",
|
||||||
|
"diagnose",
|
||||||
|
"--glossary-source-text",
|
||||||
|
"Sensei",
|
||||||
|
"--glossary-context-json",
|
||||||
|
r#"{"destination":"story.bundle"}"#,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(diagnose.command, CliCommand::GlossaryDiagnose);
|
||||||
|
let delete = parse(&[
|
||||||
|
"bat",
|
||||||
|
"i18n",
|
||||||
|
"glossary",
|
||||||
|
"delete",
|
||||||
|
"--glossary-term-id",
|
||||||
|
"term-sensei",
|
||||||
|
"--glossary-reviewer",
|
||||||
|
"operator",
|
||||||
|
"--glossary-reason",
|
||||||
|
"duplicate",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(delete.command, CliCommand::GlossaryDelete);
|
||||||
|
assert!(parse(&["bat", "i18n", "glossary", "diagnose"]).is_err());
|
||||||
|
assert!(parse(&[
|
||||||
|
"bat",
|
||||||
|
"i18n",
|
||||||
|
"glossary",
|
||||||
|
"query",
|
||||||
|
"--glossary-term-id",
|
||||||
|
"term-sensei",
|
||||||
|
])
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn translation_worker_env_defaults_apply() {
|
fn translation_worker_env_defaults_apply() {
|
||||||
let options = parse_with_env(
|
let options = parse_with_env(
|
||||||
@@ -574,6 +700,19 @@ fn grouped_workflow_commands_use_short_top_level_aliases() {
|
|||||||
assert_eq!(options.command, CliCommand::PublishLocalized);
|
assert_eq!(options.command, CliCommand::PublishLocalized);
|
||||||
assert!(options.translation_from_worker);
|
assert!(options.translation_from_worker);
|
||||||
assert_eq!(options.localized_release_id.as_deref(), Some("localized-1"));
|
assert_eq!(options.localized_release_id.as_deref(), Some("localized-1"));
|
||||||
|
let options = parse(&[
|
||||||
|
"bat",
|
||||||
|
"i18n",
|
||||||
|
"publish",
|
||||||
|
"--patch-manifest",
|
||||||
|
"/tmp/patch-manifest.json",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(options.command, CliCommand::PublishLocalized);
|
||||||
|
assert_eq!(
|
||||||
|
options.patch_manifest,
|
||||||
|
Some(PathBuf::from("/tmp/patch-manifest.json"))
|
||||||
|
);
|
||||||
assert!(parse(&[
|
assert!(parse(&[
|
||||||
"bat",
|
"bat",
|
||||||
"i18n",
|
"i18n",
|
||||||
@@ -765,6 +904,8 @@ fn translation_workbench_commands_read_update_and_clear_entries() {
|
|||||||
review_status: None,
|
review_status: None,
|
||||||
format: Some("plain".to_string()),
|
format: Some("plain".to_string()),
|
||||||
text_source_kind: Some("text_asset".to_string()),
|
text_source_kind: Some("text_asset".to_string()),
|
||||||
|
glossary_qa: None,
|
||||||
|
glossary_override: None,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
bat_infrastructure::write_translation_workbench(&path, &workbench).unwrap();
|
bat_infrastructure::write_translation_workbench(&path, &workbench).unwrap();
|
||||||
@@ -1450,6 +1591,8 @@ fn parses_explicit_source_and_disable_repair() {
|
|||||||
"--no-repair",
|
"--no-repair",
|
||||||
"--unzip",
|
"--unzip",
|
||||||
"/usr/bin/unzip",
|
"/usr/bin/unzip",
|
||||||
|
"--zip",
|
||||||
|
"/usr/bin/zip",
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let config = options.config;
|
let config = options.config;
|
||||||
@@ -1460,6 +1603,7 @@ fn parses_explicit_source_and_disable_repair() {
|
|||||||
assert!(!config.audit_local);
|
assert!(!config.audit_local);
|
||||||
assert!(!config.repair);
|
assert!(!config.repair);
|
||||||
assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip"));
|
assert_eq!(config.unzip_command, PathBuf::from("/usr/bin/unzip"));
|
||||||
|
assert_eq!(config.zip_command, PathBuf::from("/usr/bin/zip"));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
config.server_info_source,
|
config.server_info_source,
|
||||||
Some(OfficialServerInfoSource::OfficialUrl(ref url))
|
Some(OfficialServerInfoSource::OfficialUrl(ref url))
|
||||||
@@ -2139,6 +2283,8 @@ fn daemon_child_args_preserve_sync_options() {
|
|||||||
"http://127.0.0.1:7890",
|
"http://127.0.0.1:7890",
|
||||||
"--unzip",
|
"--unzip",
|
||||||
"/usr/bin/unzip",
|
"/usr/bin/unzip",
|
||||||
|
"--zip",
|
||||||
|
"/usr/bin/zip",
|
||||||
"--interval",
|
"--interval",
|
||||||
"30m",
|
"30m",
|
||||||
"--error-retry",
|
"--error-retry",
|
||||||
@@ -2155,6 +2301,9 @@ fn daemon_child_args_preserve_sync_options() {
|
|||||||
assert!(args
|
assert!(args
|
||||||
.windows(2)
|
.windows(2)
|
||||||
.any(|pair| pair == ["--output", "/tmp/daemon-output"]));
|
.any(|pair| pair == ["--output", "/tmp/daemon-output"]));
|
||||||
|
assert!(args
|
||||||
|
.windows(2)
|
||||||
|
.any(|pair| pair == ["--zip", "/usr/bin/zip"]));
|
||||||
assert!(args
|
assert!(args
|
||||||
.windows(2)
|
.windows(2)
|
||||||
.any(|pair| pair == ["--localized-output", "/tmp/daemon-localized"]));
|
.any(|pair| pair == ["--localized-output", "/tmp/daemon-localized"]));
|
||||||
@@ -2679,6 +2828,111 @@ fn dispatch_translation_memory_summary_defaults_to_worker_config_path() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_glossary_summary_reports_missing_database_without_creating_it() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let output_root = temp.path().join("output");
|
||||||
|
let state_dir = temp.path().join("state");
|
||||||
|
let (queue, _rx) = mpsc::channel::<TaskJob>();
|
||||||
|
let context = DaemonTaskContext {
|
||||||
|
registry: TaskRegistry::new(),
|
||||||
|
queue,
|
||||||
|
base_config: OfficialUpdateConfig {
|
||||||
|
output_root: output_root.clone(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
translation_worker_config: TranslationWorkerConfig::default(),
|
||||||
|
sync_lock: Arc::new(Mutex::new(())),
|
||||||
|
restart_controller: test_restart_controller,
|
||||||
|
};
|
||||||
|
|
||||||
|
let envelope = dispatch_rpc_method(
|
||||||
|
&rpc_request("translation.glossary.summary", None),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&context,
|
||||||
|
"req-glossary-summary-1".to_string(),
|
||||||
|
);
|
||||||
|
let value = serde_json::to_value(envelope).unwrap();
|
||||||
|
assert_eq!(value["ok"], true);
|
||||||
|
assert_eq!(value["data"]["available"], false);
|
||||||
|
assert_eq!(value["data"]["reason"], "database_missing");
|
||||||
|
assert!(!output_root.join("glossary.sqlite").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_glossary_delete_removes_term_and_returns_snapshot() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let output_root = temp.path().join("output");
|
||||||
|
let state_dir = temp.path().join("state");
|
||||||
|
let glossary_path = output_root.join("glossary.sqlite");
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
runtime.block_on(async {
|
||||||
|
let repository = bat_infrastructure::SqliteGlossaryRepository::new(&glossary_path)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repository
|
||||||
|
.add(bat_core::domain::GlossaryTermDraft {
|
||||||
|
term_id: "term-sensei".to_string(),
|
||||||
|
definition: bat_core::domain::GlossaryTermSnapshot {
|
||||||
|
source_term: "Sensei".to_string(),
|
||||||
|
aliases: vec!["Teacher".to_string()],
|
||||||
|
recommended_translation: "老师".to_string(),
|
||||||
|
allowed_translations: Vec::new(),
|
||||||
|
source_language: None,
|
||||||
|
target_language: None,
|
||||||
|
category: Some("person".to_string()),
|
||||||
|
priority: 10,
|
||||||
|
scope: Default::default(),
|
||||||
|
},
|
||||||
|
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
|
||||||
|
source: bat_core::domain::GlossarySourceRecord {
|
||||||
|
source_kind: bat_core::domain::GlossarySourceKind::Manual,
|
||||||
|
source_ref: Some("test".to_string()),
|
||||||
|
source_author: Some("tester".to_string()),
|
||||||
|
source_note: None,
|
||||||
|
observed_unix_seconds: 100,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let context = test_task_context_with_config(OfficialUpdateConfig {
|
||||||
|
output_root: output_root.clone(),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let envelope = dispatch_rpc_method(
|
||||||
|
&rpc_request(
|
||||||
|
"translation.glossary.delete",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"term_id": "term-sensei",
|
||||||
|
"reviewer": "reviewer",
|
||||||
|
"reason": "duplicate"
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&context,
|
||||||
|
"req-glossary-delete-1".to_string(),
|
||||||
|
);
|
||||||
|
let value = serde_json::to_value(envelope).unwrap();
|
||||||
|
assert_eq!(value["ok"], true);
|
||||||
|
assert_eq!(value["data"]["deleted"], true);
|
||||||
|
assert_eq!(value["data"]["term"]["term_id"], "term-sensei");
|
||||||
|
assert_eq!(value["data"]["term"]["source_term"], "Sensei");
|
||||||
|
|
||||||
|
runtime.block_on(async {
|
||||||
|
let repository = bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(repository.find("term-sensei").await.is_err());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
|
fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
|
||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
@@ -2701,6 +2955,19 @@ fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
|
|||||||
"translation.memory.confirm",
|
"translation.memory.confirm",
|
||||||
Some(serde_json::json!({ "record_id": "tm-record", "reviewer": 42 })),
|
Some(serde_json::json!({ "record_id": "tm-record", "reviewer": 42 })),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"translation.memory.conflicts",
|
||||||
|
Some(serde_json::json!({ "limit": "1" })),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"translation.memory.resolve_conflict",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"winner_record_id": "tm-winner",
|
||||||
|
"expected_trusted_record_ids": "tm-old",
|
||||||
|
"reviewer": "reviewer",
|
||||||
|
"reason": "selected"
|
||||||
|
})),
|
||||||
|
),
|
||||||
] {
|
] {
|
||||||
let envelope = dispatch_rpc_method(
|
let envelope = dispatch_rpc_method(
|
||||||
&rpc_request(method, params),
|
&rpc_request(method, params),
|
||||||
@@ -3968,7 +4235,7 @@ fn dispatch_catalog_status_reads_current_snapshot() {
|
|||||||
"req-cat-1".to_string(),
|
"req-cat-1".to_string(),
|
||||||
);
|
);
|
||||||
let value = serde_json::to_value(&envelope).unwrap();
|
let value = serde_json::to_value(&envelope).unwrap();
|
||||||
assert_eq!(value["ok"], true);
|
assert_eq!(value["ok"], true, "{value}");
|
||||||
assert_eq!(value["data"]["available"], true);
|
assert_eq!(value["data"]["available"], true);
|
||||||
assert_eq!(value["data"]["bundle_version"], "bundle-b2");
|
assert_eq!(value["data"]["bundle_version"], "bundle-b2");
|
||||||
assert_eq!(value["data"]["status"], "published");
|
assert_eq!(value["data"]["status"], "published");
|
||||||
@@ -4013,6 +4280,75 @@ fn dispatch_catalog_versions_lists_history() {
|
|||||||
assert!(value["data"]["failed"].as_array().unwrap().is_empty());
|
assert!(value["data"]["failed"].as_array().unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn dispatch_release_rpc_exposes_dual_release_queries_and_safe_cleanup_plan() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let state_dir = temp.path().join("state");
|
||||||
|
let output_root = temp.path().join("output");
|
||||||
|
write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
|
||||||
|
let localized_root = temp.path().join("localized");
|
||||||
|
let tasks = test_task_context_with_config(OfficialUpdateConfig {
|
||||||
|
output_root: output_root.clone(),
|
||||||
|
localized_output_root: localized_root.clone(),
|
||||||
|
..OfficialUpdateConfig::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let status = dispatch_rpc_method(
|
||||||
|
&rpc_request("release.status", None),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&tasks,
|
||||||
|
"req-release-status".to_string(),
|
||||||
|
);
|
||||||
|
let status = serde_json::to_value(status).unwrap();
|
||||||
|
assert_eq!(status["ok"], true);
|
||||||
|
assert_eq!(status["data"]["default_distribution_channel"], "official");
|
||||||
|
assert_eq!(status["data"]["official_current_release_id"], "v-current");
|
||||||
|
|
||||||
|
let list = dispatch_rpc_method(
|
||||||
|
&rpc_request(
|
||||||
|
"release.list",
|
||||||
|
Some(serde_json::json!({"channel": "official"})),
|
||||||
|
),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&tasks,
|
||||||
|
"req-release-list".to_string(),
|
||||||
|
);
|
||||||
|
let list = serde_json::to_value(list).unwrap();
|
||||||
|
assert_eq!(list["ok"], true);
|
||||||
|
assert_eq!(list["data"]["channel"], "official");
|
||||||
|
assert_eq!(list["data"]["releases"][0]["channel"], "official");
|
||||||
|
|
||||||
|
let distribution = dispatch_rpc_method(
|
||||||
|
&rpc_request(
|
||||||
|
"release.distribution",
|
||||||
|
Some(serde_json::json!({"channel": "localized"})),
|
||||||
|
),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&tasks,
|
||||||
|
"req-release-distribution".to_string(),
|
||||||
|
);
|
||||||
|
let distribution = serde_json::to_value(distribution).unwrap();
|
||||||
|
assert_eq!(distribution["ok"], true);
|
||||||
|
assert_eq!(distribution["data"]["available"], false);
|
||||||
|
assert_eq!(distribution["data"]["channel"], "localized");
|
||||||
|
|
||||||
|
let cleanup = dispatch_rpc_method(
|
||||||
|
&rpc_request("release.cleanup", None),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&tasks,
|
||||||
|
"req-release-cleanup".to_string(),
|
||||||
|
);
|
||||||
|
let cleanup = serde_json::to_value(cleanup).unwrap();
|
||||||
|
assert_eq!(cleanup["ok"], true);
|
||||||
|
assert_eq!(cleanup["data"]["execute"], false);
|
||||||
|
assert!(cleanup["data"]["plan_id"].as_str().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dispatch_catalog_diff_reports_bundle_change() {
|
fn dispatch_catalog_diff_reports_bundle_change() {
|
||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
@@ -4123,6 +4459,8 @@ fn catalog_refresh_config_is_dry_run_plan_only() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dispatch_resource_manifest_paginates() {
|
fn dispatch_resource_manifest_paginates() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
let state_dir = temp.path().join("state");
|
let state_dir = temp.path().join("state");
|
||||||
let output_root = temp.path().join("output");
|
let output_root = temp.path().join("output");
|
||||||
@@ -4150,16 +4488,80 @@ fn dispatch_resource_manifest_paginates() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
let mut manifest: bat_infrastructure::OfficialDownloadManifest =
|
||||||
|
serde_json::from_value(manifest).unwrap();
|
||||||
|
let mapping_identity = bat_infrastructure::official_distribution_mapping_identity(&manifest);
|
||||||
|
manifest.distribution_mapping_identity = Some(mapping_identity.clone());
|
||||||
|
manifest.destination_index = manifest
|
||||||
|
.entries
|
||||||
|
.values()
|
||||||
|
.map(|entry| (entry.destination.clone(), entry.url.clone()))
|
||||||
|
.collect();
|
||||||
|
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
current_dir.join("official-download-manifest.json"),
|
current_dir.join("official-download-manifest.json"),
|
||||||
serde_json::to_vec(&manifest).unwrap(),
|
&manifest_bytes,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let manifest_identity = blake3::hash(&manifest_bytes).to_hex().to_string();
|
||||||
|
let publication_identity = format!("odp-v1-{mapping_identity}-{manifest_identity}");
|
||||||
|
fs::write(
|
||||||
|
current_dir.join("official-distribution-publication.json"),
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"version": 1,
|
||||||
|
"official_release_id": "v-current",
|
||||||
|
"mapping_identity": mapping_identity,
|
||||||
|
"manifest_identity": manifest_identity,
|
||||||
|
"entry_count": 3,
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
current_dir.join("official-distribution-attestation.json"),
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"version": 1,
|
||||||
|
"channel": "official",
|
||||||
|
"official_release_id": "v-current",
|
||||||
|
"resource_root": current_dir,
|
||||||
|
"publication_identity": publication_identity,
|
||||||
|
"mapping_identity": mapping_identity,
|
||||||
|
"manifest_identity": manifest_identity,
|
||||||
|
"entry_count": 3,
|
||||||
|
"integrity_status": "verified",
|
||||||
|
"status": "ready",
|
||||||
|
"status_code": "distribution.ready",
|
||||||
|
"ready": true,
|
||||||
|
"verification_generation": 1,
|
||||||
|
"verified_at": unix_seconds_now(),
|
||||||
|
"max_age_seconds": 7260,
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
symlink(
|
||||||
|
Path::new("versions").join("v-current"),
|
||||||
|
output_root.join("current"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bound_params = serde_json::json!({
|
||||||
|
"release_id": "v-current",
|
||||||
|
"expected_publication_identity": publication_identity,
|
||||||
|
"expected_manifest_identity": manifest_identity,
|
||||||
|
"expected_verification_generation": 1,
|
||||||
|
});
|
||||||
|
|
||||||
let envelope = dispatch_rpc_method(
|
let envelope = dispatch_rpc_method(
|
||||||
&rpc_request(
|
&rpc_request(
|
||||||
"resource.manifest",
|
"resource.manifest",
|
||||||
Some(serde_json::json!({ "offset": 1, "limit": 2 })),
|
Some(serde_json::json!({
|
||||||
|
"release_id": bound_params["release_id"],
|
||||||
|
"expected_publication_identity": bound_params["expected_publication_identity"],
|
||||||
|
"expected_manifest_identity": bound_params["expected_manifest_identity"],
|
||||||
|
"expected_verification_generation": bound_params["expected_verification_generation"],
|
||||||
|
"offset": 1,
|
||||||
|
"limit": 2,
|
||||||
|
})),
|
||||||
),
|
),
|
||||||
&state_dir,
|
&state_dir,
|
||||||
&new_daemon_control(),
|
&new_daemon_control(),
|
||||||
@@ -4171,6 +4573,9 @@ fn dispatch_resource_manifest_paginates() {
|
|||||||
assert_eq!(value["data"]["available"], true);
|
assert_eq!(value["data"]["available"], true);
|
||||||
assert_eq!(value["data"]["total_entries"], 3);
|
assert_eq!(value["data"]["total_entries"], 3);
|
||||||
assert_eq!(value["data"]["offset"], 1);
|
assert_eq!(value["data"]["offset"], 1);
|
||||||
|
assert_eq!(value["data"]["release_id"], "v-current");
|
||||||
|
assert!(value["data"]["manifest_identity"].as_str().is_some());
|
||||||
|
assert!(value["data"]["generation"].as_u64().is_some());
|
||||||
let entries = value["data"]["entries"].as_array().unwrap();
|
let entries = value["data"]["entries"].as_array().unwrap();
|
||||||
assert_eq!(entries.len(), 2);
|
assert_eq!(entries.len(), 2);
|
||||||
assert_eq!(entries[0]["destination"], "b");
|
assert_eq!(entries[0]["destination"], "b");
|
||||||
@@ -4178,7 +4583,16 @@ fn dispatch_resource_manifest_paginates() {
|
|||||||
|
|
||||||
// 非法 limit → 参数错误。
|
// 非法 limit → 参数错误。
|
||||||
let envelope = dispatch_rpc_method(
|
let envelope = dispatch_rpc_method(
|
||||||
&rpc_request("resource.manifest", Some(serde_json::json!({ "limit": 0 }))),
|
&rpc_request(
|
||||||
|
"resource.manifest",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"release_id": bound_params["release_id"],
|
||||||
|
"expected_publication_identity": bound_params["expected_publication_identity"],
|
||||||
|
"expected_manifest_identity": bound_params["expected_manifest_identity"],
|
||||||
|
"expected_verification_generation": bound_params["expected_verification_generation"],
|
||||||
|
"limit": 0,
|
||||||
|
})),
|
||||||
|
),
|
||||||
&state_dir,
|
&state_dir,
|
||||||
&new_daemon_control(),
|
&new_daemon_control(),
|
||||||
&test_task_context(),
|
&test_task_context(),
|
||||||
@@ -4191,7 +4605,14 @@ fn dispatch_resource_manifest_paginates() {
|
|||||||
let envelope = dispatch_rpc_method(
|
let envelope = dispatch_rpc_method(
|
||||||
&rpc_request(
|
&rpc_request(
|
||||||
"resource.list",
|
"resource.list",
|
||||||
Some(serde_json::json!({ "offset": 2, "limit": 1 })),
|
Some(serde_json::json!({
|
||||||
|
"release_id": bound_params["release_id"],
|
||||||
|
"expected_publication_identity": bound_params["expected_publication_identity"],
|
||||||
|
"expected_manifest_identity": bound_params["expected_manifest_identity"],
|
||||||
|
"expected_verification_generation": bound_params["expected_verification_generation"],
|
||||||
|
"offset": 2,
|
||||||
|
"limit": 1,
|
||||||
|
})),
|
||||||
),
|
),
|
||||||
&state_dir,
|
&state_dir,
|
||||||
&new_daemon_control(),
|
&new_daemon_control(),
|
||||||
@@ -4204,6 +4625,151 @@ fn dispatch_resource_manifest_paginates() {
|
|||||||
let entries = value["data"]["entries"].as_array().unwrap();
|
let entries = value["data"]["entries"].as_array().unwrap();
|
||||||
assert_eq!(entries.len(), 1);
|
assert_eq!(entries.len(), 1);
|
||||||
assert_eq!(entries[0]["destination"], "c");
|
assert_eq!(entries[0]["destination"], "c");
|
||||||
|
|
||||||
|
let envelope = dispatch_rpc_method(
|
||||||
|
&rpc_request(
|
||||||
|
"resource.manifest",
|
||||||
|
Some(serde_json::json!({
|
||||||
|
"release_id": "v-current",
|
||||||
|
"expected_publication_identity": publication_identity,
|
||||||
|
"expected_manifest_identity": "wrong-generation",
|
||||||
|
"expected_verification_generation": 1,
|
||||||
|
"offset": 0,
|
||||||
|
"limit": 1,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&test_task_context(),
|
||||||
|
"req-man-4".to_string(),
|
||||||
|
);
|
||||||
|
let value = serde_json::to_value(&envelope).unwrap();
|
||||||
|
assert_eq!(value["ok"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_resource_manifest_rejects_previous_verification_generation() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let state_dir = temp.path().join("state");
|
||||||
|
let output_root = temp.path().join("output");
|
||||||
|
let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b1", None);
|
||||||
|
let manifest = serde_json::json!({
|
||||||
|
"version": 1,
|
||||||
|
"entries": {
|
||||||
|
"https://prod-clientpatch.bluearchiveyostar.com/a": {
|
||||||
|
"url": "https://prod-clientpatch.bluearchiveyostar.com/a",
|
||||||
|
"destination": "a",
|
||||||
|
"bytes": 1,
|
||||||
|
"blake3": blake3::hash(b"a").to_hex().to_string(),
|
||||||
|
},
|
||||||
|
"https://prod-clientpatch.bluearchiveyostar.com/b": {
|
||||||
|
"url": "https://prod-clientpatch.bluearchiveyostar.com/b",
|
||||||
|
"destination": "b",
|
||||||
|
"bytes": 1,
|
||||||
|
"blake3": blake3::hash(b"b").to_hex().to_string(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let mut manifest: bat_infrastructure::OfficialDownloadManifest =
|
||||||
|
serde_json::from_value(manifest).unwrap();
|
||||||
|
manifest.distribution_mapping_identity = Some(
|
||||||
|
bat_infrastructure::official_distribution_mapping_identity(&manifest),
|
||||||
|
);
|
||||||
|
manifest.destination_index = manifest
|
||||||
|
.entries
|
||||||
|
.values()
|
||||||
|
.map(|entry| (entry.destination.clone(), entry.url.clone()))
|
||||||
|
.collect();
|
||||||
|
let manifest_bytes = serde_json::to_vec(&manifest).unwrap();
|
||||||
|
fs::write(
|
||||||
|
current_dir.join("official-download-manifest.json"),
|
||||||
|
&manifest_bytes,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(current_dir.join("a"), b"a").unwrap();
|
||||||
|
fs::write(current_dir.join("b"), b"b").unwrap();
|
||||||
|
symlink(
|
||||||
|
Path::new("versions").join("v-current"),
|
||||||
|
output_root.join("current"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mapping_identity = bat_infrastructure::official_distribution_mapping_identity(&manifest);
|
||||||
|
let manifest_identity = blake3::hash(&manifest_bytes).to_hex().to_string();
|
||||||
|
let publication_identity = format!("odp-v1-{mapping_identity}-{manifest_identity}");
|
||||||
|
let write_attestation = |generation: u64| {
|
||||||
|
fs::write(
|
||||||
|
current_dir.join("official-distribution-publication.json"),
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"version": 1,
|
||||||
|
"official_release_id": "v-current",
|
||||||
|
"mapping_identity": mapping_identity,
|
||||||
|
"manifest_identity": manifest_identity,
|
||||||
|
"entry_count": 2,
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
current_dir.join("official-distribution-attestation.json"),
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"version": 1,
|
||||||
|
"channel": "official",
|
||||||
|
"official_release_id": "v-current",
|
||||||
|
"resource_root": current_dir,
|
||||||
|
"publication_identity": publication_identity,
|
||||||
|
"mapping_identity": mapping_identity,
|
||||||
|
"manifest_identity": manifest_identity,
|
||||||
|
"entry_count": 2,
|
||||||
|
"integrity_status": "verified",
|
||||||
|
"status": "ready",
|
||||||
|
"status_code": "distribution.ready",
|
||||||
|
"ready": true,
|
||||||
|
"verification_generation": generation,
|
||||||
|
"verified_at": unix_seconds_now(),
|
||||||
|
"max_age_seconds": 7260,
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
};
|
||||||
|
write_attestation(4);
|
||||||
|
|
||||||
|
let bound_params = serde_json::json!({
|
||||||
|
"release_id": "v-current",
|
||||||
|
"expected_publication_identity": publication_identity,
|
||||||
|
"expected_manifest_identity": manifest_identity,
|
||||||
|
"expected_verification_generation": 4,
|
||||||
|
"offset": 0,
|
||||||
|
"limit": 1,
|
||||||
|
});
|
||||||
|
let envelope = dispatch_rpc_method(
|
||||||
|
&rpc_request("resource.manifest", Some(bound_params.clone())),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&test_task_context(),
|
||||||
|
"req-generation-1".to_string(),
|
||||||
|
);
|
||||||
|
let value = serde_json::to_value(&envelope).unwrap();
|
||||||
|
assert_eq!(value["ok"], true);
|
||||||
|
assert_eq!(value["data"]["generation"], 4);
|
||||||
|
assert_eq!(
|
||||||
|
value["data"]["resource_root"],
|
||||||
|
current_dir.to_string_lossy().as_ref()
|
||||||
|
);
|
||||||
|
|
||||||
|
write_attestation(5);
|
||||||
|
let envelope = dispatch_rpc_method(
|
||||||
|
&rpc_request("resource.manifest", Some(bound_params)),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&test_task_context(),
|
||||||
|
"req-generation-2".to_string(),
|
||||||
|
);
|
||||||
|
let value = serde_json::to_value(&envelope).unwrap();
|
||||||
|
assert_eq!(value["ok"], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_resource_index_fixture(repository_path: &Path) {
|
fn write_resource_index_fixture(repository_path: &Path) {
|
||||||
@@ -4873,7 +5439,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
|||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
let state_dir = temp.path().join("state");
|
let state_dir = temp.path().join("state");
|
||||||
let output_root = temp.path().join("output");
|
let output_root = temp.path().join("output");
|
||||||
write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
|
let official_version = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
|
||||||
let localized_root = temp.path().join("localized");
|
let localized_root = temp.path().join("localized");
|
||||||
let localized_version = localized_root
|
let localized_version = localized_root
|
||||||
.join(LOCALIZED_VERSIONS_DIR)
|
.join(LOCALIZED_VERSIONS_DIR)
|
||||||
@@ -4884,6 +5450,8 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
|||||||
localized_root.join(LOCALIZED_CURRENT_LINK),
|
localized_root.join(LOCALIZED_CURRENT_LINK),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
fs::write(official_version.join("data.bin"), b"official").unwrap();
|
||||||
|
fs::write(localized_version.join("data.bin"), b"localized").unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
localized_root.join(LOCALIZED_VERSION_STATE_FILE),
|
localized_root.join(LOCALIZED_VERSION_STATE_FILE),
|
||||||
serde_json::to_vec(&bat_infrastructure::LocalizedVersionState {
|
serde_json::to_vec(&bat_infrastructure::LocalizedVersionState {
|
||||||
@@ -4904,9 +5472,19 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
|||||||
official_release_id: "v-current".to_string(),
|
official_release_id: "v-current".to_string(),
|
||||||
localized_release_id: "v-current".to_string(),
|
localized_release_id: "v-current".to_string(),
|
||||||
generated_unix_seconds: 124,
|
generated_unix_seconds: 124,
|
||||||
file_count: 0,
|
file_count: 1,
|
||||||
text_asset_operation_count: 0,
|
text_asset_operation_count: 0,
|
||||||
files: Vec::new(),
|
files: vec![bat_infrastructure::LocalizedPatchFile {
|
||||||
|
path: "data.bin".to_string(),
|
||||||
|
original_blake3: blake3::hash(b"official").to_hex().to_string(),
|
||||||
|
localized_blake3: blake3::hash(b"localized").to_hex().to_string(),
|
||||||
|
original_bytes: 8,
|
||||||
|
localized_bytes: 9,
|
||||||
|
byte_delta: 1,
|
||||||
|
text_asset_operations: Vec::new(),
|
||||||
|
operations: Vec::new(),
|
||||||
|
}],
|
||||||
|
patch_manifest: None,
|
||||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||||
previous_current_target: None,
|
previous_current_target: None,
|
||||||
remove_version_path: localized_version.clone(),
|
remove_version_path: localized_version.clone(),
|
||||||
@@ -4938,7 +5516,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
|||||||
assert_eq!(value["data"]["current_points_to_published_version"], true);
|
assert_eq!(value["data"]["current_points_to_published_version"], true);
|
||||||
assert_eq!(value["data"]["patch_manifest_available"], true);
|
assert_eq!(value["data"]["patch_manifest_available"], true);
|
||||||
assert_eq!(value["data"]["patch_manifest_matches_release"], true);
|
assert_eq!(value["data"]["patch_manifest_matches_release"], true);
|
||||||
assert_eq!(value["data"]["patch_file_count"], 0);
|
assert_eq!(value["data"]["patch_file_count"], 1);
|
||||||
assert_eq!(value["data"]["patch_text_asset_operation_count"], 0);
|
assert_eq!(value["data"]["patch_text_asset_operation_count"], 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
value["data"]["translation_workflow_status"],
|
value["data"]["translation_workflow_status"],
|
||||||
@@ -4948,6 +5526,23 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
|
|||||||
value["data"]["published_version_path"].as_str().unwrap(),
|
value["data"]["published_version_path"].as_str().unwrap(),
|
||||||
localized_version.to_string_lossy()
|
localized_version.to_string_lossy()
|
||||||
);
|
);
|
||||||
|
assert_eq!(value["data"]["patch_manifest_contract_status"], "legacy");
|
||||||
|
assert_eq!(value["data"]["artifact_integrity_status"], "valid");
|
||||||
|
|
||||||
|
fs::write(localized_version.join("data.bin"), b"corrupt").unwrap();
|
||||||
|
let envelope = dispatch_rpc_method(
|
||||||
|
&rpc_request("localized.status", None),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&tasks,
|
||||||
|
"req-loc-corrupt".to_string(),
|
||||||
|
);
|
||||||
|
let value = serde_json::to_value(&envelope).unwrap();
|
||||||
|
assert_eq!(value["ok"], true);
|
||||||
|
assert_eq!(value["data"]["status_code"], "localized.degraded");
|
||||||
|
assert_eq!(value["data"]["localized_release_status"], "degraded");
|
||||||
|
assert_eq!(value["data"]["artifact_integrity_status"], "invalid");
|
||||||
|
assert_eq!(value["data"]["artifact_integrity_verified"], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -5043,6 +5638,7 @@ fn dispatch_localized_rollback_restores_manifest_previous_release() {
|
|||||||
file_count: 0,
|
file_count: 0,
|
||||||
text_asset_operation_count: 0,
|
text_asset_operation_count: 0,
|
||||||
files: Vec::new(),
|
files: Vec::new(),
|
||||||
|
patch_manifest: None,
|
||||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||||
previous_current_target: None,
|
previous_current_target: None,
|
||||||
remove_version_path: previous.clone(),
|
remove_version_path: previous.clone(),
|
||||||
@@ -5061,6 +5657,7 @@ fn dispatch_localized_rollback_restores_manifest_previous_release() {
|
|||||||
file_count: 0,
|
file_count: 0,
|
||||||
text_asset_operation_count: 0,
|
text_asset_operation_count: 0,
|
||||||
files: Vec::new(),
|
files: Vec::new(),
|
||||||
|
patch_manifest: None,
|
||||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||||
previous_current_target: Some(PathBuf::from("versions/release-1")),
|
previous_current_target: Some(PathBuf::from("versions/release-1")),
|
||||||
remove_version_path: current.clone(),
|
remove_version_path: current.clone(),
|
||||||
@@ -5162,6 +5759,7 @@ fn localized_status_keeps_published_release_during_manual_proofreading() {
|
|||||||
file_count: 0,
|
file_count: 0,
|
||||||
text_asset_operation_count: 0,
|
text_asset_operation_count: 0,
|
||||||
files: Vec::new(),
|
files: Vec::new(),
|
||||||
|
patch_manifest: None,
|
||||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||||
previous_current_target: None,
|
previous_current_target: None,
|
||||||
remove_version_path: localized_version.clone(),
|
remove_version_path: localized_version.clone(),
|
||||||
|
|||||||
@@ -61,12 +61,14 @@ import_resource_repository_path = ''
|
|||||||
curl_command = 'curl'
|
curl_command = 'curl'
|
||||||
proxy = 'auto'
|
proxy = 'auto'
|
||||||
unzip_command = 'unzip'
|
unzip_command = 'unzip'
|
||||||
|
zip_command = 'zip'
|
||||||
download_concurrency = 8
|
download_concurrency = 8
|
||||||
|
|
||||||
[translation.worker]
|
[translation.worker]
|
||||||
provider = 'mock'
|
provider = 'mock'
|
||||||
fixture = ''
|
fixture = ''
|
||||||
translation_memory_path = ''
|
translation_memory_path = ''
|
||||||
|
glossary_path = ''
|
||||||
concurrency = 8
|
concurrency = 8
|
||||||
max_attempts = 3
|
max_attempts = 3
|
||||||
lease_seconds = 300
|
lease_seconds = 300
|
||||||
@@ -137,6 +139,7 @@ struct NetworkSection {
|
|||||||
curl_command: Option<PathBuf>,
|
curl_command: Option<PathBuf>,
|
||||||
proxy: Option<CurlProxyConfig>,
|
proxy: Option<CurlProxyConfig>,
|
||||||
unzip_command: Option<PathBuf>,
|
unzip_command: Option<PathBuf>,
|
||||||
|
zip_command: Option<PathBuf>,
|
||||||
download_concurrency: Option<usize>,
|
download_concurrency: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +153,7 @@ struct TranslationWorkerSection {
|
|||||||
provider: Option<String>,
|
provider: Option<String>,
|
||||||
fixture: Option<PathBuf>,
|
fixture: Option<PathBuf>,
|
||||||
translation_memory_path: Option<PathBuf>,
|
translation_memory_path: Option<PathBuf>,
|
||||||
|
glossary_path: Option<PathBuf>,
|
||||||
concurrency: Option<usize>,
|
concurrency: Option<usize>,
|
||||||
max_attempts: Option<u32>,
|
max_attempts: Option<u32>,
|
||||||
lease_seconds: Option<u64>,
|
lease_seconds: Option<u64>,
|
||||||
@@ -354,6 +358,9 @@ impl BatConfigFile {
|
|||||||
if let Some(value) = self.network.unzip_command.as_ref() {
|
if let Some(value) = self.network.unzip_command.as_ref() {
|
||||||
options.config.unzip_command = value.clone();
|
options.config.unzip_command = value.clone();
|
||||||
}
|
}
|
||||||
|
if let Some(value) = self.network.zip_command.as_ref() {
|
||||||
|
options.config.zip_command = value.clone();
|
||||||
|
}
|
||||||
if let Some(value) = self.network.download_concurrency {
|
if let Some(value) = self.network.download_concurrency {
|
||||||
options.config.download_concurrency = value;
|
options.config.download_concurrency = value;
|
||||||
}
|
}
|
||||||
@@ -367,6 +374,9 @@ impl BatConfigFile {
|
|||||||
if let Some(value) = self.translation.worker.translation_memory_path.as_ref() {
|
if let Some(value) = self.translation.worker.translation_memory_path.as_ref() {
|
||||||
options.translation_memory_path = Some(value.clone());
|
options.translation_memory_path = Some(value.clone());
|
||||||
}
|
}
|
||||||
|
if let Some(value) = self.translation.worker.glossary_path.as_ref() {
|
||||||
|
options.glossary_path = Some(value.clone());
|
||||||
|
}
|
||||||
if let Some(value) = self.translation.worker.concurrency {
|
if let Some(value) = self.translation.worker.concurrency {
|
||||||
options.worker_concurrency = value;
|
options.worker_concurrency = value;
|
||||||
}
|
}
|
||||||
@@ -570,6 +580,13 @@ impl BatConfigFile {
|
|||||||
line_number,
|
line_number,
|
||||||
)?);
|
)?);
|
||||||
}
|
}
|
||||||
|
(SectionPath::Network, "zip_command") => {
|
||||||
|
self.network.zip_command = Some(parse_required_path(
|
||||||
|
value,
|
||||||
|
"network.zip_command",
|
||||||
|
line_number,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
(SectionPath::Network, "download_concurrency") => {
|
(SectionPath::Network, "download_concurrency") => {
|
||||||
self.network.download_concurrency = Some(parse_download_concurrency(
|
self.network.download_concurrency = Some(parse_download_concurrency(
|
||||||
&parse_scalar_text(value, "network.download_concurrency", line_number)?,
|
&parse_scalar_text(value, "network.download_concurrency", line_number)?,
|
||||||
@@ -591,6 +608,10 @@ impl BatConfigFile {
|
|||||||
line_number,
|
line_number,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
(SectionPath::TranslationWorker, "glossary_path") => {
|
||||||
|
self.translation.worker.glossary_path =
|
||||||
|
parse_optional_path(value, "translation.worker.glossary_path", line_number)?;
|
||||||
|
}
|
||||||
(SectionPath::TranslationWorker, "concurrency") => {
|
(SectionPath::TranslationWorker, "concurrency") => {
|
||||||
self.translation.worker.concurrency = Some(parse_translation_worker_concurrency(
|
self.translation.worker.concurrency = Some(parse_translation_worker_concurrency(
|
||||||
&parse_scalar_text(value, "translation.worker.concurrency", line_number)?,
|
&parse_scalar_text(value, "translation.worker.concurrency", line_number)?,
|
||||||
@@ -1142,6 +1163,7 @@ import_resource_repository_path = '/srv/resources.sqlite'
|
|||||||
curl_command = '/usr/bin/curl'
|
curl_command = '/usr/bin/curl'
|
||||||
proxy = 'http://127.0.0.1:7890'
|
proxy = 'http://127.0.0.1:7890'
|
||||||
unzip_command = '/usr/bin/unzip'
|
unzip_command = '/usr/bin/unzip'
|
||||||
|
zip_command = '/usr/bin/zip'
|
||||||
download_concurrency = 16
|
download_concurrency = 16
|
||||||
|
|
||||||
[translation.worker]
|
[translation.worker]
|
||||||
|
|||||||
@@ -0,0 +1,728 @@
|
|||||||
|
use super::report_output::print_json_value;
|
||||||
|
use super::*;
|
||||||
|
use bat_core::domain::{
|
||||||
|
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossaryTermDraft,
|
||||||
|
GlossaryTermSnapshot, TranslationMemoryContext,
|
||||||
|
};
|
||||||
|
use bat_infrastructure::{SqliteGlossaryRepository, GLOSSARY_SCHEMA_VERSION};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
pub(super) fn run_glossary_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
|
let method = glossary_method(options.command)?;
|
||||||
|
if daemon_rpc_available(&options.state_dir)
|
||||||
|
&& options.resource_root.is_none()
|
||||||
|
&& !options.output_explicit
|
||||||
|
{
|
||||||
|
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||||
|
let report = daemon_rpc_call(&options.state_dir, method, glossary_cli_params(options)?)?;
|
||||||
|
print_json_value(options.output_format, &report)?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let path = glossary_cli_path(options)?;
|
||||||
|
let report =
|
||||||
|
match options.command {
|
||||||
|
CliCommand::GlossarySummary => build_glossary_summary_report(&path)?,
|
||||||
|
CliCommand::GlossaryQuery => build_glossary_query_report(
|
||||||
|
&path,
|
||||||
|
options.glossary_source_text.as_deref(),
|
||||||
|
options.glossary_category.as_deref(),
|
||||||
|
options.glossary_review_status.as_deref(),
|
||||||
|
options.query_limit,
|
||||||
|
)?,
|
||||||
|
CliCommand::GlossaryDiagnose => build_glossary_diagnose_report(
|
||||||
|
&path,
|
||||||
|
options.glossary_source_text.as_deref().unwrap_or_default(),
|
||||||
|
parse_glossary_context(options.glossary_context_json.as_deref())?,
|
||||||
|
)?,
|
||||||
|
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
|
||||||
|
let draft = glossary_term_draft(options)?;
|
||||||
|
let reviewer = options.glossary_reviewer.as_deref();
|
||||||
|
build_glossary_mutation_report(
|
||||||
|
&path,
|
||||||
|
&draft,
|
||||||
|
options.command == CliCommand::GlossaryUpdate,
|
||||||
|
reviewer,
|
||||||
|
options.glossary_reason.clone(),
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
|
||||||
|
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Glossary review 必须指定 --glossary-term-id")
|
||||||
|
})?;
|
||||||
|
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Glossary review 必须指定 --glossary-reviewer")
|
||||||
|
})?;
|
||||||
|
let status = if options.command == CliCommand::GlossaryApprove {
|
||||||
|
GlossaryReviewStatus::Approved
|
||||||
|
} else {
|
||||||
|
GlossaryReviewStatus::Deprecated
|
||||||
|
};
|
||||||
|
build_glossary_review_report(
|
||||||
|
&path,
|
||||||
|
term_id,
|
||||||
|
status,
|
||||||
|
reviewer,
|
||||||
|
options.glossary_reason.clone(),
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
CliCommand::GlossaryDelete => {
|
||||||
|
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Glossary delete 必须指定 --glossary-term-id")
|
||||||
|
})?;
|
||||||
|
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Glossary delete 必须指定 --glossary-reviewer")
|
||||||
|
})?;
|
||||||
|
let reason = options
|
||||||
|
.glossary_reason
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Glossary delete 必须指定 --glossary-reason"))?;
|
||||||
|
build_glossary_delete_report(&path, term_id, reviewer, reason)?
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
print_json_value(options.output_format, &report)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_method(command: CliCommand) -> anyhow::Result<&'static str> {
|
||||||
|
Ok(match command {
|
||||||
|
CliCommand::GlossarySummary => RPC_METHOD_GLOSSARY_SUMMARY,
|
||||||
|
CliCommand::GlossaryQuery => RPC_METHOD_GLOSSARY_QUERY,
|
||||||
|
CliCommand::GlossaryAdd => RPC_METHOD_GLOSSARY_ADD,
|
||||||
|
CliCommand::GlossaryUpdate => RPC_METHOD_GLOSSARY_UPDATE,
|
||||||
|
CliCommand::GlossaryApprove => RPC_METHOD_GLOSSARY_APPROVE,
|
||||||
|
CliCommand::GlossaryDeprecate => RPC_METHOD_GLOSSARY_DEPRECATE,
|
||||||
|
CliCommand::GlossaryDelete => RPC_METHOD_GLOSSARY_DELETE,
|
||||||
|
CliCommand::GlossaryDiagnose => RPC_METHOD_GLOSSARY_DIAGNOSE,
|
||||||
|
_ => return Err(anyhow::anyhow!("不是 Glossary 命令")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_cli_path(options: &CliOptions) -> anyhow::Result<std::path::PathBuf> {
|
||||||
|
if let Some(path) = options.glossary_path.as_ref() {
|
||||||
|
return lexical_absolute(path).map_err(anyhow::Error::msg);
|
||||||
|
}
|
||||||
|
let resource_root = options
|
||||||
|
.resource_root
|
||||||
|
.as_deref()
|
||||||
|
.map(lexical_absolute)
|
||||||
|
.transpose()
|
||||||
|
.map_err(anyhow::Error::msg)?
|
||||||
|
.unwrap_or(active_official_resource_root(&options.config.output_root)?);
|
||||||
|
Ok(SqliteGlossaryRepository::repository_path(&resource_root))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_cli_params(options: &CliOptions) -> anyhow::Result<Option<serde_json::Value>> {
|
||||||
|
let mut params = serde_json::Map::new();
|
||||||
|
if let Some(path) = options.glossary_path.as_ref() {
|
||||||
|
params.insert("glossary_path".to_string(), serde_json::json!(path));
|
||||||
|
}
|
||||||
|
match options.command {
|
||||||
|
CliCommand::GlossarySummary => {}
|
||||||
|
CliCommand::GlossaryQuery => {
|
||||||
|
if let Some(source_text) = options.glossary_source_text.as_deref() {
|
||||||
|
params.insert("source_text".to_string(), serde_json::json!(source_text));
|
||||||
|
}
|
||||||
|
if let Some(category) = options.glossary_category.as_deref() {
|
||||||
|
params.insert("category".to_string(), serde_json::json!(category));
|
||||||
|
}
|
||||||
|
if let Some(status) = options.glossary_review_status.as_deref() {
|
||||||
|
params.insert("review_status".to_string(), serde_json::json!(status));
|
||||||
|
}
|
||||||
|
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
|
||||||
|
}
|
||||||
|
CliCommand::GlossaryDiagnose => {
|
||||||
|
let source_text = options.glossary_source_text.as_deref().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("Glossary diagnose 必须指定 --glossary-source-text")
|
||||||
|
})?;
|
||||||
|
params.insert("source_text".to_string(), serde_json::json!(source_text));
|
||||||
|
params.insert(
|
||||||
|
"context".to_string(),
|
||||||
|
serde_json::json!(parse_glossary_context(
|
||||||
|
options.glossary_context_json.as_deref()
|
||||||
|
)?),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
|
||||||
|
let draft = glossary_term_draft(options)?;
|
||||||
|
params.extend(
|
||||||
|
serde_json::to_value(draft)?
|
||||||
|
.as_object()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
if options.command == CliCommand::GlossaryUpdate {
|
||||||
|
params.insert(
|
||||||
|
"reviewer".to_string(),
|
||||||
|
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
|
||||||
|
);
|
||||||
|
if let Some(reason) = options.glossary_reason.as_deref() {
|
||||||
|
params.insert("reason".to_string(), serde_json::json!(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
|
||||||
|
params.insert(
|
||||||
|
"term_id".to_string(),
|
||||||
|
serde_json::json!(options.glossary_term_id.as_deref().unwrap_or_default()),
|
||||||
|
);
|
||||||
|
params.insert(
|
||||||
|
"reviewer".to_string(),
|
||||||
|
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
|
||||||
|
);
|
||||||
|
if let Some(reason) = options.glossary_reason.as_deref() {
|
||||||
|
params.insert("reason".to_string(), serde_json::json!(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CliCommand::GlossaryDelete => {
|
||||||
|
params.insert(
|
||||||
|
"term_id".to_string(),
|
||||||
|
serde_json::json!(options.glossary_term_id.as_deref().unwrap_or_default()),
|
||||||
|
);
|
||||||
|
params.insert(
|
||||||
|
"reviewer".to_string(),
|
||||||
|
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
|
||||||
|
);
|
||||||
|
params.insert(
|
||||||
|
"reason".to_string(),
|
||||||
|
serde_json::json!(options.glossary_reason.as_deref().unwrap_or_default()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
Ok(Some(serde_json::Value::Object(params)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_term_draft(options: &CliOptions) -> anyhow::Result<GlossaryTermDraft> {
|
||||||
|
let term_id = required_option(options.glossary_term_id.as_deref(), "--glossary-term-id")?;
|
||||||
|
let source_term = required_option(
|
||||||
|
options.glossary_source_term.as_deref(),
|
||||||
|
"--glossary-source-term",
|
||||||
|
)?;
|
||||||
|
let recommended_translation = required_option(
|
||||||
|
options.glossary_recommended_translation.as_deref(),
|
||||||
|
"--glossary-recommended-translation",
|
||||||
|
)?;
|
||||||
|
let aliases = parse_string_array(
|
||||||
|
options.glossary_aliases_json.as_deref(),
|
||||||
|
"--glossary-aliases-json",
|
||||||
|
)?;
|
||||||
|
let allowed_translations = parse_string_array(
|
||||||
|
options.glossary_allowed_translations_json.as_deref(),
|
||||||
|
"--glossary-allowed-translations-json",
|
||||||
|
)?;
|
||||||
|
let scope = parse_glossary_context(options.glossary_scope_json.as_deref())?;
|
||||||
|
let source_kind = options.glossary_source_kind.as_deref().unwrap_or("manual");
|
||||||
|
let source_kind = GlossarySourceKind::parse(source_kind)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Glossary source kind 无效:{source_kind}"))?;
|
||||||
|
let review_status = options.glossary_review_status.as_deref().unwrap_or("draft");
|
||||||
|
let review_status = GlossaryReviewStatus::parse(review_status)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Glossary review status 无效:{review_status}"))?;
|
||||||
|
let now = unix_seconds_now();
|
||||||
|
Ok(GlossaryTermDraft {
|
||||||
|
term_id,
|
||||||
|
definition: GlossaryTermSnapshot {
|
||||||
|
source_term,
|
||||||
|
aliases,
|
||||||
|
recommended_translation,
|
||||||
|
allowed_translations,
|
||||||
|
source_language: options.glossary_source_language.clone(),
|
||||||
|
target_language: options.glossary_target_language.clone(),
|
||||||
|
category: options.glossary_category.clone(),
|
||||||
|
priority: options.glossary_priority,
|
||||||
|
scope,
|
||||||
|
},
|
||||||
|
review_status,
|
||||||
|
source: GlossarySourceRecord {
|
||||||
|
source_kind,
|
||||||
|
source_ref: options.glossary_source_ref.clone(),
|
||||||
|
source_author: options.glossary_source_author.clone(),
|
||||||
|
source_note: options.glossary_source_note.clone(),
|
||||||
|
observed_unix_seconds: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_option(value: Option<&str>, label: &str) -> anyhow::Result<String> {
|
||||||
|
value
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Glossary 必须指定 {label}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_string_array(value: Option<&str>, label: &str) -> anyhow::Result<Vec<String>> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
serde_json::from_str(value)
|
||||||
|
.map_err(|error| anyhow::anyhow!("{label} 必须是 JSON string array:{error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_glossary_context(value: Option<&str>) -> anyhow::Result<TranslationMemoryContext> {
|
||||||
|
let Some(value) = value else {
|
||||||
|
return Ok(BTreeMap::new());
|
||||||
|
};
|
||||||
|
serde_json::from_str(value)
|
||||||
|
.map_err(|error| anyhow::anyhow!("Glossary context 必须是 JSON object:{error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_glossary_summary_report(
|
||||||
|
path: &std::path::Path,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"available": false,
|
||||||
|
"path": path,
|
||||||
|
"reason": "database_missing",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let summary = runtime.block_on(async {
|
||||||
|
let repository = SqliteGlossaryRepository::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
repository
|
||||||
|
.summary()
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"path": path,
|
||||||
|
"schema_version": GLOSSARY_SCHEMA_VERSION,
|
||||||
|
"summary": summary,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_glossary_query_report(
|
||||||
|
path: &std::path::Path,
|
||||||
|
source_text: Option<&str>,
|
||||||
|
category: Option<&str>,
|
||||||
|
review_status: Option<&str>,
|
||||||
|
limit: usize,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let status = review_status
|
||||||
|
.map(|value| {
|
||||||
|
GlossaryReviewStatus::parse(value)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Glossary review_status 无效"))
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"available": false,
|
||||||
|
"path": path,
|
||||||
|
"source_text": source_text,
|
||||||
|
"terms": [],
|
||||||
|
"reason": "database_missing",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let terms = runtime.block_on(async {
|
||||||
|
let repository = SqliteGlossaryRepository::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
repository
|
||||||
|
.query(source_text, category, status, limit)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"path": path,
|
||||||
|
"source_text": source_text,
|
||||||
|
"terms": terms,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_glossary_diagnose_report(
|
||||||
|
path: &std::path::Path,
|
||||||
|
source_text: &str,
|
||||||
|
context: TranslationMemoryContext,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
if source_text.trim().is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("Glossary diagnose 的 source_text 不能为空"));
|
||||||
|
}
|
||||||
|
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"available": false,
|
||||||
|
"path": path,
|
||||||
|
"source_text": source_text,
|
||||||
|
"context": context,
|
||||||
|
"evaluation": {
|
||||||
|
"constraints": [],
|
||||||
|
"diagnostics": [],
|
||||||
|
"blocked": false
|
||||||
|
},
|
||||||
|
"reason": "database_missing",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let evaluation = runtime.block_on(async {
|
||||||
|
let repository = SqliteGlossaryRepository::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
repository
|
||||||
|
.diagnose(source_text, &context)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"path": path,
|
||||||
|
"source_text": source_text,
|
||||||
|
"context": context,
|
||||||
|
"evaluation": evaluation,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_glossary_mutation_report(
|
||||||
|
path: &std::path::Path,
|
||||||
|
draft: &GlossaryTermDraft,
|
||||||
|
update: bool,
|
||||||
|
reviewer: Option<&str>,
|
||||||
|
reason: Option<String>,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let term = runtime.block_on(async {
|
||||||
|
let repository = SqliteGlossaryRepository::new(path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
let result = if update {
|
||||||
|
repository
|
||||||
|
.update(
|
||||||
|
draft.clone(),
|
||||||
|
reviewer.ok_or_else(|| anyhow::anyhow!("Glossary update 需要 reviewer"))?,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
repository.add(draft.clone()).await
|
||||||
|
};
|
||||||
|
result.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"path": path,
|
||||||
|
"schema_version": GLOSSARY_SCHEMA_VERSION,
|
||||||
|
"term": term,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_glossary_review_report(
|
||||||
|
path: &std::path::Path,
|
||||||
|
term_id: &str,
|
||||||
|
status: GlossaryReviewStatus,
|
||||||
|
reviewer: &str,
|
||||||
|
reason: Option<String>,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let term = runtime.block_on(async {
|
||||||
|
let repository = SqliteGlossaryRepository::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
repository
|
||||||
|
.review(term_id, status, reviewer, reason)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"path": path,
|
||||||
|
"term": term,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_glossary_delete_report(
|
||||||
|
path: &std::path::Path,
|
||||||
|
term_id: &str,
|
||||||
|
reviewer: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let term = runtime.block_on(async {
|
||||||
|
let repository = SqliteGlossaryRepository::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
repository
|
||||||
|
.delete(term_id, reviewer, reason)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"path": path,
|
||||||
|
"schema_version": GLOSSARY_SCHEMA_VERSION,
|
||||||
|
"deleted": true,
|
||||||
|
"term": term,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn glossary_rpc_envelope(
|
||||||
|
request_id: String,
|
||||||
|
result: Result<serde_json::Value, ApiError>,
|
||||||
|
) -> RpcEnvelope {
|
||||||
|
match result {
|
||||||
|
Ok(data) => rpc_envelope_ok(request_id, "ok", data),
|
||||||
|
Err(error) => rpc_envelope_error(request_id, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn glossary_summary_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
output_root: &Path,
|
||||||
|
default_path: Option<&Path>,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let path = glossary_rpc_path(
|
||||||
|
state_dir,
|
||||||
|
output_root,
|
||||||
|
default_path,
|
||||||
|
params,
|
||||||
|
RPC_METHOD_GLOSSARY_SUMMARY,
|
||||||
|
)?;
|
||||||
|
build_glossary_summary_report(&path)
|
||||||
|
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_SUMMARY, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn glossary_query_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
output_root: &Path,
|
||||||
|
default_path: Option<&Path>,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let params = glossary_params(params, RPC_METHOD_GLOSSARY_QUERY)?;
|
||||||
|
let source_text = glossary_string(¶ms, "source_text", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||||
|
let category = glossary_string(¶ms, "category", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||||
|
let review_status = glossary_string(¶ms, "review_status", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||||
|
let limit = glossary_limit(¶ms, RPC_METHOD_GLOSSARY_QUERY)?;
|
||||||
|
let path = glossary_rpc_path(
|
||||||
|
state_dir,
|
||||||
|
output_root,
|
||||||
|
default_path,
|
||||||
|
Some(&serde_json::Value::Object(params.clone())),
|
||||||
|
RPC_METHOD_GLOSSARY_QUERY,
|
||||||
|
)?;
|
||||||
|
build_glossary_query_report(&path, source_text, category, review_status, limit)
|
||||||
|
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_QUERY, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn glossary_diagnose_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
output_root: &Path,
|
||||||
|
default_path: Option<&Path>,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
|
||||||
|
let source_text = glossary_string(¶ms, "source_text", RPC_METHOD_GLOSSARY_DIAGNOSE)?
|
||||||
|
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DIAGNOSE, "缺少 source_text"))?;
|
||||||
|
let context = glossary_context(¶ms, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
|
||||||
|
let path = glossary_rpc_path(
|
||||||
|
state_dir,
|
||||||
|
output_root,
|
||||||
|
default_path,
|
||||||
|
Some(&serde_json::Value::Object(params.clone())),
|
||||||
|
RPC_METHOD_GLOSSARY_DIAGNOSE,
|
||||||
|
)?;
|
||||||
|
build_glossary_diagnose_report(&path, source_text, context)
|
||||||
|
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DIAGNOSE, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn glossary_mutation_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
output_root: &Path,
|
||||||
|
default_path: Option<&Path>,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
update: bool,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let method = if update {
|
||||||
|
RPC_METHOD_GLOSSARY_UPDATE
|
||||||
|
} else {
|
||||||
|
RPC_METHOD_GLOSSARY_ADD
|
||||||
|
};
|
||||||
|
let params = glossary_params(params, method)?;
|
||||||
|
let draft: GlossaryTermDraft =
|
||||||
|
serde_json::from_value(serde_json::Value::Object(params.clone())).map_err(|error| {
|
||||||
|
glossary_invalid(method, format!("Glossary term 参数无效:{error}"))
|
||||||
|
})?;
|
||||||
|
let reviewer = glossary_string(¶ms, "reviewer", method)?;
|
||||||
|
if update && reviewer.is_none() {
|
||||||
|
return Err(glossary_invalid(method, "update 缺少 reviewer"));
|
||||||
|
}
|
||||||
|
let reason = glossary_string(¶ms, "reason", method)?.map(str::to_string);
|
||||||
|
let path = glossary_rpc_path(
|
||||||
|
state_dir,
|
||||||
|
output_root,
|
||||||
|
default_path,
|
||||||
|
Some(&serde_json::Value::Object(params.clone())),
|
||||||
|
method,
|
||||||
|
)?;
|
||||||
|
build_glossary_mutation_report(&path, &draft, update, reviewer, reason)
|
||||||
|
.map_err(|error| glossary_internal_error(method, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn glossary_review_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
output_root: &Path,
|
||||||
|
default_path: Option<&Path>,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
status: GlossaryReviewStatus,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let method = if status == GlossaryReviewStatus::Approved {
|
||||||
|
RPC_METHOD_GLOSSARY_APPROVE
|
||||||
|
} else {
|
||||||
|
RPC_METHOD_GLOSSARY_DEPRECATE
|
||||||
|
};
|
||||||
|
let params = glossary_params(params, method)?;
|
||||||
|
let term_id = glossary_string(¶ms, "term_id", method)?
|
||||||
|
.ok_or_else(|| glossary_invalid(method, "缺少 term_id"))?;
|
||||||
|
let reviewer = glossary_string(¶ms, "reviewer", method)?
|
||||||
|
.ok_or_else(|| glossary_invalid(method, "缺少 reviewer"))?;
|
||||||
|
let reason = glossary_string(¶ms, "reason", method)?.map(str::to_string);
|
||||||
|
let path = glossary_rpc_path(
|
||||||
|
state_dir,
|
||||||
|
output_root,
|
||||||
|
default_path,
|
||||||
|
Some(&serde_json::Value::Object(params.clone())),
|
||||||
|
method,
|
||||||
|
)?;
|
||||||
|
build_glossary_review_report(&path, term_id, status, reviewer, reason)
|
||||||
|
.map_err(|error| glossary_internal_error(method, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn glossary_delete_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
output_root: &Path,
|
||||||
|
default_path: Option<&Path>,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value, ApiError> {
|
||||||
|
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DELETE)?;
|
||||||
|
let term_id = glossary_string(¶ms, "term_id", RPC_METHOD_GLOSSARY_DELETE)?
|
||||||
|
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 term_id"))?;
|
||||||
|
let reviewer = glossary_string(¶ms, "reviewer", RPC_METHOD_GLOSSARY_DELETE)?
|
||||||
|
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reviewer"))?;
|
||||||
|
let reason = glossary_string(¶ms, "reason", RPC_METHOD_GLOSSARY_DELETE)?
|
||||||
|
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reason"))?;
|
||||||
|
let path = glossary_rpc_path(
|
||||||
|
state_dir,
|
||||||
|
output_root,
|
||||||
|
default_path,
|
||||||
|
Some(&serde_json::Value::Object(params.clone())),
|
||||||
|
RPC_METHOD_GLOSSARY_DELETE,
|
||||||
|
)?;
|
||||||
|
build_glossary_delete_report(&path, term_id, reviewer, reason)
|
||||||
|
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DELETE, error))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_rpc_path(
|
||||||
|
state_dir: &Path,
|
||||||
|
output_root: &Path,
|
||||||
|
default_path: Option<&Path>,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
method: &'static str,
|
||||||
|
) -> Result<std::path::PathBuf, ApiError> {
|
||||||
|
if let Some(path) = params
|
||||||
|
.and_then(|value| value.get("glossary_path"))
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
return lexical_absolute(Path::new(path))
|
||||||
|
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
|
||||||
|
}
|
||||||
|
if let Some(path) = default_path {
|
||||||
|
return lexical_absolute(path)
|
||||||
|
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
|
||||||
|
}
|
||||||
|
let (_, version_state) = read_daemon_resource_state(state_dir)
|
||||||
|
.map_err(|error| glossary_internal_error(method, error))?;
|
||||||
|
if let Some(record) = version_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| state.current_completed_version.as_ref())
|
||||||
|
{
|
||||||
|
return Ok(SqliteGlossaryRepository::repository_path(
|
||||||
|
&record.resource_root,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(SqliteGlossaryRepository::repository_path(output_root))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_params(
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
method: &'static str,
|
||||||
|
) -> Result<serde_json::Map<String, serde_json::Value>, ApiError> {
|
||||||
|
match params {
|
||||||
|
None | Some(serde_json::Value::Null) => Ok(serde_json::Map::new()),
|
||||||
|
Some(serde_json::Value::Object(value)) => Ok(value.clone()),
|
||||||
|
Some(_) => Err(glossary_invalid(method, "params 必须是 JSON object")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_string<'a>(
|
||||||
|
params: &'a serde_json::Map<String, serde_json::Value>,
|
||||||
|
key: &str,
|
||||||
|
method: &'static str,
|
||||||
|
) -> Result<Option<&'a str>, ApiError> {
|
||||||
|
let Some(value) = params.get(key) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if value.is_null() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.ok_or_else(|| glossary_invalid(method, format!("{key} 必须是非空字符串")))
|
||||||
|
.map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_context(
|
||||||
|
params: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
method: &'static str,
|
||||||
|
) -> Result<TranslationMemoryContext, ApiError> {
|
||||||
|
let Some(value) = params
|
||||||
|
.get("context")
|
||||||
|
.or_else(|| params.get("source_context"))
|
||||||
|
else {
|
||||||
|
return Ok(BTreeMap::new());
|
||||||
|
};
|
||||||
|
serde_json::from_value(value.clone()).map_err(|error| {
|
||||||
|
glossary_invalid(method, format!("context 必须是 JSON string map:{error}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_limit(
|
||||||
|
params: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
method: &'static str,
|
||||||
|
) -> Result<usize, ApiError> {
|
||||||
|
let limit = params
|
||||||
|
.get("limit")
|
||||||
|
.and_then(serde_json::Value::as_u64)
|
||||||
|
.unwrap_or(100);
|
||||||
|
let limit = usize::try_from(limit)
|
||||||
|
.map_err(|error| glossary_invalid(method, format!("limit 无效:{error}")))?;
|
||||||
|
if !(1..=1000).contains(&limit) {
|
||||||
|
return Err(glossary_invalid(method, "limit 必须在 1..=1000 范围内"));
|
||||||
|
}
|
||||||
|
Ok(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_invalid(method: &'static str, message: impl Into<String>) -> ApiError {
|
||||||
|
ApiError::new(ErrorCode::RPC_INVALID_PARAMS, method, message.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glossary_internal_error(method: &'static str, error: anyhow::Error) -> ApiError {
|
||||||
|
ApiError::new(ErrorCode::INTERNAL, method, error.to_string())
|
||||||
|
}
|
||||||
@@ -172,6 +172,7 @@ fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Re
|
|||||||
if options.patch_kind.is_some()
|
if options.patch_kind.is_some()
|
||||||
|| options.patch_source_path.is_some()
|
|| options.patch_source_path.is_some()
|
||||||
|| options.patch_patch_path.is_some()
|
|| options.patch_patch_path.is_some()
|
||||||
|
|| options.patch_manifest.is_some()
|
||||||
{
|
{
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
|
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
|
||||||
@@ -191,6 +192,7 @@ fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::
|
|||||||
|| options.unityfs_expected_value.is_some()
|
|| options.unityfs_expected_value.is_some()
|
||||||
|| options.unityfs_replacement_value.is_some()
|
|| options.unityfs_replacement_value.is_some()
|
||||||
|| options.unityfs_expected_semantic_value.is_some()
|
|| options.unityfs_expected_semantic_value.is_some()
|
||||||
|
|| options.patch_manifest.is_some()
|
||||||
{
|
{
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
|
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
|
||||||
|
|||||||
@@ -115,9 +115,15 @@ impl HumanReport for bat_infrastructure::TranslationWorkerReport {
|
|||||||
print_field("TM 可用", format_bool(self.translation_memory_available));
|
print_field("TM 可用", format_bool(self.translation_memory_available));
|
||||||
print_field("TM 命中 TextUnit", self.translation_memory_hit_count);
|
print_field("TM 命中 TextUnit", self.translation_memory_hit_count);
|
||||||
print_field("Provider TextUnit", self.provider_unit_count);
|
print_field("Provider TextUnit", self.provider_unit_count);
|
||||||
|
print_path_field("Glossary", &self.glossary_path);
|
||||||
|
print_field("Glossary 可用", format_bool(self.glossary_available));
|
||||||
|
print_field("Glossary blocking TextUnit", self.glossary_blocked_count);
|
||||||
for failure in &self.translation_memory_failures {
|
for failure in &self.translation_memory_failures {
|
||||||
println!(" - TM: {failure}");
|
println!(" - TM: {failure}");
|
||||||
}
|
}
|
||||||
|
for failure in &self.glossary_failures {
|
||||||
|
println!(" - Glossary: {failure}");
|
||||||
|
}
|
||||||
for failure in &self.failures {
|
for failure in &self.failures {
|
||||||
println!(
|
println!(
|
||||||
" - {} [{}] retryable={} {}",
|
" - {} [{}] retryable={} {}",
|
||||||
|
|||||||
@@ -537,8 +537,8 @@ pub(super) fn run_task_worker(
|
|||||||
registry: TaskRegistry,
|
registry: TaskRegistry,
|
||||||
sync_lock: Arc<Mutex<()>>,
|
sync_lock: Arc<Mutex<()>>,
|
||||||
control: DaemonControl,
|
control: DaemonControl,
|
||||||
|
service: OfficialUpdateService,
|
||||||
) {
|
) {
|
||||||
let service = OfficialUpdateService::new();
|
|
||||||
for job in receiver {
|
for job in receiver {
|
||||||
registry.update(&job.id, |record| {
|
registry.update(&job.id, |record| {
|
||||||
record.status = "running";
|
record.status = "running";
|
||||||
@@ -602,6 +602,30 @@ pub(super) fn run_task_worker(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let run_result = if job.kind == TaskKind::Verify {
|
||||||
|
match run_result {
|
||||||
|
Ok(report) => {
|
||||||
|
bat_infrastructure::verify_and_record_official_distribution_attestation(
|
||||||
|
&job.config,
|
||||||
|
service.attestation_max_age_seconds(),
|
||||||
|
)
|
||||||
|
.map(|_| report)
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
// If the verifier failed before returning its report
|
||||||
|
// (for example, a malformed manifest), make a best
|
||||||
|
// effort to revoke the previous ready generation.
|
||||||
|
let _ =
|
||||||
|
bat_infrastructure::verify_and_record_official_distribution_attestation(
|
||||||
|
&job.config,
|
||||||
|
service.attestation_max_age_seconds(),
|
||||||
|
);
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
run_result
|
||||||
|
};
|
||||||
run_result
|
run_result
|
||||||
.map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
|
.map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
|
||||||
.and_then(|result| result)
|
.and_then(|result| result)
|
||||||
|
|||||||
@@ -345,6 +345,11 @@ Commands:
|
|||||||
i18n handoff Query current translation handoff
|
i18n handoff Query current translation handoff
|
||||||
i18n status Show localized release status for current official release
|
i18n status Show localized release status for current official release
|
||||||
i18n task update Update one provider worker task status
|
i18n task update Update one provider worker task status
|
||||||
|
i18n glossary summary/query Show project Glossary terms and review counts
|
||||||
|
i18n glossary add/update Add or replace one Glossary term definition
|
||||||
|
i18n glossary approve/deprecate Review one Glossary term
|
||||||
|
i18n glossary delete Remove one Glossary term with reviewer and reason
|
||||||
|
i18n glossary diagnose Run deterministic Glossary QA for one TextUnit source
|
||||||
i18n publish Publish a localized release from a workbench or worker results
|
i18n publish Publish a localized release from a workbench or worker results
|
||||||
i18n rollback Roll back the current localized release
|
i18n rollback Roll back the current localized release
|
||||||
i18n schedule Manage translation schedules
|
i18n schedule Manage translation schedules
|
||||||
@@ -384,6 +389,7 @@ Examples:
|
|||||||
{binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1
|
{binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1
|
||||||
{binary} i18n proofread --json
|
{binary} i18n proofread --json
|
||||||
{binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s
|
{binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s
|
||||||
|
{binary} i18n glossary diagnose --glossary-source-text Sensei --json
|
||||||
{binary} i18n tasks --json
|
{binary} i18n tasks --json
|
||||||
{binary} i18n handoff --json
|
{binary} i18n handoff --json
|
||||||
{binary} i18n status --json
|
{binary} i18n status --json
|
||||||
@@ -416,6 +422,7 @@ Sync:
|
|||||||
--proxy <URL|auto|none> curl proxy override (default: auto from env)
|
--proxy <URL|auto|none> curl proxy override (default: auto from env)
|
||||||
--no-proxy Force direct curl connections
|
--no-proxy Force direct curl connections
|
||||||
--unzip <PATH> unzip executable (default: unzip)
|
--unzip <PATH> unzip executable (default: unzip)
|
||||||
|
--zip <PATH> zip executable (default: zip)
|
||||||
--dry-run Do not write sync state
|
--dry-run Do not write sync state
|
||||||
--plan Include planned URLs in dry-run
|
--plan Include planned URLs in dry-run
|
||||||
--force Force download/refresh
|
--force Force download/refresh
|
||||||
@@ -433,6 +440,16 @@ Sync:
|
|||||||
--provider-run-id <ID> Provider run ID for i18n task update
|
--provider-run-id <ID> Provider run ID for i18n task update
|
||||||
--translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
|
--translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
|
||||||
--translation-fixture <PATH> Mock/provider fixture for i18n worker run
|
--translation-fixture <PATH> Mock/provider fixture for i18n worker run
|
||||||
|
--glossary-path <PATH> Project Glossary SQLite path
|
||||||
|
--glossary-term-id <ID> Glossary term ID for add/update/review/delete
|
||||||
|
--glossary-source-term <TEXT> Source spelling for a Glossary term
|
||||||
|
--glossary-recommended-translation <TEXT> Recommended target translation
|
||||||
|
--glossary-source-text <TEXT> Source TextUnit text for Glossary query/diagnose
|
||||||
|
--glossary-context-json <JSON> TextUnit context for Glossary diagnose
|
||||||
|
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews/delete
|
||||||
|
--glossary-reason <TEXT> Reason for Glossary review/delete or override
|
||||||
|
--glossary-provenance <TEXT> Provenance for an explicit Glossary override
|
||||||
|
--glossary-qa-identity <ID> Current blocking Glossary QA identity for an override
|
||||||
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
|
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
|
||||||
--worker-max-attempts <N> Maximum claims per translation task
|
--worker-max-attempts <N> Maximum claims per translation task
|
||||||
--worker-lease-seconds <N> Lease seconds for one claimed task
|
--worker-lease-seconds <N> Lease seconds for one claimed task
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::report_output::print_json_value;
|
use super::report_output::print_json_value;
|
||||||
use super::*;
|
use super::*;
|
||||||
use bat_core::domain::TranslationMemoryContext;
|
use bat_core::domain::{validate_glossary_override, GlossaryOverride, TranslationMemoryContext};
|
||||||
use bat_core::repositories::TranslationMemoryRepository;
|
use bat_core::repositories::TranslationMemoryRepository;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -9,6 +9,8 @@ struct TranslationTaskResultUpdateParam {
|
|||||||
unit_id: String,
|
unit_id: String,
|
||||||
source_text: String,
|
source_text: String,
|
||||||
translated_text: String,
|
translated_text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
glossary_override: Option<GlossaryOverride>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn build_translation_tasks_report(
|
pub(super) fn build_translation_tasks_report(
|
||||||
@@ -170,6 +172,7 @@ pub(super) fn build_translation_handoff_report(
|
|||||||
pub(super) fn update_translation_task_status_report(
|
pub(super) fn update_translation_task_status_report(
|
||||||
state_dir: &Path,
|
state_dir: &Path,
|
||||||
params: Option<&serde_json::Value>,
|
params: Option<&serde_json::Value>,
|
||||||
|
configured_glossary_path: Option<&Path>,
|
||||||
) -> anyhow::Result<serde_json::Value> {
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
let task_id = rpc_string_param(params, "task_id")
|
let task_id = rpc_string_param(params, "task_id")
|
||||||
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
|
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
|
||||||
@@ -229,6 +232,22 @@ pub(super) fn update_translation_task_status_report(
|
|||||||
.find(task_id)
|
.find(task_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
let glossary_path = configured_glossary_path
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
bat_infrastructure::SqliteGlossaryRepository::repository_path(
|
||||||
|
¤t.resource_root,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let glossary = if std::fs::symlink_metadata(&glossary_path).is_ok() {
|
||||||
|
Some(
|
||||||
|
bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))?,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let results = build_manual_translation_results(
|
let results = build_manual_translation_results(
|
||||||
¤t_task,
|
¤t_task,
|
||||||
index,
|
index,
|
||||||
@@ -236,7 +255,9 @@ pub(super) fn update_translation_task_status_report(
|
|||||||
&result_provider,
|
&result_provider,
|
||||||
&result_provider_run_id,
|
&result_provider_run_id,
|
||||||
result_timestamp,
|
result_timestamp,
|
||||||
)?;
|
glossary.as_ref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
repository
|
repository
|
||||||
.update_status_with_results(
|
.update_status_with_results(
|
||||||
task_id,
|
task_id,
|
||||||
@@ -291,13 +312,14 @@ fn translation_task_result_params(
|
|||||||
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
|
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_manual_translation_results(
|
async fn build_manual_translation_results(
|
||||||
task: &bat_infrastructure::PersistedTranslationTask,
|
task: &bat_infrastructure::PersistedTranslationTask,
|
||||||
index: &bat_infrastructure::OfficialTextUnitIndex,
|
index: &bat_infrastructure::OfficialTextUnitIndex,
|
||||||
params: &[TranslationTaskResultUpdateParam],
|
params: &[TranslationTaskResultUpdateParam],
|
||||||
provider: &str,
|
provider: &str,
|
||||||
provider_run_id: &str,
|
provider_run_id: &str,
|
||||||
translated_unix_seconds: u64,
|
translated_unix_seconds: u64,
|
||||||
|
glossary: Option<&bat_infrastructure::SqliteGlossaryRepository>,
|
||||||
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
|
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
|
||||||
let index_by_id = index
|
let index_by_id = index
|
||||||
.units
|
.units
|
||||||
@@ -332,6 +354,48 @@ fn build_manual_translation_results(
|
|||||||
"TextUnit {unit_id} 的 source_text 与当前索引不一致"
|
"TextUnit {unit_id} 的 source_text 与当前索引不一致"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let glossary_qa = if let Some(glossary) = glossary {
|
||||||
|
let context = bat_infrastructure::translation_memory_context(
|
||||||
|
&unit.destination,
|
||||||
|
unit.archive_entry.as_deref(),
|
||||||
|
unit.serialized_file.as_deref(),
|
||||||
|
unit.path_id,
|
||||||
|
unit.class_id,
|
||||||
|
unit.field_path.as_deref(),
|
||||||
|
unit.format.as_deref(),
|
||||||
|
unit.asset_name.as_deref(),
|
||||||
|
unit.text_source_kind.as_deref(),
|
||||||
|
&unit.context,
|
||||||
|
);
|
||||||
|
Some(
|
||||||
|
glossary
|
||||||
|
.diagnose(&unit.source_text, &context)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("Glossary QA 失败:{error}"))?
|
||||||
|
.check_translation(¶m.translated_text),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(qa) = glossary_qa.as_ref() {
|
||||||
|
if qa.status.is_blocked() {
|
||||||
|
validate_glossary_override(qa, param.glossary_override.as_ref()).map_err(
|
||||||
|
|error| {
|
||||||
|
anyhow::anyhow!("TextUnit {} 的 glossary_override 无效:{error}", unit_id)
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
} else if param.glossary_override.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
|
||||||
|
unit_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else if param.glossary_override.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
|
||||||
|
unit_id
|
||||||
|
));
|
||||||
|
}
|
||||||
results.push(bat_infrastructure::TranslationTaskUnitResult {
|
results.push(bat_infrastructure::TranslationTaskUnitResult {
|
||||||
unit_id: unit_id.to_string(),
|
unit_id: unit_id.to_string(),
|
||||||
source_text: param.source_text.clone(),
|
source_text: param.source_text.clone(),
|
||||||
@@ -341,6 +405,8 @@ fn build_manual_translation_results(
|
|||||||
provider: provider.to_string(),
|
provider: provider.to_string(),
|
||||||
provider_run_id: provider_run_id.to_string(),
|
provider_run_id: provider_run_id.to_string(),
|
||||||
translated_unix_seconds,
|
translated_unix_seconds,
|
||||||
|
glossary_qa,
|
||||||
|
glossary_override: param.glossary_override.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(results)
|
Ok(results)
|
||||||
@@ -367,6 +433,10 @@ pub(super) fn run_translation_memory_command(options: &CliOptions) -> anyhow::Re
|
|||||||
CliCommand::TranslationMemorySummary => RPC_METHOD_TRANSLATION_MEMORY_SUMMARY,
|
CliCommand::TranslationMemorySummary => RPC_METHOD_TRANSLATION_MEMORY_SUMMARY,
|
||||||
CliCommand::TranslationMemoryQuery => RPC_METHOD_TRANSLATION_MEMORY_QUERY,
|
CliCommand::TranslationMemoryQuery => RPC_METHOD_TRANSLATION_MEMORY_QUERY,
|
||||||
CliCommand::TranslationMemoryConfirm => RPC_METHOD_TRANSLATION_MEMORY_CONFIRM,
|
CliCommand::TranslationMemoryConfirm => RPC_METHOD_TRANSLATION_MEMORY_CONFIRM,
|
||||||
|
CliCommand::TranslationMemoryConflicts => RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS,
|
||||||
|
CliCommand::TranslationMemoryResolveConflict => {
|
||||||
|
RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT
|
||||||
|
}
|
||||||
_ => return Err(anyhow::anyhow!("不是 Translation Memory 命令")),
|
_ => return Err(anyhow::anyhow!("不是 Translation Memory 命令")),
|
||||||
};
|
};
|
||||||
if daemon_rpc_available(&options.state_dir)
|
if daemon_rpc_available(&options.state_dir)
|
||||||
@@ -414,6 +484,34 @@ pub(super) fn run_translation_memory_command(options: &CliOptions) -> anyhow::Re
|
|||||||
record_id,
|
record_id,
|
||||||
reviewer,
|
reviewer,
|
||||||
options.translation_memory_reason.clone(),
|
options.translation_memory_reason.clone(),
|
||||||
|
options.translation_memory_supersede_record_id.as_deref(),
|
||||||
|
)?
|
||||||
|
}
|
||||||
|
CliCommand::TranslationMemoryConflicts => {
|
||||||
|
build_translation_memory_conflicts_report(&path, options.query_limit)?
|
||||||
|
}
|
||||||
|
CliCommand::TranslationMemoryResolveConflict => {
|
||||||
|
let winner = options
|
||||||
|
.translation_memory_record_id
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 winner record"))?;
|
||||||
|
let expected = options
|
||||||
|
.translation_memory_expected_trusted_record_ids_json
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 expected set"))?;
|
||||||
|
let expected = serde_json::from_str::<Vec<String>>(expected).map_err(|error| {
|
||||||
|
anyhow::anyhow!("expected trusted record IDs 必须是 JSON array:{error}")
|
||||||
|
})?;
|
||||||
|
let reviewer = options
|
||||||
|
.translation_memory_reviewer
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reviewer"))?;
|
||||||
|
let reason = options
|
||||||
|
.translation_memory_reason
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reason"))?;
|
||||||
|
build_translation_memory_resolve_conflict_report(
|
||||||
|
&path, winner, &expected, reviewer, reason,
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
@@ -472,10 +570,49 @@ fn translation_memory_cli_params(
|
|||||||
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-reviewer"))?;
|
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-reviewer"))?;
|
||||||
params.insert("record_id".to_string(), serde_json::json!(record_id));
|
params.insert("record_id".to_string(), serde_json::json!(record_id));
|
||||||
params.insert("reviewer".to_string(), serde_json::json!(reviewer));
|
params.insert("reviewer".to_string(), serde_json::json!(reviewer));
|
||||||
|
if let Some(supersede_record_id) =
|
||||||
|
options.translation_memory_supersede_record_id.as_deref()
|
||||||
|
{
|
||||||
|
params.insert(
|
||||||
|
"supersede_record_id".to_string(),
|
||||||
|
serde_json::json!(supersede_record_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
if let Some(reason) = options.translation_memory_reason.as_deref() {
|
if let Some(reason) = options.translation_memory_reason.as_deref() {
|
||||||
params.insert("reason".to_string(), serde_json::json!(reason));
|
params.insert("reason".to_string(), serde_json::json!(reason));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CliCommand::TranslationMemoryConflicts => {
|
||||||
|
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
|
||||||
|
}
|
||||||
|
CliCommand::TranslationMemoryResolveConflict => {
|
||||||
|
let winner = options
|
||||||
|
.translation_memory_record_id
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 winner record"))?;
|
||||||
|
let expected = options
|
||||||
|
.translation_memory_expected_trusted_record_ids_json
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 expected set"))?;
|
||||||
|
let reviewer = options
|
||||||
|
.translation_memory_reviewer
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reviewer"))?;
|
||||||
|
let reason = options
|
||||||
|
.translation_memory_reason
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TM resolve-conflict 必须指定 reason"))?;
|
||||||
|
let expected = serde_json::from_str::<Vec<String>>(expected).map_err(|error| {
|
||||||
|
anyhow::anyhow!("expected trusted record IDs 必须是 JSON array:{error}")
|
||||||
|
})?;
|
||||||
|
params.insert("winner_record_id".to_string(), serde_json::json!(winner));
|
||||||
|
params.insert(
|
||||||
|
"expected_trusted_record_ids".to_string(),
|
||||||
|
serde_json::json!(expected),
|
||||||
|
);
|
||||||
|
params.insert("reviewer".to_string(), serde_json::json!(reviewer));
|
||||||
|
params.insert("reason".to_string(), serde_json::json!(reason));
|
||||||
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
}
|
}
|
||||||
Ok(Some(serde_json::Value::Object(params)))
|
Ok(Some(serde_json::Value::Object(params)))
|
||||||
@@ -559,6 +696,7 @@ pub(super) fn build_translation_memory_confirm_report(
|
|||||||
record_id: &str,
|
record_id: &str,
|
||||||
reviewer: &str,
|
reviewer: &str,
|
||||||
reason: Option<String>,
|
reason: Option<String>,
|
||||||
|
supersede_record_id: Option<&str>,
|
||||||
) -> anyhow::Result<serde_json::Value> {
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
if record_id.trim().is_empty() || reviewer.trim().is_empty() {
|
if record_id.trim().is_empty() || reviewer.trim().is_empty() {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
@@ -579,7 +717,89 @@ pub(super) fn build_translation_memory_confirm_report(
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
repository
|
repository
|
||||||
.confirm(record_id, reviewer, reason)
|
.confirm_with_supersede(record_id, reviewer, reason, supersede_record_id)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"path": path,
|
||||||
|
"entry": entry,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_translation_memory_conflicts_report(
|
||||||
|
path: &std::path::Path,
|
||||||
|
limit: usize,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
if !(1..=1000).contains(&limit) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TM conflicts 的 limit 必须在 1..=1000 范围内"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"available": false,
|
||||||
|
"path": path,
|
||||||
|
"conflicts": [],
|
||||||
|
"reason": "database_missing",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let conflicts = runtime.block_on(async {
|
||||||
|
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
repository
|
||||||
|
.list_conflicts(limit)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
|
})?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"available": true,
|
||||||
|
"path": path,
|
||||||
|
"conflicts": conflicts,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_translation_memory_resolve_conflict_report(
|
||||||
|
path: &std::path::Path,
|
||||||
|
winner_record_id: &str,
|
||||||
|
expected_trusted_record_ids: &[String],
|
||||||
|
reviewer: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> anyhow::Result<serde_json::Value> {
|
||||||
|
if winner_record_id.trim().is_empty()
|
||||||
|
|| reviewer.trim().is_empty()
|
||||||
|
|| reason.trim().is_empty()
|
||||||
|
|| expected_trusted_record_ids.is_empty()
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TM resolve-conflict 必须指定 winner_record_id、expected_trusted_record_ids、reviewer 和 reason"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Translation Memory 数据库不存在:{}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
let entry = runtime.block_on(async {
|
||||||
|
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
repository
|
||||||
|
.resolve_conflict(
|
||||||
|
winner_record_id,
|
||||||
|
expected_trusted_record_ids,
|
||||||
|
reviewer,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -104,7 +104,12 @@ pub(super) fn run_translation_validate(options: &CliOptions) -> anyhow::Result<(
|
|||||||
.ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?;
|
.ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?;
|
||||||
let (resource_root, release_id) = current_official_release(options)?;
|
let (resource_root, release_id) = current_official_release(options)?;
|
||||||
let workbench = read_translation_workbench(path)?;
|
let workbench = read_translation_workbench(path)?;
|
||||||
let validation = validate_translation_workbench(&resource_root, &release_id, &workbench)?;
|
let validation = validate_translation_workbench_with_glossary_path(
|
||||||
|
&resource_root,
|
||||||
|
&release_id,
|
||||||
|
&workbench,
|
||||||
|
options.glossary_path.as_deref(),
|
||||||
|
)?;
|
||||||
let data = serde_json::json!({
|
let data = serde_json::json!({
|
||||||
"official_release_id": release_id,
|
"official_release_id": release_id,
|
||||||
"resource_root": resource_root,
|
"resource_root": resource_root,
|
||||||
@@ -141,7 +146,52 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
|
|||||||
.translation_id
|
.translation_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?;
|
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?;
|
||||||
let entry = set_translation(path, entry_id, text)?;
|
let glossary_override = match (
|
||||||
|
options.glossary_reviewer.as_deref(),
|
||||||
|
options.glossary_reason.as_deref(),
|
||||||
|
options.glossary_override_provenance.as_deref(),
|
||||||
|
options.glossary_qa_identity.as_deref(),
|
||||||
|
) {
|
||||||
|
(None, None, None, None) => None,
|
||||||
|
(Some(reviewer), Some(reason), Some(provenance), Some(qa_identity)) => Some(
|
||||||
|
bat_core::domain::GlossaryOverride {
|
||||||
|
qa_identity: qa_identity.to_string(),
|
||||||
|
reviewer: reviewer.to_string(),
|
||||||
|
reason: reason.to_string(),
|
||||||
|
provenance: provenance.to_string(),
|
||||||
|
confirmed_unix_seconds: unix_seconds_now(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_ => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Glossary override 必须同时指定 --glossary-qa-identity、--glossary-reviewer、--glossary-reason 和 --glossary-provenance"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let workbench = read_translation_workbench(path)?;
|
||||||
|
let glossary_path = options.glossary_path.clone().unwrap_or_else(|| {
|
||||||
|
bat_infrastructure::SqliteGlossaryRepository::repository_path(
|
||||||
|
&workbench.official_resource_root,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let entry = if std::fs::symlink_metadata(&glossary_path).is_ok() {
|
||||||
|
let (resource_root, _) = current_official_release(options)?;
|
||||||
|
set_translation_checked_with_glossary_path(
|
||||||
|
&resource_root,
|
||||||
|
path,
|
||||||
|
entry_id,
|
||||||
|
text,
|
||||||
|
glossary_override,
|
||||||
|
options.glossary_path.as_deref(),
|
||||||
|
)?
|
||||||
|
} else {
|
||||||
|
if glossary_override.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"当前项目没有 Glossary 数据库,不能提交 Glossary override"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
set_translation(path, entry_id, text)?
|
||||||
|
};
|
||||||
let data = serde_json::json!({
|
let data = serde_json::json!({
|
||||||
"translation_file": path,
|
"translation_file": path,
|
||||||
"entry": entry,
|
"entry": entry,
|
||||||
@@ -232,6 +282,7 @@ pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Resul
|
|||||||
let report = update_translation_task_status_report(
|
let report = update_translation_task_status_report(
|
||||||
&options.state_dir,
|
&options.state_dir,
|
||||||
Some(&serde_json::Value::Object(params)),
|
Some(&serde_json::Value::Object(params)),
|
||||||
|
options.glossary_path.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
print_json_value(options.output_format, &report)
|
print_json_value(options.output_format, &report)
|
||||||
}
|
}
|
||||||
@@ -288,6 +339,49 @@ pub(super) fn publish_localized_report(
|
|||||||
options: &CliOptions,
|
options: &CliOptions,
|
||||||
) -> anyhow::Result<LocalizedPatchReport> {
|
) -> anyhow::Result<LocalizedPatchReport> {
|
||||||
let (resource_root, official_release_id) = current_official_release(options)?;
|
let (resource_root, official_release_id) = current_official_release(options)?;
|
||||||
|
if let Some(manifest_path) = options.patch_manifest.as_ref() {
|
||||||
|
let manifest_bytes = read_file_no_symlink(manifest_path, "generic patch manifest")
|
||||||
|
.map_err(anyhow::Error::msg)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("generic patch manifest 不存在:{}", manifest_path.display())
|
||||||
|
})?;
|
||||||
|
let manifest: bat_patch::PatchManifest = serde_json::from_slice(&manifest_bytes)
|
||||||
|
.map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?;
|
||||||
|
bat_patch::validate_patch_manifest(&manifest)
|
||||||
|
.map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?;
|
||||||
|
if manifest.source_version != official_release_id {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"generic patch source version={} 与当前官方 release={} 不一致",
|
||||||
|
manifest.source_version,
|
||||||
|
official_release_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let localized_release_id = options
|
||||||
|
.localized_release_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| manifest.target_version.clone());
|
||||||
|
if localized_release_id != manifest.target_version {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"generic patch target version={} 必须与 localized release id={} 一致",
|
||||||
|
manifest.target_version,
|
||||||
|
localized_release_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let config = LocalizedPatchConfig::new(
|
||||||
|
resource_root,
|
||||||
|
options.config.localized_output_root.clone(),
|
||||||
|
official_release_id,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.with_archive_commands(
|
||||||
|
options.config.unzip_command.clone(),
|
||||||
|
options.config.zip_command.clone(),
|
||||||
|
)
|
||||||
|
.with_manifest(manifest)
|
||||||
|
.with_localized_release_id(localized_release_id)
|
||||||
|
.with_force(options.config.force);
|
||||||
|
return LocalizedPatchService::new().publish(&config);
|
||||||
|
}
|
||||||
let workbench = if options.translation_from_worker {
|
let workbench = if options.translation_from_worker {
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -316,7 +410,17 @@ pub(super) fn publish_localized_report(
|
|||||||
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
|
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let operations = localized_patch_operations(&resource_root, &workbench)?;
|
validate_translation_workbench_with_glossary_path(
|
||||||
|
&resource_root,
|
||||||
|
&official_release_id,
|
||||||
|
&workbench,
|
||||||
|
options.glossary_path.as_deref(),
|
||||||
|
)?;
|
||||||
|
let operations = localized_patch_operations_with_glossary_path(
|
||||||
|
&resource_root,
|
||||||
|
&workbench,
|
||||||
|
options.glossary_path.as_deref(),
|
||||||
|
)?;
|
||||||
let localized_release_id = options.localized_release_id.clone().or_else(|| {
|
let localized_release_id = options.localized_release_id.clone().or_else(|| {
|
||||||
options
|
options
|
||||||
.config
|
.config
|
||||||
@@ -329,6 +433,10 @@ pub(super) fn publish_localized_report(
|
|||||||
official_release_id,
|
official_release_id,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
)
|
)
|
||||||
|
.with_archive_commands(
|
||||||
|
options.config.unzip_command.clone(),
|
||||||
|
options.config.zip_command.clone(),
|
||||||
|
)
|
||||||
.with_operations(operations)
|
.with_operations(operations)
|
||||||
.with_force(options.config.force);
|
.with_force(options.config.force);
|
||||||
if let Some(release_id) = localized_release_id {
|
if let Some(release_id) = localized_release_id {
|
||||||
|
|||||||
@@ -30,6 +30,30 @@ impl FileSystemCasRepository {
|
|||||||
self.engine().await.map(|_| ())
|
self.engine().await.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Releases one release-owned CAS reference exactly once.
|
||||||
|
pub async fn release_reference_once(
|
||||||
|
&self,
|
||||||
|
ownership_id: &str,
|
||||||
|
ordinal: u64,
|
||||||
|
id: &ObjectId,
|
||||||
|
) -> bat_core::Result<bool> {
|
||||||
|
let hash = Self::parse_object_id(id)?;
|
||||||
|
self.engine()
|
||||||
|
.await?
|
||||||
|
.release_reference_once(ownership_id, ordinal, &hash)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns whether the durable release ownership ledger has any row.
|
||||||
|
pub async fn has_release_ownership(&self, ownership_id: &str) -> bat_core::Result<bool> {
|
||||||
|
self.engine()
|
||||||
|
.await?
|
||||||
|
.has_release_ownership(ownership_id)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_error)
|
||||||
|
}
|
||||||
|
|
||||||
async fn engine(&self) -> bat_core::Result<&engine_repository::FileSystemCasRepository> {
|
async fn engine(&self) -> bat_core::Result<&engine_repository::FileSystemCasRepository> {
|
||||||
self.inner
|
self.inner
|
||||||
.get_or_try_init(|| async {
|
.get_or_try_init(|| async {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+36
-14
@@ -13,6 +13,7 @@
|
|||||||
pub mod cas;
|
pub mod cas;
|
||||||
mod curl_transfer;
|
mod curl_transfer;
|
||||||
pub mod downloader;
|
pub mod downloader;
|
||||||
|
pub mod glossary;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod localized_patch;
|
pub mod localized_patch;
|
||||||
pub mod official_changes;
|
pub mod official_changes;
|
||||||
@@ -28,7 +29,9 @@ pub mod official_update;
|
|||||||
pub mod patch_ops;
|
pub mod patch_ops;
|
||||||
pub mod path_security;
|
pub mod path_security;
|
||||||
pub mod release_flow;
|
pub mod release_flow;
|
||||||
|
pub mod release_ops;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
|
mod sqlite_migration;
|
||||||
pub mod translation_memory;
|
pub mod translation_memory;
|
||||||
pub mod translation_tasks;
|
pub mod translation_tasks;
|
||||||
pub mod translation_worker;
|
pub mod translation_worker;
|
||||||
@@ -43,19 +46,26 @@ pub use downloader::{
|
|||||||
DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY,
|
DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||||
MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
|
MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
|
||||||
};
|
};
|
||||||
|
pub use glossary::{
|
||||||
|
SqliteGlossaryRepository, GLOSSARY_REPOSITORY_FILE, GLOSSARY_SCHEMA_COMPONENT,
|
||||||
|
GLOSSARY_SCHEMA_VERSION,
|
||||||
|
};
|
||||||
pub use import::{
|
pub use import::{
|
||||||
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
|
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
|
||||||
ResourceImportService,
|
ResourceImportService,
|
||||||
};
|
};
|
||||||
pub use localized_patch::{
|
pub use localized_patch::{
|
||||||
|
inspect_localized_release_artifact, inspect_localized_release_artifact_at,
|
||||||
mark_localized_manual_proofreading, read_localized_patch_manifest_at,
|
mark_localized_manual_proofreading, read_localized_patch_manifest_at,
|
||||||
read_localized_version_state, write_localized_version_state, LocalizedFieldPatch,
|
read_localized_version_state, write_localized_version_state, LocalizedArtifactIntegrityReport,
|
||||||
|
LocalizedDistributionEntry, LocalizedDistributionManifest, LocalizedFieldPatch,
|
||||||
LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity,
|
LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity,
|
||||||
LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata,
|
LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata,
|
||||||
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService,
|
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService,
|
||||||
LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch,
|
LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch,
|
||||||
LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
|
LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
|
||||||
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR,
|
LOCALIZED_DISTRIBUTION_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_FILE,
|
||||||
|
LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR,
|
||||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
|
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
|
||||||
LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION,
|
LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION,
|
||||||
@@ -71,15 +81,19 @@ pub use official_changes::{
|
|||||||
OFFICIAL_RESOURCE_CHANGES_VERSION,
|
OFFICIAL_RESOURCE_CHANGES_VERSION,
|
||||||
};
|
};
|
||||||
pub use official_download::{
|
pub use official_download::{
|
||||||
read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references,
|
official_distribution_mapping_identity, official_distribution_max_age_for_durations,
|
||||||
DownloadError, OfficialCasReuseReferenceManifest, OfficialDownloadManifest,
|
official_distribution_max_age_seconds, read_cas_reuse_reference_manifest_at,
|
||||||
|
read_download_manifest_at, release_cas_reuse_references, DownloadError,
|
||||||
|
OfficialCasReuseReferenceManifest, OfficialDistributionAttestation, OfficialDownloadManifest,
|
||||||
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
|
OfficialDownloadManifestEntry, OfficialLocalManifestAuditItem,
|
||||||
OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
|
OfficialLocalManifestAuditReport, OfficialLocalManifestAuditStatus,
|
||||||
OfficialLocalVerificationReport, OfficialResourceHashAlgorithm,
|
OfficialLocalVerificationReport, OfficialResourceHashAlgorithm,
|
||||||
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
|
OfficialResourceHashVerification, OfficialResourcePullItem, OfficialResourcePullProgress,
|
||||||
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
|
OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
|
||||||
OfficialResourcePullStatus, OfficialResourceReuseWarning, OfficialResourceVerification,
|
OfficialResourcePullStatus, OfficialResourceReuseWarning, OfficialResourceVerification,
|
||||||
OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS, DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS,
|
||||||
|
OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_ATTESTATION_FILE,
|
||||||
|
OFFICIAL_DISTRIBUTION_ATTESTATION_VERSION, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||||
};
|
};
|
||||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||||
pub use official_launcher::{
|
pub use official_launcher::{
|
||||||
@@ -120,13 +134,13 @@ pub use official_textunit_queue::{
|
|||||||
pub use official_update::{
|
pub use official_update::{
|
||||||
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
|
cached_game_main_config_for_metadata, diff_extended_snapshot, gc_orphan_staging,
|
||||||
gc_orphan_staging_with_cas_root, read_bootstrap_cache, read_snapshot, read_version_state,
|
gc_orphan_staging_with_cas_root, read_bootstrap_cache, read_snapshot, read_version_state,
|
||||||
write_bootstrap_cache, write_snapshot, write_version_state, ExtendedSnapshotDelta,
|
verify_and_record_official_distribution_attestation, write_bootstrap_cache, write_snapshot,
|
||||||
GameMainConfigSnapshot, LauncherMetadataSnapshot, LocalizedReleaseStatus,
|
write_version_state, ExtendedSnapshotDelta, GameMainConfigSnapshot, LauncherMetadataSnapshot,
|
||||||
OfficialBootstrapCache, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot,
|
LocalizedReleaseStatus, OfficialBootstrapCache, OfficialEndpointMarkerRole,
|
||||||
OfficialFailedVersionRecord, OfficialServerInfoSource, OfficialUpdateConfig,
|
OfficialEndpointMarkerSnapshot, OfficialFailedVersionRecord, OfficialServerInfoSource,
|
||||||
OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot,
|
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||||
OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState,
|
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||||
ResolvedBootstrap,
|
OfficialVersionRecord, OfficialVersionState, ResolvedBootstrap,
|
||||||
};
|
};
|
||||||
pub use patch_ops::{
|
pub use patch_ops::{
|
||||||
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
|
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
|
||||||
@@ -140,6 +154,12 @@ pub use path_security::{
|
|||||||
validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE,
|
validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE,
|
||||||
};
|
};
|
||||||
pub use release_flow::ReleaseFlowStatusCode;
|
pub use release_flow::ReleaseFlowStatusCode;
|
||||||
|
pub use release_ops::{
|
||||||
|
build_official_distribution_attestation, build_release_list, build_release_status,
|
||||||
|
cleanup_releases, select_release_distribution, OfficialDistributionAttestationReport,
|
||||||
|
ReleaseCleanupParams, ReleaseCleanupReport, ReleaseDistributionEntry, ReleaseDistributionPage,
|
||||||
|
ReleaseDistributionParams, ReleaseListParams, ReleaseStatusReport, ReleaseSummary,
|
||||||
|
};
|
||||||
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
|
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
|
||||||
pub use translation_memory::{
|
pub use translation_memory::{
|
||||||
translation_memory_context, translation_memory_repository_path,
|
translation_memory_context, translation_memory_repository_path,
|
||||||
@@ -168,8 +188,10 @@ pub use translation_worker::{
|
|||||||
pub use translation_workflow::{
|
pub use translation_workflow::{
|
||||||
completed_worker_translation_workbench, export_completed_worker_translation_workbench,
|
completed_worker_translation_workbench, export_completed_worker_translation_workbench,
|
||||||
export_translation_workbench, get_translation_entry, localized_patch_operations,
|
export_translation_workbench, get_translation_entry, localized_patch_operations,
|
||||||
localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation,
|
localized_patch_operations_with_glossary_path, localized_text_asset_patches,
|
||||||
unset_translation, validate_translation_workbench, write_translation_workbench,
|
read_translation_workbench, repack_bundle, set_translation, set_translation_checked,
|
||||||
|
set_translation_checked_with_glossary_path, unset_translation, validate_translation_workbench,
|
||||||
|
validate_translation_workbench_with_glossary_path, write_translation_workbench,
|
||||||
RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
|
RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
|
||||||
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
|
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,11 @@ use crate::official_changes::{
|
|||||||
write_official_resource_change_handoff, OfficialResourceChangeHandoffReport,
|
write_official_resource_change_handoff, OfficialResourceChangeHandoffReport,
|
||||||
OfficialResourceChangeSummary,
|
OfficialResourceChangeSummary,
|
||||||
};
|
};
|
||||||
|
use crate::official_download::{
|
||||||
|
official_distribution_max_age_for_durations, write_official_distribution_attestation_at,
|
||||||
|
write_official_distribution_publication_anchor_at, OfficialDistributionAttestation,
|
||||||
|
OFFICIAL_CAS_REUSE_REFERENCES_FILE, OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||||
|
};
|
||||||
use crate::official_game_main_config::{
|
use crate::official_game_main_config::{
|
||||||
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
|
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
|
||||||
OfficialGameMainConfigSourceKind,
|
OfficialGameMainConfigSourceKind,
|
||||||
@@ -126,6 +131,8 @@ pub struct OfficialUpdateConfig {
|
|||||||
pub download_concurrency: usize,
|
pub download_concurrency: usize,
|
||||||
/// Unzip command used when a metadata change requires GameMainConfig parsing.
|
/// Unzip command used when a metadata change requires GameMainConfig parsing.
|
||||||
pub unzip_command: PathBuf,
|
pub unzip_command: PathBuf,
|
||||||
|
/// Zip command used when publishing localized bundles nested in ZIP archives.
|
||||||
|
pub zip_command: PathBuf,
|
||||||
/// Dry run reports decisions and optional plan URLs without writing sync state.
|
/// Dry run reports decisions and optional plan URLs without writing sync state.
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
/// Include full download URLs when dry-running.
|
/// Include full download URLs when dry-running.
|
||||||
@@ -162,6 +169,7 @@ impl Default for OfficialUpdateConfig {
|
|||||||
curl_proxy: CurlProxyConfig::default(),
|
curl_proxy: CurlProxyConfig::default(),
|
||||||
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
|
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||||
unzip_command: PathBuf::from("unzip"),
|
unzip_command: PathBuf::from("unzip"),
|
||||||
|
zip_command: PathBuf::from("zip"),
|
||||||
dry_run: false,
|
dry_run: false,
|
||||||
plan: false,
|
plan: false,
|
||||||
force: false,
|
force: false,
|
||||||
@@ -1125,7 +1133,20 @@ impl OfficialPublishLayout {
|
|||||||
if !path_exists_no_follow(active_root)? {
|
if !path_exists_no_follow(active_root)? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)
|
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)?;
|
||||||
|
if active_root != self.root {
|
||||||
|
let cas_references = staging_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE);
|
||||||
|
if path_exists_no_follow(&cas_references)? {
|
||||||
|
ensure_safe_file_target(staging_root, &cas_references, "staging CAS 引用清单")?;
|
||||||
|
fs::remove_file(&cas_references).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"清理 active release CAS ownership 清单失败 {}:{error}",
|
||||||
|
cas_references.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn legacy_manifest_exists(&self) -> Result<bool, String> {
|
fn legacy_manifest_exists(&self) -> Result<bool, String> {
|
||||||
@@ -1192,13 +1213,48 @@ impl OfficialPublishLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Official update runner.
|
/// Official update runner.
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OfficialUpdateService;
|
pub struct OfficialUpdateService {
|
||||||
|
attestation_max_age_seconds: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for OfficialUpdateService {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl OfficialUpdateService {
|
impl OfficialUpdateService {
|
||||||
/// Creates an official update runner.
|
/// Creates an official update runner.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self
|
Self {
|
||||||
|
attestation_max_age_seconds: official_distribution_max_age_for_durations(
|
||||||
|
std::time::Duration::from_secs(
|
||||||
|
crate::official_download::DEFAULT_OFFICIAL_VERIFICATION_INTERVAL_SECONDS,
|
||||||
|
),
|
||||||
|
std::time::Duration::from_secs(
|
||||||
|
crate::official_download::DEFAULT_OFFICIAL_ERROR_RETRY_SECONDS,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an update runner using the daemon's actual watch cadence.
|
||||||
|
pub fn with_verification_cadence(
|
||||||
|
verification_interval: std::time::Duration,
|
||||||
|
error_retry: std::time::Duration,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
attestation_max_age_seconds: official_distribution_max_age_for_durations(
|
||||||
|
verification_interval,
|
||||||
|
error_retry,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the freshness window persisted with each attestation.
|
||||||
|
pub fn attestation_max_age_seconds(&self) -> u64 {
|
||||||
|
self.attestation_max_age_seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Executes one official update run.
|
/// Executes one official update run.
|
||||||
@@ -1586,11 +1642,24 @@ impl OfficialUpdateService {
|
|||||||
fetcher.download_manifest_path().display()
|
fetcher.download_manifest_path().display()
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
Some(
|
match fetcher.audit_local_manifest(&pull_plan) {
|
||||||
fetcher
|
Ok(audit) => Some(audit),
|
||||||
.audit_local_manifest(&pull_plan)
|
Err(error) => {
|
||||||
.map_err(anyhow::Error::msg)?,
|
if !config.dry_run && has_current_pointer {
|
||||||
)
|
let release_id = version_id_from_path(&active_resource_root)
|
||||||
|
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot));
|
||||||
|
let _ = write_official_distribution_attestation_at(
|
||||||
|
&active_resource_root,
|
||||||
|
&active_resource_root,
|
||||||
|
&release_id,
|
||||||
|
"invalid",
|
||||||
|
self.attestation_max_age_seconds,
|
||||||
|
vec![format!("本地 manifest 审计失败:{error}")],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(anyhow::Error::msg(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if config.audit_local {
|
} else if config.audit_local {
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"audit",
|
"audit",
|
||||||
@@ -1652,6 +1721,31 @@ impl OfficialUpdateService {
|
|||||||
check_shutdown_requested(&mut should_cancel)?;
|
check_shutdown_requested(&mut should_cancel)?;
|
||||||
let active_release_id = version_id_from_path(&active_resource_root)
|
let active_release_id = version_id_from_path(&active_resource_root)
|
||||||
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot));
|
.unwrap_or_else(|| fallback_version_id(¤t_update_snapshot));
|
||||||
|
let mut local_attestation_invalidated = false;
|
||||||
|
if !config.dry_run
|
||||||
|
&& has_current_pointer
|
||||||
|
&& local_audit.as_ref().is_some_and(|audit| !audit.is_clean())
|
||||||
|
{
|
||||||
|
let diagnostics = local_audit
|
||||||
|
.as_ref()
|
||||||
|
.map(|audit| {
|
||||||
|
vec![format!(
|
||||||
|
"本地 manifest 审计失败:{} 项需要修复",
|
||||||
|
audit.repair_needed_count()
|
||||||
|
)]
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
write_official_distribution_attestation_at(
|
||||||
|
&active_resource_root,
|
||||||
|
&active_resource_root,
|
||||||
|
&active_release_id,
|
||||||
|
"invalid",
|
||||||
|
self.attestation_max_age_seconds,
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
local_attestation_invalidated = true;
|
||||||
|
}
|
||||||
let localized_info = localized_release_info_for(config, Some(active_release_id.as_str()));
|
let localized_info = localized_release_info_for(config, Some(active_release_id.as_str()));
|
||||||
let mut report = OfficialUpdateReport {
|
let mut report = OfficialUpdateReport {
|
||||||
update_status: if should_download {
|
update_status: if should_download {
|
||||||
@@ -1765,6 +1859,32 @@ impl OfficialUpdateService {
|
|||||||
&active_resource_root,
|
&active_resource_root,
|
||||||
&snapshot_path,
|
&snapshot_path,
|
||||||
)?;
|
)?;
|
||||||
|
if !local_attestation_invalidated {
|
||||||
|
if let Some(audit) = local_audit.as_ref() {
|
||||||
|
let integrity_status = if audit.is_clean() {
|
||||||
|
"verified"
|
||||||
|
} else {
|
||||||
|
"invalid"
|
||||||
|
};
|
||||||
|
let diagnostics = if audit.is_clean() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
vec![format!(
|
||||||
|
"本地 manifest 审计失败:{} 项需要修复",
|
||||||
|
audit.repair_needed_count()
|
||||||
|
)]
|
||||||
|
};
|
||||||
|
write_official_distribution_attestation_at(
|
||||||
|
&active_resource_root,
|
||||||
|
&active_resource_root,
|
||||||
|
&active_release_id,
|
||||||
|
integrity_status,
|
||||||
|
self.attestation_max_age_seconds,
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
let active_launcher_bootstrap_path =
|
let active_launcher_bootstrap_path =
|
||||||
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
|
active_resource_root.join(OFFICIAL_LAUNCHER_BOOTSTRAP_FILE);
|
||||||
if bootstrap.is_some()
|
if bootstrap.is_some()
|
||||||
@@ -1986,6 +2106,24 @@ impl OfficialUpdateService {
|
|||||||
"audit",
|
"audit",
|
||||||
verification_progress_message(&final_verification_summary),
|
verification_progress_message(&final_verification_summary),
|
||||||
));
|
));
|
||||||
|
progress(OfficialUpdateProgress::new(
|
||||||
|
"publish",
|
||||||
|
"写入官方 distribution publication anchor",
|
||||||
|
));
|
||||||
|
write_official_distribution_publication_anchor_at(
|
||||||
|
&publish_plan.staging_path,
|
||||||
|
&publish_plan.id,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
write_official_distribution_attestation_at(
|
||||||
|
&publish_plan.staging_path,
|
||||||
|
&publish_plan.version_path,
|
||||||
|
&publish_plan.id,
|
||||||
|
"verified",
|
||||||
|
self.attestation_max_age_seconds,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
progress(OfficialUpdateProgress::new(
|
progress(OfficialUpdateProgress::new(
|
||||||
"snapshot",
|
"snapshot",
|
||||||
format!("写入快照 {}", staging_snapshot_path.display()),
|
format!("写入快照 {}", staging_snapshot_path.display()),
|
||||||
@@ -2151,6 +2289,77 @@ impl OfficialUpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs the explicit full local verification and records its result for the
|
||||||
|
/// lightweight current-distribution health RPC.
|
||||||
|
///
|
||||||
|
/// This is intentionally called only by the explicit verify task/command. The
|
||||||
|
/// high-frequency health path reads the resulting attestation and never hashes
|
||||||
|
/// resource artifacts.
|
||||||
|
pub fn verify_and_record_official_distribution_attestation(
|
||||||
|
config: &OfficialUpdateConfig,
|
||||||
|
max_age_seconds: u64,
|
||||||
|
) -> anyhow::Result<OfficialDistributionAttestation> {
|
||||||
|
let version_state = read_version_state(&config.version_state_path())?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("官方版本状态不存在,无法记录 distribution attestation"))?;
|
||||||
|
let record = version_state
|
||||||
|
.current_completed_version
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("没有当前已发布官方 release,无法记录 distribution attestation")
|
||||||
|
})?;
|
||||||
|
let resource_root = OfficialPublishLayout::new(&config.output_root)
|
||||||
|
.active_resource_root()
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
if resource_root != record.resource_root {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"current resource root 与版本状态不一致:current={} state={}",
|
||||||
|
resource_root.display(),
|
||||||
|
record.resource_root.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let verification_result =
|
||||||
|
OfficialResourcePullService::with_curl_command(&resource_root, &config.curl_command)
|
||||||
|
.with_proxy_config(config.curl_proxy.clone())
|
||||||
|
.verify_local_download_manifest();
|
||||||
|
let verification = match verification_result {
|
||||||
|
Ok(verification) => verification,
|
||||||
|
Err(error) => {
|
||||||
|
write_official_distribution_attestation_at(
|
||||||
|
&resource_root,
|
||||||
|
&resource_root,
|
||||||
|
&record.id,
|
||||||
|
"invalid",
|
||||||
|
max_age_seconds,
|
||||||
|
vec![format!("本地 manifest 验证失败:{error}")],
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"本地 manifest 验证失败,已立即使当前 distribution attestation 失效:{error}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let diagnostics = verification
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| !item.status.is_verified())
|
||||||
|
.map(|item| format!("{}: {}", item.destination.display(), item.status.as_str()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let integrity_status = if verification.is_clean() {
|
||||||
|
"verified"
|
||||||
|
} else {
|
||||||
|
"invalid"
|
||||||
|
};
|
||||||
|
write_official_distribution_attestation_at(
|
||||||
|
&resource_root,
|
||||||
|
&resource_root,
|
||||||
|
&record.id,
|
||||||
|
integrity_status,
|
||||||
|
max_age_seconds,
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
.map_err(anyhow::Error::msg)
|
||||||
|
}
|
||||||
|
|
||||||
fn run_post_sync_resource_handoff(
|
fn run_post_sync_resource_handoff(
|
||||||
previous_resource_root: Option<&Path>,
|
previous_resource_root: Option<&Path>,
|
||||||
current_resource_root: &Path,
|
current_resource_root: &Path,
|
||||||
@@ -3192,6 +3401,8 @@ fn copy_tree_no_symlink(
|
|||||||
| OFFICIAL_VERSIONS_DIR
|
| OFFICIAL_VERSIONS_DIR
|
||||||
| OFFICIAL_CURRENT_LINK
|
| OFFICIAL_CURRENT_LINK
|
||||||
| ".official-sync.lock"
|
| ".official-sync.lock"
|
||||||
|
| ".cas-owner-scope"
|
||||||
|
| OFFICIAL_DISTRIBUTION_PUBLICATION_FILE
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3221,7 +3432,15 @@ fn copy_tree_no_symlink(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Err(_error) = fs::hard_link(&source_path, &destination_path) {
|
if is_release_local_mutable_state(&source_path) {
|
||||||
|
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
|
||||||
|
format!(
|
||||||
|
"复制官方 release mutable state 失败 {} -> {}:{copy_error}",
|
||||||
|
source_path.display(),
|
||||||
|
destination_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
} else if let Err(_error) = fs::hard_link(&source_path, &destination_path) {
|
||||||
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
|
fs::copy(&source_path, &destination_path).map_err(|copy_error| {
|
||||||
format!(
|
format!(
|
||||||
"复制官方资源到 staging 失败 {} -> {}:{copy_error}",
|
"复制官方资源到 staging 失败 {} -> {}:{copy_error}",
|
||||||
@@ -3240,6 +3459,29 @@ fn copy_tree_no_symlink(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_release_local_mutable_state(path: &Path) -> bool {
|
||||||
|
matches!(
|
||||||
|
path.file_name().and_then(|name| name.to_str()),
|
||||||
|
Some(
|
||||||
|
OFFICIAL_DOWNLOAD_MANIFEST_FILE
|
||||||
|
| OFFICIAL_SYNC_SNAPSHOT_FILE
|
||||||
|
| "official-parse-cache.json"
|
||||||
|
| "official-textunit-index.json"
|
||||||
|
| "official-textunit-tasks.json"
|
||||||
|
| "crowdin-textunit-queue.json"
|
||||||
|
| "official-resource-changes.json"
|
||||||
|
| "crowdin-translation-handoff.json"
|
||||||
|
| "translation-tasks.sqlite"
|
||||||
|
| "translation-tasks.sqlite-wal"
|
||||||
|
| "translation-tasks.sqlite-shm"
|
||||||
|
| "translation-handoff.json"
|
||||||
|
| OFFICIAL_LAUNCHER_BOOTSTRAP_FILE
|
||||||
|
| OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE
|
||||||
|
| "official-cas-reuse-references.json"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn switch_current_symlink(
|
fn switch_current_symlink(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
@@ -4275,21 +4517,22 @@ fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result<Patc
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct OfficialUpdateLock {
|
pub(crate) struct OfficialUpdateLock {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OfficialUpdateLock {
|
impl OfficialUpdateLock {
|
||||||
fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result<Self> {
|
fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result<Self> {
|
||||||
validate_output_root(&config.output_root).map_err(anyhow::Error::msg)?;
|
Self::acquire_output_root(&config.output_root)
|
||||||
ensure_safe_directory_path(&config.output_root, "资源输出目录")
|
}
|
||||||
.map_err(anyhow::Error::msg)?;
|
|
||||||
fs::create_dir_all(&config.output_root)?;
|
pub(crate) fn acquire_output_root(output_root: &Path) -> anyhow::Result<Self> {
|
||||||
ensure_safe_directory_path(&config.output_root, "资源输出目录")
|
validate_output_root(output_root).map_err(anyhow::Error::msg)?;
|
||||||
.map_err(anyhow::Error::msg)?;
|
ensure_safe_directory_path(output_root, "资源输出目录").map_err(anyhow::Error::msg)?;
|
||||||
let path = config.lock_path();
|
fs::create_dir_all(output_root)?;
|
||||||
ensure_safe_file_target(&config.output_root, &path, "官方同步锁")
|
ensure_safe_directory_path(output_root, "资源输出目录").map_err(anyhow::Error::msg)?;
|
||||||
.map_err(anyhow::Error::msg)?;
|
let path = output_root.join(".official-sync.lock");
|
||||||
|
ensure_safe_file_target(output_root, &path, "官方同步锁").map_err(anyhow::Error::msg)?;
|
||||||
for attempt in 0..=1 {
|
for attempt in 0..=1 {
|
||||||
let mut options = OpenOptions::new();
|
let mut options = OpenOptions::new();
|
||||||
options.write(true).create_new(true);
|
options.write(true).create_new(true);
|
||||||
@@ -4327,6 +4570,12 @@ impl OfficialUpdateLock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn acquire_official_output_lock(
|
||||||
|
output_root: &Path,
|
||||||
|
) -> anyhow::Result<OfficialUpdateLock> {
|
||||||
|
OfficialUpdateLock::acquire_output_root(output_root)
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for OfficialUpdateLock {
|
impl Drop for OfficialUpdateLock {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let expected = std::process::id().to_string();
|
let expected = std::process::id().to_string();
|
||||||
@@ -4412,6 +4661,60 @@ fn process_exists(_pid: u32) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn staging_copy_does_not_hard_link_mutable_translation_state() {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let source = temp.path().join("old");
|
||||||
|
let destination = temp.path().join("new");
|
||||||
|
fs::create_dir_all(&source).unwrap();
|
||||||
|
let mutable_files = [
|
||||||
|
OFFICIAL_DOWNLOAD_MANIFEST_FILE,
|
||||||
|
OFFICIAL_SYNC_SNAPSHOT_FILE,
|
||||||
|
"official-parse-cache.json",
|
||||||
|
"official-textunit-index.json",
|
||||||
|
"official-textunit-tasks.json",
|
||||||
|
"crowdin-textunit-queue.json",
|
||||||
|
"official-resource-changes.json",
|
||||||
|
"crowdin-translation-handoff.json",
|
||||||
|
"translation-tasks.sqlite",
|
||||||
|
"translation-tasks.sqlite-wal",
|
||||||
|
"translation-tasks.sqlite-shm",
|
||||||
|
"translation-handoff.json",
|
||||||
|
OFFICIAL_LAUNCHER_BOOTSTRAP_FILE,
|
||||||
|
OFFICIAL_LAUNCHER_BOOTSTRAP_PENDING_FILE,
|
||||||
|
"official-cas-reuse-references.json",
|
||||||
|
];
|
||||||
|
for (index, name) in mutable_files.iter().enumerate() {
|
||||||
|
fs::write(source.join(name), format!("old-{index}")).unwrap();
|
||||||
|
}
|
||||||
|
fs::write(source.join("immutable.bundle"), b"payload").unwrap();
|
||||||
|
|
||||||
|
copy_tree_no_symlink(&source, &destination, false).unwrap();
|
||||||
|
|
||||||
|
for (index, name) in mutable_files.iter().enumerate() {
|
||||||
|
assert_ne!(
|
||||||
|
fs::metadata(source.join(name)).unwrap().ino(),
|
||||||
|
fs::metadata(destination.join(name)).unwrap().ino(),
|
||||||
|
"mutable state unexpectedly hard-linked: {name}"
|
||||||
|
);
|
||||||
|
fs::write(destination.join(name), format!("new-{index}")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(source.join(name)).unwrap(),
|
||||||
|
format!("old-{index}").as_bytes(),
|
||||||
|
"historical mutable state changed: {name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(source.join("immutable.bundle")).unwrap().ino(),
|
||||||
|
fs::metadata(destination.join("immutable.bundle"))
|
||||||
|
.unwrap()
|
||||||
|
.ino()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_app_version_carries_input_error_code() {
|
fn missing_app_version_carries_input_error_code() {
|
||||||
// 未启用 auto-discover 且未传 app-version:配置校验失败应携带
|
// 未启用 auto-discover 且未传 app-version:配置校验失败应携带
|
||||||
@@ -4772,6 +5075,227 @@ mod tests {
|
|||||||
assert_eq!(read_version_state(&path).unwrap(), Some(state));
|
assert_eq!(read_version_state(&path).unwrap(), Some(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn publication_attestation_keeps_canonical_root_across_staging_rename() {
|
||||||
|
use crate::official_download::{
|
||||||
|
official_distribution_max_age_seconds, write_official_distribution_attestation_at,
|
||||||
|
write_official_distribution_publication_anchor_at, OfficialDownloadManifest,
|
||||||
|
OfficialDownloadManifestEntry,
|
||||||
|
};
|
||||||
|
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let root = temp.path().join("official");
|
||||||
|
let layout = OfficialPublishLayout::new(&root);
|
||||||
|
let staging = layout.staging_dir.join("release-a");
|
||||||
|
let version = layout.versions_dir.join("release-a");
|
||||||
|
fs::create_dir_all(&staging).unwrap();
|
||||||
|
fs::create_dir_all(&layout.versions_dir).unwrap();
|
||||||
|
|
||||||
|
let payload = b"official";
|
||||||
|
fs::write(staging.join("data.bin"), payload).unwrap();
|
||||||
|
let url = "https://example.invalid/data.bin".to_string();
|
||||||
|
let mut manifest = OfficialDownloadManifest {
|
||||||
|
entries: [(
|
||||||
|
url.clone(),
|
||||||
|
OfficialDownloadManifestEntry {
|
||||||
|
url: url.clone(),
|
||||||
|
destination: "data.bin".to_string(),
|
||||||
|
bytes: payload.len() as u64,
|
||||||
|
blake3: blake3::hash(payload).to_hex().to_string(),
|
||||||
|
},
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
..OfficialDownloadManifest::default()
|
||||||
|
};
|
||||||
|
manifest.destination_index = [("data.bin".to_string(), url)].into_iter().collect();
|
||||||
|
manifest.distribution_mapping_identity =
|
||||||
|
Some(crate::official_download::official_distribution_mapping_identity(&manifest));
|
||||||
|
fs::write(
|
||||||
|
staging.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE),
|
||||||
|
serde_json::to_vec(&manifest).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
write_official_distribution_publication_anchor_at(&staging, "release-a").unwrap();
|
||||||
|
let max_age = official_distribution_max_age_seconds(3600, 60);
|
||||||
|
let before_publish = write_official_distribution_attestation_at(
|
||||||
|
&staging,
|
||||||
|
&version,
|
||||||
|
"release-a",
|
||||||
|
"verified",
|
||||||
|
max_age,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(before_publish.resource_root, version);
|
||||||
|
assert_eq!(before_publish.verification_generation, 1);
|
||||||
|
|
||||||
|
let plan = OfficialPublishPlan {
|
||||||
|
id: "release-a".to_string(),
|
||||||
|
staging_path: staging,
|
||||||
|
version_path: version.clone(),
|
||||||
|
reuse_existing_staging: false,
|
||||||
|
};
|
||||||
|
let published = layout.publish(&plan).unwrap();
|
||||||
|
assert_eq!(published, version);
|
||||||
|
let after_rename =
|
||||||
|
crate::official_download::read_official_distribution_attestation_at(&version)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(after_rename.resource_root, version);
|
||||||
|
assert_eq!(
|
||||||
|
after_rename.verification_generation,
|
||||||
|
before_publish.verification_generation
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot_path = version.join(OFFICIAL_SYNC_SNAPSHOT_FILE);
|
||||||
|
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
|
||||||
|
write_snapshot(&snapshot_path, &snapshot).unwrap();
|
||||||
|
let state = OfficialVersionState {
|
||||||
|
current_completed_version: Some(OfficialVersionRecord {
|
||||||
|
id: "release-a".to_string(),
|
||||||
|
app_version: snapshot.app_version.clone(),
|
||||||
|
bundle_version: snapshot.bundle_version.clone(),
|
||||||
|
addressables_root: snapshot.addressables_root.clone(),
|
||||||
|
resource_root: version.clone(),
|
||||||
|
snapshot_path,
|
||||||
|
staging_path: None,
|
||||||
|
version_path: Some(version.clone()),
|
||||||
|
started_unix_seconds: Some(1),
|
||||||
|
completed_unix_seconds: Some(2),
|
||||||
|
}),
|
||||||
|
..OfficialVersionState::default()
|
||||||
|
};
|
||||||
|
write_version_state(&root.join(OFFICIAL_VERSION_STATE_FILE), &state).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_link(root.join(OFFICIAL_CURRENT_LINK)).unwrap(),
|
||||||
|
Path::new(OFFICIAL_VERSIONS_DIR).join("release-a")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.current_completed_version
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.resource_root,
|
||||||
|
after_rename.resource_root
|
||||||
|
);
|
||||||
|
let report = crate::release_ops::build_official_distribution_attestation(&root).unwrap();
|
||||||
|
assert!(report.available);
|
||||||
|
assert!(report.ready);
|
||||||
|
assert_eq!(report.release_id, "release-a");
|
||||||
|
assert_eq!(report.resource_root, version.display().to_string());
|
||||||
|
assert_eq!(report.verification_generation, 1);
|
||||||
|
|
||||||
|
// Keep this assertion explicit: current is the only pointer used by
|
||||||
|
// the read path, and the attestation never stores the staging path.
|
||||||
|
assert!(!after_rename.resource_root.starts_with(layout.staging_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failed_local_verification_invalidates_previous_ready_generation() {
|
||||||
|
use crate::official_download::{
|
||||||
|
official_distribution_max_age_seconds, write_official_distribution_attestation_at,
|
||||||
|
write_official_distribution_publication_anchor_at, OfficialDownloadManifest,
|
||||||
|
OfficialDownloadManifestEntry,
|
||||||
|
};
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let root = temp.path().join("official");
|
||||||
|
let version = root.join(OFFICIAL_VERSIONS_DIR).join("release-a");
|
||||||
|
fs::create_dir_all(&version).unwrap();
|
||||||
|
let payload = b"official";
|
||||||
|
let url = "https://example.invalid/data.bin".to_string();
|
||||||
|
let mut manifest = OfficialDownloadManifest {
|
||||||
|
entries: [(
|
||||||
|
url.clone(),
|
||||||
|
OfficialDownloadManifestEntry {
|
||||||
|
url,
|
||||||
|
destination: "data.bin".to_string(),
|
||||||
|
bytes: payload.len() as u64,
|
||||||
|
blake3: blake3::hash(payload).to_hex().to_string(),
|
||||||
|
},
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
..OfficialDownloadManifest::default()
|
||||||
|
};
|
||||||
|
manifest.destination_index = [(
|
||||||
|
"data.bin".to_string(),
|
||||||
|
"https://example.invalid/data.bin".to_string(),
|
||||||
|
)]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
manifest.distribution_mapping_identity =
|
||||||
|
Some(crate::official_download::official_distribution_mapping_identity(&manifest));
|
||||||
|
fs::write(
|
||||||
|
version.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE),
|
||||||
|
serde_json::to_vec(&manifest).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(version.join("data.bin"), payload).unwrap();
|
||||||
|
write_official_distribution_publication_anchor_at(&version, "release-a").unwrap();
|
||||||
|
let max_age = official_distribution_max_age_seconds(3600, 60);
|
||||||
|
let initial = write_official_distribution_attestation_at(
|
||||||
|
&version,
|
||||||
|
&version,
|
||||||
|
"release-a",
|
||||||
|
"verified",
|
||||||
|
max_age,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
symlink(
|
||||||
|
Path::new(OFFICIAL_VERSIONS_DIR).join("release-a"),
|
||||||
|
root.join(OFFICIAL_CURRENT_LINK),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let snapshot_path = version.join(OFFICIAL_SYNC_SNAPSHOT_FILE);
|
||||||
|
write_snapshot(
|
||||||
|
&snapshot_path,
|
||||||
|
&OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
write_version_state(
|
||||||
|
&root.join(OFFICIAL_VERSION_STATE_FILE),
|
||||||
|
&OfficialVersionState {
|
||||||
|
current_completed_version: Some(OfficialVersionRecord {
|
||||||
|
id: "release-a".to_string(),
|
||||||
|
app_version: "app".to_string(),
|
||||||
|
bundle_version: None,
|
||||||
|
addressables_root: "root".to_string(),
|
||||||
|
resource_root: version.clone(),
|
||||||
|
snapshot_path,
|
||||||
|
staging_path: None,
|
||||||
|
version_path: Some(version.clone()),
|
||||||
|
started_unix_seconds: Some(1),
|
||||||
|
completed_unix_seconds: Some(2),
|
||||||
|
}),
|
||||||
|
..OfficialVersionState::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
fs::write(version.join(OFFICIAL_DOWNLOAD_MANIFEST_FILE), b"{malformed").unwrap();
|
||||||
|
let config = OfficialUpdateConfig {
|
||||||
|
output_root: root,
|
||||||
|
..OfficialUpdateConfig::default()
|
||||||
|
};
|
||||||
|
assert!(verify_and_record_official_distribution_attestation(&config, max_age).is_err());
|
||||||
|
let invalid = crate::official_download::read_official_distribution_attestation_at(&version)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
invalid.verification_generation,
|
||||||
|
initial.verification_generation + 1
|
||||||
|
);
|
||||||
|
assert_eq!(invalid.integrity_status, "invalid");
|
||||||
|
assert!(!invalid.ready);
|
||||||
|
assert!(invalid.verified_at.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn version_state_tracks_in_progress_success_and_failure() {
|
fn version_state_tracks_in_progress_success_and_failure() {
|
||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ pub enum ReleaseFlowStatusCode {
|
|||||||
/// A localized release is published and matches the current official release.
|
/// A localized release is published and matches the current official release.
|
||||||
#[serde(rename = "localized.published")]
|
#[serde(rename = "localized.published")]
|
||||||
LocalizedPublished,
|
LocalizedPublished,
|
||||||
|
/// A localized current release exists but its manifest or published bytes
|
||||||
|
/// fail read-only integrity verification.
|
||||||
|
#[serde(rename = "localized.degraded")]
|
||||||
|
LocalizedDegraded,
|
||||||
/// Distribution cannot serve a usable release for the observed channel.
|
/// Distribution cannot serve a usable release for the observed channel.
|
||||||
#[serde(rename = "distribution.blocked")]
|
#[serde(rename = "distribution.blocked")]
|
||||||
DistributionBlocked,
|
DistributionBlocked,
|
||||||
@@ -113,6 +117,7 @@ impl ReleaseFlowStatusCode {
|
|||||||
Self::LocalizedPending => "localized.pending",
|
Self::LocalizedPending => "localized.pending",
|
||||||
Self::LocalizedStale => "localized.stale",
|
Self::LocalizedStale => "localized.stale",
|
||||||
Self::LocalizedPublished => "localized.published",
|
Self::LocalizedPublished => "localized.published",
|
||||||
|
Self::LocalizedDegraded => "localized.degraded",
|
||||||
Self::DistributionBlocked => "distribution.blocked",
|
Self::DistributionBlocked => "distribution.blocked",
|
||||||
Self::DistributionReady => "distribution.ready",
|
Self::DistributionReady => "distribution.ready",
|
||||||
}
|
}
|
||||||
@@ -146,6 +151,7 @@ impl ReleaseFlowStatusCode {
|
|||||||
Self::LocalizedPending => "pending",
|
Self::LocalizedPending => "pending",
|
||||||
Self::LocalizedStale => "stale",
|
Self::LocalizedStale => "stale",
|
||||||
Self::LocalizedPublished => "published",
|
Self::LocalizedPublished => "published",
|
||||||
|
Self::LocalizedDegraded => "degraded",
|
||||||
Self::DistributionBlocked => "blocked",
|
Self::DistributionBlocked => "blocked",
|
||||||
Self::DistributionReady => "ready",
|
Self::DistributionReady => "ready",
|
||||||
}
|
}
|
||||||
@@ -177,6 +183,7 @@ impl ReleaseFlowStatusCode {
|
|||||||
"localized.pending" => Self::LocalizedPending,
|
"localized.pending" => Self::LocalizedPending,
|
||||||
"localized.stale" => Self::LocalizedStale,
|
"localized.stale" => Self::LocalizedStale,
|
||||||
"localized.published" => Self::LocalizedPublished,
|
"localized.published" => Self::LocalizedPublished,
|
||||||
|
"localized.degraded" => Self::LocalizedDegraded,
|
||||||
"distribution.blocked" => Self::DistributionBlocked,
|
"distribution.blocked" => Self::DistributionBlocked,
|
||||||
"distribution.ready" => Self::DistributionReady,
|
"distribution.ready" => Self::DistributionReady,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
@@ -208,7 +215,8 @@ impl ReleaseFlowStatusCode {
|
|||||||
Self::LocalizedBlockedOfficial
|
Self::LocalizedBlockedOfficial
|
||||||
| Self::LocalizedPending
|
| Self::LocalizedPending
|
||||||
| Self::LocalizedStale
|
| Self::LocalizedStale
|
||||||
| Self::LocalizedPublished => "localized_publish",
|
| Self::LocalizedPublished
|
||||||
|
| Self::LocalizedDegraded => "localized_publish",
|
||||||
Self::DistributionBlocked | Self::DistributionReady => "distribution",
|
Self::DistributionBlocked | Self::DistributionReady => "distribution",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,6 +308,7 @@ mod tests {
|
|||||||
ReleaseFlowStatusCode::LocalizedPending,
|
ReleaseFlowStatusCode::LocalizedPending,
|
||||||
ReleaseFlowStatusCode::LocalizedStale,
|
ReleaseFlowStatusCode::LocalizedStale,
|
||||||
ReleaseFlowStatusCode::LocalizedPublished,
|
ReleaseFlowStatusCode::LocalizedPublished,
|
||||||
|
ReleaseFlowStatusCode::LocalizedDegraded,
|
||||||
ReleaseFlowStatusCode::DistributionBlocked,
|
ReleaseFlowStatusCode::DistributionBlocked,
|
||||||
ReleaseFlowStatusCode::DistributionReady,
|
ReleaseFlowStatusCode::DistributionReady,
|
||||||
];
|
];
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
|||||||
|
//! Shared, deliberately small SQLite schema-migration primitives.
|
||||||
|
//!
|
||||||
|
//! Component owners still define their own schema fingerprints and migration
|
||||||
|
//! steps. This module only owns the read-only preflight, schema snapshot, and
|
||||||
|
//! writer-lock mechanics shared by the long-lived SQLite stores.
|
||||||
|
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||||
|
use sqlx::{Row, SqliteConnection, SqlitePool};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::str::FromStr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub const SCHEMA_MIGRATIONS_TABLE: &str = "schema_migrations";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SqliteColumn {
|
||||||
|
pub data_type: String,
|
||||||
|
pub not_null: bool,
|
||||||
|
pub default_value: Option<String>,
|
||||||
|
pub primary_key: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SqliteSchemaSnapshot {
|
||||||
|
/// Non-internal SQLite objects, including tables, indexes, views, and
|
||||||
|
/// triggers. Internal `sqlite_autoindex_*` objects are omitted.
|
||||||
|
pub objects: BTreeSet<(String, String)>,
|
||||||
|
pub tables: BTreeMap<String, BTreeMap<String, SqliteColumn>>,
|
||||||
|
pub indexes: BTreeMap<String, BTreeMap<String, Vec<String>>>,
|
||||||
|
pub component_version: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteSchemaSnapshot {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.objects.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ExpectedColumn<'a> {
|
||||||
|
pub name: &'a str,
|
||||||
|
pub data_type: &'a str,
|
||||||
|
pub not_null: bool,
|
||||||
|
pub default_value: Option<&'a str>,
|
||||||
|
pub primary_key: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ExpectedTable<'a> {
|
||||||
|
pub name: &'a str,
|
||||||
|
pub columns: &'a [ExpectedColumn<'a>],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ExpectedIndex<'a> {
|
||||||
|
pub table: &'a str,
|
||||||
|
pub name: &'a str,
|
||||||
|
pub columns: &'a [&'a str],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens an existing database with SQLite's read-only flag and snapshots its
|
||||||
|
/// schema before a writable connection can perform any mutation.
|
||||||
|
pub async fn read_only_preflight(
|
||||||
|
path: &Path,
|
||||||
|
component: &str,
|
||||||
|
) -> Result<SqliteSchemaSnapshot, sqlx::Error> {
|
||||||
|
let has_wal_sidecar =
|
||||||
|
sidecar_path(path, "-wal").exists() || sidecar_path(path, "-shm").exists();
|
||||||
|
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))?
|
||||||
|
.read_only(true)
|
||||||
|
.create_if_missing(false)
|
||||||
|
// A cleanly closed WAL database has all committed pages in the main
|
||||||
|
// file. Immutable read-only mode prevents SQLite from creating a new
|
||||||
|
// `-shm` sidecar during future-schema rejection. Live WAL sidecars
|
||||||
|
// must remain visible to the preflight reader.
|
||||||
|
.immutable(!has_wal_sidecar)
|
||||||
|
.busy_timeout(Duration::from_secs(30));
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect_with(options)
|
||||||
|
.await?;
|
||||||
|
let snapshot = {
|
||||||
|
let mut connection = pool.acquire().await?;
|
||||||
|
snapshot_connection(&mut connection, component).await
|
||||||
|
};
|
||||||
|
pool.close().await;
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects a writable single-connection pool, retrying the SQLite-specific
|
||||||
|
/// exclusive lock needed when a connection switches an existing database to
|
||||||
|
/// WAL mode. SQLite's busy timeout cannot wait for that PRAGMA, so the retry
|
||||||
|
/// belongs around connection establishment rather than only around writes.
|
||||||
|
pub async fn connect_writable_pool(
|
||||||
|
options: SqliteConnectOptions,
|
||||||
|
) -> Result<SqlitePool, sqlx::Error> {
|
||||||
|
const MAX_ATTEMPTS: usize = 32;
|
||||||
|
|
||||||
|
for attempt in 0..=MAX_ATTEMPTS {
|
||||||
|
match SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect_with(options.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(pool) => return Ok(pool),
|
||||||
|
Err(error) if attempt < MAX_ATTEMPTS && is_sqlite_lock_error(&error) => {
|
||||||
|
let delay_millis = (25 * (attempt as u64 + 1)).min(250);
|
||||||
|
tokio::time::sleep(Duration::from_millis(delay_millis)).await;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unreachable!("SQLite connection retry loop always returns")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_sqlite_lock_error(error: &sqlx::Error) -> bool {
|
||||||
|
error.to_string().contains("database is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshots the schema using an already-open connection. The caller may use
|
||||||
|
/// this both for read-only preflight and inside the migration transaction.
|
||||||
|
pub async fn snapshot_connection(
|
||||||
|
connection: &mut SqliteConnection,
|
||||||
|
component: &str,
|
||||||
|
) -> Result<SqliteSchemaSnapshot, sqlx::Error> {
|
||||||
|
let object_rows = sqlx::query(
|
||||||
|
"SELECT type, name FROM sqlite_master
|
||||||
|
WHERE name NOT LIKE 'sqlite_%'
|
||||||
|
ORDER BY type, name",
|
||||||
|
)
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut objects = BTreeSet::new();
|
||||||
|
let mut table_names = BTreeSet::new();
|
||||||
|
for row in object_rows {
|
||||||
|
let object_type: String = row.try_get("type")?;
|
||||||
|
let name: String = row.try_get("name")?;
|
||||||
|
if object_type == "table" {
|
||||||
|
table_names.insert(name.clone());
|
||||||
|
}
|
||||||
|
objects.insert((object_type, name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tables = BTreeMap::new();
|
||||||
|
let mut indexes = BTreeMap::new();
|
||||||
|
for table in table_names {
|
||||||
|
let quoted_table = quote_identifier(&table);
|
||||||
|
let column_rows = sqlx::query(&format!("PRAGMA table_info({quoted_table})"))
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await?;
|
||||||
|
let mut columns = BTreeMap::new();
|
||||||
|
for row in column_rows {
|
||||||
|
let name: String = row.try_get("name")?;
|
||||||
|
let data_type: String = row.try_get("type")?;
|
||||||
|
let not_null: i64 = row.try_get("notnull")?;
|
||||||
|
let default_value: Option<String> = row.try_get("dflt_value")?;
|
||||||
|
let primary_key: i64 = row.try_get("pk")?;
|
||||||
|
columns.insert(
|
||||||
|
name,
|
||||||
|
SqliteColumn {
|
||||||
|
data_type,
|
||||||
|
not_null: not_null != 0,
|
||||||
|
default_value,
|
||||||
|
primary_key: primary_key != 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tables.insert(table.clone(), columns);
|
||||||
|
|
||||||
|
let index_rows = sqlx::query(&format!("PRAGMA index_list({quoted_table})"))
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await?;
|
||||||
|
let mut table_indexes = BTreeMap::new();
|
||||||
|
for row in index_rows {
|
||||||
|
let index_name: String = row.try_get("name")?;
|
||||||
|
if index_name.starts_with("sqlite_autoindex_") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let quoted_index = quote_identifier(&index_name);
|
||||||
|
let index_columns = sqlx::query(&format!("PRAGMA index_info({quoted_index})"))
|
||||||
|
.fetch_all(&mut *connection)
|
||||||
|
.await?;
|
||||||
|
let mut columns = Vec::new();
|
||||||
|
for index_column in index_columns {
|
||||||
|
let sequence: i64 = index_column.try_get("seqno")?;
|
||||||
|
let name: Option<String> = index_column.try_get("name")?;
|
||||||
|
if sequence < 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = name.ok_or_else(|| {
|
||||||
|
sqlx::Error::Protocol(format!(
|
||||||
|
"SQLite index {index_name} has an unnamed column"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
columns.push((sequence, name));
|
||||||
|
}
|
||||||
|
columns.sort_by_key(|(sequence, _)| *sequence);
|
||||||
|
table_indexes.insert(
|
||||||
|
index_name,
|
||||||
|
columns
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, name)| name)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !table_indexes.is_empty() {
|
||||||
|
indexes.insert(table, table_indexes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let component_version = if tables.contains_key(SCHEMA_MIGRATIONS_TABLE) {
|
||||||
|
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
|
||||||
|
.bind(component)
|
||||||
|
.fetch_optional(&mut *connection)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(SqliteSchemaSnapshot {
|
||||||
|
objects,
|
||||||
|
tables,
|
||||||
|
indexes,
|
||||||
|
component_version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a real SQLite writer transaction. `BEGIN IMMEDIATE` serializes DDL
|
||||||
|
/// migration writers instead of allowing two preflight results to race.
|
||||||
|
pub async fn begin_immediate(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
) -> Result<sqlx::Transaction<'static, sqlx::Sqlite>, sqlx::Error> {
|
||||||
|
pool.begin_with("BEGIN IMMEDIATE").await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn write_component_version(
|
||||||
|
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
component: &str,
|
||||||
|
version: u32,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO schema_migrations(component, version) VALUES (?1, ?2)
|
||||||
|
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
|
||||||
|
)
|
||||||
|
.bind(component)
|
||||||
|
.bind(i64::from(version))
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_schema_migrations_table(
|
||||||
|
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
component TEXT PRIMARY KEY NOT NULL,
|
||||||
|
version INTEGER NOT NULL CHECK(version >= 1)
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&mut **transaction)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches_fingerprint(
|
||||||
|
snapshot: &SqliteSchemaSnapshot,
|
||||||
|
expected_tables: &[ExpectedTable<'_>],
|
||||||
|
expected_indexes: &[ExpectedIndex<'_>],
|
||||||
|
) -> bool {
|
||||||
|
let expected_table_names = expected_tables
|
||||||
|
.iter()
|
||||||
|
.map(|table| table.name)
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let actual_table_names: BTreeSet<&str> = snapshot.tables.keys().map(String::as_str).collect();
|
||||||
|
if actual_table_names != expected_table_names {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_object_names = expected_tables
|
||||||
|
.iter()
|
||||||
|
.map(|table| ("table", table.name))
|
||||||
|
.chain(expected_indexes.iter().map(|index| ("index", index.name)))
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let actual_object_names = snapshot
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.map(|(object_type, name)| (object_type.as_str(), name.as_str()))
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
if actual_object_names != expected_object_names {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for table in expected_tables {
|
||||||
|
let Some(actual_columns) = snapshot.tables.get(table.name) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if actual_columns.len() != table.columns.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for expected in table.columns {
|
||||||
|
let Some(actual) = actual_columns.get(expected.name) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if actual.data_type.to_ascii_uppercase() != expected.data_type
|
||||||
|
|| actual.not_null != expected.not_null
|
||||||
|
|| actual.primary_key != expected.primary_key
|
||||||
|
|| normalize_default(actual.default_value.as_deref())
|
||||||
|
!= normalize_default(expected.default_value)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_indexes = expected_indexes
|
||||||
|
.iter()
|
||||||
|
.map(|index| {
|
||||||
|
(
|
||||||
|
index.table.to_string(),
|
||||||
|
index.name.to_string(),
|
||||||
|
index
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.map(|column| (*column).to_string())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let actual_indexes = snapshot
|
||||||
|
.indexes
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(table, indexes)| {
|
||||||
|
indexes
|
||||||
|
.iter()
|
||||||
|
.map(|(name, columns)| (table.clone(), name.clone(), columns.clone()))
|
||||||
|
})
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
actual_indexes == expected_indexes
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn schema_migrations_table() -> ExpectedTable<'static> {
|
||||||
|
ExpectedTable {
|
||||||
|
name: SCHEMA_MIGRATIONS_TABLE,
|
||||||
|
columns: &[
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "component",
|
||||||
|
data_type: "TEXT",
|
||||||
|
not_null: true,
|
||||||
|
default_value: None,
|
||||||
|
primary_key: true,
|
||||||
|
},
|
||||||
|
ExpectedColumn {
|
||||||
|
name: "version",
|
||||||
|
data_type: "INTEGER",
|
||||||
|
not_null: true,
|
||||||
|
default_value: None,
|
||||||
|
primary_key: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_default(value: Option<&str>) -> Option<String> {
|
||||||
|
value.map(|value| value.trim().to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote_identifier(value: &str) -> String {
|
||||||
|
format!("\"{}\"", value.replace('"', "\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sidecar_path(path: &Path, suffix: &str) -> std::path::PathBuf {
|
||||||
|
std::path::PathBuf::from(format!("{}{}", path.display(), suffix))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
//! 租约和任务结果写入 release 级 `translation-tasks.sqlite`,跨 release 的
|
//! 租约和任务结果写入 release 级 `translation-tasks.sqlite`,跨 release 的
|
||||||
//! Translation Memory 写入项目级独立 SQLite 数据库。
|
//! Translation Memory 写入项目级独立 SQLite 数据库。
|
||||||
|
|
||||||
|
use crate::glossary::SqliteGlossaryRepository;
|
||||||
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
||||||
use crate::official_textunit_queue::read_textunit_task_queue_at;
|
use crate::official_textunit_queue::read_textunit_task_queue_at;
|
||||||
use crate::translation_memory::{translation_memory_context, SqliteTranslationMemoryRepository};
|
use crate::translation_memory::{translation_memory_context, SqliteTranslationMemoryRepository};
|
||||||
@@ -14,9 +15,10 @@ use crate::translation_tasks::{
|
|||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bat_core::domain::{
|
use bat_core::domain::{
|
||||||
TranslationMemoryDraft, TranslationMemorySourceKind, TranslationMemorySourceTrace,
|
GlossaryConstraint, GlossaryQaReport, TranslationMemoryDraft, TranslationMemorySourceKind,
|
||||||
|
TranslationMemorySourceTrace,
|
||||||
};
|
};
|
||||||
use bat_core::repositories::TranslationMemoryRepository;
|
use bat_core::repositories::{GlossaryRepository, TranslationMemoryRepository};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::env;
|
use std::env;
|
||||||
@@ -98,6 +100,9 @@ pub struct TranslationWorkerConfig {
|
|||||||
/// Translation Memory SQLite path. `None` uses the output-root default.
|
/// Translation Memory SQLite path. `None` uses the output-root default.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub translation_memory_path: Option<PathBuf>,
|
pub translation_memory_path: Option<PathBuf>,
|
||||||
|
/// Project-level Glossary SQLite path. `None` uses the output-root default.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub glossary_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TranslationWorkerConfig {
|
impl Default for TranslationWorkerConfig {
|
||||||
@@ -112,6 +117,7 @@ impl Default for TranslationWorkerConfig {
|
|||||||
max_tasks: None,
|
max_tasks: None,
|
||||||
worker_id: format!("bat-worker-{}", std::process::id()),
|
worker_id: format!("bat-worker-{}", std::process::id()),
|
||||||
translation_memory_path: None,
|
translation_memory_path: None,
|
||||||
|
glossary_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +186,9 @@ pub struct TranslationProviderUnit {
|
|||||||
/// 解析器保留的上下文,包括可选 `crowdin_string_id`。
|
/// 解析器保留的上下文,包括可选 `crowdin_string_id`。
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub context: BTreeMap<String, String>,
|
pub context: BTreeMap<String, String>,
|
||||||
|
/// Approved Glossary constraints for this TextUnit.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub glossary_constraints: Vec<GlossaryConstraint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一次 provider 批处理请求。
|
/// 一次 provider 批处理请求。
|
||||||
@@ -596,6 +605,14 @@ pub struct TranslationWorkerReport {
|
|||||||
pub provider_unit_count: usize,
|
pub provider_unit_count: usize,
|
||||||
/// Translation Memory diagnostics that did not invalidate provider work.
|
/// Translation Memory diagnostics that did not invalidate provider work.
|
||||||
pub translation_memory_failures: Vec<String>,
|
pub translation_memory_failures: Vec<String>,
|
||||||
|
/// Project-level Glossary database path used by this run.
|
||||||
|
pub glossary_path: PathBuf,
|
||||||
|
/// Whether a Glossary database was available.
|
||||||
|
pub glossary_available: bool,
|
||||||
|
/// TextUnits whose Glossary QA blocked automatic reuse or publication.
|
||||||
|
pub glossary_blocked_count: usize,
|
||||||
|
/// Glossary diagnostics that did not abort worker startup.
|
||||||
|
pub glossary_failures: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// worker 失败诊断。
|
/// worker 失败诊断。
|
||||||
@@ -623,6 +640,8 @@ struct WorkerStats {
|
|||||||
provider_unit_count: AtomicUsize,
|
provider_unit_count: AtomicUsize,
|
||||||
failures: Mutex<Vec<TranslationWorkerFailure>>,
|
failures: Mutex<Vec<TranslationWorkerFailure>>,
|
||||||
translation_memory_failures: Mutex<Vec<String>>,
|
translation_memory_failures: Mutex<Vec<String>>,
|
||||||
|
glossary_blocked_count: AtomicUsize,
|
||||||
|
glossary_failures: Mutex<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WorkerTaskContext<'a> {
|
struct WorkerTaskContext<'a> {
|
||||||
@@ -635,6 +654,7 @@ struct WorkerTaskContext<'a> {
|
|||||||
retry_backoff: Duration,
|
retry_backoff: Duration,
|
||||||
stats: &'a WorkerStats,
|
stats: &'a WorkerStats,
|
||||||
translation_memory: Option<&'a dyn TranslationMemoryRepository>,
|
translation_memory: Option<&'a dyn TranslationMemoryRepository>,
|
||||||
|
glossary: Option<&'a dyn GlossaryRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 运行一个 provider worker 轮次。
|
/// 运行一个 provider worker 轮次。
|
||||||
@@ -715,6 +735,28 @@ async fn run_translation_worker_with_provider_and_cancellation(
|
|||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
let glossary_path = config
|
||||||
|
.glossary_path
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
|
||||||
|
let (glossary, glossary_startup_failure) = if std::fs::symlink_metadata(&glossary_path).is_ok()
|
||||||
|
{
|
||||||
|
match SqliteGlossaryRepository::open(&glossary_path).await {
|
||||||
|
Ok(repository) => (Some(Arc::new(repository)), None),
|
||||||
|
Err(error) => (
|
||||||
|
None,
|
||||||
|
Some(format!(
|
||||||
|
"打开 Glossary 数据库失败 {}:{error}",
|
||||||
|
glossary_path.display()
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
|
if let Some(failure) = glossary_startup_failure.as_deref() {
|
||||||
|
return Err(anyhow::anyhow!(failure.to_string()));
|
||||||
|
}
|
||||||
let repository = Arc::new(
|
let repository = Arc::new(
|
||||||
SqliteTranslationTaskRepository::new(SqliteTranslationTaskRepository::repository_path(
|
SqliteTranslationTaskRepository::new(SqliteTranslationTaskRepository::repository_path(
|
||||||
resource_root,
|
resource_root,
|
||||||
@@ -754,6 +796,7 @@ async fn run_translation_worker_with_provider_and_cancellation(
|
|||||||
let lease_seconds = config.lease_seconds;
|
let lease_seconds = config.lease_seconds;
|
||||||
let retry_backoff = config.retry_backoff;
|
let retry_backoff = config.retry_backoff;
|
||||||
let translation_memory = translation_memory.clone();
|
let translation_memory = translation_memory.clone();
|
||||||
|
let glossary = glossary.clone();
|
||||||
let should_cancel = Arc::clone(&should_cancel);
|
let should_cancel = Arc::clone(&should_cancel);
|
||||||
handles.push(tokio::spawn(async move {
|
handles.push(tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -791,6 +834,9 @@ async fn run_translation_worker_with_provider_and_cancellation(
|
|||||||
translation_memory: translation_memory
|
translation_memory: translation_memory
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|repository| repository as &dyn TranslationMemoryRepository),
|
.map(|repository| repository as &dyn TranslationMemoryRepository),
|
||||||
|
glossary: glossary
|
||||||
|
.as_deref()
|
||||||
|
.map(|repository| repository as &dyn GlossaryRepository),
|
||||||
},
|
},
|
||||||
&task,
|
&task,
|
||||||
)
|
)
|
||||||
@@ -842,6 +888,11 @@ async fn run_translation_worker_with_provider_and_cancellation(
|
|||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| anyhow::anyhow!("读取 Translation Memory 诊断时 mutex poisoned"))?
|
.map_err(|_| anyhow::anyhow!("读取 Translation Memory 诊断时 mutex poisoned"))?
|
||||||
.clone();
|
.clone();
|
||||||
|
let glossary_failures = stats
|
||||||
|
.glossary_failures
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("读取 Glossary 诊断时 mutex poisoned"))?
|
||||||
|
.clone();
|
||||||
Ok(TranslationWorkerReport {
|
Ok(TranslationWorkerReport {
|
||||||
command: "translation-worker",
|
command: "translation-worker",
|
||||||
status: if failed_count == 0 {
|
status: if failed_count == 0 {
|
||||||
@@ -863,6 +914,10 @@ async fn run_translation_worker_with_provider_and_cancellation(
|
|||||||
translation_memory_hit_count: stats.translation_memory_hit_count.load(Ordering::Relaxed),
|
translation_memory_hit_count: stats.translation_memory_hit_count.load(Ordering::Relaxed),
|
||||||
provider_unit_count: stats.provider_unit_count.load(Ordering::Relaxed),
|
provider_unit_count: stats.provider_unit_count.load(Ordering::Relaxed),
|
||||||
translation_memory_failures,
|
translation_memory_failures,
|
||||||
|
glossary_path,
|
||||||
|
glossary_available: glossary.is_some(),
|
||||||
|
glossary_blocked_count: stats.glossary_blocked_count.load(Ordering::Relaxed),
|
||||||
|
glossary_failures,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -873,35 +928,100 @@ async fn process_claimed_task(
|
|||||||
let task_units = task_index_units(task, context.index)?;
|
let task_units = task_index_units(task, context.index)?;
|
||||||
let mut results = BTreeMap::new();
|
let mut results = BTreeMap::new();
|
||||||
let mut provider_units = Vec::new();
|
let mut provider_units = Vec::new();
|
||||||
|
let mut glossary_evaluations = BTreeMap::new();
|
||||||
for unit in &task_units {
|
for unit in &task_units {
|
||||||
|
let source_context = translation_memory_context(
|
||||||
|
&unit.destination,
|
||||||
|
unit.archive_entry.as_deref(),
|
||||||
|
unit.serialized_file.as_deref(),
|
||||||
|
unit.path_id,
|
||||||
|
unit.class_id,
|
||||||
|
unit.field_path.as_deref(),
|
||||||
|
unit.format.as_deref(),
|
||||||
|
unit.asset_name.as_deref(),
|
||||||
|
unit.text_source_kind.as_deref(),
|
||||||
|
&unit.context,
|
||||||
|
);
|
||||||
|
let glossary_evaluation = if let Some(glossary) = context.glossary {
|
||||||
|
match glossary.evaluate(&unit.source_text, &source_context).await {
|
||||||
|
Ok(evaluation) => evaluation,
|
||||||
|
Err(error) => {
|
||||||
|
record_glossary_failure(
|
||||||
|
context,
|
||||||
|
format!(
|
||||||
|
"任务 {} TextUnit {} 查询失败:{}",
|
||||||
|
task.task.task_id, unit.id, error
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
record_provider_failure(
|
||||||
|
context,
|
||||||
|
task,
|
||||||
|
TranslationProviderError::new(
|
||||||
|
TranslationProviderFailureClass::InvalidRequest,
|
||||||
|
format!("TextUnit {} 无法完成 Glossary QA;自动翻译已阻止", unit.id),
|
||||||
|
),
|
||||||
|
&results,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bat_core::domain::GlossaryEvaluation {
|
||||||
|
qa_identity: String::new(),
|
||||||
|
constraints: Vec::new(),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
blocked: false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if glossary_evaluation.blocked {
|
||||||
|
context
|
||||||
|
.stats
|
||||||
|
.glossary_blocked_count
|
||||||
|
.fetch_add(1, Ordering::Relaxed);
|
||||||
|
record_provider_failure(
|
||||||
|
context,
|
||||||
|
task,
|
||||||
|
TranslationProviderError::new(
|
||||||
|
TranslationProviderFailureClass::InvalidRequest,
|
||||||
|
format!(
|
||||||
|
"TextUnit {} 的 Glossary 存在未解决冲突,必须人工确认后才能继续",
|
||||||
|
unit.id
|
||||||
|
),
|
||||||
|
),
|
||||||
|
&results,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
glossary_evaluations.insert(unit.id.clone(), glossary_evaluation);
|
||||||
if let Some(translation_memory) = context.translation_memory {
|
if let Some(translation_memory) = context.translation_memory {
|
||||||
let source_context = translation_memory_context(
|
|
||||||
&unit.destination,
|
|
||||||
unit.archive_entry.as_deref(),
|
|
||||||
unit.serialized_file.as_deref(),
|
|
||||||
unit.path_id,
|
|
||||||
unit.class_id,
|
|
||||||
unit.field_path.as_deref(),
|
|
||||||
unit.format.as_deref(),
|
|
||||||
unit.asset_name.as_deref(),
|
|
||||||
unit.text_source_kind.as_deref(),
|
|
||||||
&unit.context,
|
|
||||||
);
|
|
||||||
match translation_memory
|
match translation_memory
|
||||||
.find_matches(&unit.source_text, &source_context, 1)
|
.find_matches(&unit.source_text, &source_context, 1)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(matches) => {
|
Ok(matches) => {
|
||||||
if let Some(found) = matches.into_iter().find(|item| item.can_auto_reuse) {
|
if let Some(found) = matches.into_iter().find(|item| item.can_auto_reuse) {
|
||||||
context
|
let qa = glossary_evaluations
|
||||||
.stats
|
.get(&unit.id)
|
||||||
.translation_memory_hit_count
|
.expect("Glossary evaluation inserted before TM lookup")
|
||||||
.fetch_add(1, Ordering::Relaxed);
|
.check_translation(&found.entry.translated_text);
|
||||||
results.insert(
|
if qa.status.is_blocked() {
|
||||||
unit.id.clone(),
|
context
|
||||||
translation_memory_result(task, unit, &found.entry),
|
.stats
|
||||||
);
|
.glossary_blocked_count
|
||||||
continue;
|
.fetch_add(1, Ordering::Relaxed);
|
||||||
|
} else {
|
||||||
|
context
|
||||||
|
.stats
|
||||||
|
.translation_memory_hit_count
|
||||||
|
.fetch_add(1, Ordering::Relaxed);
|
||||||
|
results.insert(
|
||||||
|
unit.id.clone(),
|
||||||
|
translation_memory_result(task, unit, &found.entry, qa),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -923,7 +1043,7 @@ async fn process_claimed_task(
|
|||||||
.stats
|
.stats
|
||||||
.provider_unit_count
|
.provider_unit_count
|
||||||
.fetch_add(provider_units.len(), Ordering::Relaxed);
|
.fetch_add(provider_units.len(), Ordering::Relaxed);
|
||||||
let request = match provider_request(task, &provider_units) {
|
let request = match provider_request(task, &provider_units, &glossary_evaluations) {
|
||||||
Ok(request) => request,
|
Ok(request) => request,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
record_provider_failure(
|
record_provider_failure(
|
||||||
@@ -941,23 +1061,48 @@ async fn process_claimed_task(
|
|||||||
};
|
};
|
||||||
match context.provider.translate(request.clone()).await {
|
match context.provider.translate(request.clone()).await {
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let provider_results =
|
let provider_results = match validate_provider_response(
|
||||||
match validate_provider_response(&request, response, context.provider_name) {
|
&request,
|
||||||
Ok(results) => results,
|
response,
|
||||||
Err(error) => {
|
context.provider_name,
|
||||||
record_provider_failure(
|
&glossary_evaluations,
|
||||||
context,
|
) {
|
||||||
task,
|
Ok(results) => results,
|
||||||
TranslationProviderError::new(
|
Err(error) => {
|
||||||
TranslationProviderFailureClass::InvalidRequest,
|
record_provider_failure(
|
||||||
error.to_string(),
|
context,
|
||||||
),
|
task,
|
||||||
&results,
|
TranslationProviderError::new(
|
||||||
)
|
TranslationProviderFailureClass::InvalidRequest,
|
||||||
.await?;
|
error.to_string(),
|
||||||
return Ok(());
|
),
|
||||||
}
|
&results,
|
||||||
};
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if provider_results.iter().any(|result| {
|
||||||
|
result
|
||||||
|
.glossary_qa
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|qa| qa.status.is_blocked())
|
||||||
|
}) {
|
||||||
|
for result in &provider_results {
|
||||||
|
results.insert(result.unit_id.clone(), result.clone());
|
||||||
|
}
|
||||||
|
record_provider_failure(
|
||||||
|
context,
|
||||||
|
task,
|
||||||
|
TranslationProviderError::new(
|
||||||
|
TranslationProviderFailureClass::InvalidRequest,
|
||||||
|
"provider 译文未通过 Glossary QA;需要人工 override 后才能发布",
|
||||||
|
),
|
||||||
|
&results,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
for result in &provider_results {
|
for result in &provider_results {
|
||||||
results.insert(result.unit_id.clone(), result.clone());
|
results.insert(result.unit_id.clone(), result.clone());
|
||||||
if let Some(unit) = provider_units.iter().find(|unit| unit.id == result.unit_id)
|
if let Some(unit) = provider_units.iter().find(|unit| unit.id == result.unit_id)
|
||||||
@@ -986,6 +1131,13 @@ async fn process_claimed_task(
|
|||||||
observed_unix_seconds: unix_seconds_now(),
|
observed_unix_seconds: unix_seconds_now(),
|
||||||
};
|
};
|
||||||
if let Some(translation_memory) = context.translation_memory {
|
if let Some(translation_memory) = context.translation_memory {
|
||||||
|
if results
|
||||||
|
.get(&result.unit_id)
|
||||||
|
.and_then(|value| value.glossary_qa.as_ref())
|
||||||
|
.is_some_and(|qa| qa.status.is_blocked())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Err(error) = translation_memory.upsert_candidate(draft).await {
|
if let Err(error) = translation_memory.upsert_candidate(draft).await {
|
||||||
record_translation_memory_failure(
|
record_translation_memory_failure(
|
||||||
context,
|
context,
|
||||||
@@ -1066,6 +1218,7 @@ fn task_index_units<'a>(
|
|||||||
fn provider_request(
|
fn provider_request(
|
||||||
task: &PersistedTranslationTask,
|
task: &PersistedTranslationTask,
|
||||||
index_units: &[&OfficialTextUnitIndexUnit],
|
index_units: &[&OfficialTextUnitIndexUnit],
|
||||||
|
glossary_evaluations: &BTreeMap<String, bat_core::domain::GlossaryEvaluation>,
|
||||||
) -> anyhow::Result<TranslationProviderRequest> {
|
) -> anyhow::Result<TranslationProviderRequest> {
|
||||||
let provider_run_id = task
|
let provider_run_id = task
|
||||||
.provider_run_id
|
.provider_run_id
|
||||||
@@ -1079,7 +1232,16 @@ fn provider_request(
|
|||||||
archive_entry: task.task.archive_entry.clone(),
|
archive_entry: task.task.archive_entry.clone(),
|
||||||
units: index_units
|
units: index_units
|
||||||
.iter()
|
.iter()
|
||||||
.map(|unit| provider_unit(task, unit))
|
.map(|unit| {
|
||||||
|
provider_unit(
|
||||||
|
task,
|
||||||
|
unit,
|
||||||
|
glossary_evaluations
|
||||||
|
.get(&unit.id)
|
||||||
|
.map(|evaluation| evaluation.constraints.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1249,7 @@ fn provider_request(
|
|||||||
fn provider_unit(
|
fn provider_unit(
|
||||||
task: &PersistedTranslationTask,
|
task: &PersistedTranslationTask,
|
||||||
unit: &OfficialTextUnitIndexUnit,
|
unit: &OfficialTextUnitIndexUnit,
|
||||||
|
glossary_constraints: Vec<GlossaryConstraint>,
|
||||||
) -> TranslationProviderUnit {
|
) -> TranslationProviderUnit {
|
||||||
TranslationProviderUnit {
|
TranslationProviderUnit {
|
||||||
unit_id: unit.id.clone(),
|
unit_id: unit.id.clone(),
|
||||||
@@ -1103,6 +1266,7 @@ fn provider_unit(
|
|||||||
text_source_kind: unit.text_source_kind.clone(),
|
text_source_kind: unit.text_source_kind.clone(),
|
||||||
asset_name: unit.asset_name.clone(),
|
asset_name: unit.asset_name.clone(),
|
||||||
context: unit.context.clone(),
|
context: unit.context.clone(),
|
||||||
|
glossary_constraints,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1110,6 +1274,7 @@ fn validate_provider_response(
|
|||||||
request: &TranslationProviderRequest,
|
request: &TranslationProviderRequest,
|
||||||
response: TranslationProviderResponse,
|
response: TranslationProviderResponse,
|
||||||
provider_name: &str,
|
provider_name: &str,
|
||||||
|
glossary_evaluations: &BTreeMap<String, bat_core::domain::GlossaryEvaluation>,
|
||||||
) -> anyhow::Result<Vec<TranslationTaskUnitResult>> {
|
) -> anyhow::Result<Vec<TranslationTaskUnitResult>> {
|
||||||
if response.provider_run_id != request.provider_run_id {
|
if response.provider_run_id != request.provider_run_id {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
@@ -1150,6 +1315,9 @@ fn validate_provider_response(
|
|||||||
result.unit_id
|
result.unit_id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let glossary_qa = glossary_evaluations
|
||||||
|
.get(&result.unit_id)
|
||||||
|
.map(|evaluation| evaluation.check_translation(&result.translated_text));
|
||||||
results.push(TranslationTaskUnitResult {
|
results.push(TranslationTaskUnitResult {
|
||||||
unit_id: result.unit_id,
|
unit_id: result.unit_id,
|
||||||
source_text: result.source_text,
|
source_text: result.source_text,
|
||||||
@@ -1159,6 +1327,8 @@ fn validate_provider_response(
|
|||||||
provider: provider_name.to_string(),
|
provider: provider_name.to_string(),
|
||||||
provider_run_id: request.provider_run_id.clone(),
|
provider_run_id: request.provider_run_id.clone(),
|
||||||
translated_unix_seconds: unix_seconds_now(),
|
translated_unix_seconds: unix_seconds_now(),
|
||||||
|
glossary_qa,
|
||||||
|
glossary_override: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if seen.len() != expected.len() {
|
if seen.len() != expected.len() {
|
||||||
@@ -1175,6 +1345,7 @@ fn translation_memory_result(
|
|||||||
task: &PersistedTranslationTask,
|
task: &PersistedTranslationTask,
|
||||||
unit: &OfficialTextUnitIndexUnit,
|
unit: &OfficialTextUnitIndexUnit,
|
||||||
entry: &bat_core::domain::TranslationMemoryEntry,
|
entry: &bat_core::domain::TranslationMemoryEntry,
|
||||||
|
glossary_qa: GlossaryQaReport,
|
||||||
) -> TranslationTaskUnitResult {
|
) -> TranslationTaskUnitResult {
|
||||||
TranslationTaskUnitResult {
|
TranslationTaskUnitResult {
|
||||||
unit_id: unit.id.clone(),
|
unit_id: unit.id.clone(),
|
||||||
@@ -1185,6 +1356,8 @@ fn translation_memory_result(
|
|||||||
provider: "translation_memory".to_string(),
|
provider: "translation_memory".to_string(),
|
||||||
provider_run_id: task.provider_run_id.clone().unwrap_or_default(),
|
provider_run_id: task.provider_run_id.clone().unwrap_or_default(),
|
||||||
translated_unix_seconds: unix_seconds_now(),
|
translated_unix_seconds: unix_seconds_now(),
|
||||||
|
glossary_qa: Some(glossary_qa),
|
||||||
|
glossary_override: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1222,6 +1395,16 @@ fn record_translation_memory_failure(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_glossary_failure(context: &WorkerTaskContext<'_>, message: String) -> anyhow::Result<()> {
|
||||||
|
context
|
||||||
|
.stats
|
||||||
|
.glossary_failures
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow::anyhow!("写入 Glossary 诊断时 mutex poisoned"))?
|
||||||
|
.push(message);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn record_provider_failure(
|
async fn record_provider_failure(
|
||||||
context: &WorkerTaskContext<'_>,
|
context: &WorkerTaskContext<'_>,
|
||||||
task: &PersistedTranslationTask,
|
task: &PersistedTranslationTask,
|
||||||
@@ -1339,6 +1522,7 @@ mod tests {
|
|||||||
OfficialTextUnitTask, OfficialTextUnitTaskQueue, OfficialTextUnitTaskStatus,
|
OfficialTextUnitTask, OfficialTextUnitTaskQueue, OfficialTextUnitTaskStatus,
|
||||||
OfficialTextUnitTaskSummary, OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION,
|
OfficialTextUnitTaskSummary, OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION,
|
||||||
};
|
};
|
||||||
|
use bat_core::domain::TranslationMemoryMatchKind;
|
||||||
|
|
||||||
fn fixture_root() -> (tempfile::TempDir, OfficialTextUnitTaskQueue) {
|
fn fixture_root() -> (tempfile::TempDir, OfficialTextUnitTaskQueue) {
|
||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
@@ -1455,6 +1639,135 @@ mod tests {
|
|||||||
assert_eq!(task.translation_results[0].translated_text, "translated-0");
|
assert_eq!(task.translation_results[0].translated_text, "translated-0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_sends_approved_glossary_constraints_and_persists_qa() {
|
||||||
|
let (temp, queue) = fixture_root();
|
||||||
|
let textunit_index = index(temp.path());
|
||||||
|
crate::official_textunit_queue::write_textunit_task_queue_at(temp.path(), &queue).unwrap();
|
||||||
|
crate::official_parse::write_textunit_index_at(temp.path(), &textunit_index).unwrap();
|
||||||
|
|
||||||
|
let glossary_path = temp.path().join("glossary.sqlite");
|
||||||
|
let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap();
|
||||||
|
glossary
|
||||||
|
.add(bat_core::domain::GlossaryTermDraft {
|
||||||
|
term_id: "term-source-0".to_string(),
|
||||||
|
definition: bat_core::domain::GlossaryTermSnapshot {
|
||||||
|
source_term: "source-0".to_string(),
|
||||||
|
aliases: Vec::new(),
|
||||||
|
recommended_translation: "term-0".to_string(),
|
||||||
|
allowed_translations: Vec::new(),
|
||||||
|
source_language: Some("en".to_string()),
|
||||||
|
target_language: Some("zh-Hans".to_string()),
|
||||||
|
category: Some("test".to_string()),
|
||||||
|
priority: 10,
|
||||||
|
scope: BTreeMap::new(),
|
||||||
|
},
|
||||||
|
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
|
||||||
|
source: bat_core::domain::GlossarySourceRecord {
|
||||||
|
source_kind: bat_core::domain::GlossarySourceKind::Manual,
|
||||||
|
source_ref: Some("worker-test".to_string()),
|
||||||
|
source_author: Some("test".to_string()),
|
||||||
|
source_note: None,
|
||||||
|
observed_unix_seconds: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
glossary
|
||||||
|
.review(
|
||||||
|
"term-source-0",
|
||||||
|
bat_core::domain::GlossaryReviewStatus::Approved,
|
||||||
|
"reviewer",
|
||||||
|
Some("test approval".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct GlossaryProvider {
|
||||||
|
requests: Arc<Mutex<Vec<TranslationProviderRequest>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl TranslationProvider for GlossaryProvider {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"glossary-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn translate(
|
||||||
|
&self,
|
||||||
|
request: TranslationProviderRequest,
|
||||||
|
) -> Result<TranslationProviderResponse, TranslationProviderError> {
|
||||||
|
self.requests.lock().unwrap().push(request.clone());
|
||||||
|
Ok(TranslationProviderResponse {
|
||||||
|
provider_run_id: request.provider_run_id,
|
||||||
|
units: request
|
||||||
|
.units
|
||||||
|
.into_iter()
|
||||||
|
.map(|unit| TranslationProviderUnitResult {
|
||||||
|
unit_id: unit.unit_id,
|
||||||
|
source_text: unit.source_text.clone(),
|
||||||
|
translated_text: if unit.source_text == "source-0" {
|
||||||
|
"term-0".to_string()
|
||||||
|
} else {
|
||||||
|
"translated-1".to_string()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let config = TranslationWorkerConfig {
|
||||||
|
glossary_path: Some(glossary_path),
|
||||||
|
concurrency: 1,
|
||||||
|
retry_backoff: Duration::ZERO,
|
||||||
|
..TranslationWorkerConfig::default()
|
||||||
|
};
|
||||||
|
let report = run_translation_worker_with_provider(
|
||||||
|
temp.path(),
|
||||||
|
&config,
|
||||||
|
Arc::new(GlossaryProvider {
|
||||||
|
requests: Arc::clone(&requests),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(report.completed_count, 1);
|
||||||
|
assert!(report.glossary_available);
|
||||||
|
assert_eq!(report.glossary_blocked_count, 0);
|
||||||
|
{
|
||||||
|
let requests = requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert_eq!(requests[0].units[0].glossary_constraints.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].units[0].glossary_constraints[0].term_id,
|
||||||
|
"term-source-0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let repository = SqliteTranslationTaskRepository::open(
|
||||||
|
SqliteTranslationTaskRepository::repository_path(temp.path()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let task = repository.find(&queue.tasks[0].task_id).await.unwrap();
|
||||||
|
assert_eq!(task.translation_results[0].translated_text, "term-0");
|
||||||
|
assert_eq!(
|
||||||
|
task.translation_results[0]
|
||||||
|
.glossary_qa
|
||||||
|
.as_ref()
|
||||||
|
.map(|qa| qa.status),
|
||||||
|
Some(bat_core::domain::GlossaryQaStatus::Pass)
|
||||||
|
);
|
||||||
|
assert!(task.translation_results[0]
|
||||||
|
.glossary_qa
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|qa| !qa.qa_identity.is_empty()));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_honors_cancellation_before_claiming_tasks() {
|
async fn worker_honors_cancellation_before_claiming_tasks() {
|
||||||
let (temp, queue) = fixture_root();
|
let (temp, queue) = fixture_root();
|
||||||
@@ -1605,6 +1918,137 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_does_not_reuse_a_trusted_conflict() {
|
||||||
|
let (temp, queue) = fixture_root();
|
||||||
|
let textunit_index = index(temp.path());
|
||||||
|
crate::official_textunit_queue::write_textunit_task_queue_at(temp.path(), &queue).unwrap();
|
||||||
|
crate::official_parse::write_textunit_index_at(temp.path(), &textunit_index).unwrap();
|
||||||
|
|
||||||
|
let translation_memory_path = temp.path().join("translation-memory.sqlite");
|
||||||
|
let translation_memory = SqliteTranslationMemoryRepository::new(&translation_memory_path)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let unit = &textunit_index.units[0];
|
||||||
|
let source_context = translation_memory_context(
|
||||||
|
&unit.destination,
|
||||||
|
unit.archive_entry.as_deref(),
|
||||||
|
unit.serialized_file.as_deref(),
|
||||||
|
unit.path_id,
|
||||||
|
unit.class_id,
|
||||||
|
unit.field_path.as_deref(),
|
||||||
|
unit.format.as_deref(),
|
||||||
|
unit.asset_name.as_deref(),
|
||||||
|
unit.text_source_kind.as_deref(),
|
||||||
|
&unit.context,
|
||||||
|
);
|
||||||
|
let mut trusted_ids = Vec::new();
|
||||||
|
for (release, translated) in [("release-1", "旧译文一"), ("release-2", "旧译文二")]
|
||||||
|
{
|
||||||
|
let entry = translation_memory
|
||||||
|
.upsert_candidate(TranslationMemoryDraft {
|
||||||
|
source_text: unit.source_text.clone(),
|
||||||
|
source_context: source_context.clone(),
|
||||||
|
translated_text: translated.to_string(),
|
||||||
|
translation_source_kind: TranslationMemorySourceKind::Manual,
|
||||||
|
official_release_id: release.to_string(),
|
||||||
|
source_trace: TranslationMemorySourceTrace {
|
||||||
|
official_release_id: release.to_string(),
|
||||||
|
unit_id: Some(unit.id.clone()),
|
||||||
|
task_id: Some(queue.tasks[0].task_id.clone()),
|
||||||
|
destination: Some(unit.destination.clone()),
|
||||||
|
archive_entry: unit.archive_entry.clone(),
|
||||||
|
serialized_file: unit.serialized_file.clone(),
|
||||||
|
path_id: unit.path_id,
|
||||||
|
class_id: unit.class_id,
|
||||||
|
field_path: unit.field_path.clone(),
|
||||||
|
format: unit.format.clone(),
|
||||||
|
asset_name: unit.asset_name.clone(),
|
||||||
|
text_source_kind: unit.text_source_kind.clone(),
|
||||||
|
source_url: Some(unit.source_url.clone()),
|
||||||
|
},
|
||||||
|
provider: None,
|
||||||
|
provider_run_id: None,
|
||||||
|
observed_unix_seconds: 1,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE translation_memory
|
||||||
|
SET trust_status = 'trusted', trusted_unix_seconds = 100,
|
||||||
|
trusted_by = 'legacy-reviewer', trusted_reason = 'legacy fixture'
|
||||||
|
WHERE record_id = ?1",
|
||||||
|
)
|
||||||
|
.bind(&entry.record_id)
|
||||||
|
.execute(&translation_memory.pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
trusted_ids.push(entry.record_id);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
translation_memory
|
||||||
|
.find_matches(&unit.source_text, &source_context, 10)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.match_kind == TranslationMemoryMatchKind::TrustedConflict)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
|
||||||
|
let fixture = temp.path().join("conflict-mock.json");
|
||||||
|
std::fs::write(
|
||||||
|
&fixture,
|
||||||
|
serde_json::to_vec(&serde_json::json!({
|
||||||
|
"schema_version": 1,
|
||||||
|
"translations": {
|
||||||
|
"direct:bundle#unit:0": "provider-after-conflict",
|
||||||
|
"direct:bundle#unit:1": "translated-by-provider"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = TranslationWorkerConfig {
|
||||||
|
fixture_path: Some(fixture),
|
||||||
|
translation_memory_path: Some(translation_memory_path),
|
||||||
|
concurrency: 1,
|
||||||
|
retry_backoff: Duration::ZERO,
|
||||||
|
..TranslationWorkerConfig::default()
|
||||||
|
};
|
||||||
|
let report = run_translation_worker_at(temp.path(), &config)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(report.translation_memory_hit_count, 0);
|
||||||
|
assert_eq!(report.provider_unit_count, 2);
|
||||||
|
assert_eq!(report.completed_count, 1);
|
||||||
|
|
||||||
|
let task_repository = SqliteTranslationTaskRepository::open(
|
||||||
|
SqliteTranslationTaskRepository::repository_path(temp.path()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let task = task_repository.find(&queue.tasks[0].task_id).await.unwrap();
|
||||||
|
assert_eq!(task.translation_results.len(), 2);
|
||||||
|
assert!(task
|
||||||
|
.translation_results
|
||||||
|
.iter()
|
||||||
|
.all(|result| result.source_kind == TranslationTaskResultSourceKind::Provider));
|
||||||
|
assert!(task.translation_results[0]
|
||||||
|
.translation_memory_record_id
|
||||||
|
.is_none());
|
||||||
|
for record_id in trusted_ids {
|
||||||
|
assert_eq!(
|
||||||
|
translation_memory
|
||||||
|
.find(&record_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.trust_status,
|
||||||
|
bat_core::domain::TranslationMemoryTrustStatus::Trusted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn worker_retains_tm_hits_when_provider_fails_for_remaining_units() {
|
async fn worker_retains_tm_hits_when_provider_fails_for_remaining_units() {
|
||||||
let (temp, queue) = fixture_root();
|
let (temp, queue) = fixture_root();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! Manual translation workbench and controlled UnityFS repack workflows.
|
//! Manual translation workbench and controlled UnityFS repack workflows.
|
||||||
|
|
||||||
|
use crate::glossary::SqliteGlossaryRepository;
|
||||||
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
||||||
use crate::official_textunit_queue::OfficialTextUnitTaskQuery;
|
use crate::official_textunit_queue::OfficialTextUnitTaskQuery;
|
||||||
use crate::path_security::{
|
use crate::path_security::{
|
||||||
@@ -15,6 +16,10 @@ use bat_assetbundle::{
|
|||||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
||||||
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
|
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
|
||||||
};
|
};
|
||||||
|
use bat_core::domain::{
|
||||||
|
validate_glossary_override as validate_core_glossary_override, GlossaryOverride,
|
||||||
|
GlossaryQaReport,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -90,6 +95,12 @@ pub struct TranslationWorkbenchEntry {
|
|||||||
/// Extraction source kind such as TextAsset or TypeTreeField.
|
/// Extraction source kind such as TextAsset or TypeTreeField.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub text_source_kind: Option<String>,
|
pub text_source_kind: Option<String>,
|
||||||
|
/// Deterministic Glossary QA for the current translation.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub glossary_qa: Option<GlossaryQaReport>,
|
||||||
|
/// Explicit human confirmation for a blocking Glossary deviation.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub glossary_override: Option<GlossaryOverride>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Summary produced by `i18n validate`.
|
/// Summary produced by `i18n validate`.
|
||||||
@@ -274,6 +285,72 @@ pub fn set_translation(
|
|||||||
.find(|entry| entry.id == entry_id)
|
.find(|entry| entry.id == entry_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
||||||
entry.translated_text = Some(translated_text);
|
entry.translated_text = Some(translated_text);
|
||||||
|
entry.glossary_qa = None;
|
||||||
|
entry.glossary_override = None;
|
||||||
|
let updated = entry.clone();
|
||||||
|
workbench.generated_unix_seconds = unix_seconds_now();
|
||||||
|
write_translation_workbench(workbench_path, &workbench)?;
|
||||||
|
Ok(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates one translation and evaluates the project Glossary.
|
||||||
|
pub fn set_translation_checked(
|
||||||
|
resource_root: &Path,
|
||||||
|
workbench_path: &Path,
|
||||||
|
entry_id: &str,
|
||||||
|
translated_text: String,
|
||||||
|
glossary_override: Option<GlossaryOverride>,
|
||||||
|
) -> anyhow::Result<TranslationWorkbenchEntry> {
|
||||||
|
set_translation_checked_with_glossary_path(
|
||||||
|
resource_root,
|
||||||
|
workbench_path,
|
||||||
|
entry_id,
|
||||||
|
translated_text,
|
||||||
|
glossary_override,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates one translation using an optional configured Glossary path.
|
||||||
|
pub fn set_translation_checked_with_glossary_path(
|
||||||
|
resource_root: &Path,
|
||||||
|
workbench_path: &Path,
|
||||||
|
entry_id: &str,
|
||||||
|
translated_text: String,
|
||||||
|
glossary_override: Option<GlossaryOverride>,
|
||||||
|
configured_glossary_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<TranslationWorkbenchEntry> {
|
||||||
|
let mut workbench = read_translation_workbench(workbench_path)?;
|
||||||
|
let current = read_textunit_index_at(resource_root)
|
||||||
|
.map_err(anyhow::Error::msg)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?
|
||||||
|
.units
|
||||||
|
.into_iter()
|
||||||
|
.find(|unit| unit.id == entry_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("当前 release 不存在 TextUnit:{entry_id}"))?;
|
||||||
|
let glossary_path = configured_glossary_path
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
|
||||||
|
let glossary = open_glossary_if_present(&glossary_path)?;
|
||||||
|
let qa = glossary
|
||||||
|
.as_ref()
|
||||||
|
.map(|glossary| evaluate_glossary_entry(glossary, ¤t, &translated_text))
|
||||||
|
.transpose()?;
|
||||||
|
if let Some(qa) = qa.as_ref() {
|
||||||
|
validate_current_glossary_qa(qa, glossary_override.as_ref())?;
|
||||||
|
} else if glossary_override.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Glossary override 只能用于存在 blocking QA 的译文"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let entry = workbench
|
||||||
|
.entries
|
||||||
|
.iter_mut()
|
||||||
|
.find(|entry| entry.id == entry_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
||||||
|
entry.translated_text = Some(translated_text);
|
||||||
|
entry.glossary_qa = qa;
|
||||||
|
entry.glossary_override = glossary_override;
|
||||||
let updated = entry.clone();
|
let updated = entry.clone();
|
||||||
workbench.generated_unix_seconds = unix_seconds_now();
|
workbench.generated_unix_seconds = unix_seconds_now();
|
||||||
write_translation_workbench(workbench_path, &workbench)?;
|
write_translation_workbench(workbench_path, &workbench)?;
|
||||||
@@ -305,6 +382,8 @@ pub fn unset_translation(
|
|||||||
.find(|entry| entry.id == entry_id)
|
.find(|entry| entry.id == entry_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
||||||
entry.translated_text = None;
|
entry.translated_text = None;
|
||||||
|
entry.glossary_qa = None;
|
||||||
|
entry.glossary_override = None;
|
||||||
let updated = entry.clone();
|
let updated = entry.clone();
|
||||||
workbench.generated_unix_seconds = unix_seconds_now();
|
workbench.generated_unix_seconds = unix_seconds_now();
|
||||||
write_translation_workbench(workbench_path, &workbench)?;
|
write_translation_workbench(workbench_path, &workbench)?;
|
||||||
@@ -320,6 +399,21 @@ pub fn validate_translation_workbench(
|
|||||||
resource_root: &Path,
|
resource_root: &Path,
|
||||||
official_release_id: &str,
|
official_release_id: &str,
|
||||||
workbench: &TranslationWorkbench,
|
workbench: &TranslationWorkbench,
|
||||||
|
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
|
||||||
|
validate_translation_workbench_with_glossary_path(
|
||||||
|
resource_root,
|
||||||
|
official_release_id,
|
||||||
|
workbench,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a workbench using an optional configured Glossary path.
|
||||||
|
pub fn validate_translation_workbench_with_glossary_path(
|
||||||
|
resource_root: &Path,
|
||||||
|
official_release_id: &str,
|
||||||
|
workbench: &TranslationWorkbench,
|
||||||
|
configured_glossary_path: Option<&Path>,
|
||||||
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
|
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
|
||||||
let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?;
|
let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?;
|
||||||
if workbench.official_release_id != official_release_id {
|
if workbench.official_release_id != official_release_id {
|
||||||
@@ -349,6 +443,10 @@ pub fn validate_translation_workbench(
|
|||||||
let mut changed_entries = 0;
|
let mut changed_entries = 0;
|
||||||
let mut publishable_entries = 0;
|
let mut publishable_entries = 0;
|
||||||
let mut repack_entries = 0;
|
let mut repack_entries = 0;
|
||||||
|
let glossary_path = configured_glossary_path
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
|
||||||
|
let glossary = open_glossary_if_present(&glossary_path)?;
|
||||||
|
|
||||||
for entry in &workbench.entries {
|
for entry in &workbench.entries {
|
||||||
if !seen_ids.insert(entry.id.as_str()) {
|
if !seen_ids.insert(entry.id.as_str()) {
|
||||||
@@ -366,13 +464,21 @@ pub fn validate_translation_workbench(
|
|||||||
unchanged_entries += 1;
|
unchanged_entries += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if let Some(glossary) = glossary.as_ref() {
|
||||||
|
let qa = evaluate_glossary_entry(glossary, current, translated_text)?;
|
||||||
|
validate_current_glossary_qa(&qa, entry.glossary_override.as_ref())?;
|
||||||
|
} else if entry.glossary_override.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 存在 Glossary override,但当前没有 Glossary 数据库",
|
||||||
|
entry.id
|
||||||
|
));
|
||||||
|
}
|
||||||
changed_entries += 1;
|
changed_entries += 1;
|
||||||
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
||||||
let is_publishable = entry.archive_entry.is_none()
|
let is_publishable = matches!(
|
||||||
&& matches!(
|
source_kind.as_deref(),
|
||||||
source_kind.as_deref(),
|
Some("textasset" | "typetreefield" | "managedreferencefield")
|
||||||
Some("textasset" | "typetreefield" | "managedreferencefield")
|
);
|
||||||
);
|
|
||||||
if is_publishable {
|
if is_publishable {
|
||||||
let serialized_file = entry
|
let serialized_file = entry
|
||||||
.serialized_file
|
.serialized_file
|
||||||
@@ -419,15 +525,81 @@ pub fn validate_translation_workbench(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_glossary_if_present(path: &Path) -> anyhow::Result<Option<SqliteGlossaryRepository>> {
|
||||||
|
if std::fs::symlink_metadata(path).is_err() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
runtime
|
||||||
|
.block_on(SqliteGlossaryRepository::open(path))
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_glossary_entry(
|
||||||
|
glossary: &SqliteGlossaryRepository,
|
||||||
|
unit: &OfficialTextUnitIndexUnit,
|
||||||
|
translated_text: &str,
|
||||||
|
) -> anyhow::Result<GlossaryQaReport> {
|
||||||
|
let context = crate::translation_memory::translation_memory_context(
|
||||||
|
&unit.destination,
|
||||||
|
unit.archive_entry.as_deref(),
|
||||||
|
unit.serialized_file.as_deref(),
|
||||||
|
unit.path_id,
|
||||||
|
unit.class_id,
|
||||||
|
unit.field_path.as_deref(),
|
||||||
|
unit.format.as_deref(),
|
||||||
|
unit.asset_name.as_deref(),
|
||||||
|
unit.text_source_kind.as_deref(),
|
||||||
|
&unit.context,
|
||||||
|
);
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
runtime
|
||||||
|
.block_on(glossary.diagnose(&unit.source_text, &context))
|
||||||
|
.map(|evaluation| evaluation.check_translation(translated_text))
|
||||||
|
.map_err(|error| anyhow::anyhow!("执行 Glossary QA 失败:{error}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_current_glossary_qa(
|
||||||
|
qa: &GlossaryQaReport,
|
||||||
|
glossary_override: Option<&GlossaryOverride>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if qa.status.is_blocked() {
|
||||||
|
validate_core_glossary_override(qa, glossary_override)
|
||||||
|
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
} else if glossary_override.is_some() {
|
||||||
|
return Err(anyhow::anyhow!("Glossary override 只能用于 blocking QA"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts reviewed entries to localized patch operations supported by the
|
/// Converts reviewed entries to localized patch operations supported by the
|
||||||
/// current UnityFS write layer.
|
/// current UnityFS write layer and its ZIP rewrite boundary.
|
||||||
///
|
|
||||||
/// ZIP-inner bundles are intentionally rejected here because they require a
|
|
||||||
/// separate archive rewrite boundary.
|
|
||||||
pub fn localized_patch_operations(
|
pub fn localized_patch_operations(
|
||||||
resource_root: &Path,
|
resource_root: &Path,
|
||||||
workbench: &TranslationWorkbench,
|
workbench: &TranslationWorkbench,
|
||||||
) -> anyhow::Result<Vec<LocalizedPatchInput>> {
|
) -> anyhow::Result<Vec<LocalizedPatchInput>> {
|
||||||
|
localized_patch_operations_with_glossary_path(resource_root, workbench, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts reviewed entries to patch operations using QA recomputed from the
|
||||||
|
/// current Glossary. The recomputed report is the one persisted to the
|
||||||
|
/// localized release manifest.
|
||||||
|
pub fn localized_patch_operations_with_glossary_path(
|
||||||
|
resource_root: &Path,
|
||||||
|
workbench: &TranslationWorkbench,
|
||||||
|
configured_glossary_path: Option<&Path>,
|
||||||
|
) -> anyhow::Result<Vec<LocalizedPatchInput>> {
|
||||||
|
validate_translation_workbench_with_glossary_path(
|
||||||
|
resource_root,
|
||||||
|
&workbench.official_release_id,
|
||||||
|
workbench,
|
||||||
|
configured_glossary_path,
|
||||||
|
)?;
|
||||||
let index = read_textunit_index_at(resource_root)
|
let index = read_textunit_index_at(resource_root)
|
||||||
.map_err(anyhow::Error::msg)?
|
.map_err(anyhow::Error::msg)?
|
||||||
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
|
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
|
||||||
@@ -436,6 +608,10 @@ pub fn localized_patch_operations(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|unit| (unit.id.as_str(), unit))
|
.map(|unit| (unit.id.as_str(), unit))
|
||||||
.collect::<std::collections::HashMap<_, _>>();
|
.collect::<std::collections::HashMap<_, _>>();
|
||||||
|
let glossary_path = configured_glossary_path
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
|
||||||
|
let glossary = open_glossary_if_present(&glossary_path)?;
|
||||||
let mut seen = BTreeSet::new();
|
let mut seen = BTreeSet::new();
|
||||||
let mut operations = Vec::new();
|
let mut operations = Vec::new();
|
||||||
|
|
||||||
@@ -450,6 +626,18 @@ pub fn localized_patch_operations(
|
|||||||
if translated_text == &entry.source_text {
|
if translated_text == &entry.source_text {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let current_qa = glossary
|
||||||
|
.as_ref()
|
||||||
|
.map(|glossary| evaluate_glossary_entry(glossary, current, translated_text))
|
||||||
|
.transpose()?;
|
||||||
|
if let Some(qa) = current_qa.as_ref() {
|
||||||
|
validate_current_glossary_qa(qa, entry.glossary_override.as_ref())?;
|
||||||
|
} else if entry.glossary_override.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 存在 Glossary override,但当前没有 Glossary 数据库",
|
||||||
|
entry.id
|
||||||
|
));
|
||||||
|
}
|
||||||
let Some(serialized_file) = entry.serialized_file.clone() else {
|
let Some(serialized_file) = entry.serialized_file.clone() else {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"TextUnit {} 没有 serialized_file,当前不能生成重打包 patch",
|
"TextUnit {} 没有 serialized_file,当前不能生成重打包 patch",
|
||||||
@@ -462,16 +650,11 @@ pub fn localized_patch_operations(
|
|||||||
entry.id
|
entry.id
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
if entry.archive_entry.is_some() {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle",
|
|
||||||
entry.id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
||||||
let field_path = entry.field_path.clone();
|
let field_path = entry.field_path.clone();
|
||||||
if !seen.insert((
|
if !seen.insert((
|
||||||
entry.destination.clone(),
|
entry.destination.clone(),
|
||||||
|
entry.archive_entry.clone(),
|
||||||
serialized_file.clone(),
|
serialized_file.clone(),
|
||||||
path_id,
|
path_id,
|
||||||
field_path.clone(),
|
field_path.clone(),
|
||||||
@@ -481,7 +664,11 @@ pub fn localized_patch_operations(
|
|||||||
entry.id
|
entry.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let metadata = Some(localized_patch_metadata(entry));
|
let metadata = Some(localized_patch_metadata(
|
||||||
|
entry,
|
||||||
|
current_qa.as_ref(),
|
||||||
|
entry.glossary_override.as_ref(),
|
||||||
|
));
|
||||||
match source_kind.as_deref() {
|
match source_kind.as_deref() {
|
||||||
Some("textasset") => {
|
Some("textasset") => {
|
||||||
let mut patch = TextAssetPatch::new(
|
let mut patch = TextAssetPatch::new(
|
||||||
@@ -492,6 +679,7 @@ pub fn localized_patch_operations(
|
|||||||
patch.expected_name = entry.asset_name.clone();
|
patch.expected_name = entry.asset_name.clone();
|
||||||
operations.push(LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch {
|
operations.push(LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch {
|
||||||
bundle_path: entry.destination.clone(),
|
bundle_path: entry.destination.clone(),
|
||||||
|
archive_entry: entry.archive_entry.clone(),
|
||||||
text_asset: patch,
|
text_asset: patch,
|
||||||
metadata,
|
metadata,
|
||||||
}));
|
}));
|
||||||
@@ -506,6 +694,7 @@ pub fn localized_patch_operations(
|
|||||||
operations.push(LocalizedPatchInput::StringField(
|
operations.push(LocalizedPatchInput::StringField(
|
||||||
LocalizedStringFieldPatch {
|
LocalizedStringFieldPatch {
|
||||||
bundle_path: entry.destination.clone(),
|
bundle_path: entry.destination.clone(),
|
||||||
|
archive_entry: entry.archive_entry.clone(),
|
||||||
string_field: StringFieldPatch {
|
string_field: StringFieldPatch {
|
||||||
serialized_file_path: serialized_file,
|
serialized_file_path: serialized_file,
|
||||||
path_id,
|
path_id,
|
||||||
@@ -547,6 +736,8 @@ pub fn localized_text_asset_patches(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|unit| (unit.id.as_str(), unit))
|
.map(|unit| (unit.id.as_str(), unit))
|
||||||
.collect::<std::collections::HashMap<_, _>>();
|
.collect::<std::collections::HashMap<_, _>>();
|
||||||
|
let glossary =
|
||||||
|
open_glossary_if_present(&SqliteGlossaryRepository::repository_path(resource_root))?;
|
||||||
let mut seen = BTreeSet::new();
|
let mut seen = BTreeSet::new();
|
||||||
let mut patches = Vec::new();
|
let mut patches = Vec::new();
|
||||||
|
|
||||||
@@ -573,12 +764,6 @@ pub fn localized_text_asset_patches(
|
|||||||
entry.id
|
entry.id
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
if entry.archive_entry.is_some() {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle",
|
|
||||||
entry.id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if normalized_text_source_kind(entry.text_source_kind.as_deref()).as_deref()
|
if normalized_text_source_kind(entry.text_source_kind.as_deref()).as_deref()
|
||||||
!= Some("textasset")
|
!= Some("textasset")
|
||||||
{
|
{
|
||||||
@@ -587,7 +772,12 @@ pub fn localized_text_asset_patches(
|
|||||||
entry.id
|
entry.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if !seen.insert((entry.destination.clone(), serialized_file.clone(), path_id)) {
|
if !seen.insert((
|
||||||
|
entry.destination.clone(),
|
||||||
|
entry.archive_entry.clone(),
|
||||||
|
serialized_file.clone(),
|
||||||
|
path_id,
|
||||||
|
)) {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"翻译工作台包含重复 patch 目标:{}",
|
"翻译工作台包含重复 patch 目标:{}",
|
||||||
entry.id
|
entry.id
|
||||||
@@ -599,10 +789,27 @@ pub fn localized_text_asset_patches(
|
|||||||
translated_text.as_bytes().to_vec(),
|
translated_text.as_bytes().to_vec(),
|
||||||
);
|
);
|
||||||
patch.expected_name = entry.asset_name.clone();
|
patch.expected_name = entry.asset_name.clone();
|
||||||
|
let current_qa = glossary
|
||||||
|
.as_ref()
|
||||||
|
.map(|glossary| evaluate_glossary_entry(glossary, current, translated_text))
|
||||||
|
.transpose()?;
|
||||||
|
if let Some(qa) = current_qa.as_ref() {
|
||||||
|
validate_current_glossary_qa(qa, entry.glossary_override.as_ref())?;
|
||||||
|
} else if entry.glossary_override.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 存在 Glossary override,但当前没有 Glossary 数据库",
|
||||||
|
entry.id
|
||||||
|
));
|
||||||
|
}
|
||||||
patches.push(LocalizedTextAssetPatch {
|
patches.push(LocalizedTextAssetPatch {
|
||||||
bundle_path: entry.destination.clone(),
|
bundle_path: entry.destination.clone(),
|
||||||
|
archive_entry: entry.archive_entry.clone(),
|
||||||
text_asset: patch,
|
text_asset: patch,
|
||||||
metadata: Some(localized_patch_metadata(entry)),
|
metadata: Some(localized_patch_metadata(
|
||||||
|
entry,
|
||||||
|
current_qa.as_ref(),
|
||||||
|
entry.glossary_override.as_ref(),
|
||||||
|
)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,7 +821,11 @@ pub fn localized_text_asset_patches(
|
|||||||
Ok(patches)
|
Ok(patches)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatchOperationMetadata {
|
fn localized_patch_metadata(
|
||||||
|
entry: &TranslationWorkbenchEntry,
|
||||||
|
glossary_qa: Option<&GlossaryQaReport>,
|
||||||
|
glossary_override: Option<&GlossaryOverride>,
|
||||||
|
) -> LocalizedPatchOperationMetadata {
|
||||||
LocalizedPatchOperationMetadata {
|
LocalizedPatchOperationMetadata {
|
||||||
text_unit_id: entry.id.clone(),
|
text_unit_id: entry.id.clone(),
|
||||||
source_text_blake3: blake3::hash(entry.source_text.as_bytes())
|
source_text_blake3: blake3::hash(entry.source_text.as_bytes())
|
||||||
@@ -628,6 +839,8 @@ fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatch
|
|||||||
.review_status
|
.review_status
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "manual_reviewed".to_string()),
|
.unwrap_or_else(|| "manual_reviewed".to_string()),
|
||||||
|
glossary_qa: glossary_qa.cloned(),
|
||||||
|
glossary_override: glossary_override.cloned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,6 +1124,8 @@ fn workbench_entry_from_worker_result(
|
|||||||
entry.translation_source_kind = Some(result.source_kind.as_str().to_string());
|
entry.translation_source_kind = Some(result.source_kind.as_str().to_string());
|
||||||
entry.translation_memory_record_id = result.translation_memory_record_id.clone();
|
entry.translation_memory_record_id = result.translation_memory_record_id.clone();
|
||||||
entry.translated_unix_seconds = Some(result.translated_unix_seconds);
|
entry.translated_unix_seconds = Some(result.translated_unix_seconds);
|
||||||
|
entry.glossary_qa = result.glossary_qa.clone();
|
||||||
|
entry.glossary_override = result.glossary_override.clone();
|
||||||
entry.review_status = Some(
|
entry.review_status = Some(
|
||||||
match result.source_kind {
|
match result.source_kind {
|
||||||
TranslationTaskResultSourceKind::Provider => "provider_completed",
|
TranslationTaskResultSourceKind::Provider => "provider_completed",
|
||||||
@@ -964,6 +1179,8 @@ impl TranslationWorkbenchEntry {
|
|||||||
review_status: None,
|
review_status: None,
|
||||||
format: unit.format.clone(),
|
format: unit.format.clone(),
|
||||||
text_source_kind: unit.text_source_kind.clone(),
|
text_source_kind: unit.text_source_kind.clone(),
|
||||||
|
glossary_qa: None,
|
||||||
|
glossary_override: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1003,6 +1220,8 @@ mod tests {
|
|||||||
review_status: None,
|
review_status: None,
|
||||||
format: Some("plain".to_string()),
|
format: Some("plain".to_string()),
|
||||||
text_source_kind: Some("text_asset".to_string()),
|
text_source_kind: Some("text_asset".to_string()),
|
||||||
|
glossary_qa: None,
|
||||||
|
glossary_override: None,
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1084,6 +1303,172 @@ mod tests {
|
|||||||
assert_eq!(report.unreviewed_entries, 0);
|
assert_eq!(report.unreviewed_entries, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checked_translation_rejects_override_after_glossary_change() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let index = crate::official_parse::OfficialTextUnitIndex {
|
||||||
|
version: crate::official_parse::OFFICIAL_TEXTUNIT_INDEX_VERSION,
|
||||||
|
generated_unix_seconds: 1,
|
||||||
|
resource_root: temp.path().to_path_buf(),
|
||||||
|
summary: Default::default(),
|
||||||
|
units: vec![OfficialTextUnitIndexUnit {
|
||||||
|
id: "unit-1".to_string(),
|
||||||
|
parse_entry_key: "bundle".to_string(),
|
||||||
|
source_url: "https://example.invalid/bundle".to_string(),
|
||||||
|
destination: "bundles/test.bundle".to_string(),
|
||||||
|
archive_entry: None,
|
||||||
|
source_kind: crate::official_parse::OfficialParseSourceKind::DirectBundle,
|
||||||
|
unity_version: None,
|
||||||
|
source_text: "原文".to_string(),
|
||||||
|
serialized_file: Some("CAB-test".to_string()),
|
||||||
|
path_id: Some(7),
|
||||||
|
class_id: Some(49),
|
||||||
|
field_path: None,
|
||||||
|
field_offset: None,
|
||||||
|
field_byte_size: None,
|
||||||
|
format: Some("plain".to_string()),
|
||||||
|
text_source_kind: Some("text_asset".to_string()),
|
||||||
|
asset_name: Some("Story".to_string()),
|
||||||
|
context: Default::default(),
|
||||||
|
}],
|
||||||
|
errors: Vec::new(),
|
||||||
|
};
|
||||||
|
crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap();
|
||||||
|
let workbench_path = temp.path().join("workbench.json");
|
||||||
|
write_translation_workbench(&workbench_path, &workbench(temp.path())).unwrap();
|
||||||
|
let glossary_path = temp.path().join("glossary.sqlite");
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let qa = runtime.block_on(async {
|
||||||
|
let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap();
|
||||||
|
glossary
|
||||||
|
.add(bat_core::domain::GlossaryTermDraft {
|
||||||
|
term_id: "term-source".to_string(),
|
||||||
|
definition: bat_core::domain::GlossaryTermSnapshot {
|
||||||
|
source_term: "原文".to_string(),
|
||||||
|
aliases: Vec::new(),
|
||||||
|
recommended_translation: "译文".to_string(),
|
||||||
|
allowed_translations: Vec::new(),
|
||||||
|
source_language: None,
|
||||||
|
target_language: None,
|
||||||
|
category: None,
|
||||||
|
priority: 1,
|
||||||
|
scope: BTreeMap::new(),
|
||||||
|
},
|
||||||
|
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
|
||||||
|
source: bat_core::domain::GlossarySourceRecord {
|
||||||
|
source_kind: bat_core::domain::GlossarySourceKind::Manual,
|
||||||
|
source_ref: None,
|
||||||
|
source_author: None,
|
||||||
|
source_note: None,
|
||||||
|
observed_unix_seconds: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
glossary
|
||||||
|
.review(
|
||||||
|
"term-source",
|
||||||
|
bat_core::domain::GlossaryReviewStatus::Approved,
|
||||||
|
"reviewer",
|
||||||
|
Some("approve".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let context = crate::translation_memory::translation_memory_context(
|
||||||
|
"bundles/test.bundle",
|
||||||
|
None,
|
||||||
|
Some("CAB-test"),
|
||||||
|
Some(7),
|
||||||
|
Some(49),
|
||||||
|
None,
|
||||||
|
Some("plain"),
|
||||||
|
Some("Story"),
|
||||||
|
Some("text_asset"),
|
||||||
|
&BTreeMap::new(),
|
||||||
|
);
|
||||||
|
let evaluation = glossary.diagnose("原文", &context).await.unwrap();
|
||||||
|
(glossary, evaluation.check_translation("错误"))
|
||||||
|
});
|
||||||
|
let (glossary, qa) = qa;
|
||||||
|
let override_record = GlossaryOverride {
|
||||||
|
qa_identity: qa.qa_identity.clone(),
|
||||||
|
reviewer: "reviewer".to_string(),
|
||||||
|
reason: "manual review".to_string(),
|
||||||
|
provenance: "workbench".to_string(),
|
||||||
|
confirmed_unix_seconds: 1,
|
||||||
|
};
|
||||||
|
let updated = set_translation_checked_with_glossary_path(
|
||||||
|
temp.path(),
|
||||||
|
&workbench_path,
|
||||||
|
"unit-1",
|
||||||
|
"错误".to_string(),
|
||||||
|
Some(override_record.clone()),
|
||||||
|
Some(&glossary_path),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
updated
|
||||||
|
.glossary_qa
|
||||||
|
.as_ref()
|
||||||
|
.map(|report| report.qa_identity.as_str()),
|
||||||
|
Some(qa.qa_identity.as_str())
|
||||||
|
);
|
||||||
|
let loaded = read_translation_workbench(&workbench_path).unwrap();
|
||||||
|
let validation = validate_translation_workbench_with_glossary_path(
|
||||||
|
temp.path(),
|
||||||
|
"release-1",
|
||||||
|
&loaded,
|
||||||
|
Some(&glossary_path),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(validation.status, "valid");
|
||||||
|
|
||||||
|
runtime.block_on(async {
|
||||||
|
let current = glossary.find("term-source").await.unwrap();
|
||||||
|
glossary
|
||||||
|
.update(
|
||||||
|
bat_core::domain::GlossaryTermDraft {
|
||||||
|
term_id: current.term_id,
|
||||||
|
definition: bat_core::domain::GlossaryTermSnapshot {
|
||||||
|
recommended_translation: "新译文".to_string(),
|
||||||
|
..current.definition
|
||||||
|
},
|
||||||
|
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
|
||||||
|
source: bat_core::domain::GlossarySourceRecord {
|
||||||
|
observed_unix_seconds: 2,
|
||||||
|
..current.source
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"reviewer",
|
||||||
|
Some("change recommendation".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
glossary
|
||||||
|
.review(
|
||||||
|
"term-source",
|
||||||
|
bat_core::domain::GlossaryReviewStatus::Approved,
|
||||||
|
"reviewer",
|
||||||
|
Some("approve changed term".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let error = set_translation_checked_with_glossary_path(
|
||||||
|
temp.path(),
|
||||||
|
&workbench_path,
|
||||||
|
"unit-1",
|
||||||
|
"错误".to_string(),
|
||||||
|
Some(override_record),
|
||||||
|
Some(&glossary_path),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(error.to_string().contains("qa_identity"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn localized_operations_preserve_type_tree_field_traceability() {
|
fn localized_operations_preserve_type_tree_field_traceability() {
|
||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
@@ -1123,6 +1508,59 @@ mod tests {
|
|||||||
entry.translated_text = Some("译文".to_string());
|
entry.translated_text = Some("译文".to_string());
|
||||||
entry.translation_provider = Some("mock".to_string());
|
entry.translation_provider = Some("mock".to_string());
|
||||||
entry.provider_run_id = Some("run-1".to_string());
|
entry.provider_run_id = Some("run-1".to_string());
|
||||||
|
entry.glossary_qa = Some(GlossaryQaReport {
|
||||||
|
qa_identity: "stale-workbench-qa".to_string(),
|
||||||
|
status: bat_core::domain::GlossaryQaStatus::Pass,
|
||||||
|
constraints: Vec::new(),
|
||||||
|
diagnostics: Vec::new(),
|
||||||
|
});
|
||||||
|
let glossary_path = temp.path().join("glossary.sqlite");
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap();
|
||||||
|
let expected_qa = runtime.block_on(async {
|
||||||
|
let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap();
|
||||||
|
glossary
|
||||||
|
.add(bat_core::domain::GlossaryTermDraft {
|
||||||
|
term_id: "term-source".to_string(),
|
||||||
|
definition: bat_core::domain::GlossaryTermSnapshot {
|
||||||
|
source_term: "原文".to_string(),
|
||||||
|
aliases: Vec::new(),
|
||||||
|
recommended_translation: "译文".to_string(),
|
||||||
|
allowed_translations: Vec::new(),
|
||||||
|
source_language: None,
|
||||||
|
target_language: None,
|
||||||
|
category: None,
|
||||||
|
priority: 1,
|
||||||
|
scope: BTreeMap::new(),
|
||||||
|
},
|
||||||
|
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
|
||||||
|
source: bat_core::domain::GlossarySourceRecord {
|
||||||
|
source_kind: bat_core::domain::GlossarySourceKind::Manual,
|
||||||
|
source_ref: None,
|
||||||
|
source_author: None,
|
||||||
|
source_note: None,
|
||||||
|
observed_unix_seconds: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
glossary
|
||||||
|
.review(
|
||||||
|
"term-source",
|
||||||
|
bat_core::domain::GlossaryReviewStatus::Approved,
|
||||||
|
"reviewer",
|
||||||
|
Some("approve".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
glossary
|
||||||
|
.diagnose("原文", &BTreeMap::new())
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.check_translation("译文")
|
||||||
|
});
|
||||||
|
|
||||||
let operations = localized_patch_operations(temp.path(), &workbench).unwrap();
|
let operations = localized_patch_operations(temp.path(), &workbench).unwrap();
|
||||||
assert_eq!(operations.len(), 1);
|
assert_eq!(operations.len(), 1);
|
||||||
@@ -1138,6 +1576,11 @@ mod tests {
|
|||||||
assert_eq!(metadata.text_unit_id, "unit-1");
|
assert_eq!(metadata.text_unit_id, "unit-1");
|
||||||
assert_eq!(metadata.translation_provider.as_deref(), Some("mock"));
|
assert_eq!(metadata.translation_provider.as_deref(), Some("mock"));
|
||||||
assert_eq!(metadata.provider_run_id.as_deref(), Some("run-1"));
|
assert_eq!(metadata.provider_run_id.as_deref(), Some("run-1"));
|
||||||
|
assert_eq!(
|
||||||
|
metadata.glossary_qa.as_ref().map(|qa| qa.status),
|
||||||
|
Some(bat_core::domain::GlossaryQaStatus::Pass)
|
||||||
|
);
|
||||||
|
assert_eq!(metadata.glossary_qa.as_ref(), Some(&expected_qa));
|
||||||
}
|
}
|
||||||
other => panic!("unexpected localized operation: {other:?}"),
|
other => panic!("unexpected localized operation: {other:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
+395
-7
@@ -56,6 +56,10 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/admin/translation/handoff",
|
"/admin/translation/handoff",
|
||||||
"/admin/translation/memory/summary",
|
"/admin/translation/memory/summary",
|
||||||
"/admin/translation/memory/query",
|
"/admin/translation/memory/query",
|
||||||
|
"/admin/translation/memory/conflicts",
|
||||||
|
"/admin/translation/glossary/summary",
|
||||||
|
"/admin/translation/glossary/query",
|
||||||
|
"/admin/translation/glossary/diagnose",
|
||||||
"/admin/translation/status",
|
"/admin/translation/status",
|
||||||
},
|
},
|
||||||
Controls: []string{
|
Controls: []string{
|
||||||
@@ -75,8 +79,15 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/admin/control/translation-worker-run",
|
"/admin/control/translation-worker-run",
|
||||||
"/admin/control/translation-proofread",
|
"/admin/control/translation-proofread",
|
||||||
"/admin/control/translation-memory-confirm",
|
"/admin/control/translation-memory-confirm",
|
||||||
|
"/admin/control/translation-memory-resolve-conflict",
|
||||||
|
"/admin/control/translation-glossary-add",
|
||||||
|
"/admin/control/translation-glossary-update",
|
||||||
|
"/admin/control/translation-glossary-approve",
|
||||||
|
"/admin/control/translation-glossary-deprecate",
|
||||||
|
"/admin/control/translation-glossary-delete",
|
||||||
"/admin/control/localized-publish",
|
"/admin/control/localized-publish",
|
||||||
"/admin/control/localized-rollback",
|
"/admin/control/localized-rollback",
|
||||||
|
"/admin/control/release-cleanup",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodHead {
|
if r.Method == http.MethodHead {
|
||||||
@@ -124,6 +135,14 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.handleAdminTranslationMemoryConfirm(w, r)
|
s.handleAdminTranslationMemoryConfirm(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if action == "translation-memory-resolve-conflict" {
|
||||||
|
s.handleAdminTranslationMemoryResolveConflict(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(action, "translation-glossary-") {
|
||||||
|
s.handleAdminGlossaryControl(w, r, action)
|
||||||
|
return
|
||||||
|
}
|
||||||
if action == "localized-publish" {
|
if action == "localized-publish" {
|
||||||
s.handleAdminLocalizedPublish(w, r)
|
s.handleAdminLocalizedPublish(w, r)
|
||||||
return
|
return
|
||||||
@@ -132,6 +151,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.handleAdminLocalizedRollback(w, r)
|
s.handleAdminLocalizedRollback(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if action == "release-cleanup" {
|
||||||
|
s.handleAdminReleaseCleanup(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
request, ok := decodeAdminControlRequest(w, r)
|
request, ok := decodeAdminControlRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -205,6 +228,34 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminTranslationMemoryResolveConflict(w http.ResponseWriter, r *http.Request) {
|
||||||
|
backend, ok := s.backend.(TranslationMemoryBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "translation_memory_backend_unavailable", "Rust bat Translation Memory backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var params backendrpc.TranslationMemoryResolveConflictParams
|
||||||
|
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateTranslationMemoryResolveConflictParams(params); err != nil {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_memory_params", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := backend.TranslationMemoryResolveConflict(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, "translation-memory-resolve-conflict", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||||
|
Service: "bat-api",
|
||||||
|
Action: "translation-memory-resolve-conflict",
|
||||||
|
RPCMethod: "translation.memory.resolve_conflict",
|
||||||
|
Status: "accepted",
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminTranslationTaskUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
backend, ok := s.backend.(TranslationBackend)
|
backend, ok := s.backend.(TranslationBackend)
|
||||||
if !ok || backend == nil {
|
if !ok || backend == nil {
|
||||||
@@ -309,6 +360,226 @@ func (s *Server) handleAdminTranslationMemoryConfirm(w http.ResponseWriter, r *h
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminGlossarySummary(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.(GlossaryBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params := backendrpc.GlossarySummaryParams{
|
||||||
|
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
|
||||||
|
}
|
||||||
|
result, err := backend.GlossarySummary(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, "translation-glossary-summary", 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) handleAdminGlossaryQuery(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.(GlossaryBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params := backendrpc.GlossaryQueryParams{
|
||||||
|
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
|
||||||
|
SourceText: firstTrimmedQuery(r.URL.Query(), "source_text"),
|
||||||
|
Category: firstTrimmedQuery(r.URL.Query(), "category"),
|
||||||
|
ReviewStatus: firstTrimmedQuery(r.URL.Query(), "review_status"),
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||||
|
limit, err := strconv.ParseUint(raw, 10, 64)
|
||||||
|
if err != nil || limit == 0 || limit > 1000 {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "limit must be in 1..=1000")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params.Limit = &limit
|
||||||
|
}
|
||||||
|
result, err := backend.GlossaryQuery(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, "translation-glossary-query", 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) handleAdminGlossaryDiagnose(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.(GlossaryBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params := backendrpc.GlossaryDiagnoseParams{
|
||||||
|
GlossaryPath: firstTrimmedQuery(r.URL.Query(), "glossary_path"),
|
||||||
|
SourceText: firstTrimmedQuery(r.URL.Query(), "source_text"),
|
||||||
|
}
|
||||||
|
if params.SourceText == "" {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "source_text is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if raw := firstTrimmedQuery(r.URL.Query(), "context", "source_context"); raw != "" {
|
||||||
|
if err := json.Unmarshal([]byte(raw), ¶ms.Context); err != nil || params.Context == nil {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_query", "context must be a JSON object with string values")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := backend.GlossaryDiagnose(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, "translation-glossary-diagnose", 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) handleAdminGlossaryControl(w http.ResponseWriter, r *http.Request, action string) {
|
||||||
|
backend, ok := s.backend.(GlossaryBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "glossary_backend_unavailable", "Rust bat Glossary backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "translation-glossary-add", "translation-glossary-update":
|
||||||
|
var params backendrpc.GlossaryTermMutationParams
|
||||||
|
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.SourceTerm) == "" ||
|
||||||
|
strings.TrimSpace(params.RecommendedTranslation) == "" {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, source_term and recommended_translation are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var result *backendrpc.GlossaryMutationReport
|
||||||
|
var err error
|
||||||
|
if action == "translation-glossary-add" {
|
||||||
|
result, err = backend.GlossaryAdd(r.Context(), params)
|
||||||
|
} else {
|
||||||
|
if strings.TrimSpace(params.Reviewer) == "" {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "reviewer is required for update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err = backend.GlossaryUpdate(r.Context(), params)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||||
|
Service: "bat-api",
|
||||||
|
Action: action,
|
||||||
|
RPCMethod: glossaryControlRPCMethod(action),
|
||||||
|
Status: "accepted",
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
case "translation-glossary-approve", "translation-glossary-deprecate":
|
||||||
|
var params backendrpc.GlossaryReviewParams
|
||||||
|
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(params.TermID) == "" || strings.TrimSpace(params.Reviewer) == "" {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id and reviewer are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var result *backendrpc.GlossaryMutationReport
|
||||||
|
var err error
|
||||||
|
if action == "translation-glossary-approve" {
|
||||||
|
result, err = backend.GlossaryApprove(r.Context(), params)
|
||||||
|
} else {
|
||||||
|
result, err = backend.GlossaryDeprecate(r.Context(), params)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||||
|
Service: "bat-api",
|
||||||
|
Action: action,
|
||||||
|
RPCMethod: glossaryControlRPCMethod(action),
|
||||||
|
Status: "accepted",
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
case "translation-glossary-delete":
|
||||||
|
var params backendrpc.GlossaryDeleteParams
|
||||||
|
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(params.TermID) == "" ||
|
||||||
|
strings.TrimSpace(params.Reviewer) == "" ||
|
||||||
|
strings.TrimSpace(params.Reason) == "" {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, reviewer and reason are required for delete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := backend.GlossaryDelete(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, action, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||||
|
Service: "bat-api",
|
||||||
|
Action: action,
|
||||||
|
RPCMethod: glossaryControlRPCMethod(action),
|
||||||
|
Status: "accepted",
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown glossary control action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func glossaryControlRPCMethod(action string) string {
|
||||||
|
switch action {
|
||||||
|
case "translation-glossary-add":
|
||||||
|
return "translation.glossary.add"
|
||||||
|
case "translation-glossary-update":
|
||||||
|
return "translation.glossary.update"
|
||||||
|
case "translation-glossary-approve":
|
||||||
|
return "translation.glossary.approve"
|
||||||
|
case "translation-glossary-deprecate":
|
||||||
|
return "translation.glossary.deprecate"
|
||||||
|
case "translation-glossary-delete":
|
||||||
|
return "translation.glossary.delete"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) {
|
||||||
backend, ok := s.backend.(LocalizedBackend)
|
backend, ok := s.backend.(LocalizedBackend)
|
||||||
if !ok || backend == nil {
|
if !ok || backend == nil {
|
||||||
@@ -387,6 +658,34 @@ func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Reque
|
|||||||
writeNoStoreJSON(w, http.StatusOK, result)
|
writeNoStoreJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminReleaseCleanup(w http.ResponseWriter, r *http.Request) {
|
||||||
|
backend, ok := s.backend.(ReleaseBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "release_backend_unavailable", "Rust bat release backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var params backendrpc.ReleaseCleanupParams
|
||||||
|
if !decodeAdminTranslationJSON(w, r, ¶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) {
|
func (s *Server) handleAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
@@ -751,6 +1050,37 @@ func (s *Server) handleAdminTranslationMemoryQuery(w http.ResponseWriter, r *htt
|
|||||||
writeNoStoreJSON(w, http.StatusOK, result)
|
writeNoStoreJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminTranslationMemoryConflicts(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.(TranslationMemoryBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "translation_memory_backend_unavailable", "Rust bat Translation Memory backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
params, err := translationMemoryConflictsParams(r)
|
||||||
|
if err != nil {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_translation_memory_query", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := backend.TranslationMemoryConflicts(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, "translation-memory-conflicts", 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) handleAdminSchedules(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminSchedules(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
@@ -874,6 +1204,20 @@ func translationMemoryQueryParams(r *http.Request) (backendrpc.TranslationMemory
|
|||||||
return params, nil
|
return params, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func translationMemoryConflictsParams(r *http.Request) (backendrpc.TranslationMemoryConflictsParams, error) {
|
||||||
|
params := backendrpc.TranslationMemoryConflictsParams{
|
||||||
|
TranslationMemoryPath: firstTrimmedQuery(r.URL.Query(), "translation_memory_path", "tm_path"),
|
||||||
|
}
|
||||||
|
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||||
|
limit, err := strconv.ParseUint(raw, 10, 64)
|
||||||
|
if err != nil || limit == 0 || limit > 1000 {
|
||||||
|
return backendrpc.TranslationMemoryConflictsParams{}, errors.New("limit must be in 1..=1000")
|
||||||
|
}
|
||||||
|
params.Limit = &limit
|
||||||
|
}
|
||||||
|
return params, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseTextUnitQueryParams(r *http.Request) (backendrpc.TextUnitQueryParams, error) {
|
func parseTextUnitQueryParams(r *http.Request) (backendrpc.TextUnitQueryParams, error) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
params := backendrpc.TextUnitQueryParams{
|
params := backendrpc.TextUnitQueryParams{
|
||||||
@@ -1067,15 +1411,52 @@ func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunPa
|
|||||||
|
|
||||||
func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryConfirmParams) error {
|
func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryConfirmParams) error {
|
||||||
if strings.TrimSpace(params.RecordID) == "" || strings.TrimSpace(params.Reviewer) == "" {
|
if strings.TrimSpace(params.RecordID) == "" || strings.TrimSpace(params.Reviewer) == "" {
|
||||||
return errors.New("Translation Memory confirm requires record_id and reviewer")
|
return errors.New("translation memory confirm requires record_id and reviewer")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(params.SupersedeRecordID) != "" && strings.TrimSpace(params.Reason) == "" {
|
||||||
|
return errors.New("translation memory supersede requires reason")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateTranslationMemoryResolveConflictParams(params backendrpc.TranslationMemoryResolveConflictParams) error {
|
||||||
|
if strings.TrimSpace(params.WinnerRecordID) == "" ||
|
||||||
|
strings.TrimSpace(params.Reviewer) == "" ||
|
||||||
|
strings.TrimSpace(params.Reason) == "" {
|
||||||
|
return errors.New("translation memory conflict resolution requires winner_record_id, reviewer, and reason")
|
||||||
|
}
|
||||||
|
if len(params.ExpectedTrustedRecordIDs) == 0 {
|
||||||
|
return errors.New("translation memory conflict resolution requires expected_trusted_record_ids")
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(params.ExpectedTrustedRecordIDs))
|
||||||
|
for _, id := range params.ExpectedTrustedRecordIDs {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
return errors.New("expected_trusted_record_ids cannot contain empty record IDs")
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
return errors.New("expected_trusted_record_ids must be unique")
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateLocalizedPublishParams(params backendrpc.LocalizedPublishParams) error {
|
func validateLocalizedPublishParams(params backendrpc.LocalizedPublishParams) error {
|
||||||
hasFile := strings.TrimSpace(params.TranslationFile) != ""
|
hasFile := strings.TrimSpace(params.TranslationFile) != ""
|
||||||
if hasFile == params.FromWorker {
|
hasManifest := strings.TrimSpace(params.PatchManifest) != ""
|
||||||
return errors.New("localized publish requires exactly one of translation_file or from_worker")
|
inputs := 0
|
||||||
|
if hasFile {
|
||||||
|
inputs++
|
||||||
|
}
|
||||||
|
if params.FromWorker {
|
||||||
|
inputs++
|
||||||
|
}
|
||||||
|
if hasManifest {
|
||||||
|
inputs++
|
||||||
|
}
|
||||||
|
if inputs != 1 {
|
||||||
|
return errors.New("localized publish requires exactly one of translation_file, from_worker, or patch_manifest")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1136,10 +1517,17 @@ func (s *Server) writeControlBackendError(w http.ResponseWriter, action string,
|
|||||||
message = "control request was canceled"
|
message = "control request was canceled"
|
||||||
default:
|
default:
|
||||||
var apiErr *backendrpc.APIError
|
var apiErr *backendrpc.APIError
|
||||||
if errors.As(err, &apiErr) && apiErr.Kind == "not_implemented" {
|
if errors.As(err, &apiErr) {
|
||||||
status = http.StatusNotImplemented
|
switch apiErr.Kind {
|
||||||
code = "control_not_implemented"
|
case "not_implemented":
|
||||||
message = "Rust bat does not implement this control action"
|
status = http.StatusNotImplemented
|
||||||
|
code = "control_not_implemented"
|
||||||
|
message = "Rust bat does not implement this control action"
|
||||||
|
case "rpc_invalid_params":
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
code = "invalid_control_params"
|
||||||
|
message = apiErr.Message
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.logger.Printf("bat-api control action=%s error=%v", action, err)
|
s.logger.Printf("bat-api control action=%s error=%v", action, err)
|
||||||
|
|||||||
+644
-39
@@ -32,6 +32,95 @@ func fixtureRoot(t *testing.T) string {
|
|||||||
return abs
|
return abs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyFixtureRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
source := fixtureRoot(t)
|
||||||
|
target := filepath.Join(t.TempDir(), "release")
|
||||||
|
if err := filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(source, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
destination := filepath.Join(target, rel)
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(destination, info.Mode().Perm())
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(destination, data, info.Mode().Perm())
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixtureRPCBackend(t *testing.T, root string) *fakeBackend {
|
||||||
|
t.Helper()
|
||||||
|
idx, err := LoadIndexFromResourceRoot(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
manifestEntries := make([]backendrpc.ResourceManifestEntry, 0, len(idx.Entries))
|
||||||
|
for _, entry := range idx.Entries {
|
||||||
|
size := entry.Bytes
|
||||||
|
manifestEntries = append(manifestEntries, backendrpc.ResourceManifestEntry{
|
||||||
|
URL: entry.URL,
|
||||||
|
Destination: entry.RelativePath,
|
||||||
|
Bytes: &size,
|
||||||
|
BLAKE3: entry.BLAKE3,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catalog, err := json.Marshal(map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"status": "published",
|
||||||
|
"version": map[string]any{
|
||||||
|
"id": "official-fixture",
|
||||||
|
"resource_root": root,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &fakeBackend{
|
||||||
|
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
|
||||||
|
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
|
||||||
|
catalog: catalog,
|
||||||
|
manifest: &backendrpc.ResourceManifestPage{
|
||||||
|
Available: true,
|
||||||
|
Channel: "official",
|
||||||
|
ReleaseID: "official-fixture",
|
||||||
|
ResourceRoot: root,
|
||||||
|
ManifestVersion: 1,
|
||||||
|
PublicationIdentity: "fixture-publication-v1",
|
||||||
|
MappingIdentity: "fixture-mapping-v1",
|
||||||
|
ManifestIdentity: "fixture-manifest-v1",
|
||||||
|
Generation: 1,
|
||||||
|
TotalEntries: len(manifestEntries),
|
||||||
|
Entries: manifestEntries,
|
||||||
|
},
|
||||||
|
releaseStatus: &backendrpc.ReleaseStatusReport{
|
||||||
|
Status: "ready",
|
||||||
|
StatusCode: "distribution.ready",
|
||||||
|
OfficialCurrentReleaseID: "official-fixture",
|
||||||
|
DefaultDistributionChannel: "official",
|
||||||
|
OfficialDistributionReady: true,
|
||||||
|
Releases: []backendrpc.ReleaseSummary{{
|
||||||
|
Channel: "official",
|
||||||
|
ID: "official-fixture",
|
||||||
|
Current: true,
|
||||||
|
DistributionIntegrityStatus: "valid",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixtureCurrentCDNPath = "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
|
||||||
|
|
||||||
func TestLoadIndexFromResourceRoot(t *testing.T) {
|
func TestLoadIndexFromResourceRoot(t *testing.T) {
|
||||||
idx, err := LoadIndexFromResourceRoot(fixtureRoot(t))
|
idx, err := LoadIndexFromResourceRoot(fixtureRoot(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -89,6 +178,188 @@ func TestReleaseSummaryRequiresCompleteManifest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCurrentCDNRequiresRustWholeReleaseHealth(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(t *testing.T, root string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing entry",
|
||||||
|
mutate: func(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.Remove(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "size mismatch",
|
||||||
|
mutate: func(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("too-large"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same-size corruption",
|
||||||
|
mutate: func(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
root := copyFixtureRoot(t)
|
||||||
|
tt.mutate(t, root)
|
||||||
|
backend := fixtureRPCBackend(t, root)
|
||||||
|
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
|
||||||
|
Status: "blocked",
|
||||||
|
StatusCode: "distribution.blocked",
|
||||||
|
OfficialCurrentReleaseID: "official-fixture",
|
||||||
|
DefaultDistributionChannel: "official",
|
||||||
|
OfficialDistributionReady: false,
|
||||||
|
Releases: []backendrpc.ReleaseSummary{{
|
||||||
|
Channel: "official",
|
||||||
|
ID: "official-fixture",
|
||||||
|
Current: true,
|
||||||
|
DistributionIntegrityStatus: "invalid",
|
||||||
|
Diagnostics: []string{"fixture integrity failure"},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.RequireIndexed = false
|
||||||
|
cfg.RefreshInterval = 0
|
||||||
|
if err := cfg.Normalize(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := NewServer(cfg, backend, log.New(io.Discard, "", 0))
|
||||||
|
if err := server.Refresh(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := server.index().Summary()
|
||||||
|
if summary.Ready || summary.Distribution.Ready ||
|
||||||
|
summary.Distribution.StatusCode != "distribution.blocked" {
|
||||||
|
t.Fatalf("summary=%+v", summary)
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/readyz", "/v1/bootstrap"} {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
|
||||||
|
if recorder.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("%s status=%d body=%s", path, recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil))
|
||||||
|
if recorder.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("current CDN status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if recorder.Header().Get("ETag") != "" || recorder.Header().Get("Cache-Control") != "" {
|
||||||
|
t.Fatalf("unhealthy CDN headers=%v", recorder.Header())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefreshCurrentReleaseHealthTransitionsAndClearsFailure(t *testing.T) {
|
||||||
|
root := copyFixtureRoot(t)
|
||||||
|
backend := fixtureRPCBackend(t, root)
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.RefreshInterval = 0
|
||||||
|
if err := cfg.Normalize(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := NewServer(cfg, backend, log.New(io.Discard, "", 0))
|
||||||
|
|
||||||
|
if err := server.Refresh(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !server.index().Summary().Ready {
|
||||||
|
t.Fatalf("initial release is not ready: %+v", server.index().Summary())
|
||||||
|
}
|
||||||
|
get := func() *httptest.ResponseRecorder {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, fixtureCurrentCDNPath, nil))
|
||||||
|
return recorder
|
||||||
|
}
|
||||||
|
if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
|
||||||
|
t.Fatalf("healthy CDN status=%d body=%q", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bytes stay the same size, but Rust's next release.status result
|
||||||
|
// revokes whole-release distribution authorization.
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("CORRUPTED!"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
|
||||||
|
Status: "blocked",
|
||||||
|
StatusCode: "distribution.blocked",
|
||||||
|
OfficialCurrentReleaseID: "official-fixture",
|
||||||
|
DefaultDistributionChannel: "official",
|
||||||
|
Releases: []backendrpc.ReleaseSummary{{
|
||||||
|
Channel: "official",
|
||||||
|
ID: "official-fixture",
|
||||||
|
Current: true,
|
||||||
|
DistributionIntegrityStatus: "invalid",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := server.Refresh(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if server.index().Summary().Ready || get().Code != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("unhealthy refresh summary=%+v", server.index().Summary())
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.releaseStatusErr = errors.New("release.status transport failure")
|
||||||
|
if err := server.Refresh(context.Background()); err == nil {
|
||||||
|
t.Fatal("expected refresh failure")
|
||||||
|
}
|
||||||
|
if summary := server.index().Summary(); summary.Ready || summary.ResourceRoot != "" {
|
||||||
|
t.Fatalf("failed refresh retained snapshot=%+v", summary)
|
||||||
|
}
|
||||||
|
healthRecorder := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||||
|
var health map[string]any
|
||||||
|
if err := json.Unmarshal(healthRecorder.Body.Bytes(), &health); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
refresh := health["refresh"].(map[string]any)
|
||||||
|
if refresh["last_error"] == "" {
|
||||||
|
t.Fatalf("refresh diagnostics=%v", refresh)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash"), []byte("1234567890"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend.releaseStatusErr = nil
|
||||||
|
backend.releaseStatus = &backendrpc.ReleaseStatusReport{
|
||||||
|
Status: "ready",
|
||||||
|
StatusCode: "distribution.ready",
|
||||||
|
OfficialCurrentReleaseID: "official-fixture",
|
||||||
|
DefaultDistributionChannel: "official",
|
||||||
|
OfficialDistributionReady: true,
|
||||||
|
Releases: []backendrpc.ReleaseSummary{{
|
||||||
|
Channel: "official",
|
||||||
|
ID: "official-fixture",
|
||||||
|
Current: true,
|
||||||
|
DistributionIntegrityStatus: "valid",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := server.Refresh(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if summary := server.index().Summary(); !summary.Ready || !summary.Distribution.Ready {
|
||||||
|
t.Fatalf("recovered summary=%+v", summary)
|
||||||
|
}
|
||||||
|
if recorder := get(); recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
|
||||||
|
t.Fatalf("recovered CDN status=%d body=%q", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSplitCDNPathRejectsEscape(t *testing.T) {
|
func TestSplitCDNPathRejectsEscape(t *testing.T) {
|
||||||
if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil {
|
if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil {
|
||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
@@ -553,18 +824,24 @@ func TestServerInfoRewritesAddressablesOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeBackend struct {
|
type fakeBackend struct {
|
||||||
statusCalls int
|
statusCalls int
|
||||||
doctorCalls int
|
doctorCalls int
|
||||||
status *backendrpc.DaemonStatusReport
|
releaseStatusCalls int
|
||||||
doctor *backendrpc.DoctorReport
|
attestationCalls int
|
||||||
catalog json.RawMessage
|
status *backendrpc.DaemonStatusReport
|
||||||
resource *backendrpc.ResourceState
|
doctor *backendrpc.DoctorReport
|
||||||
manifest *backendrpc.ResourceManifestPage
|
releaseStatus *backendrpc.ReleaseStatusReport
|
||||||
daemonLogs *backendrpc.LogsReport
|
releaseStatusErr error
|
||||||
taskList *backendrpc.TaskList
|
attestation *backendrpc.DistributionAttestation
|
||||||
taskStatus *backendrpc.TaskRecord
|
catalog json.RawMessage
|
||||||
taskLogs *backendrpc.TaskLogs
|
resource *backendrpc.ResourceState
|
||||||
taskCancel *backendrpc.TaskCancelResult
|
manifest *backendrpc.ResourceManifestPage
|
||||||
|
manifestParams []backendrpc.ResourceManifestParams
|
||||||
|
daemonLogs *backendrpc.LogsReport
|
||||||
|
taskList *backendrpc.TaskList
|
||||||
|
taskStatus *backendrpc.TaskRecord
|
||||||
|
taskLogs *backendrpc.TaskLogs
|
||||||
|
taskCancel *backendrpc.TaskCancelResult
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
|
func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
|
||||||
@@ -573,11 +850,100 @@ func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatu
|
|||||||
}
|
}
|
||||||
func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
|
func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
|
||||||
f.doctorCalls++
|
f.doctorCalls++
|
||||||
if f.statusCalls == 0 {
|
|
||||||
// status must be called first in real DiscoverAndIndex; this is asserted by call order.
|
|
||||||
}
|
|
||||||
return f.doctor, nil
|
return f.doctor, nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeBackend) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) {
|
||||||
|
f.releaseStatusCalls++
|
||||||
|
if f.releaseStatusErr != nil {
|
||||||
|
return nil, f.releaseStatusErr
|
||||||
|
}
|
||||||
|
if f.releaseStatus != nil {
|
||||||
|
return f.releaseStatus, nil
|
||||||
|
}
|
||||||
|
return &backendrpc.ReleaseStatusReport{
|
||||||
|
Status: "ready",
|
||||||
|
StatusCode: "distribution.ready",
|
||||||
|
DefaultDistributionChannel: "official",
|
||||||
|
OfficialDistributionReady: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
func (f *fakeBackend) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) {
|
||||||
|
f.attestationCalls++
|
||||||
|
// Keep the older test fixture controls useful while discovery moves to the
|
||||||
|
// lightweight RPC: releaseStatus still supplies the desired ready/blocked
|
||||||
|
// state unless a test explicitly installs an attestation.
|
||||||
|
if f.attestation != nil {
|
||||||
|
return f.attestation, nil
|
||||||
|
}
|
||||||
|
f.releaseStatusCalls++
|
||||||
|
if f.releaseStatusErr != nil {
|
||||||
|
return nil, f.releaseStatusErr
|
||||||
|
}
|
||||||
|
releaseStatus := f.releaseStatus
|
||||||
|
if releaseStatus == nil {
|
||||||
|
releaseStatus = &backendrpc.ReleaseStatusReport{
|
||||||
|
Status: "ready",
|
||||||
|
StatusCode: "distribution.ready",
|
||||||
|
OfficialDistributionReady: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
releaseID := "fixture-release"
|
||||||
|
resourceRoot := ""
|
||||||
|
if f.manifest != nil {
|
||||||
|
releaseID = f.manifest.ReleaseID
|
||||||
|
resourceRoot = f.manifest.ResourceRoot
|
||||||
|
}
|
||||||
|
var catalog struct {
|
||||||
|
Version struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ResourceRoot string `json:"resource_root"`
|
||||||
|
} `json:"version"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(f.catalog, &catalog) == nil {
|
||||||
|
if catalog.Version.ID != "" {
|
||||||
|
releaseID = catalog.Version.ID
|
||||||
|
}
|
||||||
|
if catalog.Version.ResourceRoot != "" {
|
||||||
|
resourceRoot = catalog.Version.ResourceRoot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if releaseStatus.OfficialCurrentReleaseID != "" {
|
||||||
|
releaseID = releaseStatus.OfficialCurrentReleaseID
|
||||||
|
}
|
||||||
|
integrity := "verified"
|
||||||
|
for _, release := range releaseStatus.Releases {
|
||||||
|
if release.Channel == "official" && release.Current {
|
||||||
|
integrity = release.DistributionIntegrityStatus
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if integrity == "valid" {
|
||||||
|
integrity = "verified"
|
||||||
|
}
|
||||||
|
verifiedAt := uint64(time.Now().Unix())
|
||||||
|
return &backendrpc.DistributionAttestation{
|
||||||
|
Available: true,
|
||||||
|
Channel: "official",
|
||||||
|
ReleaseID: releaseID,
|
||||||
|
ResourceRoot: resourceRoot,
|
||||||
|
PublicationIdentity: "fixture-publication-v1",
|
||||||
|
MappingIdentity: "fixture-mapping-v1",
|
||||||
|
ManifestIdentity: "fixture-manifest-v1",
|
||||||
|
EntryCount: func() int {
|
||||||
|
if f.manifest == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(f.manifest.Entries)
|
||||||
|
}(),
|
||||||
|
IntegrityStatus: integrity,
|
||||||
|
Status: releaseStatus.Status,
|
||||||
|
StatusCode: releaseStatus.StatusCode,
|
||||||
|
Ready: releaseStatus.OfficialDistributionReady,
|
||||||
|
VerificationGeneration: 1,
|
||||||
|
VerifiedAt: &verifiedAt,
|
||||||
|
MaxAgeSeconds: 7260,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
|
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
|
||||||
if f.resource != nil {
|
if f.resource != nil {
|
||||||
return f.resource, nil
|
return f.resource, nil
|
||||||
@@ -587,8 +953,36 @@ func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceSt
|
|||||||
func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return f.catalog, nil
|
return f.catalog, nil
|
||||||
}
|
}
|
||||||
func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
|
func (f *fakeBackend) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
|
||||||
return f.manifest, nil
|
if f.manifest == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
f.manifestParams = append(f.manifestParams, params)
|
||||||
|
page := *f.manifest
|
||||||
|
if page.Channel == "" {
|
||||||
|
page.Channel = "official"
|
||||||
|
}
|
||||||
|
if page.ReleaseID == "" {
|
||||||
|
page.ReleaseID = params.ReleaseID
|
||||||
|
}
|
||||||
|
if page.ResourceRoot == "" {
|
||||||
|
page.ResourceRoot = f.manifest.ResourceRoot
|
||||||
|
}
|
||||||
|
if page.PublicationIdentity == "" {
|
||||||
|
page.PublicationIdentity = params.ExpectedPublicationIdentity
|
||||||
|
}
|
||||||
|
if page.ManifestIdentity == "" {
|
||||||
|
page.ManifestIdentity = params.ExpectedManifestIdentity
|
||||||
|
}
|
||||||
|
if page.MappingIdentity == "" {
|
||||||
|
page.MappingIdentity = "fixture-mapping-v1"
|
||||||
|
}
|
||||||
|
if page.Generation == 0 {
|
||||||
|
page.Generation = 1
|
||||||
|
}
|
||||||
|
page.Offset = params.Offset
|
||||||
|
page.Limit = params.Limit
|
||||||
|
return &page, nil
|
||||||
}
|
}
|
||||||
func (f *fakeBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
|
func (f *fakeBackend) DaemonLogs(ctx context.Context, tail int) (*backendrpc.LogsReport, error) {
|
||||||
if f.daemonLogs != nil {
|
if f.daemonLogs != nil {
|
||||||
@@ -629,16 +1023,24 @@ func (f *fakeBackend) TaskCancel(ctx context.Context, taskID string) (*backendrp
|
|||||||
|
|
||||||
type controlBackend struct {
|
type controlBackend struct {
|
||||||
*fakeBackend
|
*fakeBackend
|
||||||
calls []string
|
calls []string
|
||||||
parseTextUnitQueries []backendrpc.TextUnitQueryParams
|
parseTextUnitQueries []backendrpc.TextUnitQueryParams
|
||||||
parseErrorQueries []backendrpc.TextUnitQueryParams
|
parseErrorQueries []backendrpc.TextUnitQueryParams
|
||||||
translationTaskUpdates []backendrpc.TranslationTaskUpdateParams
|
translationTaskUpdates []backendrpc.TranslationTaskUpdateParams
|
||||||
translationTaskListParams []backendrpc.TranslationTaskListParams
|
translationTaskListParams []backendrpc.TranslationTaskListParams
|
||||||
translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams
|
translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams
|
||||||
translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams
|
translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams
|
||||||
translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams
|
translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams
|
||||||
localizedPublishParams []backendrpc.LocalizedPublishParams
|
translationMemoryConflictsParams []backendrpc.TranslationMemoryConflictsParams
|
||||||
localizedRollbackParams []backendrpc.LocalizedRollbackParams
|
translationMemoryResolveParams []backendrpc.TranslationMemoryResolveConflictParams
|
||||||
|
glossarySummaryParams []backendrpc.GlossarySummaryParams
|
||||||
|
glossaryQueryParams []backendrpc.GlossaryQueryParams
|
||||||
|
glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams
|
||||||
|
glossaryMutationParams []backendrpc.GlossaryTermMutationParams
|
||||||
|
glossaryReviewParams []backendrpc.GlossaryReviewParams
|
||||||
|
glossaryDeleteParams []backendrpc.GlossaryDeleteParams
|
||||||
|
localizedPublishParams []backendrpc.LocalizedPublishParams
|
||||||
|
localizedRollbackParams []backendrpc.LocalizedRollbackParams
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
|
func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
|
||||||
@@ -783,18 +1185,19 @@ func (b *controlBackend) TranslationProofread(ctx context.Context) (json.RawMess
|
|||||||
func (b *controlBackend) TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) {
|
func (b *controlBackend) TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) {
|
||||||
b.calls = append(b.calls, "translation.memory.summary")
|
b.calls = append(b.calls, "translation.memory.summary")
|
||||||
b.translationMemorySummaryParams = append(b.translationMemorySummaryParams, params)
|
b.translationMemorySummaryParams = append(b.translationMemorySummaryParams, params)
|
||||||
schemaVersion := uint64(1)
|
schemaVersion := uint64(2)
|
||||||
return &backendrpc.TranslationMemorySummaryReport{
|
return &backendrpc.TranslationMemorySummaryReport{
|
||||||
Available: true,
|
Available: true,
|
||||||
Path: params.TranslationMemoryPath,
|
Path: params.TranslationMemoryPath,
|
||||||
SchemaVersion: &schemaVersion,
|
SchemaVersion: &schemaVersion,
|
||||||
Summary: &backendrpc.TranslationMemorySummary{
|
Summary: &backendrpc.TranslationMemorySummary{
|
||||||
SchemaVersion: schemaVersion,
|
SchemaVersion: schemaVersion,
|
||||||
RecordCount: 2,
|
RecordCount: 2,
|
||||||
TrustedCount: 1,
|
TrustedCount: 1,
|
||||||
CandidateCount: 1,
|
CandidateCount: 1,
|
||||||
SupersededCount: 0,
|
SupersededCount: 0,
|
||||||
RejectedCount: 0,
|
RejectedCount: 0,
|
||||||
|
CurrentTrustedCount: 1,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -825,6 +1228,110 @@ func (b *controlBackend) TranslationMemoryConfirm(ctx context.Context, params ba
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) TranslationMemoryConflicts(ctx context.Context, params backendrpc.TranslationMemoryConflictsParams) (*backendrpc.TranslationMemoryConflictsReport, error) {
|
||||||
|
b.calls = append(b.calls, "translation.memory.conflicts")
|
||||||
|
b.translationMemoryConflictsParams = append(b.translationMemoryConflictsParams, params)
|
||||||
|
return &backendrpc.TranslationMemoryConflictsReport{
|
||||||
|
Available: true,
|
||||||
|
Path: params.TranslationMemoryPath,
|
||||||
|
Conflicts: []backendrpc.TranslationMemoryConflict{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) TranslationMemoryResolveConflict(ctx context.Context, params backendrpc.TranslationMemoryResolveConflictParams) (*backendrpc.TranslationMemoryResolveConflictReport, error) {
|
||||||
|
b.calls = append(b.calls, "translation.memory.resolve_conflict")
|
||||||
|
b.translationMemoryResolveParams = append(b.translationMemoryResolveParams, params)
|
||||||
|
return &backendrpc.TranslationMemoryResolveConflictReport{
|
||||||
|
Available: true,
|
||||||
|
Path: params.TranslationMemoryPath,
|
||||||
|
Entry: backendrpc.TranslationMemoryEntry{
|
||||||
|
RecordID: params.WinnerRecordID,
|
||||||
|
TrustStatus: backendrpc.TranslationMemoryStatusTrusted,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) {
|
||||||
|
b.calls = append(b.calls, "translation.glossary.summary")
|
||||||
|
b.glossarySummaryParams = append(b.glossarySummaryParams, params)
|
||||||
|
schemaVersion := uint64(2)
|
||||||
|
return &backendrpc.GlossarySummaryReport{
|
||||||
|
Available: true,
|
||||||
|
Path: params.GlossaryPath,
|
||||||
|
SchemaVersion: &schemaVersion,
|
||||||
|
Summary: &backendrpc.GlossarySummary{
|
||||||
|
SchemaVersion: schemaVersion,
|
||||||
|
TermCount: 2,
|
||||||
|
ApprovedCount: 1,
|
||||||
|
DraftCount: 1,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) {
|
||||||
|
b.calls = append(b.calls, "translation.glossary.query")
|
||||||
|
b.glossaryQueryParams = append(b.glossaryQueryParams, params)
|
||||||
|
return &backendrpc.GlossaryQueryReport{
|
||||||
|
Available: true,
|
||||||
|
Path: params.GlossaryPath,
|
||||||
|
Terms: []backendrpc.GlossaryTerm{{
|
||||||
|
TermID: "term-sensei",
|
||||||
|
GlossaryTermSnapshot: backendrpc.GlossaryTermSnapshot{
|
||||||
|
SourceTerm: "Sensei",
|
||||||
|
RecommendedTranslation: "老师",
|
||||||
|
Priority: 10,
|
||||||
|
},
|
||||||
|
ReviewStatus: backendrpc.GlossaryStatusApproved,
|
||||||
|
Source: backendrpc.GlossarySourceRecord{
|
||||||
|
SourceKind: "manual",
|
||||||
|
ObservedUnixSeconds: 100,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) {
|
||||||
|
b.calls = append(b.calls, "translation.glossary.diagnose")
|
||||||
|
b.glossaryDiagnoseParams = append(b.glossaryDiagnoseParams, params)
|
||||||
|
return &backendrpc.GlossaryDiagnoseReport{
|
||||||
|
Available: true,
|
||||||
|
Path: params.GlossaryPath,
|
||||||
|
SourceText: params.SourceText,
|
||||||
|
Context: params.Context,
|
||||||
|
Evaluation: json.RawMessage(`{"constraints":[],"diagnostics":[],"blocked":false}`),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
|
||||||
|
b.calls = append(b.calls, "translation.glossary.add")
|
||||||
|
b.glossaryMutationParams = append(b.glossaryMutationParams, params)
|
||||||
|
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
|
||||||
|
b.calls = append(b.calls, "translation.glossary.update")
|
||||||
|
b.glossaryMutationParams = append(b.glossaryMutationParams, params)
|
||||||
|
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
|
||||||
|
b.calls = append(b.calls, "translation.glossary.approve")
|
||||||
|
b.glossaryReviewParams = append(b.glossaryReviewParams, params)
|
||||||
|
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
|
||||||
|
b.calls = append(b.calls, "translation.glossary.deprecate")
|
||||||
|
b.glossaryReviewParams = append(b.glossaryReviewParams, params)
|
||||||
|
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error) {
|
||||||
|
b.calls = append(b.calls, "translation.glossary.delete")
|
||||||
|
b.glossaryDeleteParams = append(b.glossaryDeleteParams, params)
|
||||||
|
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
|
func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
b.calls = append(b.calls, "localized.status")
|
b.calls = append(b.calls, "localized.status")
|
||||||
return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil
|
return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil
|
||||||
@@ -1044,6 +1551,60 @@ func TestAdminTranslationQueryEndpointsProxyAuthenticatedRequests(t *testing.T)
|
|||||||
t.Fatalf("missing TM query source status=%d body=%s", recorder.Code, recorder.Body.String())
|
t.Fatalf("missing TM query source status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request = httptest.NewRequest(http.MethodGet, "/admin/translation/memory/conflicts?translation_memory_path=%2Fvar%2Flib%2Fbat%2Ftranslation-memory.sqlite&limit=7", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer translation-token")
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK ||
|
||||||
|
len(backend.translationMemoryConflictsParams) != 1 ||
|
||||||
|
backend.translationMemoryConflictsParams[0].TranslationMemoryPath != "/var/lib/bat/translation-memory.sqlite" ||
|
||||||
|
backend.translationMemoryConflictsParams[0].Limit == nil ||
|
||||||
|
*backend.translationMemoryConflictsParams[0].Limit != 7 {
|
||||||
|
t.Fatalf("TM conflicts status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.translationMemoryConflictsParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/summary?glossary_path=%2Fvar%2Flib%2Fbat%2Fglossary.sqlite", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer translation-token")
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK ||
|
||||||
|
!strings.Contains(recorder.Body.String(), `"approved_count":1`) ||
|
||||||
|
len(backend.glossarySummaryParams) != 1 ||
|
||||||
|
backend.glossarySummaryParams[0].GlossaryPath != "/var/lib/bat/glossary.sqlite" {
|
||||||
|
t.Fatalf("Glossary summary status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossarySummaryParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/query?source_text=Sensei&limit=20", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer translation-token")
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK ||
|
||||||
|
len(backend.glossaryQueryParams) != 1 ||
|
||||||
|
backend.glossaryQueryParams[0].SourceText != "Sensei" ||
|
||||||
|
backend.glossaryQueryParams[0].Limit == nil ||
|
||||||
|
*backend.glossaryQueryParams[0].Limit != 20 {
|
||||||
|
t.Fatalf("Glossary query status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossaryQueryParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/diagnose?source_text=Sensei&context=%7B%22destination%22%3A%22Bundle%2Fdialogue.bundle%22%7D", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer translation-token")
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK ||
|
||||||
|
len(backend.glossaryDiagnoseParams) != 1 ||
|
||||||
|
backend.glossaryDiagnoseParams[0].SourceText != "Sensei" ||
|
||||||
|
backend.glossaryDiagnoseParams[0].Context["destination"] != "Bundle/dialogue.bundle" {
|
||||||
|
t.Fatalf("Glossary diagnose status=%d body=%s params=%#v", recorder.Code, recorder.Body.String(), backend.glossaryDiagnoseParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
request = httptest.NewRequest(http.MethodGet, "/admin/translation/glossary/diagnose", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer translation-token")
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("missing Glossary diagnose source status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
request = httptest.NewRequest(http.MethodGet, "/admin/translation/tasks?limit=0", nil)
|
request = httptest.NewRequest(http.MethodGet, "/admin/translation/tasks?limit=0", nil)
|
||||||
request.Header.Set("Authorization", "Bearer translation-token")
|
request.Header.Set("Authorization", "Bearer translation-token")
|
||||||
recorder = httptest.NewRecorder()
|
recorder = httptest.NewRecorder()
|
||||||
@@ -1114,8 +1675,8 @@ func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if fb.statusCalls != 1 || fb.doctorCalls != 1 {
|
if fb.statusCalls != 1 || fb.doctorCalls != 1 || fb.releaseStatusCalls != 1 {
|
||||||
t.Fatalf("status=%d doctor=%d", fb.statusCalls, fb.doctorCalls)
|
t.Fatalf("status=%d doctor=%d release_status=%d", fb.statusCalls, fb.doctorCalls, fb.releaseStatusCalls)
|
||||||
}
|
}
|
||||||
if !result.RPCAvailable || result.Index == nil || !result.Index.Summary().Ready {
|
if !result.RPCAvailable || result.Index == nil || !result.Index.Summary().Ready {
|
||||||
t.Fatalf("result=%+v summary=%+v", result, result.Index.Summary())
|
t.Fatalf("result=%+v summary=%+v", result, result.Index.Summary())
|
||||||
@@ -1353,9 +1914,12 @@ func (p *pollingBackend) ResourceState(ctx context.Context) (*backendrpc.Resourc
|
|||||||
func (p *pollingBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
func (p *pollingBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return nil, errors.New("unexpected catalog call")
|
return nil, errors.New("unexpected catalog call")
|
||||||
}
|
}
|
||||||
func (p *pollingBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
|
func (p *pollingBackend) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
|
||||||
return nil, errors.New("unexpected manifest call")
|
return nil, errors.New("unexpected manifest call")
|
||||||
}
|
}
|
||||||
|
func (p *pollingBackend) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) {
|
||||||
|
return nil, errors.New("unexpected attestation call")
|
||||||
|
}
|
||||||
|
|
||||||
func TestStartRefreshLoopPollsBackend(t *testing.T) {
|
func TestStartRefreshLoopPollsBackend(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
@@ -1586,6 +2150,7 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
|
|||||||
!strings.Contains(rr.Body.String(), "/admin/tasks") ||
|
!strings.Contains(rr.Body.String(), "/admin/tasks") ||
|
||||||
!strings.Contains(rr.Body.String(), "/admin/parse/text-units") ||
|
!strings.Contains(rr.Body.String(), "/admin/parse/text-units") ||
|
||||||
!strings.Contains(rr.Body.String(), "translation_results") ||
|
!strings.Contains(rr.Body.String(), "translation_results") ||
|
||||||
|
!strings.Contains(rr.Body.String(), "glossary_override") ||
|
||||||
!strings.Contains(rr.Body.String(), "task-cancel") ||
|
!strings.Contains(rr.Body.String(), "task-cancel") ||
|
||||||
!strings.Contains(rr.Body.String(), "/admin/translation/memory/query") ||
|
!strings.Contains(rr.Body.String(), "/admin/translation/memory/query") ||
|
||||||
!strings.Contains(rr.Body.String(), "translation-memory-confirm") {
|
!strings.Contains(rr.Body.String(), "translation-memory-confirm") {
|
||||||
@@ -1814,11 +2379,18 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
|||||||
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
|
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
|
||||||
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
|
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
|
||||||
{name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"},
|
{name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"},
|
||||||
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
|
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文","glossary_override":{"qa_identity":"gqa-v1-test","reviewer":"reviewer","reason":"approved deviation","provenance":"manual-review","confirmed_unix_seconds":100}}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
|
||||||
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
|
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
|
||||||
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
|
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
|
||||||
{name: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"},
|
{name: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"},
|
||||||
|
{name: "translation memory resolve conflict", action: "translation-memory-resolve-conflict", body: `{"winner_record_id":"tm-record-1","expected_trusted_record_ids":["tm-record-1","tm-record-2"],"reviewer":"reviewer","reason":"selected"}`, rpcMethod: "translation.memory.resolve_conflict", call: "translation.memory.resolve_conflict"},
|
||||||
|
{name: "translation glossary add", action: "translation-glossary-add", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.add", call: "translation.glossary.add"},
|
||||||
|
{name: "translation glossary update", action: "translation-glossary-update", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","reviewer":"reviewer","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.update", call: "translation.glossary.update"},
|
||||||
|
{name: "translation glossary approve", action: "translation-glossary-approve", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"approved"}`, rpcMethod: "translation.glossary.approve", call: "translation.glossary.approve"},
|
||||||
|
{name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"},
|
||||||
|
{name: "translation glossary delete", action: "translation-glossary-delete", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"duplicate"}`, rpcMethod: "translation.glossary.delete", call: "translation.glossary.delete"},
|
||||||
{name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"},
|
{name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"},
|
||||||
|
{name: "localized patch manifest publish", action: "localized-publish", body: `{"patch_manifest":"/tmp/patch-manifest.json","localized_release_id":"localized-1"}`, rpcMethod: "localized.publish", call: "localized.publish"},
|
||||||
{name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"},
|
{name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
@@ -1846,7 +2418,10 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
|||||||
if len(backend.translationTaskUpdates) != 1 ||
|
if len(backend.translationTaskUpdates) != 1 ||
|
||||||
backend.translationTaskUpdates[0].Provider != "manual" ||
|
backend.translationTaskUpdates[0].Provider != "manual" ||
|
||||||
len(backend.translationTaskUpdates[0].TranslationResults) != 1 ||
|
len(backend.translationTaskUpdates[0].TranslationResults) != 1 ||
|
||||||
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" {
|
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" ||
|
||||||
|
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride == nil ||
|
||||||
|
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.Reviewer != "reviewer" ||
|
||||||
|
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.QAIdentity != "gqa-v1-test" {
|
||||||
t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates)
|
t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates)
|
||||||
}
|
}
|
||||||
if len(backend.translationMemoryConfirmParams) != 1 ||
|
if len(backend.translationMemoryConfirmParams) != 1 ||
|
||||||
@@ -1854,6 +2429,20 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
|||||||
backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" {
|
backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" {
|
||||||
t.Fatalf("TM confirm params=%#v", backend.translationMemoryConfirmParams)
|
t.Fatalf("TM confirm params=%#v", backend.translationMemoryConfirmParams)
|
||||||
}
|
}
|
||||||
|
if len(backend.translationMemoryResolveParams) != 1 ||
|
||||||
|
backend.translationMemoryResolveParams[0].WinnerRecordID != "tm-record-1" ||
|
||||||
|
len(backend.translationMemoryResolveParams[0].ExpectedTrustedRecordIDs) != 2 {
|
||||||
|
t.Fatalf("TM resolve params=%#v", backend.translationMemoryResolveParams)
|
||||||
|
}
|
||||||
|
if len(backend.glossaryMutationParams) != 2 ||
|
||||||
|
backend.glossaryMutationParams[0].TermID != "term-sensei" ||
|
||||||
|
backend.glossaryMutationParams[1].Reviewer != "reviewer" ||
|
||||||
|
len(backend.glossaryReviewParams) != 2 ||
|
||||||
|
backend.glossaryReviewParams[0].TermID != "term-sensei" ||
|
||||||
|
len(backend.glossaryDeleteParams) != 1 ||
|
||||||
|
backend.glossaryDeleteParams[0].Reason != "duplicate" {
|
||||||
|
t.Fatalf("Glossary params mutation=%#v review=%#v delete=%#v", backend.glossaryMutationParams, backend.glossaryReviewParams, backend.glossaryDeleteParams)
|
||||||
|
}
|
||||||
|
|
||||||
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
|
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
|
||||||
request.Header.Set("Authorization", "Bearer control-token")
|
request.Header.Set("Authorization", "Bearer control-token")
|
||||||
@@ -1887,6 +2476,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
|||||||
t.Fatalf("invalid TM confirm status=%d body=%s", recorder.Code, recorder.Body.String())
|
t.Fatalf("invalid TM confirm status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request = httptest.NewRequest(http.MethodPost, "/admin/control/translation-memory-resolve-conflict", strings.NewReader(`{"winner_record_id":"tm-record-1","expected_trusted_record_ids":["tm-record-1","tm-record-1"],"reviewer":"reviewer","reason":"selected"}`))
|
||||||
|
request.Header.Set("Authorization", "Bearer control-token")
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid TM resolve status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"translation_file":"/tmp/workbench.json"}`))
|
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"translation_file":"/tmp/workbench.json"}`))
|
||||||
request.Header.Set("Authorization", "Bearer control-token")
|
request.Header.Set("Authorization", "Bearer control-token")
|
||||||
recorder = httptest.NewRecorder()
|
recorder = httptest.NewRecorder()
|
||||||
@@ -1894,6 +2491,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusBadRequest {
|
if recorder.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("invalid localized publish status=%d body=%s", recorder.Code, recorder.Body.String())
|
t.Fatalf("invalid localized publish status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"patch_manifest":"/tmp/patch-manifest.json"}`))
|
||||||
|
request.Header.Set("Authorization", "Bearer control-token")
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid localized patch manifest status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminLocalizedStatusRequiresAuthAndForwards(t *testing.T) {
|
func TestAdminLocalizedStatusRequiresAuthAndForwards(t *testing.T) {
|
||||||
|
|||||||
+49
-16
@@ -14,11 +14,6 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idx := s.index()
|
|
||||||
if idx == nil || idx.ResourceRoot == "" {
|
|
||||||
http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_, rel, err := SplitCDNPath(r.URL.Path)
|
_, rel, err := SplitCDNPath(r.URL.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
@@ -27,15 +22,51 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var entry ResourceEntry
|
var entry ResourceEntry
|
||||||
var hasEntry bool
|
var hasEntry bool
|
||||||
if s.cfg.RequireIndexed {
|
resourceRoot := ""
|
||||||
entry, hasEntry = idx.Lookup(rel)
|
explicitRelease := false
|
||||||
if !hasEntry || !entry.Present || !entry.SizeMatch {
|
channel := r.URL.Query().Get("channel")
|
||||||
http.NotFound(w, r)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
selected, selectErr := s.loadReleaseDistribution(r, channel, releaseID, rel)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
summary := idx.Summary()
|
||||||
|
if !summary.Ready {
|
||||||
|
// The cached health fact covers the whole current release. A
|
||||||
|
// locally present target is not enough to serve it as a healthy
|
||||||
|
// immutable artifact.
|
||||||
|
http.Error(w, "current release is not distributable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resourceRoot = idx.ResourceRoot
|
||||||
|
entry, hasEntry = idx.Lookup(rel)
|
||||||
|
}
|
||||||
|
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 {
|
if err != nil {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -45,11 +76,13 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.RequireIndexed && s.cfg.VerifySize {
|
if explicitRelease && hasEntry && uint64(info.Size()) != entry.Bytes {
|
||||||
if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
|
http.Error(w, "size mismatch with release index", http.StatusConflict)
|
||||||
http.Error(w, "size mismatch with release index", http.StatusConflict)
|
return
|
||||||
return
|
}
|
||||||
}
|
if !explicitRelease && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
|
||||||
|
http.Error(w, "size mismatch with release index", http.StatusConflict)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Open(abs)
|
file, err := os.Open(abs)
|
||||||
@@ -57,7 +90,7 @@ func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
w.Header().Set("ETag", cdnETag(entry, hasEntry, info))
|
w.Header().Set("ETag", cdnETag(entry, hasEntry, info))
|
||||||
|
|||||||
@@ -332,7 +332,8 @@ BAT_API_STATE_DIR=/tmp/bat-pid
|
|||||||
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
|
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
|
||||||
|
|
||||||
# Optional override of the published release root (fixtures / emergency only).
|
# Optional override of the published release root (fixtures / emergency only).
|
||||||
# Production obtains resource_root from daemon RPC (catalog.status / resource.manifest).
|
# Production obtains resource_root from daemon RPC (release.attestation +
|
||||||
|
# generation-bound resource.manifest).
|
||||||
# BAT_API_RESOURCE_ROOT=
|
# BAT_API_RESOURCE_ROOT=
|
||||||
|
|
||||||
# Optional server-info JSON for Addressables root rewrite
|
# Optional server-info JSON for Addressables root rewrite
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
|
|||||||
availableRaw := readContractFixture(t, "catalog-status.available.json")
|
availableRaw := readContractFixture(t, "catalog-status.available.json")
|
||||||
unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json")
|
unavailableRaw := readContractFixture(t, "catalog-status.unavailable.json")
|
||||||
manifestRaw := readContractFixture(t, "resource-manifest.page0.json")
|
manifestRaw := readContractFixture(t, "resource-manifest.page0.json")
|
||||||
|
attestationRaw := readContractFixture(t, "release-attestation.json")
|
||||||
snapshotRaw := readContractFixture(t, "official-sync-snapshot.json")
|
snapshotRaw := readContractFixture(t, "official-sync-snapshot.json")
|
||||||
|
|
||||||
for name, raw := range map[string][]byte{
|
for name, raw := range map[string][]byte{
|
||||||
"catalog available": availableRaw,
|
"catalog available": availableRaw,
|
||||||
"catalog unavailable": unavailableRaw,
|
"catalog unavailable": unavailableRaw,
|
||||||
"resource manifest": manifestRaw,
|
"resource manifest": manifestRaw,
|
||||||
|
"attestation": attestationRaw,
|
||||||
"snapshot": snapshotRaw,
|
"snapshot": snapshotRaw,
|
||||||
} {
|
} {
|
||||||
if bytes.Contains(raw, []byte("/tmp/")) {
|
if bytes.Contains(raw, []byte("/tmp/")) {
|
||||||
@@ -71,7 +73,12 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
|
|||||||
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
if err := json.Unmarshal(manifestRaw, &manifest); err != nil {
|
||||||
t.Fatalf("decode resource manifest: %v", err)
|
t.Fatalf("decode resource manifest: %v", err)
|
||||||
}
|
}
|
||||||
if !manifest.Available || manifest.ManifestVersion != 1 || manifest.TotalEntries != 2 {
|
if !manifest.Available || manifest.Channel != "official" ||
|
||||||
|
manifest.ManifestVersion != 1 || manifest.TotalEntries != 2 ||
|
||||||
|
manifest.ReleaseID != "${VERSION_ID}" ||
|
||||||
|
manifest.PublicationIdentity != "${PUBLICATION_IDENTITY}" ||
|
||||||
|
manifest.ManifestIdentity != "${MANIFEST_IDENTITY}" ||
|
||||||
|
manifest.Generation != 7 {
|
||||||
t.Fatalf("manifest header=%+v", manifest)
|
t.Fatalf("manifest header=%+v", manifest)
|
||||||
}
|
}
|
||||||
if len(manifest.Entries) != 2 {
|
if len(manifest.Entries) != 2 {
|
||||||
@@ -84,6 +91,19 @@ func TestRustContractFixturesPreserveGoMirror(t *testing.T) {
|
|||||||
t.Fatalf("manifest first entry=%+v", manifest.Entries[0])
|
t.Fatalf("manifest first entry=%+v", manifest.Entries[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var attestation backendrpc.DistributionAttestation
|
||||||
|
if err := json.Unmarshal(attestationRaw, &attestation); err != nil {
|
||||||
|
t.Fatalf("decode attestation: %v", err)
|
||||||
|
}
|
||||||
|
if !attestation.Available || !attestation.Ready ||
|
||||||
|
attestation.ReleaseID != "${VERSION_ID}" ||
|
||||||
|
attestation.ManifestIdentity != "${MANIFEST_IDENTITY}" ||
|
||||||
|
attestation.VerificationGeneration != 7 ||
|
||||||
|
attestation.MaxAgeSeconds != 7260 ||
|
||||||
|
attestation.VerifiedAt == nil || *attestation.VerifiedAt != 1000 {
|
||||||
|
t.Fatalf("attestation=%+v", attestation)
|
||||||
|
}
|
||||||
|
|
||||||
var snapshot struct {
|
var snapshot struct {
|
||||||
AppVersion string `json:"app_version"`
|
AppVersion string `json:"app_version"`
|
||||||
BundleVersion string `json:"bundle_version"`
|
BundleVersion string `json:"bundle_version"`
|
||||||
@@ -198,3 +218,44 @@ func TestTranslationMemoryRustContractMirror(t *testing.T) {
|
|||||||
t.Fatalf("missing TM summary=%+v", missing)
|
t.Fatalf("missing TM summary=%+v", missing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGlossaryRustContractMirror(t *testing.T) {
|
||||||
|
raw := readContractFixture(t, "glossary-query.json")
|
||||||
|
if bytes.Contains(raw, []byte("/tmp/")) {
|
||||||
|
t.Fatal("Glossary mirror contains a local temporary path")
|
||||||
|
}
|
||||||
|
var report backendrpc.GlossaryQueryReport
|
||||||
|
if err := json.Unmarshal(raw, &report); err != nil {
|
||||||
|
t.Fatalf("decode Glossary query mirror: %v", err)
|
||||||
|
}
|
||||||
|
if !report.Available || report.Path != "${GLOSSARY_PATH}" ||
|
||||||
|
report.SourceText != "${SOURCE_TEXT}" || len(report.Terms) != 1 {
|
||||||
|
t.Fatalf("Glossary report=%+v", report)
|
||||||
|
}
|
||||||
|
term := report.Terms[0]
|
||||||
|
if term.TermID != "${TERM_ID}" ||
|
||||||
|
term.SourceTerm != "${SOURCE_TERM}" ||
|
||||||
|
term.RecommendedTranslation != "${RECOMMENDED_TRANSLATION}" ||
|
||||||
|
term.ReviewStatus != backendrpc.GlossaryStatusApproved ||
|
||||||
|
term.Source.SourceKind != "manual" ||
|
||||||
|
term.Source.ObservedUnixSeconds != 100 ||
|
||||||
|
len(term.History) != 2 ||
|
||||||
|
term.History[0].ReviewStatus != backendrpc.GlossaryStatusDraft ||
|
||||||
|
term.History[1].Action != "approved" ||
|
||||||
|
term.History[1].ReviewStatus != backendrpc.GlossaryStatusApproved ||
|
||||||
|
term.History[1].Snapshot.Priority != 10 {
|
||||||
|
t.Fatalf("Glossary term=%+v", term)
|
||||||
|
}
|
||||||
|
var missing backendrpc.GlossarySummaryReport
|
||||||
|
if err := json.Unmarshal([]byte(`{
|
||||||
|
"available": false,
|
||||||
|
"path": "${GLOSSARY_PATH}",
|
||||||
|
"reason": "database_missing"
|
||||||
|
}`), &missing); err != nil {
|
||||||
|
t.Fatalf("decode missing Glossary summary mirror: %v", err)
|
||||||
|
}
|
||||||
|
if missing.Available || missing.Summary != nil || missing.SchemaVersion != nil ||
|
||||||
|
missing.Reason != "database_missing" {
|
||||||
|
t.Fatalf("missing Glossary summary=%+v", missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ func (s *Server) launcherBootstrapBody(sum ReleaseSummary) LauncherBootstrapResp
|
|||||||
},
|
},
|
||||||
Resource: LauncherResource{
|
Resource: LauncherResource{
|
||||||
Release: sum.Snapshot,
|
Release: sum.Snapshot,
|
||||||
|
Distribution: sum.Distribution,
|
||||||
ServerInfoURL: s.serverInfoURL(),
|
ServerInfoURL: s.serverInfoURL(),
|
||||||
ClientPatchBaseURL: s.clientPatchBaseURL(),
|
ClientPatchBaseURL: s.clientPatchBaseURL(),
|
||||||
},
|
},
|
||||||
|
|||||||
+271
-8
@@ -27,25 +27,25 @@ paths:
|
|||||||
summary: Release readiness
|
summary: Release readiness
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: A distributable release is available.
|
description: A current official release authorized by Rust release.attestation and fully represented by the bound local read snapshot is available.
|
||||||
"503":
|
"503":
|
||||||
description: No distributable release is available.
|
description: The Rust current attestation is unavailable, stale, invalid, or the bound local read snapshot is not distributable.
|
||||||
/v1/bootstrap:
|
/v1/bootstrap:
|
||||||
get:
|
get:
|
||||||
summary: Startup resource bootstrap
|
summary: Startup resource bootstrap
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Resource bootstrap response.
|
description: Resource bootstrap response with the same distribution health used by readiness and current CDN serving.
|
||||||
"503":
|
"503":
|
||||||
description: Release is not ready.
|
description: The current release is not distributable.
|
||||||
/v1/launcher/bootstrap:
|
/v1/launcher/bootstrap:
|
||||||
get:
|
get:
|
||||||
summary: Launcher-shaped resource bootstrap
|
summary: Launcher-shaped resource bootstrap
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Launcher bootstrap response.
|
description: Launcher bootstrap response with the current release distribution health.
|
||||||
"503":
|
"503":
|
||||||
description: Release is not ready.
|
description: The current release is not distributable.
|
||||||
/api/launcher/game/config:
|
/api/launcher/game/config:
|
||||||
get:
|
get:
|
||||||
summary: Resource-only launcher game config compatibility
|
summary: Resource-only launcher game config compatibility
|
||||||
@@ -78,7 +78,58 @@ paths:
|
|||||||
summary: Current release summary
|
summary: Current release summary
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Release summary.
|
description: Release summary including Rust-owned whole-release distribution health.
|
||||||
|
/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: destination
|
||||||
|
in: query
|
||||||
|
description: Optional release-relative path for single-entry lookup; Rust returns exactly one entry and revalidates the selected channel's actual bytes and BLAKE3.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 1000
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Rust-verified selected release and resource manifest page.
|
||||||
|
"409":
|
||||||
|
description: Selected release is missing, stale, damaged, or not distributable.
|
||||||
|
"503":
|
||||||
|
description: Rust bat release backend is unavailable.
|
||||||
/v1/resources:
|
/v1/resources:
|
||||||
get:
|
get:
|
||||||
summary: Paginated resource manifest entries
|
summary: Paginated resource manifest entries
|
||||||
@@ -461,6 +512,111 @@ paths:
|
|||||||
description: Missing or invalid admin token.
|
description: Missing or invalid admin token.
|
||||||
"503":
|
"503":
|
||||||
description: Rust bat Translation Memory backend is unavailable.
|
description: Rust bat Translation Memory backend is unavailable.
|
||||||
|
/admin/translation/memory/conflicts:
|
||||||
|
get:
|
||||||
|
summary: List Rust-owned Translation Memory Trusted conflicts
|
||||||
|
parameters:
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 1
|
||||||
|
maximum: 1000
|
||||||
|
default: 100
|
||||||
|
- name: translation_memory_path
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Translation Memory exact-identity Trusted conflict groups.
|
||||||
|
"400":
|
||||||
|
description: Invalid conflict list limit.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat Translation Memory backend is unavailable.
|
||||||
|
/admin/translation/glossary/summary:
|
||||||
|
get:
|
||||||
|
summary: Read Rust-owned Glossary summary
|
||||||
|
parameters:
|
||||||
|
- name: glossary_path
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Glossary availability and review-state counts.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat Glossary backend is unavailable.
|
||||||
|
/admin/translation/glossary/query:
|
||||||
|
get:
|
||||||
|
summary: Query Rust-owned Glossary terms
|
||||||
|
parameters:
|
||||||
|
- name: source_text
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: category
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: review_status
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [draft, approved, deprecated, rejected]
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 1
|
||||||
|
maximum: 1000
|
||||||
|
default: 100
|
||||||
|
- name: glossary_path
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Glossary terms with source and review history.
|
||||||
|
"400":
|
||||||
|
description: Invalid Glossary query.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat Glossary backend is unavailable.
|
||||||
|
/admin/translation/glossary/diagnose:
|
||||||
|
get:
|
||||||
|
summary: Run deterministic Glossary diagnostics
|
||||||
|
parameters:
|
||||||
|
- name: source_text
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: context
|
||||||
|
in: query
|
||||||
|
description: JSON object whose values are strings.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: glossary_path
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Glossary constraints, diagnostics, blocked decision, and stable qa_identity.
|
||||||
|
"400":
|
||||||
|
description: Missing source text or invalid context.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat Glossary backend is unavailable.
|
||||||
/admin/translation/status:
|
/admin/translation/status:
|
||||||
get:
|
get:
|
||||||
summary: Read Rust-owned localized release status
|
summary: Read Rust-owned localized release status
|
||||||
@@ -471,6 +627,32 @@ paths:
|
|||||||
description: Missing or invalid admin token.
|
description: Missing or invalid admin token.
|
||||||
"503":
|
"503":
|
||||||
description: Rust bat localized backend is unavailable.
|
description: Rust bat localized backend is unavailable.
|
||||||
|
/admin/releases/status:
|
||||||
|
get:
|
||||||
|
summary: Read the unified Rust-owned release status view
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Official/localized current relation and integrity status.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat release backend is unavailable.
|
||||||
|
/admin/releases:
|
||||||
|
get:
|
||||||
|
summary: Read Rust-owned historical release summaries
|
||||||
|
parameters:
|
||||||
|
- name: channel
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [official, localized]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Historical release summaries.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat release backend is unavailable.
|
||||||
/admin/control/{action}:
|
/admin/control/{action}:
|
||||||
post:
|
post:
|
||||||
summary: Forward an allowlisted control or schedule action to Rust bat
|
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||||
@@ -480,7 +662,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, 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-memory-resolve-conflict, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: false
|
required: false
|
||||||
content:
|
content:
|
||||||
@@ -546,6 +728,24 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
translated_text:
|
translated_text:
|
||||||
type: string
|
type: string
|
||||||
|
glossary_override:
|
||||||
|
type: object
|
||||||
|
required: [qa_identity, reviewer, reason, provenance, confirmed_unix_seconds]
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
qa_identity:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
reviewer:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
provenance:
|
||||||
|
type: string
|
||||||
|
confirmed_unix_seconds:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
minimum: 1
|
||||||
fixture_path:
|
fixture_path:
|
||||||
type: string
|
type: string
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -573,8 +773,69 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
translation_memory_path:
|
translation_memory_path:
|
||||||
type: string
|
type: string
|
||||||
|
glossary_path:
|
||||||
|
type: string
|
||||||
record_id:
|
record_id:
|
||||||
type: string
|
type: string
|
||||||
|
winner_record_id:
|
||||||
|
type: string
|
||||||
|
expected_trusted_record_ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
supersede_record_id:
|
||||||
|
type: string
|
||||||
|
reviewer:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
term_id:
|
||||||
|
type: string
|
||||||
|
source_term:
|
||||||
|
type: string
|
||||||
|
aliases:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
recommended_translation:
|
||||||
|
type: string
|
||||||
|
allowed_translations:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
source_language:
|
||||||
|
type: string
|
||||||
|
target_language:
|
||||||
|
type: string
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
priority:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
scope:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
review_status:
|
||||||
|
type: string
|
||||||
|
enum: [draft, approved, deprecated, rejected]
|
||||||
|
source:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [source_kind, observed_unix_seconds]
|
||||||
|
properties:
|
||||||
|
source_kind:
|
||||||
|
type: string
|
||||||
|
enum: [manual, imported]
|
||||||
|
source_ref:
|
||||||
|
type: string
|
||||||
|
source_author:
|
||||||
|
type: string
|
||||||
|
source_note:
|
||||||
|
type: string
|
||||||
|
observed_unix_seconds:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
reviewer:
|
reviewer:
|
||||||
type: string
|
type: string
|
||||||
reason:
|
reason:
|
||||||
@@ -583,6 +844,8 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
from_worker:
|
from_worker:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
patch_manifest:
|
||||||
|
type: string
|
||||||
localized_release_id:
|
localized_release_id:
|
||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bat-api/internal/backendrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testAttestation(root, releaseID, integrity string, ready bool) *backendrpc.DistributionAttestation {
|
||||||
|
verifiedAt := uint64(time.Now().Unix())
|
||||||
|
return &backendrpc.DistributionAttestation{
|
||||||
|
Available: true,
|
||||||
|
Channel: "official",
|
||||||
|
ReleaseID: releaseID,
|
||||||
|
ResourceRoot: root,
|
||||||
|
PublicationIdentity: "publication-" + releaseID,
|
||||||
|
MappingIdentity: "mapping-" + releaseID,
|
||||||
|
ManifestIdentity: "manifest-" + releaseID,
|
||||||
|
EntryCount: 3,
|
||||||
|
IntegrityStatus: integrity,
|
||||||
|
Status: integrity,
|
||||||
|
StatusCode: "distribution." + integrity,
|
||||||
|
Ready: ready,
|
||||||
|
VerificationGeneration: 4,
|
||||||
|
VerifiedAt: &verifiedAt,
|
||||||
|
MaxAgeSeconds: 7260,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testManifestPage(attestation *backendrpc.DistributionAttestation, offset int, entries int) *backendrpc.ResourceManifestPage {
|
||||||
|
pageEntries := make([]backendrpc.ResourceManifestEntry, entries)
|
||||||
|
for index := range pageEntries {
|
||||||
|
size := uint64(index + 1)
|
||||||
|
pageEntries[index] = backendrpc.ResourceManifestEntry{
|
||||||
|
URL: "https://example.invalid/" + string(rune('a'+offset+index)),
|
||||||
|
Destination: "resource-" + string(rune('a'+offset+index)),
|
||||||
|
Bytes: &size,
|
||||||
|
BLAKE3: "blake3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &backendrpc.ResourceManifestPage{
|
||||||
|
Available: true,
|
||||||
|
Channel: "official",
|
||||||
|
ReleaseID: attestation.ReleaseID,
|
||||||
|
ResourceRoot: attestation.ResourceRoot,
|
||||||
|
ManifestVersion: 1,
|
||||||
|
PublicationIdentity: attestation.PublicationIdentity,
|
||||||
|
MappingIdentity: attestation.MappingIdentity,
|
||||||
|
ManifestIdentity: attestation.ManifestIdentity,
|
||||||
|
Generation: attestation.VerificationGeneration,
|
||||||
|
TotalEntries: attestation.EntryCount,
|
||||||
|
Offset: offset,
|
||||||
|
Limit: 2,
|
||||||
|
Entries: pageEntries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pagedManifestBackend struct {
|
||||||
|
*fakeBackend
|
||||||
|
pages []*backendrpc.ResourceManifestPage
|
||||||
|
params []backendrpc.ResourceManifestParams
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *pagedManifestBackend) ResourceManifest(_ context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
|
||||||
|
b.params = append(b.params, params)
|
||||||
|
pageIndex := len(b.params) - 1
|
||||||
|
page := *b.pages[pageIndex]
|
||||||
|
return &page, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAllManifestEntriesRejectsMixedPages(t *testing.T) {
|
||||||
|
attestation := testAttestation("/srv/official/current", "official-a", "verified", true)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*backendrpc.ResourceManifestPage)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "release",
|
||||||
|
mutate: func(page *backendrpc.ResourceManifestPage) { page.ReleaseID = "official-b" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "root",
|
||||||
|
mutate: func(page *backendrpc.ResourceManifestPage) { page.ResourceRoot = "/srv/official/current-b" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "manifest identity",
|
||||||
|
mutate: func(page *backendrpc.ResourceManifestPage) { page.ManifestIdentity = "manifest-b" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "generation",
|
||||||
|
mutate: func(page *backendrpc.ResourceManifestPage) { page.Generation = 5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "publication identity",
|
||||||
|
mutate: func(page *backendrpc.ResourceManifestPage) { page.PublicationIdentity = "publication-b" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mapping identity",
|
||||||
|
mutate: func(page *backendrpc.ResourceManifestPage) { page.MappingIdentity = "mapping-b" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "manifest version",
|
||||||
|
mutate: func(page *backendrpc.ResourceManifestPage) { page.ManifestVersion = 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "total",
|
||||||
|
mutate: func(page *backendrpc.ResourceManifestPage) { page.TotalEntries = 4 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit",
|
||||||
|
mutate: func(page *backendrpc.ResourceManifestPage) { page.Limit = 1 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
first := testManifestPage(attestation, 0, 2)
|
||||||
|
second := testManifestPage(attestation, 2, 1)
|
||||||
|
test.mutate(second)
|
||||||
|
backend := &pagedManifestBackend{
|
||||||
|
fakeBackend: &fakeBackend{},
|
||||||
|
pages: []*backendrpc.ResourceManifestPage{first, second},
|
||||||
|
}
|
||||||
|
if _, _, _, err := fetchAllManifestEntriesWithPageSize(
|
||||||
|
context.Background(),
|
||||||
|
backend,
|
||||||
|
attestation,
|
||||||
|
2,
|
||||||
|
); err == nil {
|
||||||
|
t.Fatal("expected mixed-page validation error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAllManifestEntriesAcceptsMatchingGeneration(t *testing.T) {
|
||||||
|
attestation := testAttestation("/srv/official/current", "official-a", "verified", true)
|
||||||
|
backend := &pagedManifestBackend{
|
||||||
|
fakeBackend: &fakeBackend{},
|
||||||
|
pages: []*backendrpc.ResourceManifestPage{
|
||||||
|
testManifestPage(attestation, 0, 2),
|
||||||
|
testManifestPage(attestation, 2, 1),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
entries, version, root, err := fetchAllManifestEntriesWithPageSize(
|
||||||
|
context.Background(),
|
||||||
|
backend,
|
||||||
|
attestation,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(entries) != 3 || version != 1 || root != attestation.ResourceRoot {
|
||||||
|
t.Fatalf("entries=%d version=%d root=%q", len(entries), version, root)
|
||||||
|
}
|
||||||
|
if len(backend.params) != 2 ||
|
||||||
|
backend.params[1].ReleaseID != attestation.ReleaseID ||
|
||||||
|
backend.params[1].ExpectedManifestIdentity != attestation.ManifestIdentity ||
|
||||||
|
backend.params[1].ExpectedVerificationGeneration != attestation.VerificationGeneration {
|
||||||
|
t.Fatalf("params=%+v", backend.params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverRejectsAttestationThenCatalogCurrentSwitch(t *testing.T) {
|
||||||
|
root := fixtureRoot(t)
|
||||||
|
backend := fixtureRPCBackend(t, root)
|
||||||
|
backend.attestation = testAttestation(root, "official-a", "verified", true)
|
||||||
|
catalog, err := json.Marshal(map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"version": map[string]any{
|
||||||
|
"id": "official-b",
|
||||||
|
"resource_root": root,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend.catalog = catalog
|
||||||
|
result, err := DiscoverAndIndex(context.Background(), backend, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Index == nil || result.Index.Summary().Ready || result.Index.Summary().EntryCount != 0 {
|
||||||
|
t.Fatalf("summary=%+v", result.Index.Summary())
|
||||||
|
}
|
||||||
|
if len(backend.manifestParams) != 0 {
|
||||||
|
t.Fatalf("manifest should not be fetched after current switch: %+v", backend.manifestParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthyAttestationThenCurrentSwitchClearsSnapshot(t *testing.T) {
|
||||||
|
root := copyFixtureRoot(t)
|
||||||
|
backend := fixtureRPCBackend(t, root)
|
||||||
|
backend.attestation = testAttestation(root, "official-a", "verified", true)
|
||||||
|
backend.attestation.EntryCount = 2
|
||||||
|
backend.catalog = mustCatalogForTest(t, root, "official-a")
|
||||||
|
backend.manifest.ReleaseID = "official-a"
|
||||||
|
backend.manifest.PublicationIdentity = "publication-official-a"
|
||||||
|
backend.manifest.MappingIdentity = "mapping-official-a"
|
||||||
|
backend.manifest.ManifestIdentity = "manifest-official-a"
|
||||||
|
backend.manifest.Generation = 4
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.RefreshInterval = 0
|
||||||
|
if err := cfg.Normalize(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
server := NewServer(cfg, backend, nil)
|
||||||
|
if err := server.Refresh(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !server.index().Summary().Ready {
|
||||||
|
t.Fatal("initial snapshot should be ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
backend.attestation = testAttestation(root, "official-b", "verified", true)
|
||||||
|
backend.catalog = mustCatalogForTest(t, root, "official-b")
|
||||||
|
if err := server.Refresh(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if summary := server.index().Summary(); summary.Ready || summary.ResourceRoot != "" {
|
||||||
|
t.Fatalf("mixed snapshot was retained: %+v", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCatalogForTest(t *testing.T, root, releaseID string) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := json.Marshal(map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"version": map[string]any{
|
||||||
|
"id": releaseID,
|
||||||
|
"resource_root": root,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaleOrInvalidMatchingAttestationNeverReadiesIndex(t *testing.T) {
|
||||||
|
for _, integrity := range []string{"stale", "invalid"} {
|
||||||
|
t.Run(integrity, func(t *testing.T) {
|
||||||
|
root := fixtureRoot(t)
|
||||||
|
backend := fixtureRPCBackend(t, root)
|
||||||
|
backend.attestation = testAttestation(root, "official-a", integrity, false)
|
||||||
|
backend.attestation.EntryCount = 2
|
||||||
|
backend.catalog = mustCatalogForTest(t, root, "official-a")
|
||||||
|
backend.manifest.ReleaseID = "official-a"
|
||||||
|
backend.manifest.PublicationIdentity = "publication-official-a"
|
||||||
|
backend.manifest.MappingIdentity = "mapping-official-a"
|
||||||
|
backend.manifest.ManifestIdentity = "manifest-official-a"
|
||||||
|
backend.manifest.Generation = 4
|
||||||
|
result, err := DiscoverAndIndex(context.Background(), backend, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Index == nil || result.Index.Summary().Ready ||
|
||||||
|
result.Index.Summary().Distribution.Ready {
|
||||||
|
t.Fatalf("summary=%+v", result.Index.Summary())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpiredReadyAttestationNeverReadiesIndex(t *testing.T) {
|
||||||
|
root := fixtureRoot(t)
|
||||||
|
backend := fixtureRPCBackend(t, root)
|
||||||
|
attestation := testAttestation(root, "official-fixture", "verified", true)
|
||||||
|
expired := uint64(time.Now().Unix()) - attestation.MaxAgeSeconds - 1
|
||||||
|
attestation.VerifiedAt = &expired
|
||||||
|
backend.attestation = attestation
|
||||||
|
result, err := DiscoverAndIndex(context.Background(), backend, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Index == nil || result.Index.Summary().Ready {
|
||||||
|
t.Fatalf("expired attestation unexpectedly ready: %+v", result.Index.Summary())
|
||||||
|
}
|
||||||
|
if len(backend.manifestParams) != 0 {
|
||||||
|
t.Fatalf("manifest should not be fetched for expired attestation: %+v", backend.manifestParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
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, destination string) (*backendrpc.ReleaseDistributionPage, error) {
|
||||||
|
return s.loadReleaseDistributionFrom(r, backendrpc.ReleaseDistributionParams{
|
||||||
|
Channel: channel,
|
||||||
|
ReleaseID: releaseID,
|
||||||
|
Destination: destination,
|
||||||
|
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 destination := strings.TrimSpace(query.Get("destination")); destination != "" {
|
||||||
|
params.Destination = destination
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if params.Destination != "" {
|
||||||
|
if result.Total != 1 || result.Offset != 0 || result.Limit != 1 || len(result.Entries) != 1 {
|
||||||
|
return nil, &releaseSelectorError{message: "Rust single-entry release distribution response is invalid"}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if result.Total <= len(result.Entries) {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
Destination: params.Destination,
|
||||||
|
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,376 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bat-api/internal/backendrpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
type releaseBackendStub struct {
|
||||||
|
*fakeBackend
|
||||||
|
root string
|
||||||
|
available bool
|
||||||
|
distributionParams []backendrpc.ReleaseDistributionParams
|
||||||
|
largeDistribution bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type variableDistributionBackend struct {
|
||||||
|
*releaseBackendStub
|
||||||
|
officialRoot string
|
||||||
|
localizedRoot string
|
||||||
|
officialBytes []byte
|
||||||
|
localizedBytes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *variableDistributionBackend) ReleaseDistribution(_ context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
|
||||||
|
b.distributionParams = append(b.distributionParams, params)
|
||||||
|
root := b.officialRoot
|
||||||
|
data := b.officialBytes
|
||||||
|
channel := "official"
|
||||||
|
releaseID := "official-1"
|
||||||
|
hash := "official-b3"
|
||||||
|
if params.Channel == "localized" {
|
||||||
|
root = b.localizedRoot
|
||||||
|
data = b.localizedBytes
|
||||||
|
channel = "localized"
|
||||||
|
releaseID = "localized-1"
|
||||||
|
hash = "localized-b3"
|
||||||
|
}
|
||||||
|
return &backendrpc.ReleaseDistributionPage{
|
||||||
|
Available: true,
|
||||||
|
Channel: channel,
|
||||||
|
ReleaseID: releaseID,
|
||||||
|
ResourceRoot: root,
|
||||||
|
Status: "ready",
|
||||||
|
StatusCode: "distribution.ready",
|
||||||
|
ArtifactIntegrityStatus: "valid",
|
||||||
|
Total: 1,
|
||||||
|
Offset: 0,
|
||||||
|
Limit: 1,
|
||||||
|
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: uint64(len(data)),
|
||||||
|
BLAKE3: hash,
|
||||||
|
}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if b.largeDistribution {
|
||||||
|
target := "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
|
||||||
|
if params.Destination != "" {
|
||||||
|
return &backendrpc.ReleaseDistributionPage{
|
||||||
|
Available: b.available,
|
||||||
|
Channel: params.Channel,
|
||||||
|
ReleaseID: params.ReleaseID,
|
||||||
|
ResourceRoot: b.root,
|
||||||
|
Status: "ready",
|
||||||
|
StatusCode: "distribution.ready",
|
||||||
|
ArtifactIntegrityStatus: "valid",
|
||||||
|
Total: 1,
|
||||||
|
Offset: 0,
|
||||||
|
Limit: 1,
|
||||||
|
Entries: []backendrpc.ReleaseDistributionEntry{{
|
||||||
|
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
|
||||||
|
Destination: target,
|
||||||
|
Bytes: 21,
|
||||||
|
BLAKE3: "not-used-by-http-index",
|
||||||
|
}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
entries := make([]backendrpc.ReleaseDistributionEntry, 5000)
|
||||||
|
for index := range entries {
|
||||||
|
entries[index] = backendrpc.ReleaseDistributionEntry{
|
||||||
|
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/resource.bytes",
|
||||||
|
Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/resource-" + strconv.Itoa(index) + ".bytes",
|
||||||
|
Bytes: 21,
|
||||||
|
BLAKE3: "not-used-by-http-index",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries[0].Destination = target
|
||||||
|
return &backendrpc.ReleaseDistributionPage{
|
||||||
|
Available: b.available,
|
||||||
|
Channel: params.Channel,
|
||||||
|
ReleaseID: params.ReleaseID,
|
||||||
|
ResourceRoot: b.root,
|
||||||
|
Status: "ready",
|
||||||
|
StatusCode: "distribution.ready",
|
||||||
|
ArtifactIntegrityStatus: "valid",
|
||||||
|
Total: len(entries),
|
||||||
|
Offset: 0,
|
||||||
|
Limit: len(entries),
|
||||||
|
Entries: entries,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return &backendrpc.ReleaseDistributionPage{
|
||||||
|
Available: b.available,
|
||||||
|
Channel: params.Channel,
|
||||||
|
ReleaseID: params.ReleaseID,
|
||||||
|
ResourceRoot: b.root,
|
||||||
|
Status: "ready",
|
||||||
|
StatusCode: "distribution.ready",
|
||||||
|
ArtifactIntegrityStatus: "valid",
|
||||||
|
Total: 1,
|
||||||
|
Offset: 0,
|
||||||
|
Limit: 1,
|
||||||
|
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 TestCDNSingleEntryLookupDoesNotPaginateLargeDistribution(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.RequireIndexed = false
|
||||||
|
if err := cfg.Normalize(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend := &releaseBackendStub{
|
||||||
|
fakeBackend: &fakeBackend{},
|
||||||
|
root: fixtureRoot(t),
|
||||||
|
available: true,
|
||||||
|
largeDistribution: true,
|
||||||
|
}
|
||||||
|
server := NewServer(cfg, backend, nil)
|
||||||
|
request := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized&release_id=localized-1",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Body.String() != "TABLE_CATALOG_FIXTURE" {
|
||||||
|
t.Fatalf("large distribution CDN status=%d body=%q", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if len(backend.distributionParams) != 1 {
|
||||||
|
t.Fatalf("single-entry lookup made %d backend calls", len(backend.distributionParams))
|
||||||
|
}
|
||||||
|
params := backend.distributionParams[0]
|
||||||
|
if params.Destination != "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes" {
|
||||||
|
t.Fatalf("single-entry destination=%q", params.Destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCDNUsesLocalizedBytesAndHashForGetAndHead(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.RequireIndexed = false
|
||||||
|
if err := cfg.Normalize(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rel := filepath.FromSlash("prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes")
|
||||||
|
officialBytes := []byte("official-A")
|
||||||
|
localizedBytes := []byte("localized-B-with-a-different-length")
|
||||||
|
officialRoot := t.TempDir()
|
||||||
|
localizedRoot := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(officialRoot, rel)), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filepath.Join(localizedRoot, rel)), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(officialRoot, rel), officialBytes, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(localizedRoot, rel), localizedBytes, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend := &variableDistributionBackend{
|
||||||
|
releaseBackendStub: &releaseBackendStub{fakeBackend: &fakeBackend{}},
|
||||||
|
officialRoot: officialRoot,
|
||||||
|
localizedRoot: localizedRoot,
|
||||||
|
officialBytes: officialBytes,
|
||||||
|
localizedBytes: localizedBytes,
|
||||||
|
}
|
||||||
|
server := NewServer(cfg, backend, nil)
|
||||||
|
|
||||||
|
localizedURL := "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=localized&release_id=localized-1"
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, localizedURL, nil))
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Body.String() != string(localizedBytes) {
|
||||||
|
t.Fatalf("localized GET status=%d body=%q", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if recorder.Header().Get("ETag") != `"blake3-localized-b3"` {
|
||||||
|
t.Fatalf("localized ETag=%q", recorder.Header().Get("ETag"))
|
||||||
|
}
|
||||||
|
if recorder.Result().ContentLength != int64(len(localizedBytes)) {
|
||||||
|
t.Fatalf("localized Content-Length=%d", recorder.Result().ContentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodHead, localizedURL, nil))
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Body.Len() != 0 {
|
||||||
|
t.Fatalf("localized HEAD status=%d body=%d", recorder.Code, recorder.Body.Len())
|
||||||
|
}
|
||||||
|
if recorder.Header().Get("ETag") != `"blake3-localized-b3"` ||
|
||||||
|
recorder.Result().ContentLength != int64(len(localizedBytes)) {
|
||||||
|
t.Fatalf("localized HEAD headers etag=%q length=%d", recorder.Header().Get("ETag"), recorder.Result().ContentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
officialURL := "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes?channel=official&release_id=official-1"
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
server.Handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, officialURL, nil))
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Body.String() != string(officialBytes) {
|
||||||
|
t.Fatalf("official GET status=%d body=%q", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if recorder.Header().Get("ETag") != `"blake3-official-b3"` ||
|
||||||
|
recorder.Result().ContentLength != int64(len(officialBytes)) {
|
||||||
|
t.Fatalf("official headers etag=%q length=%d", recorder.Header().Get("ETag"), recorder.Result().ContentLength)
|
||||||
|
}
|
||||||
|
if len(backend.distributionParams) != 3 {
|
||||||
|
t.Fatalf("backend calls=%d want=3", len(backend.distributionParams))
|
||||||
|
}
|
||||||
|
for _, params := range backend.distributionParams {
|
||||||
|
if params.Destination != "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes" {
|
||||||
|
t.Fatalf("backend destination=%q", params.Destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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&destination=TableBundles%2FTableCatalog.bytes", 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].Destination != "TableBundles/TableCatalog.bytes" ||
|
||||||
|
backend.distributionParams[0].Offset != 0 ||
|
||||||
|
backend.distributionParams[0].Limit != 0 {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,17 +55,43 @@ type GameMainConfigSummary struct {
|
|||||||
DefaultConnectionGroup string `json:"default_connection_group,omitempty"`
|
DefaultConnectionGroup string `json:"default_connection_group,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DistributionHealth is the release-level authorization used by read paths.
|
||||||
|
//
|
||||||
|
// In RPC mode these fields are copied from Rust's current official
|
||||||
|
// attestation. The local manifest checks only establish that this process has
|
||||||
|
// a complete, safe read snapshot; they do not replace Rust's verifier.
|
||||||
|
type DistributionHealth struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
ReleaseID string `json:"release_id,omitempty"`
|
||||||
|
ResourceRoot string `json:"resource_root,omitempty"`
|
||||||
|
PublicationIdentity string `json:"publication_identity,omitempty"`
|
||||||
|
MappingIdentity string `json:"mapping_identity,omitempty"`
|
||||||
|
ManifestIdentity string `json:"manifest_identity,omitempty"`
|
||||||
|
EntryCount int `json:"entry_count,omitempty"`
|
||||||
|
VerificationGeneration uint64 `json:"verification_generation,omitempty"`
|
||||||
|
VerifiedAt *uint64 `json:"verified_at,omitempty"`
|
||||||
|
MaxAgeSeconds uint64 `json:"max_age_seconds,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
StatusCode string `json:"status_code,omitempty"`
|
||||||
|
IntegrityStatus string `json:"integrity_status,omitempty"`
|
||||||
|
Diagnostics []string `json:"diagnostics,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ReleaseIndex is the in-memory view of a published resource root.
|
// ReleaseIndex is the in-memory view of a published resource root.
|
||||||
type ReleaseIndex struct {
|
type ReleaseIndex struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
ResourceRoot string `json:"resource_root"`
|
ResourceRoot string `json:"resource_root"`
|
||||||
Source string `json:"source"` // "rpc" | "resource_root" | "empty"
|
Source string `json:"source"` // "rpc" | "resource_root" | "empty"
|
||||||
RPCAvailable bool `json:"rpc_available"`
|
RPCAvailable bool `json:"rpc_available"`
|
||||||
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
|
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
|
||||||
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
|
Distribution DistributionHealth `json:"distribution"`
|
||||||
ManifestVersion int `json:"manifest_version,omitempty"`
|
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
|
||||||
Entries []ResourceEntry `json:"entries"`
|
ManifestVersion int `json:"manifest_version,omitempty"`
|
||||||
|
Entries []ResourceEntry `json:"entries"`
|
||||||
// byRel maps relative path (host/path...) to entry index.
|
// byRel maps relative path (host/path...) to entry index.
|
||||||
byRel map[string]int
|
byRel map[string]int
|
||||||
// MissingOnDisk lists relative paths present in the index but absent on disk.
|
// MissingOnDisk lists relative paths present in the index but absent on disk.
|
||||||
@@ -74,16 +100,17 @@ type ReleaseIndex struct {
|
|||||||
|
|
||||||
// Summary returns a JSON-serializable overview without the full entry list.
|
// Summary returns a JSON-serializable overview without the full entry list.
|
||||||
type ReleaseSummary struct {
|
type ReleaseSummary struct {
|
||||||
ResourceRoot string `json:"resource_root"`
|
ResourceRoot string `json:"resource_root"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
RPCAvailable bool `json:"rpc_available"`
|
RPCAvailable bool `json:"rpc_available"`
|
||||||
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
|
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
|
||||||
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
|
Distribution DistributionHealth `json:"distribution"`
|
||||||
ManifestVersion int `json:"manifest_version,omitempty"`
|
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
|
||||||
EntryCount int `json:"entry_count"`
|
ManifestVersion int `json:"manifest_version,omitempty"`
|
||||||
PresentCount int `json:"present_count"`
|
EntryCount int `json:"entry_count"`
|
||||||
MissingCount int `json:"missing_count"`
|
PresentCount int `json:"present_count"`
|
||||||
Ready bool `json:"ready"`
|
MissingCount int `json:"missing_count"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary builds a compact release overview.
|
// Summary builds a compact release overview.
|
||||||
@@ -96,20 +123,37 @@ func (idx *ReleaseIndex) Summary() ReleaseSummary {
|
|||||||
present++
|
present++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
distribution := idx.Distribution
|
||||||
|
distribution.Diagnostics = append([]string(nil), idx.Distribution.Diagnostics...)
|
||||||
|
localComplete := idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries)
|
||||||
|
// Hand-built indexes are retained for compatibility with local tests and
|
||||||
|
// diagnostics. Any index explicitly sourced from RPC must carry the Rust
|
||||||
|
// health fact; an RPC index without it is never considered distributable.
|
||||||
|
if distribution.Source == "" {
|
||||||
|
distribution.Ready = localComplete && idx.Source != "rpc" && idx.Source != "rpc+local_manifest"
|
||||||
|
} else {
|
||||||
|
distribution.Ready = distribution.Available &&
|
||||||
|
distribution.Ready &&
|
||||||
|
localComplete &&
|
||||||
|
(idx.Source != "rpc" ||
|
||||||
|
(distribution.ManifestIdentity != "" &&
|
||||||
|
distribution.EntryCount == len(idx.Entries)))
|
||||||
|
}
|
||||||
return ReleaseSummary{
|
return ReleaseSummary{
|
||||||
ResourceRoot: idx.ResourceRoot,
|
ResourceRoot: idx.ResourceRoot,
|
||||||
Source: idx.Source,
|
Source: idx.Source,
|
||||||
RPCAvailable: idx.RPCAvailable,
|
RPCAvailable: idx.RPCAvailable,
|
||||||
DoctorHealthy: idx.DoctorHealthy,
|
DoctorHealthy: idx.DoctorHealthy,
|
||||||
|
Distribution: distribution,
|
||||||
Snapshot: idx.Snapshot,
|
Snapshot: idx.Snapshot,
|
||||||
ManifestVersion: idx.ManifestVersion,
|
ManifestVersion: idx.ManifestVersion,
|
||||||
EntryCount: len(idx.Entries),
|
EntryCount: len(idx.Entries),
|
||||||
PresentCount: present,
|
PresentCount: present,
|
||||||
MissingCount: len(idx.MissingOnDisk),
|
MissingCount: len(idx.MissingOnDisk),
|
||||||
// A release is distributable only when every manifest entry is present
|
// A release is distributable only when Rust authorizes it and every
|
||||||
// and has the expected size. Serving a partial release can leave clients
|
// entry in this process's read snapshot is usable. Serving a partial
|
||||||
// with an apparently valid bootstrap and an unrecoverable download set.
|
// release can leave clients with an unrecoverable download set.
|
||||||
Ready: idx.ResourceRoot != "" && len(idx.Entries) > 0 && present == len(idx.Entries),
|
Ready: distribution.Ready,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +202,7 @@ func BuildIndexFromManifestEntries(
|
|||||||
snapshot *SnapshotSummary,
|
snapshot *SnapshotSummary,
|
||||||
manifestVersion int,
|
manifestVersion int,
|
||||||
entries []manifestEntry,
|
entries []manifestEntry,
|
||||||
|
distribution DistributionHealth,
|
||||||
) (*ReleaseIndex, error) {
|
) (*ReleaseIndex, error) {
|
||||||
rootAbs, err := filepath.Abs(resourceRoot)
|
rootAbs, err := filepath.Abs(resourceRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -168,7 +213,8 @@ func BuildIndexFromManifestEntries(
|
|||||||
Source: source,
|
Source: source,
|
||||||
RPCAvailable: rpcAvailable,
|
RPCAvailable: rpcAvailable,
|
||||||
DoctorHealthy: doctorHealthy,
|
DoctorHealthy: doctorHealthy,
|
||||||
Snapshot: snapshot,
|
Distribution: distribution,
|
||||||
|
Snapshot: snapshotWithDistributionHealth(snapshot, distribution),
|
||||||
ManifestVersion: manifestVersion,
|
ManifestVersion: manifestVersion,
|
||||||
byRel: make(map[string]int),
|
byRel: make(map[string]int),
|
||||||
}
|
}
|
||||||
@@ -258,7 +304,22 @@ func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return BuildIndexFromManifestEntries(rootAbs, "resource_root", false, nil, snapshot, manifest.Version, entries)
|
return BuildIndexFromManifestEntries(
|
||||||
|
rootAbs,
|
||||||
|
"resource_root",
|
||||||
|
false,
|
||||||
|
nil,
|
||||||
|
snapshot,
|
||||||
|
manifest.Version,
|
||||||
|
entries,
|
||||||
|
DistributionHealth{
|
||||||
|
Available: true,
|
||||||
|
Ready: true,
|
||||||
|
Source: "resource_root_override",
|
||||||
|
Status: "ready",
|
||||||
|
StatusCode: "resource_root.ready",
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
type manifestEntry struct {
|
type manifestEntry struct {
|
||||||
|
|||||||
+15
-13
@@ -51,16 +51,17 @@ type BootstrapAPI struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapResource struct {
|
type BootstrapResource struct {
|
||||||
Release *SnapshotSummary `json:"release,omitempty"`
|
Release *SnapshotSummary `json:"release,omitempty"`
|
||||||
ResourceRoot string `json:"resource_root"`
|
ResourceRoot string `json:"resource_root"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
ManifestVersion int `json:"manifest_version,omitempty"`
|
Distribution DistributionHealth `json:"distribution"`
|
||||||
EntryCount int `json:"entry_count"`
|
ManifestVersion int `json:"manifest_version,omitempty"`
|
||||||
PresentCount int `json:"present_count"`
|
EntryCount int `json:"entry_count"`
|
||||||
MissingCount int `json:"missing_count"`
|
PresentCount int `json:"present_count"`
|
||||||
ServerInfoURL string `json:"server_info_url"`
|
MissingCount int `json:"missing_count"`
|
||||||
ClientPatchBaseURL string `json:"client_patch_base_url"`
|
ServerInfoURL string `json:"server_info_url"`
|
||||||
AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"`
|
ClientPatchBaseURL string `json:"client_patch_base_url"`
|
||||||
|
AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BootstrapPolicy struct {
|
type BootstrapPolicy struct {
|
||||||
@@ -113,9 +114,10 @@ type LauncherPolicy struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LauncherResource struct {
|
type LauncherResource struct {
|
||||||
Release *SnapshotSummary `json:"release,omitempty"`
|
Release *SnapshotSummary `json:"release,omitempty"`
|
||||||
ServerInfoURL string `json:"server_info_url"`
|
Distribution DistributionHealth `json:"distribution"`
|
||||||
ClientPatchBaseURL string `json:"client_patch_base_url"`
|
ServerInfoURL string `json:"server_info_url"`
|
||||||
|
ClientPatchBaseURL string `json:"client_patch_base_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LauncherEndpointSet struct {
|
type LauncherEndpointSet struct {
|
||||||
|
|||||||
+391
-42
@@ -6,22 +6,25 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"bat-api/internal/backendrpc"
|
"bat-api/internal/backendrpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Backend is the subset of daemon RPC used by bat-api.
|
// Backend is the subset of daemon RPC used by bat-api.
|
||||||
//
|
//
|
||||||
// Call order for discovery (per plan review):
|
// Call order for discovery:
|
||||||
// 1. daemon.status
|
// 1. daemon.status
|
||||||
// 2. daemon.doctor
|
// 2. daemon.doctor
|
||||||
// 3. catalog.status / resource.manifest (and resource.state as needed)
|
// 3. release.attestation
|
||||||
|
// 4. catalog.status / bound resource.manifest (and resource.state as needed)
|
||||||
type Backend interface {
|
type Backend interface {
|
||||||
DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error)
|
DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error)
|
||||||
DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error)
|
DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error)
|
||||||
ResourceState(ctx context.Context) (*backendrpc.ResourceState, error)
|
ResourceState(ctx context.Context) (*backendrpc.ResourceState, error)
|
||||||
CatalogStatus(ctx context.Context) (json.RawMessage, error)
|
CatalogStatus(ctx context.Context) (json.RawMessage, error)
|
||||||
ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error)
|
ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error)
|
||||||
|
ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControlBackend is the explicitly allowlisted mutation subset exposed through
|
// ControlBackend is the explicitly allowlisted mutation subset exposed through
|
||||||
@@ -90,6 +93,21 @@ type TranslationMemoryBackend interface {
|
|||||||
TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error)
|
TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error)
|
||||||
TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error)
|
TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, error)
|
||||||
TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error)
|
TranslationMemoryConfirm(ctx context.Context, params backendrpc.TranslationMemoryConfirmParams) (*backendrpc.TranslationMemoryConfirmReport, error)
|
||||||
|
TranslationMemoryConflicts(ctx context.Context, params backendrpc.TranslationMemoryConflictsParams) (*backendrpc.TranslationMemoryConflictsReport, error)
|
||||||
|
TranslationMemoryResolveConflict(ctx context.Context, params backendrpc.TranslationMemoryResolveConflictParams) (*backendrpc.TranslationMemoryResolveConflictReport, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlossaryBackend exposes Rust-owned Glossary management and diagnostics.
|
||||||
|
// Go forwards these typed calls and never opens the Glossary database.
|
||||||
|
type GlossaryBackend interface {
|
||||||
|
GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error)
|
||||||
|
GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error)
|
||||||
|
GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error)
|
||||||
|
GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error)
|
||||||
|
GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error)
|
||||||
|
GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
|
||||||
|
GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
|
||||||
|
GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LocalizedBackend exposes localized release status and the explicit
|
// LocalizedBackend exposes localized release status and the explicit
|
||||||
@@ -100,6 +118,22 @@ type LocalizedBackend interface {
|
|||||||
LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error)
|
LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReleaseStatusBackend exposes the Rust-owned release health fact used during
|
||||||
|
// discovery. It is kept separate so lightweight test/diagnostic backends do
|
||||||
|
// not have to implement the administrative release surface.
|
||||||
|
type ReleaseStatusBackend interface {
|
||||||
|
ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseBackend exposes Rust-owned dual-release queries, distribution
|
||||||
|
// selection and the explicit cleanup operation.
|
||||||
|
type ReleaseBackend interface {
|
||||||
|
ReleaseStatusBackend
|
||||||
|
ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error)
|
||||||
|
ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error)
|
||||||
|
ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error)
|
||||||
|
}
|
||||||
|
|
||||||
// RPCClient adapts *backendrpc.Client to Backend.
|
// RPCClient adapts *backendrpc.Client to Backend.
|
||||||
type RPCClient struct {
|
type RPCClient struct {
|
||||||
Client *backendrpc.Client
|
Client *backendrpc.Client
|
||||||
@@ -117,8 +151,11 @@ func (r RPCClient) ResourceState(ctx context.Context) (*backendrpc.ResourceState
|
|||||||
func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return r.Client.CatalogStatus(ctx)
|
return r.Client.CatalogStatus(ctx)
|
||||||
}
|
}
|
||||||
func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
|
func (r RPCClient) ResourceManifest(ctx context.Context, params backendrpc.ResourceManifestParams) (*backendrpc.ResourceManifestPage, error) {
|
||||||
return r.Client.ResourceManifest(ctx, offset, limit)
|
return r.Client.ResourceManifest(ctx, params)
|
||||||
|
}
|
||||||
|
func (r RPCClient) ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error) {
|
||||||
|
return r.Client.ReleaseAttestation(ctx)
|
||||||
}
|
}
|
||||||
func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
|
func (r RPCClient) DaemonRestart(ctx context.Context) (*backendrpc.Ack, error) {
|
||||||
return r.Client.DaemonRestart(ctx)
|
return r.Client.DaemonRestart(ctx)
|
||||||
@@ -203,6 +240,39 @@ func (r RPCClient) TranslationMemoryConfirm(ctx context.Context, params backendr
|
|||||||
return r.Client.TranslationMemoryConfirm(ctx, params)
|
return r.Client.TranslationMemoryConfirm(ctx, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r RPCClient) TranslationMemoryConflicts(ctx context.Context, params backendrpc.TranslationMemoryConflictsParams) (*backendrpc.TranslationMemoryConflictsReport, error) {
|
||||||
|
return r.Client.TranslationMemoryConflicts(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RPCClient) TranslationMemoryResolveConflict(ctx context.Context, params backendrpc.TranslationMemoryResolveConflictParams) (*backendrpc.TranslationMemoryResolveConflictReport, error) {
|
||||||
|
return r.Client.TranslationMemoryResolveConflict(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RPCClient) GlossarySummary(ctx context.Context, params backendrpc.GlossarySummaryParams) (*backendrpc.GlossarySummaryReport, error) {
|
||||||
|
return r.Client.GlossarySummary(ctx, params)
|
||||||
|
}
|
||||||
|
func (r RPCClient) GlossaryQuery(ctx context.Context, params backendrpc.GlossaryQueryParams) (*backendrpc.GlossaryQueryReport, error) {
|
||||||
|
return r.Client.GlossaryQuery(ctx, params)
|
||||||
|
}
|
||||||
|
func (r RPCClient) GlossaryDiagnose(ctx context.Context, params backendrpc.GlossaryDiagnoseParams) (*backendrpc.GlossaryDiagnoseReport, error) {
|
||||||
|
return r.Client.GlossaryDiagnose(ctx, params)
|
||||||
|
}
|
||||||
|
func (r RPCClient) GlossaryAdd(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
|
||||||
|
return r.Client.GlossaryAdd(ctx, params)
|
||||||
|
}
|
||||||
|
func (r RPCClient) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) {
|
||||||
|
return r.Client.GlossaryUpdate(ctx, params)
|
||||||
|
}
|
||||||
|
func (r RPCClient) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
|
||||||
|
return r.Client.GlossaryApprove(ctx, params)
|
||||||
|
}
|
||||||
|
func (r RPCClient) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
|
||||||
|
return r.Client.GlossaryDeprecate(ctx, params)
|
||||||
|
}
|
||||||
|
func (r RPCClient) GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error) {
|
||||||
|
return r.Client.GlossaryDelete(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
|
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return r.Client.LocalizedStatus(ctx)
|
return r.Client.LocalizedStatus(ctx)
|
||||||
}
|
}
|
||||||
@@ -215,6 +285,22 @@ func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.Loca
|
|||||||
return r.Client.LocalizedRollback(ctx, params)
|
return r.Client.LocalizedRollback(ctx, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r RPCClient) ReleaseStatus(ctx context.Context) (*backendrpc.ReleaseStatusReport, error) {
|
||||||
|
return r.Client.ReleaseStatus(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RPCClient) ReleaseList(ctx context.Context, params backendrpc.ReleaseListParams) (*backendrpc.ReleaseListReport, error) {
|
||||||
|
return r.Client.ReleaseList(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RPCClient) ReleaseDistribution(ctx context.Context, params backendrpc.ReleaseDistributionParams) (*backendrpc.ReleaseDistributionPage, error) {
|
||||||
|
return r.Client.ReleaseDistribution(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RPCClient) ReleaseCleanup(ctx context.Context, params backendrpc.ReleaseCleanupParams) (*backendrpc.ReleaseCleanupReport, error) {
|
||||||
|
return r.Client.ReleaseCleanup(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
|
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return r.Client.ParseStatus(ctx)
|
return r.Client.ParseStatus(ctx)
|
||||||
}
|
}
|
||||||
@@ -240,14 +326,20 @@ type DiscoverResult struct {
|
|||||||
DoctorHealthy *bool
|
DoctorHealthy *bool
|
||||||
Status *backendrpc.DaemonStatusReport
|
Status *backendrpc.DaemonStatusReport
|
||||||
Doctor *backendrpc.DoctorReport
|
Doctor *backendrpc.DoctorReport
|
||||||
|
Attestation *backendrpc.DistributionAttestation
|
||||||
|
ReleaseStatus *backendrpc.ReleaseStatusReport
|
||||||
|
Distribution DistributionHealth
|
||||||
Snapshot *SnapshotSummary
|
Snapshot *SnapshotSummary
|
||||||
ResourceRoot string
|
ResourceRoot string
|
||||||
Index *ReleaseIndex
|
Index *ReleaseIndex
|
||||||
Warnings []string
|
Warnings []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// DiscoverAndIndex contacts the daemon (status first, then doctor) and builds
|
// DiscoverAndIndex contacts the daemon (status first, then doctor, then the
|
||||||
// a release index from paginated resource.manifest plus on-disk checks.
|
// lightweight current-release attestation) and builds a release index from
|
||||||
|
// pages bound to that attestation plus on-disk checks. Rust's attestation is
|
||||||
|
// the only release-level integrity authorization used for the production RPC
|
||||||
|
// path; release.status remains an administrative diagnostic.
|
||||||
//
|
//
|
||||||
// If resourceRootOverride is non-empty, it wins over RPC-reported roots after
|
// If resourceRootOverride is non-empty, it wins over RPC-reported roots after
|
||||||
// RPC health probes (still preferred for production to call status/doctor).
|
// RPC health probes (still preferred for production to call status/doctor).
|
||||||
@@ -263,6 +355,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out.ResourceRoot = idx.ResourceRoot
|
out.ResourceRoot = idx.ResourceRoot
|
||||||
|
out.Distribution = idx.Distribution
|
||||||
out.Index = idx
|
out.Index = idx
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -277,6 +370,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr)
|
return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr)
|
||||||
}
|
}
|
||||||
out.ResourceRoot = idx.ResourceRoot
|
out.ResourceRoot = idx.ResourceRoot
|
||||||
|
out.Distribution = idx.Distribution
|
||||||
out.Index = idx
|
out.Index = idx
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -296,9 +390,60 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
out.DoctorHealthy = &h
|
out.DoctorHealthy = &h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An explicit root is a fixture/emergency read-only override. Keep it
|
||||||
|
// outside the production RPC release-health contract, while still probing
|
||||||
|
// daemon status and doctor first.
|
||||||
|
if resourceRootOverride != "" {
|
||||||
|
idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride)
|
||||||
|
if loadErr != nil {
|
||||||
|
return out, fmt.Errorf("resource-root override load failed: %w", loadErr)
|
||||||
|
}
|
||||||
|
idx.Source = "resource_root"
|
||||||
|
idx.RPCAvailable = true
|
||||||
|
idx.DoctorHealthy = out.DoctorHealthy
|
||||||
|
out.ResourceRoot = idx.ResourceRoot
|
||||||
|
out.Snapshot = idx.Snapshot
|
||||||
|
out.Distribution = idx.Distribution
|
||||||
|
out.Index = idx
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) release.attestation is the Rust-owned current-release distribution gate.
|
||||||
|
attestationBackend, ok := backend.(AttestationBackend)
|
||||||
|
if !ok {
|
||||||
|
out.Warnings = append(out.Warnings, "release.attestation: backend does not expose Rust health proof")
|
||||||
|
return emptyRPCResult(out, nil, "Rust release health is unavailable"),
|
||||||
|
fmt.Errorf("rust release health is unavailable")
|
||||||
|
}
|
||||||
|
attestation, err := attestationBackend.ReleaseAttestation(ctx)
|
||||||
|
if err != nil {
|
||||||
|
out.Warnings = append(out.Warnings, fmt.Sprintf("release.attestation: %v", err))
|
||||||
|
return emptyRPCResult(out, nil, "Rust release health query failed"),
|
||||||
|
fmt.Errorf("release.attestation failed: %w", err)
|
||||||
|
}
|
||||||
|
if attestation == nil {
|
||||||
|
out.Warnings = append(out.Warnings, "release.attestation: empty response")
|
||||||
|
return emptyRPCResult(out, nil, "Rust release health query returned no response"),
|
||||||
|
fmt.Errorf("release.attestation returned an empty response")
|
||||||
|
}
|
||||||
|
out.Attestation = attestation
|
||||||
|
out.Distribution = rustAttestationHealth(attestation)
|
||||||
|
if !attestation.Available ||
|
||||||
|
!attestation.Ready ||
|
||||||
|
attestation.Channel != "official" ||
|
||||||
|
attestation.IntegrityStatus != "verified" ||
|
||||||
|
attestation.VerificationGeneration == 0 ||
|
||||||
|
!attestationIsFresh(attestation) {
|
||||||
|
return emptyRPCResult(
|
||||||
|
out,
|
||||||
|
nil,
|
||||||
|
"Rust current official distribution attestation is unavailable or not ready",
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
// Catalog / resource discovery
|
// Catalog / resource discovery
|
||||||
var snapshot *SnapshotSummary
|
var snapshot *SnapshotSummary
|
||||||
var resourceRoot string
|
resourceRoot := attestation.ResourceRoot
|
||||||
catalogAvailabilityKnown := false
|
catalogAvailabilityKnown := false
|
||||||
catalogAvailable := false
|
catalogAvailable := false
|
||||||
|
|
||||||
@@ -312,6 +457,33 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
resourceRoot = root
|
resourceRoot = root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if snapshot != nil &&
|
||||||
|
snapshot.VersionID != "" &&
|
||||||
|
attestation.ReleaseID != "" &&
|
||||||
|
snapshot.VersionID != attestation.ReleaseID {
|
||||||
|
return emptyRPCResult(
|
||||||
|
out,
|
||||||
|
snapshot,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"release.attestation current ID %q does not match catalog current ID %q",
|
||||||
|
attestation.ReleaseID,
|
||||||
|
snapshot.VersionID,
|
||||||
|
),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
if resourceRoot != "" &&
|
||||||
|
attestation.ResourceRoot != "" &&
|
||||||
|
resourceRoot != attestation.ResourceRoot {
|
||||||
|
return emptyRPCResult(
|
||||||
|
out,
|
||||||
|
snapshot,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"catalog current root %q does not match attestation root %q",
|
||||||
|
resourceRoot,
|
||||||
|
attestation.ResourceRoot,
|
||||||
|
),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
if catalogAvailabilityKnown && !catalogAvailable {
|
if catalogAvailabilityKnown && !catalogAvailable {
|
||||||
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil
|
return emptyRPCResult(out, snapshot, "catalog.status available=false; no published release"), nil
|
||||||
}
|
}
|
||||||
@@ -330,12 +502,13 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
resourceRoot = resourceRootOverride
|
resourceRoot = resourceRootOverride
|
||||||
}
|
}
|
||||||
if resourceRoot == "" {
|
if resourceRoot == "" {
|
||||||
out.Snapshot = snapshot
|
out.Snapshot = snapshotWithDistributionHealth(snapshot, out.Distribution)
|
||||||
out.Index = &ReleaseIndex{
|
out.Index = &ReleaseIndex{
|
||||||
Source: "rpc",
|
Source: "rpc",
|
||||||
RPCAvailable: true,
|
RPCAvailable: true,
|
||||||
DoctorHealthy: out.DoctorHealthy,
|
DoctorHealthy: out.DoctorHealthy,
|
||||||
Snapshot: snapshot,
|
Distribution: out.Distribution,
|
||||||
|
Snapshot: snapshotWithDistributionHealth(snapshot, out.Distribution),
|
||||||
byRel: map[string]int{},
|
byRel: map[string]int{},
|
||||||
}
|
}
|
||||||
out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version")
|
out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version")
|
||||||
@@ -355,33 +528,30 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend)
|
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend, attestation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
|
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
|
||||||
// Fallback: load local manifest file under root.
|
// Without the RPC manifest there is no evidence that the local
|
||||||
idx, loadErr := LoadIndexFromResourceRoot(resourceRoot)
|
// snapshot matches the Rust health fact. Do not pair a fresh health
|
||||||
if loadErr != nil {
|
// result with a potentially stale on-disk manifest.
|
||||||
if resourceRootOverride == "" {
|
return emptyRPCResult(
|
||||||
return emptyRPCResult(
|
out,
|
||||||
out,
|
snapshot,
|
||||||
snapshot,
|
fmt.Sprintf("published release cannot be indexed: %v", err),
|
||||||
fmt.Sprintf("published release cannot be indexed: %v", loadErr),
|
), nil
|
||||||
), nil
|
|
||||||
}
|
|
||||||
return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr)
|
|
||||||
}
|
|
||||||
idx.Source = "rpc+local_manifest"
|
|
||||||
idx.RPCAvailable = true
|
|
||||||
idx.DoctorHealthy = out.DoctorHealthy
|
|
||||||
if snapshot != nil {
|
|
||||||
idx.Snapshot = snapshot
|
|
||||||
}
|
|
||||||
out.ResourceRoot = idx.ResourceRoot
|
|
||||||
out.Snapshot = idx.Snapshot
|
|
||||||
out.Index = idx
|
|
||||||
return out, nil
|
|
||||||
}
|
}
|
||||||
if rootFromManifest != "" {
|
if rootFromManifest != "" {
|
||||||
|
if attestation.ResourceRoot != "" && rootFromManifest != attestation.ResourceRoot {
|
||||||
|
return emptyRPCResult(
|
||||||
|
out,
|
||||||
|
snapshot,
|
||||||
|
fmt.Sprintf(
|
||||||
|
"resource.manifest resource root %q does not match attestation root %q",
|
||||||
|
rootFromManifest,
|
||||||
|
attestation.ResourceRoot,
|
||||||
|
),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
resourceRoot = rootFromManifest
|
resourceRoot = rootFromManifest
|
||||||
}
|
}
|
||||||
if resourceRootOverride != "" {
|
if resourceRootOverride != "" {
|
||||||
@@ -396,6 +566,7 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
snapshot,
|
snapshot,
|
||||||
manifestVersion,
|
manifestVersion,
|
||||||
entries,
|
entries,
|
||||||
|
out.Distribution,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if resourceRootOverride == "" {
|
if resourceRootOverride == "" {
|
||||||
@@ -408,18 +579,32 @@ func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.ResourceRoot = idx.ResourceRoot
|
out.ResourceRoot = idx.ResourceRoot
|
||||||
out.Snapshot = snapshot
|
out.Snapshot = idx.Snapshot
|
||||||
out.Index = idx
|
out.Index = idx
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AttestationBackend exposes the lightweight current official health proof.
|
||||||
|
// It is intentionally separate from ReleaseStatusBackend because health
|
||||||
|
// refreshes must not require the historical release scan.
|
||||||
|
type AttestationBackend interface {
|
||||||
|
ReleaseAttestation(ctx context.Context) (*backendrpc.DistributionAttestation, error)
|
||||||
|
}
|
||||||
|
|
||||||
func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
|
func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning string) *DiscoverResult {
|
||||||
|
distribution := out.Distribution
|
||||||
|
if distribution.Source == "" {
|
||||||
|
distribution = unavailableRustDistributionHealth()
|
||||||
|
out.Distribution = distribution
|
||||||
|
}
|
||||||
|
snapshot = snapshotWithDistributionHealth(snapshot, distribution)
|
||||||
out.ResourceRoot = ""
|
out.ResourceRoot = ""
|
||||||
out.Snapshot = snapshot
|
out.Snapshot = snapshot
|
||||||
out.Index = &ReleaseIndex{
|
out.Index = &ReleaseIndex{
|
||||||
Source: "rpc",
|
Source: "rpc",
|
||||||
RPCAvailable: true,
|
RPCAvailable: true,
|
||||||
DoctorHealthy: out.DoctorHealthy,
|
DoctorHealthy: out.DoctorHealthy,
|
||||||
|
Distribution: distribution,
|
||||||
Snapshot: snapshot,
|
Snapshot: snapshot,
|
||||||
byRel: map[string]int{},
|
byRel: map[string]int{},
|
||||||
}
|
}
|
||||||
@@ -429,6 +614,79 @@ func emptyRPCResult(out *DiscoverResult, snapshot *SnapshotSummary, warning stri
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rustAttestationHealth(report *backendrpc.DistributionAttestation) DistributionHealth {
|
||||||
|
health := DistributionHealth{
|
||||||
|
Source: "rust_release_attestation",
|
||||||
|
Status: "unavailable",
|
||||||
|
StatusCode: "distribution.attestation_unavailable",
|
||||||
|
IntegrityStatus: "unavailable",
|
||||||
|
}
|
||||||
|
if report == nil {
|
||||||
|
return health
|
||||||
|
}
|
||||||
|
health.Available = report.Available
|
||||||
|
health.Ready = report.Ready
|
||||||
|
health.Channel = report.Channel
|
||||||
|
health.ReleaseID = report.ReleaseID
|
||||||
|
health.ResourceRoot = report.ResourceRoot
|
||||||
|
health.PublicationIdentity = report.PublicationIdentity
|
||||||
|
health.MappingIdentity = report.MappingIdentity
|
||||||
|
health.ManifestIdentity = report.ManifestIdentity
|
||||||
|
health.EntryCount = report.EntryCount
|
||||||
|
health.VerificationGeneration = report.VerificationGeneration
|
||||||
|
health.VerifiedAt = report.VerifiedAt
|
||||||
|
health.MaxAgeSeconds = report.MaxAgeSeconds
|
||||||
|
health.Status = report.Status
|
||||||
|
health.StatusCode = report.StatusCode
|
||||||
|
health.IntegrityStatus = report.IntegrityStatus
|
||||||
|
health.Diagnostics = append([]string(nil), report.Diagnostics...)
|
||||||
|
if health.Status == "" {
|
||||||
|
health.Status = "unavailable"
|
||||||
|
}
|
||||||
|
if health.StatusCode == "" {
|
||||||
|
health.StatusCode = "distribution.attestation_unavailable"
|
||||||
|
}
|
||||||
|
if health.IntegrityStatus == "" {
|
||||||
|
health.IntegrityStatus = "unavailable"
|
||||||
|
}
|
||||||
|
return health
|
||||||
|
}
|
||||||
|
|
||||||
|
func unavailableRustDistributionHealth() DistributionHealth {
|
||||||
|
return DistributionHealth{
|
||||||
|
Source: "rust_release_attestation",
|
||||||
|
Status: "unavailable",
|
||||||
|
StatusCode: "distribution.attestation_unavailable",
|
||||||
|
IntegrityStatus: "unavailable",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func attestationIsFresh(attestation *backendrpc.DistributionAttestation) bool {
|
||||||
|
if attestation == nil || attestation.MaxAgeSeconds == 0 || attestation.VerifiedAt == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := uint64(time.Now().Unix())
|
||||||
|
age := uint64(0)
|
||||||
|
if now > *attestation.VerifiedAt {
|
||||||
|
age = now - *attestation.VerifiedAt
|
||||||
|
}
|
||||||
|
return age <= attestation.MaxAgeSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotWithDistributionHealth(snapshot *SnapshotSummary, health DistributionHealth) *SnapshotSummary {
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
updated := *snapshot
|
||||||
|
if health.Status != "" {
|
||||||
|
updated.DistributionStatus = health.Status
|
||||||
|
}
|
||||||
|
if health.StatusCode != "" {
|
||||||
|
updated.DistributionStatusCode = health.StatusCode
|
||||||
|
}
|
||||||
|
return &updated
|
||||||
|
}
|
||||||
|
|
||||||
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
|
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
|
||||||
if len(raw) == 0 || string(raw) == "null" {
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
return nil, "", false
|
return nil, "", false
|
||||||
@@ -488,25 +746,106 @@ func parseCatalogAvailability(raw json.RawMessage) (bool, bool) {
|
|||||||
return *payload.Available, true
|
return *payload.Available, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) {
|
func fetchAllManifestEntries(
|
||||||
const pageSize = 500
|
ctx context.Context,
|
||||||
|
backend Backend,
|
||||||
|
attestation *backendrpc.DistributionAttestation,
|
||||||
|
) ([]manifestEntry, int, string, error) {
|
||||||
|
return fetchAllManifestEntriesWithPageSize(ctx, backend, attestation, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAllManifestEntriesWithPageSize(
|
||||||
|
ctx context.Context,
|
||||||
|
backend Backend,
|
||||||
|
attestation *backendrpc.DistributionAttestation,
|
||||||
|
pageSize int,
|
||||||
|
) ([]manifestEntry, int, string, error) {
|
||||||
|
if pageSize <= 0 {
|
||||||
|
return nil, 0, "", fmt.Errorf("manifest page size must be positive")
|
||||||
|
}
|
||||||
|
if attestation == nil {
|
||||||
|
return nil, 0, "", fmt.Errorf("resource.manifest requires a Rust attestation")
|
||||||
|
}
|
||||||
|
if !attestation.Available ||
|
||||||
|
!attestation.Ready ||
|
||||||
|
attestation.Channel != "official" ||
|
||||||
|
attestation.IntegrityStatus != "verified" ||
|
||||||
|
attestation.VerificationGeneration == 0 ||
|
||||||
|
!attestationIsFresh(attestation) {
|
||||||
|
return nil, 0, "", fmt.Errorf("resource.manifest attestation is not ready")
|
||||||
|
}
|
||||||
offset := 0
|
offset := 0
|
||||||
var all []manifestEntry
|
var all []manifestEntry
|
||||||
var version int
|
var version, limit int
|
||||||
var root string
|
var channel, root, releaseID, publicationIdentity, mappingIdentity, manifestIdentity string
|
||||||
|
var generation uint64
|
||||||
|
total := -1
|
||||||
for {
|
for {
|
||||||
page, err := backend.ResourceManifest(ctx, offset, pageSize)
|
page, err := backend.ResourceManifest(ctx, backendrpc.ResourceManifestParams{
|
||||||
|
ReleaseID: attestation.ReleaseID,
|
||||||
|
ExpectedPublicationIdentity: attestation.PublicationIdentity,
|
||||||
|
ExpectedManifestIdentity: attestation.ManifestIdentity,
|
||||||
|
ExpectedVerificationGeneration: attestation.VerificationGeneration,
|
||||||
|
Offset: offset,
|
||||||
|
Limit: pageSize,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, "", err
|
return nil, 0, "", err
|
||||||
}
|
}
|
||||||
|
if page == nil {
|
||||||
|
return nil, 0, "", fmt.Errorf("resource.manifest returned nil page")
|
||||||
|
}
|
||||||
if !page.Available {
|
if !page.Available {
|
||||||
return nil, 0, "", fmt.Errorf("resource.manifest available=false")
|
return nil, 0, "", fmt.Errorf("resource.manifest available=false")
|
||||||
}
|
}
|
||||||
if root == "" {
|
if root == "" {
|
||||||
root = page.ResourceRoot
|
root = page.ResourceRoot
|
||||||
}
|
channel = page.Channel
|
||||||
if version == 0 {
|
releaseID = page.ReleaseID
|
||||||
|
publicationIdentity = page.PublicationIdentity
|
||||||
|
mappingIdentity = page.MappingIdentity
|
||||||
|
manifestIdentity = page.ManifestIdentity
|
||||||
|
generation = page.Generation
|
||||||
version = page.ManifestVersion
|
version = page.ManifestVersion
|
||||||
|
total = page.TotalEntries
|
||||||
|
limit = page.Limit
|
||||||
|
} else if page.ResourceRoot != root ||
|
||||||
|
page.Channel != channel ||
|
||||||
|
page.ReleaseID != releaseID ||
|
||||||
|
page.PublicationIdentity != publicationIdentity ||
|
||||||
|
page.MappingIdentity != mappingIdentity ||
|
||||||
|
page.ManifestIdentity != manifestIdentity ||
|
||||||
|
page.Generation != generation ||
|
||||||
|
page.ManifestVersion != version ||
|
||||||
|
page.TotalEntries != total ||
|
||||||
|
page.Limit != limit {
|
||||||
|
return nil, 0, "", fmt.Errorf("resource.manifest page identity or total changed")
|
||||||
|
}
|
||||||
|
if page.Offset != offset {
|
||||||
|
return nil, 0, "", fmt.Errorf(
|
||||||
|
"resource.manifest page offset mismatch: requested=%d actual=%d",
|
||||||
|
offset,
|
||||||
|
page.Offset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if page.Limit != pageSize {
|
||||||
|
return nil, 0, "", fmt.Errorf("resource.manifest page limit is unreasonable: %d", page.Limit)
|
||||||
|
}
|
||||||
|
if page.TotalEntries < 0 || len(page.Entries) > page.Limit {
|
||||||
|
return nil, 0, "", fmt.Errorf("resource.manifest page entry count is unreasonable")
|
||||||
|
}
|
||||||
|
if total < 0 || offset > total || offset+len(page.Entries) > total {
|
||||||
|
return nil, 0, "", fmt.Errorf("resource.manifest page exceeds declared total")
|
||||||
|
}
|
||||||
|
if channel != attestation.Channel ||
|
||||||
|
releaseID != attestation.ReleaseID ||
|
||||||
|
root != attestation.ResourceRoot ||
|
||||||
|
publicationIdentity != attestation.PublicationIdentity ||
|
||||||
|
mappingIdentity != attestation.MappingIdentity ||
|
||||||
|
manifestIdentity != attestation.ManifestIdentity ||
|
||||||
|
generation != attestation.VerificationGeneration ||
|
||||||
|
total != attestation.EntryCount {
|
||||||
|
return nil, 0, "", fmt.Errorf("resource.manifest page does not match attestation")
|
||||||
}
|
}
|
||||||
for _, e := range page.Entries {
|
for _, e := range page.Entries {
|
||||||
var bytes uint64
|
var bytes uint64
|
||||||
@@ -521,9 +860,19 @@ func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEn
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
offset += len(page.Entries)
|
offset += len(page.Entries)
|
||||||
if len(page.Entries) == 0 || offset >= page.TotalEntries {
|
if offset == total {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if len(page.Entries) == 0 || len(page.Entries) < page.Limit {
|
||||||
|
return nil, 0, "", fmt.Errorf("resource.manifest page has a gap before total")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(all) != total {
|
||||||
|
return nil, 0, "", fmt.Errorf(
|
||||||
|
"resource.manifest final entry count mismatch: entries=%d total=%d",
|
||||||
|
len(all),
|
||||||
|
total,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return all, version, root, nil
|
return all, version, root, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-10
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -18,9 +19,10 @@ type Server struct {
|
|||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
limiter *tokenBucketLimiter
|
limiter *tokenBucketLimiter
|
||||||
|
|
||||||
mu sync.RWMutex
|
refreshMu sync.Mutex
|
||||||
idx *ReleaseIndex
|
mu sync.RWMutex
|
||||||
meta DiscoverResult
|
idx *ReleaseIndex
|
||||||
|
meta DiscoverResult
|
||||||
|
|
||||||
refreshInProgress bool
|
refreshInProgress bool
|
||||||
lastRefreshStart time.Time
|
lastRefreshStart time.Time
|
||||||
@@ -51,6 +53,8 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("/v1/bootstrap", s.handleBootstrap)
|
mux.HandleFunc("/v1/bootstrap", s.handleBootstrap)
|
||||||
mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap)
|
mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap)
|
||||||
mux.HandleFunc("/v1/release", s.handleRelease)
|
mux.HandleFunc("/v1/release", s.handleRelease)
|
||||||
|
mux.HandleFunc("/v1/releases", s.handleReleaseList)
|
||||||
|
mux.HandleFunc("/v1/distribution", s.handleReleaseDistribution)
|
||||||
mux.HandleFunc("/v1/resources", s.handleResources)
|
mux.HandleFunc("/v1/resources", s.handleResources)
|
||||||
mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug)
|
mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug)
|
||||||
mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig)
|
mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig)
|
||||||
@@ -76,7 +80,13 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
|
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
|
||||||
mux.HandleFunc("/admin/translation/memory/summary", s.handleAdminTranslationMemorySummary)
|
mux.HandleFunc("/admin/translation/memory/summary", s.handleAdminTranslationMemorySummary)
|
||||||
mux.HandleFunc("/admin/translation/memory/query", s.handleAdminTranslationMemoryQuery)
|
mux.HandleFunc("/admin/translation/memory/query", s.handleAdminTranslationMemoryQuery)
|
||||||
|
mux.HandleFunc("/admin/translation/memory/conflicts", s.handleAdminTranslationMemoryConflicts)
|
||||||
|
mux.HandleFunc("/admin/translation/glossary/summary", s.handleAdminGlossarySummary)
|
||||||
|
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/translation/status", s.handleAdminLocalizedStatus)
|
||||||
|
mux.HandleFunc("/admin/releases/status", s.handleAdminReleaseStatus)
|
||||||
|
mux.HandleFunc("/admin/releases", s.handleAdminReleaseList)
|
||||||
mux.HandleFunc("/admin/control/", s.handleAdminControl)
|
mux.HandleFunc("/admin/control/", s.handleAdminControl)
|
||||||
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
||||||
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
||||||
@@ -88,10 +98,46 @@ func (s *Server) Handler() http.Handler {
|
|||||||
|
|
||||||
// Refresh rebuilds the release index via RPC (and optional resource-root override).
|
// Refresh rebuilds the release index via RPC (and optional resource-root override).
|
||||||
func (s *Server) Refresh(ctx context.Context) error {
|
func (s *Server) Refresh(ctx context.Context) error {
|
||||||
|
// Serialize refreshes so an older, slower RPC response cannot replace a
|
||||||
|
// newer snapshot and so refresh diagnostics describe one attempt at a time.
|
||||||
|
s.refreshMu.Lock()
|
||||||
|
defer s.refreshMu.Unlock()
|
||||||
|
|
||||||
started := s.beginRefresh()
|
started := s.beginRefresh()
|
||||||
result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot)
|
result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.finishRefresh(started, err, nil)
|
s.mu.Lock()
|
||||||
|
warnings := []string(nil)
|
||||||
|
if result != nil {
|
||||||
|
s.meta = *result
|
||||||
|
warnings = append(warnings, result.Warnings...)
|
||||||
|
} else {
|
||||||
|
s.meta = DiscoverResult{}
|
||||||
|
}
|
||||||
|
warnings = append(warnings, fmt.Sprintf("refresh: %v", err))
|
||||||
|
s.idx = &ReleaseIndex{
|
||||||
|
Source: "empty",
|
||||||
|
Distribution: unavailableRustDistributionHealth(),
|
||||||
|
byRel: map[string]int{},
|
||||||
|
}
|
||||||
|
s.meta.Index = s.idx
|
||||||
|
s.meta.ResourceRoot = ""
|
||||||
|
s.meta.Distribution = s.idx.Distribution
|
||||||
|
s.finishRefreshLocked(started, err, warnings)
|
||||||
|
s.mu.Unlock()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result == nil || result.Index == nil {
|
||||||
|
err := fmt.Errorf("refresh returned no release index")
|
||||||
|
s.mu.Lock()
|
||||||
|
s.idx = &ReleaseIndex{
|
||||||
|
Source: "empty",
|
||||||
|
Distribution: unavailableRustDistributionHealth(),
|
||||||
|
byRel: map[string]int{},
|
||||||
|
}
|
||||||
|
s.meta = DiscoverResult{Index: s.idx, Distribution: s.idx.Distribution}
|
||||||
|
s.finishRefreshLocked(started, err, []string{err.Error()})
|
||||||
|
s.mu.Unlock()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -129,6 +175,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/v1/bootstrap",
|
"/v1/bootstrap",
|
||||||
"/v1/launcher/bootstrap",
|
"/v1/launcher/bootstrap",
|
||||||
"/v1/release",
|
"/v1/release",
|
||||||
|
"/v1/releases",
|
||||||
|
"/v1/distribution",
|
||||||
"/v1/resources",
|
"/v1/resources",
|
||||||
"/v1/server-info",
|
"/v1/server-info",
|
||||||
"/api/launcher/game/config",
|
"/api/launcher/game/config",
|
||||||
@@ -155,7 +203,13 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/admin/translation/handoff",
|
"/admin/translation/handoff",
|
||||||
"/admin/translation/memory/summary",
|
"/admin/translation/memory/summary",
|
||||||
"/admin/translation/memory/query",
|
"/admin/translation/memory/query",
|
||||||
|
"/admin/translation/memory/conflicts",
|
||||||
|
"/admin/translation/glossary/summary",
|
||||||
|
"/admin/translation/glossary/query",
|
||||||
|
"/admin/translation/glossary/diagnose",
|
||||||
"/admin/translation/status",
|
"/admin/translation/status",
|
||||||
|
"/admin/releases/status",
|
||||||
|
"/admin/releases",
|
||||||
"/admin/control/{action}",
|
"/admin/control/{action}",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -215,6 +269,7 @@ func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
|||||||
Release: sum.Snapshot,
|
Release: sum.Snapshot,
|
||||||
ResourceRoot: sum.ResourceRoot,
|
ResourceRoot: sum.ResourceRoot,
|
||||||
Source: sum.Source,
|
Source: sum.Source,
|
||||||
|
Distribution: sum.Distribution,
|
||||||
ManifestVersion: sum.ManifestVersion,
|
ManifestVersion: sum.ManifestVersion,
|
||||||
EntryCount: sum.EntryCount,
|
EntryCount: sum.EntryCount,
|
||||||
PresentCount: sum.PresentCount,
|
PresentCount: sum.PresentCount,
|
||||||
@@ -273,6 +328,7 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
|||||||
"missing_count": sum.MissingCount,
|
"missing_count": sum.MissingCount,
|
||||||
"source": sum.Source,
|
"source": sum.Source,
|
||||||
"warnings": meta.Warnings,
|
"warnings": meta.Warnings,
|
||||||
|
"distribution": sum.Distribution,
|
||||||
// Database/redis are reserved config surface for a normal API process.
|
// Database/redis are reserved config surface for a normal API process.
|
||||||
"database_configured": s.cfg.DatabaseURL != "",
|
"database_configured": s.cfg.DatabaseURL != "",
|
||||||
"redis_configured": s.cfg.RedisURL != "",
|
"redis_configured": s.cfg.RedisURL != "",
|
||||||
@@ -302,6 +358,7 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
|
|||||||
"present_count": sum.PresentCount,
|
"present_count": sum.PresentCount,
|
||||||
"missing_count": sum.MissingCount,
|
"missing_count": sum.MissingCount,
|
||||||
"source": sum.Source,
|
"source": sum.Source,
|
||||||
|
"distribution": sum.Distribution,
|
||||||
"refresh": s.refreshSnapshot(),
|
"refresh": s.refreshSnapshot(),
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodHead {
|
if r.Method == http.MethodHead {
|
||||||
@@ -416,12 +473,6 @@ func (s *Server) beginRefresh() time.Time {
|
|||||||
return now
|
return now
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) finishRefresh(started time.Time, err error, warnings []string) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
s.finishRefreshLocked(started, err, warnings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) {
|
func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
s.refreshInProgress = false
|
s.refreshInProgress = false
|
||||||
|
|||||||
+6
-1
@@ -7,9 +7,14 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本
|
|||||||
覆盖范围:
|
覆盖范围:
|
||||||
|
|
||||||
- `catalog.status` 可用与不可用响应。
|
- `catalog.status` 可用与不可用响应。
|
||||||
- `resource.manifest` 第一页分页响应。
|
- `resource.manifest` 第一页分页响应,包含 release/publication/mapping/manifest
|
||||||
|
identity 和 generation 绑定字段;生产请求必须回传 attestation 的
|
||||||
|
`expected_verification_generation`。
|
||||||
|
- `release.attestation` 当前 official health/publication proof,默认 fixture freshness
|
||||||
|
window 为 7260 秒(默认 3600 秒验证周期和 60 秒失败重试周期)。
|
||||||
- 对应 release 的 `official-sync-snapshot.json`。
|
- 对应 release 的 `official-sync-snapshot.json`。
|
||||||
- `launcher_metadata` 与 `game_main_config_bootstrap` 的 Go mirror 解码。
|
- `launcher_metadata` 与 `game_main_config_bootstrap` 的 Go mirror 解码。
|
||||||
|
- Rust Glossary domain/feature contract V1、SQLite persistence schema V2 的 query 响应,覆盖 alias、approved review、source provenance 和完整 history。
|
||||||
|
|
||||||
这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live
|
这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live
|
||||||
daemon socket 或完整发布切换验证。
|
daemon socket 或完整发布切换验证。
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"available": true,
|
||||||
|
"path": "${GLOSSARY_PATH}",
|
||||||
|
"source_text": "${SOURCE_TEXT}",
|
||||||
|
"terms": [
|
||||||
|
{
|
||||||
|
"term_id": "${TERM_ID}",
|
||||||
|
"source_term": "${SOURCE_TERM}",
|
||||||
|
"aliases": [
|
||||||
|
"${ALIAS}"
|
||||||
|
],
|
||||||
|
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
|
||||||
|
"allowed_translations": [
|
||||||
|
"${ALLOWED_TRANSLATION}"
|
||||||
|
],
|
||||||
|
"source_language": "en",
|
||||||
|
"target_language": "zh-Hans",
|
||||||
|
"category": "character",
|
||||||
|
"priority": 10,
|
||||||
|
"scope": {
|
||||||
|
"destination": "${DESTINATION}"
|
||||||
|
},
|
||||||
|
"review_status": "approved",
|
||||||
|
"source": {
|
||||||
|
"source_kind": "manual",
|
||||||
|
"source_ref": "${SOURCE_REF}",
|
||||||
|
"source_author": "${SOURCE_AUTHOR}",
|
||||||
|
"source_note": "${SOURCE_NOTE}",
|
||||||
|
"observed_unix_seconds": 100
|
||||||
|
},
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"history_id": "${HISTORY_CREATED_ID}",
|
||||||
|
"action": "created",
|
||||||
|
"source": {
|
||||||
|
"source_kind": "manual",
|
||||||
|
"source_ref": "${SOURCE_REF}",
|
||||||
|
"source_author": "${SOURCE_AUTHOR}",
|
||||||
|
"source_note": "${SOURCE_NOTE}",
|
||||||
|
"observed_unix_seconds": 100
|
||||||
|
},
|
||||||
|
"review_status": "draft",
|
||||||
|
"snapshot": {
|
||||||
|
"source_term": "${SOURCE_TERM}",
|
||||||
|
"aliases": [
|
||||||
|
"${ALIAS}"
|
||||||
|
],
|
||||||
|
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
|
||||||
|
"allowed_translations": [
|
||||||
|
"${ALLOWED_TRANSLATION}"
|
||||||
|
],
|
||||||
|
"source_language": "en",
|
||||||
|
"target_language": "zh-Hans",
|
||||||
|
"category": "character",
|
||||||
|
"priority": 10,
|
||||||
|
"scope": {
|
||||||
|
"destination": "${DESTINATION}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observed_unix_seconds": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"history_id": "${HISTORY_APPROVED_ID}",
|
||||||
|
"action": "approved",
|
||||||
|
"reviewer": "${REVIEWER}",
|
||||||
|
"reason": "reviewed",
|
||||||
|
"source": {
|
||||||
|
"source_kind": "manual",
|
||||||
|
"source_ref": "${SOURCE_REF}",
|
||||||
|
"source_author": "${SOURCE_AUTHOR}",
|
||||||
|
"source_note": "${SOURCE_NOTE}",
|
||||||
|
"observed_unix_seconds": 100
|
||||||
|
},
|
||||||
|
"review_status": "approved",
|
||||||
|
"snapshot": {
|
||||||
|
"source_term": "${SOURCE_TERM}",
|
||||||
|
"aliases": [
|
||||||
|
"${ALIAS}"
|
||||||
|
],
|
||||||
|
"recommended_translation": "${RECOMMENDED_TRANSLATION}",
|
||||||
|
"allowed_translations": [
|
||||||
|
"${ALLOWED_TRANSLATION}"
|
||||||
|
],
|
||||||
|
"source_language": "en",
|
||||||
|
"target_language": "zh-Hans",
|
||||||
|
"category": "character",
|
||||||
|
"priority": 10,
|
||||||
|
"scope": {
|
||||||
|
"destination": "${DESTINATION}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observed_unix_seconds": 101
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_unix_seconds": 100,
|
||||||
|
"updated_unix_seconds": 101
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"available": true,
|
||||||
|
"channel": "official",
|
||||||
|
"diagnostics": [],
|
||||||
|
"entry_count": 2,
|
||||||
|
"integrity_status": "verified",
|
||||||
|
"manifest_identity": "${MANIFEST_IDENTITY}",
|
||||||
|
"mapping_identity": "${MAPPING_IDENTITY}",
|
||||||
|
"max_age_seconds": 7260,
|
||||||
|
"publication_identity": "${PUBLICATION_IDENTITY}",
|
||||||
|
"ready": true,
|
||||||
|
"release_id": "${VERSION_ID}",
|
||||||
|
"resource_root": "${RESOURCE_ROOT}",
|
||||||
|
"status": "ready",
|
||||||
|
"status_code": "distribution.ready",
|
||||||
|
"verification_generation": 7,
|
||||||
|
"verified_at": 1000
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"available": true,
|
"available": true,
|
||||||
|
"channel": "official",
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
{
|
||||||
"blake3": "0000000000000000000000000000000000000000000000000000000000000000",
|
"blake3": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||||
@@ -14,9 +15,14 @@
|
|||||||
"url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash"
|
"url": "https://prod-clientpatch.bluearchiveyostar.com/{addressables-root}/TableBundles/TableCatalog.hash"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"generation": 7,
|
||||||
"limit": 2,
|
"limit": 2,
|
||||||
|
"manifest_identity": "${MANIFEST_IDENTITY}",
|
||||||
"manifest_version": 1,
|
"manifest_version": 1,
|
||||||
|
"mapping_identity": "${MAPPING_IDENTITY}",
|
||||||
"offset": 0,
|
"offset": 0,
|
||||||
|
"publication_identity": "${PUBLICATION_IDENTITY}",
|
||||||
|
"release_id": "${VERSION_ID}",
|
||||||
"resource_root": "${RESOURCE_ROOT}",
|
"resource_root": "${RESOURCE_ROOT}",
|
||||||
"total_entries": 2
|
"total_entries": 2
|
||||||
}
|
}
|
||||||
|
|||||||
+468
-23
@@ -118,7 +118,7 @@ func (c *Client) callEnvelope(ctx context.Context, method string, params any) (*
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
if deadline, ok := c.deadline(ctx); ok {
|
if deadline, ok := c.deadline(ctx); ok {
|
||||||
_ = conn.SetDeadline(deadline)
|
_ = conn.SetDeadline(deadline)
|
||||||
@@ -182,6 +182,118 @@ type pageParam struct {
|
|||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReleaseListParams selects one Rust-owned release namespace.
|
||||||
|
type ReleaseListParams struct {
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseDistributionParams selects a verified release for distribution.
|
||||||
|
type ReleaseDistributionParams struct {
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
ReleaseID string `json:"release_id,omitempty"`
|
||||||
|
Offset int `json:"offset,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Destination string `json:"destination,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseCleanupParams controls the dry-run/execute cleanup pair.
|
||||||
|
type ReleaseCleanupParams struct {
|
||||||
|
Execute bool `json:"execute,omitempty"`
|
||||||
|
PlanID string `json:"plan_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseSummary mirrors Rust's dual-release historical summary.
|
||||||
|
type ReleaseSummary struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
SourceOfficialReleaseID string `json:"source_official_release_id,omitempty"`
|
||||||
|
CreatedUnixSeconds *uint64 `json:"created_unix_seconds,omitempty"`
|
||||||
|
PublishedUnixSeconds *uint64 `json:"published_unix_seconds,omitempty"`
|
||||||
|
Current bool `json:"current"`
|
||||||
|
CurrentPointerValid bool `json:"current_pointer_valid"`
|
||||||
|
RollbackAvailable bool `json:"rollback_available"`
|
||||||
|
Stale bool `json:"stale"`
|
||||||
|
Damaged bool `json:"damaged"`
|
||||||
|
Referenced bool `json:"referenced"`
|
||||||
|
Unknown bool `json:"unknown"`
|
||||||
|
Lifecycle string `json:"lifecycle"`
|
||||||
|
ManifestContractStatus string `json:"manifest_contract_status"`
|
||||||
|
ArtifactIntegrityStatus string `json:"artifact_integrity_status"`
|
||||||
|
DistributionIntegrityStatus string `json:"distribution_integrity_status"`
|
||||||
|
Legacy bool `json:"legacy"`
|
||||||
|
RollbackPreviousReleaseID string `json:"rollback_previous_release_id,omitempty"`
|
||||||
|
Diagnostics []string `json:"diagnostics,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseStatusReport is the unified official/localized release view.
|
||||||
|
type ReleaseStatusReport struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusCode string `json:"status_code"`
|
||||||
|
OfficialCurrentReleaseID string `json:"official_current_release_id,omitempty"`
|
||||||
|
LocalizedCurrentReleaseID string `json:"localized_current_release_id,omitempty"`
|
||||||
|
LocalizedSourceOfficialID string `json:"localized_source_official_release_id,omitempty"`
|
||||||
|
CurrentReleasesMatch bool `json:"current_releases_match"`
|
||||||
|
DefaultDistributionChannel string `json:"default_distribution_channel"`
|
||||||
|
OfficialDistributionReady bool `json:"official_distribution_ready"`
|
||||||
|
LocalizedDistributionReady bool `json:"localized_distribution_ready"`
|
||||||
|
Releases []ReleaseSummary `json:"releases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseListReport is the filtered historical release response.
|
||||||
|
type ReleaseListReport struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusCode string `json:"status_code"`
|
||||||
|
Channel string `json:"channel,omitempty"`
|
||||||
|
Releases []ReleaseSummary `json:"releases"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseDistributionEntry is one Rust-verified resource manifest entry.
|
||||||
|
type ReleaseDistributionEntry struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Destination string `json:"destination"`
|
||||||
|
Bytes uint64 `json:"bytes"`
|
||||||
|
BLAKE3 string `json:"blake3"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseDistributionPage is a typed page for one selected release.
|
||||||
|
type ReleaseDistributionPage struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ReleaseID string `json:"release_id,omitempty"`
|
||||||
|
ResourceRoot string `json:"resource_root,omitempty"`
|
||||||
|
SourceOfficialReleaseID string `json:"source_official_release_id,omitempty"`
|
||||||
|
Current bool `json:"current"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusCode string `json:"status_code"`
|
||||||
|
ArtifactIntegrityStatus string `json:"artifact_integrity_status"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Entries []ReleaseDistributionEntry `json:"entries"`
|
||||||
|
Diagnostics []string `json:"diagnostics,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseCleanupEntry is one retained or removable cleanup observation.
|
||||||
|
type ReleaseCleanupEntry struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Candidate bool `json:"candidate"`
|
||||||
|
RetainReasons []string `json:"retain_reasons,omitempty"`
|
||||||
|
BlockingReferences []string `json:"blocking_references,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseCleanupReport is the dry-run or execute result.
|
||||||
|
type ReleaseCleanupReport struct {
|
||||||
|
Execute bool `json:"execute"`
|
||||||
|
PlanID string `json:"plan_id"`
|
||||||
|
Revalidated bool `json:"revalidated"`
|
||||||
|
Entries []ReleaseCleanupEntry `json:"entries"`
|
||||||
|
Removed []string `json:"removed"`
|
||||||
|
Diagnostics []string `json:"diagnostics,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type tailParam struct {
|
type tailParam struct {
|
||||||
Tail int `json:"tail"`
|
Tail int `json:"tail"`
|
||||||
}
|
}
|
||||||
@@ -267,9 +379,10 @@ type ScheduleRunParams struct {
|
|||||||
// TranslationTaskUnitResultParam is the dashboard/manual-review subset of one
|
// TranslationTaskUnitResultParam is the dashboard/manual-review subset of one
|
||||||
// TextUnit result accepted by Rust translation.task.update.
|
// TextUnit result accepted by Rust translation.task.update.
|
||||||
type TranslationTaskUnitResultParam struct {
|
type TranslationTaskUnitResultParam struct {
|
||||||
UnitID string `json:"unit_id"`
|
UnitID string `json:"unit_id"`
|
||||||
SourceText string `json:"source_text"`
|
SourceText string `json:"source_text"`
|
||||||
TranslatedText string `json:"translated_text"`
|
TranslatedText string `json:"translated_text"`
|
||||||
|
GlossaryOverride *GlossaryOverride `json:"glossary_override,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TranslationTaskUpdateParams is used by translation.task.update to persist
|
// TranslationTaskUpdateParams is used by translation.task.update to persist
|
||||||
@@ -310,6 +423,7 @@ type TranslationWorkerRunParams struct {
|
|||||||
Provider string `json:"provider,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
FixturePath string `json:"fixture_path,omitempty"`
|
FixturePath string `json:"fixture_path,omitempty"`
|
||||||
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
|
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
|
||||||
|
GlossaryPath string `json:"glossary_path,omitempty"`
|
||||||
Concurrency *uint64 `json:"concurrency,omitempty"`
|
Concurrency *uint64 `json:"concurrency,omitempty"`
|
||||||
MaxAttempts *uint64 `json:"max_attempts,omitempty"`
|
MaxAttempts *uint64 `json:"max_attempts,omitempty"`
|
||||||
LeaseSeconds *uint64 `json:"lease_seconds,omitempty"`
|
LeaseSeconds *uint64 `json:"lease_seconds,omitempty"`
|
||||||
@@ -323,6 +437,7 @@ type TranslationWorkerConfig struct {
|
|||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
FixturePath string `json:"fixture_path,omitempty"`
|
FixturePath string `json:"fixture_path,omitempty"`
|
||||||
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
|
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
|
||||||
|
GlossaryPath string `json:"glossary_path,omitempty"`
|
||||||
Concurrency uint64 `json:"concurrency"`
|
Concurrency uint64 `json:"concurrency"`
|
||||||
MaxAttempts uint64 `json:"max_attempts"`
|
MaxAttempts uint64 `json:"max_attempts"`
|
||||||
LeaseSeconds uint64 `json:"lease_seconds"`
|
LeaseSeconds uint64 `json:"lease_seconds"`
|
||||||
@@ -364,9 +479,10 @@ const (
|
|||||||
type TranslationMemoryMatchKind string
|
type TranslationMemoryMatchKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TranslationMemoryMatchStrongExact TranslationMemoryMatchKind = "strong_exact"
|
TranslationMemoryMatchStrongExact TranslationMemoryMatchKind = "strong_exact"
|
||||||
TranslationMemoryMatchCandidateExact TranslationMemoryMatchKind = "candidate_exact"
|
TranslationMemoryMatchTrustedConflict TranslationMemoryMatchKind = "trusted_conflict"
|
||||||
TranslationMemoryMatchSourceOnly TranslationMemoryMatchKind = "source_only"
|
TranslationMemoryMatchCandidateExact TranslationMemoryMatchKind = "candidate_exact"
|
||||||
|
TranslationMemoryMatchSourceOnly TranslationMemoryMatchKind = "source_only"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TranslationMemorySummaryParams selects an optional Rust-owned TM database.
|
// TranslationMemorySummaryParams selects an optional Rust-owned TM database.
|
||||||
@@ -389,16 +505,19 @@ type TranslationMemoryConfirmParams struct {
|
|||||||
RecordID string `json:"record_id"`
|
RecordID string `json:"record_id"`
|
||||||
Reviewer string `json:"reviewer"`
|
Reviewer string `json:"reviewer"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
|
SupersedeRecordID string `json:"supersede_record_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TranslationMemorySummary mirrors translation.memory.summary data.
|
// TranslationMemorySummary mirrors translation.memory.summary data.
|
||||||
type TranslationMemorySummary struct {
|
type TranslationMemorySummary struct {
|
||||||
SchemaVersion uint64 `json:"schema_version"`
|
SchemaVersion uint64 `json:"schema_version"`
|
||||||
RecordCount uint64 `json:"record_count"`
|
RecordCount uint64 `json:"record_count"`
|
||||||
TrustedCount uint64 `json:"trusted_count"`
|
TrustedCount uint64 `json:"trusted_count"`
|
||||||
CandidateCount uint64 `json:"candidate_count"`
|
CandidateCount uint64 `json:"candidate_count"`
|
||||||
SupersededCount uint64 `json:"superseded_count"`
|
SupersededCount uint64 `json:"superseded_count"`
|
||||||
RejectedCount uint64 `json:"rejected_count"`
|
RejectedCount uint64 `json:"rejected_count"`
|
||||||
|
TrustedConflictGroupCount uint64 `json:"trusted_conflict_group_count"`
|
||||||
|
CurrentTrustedCount uint64 `json:"current_trusted_count,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TranslationMemorySummaryReport distinguishes a missing database from an
|
// TranslationMemorySummaryReport distinguishes a missing database from an
|
||||||
@@ -476,11 +595,211 @@ type TranslationMemoryConfirmReport struct {
|
|||||||
Entry TranslationMemoryEntry `json:"entry"`
|
Entry TranslationMemoryEntry `json:"entry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TranslationMemoryConflict groups all records sharing one exact source
|
||||||
|
// identity when more than one current Trusted record exists.
|
||||||
|
type TranslationMemoryConflict struct {
|
||||||
|
SourceText string `json:"source_text"`
|
||||||
|
SourceHash string `json:"source_hash"`
|
||||||
|
SourceContext TranslationMemoryContext `json:"source_context,omitempty"`
|
||||||
|
SourceContextHash string `json:"source_context_hash"`
|
||||||
|
TrustedRecordIDs []string `json:"trusted_record_ids"`
|
||||||
|
Records []TranslationMemoryEntry `json:"records"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslationMemoryConflictsParams lists current Trusted conflicts.
|
||||||
|
type TranslationMemoryConflictsParams struct {
|
||||||
|
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
|
||||||
|
Limit *uint64 `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslationMemoryConflictsReport mirrors translation.memory.conflicts data.
|
||||||
|
type TranslationMemoryConflictsReport struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Conflicts []TranslationMemoryConflict `json:"conflicts"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslationMemoryResolveConflictParams resolves one exact-identity conflict.
|
||||||
|
type TranslationMemoryResolveConflictParams struct {
|
||||||
|
TranslationMemoryPath string `json:"translation_memory_path,omitempty"`
|
||||||
|
WinnerRecordID string `json:"winner_record_id"`
|
||||||
|
ExpectedTrustedRecordIDs []string `json:"expected_trusted_record_ids"`
|
||||||
|
Reviewer string `json:"reviewer"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslationMemoryResolveConflictReport mirrors the conflict resolution result.
|
||||||
|
type TranslationMemoryResolveConflictReport struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Entry TranslationMemoryEntry `json:"entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlossaryReviewStatus is the Rust-owned term review state.
|
||||||
|
type GlossaryReviewStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GlossaryStatusDraft GlossaryReviewStatus = "draft"
|
||||||
|
GlossaryStatusApproved GlossaryReviewStatus = "approved"
|
||||||
|
GlossaryStatusDeprecated GlossaryReviewStatus = "deprecated"
|
||||||
|
GlossaryStatusRejected GlossaryReviewStatus = "rejected"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GlossarySourceRecord identifies the source/provenance of a term.
|
||||||
|
type GlossarySourceRecord struct {
|
||||||
|
SourceKind string `json:"source_kind"`
|
||||||
|
SourceRef *string `json:"source_ref,omitempty"`
|
||||||
|
SourceAuthor *string `json:"source_author,omitempty"`
|
||||||
|
SourceNote *string `json:"source_note,omitempty"`
|
||||||
|
ObservedUnixSeconds uint64 `json:"observed_unix_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlossaryTermSnapshot is the versioned definition shared by Rust and Go.
|
||||||
|
type GlossaryTermSnapshot struct {
|
||||||
|
SourceTerm string `json:"source_term"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
RecommendedTranslation string `json:"recommended_translation"`
|
||||||
|
AllowedTranslations []string `json:"allowed_translations,omitempty"`
|
||||||
|
SourceLanguage *string `json:"source_language,omitempty"`
|
||||||
|
TargetLanguage *string `json:"target_language,omitempty"`
|
||||||
|
Category *string `json:"category,omitempty"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Scope map[string]string `json:"scope,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlossaryOverride records explicit human approval for a deviation.
|
||||||
|
type GlossaryOverride struct {
|
||||||
|
QAIdentity string `json:"qa_identity"`
|
||||||
|
Reviewer string `json:"reviewer"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Provenance string `json:"provenance"`
|
||||||
|
ConfirmedUnixSeconds uint64 `json:"confirmed_unix_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlossaryTerm mirrors a persisted Rust term and its source history.
|
||||||
|
type GlossaryTerm struct {
|
||||||
|
TermID string `json:"term_id"`
|
||||||
|
GlossaryTermSnapshot
|
||||||
|
ReviewStatus GlossaryReviewStatus `json:"review_status"`
|
||||||
|
Source GlossarySourceRecord `json:"source"`
|
||||||
|
History []GlossaryHistoryRecord `json:"history,omitempty"`
|
||||||
|
CreatedUnixSeconds uint64 `json:"created_unix_seconds"`
|
||||||
|
UpdatedUnixSeconds uint64 `json:"updated_unix_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlossaryHistoryRecord is one durable term mutation.
|
||||||
|
type GlossaryHistoryRecord struct {
|
||||||
|
HistoryID string `json:"history_id"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Reviewer *string `json:"reviewer,omitempty"`
|
||||||
|
Reason *string `json:"reason,omitempty"`
|
||||||
|
Source GlossarySourceRecord `json:"source"`
|
||||||
|
ReviewStatus GlossaryReviewStatus `json:"review_status"`
|
||||||
|
Snapshot GlossaryTermSnapshot `json:"snapshot"`
|
||||||
|
ObservedUnixSeconds uint64 `json:"observed_unix_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlossarySummary mirrors translation.glossary.summary.
|
||||||
|
type GlossarySummary struct {
|
||||||
|
SchemaVersion uint64 `json:"schema_version"`
|
||||||
|
TermCount uint64 `json:"term_count"`
|
||||||
|
ApprovedCount uint64 `json:"approved_count"`
|
||||||
|
DraftCount uint64 `json:"draft_count"`
|
||||||
|
DeprecatedCount uint64 `json:"deprecated_count"`
|
||||||
|
RejectedCount uint64 `json:"rejected_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlossarySummaryParams struct {
|
||||||
|
GlossaryPath string `json:"glossary_path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlossaryQueryParams struct {
|
||||||
|
GlossaryPath string `json:"glossary_path,omitempty"`
|
||||||
|
SourceText string `json:"source_text,omitempty"`
|
||||||
|
Category string `json:"category,omitempty"`
|
||||||
|
ReviewStatus string `json:"review_status,omitempty"`
|
||||||
|
Limit *uint64 `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlossaryDiagnoseParams struct {
|
||||||
|
GlossaryPath string `json:"glossary_path,omitempty"`
|
||||||
|
SourceText string `json:"source_text"`
|
||||||
|
Context map[string]string `json:"context,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlossaryTermMutationParams struct {
|
||||||
|
GlossaryPath string `json:"glossary_path,omitempty"`
|
||||||
|
TermID string `json:"term_id"`
|
||||||
|
SourceTerm string `json:"source_term"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
|
RecommendedTranslation string `json:"recommended_translation"`
|
||||||
|
AllowedTranslations []string `json:"allowed_translations,omitempty"`
|
||||||
|
SourceLanguage *string `json:"source_language,omitempty"`
|
||||||
|
TargetLanguage *string `json:"target_language,omitempty"`
|
||||||
|
Category *string `json:"category,omitempty"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
Scope map[string]string `json:"scope,omitempty"`
|
||||||
|
ReviewStatus string `json:"review_status"`
|
||||||
|
Source GlossarySourceRecord `json:"source"`
|
||||||
|
Reviewer string `json:"reviewer,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlossaryReviewParams struct {
|
||||||
|
GlossaryPath string `json:"glossary_path,omitempty"`
|
||||||
|
TermID string `json:"term_id"`
|
||||||
|
Reviewer string `json:"reviewer"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlossaryDeleteParams struct {
|
||||||
|
GlossaryPath string `json:"glossary_path,omitempty"`
|
||||||
|
TermID string `json:"term_id"`
|
||||||
|
Reviewer string `json:"reviewer"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlossarySummaryReport struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
SchemaVersion *uint64 `json:"schema_version,omitempty"`
|
||||||
|
Summary *GlossarySummary `json:"summary,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlossaryQueryReport struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
SourceText string `json:"source_text"`
|
||||||
|
Terms []GlossaryTerm `json:"terms"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlossaryMutationReport struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
SchemaVersion *uint64 `json:"schema_version,omitempty"`
|
||||||
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
|
Term GlossaryTerm `json:"term"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlossaryDiagnoseReport struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
SourceText string `json:"source_text"`
|
||||||
|
Context map[string]string `json:"context,omitempty"`
|
||||||
|
Evaluation json.RawMessage `json:"evaluation"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// LocalizedPublishParams selects the source of one localized release
|
// LocalizedPublishParams selects the source of one localized release
|
||||||
// publication. TranslationFile and FromWorker are mutually exclusive.
|
// publication. Exactly one of TranslationFile, FromWorker, or PatchManifest
|
||||||
|
// must be set.
|
||||||
type LocalizedPublishParams struct {
|
type LocalizedPublishParams struct {
|
||||||
TranslationFile string `json:"translation_file,omitempty"`
|
TranslationFile string `json:"translation_file,omitempty"`
|
||||||
FromWorker bool `json:"from_worker,omitempty"`
|
FromWorker bool `json:"from_worker,omitempty"`
|
||||||
|
PatchManifest string `json:"patch_manifest,omitempty"`
|
||||||
LocalizedReleaseID string `json:"localized_release_id,omitempty"`
|
LocalizedReleaseID string `json:"localized_release_id,omitempty"`
|
||||||
Force bool `json:"force,omitempty"`
|
Force bool `json:"force,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -603,14 +922,30 @@ type ResourceManifestEntry struct {
|
|||||||
BLAKE3 string `json:"blake3,omitempty"`
|
BLAKE3 string `json:"blake3,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResourceManifestParams binds every page to one attested official release.
|
||||||
|
type ResourceManifestParams struct {
|
||||||
|
ReleaseID string `json:"release_id,omitempty"`
|
||||||
|
ExpectedPublicationIdentity string `json:"expected_publication_identity,omitempty"`
|
||||||
|
ExpectedManifestIdentity string `json:"expected_manifest_identity,omitempty"`
|
||||||
|
ExpectedVerificationGeneration uint64 `json:"expected_verification_generation"`
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
type ResourceManifestPage struct {
|
type ResourceManifestPage struct {
|
||||||
Available bool `json:"available"`
|
Available bool `json:"available"`
|
||||||
ResourceRoot string `json:"resource_root,omitempty"`
|
Channel string `json:"channel,omitempty"`
|
||||||
ManifestVersion int `json:"manifest_version,omitempty"`
|
ReleaseID string `json:"release_id,omitempty"`
|
||||||
TotalEntries int `json:"total_entries,omitempty"`
|
ResourceRoot string `json:"resource_root,omitempty"`
|
||||||
Offset int `json:"offset,omitempty"`
|
ManifestVersion int `json:"manifest_version,omitempty"`
|
||||||
Limit int `json:"limit,omitempty"`
|
PublicationIdentity string `json:"publication_identity,omitempty"`
|
||||||
Entries []ResourceManifestEntry `json:"entries,omitempty"`
|
MappingIdentity string `json:"mapping_identity,omitempty"`
|
||||||
|
ManifestIdentity string `json:"manifest_identity,omitempty"`
|
||||||
|
Generation uint64 `json:"generation"`
|
||||||
|
TotalEntries int `json:"total_entries,omitempty"`
|
||||||
|
Offset int `json:"offset,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Entries []ResourceManifestEntry `json:"entries,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) DaemonStatus(ctx context.Context) (*DaemonStatusReport, error) {
|
func (c *Client) DaemonStatus(ctx context.Context) (*DaemonStatusReport, error) {
|
||||||
@@ -679,9 +1014,35 @@ func (c *Client) ResourceRepair(ctx context.Context) (*TaskAccepted, error) {
|
|||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ResourceManifest(ctx context.Context, offset int, limit int) (*ResourceManifestPage, error) {
|
// DistributionAttestation is the Rust-owned current official health proof.
|
||||||
|
type DistributionAttestation struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ReleaseID string `json:"release_id"`
|
||||||
|
ResourceRoot string `json:"resource_root"`
|
||||||
|
PublicationIdentity string `json:"publication_identity"`
|
||||||
|
MappingIdentity string `json:"mapping_identity"`
|
||||||
|
ManifestIdentity string `json:"manifest_identity"`
|
||||||
|
EntryCount int `json:"entry_count"`
|
||||||
|
IntegrityStatus string `json:"integrity_status"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusCode string `json:"status_code"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
VerificationGeneration uint64 `json:"verification_generation"`
|
||||||
|
VerifiedAt *uint64 `json:"verified_at,omitempty"`
|
||||||
|
MaxAgeSeconds uint64 `json:"max_age_seconds"`
|
||||||
|
Diagnostics []string `json:"diagnostics,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReleaseAttestation(ctx context.Context) (*DistributionAttestation, error) {
|
||||||
|
var out DistributionAttestation
|
||||||
|
_, err := c.Call(ctx, "release.attestation", nil, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ResourceManifest(ctx context.Context, params ResourceManifestParams) (*ResourceManifestPage, error) {
|
||||||
var out ResourceManifestPage
|
var out ResourceManifestPage
|
||||||
_, err := c.Call(ctx, "resource.manifest", pageParam{Offset: offset, Limit: limit}, &out)
|
_, err := c.Call(ctx, "resource.manifest", params, &out)
|
||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,6 +1112,66 @@ func (c *Client) TranslationMemoryConfirm(ctx context.Context, params Translatio
|
|||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) TranslationMemoryConflicts(ctx context.Context, params TranslationMemoryConflictsParams) (*TranslationMemoryConflictsReport, error) {
|
||||||
|
var out TranslationMemoryConflictsReport
|
||||||
|
_, err := c.Call(ctx, "translation.memory.conflicts", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) TranslationMemoryResolveConflict(ctx context.Context, params TranslationMemoryResolveConflictParams) (*TranslationMemoryResolveConflictReport, error) {
|
||||||
|
var out TranslationMemoryResolveConflictReport
|
||||||
|
_, err := c.Call(ctx, "translation.memory.resolve_conflict", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GlossarySummary(ctx context.Context, params GlossarySummaryParams) (*GlossarySummaryReport, error) {
|
||||||
|
var out GlossarySummaryReport
|
||||||
|
_, err := c.Call(ctx, "translation.glossary.summary", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GlossaryQuery(ctx context.Context, params GlossaryQueryParams) (*GlossaryQueryReport, error) {
|
||||||
|
var out GlossaryQueryReport
|
||||||
|
_, err := c.Call(ctx, "translation.glossary.query", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GlossaryDiagnose(ctx context.Context, params GlossaryDiagnoseParams) (*GlossaryDiagnoseReport, error) {
|
||||||
|
var out GlossaryDiagnoseReport
|
||||||
|
_, err := c.Call(ctx, "translation.glossary.diagnose", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GlossaryAdd(ctx context.Context, params GlossaryTermMutationParams) (*GlossaryMutationReport, error) {
|
||||||
|
var out GlossaryMutationReport
|
||||||
|
_, err := c.Call(ctx, "translation.glossary.add", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GlossaryUpdate(ctx context.Context, params GlossaryTermMutationParams) (*GlossaryMutationReport, error) {
|
||||||
|
var out GlossaryMutationReport
|
||||||
|
_, err := c.Call(ctx, "translation.glossary.update", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GlossaryApprove(ctx context.Context, params GlossaryReviewParams) (*GlossaryMutationReport, error) {
|
||||||
|
var out GlossaryMutationReport
|
||||||
|
_, err := c.Call(ctx, "translation.glossary.approve", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GlossaryDeprecate(ctx context.Context, params GlossaryReviewParams) (*GlossaryMutationReport, error) {
|
||||||
|
var out GlossaryMutationReport
|
||||||
|
_, err := c.Call(ctx, "translation.glossary.deprecate", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GlossaryDelete(ctx context.Context, params GlossaryDeleteParams) (*GlossaryMutationReport, error) {
|
||||||
|
var out GlossaryMutationReport
|
||||||
|
_, err := c.Call(ctx, "translation.glossary.delete", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) {
|
func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) {
|
||||||
return c.rawData(ctx, "localized.publish", params)
|
return c.rawData(ctx, "localized.publish", params)
|
||||||
}
|
}
|
||||||
@@ -771,6 +1192,30 @@ func (c *Client) CatalogDiff(ctx context.Context) (json.RawMessage, error) {
|
|||||||
return c.rawData(ctx, "catalog.diff", nil)
|
return c.rawData(ctx, "catalog.diff", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReleaseStatus(ctx context.Context) (*ReleaseStatusReport, error) {
|
||||||
|
var out ReleaseStatusReport
|
||||||
|
_, err := c.Call(ctx, "release.status", nil, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReleaseList(ctx context.Context, params ReleaseListParams) (*ReleaseListReport, error) {
|
||||||
|
var out ReleaseListReport
|
||||||
|
_, err := c.Call(ctx, "release.list", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReleaseDistribution(ctx context.Context, params ReleaseDistributionParams) (*ReleaseDistributionPage, error) {
|
||||||
|
var out ReleaseDistributionPage
|
||||||
|
_, err := c.Call(ctx, "release.distribution", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ReleaseCleanup(ctx context.Context, params ReleaseCleanupParams) (*ReleaseCleanupReport, error) {
|
||||||
|
var out ReleaseCleanupReport
|
||||||
|
_, err := c.Call(ctx, "release.cleanup", params, &out)
|
||||||
|
return &out, err
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) {
|
func (c *Client) CatalogRefresh(ctx context.Context, force bool) (*TaskAccepted, error) {
|
||||||
var out TaskAccepted
|
var out TaskAccepted
|
||||||
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
|
_, err := c.Call(ctx, "catalog.refresh", boolParam{Force: force}, &out)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func newTestClient(t *testing.T, handler func(t *testing.T, req testRequest) tes
|
|||||||
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
|
client.DialContext = func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||||
clientConn, serverConn := net.Pipe()
|
clientConn, serverConn := net.Pipe()
|
||||||
go func(conn net.Conn) {
|
go func(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer func() { _ = conn.Close() }()
|
||||||
line, err := bufio.NewReader(conn).ReadBytes('\n')
|
line, err := bufio.NewReader(conn).ReadBytes('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -88,6 +88,94 @@ 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 TestReleaseStatusUsesWholeReleaseHealthFact(t *testing.T) {
|
||||||
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
|
if req.Method != "release.status" {
|
||||||
|
t.Fatalf("method = %s", req.Method)
|
||||||
|
}
|
||||||
|
return testResponse{
|
||||||
|
Result: testEnvelope{
|
||||||
|
OK: true,
|
||||||
|
Status: "ok",
|
||||||
|
Data: map[string]any{
|
||||||
|
"status": "blocked",
|
||||||
|
"status_code": "distribution.blocked",
|
||||||
|
"default_distribution_channel": "official",
|
||||||
|
"official_current_release_id": "official-1",
|
||||||
|
"official_distribution_ready": false,
|
||||||
|
"releases": []any{map[string]any{
|
||||||
|
"channel": "official",
|
||||||
|
"id": "official-1",
|
||||||
|
"current": true,
|
||||||
|
"distribution_integrity_status": "invalid",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
report, err := client.ReleaseStatus(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReleaseStatus error: %v", err)
|
||||||
|
}
|
||||||
|
if report.StatusCode != "distribution.blocked" ||
|
||||||
|
report.OfficialDistributionReady ||
|
||||||
|
report.OfficialCurrentReleaseID != "official-1" ||
|
||||||
|
len(report.Releases) != 1 ||
|
||||||
|
report.Releases[0].DistributionIntegrityStatus != "invalid" {
|
||||||
|
t.Fatalf("report = %#v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDaemonRestartSendsControlMethod(t *testing.T) {
|
func TestDaemonRestartSendsControlMethod(t *testing.T) {
|
||||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
if req.Method != "daemon.restart" {
|
if req.Method != "daemon.restart" {
|
||||||
@@ -161,6 +249,103 @@ func TestParseTextUnitsSendsQuery(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReleaseAttestationMirrorsCurrentOfficialHealth(t *testing.T) {
|
||||||
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
|
if req.Method != "release.attestation" {
|
||||||
|
t.Fatalf("method = %s", req.Method)
|
||||||
|
}
|
||||||
|
return testResponse{
|
||||||
|
Result: testEnvelope{
|
||||||
|
OK: true,
|
||||||
|
Status: "ok",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"channel": "official",
|
||||||
|
"release_id": "official-a",
|
||||||
|
"resource_root": "/srv/official/versions/official-a",
|
||||||
|
"publication_identity": "odp-v1-publication-a",
|
||||||
|
"mapping_identity": "odm-v1-mapping-a",
|
||||||
|
"manifest_identity": "manifest-a",
|
||||||
|
"entry_count": 2,
|
||||||
|
"integrity_status": "verified",
|
||||||
|
"status": "ready",
|
||||||
|
"status_code": "distribution.ready",
|
||||||
|
"ready": true,
|
||||||
|
"verification_generation": 7,
|
||||||
|
"verified_at": 1234,
|
||||||
|
"max_age_seconds": 7260,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
report, err := client.ReleaseAttestation(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReleaseAttestation error: %v", err)
|
||||||
|
}
|
||||||
|
if !report.Ready || report.ReleaseID != "official-a" ||
|
||||||
|
report.ManifestIdentity != "manifest-a" ||
|
||||||
|
report.VerificationGeneration != 7 {
|
||||||
|
t.Fatalf("report = %#v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResourceManifestSendsAttestedGenerationParams(t *testing.T) {
|
||||||
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
|
if req.Method != "resource.manifest" {
|
||||||
|
t.Fatalf("method = %s", req.Method)
|
||||||
|
}
|
||||||
|
var params ResourceManifestParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode params: %v", err)
|
||||||
|
}
|
||||||
|
if params.ReleaseID != "official-a" ||
|
||||||
|
params.ExpectedPublicationIdentity != "odp-v1-publication-a" ||
|
||||||
|
params.ExpectedManifestIdentity != "manifest-a" ||
|
||||||
|
params.ExpectedVerificationGeneration != 7 ||
|
||||||
|
params.Offset != 1 || params.Limit != 100 {
|
||||||
|
t.Fatalf("params = %#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{
|
||||||
|
Result: testEnvelope{
|
||||||
|
OK: true,
|
||||||
|
Status: "ok",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"channel": "official",
|
||||||
|
"release_id": "official-a",
|
||||||
|
"resource_root": "/srv/official/versions/official-a",
|
||||||
|
"manifest_version": 1,
|
||||||
|
"publication_identity": "odp-v1-publication-a",
|
||||||
|
"mapping_identity": "odm-v1-mapping-a",
|
||||||
|
"manifest_identity": "manifest-a",
|
||||||
|
"generation": 7,
|
||||||
|
"total_entries": 2,
|
||||||
|
"offset": 1,
|
||||||
|
"limit": 100,
|
||||||
|
"entries": []any{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
page, err := client.ResourceManifest(context.Background(), ResourceManifestParams{
|
||||||
|
ReleaseID: "official-a",
|
||||||
|
ExpectedPublicationIdentity: "odp-v1-publication-a",
|
||||||
|
ExpectedManifestIdentity: "manifest-a",
|
||||||
|
ExpectedVerificationGeneration: 7,
|
||||||
|
Offset: 1,
|
||||||
|
Limit: 100,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResourceManifest error: %v", err)
|
||||||
|
}
|
||||||
|
if page.ReleaseID != "official-a" || page.ManifestIdentity != "manifest-a" ||
|
||||||
|
page.Generation != 7 {
|
||||||
|
t.Fatalf("page = %#v", page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) {
|
func TestUnityFSPatchFieldSendsTaggedReplacement(t *testing.T) {
|
||||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
if req.Method != "unityfs.patch_field" {
|
if req.Method != "unityfs.patch_field" {
|
||||||
@@ -463,6 +648,13 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
|
|||||||
UnitID: "direct:a#unit:0",
|
UnitID: "direct:a#unit:0",
|
||||||
SourceText: "source",
|
SourceText: "source",
|
||||||
TranslatedText: "译文",
|
TranslatedText: "译文",
|
||||||
|
GlossaryOverride: &GlossaryOverride{
|
||||||
|
QAIdentity: "gqa-v1-test",
|
||||||
|
Reviewer: "reviewer",
|
||||||
|
Reason: "approved deviation",
|
||||||
|
Provenance: "manual-review",
|
||||||
|
ConfirmedUnixSeconds: 100,
|
||||||
|
},
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -564,14 +756,15 @@ func TestTranslationMemoryTypedContract(t *testing.T) {
|
|||||||
Data: map[string]any{
|
Data: map[string]any{
|
||||||
"available": true,
|
"available": true,
|
||||||
"path": "/var/lib/bat/translation-memory.sqlite",
|
"path": "/var/lib/bat/translation-memory.sqlite",
|
||||||
"schema_version": 1,
|
"schema_version": 2,
|
||||||
"summary": map[string]any{
|
"summary": map[string]any{
|
||||||
"schema_version": 1,
|
"schema_version": 2,
|
||||||
"record_count": 3,
|
"record_count": 3,
|
||||||
"trusted_count": 1,
|
"trusted_count": 1,
|
||||||
"candidate_count": 1,
|
"candidate_count": 1,
|
||||||
"superseded_count": 1,
|
"superseded_count": 1,
|
||||||
"rejected_count": 0,
|
"rejected_count": 0,
|
||||||
|
"current_trusted_count": 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -667,6 +860,50 @@ func TestTranslationMemoryTypedContract(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
case "translation.memory.conflicts":
|
||||||
|
var params TranslationMemoryConflictsParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode conflicts params: %v", err)
|
||||||
|
}
|
||||||
|
if params.TranslationMemoryPath != "/var/lib/bat/translation-memory.sqlite" ||
|
||||||
|
params.Limit == nil || *params.Limit != limit {
|
||||||
|
t.Fatalf("conflicts params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{
|
||||||
|
Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-tm-conflicts",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/translation-memory.sqlite",
|
||||||
|
"conflicts": []any{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
case "translation.memory.resolve_conflict":
|
||||||
|
var params TranslationMemoryResolveConflictParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode resolve params: %v", err)
|
||||||
|
}
|
||||||
|
if params.WinnerRecordID != "tm-record-1" ||
|
||||||
|
len(params.ExpectedTrustedRecordIDs) != 2 ||
|
||||||
|
params.ExpectedTrustedRecordIDs[1] != "tm-record-2" ||
|
||||||
|
params.Reviewer != "reviewer" ||
|
||||||
|
params.Reason != "selected" {
|
||||||
|
t.Fatalf("resolve params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{
|
||||||
|
Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-tm-resolve",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/translation-memory.sqlite",
|
||||||
|
"entry": map[string]any{
|
||||||
|
"record_id": "tm-record-1",
|
||||||
|
"trust_status": "trusted",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected method %q", req.Method)
|
t.Fatalf("unexpected method %q", req.Method)
|
||||||
return testResponse{}
|
return testResponse{}
|
||||||
@@ -723,6 +960,32 @@ func TestTranslationMemoryTypedContract(t *testing.T) {
|
|||||||
confirmed.Entry.RecordID != "tm-record-1" {
|
confirmed.Entry.RecordID != "tm-record-1" {
|
||||||
t.Fatalf("confirmed=%#v", confirmed)
|
t.Fatalf("confirmed=%#v", confirmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conflicts, err := client.TranslationMemoryConflicts(context.Background(), TranslationMemoryConflictsParams{
|
||||||
|
TranslationMemoryPath: "/var/lib/bat/translation-memory.sqlite",
|
||||||
|
Limit: &limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TranslationMemoryConflicts error: %v", err)
|
||||||
|
}
|
||||||
|
if !conflicts.Available || len(conflicts.Conflicts) != 0 {
|
||||||
|
t.Fatalf("conflicts=%#v", conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := client.TranslationMemoryResolveConflict(context.Background(), TranslationMemoryResolveConflictParams{
|
||||||
|
TranslationMemoryPath: "/var/lib/bat/translation-memory.sqlite",
|
||||||
|
WinnerRecordID: "tm-record-1",
|
||||||
|
ExpectedTrustedRecordIDs: []string{"tm-record-1", "tm-record-2"},
|
||||||
|
Reviewer: "reviewer",
|
||||||
|
Reason: "selected",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("TranslationMemoryResolveConflict error: %v", err)
|
||||||
|
}
|
||||||
|
if !resolved.Available || resolved.Entry.RecordID != "tm-record-1" ||
|
||||||
|
resolved.Entry.TrustStatus != TranslationMemoryStatusTrusted {
|
||||||
|
t.Fatalf("resolved=%#v", resolved)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTranslationProofreadUsesRustMethod(t *testing.T) {
|
func TestTranslationProofreadUsesRustMethod(t *testing.T) {
|
||||||
@@ -790,6 +1053,38 @@ func TestLocalizedPublishSendsWorkerSourceAndReleaseOptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLocalizedPublishSendsPatchManifestSource(t *testing.T) {
|
||||||
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
|
var params LocalizedPublishParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode params: %v", err)
|
||||||
|
}
|
||||||
|
if params.PatchManifest != "/tmp/patch-manifest.json" ||
|
||||||
|
params.TranslationFile != "" || params.FromWorker {
|
||||||
|
t.Fatalf("params = %#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{
|
||||||
|
Result: testEnvelope{
|
||||||
|
OK: true,
|
||||||
|
Status: "ok",
|
||||||
|
RequestID: "req-test-localized-patch-manifest",
|
||||||
|
Data: map[string]any{"status": "published"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
raw, err := client.LocalizedPublish(context.Background(), LocalizedPublishParams{
|
||||||
|
PatchManifest: "/tmp/patch-manifest.json",
|
||||||
|
LocalizedReleaseID: "localized-v1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LocalizedPublish error: %v", err)
|
||||||
|
}
|
||||||
|
if !json.Valid(raw) {
|
||||||
|
t.Fatalf("invalid raw JSON: %s", string(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) {
|
func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) {
|
||||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
if req.Method != "localized.rollback" {
|
if req.Method != "localized.rollback" {
|
||||||
@@ -823,6 +1118,270 @@ func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGlossaryTypedContract(t *testing.T) {
|
||||||
|
limit := uint64(20)
|
||||||
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
|
switch req.Method {
|
||||||
|
case "translation.glossary.summary":
|
||||||
|
var params GlossarySummaryParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode summary params: %v", err)
|
||||||
|
}
|
||||||
|
if params.GlossaryPath != "/var/lib/bat/glossary.sqlite" {
|
||||||
|
t.Fatalf("summary params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-summary",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"schema_version": 2,
|
||||||
|
"summary": map[string]any{
|
||||||
|
"schema_version": 2,
|
||||||
|
"term_count": 2,
|
||||||
|
"approved_count": 1,
|
||||||
|
"draft_count": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.query":
|
||||||
|
var params GlossaryQueryParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode query params: %v", err)
|
||||||
|
}
|
||||||
|
if params.SourceText != "Sensei" ||
|
||||||
|
params.GlossaryPath != "/var/lib/bat/glossary.sqlite" ||
|
||||||
|
params.Limit == nil || *params.Limit != limit {
|
||||||
|
t.Fatalf("query params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-query",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"terms": []any{map[string]any{
|
||||||
|
"term_id": "term-sensei",
|
||||||
|
"source_term": "Sensei",
|
||||||
|
"recommended_translation": "老师",
|
||||||
|
"priority": 10,
|
||||||
|
"review_status": "approved",
|
||||||
|
"source": map[string]any{
|
||||||
|
"source_kind": "manual",
|
||||||
|
"observed_unix_seconds": 100,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.diagnose":
|
||||||
|
var params GlossaryDiagnoseParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode diagnose params: %v", err)
|
||||||
|
}
|
||||||
|
if params.SourceText != "Sensei" || params.Context["destination"] != "Bundle/dialogue.bundle" {
|
||||||
|
t.Fatalf("diagnose params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-diagnose",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"source_text": "Sensei",
|
||||||
|
"evaluation": map[string]any{
|
||||||
|
"constraints": []any{map[string]any{
|
||||||
|
"term_id": "term-sensei",
|
||||||
|
"matched_source": "Sensei",
|
||||||
|
"recommended_translation": "老师",
|
||||||
|
}},
|
||||||
|
"diagnostics": []any{},
|
||||||
|
"blocked": false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.add":
|
||||||
|
var params GlossaryTermMutationParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode add params: %v", err)
|
||||||
|
}
|
||||||
|
if params.TermID != "term-sensei" || params.Source.SourceKind != "manual" ||
|
||||||
|
params.Source.ObservedUnixSeconds != 100 {
|
||||||
|
t.Fatalf("add params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-add",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"term": map[string]any{
|
||||||
|
"term_id": "term-sensei",
|
||||||
|
"source_term": "Sensei",
|
||||||
|
"recommended_translation": "老师",
|
||||||
|
"review_status": "draft",
|
||||||
|
"source": map[string]any{
|
||||||
|
"source_kind": "manual",
|
||||||
|
"observed_unix_seconds": 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.update":
|
||||||
|
var params GlossaryTermMutationParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode update params: %v", err)
|
||||||
|
}
|
||||||
|
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.ReviewStatus != "draft" {
|
||||||
|
t.Fatalf("update params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-update",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"term": map[string]any{
|
||||||
|
"term_id": "term-sensei",
|
||||||
|
"source_term": "Sensei",
|
||||||
|
"recommended_translation": "老师",
|
||||||
|
"review_status": "draft",
|
||||||
|
"source": map[string]any{
|
||||||
|
"source_kind": "manual",
|
||||||
|
"observed_unix_seconds": 101,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.approve":
|
||||||
|
var params GlossaryReviewParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode approve params: %v", err)
|
||||||
|
}
|
||||||
|
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" {
|
||||||
|
t.Fatalf("approve params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-approve",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"term": map[string]any{"term_id": "term-sensei", "review_status": "approved"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.deprecate":
|
||||||
|
var params GlossaryReviewParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode deprecate params: %v", err)
|
||||||
|
}
|
||||||
|
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.Reason != "retired" {
|
||||||
|
t.Fatalf("deprecate params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-deprecate",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"term": map[string]any{"term_id": "term-sensei", "review_status": "deprecated"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
case "translation.glossary.delete":
|
||||||
|
var params GlossaryDeleteParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode delete params: %v", err)
|
||||||
|
}
|
||||||
|
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.Reason != "duplicate" {
|
||||||
|
t.Fatalf("delete params=%#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{Result: testEnvelope{
|
||||||
|
OK: true, Status: "ok", RequestID: "req-glossary-delete",
|
||||||
|
Data: map[string]any{
|
||||||
|
"available": true,
|
||||||
|
"path": "/var/lib/bat/glossary.sqlite",
|
||||||
|
"deleted": true,
|
||||||
|
"term": map[string]any{"term_id": "term-sensei"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected method %q", req.Method)
|
||||||
|
return testResponse{}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
summary, err := client.GlossarySummary(context.Background(), GlossarySummaryParams{
|
||||||
|
GlossaryPath: "/var/lib/bat/glossary.sqlite",
|
||||||
|
})
|
||||||
|
if err != nil || summary.Summary == nil || summary.Summary.ApprovedCount != 1 {
|
||||||
|
t.Fatalf("summary=%#v err=%v", summary, err)
|
||||||
|
}
|
||||||
|
query, err := client.GlossaryQuery(context.Background(), GlossaryQueryParams{
|
||||||
|
GlossaryPath: "/var/lib/bat/glossary.sqlite",
|
||||||
|
SourceText: "Sensei",
|
||||||
|
Limit: &limit,
|
||||||
|
})
|
||||||
|
if err != nil || len(query.Terms) != 1 || query.Terms[0].ReviewStatus != GlossaryStatusApproved {
|
||||||
|
t.Fatalf("query=%#v err=%v", query, err)
|
||||||
|
}
|
||||||
|
diagnose, err := client.GlossaryDiagnose(context.Background(), GlossaryDiagnoseParams{
|
||||||
|
GlossaryPath: "/var/lib/bat/glossary.sqlite",
|
||||||
|
SourceText: "Sensei",
|
||||||
|
Context: TranslationMemoryContext{"destination": "Bundle/dialogue.bundle"},
|
||||||
|
})
|
||||||
|
if err != nil || !json.Valid(diagnose.Evaluation) {
|
||||||
|
t.Fatalf("diagnose=%#v err=%v", diagnose, err)
|
||||||
|
}
|
||||||
|
add, err := client.GlossaryAdd(context.Background(), GlossaryTermMutationParams{
|
||||||
|
GlossaryPath: "/var/lib/bat/glossary.sqlite",
|
||||||
|
TermID: "term-sensei",
|
||||||
|
SourceTerm: "Sensei",
|
||||||
|
RecommendedTranslation: "老师",
|
||||||
|
ReviewStatus: "draft",
|
||||||
|
Source: GlossarySourceRecord{
|
||||||
|
SourceKind: "manual",
|
||||||
|
ObservedUnixSeconds: 100,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil || add.Term.TermID != "term-sensei" {
|
||||||
|
t.Fatalf("add=%#v err=%v", add, err)
|
||||||
|
}
|
||||||
|
updated, err := client.GlossaryUpdate(context.Background(), GlossaryTermMutationParams{
|
||||||
|
GlossaryPath: "/var/lib/bat/glossary.sqlite",
|
||||||
|
TermID: "term-sensei",
|
||||||
|
SourceTerm: "Sensei",
|
||||||
|
RecommendedTranslation: "老师",
|
||||||
|
ReviewStatus: "draft",
|
||||||
|
Reviewer: "reviewer",
|
||||||
|
Source: GlossarySourceRecord{
|
||||||
|
SourceKind: "manual",
|
||||||
|
ObservedUnixSeconds: 101,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil || updated.Term.ReviewStatus != GlossaryStatusDraft {
|
||||||
|
t.Fatalf("update=%#v err=%v", updated, err)
|
||||||
|
}
|
||||||
|
approved, err := client.GlossaryApprove(context.Background(), GlossaryReviewParams{
|
||||||
|
GlossaryPath: "/var/lib/bat/glossary.sqlite",
|
||||||
|
TermID: "term-sensei",
|
||||||
|
Reviewer: "reviewer",
|
||||||
|
})
|
||||||
|
if err != nil || approved.Term.ReviewStatus != GlossaryStatusApproved {
|
||||||
|
t.Fatalf("approve=%#v err=%v", approved, err)
|
||||||
|
}
|
||||||
|
deprecated, err := client.GlossaryDeprecate(context.Background(), GlossaryReviewParams{
|
||||||
|
GlossaryPath: "/var/lib/bat/glossary.sqlite",
|
||||||
|
TermID: "term-sensei",
|
||||||
|
Reviewer: "reviewer",
|
||||||
|
Reason: "retired",
|
||||||
|
})
|
||||||
|
if err != nil || deprecated.Term.ReviewStatus != GlossaryStatusDeprecated {
|
||||||
|
t.Fatalf("deprecate=%#v err=%v", deprecated, err)
|
||||||
|
}
|
||||||
|
deleted, err := client.GlossaryDelete(context.Background(), GlossaryDeleteParams{
|
||||||
|
GlossaryPath: "/var/lib/bat/glossary.sqlite",
|
||||||
|
TermID: "term-sensei",
|
||||||
|
Reviewer: "reviewer",
|
||||||
|
Reason: "duplicate",
|
||||||
|
})
|
||||||
|
if err != nil || !deleted.Available || !deleted.Deleted {
|
||||||
|
t.Fatalf("delete=%#v err=%v", deleted, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplicationErrorReturnsAPIError(t *testing.T) {
|
func TestApplicationErrorReturnsAPIError(t *testing.T) {
|
||||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
if req.Method != "task.status" {
|
if req.Method != "task.status" {
|
||||||
|
|||||||
@@ -141,12 +141,43 @@ require_contains "docs/reports/CURRENT_GAPS.md" "daemon.clean-stable"
|
|||||||
require_contains "docs/reference/rpc-backend-api.md" "daemon.restart"
|
require_contains "docs/reference/rpc-backend-api.md" "daemon.restart"
|
||||||
require_contains "docs/reference/rpc-backend-api.md" "daemon.clean-stable"
|
require_contains "docs/reference/rpc-backend-api.md" "daemon.clean-stable"
|
||||||
require_contains "docs/reference/rpc-backend-api.md" "localized_release_status"
|
require_contains "docs/reference/rpc-backend-api.md" "localized_release_status"
|
||||||
|
require_contains "docs/reference/rpc-backend-api.md" "official_distribution_ready"
|
||||||
|
require_contains "docs/reference/rpc-backend-api.md" "release.attestation"
|
||||||
|
require_contains "docs/reference/rpc-backend-api.md" "expected_manifest_identity"
|
||||||
|
require_contains "docs/reference/rpc-backend-api.md" "expected_verification_generation"
|
||||||
|
require_contains "docs/architecture/resource-release-layout.md" "release.attestation"
|
||||||
|
|
||||||
require_contains "Makefile" "check-docs:"
|
require_contains "Makefile" "check-docs:"
|
||||||
require_contains ".gitea/workflows/bat.yml" "make check-docs"
|
require_contains "Makefile" "check-go-format:"
|
||||||
require_contains ".gitea/workflows/bat.yml" "make test-go-api"
|
require_contains "Makefile" "format: fmt"
|
||||||
require_contains ".gitea/workflows/bat.yml" "go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/..."
|
require_contains "Makefile" "ci-check:"
|
||||||
require_contains ".gitea/workflows/bat.yml" "go build -o /tmp/bat-api ./cmd/bat-api"
|
require_contains "Makefile" "ci: ci-check"
|
||||||
|
require_file "scripts/ci-check.sh"
|
||||||
|
require_file "scripts/check-go-format.sh"
|
||||||
|
require_file "scripts/ci-versions.sh"
|
||||||
|
require_contains "scripts/ci-check.sh" "RUN required:"
|
||||||
|
require_contains "scripts/ci-check.sh" "make check-go-format"
|
||||||
|
require_contains "scripts/ci-check.sh" "Go lint version"
|
||||||
|
require_contains "scripts/ci-check.sh" "GOLANGCI_LINT_VERSION"
|
||||||
|
require_contains "scripts/ci-versions.sh" 'GOLANGCI_LINT_VERSION="2.12.2"'
|
||||||
|
require_contains "docs/guides/development.md" "golangci-lint --version"
|
||||||
|
require_contains "docs/guides/development.md" "required gate"
|
||||||
|
require_contains "docs/guides/development.md" "make ci-check"
|
||||||
|
require_contains "docs/guides/development.md" "唯一完整 required quality gate"
|
||||||
|
if grep -RIEq --exclude=check-doc-status.sh --exclude-dir=.git --exclude-dir=archive --exclude-dir=historical \
|
||||||
|
--exclude-dir=target --exclude-dir=bin \
|
||||||
|
-- '可选 lint|optional lint|optional golangci-lint' .; then
|
||||||
|
fail "current documentation still describes Go lint as optional"
|
||||||
|
fi
|
||||||
|
if grep -Fq "ci: fmt" Makefile; then
|
||||||
|
fail "Makefile ci target must not run the mutating fmt target"
|
||||||
|
fi
|
||||||
|
if grep -RIEq --include='*.md' --exclude-dir=.git --exclude-dir=archive --exclude-dir=historical \
|
||||||
|
--exclude-dir=target --exclude-dir=bin \
|
||||||
|
-- '自托管 Gitea|Gitea[[:space:]]+(runner|CI|workflow)|self-hosted|self hosted|\.gitea/workflows' \
|
||||||
|
README.md CURRENT_STATUS.md TODO.md docs PROJECT_PLAN.md AGENTS.md DOCS_INDEX.md 2>/dev/null; then
|
||||||
|
fail "current documentation still describes a Gitea/self-hosted CI runner"
|
||||||
|
fi
|
||||||
require_contains "Makefile" "cargo clippy --workspace --all-targets -- -D warnings"
|
require_contains "Makefile" "cargo clippy --workspace --all-targets -- -D warnings"
|
||||||
|
|
||||||
openapi_tmp="$(mktemp)"
|
openapi_tmp="$(mktemp)"
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
: "${GOCACHE:=/tmp/bat-go-cache}"
|
||||||
|
export GOCACHE
|
||||||
|
|
||||||
|
mapfile -t go_dirs < <(go list -f '{{.Dir}}' ./...)
|
||||||
|
go_files=()
|
||||||
|
for dir in "${go_dirs[@]}"; do
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
go_files+=("${file}")
|
||||||
|
done < <(find "${dir}" -maxdepth 1 -type f -name '*.go' -print0)
|
||||||
|
done
|
||||||
|
|
||||||
|
if ((${#go_files[@]} == 0)); then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
formatted="$(gofmt -l "${go_files[@]}")"
|
||||||
|
if [[ -n "${formatted}" ]]; then
|
||||||
|
printf 'gofmt required; files need formatting:\n%s\n' "${formatted}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "${repo_root}"
|
||||||
|
|
||||||
|
: "${GOCACHE:=/tmp/bat-go-cache}"
|
||||||
|
export GOCACHE
|
||||||
|
: "${XDG_CACHE_HOME:=/tmp/bat-xdg-cache}"
|
||||||
|
export XDG_CACHE_HOME
|
||||||
|
|
||||||
|
source "${repo_root}/scripts/ci-versions.sh"
|
||||||
|
export GOLANGCI_LINT_VERSION
|
||||||
|
|
||||||
|
run_required() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
printf 'RUN required: %s\n' "${name}"
|
||||||
|
"$@"
|
||||||
|
printf 'PASS required: %s\n' "${name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_required "Rust formatting" cargo fmt --all -- --check
|
||||||
|
run_required "Go formatting" make check-go-format
|
||||||
|
run_required "Rust check" cargo check --workspace --locked
|
||||||
|
run_required "Rust release build" cargo build --workspace --release --locked
|
||||||
|
run_required "Rust clippy" cargo clippy --workspace --all-targets --locked -- -D warnings
|
||||||
|
run_required "Rust tests" cargo test --workspace --locked
|
||||||
|
run_required "Go API tests" go test ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||||
|
run_required "Go API vet" go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
|
||||||
|
run_required "Go API build" go build -o /tmp/bat-api ./cmd/bat-api
|
||||||
|
run_required "Go lint version" bash -c '
|
||||||
|
source scripts/ci-versions.sh
|
||||||
|
command -v golangci-lint >/dev/null 2>&1 ||
|
||||||
|
{ printf "golangci-lint %s is required but not installed\n" "${GOLANGCI_LINT_VERSION}" >&2; exit 1; }
|
||||||
|
actual="$(golangci_lint_actual_version)"
|
||||||
|
if [[ "${actual}" != "${GOLANGCI_LINT_VERSION}" ]]; then
|
||||||
|
printf "golangci-lint version mismatch: required=%s actual=%s\n" \
|
||||||
|
"${GOLANGCI_LINT_VERSION}" "${actual:-unknown}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
'
|
||||||
|
run_required "Go lint" golangci-lint run ./...
|
||||||
|
run_required "Documentation, OpenAPI, and contract checks" make check-docs
|
||||||
|
|
||||||
|
printf 'all required check-only gates passed\n'
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Canonical versions for required local quality-gate tools.
|
||||||
|
GOLANGCI_LINT_VERSION="2.12.2"
|
||||||
|
|
||||||
|
golangci_lint_actual_version() {
|
||||||
|
golangci-lint version 2>/dev/null |
|
||||||
|
sed -n 's/.*has version \([^ ]*\).*/\1/p'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user