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