feat(bat): 补全工作流命令与人工校对状态
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

Refs #43
This commit is contained in:
2026-08-19 21:33:33 +08:00
parent 550ee7fd9a
commit 72c1a879a3
21 changed files with 797 additions and 41 deletions
+188 -2
View File
@@ -23,6 +23,12 @@ pub const LOCALIZED_VERSION_STATE_FILE: &str = "localized-version-state.json";
pub const LOCALIZED_PATCH_MANIFEST_FILE: &str = "localized-patch-manifest.json";
/// Current localized patch manifest schema version.
pub const LOCALIZED_PATCH_MANIFEST_VERSION: u32 = 1;
/// Current localized version state schema version.
pub const LOCALIZED_VERSION_STATE_VERSION: u32 = 1;
/// Stable marker for localized releases that are under human proofreading.
pub const LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING: &str = "manual_proofreading";
/// Human label for `LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING`.
pub const LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL: &str = "人工校对中";
/// One patch operation against a bundle in an official release.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -94,6 +100,7 @@ impl LocalizedPatchConfig {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedVersionState {
/// State schema version.
#[serde(default = "default_localized_version_state_version")]
pub state_version: u32,
/// Official release ID used as the patch source.
pub official_release_id: String,
@@ -101,10 +108,59 @@ pub struct LocalizedVersionState {
pub current_release_id: Option<String>,
/// Stable status label.
pub status: String,
/// Review/proofreading workflow marker independent from publish status.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_workflow_status: Option<String>,
/// Last update time.
pub updated_unix_seconds: u64,
}
impl LocalizedVersionState {
/// Returns the stable translation workflow status, if set.
pub fn translation_workflow_status(&self) -> Option<&str> {
self.translation_workflow_status.as_deref()
}
/// Returns the user-facing translation workflow label, if set.
pub fn translation_workflow_label(&self) -> Option<&'static str> {
match self.translation_workflow_status() {
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING) => {
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL)
}
_ => None,
}
}
}
/// Report produced when changing localized translation workflow state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LocalizedTranslationWorkflowReport {
/// Stable command name.
pub command: &'static str,
/// Stable operation status.
pub status: &'static str,
/// Localized output root.
pub localized_output_root: PathBuf,
/// State file written under the localized output root.
pub state_path: PathBuf,
/// Official release associated with the workflow state.
pub official_release_id: String,
/// Current published localized release ID, when one exists.
pub current_release_id: Option<String>,
/// Stable localized publication label, such as `localized`.
pub localized_release_status: String,
/// Stable translation workflow marker.
pub translation_workflow_status: String,
/// Stable lifecycle status code for read-side callers.
pub translation_workflow_status_code: &'static str,
/// Human label for the workflow status.
pub translation_workflow_label: &'static str,
/// Whether an existing localized release remains publishable after the change.
pub publish_allowed: bool,
/// Last update time written to the state file.
pub updated_unix_seconds: u64,
}
/// One changed file in a localized patch manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizedPatchFile {
@@ -295,6 +351,10 @@ impl LocalizedPatchService {
.localized_output_root
.join(LOCALIZED_VERSION_STATE_FILE);
let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
let previous_state = read_localized_version_state(&config.localized_output_root)?;
let translation_workflow_status = previous_state
.filter(|state| state.official_release_id == config.release_id)
.and_then(|state| state.translation_workflow_status);
if version_path.exists() {
let message = if config.force {
@@ -373,10 +433,11 @@ impl LocalizedPatchService {
)?;
let state = LocalizedVersionState {
state_version: 1,
state_version: LOCALIZED_VERSION_STATE_VERSION,
official_release_id: config.release_id.clone(),
current_release_id: Some(config.published_release_id().to_string()),
status: "localized".to_string(),
translation_workflow_status,
updated_unix_seconds: unix_seconds_now(),
};
write_file_atomic(
@@ -415,7 +476,7 @@ pub fn read_localized_version_state(
return Ok(None);
};
let state: LocalizedVersionState = serde_json::from_slice(&bytes)?;
if state.state_version != 1 {
if state.state_version != LOCALIZED_VERSION_STATE_VERSION {
return Err(anyhow::anyhow!(
"不支持的汉化版本状态 schema:{},当前版本=1",
state.state_version
@@ -424,6 +485,70 @@ pub fn read_localized_version_state(
Ok(Some(state))
}
/// Writes the localized version state atomically.
pub fn write_localized_version_state(
localized_output_root: &Path,
state: &LocalizedVersionState,
) -> anyhow::Result<PathBuf> {
ensure_safe_directory_path(localized_output_root, "汉化输出目录")
.map_err(anyhow::Error::msg)?;
let path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE);
write_file_atomic(
&path,
&serde_json::to_vec_pretty(state)?,
STATE_FILE_MODE,
"汉化版本状态",
)
.map_err(anyhow::Error::msg)?;
Ok(path)
}
/// Marks the current localized workflow as under manual proofreading without
/// changing the published localized release pointer.
pub fn mark_localized_manual_proofreading(
localized_output_root: &Path,
official_release_id: &str,
) -> anyhow::Result<LocalizedTranslationWorkflowReport> {
let mut state = read_localized_version_state(localized_output_root)?.unwrap_or_else(|| {
LocalizedVersionState {
state_version: LOCALIZED_VERSION_STATE_VERSION,
official_release_id: official_release_id.to_string(),
current_release_id: None,
status: "not_localized".to_string(),
translation_workflow_status: None,
updated_unix_seconds: 0,
}
});
if state.official_release_id != official_release_id {
return Err(anyhow::anyhow!(
"汉化状态 release={} 与当前官方 release={} 不一致;请先重新发布或指定匹配的官方 release",
state.official_release_id,
official_release_id
));
}
state.state_version = LOCALIZED_VERSION_STATE_VERSION;
state.translation_workflow_status =
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING.to_string());
state.updated_unix_seconds = unix_seconds_now();
let state_path = write_localized_version_state(localized_output_root, &state)?;
Ok(LocalizedTranslationWorkflowReport {
command: "translation-proofread",
status: "updated",
localized_output_root: localized_output_root.to_path_buf(),
state_path,
official_release_id: state.official_release_id.clone(),
current_release_id: state.current_release_id.clone(),
localized_release_status: state.status.clone(),
translation_workflow_status: LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING.to_string(),
translation_workflow_status_code:
crate::ReleaseFlowStatusCode::TranslationManualProofreading.as_str(),
translation_workflow_label: LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL,
publish_allowed: state.status == "localized" && state.current_release_id.is_some(),
updated_unix_seconds: state.updated_unix_seconds,
})
}
/// Reads a localized patch manifest from a published version directory.
pub fn read_localized_patch_manifest_at(
version_path: &Path,
@@ -754,6 +879,10 @@ fn default_patch_manifest_version() -> u32 {
LOCALIZED_PATCH_MANIFEST_VERSION
}
fn default_localized_version_state_version() -> u32 {
LOCALIZED_VERSION_STATE_VERSION
}
#[cfg(test)]
mod tests {
use super::*;
@@ -846,6 +975,7 @@ mod tests {
serde_json::from_slice(&fs::read(report.state_path).unwrap()).unwrap();
assert_eq!(state.status, "localized");
assert_eq!(state.current_release_id.as_deref(), Some("release-1"));
assert_eq!(state.translation_workflow_status(), None);
assert!(report.patch_manifest_path.is_file());
assert_eq!(report.manifest.file_count, 0);
assert_eq!(report.integrity.verified_changed_file_count, 0);
@@ -857,6 +987,62 @@ mod tests {
assert_eq!(manifest.rollback.previous_current_target, None);
}
#[test]
fn localized_state_reads_legacy_file_without_workflow_status() {
let temp = TempDir::new().unwrap();
let localized = temp.path().join("localized");
fs::create_dir_all(&localized).unwrap();
fs::write(
localized.join(LOCALIZED_VERSION_STATE_FILE),
br#"{
"state_version": 1,
"official_release_id": "release-1",
"current_release_id": "release-1",
"status": "localized",
"updated_unix_seconds": 123
}"#,
)
.unwrap();
let state = read_localized_version_state(&localized).unwrap().unwrap();
assert_eq!(state.status, "localized");
assert_eq!(state.translation_workflow_status(), None);
assert_eq!(state.translation_workflow_label(), None);
}
#[test]
fn manual_proofreading_marker_preserves_published_state() {
let temp = TempDir::new().unwrap();
let localized = temp.path().join("localized");
fs::create_dir_all(&localized).unwrap();
write_localized_version_state(
&localized,
&LocalizedVersionState {
state_version: LOCALIZED_VERSION_STATE_VERSION,
official_release_id: "release-1".to_string(),
current_release_id: Some("release-1-auto".to_string()),
status: "localized".to_string(),
translation_workflow_status: None,
updated_unix_seconds: 123,
},
)
.unwrap();
let report = mark_localized_manual_proofreading(&localized, "release-1").unwrap();
let state = read_localized_version_state(&localized).unwrap().unwrap();
assert_eq!(report.translation_workflow_label, "人工校对中");
assert_eq!(
state.translation_workflow_status(),
Some(LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING)
);
assert_eq!(state.translation_workflow_label(), Some("人工校对中"));
assert_eq!(state.status, "localized");
assert_eq!(state.current_release_id.as_deref(), Some("release-1-auto"));
assert!(report.publish_allowed);
}
#[cfg(unix)]
#[test]
fn failed_patch_publish_cleans_staging_and_unpublished_version() {