feat: backend service

This commit is contained in:
Ilia Denisov
2026-05-06 10:14:55 +03:00
committed by GitHub
parent 3e2622757e
commit f446c6a2ac
1486 changed files with 49720 additions and 266401 deletions
+150
View File
@@ -0,0 +1,150 @@
package user
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// Tier values mirror the closed set documented in
// `backend/README.md` and enforced by CHECK constraints
// on `entitlement_records` and `entitlement_snapshots`.
const (
TierFree = "free"
TierMonthly = "monthly"
TierYearly = "yearly"
TierPermanent = "permanent"
)
// EntitlementSnapshot is the read-side view of the user's current
// entitlement. It mirrors the OpenAPI `EntitlementSnapshot` schema.
type EntitlementSnapshot struct {
UserID uuid.UUID
Tier string
IsPaid bool
Source string
Actor ActorRef
ReasonCode string
StartsAt time.Time
EndsAt *time.Time
MaxRegisteredRaceNames int32
UpdatedAt time.Time
}
// ApplyEntitlementInput carries the admin-supplied parameters of
// `POST /api/v1/admin/users/{user_id}/entitlements`. StartsAt and
// EndsAt are optional: when omitted the tier policy supplies sensible
// defaults (StartsAt = now, EndsAt = now + tier window for monthly /
// yearly, otherwise NULL).
type ApplyEntitlementInput struct {
UserID uuid.UUID
Tier string
Source string
Actor ActorRef
ReasonCode string
StartsAt *time.Time
EndsAt *time.Time
}
// tierBinding is the static policy table that maps tiers to their
// derived attributes. The implementation keeps the values inline; later stages
// can move the table to configuration if marketing requirements
// diverge.
type tierBinding struct {
IsPaid bool
MaxRegisteredRaceNames int32
DefaultDuration time.Duration // 0 = no expiry
}
var tierPolicy = map[string]tierBinding{
TierFree: {IsPaid: false, MaxRegisteredRaceNames: 1, DefaultDuration: 0},
TierMonthly: {IsPaid: true, MaxRegisteredRaceNames: 5, DefaultDuration: 30 * 24 * time.Hour},
TierYearly: {IsPaid: true, MaxRegisteredRaceNames: 5, DefaultDuration: 365 * 24 * time.Hour},
TierPermanent: {IsPaid: true, MaxRegisteredRaceNames: 5, DefaultDuration: 0},
}
// defaultFreeSnapshot returns the entitlement snapshot installed for a
// brand-new account at registration time.
func defaultFreeSnapshot(userID uuid.UUID, now time.Time) EntitlementSnapshot {
binding := tierPolicy[TierFree]
return EntitlementSnapshot{
UserID: userID,
Tier: TierFree,
IsPaid: binding.IsPaid,
Source: "system",
Actor: ActorRef{Type: "system"},
ReasonCode: "",
StartsAt: now,
EndsAt: nil,
MaxRegisteredRaceNames: binding.MaxRegisteredRaceNames,
UpdatedAt: now,
}
}
// ApplyEntitlement validates the supplied input, derives missing
// attributes from the tier policy, persists an immutable record and a
// fresh snapshot atomically, refreshes the cache, and returns the
// up-to-date account aggregate.
//
// Tier downgrades are accepted: lobby state in the consolidated implementation enforces the
// "already-registered race names are never revoked on downgrade" rule
// at the lobby surface; user.Service is unconcerned with that
// invariant on the entitlement write path.
func (s *Service) ApplyEntitlement(ctx context.Context, input ApplyEntitlementInput) (Account, error) {
if input.UserID == uuid.Nil {
return Account{}, ErrAccountNotFound
}
binding, ok := tierPolicy[strings.TrimSpace(input.Tier)]
if !ok {
return Account{}, fmt.Errorf("%w: %q", ErrInvalidTier, input.Tier)
}
if err := input.Actor.Validate(); err != nil {
return Account{}, err
}
source := strings.TrimSpace(input.Source)
if source == "" {
return Account{}, fmt.Errorf("%w: source must be non-empty", ErrInvalidInput)
}
now := s.deps.Now().UTC()
startsAt := now
if input.StartsAt != nil {
startsAt = input.StartsAt.UTC()
}
var endsAt *time.Time
switch {
case input.EndsAt != nil:
t := input.EndsAt.UTC()
endsAt = &t
case binding.DefaultDuration > 0:
t := startsAt.Add(binding.DefaultDuration)
endsAt = &t
}
snapshot := EntitlementSnapshot{
UserID: input.UserID,
Tier: input.Tier,
IsPaid: binding.IsPaid,
Source: source,
Actor: input.Actor,
ReasonCode: input.ReasonCode,
StartsAt: startsAt,
EndsAt: endsAt,
MaxRegisteredRaceNames: binding.MaxRegisteredRaceNames,
UpdatedAt: now,
}
persisted, err := s.deps.Store.ApplyEntitlementTx(ctx, snapshot)
if err != nil {
if errors.Is(err, ErrAccountNotFound) {
return Account{}, err
}
return Account{}, fmt.Errorf("user apply entitlement: %w", err)
}
s.deps.Cache.Add(persisted)
return s.GetAccount(ctx, input.UserID)
}