feat(patch):统一清单驱动汉化发布
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-12 01:35:29 +08:00
parent 69b6e36bf0
commit 8a77502272
26 changed files with 2250 additions and 158 deletions
+77 -6
View File
@@ -395,6 +395,7 @@ struct CliOptions {
output_explicit: bool,
resource_root: Option<PathBuf>,
translation_file: Option<PathBuf>,
patch_manifest: Option<PathBuf>,
translation_id: Option<String>,
translation_text: Option<String>,
translation_text_file: Option<PathBuf>,
@@ -521,6 +522,7 @@ impl Default for CliOptions {
output_explicit: false,
resource_root: None,
translation_file: None,
patch_manifest: None,
translation_id: None,
translation_text: None,
translation_text_file: None,
@@ -1130,6 +1132,8 @@ struct LocalizedPublishRpcParams {
alias = "from_worker_results"
)]
from_worker: bool,
#[serde(default, alias = "patch_manifest_path")]
patch_manifest: Option<PathBuf>,
#[serde(default, alias = "release_id")]
localized_release_id: Option<String>,
#[serde(default)]
@@ -1226,8 +1230,8 @@ fn is_pending_rpc_method(method: &str) -> bool {
// 等语义方法创建,通用创建接口暂不开放。
// daemon.clean-stableCLI 侧按进程生命周期处理;
// live RPC 内不做在线清理。
// patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建、复杂
// UnityFS 语义编辑和 inspect 等子命令仍未开放。
// patch.* / unityfs.*:文件级写入入口已开放;发布级 patch 构建和 inspect
// 等子命令仍未开放,发布级 generic manifest 通过 localized.publish 进入
matches!(method, "task.create" | RPC_METHOD_CLEAN_STABLE)
|| (method.starts_with("patch.") && method != RPC_METHOD_PATCH_APPLY)
|| (method.starts_with("unityfs.")
@@ -3277,8 +3281,13 @@ fn build_localized_status_report(
let mut patch_manifest_path = None;
let mut patch_manifest_available = false;
let mut patch_manifest_matches_release = false;
let mut patch_manifest_integrity_status = None;
let mut patch_manifest_source_version = None;
let mut patch_manifest_target_version = None;
let mut patch_file_count = None;
let mut patch_text_asset_operation_count = None;
let mut patch_operation_count = None;
let mut patch_kind_counts = serde_json::Map::new();
let mut rollback_previous_current_target = None;
let mut flow_status_code = if official_version_id.is_some() {
ReleaseFlowStatusCode::LocalizedPending
@@ -3326,6 +3335,47 @@ fn build_localized_status_report(
patch_file_count = Some(manifest.file_count);
patch_text_asset_operation_count = Some(manifest.text_asset_operation_count);
rollback_previous_current_target = manifest.rollback.previous_current_target;
if let Some(generic_manifest) = manifest.patch_manifest.as_ref() {
patch_manifest_source_version =
Some(generic_manifest.source_version.clone());
patch_manifest_target_version =
Some(generic_manifest.target_version.clone());
let mut operation_count = 0usize;
for file in &generic_manifest.files {
for operation in &file.operations {
operation_count += 1;
let key = match operation.patch_kind() {
bat_patch::PatchKind::Binary => "binary",
bat_patch::PatchKind::Json => "json",
bat_patch::PatchKind::Text => "text",
bat_patch::PatchKind::UnityFsTextAsset => "unityfs_text_asset",
bat_patch::PatchKind::UnityFsStringField => {
"unityfs_string_field"
}
bat_patch::PatchKind::UnityFsField => "unityfs_field",
bat_patch::PatchKind::Mixed => "mixed",
}
.to_string();
let count = patch_kind_counts
.entry(key)
.or_insert_with(|| serde_json::json!(0));
*count = serde_json::json!(count.as_u64().unwrap_or_default() + 1);
}
}
patch_operation_count = Some(operation_count);
patch_manifest_integrity_status = Some(
if bat_patch::validate_patch_manifest(generic_manifest).is_ok() {
"valid"
} else {
"invalid"
},
);
patch_manifest_matches_release &= generic_manifest.source_version
== manifest.official_release_id
&& generic_manifest.target_version == manifest.localized_release_id;
} else {
patch_manifest_integrity_status = Some("legacy");
}
}
if candidate.is_dir()
&& current_points_to_published_version
@@ -3363,8 +3413,13 @@ fn build_localized_status_report(
"patch_manifest_path": patch_manifest_path,
"patch_manifest_available": patch_manifest_available,
"patch_manifest_matches_release": patch_manifest_matches_release,
"patch_manifest_integrity_status": patch_manifest_integrity_status,
"patch_manifest_source_version": patch_manifest_source_version,
"patch_manifest_target_version": patch_manifest_target_version,
"patch_file_count": patch_file_count,
"patch_text_asset_operation_count": patch_text_asset_operation_count,
"patch_operation_count": patch_operation_count,
"patch_kind_counts": patch_kind_counts,
"rollback_previous_current_target": rollback_previous_current_target,
"matches_current_official_release": matches_current_official_release,
"current_points_to_published_version": current_points_to_published_version,
@@ -3487,11 +3542,17 @@ fn localized_publish_rpc_report(
let translation_file = params
.translation_file
.filter(|path| !path.as_os_str().is_empty());
if translation_file.is_some() == params.from_worker {
let patch_manifest = params
.patch_manifest
.filter(|path| !path.as_os_str().is_empty());
let input_count = usize::from(translation_file.is_some())
+ usize::from(params.from_worker)
+ usize::from(patch_manifest.is_some());
if input_count != 1 {
return Err(ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_LOCALIZED_PUBLISH,
"localized.publish 必须且只能指定 translation_filefrom_worker",
"localized.publish 必须且只能指定 translation_filefrom_worker 或 patch_manifest",
));
}
let mut config = base_config.clone();
@@ -3501,6 +3562,7 @@ fn localized_publish_rpc_report(
config,
state_dir: state_dir.to_path_buf(),
translation_file,
patch_manifest,
translation_from_worker: params.from_worker,
localized_release_id: normalize_optional_rpc_string(params.localized_release_id),
..CliOptions::default()
@@ -6844,6 +6906,9 @@ fn parse_args_with_env(
options.translation_file =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
}
"--patch-manifest" | "--patch-manifest-file" => {
options.patch_manifest = Some(PathBuf::from(next_option_value(&mut args, &flag)?));
}
"--translation-id" => {
options.translation_id = Some(next_option_value(&mut args, &flag)?);
}
@@ -7554,6 +7619,9 @@ fn parse_args_with_env(
if options.command != CliCommand::PublishLocalized && options.translation_from_worker {
return Err(anyhow::anyhow!("--from-worker 只适用于 i18n publish"));
}
if options.command != CliCommand::PublishLocalized && options.patch_manifest.is_some() {
return Err(anyhow::anyhow!("--patch-manifest 只适用于 i18n publish"));
}
match options.command {
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
@@ -7805,10 +7873,13 @@ fn parse_args_with_env(
));
}
if matches!(options.command, CliCommand::PublishLocalized)
&& options.translation_file.is_some() == options.translation_from_worker
&& (usize::from(options.translation_file.is_some())
+ usize::from(options.translation_from_worker)
+ usize::from(options.patch_manifest.is_some())
!= 1)
{
return Err(anyhow::anyhow!(
"i18n publish 必须且只能指定 --translation-file--from-worker"
"i18n publish 必须且只能指定 --translation-file--from-worker 或 --patch-manifest"
));
}
}
+17
View File
@@ -659,6 +659,19 @@ fn grouped_workflow_commands_use_short_top_level_aliases() {
assert_eq!(options.command, CliCommand::PublishLocalized);
assert!(options.translation_from_worker);
assert_eq!(options.localized_release_id.as_deref(), Some("localized-1"));
let options = parse(&[
"bat",
"i18n",
"publish",
"--patch-manifest",
"/tmp/patch-manifest.json",
])
.unwrap();
assert_eq!(options.command, CliCommand::PublishLocalized);
assert_eq!(
options.patch_manifest,
Some(PathBuf::from("/tmp/patch-manifest.json"))
);
assert!(parse(&[
"bat",
"i18n",
@@ -5107,6 +5120,7 @@ fn dispatch_localized_status_verifies_current_release_pointer() {
file_count: 0,
text_asset_operation_count: 0,
files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None,
remove_version_path: localized_version.clone(),
@@ -5243,6 +5257,7 @@ fn dispatch_localized_rollback_restores_manifest_previous_release() {
file_count: 0,
text_asset_operation_count: 0,
files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None,
remove_version_path: previous.clone(),
@@ -5261,6 +5276,7 @@ fn dispatch_localized_rollback_restores_manifest_previous_release() {
file_count: 0,
text_asset_operation_count: 0,
files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: Some(PathBuf::from("versions/release-1")),
remove_version_path: current.clone(),
@@ -5362,6 +5378,7 @@ fn localized_status_keeps_published_release_during_manual_proofreading() {
file_count: 0,
text_asset_operation_count: 0,
files: Vec::new(),
patch_manifest: None,
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None,
remove_version_path: localized_version.clone(),
@@ -172,6 +172,7 @@ fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Re
if options.patch_kind.is_some()
|| options.patch_source_path.is_some()
|| options.patch_patch_path.is_some()
|| options.patch_manifest.is_some()
{
return Err(anyhow::anyhow!(
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
@@ -191,6 +192,7 @@ fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::
|| options.unityfs_expected_value.is_some()
|| options.unityfs_replacement_value.is_some()
|| options.unityfs_expected_semantic_value.is_some()
|| options.patch_manifest.is_some()
{
return Err(anyhow::anyhow!(
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
@@ -339,6 +339,49 @@ pub(super) fn publish_localized_report(
options: &CliOptions,
) -> anyhow::Result<LocalizedPatchReport> {
let (resource_root, official_release_id) = current_official_release(options)?;
if let Some(manifest_path) = options.patch_manifest.as_ref() {
let manifest_bytes = read_file_no_symlink(manifest_path, "generic patch manifest")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| {
anyhow::anyhow!("generic patch manifest 不存在:{}", manifest_path.display())
})?;
let manifest: bat_patch::PatchManifest = serde_json::from_slice(&manifest_bytes)
.map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?;
bat_patch::validate_patch_manifest(&manifest)
.map_err(|error| anyhow::anyhow!("generic patch manifest 无效:{error}"))?;
if manifest.source_version != official_release_id {
return Err(anyhow::anyhow!(
"generic patch source version={} 与当前官方 release={} 不一致",
manifest.source_version,
official_release_id
));
}
let localized_release_id = options
.localized_release_id
.clone()
.unwrap_or_else(|| manifest.target_version.clone());
if localized_release_id != manifest.target_version {
return Err(anyhow::anyhow!(
"generic patch target version={} 必须与 localized release id={} 一致",
manifest.target_version,
localized_release_id
));
}
let config = LocalizedPatchConfig::new(
resource_root,
options.config.localized_output_root.clone(),
official_release_id,
Vec::new(),
)
.with_archive_commands(
options.config.unzip_command.clone(),
options.config.zip_command.clone(),
)
.with_manifest(manifest)
.with_localized_release_id(localized_release_id)
.with_force(options.config.force);
return LocalizedPatchService::new().publish(&config);
}
let workbench = if options.translation_from_worker {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
File diff suppressed because it is too large Load Diff