feat(i18n): 完成 localized patch 发布回滚闭环
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

Closes #45
This commit is contained in:
2026-08-31 00:02:55 +08:00
parent f441f1810e
commit ab21344773
26 changed files with 1916 additions and 140 deletions
+97
View File
@@ -39,6 +39,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/schedules",
"/admin/translation/tasks",
"/admin/translation/handoff",
"/admin/translation/status",
},
Controls: []string{
"/admin/control/reload",
@@ -55,6 +56,8 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/translation-task-update",
"/admin/control/translation-worker-run",
"/admin/control/translation-proofread",
"/admin/control/localized-publish",
"/admin/control/localized-rollback",
},
}
if r.Method == http.MethodHead {
@@ -94,6 +97,14 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
s.handleAdminTranslationProofread(w, r)
return
}
if action == "localized-publish" {
s.handleAdminLocalizedPublish(w, r)
return
}
if action == "localized-rollback" {
s.handleAdminLocalizedRollback(w, r)
return
}
request, ok := decodeAdminControlRequest(w, r)
if !ok {
return
@@ -243,6 +254,84 @@ func (s *Server) handleAdminTranslationProofread(w http.ResponseWriter, r *http.
})
}
func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(LocalizedBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable")
return
}
var params backendrpc.LocalizedPublishParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if err := validateLocalizedPublishParams(params); err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_localized_params", err.Error())
return
}
result, err := backend.LocalizedPublish(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "localized-publish", err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: "localized-publish",
RPCMethod: "localized.publish",
Status: "accepted",
Result: result,
})
}
func (s *Server) handleAdminLocalizedRollback(w http.ResponseWriter, r *http.Request) {
backend, ok := s.backend.(LocalizedBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable")
return
}
var params backendrpc.LocalizedRollbackParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
result, err := backend.LocalizedRollback(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, "localized-rollback", err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: "localized-rollback",
RPCMethod: "localized.rollback",
Status: "accepted",
Result: result,
})
}
func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
backend, ok := s.backend.(LocalizedBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable")
return
}
result, err := backend.LocalizedStatus(r.Context())
if err != nil {
s.writeControlBackendError(w, "localized-status", err)
return
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, result)
}
func (s *Server) handleAdminTranslationTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -494,6 +583,14 @@ func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunPa
return nil
}
func validateLocalizedPublishParams(params backendrpc.LocalizedPublishParams) error {
hasFile := strings.TrimSpace(params.TranslationFile) != ""
if hasFile == params.FromWorker {
return errors.New("localized publish requires exactly one of translation_file or from_worker")
}
return nil
}
func decodeAdminJSON(w http.ResponseWriter, r *http.Request, target any, errorCode string, subject string) bool {
if r.Body == nil {
return true
+60
View File
@@ -590,6 +590,8 @@ type controlBackend struct {
*fakeBackend
calls []string
translationTaskListParams []backendrpc.TranslationTaskListParams
localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams
}
func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
@@ -669,6 +671,23 @@ func (b *controlBackend) TranslationProofread(ctx context.Context) (json.RawMess
return json.RawMessage(`{"translation_workflow_status":"manual_proofreading"}`), 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
}
func (b *controlBackend) LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) {
b.calls = append(b.calls, "localized.publish")
b.localizedPublishParams = append(b.localizedPublishParams, params)
return json.RawMessage(`{"status":"published","localized_release_id":"localized-1"}`), nil
}
func (b *controlBackend) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) {
b.calls = append(b.calls, "localized.rollback")
b.localizedRollbackParams = append(b.localizedRollbackParams, params)
return json.RawMessage(`{"status":"rolled_back","rolled_back_release_id":"localized-1"}`), nil
}
type scheduleBackend struct {
*controlBackend
scheduleCalls []string
@@ -1420,6 +1439,8 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"failed","failure_reason":"provider rejected payload","provider_run_id":"provider-run-1"}`, 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: "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"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -1458,6 +1479,45 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid translation worker status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"translation_file":"/tmp/workbench.json"}`))
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("invalid localized publish status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestAdminLocalizedStatusRequiresAuthAndForwards(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "control-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &controlBackend{fakeBackend: &fakeBackend{}}
s := NewServer(cfg, backend, nil)
request := httptest.NewRequest(http.MethodGet, "/admin/translation/status", nil)
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status=%d body=%s", recorder.Code, recorder.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/admin/translation/status", nil)
request.Header.Set("Authorization", "Bearer control-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), `"localized.published"`) {
t.Fatalf("status body=%s", recorder.Body.String())
}
if len(backend.calls) != 1 || backend.calls[0] != "localized.status" {
t.Fatalf("calls=%v", backend.calls)
}
}
func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) {
+17 -1
View File
@@ -213,6 +213,16 @@ paths:
description: Missing or invalid admin token.
"503":
description: Rust bat translation backend is unavailable.
/admin/translation/status:
get:
summary: Read Rust-owned localized release status
responses:
"200":
description: Current localized status JSON report from Rust bat.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat localized backend is unavailable.
/admin/control/{action}:
post:
summary: Forward an allowlisted control or schedule action to Rust bat
@@ -222,7 +232,7 @@ paths:
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread]
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback]
requestBody:
required: false
content:
@@ -301,6 +311,12 @@ paths:
minimum: 1
worker_id:
type: string
translation_file:
type: string
from_worker:
type: boolean
localized_release_id:
type: string
responses:
"202":
description: Rust bat accepted the control request.
+21 -3
View File
@@ -60,6 +60,14 @@ type TranslationBackend interface {
TranslationProofread(ctx context.Context) (json.RawMessage, error)
}
// LocalizedBackend exposes localized release status and the explicit
// publish/rollback controls used by the authenticated dashboard.
type LocalizedBackend interface {
LocalizedStatus(ctx context.Context) (json.RawMessage, error)
LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error)
LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error)
}
// RPCClient adapts *backendrpc.Client to Backend.
type RPCClient struct {
Client *backendrpc.Client
@@ -135,6 +143,19 @@ func (r RPCClient) TranslationWorkerRun(ctx context.Context, params backendrpc.T
func (r RPCClient) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
return r.Client.TranslationProofread(ctx)
}
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.LocalizedStatus(ctx)
}
func (r RPCClient) LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) {
return r.Client.LocalizedPublish(ctx, params)
}
func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) {
return r.Client.LocalizedRollback(ctx, params)
}
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.ParseStatus(ctx)
}
@@ -144,9 +165,6 @@ func (r RPCClient) ParseTextUnits(ctx context.Context, query backendrpc.TextUnit
func (r RPCClient) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
return r.Client.ParseErrors(ctx, query)
}
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.LocalizedStatus(ctx)
}
func (r RPCClient) UnityFSPatchTextAsset(ctx context.Context, params backendrpc.UnityFSTextAssetPatchParams) (json.RawMessage, error) {
return r.Client.UnityFSPatchTextAsset(ctx, params)
}
+2
View File
@@ -64,6 +64,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/admin/schedules", s.handleAdminSchedules)
mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks)
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus)
mux.HandleFunc("/admin/control/", s.handleAdminControl)
mux.HandleFunc("/admin/", s.handleAdminIndex)
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
@@ -131,6 +132,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/admin/schedules",
"/admin/translation/tasks",
"/admin/translation/handoff",
"/admin/translation/status",
"/admin/control/{action}",
},
})
+22
View File
@@ -325,6 +325,20 @@ type TranslationWorkerRunResult struct {
Worker TranslationWorkerConfig `json:"worker"`
}
// LocalizedPublishParams selects the source of one localized release
// publication. TranslationFile and FromWorker are mutually exclusive.
type LocalizedPublishParams struct {
TranslationFile string `json:"translation_file,omitempty"`
FromWorker bool `json:"from_worker,omitempty"`
LocalizedReleaseID string `json:"localized_release_id,omitempty"`
Force bool `json:"force,omitempty"`
}
// LocalizedRollbackParams optionally restricts rollback to one current release.
type LocalizedRollbackParams struct {
LocalizedReleaseID string `json:"localized_release_id,omitempty"`
}
// TranslationProofread marks the current localized workflow as manual
// proofreading. Rust owns the persisted localized state.
func (c *Client) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
@@ -568,6 +582,14 @@ func (c *Client) TranslationWorkerRun(ctx context.Context, params TranslationWor
return &out, err
}
func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) {
return c.rawData(ctx, "localized.publish", params)
}
func (c *Client) LocalizedRollback(ctx context.Context, params LocalizedRollbackParams) (json.RawMessage, error) {
return c.rawData(ctx, "localized.rollback", params)
}
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.status", nil)
}
+69
View File
@@ -561,6 +561,75 @@ func TestTranslationProofreadUsesRustMethod(t *testing.T) {
}
}
func TestLocalizedPublishSendsWorkerSourceAndReleaseOptions(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "localized.publish" {
t.Fatalf("method = %s", req.Method)
}
var params LocalizedPublishParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if !params.FromWorker || params.TranslationFile != "" ||
params.LocalizedReleaseID != "localized-1" || !params.Force {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-localized-publish",
Data: map[string]any{"status": "published"},
},
}
})
raw, err := client.LocalizedPublish(context.Background(), LocalizedPublishParams{
FromWorker: true,
LocalizedReleaseID: "localized-1",
Force: true,
})
if err != nil {
t.Fatalf("LocalizedPublish error: %v", err)
}
if !json.Valid(raw) {
t.Fatalf("invalid raw JSON: %s", string(raw))
}
}
func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "localized.rollback" {
t.Fatalf("method = %s", req.Method)
}
var params LocalizedRollbackParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.LocalizedReleaseID != "localized-1" {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-localized-rollback",
Data: map[string]any{"status": "rolled_back"},
},
}
})
raw, err := client.LocalizedRollback(context.Background(), LocalizedRollbackParams{
LocalizedReleaseID: "localized-1",
})
if err != nil {
t.Fatalf("LocalizedRollback error: %v", err)
}
if !json.Valid(raw) {
t.Fatalf("invalid raw JSON: %s", string(raw))
}
}
func TestApplicationErrorReturnsAPIError(t *testing.T) {
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "task.status" {