12e616ceae
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The merchant accepts payments as a sole proprietor on НПД, which is outside 54-ФЗ: there is no online cash register, YooKassa does not serve receipts for that regime at all (checked with their support), and each operation is reported by the merchant to «Мой налог», which issues the чек. So no `receipt` is sent with a payment or a refund. This also removes a failure class rather than just code: a malformed receipt was an API error at payment creation, which broke the purchase outright. The fiscal code is kept dormant rather than deleted. All of it now sits behind one switch, `BACKEND_YOOKASSA_VAT_CODE`: unset — the default — builds and sends nothing; a 54-ФЗ rate code turns «Чеки от ЮKassa» back on unchanged. The return is foreseeable, which is why the switch exists: НПД carries an annual income ceiling, and losing the regime puts 54-ФЗ back in force, at which point this is a deploy-variable edit instead of writing the integration again. The D36 email anchor still gates a direct purchase. It had two justifications — a recovery anchor and the receipt address — and only the second is gone; without an email a paying customer who loses the account loses the chips with it. What was missing is that the rule was enforced but never communicated: the wallet showed the packs to a player signed in through VK or Telegram in a browser, and tapping Buy produced a bare "something went wrong". It now says "add an email in your profile" in the buy tab instead, linking to the profile; spending already-earned chips is untouched. The predicate is a pure function so it is covered by the node-env unit tests rather than needing a browser. Tests: the receipt-off default is pinned by an integration test asserting a purchase carries no receipt, and the dormant path by one that switches a VAT code on and checks the receipt reappears with the right fiscal attributes; plus unit coverage for the enable predicate and the wallet's email rule. A note for whoever runs the numbers next: the shared (svelte + i18n) chunk is now 40 bytes under its 31 KB gzip budget. Decision D51 revised.
331 lines
12 KiB
Go
331 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"
|
|
"scrabble/backend/internal/yookassa"
|
|
)
|
|
|
|
// 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
|
|
// YooKassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
|
|
// set leaves the order and notification endpoints unregistered and falls the direct rail back to
|
|
// Robokassa.
|
|
YooKassa yookassa.Shops
|
|
// YooKassaVatCode is the VAT rate code stamped on every fiscal receipt line (54-ФЗ tag 1199).
|
|
// Unset sends no receipt at all — the state a merchant outside 54-ФЗ runs in.
|
|
YooKassaVatCode int
|
|
// PublicBaseURL is the canonical https origin the payment return URL is built from. It is
|
|
// required whenever YooKassa is configured.
|
|
PublicBaseURL string
|
|
// Robokassa configures the retired direct-rail provider — one merchant shop per channel. No
|
|
// deployment sets it, so the set is empty and the direct rail resolves to YooKassa; it stays
|
|
// wired so restoring the rail is a credentials change (backend/internal/robokassa/README.md).
|
|
Robokassa robokassa.Shops
|
|
}
|
|
|
|
// 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
|
|
yookassa yookassa.Shops
|
|
vatCode int
|
|
publicURL string
|
|
robokassa robokassa.Shops
|
|
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,
|
|
yookassa: deps.YooKassa,
|
|
vatCode: deps.YooKassaVatCode,
|
|
publicURL: deps.PublicBaseURL,
|
|
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)
|
|
}
|
|
}
|