feat: backend service
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user