219 lines
7.7 KiB
Go
219 lines
7.7 KiB
Go
// 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
|
||
}
|