Files
BlueArchiveToolkit/internal/api/server.go
T
nyaKazuha f441f1810e
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s
feat(i18n): 接入翻译 provider worker
Closes #44
2026-08-30 21:13:31 +08:00

492 lines
16 KiB
Go

package api
import (
"context"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// Server is the bat-api HTTP server for resource bootstrap and distribution.
type Server struct {
cfg Config
backend Backend
logger *log.Logger
limiter *tokenBucketLimiter
mu sync.RWMutex
idx *ReleaseIndex
meta DiscoverResult
refreshInProgress bool
lastRefreshStart time.Time
lastRefreshFinish time.Time
lastRefreshOK time.Time
lastRefreshError string
lastRefreshWarns []string
lastRefreshDur time.Duration
}
// NewServer constructs a server. Call Refresh before Listen when possible.
func NewServer(cfg Config, backend Backend, logger *log.Logger) *Server {
if logger == nil {
logger = log.Default()
}
var limiter *tokenBucketLimiter
if cfg.RateLimitRPS > 0 {
limiter = newTokenBucketLimiter(cfg.RateLimitRPS, cfg.RateLimitBurst)
}
return &Server{cfg: cfg, backend: backend, logger: logger, limiter: limiter}
}
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/readyz", s.handleReadyz)
mux.HandleFunc("/v1/bootstrap", s.handleBootstrap)
mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap)
mux.HandleFunc("/v1/release", s.handleRelease)
mux.HandleFunc("/v1/resources", s.handleResources)
mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug)
mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig)
mux.HandleFunc("/api/launcher/game/config/json", s.handleLauncherGameConfigJSON)
mux.HandleFunc("/api/launcher/advanced/game/download/cdn", s.handleLauncherCdnConfig)
mux.HandleFunc(launcherHostPath("/api/launcher/game/config"), s.handleLauncherGameConfig)
mux.HandleFunc(launcherHostPath("/api/launcher/game/config/json"), s.handleLauncherGameConfigJSON)
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/translation/tasks", s.handleAdminTranslationTasks)
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
mux.HandleFunc("/admin/control/", s.handleAdminControl)
mux.HandleFunc("/admin/", s.handleAdminIndex)
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
mux.HandleFunc("/"+ClientPatchHost+"/", s.serveCDN)
// Catch-all for other official host prefixes and 404.
mux.HandleFunc("/", s.handleRoot)
return s.wrapHandler(mux)
}
// Refresh rebuilds the release index via RPC (and optional resource-root override).
func (s *Server) Refresh(ctx context.Context) error {
started := s.beginRefresh()
result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot)
if err != nil {
s.finishRefresh(started, err, nil)
return err
}
s.mu.Lock()
s.meta = *result
s.idx = result.Index
s.finishRefreshLocked(started, nil, result.Warnings)
s.mu.Unlock()
for _, w := range result.Warnings {
s.logger.Printf("bat-api discover warning: %s", w)
}
return nil
}
func (s *Server) index() *ReleaseIndex {
s.mu.RLock()
defer s.mu.RUnlock()
return s.idx
}
func (s *Server) discoverMeta() DiscoverResult {
s.mu.RLock()
defer s.mu.RUnlock()
return s.meta
}
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
writeNoStoreJSON(w, http.StatusOK, RootResponse{
Service: "bat-api",
Role: "resource_bootstrap_and_distribution",
Note: "resource auto-pull is owned by Rust bat; this service bootstraps and distributes published resources",
Endpoints: []string{
"/healthz",
"/readyz",
"/v1/bootstrap",
"/v1/launcher/bootstrap",
"/v1/release",
"/v1/resources",
"/v1/server-info",
"/api/launcher/game/config",
"/api/launcher/game/config/json",
"/api/launcher/advanced/game/download/cdn",
"/" + LauncherAPIHost + "/api/launcher/game/config",
"/" + LauncherAPIHost + "/api/launcher/game/config/json",
"/" + LauncherAPIHost + "/api/launcher/advanced/game/download/cdn",
"/" + ClientPatchHost + "/...",
"/" + ServerInfoHost + "/...",
"/openapi.yaml",
"/admin/",
"/admin/schedules",
"/admin/translation/tasks",
"/admin/translation/handoff",
"/admin/control/{action}",
},
})
return
}
// Attempt CDN for known hosts not registered above.
if strings.HasPrefix(r.URL.Path, "/"+ClientPatchHost+"/") ||
strings.HasPrefix(r.URL.Path, "/"+ServerInfoHost+"/") {
if strings.HasPrefix(r.URL.Path, "/"+ServerInfoHost+"/") {
s.handleServerInfoCDN(w, r)
return
}
s.serveCDN(w, r)
return
}
http.NotFound(w, r)
}
func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
meta := s.discoverMeta()
sum := ReleaseSummary{}
if idx := s.index(); idx != nil {
sum = idx.Summary()
}
publicBase := strings.TrimRight(s.cfg.PublicBaseURL, "/")
addressablesRoot := ""
if sum.Snapshot != nil && sum.Snapshot.AddressablesRoot != "" {
if rewritten, ok := rewriteAddressablesRoot(sum.Snapshot.AddressablesRoot, publicBase); ok {
addressablesRoot = rewritten
}
}
status := http.StatusOK
if !sum.Ready {
status = http.StatusServiceUnavailable
}
writeNoStoreJSON(w, status, BootstrapResponse{
Service: "bat-api",
Ready: sum.Ready,
Bat: BootstrapBat{
Role: "sync_daemon_and_release_producer",
Socket: s.cfg.SocketPath,
RPCAvailable: meta.RPCAvailable || sum.RPCAvailable,
DoctorHealthy: sum.DoctorHealthy,
},
BatAPI: BootstrapAPI{
Role: "resource_bootstrap_and_distribution",
PublicBase: publicBase,
RefreshIntervalSeconds: int64(s.cfg.RefreshInterval.Seconds()),
Refresh: s.refreshSnapshot(),
},
Resource: BootstrapResource{
Release: sum.Snapshot,
ResourceRoot: sum.ResourceRoot,
Source: sum.Source,
ManifestVersion: sum.ManifestVersion,
EntryCount: sum.EntryCount,
PresentCount: sum.PresentCount,
MissingCount: sum.MissingCount,
ServerInfoURL: publicBase + "/" + ServerInfoHost + "/server-info.json",
ClientPatchBaseURL: publicBase + "/" + ClientPatchHost,
AddressablesCatalogURLRoot: addressablesRoot,
},
Policy: BootstrapPolicy{
PullOwner: "rust_bat",
ResourceRootDiscovery: "bat_sock_rpc",
Deployment: "co_located_with_rust_bat_or_shared_filesystem",
ResourceRootOverride: s.cfg.ResourceRoot != "",
ServesOnlyPublishedRelease: true,
WritesReleaseState: false,
FullGameBusinessAPI: false,
},
Endpoints: []string{
"/v1/launcher/bootstrap",
"/api/launcher/game/config",
"/api/launcher/game/config/json",
"/api/launcher/advanced/game/download/cdn",
"/v1/server-info",
"/" + ServerInfoHost + "/server-info.json",
"/" + ClientPatchHost + "/...",
},
Warnings: meta.Warnings,
})
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
meta := s.discoverMeta()
sum := ReleaseSummary{}
if idx := s.index(); idx != nil {
sum = idx.Summary()
}
writeNoStoreJSON(w, http.StatusOK, map[string]any{
"ok": true,
"service": "bat-api",
"listen": s.cfg.Listen,
"socket": s.cfg.SocketPath,
"public_base": s.cfg.PublicBaseURL,
"rpc_available": meta.RPCAvailable || sum.RPCAvailable,
"doctor_healthy": sum.DoctorHealthy,
"refresh_interval_seconds": int64(s.cfg.RefreshInterval.Seconds()),
"refresh": s.refreshSnapshot(),
"resource_root": sum.ResourceRoot,
"resource_root_override_configured": s.cfg.ResourceRoot != "",
"ready": sum.Ready,
"entry_count": sum.EntryCount,
"present_count": sum.PresentCount,
"missing_count": sum.MissingCount,
"source": sum.Source,
"warnings": meta.Warnings,
// Database/redis are reserved config surface for a normal API process.
"database_configured": s.cfg.DatabaseURL != "",
"redis_configured": s.cfg.RedisURL != "",
})
}
func (s *Server) handleReadyz(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
}
meta := s.discoverMeta()
sum := ReleaseSummary{}
if idx := s.index(); idx != nil {
sum = idx.Summary()
}
status := http.StatusOK
if !sum.Ready {
status = http.StatusServiceUnavailable
}
body := map[string]any{
"ready": sum.Ready,
"service": "bat-api",
"rpc_available": meta.RPCAvailable || sum.RPCAvailable,
"resource_root": sum.ResourceRoot,
"entry_count": sum.EntryCount,
"present_count": sum.PresentCount,
"missing_count": sum.MissingCount,
"source": sum.Source,
"refresh": s.refreshSnapshot(),
}
if r.Method == http.MethodHead {
w.WriteHeader(status)
return
}
writeNoStoreJSON(w, status, body)
}
func (s *Server) handleRelease(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
idx := s.index()
if idx == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_not_ready", "release index not ready")
return
}
writeNoStoreJSON(w, http.StatusOK, idx.Summary())
}
func (s *Server) handleResources(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
idx := s.index()
if idx == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_not_ready", "index not ready")
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 100
}
if limit > s.cfg.MaxResourcePageLimit {
limit = s.cfg.MaxResourcePageLimit
}
items, total := idx.List(offset, limit)
writeNoStoreJSON(w, http.StatusOK, ResourceListResponse{
Total: total,
Offset: offset,
Limit: limit,
Items: items,
})
}
func (s *Server) handleServerInfoDebug(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
s.writeServerInfo(w, r, "")
}
func (s *Server) handleServerInfoCDN(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
}
name := strings.TrimPrefix(r.URL.Path, "/"+ServerInfoHost+"/")
name = strings.TrimPrefix(name, "/")
s.writeServerInfo(w, r, name)
}
func (s *Server) writeServerInfo(w http.ResponseWriter, r *http.Request, name string) {
raw, err := LoadServerInfoBytes(s.cfg, s.index(), name)
if err != nil {
writeErrorJSON(w, http.StatusNotFound, "server_info_not_found", err.Error())
return
}
rewritten, err := RewriteServerInfoAddressables(raw, s.cfg.PublicBaseURL)
if err != nil {
writeErrorJSON(w, http.StatusInternalServerError, "server_info_invalid", err.Error())
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rewritten)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
data, err := json.Marshal(body)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
fallback, _ := json.Marshal(ErrorResponse{
Error: ErrorBody{Code: "internal_error", Message: err.Error(), Status: http.StatusInternalServerError},
})
_, _ = w.Write(fallback)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(data)
}
func (s *Server) beginRefresh() time.Time {
now := time.Now()
s.mu.Lock()
s.refreshInProgress = true
s.lastRefreshStart = now
s.mu.Unlock()
return now
}
func (s *Server) finishRefresh(started time.Time, err error, warnings []string) {
s.mu.Lock()
defer s.mu.Unlock()
s.finishRefreshLocked(started, err, warnings)
}
func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) {
now := time.Now()
s.refreshInProgress = false
s.lastRefreshFinish = now
s.lastRefreshDur = now.Sub(started)
s.lastRefreshWarns = append([]string(nil), warnings...)
if err != nil {
s.lastRefreshError = err.Error()
return
}
s.lastRefreshError = ""
s.lastRefreshOK = now
}
func (s *Server) refreshSnapshot() RefreshDiagnostics {
s.mu.RLock()
defer s.mu.RUnlock()
warnings := append([]string(nil), s.lastRefreshWarns...)
return RefreshDiagnostics{
InProgress: s.refreshInProgress,
LastAttemptUnixSeconds: unixPtr(s.lastRefreshStart),
LastFinishedUnixSeconds: unixPtr(s.lastRefreshFinish),
LastSuccessUnixSeconds: unixPtr(s.lastRefreshOK),
LastDurationMilliseconds: s.lastRefreshDur.Milliseconds(),
LastError: s.lastRefreshError,
LastWarningCount: len(warnings),
LastWarnings: warnings,
RefreshIntervalSeconds: int64(s.cfg.RefreshInterval.Seconds()),
ResourceRootOverrideActive: s.cfg.ResourceRoot != "",
}
}
func unixPtr(t time.Time) *int64 {
if t.IsZero() {
return nil
}
v := t.Unix()
return &v
}
// StartRefreshLoop keeps the in-memory release index aligned with the Rust bat daemon.
func (s *Server) StartRefreshLoop(ctx context.Context) {
if s.cfg.RefreshInterval <= 0 {
s.logger.Printf("bat-api periodic release discovery disabled")
return
}
go func() {
ticker := time.NewTicker(s.cfg.RefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
refreshCtx, cancel := context.WithTimeout(ctx, s.cfg.RPCTimeout+5*time.Second)
if err := s.Refresh(refreshCtx); err != nil {
s.logger.Printf("periodic discover failed: %v", err)
}
cancel()
}
}
}()
}
// ListenAndServe starts the HTTP server until ctx is cancelled.
func (s *Server) ListenAndServe(ctx context.Context) error {
httpServer := &http.Server{
Addr: s.cfg.Listen,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
errCh := make(chan error, 1)
go func() {
s.logger.Printf("bat-api listening on %s (socket=%s public=%s)", s.cfg.Listen, s.cfg.SocketPath, s.cfg.PublicBaseURL)
errCh <- httpServer.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
return ctx.Err()
case err := <-errCh:
if err == http.ErrServerClosed {
return nil
}
return err
}
}