168 lines
5.2 KiB
Go
168 lines
5.2 KiB
Go
// Package membership defines the membership record domain model, status
|
|
// machine, and sentinel errors owned by Game Lobby Service for platform
|
|
// participant roster state.
|
|
package membership
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"galaxy/lobby/internal/domain/common"
|
|
)
|
|
|
|
// Membership stores one durable membership record owned by Game Lobby
|
|
// Service. Memberships are the platform roster entry that binds a user to
|
|
// a game with a confirmed race name.
|
|
type Membership struct {
|
|
// MembershipID identifies the record.
|
|
MembershipID common.MembershipID
|
|
|
|
// GameID identifies the game this membership belongs to.
|
|
GameID common.GameID
|
|
|
|
// UserID stores the platform user id of the member.
|
|
UserID string
|
|
|
|
// RaceName stores the confirmed in-game name in its original casing.
|
|
// It is reserved in the Race Name Directory under CanonicalKey.
|
|
RaceName string
|
|
|
|
// CanonicalKey stores the policy-derived canonical form of RaceName.
|
|
// It is the join key used by the Race Name Directory and downstream
|
|
// readers (capability evaluation, cascade release) so they never need
|
|
// to re-derive canonical keys from the original-casing RaceName.
|
|
CanonicalKey string
|
|
|
|
// Status stores the current membership status.
|
|
Status Status
|
|
|
|
// JoinedAt stores when the record entered the active status.
|
|
JoinedAt time.Time
|
|
|
|
// RemovedAt stores when the record transitioned out of active. It is
|
|
// nil while the membership is still active.
|
|
RemovedAt *time.Time
|
|
}
|
|
|
|
// NewMembershipInput groups all fields required to create an active
|
|
// membership record.
|
|
type NewMembershipInput struct {
|
|
// MembershipID identifies the new record.
|
|
MembershipID common.MembershipID
|
|
|
|
// GameID identifies the game the member is joining.
|
|
GameID common.GameID
|
|
|
|
// UserID stores the platform user id of the member.
|
|
UserID string
|
|
|
|
// RaceName stores the confirmed in-game name reserved for the member
|
|
// in its original casing.
|
|
RaceName string
|
|
|
|
// CanonicalKey stores the policy-derived canonical form of RaceName.
|
|
// Callers obtain it from RaceNameDirectory.Canonicalize before
|
|
// constructing the membership so that the record carries the same key
|
|
// the directory uses internally.
|
|
CanonicalKey string
|
|
|
|
// Now stores the creation wall-clock used for JoinedAt.
|
|
Now time.Time
|
|
}
|
|
|
|
// New validates input and returns an active Membership record. Validation
|
|
// errors are returned verbatim so callers can surface them as
|
|
// invalid_request.
|
|
func New(input NewMembershipInput) (Membership, error) {
|
|
if err := input.Validate(); err != nil {
|
|
return Membership{}, err
|
|
}
|
|
|
|
record := Membership{
|
|
MembershipID: input.MembershipID,
|
|
GameID: input.GameID,
|
|
UserID: strings.TrimSpace(input.UserID),
|
|
RaceName: strings.TrimSpace(input.RaceName),
|
|
CanonicalKey: strings.TrimSpace(input.CanonicalKey),
|
|
Status: StatusActive,
|
|
JoinedAt: input.Now.UTC(),
|
|
}
|
|
|
|
if err := record.Validate(); err != nil {
|
|
return Membership{}, err
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// Validate reports whether input satisfies the frozen membership-record
|
|
// invariants required to construct an active record.
|
|
func (input NewMembershipInput) Validate() error {
|
|
if err := input.MembershipID.Validate(); err != nil {
|
|
return fmt.Errorf("membership id: %w", err)
|
|
}
|
|
if err := input.GameID.Validate(); err != nil {
|
|
return fmt.Errorf("game id: %w", err)
|
|
}
|
|
if strings.TrimSpace(input.UserID) == "" {
|
|
return fmt.Errorf("user id must not be empty")
|
|
}
|
|
if strings.TrimSpace(input.RaceName) == "" {
|
|
return fmt.Errorf("race name must not be empty")
|
|
}
|
|
if strings.TrimSpace(input.CanonicalKey) == "" {
|
|
return fmt.Errorf("canonical key must not be empty")
|
|
}
|
|
if input.Now.IsZero() {
|
|
return fmt.Errorf("now must not be zero")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate reports whether record satisfies the full invariants.
|
|
// Every marshal and unmarshal round-trip calls Validate to guarantee that
|
|
// the Redis store never exposes malformed records.
|
|
func (record Membership) Validate() error {
|
|
if err := record.MembershipID.Validate(); err != nil {
|
|
return fmt.Errorf("membership id: %w", err)
|
|
}
|
|
if err := record.GameID.Validate(); err != nil {
|
|
return fmt.Errorf("game id: %w", err)
|
|
}
|
|
if strings.TrimSpace(record.UserID) == "" {
|
|
return fmt.Errorf("user id must not be empty")
|
|
}
|
|
if strings.TrimSpace(record.RaceName) == "" {
|
|
return fmt.Errorf("race name must not be empty")
|
|
}
|
|
if strings.TrimSpace(record.CanonicalKey) == "" {
|
|
return fmt.Errorf("canonical key must not be empty")
|
|
}
|
|
if strings.TrimSpace(record.CanonicalKey) != record.CanonicalKey {
|
|
return fmt.Errorf("canonical key must not contain surrounding whitespace")
|
|
}
|
|
if !record.Status.IsKnown() {
|
|
return fmt.Errorf("status %q is unsupported", record.Status)
|
|
}
|
|
if record.JoinedAt.IsZero() {
|
|
return fmt.Errorf("joined at must not be zero")
|
|
}
|
|
if record.Status == StatusActive {
|
|
if record.RemovedAt != nil {
|
|
return fmt.Errorf("removed at must be nil for active memberships")
|
|
}
|
|
} else {
|
|
if record.RemovedAt == nil {
|
|
return fmt.Errorf("removed at must not be nil for %q memberships", record.Status)
|
|
}
|
|
if record.RemovedAt.IsZero() {
|
|
return fmt.Errorf("removed at must not be zero when present")
|
|
}
|
|
if record.RemovedAt.Before(record.JoinedAt) {
|
|
return fmt.Errorf("removed at must not be before joined at")
|
|
}
|
|
}
|
|
return nil
|
|
}
|