mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
feat(bat): 完善工作流调度与 dashboard RPC
补全资源拉取、解析、翻译、重打包和本地化发布命令,支持单次、限定次数与周期调度。移除 TUI 计划并通过 schedule.* RPC 暴露给 bat-api dashboard。 Closes #43
This commit is contained in:
@@ -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, ¶ms) {
|
||||
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, ¶ms) {
|
||||
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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user