e3c2e80a0a
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The direct rail runs on НПД, where the provider neither files with the tax service nor issues a receipt — so nobody was doing it. This registers each rouble purchase, annuls its receipt on a refund, and hands the buyer the receipt by email. Two properties of the (unofficial) lknpd API shape the design. Registering an income takes no idempotency key, so an error does not mean nothing happened: the service name is frozen before the call and carries a marker from the tail of the order id, and after a failure the taxpayer's income list is searched for that exact name. Found means filed; not found halts the queue for a human, because declaring an income twice is as wrong as not declaring it. And faults are classified rather than logged: a token is renewed silently, a throttle backs off, an outage retries, but three unfixable rejections take the rail out of service — a changed format must not become thousands of requests overnight. The console button and the worker share one RunBatch. Automatic mode is armed from the console, not from configuration, so the operator can watch a run go through by hand first. A daily watchdog runs whether or not it is armed, since the case it exists for is the export being off. An idle queue issues no call at all — not even an authentication. No payment path changed: the purchase letter rides the existing payment-event outbox on its own cursor, the receipt and annulment letters ride the export row. Decisions D53-D60.
338 lines
13 KiB
Go
338 lines
13 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/mynalogsync"
|
|
"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
|
|
// MyNalog drives the professional-income tax export from the admin console. A nil MyNalog
|
|
// leaves the console section unregistered, which is how a deployment without an encryption key
|
|
// runs — the rail is dormant rather than half-present.
|
|
MyNalog *mynalogsync.Exporter
|
|
}
|
|
|
|
// 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
|
|
mynalog *mynalogsync.Exporter
|
|
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,
|
|
mynalog: deps.MyNalog,
|
|
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)
|
|
}
|
|
}
|