feat: user service

This commit is contained in:
Ilia Denisov
2026-04-10 19:05:02 +02:00
committed by GitHub
parent 710bad712e
commit 23ffcb7535
140 changed files with 33418 additions and 952 deletions
@@ -0,0 +1,200 @@
// Package redisstate defines the frozen Redis logical keyspace and pagination
// helpers used by future User Service storage adapters.
package redisstate
import (
"encoding/base64"
"fmt"
"strings"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
const defaultPrefix = "user:"
// Keyspace builds the frozen Redis logical keys used by future storage
// adapters. The package intentionally exposes key construction only and does
// not depend on any Redis client.
type Keyspace struct {
// Prefix stores the namespace prefix applied to every key. The zero value
// uses `user:`.
Prefix string
}
// Account returns the primary user-account key for userID.
func (k Keyspace) Account(userID common.UserID) string {
return k.prefix() + "account:" + encodeKeyComponent(userID.String())
}
// EmailLookup returns the exact normalized e-mail lookup key.
func (k Keyspace) EmailLookup(email common.Email) string {
return k.prefix() + "lookup:email:" + encodeKeyComponent(email.String())
}
// RaceNameLookup returns the exact stored race-name lookup key.
func (k Keyspace) RaceNameLookup(raceName common.RaceName) string {
return k.prefix() + "lookup:race-name:" + encodeKeyComponent(raceName.String())
}
// RaceNameReservation returns the replaceable canonical race-name reservation
// key.
func (k Keyspace) RaceNameReservation(key account.RaceNameCanonicalKey) string {
return k.prefix() + "reservation:race-name:" + encodeKeyComponent(key.String())
}
// BlockedEmailSubject returns the dedicated blocked-email-subject key.
func (k Keyspace) BlockedEmailSubject(email common.Email) string {
return k.prefix() + "blocked-email:" + encodeKeyComponent(email.String())
}
// EntitlementRecord returns the primary entitlement history-record key.
func (k Keyspace) EntitlementRecord(recordID entitlement.EntitlementRecordID) string {
return k.prefix() + "entitlement:record:" + encodeKeyComponent(recordID.String())
}
// EntitlementHistory returns the per-user entitlement-history index key.
func (k Keyspace) EntitlementHistory(userID common.UserID) string {
return k.prefix() + "entitlement:history:" + encodeKeyComponent(userID.String())
}
// EntitlementSnapshot returns the current entitlement-snapshot key.
func (k Keyspace) EntitlementSnapshot(userID common.UserID) string {
return k.prefix() + "entitlement:snapshot:" + encodeKeyComponent(userID.String())
}
// SanctionRecord returns the primary sanction history-record key.
func (k Keyspace) SanctionRecord(recordID policy.SanctionRecordID) string {
return k.prefix() + "sanction:record:" + encodeKeyComponent(recordID.String())
}
// SanctionHistory returns the per-user sanction-history index key.
func (k Keyspace) SanctionHistory(userID common.UserID) string {
return k.prefix() + "sanction:history:" + encodeKeyComponent(userID.String())
}
// ActiveSanction returns the per-user active-sanction slot for one sanction
// code. The slot guarantees at most one active sanction per `user_id +
// sanction_code`.
func (k Keyspace) ActiveSanction(userID common.UserID, code policy.SanctionCode) string {
return k.prefix() + "sanction:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
}
// LimitRecord returns the primary limit history-record key.
func (k Keyspace) LimitRecord(recordID policy.LimitRecordID) string {
return k.prefix() + "limit:record:" + encodeKeyComponent(recordID.String())
}
// LimitHistory returns the per-user limit-history index key.
func (k Keyspace) LimitHistory(userID common.UserID) string {
return k.prefix() + "limit:history:" + encodeKeyComponent(userID.String())
}
// ActiveLimit returns the per-user active-limit slot for one limit code. The
// slot guarantees at most one active limit per `user_id + limit_code`.
func (k Keyspace) ActiveLimit(userID common.UserID, code policy.LimitCode) string {
return k.prefix() + "limit:active:" + encodeKeyComponent(userID.String()) + ":" + encodeKeyComponent(string(code))
}
// CreatedAtIndex returns the deterministic newest-first user-ordering index.
func (k Keyspace) CreatedAtIndex() string {
return k.prefix() + "index:created-at"
}
// PaidStateIndex returns the coarse free-versus-paid index key.
func (k Keyspace) PaidStateIndex(state entitlement.PaidState) string {
return k.prefix() + "index:paid-state:" + encodeKeyComponent(string(state))
}
// FinitePaidExpiryIndex returns the finite paid-expiry index key. Lifetime
// plans intentionally do not participate in this index.
func (k Keyspace) FinitePaidExpiryIndex() string {
return k.prefix() + "index:paid-expiry:finite"
}
// DeclaredCountryIndex returns the current declared-country reverse-lookup
// index key.
func (k Keyspace) DeclaredCountryIndex(code common.CountryCode) string {
return k.prefix() + "index:declared-country:" + encodeKeyComponent(code.String())
}
// ActiveSanctionCodeIndex returns the reverse-lookup index key for users with
// an active sanction code.
func (k Keyspace) ActiveSanctionCodeIndex(code policy.SanctionCode) string {
return k.prefix() + "index:active-sanction:" + encodeKeyComponent(string(code))
}
// ActiveLimitCodeIndex returns the reverse-lookup index key for users with an
// active limit code.
func (k Keyspace) ActiveLimitCodeIndex(code policy.LimitCode) string {
return k.prefix() + "index:active-limit:" + encodeKeyComponent(string(code))
}
// EligibilityMarkerIndex returns the reverse-lookup index key for one derived
// eligibility marker boolean.
func (k Keyspace) EligibilityMarkerIndex(marker policy.EligibilityMarker, value bool) string {
return fmt.Sprintf("%sindex:eligibility:%s:%t", k.prefix(), encodeKeyComponent(string(marker)), value)
}
// CreatedAtScore returns the frozen ZSET score representation for created-at
// ordering and deterministic pagination.
func CreatedAtScore(createdAt time.Time) float64 {
return float64(createdAt.UTC().UnixMicro())
}
// ExpiryScore returns the frozen ZSET score representation for finite paid
// expiry ordering.
func ExpiryScore(expiresAt time.Time) float64 {
return float64(expiresAt.UTC().UnixMicro())
}
// PageCursor identifies the last seen `(created_at, user_id)` tuple used by
// deterministic newest-first pagination.
type PageCursor struct {
// CreatedAt stores the created-at component of the last seen row.
CreatedAt time.Time
// UserID stores the user-id tiebreaker component of the last seen row.
UserID common.UserID
}
// Validate reports whether PageCursor contains a complete cursor tuple.
func (cursor PageCursor) Validate() error {
if err := common.ValidateTimestamp("page cursor created at", cursor.CreatedAt); err != nil {
return err
}
if err := cursor.UserID.Validate(); err != nil {
return fmt.Errorf("page cursor user id: %w", err)
}
return nil
}
// ComparePageOrder compares two listing positions using the frozen ordering:
// `created_at desc`, then `user_id desc`.
func ComparePageOrder(left PageCursor, right PageCursor) int {
switch {
case left.CreatedAt.After(right.CreatedAt):
return -1
case left.CreatedAt.Before(right.CreatedAt):
return 1
default:
return -strings.Compare(left.UserID.String(), right.UserID.String())
}
}
func (k Keyspace) prefix() string {
prefix := strings.TrimSpace(k.Prefix)
if prefix == "" {
return defaultPrefix
}
return prefix
}
func encodeKeyComponent(value string) string {
return base64.RawURLEncoding.EncodeToString([]byte(value))
}
@@ -0,0 +1,59 @@
package redisstate
import (
"testing"
"time"
"galaxy/user/internal/domain/account"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"github.com/stretchr/testify/require"
)
func TestKeyspaceBuildsStableKeys(t *testing.T) {
t.Parallel()
keyspace := Keyspace{Prefix: "custom:"}
require.Equal(t, "custom:account:dXNlci0xMjM", keyspace.Account(common.UserID("user-123")))
require.Equal(t, "custom:lookup:email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.EmailLookup(common.Email("pilot@example.com")))
require.Equal(t, "custom:lookup:race-name:UGlsb3QgTm92YQ", keyspace.RaceNameLookup(common.RaceName("Pilot Nova")))
require.Equal(t, "custom:reservation:race-name:cGlsb3Qtbm92YQ", keyspace.RaceNameReservation(account.RaceNameCanonicalKey("pilot-nova")))
require.Equal(t, "custom:blocked-email:cGlsb3RAZXhhbXBsZS5jb20", keyspace.BlockedEmailSubject(common.Email("pilot@example.com")))
require.Equal(t, "custom:entitlement:record:ZW50aXRsZW1lbnQtMTIz", keyspace.EntitlementRecord(entitlement.EntitlementRecordID("entitlement-123")))
require.Equal(t, "custom:sanction:record:c2FuY3Rpb24tMQ", keyspace.SanctionRecord(policy.SanctionRecordID("sanction-1")))
require.Equal(t, "custom:limit:record:bGltaXQtMQ", keyspace.LimitRecord(policy.LimitRecordID("limit-1")))
require.Equal(t, "custom:sanction:active:dXNlci0xMjM:bG9naW5fYmxvY2s", keyspace.ActiveSanction(common.UserID("user-123"), policy.SanctionCodeLoginBlock))
require.Equal(t, "custom:limit:active:dXNlci0xMjM:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimit(common.UserID("user-123"), policy.LimitCodeMaxOwnedPrivateGames))
require.Equal(t, "custom:index:created-at", keyspace.CreatedAtIndex())
require.Equal(t, "custom:index:paid-state:cGFpZA", keyspace.PaidStateIndex(entitlement.PaidStatePaid))
require.Equal(t, "custom:index:paid-expiry:finite", keyspace.FinitePaidExpiryIndex())
require.Equal(t, "custom:index:declared-country:REU", keyspace.DeclaredCountryIndex(common.CountryCode("DE")))
require.Equal(t, "custom:index:active-sanction:bG9naW5fYmxvY2s", keyspace.ActiveSanctionCodeIndex(policy.SanctionCodeLoginBlock))
require.Equal(t, "custom:index:active-limit:bWF4X293bmVkX3ByaXZhdGVfZ2FtZXM", keyspace.ActiveLimitCodeIndex(policy.LimitCodeMaxOwnedPrivateGames))
require.Equal(t, "custom:index:eligibility:Y2FuX2xvZ2lu:true", keyspace.EligibilityMarkerIndex(policy.EligibilityMarkerCanLogin, true))
}
func TestComparePageOrder(t *testing.T) {
t.Parallel()
newer := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-200")}
older := PageCursor{CreatedAt: time.Unix(10, 0).UTC(), UserID: common.UserID("user-100")}
sameTimeHigherUserID := PageCursor{CreatedAt: time.Unix(20, 0).UTC(), UserID: common.UserID("user-300")}
require.Negative(t, ComparePageOrder(newer, older))
require.Positive(t, ComparePageOrder(older, newer))
require.Negative(t, ComparePageOrder(sameTimeHigherUserID, newer))
}
func TestScoresUseUnixMicro(t *testing.T) {
t.Parallel()
value := time.Unix(1_775_240_000, 123_000).UTC()
want := float64(value.UnixMicro())
require.Equal(t, want, CreatedAtScore(value))
require.Equal(t, want, ExpiryScore(value))
}
@@ -0,0 +1,191 @@
package redisstate
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
)
var (
// ErrPageTokenFiltersMismatch reports that a supplied page token was created
// for a different normalized filter set.
ErrPageTokenFiltersMismatch = errors.New("page token filters do not match current filters")
)
// UserListFilters stores the frozen admin-listing filter set that becomes part
// of the opaque page token fingerprint.
type UserListFilters struct {
// PaidState stores the coarse free-versus-paid filter.
PaidState entitlement.PaidState
// PaidExpiresBefore stores the optional finite-paid expiry upper bound.
PaidExpiresBefore *time.Time
// PaidExpiresAfter stores the optional finite-paid expiry lower bound.
PaidExpiresAfter *time.Time
// DeclaredCountry stores the optional declared-country filter.
DeclaredCountry common.CountryCode
// SanctionCode stores the optional active-sanction filter.
SanctionCode policy.SanctionCode
// LimitCode stores the optional active-limit filter.
LimitCode policy.LimitCode
// CanLogin stores the optional login-eligibility filter.
CanLogin *bool
// CanCreatePrivateGame stores the optional private-game-create eligibility
// filter.
CanCreatePrivateGame *bool
// CanJoinGame stores the optional join-game eligibility filter.
CanJoinGame *bool
}
// Validate reports whether UserListFilters is structurally valid.
func (filters UserListFilters) Validate() error {
if !filters.PaidState.IsKnown() {
return fmt.Errorf("paid state %q is unsupported", filters.PaidState)
}
if filters.PaidExpiresBefore != nil && filters.PaidExpiresBefore.IsZero() {
return fmt.Errorf("paid expires before must not be zero")
}
if filters.PaidExpiresAfter != nil && filters.PaidExpiresAfter.IsZero() {
return fmt.Errorf("paid expires after must not be zero")
}
if !filters.DeclaredCountry.IsZero() {
if err := filters.DeclaredCountry.Validate(); err != nil {
return fmt.Errorf("declared country: %w", err)
}
}
if filters.SanctionCode != "" && !filters.SanctionCode.IsKnown() {
return fmt.Errorf("sanction code %q is unsupported", filters.SanctionCode)
}
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
}
return nil
}
// EncodePageToken encodes cursor and filters into the frozen opaque page token
// format.
func EncodePageToken(cursor PageCursor, filters UserListFilters) (string, error) {
if err := cursor.Validate(); err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
fingerprint, err := normalizeFilters(filters)
if err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
payload, err := json.Marshal(pageTokenPayload{
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
UserID: cursor.UserID.String(),
Filters: fingerprint,
})
if err != nil {
return "", fmt.Errorf("encode page token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
// DecodePageToken decodes raw into the frozen page cursor and verifies that
// the embedded normalized filter set matches expectedFilters.
func DecodePageToken(raw string, expectedFilters UserListFilters) (PageCursor, error) {
fingerprint, err := normalizeFilters(expectedFilters)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
payload, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
var token pageTokenPayload
if err := json.Unmarshal(payload, &token); err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
if token.Filters != fingerprint {
return PageCursor{}, ErrPageTokenFiltersMismatch
}
createdAt, err := time.Parse(time.RFC3339Nano, token.CreatedAt)
if err != nil {
return PageCursor{}, fmt.Errorf("decode page token: parse created_at: %w", err)
}
cursor := PageCursor{
CreatedAt: createdAt.UTC(),
UserID: common.UserID(token.UserID),
}
if err := cursor.Validate(); err != nil {
return PageCursor{}, fmt.Errorf("decode page token: %w", err)
}
return cursor, nil
}
type pageTokenPayload struct {
CreatedAt string `json:"created_at"`
UserID string `json:"user_id"`
Filters normalizedFilterPayload `json:"filters"`
}
type normalizedFilterPayload struct {
PaidState string `json:"paid_state,omitempty"`
PaidExpiresBeforeUTC string `json:"paid_expires_before_utc,omitempty"`
PaidExpiresAfterUTC string `json:"paid_expires_after_utc,omitempty"`
DeclaredCountry string `json:"declared_country,omitempty"`
SanctionCode string `json:"sanction_code,omitempty"`
LimitCode string `json:"limit_code,omitempty"`
CanLogin string `json:"can_login,omitempty"`
CanCreatePrivateGame string `json:"can_create_private_game,omitempty"`
CanJoinGame string `json:"can_join_game,omitempty"`
}
func normalizeFilters(filters UserListFilters) (normalizedFilterPayload, error) {
if err := filters.Validate(); err != nil {
return normalizedFilterPayload{}, err
}
return normalizedFilterPayload{
PaidState: string(filters.PaidState),
PaidExpiresBeforeUTC: formatOptionalTime(filters.PaidExpiresBefore),
PaidExpiresAfterUTC: formatOptionalTime(filters.PaidExpiresAfter),
DeclaredCountry: filters.DeclaredCountry.String(),
SanctionCode: string(filters.SanctionCode),
LimitCode: string(filters.LimitCode),
CanLogin: formatOptionalBool(filters.CanLogin),
CanCreatePrivateGame: formatOptionalBool(filters.CanCreatePrivateGame),
CanJoinGame: formatOptionalBool(filters.CanJoinGame),
}, nil
}
func formatOptionalTime(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339Nano)
}
func formatOptionalBool(value *bool) string {
if value == nil {
return ""
}
if *value {
return "true"
}
return "false"
}
@@ -0,0 +1,70 @@
package redisstate
import (
"testing"
"time"
"galaxy/user/internal/domain/common"
"galaxy/user/internal/domain/entitlement"
"galaxy/user/internal/domain/policy"
"github.com/stretchr/testify/require"
)
func TestEncodeDecodePageToken(t *testing.T) {
t.Parallel()
before := time.Unix(1_775_250_000, 0).UTC()
after := time.Unix(1_775_240_000, 0).UTC()
canLogin := true
canCreate := false
canJoin := true
filters := UserListFilters{
PaidState: entitlement.PaidStatePaid,
PaidExpiresBefore: &before,
PaidExpiresAfter: &after,
DeclaredCountry: common.CountryCode("DE"),
SanctionCode: policy.SanctionCodeLoginBlock,
LimitCode: policy.LimitCodeMaxOwnedPrivateGames,
CanLogin: &canLogin,
CanCreatePrivateGame: &canCreate,
CanJoinGame: &canJoin,
}
cursor := PageCursor{
CreatedAt: time.Unix(1_775_240_100, 987_000_000).UTC(),
UserID: common.UserID("user-123"),
}
token, err := EncodePageToken(cursor, filters)
require.NoError(t, err)
decoded, err := DecodePageToken(token, filters)
require.NoError(t, err)
require.Equal(t, cursor, decoded)
}
func TestDecodePageTokenFilterMismatch(t *testing.T) {
t.Parallel()
cursor := PageCursor{
CreatedAt: time.Unix(1_775_240_100, 0).UTC(),
UserID: common.UserID("user-123"),
}
filters := UserListFilters{
PaidState: entitlement.PaidStatePaid,
}
token, err := EncodePageToken(cursor, filters)
require.NoError(t, err)
_, err = DecodePageToken(token, UserListFilters{PaidState: entitlement.PaidStateFree})
require.ErrorIs(t, err, ErrPageTokenFiltersMismatch)
}
func TestDecodePageTokenRejectsInvalidInput(t *testing.T) {
t.Parallel()
_, err := DecodePageToken("%%%not-base64%%%", UserListFilters{})
require.Error(t, err)
}