feat(bat): 完善工作流调度与 dashboard RPC
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

补全资源拉取、解析、翻译、重打包和本地化发布命令,支持单次、限定次数与周期调度。移除 TUI 计划并通过 schedule.* RPC 暴露给 bat-api dashboard。

Closes #43
This commit is contained in:
2026-08-03 22:18:52 +08:00
parent 3b103be8a9
commit 0784d5b532
27 changed files with 2931 additions and 58 deletions
+109
View File
@@ -12,6 +12,7 @@ import (
)
const adminControlMaxBodyBytes = 1024
const adminScheduleMaxBodyBytes = 64 * 1024
type adminControlRequest struct {
Force bool `json:"force"`
@@ -33,6 +34,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/v1/release",
"/v1/resources",
"/openapi.yaml",
"/admin/schedules",
},
Controls: []string{
"/admin/control/reload",
@@ -42,6 +44,10 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/verify",
"/admin/control/repair",
"/admin/control/catalog-refresh",
"/admin/control/schedule-add",
"/admin/control/schedule-update",
"/admin/control/schedule-remove",
"/admin/control/schedule-run",
},
}
if r.Method == http.MethodHead {
@@ -65,6 +71,10 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action")
return
}
if strings.HasPrefix(action, "schedule-") {
s.handleAdminScheduleControl(w, r, action)
return
}
request, ok := decodeAdminControlRequest(w, r)
if !ok {
return
@@ -138,6 +148,85 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleAdminSchedules(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.(ScheduleBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable")
return
}
result, err := backend.ScheduleList(r.Context())
if err != nil {
s.writeControlBackendError(w, "schedule-list", 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) handleAdminScheduleControl(w http.ResponseWriter, r *http.Request, action string) {
backend, ok := s.backend.(ScheduleBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "schedule_backend_unavailable", "Rust bat schedule backend is unavailable")
return
}
var (
method string
result json.RawMessage
err error
)
switch action {
case "schedule-add", "schedule-update", "schedule-remove":
var params backendrpc.ScheduleMutationParams
if !decodeAdminScheduleJSON(w, r, &params) {
return
}
switch action {
case "schedule-add":
method = "schedule.add"
result, err = backend.ScheduleAdd(r.Context(), params)
case "schedule-update":
method = "schedule.update"
result, err = backend.ScheduleUpdate(r.Context(), params)
default:
method = "schedule.remove"
result, err = backend.ScheduleRemove(r.Context(), params)
}
case "schedule-run":
var params backendrpc.ScheduleRunParams
if !decodeAdminScheduleJSON(w, r, &params) {
return
}
method = "schedule.run"
result, err = backend.ScheduleRun(r.Context(), params)
default:
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown control action")
return
}
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: method,
Status: "accepted",
Result: result,
})
}
func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool {
if s.cfg.AuthToken == "" {
writeErrorJSON(w, http.StatusForbidden, "admin_auth_required", "admin controls require BAT_API_AUTH_TOKEN")
@@ -151,6 +240,26 @@ func (s *Server) requireAdminToken(w http.ResponseWriter, r *http.Request) bool
return true
}
func decodeAdminScheduleJSON(w http.ResponseWriter, r *http.Request, target any) bool {
if r.Body == nil {
return true
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, adminScheduleMaxBodyBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
if errors.Is(err, io.EOF) {
return true
}
writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_params", "schedule request must be a JSON object")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_params", "schedule request must contain exactly one JSON object")
return false
}
return true
}
func decodeAdminControlRequest(w http.ResponseWriter, r *http.Request) (adminControlRequest, bool) {
var request adminControlRequest
if r.Body == nil {
+90
View File
@@ -588,6 +588,96 @@ func (b *controlBackend) CatalogRefresh(ctx context.Context, force bool) (*backe
return &backendrpc.TaskAccepted{TaskID: "task-catalog-refresh-1", Kind: "catalog.refresh"}, nil
}
type scheduleBackend struct {
*controlBackend
scheduleCalls []string
scheduleRaw json.RawMessage
}
func (b *scheduleBackend) ScheduleList(ctx context.Context) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.list")
return b.scheduleRaw, nil
}
func (b *scheduleBackend) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.add")
return b.scheduleRaw, nil
}
func (b *scheduleBackend) ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.update")
return b.scheduleRaw, nil
}
func (b *scheduleBackend) ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.remove")
return b.scheduleRaw, nil
}
func (b *scheduleBackend) ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) {
b.scheduleCalls = append(b.scheduleCalls, "schedule.run")
return b.scheduleRaw, nil
}
func TestAdminScheduleEndpointsProxyAuthenticatedRequests(t *testing.T) {
cfg := DefaultConfig()
cfg.AuthToken = "schedule-token"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
backend := &scheduleBackend{
controlBackend: &controlBackend{fakeBackend: &fakeBackend{}},
scheduleRaw: json.RawMessage(`{"command":"schedule-list","status":"ok","schedules":[]}`),
}
s := NewServer(cfg, backend, nil)
request := httptest.NewRequest(http.MethodGet, "/admin/schedules", nil)
request.Header.Set("Authorization", "Bearer schedule-token")
recorder := httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("list status=%d body=%s", recorder.Code, recorder.Body.String())
}
if !json.Valid(recorder.Body.Bytes()) {
t.Fatalf("list body is not JSON: %s", recorder.Body.String())
}
request = httptest.NewRequest(
http.MethodPost,
"/admin/control/schedule-update",
strings.NewReader(`{"id":"nightly-pull","every_seconds":3600,"clear_every":false}`),
)
request.Header.Set("Authorization", "Bearer schedule-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted {
t.Fatalf("update status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.scheduleCalls) != 2 ||
backend.scheduleCalls[0] != "schedule.list" ||
backend.scheduleCalls[1] != "schedule.update" {
t.Fatalf("schedule calls=%v", backend.scheduleCalls)
}
request = httptest.NewRequest(http.MethodPost, "/admin/control/schedule-run", nil)
request.Header.Set("Authorization", "Bearer schedule-token")
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted {
t.Fatalf("run status=%d body=%s", recorder.Code, recorder.Body.String())
}
if len(backend.scheduleCalls) != 3 || backend.scheduleCalls[2] != "schedule.run" {
t.Fatalf("schedule calls=%v", backend.scheduleCalls)
}
request = httptest.NewRequest(http.MethodGet, "/admin/schedules", nil)
recorder = httptest.NewRecorder()
s.Handler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated list status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
root := fixtureRoot(t)
bytes := uint64(20)
+40 -2
View File
@@ -115,16 +115,26 @@ paths:
responses:
"200":
description: Admin links and allowlisted control actions.
/admin/schedules:
get:
summary: List Rust-owned resource workflow schedules
responses:
"200":
description: Current schedule JSON report.
"401":
description: Missing or invalid admin token.
"503":
description: Rust bat schedule backend is unavailable.
/admin/control/{action}:
post:
summary: Forward an allowlisted control action to Rust bat
summary: Forward an allowlisted control or schedule action to Rust bat
parameters:
- name: action
in: path
required: true
schema:
type: string
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh]
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run]
requestBody:
required: false
content:
@@ -135,6 +145,34 @@ paths:
properties:
force:
type: boolean
id:
type: string
group:
type: string
action:
type: string
args:
type: array
items:
type: string
next_run_unix_seconds:
type: integer
format: int64
delay_seconds:
type: integer
format: int64
every_seconds:
type: integer
format: int64
count:
type: integer
format: int64
clear_args:
type: boolean
clear_every:
type: boolean
enabled:
type: boolean
responses:
"202":
description: Rust bat accepted the control request.
+26
View File
@@ -38,6 +38,17 @@ type ControlBackend interface {
CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error)
}
// ScheduleBackend exposes the Rust-owned schedule store to an authenticated
// dashboard. The JSON result remains Rust's report shape so the API does not
// duplicate schedule state or invent a second schema.
type ScheduleBackend interface {
ScheduleList(ctx context.Context) (json.RawMessage, error)
ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error)
ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error)
ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error)
ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error)
}
// RPCClient adapts *backendrpc.Client to Backend.
type RPCClient struct {
Client *backendrpc.Client
@@ -79,6 +90,21 @@ func (r RPCClient) ResourceRepair(ctx context.Context) (*backendrpc.TaskAccepted
func (r RPCClient) CatalogRefresh(ctx context.Context, force bool) (*backendrpc.TaskAccepted, error) {
return r.Client.CatalogRefresh(ctx, force)
}
func (r RPCClient) ScheduleList(ctx context.Context) (json.RawMessage, error) {
return r.Client.ScheduleList(ctx)
}
func (r RPCClient) ScheduleAdd(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
return r.Client.ScheduleAdd(ctx, params)
}
func (r RPCClient) ScheduleUpdate(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
return r.Client.ScheduleUpdate(ctx, params)
}
func (r RPCClient) ScheduleRemove(ctx context.Context, params backendrpc.ScheduleMutationParams) (json.RawMessage, error) {
return r.Client.ScheduleRemove(ctx, params)
}
func (r RPCClient) ScheduleRun(ctx context.Context, params backendrpc.ScheduleRunParams) (json.RawMessage, error) {
return r.Client.ScheduleRun(ctx, params)
}
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.ParseStatus(ctx)
}
+2
View File
@@ -61,6 +61,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig)
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
mux.HandleFunc("/admin/schedules", s.handleAdminSchedules)
mux.HandleFunc("/admin/control/", s.handleAdminControl)
mux.HandleFunc("/admin/", s.handleAdminIndex)
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
@@ -125,6 +126,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
"/" + ServerInfoHost + "/...",
"/openapi.yaml",
"/admin/",
"/admin/schedules",
"/admin/control/{action}",
},
})
+43
View File
@@ -232,6 +232,29 @@ type UnityFSFieldPatchParams struct {
ExpectedValue json.RawMessage `json:"expected_value,omitempty"`
}
// ScheduleMutationParams is shared by schedule.add, schedule.update, and
// schedule.remove. Omitted pointer fields preserve the existing schedule on
// update; clear_* fields explicitly remove values.
type ScheduleMutationParams struct {
ID string `json:"id,omitempty"`
Group string `json:"group,omitempty"`
Action string `json:"action,omitempty"`
Args []string `json:"args,omitempty"`
NextRunUnixSeconds *uint64 `json:"next_run_unix_seconds,omitempty"`
DelaySeconds *uint64 `json:"delay_seconds,omitempty"`
EverySeconds *uint64 `json:"every_seconds,omitempty"`
Count *uint64 `json:"count,omitempty"`
ClearArgs bool `json:"clear_args,omitempty"`
ClearEvery bool `json:"clear_every,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
// ScheduleRunParams selects a schedule or runs every due enabled schedule.
type ScheduleRunParams struct {
ID string `json:"id,omitempty"`
Force bool `json:"force,omitempty"`
}
// Ack is returned by accepted daemon control methods.
type Ack struct {
Command string `json:"command"`
@@ -427,6 +450,26 @@ func (c *Client) ResourceList(ctx context.Context, offset int, limit int) (*Reso
return &out, err
}
func (c *Client) ScheduleList(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.list", nil)
}
func (c *Client) ScheduleAdd(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.add", params)
}
func (c *Client) ScheduleUpdate(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.update", params)
}
func (c *Client) ScheduleRemove(ctx context.Context, params ScheduleMutationParams) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.remove", params)
}
func (c *Client) ScheduleRun(ctx context.Context, params ScheduleRunParams) (json.RawMessage, error) {
return c.rawData(ctx, "schedule.run", params)
}
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return c.rawData(ctx, "catalog.status", nil)
}
+46
View File
@@ -252,6 +252,52 @@ func TestResourceListSendsPagination(t *testing.T) {
}
}
func TestScheduleUpdateSendsMutationParams(t *testing.T) {
every := uint64(3600)
count := uint64(4)
enabled := true
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
if req.Method != "schedule.update" {
t.Fatalf("method = %s", req.Method)
}
var params ScheduleMutationParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode params: %v", err)
}
if params.ID != "nightly-pull" || params.Group != "res" || params.Action != "pull" ||
params.EverySeconds == nil || *params.EverySeconds != every ||
params.Count == nil || *params.Count != count || params.Enabled == nil || !*params.Enabled {
t.Fatalf("params = %#v", params)
}
return testResponse{
Result: testEnvelope{
OK: true,
Status: "ok",
RequestID: "req-test-schedule",
Data: map[string]any{
"command": "schedule-update",
"status": "updated",
},
},
}
})
raw, err := client.ScheduleUpdate(context.Background(), ScheduleMutationParams{
ID: "nightly-pull",
Group: "res",
Action: "pull",
EverySeconds: &every,
Count: &count,
Enabled: &enabled,
})
if err != nil {
t.Fatalf("ScheduleUpdate 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" {