Files
scrabble-game/backend/internal/server/server.go
T
Ilia Denisov ce8b5026c1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
feat(payments): add the payments schema, currency domain and money type
Stand up the payments data foundation: a self-contained `payments` Postgres
schema for the in-game currency, wallets, benefits, catalog, orders and the
append-only operations ledger, behind a domain package — nothing wired to real
money yet.

- Migration 00010: the `payments` schema and a NOLOGIN confinement role (ALL on
  payments.*, nothing on backend); the ledger with a BEFORE UPDATE/DELETE
  append-only trigger and a partial idempotency index; the materialised
  balances/benefits; the catalog (atoms seeded) + products + per-method prices;
  the typed single-row config; orders and payment_events. There is no
  cross-schema foreign key — account_id is a plain uuid kept consistent in code,
  which keeps the domain extractable. Expand-contract and reversible.
- Money is a bigint in the currency's minor units carried by a `Money` value
  type (exact, math/big): no float ever touches an amount, and a whole-unit
  currency cannot hold a fraction.
- Extend jetgen to generate the payments schema; construct the service in the
  composition root behind a narrow interface with a boot reachability check.
- Tests: integration (role confinement via SET ROLE, the append-only trigger,
  CHECK constraints, the idempotency index, and a forward+backward migration),
  Money unit tests, and an import-boundary test keeping the payments jet code
  private to the domain.
- Docs: PLAN.md, docs/PAYMENTS.md (+ _ru mirror) updated to the built model.
2026-07-08 01:07:56 +02:00

296 lines
10 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/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render"
"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
// 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
}
// 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
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,
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())
// 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)
}
}