feat(i18n): 完成 localized patch 发布回滚闭环
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

Closes #45
This commit is contained in:
2026-08-31 00:02:55 +08:00
parent f441f1810e
commit ab21344773
26 changed files with 1916 additions and 140 deletions
+302 -33
View File
@@ -7,24 +7,25 @@ use bat_core::{ApiError, ErrorCode};
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
use bat_infrastructure::{
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
apply_unityfs_text_asset_patch_file, changed_endpoint_urls, diff_extended_snapshot,
export_translation_workbench, gc_orphan_staging, get_translation_entry, lexical_absolute,
localized_text_asset_patches, open_append_file, read_download_manifest_at,
read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state,
read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench,
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
unset_translation, validate_output_root, validate_runtime_state_dir,
validate_translation_workbench, write_file_atomic, write_official_textunit_queues,
CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig, LocalizedPatchReport,
LocalizedPatchService, OfficialEndpointMarkerRole, OfficialFailedVersionRecord,
OfficialParseCacheService, OfficialParseConfig, OfficialResourceHashVerification,
OfficialResourceVerification, OfficialServerInfoSource, OfficialTextUnitQuery,
OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport,
OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus,
OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PatchApplyKind,
PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport,
SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationProviderKind,
TranslationTaskStatus, TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport,
apply_unityfs_text_asset_patch_file, changed_endpoint_urls,
completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench,
gc_orphan_staging, get_translation_entry, lexical_absolute, localized_patch_operations,
open_append_file, read_download_manifest_at, read_file_no_symlink,
read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at,
read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state,
redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, unset_translation,
validate_output_root, validate_runtime_state_dir, validate_translation_workbench,
write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
OfficialParseConfig, OfficialResourceHashVerification, OfficialResourceVerification,
OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams,
PatchApplyReport, ReleaseFlowStatusCode, RepackReport, SqliteResourceRepository,
SqliteTranslationTaskRepository, TranslationProviderKind, TranslationTaskStatus,
TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport,
UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE,
DEFAULT_TRANSLATION_CONCURRENCY, DEFAULT_TRANSLATION_LEASE_SECONDS,
DEFAULT_TRANSLATION_MAX_ATTEMPTS, DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK,
@@ -84,6 +85,7 @@ use translation_query::{
update_translation_task_status_report,
};
use workflow_commands::{
localized_rollback_report, publish_localized_report, run_localized_rollback,
run_parse_clear_cache, run_parse_once, run_publish_localized, run_repack, run_translate_once,
run_translation_get, run_translation_proofread, run_translation_set,
run_translation_task_update, run_translation_unset, run_translation_validate,
@@ -244,6 +246,10 @@ fn run() -> anyhow::Result<i32> {
run_repeated_workflow(&options, "publish-localized", run_publish_localized)?;
Ok(0)
}
CliCommand::LocalizedRollback => {
run_localized_rollback(&options)?;
Ok(0)
}
CliCommand::ScheduleList => {
run_schedule_list(&options)?;
Ok(0)
@@ -385,6 +391,7 @@ struct CliOptions {
translation_text_file: Option<PathBuf>,
translation_failure_reason: Option<String>,
translation_provider_run_id: Option<String>,
translation_from_worker: bool,
translation_provider: Option<String>,
translation_fixture: Option<PathBuf>,
worker_concurrency: usize,
@@ -479,6 +486,7 @@ impl Default for CliOptions {
translation_text_file: None,
translation_failure_reason: None,
translation_provider_run_id: None,
translation_from_worker: false,
translation_provider: None,
translation_fixture: None,
worker_concurrency: DEFAULT_TRANSLATION_CONCURRENCY,
@@ -575,6 +583,7 @@ enum CliCommand {
TranslationProofread,
Repack,
PublishLocalized,
LocalizedRollback,
ScheduleList,
ScheduleAdd,
ScheduleUpdate,
@@ -648,6 +657,7 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
registry,
queue: task_tx,
base_config: options.config.clone(),
sync_lock: Arc::clone(&sync_lock),
restart_controller: spawn_daemon_restart_controller,
};
let server =
@@ -1023,6 +1033,35 @@ struct DaemonRpcAck {
force: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LocalizedPublishRpcParams {
#[serde(default, alias = "workbench", alias = "workbench_path")]
translation_file: Option<PathBuf>,
#[serde(
default,
alias = "translation_from_worker",
alias = "from_worker_results"
)]
from_worker: bool,
#[serde(default, alias = "release_id")]
localized_release_id: Option<String>,
#[serde(default)]
force: bool,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct LocalizedRollbackRpcParams {
#[serde(
default,
alias = "release_id",
alias = "expected_release_id",
alias = "expected_localized_release_id"
)]
localized_release_id: Option<String>,
}
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
const RPC_METHOD_STATUS: &str = "daemon.status";
const RPC_METHOD_STOP: &str = "daemon.stop";
@@ -1053,6 +1092,8 @@ const RPC_METHOD_TRANSLATION_TASK_UPDATE: &str = "translation.task.update";
const RPC_METHOD_TRANSLATION_PROOFREAD: &str = "translation.proofread";
const RPC_METHOD_TRANSLATION_WORKER_RUN: &str = "translation.worker.run";
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish";
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
@@ -2048,12 +2089,18 @@ fn dispatch_rpc_method(
"translation.task.update",
update_translation_task_status_report(state_dir, request.params.as_ref()),
),
RPC_METHOD_TRANSLATION_PROOFREAD => rpc_envelope_from_result(
request_id,
"translation.proofread",
mark_localized_manual_proofreading_report(state_dir, &tasks.base_config)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
),
RPC_METHOD_TRANSLATION_PROOFREAD => {
let _sync_guard = tasks
.sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
rpc_envelope_from_result(
request_id,
"translation.proofread",
mark_localized_manual_proofreading_report(state_dir, &tasks.base_config)
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
)
}
RPC_METHOD_TRANSLATION_WORKER_RUN => {
let config = match rpc_translation_worker_config(request.params.as_ref()) {
Ok(config) => config,
@@ -2061,11 +2108,53 @@ fn dispatch_rpc_method(
};
enqueue_translation_worker_envelope(tasks, config, request_id)
}
RPC_METHOD_LOCALIZED_STATUS => rpc_envelope_from_result(
request_id,
"localized.status",
build_localized_status_report(state_dir, &tasks.base_config),
),
RPC_METHOD_LOCALIZED_STATUS => {
let _sync_guard = tasks
.sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
rpc_envelope_from_result(
request_id,
"localized.status",
build_localized_status_report(state_dir, &tasks.base_config),
)
}
RPC_METHOD_LOCALIZED_PUBLISH => {
let _sync_guard = tasks
.sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
match localized_publish_rpc_report(
state_dir,
&tasks.base_config,
request.params.as_ref(),
) {
Ok(report) => rpc_envelope_from_result(
request_id,
RPC_METHOD_LOCALIZED_PUBLISH,
serde_json::to_value(report).map_err(anyhow::Error::from),
),
Err(error) => rpc_envelope_error(request_id, error),
}
}
RPC_METHOD_LOCALIZED_ROLLBACK => {
let _sync_guard = tasks
.sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
match localized_rollback_rpc_report(
state_dir,
&tasks.base_config,
request.params.as_ref(),
) {
Ok(report) => rpc_envelope_from_result(
request_id,
RPC_METHOD_LOCALIZED_ROLLBACK,
serde_json::to_value(report).map_err(anyhow::Error::from),
),
Err(error) => rpc_envelope_error(request_id, error),
}
}
RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result(
request_id,
"catalog.status",
@@ -3150,6 +3239,98 @@ fn rpc_struct_params<T: DeserializeOwned>(
})
}
fn rpc_optional_struct_params<T: DeserializeOwned + Default>(
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<T, ApiError> {
let Some(params) = params else {
return Ok(T::default());
};
if params.is_null() {
return Ok(T::default());
}
if !params.is_object() {
return Err(ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
method,
"params 必须是 JSON object",
));
}
serde_json::from_value(params.clone()).map_err(|error| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
method,
format!("params 无效:{error}"),
)
})
}
fn localized_publish_rpc_report(
state_dir: &Path,
base_config: &OfficialUpdateConfig,
params: Option<&serde_json::Value>,
) -> Result<LocalizedPatchReport, ApiError> {
let params: LocalizedPublishRpcParams =
rpc_optional_struct_params(params, RPC_METHOD_LOCALIZED_PUBLISH)?;
let translation_file = params
.translation_file
.filter(|path| !path.as_os_str().is_empty());
if translation_file.is_some() == params.from_worker {
return Err(ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_LOCALIZED_PUBLISH,
"localized.publish 必须且只能指定 translation_file 或 from_worker",
));
}
let mut config = base_config.clone();
config.force = params.force;
let options = CliOptions {
command: CliCommand::PublishLocalized,
config,
state_dir: state_dir.to_path_buf(),
translation_file,
translation_from_worker: params.from_worker,
localized_release_id: normalize_optional_rpc_string(params.localized_release_id),
..CliOptions::default()
};
publish_localized_report(&options).map_err(|error| {
ApiError::new(
ErrorCode::INTERNAL,
RPC_METHOD_LOCALIZED_PUBLISH,
error.to_string(),
)
})
}
fn localized_rollback_rpc_report(
state_dir: &Path,
base_config: &OfficialUpdateConfig,
params: Option<&serde_json::Value>,
) -> Result<LocalizedRollbackReport, ApiError> {
let params: LocalizedRollbackRpcParams =
rpc_optional_struct_params(params, RPC_METHOD_LOCALIZED_ROLLBACK)?;
let options = CliOptions {
command: CliCommand::LocalizedRollback,
config: base_config.clone(),
state_dir: state_dir.to_path_buf(),
localized_release_id: normalize_optional_rpc_string(params.localized_release_id),
..CliOptions::default()
};
localized_rollback_report(&options).map_err(|error| {
ApiError::new(
ErrorCode::INTERNAL,
RPC_METHOD_LOCALIZED_ROLLBACK,
error.to_string(),
)
})
}
fn normalize_optional_rpc_string(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn rpc_translation_worker_config(
params: Option<&serde_json::Value>,
) -> Result<TranslationWorkerConfig, ApiError> {
@@ -4484,6 +4665,24 @@ impl HumanReport for LocalizedPatchReport {
}
}
impl HumanReport for LocalizedRollbackReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化 release 回滚");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("回滚 release", &self.rolled_back_release_id);
print_optional_field("恢复 release", self.restored_release_id.as_deref());
print_path_field("汉化输出目录", &self.localized_output_root);
print_path_field("current", &self.current_path);
print_path_field("状态文件", &self.state_path);
print_path_field("删除版本目录", &self.removed_version_path);
print_optional_path_field("恢复 current 目标", self.restored_current_target.as_ref());
print_field("新状态", &self.state.status);
print_optional_field("当前 release", self.state.current_release_id.as_deref());
Ok(())
}
}
impl HumanReport for bat_infrastructure::LocalizedTranslationWorkflowReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化工作流状态");
@@ -6971,6 +7170,10 @@ fn parse_args_with_env(
ensure_command_not_set(options.command, "localized-status")?;
options.command = CliCommand::LocalizedStatus;
}
"localized-rollback" => {
ensure_command_not_set(options.command, "localized-rollback")?;
options.command = CliCommand::LocalizedRollback;
}
"resource-index" => {
ensure_command_not_set(options.command, "resource-index")?;
options.command = CliCommand::ResourceIndex;
@@ -7069,6 +7272,9 @@ fn parse_args_with_env(
options.translation_text_file =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
}
"--from-worker" | "--translation-from-worker" => {
options.translation_from_worker = true;
}
"--failure-reason" | "--reason" => {
options.translation_failure_reason = Some(next_option_value(&mut args, &flag)?);
}
@@ -7589,6 +7795,9 @@ fn parse_args_with_env(
{
return Err(anyhow::anyhow!("翻译 worker 参数只适用于 i18n worker run"));
}
if options.command != CliCommand::PublishLocalized && options.translation_from_worker {
return Err(anyhow::anyhow!("--from-worker 只适用于 i18n publish"));
}
match options.command {
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
@@ -7671,6 +7880,13 @@ fn parse_args_with_env(
"parse/translate/publish-localized 不能使用 --dry-run"
));
}
if matches!(options.command, CliCommand::PublishLocalized)
&& options.translation_file.is_some() == options.translation_from_worker
{
return Err(anyhow::anyhow!(
"i18n publish 必须且只能指定 --translation-file 或 --from-worker"
));
}
}
CliCommand::ParseClearCache => {
if options.watch || options.daemon || options.daemon_child {
@@ -7693,9 +7909,13 @@ fn parse_args_with_env(
if options.watch || options.daemon || options.daemon_child {
return Err(anyhow::anyhow!("i18n validate 只支持单次执行"));
}
if options.config.force || options.config.dry_run || options.run_count.is_some() {
if options.config.force
|| options.config.dry_run
|| options.run_count.is_some()
|| options.translation_from_worker
{
return Err(anyhow::anyhow!(
"i18n validate 不支持 --force、--dry-run--run-count"
"i18n validate 不支持 --force、--dry-run--run-count 或 --from-worker"
));
}
options.progress = false;
@@ -7737,6 +7957,7 @@ fn parse_args_with_env(
|| options.schedule_option_explicit
|| options.translation_failure_reason.is_some()
|| options.translation_provider_run_id.is_some()
|| options.translation_from_worker
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config, &options.env_baseline_config)
{
@@ -7751,7 +7972,11 @@ fn parse_args_with_env(
if options.watch || options.daemon || options.daemon_child {
return Err(anyhow::anyhow!("repack 只支持单次执行"));
}
if options.config.force || options.sync_option_explicit || options.run_count.is_some() {
if options.config.force
|| options.sync_option_explicit
|| options.run_count.is_some()
|| options.translation_from_worker
{
return Err(anyhow::anyhow!("repack 不接受资源同步选项"));
}
options.progress = false;
@@ -7778,6 +8003,7 @@ fn parse_args_with_env(
|| options.translation_id.is_some()
|| options.translation_text.is_some()
|| options.translation_text_file.is_some()
|| options.translation_from_worker
|| options.localized_release_id.is_some()
|| options.repack_spec.is_some()
|| options.query_offset != 0
@@ -7828,6 +8054,7 @@ fn parse_args_with_env(
|| options.translation_text_file.is_some()
|| options.translation_failure_reason.is_some()
|| options.translation_provider_run_id.is_some()
|| options.translation_from_worker
|| options.localized_release_id.is_some()
|| options.repack_spec.is_some()
|| options.proxy_option_explicit
@@ -7882,6 +8109,7 @@ fn parse_args_with_env(
|| options.translation_text_file.is_some()
|| options.translation_failure_reason.is_some()
|| options.translation_provider_run_id.is_some()
|| options.translation_from_worker
|| options.localized_release_id.is_some()
|| options.repack_spec.is_some()
|| options.query_option_explicit
@@ -7894,6 +8122,41 @@ fn parse_args_with_env(
options.progress = false;
options.banner = false;
}
CliCommand::LocalizedRollback => {
if options.watch || options.daemon || options.daemon_child {
return Err(anyhow::anyhow!("i18n rollback 只支持单次执行或 RPC 调用"));
}
if options.config.force
|| options.config.dry_run
|| options.run_count.is_some()
|| options.sync_option_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config, &options.env_baseline_config)
{
return Err(anyhow::anyhow!(
"i18n rollback 只接受 --output、--localized-output、--state-dir、--localized-release-id 和 --json/--human"
));
}
if options.resource_root.is_some()
|| options.translation_file.is_some()
|| options.translation_id.is_some()
|| options.translation_text.is_some()
|| options.translation_text_file.is_some()
|| options.translation_failure_reason.is_some()
|| options.translation_provider_run_id.is_some()
|| options.translation_from_worker
|| options.repack_spec.is_some()
|| options.query_option_explicit
|| options.schedule_option_explicit
|| options.translation_worker_option_explicit
{
return Err(anyhow::anyhow!(
"i18n rollback 不接受工作台、任务、查询、调度、repack 或 worker 参数"
));
}
options.progress = false;
options.banner = false;
}
CliCommand::ScheduleList
| CliCommand::ScheduleAdd
| CliCommand::ScheduleUpdate
@@ -8103,6 +8366,7 @@ fn parse_translation_command(
"unset" | "clear" => CliCommand::TranslationUnset,
"validate" => CliCommand::TranslationValidate,
"publish" => CliCommand::PublishLocalized,
"rollback" => CliCommand::LocalizedRollback,
"proofread" => CliCommand::TranslationProofread,
"tasks" => CliCommand::TranslationTasks,
"handoff" => CliCommand::TranslationHandoff,
@@ -8333,7 +8597,8 @@ fn print_usage(binary: &str) {
eprintln!(" i18n handoff Query current translation handoff");
eprintln!(" i18n status Show localized release status for current official release");
eprintln!(" i18n task update Update one provider worker task status");
eprintln!(" i18n publish Publish a localized release");
eprintln!(" i18n publish Publish a localized release from a workbench or worker results");
eprintln!(" i18n rollback Roll back the current localized release");
eprintln!(" i18n schedule Manage translation schedules");
eprintln!(" refresh Run one update check, or ask a live daemon to refresh");
eprintln!(" verify Verify remote plan, local manifest, and official seed hashes");
@@ -8344,6 +8609,7 @@ fn print_usage(binary: &str) {
eprintln!(" translation-tasks Query current offline TextUnit translation task status");
eprintln!(" translation-handoff Query current translation job/unit/provider handoff");
eprintln!(" localized-status Show localized release status for current official release");
eprintln!(" localized-rollback Roll back the current localized release");
eprintln!(" resource-index Query CAS + ResourceRepository index");
eprintln!(" patch-apply Apply a Binary/JSON/Text patch file");
eprintln!(" unityfs-patch-text-asset Patch one UnityFS TextAsset object");
@@ -8381,6 +8647,8 @@ fn print_usage(binary: &str) {
eprintln!(" {binary} i18n handoff --json");
eprintln!(" {binary} i18n status --json");
eprintln!(" {binary} i18n publish --translation-file /tmp/bat-workbench.json --force");
eprintln!(" {binary} i18n publish --from-worker --localized-release-id release-manual-1");
eprintln!(" {binary} i18n rollback --localized-release-id release-manual-1");
eprintln!(" {binary} status");
eprintln!(" {binary} refresh --force --json");
eprintln!();
@@ -8429,6 +8697,7 @@ fn print_usage(binary: &str) {
eprintln!(" --translation-id <ID> TextUnit ID for i18n set");
eprintln!(" --translated-text <TEXT> Inline translation for i18n set");
eprintln!(" --translated-file <PATH> UTF-8 translation file for i18n set");
eprintln!(" --from-worker Build publish input from completed provider worker results");
eprintln!(" --failure-reason <TEXT> Provider failure reason for i18n task update");
eprintln!(" --provider-run-id <ID> Provider run ID for i18n task update");
eprintln!(" --translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)");
+168
View File
@@ -299,6 +299,27 @@ fn translation_worker_rpc_params_are_strict_and_accept_aliases() {
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"max_tasks": 0}))).is_err());
}
#[test]
fn localized_publish_rpc_params_require_one_input_source() {
let config = OfficialUpdateConfig::default();
assert!(localized_publish_rpc_report(Path::new("/tmp/bat-state"), &config, None,).is_err());
assert!(localized_publish_rpc_report(
Path::new("/tmp/bat-state"),
&config,
Some(&serde_json::json!({
"translation_file": "/tmp/workbench.json",
"from_worker": true
})),
)
.is_err());
assert!(localized_publish_rpc_report(
Path::new("/tmp/bat-state"),
&config,
Some(&serde_json::json!({"unexpected": true})),
)
.is_err());
}
#[test]
fn grouped_workflow_commands_use_short_top_level_aliases() {
let options = parse(&[
@@ -388,6 +409,38 @@ fn grouped_workflow_commands_use_short_top_level_aliases() {
.unwrap();
assert_eq!(options.command, CliCommand::TranslationValidate);
let options = parse(&[
"bat",
"i18n",
"publish",
"--from-worker",
"--localized-release-id",
"localized-1",
])
.unwrap();
assert_eq!(options.command, CliCommand::PublishLocalized);
assert!(options.translation_from_worker);
assert_eq!(options.localized_release_id.as_deref(), Some("localized-1"));
assert!(parse(&[
"bat",
"i18n",
"publish",
"--from-worker",
"--translation-file",
"/tmp/workbench.json",
])
.is_err());
let options = parse(&[
"bat",
"i18n",
"rollback",
"--localized-release-id",
"localized-1",
])
.unwrap();
assert_eq!(options.command, CliCommand::LocalizedRollback);
let options = parse(&[
"bat",
"i18n",
@@ -548,8 +601,13 @@ fn translation_workbench_commands_read_update_and_clear_entries() {
serialized_file: Some("CAB-test".to_string()),
path_id: Some(7),
asset_name: Some("Story".to_string()),
field_path: None,
source_text: "原文".to_string(),
translated_text: None,
translation_provider: None,
provider_run_id: None,
translated_unix_seconds: None,
review_status: None,
format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()),
}],
@@ -2048,6 +2106,7 @@ fn test_task_context_with_config(base_config: OfficialUpdateConfig) -> DaemonTas
registry: TaskRegistry::new(),
queue,
base_config,
sync_lock: Arc::new(Mutex::new(())),
restart_controller: test_restart_controller,
}
}
@@ -2217,6 +2276,7 @@ fn dispatch_daemon_doctor_returns_report() {
registry: TaskRegistry::new(),
queue,
base_config,
sync_lock: Arc::new(Mutex::new(())),
restart_controller: test_restart_controller,
};
@@ -2246,6 +2306,7 @@ fn dispatch_resource_sync_enqueues_task() {
registry: TaskRegistry::new(),
queue,
base_config: OfficialUpdateConfig::default(),
sync_lock: Arc::new(Mutex::new(())),
restart_controller: test_restart_controller,
};
@@ -2306,6 +2367,7 @@ fn dispatch_resource_repair_enqueues_repair_task() {
registry: TaskRegistry::new(),
queue,
base_config,
sync_lock: Arc::new(Mutex::new(())),
restart_controller: test_restart_controller,
};
@@ -3597,6 +3659,7 @@ fn dispatch_catalog_refresh_enqueues_task() {
registry: TaskRegistry::new(),
queue,
base_config: OfficialUpdateConfig::default(),
sync_lock: Arc::new(Mutex::new(())),
restart_controller: test_restart_controller,
};
let envelope = dispatch_rpc_method(
@@ -4508,6 +4571,111 @@ fn dispatch_translation_proofread_marks_state_for_dashboard() {
);
}
#[cfg(unix)]
#[test]
fn dispatch_localized_rollback_restores_manifest_previous_release() {
use std::os::unix::fs::symlink;
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().join("state");
let output_root = temp.path().join("output");
let localized_root = temp.path().join("localized");
write_catalog_fixture_with_localized(
&state_dir,
&output_root,
&localized_root,
"bundle-b2",
None,
);
let previous = localized_root
.join(LOCALIZED_VERSIONS_DIR)
.join("release-1");
let current = localized_root
.join(LOCALIZED_VERSIONS_DIR)
.join("release-2");
fs::create_dir_all(&previous).unwrap();
fs::create_dir_all(&current).unwrap();
fs::write(
previous.join(LOCALIZED_PATCH_MANIFEST_FILE),
serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest {
manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION,
official_release_id: "v-current".to_string(),
localized_release_id: "release-1".to_string(),
generated_unix_seconds: 123,
file_count: 0,
text_asset_operation_count: 0,
files: Vec::new(),
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: None,
remove_version_path: previous.clone(),
},
})
.unwrap(),
)
.unwrap();
fs::write(
current.join(LOCALIZED_PATCH_MANIFEST_FILE),
serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest {
manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION,
official_release_id: "v-current".to_string(),
localized_release_id: "release-2".to_string(),
generated_unix_seconds: 124,
file_count: 0,
text_asset_operation_count: 0,
files: Vec::new(),
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
previous_current_target: Some(PathBuf::from("versions/release-1")),
remove_version_path: current.clone(),
},
})
.unwrap(),
)
.unwrap();
symlink(
Path::new(LOCALIZED_VERSIONS_DIR).join("release-2"),
localized_root.join(LOCALIZED_CURRENT_LINK),
)
.unwrap();
fs::write(
localized_root.join(LOCALIZED_VERSION_STATE_FILE),
serde_json::to_vec(&bat_infrastructure::LocalizedVersionState {
state_version: bat_infrastructure::LOCALIZED_VERSION_STATE_VERSION,
official_release_id: "v-current".to_string(),
current_release_id: Some("release-2".to_string()),
status: "localized".to_string(),
translation_workflow_status: None,
updated_unix_seconds: 124,
})
.unwrap(),
)
.unwrap();
let tasks = test_task_context_with_config(OfficialUpdateConfig {
localized_output_root: localized_root.clone(),
..OfficialUpdateConfig::default()
});
let envelope = dispatch_rpc_method(
&rpc_request(
"localized.rollback",
Some(serde_json::json!({"localized_release_id": "release-2"})),
),
&state_dir,
&new_daemon_control(),
&tasks,
"req-localized-rollback-1".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["status"], "rolled_back");
assert_eq!(value["data"]["rolled_back_release_id"], "release-2");
assert_eq!(value["data"]["restored_release_id"], "release-1");
assert!(!current.exists());
assert_eq!(
fs::read_link(localized_root.join(LOCALIZED_CURRENT_LINK)).unwrap(),
PathBuf::from("versions/release-1")
);
}
#[cfg(unix)]
#[test]
fn localized_status_keeps_published_release_during_manual_proofreading() {
@@ -515,6 +515,8 @@ pub(super) struct DaemonTaskContext {
pub(super) registry: TaskRegistry,
pub(super) queue: mpsc::Sender<TaskJob>,
pub(super) base_config: OfficialUpdateConfig,
/// 串行化会读取或修改已发布资源状态的 daemon 操作。
pub(super) sync_lock: Arc<Mutex<()>>,
pub(super) restart_controller: DaemonRestartController,
}
+41 -10
View File
@@ -299,13 +299,25 @@ pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> {
print_report(options.output_format, &report)
}
pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> {
let translation_file = options
.translation_file
.as_ref()
.ok_or_else(|| anyhow::anyhow!("publish-localized 必须指定 --translation-file"))?;
pub(super) fn publish_localized_report(
options: &CliOptions,
) -> anyhow::Result<LocalizedPatchReport> {
let (resource_root, official_release_id) = current_official_release(options)?;
let workbench = read_translation_workbench(translation_file)?;
let workbench = if options.translation_from_worker {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(completed_worker_translation_workbench(
&resource_root,
&official_release_id,
))?
} else {
let translation_file = options
.translation_file
.as_ref()
.ok_or_else(|| anyhow::anyhow!("publish-localized 必须指定 --translation-file"))?;
read_translation_workbench(translation_file)?
};
if workbench.official_release_id != official_release_id {
return Err(anyhow::anyhow!(
"翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出",
@@ -319,7 +331,7 @@ pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()>
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
));
}
let patches = localized_text_asset_patches(&resource_root, &workbench)?;
let operations = localized_patch_operations(&resource_root, &workbench)?;
let localized_release_id = options.localized_release_id.clone().or_else(|| {
options
.config
@@ -330,17 +342,36 @@ pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()>
resource_root,
options.config.localized_output_root.clone(),
official_release_id,
patches,
Vec::new(),
)
.with_operations(operations)
.with_force(options.config.force);
if let Some(release_id) = localized_release_id {
config = config.with_localized_release_id(release_id);
}
let report = LocalizedPatchService::new().publish(&config)?;
LocalizedPatchService::new().publish(&config)
}
pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> {
let report = publish_localized_report(options)?;
print_report(options.output_format, &report)
}
fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> {
pub(super) fn localized_rollback_report(
options: &CliOptions,
) -> anyhow::Result<LocalizedRollbackReport> {
LocalizedPatchService::new().rollback(
&options.config.localized_output_root,
options.localized_release_id.as_deref(),
)
}
pub(super) fn run_localized_rollback(options: &CliOptions) -> anyhow::Result<()> {
let report = localized_rollback_report(options)?;
print_report(options.output_format, &report)
}
pub(super) fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> {
let state = read_version_state(&options.config.version_state_path())?;
let resource_root = if let Some(resource_root) = options.resource_root.clone() {
lexical_absolute(&resource_root).map_err(anyhow::Error::msg)?