feat(bat): 补全工作流校验与调度过滤
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

新增 parse clear-cache 和 i18n validate,补齐 schedule 的作用域过滤与单轮执行上限,并将列表过滤参数暴露给 bat-api dashboard。同步 RPC、OpenAPI、用户文档和回归测试。

Refs #43
This commit is contained in:
2026-08-03 23:47:59 +08:00
parent 0784d5b532
commit 1933d6acb0
19 changed files with 714 additions and 49 deletions
+155 -1
View File
@@ -11,7 +11,7 @@ use bat_assetbundle::{
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -66,6 +66,27 @@ pub struct TranslationWorkbenchEntry {
pub text_source_kind: Option<String>,
}
/// Summary produced by `i18n validate`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TranslationWorkbenchValidationReport {
/// Stable command name.
pub command: &'static str,
/// Validation status.
pub status: &'static str,
/// Number of entries in the workbench.
pub total_entries: usize,
/// Entries without a human translation.
pub unreviewed_entries: usize,
/// Entries whose translation equals the source text.
pub unchanged_entries: usize,
/// Entries with a changed translation.
pub changed_entries: usize,
/// Changed direct TextAsset entries usable by `i18n publish`.
pub publishable_entries: usize,
/// Changed TypeTree or nested-archive entries requiring `parse repack`.
pub repack_entries: usize,
}
/// Exports the current official TextUnit index as an editable workbench.
pub fn export_translation_workbench(
resource_root: &Path,
@@ -145,6 +166,99 @@ pub fn set_translation(
Ok(updated)
}
/// Validates a workbench against the current official TextUnit index.
///
/// This checks the release identity and every stored source/target location
/// before a publish operation. Unsupported patch targets are reported as
/// `repack_entries` so reviewers can choose the appropriate command.
pub fn validate_translation_workbench(
resource_root: &Path,
official_release_id: &str,
workbench: &TranslationWorkbench,
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?;
if workbench.official_release_id != official_release_id {
return Err(anyhow::anyhow!(
"翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出",
workbench.official_release_id,
official_release_id
));
}
if workbench.official_resource_root != expected_root {
return Err(anyhow::anyhow!(
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
));
}
let index = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
let index_by_id = index
.units
.iter()
.map(|unit| (unit.id.as_str(), unit))
.collect::<HashMap<_, _>>();
let mut seen_ids = BTreeSet::new();
let mut seen_patch_targets = BTreeSet::new();
let mut unreviewed_entries = 0;
let mut unchanged_entries = 0;
let mut changed_entries = 0;
let mut publishable_entries = 0;
let mut repack_entries = 0;
for entry in &workbench.entries {
if !seen_ids.insert(entry.id.as_str()) {
return Err(anyhow::anyhow!("翻译工作台包含重复 TextUnit:{}", entry.id));
}
let current = index_by_id
.get(entry.id.as_str())
.ok_or_else(|| anyhow::anyhow!("翻译工作台条目不属于当前 release:{}", entry.id))?;
validate_workbench_entry(entry, current)?;
let Some(translated_text) = entry.translated_text.as_ref() else {
unreviewed_entries += 1;
continue;
};
if translated_text == &entry.source_text {
unchanged_entries += 1;
continue;
}
changed_entries += 1;
let is_publishable = entry.archive_entry.is_none()
&& entry.text_source_kind.as_deref() == Some("text_asset");
if is_publishable {
let serialized_file = entry
.serialized_file
.as_ref()
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 serialized_file", entry.id))?;
let path_id = entry
.path_id
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 path_id", entry.id))?;
if !seen_patch_targets.insert((
entry.destination.clone(),
serialized_file.clone(),
path_id,
)) {
return Err(anyhow::anyhow!(
"翻译工作台包含重复 patch 目标:{}",
entry.id
));
}
publishable_entries += 1;
} else {
repack_entries += 1;
}
}
Ok(TranslationWorkbenchValidationReport {
command: "translation-validate",
status: "valid",
total_entries: workbench.entries.len(),
unreviewed_entries,
unchanged_entries,
changed_entries,
publishable_entries,
repack_entries,
})
}
/// Converts reviewed direct TextAsset entries to localized patch operations.
///
/// TypeTree fields and zip-inner bundles are intentionally rejected here.
@@ -541,4 +655,44 @@ mod tests {
let error = set_translation(&path, "missing", "译文".to_string()).unwrap_err();
assert!(error.to_string().contains("不存在 TextUnit"));
}
#[test]
fn validation_reports_publishable_and_unreviewed_entries() {
let temp = tempfile::TempDir::new().unwrap();
let index = crate::official_parse::OfficialTextUnitIndex {
version: crate::official_parse::OFFICIAL_TEXTUNIT_INDEX_VERSION,
generated_unix_seconds: 1,
resource_root: temp.path().to_path_buf(),
summary: Default::default(),
units: vec![OfficialTextUnitIndexUnit {
id: "unit-1".to_string(),
parse_entry_key: "bundle".to_string(),
source_url: "https://example.invalid/bundle".to_string(),
destination: "bundles/test.bundle".to_string(),
archive_entry: None,
source_kind: crate::official_parse::OfficialParseSourceKind::DirectBundle,
unity_version: None,
source_text: "原文".to_string(),
serialized_file: Some("CAB-test".to_string()),
path_id: Some(7),
class_id: Some(49),
field_path: None,
field_offset: None,
field_byte_size: None,
format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()),
asset_name: Some("Story".to_string()),
context: Default::default(),
}],
errors: Vec::new(),
};
crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap();
let mut wb = workbench(temp.path());
wb.entries[0].translated_text = Some("译文".to_string());
let report = validate_translation_workbench(temp.path(), "release-1", &wb).unwrap();
assert_eq!(report.total_entries, 1);
assert_eq!(report.changed_entries, 1);
assert_eq!(report.publishable_entries, 1);
assert_eq!(report.unreviewed_entries, 0);
}
}