ed53e25e57
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m41s
Cap a player's simultaneous unfinished games per kind (vs_ai, random, friends) with independent guest and durable-account tiers, held in a new single-row backend.config table (-1 = unlimited) behind an in-memory cache and editable live in the admin console (/_gm/limits). Each game is tagged with games.game_kind on creation. This replaces the earlier flat MaxActiveQuickGames=10 combined cap: the per-tier/kind config is the single mechanism, enforced at the same handler gate (ensureUnderGameLimit by kind on lobby/enqueue) plus the durable friends cap in CreateInvitation. game.Service.AtGameLimit only resolves the tier and counts; the limit policy stays at the request edge. Guests are now refused friend requests, friend-code redemption, befriend-in-game and invitation creation outright (403 guest_forbidden) -- previously only the UI hid these. Admin: a kind column in both game lists and the config editor. Defaults: guest 1 vs_ai / 1 random / 0 friends; durable 10 / 10 / 10.
313 lines
12 KiB
Go
313 lines
12 KiB
Go
// Package server wires the backend's HTTP listener: the gin engine, its route
|
|
// groups, the per-request telemetry middleware and the start/stop lifecycle.
|
|
//
|
|
// The /api/v1 route groups (public, user, internal, admin) attach their endpoints
|
|
// to a stable structure; the /user group requires the X-User-ID identity header.
|
|
// The probes /healthz (liveness) and /readyz (database + session-cache readiness)
|
|
// are unauthenticated.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/adminconsole"
|
|
"scrabble/backend/internal/ads"
|
|
"scrabble/backend/internal/banview"
|
|
"scrabble/backend/internal/connector"
|
|
"scrabble/backend/internal/engine"
|
|
"scrabble/backend/internal/feedback"
|
|
"scrabble/backend/internal/game"
|
|
"scrabble/backend/internal/gamelimits"
|
|
"scrabble/backend/internal/link"
|
|
"scrabble/backend/internal/lobby"
|
|
"scrabble/backend/internal/notify"
|
|
"scrabble/backend/internal/payments"
|
|
"scrabble/backend/internal/ratewatch"
|
|
"scrabble/backend/internal/render"
|
|
"scrabble/backend/internal/robokassa"
|
|
"scrabble/backend/internal/session"
|
|
"scrabble/backend/internal/social"
|
|
"scrabble/backend/internal/telemetry"
|
|
)
|
|
|
|
// shutdownTimeout bounds how long Run waits for in-flight requests to finish
|
|
// during a graceful shutdown.
|
|
const shutdownTimeout = 10 * time.Second
|
|
|
|
// defaultPingTimeout bounds the /readyz database ping when Deps.PingTimeout is
|
|
// not set.
|
|
const defaultPingTimeout = 5 * time.Second
|
|
|
|
// Deps carries the runtime dependencies the HTTP layer needs.
|
|
type Deps struct {
|
|
// Logger receives lifecycle, request and readiness diagnostics.
|
|
Logger *zap.Logger
|
|
// DB backs the /readyz database ping. A nil DB skips the database check.
|
|
DB *sql.DB
|
|
// PingTimeout bounds the /readyz database ping.
|
|
PingTimeout time.Duration
|
|
// SessionsReady reports whether the session cache has been warmed. A nil
|
|
// func skips the session-readiness check.
|
|
SessionsReady func() bool
|
|
// Sessions, Accounts and Games are the identity, account and game-domain
|
|
// services the REST handlers route to.
|
|
Sessions *session.Service
|
|
Accounts *account.Store
|
|
Games *game.Service
|
|
// Feedback is the user-feedback domain service (the /api/v1/user/feedback
|
|
// endpoints and the console section). A nil Feedback disables them.
|
|
Feedback *feedback.Service
|
|
// Social, Matchmaker, Invitations and Emails are the domain services
|
|
// the REST handlers route to.
|
|
Social *social.Service
|
|
Matchmaker *lobby.Matchmaker
|
|
Invitations *lobby.InvitationService
|
|
Emails *account.EmailService
|
|
// Links drives account linking & merge: the /api/v1/user/link
|
|
// endpoints. A nil Links disables them.
|
|
Links *link.Service
|
|
// Registry holds the resident dictionaries; the admin console reads its
|
|
// versions and installs new ones uploaded through it. DictDir is the dictionary
|
|
// directory the console stages uploads in and writes version subdirectories to.
|
|
// A nil Registry disables the console.
|
|
Registry *engine.Registry
|
|
DictDir string
|
|
// Connector is the backend's Telegram connector client for operator broadcasts;
|
|
// nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured"
|
|
// notice).
|
|
Connector *connector.Client
|
|
// RateWatch ingests the gateway's rate-limiter rejection reports: the
|
|
// admin console's throttled view + the high-rate auto-flag. A nil RateWatch
|
|
// disables the internal report endpoint and the console view.
|
|
RateWatch *ratewatch.Watch
|
|
// BanView mirrors the gateway's active IP bans for the admin console and
|
|
// collects operator unban requests. A nil BanView disables the internal
|
|
// ban-sync endpoint and the console's active-bans panel.
|
|
BanView *banview.View
|
|
// Ads is the advertising-banner domain service: campaign rotation feeding the
|
|
// profile.get banner block, plus the banner admin console section. A nil Ads
|
|
// omits the banner block and disables the banner console.
|
|
Ads *ads.Service
|
|
// Payments is the in-game currency domain service (wallet, benefits, catalog).
|
|
// The data-foundation layer exposes only a reachability check; its user and
|
|
// console routes are registered when the wallet surface lands. A nil Payments
|
|
// omits them.
|
|
Payments *payments.Service
|
|
// GameLimits is the per-tier, per-kind active-game limit config, cached in memory. The
|
|
// game domain reads it through game.Service (SetGameLimits) for the new-game gate; the admin
|
|
// console reads and edits it here. A nil GameLimits omits the limits console section.
|
|
GameLimits *gamelimits.Service
|
|
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
|
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
|
// them (notify.Nop).
|
|
Notifier notify.Publisher
|
|
// ExportSignKey signs the finished-game export download URLs (export.go). An
|
|
// empty key leaves the export-URL endpoints answering 503/404.
|
|
ExportSignKey string
|
|
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
|
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
|
Renderer *render.Client
|
|
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
|
|
// order and Result-callback endpoints unregistered.
|
|
Robokassa robokassa.Config
|
|
}
|
|
|
|
// Server owns the gin engine, the underlying HTTP server and the readiness
|
|
// dependencies.
|
|
type Server struct {
|
|
log *zap.Logger
|
|
http *http.Server
|
|
db *sql.DB
|
|
pingTimeout time.Duration
|
|
sessionsReady func() bool
|
|
|
|
sessions *session.Service
|
|
accounts *account.Store
|
|
games *game.Service
|
|
feedback *feedback.Service
|
|
social *social.Service
|
|
matchmaker *lobby.Matchmaker
|
|
invitations *lobby.InvitationService
|
|
emails *account.EmailService
|
|
links *link.Service
|
|
registry *engine.Registry
|
|
dictDir string
|
|
connector *connector.Client
|
|
ratewatch *ratewatch.Watch
|
|
banview *banview.View
|
|
ads *ads.Service
|
|
payments *payments.Service
|
|
gamelimits *gamelimits.Service
|
|
robokassa robokassa.Config
|
|
notifier notify.Publisher
|
|
console *adminconsole.Renderer
|
|
exportKey []byte
|
|
renderer *render.Client
|
|
|
|
public *gin.RouterGroup
|
|
user *gin.RouterGroup
|
|
internal *gin.RouterGroup
|
|
}
|
|
|
|
// New returns a Server that will listen on addr. It installs the recovery and
|
|
// telemetry middleware, the infrastructure probes, and the /api/v1 route groups.
|
|
func New(addr string, deps Deps) *Server {
|
|
log := deps.Logger
|
|
if log == nil {
|
|
log = zap.NewNop()
|
|
}
|
|
pingTimeout := deps.PingTimeout
|
|
if pingTimeout <= 0 {
|
|
pingTimeout = defaultPingTimeout
|
|
}
|
|
|
|
gin.SetMode(gin.ReleaseMode)
|
|
engine := gin.New()
|
|
engine.Use(gin.Recovery())
|
|
engine.Use(telemetry.Middleware(log))
|
|
|
|
notifier := deps.Notifier
|
|
if notifier == nil {
|
|
notifier = notify.Nop{}
|
|
}
|
|
|
|
s := &Server{
|
|
log: log,
|
|
db: deps.DB,
|
|
pingTimeout: pingTimeout,
|
|
sessionsReady: deps.SessionsReady,
|
|
sessions: deps.Sessions,
|
|
accounts: deps.Accounts,
|
|
games: deps.Games,
|
|
feedback: deps.Feedback,
|
|
social: deps.Social,
|
|
matchmaker: deps.Matchmaker,
|
|
invitations: deps.Invitations,
|
|
emails: deps.Emails,
|
|
links: deps.Links,
|
|
registry: deps.Registry,
|
|
dictDir: deps.DictDir,
|
|
connector: deps.Connector,
|
|
ratewatch: deps.RateWatch,
|
|
banview: deps.BanView,
|
|
ads: deps.Ads,
|
|
payments: deps.Payments,
|
|
gamelimits: deps.GameLimits,
|
|
robokassa: deps.Robokassa,
|
|
notifier: notifier,
|
|
renderer: deps.Renderer,
|
|
http: &http.Server{Addr: addr, Handler: engine},
|
|
}
|
|
if deps.ExportSignKey != "" {
|
|
s.exportKey = []byte(deps.ExportSignKey)
|
|
}
|
|
s.registerProbes(engine)
|
|
s.registerAPIGroups(engine)
|
|
s.registerRoutes()
|
|
s.registerConsole(engine)
|
|
return s
|
|
}
|
|
|
|
// registerProbes installs the unauthenticated infrastructure probes: /healthz
|
|
// reports process liveness and /readyz reports readiness to serve traffic
|
|
// (database reachable and session cache warmed).
|
|
func (s *Server) registerProbes(engine *gin.Engine) {
|
|
engine.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
|
|
engine.GET("/readyz", s.readyz)
|
|
}
|
|
|
|
// readyz reports 200 only when the database answers a bounded ping and the
|
|
// session cache is warmed; otherwise 503.
|
|
func (s *Server) readyz(c *gin.Context) {
|
|
if s.db != nil {
|
|
ctx, cancel := context.WithTimeout(c.Request.Context(), s.pingTimeout)
|
|
defer cancel()
|
|
if err := s.db.PingContext(ctx); err != nil {
|
|
s.log.Warn("readiness: database ping failed", zap.Error(err))
|
|
c.String(http.StatusServiceUnavailable, "database unavailable")
|
|
return
|
|
}
|
|
}
|
|
if s.sessionsReady != nil && !s.sessionsReady() {
|
|
c.String(http.StatusServiceUnavailable, "sessions not ready")
|
|
return
|
|
}
|
|
c.String(http.StatusOK, "ok")
|
|
}
|
|
|
|
// registerAPIGroups wires the /api/v1 route groups. They are populated by the
|
|
// stages that add their first endpoint; the /user group requires X-User-ID,
|
|
// which the gateway injects after resolving a session.
|
|
func (s *Server) registerAPIGroups(engine *gin.Engine) {
|
|
v1 := engine.Group("/api/v1")
|
|
s.public = v1.Group("/public")
|
|
s.user = v1.Group("/user")
|
|
s.user.Use(RequireUserID())
|
|
// Capture the gateway-injected trusted platform (X-Platform) into the request context,
|
|
// so the payments gate can read it via platform(c). Optional: an untrusted session simply
|
|
// carries no platform and is treated as view-only. Never rejects.
|
|
s.user.Use(platformContext())
|
|
// The suspension gate runs after identity is established: a blocked account is refused on
|
|
// every user route (except the block-status probe) so the UI can show the blocked screen.
|
|
s.user.Use(s.requireNotSuspended())
|
|
s.internal = v1.Group("/internal")
|
|
}
|
|
|
|
// PublicGroup returns the unauthenticated public route group.
|
|
func (s *Server) PublicGroup() *gin.RouterGroup { return s.public }
|
|
|
|
// UserGroup returns the authenticated user route group (requires X-User-ID).
|
|
func (s *Server) UserGroup() *gin.RouterGroup { return s.user }
|
|
|
|
// InternalGroup returns the gateway-facing internal route group.
|
|
func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal }
|
|
|
|
// Social returns the social domain service for the handlers.
|
|
func (s *Server) Social() *social.Service { return s.social }
|
|
|
|
// Matchmaker returns the in-memory matchmaking pool for the handlers.
|
|
func (s *Server) Matchmaker() *lobby.Matchmaker { return s.matchmaker }
|
|
|
|
// Invitations returns the friend-game invitation service for the handlers.
|
|
func (s *Server) Invitations() *lobby.InvitationService { return s.invitations }
|
|
|
|
// Emails returns the email confirm-code service for the handlers.
|
|
func (s *Server) Emails() *account.EmailService { return s.emails }
|
|
|
|
// Handler returns the underlying HTTP handler. It lets tests drive the server
|
|
// without binding a socket and lets callers compose the backend behind
|
|
// another listener.
|
|
func (s *Server) Handler() http.Handler { return s.http.Handler }
|
|
|
|
// Run starts the listener and blocks until ctx is cancelled, then shuts the
|
|
// server down gracefully within shutdownTimeout. It returns the first error
|
|
// that is not the expected http.ErrServerClosed.
|
|
func (s *Server) Run(ctx context.Context) error {
|
|
errc := make(chan error, 1)
|
|
go func() {
|
|
s.log.Info("http listener starting", zap.String("addr", s.http.Addr))
|
|
errc <- s.http.ListenAndServe()
|
|
}()
|
|
|
|
select {
|
|
case err := <-errc:
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
case <-ctx.Done():
|
|
s.log.Info("http listener stopping")
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
defer cancel()
|
|
return s.http.Shutdown(shutdownCtx)
|
|
}
|
|
}
|