From ff1adb91eed381a6a4cdf0067b0f86dcac83721e Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Fri, 18 Sep 2026 06:44:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(tm):=E5=BB=BA=E7=AB=8B=20Trusted=20?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E6=80=A7=E4=B8=8E=20Supersede=20=E6=B2=BB?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/bat.yml | 268 --- CURRENT_STATUS.md | 19 +- PROJECT_PLAN.md | 16 +- README.md | 4 +- TODO.md | 31 +- USERGUIDE.md | 26 +- api/openapi/bat-api.yaml | 39 +- core/src/domain/mod.rs | 7 +- core/src/domain/translation_memory.rs | 24 + .../translation_memory_repository.rs | 27 +- docs/api/README.md | 2 +- docs/architecture/README.md | 11 +- ...4-rust-bat-go-bat-api-resource-boundary.md | 6 +- .../architecture/official-resource-backend.md | 6 +- docs/guides/bat-workflows.md | 4 +- docs/guides/deployment.md | 4 +- docs/guides/development.md | 12 +- docs/reference/rpc-backend-api.md | 16 +- .../BAT_API_CONTRACT_FIXTURE_HANDOFF.md | 3 +- docs/reports/CURRENT_GAPS.md | 15 +- docs/reports/GO_STATUS.md | 2 +- infrastructure/src/bin/bat/app.rs | 297 +++- infrastructure/src/bin/bat/app_tests.rs | 54 + .../src/bin/bat/translation_query.rs | 156 +- infrastructure/src/glossary.rs | 2 +- infrastructure/src/translation_memory.rs | 1570 ++++++++++++++++- infrastructure/src/translation_worker.rs | 132 ++ internal/api/admin.go | 120 +- internal/api/api_test.go | 100 +- internal/api/openapi.go | 39 +- internal/api/rpc_release.go | 10 + internal/api/server.go | 2 + internal/api/testdata/contract/README.md | 2 +- internal/backendrpc/client.go | 75 +- internal/backendrpc/client_test.go | 89 +- scripts/check-doc-status.sh | 17 +- scripts/ci-versions.sh | 2 +- 37 files changed, 2679 insertions(+), 530 deletions(-) delete mode 100644 .gitea/workflows/bat.yml diff --git a/.gitea/workflows/bat.yml b/.gitea/workflows/bat.yml deleted file mode 100644 index 8086965..0000000 --- a/.gitea/workflows/bat.yml +++ /dev/null @@ -1,268 +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: Check Go formatting - shell: bash - run: | - set -euo pipefail - - source /var/lib/act_runner/env.sh - make check-go-format - - - 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: Required Go lint - shell: bash - run: | - set -euo pipefail - - source /var/lib/act_runner/env.sh - source scripts/ci-versions.sh - command -v golangci-lint >/dev/null 2>&1 - actual="$(golangci_lint_actual_version)" - test "${actual}" = "${GOLANGCI_LINT_VERSION}" - export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp/bat-xdg-cache}" - golangci-lint run ./... - - - name: Run documentation status gate - shell: bash - run: | - set -euo pipefail - - make check-docs diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index f9e07ba..f2d3427 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -32,13 +32,13 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 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 位于 `/translation-memory.sqlite`,不放在 `versions/` 或 release task 库中; +当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`;跨 release 的 Translation Memory SQLite persistence schema V2 位于 `/translation-memory.sqlite`,不放在 `versions/` 或 release task 库中; `translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整 -job/unit/provider run 状态;`translation.memory.summary/query/confirm` 提供 -Rust-owned TM 的摘要、source/context 查询和显式 trusted 确认,`bat-api` 仅作 -typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取 +job/unit/provider run 状态;`translation.memory.summary/query/confirm/conflicts/resolve_conflict` +提供 Rust-owned TM 的摘要、source/context 查询、显式 trusted 确认和冲突治理, +`bat-api` 仅作 typed 管理转发。当前下载实现使用默认 8 个独立 worker,完成后动态领取 任务,最终资源报告按 pull plan 顺序输出。 -项目级 Glossary V2 位于 `/glossary.sqlite`,独立于 release task 和 TM; +项目级 Glossary 使用 Glossary domain/feature contract V1,由 SQLite persistence schema V2 承载,位于 `/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 必须携带 @@ -48,8 +48,11 @@ TextUnit scope、source history 和 approved review。worker、TM 复用、人 三个长期 SQLite owner 现在统一使用只读 schema preflight、精确 component fingerprint 和 `BEGIN IMMEDIATE` writer transaction:Translation Tasks 从 V1 -按显式 `v1 -> v2` step 迁移,当前版本为 V2;Translation Memory 当前版本为 V1, -Glossary 当前版本为 V2。Glossary V2 正式吸收历史上未升版本的 +按显式 `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, @@ -321,7 +324,7 @@ cargo run -p bat-infrastructure --bin bat -- \ --watch ``` -资源 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`、`translation.glossary.*`、`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。 生产要求: diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 28080fe..c641728 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -46,7 +46,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 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 兼容层。 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` 严格校验;ZIP 内 bundle 在 `archive_entry` 可验证时会重写外层 ZIP。真实 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;真实运行记录处于长期运行测试阶段,报告待后续提供。 8. 内嵌 dashboard MVP 已实现;完整 Web 协作后台、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发/OpenAPI/管理控制面已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。 9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。 @@ -169,7 +169,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 **目标**:能够获取、解析和同步 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 仍待完成。 交付物: @@ -260,10 +260,10 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 交付物: -1. Translation Memory V1 已使用项目级 SQLite schema:source raw/hash、translation、完整 context、candidate/trusted 和 provenance。 -2. 已实现 raw source + 完整 context exact match;模糊匹配和完整导入导出仍待实现。Glossary V2 已作为独立项目级 SQLite 资产接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override。 -3. 已实现显式 per-record confirm;Glossary V2 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。 -4. 已实现 `bat i18n memory summary|query|confirm` 与对应 Rust RPC。 +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、current Trusted 唯一性和冲突诊断;模糊匹配和完整导入导出仍待实现。Glossary domain/feature contract V1 已由 SQLite persistence schema V2 承载,接入 approved review、scope/alias/priority、provider constraints、确定性 QA 和显式 override。 +3. 已实现显式 per-record confirm、supersede 和历史冲突 resolve;Glossary 已实现术语优先级、别名、分类、冲突检测和审核历史,批量审核与完整导入导出仍待实现。 +4. 已实现 `bat i18n memory summary|query|confirm|conflicts|resolve-conflict` 与对应 Rust RPC。 验收标准: @@ -370,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:本地开发、服务端部署。 3. 数据备份与恢复文档。 4. 用户文档、开发文档、故障排查文档。 @@ -462,7 +462,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 按最终目标计算,当前总体完成度不固定写单一百分比,以模块状态、源码、测试和契约为准。 -已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、Translation Memory V1、Glossary 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 兼容和官方资源长期运行报告。 +已完成的是稳定基线、架构骨架、部分接口、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 兼容和官方资源长期运行报告。 --- diff --git a/README.md b/README.md index 815d93f..ea44e85 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - 官方同步会维护 `/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 发布后会生成 `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 和 Glossary 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 和 provenance 查询。模糊匹配和完整 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/` 并切换 `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-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。 @@ -31,7 +31,7 @@ - `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面、launcher 资源引导兼容和内嵌 dashboard MVP 已可用。 - 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。 - 复杂 AssetBundle 重打包和完整翻译资产编排仍未完成;当前 generic manifest 已驱动已验证的 Binary/JSON/Text 与 UnityFS localized 操作,未知结构仍明确拒绝。 -- Translation Memory、Glossary 和完整 Provider 扩展体系:Translation Memory V1 与 Glossary V2 已由 Rust `bat` 持有;仍未实现的是模糊匹配、完整 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 协作后台。 详细状态见: diff --git a/TODO.md b/TODO.md index 65f93d9..effc569 100644 --- a/TODO.md +++ b/TODO.md @@ -319,14 +319,15 @@ T03 → T16 显式迁移。V1-A/V1-B 均兼容缺失 `schema_migrations` 或 component row,未知和 future schema 仍 fail closed;已验证业务 term/history、deletion audit 保留、失败回滚重试、 V1-A 并发迁移和 V2 reopen no-op。当前 Translation Tasks 为 V2、Translation Memory -为 V1、Glossary 为 V2。 +persistence schema 为 V2、Glossary domain/feature contract 为 V1 且 persistence schema +为 V2。 --- # T04 — Translation Memory Trusted 唯一性与 Supersede 治理 **类型:** P2 -**状态:** Ready +**状态:** Done ## 问题 @@ -570,17 +571,18 @@ CI 检查必须: * 明确报告每项 gate; * required tool 缺失时不应伪装成全部通过; * required tool 缺失或版本不匹配必须失败; -* 与实际 Gitea CI 尽量保持一致。 +* 与本地 `make ci-check` 的完整 required quality gate 保持一致。 ## 完成记录 `make format` / `make fmt` 保留为显式写入命令,`make ci-check` 和兼容的 `make ci` 只执行 read-only required gates;共享 `scripts/check-go-format.sh` 由 -`make check-go-format`、本地 `scripts/ci-check.sh` 和 Gitea workflow 共用。`golangci-lint -2.12.2` 由 `scripts/ci-versions.sh` 固定,缺失或版本不匹配失败;OpenAPI、RPC contract -和文档一致性由 `make check-docs` 纳入。Gitea self-hosted runner 执行相同的 required -Rust/Go/docs 语义,不添加 GitHub Actions。最终 workspace gates、`make test-go-api`、 -`make check-docs` 和 `make ci-check` 均已通过。 +`make check-go-format` 和本地 `scripts/ci-check.sh` 共用。`golangci-lint 2.12.2` +由 `scripts/ci-versions.sh` 固定,缺失或版本不匹配失败;OpenAPI、RPC contract +和文档一致性由 `make check-docs` 纳入。项目以本地 `make ci-check` 作为唯一完整 +required quality gate,开发过程中可运行 focused checks 以快速反馈,但提交前完整 gate +不得省略;仓库不依赖 Gitea、GitHub Actions 或其它远端 CI runner。最终 workspace +gates、`make test-go-api`、`make check-docs` 和 `make ci-check` 均已通过。 建议统一覆盖: @@ -835,12 +837,21 @@ T09/T10 ─→ T11 增加 AssetBundle/Patch 真实验证 **Hard Blocker:T02** +## 完成记录 + +已完成 Translation Memory persistence schema V2、current Trusted 唯一性、显式 +supersede、历史 Trusted 冲突诊断与 `resolve_conflict`。确认和冲突解决均使用 +`BEGIN IMMEDIATE`、稳定 record ID 和 stale snapshot 校验,保留双向 supersede +关系与 audit event;worker 遇到冲突不会自动复用。Rust RPC/CLI、Go +`bat-api` typed forwarding、OpenAPI、focused regression/concurrency tests 和 +文档已同步。 + --- # T14 — G-012 Translation Memory 后续扩展 **类型:** Feature -**状态:** Blocked +**状态:** Ready ## 前置 @@ -874,7 +885,7 @@ Embedding/vector 不应默认进入第一阶段。 ## 目标 -在现有 Glossary V2 基础上逐步增加: +在现有 Glossary domain/feature contract V1、persistence schema V2 基础上逐步增加: * bulk import/export; * richer search; diff --git a/USERGUIDE.md b/USERGUIDE.md index 5da2b05..a831913 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -189,7 +189,8 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn | `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-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 记录 | +| `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 | @@ -300,7 +301,7 @@ curl -i -H 'Range: bytes=0-1023' \ - 已运行的 daemon 不会热读 `config.toml`;默认 `reload` 只唤醒后台重新发现和刷新。需要应用配置文件变更时,使用带显式启动参数的 `restart`/`reload`,或先 `stop` 再重新启动 daemon。 - `BAT_REDIS_URL` / `BAT_REDIS_PASSWORD` 为**预留键**:Redis 任务后端尚未接入,当前任务历史持久化在 `/bat-tasks.json`。 -### Translation Memory V1 +### Translation Memory persistence schema V2 Translation Memory 由 Rust `bat` 独立持有,默认路径为 `/translation-memory.sqlite`,不在 `versions/` 内,也不使用当前 release @@ -311,16 +312,23 @@ Translation Memory 由 Rust `bat` 独立持有,默认路径为 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 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 -自动复用。provider 输出写入先是 `candidate`;manual task result 即使 completed 也不会自动 -建立 TM 或 trusted。查询、诊断和显式 confirm 对应 Rust -RPC `translation.memory.summary`、`translation.memory.query`、`translation.memory.confirm`。 -context 不完整或不一致、normalized source 辅助命中和 workflow `proofread` 都不会自动 -建立 trusted 记录。 +只有 raw source 完全相同、完整 context 完全相同且状态为 current `trusted` 的单条记录会被 +worker 自动复用。provider 输出写入先是 `candidate`;manual task result 即使 completed +也不会自动建立 TM 或 trusted。查询、诊断、确认和冲突治理对应 Rust +RPC `translation.memory.summary`、`translation.memory.query`、 +`translation.memory.confirm`、`translation.memory.conflicts` 和 +`translation.memory.resolve_conflict`。同一 identity 存在多个 current Trusted 时, +查询返回 `trusted_conflict`,worker 禁止自动复用;确认不同译文必须显式指定 supersede +目标,历史冲突必须通过 resolve_conflict 选择稳定 record ID。context 不完整或不一致、 +normalized source 辅助命中和 workflow `proofread` 都不会自动建立 trusted 记录。 -### Glossary V2 +### Glossary domain/feature contract V1,SQLite persistence schema V2 Glossary 由 Rust `bat` 独立持有,默认路径为 `/glossary.sqlite`,不位于 `versions/`,也不与 TM 或当前 release 的 task 库共用。配置覆盖方式为 diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index fe5dc84..32e3f88 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -505,6 +505,31 @@ paths: description: Missing or invalid admin token. "503": 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 @@ -630,7 +655,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup] + 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: required: false content: @@ -745,6 +770,18 @@ paths: type: string record_id: 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: diff --git a/core/src/domain/mod.rs b/core/src/domain/mod.rs index 5a42aab..86be6ee 100644 --- a/core/src/domain/mod.rs +++ b/core/src/domain/mod.rs @@ -23,7 +23,8 @@ pub use translation::{ TranslationStatus, }; pub use translation_memory::{ - TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry, - TranslationMemoryMatch, TranslationMemoryMatchKind, TranslationMemorySourceKind, - TranslationMemorySourceTrace, TranslationMemorySummary, TranslationMemoryTrustStatus, + TranslationMemoryConflict, TranslationMemoryContext, TranslationMemoryDraft, + TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemoryMatchKind, + TranslationMemorySourceKind, TranslationMemorySourceTrace, TranslationMemorySummary, + TranslationMemoryTrustStatus, }; diff --git a/core/src/domain/translation_memory.rs b/core/src/domain/translation_memory.rs index a51ccae..cd11460 100644 --- a/core/src/domain/translation_memory.rs +++ b/core/src/domain/translation_memory.rs @@ -57,6 +57,8 @@ impl TranslationMemoryTrustStatus { pub enum TranslationMemoryMatchKind { /// 原始 source 和上下文都完全匹配,且记录可信,可自动复用。 StrongExact, + /// 同一 exact identity 存在多个 Trusted,必须人工治理。 + TrustedConflict, /// 原始 source 完全匹配,但上下文不同或不足,不能自动复用。 CandidateExact, /// 原始 source 匹配,但上下文不兼容,不能自动复用。 @@ -68,6 +70,7 @@ impl TranslationMemoryMatchKind { pub const fn as_str(&self) -> &'static str { match self { Self::StrongExact => "strong_exact", + Self::TrustedConflict => "trusted_conflict", Self::CandidateExact => "candidate_exact", Self::SourceOnly => "source_only", } @@ -210,6 +213,23 @@ pub struct TranslationMemoryMatch { 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, + /// 冲突组记录及其原始 trust provenance。 + pub records: Vec, +} + /// TM 仓储摘要。 #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct TranslationMemorySummary { @@ -225,4 +245,8 @@ pub struct TranslationMemorySummary { pub superseded_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, } diff --git a/core/src/repositories/translation_memory_repository.rs b/core/src/repositories/translation_memory_repository.rs index 5a434fe..5769058 100644 --- a/core/src/repositories/translation_memory_repository.rs +++ b/core/src/repositories/translation_memory_repository.rs @@ -1,14 +1,14 @@ //! Translation Memory 仓储契约。 use crate::domain::{ - TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry, - TranslationMemoryMatch, TranslationMemorySummary, + TranslationMemoryConflict, TranslationMemoryContext, TranslationMemoryDraft, + TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemorySummary, }; use async_trait::async_trait; /// 跨 official release 持久化的 Translation Memory 仓储。 /// -/// 该契约只描述 V1 的精确查询和明确人工确认。仓储实现不得把 +/// 该契约描述 exact-match TM 和明确人工 Trusted 治理。仓储实现不得把 /// `TranslationTaskStatus::Completed` 或 provider 成功隐式解释为 trusted。 #[async_trait] pub trait TranslationMemoryRepository: Send + Sync { @@ -39,6 +39,27 @@ pub trait TranslationMemoryRepository: Send + Sync { reason: Option, ) -> crate::Result; + /// 确认记录,并在需要时显式 supersede 当前唯一 Trusted。 + async fn confirm_with_supersede( + &self, + record_id: &str, + reviewer: &str, + reason: Option, + supersede_record_id: Option<&str>, + ) -> crate::Result; + + /// 列出历史上存在多个 Trusted 的 exact identity 冲突组。 + async fn list_conflicts(&self, limit: usize) -> crate::Result>; + + /// 使用事务内精确的 expected set 显式解决一个 Trusted 冲突组。 + async fn resolve_conflict( + &self, + winner_record_id: &str, + expected_trusted_record_ids: &[String], + reviewer: &str, + reason: &str, + ) -> crate::Result; + /// 按稳定记录 ID 读取一条 TM 记录。 async fn find(&self, record_id: &str) -> crate::Result; diff --git a/docs/api/README.md b/docs/api/README.md index d34a6ec..aa46408 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -21,7 +21,7 @@ Go `cmd/bat-api` 是资源 bootstrap、已发布资源分发和鉴权控制服 - `/admin/` 与白名单 `/admin/control/{action}`;其中翻译管理面包含 `/admin/translation/tasks`、`/admin/translation/handoff`、 `/admin/translation/memory/summary`、`/admin/translation/memory/query` 和 - `translation-memory-confirm` 转发 + `translation-memory-confirm`、`translation-memory-resolve-conflict` 转发 - `/openapi.yaml` HTTP 路由的 OpenAPI 文本由 `internal/api/openapi.go` 提供,运行中的服务也可 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0a1ef1d..1aa1325 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -196,8 +196,10 @@ pub struct ParserRegistry { ### 4. 翻译系统(目标扩展,Go;当前 worker 由 Rust `bat` 承担) 当前已实现的是 Rust `bat` 的离线 TextUnit 队列、mock/Crowdin provider worker、 -lease/retry、结果落库、项目级 Translation Memory V1 和独立 Glossary V2。TM 位于独立 -SQLite,按 raw source + 完整 context 做 trusted exact reuse,candidate 必须显式 confirm; +lease/retry、结果落库、项目级 Translation Memory persistence schema V2 和独立 Glossary +domain/feature contract V1(SQLite persistence schema V2)。TM 位于独立 SQLite,按 raw +source + 完整 context 做 current Trusted exact reuse,candidate 必须显式 confirm; +同一 identity 的不同译文必须显式 supersede,历史 Trusted 冲突必须显式 resolve; Glossary 只有 approved term 进入 provider/TM 自动流程,并在结果上执行确定性 QA;模糊 匹配和完整 Provider 体系仍属后续缺口。 @@ -229,7 +231,7 @@ type TranslationProvider interface { - Azure Translator Provider **翻译记忆库**: -- 当前 V1:raw source 完全相同、完整 context 完全相同且记录为 trusted 时自动复用。 +- 当前规则:raw source 完全相同、完整 context 完全相同且只有一条 current Trusted 时自动复用。 - provider 输出写入先是 candidate;manual task result 不会自动建立 TM 或 trusted。`bat i18n memory confirm` 显式确认单条记录后才可自动复用。 - source、context、release、TextUnit、provider 和 run provenance 保存在 Rust TM SQLite 中。 - 模糊匹配、术语优先级和 PostgreSQL 服务化仍不是当前实现。 @@ -296,7 +298,8 @@ HTTP Request → Middleware (Auth, CORS, Logger) → Handler → Service → Rep ### 7. Web 后台 (Vue 3,目标设计) -当前只有 `bat-api` 内嵌 dashboard MVP;Rust `bat` 的 Glossary V2 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。 +当前只有 `bat-api` 内嵌 dashboard MVP;Rust `bat` 的 Glossary domain/feature contract V1 +及 SQLite persistence schema V2 已实现,登录、角色、Web 术语管理和完整协作审核仍未实现。 **技术栈**: - Vue 3 + Composition API diff --git a/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md b/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md index 815d32a..7bcb87d 100644 --- a/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md +++ b/docs/architecture/adr/0004-rust-bat-go-bat-api-resource-boundary.md @@ -36,8 +36,10 @@ - 新的跨语言控制和查询能力优先增加 Rust RPC contract,再由 `internal/backendrpc` 消费。 -4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标;Rust `bat` 已持有 Glossary V2,Web 术语协作视图仍待建设**; - Translation Memory V1 已由 Rust `bat` 持有,不能从目标架构图推断 Go 侧拥有第二份状态。 +4. **完整游戏业务 API、完整 Web 协作后台和 Provider 扩展体系仍是后续目标;Rust `bat` 已持有 + Glossary domain/feature contract V1(SQLite persistence schema V2),Web 术语协作视图仍待建设**; + Translation Memory persistence schema V2 已由 Rust `bat` 持有,不能从目标架构图推断 Go + 侧拥有第二份状态。 --- diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index d75562d..b140557 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -180,9 +180,9 @@ `available=false`,不会因为查询创建空库。发布后的 TextUnit 队列还会在当前 release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrations` 管理 V2 queued/running/failed/completed/skipped、provider run、lease、失败分类、 -重试计划和 TextUnit 级译文结果。跨 release 的 Translation Memory V1 独立存储在 -`/translation-memory.sqlite`,记录 raw source/hash、完整 context、candidate/ -trusted 和 release/TextUnit/provider/run provenance;`translation.tasks` 优先查询这份状态库, +重试计划和 TextUnit 级译文结果。跨 release 的 Translation Memory persistence schema V2 +独立存储在 `/translation-memory.sqlite`,记录 raw source/hash、完整 context、 +candidate/trusted 和 release/TextUnit/provider/run provenance;`translation.tasks` 优先查询这份状态库, `translation.worker.run` 由 Rust worker 回写状态;`translation.task.update` 仍供外部 provider 流程回写状态; 没有状态库的旧 release 才回退到 immutable JSON 队列。`bat doctor cas` 已提供只读 CAS 根目录、对象目录、元数据库文件和对象统计诊断;`resource.index` diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index 6a070b2..fa14b7d 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -213,8 +213,8 @@ bat i18n worker run \ `--watch`,因此可以单次、限定次数或周期执行;`--run-count > 1` 时仍必须 显式指定 `--interval`。 -Glossary V2 是 Rust `bat` 持有的独立项目级 SQLite 资产,默认位于 -`/glossary.sqlite`;V2 正式吸收历史上的 `glossary_term_deletions` +Glossary domain/feature contract V1 由 Rust `bat` 持有,并由 SQLite persistence schema V2 +承载,默认位于 `/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 返回 diff --git a/docs/guides/deployment.md b/docs/guides/deployment.md index ee2c720..370f374 100644 --- a/docs/guides/deployment.md +++ b/docs/guides/deployment.md @@ -9,8 +9,8 @@ BlueArchive Toolkit 的部署文档分为当前可用模式和目标模式: 2. **官方资源同步生产任务**:当前可用,运行 Rust `bat --watch` 或 RPC/daemon 模式。 3. **bat-api 资源 bootstrap / 分发服务**:当前可用,和 Rust `bat` 在同一服务器/容器环境运行,经 `bat.sock` RPC 获取当前 `resource_root`。 4. **可选数据库开发环境**:PostgreSQL/Redis 只服务于未来的 Go 服务层、完整 Web 协作后台和 - Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory V1 - 使用 `/translation-memory.sqlite`。 + Provider 扩展,不是当前 `bat` / `bat-api` 的生产运行依赖;当前 Translation Memory + persistence schema V2 使用 `/translation-memory.sqlite`。 5. **完整单机/分布式部署**:尚未提供。完整游戏业务 API、数据库迁移和 Web 未实现前,不把它作为可执行部署方案。 --- diff --git a/docs/guides/development.md b/docs/guides/development.md index 85bd93d..ab875af 100644 --- a/docs/guides/development.md +++ b/docs/guides/development.md @@ -23,9 +23,11 @@ rustc --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 git --version @@ -38,11 +40,9 @@ golangci-lint --version # 必须为 2.12.2 ``` 缺少上述命令、版本不匹配或 `golangci-lint` 不是 2.12.2 都会使 required gate 失败; -`golangci-lint 2.12.2` 是 required gate,不是可选检查。该 workflow 会用 -`GITHUB_SERVER_URL`、`GITHUB_REPOSITORY`、`GITHUB_REF` 和 `GITHUB_SHA` 手动 `git fetch` -当前提交,再执行 Rust workspace 的只读格式检查、检查、构建、clippy 和测试,以及通过 +`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 和文档状态门禁。 -这样可以避免自托管 runner 在准备阶段通过代理克隆第三方 action 仓库。 #### Docker ```bash diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index 484ec02..1c0fccc 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -261,9 +261,11 @@ rollback 或 repair。 | `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.proofread` | 已实现 | `null` | 将当前汉化 workflow 标记为人工校对中,返回工作流状态报告。 | -| `translation.memory.summary` | 已实现 | 可选 `{ "translation_memory_path": "..." }` | 返回 TM schema 版本、总记录数及 candidate/trusted/rejected/superseded 状态计数。 | -| `translation.memory.query` | 已实现 | `{ "source_text": "...", "source_context": {...}, "limit": 100 }` | 按 raw source 查询记录,返回 match kind、trust、translation 和 provenance。 | -| `translation.memory.confirm` | 已实现 | `{ "record_id": "...", "reviewer": "...", "reason": "..." }` | 显式确认一条 candidate 为 trusted;worker 之后才可自动复用。 | +| `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;conflict 结果不可自动复用。 | +| `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 决策。 | @@ -535,7 +537,8 @@ CLI 对应关系: | `bat i18n worker run` | `translation.worker.run` | | `bat i18n proofread` | `translation.proofread` | | `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` | @@ -573,7 +576,8 @@ CLI 对应关系: `localized.status`、`localized.publish`、`localized.rollback`、 `translation.tasks`、`translation.handoff`、`translation.task.update`、 `translation.worker.run`、`translation.proofread`、`translation.memory.summary`、 - `translation.memory.query`、`translation.memory.confirm`、`translation.glossary.summary`、 + `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`、 @@ -594,7 +598,7 @@ CLI 对应关系: | `TaskBackend` | `task.list`、`task.status`、`task.logs`、`task.cancel` | 鉴权后的 daemon 任务查询和取消 | | `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 触发与人工校对标记 | -| `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 状态、发布与显式回滚 | | `ReleaseBackend` | `release.status`、`release.list`、`release.distribution`、`release.cleanup` | 鉴权后的双 release 查询、验证分发选择和 dry-run/execute cleanup;Go 不持有 release 状态 | diff --git a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md index 22afd58..78187df 100644 --- a/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md +++ b/docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md @@ -74,7 +74,8 @@ Rust 窗口请基于当前真实代码生成或导出以下 JSON: 2. `catalog.status` available=false 响应。 3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。 4. 对应 release 的 `official-sync-snapshot.json`。 -5. Rust Glossary V2 的 `translation.glossary.query` 响应,至少包含 alias、approved +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 或临时目录,但不能依赖开发机真实资源目录。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 3e2b063..6e54aa5 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -152,17 +152,18 @@ official/localized distribution 均被阻断,普通查询不会自动重建。 更完整的查询/权限/损坏恢复、模糊 TM、bat.sock peer credential/perms、FFI 生命周期、 资源大小/限额与更强的持久化 fsync 语义仍按后续专项推进。 -### G-012:Translation Memory V1 已实现,扩展能力仍缺失 +### G-012:Translation Memory persistence schema V2 已实现,扩展能力仍缺失 -Rust `bat` 已提供独立项目级 SQLite TM,当前 schema version 为 V1;schema 打开遵守 +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 才能建立 trusted 记录;worker 只自动复用 -trusted 的 raw source + 完整 context exact match,并在复用前执行已批准 Glossary 的 -确定性 QA。Go `bat-api` 已提供鉴权的 summary/query 只读接口和 confirm 转发,但 Go -不持有 TM 状态。仍缺少模糊匹配和更丰富的导入导出历史能力。 +区分 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 V2 已实现,协作视图仍缺失 +### 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 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index e262d58..0c83583 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -95,7 +95,7 @@ | 组件 | 路径 | 状态 | 说明 | |---|---|---|---| | Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 | -| RPC client | `internal/backendrpc` | **完成** | 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`、`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 输出字段 | +| 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` + `/v1/releases` + `/v1/distribution` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + release/localized/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` | diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index e23ebbf..afa8454 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -106,7 +106,8 @@ use terminal_output::RotatingStructuredLogger; use terminal_output::STARTUP_BANNER; use translation_query::{ build_translation_handoff_report, build_translation_memory_confirm_report, - build_translation_memory_query_report, build_translation_memory_summary_report, + build_translation_memory_conflicts_report, build_translation_memory_query_report, + build_translation_memory_resolve_conflict_report, build_translation_memory_summary_report, build_translation_tasks_report, run_translation_memory_command, textunit_query_json, update_translation_task_status_report, }; @@ -242,7 +243,9 @@ fn run() -> anyhow::Result { } CliCommand::TranslationMemorySummary | CliCommand::TranslationMemoryQuery - | CliCommand::TranslationMemoryConfirm => { + | CliCommand::TranslationMemoryConfirm + | CliCommand::TranslationMemoryConflicts + | CliCommand::TranslationMemoryResolveConflict => { run_translation_memory_command(&options)?; Ok(0) } @@ -421,6 +424,8 @@ struct CliOptions { translation_memory_source_text: Option, translation_memory_context_json: Option, translation_memory_record_id: Option, + translation_memory_supersede_record_id: Option, + translation_memory_expected_trusted_record_ids_json: Option, translation_memory_reviewer: Option, translation_memory_reason: Option, glossary_term_id: Option, @@ -548,6 +553,8 @@ impl Default for CliOptions { translation_memory_source_text: None, translation_memory_context_json: None, translation_memory_record_id: None, + translation_memory_supersede_record_id: None, + translation_memory_expected_trusted_record_ids_json: None, translation_memory_reviewer: None, translation_memory_reason: None, glossary_term_id: None, @@ -666,6 +673,8 @@ enum CliCommand { TranslationMemorySummary, TranslationMemoryQuery, TranslationMemoryConfirm, + TranslationMemoryConflicts, + TranslationMemoryResolveConflict, GlossarySummary, GlossaryQuery, GlossaryAdd, @@ -1223,6 +1232,8 @@ const RPC_METHOD_TRANSLATION_WORKER_RUN: &str = "translation.worker.run"; const RPC_METHOD_TRANSLATION_MEMORY_SUMMARY: &str = "translation.memory.summary"; const RPC_METHOD_TRANSLATION_MEMORY_QUERY: &str = "translation.memory.query"; const RPC_METHOD_TRANSLATION_MEMORY_CONFIRM: &str = "translation.memory.confirm"; +const RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS: &str = "translation.memory.conflicts"; +const RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT: &str = "translation.memory.resolve_conflict"; const RPC_METHOD_GLOSSARY_SUMMARY: &str = "translation.glossary.summary"; const RPC_METHOD_GLOSSARY_QUERY: &str = "translation.glossary.query"; const RPC_METHOD_GLOSSARY_ADD: &str = "translation.glossary.add"; @@ -2304,6 +2315,30 @@ fn dispatch_rpc_method( request.params.as_ref(), ), ), + RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS => translation_memory_rpc_envelope( + request_id, + translation_memory_conflicts_rpc_report( + state_dir, + &tasks.base_config.output_root, + tasks + .translation_worker_config + .translation_memory_path + .as_deref(), + request.params.as_ref(), + ), + ), + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT => translation_memory_rpc_envelope( + request_id, + translation_memory_resolve_conflict_rpc_report( + state_dir, + &tasks.base_config.output_root, + tasks + .translation_worker_config + .translation_memory_path + .as_deref(), + request.params.as_ref(), + ), + ), RPC_METHOD_GLOSSARY_SUMMARY => glossary_rpc_envelope( request_id, glossary_summary_rpc_report( @@ -4157,6 +4192,28 @@ fn translation_memory_rpc_envelope( } } +fn translation_memory_api_error(method: &'static str, error: anyhow::Error) -> ApiError { + let message = error.to_string(); + let code = if message.starts_with("Invalid argument:") + || message.starts_with("Object not found:") + || [ + "trusted_conflict_requires_resolution", + "explicit_supersede_required", + "trusted_translation_already_exists", + "stale_supersede_target", + "conflict_snapshot_stale", + "invalid_conflict_winner", + ] + .iter() + .any(|marker| message.contains(marker)) + { + ErrorCode::RPC_INVALID_PARAMS + } else { + ErrorCode::INTERNAL + }; + ApiError::new(code, method, message) +} + fn translation_memory_rpc_params<'a>( params: Option<&'a serde_json::Value>, method: &'static str, @@ -4240,17 +4297,16 @@ fn translation_memory_rpc_context( }) } -fn translation_memory_rpc_limit(params: Option<&serde_json::Value>) -> Result { - let limit = match translation_memory_rpc_value( - params, - &["limit"], - RPC_METHOD_TRANSLATION_MEMORY_QUERY, - )? { +fn translation_memory_rpc_limit( + params: Option<&serde_json::Value>, + method: &'static str, +) -> Result { + let limit = match translation_memory_rpc_value(params, &["limit"], method)? { None | Some(serde_json::Value::Null) => 100, Some(value) => value.as_u64().ok_or_else(|| { ApiError::new( ErrorCode::RPC_INVALID_PARAMS, - RPC_METHOD_TRANSLATION_MEMORY_QUERY, + method, "limit 必须是非负整数 JSON number", ) })?, @@ -4258,14 +4314,14 @@ fn translation_memory_rpc_limit(params: Option<&serde_json::Value>) -> Result, + params: Option<&serde_json::Value>, +) -> Result { + let limit = translation_memory_rpc_limit(params, RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS)?; + let path = translation_memory_rpc_path( + state_dir, + output_root, + default_translation_memory_path, + params, + RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS, + )?; + build_translation_memory_conflicts_report(&path, limit).map_err(|error| { + translation_memory_api_error(RPC_METHOD_TRANSLATION_MEMORY_CONFLICTS, error) + }) +} + +fn translation_memory_resolve_conflict_rpc_report( + state_dir: &Path, + output_root: &Path, + default_translation_memory_path: Option<&Path>, + params: Option<&serde_json::Value>, +) -> Result { + let winner_record_id = translation_memory_rpc_string_param( + params, + &["winner_record_id", "record_id"], + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + "winner_record_id", + )? + .ok_or_else(|| { ApiError::new( - ErrorCode::INTERNAL, - RPC_METHOD_TRANSLATION_MEMORY_CONFIRM, - error.to_string(), + ErrorCode::RPC_INVALID_PARAMS, + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + "translation.memory.resolve_conflict 缺少 winner_record_id", ) + })?; + let reviewer = translation_memory_rpc_string_param( + params, + &["reviewer"], + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + "reviewer", + )? + .ok_or_else(|| { + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + "translation.memory.resolve_conflict 缺少 reviewer", + ) + })?; + let reason = translation_memory_rpc_string_param( + params, + &["reason"], + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + "reason", + )? + .ok_or_else(|| { + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + "translation.memory.resolve_conflict 缺少 reason", + ) + })?; + let expected = translation_memory_rpc_value( + params, + &["expected_trusted_record_ids"], + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + )? + .ok_or_else(|| { + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + "translation.memory.resolve_conflict 缺少 expected_trusted_record_ids", + ) + })?; + let expected = expected.as_array().ok_or_else(|| { + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + "expected_trusted_record_ids 必须是 JSON array", + ) + })?; + let expected = expected + .iter() + .map(|value| { + value.as_str().map(str::to_string).ok_or_else(|| { + ApiError::new( + ErrorCode::RPC_INVALID_PARAMS, + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + "expected_trusted_record_ids 必须只包含字符串", + ) + }) + }) + .collect::, _>>()?; + let path = translation_memory_rpc_path( + state_dir, + output_root, + default_translation_memory_path, + params, + RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, + )?; + build_translation_memory_resolve_conflict_report( + &path, + winner_record_id, + &expected, + reviewer, + reason, + ) + .map_err(|error| { + translation_memory_api_error(RPC_METHOD_TRANSLATION_MEMORY_RESOLVE_CONFLICT, error) }) } @@ -7350,10 +7518,20 @@ fn parse_args_with_env( Some(next_option_value(&mut args, &flag)?); options.translation_memory_command_option_explicit = true; } - "--tm-record-id" => { + "--tm-record-id" | "--tm-winner-record-id" => { options.translation_memory_record_id = Some(next_option_value(&mut args, &flag)?); options.translation_memory_command_option_explicit = true; } + "--tm-supersede-record-id" => { + options.translation_memory_supersede_record_id = + Some(next_option_value(&mut args, &flag)?); + options.translation_memory_command_option_explicit = true; + } + "--tm-expected-trusted-record-ids-json" => { + options.translation_memory_expected_trusted_record_ids_json = + Some(next_option_value(&mut args, &flag)?); + options.translation_memory_command_option_explicit = true; + } "--tm-reviewer" => { options.translation_memory_reviewer = Some(next_option_value(&mut args, &flag)?); options.translation_memory_command_option_explicit = true; @@ -7938,6 +8116,8 @@ fn parse_args_with_env( CliCommand::TranslationMemorySummary | CliCommand::TranslationMemoryQuery | CliCommand::TranslationMemoryConfirm + | CliCommand::TranslationMemoryConflicts + | CliCommand::TranslationMemoryResolveConflict | CliCommand::Restart | CliCommand::Reload ) @@ -7981,6 +8161,8 @@ fn parse_args_with_env( | CliCommand::TranslationMemorySummary | CliCommand::TranslationMemoryQuery | CliCommand::TranslationMemoryConfirm + | CliCommand::TranslationMemoryConflicts + | CliCommand::TranslationMemoryResolveConflict | CliCommand::GlossarySummary | CliCommand::GlossaryQuery | CliCommand::GlossaryAdd @@ -8004,6 +8186,8 @@ fn parse_args_with_env( CliCommand::TranslationMemorySummary | CliCommand::TranslationMemoryQuery | CliCommand::TranslationMemoryConfirm + | CliCommand::TranslationMemoryConflicts + | CliCommand::TranslationMemoryResolveConflict ) { return Err(anyhow::anyhow!( @@ -8044,7 +8228,9 @@ fn parse_args_with_env( } CliCommand::TranslationMemorySummary | CliCommand::TranslationMemoryQuery - | CliCommand::TranslationMemoryConfirm => { + | CliCommand::TranslationMemoryConfirm + | CliCommand::TranslationMemoryConflicts + | CliCommand::TranslationMemoryResolveConflict => { if options.watch || options.daemon || options.daemon_child { return Err(anyhow::anyhow!("i18n memory 命令只支持单次执行或 RPC 调用")); } @@ -8079,6 +8265,10 @@ fn parse_args_with_env( } CliCommand::TranslationMemoryQuery => { if options.translation_memory_record_id.is_some() + || options.translation_memory_supersede_record_id.is_some() + || options + .translation_memory_expected_trusted_record_ids_json + .is_some() || options.translation_memory_reviewer.is_some() || options.translation_memory_reason.is_some() { @@ -8088,11 +8278,38 @@ fn parse_args_with_env( CliCommand::TranslationMemoryConfirm => { if options.translation_memory_source_text.is_some() || options.translation_memory_context_json.is_some() + || options + .translation_memory_expected_trusted_record_ids_json + .is_some() || options.query_limit != 100 { return Err(anyhow::anyhow!("i18n memory confirm 不接受 query 参数")); } } + CliCommand::TranslationMemoryConflicts => { + if options.translation_memory_source_text.is_some() + || options.translation_memory_context_json.is_some() + || options.translation_memory_record_id.is_some() + || options.translation_memory_supersede_record_id.is_some() + || options.translation_memory_reviewer.is_some() + || options.translation_memory_reason.is_some() + { + return Err(anyhow::anyhow!( + "i18n memory conflicts 不接受 confirm/query 参数" + )); + } + } + CliCommand::TranslationMemoryResolveConflict => { + if options.translation_memory_source_text.is_some() + || options.translation_memory_context_json.is_some() + || options.translation_memory_supersede_record_id.is_some() + || options.query_limit != 100 + { + return Err(anyhow::anyhow!( + "i18n memory resolve-conflict 不接受 query/supersede 参数" + )); + } + } _ => unreachable!(), } if matches!(options.command, CliCommand::TranslationMemoryQuery) @@ -8110,6 +8327,20 @@ fn parse_args_with_env( "i18n memory confirm 必须指定 --tm-record-id 和 --tm-reviewer" )); } + if matches!( + options.command, + CliCommand::TranslationMemoryResolveConflict + ) && (options.translation_memory_record_id.is_none() + || options + .translation_memory_expected_trusted_record_ids_json + .is_none() + || options.translation_memory_reviewer.is_none() + || options.translation_memory_reason.is_none()) + { + return Err(anyhow::anyhow!( + "i18n memory resolve-conflict 必须指定 --tm-record-id(winner)、--tm-expected-trusted-record-ids-json、--tm-reviewer 和 --tm-reason" + )); + } options.progress = false; options.banner = false; } @@ -8779,6 +9010,8 @@ fn parse_translation_memory_command( "summary" | "status" => CliCommand::TranslationMemorySummary, "query" | "find" => CliCommand::TranslationMemoryQuery, "confirm" | "trust" => CliCommand::TranslationMemoryConfirm, + "conflicts" | "list-conflicts" => CliCommand::TranslationMemoryConflicts, + "resolve-conflict" | "resolve" => CliCommand::TranslationMemoryResolveConflict, other => return Err(anyhow::anyhow!("未知 translation memory 二级命令:{other}")), }; ensure_command_not_set(options.command, &format!("translation memory {action}"))?; diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index bae6052..4103b69 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -260,6 +260,47 @@ fn translation_memory_commands_parse_and_validate() { ]) .unwrap(); 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", @@ -2914,6 +2955,19 @@ fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() { "translation.memory.confirm", 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( &rpc_request(method, params), diff --git a/infrastructure/src/bin/bat/translation_query.rs b/infrastructure/src/bin/bat/translation_query.rs index 2147e59..fa18f54 100644 --- a/infrastructure/src/bin/bat/translation_query.rs +++ b/infrastructure/src/bin/bat/translation_query.rs @@ -433,6 +433,10 @@ pub(super) fn run_translation_memory_command(options: &CliOptions) -> anyhow::Re CliCommand::TranslationMemorySummary => RPC_METHOD_TRANSLATION_MEMORY_SUMMARY, CliCommand::TranslationMemoryQuery => RPC_METHOD_TRANSLATION_MEMORY_QUERY, 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 命令")), }; if daemon_rpc_available(&options.state_dir) @@ -480,6 +484,34 @@ pub(super) fn run_translation_memory_command(options: &CliOptions) -> anyhow::Re record_id, reviewer, 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::>(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!(), @@ -538,10 +570,49 @@ fn translation_memory_cli_params( .ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-reviewer"))?; params.insert("record_id".to_string(), serde_json::json!(record_id)); 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() { 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::>(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!(), } Ok(Some(serde_json::Value::Object(params))) @@ -625,6 +696,7 @@ pub(super) fn build_translation_memory_confirm_report( record_id: &str, reviewer: &str, reason: Option, + supersede_record_id: Option<&str>, ) -> anyhow::Result { if record_id.trim().is_empty() || reviewer.trim().is_empty() { return Err(anyhow::anyhow!( @@ -645,7 +717,89 @@ pub(super) fn build_translation_memory_confirm_report( .await .map_err(|error| anyhow::anyhow!("{error}"))?; 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 { + 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 { + 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 .map_err(|error| anyhow::anyhow!("{error}")) })?; diff --git a/infrastructure/src/glossary.rs b/infrastructure/src/glossary.rs index cef0a4e..36cbe0e 100644 --- a/infrastructure/src/glossary.rs +++ b/infrastructure/src/glossary.rs @@ -1,4 +1,4 @@ -//! Project-level Glossary V2 SQLite repository. +//! Project-level Glossary SQLite persistence schema V2 repository. use crate::path_security::{ ensure_safe_directory_path, lexical_absolute, set_file_mode, STATE_FILE_MODE, diff --git a/infrastructure/src/translation_memory.rs b/infrastructure/src/translation_memory.rs index 67f6ae1..f7f9495 100644 --- a/infrastructure/src/translation_memory.rs +++ b/infrastructure/src/translation_memory.rs @@ -6,9 +6,9 @@ use crate::sqlite_migration::{ }; use async_trait::async_trait; use bat_core::domain::{ - TranslationMemoryContext, TranslationMemoryDraft, TranslationMemoryEntry, - TranslationMemoryMatch, TranslationMemoryMatchKind, TranslationMemorySourceKind, - TranslationMemorySummary, TranslationMemoryTrustStatus, + TranslationMemoryConflict, TranslationMemoryContext, TranslationMemoryDraft, + TranslationMemoryEntry, TranslationMemoryMatch, TranslationMemoryMatchKind, + TranslationMemorySourceKind, TranslationMemorySummary, TranslationMemoryTrustStatus, }; use bat_core::repositories::TranslationMemoryRepository; use bat_core::{Error, Result}; @@ -21,7 +21,7 @@ use std::str::FromStr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// TM SQLite schema 版本。 -pub const TRANSLATION_MEMORY_SCHEMA_VERSION: u32 = 1; +pub const TRANSLATION_MEMORY_SCHEMA_VERSION: u32 = 2; /// TM schema migration component。 pub const TRANSLATION_MEMORY_SCHEMA_COMPONENT: &str = "translation_memory"; /// 默认 TM 数据库文件名。 @@ -162,9 +162,51 @@ impl SqliteTranslationMemoryRepository { .await .map_err(db_error)?; } - TranslationMemorySchemaState::Version(version) - if version == TRANSLATION_MEMORY_SCHEMA_VERSION => - { + TranslationMemorySchemaState::V1 => { + let revalidated = sqlite_migration::snapshot_connection( + transaction.as_mut(), + TRANSLATION_MEMORY_SCHEMA_COMPONENT, + ) + .await + .map_err(db_error)?; + if !matches_translation_memory_v1_fingerprint(&revalidated) + && !matches_translation_memory_v1_component_fingerprint(&revalidated) + { + return Err(invalid_translation_memory_schema( + "V1 migration 起点在事务内发生变化".to_string(), + )); + } + if !revalidated + .tables + .contains_key(sqlite_migration::SCHEMA_MIGRATIONS_TABLE) + { + sqlite_migration::create_schema_migrations_table(transaction) + .await + .map_err(db_error)?; + } + create_translation_memory_governance_table(transaction, fail_after_step).await?; + let migrated = sqlite_migration::snapshot_connection( + transaction.as_mut(), + TRANSLATION_MEMORY_SCHEMA_COMPONENT, + ) + .await + .map_err(db_error)?; + if !matches_translation_memory_v2_fingerprint(&migrated) + && !matches_translation_memory_v2_component_fingerprint(&migrated) + { + return Err(invalid_translation_memory_schema( + "V1 migration 结果与 V2 fingerprint 不一致".to_string(), + )); + } + sqlite_migration::write_component_version( + transaction, + TRANSLATION_MEMORY_SCHEMA_COMPONENT, + TRANSLATION_MEMORY_SCHEMA_VERSION, + ) + .await + .map_err(db_error)?; + } + TranslationMemorySchemaState::V2 => { if snapshot.component_version.is_none() { if !snapshot .tables @@ -183,11 +225,6 @@ impl SqliteTranslationMemoryRepository { .map_err(db_error)?; } } - TranslationMemorySchemaState::Version(version) => { - return Err(invalid_translation_memory_schema(format!( - "内部不支持的迁移起点 {version}" - ))); - } } let final_snapshot = sqlite_migration::snapshot_connection( @@ -197,7 +234,7 @@ impl SqliteTranslationMemoryRepository { .await .map_err(db_error)?; if final_snapshot.component_version != Some(i64::from(TRANSLATION_MEMORY_SCHEMA_VERSION)) - || !matches_translation_memory_fingerprint(&final_snapshot) + || !matches_translation_memory_v2_fingerprint(&final_snapshot) { return Err(invalid_translation_memory_schema( "migration 结果与当前 schema fingerprint 不一致".to_string(), @@ -230,7 +267,8 @@ impl SqliteTranslationMemoryRepository { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TranslationMemorySchemaState { Empty, - Version(u32), + V1, + V2, } fn classify_translation_memory_schema( @@ -251,18 +289,17 @@ fn classify_translation_memory_schema( ))); } } - if (matches_translation_memory_fingerprint(snapshot) + let v1 = matches_translation_memory_v1_fingerprint(snapshot) || (snapshot.component_version.is_none() - && matches_translation_memory_component_fingerprint(snapshot))) - && snapshot - .component_version - .is_none_or(|observed| observed == i64::from(TRANSLATION_MEMORY_SCHEMA_VERSION)) - { - return Ok(TranslationMemorySchemaState::Version( - TRANSLATION_MEMORY_SCHEMA_VERSION, - )); - } + && matches_translation_memory_v1_component_fingerprint(snapshot)); + let v2 = matches_translation_memory_v2_fingerprint(snapshot) + || (snapshot.component_version.is_none() + && matches_translation_memory_v2_component_fingerprint(snapshot)); match snapshot.component_version { + Some(1) if v1 => Ok(TranslationMemorySchemaState::V1), + None if v1 => Ok(TranslationMemorySchemaState::V1), + Some(2) if v2 => Ok(TranslationMemorySchemaState::V2), + None if v2 => Ok(TranslationMemorySchemaState::V2), Some(observed) => Err(invalid_translation_memory_schema(format!( "schema version 与实际结构不一致:observed={observed}, supported={TRANSLATION_MEMORY_SCHEMA_VERSION}" ))), @@ -276,23 +313,28 @@ fn invalid_translation_memory_schema(detail: String) -> Error { Error::InvalidArgument(format!("Translation Memory schema 无效:{detail}")) } -fn matches_translation_memory_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { - matches_translation_memory_fingerprint_with_migrations(snapshot, true) +fn matches_translation_memory_v1_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { + matches_translation_memory_fingerprint_with_migrations(snapshot, true, false) } -fn matches_translation_memory_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { - matches_translation_memory_fingerprint_with_migrations(snapshot, false) +fn matches_translation_memory_v1_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { + matches_translation_memory_fingerprint_with_migrations(snapshot, false, false) +} + +fn matches_translation_memory_v2_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { + matches_translation_memory_fingerprint_with_migrations(snapshot, true, true) +} + +fn matches_translation_memory_v2_component_fingerprint(snapshot: &SqliteSchemaSnapshot) -> bool { + matches_translation_memory_fingerprint_with_migrations(snapshot, false, true) } fn matches_translation_memory_fingerprint_with_migrations( snapshot: &SqliteSchemaSnapshot, include_migrations: bool, + include_governance: bool, ) -> bool { - let tables = [ExpectedTable { - name: "translation_memory", - columns: &TRANSLATION_MEMORY_COLUMNS, - }]; - let indexes = [ + let mut indexes = vec![ ExpectedIndex { table: "translation_memory", name: "idx_translation_memory_source_hash", @@ -309,9 +351,32 @@ fn matches_translation_memory_fingerprint_with_migrations( columns: &["source_hash", "source_context_hash"], }, ]; + if include_governance { + indexes.extend([ + ExpectedIndex { + table: "translation_memory_trust_events", + name: "idx_translation_memory_trust_events_identity", + columns: &["source_hash", "source_context_hash"], + }, + ExpectedIndex { + table: "translation_memory_trust_events", + name: "idx_translation_memory_trust_events_observed", + columns: &["observed_unix_seconds"], + }, + ]); + } + let mut tables = vec![ExpectedTable { + name: "translation_memory", + columns: &TRANSLATION_MEMORY_COLUMNS, + }]; + if include_governance { + tables.push(ExpectedTable { + name: "translation_memory_trust_events", + columns: &TRANSLATION_MEMORY_TRUST_EVENT_COLUMNS, + }); + } if include_migrations { - let tables = [sqlite_migration::schema_migrations_table(), tables[0]]; - return sqlite_migration::matches_fingerprint(snapshot, &tables, &indexes); + tables.insert(0, sqlite_migration::schema_migrations_table()); } sqlite_migration::matches_fingerprint(snapshot, &tables, &indexes) } @@ -459,6 +524,86 @@ const TRANSLATION_MEMORY_COLUMNS: [ExpectedColumn<'static>; 20] = [ }, ]; +const TRANSLATION_MEMORY_TRUST_EVENT_COLUMNS: [ExpectedColumn<'static>; 11] = [ + ExpectedColumn { + name: "event_id", + data_type: "TEXT", + not_null: true, + default_value: None, + primary_key: true, + }, + ExpectedColumn { + name: "action", + data_type: "TEXT", + not_null: true, + default_value: None, + primary_key: false, + }, + ExpectedColumn { + name: "winner_record_id", + data_type: "TEXT", + not_null: false, + default_value: None, + primary_key: false, + }, + ExpectedColumn { + name: "affected_record_ids_json", + data_type: "TEXT", + not_null: true, + default_value: None, + primary_key: false, + }, + ExpectedColumn { + name: "source_text", + data_type: "TEXT", + not_null: true, + default_value: None, + primary_key: false, + }, + ExpectedColumn { + name: "source_hash", + data_type: "TEXT", + not_null: true, + default_value: None, + primary_key: false, + }, + ExpectedColumn { + name: "source_context_json", + data_type: "TEXT", + not_null: true, + default_value: None, + primary_key: false, + }, + ExpectedColumn { + name: "source_context_hash", + data_type: "TEXT", + not_null: true, + default_value: None, + primary_key: false, + }, + ExpectedColumn { + name: "reviewer", + data_type: "TEXT", + not_null: true, + default_value: None, + primary_key: false, + }, + ExpectedColumn { + name: "reason", + data_type: "TEXT", + not_null: false, + default_value: None, + primary_key: false, + }, + ExpectedColumn { + name: "observed_unix_seconds", + data_type: "INTEGER", + not_null: true, + default_value: None, + primary_key: false, + }, +]; + async fn create_translation_memory_schema( transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, fail_after_step: Option, @@ -502,6 +647,29 @@ async fn create_translation_memory_schema( ON translation_memory(normalized_source_text)", "CREATE INDEX idx_translation_memory_context ON translation_memory(source_hash, source_context_hash)", + "CREATE TABLE translation_memory_trust_events ( + event_id TEXT PRIMARY KEY NOT NULL, + action TEXT NOT NULL, + winner_record_id TEXT, + affected_record_ids_json TEXT NOT NULL, + source_text TEXT NOT NULL, + source_hash TEXT NOT NULL, + source_context_json TEXT NOT NULL, + source_context_hash TEXT NOT NULL, + reviewer TEXT NOT NULL, + reason TEXT, + observed_unix_seconds INTEGER NOT NULL, + CHECK (action IN ('confirm', 'supersede', 'resolve_conflict')), + CHECK (length(affected_record_ids_json) > 0), + CHECK (length(source_text) > 0), + CHECK (length(source_hash) > 0), + CHECK (length(source_context_hash) > 0), + CHECK (length(reviewer) > 0) + )", + "CREATE INDEX idx_translation_memory_trust_events_identity + ON translation_memory_trust_events(source_hash, source_context_hash)", + "CREATE INDEX idx_translation_memory_trust_events_observed + ON translation_memory_trust_events(observed_unix_seconds)", ]; for (index, statement) in steps.iter().enumerate() { sqlx::query(statement) @@ -518,6 +686,50 @@ async fn create_translation_memory_schema( Ok(()) } +async fn create_translation_memory_governance_table( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + fail_after_step: Option, +) -> Result<()> { + let steps = [ + "CREATE TABLE translation_memory_trust_events ( + event_id TEXT PRIMARY KEY NOT NULL, + action TEXT NOT NULL, + winner_record_id TEXT, + affected_record_ids_json TEXT NOT NULL, + source_text TEXT NOT NULL, + source_hash TEXT NOT NULL, + source_context_json TEXT NOT NULL, + source_context_hash TEXT NOT NULL, + reviewer TEXT NOT NULL, + reason TEXT, + observed_unix_seconds INTEGER NOT NULL, + CHECK (action IN ('confirm', 'supersede', 'resolve_conflict')), + CHECK (length(affected_record_ids_json) > 0), + CHECK (length(source_text) > 0), + CHECK (length(source_hash) > 0), + CHECK (length(source_context_hash) > 0), + CHECK (length(reviewer) > 0) + )", + "CREATE INDEX idx_translation_memory_trust_events_identity + ON translation_memory_trust_events(source_hash, source_context_hash)", + "CREATE INDEX idx_translation_memory_trust_events_observed + ON translation_memory_trust_events(observed_unix_seconds)", + ]; + for (index, statement) in steps.iter().enumerate() { + sqlx::query(statement) + .execute(&mut **transaction) + .await + .map_err(db_error)?; + if fail_after_step == Some(index + 1) { + return Err(Error::Other(anyhow::anyhow!( + "Translation Memory V1 migration failed after step {}", + index + 1 + ))); + } + } + Ok(()) +} + #[async_trait] impl TranslationMemoryRepository for SqliteTranslationMemoryRepository { async fn upsert_candidate( @@ -635,34 +847,46 @@ impl TranslationMemoryRepository for SqliteTranslationMemoryRepository { created_unix_seconds, updated_unix_seconds, trusted_unix_seconds, trusted_by, trusted_reason, supersedes_record_id, superseded_by_record_id FROM translation_memory - WHERE source_hash = ?1 OR normalized_source_text = ?2 + WHERE source_hash = ?1 OR normalized_source_text = ?2 OR source_text = ?3 ORDER BY updated_unix_seconds DESC, record_id ASC "#, ) .bind(source_hash) .bind(&normalized_source_text) + .bind(source_text) .fetch_all(&self.pool) .await .map_err(db_error)?; - let mut matches = rows + let matches = rows .into_iter() .map(row_to_entry) .collect::>>()? - .into_iter() + .into_iter(); + let exact_trusted_count = matches + .clone() + .filter(|entry| { + entry.source_text == source_text + && entry.source_context == *source_context + && is_current_trusted(entry) + }) + .count(); + let mut matches = matches .filter_map(|entry| { let raw_exact = entry.source_text == source_text; let normalized_exact = entry.normalized_source_text == normalized_source_text; if !raw_exact && !normalized_exact { return None; } - let same_context = !source_context.is_empty() - && !entry.source_context.is_empty() - && entry.source_context == *source_context; + let same_context = raw_exact && entry.source_context == *source_context; + let conflict = same_context && exact_trusted_count > 1; let strong = raw_exact && same_context - && entry.trust_status == TranslationMemoryTrustStatus::Trusted; + && exact_trusted_count == 1 + && is_current_trusted(&entry); let match_kind = if strong { TranslationMemoryMatchKind::StrongExact + } else if conflict { + TranslationMemoryMatchKind::TrustedConflict } else if raw_exact { TranslationMemoryMatchKind::CandidateExact } else { @@ -695,40 +919,108 @@ impl TranslationMemoryRepository for SqliteTranslationMemoryRepository { record_id: &str, reviewer: &str, reason: Option, + ) -> Result { + self.confirm_with_supersede(record_id, reviewer, reason, None) + .await + } + + async fn confirm_with_supersede( + &self, + record_id: &str, + reviewer: &str, + reason: Option, + supersede_record_id: Option<&str>, ) -> Result { if reviewer.trim().is_empty() { return Err(Error::InvalidArgument( "Translation Memory reviewer 不能为空".to_string(), )); } - let current = self.find(record_id).await?; - if current.trust_status == TranslationMemoryTrustStatus::Trusted { - return Ok(current); - } - if current.trust_status != TranslationMemoryTrustStatus::Candidate { - return Err(Error::InvalidArgument(format!( - "Translation Memory 记录 {} 当前状态为 {},不能确认", + let reviewer = reviewer.trim(); + let reason = reason + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let mut transaction = sqlite_migration::begin_immediate(&self.pool) + .await + .map_err(db_error)?; + let result = self + .confirm_in_transaction( + &mut transaction, record_id, - current.trust_status.as_str() - ))); + reviewer, + reason.as_deref(), + supersede_record_id, + ) + .await; + match result { + Ok(()) => { + transaction.commit().await.map_err(db_error)?; + self.find(record_id).await + } + Err(error) => { + let _ = transaction.rollback().await; + Err(error) + } + } + } + + async fn list_conflicts(&self, limit: usize) -> Result> { + if limit == 0 || limit > 1000 { + return Err(Error::InvalidArgument( + "Translation Memory conflict limit 必须在 1..=1000 范围内".to_string(), + )); + } + let entries = self.load_all_entries().await?; + Ok(conflict_groups(entries).into_iter().take(limit).collect()) + } + + async fn resolve_conflict( + &self, + winner_record_id: &str, + expected_trusted_record_ids: &[String], + reviewer: &str, + reason: &str, + ) -> Result { + if reviewer.trim().is_empty() || reason.trim().is_empty() { + return Err(Error::InvalidArgument( + "conflict resolution requires non-empty reviewer and reason".to_string(), + )); + } + if expected_trusted_record_ids.is_empty() { + return Err(Error::InvalidArgument( + "conflict_snapshot_stale: expected_trusted_record_ids 不能为空".to_string(), + )); + } + let mut expected = expected_trusted_record_ids.to_vec(); + expected.sort(); + expected.dedup(); + if expected.len() != expected_trusted_record_ids.len() { + return Err(Error::InvalidArgument( + "conflict_snapshot_stale: expected_trusted_record_ids 必须唯一".to_string(), + )); + } + let mut transaction = sqlite_migration::begin_immediate(&self.pool) + .await + .map_err(db_error)?; + let result = self + .resolve_conflict_in_transaction( + &mut transaction, + winner_record_id, + &expected, + reviewer.trim(), + reason.trim(), + ) + .await; + match result { + Ok(()) => { + transaction.commit().await.map_err(db_error)?; + self.find(winner_record_id).await + } + Err(error) => { + let _ = transaction.rollback().await; + Err(error) + } } - let now = unix_seconds_now(); - sqlx::query( - r#" - UPDATE translation_memory - SET trust_status = 'trusted', trusted_unix_seconds = ?2, - trusted_by = ?3, trusted_reason = ?4, updated_unix_seconds = ?2 - WHERE record_id = ?1 AND trust_status = 'candidate' - "#, - ) - .bind(record_id) - .bind(i64::try_from(now).unwrap_or(i64::MAX)) - .bind(reviewer.trim()) - .bind(reason.filter(|value| !value.trim().is_empty())) - .execute(&self.pool) - .await - .map_err(db_error)?; - self.find(record_id).await } async fn find(&self, record_id: &str) -> Result { @@ -738,32 +1030,519 @@ impl TranslationMemoryRepository for SqliteTranslationMemoryRepository { } async fn summary(&self) -> Result { - let row = sqlx::query( - r#" - SELECT COUNT(*) AS record_count, - SUM(CASE WHEN trust_status = 'trusted' THEN 1 ELSE 0 END) AS trusted_count, - SUM(CASE WHEN trust_status = 'candidate' THEN 1 ELSE 0 END) AS candidate_count, - SUM(CASE WHEN trust_status = 'superseded' THEN 1 ELSE 0 END) AS superseded_count, - SUM(CASE WHEN trust_status = 'rejected' THEN 1 ELSE 0 END) AS rejected_count - FROM translation_memory - "#, - ) - .fetch_one(&self.pool) - .await - .map_err(db_error)?; + let entries = self.load_all_entries().await?; + let record_count = entries.len() as u64; + let trusted_count = entries + .iter() + .filter(|entry| entry.trust_status == TranslationMemoryTrustStatus::Trusted) + .count() as u64; + let candidate_count = entries + .iter() + .filter(|entry| entry.trust_status == TranslationMemoryTrustStatus::Candidate) + .count() as u64; + let superseded_count = entries + .iter() + .filter(|entry| entry.trust_status == TranslationMemoryTrustStatus::Superseded) + .count() as u64; + let rejected_count = entries + .iter() + .filter(|entry| entry.trust_status == TranslationMemoryTrustStatus::Rejected) + .count() as u64; + let groups = group_entries(entries); + let trusted_conflict_group_count = groups + .values() + .filter(|entries| current_trusted_ids(entries).len() > 1) + .count() as u64; + let current_trusted_count = groups + .values() + .filter(|entries| current_trusted_ids(entries).len() == 1) + .count() as u64; Ok(TranslationMemorySummary { schema_version: TRANSLATION_MEMORY_SCHEMA_VERSION, - record_count: row.try_get::("record_count").map_err(db_error)? as u64, - trusted_count: row.try_get::("trusted_count").map_err(db_error)? as u64, - candidate_count: row.try_get::("candidate_count").map_err(db_error)? as u64, - superseded_count: row - .try_get::("superseded_count") - .map_err(db_error)? as u64, - rejected_count: row.try_get::("rejected_count").map_err(db_error)? as u64, + record_count, + trusted_count, + candidate_count, + superseded_count, + rejected_count, + trusted_conflict_group_count, + current_trusted_count, }) } } +impl SqliteTranslationMemoryRepository { + async fn load_all_entries(&self) -> Result> { + let rows = sqlx::query(TRANSLATION_MEMORY_SELECT) + .fetch_all(&self.pool) + .await + .map_err(db_error)?; + rows.into_iter().map(row_to_entry).collect() + } + + async fn confirm_in_transaction( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + record_id: &str, + reviewer: &str, + reason: Option<&str>, + supersede_record_id: Option<&str>, + ) -> Result<()> { + let target = find_optional_in_transaction(transaction, record_id) + .await? + .ok_or_else(|| Error::NotFound(record_id.to_string()))?; + let identity_entries = exact_identity_entries_in_transaction( + transaction, + &target.source_text, + &target.source_context, + ) + .await?; + let current_ids = current_trusted_ids(&identity_entries); + + if target.trust_status == TranslationMemoryTrustStatus::Trusted + && target.superseded_by_record_id.is_none() + { + if current_ids.len() > 1 { + return Err(trusted_conflict_error(¤t_ids)); + } + if current_ids == [record_id.to_string()] { + return Ok(()); + } + } + if target.trust_status != TranslationMemoryTrustStatus::Candidate { + return Err(Error::InvalidArgument(format!( + "Translation Memory 记录 {} 当前状态为 {},不能确认", + record_id, + target.trust_status.as_str() + ))); + } + if current_ids.len() > 1 { + return Err(trusted_conflict_error(¤t_ids)); + } + + let now = unix_seconds_now(); + if let Some(current_id) = current_ids.first() { + if target.translated_text + == identity_entries + .iter() + .find(|entry| entry.record_id == *current_id) + .map(|entry| entry.translated_text.as_str()) + .unwrap_or_default() + { + return Err(Error::InvalidArgument(format!( + "trusted_translation_already_exists: current_trusted_record_id={current_id}" + ))); + } + let Some(expected) = supersede_record_id else { + return Err(Error::InvalidArgument(format!( + "explicit_supersede_required: current_trusted_record_id={current_id}" + ))); + }; + if expected != current_id { + return Err(Error::InvalidArgument(format!( + "stale_supersede_target: current_trusted_record_id={current_id}, expected={expected}" + ))); + } + if reason.is_none_or(str::is_empty) { + return Err(Error::InvalidArgument( + "supersede requires non-empty reason".to_string(), + )); + } + sqlx::query( + "UPDATE translation_memory + SET trust_status = 'superseded', superseded_by_record_id = ?2, + updated_unix_seconds = ?3 + WHERE record_id = ?1 AND trust_status = 'trusted'", + ) + .bind(current_id) + .bind(record_id) + .bind(i64::try_from(now).unwrap_or(i64::MAX)) + .execute(&mut **transaction) + .await + .map_err(db_error)?; + sqlx::query( + "UPDATE translation_memory + SET trust_status = 'trusted', trusted_unix_seconds = ?2, + trusted_by = ?3, trusted_reason = ?4, + supersedes_record_id = ?5, updated_unix_seconds = ?2 + WHERE record_id = ?1 AND trust_status = 'candidate'", + ) + .bind(record_id) + .bind(i64::try_from(now).unwrap_or(i64::MAX)) + .bind(reviewer) + .bind(reason) + .bind(current_id) + .execute(&mut **transaction) + .await + .map_err(db_error)?; + insert_trust_event( + transaction, + "supersede", + Some(record_id), + &[current_id.clone(), record_id.to_string()], + &target, + reviewer, + reason, + now, + ) + .await?; + } else { + if supersede_record_id.is_some() { + return Err(Error::InvalidArgument( + "stale_supersede_target: 当前 exact identity 没有 current Trusted".to_string(), + )); + } + sqlx::query( + "UPDATE translation_memory + SET trust_status = 'trusted', trusted_unix_seconds = ?2, + trusted_by = ?3, trusted_reason = ?4, updated_unix_seconds = ?2 + WHERE record_id = ?1 AND trust_status = 'candidate'", + ) + .bind(record_id) + .bind(i64::try_from(now).unwrap_or(i64::MAX)) + .bind(reviewer) + .bind(reason) + .execute(&mut **transaction) + .await + .map_err(db_error)?; + insert_trust_event( + transaction, + "confirm", + Some(record_id), + &[record_id.to_string()], + &target, + reviewer, + reason, + now, + ) + .await?; + } + + let final_entries = exact_identity_entries_in_transaction( + transaction, + &target.source_text, + &target.source_context, + ) + .await?; + if current_trusted_ids(&final_entries) != [record_id.to_string()] { + return Err(Error::InvalidArgument( + "Translation Memory confirm 未建立唯一 current Trusted".to_string(), + )); + } + Ok(()) + } + + async fn resolve_conflict_in_transaction( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + winner_record_id: &str, + expected_trusted_record_ids: &[String], + reviewer: &str, + reason: &str, + ) -> Result<()> { + let winner = find_optional_in_transaction(transaction, winner_record_id) + .await? + .ok_or_else(|| Error::NotFound(winner_record_id.to_string()))?; + let identity_entries = exact_identity_entries_in_transaction( + transaction, + &winner.source_text, + &winner.source_context, + ) + .await?; + let current_ids = current_trusted_ids(&identity_entries); + if current_ids.len() <= 1 || current_ids != expected_trusted_record_ids { + return Err(Error::InvalidArgument(format!( + "conflict_snapshot_stale: current_trusted_record_ids={}", + current_ids.join(",") + ))); + } + if winner.trust_status != TranslationMemoryTrustStatus::Candidate + && !current_ids.contains(&winner.record_id) + { + return Err(Error::InvalidArgument(format!( + "invalid_conflict_winner: record_id={winner_record_id}" + ))); + } + let winner_is_candidate = winner.trust_status == TranslationMemoryTrustStatus::Candidate; + let loser_ids = current_ids + .iter() + .filter(|record_id| record_id.as_str() != winner_record_id) + .cloned() + .collect::>(); + if loser_ids.is_empty() && !winner_is_candidate { + return Err(Error::InvalidArgument( + "invalid_conflict_winner: winner 不属于 conflict set".to_string(), + )); + } + let now = unix_seconds_now(); + for loser_id in &loser_ids { + sqlx::query( + "UPDATE translation_memory + SET trust_status = 'superseded', superseded_by_record_id = ?2, + updated_unix_seconds = ?3 + WHERE record_id = ?1 AND trust_status = 'trusted'", + ) + .bind(loser_id) + .bind(winner_record_id) + .bind(i64::try_from(now).unwrap_or(i64::MAX)) + .execute(&mut **transaction) + .await + .map_err(db_error)?; + } + if winner_is_candidate { + let supersedes = (loser_ids.len() == 1).then(|| loser_ids[0].clone()); + sqlx::query( + "UPDATE translation_memory + SET trust_status = 'trusted', trusted_unix_seconds = ?2, + trusted_by = ?3, trusted_reason = ?4, + supersedes_record_id = ?5, updated_unix_seconds = ?2 + WHERE record_id = ?1 AND trust_status = 'candidate'", + ) + .bind(winner_record_id) + .bind(i64::try_from(now).unwrap_or(i64::MAX)) + .bind(reviewer) + .bind(reason) + .bind(supersedes) + .execute(&mut **transaction) + .await + .map_err(db_error)?; + } else if loser_ids.len() == 1 { + sqlx::query( + "UPDATE translation_memory + SET supersedes_record_id = ?, updated_unix_seconds = ? + WHERE record_id = ?", + ) + .bind(&loser_ids[0]) + .bind(i64::try_from(now).unwrap_or(i64::MAX)) + .bind(winner_record_id) + .execute(&mut **transaction) + .await + .map_err(db_error)?; + } + let mut affected = expected_trusted_record_ids.to_vec(); + if winner_is_candidate { + affected.push(winner_record_id.to_string()); + affected.sort(); + } + insert_trust_event( + transaction, + "resolve_conflict", + Some(winner_record_id), + &affected, + &winner, + reviewer, + Some(reason), + now, + ) + .await?; + let final_entries = exact_identity_entries_in_transaction( + transaction, + &winner.source_text, + &winner.source_context, + ) + .await?; + let final_ids = current_trusted_ids(&final_entries); + if final_ids != [winner_record_id.to_string()] + || expected_trusted_record_ids.iter().any(|record_id| { + record_id != winner_record_id + && final_entries.iter().any(|entry| { + entry.record_id == *record_id + && entry.trust_status != TranslationMemoryTrustStatus::Superseded + }) + }) + { + return Err(Error::InvalidArgument( + "Translation Memory conflict resolution 未建立唯一 current Trusted".to_string(), + )); + } + Ok(()) + } +} + +const TRANSLATION_MEMORY_SELECT: &str = r#" +SELECT record_id, source_text, source_hash, normalized_source_text, + source_context_json, source_context_hash, translated_text, + translation_source_kind, trust_status, official_release_id, + source_trace_json, provider, provider_run_id, + created_unix_seconds, updated_unix_seconds, trusted_unix_seconds, + trusted_by, trusted_reason, supersedes_record_id, superseded_by_record_id +FROM translation_memory +"#; + +async fn find_optional_in_transaction( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + record_id: &str, +) -> Result> { + let row = sqlx::query(&format!( + "{TRANSLATION_MEMORY_SELECT}\nWHERE record_id = ?1" + )) + .bind(record_id) + .fetch_optional(&mut **transaction) + .await + .map_err(db_error)?; + row.map(row_to_entry).transpose() +} + +async fn exact_identity_entries_in_transaction( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + source_text: &str, + source_context: &TranslationMemoryContext, +) -> Result> { + let rows = sqlx::query(&format!( + "{TRANSLATION_MEMORY_SELECT}\nWHERE source_hash = ?1 OR source_text = ?2" + )) + .bind(hash_text(source_text)) + .bind(source_text) + .fetch_all(&mut **transaction) + .await + .map_err(db_error)?; + rows.into_iter() + .map(row_to_entry) + .collect::>>() + .map(|entries| { + entries + .into_iter() + .filter(|entry| { + entry.source_text == source_text && entry.source_context == *source_context + }) + .collect() + }) +} + +fn is_current_trusted(entry: &TranslationMemoryEntry) -> bool { + entry.trust_status == TranslationMemoryTrustStatus::Trusted + && entry.superseded_by_record_id.is_none() +} + +fn current_trusted_ids(entries: &[TranslationMemoryEntry]) -> Vec { + let mut ids = entries + .iter() + .filter(|entry| is_current_trusted(entry)) + .map(|entry| entry.record_id.clone()) + .collect::>(); + ids.sort(); + ids +} + +fn group_key(entry: &TranslationMemoryEntry) -> Result<(String, String)> { + let context = serde_json::to_string(&entry.source_context) + .map_err(|error| Error::Serialization(error.to_string()))?; + Ok((entry.source_text.clone(), context)) +} + +fn group_entries( + entries: Vec, +) -> std::collections::BTreeMap<(String, String), Vec> { + let mut groups = std::collections::BTreeMap::new(); + for entry in entries { + if let Ok(key) = group_key(&entry) { + groups.entry(key).or_insert_with(Vec::new).push(entry); + } + } + groups +} + +fn conflict_groups(entries: Vec) -> Vec { + let mut conflicts = group_entries(entries) + .into_values() + .filter_map(|mut records| { + let trusted_record_ids = current_trusted_ids(&records); + if trusted_record_ids.len() <= 1 { + return None; + } + records.sort_by(|left, right| left.record_id.cmp(&right.record_id)); + let first = records.first()?; + Some(TranslationMemoryConflict { + source_text: first.source_text.clone(), + source_hash: first.source_hash.clone(), + source_context: first.source_context.clone(), + source_context_hash: first.source_context_hash.clone(), + trusted_record_ids, + records, + }) + }) + .collect::>(); + conflicts.sort_by(|left, right| { + left.source_text + .cmp(&right.source_text) + .then_with(|| left.source_context_hash.cmp(&right.source_context_hash)) + }); + conflicts +} + +#[allow(clippy::too_many_arguments)] +async fn insert_trust_event( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + action: &str, + winner_record_id: Option<&str>, + affected_record_ids: &[String], + identity: &TranslationMemoryEntry, + reviewer: &str, + reason: Option<&str>, + observed_unix_seconds: u64, +) -> Result<()> { + let affected_record_ids_json = serde_json::to_string(affected_record_ids) + .map_err(|error| Error::Serialization(error.to_string()))?; + let source_context_json = serde_json::to_string(&identity.source_context) + .map_err(|error| Error::Serialization(error.to_string()))?; + let event_id = trust_event_id( + action, + winner_record_id, + affected_record_ids, + observed_unix_seconds, + ); + sqlx::query( + "INSERT INTO translation_memory_trust_events ( + event_id, action, winner_record_id, affected_record_ids_json, + source_text, source_hash, source_context_json, source_context_hash, + reviewer, reason, observed_unix_seconds + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + ) + .bind(event_id) + .bind(action) + .bind(winner_record_id) + .bind(affected_record_ids_json) + .bind(&identity.source_text) + .bind(&identity.source_hash) + .bind(source_context_json) + .bind(&identity.source_context_hash) + .bind(reviewer) + .bind(reason) + .bind(i64::try_from(observed_unix_seconds).unwrap_or(i64::MAX)) + .execute(&mut **transaction) + .await + .map_err(db_error)?; + Ok(()) +} + +fn trust_event_id( + action: &str, + winner_record_id: Option<&str>, + affected_record_ids: &[String], + observed_unix_seconds: u64, +) -> String { + let mut value = format!( + "{action}|{}|{observed_unix_seconds}|{}", + winner_record_id.unwrap_or_default(), + affected_record_ids.join(",") + ); + value.push('|'); + value.push_str( + &SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_string(), + ); + format!("tm-event-{}", blake3::hash(value.as_bytes()).to_hex()) +} + +fn trusted_conflict_error(record_ids: &[String]) -> Error { + Error::InvalidArgument(format!( + "trusted_conflict_requires_resolution: trusted_record_ids={}", + record_ids.join(",") + )) +} + /// 由 active official release 根目录计算默认 TM 数据库路径。 pub fn translation_memory_repository_path(resource_root: &Path) -> PathBuf { SqliteTranslationMemoryRepository::repository_path(resource_root) @@ -946,9 +1725,10 @@ fn parse_trust_status(value: &str) -> Result { fn match_rank(value: &TranslationMemoryMatch) -> u8 { match value.match_kind { TranslationMemoryMatchKind::StrongExact if value.can_auto_reuse => 0, - TranslationMemoryMatchKind::CandidateExact => 1, - TranslationMemoryMatchKind::SourceOnly => 2, - TranslationMemoryMatchKind::StrongExact => 1, + TranslationMemoryMatchKind::TrustedConflict => 1, + TranslationMemoryMatchKind::CandidateExact => 2, + TranslationMemoryMatchKind::SourceOnly => 3, + TranslationMemoryMatchKind::StrongExact => 2, } } @@ -1077,6 +1857,161 @@ mod tests { SqliteTranslationMemoryRepository { pool } } + async fn write_v1_fixture( + path: &std::path::Path, + with_migrations: bool, + records: Vec<(TranslationMemoryDraft, TranslationMemoryTrustStatus)>, + ) -> Vec { + let repository = raw_repository(path, true).await; + if with_migrations { + sqlx::query( + "CREATE TABLE schema_migrations ( + component TEXT PRIMARY KEY NOT NULL, + version INTEGER NOT NULL CHECK(version >= 1) + )", + ) + .execute(&repository.pool) + .await + .unwrap(); + sqlx::query("INSERT INTO schema_migrations(component, version) VALUES (?1, 1)") + .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) + .execute(&repository.pool) + .await + .unwrap(); + } + sqlx::query( + "CREATE TABLE translation_memory ( + record_id TEXT PRIMARY KEY NOT NULL, + source_text TEXT NOT NULL, + source_hash TEXT NOT NULL, + normalized_source_text TEXT NOT NULL, + source_context_json TEXT NOT NULL, + source_context_hash TEXT NOT NULL, + translated_text TEXT NOT NULL, + translation_source_kind TEXT NOT NULL, + trust_status TEXT NOT NULL, + official_release_id TEXT NOT NULL, + source_trace_json TEXT NOT NULL, + provider TEXT, + provider_run_id TEXT, + created_unix_seconds INTEGER NOT NULL, + updated_unix_seconds INTEGER NOT NULL, + trusted_unix_seconds INTEGER, + trusted_by TEXT, + trusted_reason TEXT, + supersedes_record_id TEXT, + superseded_by_record_id TEXT, + CHECK (length(source_text) > 0), + CHECK (length(source_hash) > 0), + CHECK (length(source_context_hash) > 0), + CHECK (length(official_release_id) > 0), + CHECK (translation_source_kind IN ('provider', 'manual', 'imported')), + CHECK (trust_status IN ('candidate', 'trusted', 'superseded', 'rejected')) + )", + ) + .execute(&repository.pool) + .await + .unwrap(); + for statement in [ + "CREATE INDEX idx_translation_memory_source_hash ON translation_memory(source_hash)", + "CREATE INDEX idx_translation_memory_normalized_source ON translation_memory(normalized_source_text)", + "CREATE INDEX idx_translation_memory_context ON translation_memory(source_hash, source_context_hash)", + ] { + sqlx::query(statement) + .execute(&repository.pool) + .await + .unwrap(); + } + + let mut record_ids = Vec::new(); + for (draft, status) in records { + let entry = entry_from_draft(draft).unwrap(); + let source_context_json = serde_json::to_string(&entry.source_context).unwrap(); + let source_trace_json = serde_json::to_string(&entry.source_trace).unwrap(); + let trusted = status == TranslationMemoryTrustStatus::Trusted; + sqlx::query( + "INSERT INTO translation_memory ( + record_id, source_text, source_hash, normalized_source_text, + source_context_json, source_context_hash, translated_text, + translation_source_kind, trust_status, official_release_id, + source_trace_json, provider, provider_run_id, + created_unix_seconds, updated_unix_seconds, + trusted_unix_seconds, trusted_by, trusted_reason, + supersedes_record_id, superseded_by_record_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, + ?11, ?12, ?13, ?14, ?14, ?15, ?16, ?17, ?18, ?19)", + ) + .bind(&entry.record_id) + .bind(&entry.source_text) + .bind(&entry.source_hash) + .bind(&entry.normalized_source_text) + .bind(source_context_json) + .bind(&entry.source_context_hash) + .bind(&entry.translated_text) + .bind(entry.translation_source_kind.as_str()) + .bind(status.as_str()) + .bind(&entry.official_release_id) + .bind(source_trace_json) + .bind(&entry.provider) + .bind(&entry.provider_run_id) + .bind(i64::try_from(entry.created_unix_seconds).unwrap()) + .bind(trusted.then_some(100_i64)) + .bind(trusted.then_some("legacy-reviewer")) + .bind(trusted.then_some("legacy fixture")) + .bind(&entry.supersedes_record_id) + .bind(&entry.superseded_by_record_id) + .execute(&repository.pool) + .await + .unwrap(); + record_ids.push(entry.record_id); + } + repository.pool.close().await; + record_ids + } + + async fn force_trusted(repository: &SqliteTranslationMemoryRepository, record_id: &str) { + sqlx::query( + "UPDATE translation_memory + SET trust_status = 'trusted', trusted_unix_seconds = 100, + trusted_by = 'legacy-reviewer', trusted_reason = 'legacy fixture', + superseded_by_record_id = NULL + WHERE record_id = ?1", + ) + .bind(record_id) + .execute(&repository.pool) + .await + .unwrap(); + } + + async fn trust_event_count(repository: &SqliteTranslationMemoryRepository) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM translation_memory_trust_events") + .fetch_one(&repository.pool) + .await + .unwrap() + } + + async fn current_trusted_count( + repository: &SqliteTranslationMemoryRepository, + source_text: &str, + source_context: &TranslationMemoryContext, + ) -> i64 { + let source_hash = hash_text(source_text); + let source_context_hash = hash_context(source_context).unwrap(); + sqlx::query_scalar( + "SELECT COUNT(*) FROM translation_memory + WHERE source_hash = ?1 AND source_context_hash = ?2 + AND source_text = ?3 AND source_context_json = ?4 + AND trust_status = 'trusted' AND superseded_by_record_id IS NULL", + ) + .bind(source_hash) + .bind(source_context_hash) + .bind(source_text) + .bind(serde_json::to_string(source_context).unwrap()) + .fetch_one(&repository.pool) + .await + .unwrap() + } + #[tokio::test] async fn sqlite_translation_memory_preserves_data_across_reopen_and_missing_row() { let temp = tempfile::TempDir::new().unwrap(); @@ -1109,6 +2044,133 @@ mod tests { ); } + #[tokio::test] + async fn sqlite_translation_memory_migrates_independent_v1_fixture_with_historical_conflict() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("legacy-v1.sqlite"); + let first = draft("release-1", "Hello", "你好"); + let context = first.source_context.clone(); + let ids = write_v1_fixture( + &path, + true, + vec![ + (first, TranslationMemoryTrustStatus::Trusted), + ( + draft("release-2", "Hello", "您好"), + TranslationMemoryTrustStatus::Trusted, + ), + ( + draft("release-3", "Hello", "你好呀"), + TranslationMemoryTrustStatus::Candidate, + ), + ], + ) + .await; + + let repository = SqliteTranslationMemoryRepository::new(&path).await.unwrap(); + let summary = repository.summary().await.unwrap(); + assert_eq!(summary.schema_version, 2); + assert_eq!(summary.record_count, 3); + assert_eq!(summary.trusted_count, 2); + assert_eq!(summary.trusted_conflict_group_count, 1); + assert_eq!(summary.current_trusted_count, 0); + let matches = repository + .find_matches("Hello", &context, 10) + .await + .unwrap(); + assert_eq!(matches.len(), 3); + assert!(matches.iter().all(|item| { + item.match_kind == TranslationMemoryMatchKind::TrustedConflict && !item.can_auto_reuse + })); + assert_eq!( + repository.find(&ids[0]).await.unwrap().translated_text, + "你好" + ); + assert_eq!(trust_event_count(&repository).await, 0); + } + + #[tokio::test] + async fn sqlite_translation_memory_migrates_v1_without_schema_migrations_row() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("legacy-v1-no-migrations.sqlite"); + write_v1_fixture( + &path, + false, + vec![( + draft("release-1", "Hello", "你好"), + TranslationMemoryTrustStatus::Trusted, + )], + ) + .await; + let repository = SqliteTranslationMemoryRepository::new(&path).await.unwrap(); + let version: i64 = + sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") + .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) + .fetch_one(&repository.pool) + .await + .unwrap(); + assert_eq!(version, 2); + assert_eq!(repository.summary().await.unwrap().record_count, 1); + } + + #[tokio::test] + async fn sqlite_translation_memory_v1_migration_rolls_back_and_retries() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("legacy-v1-rollback.sqlite"); + write_v1_fixture( + &path, + true, + vec![( + draft("release-1", "Hello", "你好"), + TranslationMemoryTrustStatus::Candidate, + )], + ) + .await; + let repository = raw_repository(&path, false).await; + assert!(repository.init_schema_with_test_failure(1).await.is_err()); + let governance_tables: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name = 'translation_memory_trust_events'", + ) + .fetch_one(&repository.pool) + .await + .unwrap(); + assert_eq!(governance_tables, 0); + let version: i64 = + sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1") + .bind(TRANSLATION_MEMORY_SCHEMA_COMPONENT) + .fetch_one(&repository.pool) + .await + .unwrap(); + assert_eq!(version, 1); + repository.init_schema().await.unwrap(); + assert_eq!(repository.summary().await.unwrap().schema_version, 2); + } + + #[tokio::test] + async fn sqlite_translation_memory_concurrent_v1_open_migrates_once() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("legacy-v1-concurrent.sqlite"); + write_v1_fixture( + &path, + true, + vec![( + draft("release-1", "Hello", "你好"), + TranslationMemoryTrustStatus::Candidate, + )], + ) + .await; + let (left, right) = tokio::join!( + SqliteTranslationMemoryRepository::new(&path), + SqliteTranslationMemoryRepository::new(&path) + ); + assert!(left.is_ok(), "left migration failed: {left:?}"); + assert!(right.is_ok(), "right migration failed: {right:?}"); + let repository = left.unwrap(); + assert_eq!(repository.summary().await.unwrap().schema_version, 2); + assert_eq!(trust_event_count(&repository).await, 0); + } + #[tokio::test] async fn sqlite_translation_memory_future_schema_is_read_only_failure() { let temp = tempfile::TempDir::new().unwrap(); @@ -1190,7 +2252,7 @@ mod tests { .fetch_one(&repository.pool) .await .unwrap(); - assert_eq!(version, 1); + assert_eq!(version, 2); } #[tokio::test] @@ -1300,6 +2362,330 @@ mod tests { assert!(!matches[0].can_auto_reuse); } + #[tokio::test] + async fn translation_memory_confirm_is_idempotent_and_requires_explicit_supersede() { + let temp = tempfile::TempDir::new().unwrap(); + let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) + .await + .unwrap(); + let trusted_candidate = repository + .upsert_candidate(draft("release-1", "Hello", "你好")) + .await + .unwrap(); + let trusted = repository + .confirm( + &trusted_candidate.record_id, + "reviewer-1", + Some("accepted".to_string()), + ) + .await + .unwrap(); + assert_eq!(trust_event_count(&repository).await, 1); + let idempotent = repository + .confirm( + &trusted.record_id, + "reviewer-2", + Some("duplicate review".to_string()), + ) + .await + .unwrap(); + assert_eq!(idempotent.trusted_by.as_deref(), Some("reviewer-1")); + assert_eq!(trust_event_count(&repository).await, 1); + + let equivalent = repository + .upsert_candidate(draft("release-2", "Hello", "你好")) + .await + .unwrap(); + let duplicate_error = repository + .confirm(&equivalent.record_id, "reviewer", Some("same".to_string())) + .await + .unwrap_err(); + assert!(duplicate_error + .to_string() + .contains("trusted_translation_already_exists")); + + let replacement = repository + .upsert_candidate(draft("release-3", "Hello", "您好")) + .await + .unwrap(); + let required_error = repository + .confirm( + &replacement.record_id, + "reviewer", + Some("replace".to_string()), + ) + .await + .unwrap_err(); + assert!(required_error + .to_string() + .contains("explicit_supersede_required")); + let stale_error = repository + .confirm_with_supersede( + &replacement.record_id, + "reviewer", + Some("replace".to_string()), + Some("wrong-record"), + ) + .await + .unwrap_err(); + assert!(stale_error.to_string().contains("stale_supersede_target")); + let superseded = repository + .confirm_with_supersede( + &replacement.record_id, + "reviewer", + Some("approved replacement".to_string()), + Some(&trusted.record_id), + ) + .await + .unwrap(); + assert_eq!( + superseded.supersedes_record_id.as_deref(), + Some(trusted.record_id.as_str()) + ); + assert_eq!( + repository + .find(&trusted.record_id) + .await + .unwrap() + .superseded_by_record_id + .as_deref(), + Some(replacement.record_id.as_str()) + ); + assert_eq!(trust_event_count(&repository).await, 2); + let summary = repository.summary().await.unwrap(); + assert_eq!(summary.trusted_conflict_group_count, 0); + assert_eq!(summary.current_trusted_count, 1); + } + + #[tokio::test] + async fn translation_memory_historical_conflict_rejects_confirm_and_supports_existing_winner_resolution( + ) { + let temp = tempfile::TempDir::new().unwrap(); + let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) + .await + .unwrap(); + let first = repository + .upsert_candidate(draft("release-1", "Hello", "你好")) + .await + .unwrap(); + let second = repository + .upsert_candidate(draft("release-2", "Hello", "您好")) + .await + .unwrap(); + let candidate = repository + .upsert_candidate(draft("release-3", "Hello", "你好呀")) + .await + .unwrap(); + force_trusted(&repository, &first.record_id).await; + force_trusted(&repository, &second.record_id).await; + + let conflict_error = repository + .confirm( + &candidate.record_id, + "reviewer", + Some("resolve".to_string()), + ) + .await + .unwrap_err(); + assert!(conflict_error + .to_string() + .contains("trusted_conflict_requires_resolution")); + let conflicts = repository.list_conflicts(10).await.unwrap(); + assert_eq!(conflicts.len(), 1); + assert_eq!(conflicts[0].trusted_record_ids.len(), 2); + assert_eq!(conflicts[0].records.len(), 3); + + let winner = repository + .resolve_conflict( + &first.record_id, + &[second.record_id.clone(), first.record_id.clone()], + "resolver", + "keep established translation", + ) + .await + .unwrap(); + assert_eq!(winner.record_id, first.record_id); + assert_eq!( + winner.supersedes_record_id.as_deref(), + Some(second.record_id.as_str()) + ); + let loser = repository.find(&second.record_id).await.unwrap(); + assert_eq!( + loser.superseded_by_record_id.as_deref(), + Some(first.record_id.as_str()) + ); + assert_eq!( + repository + .find(&candidate.record_id) + .await + .unwrap() + .trust_status, + TranslationMemoryTrustStatus::Candidate + ); + let stale = repository + .resolve_conflict( + &first.record_id, + &[first.record_id.clone(), second.record_id.clone()], + "resolver", + "stale retry", + ) + .await + .unwrap_err(); + assert!(stale.to_string().contains("conflict_snapshot_stale")); + assert_eq!(trust_event_count(&repository).await, 1); + let event = sqlx::query( + "SELECT action, affected_record_ids_json + FROM translation_memory_trust_events", + ) + .fetch_one(&repository.pool) + .await + .unwrap(); + assert_eq!(event.get::("action"), "resolve_conflict"); + let affected: Vec = + serde_json::from_str(&event.get::("affected_record_ids_json")).unwrap(); + let mut expected_affected = vec![first.record_id.clone(), second.record_id.clone()]; + expected_affected.sort(); + assert_eq!(affected, expected_affected); + } + + #[tokio::test] + async fn translation_memory_conflict_resolution_with_candidate_winner_has_no_fake_chain() { + let temp = tempfile::TempDir::new().unwrap(); + let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) + .await + .unwrap(); + let first = repository + .upsert_candidate(draft("release-1", "Hello", "你好")) + .await + .unwrap(); + let second = repository + .upsert_candidate(draft("release-2", "Hello", "您好")) + .await + .unwrap(); + let candidate = repository + .upsert_candidate(draft("release-3", "Hello", "你好呀")) + .await + .unwrap(); + force_trusted(&repository, &first.record_id).await; + force_trusted(&repository, &second.record_id).await; + + let winner = repository + .resolve_conflict( + &candidate.record_id, + &[first.record_id.clone(), second.record_id.clone()], + "resolver", + "select candidate", + ) + .await + .unwrap(); + assert_eq!(winner.trust_status, TranslationMemoryTrustStatus::Trusted); + assert!(winner.supersedes_record_id.is_none()); + for loser in [&first.record_id, &second.record_id] { + assert_eq!( + repository + .find(loser) + .await + .unwrap() + .superseded_by_record_id + .as_deref(), + Some(candidate.record_id.as_str()) + ); + } + let event_ids: Vec = serde_json::from_str( + &sqlx::query_scalar::<_, String>( + "SELECT affected_record_ids_json + FROM translation_memory_trust_events + WHERE action = 'resolve_conflict'", + ) + .fetch_one(&repository.pool) + .await + .unwrap(), + ) + .unwrap(); + let mut expected_event_ids = vec![ + first.record_id.clone(), + second.record_id.clone(), + candidate.record_id.clone(), + ]; + expected_event_ids.sort(); + assert_eq!(event_ids, expected_event_ids); + assert_eq!(repository.summary().await.unwrap().current_trusted_count, 1); + } + + #[tokio::test] + async fn translation_memory_concurrent_confirms_and_supersedes_keep_one_current_trusted() { + let temp = tempfile::TempDir::new().unwrap(); + let repository = SqliteTranslationMemoryRepository::new(temp.path().join("tm.sqlite")) + .await + .unwrap(); + let first = repository + .upsert_candidate(draft("release-1", "Hello", "你好")) + .await + .unwrap(); + let second = repository + .upsert_candidate(draft("release-2", "Hello", "您好")) + .await + .unwrap(); + let (left, right) = tokio::join!( + repository.confirm(&first.record_id, "reviewer-a", Some("a".to_string())), + repository.confirm(&second.record_id, "reviewer-b", Some("b".to_string())) + ); + assert_eq!(left.is_ok() as u8 + right.is_ok() as u8, 1); + let errors = [left.as_ref().err(), right.as_ref().err()]; + assert!(errors.iter().flatten().any(|error| { + error.to_string().contains("explicit_supersede_required") + || error + .to_string() + .contains("trusted_translation_already_exists") + })); + let current_id = if left.is_ok() { + first.record_id.clone() + } else { + second.record_id.clone() + }; + let third = repository + .upsert_candidate(draft("release-3", "Hello", "你好呀")) + .await + .unwrap(); + let fourth = repository + .upsert_candidate(draft("release-4", "Hello", "你好喔")) + .await + .unwrap(); + let (left, right) = tokio::join!( + repository.confirm_with_supersede( + &third.record_id, + "reviewer-c", + Some("replace".to_string()), + Some(¤t_id), + ), + repository.confirm_with_supersede( + &fourth.record_id, + "reviewer-d", + Some("replace".to_string()), + Some(¤t_id), + ) + ); + assert_eq!(left.is_ok() as u8 + right.is_ok() as u8, 1); + assert!(left.is_ok() || right.is_ok()); + assert!(left.as_ref().err().is_none_or(|error| { + error.to_string().contains("stale_supersede_target") + || error + .to_string() + .contains("trusted_translation_already_exists") + })); + assert!(right.as_ref().err().is_none_or(|error| { + error.to_string().contains("stale_supersede_target") + || error + .to_string() + .contains("trusted_translation_already_exists") + })); + let identity = draft("release-4", "Hello", "ignored"); + assert_eq!( + current_trusted_count(&repository, "Hello", &identity.source_context).await, + 1 + ); + } + #[tokio::test] async fn rejects_empty_translation_candidates() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/infrastructure/src/translation_worker.rs b/infrastructure/src/translation_worker.rs index 8fb8a7a..ed34ecf 100644 --- a/infrastructure/src/translation_worker.rs +++ b/infrastructure/src/translation_worker.rs @@ -1522,6 +1522,7 @@ mod tests { OfficialTextUnitTask, OfficialTextUnitTaskQueue, OfficialTextUnitTaskStatus, OfficialTextUnitTaskSummary, OFFICIAL_TEXTUNIT_TASK_QUEUE_VERSION, }; + use bat_core::domain::TranslationMemoryMatchKind; fn fixture_root() -> (tempfile::TempDir, OfficialTextUnitTaskQueue) { let temp = tempfile::TempDir::new().unwrap(); @@ -1917,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] async fn worker_retains_tm_hits_when_provider_fails_for_remaining_units() { let (temp, queue) = fixture_root(); diff --git a/internal/api/admin.go b/internal/api/admin.go index a405053..80caf1d 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -56,6 +56,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/translation/handoff", "/admin/translation/memory/summary", "/admin/translation/memory/query", + "/admin/translation/memory/conflicts", "/admin/translation/glossary/summary", "/admin/translation/glossary/query", "/admin/translation/glossary/diagnose", @@ -78,6 +79,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/control/translation-worker-run", "/admin/control/translation-proofread", "/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", @@ -133,6 +135,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) { s.handleAdminTranslationMemoryConfirm(w, r) return } + if action == "translation-memory-resolve-conflict" { + s.handleAdminTranslationMemoryResolveConflict(w, r) + return + } if strings.HasPrefix(action, "translation-glossary-") { s.handleAdminGlossaryControl(w, r, action) return @@ -222,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) { backend, ok := s.backend.(TranslationBackend) if !ok || backend == nil { @@ -1016,6 +1050,37 @@ func (s *Server) handleAdminTranslationMemoryQuery(w http.ResponseWriter, r *htt 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) { if r.Method != http.MethodGet && r.Method != http.MethodHead { writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") @@ -1139,6 +1204,20 @@ func translationMemoryQueryParams(r *http.Request) (backendrpc.TranslationMemory 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) { query := r.URL.Query() params := backendrpc.TextUnitQueryParams{ @@ -1334,6 +1413,32 @@ func validateTranslationMemoryConfirmParams(params backendrpc.TranslationMemoryC if strings.TrimSpace(params.RecordID) == "" || strings.TrimSpace(params.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 } @@ -1412,10 +1517,17 @@ func (s *Server) writeControlBackendError(w http.ResponseWriter, action string, message = "control request was canceled" default: var apiErr *backendrpc.APIError - if errors.As(err, &apiErr) && apiErr.Kind == "not_implemented" { - status = http.StatusNotImplemented - code = "control_not_implemented" - message = "Rust bat does not implement this control action" + if errors.As(err, &apiErr) { + switch apiErr.Kind { + case "not_implemented": + 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) diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 03166fc..5c91d93 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1023,22 +1023,24 @@ func (f *fakeBackend) TaskCancel(ctx context.Context, taskID string) (*backendrp type controlBackend struct { *fakeBackend - calls []string - parseTextUnitQueries []backendrpc.TextUnitQueryParams - parseErrorQueries []backendrpc.TextUnitQueryParams - translationTaskUpdates []backendrpc.TranslationTaskUpdateParams - translationTaskListParams []backendrpc.TranslationTaskListParams - translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams - translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams - translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams - glossarySummaryParams []backendrpc.GlossarySummaryParams - glossaryQueryParams []backendrpc.GlossaryQueryParams - glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams - glossaryMutationParams []backendrpc.GlossaryTermMutationParams - glossaryReviewParams []backendrpc.GlossaryReviewParams - glossaryDeleteParams []backendrpc.GlossaryDeleteParams - localizedPublishParams []backendrpc.LocalizedPublishParams - localizedRollbackParams []backendrpc.LocalizedRollbackParams + calls []string + parseTextUnitQueries []backendrpc.TextUnitQueryParams + parseErrorQueries []backendrpc.TextUnitQueryParams + translationTaskUpdates []backendrpc.TranslationTaskUpdateParams + translationTaskListParams []backendrpc.TranslationTaskListParams + translationMemorySummaryParams []backendrpc.TranslationMemorySummaryParams + translationMemoryQueryParams []backendrpc.TranslationMemoryQueryParams + translationMemoryConfirmParams []backendrpc.TranslationMemoryConfirmParams + translationMemoryConflictsParams []backendrpc.TranslationMemoryConflictsParams + 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) { @@ -1183,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) { b.calls = append(b.calls, "translation.memory.summary") b.translationMemorySummaryParams = append(b.translationMemorySummaryParams, params) - schemaVersion := uint64(1) + schemaVersion := uint64(2) return &backendrpc.TranslationMemorySummaryReport{ Available: true, Path: params.TranslationMemoryPath, SchemaVersion: &schemaVersion, Summary: &backendrpc.TranslationMemorySummary{ - SchemaVersion: schemaVersion, - RecordCount: 2, - TrustedCount: 1, - CandidateCount: 1, - SupersededCount: 0, - RejectedCount: 0, + SchemaVersion: schemaVersion, + RecordCount: 2, + TrustedCount: 1, + CandidateCount: 1, + SupersededCount: 0, + RejectedCount: 0, + CurrentTrustedCount: 1, }, }, nil } @@ -1225,10 +1228,33 @@ func (b *controlBackend) TranslationMemoryConfirm(ctx context.Context, params ba }, 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(1) + schemaVersion := uint64(2) return &backendrpc.GlossarySummaryReport{ Available: true, Path: params.GlossaryPath, @@ -1525,6 +1551,18 @@ func TestAdminTranslationQueryEndpointsProxyAuthenticatedRequests(t *testing.T) 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() @@ -2345,6 +2383,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { {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 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"}, @@ -2390,6 +2429,11 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { backend.translationMemoryConfirmParams[0].Reviewer != "reviewer" { 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" || @@ -2432,6 +2476,14 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { 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.Header.Set("Authorization", "Bearer control-token") recorder = httptest.NewRecorder() diff --git a/internal/api/openapi.go b/internal/api/openapi.go index a7a0d06..8f370d9 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -512,6 +512,31 @@ paths: description: Missing or invalid admin token. "503": 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 @@ -637,7 +662,7 @@ paths: required: true schema: type: string - enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback, release-cleanup] + 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: required: false content: @@ -752,6 +777,18 @@ paths: type: string record_id: 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: diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index 2646556..17ca6ca 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -93,6 +93,8 @@ type TranslationMemoryBackend interface { TranslationMemorySummary(ctx context.Context, params backendrpc.TranslationMemorySummaryParams) (*backendrpc.TranslationMemorySummaryReport, error) TranslationMemoryQuery(ctx context.Context, params backendrpc.TranslationMemoryQueryParams) (*backendrpc.TranslationMemoryQueryReport, 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. @@ -238,6 +240,14 @@ func (r RPCClient) TranslationMemoryConfirm(ctx context.Context, params backendr 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) } diff --git a/internal/api/server.go b/internal/api/server.go index 3c6b95e..0867e43 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -80,6 +80,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff) mux.HandleFunc("/admin/translation/memory/summary", s.handleAdminTranslationMemorySummary) 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) @@ -202,6 +203,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { "/admin/translation/handoff", "/admin/translation/memory/summary", "/admin/translation/memory/query", + "/admin/translation/memory/conflicts", "/admin/translation/glossary/summary", "/admin/translation/glossary/query", "/admin/translation/glossary/diagnose", diff --git a/internal/api/testdata/contract/README.md b/internal/api/testdata/contract/README.md index ca29207..537a2ea 100644 --- a/internal/api/testdata/contract/README.md +++ b/internal/api/testdata/contract/README.md @@ -14,7 +14,7 @@ contract fixture。JSON 由 Rust 代码路径产出后归一化,只替换本 window 为 7260 秒(默认 3600 秒验证周期和 60 秒失败重试周期)。 - 对应 release 的 `official-sync-snapshot.json`。 - `launcher_metadata` 与 `game_main_config_bootstrap` 的 Go mirror 解码。 -- Rust Glossary V2 query 响应,覆盖 alias、approved review、source provenance 和完整 history。 +- Rust Glossary domain/feature contract V1、SQLite persistence schema V2 的 query 响应,覆盖 alias、approved review、source provenance 和完整 history。 这些 fixture 只用于 schema / mirror 回归,不代表真实资源版本,也不替代 live daemon socket 或完整发布切换验证。 diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index f403265..ea333bc 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -479,9 +479,10 @@ const ( type TranslationMemoryMatchKind string const ( - TranslationMemoryMatchStrongExact TranslationMemoryMatchKind = "strong_exact" - TranslationMemoryMatchCandidateExact TranslationMemoryMatchKind = "candidate_exact" - TranslationMemoryMatchSourceOnly TranslationMemoryMatchKind = "source_only" + TranslationMemoryMatchStrongExact TranslationMemoryMatchKind = "strong_exact" + TranslationMemoryMatchTrustedConflict TranslationMemoryMatchKind = "trusted_conflict" + TranslationMemoryMatchCandidateExact TranslationMemoryMatchKind = "candidate_exact" + TranslationMemoryMatchSourceOnly TranslationMemoryMatchKind = "source_only" ) // TranslationMemorySummaryParams selects an optional Rust-owned TM database. @@ -504,16 +505,19 @@ type TranslationMemoryConfirmParams struct { RecordID string `json:"record_id"` Reviewer string `json:"reviewer"` Reason string `json:"reason,omitempty"` + SupersedeRecordID string `json:"supersede_record_id,omitempty"` } // TranslationMemorySummary mirrors translation.memory.summary data. type TranslationMemorySummary struct { - SchemaVersion uint64 `json:"schema_version"` - RecordCount uint64 `json:"record_count"` - TrustedCount uint64 `json:"trusted_count"` - CandidateCount uint64 `json:"candidate_count"` - SupersededCount uint64 `json:"superseded_count"` - RejectedCount uint64 `json:"rejected_count"` + SchemaVersion uint64 `json:"schema_version"` + RecordCount uint64 `json:"record_count"` + TrustedCount uint64 `json:"trusted_count"` + CandidateCount uint64 `json:"candidate_count"` + SupersededCount uint64 `json:"superseded_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 @@ -591,6 +595,47 @@ type TranslationMemoryConfirmReport struct { 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 @@ -1067,6 +1112,18 @@ func (c *Client) TranslationMemoryConfirm(ctx context.Context, params Translatio 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) diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index bc3f3f8..15d3dbe 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -756,14 +756,15 @@ func TestTranslationMemoryTypedContract(t *testing.T) { Data: map[string]any{ "available": true, "path": "/var/lib/bat/translation-memory.sqlite", - "schema_version": 1, + "schema_version": 2, "summary": map[string]any{ - "schema_version": 1, - "record_count": 3, - "trusted_count": 1, - "candidate_count": 1, - "superseded_count": 1, - "rejected_count": 0, + "schema_version": 2, + "record_count": 3, + "trusted_count": 1, + "candidate_count": 1, + "superseded_count": 1, + "rejected_count": 0, + "current_trusted_count": 1, }, }, }, @@ -859,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: t.Fatalf("unexpected method %q", req.Method) return testResponse{} @@ -915,6 +960,32 @@ func TestTranslationMemoryTypedContract(t *testing.T) { confirmed.Entry.RecordID != "tm-record-1" { 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) { @@ -1064,9 +1135,9 @@ func TestGlossaryTypedContract(t *testing.T) { Data: map[string]any{ "available": true, "path": "/var/lib/bat/glossary.sqlite", - "schema_version": 1, + "schema_version": 2, "summary": map[string]any{ - "schema_version": 1, + "schema_version": 2, "term_count": 2, "approved_count": 1, "draft_count": 1, diff --git a/scripts/check-doc-status.sh b/scripts/check-doc-status.sh index 8d3fd08..7b6098d 100644 --- a/scripts/check-doc-status.sh +++ b/scripts/check-doc-status.sh @@ -162,21 +162,22 @@ 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 -require_contains ".gitea/workflows/bat.yml" "make check-docs" -require_contains ".gitea/workflows/bat.yml" "make test-go-api" -require_contains ".gitea/workflows/bat.yml" "go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/..." -require_contains ".gitea/workflows/bat.yml" "go build -o /tmp/bat-api ./cmd/bat-api" -require_contains ".gitea/workflows/bat.yml" "Check Go formatting" -require_contains ".gitea/workflows/bat.yml" "make check-go-format" -require_contains ".gitea/workflows/bat.yml" "Required Go lint" -require_contains ".gitea/workflows/bat.yml" "source scripts/ci-versions.sh" +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" openapi_tmp="$(mktemp)" diff --git a/scripts/ci-versions.sh b/scripts/ci-versions.sh index b140108..0d3f18e 100644 --- a/scripts/ci-versions.sh +++ b/scripts/ci-versions.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Canonical versions for required local and Gitea CI tools. +# Canonical versions for required local quality-gate tools. GOLANGCI_LINT_VERSION="2.12.2" golangci_lint_actual_version() {