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
+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);
}
}