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
+218
View File
@@ -0,0 +1,218 @@
// Package user owns the platform's account identity records inside the
// `backend.accounts` table together with the entitlement, sanction,
// limit and soft-delete surfaces documented in `backend/PLAN.md` §5.2.
//
// The implementation expanded the surface introduced by currently: the package
// now exposes account read/mutation flows, admin-side overrides
// (sanctions, limits, entitlements), in-process soft-delete cascades
// across `lobby`, `notification`, `geo`, and a write-through
// entitlement-snapshot cache that mirrors the
// `backend/internal/auth.Cache` pattern.
//
// External dependencies that have not landed yet (lobby in 5.4,
// notification in 5.7) are injected through the LobbyCascade and
// NotificationCascade interfaces; the package ships no-op
// implementations that satisfy those contracts until the real services
// arrive.
package user
import (
"context"
"crypto/rand"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap"
)
// Constraint names mirror the names declared in
// `backend/internal/postgres/migrations/00001_init.sql`. Keeping them as
// constants avoids string-typo surprises at runtime when error
// classification asks Postgres which UNIQUE was violated.
const (
constraintAccountsEmailUnique = "accounts_email_unique"
constraintAccountsUserNameUnique = "accounts_user_name_unique"
)
// pgErrCodeUniqueViolation is the SQLSTATE value emitted by Postgres when
// a UNIQUE constraint is violated. The pgx driver surfaces the value on
// `*pgconn.PgError`.
const pgErrCodeUniqueViolation = "23505"
// userNameCharset is the alphabet of the placeholder `Player-XXXXXXXX`
// suffix. Mixed-case letters plus digits gives 62^8 ≈ 2.18×10¹⁴
// possibilities, which makes 10 collision retries an enormous safety
// margin even at MVP scale.
const userNameCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
// userNameSuffixLen is the length of the random suffix appended after
// `Player-`.
const userNameSuffixLen = 8
// Deps aggregates every collaborator the user Service depends on.
// Constructing the Service through Deps (rather than positional args)
// keeps wiring patches small when new dependencies are added.
//
// Store must be non-nil. Cache, Lobby, Notification, Geo and
// SessionRevoker are tested-in-isolation interfaces; production wires
// the matching real implementations through `cmd/backend/main.go`.
type Deps struct {
Store *Store
Cache *Cache
Lobby LobbyCascade
Notification NotificationCascade
Geo GeoCascade
SessionRevoker SessionRevoker
// UserNameMaxRetries caps the retry budget for synthesising a unique
// placeholder `accounts.user_name` at registration. A zero or
// negative value falls back to 1.
UserNameMaxRetries int
// Logger is named under "user" by NewService. Nil falls back to
// zap.NewNop.
Logger *zap.Logger
// Now overrides time.Now for deterministic tests. A nil Now defaults
// to time.Now in NewService.
Now func() time.Time
}
// Service is the user-domain entry point. Concurrency safety is
// delegated to Postgres for persisted state and to the embedded Cache
// for the in-memory entitlement snapshot projection.
type Service struct {
deps Deps
}
// NewService constructs a Service from deps. A nil Now defaults to
// time.Now; a nil Logger defaults to zap.NewNop. DB and Store must be
// supplied — calling Service methods with nil values will panic at
// first use, matching how main.go signals missing wiring.
func NewService(deps Deps) *Service {
if deps.Now == nil {
deps.Now = time.Now
}
if deps.Logger == nil {
deps.Logger = zap.NewNop()
}
deps.Logger = deps.Logger.Named("user")
if deps.UserNameMaxRetries <= 0 {
deps.UserNameMaxRetries = 1
}
return &Service{deps: deps}
}
// EnsureByEmail returns the user_id of the live account whose email
// matches the supplied (lower-cased, trimmed) value, creating a new
// account if none exists.
//
// For new accounts the function uses the supplied "would-be" values:
// preferredLanguage is written as-is, timeZone is written as-is, and
// declaredCountry is written as NULL when empty. Existing accounts keep
// every stored value; only their user_id is returned.
//
// EnsureByEmail is idempotent on email under concurrent calls. The
// implementation uses ON CONFLICT (email) DO NOTHING RETURNING so a
// concurrent inserter does not double-create. Synthetic user_name
// collisions are retried with a fresh suffix up to UserNameMaxRetries
// times.
//
// On a successful new-account insert the function additionally
// materialises the default `free` entitlement snapshot inside the same
// transaction so no account exists without a snapshot, and refreshes
// the in-memory cache with the freshly persisted snapshot.
func (s *Service) EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error) {
normalised := strings.ToLower(strings.TrimSpace(email))
if normalised == "" {
return uuid.Nil, errors.New("ensure account by email: email is empty")
}
if userID, ok, err := s.deps.Store.LookupAccountIDByEmail(ctx, normalised); err != nil {
return uuid.Nil, fmt.Errorf("ensure account by email: lookup: %w", err)
} else if ok {
return userID, nil
}
return s.insertNew(ctx, normalised, preferredLanguage, timeZone, declaredCountry)
}
func (s *Service) insertNew(ctx context.Context, email, prefLang, tz, country string) (uuid.UUID, error) {
for attempt := 0; attempt < s.deps.UserNameMaxRetries; attempt++ {
userName, err := generatePlayerName()
if err != nil {
return uuid.Nil, fmt.Errorf("ensure account by email: generate user_name: %w", err)
}
userID := uuid.New()
now := s.deps.Now().UTC()
snapshot := defaultFreeSnapshot(userID, now)
insertedID, err := s.deps.Store.InsertAccountWithSnapshot(ctx, accountInsert{
UserID: userID,
Email: email,
UserName: userName,
PreferredLanguage: prefLang,
TimeZone: tz,
DeclaredCountry: country,
}, snapshot)
switch {
case err == nil:
s.deps.Cache.Add(snapshot)
return insertedID, nil
case errors.Is(err, errEmailRace):
existing, ok, lerr := s.deps.Store.LookupAccountIDByEmail(ctx, email)
if lerr != nil {
return uuid.Nil, fmt.Errorf("ensure account by email: lookup after race: %w", lerr)
}
if !ok {
return uuid.Nil, fmt.Errorf("ensure account by email: email exists yet lookup empty (likely soft-deleted)")
}
return existing, nil
case isUniqueViolation(err, constraintAccountsUserNameUnique):
continue
default:
return uuid.Nil, fmt.Errorf("ensure account by email: insert: %w", err)
}
}
return uuid.Nil, fmt.Errorf("ensure account by email: user_name collisions exceeded %d retries", s.deps.UserNameMaxRetries)
}
// generatePlayerName produces a `Player-XXXXXXXX` placeholder where the
// suffix is eight cryptographically-random alphanumeric characters. The
// modulo-bias of `byte%62` is acceptable here: collision avoidance is
// the only invariant — the placeholder never carries cryptographic
// significance and a future stage may surface a separate "claim
// user_name" flow.
func generatePlayerName() (string, error) {
suffix := make([]byte, userNameSuffixLen)
if _, err := rand.Read(suffix); err != nil {
return "", err
}
for i := range suffix {
suffix[i] = userNameCharset[int(suffix[i])%len(userNameCharset)]
}
var sb strings.Builder
sb.Grow(len("Player-") + userNameSuffixLen)
sb.WriteString("Player-")
sb.Write(suffix)
return sb.String(), nil
}
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
}