feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+246
View File
@@ -0,0 +1,246 @@
// Package lobby owns the platform-side game lifecycle of the Galaxy
// `backend` service. It implements the substage 5.4 surface documented in
// `backend/PLAN.md` §5.4 and `backend/README.md`:
//
// - Games CRUD with the enrollment/start/finish state machine.
// - Applications, invites, and memberships with their lifecycles.
// - Race Name Directory: registered, reservation, pending_registration
// tiers with platform-wide canonical-key uniqueness.
// - User-blocked and user-deleted cascades wired into `internal/user`
// through the `LobbyCascade` interface.
// - Inbound runtime hooks (`OnRuntimeSnapshot`, `OnGameFinished`) called
// by `internal/runtime` once The implementation lands.
// - A periodic sweeper goroutine that releases expired
// `pending_registration` rows and auto-closes enrollment-expired
// games.
//
// Stages 5.5 / 5.7 inject the real RuntimeGateway and
// NotificationPublisher; until then `NewNoopRuntimeGateway` and
// `NewNoopNotificationPublisher` keep the package callable end-to-end.
package lobby
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"galaxy/backend/internal/config"
"github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap"
)
// pgErrCodeUniqueViolation is the SQLSTATE value Postgres emits on a
// UNIQUE constraint violation. Duplicated from `internal/user` and
// `internal/admin` so the lobby package does not import either.
const pgErrCodeUniqueViolation = "23505"
// pgErrCodeCheckViolation is the SQLSTATE value Postgres emits when a
// CHECK constraint rejects a row. Used to map invalid status writes to
// ErrInvalidInput at the boundary.
const pgErrCodeCheckViolation = "23514"
// inviteCodeBytes is the half-byte length of a generated invite code.
// Each byte yields two hex characters, so the wire string is 16 chars.
const inviteCodeBytes = 8
// Visibility values stored verbatim in `games.visibility`.
const (
VisibilityPublic = "public"
VisibilityPrivate = "private"
)
// Game status vocabulary mirrors `games_status_chk` in
// `backend/internal/postgres/migrations/00001_init.sql`.
const (
GameStatusDraft = "draft"
GameStatusEnrollmentOpen = "enrollment_open"
GameStatusReadyToStart = "ready_to_start"
GameStatusStarting = "starting"
GameStatusStartFailed = "start_failed"
GameStatusRunning = "running"
GameStatusPaused = "paused"
GameStatusFinished = "finished"
GameStatusCancelled = "cancelled"
)
// Application status vocabulary mirrors `applications_status_chk`.
const (
ApplicationStatusPending = "pending"
ApplicationStatusApproved = "approved"
ApplicationStatusRejected = "rejected"
)
// Invite status vocabulary mirrors `invites_status_chk`.
const (
InviteStatusPending = "pending"
InviteStatusRedeemed = "redeemed"
InviteStatusDeclined = "declined"
InviteStatusRevoked = "revoked"
InviteStatusExpired = "expired"
)
// Membership status vocabulary mirrors `memberships_status_chk`.
const (
MembershipStatusActive = "active"
MembershipStatusRemoved = "removed"
MembershipStatusBlocked = "blocked"
)
// Race-name status vocabulary mirrors `race_names_status_chk`.
const (
RaceNameStatusRegistered = "registered"
RaceNameStatusReservation = "reservation"
RaceNameStatusPendingRegistration = "pending_registration"
)
// Notification kinds emitted by lobby. Mirrors
// `backend/README.md` §10, where the channel mapping is documented.
const (
NotificationLobbyInviteReceived = "lobby.invite.received"
NotificationLobbyInviteRevoked = "lobby.invite.revoked"
NotificationLobbyApplicationSubmitted = "lobby.application.submitted"
NotificationLobbyApplicationApproved = "lobby.application.approved"
NotificationLobbyApplicationRejected = "lobby.application.rejected"
NotificationLobbyMembershipRemoved = "lobby.membership.removed"
NotificationLobbyMembershipBlocked = "lobby.membership.blocked"
NotificationLobbyRaceNameRegistered = "lobby.race_name.registered"
NotificationLobbyRaceNamePending = "lobby.race_name.pending"
NotificationLobbyRaceNameExpired = "lobby.race_name.expired"
)
// Deps aggregates every collaborator the lobby Service depends on.
//
// Store and Cache are required. Logger and Now default to zap.NewNop /
// time.Now when nil. Runtime, Notification, Entitlement and Policy fall
// back to safe defaults (no-op publishers and a default-locale Policy)
// so unit tests can construct a Service with only Store + Cache populated.
type Deps struct {
Store *Store
Cache *Cache
Runtime RuntimeGateway
Notification NotificationPublisher
Entitlement EntitlementProvider
Policy *Policy
Config config.LobbyConfig
Logger *zap.Logger
Now func() time.Time
}
// Service is the lobby-domain entry point. Every public method is
// goroutine-safe; concurrency safety is delegated to Postgres for
// persisted state and to `*Cache` for the in-memory projection.
type Service struct {
deps Deps
}
// NewService constructs a Service from deps. Logger and Now are
// defaulted; Store and Cache must be non-nil — calling any method with
// a nil Store/Cache will panic at first use (matching how main.go
// signals missing wiring).
func NewService(deps Deps) (*Service, error) {
if deps.Logger == nil {
deps.Logger = zap.NewNop()
}
deps.Logger = deps.Logger.Named("lobby")
if deps.Now == nil {
deps.Now = time.Now
}
if deps.Runtime == nil {
deps.Runtime = NewNoopRuntimeGateway(deps.Logger)
}
if deps.Notification == nil {
deps.Notification = NewNoopNotificationPublisher(deps.Logger)
}
if deps.Policy == nil {
policy, err := NewPolicy()
if err != nil {
return nil, fmt.Errorf("lobby: build default race-name policy: %w", err)
}
deps.Policy = policy
}
if deps.Config.SweeperInterval <= 0 {
deps.Config.SweeperInterval = 60 * time.Second
}
if deps.Config.PendingRegistrationTTL <= 0 {
deps.Config.PendingRegistrationTTL = 30 * 24 * time.Hour
}
if deps.Config.InviteDefaultTTL <= 0 {
deps.Config.InviteDefaultTTL = 7 * 24 * time.Hour
}
return &Service{deps: deps}, nil
}
// Logger exposes the named logger used by the service. Mainly useful for
// tests asserting on log output.
func (s *Service) Logger() *zap.Logger {
if s == nil {
return zap.NewNop()
}
return s.deps.Logger
}
// Cache returns the in-memory projection. Used by main.go for the
// readiness probe and by tests.
func (s *Service) Cache() *Cache {
if s == nil {
return nil
}
return s.deps.Cache
}
// Config returns the lobby-side runtime configuration. Used by the
// sweeper to read the tick interval and by tests to assert the
// pending-registration TTL.
func (s *Service) Config() config.LobbyConfig {
if s == nil {
return config.LobbyConfig{}
}
return s.deps.Config
}
// generateInviteCode produces an `inviteCodeBytes`-byte hex code used
// for code-based invites. The function uses `crypto/rand`; a failure to
// read entropy is propagated to the caller.
func generateInviteCode() (string, error) {
buf := make([]byte, inviteCodeBytes)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("lobby: generate invite code: %w", err)
}
return hex.EncodeToString(buf), nil
}
// isUniqueViolation reports whether err is a Postgres UNIQUE violation,
// optionally restricted to a specific constraint name. When
// constraintName is empty any UNIQUE violation matches.
func isUniqueViolation(err error, constraintName string) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
if pgErr.Code != pgErrCodeUniqueViolation {
return false
}
if constraintName == "" {
return true
}
return pgErr.ConstraintName == constraintName
}
// isCheckViolation reports whether err is a Postgres CHECK constraint
// violation, optionally restricted to a specific constraint name.
func isCheckViolation(err error, constraintName string) bool {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) {
return false
}
if pgErr.Code != pgErrCodeCheckViolation {
return false
}
if constraintName == "" {
return true
}
return pgErr.ConstraintName == constraintName
}