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
@@ -0,0 +1,318 @@
package server
import (
"context"
"errors"
"net/http"
"time"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/telemetry"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// respondLobbyError maps lobby-package sentinel errors to the standard
// JSON error envelope. Unknown errors land on a 500.
func respondLobbyError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) {
switch {
case errors.Is(err, lobby.ErrInvalidInput):
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error())
case errors.Is(err, lobby.ErrNotFound):
httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found")
case errors.Is(err, lobby.ErrForbidden):
httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error())
case errors.Is(err, lobby.ErrConflict),
errors.Is(err, lobby.ErrInvalidStatus),
errors.Is(err, lobby.ErrRaceNameTaken),
errors.Is(err, lobby.ErrEntitlementExceeded),
errors.Is(err, lobby.ErrPendingExpired):
httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error())
default:
logger.Error(op+" failed",
append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))...,
)
httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error")
}
}
// parseGameIDParam reads `game_id` from the path. Writes 400 envelope on
// invalid input and returns false in that case.
func parseGameIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("game_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
func parseApplicationIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("application_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "application_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
func parseInviteIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("invite_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "invite_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
func parseMembershipIDParam(c *gin.Context) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("membership_id"))
if err != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "membership_id must be a valid UUID")
return uuid.Nil, false
}
return parsed, true
}
// gameSummaryWire mirrors `GameSummary` from openapi.yaml.
type gameSummaryWire struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
GameType string `json:"game_type"`
Status string `json:"status"`
OwnerUserID *string `json:"owner_user_id,omitempty"`
MinPlayers int32 `json:"min_players"`
MaxPlayers int32 `json:"max_players"`
EnrollmentEndsAt string `json:"enrollment_ends_at"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// lobbyGameDetailWire mirrors `LobbyGameDetail` from openapi.yaml.
type lobbyGameDetailWire struct {
gameSummaryWire
Visibility string `json:"visibility"`
Description string `json:"description,omitempty"`
TurnSchedule string `json:"turn_schedule"`
TargetEngineVersion string `json:"target_engine_version"`
StartGapHours int32 `json:"start_gap_hours"`
StartGapPlayers int32 `json:"start_gap_players"`
CurrentTurn int32 `json:"current_turn"`
RuntimeStatus string `json:"runtime_status"`
EngineHealth string `json:"engine_health,omitempty"`
StartedAt *string `json:"started_at,omitempty"`
FinishedAt *string `json:"finished_at,omitempty"`
}
func gameSummaryToWire(g lobby.GameRecord) gameSummaryWire {
out := gameSummaryWire{
GameID: g.GameID.String(),
GameName: g.GameName,
GameType: g.Visibility,
Status: g.Status,
MinPlayers: g.MinPlayers,
MaxPlayers: g.MaxPlayers,
EnrollmentEndsAt: g.EnrollmentEndsAt.UTC().Format(timestampLayout),
CreatedAt: g.CreatedAt.UTC().Format(timestampLayout),
UpdatedAt: g.UpdatedAt.UTC().Format(timestampLayout),
}
if g.OwnerUserID != nil {
s := g.OwnerUserID.String()
out.OwnerUserID = &s
}
return out
}
func lobbyGameDetailToWire(g lobby.GameRecord) lobbyGameDetailWire {
out := lobbyGameDetailWire{
gameSummaryWire: gameSummaryToWire(g),
Visibility: g.Visibility,
Description: g.Description,
TurnSchedule: g.TurnSchedule,
TargetEngineVersion: g.TargetEngineVersion,
StartGapHours: g.StartGapHours,
StartGapPlayers: g.StartGapPlayers,
CurrentTurn: g.RuntimeSnapshot.CurrentTurn,
RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus,
EngineHealth: g.RuntimeSnapshot.EngineHealth,
}
if g.StartedAt != nil {
s := g.StartedAt.UTC().Format(timestampLayout)
out.StartedAt = &s
}
if g.FinishedAt != nil {
s := g.FinishedAt.UTC().Format(timestampLayout)
out.FinishedAt = &s
}
return out
}
// lobbyGameStateChangeWire mirrors `LobbyGameStateChange`.
type lobbyGameStateChangeWire struct {
GameID string `json:"game_id"`
Status string `json:"status"`
RuntimeStatus string `json:"runtime_status,omitempty"`
}
func lobbyGameStateChangeToWire(g lobby.GameRecord) lobbyGameStateChangeWire {
return lobbyGameStateChangeWire{
GameID: g.GameID.String(),
Status: g.Status,
RuntimeStatus: g.RuntimeSnapshot.RuntimeStatus,
}
}
// lobbyApplicationDetailWire mirrors `LobbyApplicationDetail`.
type lobbyApplicationDetailWire struct {
ApplicationID string `json:"application_id"`
GameID string `json:"game_id"`
ApplicantUserID string `json:"applicant_user_id"`
RaceName string `json:"race_name"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
DecidedAt *string `json:"decided_at,omitempty"`
}
func lobbyApplicationDetailToWire(a lobby.Application) lobbyApplicationDetailWire {
out := lobbyApplicationDetailWire{
ApplicationID: a.ApplicationID.String(),
GameID: a.GameID.String(),
ApplicantUserID: a.ApplicantUserID.String(),
RaceName: a.RaceName,
Status: a.Status,
CreatedAt: a.CreatedAt.UTC().Format(timestampLayout),
}
if a.DecidedAt != nil {
s := a.DecidedAt.UTC().Format(timestampLayout)
out.DecidedAt = &s
}
return out
}
// lobbyInviteDetailWire mirrors `LobbyInviteDetail`.
type lobbyInviteDetailWire struct {
InviteID string `json:"invite_id"`
GameID string `json:"game_id"`
InviterUserID string `json:"inviter_user_id"`
InvitedUserID *string `json:"invited_user_id,omitempty"`
Code *string `json:"code,omitempty"`
RaceName string `json:"race_name"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
DecidedAt *string `json:"decided_at,omitempty"`
}
func lobbyInviteDetailToWire(i lobby.Invite) lobbyInviteDetailWire {
out := lobbyInviteDetailWire{
InviteID: i.InviteID.String(),
GameID: i.GameID.String(),
InviterUserID: i.InviterUserID.String(),
RaceName: i.RaceName,
Status: i.Status,
CreatedAt: i.CreatedAt.UTC().Format(timestampLayout),
ExpiresAt: i.ExpiresAt.UTC().Format(timestampLayout),
}
if i.InvitedUserID != nil {
s := i.InvitedUserID.String()
out.InvitedUserID = &s
}
if i.Code != "" {
c := i.Code
out.Code = &c
}
if i.DecidedAt != nil {
s := i.DecidedAt.UTC().Format(timestampLayout)
out.DecidedAt = &s
}
return out
}
// lobbyMembershipDetailWire mirrors `LobbyMembershipDetail`.
type lobbyMembershipDetailWire struct {
MembershipID string `json:"membership_id"`
GameID string `json:"game_id"`
UserID string `json:"user_id"`
RaceName string `json:"race_name"`
CanonicalKey string `json:"canonical_key"`
Status string `json:"status"`
JoinedAt string `json:"joined_at"`
RemovedAt *string `json:"removed_at,omitempty"`
}
func lobbyMembershipDetailToWire(m lobby.Membership) lobbyMembershipDetailWire {
out := lobbyMembershipDetailWire{
MembershipID: m.MembershipID.String(),
GameID: m.GameID.String(),
UserID: m.UserID.String(),
RaceName: m.RaceName,
CanonicalKey: m.CanonicalKey,
Status: m.Status,
JoinedAt: m.JoinedAt.UTC().Format(timestampLayout),
}
if m.RemovedAt != nil {
s := m.RemovedAt.UTC().Format(timestampLayout)
out.RemovedAt = &s
}
return out
}
// raceNameDetailWire mirrors `RaceNameDetail`.
type raceNameDetailWire struct {
Name string `json:"name"`
Canonical string `json:"canonical"`
Status string `json:"status"`
OwnerUserID string `json:"owner_user_id"`
GameID *string `json:"game_id,omitempty"`
SourceGameID *string `json:"source_game_id,omitempty"`
ReservedAt *string `json:"reserved_at,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
RegisteredAt *string `json:"registered_at,omitempty"`
}
func raceNameDetailToWire(e lobby.RaceNameEntry) raceNameDetailWire {
out := raceNameDetailWire{
Name: e.Name,
Canonical: string(e.Canonical),
Status: e.Status,
OwnerUserID: e.OwnerUserID.String(),
}
if e.GameID != (uuid.UUID{}) {
s := e.GameID.String()
out.GameID = &s
}
if e.SourceGameID != nil {
s := e.SourceGameID.String()
out.SourceGameID = &s
}
if e.ReservedAt != nil {
s := e.ReservedAt.UTC().Format(timestampLayout)
out.ReservedAt = &s
}
if e.ExpiresAt != nil {
s := e.ExpiresAt.UTC().Format(timestampLayout)
out.ExpiresAt = &s
}
if e.RegisteredAt != nil {
s := e.RegisteredAt.UTC().Format(timestampLayout)
out.RegisteredAt = &s
}
return out
}
// parseTimePtrField parses a wire timestamp pointer into a time.Time
// pointer. Empty / nil input yields nil. Invalid timestamps return an
// error.
func parseTimePtrField(raw *string, field string) (*time.Time, error) {
if raw == nil || *raw == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339Nano, *raw)
if err != nil {
return nil, errors.New(field + " must be RFC 3339")
}
return &t, nil
}