feat: game lobby service
This commit is contained in:
@@ -3,7 +3,6 @@ package ports
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
@@ -14,9 +13,6 @@ import (
|
||||
type CreateAccountInput struct {
|
||||
// Account stores the durable user-account state.
|
||||
Account account.UserAccount
|
||||
|
||||
// Reservation stores the canonical race-name reservation linked to Account.
|
||||
Reservation account.RaceNameReservation
|
||||
}
|
||||
|
||||
// Validate reports whether CreateAccountInput is structurally complete.
|
||||
@@ -24,61 +20,6 @@ func (input CreateAccountInput) Validate() error {
|
||||
if err := input.Account.Validate(); err != nil {
|
||||
return fmt.Errorf("create account input account: %w", err)
|
||||
}
|
||||
if err := input.Reservation.Validate(); err != nil {
|
||||
return fmt.Errorf("create account input reservation: %w", err)
|
||||
}
|
||||
if input.Account.UserID != input.Reservation.UserID {
|
||||
return fmt.Errorf("create account input reservation user id must match account user id")
|
||||
}
|
||||
if input.Account.RaceName != input.Reservation.RaceName {
|
||||
return fmt.Errorf("create account input reservation race name must match account race name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameRaceNameInput stores the atomic state required to replace one stored
|
||||
// race name and its canonical reservation.
|
||||
type RenameRaceNameInput struct {
|
||||
// UserID identifies the account that must be updated.
|
||||
UserID common.UserID
|
||||
|
||||
// CurrentCanonicalKey stores the currently owned canonical reservation key.
|
||||
CurrentCanonicalKey account.RaceNameCanonicalKey
|
||||
|
||||
// NewRaceName stores the replacement exact stored race name.
|
||||
NewRaceName common.RaceName
|
||||
|
||||
// NewReservation stores the replacement canonical reservation.
|
||||
NewReservation account.RaceNameReservation
|
||||
|
||||
// UpdatedAt stores the account mutation timestamp.
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate reports whether RenameRaceNameInput is structurally complete.
|
||||
func (input RenameRaceNameInput) Validate() error {
|
||||
if err := input.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input user id: %w", err)
|
||||
}
|
||||
if err := input.CurrentCanonicalKey.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input current canonical key: %w", err)
|
||||
}
|
||||
if err := input.NewRaceName.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input race name: %w", err)
|
||||
}
|
||||
if err := input.NewReservation.Validate(); err != nil {
|
||||
return fmt.Errorf("rename race name input reservation: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("rename race name input updated at", input.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if input.NewReservation.UserID != input.UserID {
|
||||
return fmt.Errorf("rename race name input reservation user id must match user id")
|
||||
}
|
||||
if input.NewReservation.RaceName != input.NewRaceName {
|
||||
return fmt.Errorf("rename race name input reservation race name must match new race name")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -87,7 +28,7 @@ func (input RenameRaceNameInput) Validate() error {
|
||||
// exact lookup mappings.
|
||||
type UserAccountStore interface {
|
||||
// Create stores one new account record. Implementations must wrap
|
||||
// ErrConflict when the user id, e-mail, or exact race-name lookup already
|
||||
// ErrConflict when the user id, e-mail, or exact user-name lookup already
|
||||
// exists.
|
||||
Create(ctx context.Context, input CreateAccountInput) error
|
||||
|
||||
@@ -98,33 +39,17 @@ type UserAccountStore interface {
|
||||
// address.
|
||||
GetByEmail(ctx context.Context, email common.Email) (account.UserAccount, error)
|
||||
|
||||
// GetByRaceName returns the stored account identified by the exact stored
|
||||
// race name.
|
||||
GetByRaceName(ctx context.Context, raceName common.RaceName) (account.UserAccount, error)
|
||||
// GetByUserName returns the stored account identified by the exact stored
|
||||
// user name.
|
||||
GetByUserName(ctx context.Context, userName common.UserName) (account.UserAccount, error)
|
||||
|
||||
// ExistsByUserID reports whether userID currently identifies a stored
|
||||
// account.
|
||||
ExistsByUserID(ctx context.Context, userID common.UserID) (bool, error)
|
||||
|
||||
// RenameRaceName replaces the stored race name of userID and swaps the
|
||||
// exact race-name lookup atomically. Implementations must wrap ErrConflict
|
||||
// when newRaceName is already owned by another account.
|
||||
RenameRaceName(ctx context.Context, input RenameRaceNameInput) error
|
||||
|
||||
// Update replaces the stored account state for record.UserID.
|
||||
//
|
||||
// Implementations must wrap ErrConflict when the replacement record
|
||||
// attempts to mutate `user_name` or `email`.
|
||||
Update(ctx context.Context, record account.UserAccount) error
|
||||
}
|
||||
|
||||
// RaceNameReservationStore persists source-of-truth race-name reservations.
|
||||
type RaceNameReservationStore interface {
|
||||
// Create stores one new race-name reservation keyed by its canonical
|
||||
// uniqueness key. Implementations must wrap ErrConflict when the canonical
|
||||
// key is already reserved.
|
||||
Create(ctx context.Context, record account.RaceNameReservation) error
|
||||
|
||||
// GetByCanonicalKey returns the stored reservation identified by key.
|
||||
GetByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) (account.RaceNameReservation, error)
|
||||
|
||||
// DeleteByCanonicalKey removes the reservation identified by key.
|
||||
DeleteByCanonicalKey(ctx context.Context, key account.RaceNameCanonicalKey) error
|
||||
}
|
||||
|
||||
@@ -130,9 +130,6 @@ type EnsureByEmailInput struct {
|
||||
// EntitlementRecord stores the initial entitlement history record that must
|
||||
// be created atomically with Entitlement.
|
||||
EntitlementRecord entitlement.PeriodRecord
|
||||
|
||||
// Reservation stores the canonical race-name reservation for Account.
|
||||
Reservation account.RaceNameReservation
|
||||
}
|
||||
|
||||
// Validate reports whether EnsureByEmailInput is structurally complete.
|
||||
@@ -149,9 +146,6 @@ func (input EnsureByEmailInput) Validate() error {
|
||||
if err := input.EntitlementRecord.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record: %w", err)
|
||||
}
|
||||
if err := input.Reservation.Validate(); err != nil {
|
||||
return fmt.Errorf("ensure-by-email input reservation: %w", err)
|
||||
}
|
||||
if input.Account.Email != input.Email {
|
||||
return fmt.Errorf("ensure-by-email input account email must match request email")
|
||||
}
|
||||
@@ -161,12 +155,6 @@ func (input EnsureByEmailInput) Validate() error {
|
||||
if input.Account.UserID != input.EntitlementRecord.UserID {
|
||||
return fmt.Errorf("ensure-by-email input account user id must match entitlement record user id")
|
||||
}
|
||||
if input.Account.UserID != input.Reservation.UserID {
|
||||
return fmt.Errorf("ensure-by-email input account user id must match reservation user id")
|
||||
}
|
||||
if input.Account.RaceName != input.Reservation.RaceName {
|
||||
return fmt.Errorf("ensure-by-email input account race name must match reservation race name")
|
||||
}
|
||||
if input.EntitlementRecord.PlanCode != input.Entitlement.PlanCode {
|
||||
return fmt.Errorf("ensure-by-email input entitlement record plan code must match entitlement snapshot plan code")
|
||||
}
|
||||
|
||||
@@ -182,8 +182,13 @@ type ProfileChangedEvent struct {
|
||||
// Operation stores the profile-change event kind.
|
||||
Operation ProfileChangedOperation
|
||||
|
||||
// RaceName stores the latest exact race name after the commit.
|
||||
RaceName common.RaceName
|
||||
// UserName stores the immutable handle associated with the account at the
|
||||
// moment the event is published.
|
||||
UserName common.UserName
|
||||
|
||||
// DisplayName stores the latest display name after the commit. An empty
|
||||
// value is valid and means no display name is set.
|
||||
DisplayName common.DisplayName
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
@@ -194,8 +199,11 @@ func (event ProfileChangedEvent) Validate() error {
|
||||
if !event.Operation.IsKnown() {
|
||||
return fmt.Errorf("profile changed event operation %q is unsupported", event.Operation)
|
||||
}
|
||||
if err := event.RaceName.Validate(); err != nil {
|
||||
return fmt.Errorf("profile changed event race name: %w", err)
|
||||
if err := event.UserName.Validate(); err != nil {
|
||||
return fmt.Errorf("profile changed event user name: %w", err)
|
||||
}
|
||||
if err := event.DisplayName.Validate(); err != nil {
|
||||
return fmt.Errorf("profile changed event display name: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -22,10 +22,10 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrRaceNameConflict reports that a mutation specifically failed because a
|
||||
// race-name lookup or canonical reservation is already owned by another
|
||||
// user. The sentinel still matches ErrConflict via errors.Is so callers can
|
||||
// ErrUserNameConflict reports that a mutation specifically failed because
|
||||
// the auto-generated `user_name` lookup is already owned by another user.
|
||||
// The sentinel still matches ErrConflict via errors.Is so callers can
|
||||
// preserve the stable public conflict semantics while collecting more
|
||||
// precise observability.
|
||||
ErrRaceNameConflict = fmt.Errorf("%w: race name conflict", ErrConflict)
|
||||
ErrUserNameConflict = fmt.Errorf("%w: user name conflict", ErrConflict)
|
||||
)
|
||||
|
||||
@@ -6,14 +6,15 @@ import (
|
||||
"galaxy/user/internal/domain/policy"
|
||||
)
|
||||
|
||||
// IDGenerator creates new user identifiers and generated initial race names.
|
||||
// IDGenerator creates new user identifiers and auto-generated user names.
|
||||
type IDGenerator interface {
|
||||
// NewUserID returns one newly generated stable user identifier.
|
||||
NewUserID() (common.UserID, error)
|
||||
|
||||
// NewInitialRaceName returns one generated initial race name in the
|
||||
// `player-<shortid>` form.
|
||||
NewInitialRaceName() (common.RaceName, error)
|
||||
// NewUserName returns one generated immutable user name in the
|
||||
// `player-<suffix>` form. The suffix is eight characters drawn from a
|
||||
// confusable-free alphanumeric alphabet.
|
||||
NewUserName() (common.UserName, error)
|
||||
|
||||
// NewEntitlementRecordID returns one newly generated entitlement history
|
||||
// record identifier.
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"galaxy/user/internal/domain/account"
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// RaceNamePolicy produces the canonical uniqueness key used to reserve one
|
||||
// replaceable race-name slot.
|
||||
type RaceNamePolicy interface {
|
||||
// CanonicalKey returns the stable reservation key for raceName. Callers are
|
||||
// expected to pass a validated raceName value.
|
||||
CanonicalKey(raceName common.RaceName) (account.RaceNameCanonicalKey, error)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package ports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galaxy/user/internal/domain/common"
|
||||
)
|
||||
|
||||
// UserLifecycleEventType identifies one user-lifecycle event kind propagated
|
||||
// to `Game Lobby` through the dedicated Redis Stream.
|
||||
type UserLifecycleEventType string
|
||||
|
||||
const (
|
||||
// UserLifecyclePermanentBlockedEventType identifies the post-commit event
|
||||
// emitted when `SanctionCodePermanentBlock` becomes active on an account.
|
||||
UserLifecyclePermanentBlockedEventType UserLifecycleEventType = "user.lifecycle.permanent_blocked"
|
||||
|
||||
// UserLifecycleDeletedEventType identifies the post-commit event emitted
|
||||
// when a trusted `DeleteUser` command soft-deletes an account.
|
||||
UserLifecycleDeletedEventType UserLifecycleEventType = "user.lifecycle.deleted"
|
||||
)
|
||||
|
||||
// IsKnown reports whether the event type belongs to the frozen vocabulary.
|
||||
func (eventType UserLifecycleEventType) IsKnown() bool {
|
||||
switch eventType {
|
||||
case UserLifecyclePermanentBlockedEventType, UserLifecycleDeletedEventType:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UserLifecycleEvent stores one post-commit user-lifecycle event envelope
|
||||
// published to the `user:lifecycle_events` Redis Stream and consumed by
|
||||
// `Game Lobby` for Race Name Directory cascade release.
|
||||
type UserLifecycleEvent struct {
|
||||
// EventType stores the frozen lifecycle event discriminator.
|
||||
EventType UserLifecycleEventType
|
||||
|
||||
// UserID identifies the regular user whose lifecycle state changed.
|
||||
UserID common.UserID
|
||||
|
||||
// OccurredAt stores the committed mutation timestamp.
|
||||
OccurredAt time.Time
|
||||
|
||||
// Source stores the machine-readable mutation source. For Stage 22 this is
|
||||
// always `admin_internal_api`.
|
||||
Source common.Source
|
||||
|
||||
// Actor stores the audit actor metadata attached to the committed
|
||||
// mutation.
|
||||
Actor common.ActorRef
|
||||
|
||||
// ReasonCode stores the committed reason_code for the mutation.
|
||||
ReasonCode common.ReasonCode
|
||||
|
||||
// TraceID stores the optional OpenTelemetry trace identifier propagated
|
||||
// from the current request context.
|
||||
TraceID string
|
||||
}
|
||||
|
||||
// Validate reports whether event is structurally complete.
|
||||
func (event UserLifecycleEvent) Validate() error {
|
||||
if !event.EventType.IsKnown() {
|
||||
return fmt.Errorf("user lifecycle event type %q is unsupported", event.EventType)
|
||||
}
|
||||
if err := event.UserID.Validate(); err != nil {
|
||||
return fmt.Errorf("user lifecycle event user id: %w", err)
|
||||
}
|
||||
if err := common.ValidateTimestamp("user lifecycle event occurred at", event.OccurredAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := event.Source.Validate(); err != nil {
|
||||
return fmt.Errorf("user lifecycle event source: %w", err)
|
||||
}
|
||||
if err := event.Actor.Validate(); err != nil {
|
||||
return fmt.Errorf("user lifecycle event actor: %w", err)
|
||||
}
|
||||
if err := event.ReasonCode.Validate(); err != nil {
|
||||
return fmt.Errorf("user lifecycle event reason code: %w", err)
|
||||
}
|
||||
if event.TraceID != "" && strings.TrimSpace(event.TraceID) != event.TraceID {
|
||||
return fmt.Errorf("user lifecycle event trace id must not contain surrounding whitespace")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserLifecyclePublisher publishes one committed user-lifecycle event to the
|
||||
// dedicated `user:lifecycle_events` Redis Stream.
|
||||
type UserLifecyclePublisher interface {
|
||||
// PublishUserLifecycleEvent propagates one committed lifecycle event. The
|
||||
// implementation must validate the event envelope and perform exactly one
|
||||
// idempotent append per call.
|
||||
PublishUserLifecycleEvent(ctx context.Context, event UserLifecycleEvent) error
|
||||
}
|
||||
@@ -21,6 +21,29 @@ const (
|
||||
MaxUserListPageSize = 200
|
||||
)
|
||||
|
||||
// DisplayNameMatchMode selects between exact and prefix `display_name`
|
||||
// comparison used by the admin listing filter.
|
||||
type DisplayNameMatchMode string
|
||||
|
||||
const (
|
||||
// DisplayNameMatchModeExact matches `display_name` exactly after trimming.
|
||||
DisplayNameMatchModeExact DisplayNameMatchMode = "exact"
|
||||
|
||||
// DisplayNameMatchModePrefix matches `display_name` by stored-value prefix
|
||||
// after trimming.
|
||||
DisplayNameMatchModePrefix DisplayNameMatchMode = "prefix"
|
||||
)
|
||||
|
||||
// IsKnown reports whether the mode belongs to the supported vocabulary.
|
||||
func (mode DisplayNameMatchMode) IsKnown() bool {
|
||||
switch mode {
|
||||
case DisplayNameMatchModeExact, DisplayNameMatchModePrefix:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UserListFilters stores the frozen admin-listing filter set.
|
||||
type UserListFilters struct {
|
||||
// PaidState stores the optional coarse free-versus-paid filter.
|
||||
@@ -43,6 +66,16 @@ type UserListFilters struct {
|
||||
// LimitCode stores the optional active user-specific limit filter.
|
||||
LimitCode policy.LimitCode
|
||||
|
||||
// UserName stores the optional exact `user_name` filter.
|
||||
UserName common.UserName
|
||||
|
||||
// DisplayName stores the optional `display_name` filter value.
|
||||
DisplayName common.DisplayName
|
||||
|
||||
// DisplayNameMatch selects between exact and prefix comparison for
|
||||
// DisplayName. The zero value is treated as DisplayNameMatchModeExact.
|
||||
DisplayNameMatch DisplayNameMatchMode
|
||||
|
||||
// CanLogin stores the optional derived login-eligibility filter.
|
||||
CanLogin *bool
|
||||
|
||||
@@ -76,6 +109,22 @@ func (filters UserListFilters) Validate() error {
|
||||
if filters.LimitCode != "" && !filters.LimitCode.IsKnown() {
|
||||
return fmt.Errorf("limit code %q is unsupported", filters.LimitCode)
|
||||
}
|
||||
if !filters.UserName.IsZero() {
|
||||
if err := filters.UserName.Validate(); err != nil {
|
||||
return fmt.Errorf("user name: %w", err)
|
||||
}
|
||||
}
|
||||
if !filters.DisplayName.IsZero() {
|
||||
if err := filters.DisplayName.Validate(); err != nil {
|
||||
return fmt.Errorf("display name: %w", err)
|
||||
}
|
||||
}
|
||||
if filters.DisplayNameMatch != "" && !filters.DisplayNameMatch.IsKnown() {
|
||||
return fmt.Errorf("display name match mode %q is unsupported", filters.DisplayNameMatch)
|
||||
}
|
||||
if filters.DisplayName.IsZero() && filters.DisplayNameMatch != "" {
|
||||
return fmt.Errorf("display name match mode requires a display_name value")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user