mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 14:14:53 +08:00
fix(glossary): 补齐术语删除和审核门禁
This commit is contained in:
+139
-14
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user