Files
galaxy-game/backend/internal/user/user.go
T
2026-05-06 10:14:55 +03:00

219 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}