Files
BlueArchiveToolkit/internal/api/admin.go
T
nyaKazuha 1933d6acb0
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
feat(bat): 补全工作流校验与调度过滤
新增 parse clear-cache 和 i18n validate,补齐 schedule 的作用域过滤与单轮执行上限,并将列表过滤参数暴露给 bat-api dashboard。同步 RPC、OpenAPI、用户文档和回归测试。

Refs #43
2026-08-03 23:47:59 +08:00

330 lines
9.7 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"bat-api/internal/backendrpc"
)
const adminControlMaxBodyBytes = 1024
const adminScheduleMaxBodyBytes = 64 * 1024
type adminControlRequest struct {
Force bool `json:"force"`
}
func (s *Server) handleAdminIndex(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
}
body := AdminIndexResponse{
Service: "bat-api",
Panel: "admin",
Status: "available",
Links: []string{
"/healthz",
"/readyz",
"/v1/bootstrap",
"/v1/release",
"/v1/resources",
"/openapi.yaml",
"/admin/schedules",
},
Controls: []string{
"/admin/control/reload",
"/admin/control/refresh",
"/admin/control/restart",
"/admin/control/sync",
"/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 {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, body)
}
func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
if !s.requireAdminToken(w, r) {
return
}
action := strings.TrimPrefix(r.URL.Path, "/admin/control/")
if action == "" || strings.Contains(action, "/") {
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
}
backend, ok := s.backend.(ControlBackend)
if !ok || backend == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "control_backend_unavailable", "Rust bat control backend is unavailable")
return
}
var (
method string
result any
err error
)
switch action {
case "reload":
if request.Force {
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "reload does not accept force")
return
}
method = "daemon.reload"
result, err = backend.DaemonReload(r.Context())
case "refresh":
method = "daemon.refresh"
result, err = backend.DaemonRefresh(r.Context(), request.Force)
case "restart":
if request.Force {
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "restart does not accept force")
return
}
method = "daemon.restart"
result, err = backend.DaemonRestart(r.Context())
case "sync":
method = "resource.sync"
result, err = backend.ResourceSync(r.Context(), request.Force)
case "verify":
if request.Force {
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "verify does not accept force")
return
}
method = "resource.verify"
result, err = backend.ResourceVerify(r.Context())
case "repair":
if request.Force {
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "repair does not accept force")
return
}
method = "resource.repair"
result, err = backend.ResourceRepair(r.Context())
case "catalog-refresh":
method = "catalog.refresh"
result, err = backend.CatalogRefresh(r.Context(), request.Force)
case "stop", "clean-stable":
writeErrorJSON(w, http.StatusForbidden, "control_not_allowed", "control action is not exposed by bat-api")
return
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) 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
}
params, err := scheduleListParams(r)
if err != nil {
writeErrorJSON(w, http.StatusBadRequest, "invalid_schedule_query", err.Error())
return
}
result, err := backend.ScheduleList(r.Context(), params)
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 scheduleListParams(r *http.Request) (backendrpc.ScheduleListParams, error) {
query := r.URL.Query()
params := backendrpc.ScheduleListParams{
ID: query.Get("id"),
Group: query.Get("group"),
}
if raw := query.Get("enabled"); raw != "" {
enabled, err := strconv.ParseBool(raw)
if err != nil {
return backendrpc.ScheduleListParams{}, errors.New("enabled must be a boolean")
}
params.Enabled = &enabled
}
return params, nil
}
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")
return false
}
if !constantTimeTokenEqual(s.requestToken(r), s.cfg.AuthToken) {
w.Header().Set("WWW-Authenticate", `Bearer realm="bat-api-admin"`)
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized", "missing or invalid access token")
return false
}
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 {
return request, true
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, adminControlMaxBodyBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
if errors.Is(err, io.EOF) {
return request, true
}
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must be a JSON object with an optional force boolean")
return adminControlRequest{}, false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeErrorJSON(w, http.StatusBadRequest, "invalid_control_params", "control request must contain exactly one JSON object")
return adminControlRequest{}, false
}
return request, true
}
func (s *Server) writeControlBackendError(w http.ResponseWriter, action string, err error) {
status := http.StatusBadGateway
code := "control_backend_failed"
message := "Rust bat rejected the control request"
switch {
case errors.Is(err, context.DeadlineExceeded):
status = http.StatusGatewayTimeout
code = "control_backend_timeout"
message = "Rust bat control request timed out"
case errors.Is(err, context.Canceled):
status = http.StatusRequestTimeout
code = "control_request_canceled"
message = "control request was canceled"
default:
var apiErr *backendrpc.APIError
if errors.As(err, &apiErr) && apiErr.Kind == "not_implemented" {
status = http.StatusNotImplemented
code = "control_not_implemented"
message = "Rust bat does not implement this control action"
}
}
s.logger.Printf("bat-api control action=%s error=%v", action, err)
writeErrorJSON(w, status, code, message)
}