feat(bat-api): 实现内嵌 dashboard
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

Closes #46
This commit is contained in:
2026-08-31 23:17:23 +08:00
parent ab21344773
commit 4ed81f0030
30 changed files with 3527 additions and 91 deletions
+19 -3
View File
@@ -4190,6 +4190,7 @@ fn dispatch_translation_tasks_filters_current_queue() {
let output_root = temp.path().join("output");
let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
write_textunit_task_queue_fixture(&current_dir);
write_textunit_index_fixture(&current_dir);
write_translation_task_repository_fixture(&current_dir);
let envelope = dispatch_rpc_method(
@@ -4290,7 +4291,13 @@ fn dispatch_translation_tasks_filters_current_queue() {
Some(serde_json::json!({
"task_id": "textunit/v-current/Bundle/a.bundle",
"status": "completed",
"provider_run_id": "crowdin-run-2"
"provider": "manual",
"provider_run_id": "manual-run-1",
"translation_results": [{
"unit_id": "direct:a#unit:0",
"source_text": "こんにちは",
"translated_text": "你好"
}]
})),
),
&state_dir,
@@ -4305,7 +4312,16 @@ fn dispatch_translation_tasks_filters_current_queue() {
value["data"]["entry"]["failure_reason"],
serde_json::Value::Null
);
assert_eq!(value["data"]["entry"]["provider_run_id"], "crowdin-run-2");
assert_eq!(value["data"]["entry"]["provider_run_id"], "manual-run-1");
assert_eq!(value["data"]["entry"]["provider"], "manual");
assert_eq!(
value["data"]["entry"]["translation_results"][0]["unit_id"],
"direct:a#unit:0"
);
assert_eq!(
value["data"]["entry"]["translation_results"][0]["translated_text"],
"你好"
);
assert!(value["data"]["entry"]["completed_unix_seconds"].is_number());
let envelope = dispatch_rpc_method(
@@ -4329,7 +4345,7 @@ fn dispatch_translation_tasks_filters_current_queue() {
assert_eq!(value["data"]["handoff"]["units"][0]["status"], "translated");
assert_eq!(
value["data"]["handoff"]["provider_runs"][0]["provider_run_id"],
"crowdin-run-2"
"manual-run-1"
);
}
+129 -4
View File
@@ -1,5 +1,12 @@
use super::*;
#[derive(Debug, Deserialize)]
struct TranslationTaskResultUpdateParam {
unit_id: String,
source_text: String,
translated_text: String,
}
pub(super) fn build_translation_tasks_report(
state_dir: &Path,
query: OfficialTextUnitTaskQuery,
@@ -170,6 +177,13 @@ pub(super) fn update_translation_task_status_report(
.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 provider = rpc_string_param(params, "provider").map(str::to_string);
let result_params = translation_task_result_params(params)?;
if !result_params.is_empty() && status != TranslationTaskStatus::Completed {
return Err(anyhow::anyhow!(
"translation_results 只能随 completed 状态写入"
));
}
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
@@ -182,6 +196,22 @@ pub(super) fn update_translation_task_status_report(
repository_path.display()
));
}
let textunit_index = if result_params.is_empty() {
None
} else {
Some(
read_textunit_index_at(&current.resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| {
anyhow::anyhow!("当前 release 缺少 TextUnit 明细索引,无法校验人工校对结果")
})?,
)
};
let result_provider = provider.clone().unwrap_or_else(|| "manual".to_string());
let result_timestamp = unix_seconds_now();
let result_provider_run_id = provider_run_id
.clone()
.unwrap_or_else(|| format!("manual-{result_timestamp}"));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
@@ -190,10 +220,36 @@ pub(super) fn update_translation_task_status_report(
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}"))
if let Some(index) = textunit_index.as_ref() {
let current_task = repository
.find(task_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let results = build_manual_translation_results(
&current_task,
index,
&result_params,
&result_provider,
&result_provider_run_id,
result_timestamp,
)?;
repository
.update_status_with_results(
task_id,
status,
failure_reason,
Some(result_provider_run_id),
Some(result_provider),
Some(&results),
)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
} else {
repository
.update_status(task_id, status, failure_reason, provider_run_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
}
})?;
Ok(serde_json::json!({
"available": true,
@@ -215,6 +271,75 @@ pub(super) fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::
})
}
fn translation_task_result_params(
params: Option<&serde_json::Value>,
) -> anyhow::Result<Vec<TranslationTaskResultUpdateParam>> {
let Some(value) = params
.and_then(|params| params.get("translation_results"))
.or_else(|| params.and_then(|params| params.get("results")))
else {
return Ok(Vec::new());
};
if value.is_null() {
return Ok(Vec::new());
}
serde_json::from_value(value.clone())
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
}
fn build_manual_translation_results(
task: &bat_infrastructure::PersistedTranslationTask,
index: &bat_infrastructure::OfficialTextUnitIndex,
params: &[TranslationTaskResultUpdateParam],
provider: &str,
provider_run_id: &str,
translated_unix_seconds: u64,
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
let index_by_id = index
.units
.iter()
.map(|unit| (unit.id.as_str(), unit))
.collect::<std::collections::BTreeMap<_, _>>();
let mut seen = std::collections::BTreeSet::new();
let mut results = Vec::with_capacity(params.len());
for param in params {
let unit_id = param.unit_id.trim();
if unit_id.is_empty() {
return Err(anyhow::anyhow!("translation_results[].unit_id 不能为空"));
}
if !seen.insert(unit_id.to_string()) {
return Err(anyhow::anyhow!(
"translation_results 包含重复 TextUnit{unit_id}"
));
}
let unit = index_by_id
.get(unit_id)
.ok_or_else(|| anyhow::anyhow!("translation_results 引用了未知 TextUnit{unit_id}"))?;
if unit.destination != task.task.destination
|| unit.archive_entry != task.task.archive_entry
{
return Err(anyhow::anyhow!(
"TextUnit {unit_id} 不属于翻译任务 {}",
task.task.task_id
));
}
if param.source_text != unit.source_text {
return Err(anyhow::anyhow!(
"TextUnit {unit_id} 的 source_text 与当前索引不一致"
));
}
results.push(bat_infrastructure::TranslationTaskUnitResult {
unit_id: unit_id.to_string(),
source_text: param.source_text.clone(),
translated_text: param.translated_text.clone(),
provider: provider.to_string(),
provider_run_id: provider_run_id.to_string(),
translated_unix_seconds,
});
}
Ok(results)
}
pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json::Value {
serde_json::json!({
"task_id": query.task_id.clone(),
+78
View File
@@ -1217,6 +1217,27 @@ impl SqliteTranslationTaskRepository {
status: TranslationTaskStatus,
failure_reason: Option<String>,
provider_run_id: Option<String>,
) -> Result<PersistedTranslationTask> {
self.update_status_with_results(
task_id,
status,
failure_reason,
provider_run_id,
None,
None,
)
.await
}
/// Updates provider state and optionally replaces durable TextUnit results.
pub async fn update_status_with_results(
&self,
task_id: &str,
status: TranslationTaskStatus,
failure_reason: Option<String>,
provider_run_id: Option<String>,
provider: Option<String>,
translation_results: Option<&[TranslationTaskUnitResult]>,
) -> Result<PersistedTranslationTask> {
let current = self.find(task_id).await?;
let now = unix_seconds_now_i64();
@@ -1228,6 +1249,13 @@ impl SqliteTranslationTaskRepository {
current.attempt_count
};
let normalized_reason = failure_reason.filter(|reason| !reason.trim().is_empty());
let provider_run_id = provider_run_id.filter(|value| !value.trim().is_empty());
let provider = provider.filter(|value| !value.trim().is_empty());
let translation_results_json =
translation_results
.map(serde_json::to_string)
.transpose()
.map_err(|error| bat_core::Error::Serialization(error.to_string()))?;
let completed = (status == TranslationTaskStatus::Completed).then_some(now);
sqlx::query(
r#"
@@ -1235,6 +1263,8 @@ impl SqliteTranslationTaskRepository {
SET worker_status = ?2, failure_reason = ?3, attempt_count = ?4,
updated_unix_seconds = ?5, completed_unix_seconds = ?6,
provider_run_id = COALESCE(?7, provider_run_id),
provider = COALESCE(?8, provider),
translation_results_json = COALESCE(?9, translation_results_json),
lease_owner = NULL, lease_expires_unix_seconds = NULL,
failure_class = NULL, failure_retryable = 0,
next_attempt_unix_seconds = NULL
@@ -1248,6 +1278,8 @@ impl SqliteTranslationTaskRepository {
.bind(now)
.bind(completed)
.bind(provider_run_id)
.bind(provider)
.bind(translation_results_json)
.execute(&self.pool)
.await
.map_err(db_error)?;
@@ -1696,6 +1728,52 @@ mod tests {
assert_eq!(retrievable[0].attempt_count, 1);
}
#[tokio::test]
async fn sqlite_translation_tasks_persist_manual_results_without_worker_lease() {
let temp = tempfile::TempDir::new().unwrap();
let repository =
SqliteTranslationTaskRepository::new(temp.path().join("translation-tasks.sqlite"))
.await
.unwrap();
let queue = queue(vec![task(
"task-a",
"Bundles/a.bundle",
OfficialTextUnitTaskStatus::QueuedOffline,
Some(OfficialParseStatus::Parsed),
None,
)]);
repository.sync_queue(&queue).await.unwrap();
let result = TranslationTaskUnitResult {
unit_id: "unit-a".to_string(),
source_text: "source".to_string(),
translated_text: "manual translation".to_string(),
provider: "manual".to_string(),
provider_run_id: "manual-run-1".to_string(),
translated_unix_seconds: 321,
};
let updated = repository
.update_status_with_results(
"task-a",
TranslationTaskStatus::Completed,
None,
Some("manual-run-1".to_string()),
Some("manual".to_string()),
Some(std::slice::from_ref(&result)),
)
.await
.unwrap();
assert_eq!(updated.task_status, TranslationTaskStatus::Completed);
assert_eq!(updated.provider.as_deref(), Some("manual"));
assert_eq!(updated.provider_run_id.as_deref(), Some("manual-run-1"));
assert_eq!(updated.translation_results, vec![result.clone()]);
assert_eq!(
repository.find("task-a").await.unwrap().translation_results,
vec![result]
);
}
#[tokio::test]
async fn sqlite_translation_tasks_recover_expired_leases_for_retry() {
let temp = tempfile::TempDir::new().unwrap();