feat(i18n): 接入翻译 provider worker
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

Closes #44
This commit is contained in:
2026-08-30 21:13:31 +08:00
parent 7f465523e1
commit f441f1810e
29 changed files with 3815 additions and 130 deletions
+541 -7
View File
@@ -23,14 +23,16 @@ use bat_infrastructure::{
OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus,
OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PatchApplyKind,
PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport,
SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationTaskStatus,
UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams,
UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE, LOCALIZED_CURRENT_LINK,
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,
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE,
PRIVATE_FILE_MODE,
LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MAX_TRANSLATION_CONCURRENCY,
MIN_DOWNLOAD_CONCURRENCY, MIN_TRANSLATION_CONCURRENCY, OFFICIAL_PARSE_CACHE_FILE,
OFFICIAL_TEXTUNIT_INDEX_FILE, OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE, PRIVATE_FILE_MODE,
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -85,6 +87,7 @@ use workflow_commands::{
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,
run_translation_worker,
};
const EXIT_ERROR: i32 = 1;
@@ -229,6 +232,10 @@ fn run() -> anyhow::Result<i32> {
run_translation_proofread(&options)?;
Ok(0)
}
CliCommand::TranslationWorker => {
run_repeated_workflow(&options, "translation-worker", run_translation_worker)?;
Ok(0)
}
CliCommand::Repack => {
run_repack(&options)?;
Ok(0)
@@ -378,6 +385,15 @@ struct CliOptions {
translation_text_file: Option<PathBuf>,
translation_failure_reason: Option<String>,
translation_provider_run_id: Option<String>,
translation_provider: Option<String>,
translation_fixture: Option<PathBuf>,
worker_concurrency: usize,
worker_max_attempts: u32,
worker_lease_seconds: u64,
worker_retry_backoff: Duration,
worker_max_tasks: Option<usize>,
worker_id: Option<String>,
translation_worker_option_explicit: bool,
localized_release_id: Option<String>,
repack_spec: Option<PathBuf>,
schedule_group: Option<String>,
@@ -463,6 +479,15 @@ impl Default for CliOptions {
translation_text_file: None,
translation_failure_reason: None,
translation_provider_run_id: None,
translation_provider: None,
translation_fixture: None,
worker_concurrency: DEFAULT_TRANSLATION_CONCURRENCY,
worker_max_attempts: DEFAULT_TRANSLATION_MAX_ATTEMPTS,
worker_lease_seconds: DEFAULT_TRANSLATION_LEASE_SECONDS,
worker_retry_backoff: DEFAULT_TRANSLATION_RETRY_BACKOFF,
worker_max_tasks: None,
worker_id: None,
translation_worker_option_explicit: false,
localized_release_id: None,
repack_spec: None,
schedule_group: None,
@@ -546,6 +571,7 @@ enum CliCommand {
TranslationGet,
TranslationUnset,
TranslationTaskUpdate,
TranslationWorker,
TranslationProofread,
Repack,
PublishLocalized,
@@ -1025,6 +1051,7 @@ const RPC_METHOD_TRANSLATION_TASKS: &str = "translation.tasks";
const RPC_METHOD_TRANSLATION_HANDOFF: &str = "translation.handoff";
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_CATALOG_STATUS: &str = "catalog.status";
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
@@ -2027,6 +2054,13 @@ fn dispatch_rpc_method(
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,
Err(error) => return rpc_envelope_error(request_id, error),
};
enqueue_translation_worker_envelope(tasks, config, request_id)
}
RPC_METHOD_LOCALIZED_STATUS => rpc_envelope_from_result(
request_id,
"localized.status",
@@ -2211,7 +2245,9 @@ fn enqueue_task_envelope(
.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
let job = TaskJob {
id: task_id.clone(),
kind,
config,
translation_worker_config: None,
cancel,
};
if tasks.queue.send(job).is_err() {
@@ -2237,6 +2273,50 @@ fn enqueue_task_envelope(
)
}
fn enqueue_translation_worker_envelope(
tasks: &DaemonTaskContext,
worker_config: TranslationWorkerConfig,
request_id: String,
) -> RpcEnvelope {
let config = TaskKind::TranslationWorker.build_config(&tasks.base_config, false);
let task_id = tasks.registry.create(TaskKind::TranslationWorker);
let cancel = tasks
.registry
.cancel_flag(&task_id)
.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
let job = TaskJob {
id: task_id.clone(),
kind: TaskKind::TranslationWorker,
config,
translation_worker_config: Some(worker_config.clone()),
cancel,
};
if tasks.queue.send(job).is_err() {
tasks.registry.update(&task_id, |record| {
record.status = "failed";
record.finished_at = Some(unix_seconds_now());
record.error = Some(ApiError::new(
ErrorCode::INTERNAL,
"task.enqueue",
"任务执行器不可用",
));
});
return rpc_envelope_error(
request_id,
ApiError::new(ErrorCode::INTERNAL, "task.enqueue", "任务执行器不可用"),
);
}
rpc_envelope_ok(
request_id,
"accepted",
serde_json::json!({
"task_id": task_id,
"kind": RPC_METHOD_TRANSLATION_WORKER_RUN,
"worker": worker_config,
}),
)
}
fn rpc_ack_value(
command: &'static str,
message: &'static str,
@@ -3070,6 +3150,207 @@ fn rpc_struct_params<T: DeserializeOwned>(
})
}
fn rpc_translation_worker_config(
params: Option<&serde_json::Value>,
) -> Result<TranslationWorkerConfig, ApiError> {
let empty = serde_json::json!({});
let params = params.unwrap_or(&empty);
if !params.is_object() {
return Err(ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_WORKER_RUN,
"params 必须是 JSON object",
));
}
let provider = rpc_translation_worker_string_param(
params,
&["provider", "translation_provider"],
"provider",
)?
.unwrap_or_else(|| TranslationProviderKind::Mock.as_str().to_string());
let provider = TranslationProviderKind::parse(&provider).ok_or_else(|| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_WORKER_RUN,
format!("不支持的 translation provider{provider}"),
)
})?;
let concurrency = rpc_translation_worker_usize_param(
params,
&[
"concurrency",
"worker_concurrency",
"translation_concurrency",
],
"concurrency",
)?
.unwrap_or(DEFAULT_TRANSLATION_CONCURRENCY);
let max_attempts = rpc_translation_worker_u32_param(
params,
&[
"max_attempts",
"worker_max_attempts",
"translation_max_attempts",
],
"max_attempts",
)?
.unwrap_or(DEFAULT_TRANSLATION_MAX_ATTEMPTS);
let lease_seconds = rpc_translation_worker_u64_param(
params,
&[
"lease_seconds",
"worker_lease_seconds",
"translation_lease_seconds",
],
"lease_seconds",
)?
.unwrap_or(DEFAULT_TRANSLATION_LEASE_SECONDS);
let retry_backoff_seconds = rpc_translation_worker_u64_param(
params,
&[
"retry_backoff_seconds",
"worker_retry_backoff_seconds",
"translation_retry_backoff_seconds",
],
"retry_backoff_seconds",
)?
.unwrap_or(DEFAULT_TRANSLATION_RETRY_BACKOFF.as_secs());
let max_tasks = rpc_translation_worker_usize_param(
params,
&["max_tasks", "worker_max_tasks", "translation_max_tasks"],
"max_tasks",
)?;
let config = TranslationWorkerConfig {
provider,
fixture_path: rpc_translation_worker_string_param(
params,
&[
"fixture_path",
"translation_fixture",
"provider_fixture",
"mock_fixture",
"fixture",
],
"fixture_path",
)?
.map(PathBuf::from),
concurrency,
max_attempts,
lease_seconds,
retry_backoff: Duration::from_secs(retry_backoff_seconds),
max_tasks,
worker_id: rpc_translation_worker_string_param(
params,
&["worker_id", "translation_worker_id"],
"worker_id",
)?
.unwrap_or_else(|| "bat-rpc-worker".to_string()),
};
config.validate().map_err(|error| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_WORKER_RUN,
error.to_string(),
)
})?;
Ok(config)
}
fn rpc_translation_worker_string_param(
params: &serde_json::Value,
aliases: &[&str],
label: &str,
) -> Result<Option<String>, ApiError> {
for key in aliases {
let Some(value) = params.get(*key) else {
continue;
};
if value.is_null() {
return Ok(None);
}
let Some(text) = value.as_str() else {
return Err(ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_WORKER_RUN,
format!("{label} 必须是字符串"),
));
};
let text = text.trim();
if text.is_empty() {
return Ok(None);
}
return Ok(Some(text.to_string()));
}
Ok(None)
}
fn rpc_translation_worker_u64_param(
params: &serde_json::Value,
aliases: &[&str],
label: &str,
) -> Result<Option<u64>, ApiError> {
for key in aliases {
let Some(value) = params.get(*key) else {
continue;
};
if value.is_null() {
return Ok(None);
}
if value.as_i64().is_some_and(|number| number < 0) {
return Err(ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_WORKER_RUN,
format!("{label} 不能为负数"),
));
}
let Some(number) = value.as_u64() else {
return Err(ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_WORKER_RUN,
format!("{label} 必须是非负整数 JSON number"),
));
};
return Ok(Some(number));
}
Ok(None)
}
fn rpc_translation_worker_usize_param(
params: &serde_json::Value,
aliases: &[&str],
label: &str,
) -> Result<Option<usize>, ApiError> {
rpc_translation_worker_u64_param(params, aliases, label)?
.map(|number| {
usize::try_from(number).map_err(|error| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_WORKER_RUN,
format!("{label} 无效:{error}"),
)
})
})
.transpose()
}
fn rpc_translation_worker_u32_param(
params: &serde_json::Value,
aliases: &[&str],
label: &str,
) -> Result<Option<u32>, ApiError> {
rpc_translation_worker_u64_param(params, aliases, label)?
.map(|number| {
u32::try_from(number).map_err(|error| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_TRANSLATION_WORKER_RUN,
format!("{label} 无效:{error}"),
)
})
})
.transpose()
}
/// 分页参数:`offset` 默认 0`limit` 默认 100,范围 1..=1000。
fn rpc_page_params(params: Option<&serde_json::Value>) -> anyhow::Result<(usize, usize)> {
let offset = params
@@ -4221,6 +4502,32 @@ impl HumanReport for bat_infrastructure::LocalizedTranslationWorkflowReport {
}
}
impl HumanReport for bat_infrastructure::TranslationWorkerReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("翻译 provider worker");
print_field("命令", self.command);
print_field("状态", self.status);
print_field("官方 release", &self.official_release_id);
print_field("provider", &self.provider);
print_field("回收 lease", self.recovered_lease_count);
print_field("领取任务", self.claimed_count);
print_field("完成任务", self.completed_count);
print_field("失败任务", self.failed_count);
print_field("已安排重试", self.retry_scheduled_count);
print_field("剩余任务", self.remaining_count);
for failure in &self.failures {
println!(
" - {} [{}] retryable={} {}",
failure.task_id,
failure.failure_class,
format_bool(failure.retryable),
failure.failure_reason
);
}
Ok(())
}
}
fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
if value.get("running").is_some() && value.get("state_dir").is_some() {
print_title("后台状态");
@@ -6321,6 +6628,17 @@ BAT_AUTO_DISCOVER=1
#BAT_DOWNLOAD_CONCURRENCY=8
#BAT_UNZIP=unzip
# ---- provider worker ----
# providermock fixture crowdin CROWDIN_*
#BAT_TRANSLATION_PROVIDER=mock
#BAT_TRANSLATION_FIXTURE=
#BAT_TRANSLATION_CONCURRENCY=8
#BAT_TRANSLATION_MAX_ATTEMPTS=3
#BAT_TRANSLATION_LEASE_SECONDS=300
#BAT_TRANSLATION_RETRY_BACKOFF_SECONDS=5
#BAT_TRANSLATION_MAX_TASKS=
#BAT_TRANSLATION_WORKER_ID=
# ---- ----
# 1 JSON
#BAT_JSON=0
@@ -6504,6 +6822,38 @@ fn apply_bat_env_overrides(
if let Some(v) = value("BAT_UNZIP") {
options.config.unzip_command = PathBuf::from(v);
}
if let Some(v) = value("BAT_TRANSLATION_PROVIDER") {
options.translation_provider = Some(v);
}
if let Some(v) = value("BAT_TRANSLATION_FIXTURE") {
options.translation_fixture = Some(PathBuf::from(v));
}
if let Some(v) = value("BAT_TRANSLATION_CONCURRENCY") {
options.worker_concurrency =
parse_translation_worker_concurrency(&v, "环境变量 BAT_TRANSLATION_CONCURRENCY")?;
}
if let Some(v) = value("BAT_TRANSLATION_MAX_ATTEMPTS") {
options.worker_max_attempts =
parse_positive_u32(&v, "环境变量 BAT_TRANSLATION_MAX_ATTEMPTS")?;
}
if let Some(v) = value("BAT_TRANSLATION_LEASE_SECONDS") {
options.worker_lease_seconds =
parse_positive_u64(&v, "环境变量 BAT_TRANSLATION_LEASE_SECONDS")?;
}
if let Some(v) = value("BAT_TRANSLATION_RETRY_BACKOFF_SECONDS") {
options.worker_retry_backoff = Duration::from_secs(v.parse::<u64>().map_err(|error| {
anyhow::anyhow!("环境变量 BAT_TRANSLATION_RETRY_BACKOFF_SECONDS 的秒数无效:{error}")
})?);
}
if let Some(v) = value("BAT_TRANSLATION_MAX_TASKS") {
options.worker_max_tasks = Some(parse_positive_usize(
&v,
"环境变量 BAT_TRANSLATION_MAX_TASKS",
)?);
}
if let Some(v) = value("BAT_TRANSLATION_WORKER_ID") {
options.worker_id = Some(v);
}
if let Some(v) = value("BAT_PROXY") {
options.config.curl_proxy = parse_proxy_config(&v)?;
}
@@ -6725,6 +7075,55 @@ fn parse_args_with_env(
"--provider-run-id" => {
options.translation_provider_run_id = Some(next_option_value(&mut args, &flag)?);
}
"--translation-provider" | "--provider" => {
options.translation_provider = Some(next_option_value(&mut args, &flag)?);
options.translation_worker_option_explicit = true;
}
"--translation-fixture" | "--provider-fixture" | "--mock-fixture" | "--fixture" => {
options.translation_fixture =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.translation_worker_option_explicit = true;
}
"--worker-concurrency" | "--translation-concurrency" => {
options.worker_concurrency = parse_translation_worker_concurrency(
&next_option_value(&mut args, &flag)?,
&flag,
)?;
options.translation_worker_option_explicit = true;
}
"--worker-max-attempts" | "--translation-max-attempts" => {
options.worker_max_attempts =
parse_positive_u32(&next_option_value(&mut args, &flag)?, &flag)?;
options.translation_worker_option_explicit = true;
}
"--worker-lease-seconds" | "--translation-lease-seconds" => {
options.worker_lease_seconds =
parse_positive_u64(&next_option_value(&mut args, &flag)?, &flag)?;
options.translation_worker_option_explicit = true;
}
"--worker-retry-backoff" | "--translation-retry-backoff" => {
options.worker_retry_backoff =
parse_duration(&next_option_value(&mut args, &flag)?)?;
options.translation_worker_option_explicit = true;
}
"--worker-retry-backoff-seconds" | "--translation-retry-backoff-seconds" => {
let seconds = next_option_value(&mut args, &flag)?
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?;
options.worker_retry_backoff = Duration::from_secs(seconds);
options.translation_worker_option_explicit = true;
}
"--worker-max-tasks" | "--translation-max-tasks" => {
options.worker_max_tasks = Some(parse_positive_usize(
&next_option_value(&mut args, &flag)?,
&flag,
)?);
options.translation_worker_option_explicit = true;
}
"--worker-id" | "--translation-worker-id" => {
options.worker_id = Some(next_option_value(&mut args, &flag)?);
options.translation_worker_option_explicit = true;
}
"--localized-release-id" => {
options.localized_release_id = Some(next_option_value(&mut args, &flag)?);
}
@@ -7185,6 +7584,12 @@ fn parse_args_with_env(
));
}
if options.command != CliCommand::TranslationWorker
&& options.translation_worker_option_explicit
{
return Err(anyhow::anyhow!("翻译 worker 参数只适用于 i18n worker run"));
}
match options.command {
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
if options.sync_option_explicit
@@ -7406,6 +7811,56 @@ fn parse_args_with_env(
options.progress = false;
options.banner = false;
}
CliCommand::TranslationWorker => {
if options.daemon || options.daemon_child {
return Err(anyhow::anyhow!(
"i18n worker run 使用 --watch 或 --run-count,不支持 daemon"
));
}
if options.config.force
|| options.config.dry_run
|| options.schedule_option_explicit
|| options.query_option_explicit
|| options.write_patch_option_explicit
|| 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.localized_release_id.is_some()
|| options.repack_spec.is_some()
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config, &options.env_baseline_config)
{
return Err(anyhow::anyhow!(
"i18n worker run 只接受 --output、--resource-root、--state-dir、worker 参数和轮询参数"
));
}
let provider = options
.translation_provider
.as_deref()
.unwrap_or(TranslationProviderKind::Mock.as_str());
if TranslationProviderKind::parse(provider).is_none() {
return Err(anyhow::anyhow!(
"i18n worker run 的 provider 无效:{provider}"
));
}
TranslationWorkerConfig {
provider: TranslationProviderKind::parse(provider).expect("provider 已在上方校验"),
fixture_path: options.translation_fixture.clone(),
concurrency: options.worker_concurrency,
max_attempts: options.worker_max_attempts,
lease_seconds: options.worker_lease_seconds,
retry_backoff: options.worker_retry_backoff,
max_tasks: options.worker_max_tasks,
worker_id: options
.worker_id
.clone()
.unwrap_or_else(|| "bat-worker-validation".to_string()),
}
.validate()?;
}
CliCommand::TranslationProofread => {
if options.watch || options.daemon || options.daemon_child {
return Err(anyhow::anyhow!("i18n proofread 只支持单次执行或 RPC 调用"));
@@ -7506,6 +7961,7 @@ fn parse_args_with_env(
CliCommand::Pull
| CliCommand::Parse
| CliCommand::Translate
| CliCommand::TranslationWorker
| CliCommand::PublishLocalized
)
{
@@ -7515,7 +7971,11 @@ fn parse_args_with_env(
}
if matches!(
options.command,
CliCommand::Pull | CliCommand::Parse | CliCommand::Translate | CliCommand::PublishLocalized
CliCommand::Pull
| CliCommand::Parse
| CliCommand::Translate
| CliCommand::PublishLocalized
| CliCommand::TranslationWorker
) {
if options.watch && options.run_count.is_some() {
return Err(anyhow::anyhow!(
@@ -7632,6 +8092,9 @@ fn parse_translation_command(
if action == "workbench" || action == "wb" {
return parse_translation_workbench_command(args, options);
}
if action == "worker" {
return parse_translation_worker_command(args, options);
}
let command = match action.as_str() {
"run" => CliCommand::Translate,
"export" => CliCommand::Translate,
@@ -7655,6 +8118,20 @@ fn parse_translation_command(
Ok(())
}
fn parse_translation_worker_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
) -> anyhow::Result<()> {
let action = next_option_value(args, "translation worker")?;
let command = match action.as_str() {
"run" => CliCommand::TranslationWorker,
other => return Err(anyhow::anyhow!("未知 translation worker 二级命令:{other}")),
};
ensure_command_not_set(options.command, &format!("translation worker {action}"))?;
options.command = command;
Ok(())
}
fn parse_translation_workbench_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
@@ -7738,6 +8215,48 @@ fn parse_download_concurrency(value: &str, source: &str) -> anyhow::Result<usize
Ok(parsed)
}
fn parse_translation_worker_concurrency(value: &str, source: &str) -> anyhow::Result<usize> {
let parsed = value
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{source} 无效:{error}"))?;
if !(MIN_TRANSLATION_CONCURRENCY..=MAX_TRANSLATION_CONCURRENCY).contains(&parsed) {
return Err(anyhow::anyhow!(
"{source} 必须在 {MIN_TRANSLATION_CONCURRENCY}..={MAX_TRANSLATION_CONCURRENCY} 范围内"
));
}
Ok(parsed)
}
fn parse_positive_usize(value: &str, source: &str) -> anyhow::Result<usize> {
let parsed = value
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{source} 无效:{error}"))?;
if parsed == 0 {
return Err(anyhow::anyhow!("{source} 必须大于 0"));
}
Ok(parsed)
}
fn parse_positive_u32(value: &str, source: &str) -> anyhow::Result<u32> {
let parsed = value
.parse::<u32>()
.map_err(|error| anyhow::anyhow!("{source} 无效:{error}"))?;
if parsed == 0 {
return Err(anyhow::anyhow!("{source} 必须大于 0"));
}
Ok(parsed)
}
fn parse_positive_u64(value: &str, source: &str) -> anyhow::Result<u64> {
let parsed = value
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("{source} 无效:{error}"))?;
if parsed == 0 {
return Err(anyhow::anyhow!("{source} 必须大于 0"));
}
Ok(parsed)
}
fn next_option_value(
args: &mut impl Iterator<Item = String>,
flag: &str,
@@ -7807,6 +8326,7 @@ fn print_usage(binary: &str) {
eprintln!(" i18n unset Clear one translated workbench entry");
eprintln!(" i18n validate Validate workbench against the current official release");
eprintln!(" i18n proofread Mark localized workflow as manual proofreading");
eprintln!(" i18n worker run Run translation provider worker once or repeatedly");
eprintln!(
" i18n tasks / i18n task list / i18n task status Query current offline TextUnit translation task status"
);
@@ -7854,6 +8374,9 @@ fn print_usage(binary: &str) {
" {binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1"
);
eprintln!(" {binary} i18n proofread --json");
eprintln!(
" {binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s"
);
eprintln!(" {binary} i18n tasks --json");
eprintln!(" {binary} i18n handoff --json");
eprintln!(" {binary} i18n status --json");
@@ -7908,6 +8431,17 @@ fn print_usage(binary: &str) {
eprintln!(" --translated-file <PATH> UTF-8 translation file for i18n set");
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)");
eprintln!(" --translation-fixture <PATH> Mock/provider fixture for i18n worker run");
eprintln!(
" --worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)"
);
eprintln!(" --worker-max-attempts <N> Maximum claims per translation task");
eprintln!(" --worker-lease-seconds <N> Lease seconds for one claimed task");
eprintln!(" --worker-retry-backoff <DURATION> Retry backoff after retryable failure");
eprintln!(" --worker-retry-backoff-seconds <N> Retry backoff seconds");
eprintln!(" --worker-max-tasks <N> Maximum tasks claimed in one worker run");
eprintln!(" --worker-id <ID> Worker ID prefix for lease diagnostics");
eprintln!(" --localized-release-id <ID> Explicit localized publication ID");
eprintln!(" --repack-spec <PATH> UnityFS batch repack JSON spec");
eprintln!();
+159
View File
@@ -140,6 +140,165 @@ fn env_invalid_values_error() {
assert!(parse(&["bat", "--download-concurrency", "257"]).is_err());
}
#[test]
fn translation_worker_command_options_are_validated() {
let options = parse(&[
"bat",
"i18n",
"worker",
"run",
"--provider",
"mock",
"--translation-fixture",
"/tmp/mock-translation.json",
"--worker-concurrency",
"8",
"--worker-max-attempts",
"4",
"--worker-lease-seconds",
"60",
"--worker-retry-backoff",
"0s",
"--worker-max-tasks",
"2",
"--worker-id",
"manual-run",
"--run-count",
"2",
"--interval",
"30s",
])
.unwrap();
assert_eq!(options.command, CliCommand::TranslationWorker);
assert_eq!(options.translation_provider.as_deref(), Some("mock"));
assert_eq!(
options.translation_fixture,
Some(PathBuf::from("/tmp/mock-translation.json"))
);
assert_eq!(options.worker_concurrency, 8);
assert_eq!(options.worker_max_attempts, 4);
assert_eq!(options.worker_lease_seconds, 60);
assert_eq!(options.worker_retry_backoff, Duration::ZERO);
assert_eq!(options.worker_max_tasks, Some(2));
assert_eq!(options.worker_id.as_deref(), Some("manual-run"));
assert_eq!(options.run_count, Some(2));
assert!(parse(&["bat", "i18n", "worker", "run"]).is_ok());
assert!(parse(&[
"bat",
"translation",
"worker",
"run",
"--translation-concurrency",
"256"
])
.is_ok());
assert!(parse(&["bat", "i18n", "worker", "run", "--worker-concurrency", "0"]).is_err());
assert!(parse(&[
"bat",
"i18n",
"worker",
"run",
"--worker-concurrency",
"257"
])
.is_err());
assert!(parse(&["bat", "i18n", "worker", "run", "--worker-max-attempts", "0"]).is_err());
assert!(parse(&[
"bat",
"i18n",
"worker",
"run",
"--worker-lease-seconds",
"0"
])
.is_err());
assert!(parse(&["bat", "i18n", "worker", "run", "--worker-max-tasks", "0"]).is_err());
assert!(parse(&["bat", "i18n", "run", "--worker-concurrency", "8"]).is_err());
assert!(parse(&["bat", "i18n", "worker", "run", "--provider", "unknown"]).is_err());
}
#[test]
fn translation_worker_env_defaults_apply() {
let options = parse_with_env(
&["bat", "i18n", "worker", "run"],
&[
("BAT_TRANSLATION_PROVIDER", "mock"),
("BAT_TRANSLATION_FIXTURE", "/tmp/fixture.json"),
("BAT_TRANSLATION_CONCURRENCY", "16"),
("BAT_TRANSLATION_MAX_ATTEMPTS", "5"),
("BAT_TRANSLATION_LEASE_SECONDS", "120"),
("BAT_TRANSLATION_RETRY_BACKOFF_SECONDS", "0"),
("BAT_TRANSLATION_MAX_TASKS", "7"),
("BAT_TRANSLATION_WORKER_ID", "env-worker"),
],
)
.unwrap();
assert_eq!(options.command, CliCommand::TranslationWorker);
assert_eq!(options.translation_provider.as_deref(), Some("mock"));
assert_eq!(
options.translation_fixture,
Some(PathBuf::from("/tmp/fixture.json"))
);
assert_eq!(options.worker_concurrency, 16);
assert_eq!(options.worker_max_attempts, 5);
assert_eq!(options.worker_lease_seconds, 120);
assert_eq!(options.worker_retry_backoff, Duration::ZERO);
assert_eq!(options.worker_max_tasks, Some(7));
assert_eq!(options.worker_id.as_deref(), Some("env-worker"));
assert!(parse_with_env(
&["bat", "i18n", "worker", "run"],
&[("BAT_TRANSLATION_CONCURRENCY", "257")]
)
.is_err());
assert!(parse_with_env(
&["bat", "i18n", "worker", "run"],
&[("BAT_TRANSLATION_MAX_ATTEMPTS", "0")]
)
.is_err());
}
#[test]
fn translation_worker_rpc_params_are_strict_and_accept_aliases() {
let default_config = rpc_translation_worker_config(None).unwrap();
assert_eq!(default_config.provider, TranslationProviderKind::Mock);
assert_eq!(default_config.concurrency, DEFAULT_TRANSLATION_CONCURRENCY);
let config = rpc_translation_worker_config(Some(&serde_json::json!({
"translation_provider": "mock",
"provider_fixture": "/tmp/mock-provider.json",
"worker_concurrency": 8,
"worker_max_attempts": 4,
"worker_lease_seconds": 60,
"worker_retry_backoff_seconds": 0,
"worker_max_tasks": 2,
"translation_worker_id": "rpc-worker"
})))
.unwrap();
assert_eq!(config.provider, TranslationProviderKind::Mock);
assert_eq!(
config.fixture_path,
Some(PathBuf::from("/tmp/mock-provider.json"))
);
assert_eq!(config.concurrency, 8);
assert_eq!(config.max_attempts, 4);
assert_eq!(config.lease_seconds, 60);
assert_eq!(config.retry_backoff, Duration::ZERO);
assert_eq!(config.max_tasks, Some(2));
assert_eq!(config.worker_id, "rpc-worker");
assert!(rpc_translation_worker_config(Some(&serde_json::json!([]))).is_err());
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"provider": "bad"}))).is_err());
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"concurrency": "8"}))).is_err());
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"concurrency": -1}))).is_err());
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"concurrency": 0}))).is_err());
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"concurrency": 257}))).is_err());
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"max_attempts": 0}))).is_err());
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"lease_seconds": 0}))).is_err());
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"max_tasks": 0}))).is_err());
}
#[test]
fn grouped_workflow_commands_use_short_top_level_aliases() {
let options = parse(&[
+68 -26
View File
@@ -4,12 +4,14 @@ pub(super) const MAX_RETAINED_TASKS: usize = 64;
/// 每个任务保留的进度日志行数上限。
pub(super) const MAX_TASK_LOG_LINES: usize = 200;
/// 任务类型:目前覆盖官方同步、校验与 catalog 更新检查。
#[derive(Debug, Clone, Copy)]
/// 任务类型:覆盖资源同步、校验、修复、翻译 worker 与 catalog 更新检查。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum TaskKind {
Sync,
Verify,
Repair,
/// 翻译 provider worker 轮次。
TranslationWorker,
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
Refresh,
}
@@ -20,6 +22,7 @@ impl TaskKind {
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
Self::TranslationWorker => RPC_METHOD_TRANSLATION_WORKER_RUN,
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
}
}
@@ -56,6 +59,10 @@ impl TaskKind {
config.repair = false;
config.force = force;
}
Self::TranslationWorker => {
config.dry_run = false;
config.force = false;
}
}
config
}
@@ -155,6 +162,7 @@ fn task_kind_static(kind: &str) -> Option<&'static str> {
RPC_METHOD_RESOURCE_SYNC => Some(RPC_METHOD_RESOURCE_SYNC),
RPC_METHOD_RESOURCE_VERIFY => Some(RPC_METHOD_RESOURCE_VERIFY),
RPC_METHOD_RESOURCE_REPAIR => Some(RPC_METHOD_RESOURCE_REPAIR),
RPC_METHOD_TRANSLATION_WORKER_RUN => Some(RPC_METHOD_TRANSLATION_WORKER_RUN),
RPC_METHOD_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH),
_ => None,
}
@@ -494,7 +502,9 @@ impl TaskStore {
/// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。
pub(super) struct TaskJob {
pub(super) id: String,
pub(super) kind: TaskKind,
pub(super) config: OfficialUpdateConfig,
pub(super) translation_worker_config: Option<TranslationWorkerConfig>,
/// 与任务记录共享的取消标志。
pub(super) cancel: Arc<AtomicBool>,
}
@@ -528,36 +538,68 @@ pub(super) fn run_task_worker(
});
let cancel = Arc::clone(&job.cancel);
let run_result = {
let _sync_guard = sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let progress_registry = registry.clone();
let progress_id = job.id.clone();
let cancel_check = Arc::clone(&cancel);
let stop_control = Arc::clone(&control);
service.run_with_progress_and_cancellation(
&job.config,
|event| {
progress_registry
.append_log(&progress_id, format!("[{}] {}", event.stage, event.message));
progress_registry.update(&progress_id, |record| {
record.stage = Some(event.stage.to_string());
record.message = Some(event.message.clone());
});
},
|| {
cancel_check.load(Ordering::Relaxed)
|| daemon_control_stop_requested(Some(&stop_control))
},
)
let run_result = if job.kind == TaskKind::TranslationWorker {
let worker_config = job
.translation_worker_config
.as_ref()
.ok_or_else(|| anyhow::anyhow!("translation worker 任务缺少运行配置"));
worker_config.and_then(|worker_config| {
let _sync_guard = sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
registry.append_log(&job.id, "[translation-worker] 开始执行".to_string());
registry.update(&job.id, |record| {
record.stage = Some("translation-worker".to_string());
record.message = Some("翻译 provider worker 正在执行".to_string());
});
let resource_root = active_official_resource_root(&job.config.output_root)?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime
.block_on(bat_infrastructure::run_translation_worker_at(
&resource_root,
worker_config,
))
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from))
})
} else {
let run_result = {
let _sync_guard = sync_lock
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let progress_registry = registry.clone();
let progress_id = job.id.clone();
let cancel_check = Arc::clone(&cancel);
let stop_control = Arc::clone(&control);
service.run_with_progress_and_cancellation(
&job.config,
|event| {
progress_registry.append_log(
&progress_id,
format!("[{}] {}", event.stage, event.message),
);
progress_registry.update(&progress_id, |record| {
record.stage = Some(event.stage.to_string());
record.message = Some(event.message.clone());
});
},
|| {
cancel_check.load(Ordering::Relaxed)
|| daemon_control_stop_requested(Some(&stop_control))
},
)
};
run_result
.map(|report| serde_json::to_value(&report).map_err(anyhow::Error::from))
.and_then(|result| result)
};
match run_result {
Ok(report) => registry.update(&job.id, |record| {
record.status = "succeeded";
record.finished_at = Some(unix_seconds_now());
record.result = serde_json::to_value(&report).ok();
record.result = Some(report);
}),
Err(error) => {
let cancelled = cancel.load(Ordering::Relaxed);
@@ -253,6 +253,43 @@ pub(super) fn run_translation_proofread(options: &CliOptions) -> anyhow::Result<
print_report(options.output_format, &report)
}
pub(super) fn run_translation_worker(options: &CliOptions) -> anyhow::Result<()> {
let resource_root = options
.resource_root
.clone()
.map(|path| lexical_absolute(&path).map_err(anyhow::Error::msg))
.transpose()?
.unwrap_or(active_official_resource_root(&options.config.output_root)?);
let provider = options
.translation_provider
.as_deref()
.unwrap_or(TranslationProviderKind::Mock.as_str());
let provider = TranslationProviderKind::parse(provider)
.ok_or_else(|| anyhow::anyhow!("i18n worker run 的 provider 无效:{provider}"))?;
let config = TranslationWorkerConfig {
provider,
fixture_path: options.translation_fixture.clone(),
concurrency: options.worker_concurrency,
max_attempts: options.worker_max_attempts,
lease_seconds: options.worker_lease_seconds,
retry_backoff: options.worker_retry_backoff,
max_tasks: options.worker_max_tasks,
worker_id: options
.worker_id
.clone()
.unwrap_or_else(|| format!("bat-worker-{}", std::process::id())),
};
config.validate()?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let report = runtime.block_on(bat_infrastructure::run_translation_worker_at(
&resource_root,
&config,
))?;
print_report(options.output_format, &report)
}
pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> {
let spec = options
.repack_spec