fix: 拆分 bat 控制面

This commit is contained in:
2026-08-03 00:43:11 +08:00
parent 2b053e247d
commit 9a5b3ba39b
10 changed files with 12083 additions and 12043 deletions
+7 -1
View File
@@ -8,7 +8,13 @@ BlueArchive Toolkit 采用 **Monorepo + 多语言混合** 架构,旨在构建
当前已经可用的官方资源入口包括:
- `infrastructure/src/bin/bat_official_sync.rs`Linux 官方资源同步正式入口,构建为 `bat`,支持 one-shot、`--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `infrastructure/src/bin/bat_official_sync.rs`Linux 官方资源同步入口,构建为 `bat`;控制面组合与实现位于 `infrastructure/src/bin/bat/`,支持 one-shot、`--watch``--daemon``status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- `infrastructure/src/bin/bat/app.rs`CLI/env、daemon/watch、RPC dispatch、状态输出与顶层流程组合。
- `infrastructure/src/bin/bat/task_registry.rs`:任务注册表、任务持久化、取消和 daemon worker。
- `infrastructure/src/bin/bat/readonly_query.rs`parse/resource/translation/localized 只读查询及 RPC 选择。
- `infrastructure/src/bin/bat/translation_query.rs`:翻译任务与 handoff 查询、worker 状态更新。
- `infrastructure/src/bin/bat/patch_commands.rs`:文件 patch 与 UnityFS 写入命令参数校验和执行。
- `infrastructure/src/bin/bat/app_tests.rs`:控制面回归测试,避免测试代码继续堆积在入口实现中。
- `infrastructure/src/official_update.rs`:官方自动更新核心服务,负责 auto-discover、snapshot、marker diff、本地 audit/repair。
- `infrastructure/examples/official_pull_plan.rs`:开发/审计用 pull plan 入口。
- `infrastructure/examples/official_update_check.rs`:历史/开发入口,生产优先使用 `bat`
@@ -246,7 +246,11 @@ release 根目录写入 `translation-tasks.sqlite`,由版本化 `schema_migrat
对应实现主要在:
- `infrastructure/src/official_update.rs`
- `infrastructure/src/bin/bat_official_sync.rs`
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口)
- `infrastructure/src/bin/bat/app.rs`(控制面组合)
- `infrastructure/src/bin/bat/task_registry.rs`(任务注册表、持久化和 worker)
- `infrastructure/src/bin/bat/readonly_query.rs``translation_query.rs`(只读查询)
- `infrastructure/src/bin/bat/patch_commands.rs`patch 命令)
- `infrastructure/examples/official_update_check.rs`(历史/开发入口)
## 4. 官方 bootstrap 与用户流程
+1 -1
View File
@@ -356,7 +356,7 @@ make official-smoke
- `infrastructure/examples/official_launcher_bootstrap.rs`
- `infrastructure/examples/official_pull_plan.rs`
- `infrastructure/src/bin/bat_official_sync.rs`
- `infrastructure/src/bin/bat_official_sync.rs`(薄入口;控制面实现位于同目录 `bat/`
- `infrastructure/examples/official_update_check.rs`(历史/开发入口;生产优先使用 `bat`
- `adapters/examples/yostar_jp_client_bootstrap.rs`
- `adapters/examples/yostar_jp_discovery.rs`
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,199 @@
pub(super) fn run_write_patch_command(options: &CliOptions) -> anyhow::Result<()> {
match options.command {
CliCommand::PatchApply => {
let params = patch_apply_params_from_options(options)?;
let report = apply_patch_file(&params)?;
print_report(options.output_format, &report)
}
CliCommand::UnityFsPatchTextAsset => {
let params = unityfs_text_asset_params_from_options(options)?;
let report = apply_unityfs_text_asset_patch_file(&params)?;
print_report(options.output_format, &report)
}
CliCommand::UnityFsPatchStringField => {
let params = unityfs_string_field_params_from_options(options)?;
let report = apply_unityfs_string_field_patch_file(&params)?;
print_report(options.output_format, &report)
}
CliCommand::UnityFsPatchField => {
let params = unityfs_field_params_from_options(options)?;
let report = apply_unityfs_field_patch_file(&params)?;
print_report(options.output_format, &report)
}
_ => Err(anyhow::anyhow!("不是写入 patch 命令")),
}
}
pub(super) fn is_write_patch_command(command: CliCommand) -> bool {
matches!(
command,
CliCommand::PatchApply
| CliCommand::UnityFsPatchTextAsset
| CliCommand::UnityFsPatchStringField
| CliCommand::UnityFsPatchField
)
}
pub(super) fn validate_write_patch_options(options: &CliOptions) -> anyhow::Result<()> {
match options.command {
CliCommand::PatchApply => {
let _ = patch_apply_params_from_options(options)?;
reject_unityfs_write_options(options, "patch-apply")?;
}
CliCommand::UnityFsPatchTextAsset => {
let _ = unityfs_text_asset_params_from_options(options)?;
reject_patch_apply_options(options, "unityfs-patch-text-asset")?;
if options.unityfs_field_path.is_some()
|| options.unityfs_replacement_text.is_some()
|| options.unityfs_expected_value.is_some()
{
return Err(anyhow::anyhow!(
"unityfs-patch-text-asset 不接受 --field-path、--string-field-path、--replacement-text 或 --expected-value"
));
}
}
CliCommand::UnityFsPatchStringField => {
let _ = unityfs_string_field_params_from_options(options)?;
reject_patch_apply_options(options, "unityfs-patch-string-field")?;
if options.unityfs_expected_name.is_some() {
return Err(anyhow::anyhow!(
"unityfs-patch-string-field 不接受 --expected-name"
));
}
}
CliCommand::UnityFsPatchField => {
let _ = unityfs_field_params_from_options(options)?;
reject_patch_apply_options(options, "unityfs-patch-field")?;
if options.unityfs_expected_name.is_some()
|| options.unityfs_replacement_text.is_some()
|| options.unityfs_expected_value.is_some()
{
return Err(anyhow::anyhow!(
"unityfs-patch-field 不接受 --expected-name、--replacement-text 或 --expected-value;请使用 --replacement-json / --expected-json"
));
}
}
_ => {}
}
Ok(())
}
fn patch_apply_params_from_options(options: &CliOptions) -> anyhow::Result<PatchApplyParams> {
Ok(PatchApplyParams {
kind: require_cli_option(options.patch_kind, "--patch-kind")?,
source_path: require_cli_option(options.patch_source_path.clone(), "--source-file")?,
patch_path: require_cli_option(options.patch_patch_path.clone(), "--patch-file")?,
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
})
}
fn unityfs_text_asset_params_from_options(
options: &CliOptions,
) -> anyhow::Result<UnityFsTextAssetPatchParams> {
Ok(UnityFsTextAssetPatchParams {
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
serialized_file_path: require_cli_option(
options.unityfs_serialized_file_path.clone(),
"--serialized-file",
)?,
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
replacement_path: require_cli_option(
options.unityfs_replacement_path.clone(),
"--replacement-file",
)?,
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
expected_name: options.unityfs_expected_name.clone(),
})
}
fn unityfs_string_field_params_from_options(
options: &CliOptions,
) -> anyhow::Result<UnityFsStringFieldPatchParams> {
let has_replacement_text = options.unityfs_replacement_text.is_some();
let has_replacement_path = options.unityfs_replacement_path.is_some();
if has_replacement_text == has_replacement_path {
return Err(anyhow::anyhow!(
"unityfs-patch-string-field 必须且只能指定 --replacement-text 或 --replacement-file 其中一个"
));
}
Ok(UnityFsStringFieldPatchParams {
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
serialized_file_path: require_cli_option(
options.unityfs_serialized_file_path.clone(),
"--serialized-file",
)?,
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
field_path: require_cli_option(
options.unityfs_field_path.clone(),
"--field-path/--string-field-path",
)?,
replacement_text: options.unityfs_replacement_text.clone(),
replacement_path: options.unityfs_replacement_path.clone(),
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
expected_value: options.unityfs_expected_value.clone(),
})
}
fn unityfs_field_params_from_options(
options: &CliOptions,
) -> anyhow::Result<UnityFsFieldPatchParams> {
if options.unityfs_replacement_path.is_some() {
return Err(anyhow::anyhow!(
"unityfs-patch-field 不接受 --replacement-file;请使用 --replacement-json"
));
}
Ok(UnityFsFieldPatchParams {
bundle_path: require_cli_option(options.unityfs_bundle_path.clone(), "--bundle-file")?,
serialized_file_path: require_cli_option(
options.unityfs_serialized_file_path.clone(),
"--serialized-file",
)?,
path_id: require_cli_option(options.unityfs_path_id, "--object-path-id")?,
field_path: require_cli_option(
options.unityfs_field_path.clone(),
"--field-path/--string-field-path",
)?,
replacement: require_cli_option(
options.unityfs_replacement_value.clone(),
"--replacement-json",
)?,
target_path: require_cli_option(options.patch_target_path.clone(), "--target-file")?,
expected_value: options.unityfs_expected_semantic_value.clone(),
})
}
fn require_cli_option<T>(value: Option<T>, name: &str) -> anyhow::Result<T> {
value.ok_or_else(|| anyhow::anyhow!("缺少必要参数 {name}"))
}
fn reject_patch_apply_options(options: &CliOptions, command: &str) -> anyhow::Result<()> {
if options.patch_kind.is_some()
|| options.patch_source_path.is_some()
|| options.patch_patch_path.is_some()
{
return Err(anyhow::anyhow!(
"{command} 不接受 --patch-kind、--source-file 或 --patch-file"
));
}
Ok(())
}
fn reject_unityfs_write_options(options: &CliOptions, command: &str) -> anyhow::Result<()> {
if options.unityfs_bundle_path.is_some()
|| options.unityfs_serialized_file_path.is_some()
|| options.unityfs_path_id.is_some()
|| options.unityfs_field_path.is_some()
|| options.unityfs_replacement_path.is_some()
|| options.unityfs_replacement_text.is_some()
|| options.unityfs_expected_name.is_some()
|| options.unityfs_expected_value.is_some()
|| options.unityfs_replacement_value.is_some()
|| options.unityfs_expected_semantic_value.is_some()
{
return Err(anyhow::anyhow!(
"{command} 不接受 UnityFS 写入参数;请改用 unityfs-patch-* 命令"
));
}
Ok(())
}
use super::*;
@@ -0,0 +1,271 @@
use super::*;
pub(super) fn run_readonly_query_command(options: &CliOptions) -> anyhow::Result<()> {
run_readonly_query_command_with_rpc(options, daemon_rpc_available, daemon_rpc_call)
}
pub(super) fn run_readonly_query_command_with_rpc(
options: &CliOptions,
rpc_available: impl Fn(&Path) -> bool,
rpc_call: impl Fn(&Path, &str, Option<serde_json::Value>) -> anyhow::Result<serde_json::Value>,
) -> anyhow::Result<()> {
let method = readonly_query_rpc_method(options.command)
.ok_or_else(|| anyhow::anyhow!("不是只读查询命令"))?;
if rpc_available(&options.state_dir) && !readonly_query_requires_local_config(options) {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let report = rpc_call(
&options.state_dir,
method,
readonly_query_rpc_params(options),
)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
let report = build_readonly_query_report(options, method)?;
print_json_value(options.output_format, &report)
}
fn readonly_query_rpc_method(command: CliCommand) -> Option<&'static str> {
match command {
CliCommand::ParseStatus => Some(RPC_METHOD_PARSE_STATUS),
CliCommand::ParseTextUnits => Some(RPC_METHOD_PARSE_TEXT_UNITS),
CliCommand::ParseErrors => Some(RPC_METHOD_PARSE_ERRORS),
CliCommand::TranslationTasks => Some(RPC_METHOD_TRANSLATION_TASKS),
CliCommand::TranslationHandoff => Some(RPC_METHOD_TRANSLATION_HANDOFF),
CliCommand::LocalizedStatus => Some(RPC_METHOD_LOCALIZED_STATUS),
CliCommand::ResourceIndex => Some(RPC_METHOD_RESOURCE_INDEX),
_ => None,
}
}
fn readonly_query_rpc_params(options: &CliOptions) -> Option<serde_json::Value> {
let mut params = serde_json::Map::new();
match options.command {
CliCommand::ResourceIndex
| CliCommand::ParseTextUnits
| CliCommand::ParseErrors
| CliCommand::TranslationTasks => {
params.insert(
"offset".to_string(),
serde_json::json!(options.query_offset),
);
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
}
_ => return None,
}
match options.command {
CliCommand::ResourceIndex => {
if let Some(resource_type) = options.query_resource_type {
params.insert(
"resource_type".to_string(),
serde_json::json!(resource_type_rpc_label(resource_type)),
);
}
if let Some(hash) = options.query_hash.as_ref() {
params.insert("hash".to_string(), serde_json::json!(hash));
}
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
}
if let Some(release_id) = options.query_official_release_id.as_ref() {
params.insert(
"official_release_id".to_string(),
serde_json::json!(release_id),
);
}
if let Some(platform) = options.query_platform.as_ref() {
params.insert("platform".to_string(), serde_json::json!(platform));
}
if let Some(destination) = options.query_destination.as_ref() {
params.insert("destination".to_string(), serde_json::json!(destination));
}
if let Some(bundle_path) = options.query_bundle_path.as_ref() {
params.insert("bundle_path".to_string(), serde_json::json!(bundle_path));
}
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
params.insert(
"archive_entry".to_string(),
serde_json::json!(archive_entry),
);
}
if let Some(parse_status) = options.query_parse_status.as_ref() {
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
}
if let Some(format) = options.query_format.as_ref() {
params.insert("text_unit_format".to_string(), serde_json::json!(format));
}
}
CliCommand::ParseTextUnits | CliCommand::ParseErrors => {
if let Some(destination) = options.query_destination.as_ref() {
params.insert("destination".to_string(), serde_json::json!(destination));
}
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
}
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
params.insert(
"archive_entry".to_string(),
serde_json::json!(archive_entry),
);
}
if let Some(path_id) = options.query_path_id {
params.insert("path_id".to_string(), serde_json::json!(path_id));
}
if let Some(class_id) = options.query_class_id {
params.insert("class_id".to_string(), serde_json::json!(class_id));
}
if let Some(field_path) = options.query_field_path.as_ref() {
params.insert("field_path".to_string(), serde_json::json!(field_path));
}
if let Some(format) = options.query_format.as_ref() {
params.insert("format".to_string(), serde_json::json!(format));
}
}
CliCommand::TranslationTasks => {
if let Some(task_id) = options.query_task_id.as_ref() {
params.insert("task_id".to_string(), serde_json::json!(task_id));
}
if let Some(release_id) = options.query_official_release_id.as_ref() {
params.insert(
"official_release_id".to_string(),
serde_json::json!(release_id),
);
}
if let Some(destination) = options.query_destination.as_ref() {
params.insert("destination".to_string(), serde_json::json!(destination));
}
if let Some(path_pattern) = options.query_path_pattern.as_ref() {
params.insert("path_pattern".to_string(), serde_json::json!(path_pattern));
}
if let Some(archive_entry) = options.query_archive_entry.as_ref() {
params.insert(
"archive_entry".to_string(),
serde_json::json!(archive_entry),
);
}
if let Some(status) = options.query_task_status.as_ref() {
params.insert("status".to_string(), serde_json::json!(status));
}
if let Some(status) = options.query_worker_status.as_ref() {
params.insert("worker_status".to_string(), serde_json::json!(status));
}
if let Some(parse_status) = options.query_parse_status.as_ref() {
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
}
if let Some(format) = options.query_format.as_ref() {
params.insert("text_unit_format".to_string(), serde_json::json!(format));
}
if let Some(has_reason) = options.query_has_reason {
params.insert("has_reason".to_string(), serde_json::json!(has_reason));
}
if let Some(has_failure_reason) = options.query_has_failure_reason {
params.insert(
"has_failure_reason".to_string(),
serde_json::json!(has_failure_reason),
);
}
}
_ => {}
}
Some(serde_json::Value::Object(params))
}
fn readonly_query_requires_local_config(options: &CliOptions) -> bool {
matches!(options.command, CliCommand::ResourceIndex)
&& options.config.import_resource_repository_path.is_some()
}
fn build_readonly_query_report(
options: &CliOptions,
method: &str,
) -> anyhow::Result<serde_json::Value> {
match method {
RPC_METHOD_PARSE_STATUS => build_parse_status_report(&options.state_dir),
RPC_METHOD_PARSE_TEXT_UNITS => build_parse_text_units_report(
&options.state_dir,
textunit_query_from_options(options),
options.query_offset,
options.query_limit,
),
RPC_METHOD_PARSE_ERRORS => build_parse_errors_report(
&options.state_dir,
textunit_query_from_options(options),
options.query_offset,
options.query_limit,
),
RPC_METHOD_TRANSLATION_TASKS => build_translation_tasks_report(
&options.state_dir,
translation_task_query_from_options(options),
options.query_offset,
options.query_limit,
),
RPC_METHOD_TRANSLATION_HANDOFF => build_translation_handoff_report(&options.state_dir),
RPC_METHOD_LOCALIZED_STATUS => {
build_localized_status_report(&options.state_dir, &options.config)
}
RPC_METHOD_RESOURCE_INDEX => build_resource_index_report(
&options.state_dir,
&options.config,
resource_index_query_from_options(options),
options.query_offset,
options.query_limit,
),
_ => Err(anyhow::anyhow!("不支持的只读查询方法:{method}")),
}
}
pub(super) fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> {
let has_resource_index_only_filter = options.query_resource_type.is_some()
|| options.query_hash.is_some()
|| options.query_platform.is_some()
|| options.query_bundle_path.is_some();
let has_parse_object_filter = options.query_path_id.is_some()
|| options.query_class_id.is_some()
|| options.query_field_path.is_some();
let has_translation_task_filter = options.query_task_id.is_some()
|| options.query_task_status.is_some()
|| options.query_worker_status.is_some()
|| options.query_has_reason.is_some()
|| options.query_has_failure_reason.is_some();
match options.command {
CliCommand::ResourceIndex => {
if has_parse_object_filter || has_translation_task_filter {
return Err(anyhow::anyhow!(
"--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
));
}
}
CliCommand::ParseTextUnits | CliCommand::ParseErrors => {
if has_resource_index_only_filter
|| has_translation_task_filter
|| options.query_official_release_id.is_some()
|| options.query_parse_status.is_some()
{
return Err(anyhow::anyhow!(
"--resource-type/--hash/--release-id/--platform/--bundle-path/--parse-status 只适用于 resource-index 或 translation-tasks--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
));
}
}
CliCommand::TranslationTasks => {
if has_resource_index_only_filter || has_parse_object_filter {
return Err(anyhow::anyhow!(
"--resource-type/--hash/--platform/--bundle-path 只适用于 resource-index--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors"
));
}
}
CliCommand::TranslationHandoff if options.query_option_explicit => {
return Err(anyhow::anyhow!(
"translation-handoff 不接受查询过滤参数;请使用 translation-tasks 查询单项任务"
));
}
CliCommand::ParseStatus | CliCommand::LocalizedStatus if options.query_option_explicit => {
return Err(anyhow::anyhow!(
"查询过滤参数只适用于 resource-index、parse-text-units、parse-errors 或 translation-tasks"
));
}
_ => {}
}
Ok(())
}
+587
View File
@@ -0,0 +1,587 @@
use super::*;
pub(super) const MAX_RETAINED_TASKS: usize = 64;
/// 每个任务保留的进度日志行数上限。
pub(super) const MAX_TASK_LOG_LINES: usize = 200;
/// 任务类型:目前覆盖官方同步、校验与 catalog 更新检查。
#[derive(Debug, Clone, Copy)]
pub(super) enum TaskKind {
Sync,
Verify,
Repair,
/// catalog 更新检查:只做发现 + 拉取计划(dry-run),不下载不审计。
Refresh,
}
impl TaskKind {
pub(super) fn method(self) -> &'static str {
match self {
Self::Sync => RPC_METHOD_RESOURCE_SYNC,
Self::Verify => RPC_METHOD_RESOURCE_VERIFY,
Self::Repair => RPC_METHOD_RESOURCE_REPAIR,
Self::Refresh => RPC_METHOD_CATALOG_REFRESH,
}
}
/// 由 daemon 基准配置派生该任务的实际同步配置。
pub(super) fn build_config(
self,
base: &OfficialUpdateConfig,
force: bool,
) -> OfficialUpdateConfig {
let mut config = base.clone();
match self {
Self::Sync => {
config.dry_run = false;
config.force = config.force || force;
}
Self::Verify => {
config.dry_run = true;
config.plan = true;
config.audit_local = true;
config.repair = false;
config.force = false;
}
Self::Repair => {
config.dry_run = false;
config.audit_local = true;
config.repair = true;
config.force = false;
}
Self::Refresh => {
config.dry_run = true;
config.plan = true;
config.audit_local = false;
config.repair = false;
config.force = force;
}
}
config
}
}
/// 请求取消任务的结果。
pub(super) enum CancelOutcome {
Requested,
AlreadyFinished,
NotFound,
}
/// 单个任务的可轮询记录。
#[derive(Debug, Clone, Serialize)]
pub(super) struct TaskRecord {
pub(super) id: String,
pub(super) kind: &'static str,
/// `queued` | `running` | `succeeded` | `failed` | `cancelled`。
pub(super) status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) stage: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) message: Option<String>,
pub(super) created_at: u64,
pub(super) updated_at: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) started_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) finished_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) error: Option<ApiError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) result: Option<serde_json::Value>,
/// 取消标志,worker 的 should_cancel 检查它;不参与序列化。
#[serde(skip)]
pub(super) cancel: Arc<AtomicBool>,
/// 进度日志(有界),经 task.logs 返回;不参与 task.status 序列化。
#[serde(skip)]
pub(super) log: Vec<String>,
}
impl TaskRecord {
pub(super) fn is_finished(&self) -> bool {
matches!(self.status, "succeeded" | "failed" | "cancelled")
}
}
struct TaskStore {
tasks: HashMap<String, TaskRecord>,
order: Vec<String>,
seq: u64,
/// 任务历史持久化文件路径;`None` 表示纯内存(测试等非 daemon 场景)。
persist_path: Option<PathBuf>,
}
/// daemon 任务历史持久化文件名(位于 state dir 内,`0600` 原子写)。
pub(super) const TASKS_FILE_NAME: &str = "bat-tasks.json";
/// 任务历史文件结构版本。
pub(super) const TASKS_FILE_VERSION: u32 = 1;
/// 任务历史文件的持久化形态(版本化;daemon 重启后恢复任务历史用)。
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct PersistedTaskFile {
pub(super) version: u32,
/// 任务 ID 序号计数器;恢复它避免 pid 复用时新任务与历史任务撞 ID。
pub(super) seq: u64,
pub(super) tasks: Vec<PersistedTaskRecord>,
}
#[derive(Debug, Serialize, Deserialize)]
pub(super) struct PersistedTaskRecord {
id: String,
kind: String,
pub(super) status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
stage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
message: Option<String>,
created_at: u64,
updated_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
started_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
finished_at: Option<u64>,
/// `ApiError` 的序列化形态(code/kind/domain/location/message/retryable)。
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
log: Vec<String>,
}
/// 把持久化的任务类型映射回静态字符串;未识别(如未来版本新增)返回 `None`。
fn task_kind_static(kind: &str) -> Option<&'static str> {
match kind {
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_CATALOG_REFRESH => Some(RPC_METHOD_CATALOG_REFRESH),
_ => None,
}
}
/// 把持久化的任务状态映射回静态字符串;未识别返回 `None`。
fn task_status_static(status: &str) -> Option<&'static str> {
match status {
"queued" => Some("queued"),
"running" => Some("running"),
"succeeded" => Some("succeeded"),
"failed" => Some("failed"),
"cancelled" => Some("cancelled"),
_ => None,
}
}
impl PersistedTaskRecord {
fn from_record(record: &TaskRecord) -> Self {
Self {
id: record.id.clone(),
kind: record.kind.to_string(),
status: record.status.to_string(),
stage: record.stage.clone(),
message: record.message.clone(),
created_at: record.created_at,
updated_at: record.updated_at,
started_at: record.started_at,
finished_at: record.finished_at,
error: record
.error
.as_ref()
.and_then(|error| serde_json::to_value(error).ok()),
result: record.result.clone(),
log: record.log.clone(),
}
}
/// 还原为内存任务记录;kind/status 未识别时返回 `None`(调用方计数跳过)。
fn into_record(self) -> Option<TaskRecord> {
let kind = task_kind_static(&self.kind)?;
let status = task_status_static(&self.status)?;
// 错误从序列化形态还原:code 经码表反查(未登记回退 internal),
// location 固定为任务执行器(当前全部任务错误的唯一来源)。
let error = self.error.as_ref().map(|value| {
let code = value
.get("code")
.and_then(serde_json::Value::as_str)
.and_then(ErrorCode::from_id)
.unwrap_or(ErrorCode::INTERNAL);
let message = value
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("<持久化错误信息缺失>")
.to_string();
ApiError::new(code, "task.executor", message)
});
Some(TaskRecord {
id: self.id,
kind,
status,
stage: self.stage,
message: self.message,
created_at: self.created_at,
updated_at: self.updated_at,
started_at: self.started_at,
finished_at: self.finished_at,
error,
result: self.result,
cancel: Arc::new(AtomicBool::new(false)),
log: self.log,
})
}
}
/// 读取任务历史文件。文件缺失返回 `Ok(None)`;symlink、解析失败或版本不支持返回 `Err`。
fn load_persisted_tasks(path: &Path) -> Result<Option<PersistedTaskFile>, String> {
let Some(bytes) = read_file_no_symlink(path, "任务历史")? else {
return Ok(None);
};
let file: PersistedTaskFile = serde_json::from_slice(&bytes)
.map_err(|error| format!("解析任务历史失败 {}{error}", path.display()))?;
if file.version != TASKS_FILE_VERSION {
return Err(format!(
"不支持的任务历史版本 {},文件 {}",
file.version,
path.display()
));
}
Ok(Some(file))
}
/// 任务注册表句柄:包住内存存储,供 RPC handler 与 worker 共享。
///
/// 通过方法访问(而非直接摸内部 map),便于将来换成 Redis 等持久化后端。
#[derive(Clone)]
pub(super) struct TaskRegistry {
inner: Arc<Mutex<TaskStore>>,
}
impl TaskRegistry {
/// 纯内存注册表(无持久化);生产 daemon 走 [`Self::with_persistence`]。
#[cfg(test)]
pub(super) fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(TaskStore {
tasks: HashMap::new(),
order: Vec::new(),
seq: 0,
persist_path: None,
})),
}
}
/// 从 state dir 恢复任务历史并启用持久化。
///
/// 中断时仍处于 queued/running 的任务标记为 `failed``TASK_INTERRUPTED`);
/// 文件缺失按空历史处理;文件损坏或版本不支持时改名 `.corrupt` 留证并从
/// 空历史开始。返回注册表与恢复摘要(供 daemon 日志记录)。
pub(super) fn with_persistence(state_dir: &Path) -> (Self, String) {
let path = state_dir.join(TASKS_FILE_NAME);
let now = unix_seconds_now();
let mut seq = 0;
let mut tasks = HashMap::new();
let mut order = Vec::new();
let summary = match load_persisted_tasks(&path) {
Ok(None) => "无历史任务文件,从空任务历史开始".to_string(),
Ok(Some(file)) => {
seq = file.seq;
let total = file.tasks.len();
let mut interrupted = 0usize;
let mut skipped = 0usize;
for persisted in file.tasks {
let Some(mut record) = persisted.into_record() else {
skipped += 1;
continue;
};
if !record.is_finished() {
interrupted += 1;
record.status = "failed";
record.finished_at = Some(now);
record.updated_at = now;
record.error = Some(ApiError::new(
ErrorCode::TASK_INTERRUPTED,
"task.executor",
"daemon 停止/重启导致任务中断",
));
record
.log
.push("[daemon] 任务因 daemon 停止/重启而中断".to_string());
}
if tasks.insert(record.id.clone(), record.clone()).is_none() {
order.push(record.id);
} else {
skipped += 1;
}
}
format!("恢复任务历史 {total} 条(标记中断 {interrupted} 条,跳过无法识别 {skipped} 条)")
}
Err(error) => {
// 保留损坏文件供诊断(改名而非覆盖),从空历史开始。
let corrupt = path.with_extension("json.corrupt");
if fs::rename(&path, &corrupt).is_ok() {
format!(
"任务历史不可用({error});原文件已改名保留为 {}",
corrupt.display()
)
} else {
format!("任务历史不可用({error});且无法改名保留原文件")
}
}
};
let registry = Self {
inner: Arc::new(Mutex::new(TaskStore {
tasks,
order,
seq,
persist_path: Some(path),
})),
};
// 把中断标记(或空历史)立即写回,保证文件与内存视图一致。
registry.lock().persist();
(registry, summary)
}
fn lock(&self) -> std::sync::MutexGuard<'_, TaskStore> {
self.inner
.lock()
.unwrap_or_else(|poison| poison.into_inner())
}
/// 创建 queued 任务并返回 task_id。
pub(super) fn create(&self, kind: TaskKind) -> String {
let now = unix_seconds_now();
let mut store = self.lock();
store.seq += 1;
let id = format!("task-{}-{}", std::process::id(), store.seq);
let record = TaskRecord {
id: id.clone(),
kind: kind.method(),
status: "queued",
stage: None,
message: None,
created_at: now,
updated_at: now,
started_at: None,
finished_at: None,
error: None,
result: None,
cancel: Arc::new(AtomicBool::new(false)),
log: Vec::new(),
};
store.tasks.insert(id.clone(), record);
store.order.push(id.clone());
store.prune();
store.persist();
id
}
pub(super) fn update<F: FnOnce(&mut TaskRecord)>(&self, id: &str, update: F) {
let mut store = self.lock();
let mut status_changed = false;
if let Some(record) = store.tasks.get_mut(id) {
let previous_status = record.status;
update(record);
record.updated_at = unix_seconds_now();
status_changed = record.status != previous_status;
}
// 只在生命周期转换时落盘;stage/message/log 的高频进度更新以内存为准,
// 随下一次转换一起写入(避免每个进度事件一次磁盘写)。
if status_changed {
store.persist();
}
}
pub(super) fn get(&self, id: &str) -> Option<TaskRecord> {
self.lock().tasks.get(id).cloned()
}
/// 返回任务的取消标志(与 worker 共享同一 Arc)。
pub(super) fn cancel_flag(&self, id: &str) -> Option<Arc<AtomicBool>> {
self.lock()
.tasks
.get(id)
.map(|record| Arc::clone(&record.cancel))
}
/// 追加一行进度日志,超出上限时丢弃最旧的。
pub(super) fn append_log(&self, id: &str, line: String) {
let mut store = self.lock();
if let Some(record) = store.tasks.get_mut(id) {
record.log.push(line);
if record.log.len() > MAX_TASK_LOG_LINES {
let overflow = record.log.len() - MAX_TASK_LOG_LINES;
record.log.drain(0..overflow);
}
}
}
/// 返回任务的进度日志。
pub(super) fn logs(&self, id: &str) -> Option<Vec<String>> {
self.lock().tasks.get(id).map(|record| record.log.clone())
}
/// 请求取消任务:未结束的置取消标志,已结束的原样返回,不存在返回 NotFound。
pub(super) fn request_cancel(&self, id: &str) -> CancelOutcome {
let store = self.lock();
match store.tasks.get(id) {
None => CancelOutcome::NotFound,
Some(record) if record.is_finished() => CancelOutcome::AlreadyFinished,
Some(record) => {
record.cancel.store(true, Ordering::Relaxed);
CancelOutcome::Requested
}
}
}
/// 返回全部任务,最新创建的在前。
pub(super) fn list(&self) -> Vec<TaskRecord> {
let store = self.lock();
store
.order
.iter()
.rev()
.filter_map(|id| store.tasks.get(id).cloned())
.collect()
}
}
impl TaskStore {
/// 把当前任务历史落盘(`0600` 原子写、不跟随 symlink)。
///
/// 持久化未启用时为 no-op;写失败只记 stderr(进 daemon 日志),
/// 不让持久化故障拖垮任务执行本身。
fn persist(&self) {
let Some(path) = &self.persist_path else {
return;
};
let file = PersistedTaskFile {
version: TASKS_FILE_VERSION,
seq: self.seq,
tasks: self
.order
.iter()
.filter_map(|id| self.tasks.get(id))
.map(PersistedTaskRecord::from_record)
.collect(),
};
match serde_json::to_vec_pretty(&file) {
Ok(bytes) => {
if let Err(error) = write_file_atomic(path, &bytes, PRIVATE_FILE_MODE, "任务历史")
{
eprintln!("[daemon] 任务历史落盘失败:{error}");
}
}
Err(error) => eprintln!("[daemon] 任务历史序列化失败:{error}"),
}
}
/// 裁剪最旧的已结束任务,把内存占用控制在上限内;运行中/排队中的任务不裁剪。
fn prune(&mut self) {
while self.order.len() > MAX_RETAINED_TASKS {
let Some(position) = self.order.iter().position(|id| {
self.tasks
.get(id)
.map(TaskRecord::is_finished)
.unwrap_or(true)
}) else {
break;
};
let id = self.order.remove(position);
self.tasks.remove(&id);
}
}
}
/// 提交给任务 worker 的作业(配置已按任务类型派生完毕)。
pub(super) struct TaskJob {
pub(super) id: String,
pub(super) config: OfficialUpdateConfig,
/// 与任务记录共享的取消标志。
pub(super) cancel: Arc<AtomicBool>,
}
/// daemon 任务上下文:RPC handler 借它创建任务、入队和读取。
#[derive(Clone)]
pub(super) struct DaemonTaskContext {
pub(super) registry: TaskRegistry,
pub(super) queue: mpsc::Sender<TaskJob>,
pub(super) base_config: OfficialUpdateConfig,
pub(super) restart_controller: DaemonRestartController,
}
pub(super) type DaemonRestartController = fn(&Path) -> anyhow::Result<u32>;
/// 任务 worker:单线程 FIFO 消费任务队列,串行执行官方同步/校验。
///
/// 每个任务执行前获取进程内 `sync_lock`,与 watch 循环互斥(等待而非撞文件锁失败);
/// 进度写入任务记录;`should_cancel` 接 daemon 停止标志,停机时中止在途任务。
pub(super) fn run_task_worker(
receiver: mpsc::Receiver<TaskJob>,
registry: TaskRegistry,
sync_lock: Arc<Mutex<()>>,
control: DaemonControl,
) {
let service = OfficialUpdateService::new();
for job in receiver {
registry.update(&job.id, |record| {
record.status = "running";
record.started_at = Some(unix_seconds_now());
});
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))
},
)
};
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();
}),
Err(error) => {
let cancelled = cancel.load(Ordering::Relaxed);
// 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。
let code = error
.downcast_ref::<bat_infrastructure::DownloadError>()
.map(bat_infrastructure::DownloadError::code)
.unwrap_or(ErrorCode::INTERNAL);
registry.update(&job.id, |record| {
record.finished_at = Some(unix_seconds_now());
if cancelled {
record.status = "cancelled";
record.error = Some(ApiError::new(
ErrorCode::INTERNAL,
"task.executor",
"任务已取消",
));
} else {
record.status = "failed";
record.error =
Some(ApiError::new(code, "task.executor", error.to_string()));
}
});
}
}
}
}
@@ -0,0 +1,232 @@
use super::*;
pub(super) fn build_translation_tasks_report(
state_dir: &Path,
query: OfficialTextUnitTaskQuery,
offset: usize,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let Some(record) = current else {
return Ok(serde_json::json!({ "available": false }));
};
let task_queue_path = record
.resource_root
.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
let task_repository_path =
SqliteTranslationTaskRepository::repository_path(&record.resource_root);
let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(&record.resource_root)
.map_err(anyhow::Error::msg)?
else {
return Ok(serde_json::json!({
"available": false,
"current_version_id": record.id,
"resource_root": record.resource_root,
"textunit_task_queue_path": task_queue_path,
"task_repository_path": task_repository_path,
"task_repository_available": false,
}));
};
let task_repository_available =
sqlite_file_exists_no_symlink(&task_repository_path, "翻译任务状态数据库")?;
let (total_entries, entries) = if task_repository_available {
query_translation_task_repository(&task_repository_path, &query, offset, limit)?
} else {
let mut queue_query = query.clone();
queue_query.task_status = None;
queue_query.has_failure_reason = None;
let matches = bat_infrastructure::query_textunit_tasks(&queue, &queue_query);
let persisted = matches
.into_iter()
.cloned()
.map(|task| {
bat_infrastructure::PersistedTranslationTask::from_queued_task(
task,
queue.generated_unix_seconds,
)
})
.filter(|task| {
query
.task_status
.as_ref()
.is_none_or(|status| task.task_status.as_str() == status)
})
.filter(|task| {
query
.has_failure_reason
.is_none_or(|has_reason| task.failure_reason.is_some() == has_reason)
})
.collect::<Vec<_>>();
let total_entries = persisted.len();
let entries = persisted
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
(total_entries as u64, entries)
};
Ok(serde_json::json!({
"available": true,
"current_version_id": record.id,
"resource_root": record.resource_root,
"textunit_task_queue_path": task_queue_path,
"task_repository_path": task_repository_path,
"task_repository_available": task_repository_available,
"task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION,
"summary": queue.summary,
"total_entries": total_entries,
"offset": offset,
"limit": limit,
"query": translation_task_query_json(&query),
"entries": entries,
}))
}
pub(super) fn build_translation_handoff_report(
state_dir: &Path,
) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let Some(record) = current else {
return Ok(serde_json::json!({ "available": false }));
};
let resource_root = &record.resource_root;
let task_queue_path = resource_root.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
let handoff_path = resource_root.join(bat_infrastructure::TRANSLATION_HANDOFF_FILE);
let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root);
let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(resource_root)
.map_err(anyhow::Error::msg)?
else {
return Ok(serde_json::json!({
"available": false,
"current_version_id": record.id,
"resource_root": resource_root,
"textunit_task_queue_path": task_queue_path,
"translation_handoff_path": handoff_path,
"task_repository_path": repository_path,
}));
};
let task_repository_available =
sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")?;
let tasks = if task_repository_available {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
let repository = SqliteTranslationTaskRepository::open(&repository_path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.list(&OfficialTextUnitTaskQuery::default())
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?
} else {
queue
.tasks
.iter()
.cloned()
.map(|task| {
bat_infrastructure::PersistedTranslationTask::from_queued_task(
task,
queue.generated_unix_seconds,
)
})
.collect::<Vec<_>>()
};
let handoff = bat_infrastructure::build_translation_handoff(&queue, &tasks);
let handoff_file_available = sqlite_file_exists_no_symlink(&handoff_path, "翻译 handoff")?;
Ok(serde_json::json!({
"available": true,
"current_version_id": record.id,
"resource_root": resource_root,
"textunit_task_queue_path": task_queue_path,
"translation_handoff_path": handoff_path,
"translation_handoff_file_available": handoff_file_available,
"task_repository_path": repository_path,
"task_repository_available": task_repository_available,
"task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION,
"handoff_schema_version": bat_infrastructure::TRANSLATION_HANDOFF_SCHEMA_VERSION,
"handoff": handoff,
}))
}
pub(super) fn update_translation_task_status_report(
state_dir: &Path,
params: Option<&serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
let task_id = rpc_string_param(params, "task_id")
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
let status_label = rpc_string_param(params, "status")
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 status"))?;
let status = TranslationTaskStatus::parse(status_label)
.ok_or_else(|| anyhow::anyhow!("不支持的翻译任务 worker 状态:{status_label}"))?;
let failure_reason = rpc_string_param(params, "failure_reason")
.or_else(|| rpc_string_param(params, "reason"))
.map(str::to_string);
let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string);
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
.ok_or_else(|| anyhow::anyhow!("没有可更新翻译任务的当前官方 release"))?;
let repository_path = SqliteTranslationTaskRepository::repository_path(&current.resource_root);
if !sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")? {
return Err(anyhow::anyhow!(
"翻译任务状态数据库不存在:{}",
repository_path.display()
));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let task = runtime.block_on(async {
let repository = SqliteTranslationTaskRepository::open(&repository_path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.update_status(task_id, status, failure_reason, provider_run_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"current_version_id": current.id,
"task_repository_path": repository_path,
"entry": task,
}))
}
pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::Value {
serde_json::json!({
"destination": query.destination.clone(),
"path_pattern": query.path_pattern.clone(),
"archive_entry": query.archive_entry.clone(),
"path_id": query.path_id,
"class_id": query.class_id,
"field_path": query.field_path.clone(),
"format": query.format.clone(),
})
}
pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value {
serde_json::json!({
"task_id": query.task_id.clone(),
"official_release_id": query.official_release_id.clone(),
"destination": query.destination.clone(),
"path_pattern": query.path_pattern.clone(),
"archive_entry": query.archive_entry.clone(),
"status": query.status.clone(),
"task_status": query.task_status.clone(),
"parse_status": query.parse_status.clone(),
"text_unit_format": query.text_unit_format.clone(),
"has_reason": query.has_reason,
"has_failure_reason": query.has_failure_reason,
})
}
File diff suppressed because it is too large Load Diff