fix(glossary): 补齐术语删除和审核门禁
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-08 20:59:27 +08:00
parent 94483ff14d
commit 0275a890bc
19 changed files with 589 additions and 32 deletions
+28 -3
View File
@@ -74,9 +74,9 @@ mod translation_query;
#[path = "workflow_commands.rs"]
mod workflow_commands;
use glossary_query::{
glossary_diagnose_rpc_report, glossary_mutation_rpc_report, glossary_query_rpc_report,
glossary_review_rpc_report, glossary_rpc_envelope, glossary_summary_rpc_report,
run_glossary_command,
glossary_delete_rpc_report, glossary_diagnose_rpc_report, glossary_mutation_rpc_report,
glossary_query_rpc_report, glossary_review_rpc_report, glossary_rpc_envelope,
glossary_summary_rpc_report, run_glossary_command,
};
use patch_commands::{
is_write_patch_command, run_write_patch_command, validate_write_patch_options,
@@ -243,6 +243,7 @@ fn run() -> anyhow::Result<i32> {
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDelete
| CliCommand::GlossaryDiagnose => {
run_glossary_command(&options)?;
Ok(0)
@@ -658,6 +659,7 @@ enum CliCommand {
GlossaryUpdate,
GlossaryApprove,
GlossaryDeprecate,
GlossaryDelete,
GlossaryDiagnose,
Repack,
PublishLocalized,
@@ -1182,6 +1184,7 @@ const RPC_METHOD_GLOSSARY_ADD: &str = "translation.glossary.add";
const RPC_METHOD_GLOSSARY_UPDATE: &str = "translation.glossary.update";
const RPC_METHOD_GLOSSARY_APPROVE: &str = "translation.glossary.approve";
const RPC_METHOD_GLOSSARY_DEPRECATE: &str = "translation.glossary.deprecate";
const RPC_METHOD_GLOSSARY_DELETE: &str = "translation.glossary.delete";
const RPC_METHOD_GLOSSARY_DIAGNOSE: &str = "translation.glossary.diagnose";
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish";
@@ -2296,6 +2299,15 @@ fn dispatch_rpc_method(
},
),
),
RPC_METHOD_GLOSSARY_DELETE => glossary_rpc_envelope(
request_id,
glossary_delete_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_TRANSLATION_WORKER_RUN => {
let config = match rpc_translation_worker_config_with_defaults(
request.params.as_ref(),
@@ -7474,6 +7486,7 @@ fn parse_args_with_env(
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDelete
| CliCommand::GlossaryDiagnose
| CliCommand::TranslationSet
| CliCommand::TranslationValidate
@@ -7629,6 +7642,7 @@ fn parse_args_with_env(
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDelete
| CliCommand::GlossaryDiagnose => {
if options.watch
|| options.daemon
@@ -7686,6 +7700,16 @@ fn parse_args_with_env(
));
}
}
CliCommand::GlossaryDelete => {
if options.glossary_term_id.is_none()
|| options.glossary_reviewer.is_none()
|| options.glossary_reason.is_none()
{
return Err(anyhow::anyhow!(
"Glossary delete 必须指定 --glossary-term-id、--glossary-reviewer 和 --glossary-reason"
));
}
}
_ => unreachable!(),
}
options.progress = false;
@@ -8293,6 +8317,7 @@ fn parse_translation_glossary_command(
"update" | "edit" => CliCommand::GlossaryUpdate,
"approve" | "trust" => CliCommand::GlossaryApprove,
"deprecate" | "retire" => CliCommand::GlossaryDeprecate,
"delete" | "remove" => CliCommand::GlossaryDelete,
"diagnose" | "check" => CliCommand::GlossaryDiagnose,
other => {
return Err(anyhow::anyhow!(
+87
View File
@@ -425,6 +425,20 @@ fn glossary_commands_parse_and_validate() {
])
.unwrap();
assert_eq!(diagnose.command, CliCommand::GlossaryDiagnose);
let delete = parse(&[
"bat",
"i18n",
"glossary",
"delete",
"--glossary-term-id",
"term-sensei",
"--glossary-reviewer",
"operator",
"--glossary-reason",
"duplicate",
])
.unwrap();
assert_eq!(delete.command, CliCommand::GlossaryDelete);
assert!(parse(&["bat", "i18n", "glossary", "diagnose"]).is_err());
assert!(parse(&[
"bat",
@@ -2784,6 +2798,79 @@ fn dispatch_glossary_summary_reports_missing_database_without_creating_it() {
assert!(!output_root.join("glossary.sqlite").exists());
}
#[test]
fn dispatch_glossary_delete_removes_term_and_returns_snapshot() {
let temp = tempfile::TempDir::new().unwrap();
let output_root = temp.path().join("output");
let state_dir = temp.path().join("state");
let glossary_path = output_root.join("glossary.sqlite");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let repository = bat_infrastructure::SqliteGlossaryRepository::new(&glossary_path)
.await
.unwrap();
repository
.add(bat_core::domain::GlossaryTermDraft {
term_id: "term-sensei".to_string(),
definition: bat_core::domain::GlossaryTermSnapshot {
source_term: "Sensei".to_string(),
aliases: vec!["Teacher".to_string()],
recommended_translation: "老师".to_string(),
allowed_translations: Vec::new(),
source_language: None,
target_language: None,
category: Some("person".to_string()),
priority: 10,
scope: Default::default(),
},
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
source: bat_core::domain::GlossarySourceRecord {
source_kind: bat_core::domain::GlossarySourceKind::Manual,
source_ref: Some("test".to_string()),
source_author: Some("tester".to_string()),
source_note: None,
observed_unix_seconds: 100,
},
})
.await
.unwrap();
});
let context = test_task_context_with_config(OfficialUpdateConfig {
output_root: output_root.clone(),
..Default::default()
});
let envelope = dispatch_rpc_method(
&rpc_request(
"translation.glossary.delete",
Some(serde_json::json!({
"term_id": "term-sensei",
"reviewer": "reviewer",
"reason": "duplicate"
})),
),
&state_dir,
&new_daemon_control(),
&context,
"req-glossary-delete-1".to_string(),
);
let value = serde_json::to_value(envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["deleted"], true);
assert_eq!(value["data"]["term"]["term_id"], "term-sensei");
assert_eq!(value["data"]["term"]["source_term"], "Sensei");
runtime.block_on(async {
let repository = bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
.await
.unwrap();
assert!(repository.find("term-sensei").await.is_err());
});
}
#[test]
fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
let temp = tempfile::TempDir::new().unwrap();
@@ -65,6 +65,19 @@ pub(super) fn run_glossary_command(options: &CliOptions) -> anyhow::Result<()> {
options.glossary_reason.clone(),
)?
}
CliCommand::GlossaryDelete => {
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary delete 必须指定 --glossary-term-id")
})?;
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary delete 必须指定 --glossary-reviewer")
})?;
let reason = options
.glossary_reason
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Glossary delete 必须指定 --glossary-reason"))?;
build_glossary_delete_report(&path, term_id, reviewer, reason)?
}
_ => unreachable!(),
};
print_json_value(options.output_format, &report)
@@ -78,6 +91,7 @@ fn glossary_method(command: CliCommand) -> anyhow::Result<&'static str> {
CliCommand::GlossaryUpdate => RPC_METHOD_GLOSSARY_UPDATE,
CliCommand::GlossaryApprove => RPC_METHOD_GLOSSARY_APPROVE,
CliCommand::GlossaryDeprecate => RPC_METHOD_GLOSSARY_DEPRECATE,
CliCommand::GlossaryDelete => RPC_METHOD_GLOSSARY_DELETE,
CliCommand::GlossaryDiagnose => RPC_METHOD_GLOSSARY_DIAGNOSE,
_ => return Err(anyhow::anyhow!("不是 Glossary 命令")),
})
@@ -159,6 +173,20 @@ fn glossary_cli_params(options: &CliOptions) -> anyhow::Result<Option<serde_json
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
CliCommand::GlossaryDelete => {
params.insert(
"term_id".to_string(),
serde_json::json!(options.glossary_term_id.as_deref().unwrap_or_default()),
);
params.insert(
"reviewer".to_string(),
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
);
params.insert(
"reason".to_string(),
serde_json::json!(options.glossary_reason.as_deref().unwrap_or_default()),
);
}
_ => unreachable!(),
}
Ok(Some(serde_json::Value::Object(params)))
@@ -414,6 +442,33 @@ pub(super) fn build_glossary_review_report(
}))
}
pub(super) fn build_glossary_delete_report(
path: &std::path::Path,
term_id: &str,
reviewer: &str,
reason: &str,
) -> anyhow::Result<serde_json::Value> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let term = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.delete(term_id, reviewer, reason)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"deleted": true,
"term": term,
}))
}
pub(super) fn glossary_rpc_envelope(
request_id: String,
result: Result<serde_json::Value, ApiError>,
@@ -546,6 +601,30 @@ pub(super) fn glossary_review_rpc_report(
.map_err(|error| glossary_internal_error(method, error))
}
pub(super) fn glossary_delete_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DELETE)?;
let term_id = glossary_string(&params, "term_id", RPC_METHOD_GLOSSARY_DELETE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 term_id"))?;
let reviewer = glossary_string(&params, "reviewer", RPC_METHOD_GLOSSARY_DELETE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reviewer"))?;
let reason = glossary_string(&params, "reason", RPC_METHOD_GLOSSARY_DELETE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reason"))?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_DELETE,
)?;
build_glossary_delete_report(&path, term_id, reviewer, reason)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DELETE, error))
}
fn glossary_rpc_path(
state_dir: &Path,
output_root: &Path,
@@ -348,6 +348,7 @@ Commands:
i18n glossary summary/query Show project Glossary terms and review counts
i18n glossary add/update Add or replace one Glossary term definition
i18n glossary approve/deprecate Review one Glossary term
i18n glossary delete Remove one Glossary term with reviewer and reason
i18n glossary diagnose Run deterministic Glossary QA for one TextUnit source
i18n publish Publish a localized release from a workbench or worker results
i18n rollback Roll back the current localized release
@@ -439,13 +440,13 @@ Sync:
--translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
--translation-fixture <PATH> Mock/provider fixture for i18n worker run
--glossary-path <PATH> Project Glossary SQLite path
--glossary-term-id <ID> Glossary term ID for add/update/review
--glossary-term-id <ID> Glossary term ID for add/update/review/delete
--glossary-source-term <TEXT> Source spelling for a Glossary term
--glossary-recommended-translation <TEXT> Recommended target translation
--glossary-source-text <TEXT> Source TextUnit text for Glossary query/diagnose
--glossary-context-json <JSON> TextUnit context for Glossary diagnose
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews
--glossary-reason <TEXT> Reason for Glossary review or override
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews/delete
--glossary-reason <TEXT> Reason for Glossary review/delete or override
--glossary-provenance <TEXT> Provenance for an explicit Glossary override
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
--worker-max-attempts <N> Maximum claims per translation task
+139 -14
View File
@@ -160,6 +160,21 @@ impl SqliteGlossaryRepository {
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS glossary_term_deletions (
deletion_id TEXT PRIMARY KEY NOT NULL,
term_id TEXT NOT NULL,
reviewer TEXT NOT NULL,
reason TEXT NOT NULL,
source_json TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
history_json TEXT NOT NULL,
observed_unix_seconds INTEGER NOT NULL
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_glossary_status
ON glossary_terms(review_status, priority DESC, term_id)",
@@ -244,6 +259,31 @@ impl SqliteGlossaryRepository {
"Glossary query limit 必须在 1..=1000 范围内".to_string(),
));
}
let candidates = self.load_terms(category, review_status).await?;
let mut terms = Vec::new();
for term in candidates {
if source_text.is_none_or(|source| {
let mut spellings = vec![term.definition.source_term.as_str()];
spellings.extend(term.definition.aliases.iter().map(String::as_str));
spellings
.into_iter()
.filter(|spelling| !spelling.is_empty())
.any(|spelling| source.contains(spelling))
}) {
terms.push(term);
if terms.len() >= limit {
break;
}
}
}
Ok(terms)
}
async fn load_terms(
&self,
category: Option<&str>,
review_status: Option<GlossaryReviewStatus>,
) -> Result<Vec<GlossaryTerm>> {
let rows = sqlx::query(
"SELECT term_id FROM glossary_terms
WHERE (?1 IS NULL OR category = ?1)
@@ -258,19 +298,7 @@ impl SqliteGlossaryRepository {
let mut terms = Vec::new();
for row in rows {
let term_id: String = row.try_get("term_id").map_err(db_error)?;
let term = self.find(&term_id).await?;
if source_text.is_none_or(|source| {
let mut spellings = vec![term.definition.source_term.as_str()];
spellings.extend(term.definition.aliases.iter().map(String::as_str));
spellings
.into_iter()
.any(|spelling| source.contains(spelling))
}) {
terms.push(term);
if terms.len() >= limit {
break;
}
}
terms.push(self.find(&term_id).await?);
}
Ok(terms)
}
@@ -303,6 +331,11 @@ impl SqliteGlossaryRepository {
/// Adds a term and records its source snapshot.
pub async fn add(&self, draft: GlossaryTermDraft) -> Result<GlossaryTerm> {
validate_glossary_draft(&draft)?;
if draft.review_status != GlossaryReviewStatus::Draft {
return Err(Error::InvalidArgument(
"Glossary add 只能创建 draft;请通过 review/approve 使术语生效".to_string(),
));
}
let now = draft.source.observed_unix_seconds;
let term = term_from_draft(&draft, now, now);
let mut transaction = self.pool.begin().await.map_err(db_error)?;
@@ -332,6 +365,11 @@ impl SqliteGlossaryRepository {
reason: Option<String>,
) -> Result<GlossaryTerm> {
validate_glossary_draft(&draft)?;
if draft.review_status != GlossaryReviewStatus::Draft {
return Err(Error::InvalidArgument(
"Glossary update 只能写入 draft;修改 approved 术语后必须重新 approve".to_string(),
));
}
if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary update reviewer 不能为空".to_string(),
@@ -355,6 +393,76 @@ impl SqliteGlossaryRepository {
self.find(&draft.term_id).await
}
/// Permanently removes a term and its stored history after explicit review.
pub async fn delete(
&self,
term_id: &str,
reviewer: &str,
reason: &str,
) -> Result<GlossaryTerm> {
if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary delete reviewer 不能为空".to_string(),
));
}
if reason.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary delete reason 不能为空".to_string(),
));
}
let term = self.find(term_id).await?;
let mut transaction = self.pool.begin().await.map_err(db_error)?;
let source_json = json(&term.source)?;
let snapshot_json = json(&term.definition)?;
let history_json = json(&term.history)?;
let observed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut deletion_hasher = blake3::Hasher::new();
for value in [term_id, reviewer.trim(), reason.trim()] {
deletion_hasher.update(value.as_bytes());
deletion_hasher.update(&[0]);
}
deletion_hasher.update(&observed.to_le_bytes());
deletion_hasher.update(
&SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes(),
);
sqlx::query(
"INSERT INTO glossary_term_deletions (
deletion_id, term_id, reviewer, reason, source_json,
snapshot_json, history_json, observed_unix_seconds
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)
.bind(format!("gld-{}", deletion_hasher.finalize().to_hex()))
.bind(term_id)
.bind(reviewer.trim())
.bind(reason.trim())
.bind(source_json)
.bind(snapshot_json)
.bind(history_json)
.bind(i64::try_from(observed).unwrap_or(i64::MAX))
.execute(&mut *transaction)
.await
.map_err(db_error)?;
sqlx::query("DELETE FROM glossary_term_history WHERE term_id = ?1")
.bind(term_id)
.execute(&mut *transaction)
.await
.map_err(db_error)?;
sqlx::query("DELETE FROM glossary_terms WHERE term_id = ?1")
.bind(term_id)
.execute(&mut *transaction)
.await
.map_err(db_error)?;
transaction.commit().await.map_err(db_error)?;
Ok(term)
}
/// Changes review state and records a source/review history entry.
pub async fn review(
&self,
@@ -416,7 +524,7 @@ impl SqliteGlossaryRepository {
context: &TranslationMemoryContext,
) -> Result<GlossaryEvaluation> {
let terms = self
.query(None, None, Some(GlossaryReviewStatus::Approved), 1000)
.load_terms(None, Some(GlossaryReviewStatus::Approved))
.await?;
Ok(evaluate_glossary(&terms, source_text, context))
}
@@ -723,6 +831,10 @@ mod tests {
let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite"))
.await
.unwrap();
assert!(repository
.add(draft(GlossaryReviewStatus::Approved))
.await
.is_err());
repository
.add(draft(GlossaryReviewStatus::Draft))
.await
@@ -753,5 +865,18 @@ mod tests {
assert_eq!(term.history[1].action, "approved");
let summary = repository.summary().await.unwrap();
assert_eq!(summary.approved_count, 1);
let deleted = repository
.delete("term-sensei", "reviewer", "remove duplicate")
.await
.unwrap();
assert_eq!(deleted.history.len(), 2);
assert!(repository.find("term-sensei").await.is_err());
assert_eq!(repository.summary().await.unwrap().term_count, 0);
let deletion_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM glossary_term_deletions")
.fetch_one(&repository.pool)
.await
.unwrap();
assert_eq!(deletion_count, 1);
}
}
+10 -1
View File
@@ -1660,7 +1660,7 @@ mod tests {
priority: 10,
scope: BTreeMap::new(),
},
review_status: bat_core::domain::GlossaryReviewStatus::Approved,
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
source: bat_core::domain::GlossarySourceRecord {
source_kind: bat_core::domain::GlossarySourceKind::Manual,
source_ref: Some("worker-test".to_string()),
@@ -1671,6 +1671,15 @@ mod tests {
})
.await
.unwrap();
glossary
.review(
"term-source-0",
bat_core::domain::GlossaryReviewStatus::Approved,
"reviewer",
Some("test approval".to_string()),
)
.await
.unwrap();
#[derive(Debug)]
struct GlossaryProvider {