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
+26
View File
@@ -82,6 +82,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/translation-glossary-update",
"/admin/control/translation-glossary-approve",
"/admin/control/translation-glossary-deprecate",
"/admin/control/translation-glossary-delete",
"/admin/control/localized-publish",
"/admin/control/localized-rollback",
},
@@ -495,6 +496,29 @@ func (s *Server) handleAdminGlossaryControl(w http.ResponseWriter, r *http.Reque
Status: "accepted",
Result: result,
})
case "translation-glossary-delete":
var params backendrpc.GlossaryDeleteParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TermID) == "" ||
strings.TrimSpace(params.Reviewer) == "" ||
strings.TrimSpace(params.Reason) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, reviewer and reason are required for delete")
return
}
result, err := backend.GlossaryDelete(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: glossaryControlRPCMethod(action),
Status: "accepted",
Result: result,
})
default:
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown glossary control action")
}
@@ -510,6 +534,8 @@ func glossaryControlRPCMethod(action string) string {
return "translation.glossary.approve"
case "translation-glossary-deprecate":
return "translation.glossary.deprecate"
case "translation-glossary-delete":
return "translation.glossary.delete"
default:
return ""
}
+17 -4
View File
@@ -642,6 +642,7 @@ type controlBackend struct {
glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams
glossaryMutationParams []backendrpc.GlossaryTermMutationParams
glossaryReviewParams []backendrpc.GlossaryReviewParams
glossaryDeleteParams []backendrpc.GlossaryDeleteParams
localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams
}
@@ -905,6 +906,12 @@ func (b *controlBackend) GlossaryDeprecate(ctx context.Context, params backendrp
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.delete")
b.glossaryDeleteParams = append(b.glossaryDeleteParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
b.calls = append(b.calls, "localized.status")
return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil
@@ -1708,6 +1715,7 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
!strings.Contains(rr.Body.String(), "/admin/tasks") ||
!strings.Contains(rr.Body.String(), "/admin/parse/text-units") ||
!strings.Contains(rr.Body.String(), "translation_results") ||
!strings.Contains(rr.Body.String(), "glossary_override") ||
!strings.Contains(rr.Body.String(), "task-cancel") ||
!strings.Contains(rr.Body.String(), "/admin/translation/memory/query") ||
!strings.Contains(rr.Body.String(), "translation-memory-confirm") {
@@ -1936,7 +1944,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
{name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文","glossary_override":{"reviewer":"reviewer","reason":"approved deviation","provenance":"manual-review","confirmed_unix_seconds":100}}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
{name: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"},
@@ -1944,6 +1952,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "translation glossary update", action: "translation-glossary-update", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","reviewer":"reviewer","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.update", call: "translation.glossary.update"},
{name: "translation glossary approve", action: "translation-glossary-approve", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"approved"}`, rpcMethod: "translation.glossary.approve", call: "translation.glossary.approve"},
{name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"},
{name: "translation glossary delete", action: "translation-glossary-delete", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"duplicate"}`, rpcMethod: "translation.glossary.delete", call: "translation.glossary.delete"},
{name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"},
{name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"},
}
@@ -1972,7 +1981,9 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
if len(backend.translationTaskUpdates) != 1 ||
backend.translationTaskUpdates[0].Provider != "manual" ||
len(backend.translationTaskUpdates[0].TranslationResults) != 1 ||
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" {
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride == nil ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.Reviewer != "reviewer" {
t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates)
}
if len(backend.translationMemoryConfirmParams) != 1 ||
@@ -1984,8 +1995,10 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
backend.glossaryMutationParams[0].TermID != "term-sensei" ||
backend.glossaryMutationParams[1].Reviewer != "reviewer" ||
len(backend.glossaryReviewParams) != 2 ||
backend.glossaryReviewParams[0].TermID != "term-sensei" {
t.Fatalf("Glossary params mutation=%#v review=%#v", backend.glossaryMutationParams, backend.glossaryReviewParams)
backend.glossaryReviewParams[0].TermID != "term-sensei" ||
len(backend.glossaryDeleteParams) != 1 ||
backend.glossaryDeleteParams[0].Reason != "duplicate" {
t.Fatalf("Glossary params mutation=%#v review=%#v delete=%#v", backend.glossaryMutationParams, backend.glossaryReviewParams, backend.glossaryDeleteParams)
}
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
+16 -1
View File
@@ -560,7 +560,7 @@ paths:
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, localized-publish, localized-rollback]
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback]
requestBody:
required: false
content:
@@ -626,6 +626,21 @@ paths:
type: string
translated_text:
type: string
glossary_override:
type: object
required: [reviewer, reason, provenance, confirmed_unix_seconds]
additionalProperties: false
properties:
reviewer:
type: string
reason:
type: string
provenance:
type: string
confirmed_unix_seconds:
type: integer
format: int64
minimum: 1
fixture_path:
type: string
concurrency:
+4
View File
@@ -102,6 +102,7 @@ type GlossaryBackend interface {
GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error)
}
// LocalizedBackend exposes localized release status and the explicit
@@ -236,6 +237,9 @@ func (r RPCClient) GlossaryApprove(ctx context.Context, params backendrpc.Glossa
func (r RPCClient) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryDeprecate(ctx, params)
}
func (r RPCClient) GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryDelete(ctx, params)
}
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.LocalizedStatus(ctx)
+14
View File
@@ -595,6 +595,13 @@ type GlossaryReviewParams struct {
Reason string `json:"reason,omitempty"`
}
type GlossaryDeleteParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
TermID string `json:"term_id"`
Reviewer string `json:"reviewer"`
Reason string `json:"reason"`
}
type GlossarySummaryReport struct {
Available bool `json:"available"`
Path string `json:"path"`
@@ -615,6 +622,7 @@ type GlossaryMutationReport struct {
Available bool `json:"available"`
Path string `json:"path"`
SchemaVersion *uint64 `json:"schema_version,omitempty"`
Deleted bool `json:"deleted,omitempty"`
Term GlossaryTerm `json:"term"`
}
@@ -944,6 +952,12 @@ func (c *Client) GlossaryDeprecate(ctx context.Context, params GlossaryReviewPar
return &out, err
}
func (c *Client) GlossaryDelete(ctx context.Context, params GlossaryDeleteParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.delete", params, &out)
return &out, err
}
func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) {
return c.rawData(ctx, "localized.publish", params)
}
+97
View File
@@ -463,6 +463,12 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
UnitID: "direct:a#unit:0",
SourceText: "source",
TranslatedText: "译文",
GlossaryOverride: &GlossaryOverride{
Reviewer: "reviewer",
Reason: "approved deviation",
Provenance: "manual-review",
ConfirmedUnixSeconds: 100,
},
}},
})
if err != nil {
@@ -928,6 +934,31 @@ func TestGlossaryTypedContract(t *testing.T) {
},
},
}}
case "translation.glossary.update":
var params GlossaryTermMutationParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode update params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.ReviewStatus != "draft" {
t.Fatalf("update params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-update",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{
"term_id": "term-sensei",
"source_term": "Sensei",
"recommended_translation": "老师",
"review_status": "draft",
"source": map[string]any{
"source_kind": "manual",
"observed_unix_seconds": 101,
},
},
},
}}
case "translation.glossary.approve":
var params GlossaryReviewParams
if err := json.Unmarshal(req.Params, &params); err != nil {
@@ -944,6 +975,39 @@ func TestGlossaryTypedContract(t *testing.T) {
"term": map[string]any{"term_id": "term-sensei", "review_status": "approved"},
},
}}
case "translation.glossary.deprecate":
var params GlossaryReviewParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode deprecate params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.Reason != "retired" {
t.Fatalf("deprecate params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-deprecate",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{"term_id": "term-sensei", "review_status": "deprecated"},
},
}}
case "translation.glossary.delete":
var params GlossaryDeleteParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode delete params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.Reason != "duplicate" {
t.Fatalf("delete params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-delete",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"deleted": true,
"term": map[string]any{"term_id": "term-sensei"},
},
}}
default:
t.Fatalf("unexpected method %q", req.Method)
return testResponse{}
@@ -986,6 +1050,21 @@ func TestGlossaryTypedContract(t *testing.T) {
if err != nil || add.Term.TermID != "term-sensei" {
t.Fatalf("add=%#v err=%v", add, err)
}
updated, err := client.GlossaryUpdate(context.Background(), GlossaryTermMutationParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
SourceTerm: "Sensei",
RecommendedTranslation: "老师",
ReviewStatus: "draft",
Reviewer: "reviewer",
Source: GlossarySourceRecord{
SourceKind: "manual",
ObservedUnixSeconds: 101,
},
})
if err != nil || updated.Term.ReviewStatus != GlossaryStatusDraft {
t.Fatalf("update=%#v err=%v", updated, err)
}
approved, err := client.GlossaryApprove(context.Background(), GlossaryReviewParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
@@ -994,6 +1073,24 @@ func TestGlossaryTypedContract(t *testing.T) {
if err != nil || approved.Term.ReviewStatus != GlossaryStatusApproved {
t.Fatalf("approve=%#v err=%v", approved, err)
}
deprecated, err := client.GlossaryDeprecate(context.Background(), GlossaryReviewParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
Reviewer: "reviewer",
Reason: "retired",
})
if err != nil || deprecated.Term.ReviewStatus != GlossaryStatusDeprecated {
t.Fatalf("deprecate=%#v err=%v", deprecated, err)
}
deleted, err := client.GlossaryDelete(context.Background(), GlossaryDeleteParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
Reviewer: "reviewer",
Reason: "duplicate",
})
if err != nil || !deleted.Available || !deleted.Deleted {
t.Fatalf("delete=%#v err=%v", deleted, err)
}
}
func TestApplicationErrorReturnsAPIError(t *testing.T) {