Implemented '2.4 Leaderboard Service (Port 8083)'
parent
3cc74867a7
commit
80003d19ca
@ -1,22 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/adaptor"
|
||||
|
||||
applb "knowfoolery/backend/services/leaderboard-service/internal/application/leaderboard"
|
||||
lbconfig "knowfoolery/backend/services/leaderboard-service/internal/infra/config"
|
||||
lbent "knowfoolery/backend/services/leaderboard-service/internal/infra/persistence/ent"
|
||||
lbstate "knowfoolery/backend/services/leaderboard-service/internal/infra/state"
|
||||
httpapi "knowfoolery/backend/services/leaderboard-service/internal/interfaces/http"
|
||||
"knowfoolery/backend/shared/infra/auth/zitadel"
|
||||
sharedredis "knowfoolery/backend/shared/infra/database/redis"
|
||||
"knowfoolery/backend/shared/infra/observability/logging"
|
||||
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
|
||||
"knowfoolery/backend/shared/infra/observability/tracing"
|
||||
"knowfoolery/backend/shared/infra/utils/serviceboot"
|
||||
"knowfoolery/backend/shared/infra/utils/validation"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := serviceboot.Config{
|
||||
AppName: "Know Foolery - Leaderboard Service",
|
||||
serviceCfg := lbconfig.FromEnv()
|
||||
logger := logging.NewLogger(serviceCfg.Logging)
|
||||
metrics := sharedmetrics.NewMetrics(serviceCfg.Metrics)
|
||||
|
||||
tracer, err := tracing.NewTracer(serviceCfg.Tracing)
|
||||
if err != nil {
|
||||
logger.Fatal("failed to initialize tracer")
|
||||
}
|
||||
defer func() { _ = tracer.Shutdown(context.Background()) }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
persistence, err := lbent.NewClient(ctx, serviceCfg.Postgres)
|
||||
if err != nil {
|
||||
logger.WithError(err).Fatal("failed to initialize postgres client")
|
||||
}
|
||||
defer persistence.Close()
|
||||
|
||||
repo := lbent.NewLeaderboardRepository(persistence)
|
||||
if err := repo.EnsureSchema(ctx); err != nil {
|
||||
logger.WithError(err).Fatal("failed to ensure schema")
|
||||
}
|
||||
|
||||
var redisClient *sharedredis.Client
|
||||
if c, redisErr := sharedredis.NewClient(serviceCfg.Redis); redisErr == nil {
|
||||
redisClient = c
|
||||
defer func() { _ = redisClient.Close() }()
|
||||
} else {
|
||||
logger.WithError(redisErr).Warn("redis unavailable; running without cache")
|
||||
}
|
||||
|
||||
state := lbstate.NewStore(redisClient)
|
||||
service := applb.NewService(repo, state, applb.Config{
|
||||
TopLimit: serviceCfg.TopLimit,
|
||||
PlayerHistoryDefault: serviceCfg.PlayerHistoryDefault,
|
||||
PlayerHistoryMax: serviceCfg.PlayerHistoryMax,
|
||||
CacheTTL: serviceCfg.CacheTTL,
|
||||
UpdateRequireAuth: serviceCfg.UpdateRequireAuth,
|
||||
})
|
||||
handler := httpapi.NewHandler(
|
||||
service,
|
||||
validation.NewValidator(),
|
||||
logger,
|
||||
metrics,
|
||||
serviceCfg.UpdateRequireAuth,
|
||||
serviceCfg.PlayerHistoryDefault,
|
||||
serviceCfg.PlayerHistoryMax,
|
||||
)
|
||||
|
||||
bootCfg := serviceboot.Config{
|
||||
AppName: serviceCfg.AppName,
|
||||
ServiceSlug: "leaderboard",
|
||||
PortEnv: "LEADERBOARD_PORT",
|
||||
DefaultPort: 8083,
|
||||
DefaultPort: serviceCfg.Port,
|
||||
}
|
||||
app := serviceboot.NewFiberApp(bootCfg)
|
||||
serviceboot.RegisterHealth(app, bootCfg.ServiceSlug)
|
||||
serviceboot.RegisterReadiness(
|
||||
app,
|
||||
2*time.Second,
|
||||
serviceboot.ReadyCheck{
|
||||
Name: "postgres",
|
||||
Required: true,
|
||||
Probe: persistence.Pool.Ping,
|
||||
},
|
||||
serviceboot.ReadyCheck{
|
||||
Name: "redis",
|
||||
Required: false,
|
||||
Probe: func(ctx context.Context) error {
|
||||
if redisClient == nil {
|
||||
return nil
|
||||
}
|
||||
return redisClient.HealthCheck(ctx)
|
||||
},
|
||||
},
|
||||
)
|
||||
app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler()))
|
||||
|
||||
app := serviceboot.NewFiberApp(cfg)
|
||||
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
|
||||
authMiddleware := buildAuthMiddleware(serviceCfg)
|
||||
httpapi.RegisterRoutes(app, handler, authMiddleware)
|
||||
|
||||
addr := serviceboot.ListenAddress(cfg.PortEnv, cfg.DefaultPort)
|
||||
addr := serviceboot.ListenAddress(bootCfg.PortEnv, bootCfg.DefaultPort)
|
||||
log.Fatal(serviceboot.Run(app, addr))
|
||||
}
|
||||
|
||||
func buildAuthMiddleware(cfg lbconfig.Config) fiber.Handler {
|
||||
return zitadel.BuildJWTMiddleware(zitadel.MiddlewareFactoryConfig{
|
||||
BaseURL: cfg.ZitadelBaseURL,
|
||||
ClientID: cfg.ZitadelClientID,
|
||||
ClientSecret: cfg.ZitadelSecret,
|
||||
Issuer: cfg.ZitadelIssuer,
|
||||
Audience: cfg.ZitadelAudience,
|
||||
RequiredClaims: []string{
|
||||
"sub",
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,70 @@
|
||||
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
|
||||
github.com/MicahParks/keyfunc/v3 v3.7.0 h1:pdafUNyq+p3ZlvjJX1HWFP7MA3+cLpDtg69U3kITJGM=
|
||||
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
|
||||
github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.3 h1:7Q2I+HsIqnIEEDB+9oe7Gadpakh6ZLhXpTYz/L20vrg=
|
||||
github.com/gofiber/utils/v2 v2.0.0-beta.4 h1:1gjbVFFwVwUb9arPcqiB6iEjHBwo7cHsyS41NeIW3co=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
package leaderboard
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
|
||||
sharedtypes "knowfoolery/backend/shared/domain/types"
|
||||
)
|
||||
|
||||
// UpdateScoreInput is the internal score ingestion payload.
|
||||
type UpdateScoreInput struct {
|
||||
SessionID string
|
||||
PlayerID string
|
||||
PlayerName string
|
||||
TotalScore int
|
||||
QuestionsAsked int
|
||||
QuestionsCorrect int
|
||||
HintsUsed int
|
||||
DurationSeconds int
|
||||
CompletedAt time.Time
|
||||
CompletionType string
|
||||
}
|
||||
|
||||
// GetTopInput captures top leaderboard query options.
|
||||
type GetTopInput struct {
|
||||
CompletionType string
|
||||
Window domain.Window
|
||||
}
|
||||
|
||||
// GetPlayerRankingInput captures player ranking query.
|
||||
type GetPlayerRankingInput struct {
|
||||
PlayerID string
|
||||
Pagination sharedtypes.Pagination
|
||||
}
|
||||
|
||||
// GetStatsInput captures global stats query options.
|
||||
type GetStatsInput struct {
|
||||
CompletionType string
|
||||
Window domain.Window
|
||||
}
|
||||
|
||||
// RankedEntry is one ranked leaderboard line.
|
||||
type RankedEntry struct {
|
||||
Rank int `json:"rank"`
|
||||
Entry domain.LeaderboardEntry `json:"entry"`
|
||||
}
|
||||
|
||||
// Top10Result returns top entries.
|
||||
type Top10Result struct {
|
||||
Items []RankedEntry `json:"items"`
|
||||
}
|
||||
|
||||
// PlayerRankingResult returns player rank and history.
|
||||
type PlayerRankingResult struct {
|
||||
Player domain.PlayerStats `json:"player"`
|
||||
Rank int64 `json:"rank"`
|
||||
History []domain.LeaderboardEntry `json:"history"`
|
||||
Pagination sharedtypes.Pagination `json:"pagination"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
@ -0,0 +1,243 @@
|
||||
package leaderboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
|
||||
sharedsecurity "knowfoolery/backend/shared/infra/security"
|
||||
)
|
||||
|
||||
// StateStore defines cache operations used by leaderboard service.
|
||||
type StateStore interface {
|
||||
Get(ctx context.Context, key string) (string, bool)
|
||||
Set(ctx context.Context, key, value string, ttl time.Duration) error
|
||||
Delete(ctx context.Context, keys ...string) error
|
||||
}
|
||||
|
||||
// Config controls leaderboard behavior.
|
||||
type Config struct {
|
||||
TopLimit int
|
||||
PlayerHistoryDefault int
|
||||
PlayerHistoryMax int
|
||||
CacheTTL time.Duration
|
||||
UpdateRequireAuth bool
|
||||
}
|
||||
|
||||
// Service orchestrates leaderboard use-cases.
|
||||
type Service struct {
|
||||
repo domain.Repository
|
||||
state StateStore
|
||||
cfg Config
|
||||
}
|
||||
|
||||
// NewService creates a leaderboard service.
|
||||
func NewService(repo domain.Repository, state StateStore, cfg Config) *Service {
|
||||
if cfg.TopLimit <= 0 {
|
||||
cfg.TopLimit = 10
|
||||
}
|
||||
if cfg.PlayerHistoryDefault <= 0 {
|
||||
cfg.PlayerHistoryDefault = 20
|
||||
}
|
||||
if cfg.PlayerHistoryMax <= 0 {
|
||||
cfg.PlayerHistoryMax = 100
|
||||
}
|
||||
if cfg.CacheTTL <= 0 {
|
||||
cfg.CacheTTL = 60 * time.Second
|
||||
}
|
||||
return &Service{repo: repo, state: state, cfg: cfg}
|
||||
}
|
||||
|
||||
// UpdateScore ingests a final session result.
|
||||
func (s *Service) UpdateScore(ctx context.Context, in UpdateScoreInput) (*domain.LeaderboardEntry, error) {
|
||||
entry, err := s.validateUpdateInput(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ingested, _, err := s.repo.IngestEntry(ctx, entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = s.state.Delete(
|
||||
ctx,
|
||||
"lb:top10:v1",
|
||||
"lb:stats:global:v1",
|
||||
"lb:rank:"+ingested.PlayerID,
|
||||
)
|
||||
return ingested, nil
|
||||
}
|
||||
|
||||
// GetTop10 returns top leaderboard entries.
|
||||
func (s *Service) GetTop10(ctx context.Context, in GetTopInput) (*Top10Result, error) {
|
||||
filter := domain.TopFilter{
|
||||
CompletionType: strings.TrimSpace(in.CompletionType),
|
||||
Window: normalizeWindow(in.Window),
|
||||
}
|
||||
cacheKey := "lb:top10:v1:" + filter.CompletionType + ":" + string(filter.Window)
|
||||
if payload, ok := s.state.Get(ctx, cacheKey); ok {
|
||||
var result Top10Result
|
||||
if err := json.Unmarshal([]byte(payload), &result); err == nil {
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
|
||||
items, err := s.repo.ListTop(ctx, filter, s.cfg.TopLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &Top10Result{Items: make([]RankedEntry, 0, len(items))}
|
||||
for i, item := range items {
|
||||
result.Items = append(result.Items, RankedEntry{
|
||||
Rank: i + 1,
|
||||
Entry: *item,
|
||||
})
|
||||
}
|
||||
|
||||
if payload, err := json.Marshal(result); err == nil {
|
||||
_ = s.state.Set(ctx, cacheKey, string(payload), s.cfg.CacheTTL)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPlayerRanking returns player rank, stats, and paginated history.
|
||||
func (s *Service) GetPlayerRanking(ctx context.Context, in GetPlayerRankingInput) (*PlayerRankingResult, error) {
|
||||
playerID := strings.TrimSpace(in.PlayerID)
|
||||
if playerID == "" {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
p := in.Pagination
|
||||
if p.Page <= 0 {
|
||||
p.Page = 1
|
||||
}
|
||||
if p.PageSize <= 0 {
|
||||
p.PageSize = s.cfg.PlayerHistoryDefault
|
||||
}
|
||||
if p.PageSize > s.cfg.PlayerHistoryMax {
|
||||
p.PageSize = s.cfg.PlayerHistoryMax
|
||||
}
|
||||
|
||||
player, err := s.repo.GetPlayerStats(ctx, playerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rank int64
|
||||
rankKey := "lb:rank:" + playerID
|
||||
if cached, ok := s.state.Get(ctx, rankKey); ok {
|
||||
if parsed, parseErr := strconv.ParseInt(cached, 10, 64); parseErr == nil {
|
||||
rank = parsed
|
||||
}
|
||||
}
|
||||
if rank == 0 {
|
||||
rank, err = s.repo.GetPlayerRank(ctx, playerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.state.Set(ctx, rankKey, strconv.FormatInt(rank, 10), s.cfg.CacheTTL)
|
||||
}
|
||||
|
||||
historyItems, total, err := s.repo.ListPlayerHistory(ctx, playerID, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
history := make([]domain.LeaderboardEntry, 0, len(historyItems))
|
||||
for _, item := range historyItems {
|
||||
history = append(history, *item)
|
||||
}
|
||||
|
||||
return &PlayerRankingResult{
|
||||
Player: *player,
|
||||
Rank: rank,
|
||||
History: history,
|
||||
Pagination: p,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetGlobalStats returns aggregate leaderboard statistics.
|
||||
func (s *Service) GetGlobalStats(ctx context.Context, in GetStatsInput) (*domain.GlobalStats, error) {
|
||||
filter := domain.TopFilter{
|
||||
CompletionType: strings.TrimSpace(in.CompletionType),
|
||||
Window: normalizeWindow(in.Window),
|
||||
}
|
||||
cacheKey := "lb:stats:global:v1:" + filter.CompletionType + ":" + string(filter.Window)
|
||||
if payload, ok := s.state.Get(ctx, cacheKey); ok {
|
||||
var result domain.GlobalStats
|
||||
if err := json.Unmarshal([]byte(payload), &result); err == nil {
|
||||
return &result, nil
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := s.repo.GetGlobalStats(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload, err := json.Marshal(stats); err == nil {
|
||||
_ = s.state.Set(ctx, cacheKey, string(payload), s.cfg.CacheTTL)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateUpdateInput(in UpdateScoreInput) (*domain.LeaderboardEntry, error) {
|
||||
sessionID := strings.TrimSpace(in.SessionID)
|
||||
playerID := strings.TrimSpace(in.PlayerID)
|
||||
if sessionID == "" || playerID == "" {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
if in.TotalScore < 0 || in.QuestionsAsked < 0 || in.QuestionsCorrect < 0 || in.DurationSeconds < 0 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
if in.QuestionsCorrect > in.QuestionsAsked {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
completionType := strings.TrimSpace(in.CompletionType)
|
||||
if completionType == "" {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
if completionType != string(domain.CompletionCompleted) &&
|
||||
completionType != string(domain.CompletionTimedOut) &&
|
||||
completionType != string(domain.CompletionAbandoned) {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
completedAt := in.CompletedAt.UTC()
|
||||
if completedAt.IsZero() {
|
||||
completedAt = time.Now().UTC()
|
||||
}
|
||||
rate := 0.0
|
||||
if in.QuestionsAsked > 0 {
|
||||
rate = float64(in.QuestionsCorrect) * 100.0 / float64(in.QuestionsAsked)
|
||||
}
|
||||
playerName := sharedsecurity.SanitizePlayerName(in.PlayerName)
|
||||
if playerName == "" {
|
||||
playerName = "Player"
|
||||
}
|
||||
|
||||
return &domain.LeaderboardEntry{
|
||||
SessionID: sessionID,
|
||||
PlayerID: playerID,
|
||||
PlayerName: playerName,
|
||||
Score: in.TotalScore,
|
||||
QuestionsAsked: in.QuestionsAsked,
|
||||
QuestionsCorrect: in.QuestionsCorrect,
|
||||
HintsUsed: in.HintsUsed,
|
||||
DurationSeconds: in.DurationSeconds,
|
||||
SuccessRate: rate,
|
||||
CompletionType: domain.CompletionType(completionType),
|
||||
CompletedAt: completedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeWindow(w domain.Window) domain.Window {
|
||||
switch w {
|
||||
case domain.Window24h, domain.Window7d, domain.Window30d, domain.WindowAll:
|
||||
return w
|
||||
default:
|
||||
return domain.WindowAll
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,227 @@
|
||||
package leaderboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
|
||||
sharedtypes "knowfoolery/backend/shared/domain/types"
|
||||
)
|
||||
|
||||
type fakeRepo struct {
|
||||
entries []*domain.LeaderboardEntry
|
||||
stats map[string]*domain.PlayerStats
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{
|
||||
entries: make([]*domain.LeaderboardEntry, 0),
|
||||
stats: map[string]*domain.PlayerStats{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) EnsureSchema(ctx context.Context) error { return nil }
|
||||
|
||||
func (r *fakeRepo) IngestEntry(
|
||||
ctx context.Context,
|
||||
entry *domain.LeaderboardEntry,
|
||||
) (*domain.LeaderboardEntry, bool, error) {
|
||||
for _, e := range r.entries {
|
||||
if e.SessionID == entry.SessionID {
|
||||
return e, true, nil
|
||||
}
|
||||
}
|
||||
cp := *entry
|
||||
cp.ID = "id-" + entry.SessionID
|
||||
cp.CreatedAt = time.Now().UTC()
|
||||
r.entries = append(r.entries, &cp)
|
||||
|
||||
stats := r.stats[entry.PlayerID]
|
||||
if stats == nil {
|
||||
best := entry.DurationSeconds
|
||||
stats = &domain.PlayerStats{
|
||||
PlayerID: entry.PlayerID,
|
||||
PlayerName: entry.PlayerName,
|
||||
GamesPlayed: 1,
|
||||
GamesCompleted: 0,
|
||||
TotalScore: int64(entry.Score),
|
||||
BestScore: entry.Score,
|
||||
AvgScore: float64(entry.Score),
|
||||
AvgSuccessRate: entry.SuccessRate,
|
||||
TotalQuestions: int64(entry.QuestionsAsked),
|
||||
TotalCorrect: int64(entry.QuestionsCorrect),
|
||||
BestDurationSec: &best,
|
||||
}
|
||||
if entry.CompletionType == domain.CompletionCompleted {
|
||||
stats.GamesCompleted = 1
|
||||
}
|
||||
r.stats[entry.PlayerID] = stats
|
||||
return &cp, false, nil
|
||||
}
|
||||
|
||||
stats.GamesPlayed++
|
||||
stats.TotalScore += int64(entry.Score)
|
||||
stats.TotalQuestions += int64(entry.QuestionsAsked)
|
||||
stats.TotalCorrect += int64(entry.QuestionsCorrect)
|
||||
stats.AvgScore = float64(stats.TotalScore) / float64(stats.GamesPlayed)
|
||||
if stats.TotalQuestions > 0 {
|
||||
stats.AvgSuccessRate = float64(stats.TotalCorrect) * 100 / float64(stats.TotalQuestions)
|
||||
}
|
||||
if entry.Score > stats.BestScore {
|
||||
stats.BestScore = entry.Score
|
||||
best := entry.DurationSeconds
|
||||
stats.BestDurationSec = &best
|
||||
}
|
||||
return &cp, false, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListTop(ctx context.Context, filter domain.TopFilter, limit int) ([]*domain.LeaderboardEntry, error) {
|
||||
if len(r.entries) < limit {
|
||||
limit = len(r.entries)
|
||||
}
|
||||
out := make([]*domain.LeaderboardEntry, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
out = append(out, r.entries[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetPlayerStats(ctx context.Context, playerID string) (*domain.PlayerStats, error) {
|
||||
stats := r.stats[playerID]
|
||||
if stats == nil {
|
||||
return nil, domain.ErrPlayerNotFound
|
||||
}
|
||||
cp := *stats
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetPlayerRank(ctx context.Context, playerID string) (int64, error) {
|
||||
if _, ok := r.stats[playerID]; !ok {
|
||||
return 0, domain.ErrPlayerNotFound
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListPlayerHistory(
|
||||
ctx context.Context,
|
||||
playerID string,
|
||||
pagination sharedtypes.Pagination,
|
||||
) ([]*domain.LeaderboardEntry, int64, error) {
|
||||
out := make([]*domain.LeaderboardEntry, 0)
|
||||
for _, entry := range r.entries {
|
||||
if entry.PlayerID == playerID {
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
return out, int64(len(out)), nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) {
|
||||
return &domain.GlobalStats{TotalGames: int64(len(r.entries)), UpdatedAt: time.Now().UTC()}, nil
|
||||
}
|
||||
|
||||
type fakeState struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func newFakeState() *fakeState {
|
||||
return &fakeState{data: map[string]string{}}
|
||||
}
|
||||
func (s *fakeState) Get(ctx context.Context, key string) (string, bool) {
|
||||
v, ok := s.data[key]
|
||||
return v, ok
|
||||
}
|
||||
func (s *fakeState) Set(ctx context.Context, key, value string, ttl time.Duration) error {
|
||||
s.data[key] = value
|
||||
return nil
|
||||
}
|
||||
func (s *fakeState) Delete(ctx context.Context, keys ...string) error {
|
||||
for _, key := range keys {
|
||||
delete(s.data, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUpdateScoreIdempotent(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
state := newFakeState()
|
||||
svc := NewService(repo, state, Config{})
|
||||
|
||||
in := UpdateScoreInput{
|
||||
SessionID: "s1",
|
||||
PlayerID: "u1",
|
||||
PlayerName: "Alice",
|
||||
TotalScore: 8,
|
||||
QuestionsAsked: 10,
|
||||
QuestionsCorrect: 7,
|
||||
HintsUsed: 1,
|
||||
DurationSeconds: 100,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
CompletionType: "completed",
|
||||
}
|
||||
one, err := svc.UpdateScore(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScore first failed: %v", err)
|
||||
}
|
||||
two, err := svc.UpdateScore(context.Background(), in)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateScore second failed: %v", err)
|
||||
}
|
||||
if one.ID != two.ID {
|
||||
t.Fatalf("idempotency failed: %s != %s", one.ID, two.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateScoreValidatesInput(t *testing.T) {
|
||||
svc := NewService(newFakeRepo(), newFakeState(), Config{})
|
||||
_, err := svc.UpdateScore(context.Background(), UpdateScoreInput{
|
||||
SessionID: "s1",
|
||||
PlayerID: "u1",
|
||||
PlayerName: "A",
|
||||
TotalScore: 1,
|
||||
QuestionsAsked: 1,
|
||||
QuestionsCorrect: 2,
|
||||
DurationSeconds: 1,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
CompletionType: "completed",
|
||||
})
|
||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Fatalf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPlayerRanking(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
state := newFakeState()
|
||||
svc := NewService(repo, state, Config{})
|
||||
_, _ = svc.UpdateScore(context.Background(), UpdateScoreInput{
|
||||
SessionID: "s1",
|
||||
PlayerID: "u1",
|
||||
PlayerName: "Alice",
|
||||
TotalScore: 3,
|
||||
QuestionsAsked: 4,
|
||||
QuestionsCorrect: 2,
|
||||
DurationSeconds: 50,
|
||||
CompletedAt: time.Now().UTC(),
|
||||
CompletionType: "completed",
|
||||
})
|
||||
|
||||
result, err := svc.GetPlayerRanking(context.Background(), GetPlayerRankingInput{
|
||||
PlayerID: "u1",
|
||||
Pagination: sharedtypes.Pagination{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPlayerRanking failed: %v", err)
|
||||
}
|
||||
if result.Rank != 1 {
|
||||
t.Fatalf("rank=%d want=1", result.Rank)
|
||||
}
|
||||
if len(result.History) != 1 {
|
||||
t.Fatalf("history len=%d want=1", len(result.History))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
package leaderboard
|
||||
|
||||
import "time"
|
||||
|
||||
// CompletionType represents how a game session ended.
|
||||
type CompletionType string
|
||||
|
||||
const (
|
||||
CompletionCompleted CompletionType = "completed"
|
||||
CompletionTimedOut CompletionType = "timed_out"
|
||||
CompletionAbandoned CompletionType = "abandoned"
|
||||
)
|
||||
|
||||
// LeaderboardEntry stores one finalized game result.
|
||||
type LeaderboardEntry struct {
|
||||
ID string
|
||||
SessionID string
|
||||
PlayerID string
|
||||
PlayerName string
|
||||
Score int
|
||||
QuestionsAsked int
|
||||
QuestionsCorrect int
|
||||
HintsUsed int
|
||||
DurationSeconds int
|
||||
SuccessRate float64
|
||||
CompletionType CompletionType
|
||||
CompletedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PlayerStats stores aggregated player-level ranking data.
|
||||
type PlayerStats struct {
|
||||
PlayerID string
|
||||
PlayerName string
|
||||
GamesPlayed int
|
||||
GamesCompleted int
|
||||
TotalScore int64
|
||||
BestScore int
|
||||
AvgScore float64
|
||||
AvgSuccessRate float64
|
||||
TotalQuestions int64
|
||||
TotalCorrect int64
|
||||
BestDurationSec *int
|
||||
LastPlayedAt *time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// GlobalStats stores global leaderboard aggregates.
|
||||
type GlobalStats struct {
|
||||
TotalGames int64
|
||||
TotalPlayers int64
|
||||
AvgScore float64
|
||||
AvgSuccessRate float64
|
||||
MaxScore int
|
||||
ScoreP50 float64
|
||||
ScoreP90 float64
|
||||
ScoreP99 float64
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Window is a top/stats time filter.
|
||||
type Window string
|
||||
|
||||
const (
|
||||
WindowAll Window = "all"
|
||||
Window24h Window = "24h"
|
||||
Window7d Window = "7d"
|
||||
Window30d Window = "30d"
|
||||
)
|
||||
@ -0,0 +1,12 @@
|
||||
package leaderboard
|
||||
|
||||
import sharederrors "knowfoolery/backend/shared/domain/errors"
|
||||
|
||||
var (
|
||||
// ErrInvalidInput indicates invalid leaderboard request fields.
|
||||
ErrInvalidInput = sharederrors.New(sharederrors.CodeValidationFailed, "invalid leaderboard input")
|
||||
// ErrForbidden indicates caller is not authorized for requested operation.
|
||||
ErrForbidden = sharederrors.New(sharederrors.CodeForbidden, "forbidden")
|
||||
// ErrPlayerNotFound indicates missing player ranking data.
|
||||
ErrPlayerNotFound = sharederrors.New(sharederrors.CodeNotFound, "player not found")
|
||||
)
|
||||
@ -0,0 +1,28 @@
|
||||
package leaderboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sharedtypes "knowfoolery/backend/shared/domain/types"
|
||||
)
|
||||
|
||||
// TopFilter narrows leaderboard list/stat queries.
|
||||
type TopFilter struct {
|
||||
CompletionType string
|
||||
Window Window
|
||||
}
|
||||
|
||||
// Repository defines leaderboard persistence behavior.
|
||||
type Repository interface {
|
||||
EnsureSchema(ctx context.Context) error
|
||||
IngestEntry(ctx context.Context, entry *LeaderboardEntry) (*LeaderboardEntry, bool, error)
|
||||
ListTop(ctx context.Context, filter TopFilter, limit int) ([]*LeaderboardEntry, error)
|
||||
GetPlayerStats(ctx context.Context, playerID string) (*PlayerStats, error)
|
||||
GetPlayerRank(ctx context.Context, playerID string) (int64, error)
|
||||
ListPlayerHistory(
|
||||
ctx context.Context,
|
||||
playerID string,
|
||||
pagination sharedtypes.Pagination,
|
||||
) ([]*LeaderboardEntry, int64, error)
|
||||
GetGlobalStats(ctx context.Context, filter TopFilter) (*GlobalStats, error)
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
|
||||
sharedredis "knowfoolery/backend/shared/infra/database/redis"
|
||||
"knowfoolery/backend/shared/infra/observability/logging"
|
||||
"knowfoolery/backend/shared/infra/observability/metrics"
|
||||
"knowfoolery/backend/shared/infra/observability/tracing"
|
||||
"knowfoolery/backend/shared/infra/utils/envutil"
|
||||
)
|
||||
|
||||
// Config holds runtime configuration for leaderboard-service.
|
||||
type Config struct {
|
||||
AppName string
|
||||
Port int
|
||||
|
||||
TopLimit int
|
||||
PlayerHistoryDefault int
|
||||
PlayerHistoryMax int
|
||||
CacheTTL time.Duration
|
||||
UpdateRequireAuth bool
|
||||
UpstreamHTTPTimeout time.Duration
|
||||
|
||||
Postgres sharedpostgres.Config
|
||||
Redis sharedredis.Config
|
||||
Tracing tracing.Config
|
||||
Metrics metrics.Config
|
||||
Logging logging.Config
|
||||
|
||||
ZitadelBaseURL string
|
||||
ZitadelIssuer string
|
||||
ZitadelAudience string
|
||||
ZitadelClientID string
|
||||
ZitadelSecret string
|
||||
}
|
||||
|
||||
// FromEnv builds config from env vars.
|
||||
func FromEnv() Config {
|
||||
env := envutil.String("ENVIRONMENT", "development")
|
||||
serviceName := "leaderboard-service"
|
||||
|
||||
logCfg := logging.DefaultConfig()
|
||||
logCfg.ServiceName = serviceName
|
||||
logCfg.Environment = env
|
||||
logCfg.Level = envutil.String("LOG_LEVEL", logCfg.Level)
|
||||
|
||||
traceCfg := tracing.ConfigFromEnv()
|
||||
if traceCfg.ServiceName == "knowfoolery" {
|
||||
traceCfg.ServiceName = serviceName
|
||||
}
|
||||
traceCfg.Environment = env
|
||||
|
||||
metricsCfg := metrics.ConfigFromEnv()
|
||||
if metricsCfg.ServiceName == "knowfoolery" {
|
||||
metricsCfg.ServiceName = serviceName
|
||||
}
|
||||
|
||||
return Config{
|
||||
AppName: "Know Foolery - Leaderboard Service",
|
||||
Port: envutil.Int("LEADERBOARD_PORT", 8083),
|
||||
TopLimit: envutil.Int("LEADERBOARD_TOP_LIMIT", 10),
|
||||
PlayerHistoryDefault: envutil.Int("LEADERBOARD_PLAYER_HISTORY_DEFAULT_LIMIT", 20),
|
||||
PlayerHistoryMax: envutil.Int("LEADERBOARD_PLAYER_HISTORY_MAX_LIMIT", 100),
|
||||
CacheTTL: envutil.Duration("LEADERBOARD_CACHE_TTL", 60*time.Second),
|
||||
UpdateRequireAuth: parseBool("LEADERBOARD_UPDATE_REQUIRE_AUTH", true),
|
||||
UpstreamHTTPTimeout: envutil.Duration("UPSTREAM_HTTP_TIMEOUT", 3*time.Second),
|
||||
|
||||
Postgres: sharedpostgres.ConfigFromEnv(),
|
||||
Redis: sharedredis.ConfigFromEnv(),
|
||||
Tracing: traceCfg,
|
||||
Metrics: metricsCfg,
|
||||
Logging: logCfg,
|
||||
|
||||
ZitadelBaseURL: envutil.String("ZITADEL_URL", ""),
|
||||
ZitadelIssuer: envutil.String("ZITADEL_ISSUER", ""),
|
||||
ZitadelAudience: envutil.String("ZITADEL_AUDIENCE", ""),
|
||||
ZitadelClientID: envutil.String("ZITADEL_CLIENT_ID", ""),
|
||||
ZitadelSecret: envutil.String("ZITADEL_CLIENT_SECRET", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func parseBool(key string, fallback bool) bool {
|
||||
v := envutil.String(key, "")
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sharedpostgres "knowfoolery/backend/shared/infra/database/postgres"
|
||||
)
|
||||
|
||||
// Client aliases shared postgres client.
|
||||
type Client = sharedpostgres.Client
|
||||
|
||||
// NewClient creates postgres pooled client.
|
||||
func NewClient(ctx context.Context, cfg sharedpostgres.Config) (*Client, error) {
|
||||
return sharedpostgres.NewClient(ctx, cfg)
|
||||
}
|
||||
@ -0,0 +1,438 @@
|
||||
package ent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
|
||||
sharedtypes "knowfoolery/backend/shared/domain/types"
|
||||
)
|
||||
|
||||
// LeaderboardRepository implements leaderboard persistence.
|
||||
type LeaderboardRepository struct {
|
||||
client *Client
|
||||
}
|
||||
|
||||
// NewLeaderboardRepository creates leaderboard repository.
|
||||
func NewLeaderboardRepository(client *Client) *LeaderboardRepository {
|
||||
return &LeaderboardRepository{client: client}
|
||||
}
|
||||
|
||||
// EnsureSchema creates required tables and indexes.
|
||||
func (r *LeaderboardRepository) EnsureSchema(ctx context.Context) error {
|
||||
const ddl = `
|
||||
CREATE TABLE IF NOT EXISTS leaderboard_entries (
|
||||
id UUID PRIMARY KEY,
|
||||
session_id VARCHAR(64) NOT NULL UNIQUE,
|
||||
player_id VARCHAR(128) NOT NULL,
|
||||
player_name VARCHAR(50) NOT NULL,
|
||||
score INT NOT NULL,
|
||||
questions_asked INT NOT NULL,
|
||||
questions_correct INT NOT NULL,
|
||||
hints_used INT NOT NULL DEFAULT 0,
|
||||
duration_seconds INT NOT NULL,
|
||||
success_rate NUMERIC(5,2) NOT NULL,
|
||||
completion_type VARCHAR(16) NOT NULL,
|
||||
completed_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_lb_entries_rank_order
|
||||
ON leaderboard_entries (score DESC, duration_seconds ASC, completed_at ASC);
|
||||
CREATE INDEX IF NOT EXISTS idx_lb_entries_player_time
|
||||
ON leaderboard_entries (player_id, completed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_lb_entries_completion_time
|
||||
ON leaderboard_entries (completion_type, completed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_lb_entries_created
|
||||
ON leaderboard_entries (created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leaderboard_player_stats (
|
||||
player_id VARCHAR(128) PRIMARY KEY,
|
||||
player_name VARCHAR(50) NOT NULL,
|
||||
games_played INT NOT NULL DEFAULT 0,
|
||||
games_completed INT NOT NULL DEFAULT 0,
|
||||
total_score BIGINT NOT NULL DEFAULT 0,
|
||||
best_score INT NOT NULL DEFAULT 0,
|
||||
avg_score NUMERIC(10,2) NOT NULL DEFAULT 0,
|
||||
avg_success_rate NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
total_questions BIGINT NOT NULL DEFAULT 0,
|
||||
total_correct BIGINT NOT NULL DEFAULT 0,
|
||||
best_duration_seconds INT NULL,
|
||||
last_played_at TIMESTAMPTZ NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_lb_player_rank_order
|
||||
ON leaderboard_player_stats (best_score DESC, best_duration_seconds ASC, last_played_at ASC);
|
||||
CREATE INDEX IF NOT EXISTS idx_lb_player_last_played
|
||||
ON leaderboard_player_stats (last_played_at DESC);
|
||||
`
|
||||
_, err := r.client.Pool.Exec(ctx, ddl)
|
||||
return err
|
||||
}
|
||||
|
||||
// IngestEntry inserts one new entry and upserts player stats atomically.
|
||||
func (r *LeaderboardRepository) IngestEntry(
|
||||
ctx context.Context,
|
||||
entry *domain.LeaderboardEntry,
|
||||
) (*domain.LeaderboardEntry, bool, error) {
|
||||
tx, err := r.client.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
existing, getErr := r.getEntryBySessionIDTx(ctx, tx, entry.SessionID)
|
||||
if getErr == nil {
|
||||
if commitErr := tx.Commit(ctx); commitErr != nil {
|
||||
return nil, false, commitErr
|
||||
}
|
||||
return existing, true, nil
|
||||
}
|
||||
if !errors.Is(getErr, pgx.ErrNoRows) {
|
||||
return nil, false, getErr
|
||||
}
|
||||
|
||||
inserted, err := r.insertEntryTx(ctx, tx, entry)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := r.upsertPlayerStatsTx(ctx, tx, inserted); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return inserted, false, nil
|
||||
}
|
||||
|
||||
// ListTop lists top entries for given filter.
|
||||
func (r *LeaderboardRepository) ListTop(
|
||||
ctx context.Context,
|
||||
filter domain.TopFilter,
|
||||
limit int,
|
||||
) ([]*domain.LeaderboardEntry, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
where, args := buildFilterWhere(filter)
|
||||
args = append(args, limit)
|
||||
q := `
|
||||
SELECT id, session_id, player_id, player_name, score, questions_asked, questions_correct, hints_used,
|
||||
success_rate, duration_seconds, completion_type, completed_at, created_at
|
||||
FROM leaderboard_entries
|
||||
` + where + `
|
||||
ORDER BY score DESC, duration_seconds ASC, completed_at ASC
|
||||
LIMIT $` + strconvI(len(args))
|
||||
|
||||
rows, err := r.client.Pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]*domain.LeaderboardEntry, 0)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanEntry(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// GetPlayerStats returns one player's aggregate stats.
|
||||
func (r *LeaderboardRepository) GetPlayerStats(ctx context.Context, playerID string) (*domain.PlayerStats, error) {
|
||||
const q = `
|
||||
SELECT player_id, player_name, games_played, games_completed, total_score, best_score, avg_score,
|
||||
avg_success_rate, total_questions, total_correct, best_duration_seconds, last_played_at, updated_at
|
||||
FROM leaderboard_player_stats
|
||||
WHERE player_id=$1`
|
||||
|
||||
row := r.client.Pool.QueryRow(ctx, q, playerID)
|
||||
stats, err := scanPlayerStats(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrPlayerNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetPlayerRank computes current rank for one player.
|
||||
func (r *LeaderboardRepository) GetPlayerRank(ctx context.Context, playerID string) (int64, error) {
|
||||
const q = `
|
||||
WITH ranked AS (
|
||||
SELECT player_id,
|
||||
RANK() OVER (
|
||||
ORDER BY best_score DESC, best_duration_seconds ASC NULLS LAST, last_played_at ASC NULLS LAST
|
||||
) AS rank_value
|
||||
FROM leaderboard_player_stats
|
||||
)
|
||||
SELECT rank_value FROM ranked WHERE player_id=$1`
|
||||
row := r.client.Pool.QueryRow(ctx, q, playerID)
|
||||
var rank int64
|
||||
if err := row.Scan(&rank); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, domain.ErrPlayerNotFound
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return rank, nil
|
||||
}
|
||||
|
||||
// ListPlayerHistory returns paginated history for one player.
|
||||
func (r *LeaderboardRepository) ListPlayerHistory(
|
||||
ctx context.Context,
|
||||
playerID string,
|
||||
pagination sharedtypes.Pagination,
|
||||
) ([]*domain.LeaderboardEntry, int64, error) {
|
||||
pagination.Normalize()
|
||||
|
||||
const qCount = `SELECT COUNT(1) FROM leaderboard_entries WHERE player_id=$1`
|
||||
var total int64
|
||||
if err := r.client.Pool.QueryRow(ctx, qCount, playerID).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
const q = `
|
||||
SELECT id, session_id, player_id, player_name, score, questions_asked, questions_correct, hints_used,
|
||||
success_rate, duration_seconds, completion_type, completed_at, created_at
|
||||
FROM leaderboard_entries
|
||||
WHERE player_id=$1
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT $2 OFFSET $3`
|
||||
rows, err := r.client.Pool.Query(ctx, q, playerID, pagination.Limit(), pagination.Offset())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]*domain.LeaderboardEntry, 0)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanEntry(rows)
|
||||
if scanErr != nil {
|
||||
return nil, 0, scanErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
// GetGlobalStats computes global leaderboard statistics.
|
||||
func (r *LeaderboardRepository) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) {
|
||||
where, args := buildFilterWhere(filter)
|
||||
q := `
|
||||
SELECT
|
||||
COUNT(1) AS total_games,
|
||||
COUNT(DISTINCT player_id) AS total_players,
|
||||
COALESCE(AVG(score), 0) AS avg_score,
|
||||
COALESCE(AVG(success_rate), 0) AS avg_success_rate,
|
||||
COALESCE(MAX(score), 0) AS max_score,
|
||||
COALESCE(percentile_cont(0.5) WITHIN GROUP (ORDER BY score), 0) AS score_p50,
|
||||
COALESCE(percentile_cont(0.9) WITHIN GROUP (ORDER BY score), 0) AS score_p90,
|
||||
COALESCE(percentile_cont(0.99) WITHIN GROUP (ORDER BY score), 0) AS score_p99
|
||||
FROM leaderboard_entries
|
||||
` + where
|
||||
|
||||
row := r.client.Pool.QueryRow(ctx, q, args...)
|
||||
stats := &domain.GlobalStats{UpdatedAt: time.Now().UTC()}
|
||||
if err := row.Scan(
|
||||
&stats.TotalGames,
|
||||
&stats.TotalPlayers,
|
||||
&stats.AvgScore,
|
||||
&stats.AvgSuccessRate,
|
||||
&stats.MaxScore,
|
||||
&stats.ScoreP50,
|
||||
&stats.ScoreP90,
|
||||
&stats.ScoreP99,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (r *LeaderboardRepository) getEntryBySessionIDTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
sessionID string,
|
||||
) (*domain.LeaderboardEntry, error) {
|
||||
const q = `
|
||||
SELECT id, session_id, player_id, player_name, score, questions_asked, questions_correct, hints_used,
|
||||
success_rate, duration_seconds, completion_type, completed_at, created_at
|
||||
FROM leaderboard_entries WHERE session_id=$1`
|
||||
return scanEntry(tx.QueryRow(ctx, q, sessionID))
|
||||
}
|
||||
|
||||
func (r *LeaderboardRepository) insertEntryTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
entry *domain.LeaderboardEntry,
|
||||
) (*domain.LeaderboardEntry, error) {
|
||||
const q = `
|
||||
INSERT INTO leaderboard_entries (
|
||||
id, session_id, player_id, player_name, score, questions_asked, questions_correct, hints_used,
|
||||
success_rate, duration_seconds, completion_type, completed_at, created_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW())
|
||||
RETURNING id, session_id, player_id, player_name, score, questions_asked, questions_correct, hints_used,
|
||||
success_rate, duration_seconds, completion_type, completed_at, created_at`
|
||||
|
||||
row := tx.QueryRow(ctx, q,
|
||||
uuid.NewString(),
|
||||
entry.SessionID,
|
||||
entry.PlayerID,
|
||||
entry.PlayerName,
|
||||
entry.Score,
|
||||
entry.QuestionsAsked,
|
||||
entry.QuestionsCorrect,
|
||||
entry.HintsUsed,
|
||||
entry.SuccessRate,
|
||||
entry.DurationSeconds,
|
||||
string(entry.CompletionType),
|
||||
entry.CompletedAt,
|
||||
)
|
||||
return scanEntry(row)
|
||||
}
|
||||
|
||||
func (r *LeaderboardRepository) upsertPlayerStatsTx(
|
||||
ctx context.Context,
|
||||
tx pgx.Tx,
|
||||
entry *domain.LeaderboardEntry,
|
||||
) error {
|
||||
const q = `
|
||||
INSERT INTO leaderboard_player_stats (
|
||||
player_id, player_name, games_played, games_completed, total_score, best_score, avg_score,
|
||||
avg_success_rate, total_questions, total_correct, best_duration_seconds, last_played_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, 1, CASE WHEN $3='completed' THEN 1 ELSE 0 END, $4, $4, $4::numeric, $5, $6, $7, $8, $9, NOW()
|
||||
)
|
||||
ON CONFLICT (player_id)
|
||||
DO UPDATE SET
|
||||
player_name = EXCLUDED.player_name,
|
||||
games_played = leaderboard_player_stats.games_played + 1,
|
||||
games_completed = leaderboard_player_stats.games_completed
|
||||
+ CASE WHEN EXCLUDED.games_completed > 0 THEN 1 ELSE 0 END,
|
||||
total_score = leaderboard_player_stats.total_score + EXCLUDED.total_score,
|
||||
best_score = GREATEST(leaderboard_player_stats.best_score, EXCLUDED.best_score),
|
||||
avg_score = (
|
||||
(leaderboard_player_stats.total_score + EXCLUDED.total_score)::numeric
|
||||
/ (leaderboard_player_stats.games_played + 1)::numeric
|
||||
),
|
||||
total_questions = leaderboard_player_stats.total_questions + EXCLUDED.total_questions,
|
||||
total_correct = leaderboard_player_stats.total_correct + EXCLUDED.total_correct,
|
||||
avg_success_rate = CASE
|
||||
WHEN (leaderboard_player_stats.total_questions + EXCLUDED.total_questions) = 0 THEN 0
|
||||
ELSE (
|
||||
(leaderboard_player_stats.total_correct + EXCLUDED.total_correct)::numeric * 100
|
||||
/ (leaderboard_player_stats.total_questions + EXCLUDED.total_questions)::numeric
|
||||
)
|
||||
END,
|
||||
best_duration_seconds = CASE
|
||||
WHEN EXCLUDED.best_score > leaderboard_player_stats.best_score THEN EXCLUDED.best_duration_seconds
|
||||
WHEN EXCLUDED.best_score = leaderboard_player_stats.best_score THEN LEAST(
|
||||
COALESCE(leaderboard_player_stats.best_duration_seconds, EXCLUDED.best_duration_seconds),
|
||||
EXCLUDED.best_duration_seconds
|
||||
)
|
||||
ELSE leaderboard_player_stats.best_duration_seconds
|
||||
END,
|
||||
last_played_at = GREATEST(COALESCE(leaderboard_player_stats.last_played_at, EXCLUDED.last_played_at), EXCLUDED.last_played_at),
|
||||
updated_at = NOW()`
|
||||
|
||||
_, err := tx.Exec(ctx, q,
|
||||
entry.PlayerID,
|
||||
entry.PlayerName,
|
||||
string(entry.CompletionType),
|
||||
entry.Score,
|
||||
entry.SuccessRate,
|
||||
entry.QuestionsAsked,
|
||||
entry.QuestionsCorrect,
|
||||
entry.DurationSeconds,
|
||||
entry.CompletedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func buildFilterWhere(filter domain.TopFilter) (string, []any) {
|
||||
parts := make([]string, 0, 2)
|
||||
args := make([]any, 0, 2)
|
||||
if strings.TrimSpace(filter.CompletionType) != "" {
|
||||
args = append(args, filter.CompletionType)
|
||||
parts = append(parts, "completion_type=$"+strconvI(len(args)))
|
||||
}
|
||||
switch filter.Window {
|
||||
case domain.Window24h:
|
||||
args = append(args, time.Now().UTC().Add(-24*time.Hour))
|
||||
parts = append(parts, "completed_at>=$"+strconvI(len(args)))
|
||||
case domain.Window7d:
|
||||
args = append(args, time.Now().UTC().Add(-7*24*time.Hour))
|
||||
parts = append(parts, "completed_at>=$"+strconvI(len(args)))
|
||||
case domain.Window30d:
|
||||
args = append(args, time.Now().UTC().Add(-30*24*time.Hour))
|
||||
parts = append(parts, "completed_at>=$"+strconvI(len(args)))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return "WHERE " + strings.Join(parts, " AND "), args
|
||||
}
|
||||
|
||||
func scanEntry(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*domain.LeaderboardEntry, error) {
|
||||
var entry domain.LeaderboardEntry
|
||||
var completionType string
|
||||
if err := scanner.Scan(
|
||||
&entry.ID,
|
||||
&entry.SessionID,
|
||||
&entry.PlayerID,
|
||||
&entry.PlayerName,
|
||||
&entry.Score,
|
||||
&entry.QuestionsAsked,
|
||||
&entry.QuestionsCorrect,
|
||||
&entry.HintsUsed,
|
||||
&entry.SuccessRate,
|
||||
&entry.DurationSeconds,
|
||||
&completionType,
|
||||
&entry.CompletedAt,
|
||||
&entry.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry.CompletionType = domain.CompletionType(completionType)
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func scanPlayerStats(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*domain.PlayerStats, error) {
|
||||
var stats domain.PlayerStats
|
||||
if err := scanner.Scan(
|
||||
&stats.PlayerID,
|
||||
&stats.PlayerName,
|
||||
&stats.GamesPlayed,
|
||||
&stats.GamesCompleted,
|
||||
&stats.TotalScore,
|
||||
&stats.BestScore,
|
||||
&stats.AvgScore,
|
||||
&stats.AvgSuccessRate,
|
||||
&stats.TotalQuestions,
|
||||
&stats.TotalCorrect,
|
||||
&stats.BestDurationSec,
|
||||
&stats.LastPlayedAt,
|
||||
&stats.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
func strconvI(n int) string {
|
||||
return strconv.FormatInt(int64(n), 10)
|
||||
}
|
||||
|
||||
var _ domain.Repository = (*LeaderboardRepository)(nil)
|
||||
@ -0,0 +1,47 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sharedredis "knowfoolery/backend/shared/infra/database/redis"
|
||||
)
|
||||
|
||||
// Store provides redis-backed cache operations.
|
||||
type Store struct {
|
||||
redis *sharedredis.Client
|
||||
}
|
||||
|
||||
// NewStore creates cache store.
|
||||
func NewStore(redisClient *sharedredis.Client) *Store {
|
||||
return &Store{redis: redisClient}
|
||||
}
|
||||
|
||||
// Get retrieves a cached string value.
|
||||
func (s *Store) Get(ctx context.Context, key string) (string, bool) {
|
||||
if s.redis == nil {
|
||||
return "", false
|
||||
}
|
||||
value, err := s.redis.Get(ctx, key)
|
||||
if err != nil || strings.TrimSpace(value) == "" {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
// Set writes a cache value with TTL.
|
||||
func (s *Store) Set(ctx context.Context, key, value string, ttl time.Duration) error {
|
||||
if s.redis == nil {
|
||||
return nil
|
||||
}
|
||||
return s.redis.Set(ctx, key, value, ttl)
|
||||
}
|
||||
|
||||
// Delete removes cache keys.
|
||||
func (s *Store) Delete(ctx context.Context, keys ...string) error {
|
||||
if s.redis == nil || len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.redis.Delete(ctx, keys...)
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
|
||||
app "knowfoolery/backend/services/leaderboard-service/internal/application/leaderboard"
|
||||
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
|
||||
sharederrors "knowfoolery/backend/shared/domain/errors"
|
||||
sharedtypes "knowfoolery/backend/shared/domain/types"
|
||||
"knowfoolery/backend/shared/infra/auth/zitadel"
|
||||
"knowfoolery/backend/shared/infra/observability/logging"
|
||||
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
|
||||
"knowfoolery/backend/shared/infra/utils/httputil"
|
||||
"knowfoolery/backend/shared/infra/utils/validation"
|
||||
)
|
||||
|
||||
// Handler implements leaderboard HTTP endpoints.
|
||||
type Handler struct {
|
||||
service *app.Service
|
||||
validator *validation.Validator
|
||||
logger *logging.Logger
|
||||
metrics *sharedmetrics.Metrics
|
||||
updateRequireAuth bool
|
||||
defaultPageSize int
|
||||
maxPageSize int
|
||||
}
|
||||
|
||||
// NewHandler creates handler set.
|
||||
func NewHandler(
|
||||
service *app.Service,
|
||||
validator *validation.Validator,
|
||||
logger *logging.Logger,
|
||||
metrics *sharedmetrics.Metrics,
|
||||
updateRequireAuth bool,
|
||||
defaultPageSize int,
|
||||
maxPageSize int,
|
||||
) *Handler {
|
||||
if defaultPageSize <= 0 {
|
||||
defaultPageSize = 20
|
||||
}
|
||||
if maxPageSize <= 0 {
|
||||
maxPageSize = 100
|
||||
}
|
||||
return &Handler{
|
||||
service: service,
|
||||
validator: validator,
|
||||
logger: logger,
|
||||
metrics: metrics,
|
||||
updateRequireAuth: updateRequireAuth,
|
||||
defaultPageSize: defaultPageSize,
|
||||
maxPageSize: maxPageSize,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTop10 handles GET /leaderboard/top10.
|
||||
func (h *Handler) GetTop10(c fiber.Ctx) error {
|
||||
result, err := h.service.GetTop10(c.Context(), app.GetTopInput{
|
||||
CompletionType: strings.TrimSpace(c.Query("completion_type")),
|
||||
Window: domain.Window(strings.TrimSpace(c.Query("window", string(domain.WindowAll)))),
|
||||
})
|
||||
if err != nil {
|
||||
return h.sendMappedError(c, err)
|
||||
}
|
||||
h.recordRequestMetric("GET", "/leaderboard/top10", fiber.StatusOK)
|
||||
return httputil.OK(c, result)
|
||||
}
|
||||
|
||||
// GetPlayerRanking handles GET /leaderboard/players/:id.
|
||||
func (h *Handler) GetPlayerRanking(c fiber.Ctx) error {
|
||||
playerID := strings.TrimSpace(c.Params("id"))
|
||||
claims := authClaimsFromContext(c)
|
||||
if !claims.IsAdmin && claims.UserID != playerID {
|
||||
return httputil.SendError(c, domain.ErrForbidden)
|
||||
}
|
||||
|
||||
page := atoiWithDefault(c.Query("page"), 1)
|
||||
pageSize := atoiWithDefault(c.Query("page_size"), h.defaultPageSize)
|
||||
if pageSize > h.maxPageSize {
|
||||
pageSize = h.maxPageSize
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = h.defaultPageSize
|
||||
}
|
||||
|
||||
result, err := h.service.GetPlayerRanking(c.Context(), app.GetPlayerRankingInput{
|
||||
PlayerID: playerID,
|
||||
Pagination: sharedtypes.Pagination{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return h.sendMappedError(c, err)
|
||||
}
|
||||
h.recordRequestMetric("GET", "/leaderboard/players/{id}", fiber.StatusOK)
|
||||
return httputil.OK(c, result)
|
||||
}
|
||||
|
||||
// GetStats handles GET /leaderboard/stats.
|
||||
func (h *Handler) GetStats(c fiber.Ctx) error {
|
||||
stats, err := h.service.GetGlobalStats(c.Context(), app.GetStatsInput{
|
||||
CompletionType: strings.TrimSpace(c.Query("completion_type")),
|
||||
Window: domain.Window(strings.TrimSpace(c.Query("window", string(domain.WindowAll)))),
|
||||
})
|
||||
if err != nil {
|
||||
return h.sendMappedError(c, err)
|
||||
}
|
||||
h.recordRequestMetric("GET", "/leaderboard/stats", fiber.StatusOK)
|
||||
return httputil.OK(c, stats)
|
||||
}
|
||||
|
||||
// Update handles POST /leaderboard/update.
|
||||
func (h *Handler) Update(c fiber.Ctx) error {
|
||||
if h.updateRequireAuth {
|
||||
claims := authClaimsFromContext(c)
|
||||
if claims.UserID == "" || (!claims.IsAdmin && !claims.IsService) {
|
||||
return httputil.SendError(c, sharederrors.New(sharederrors.CodeForbidden, "forbidden"))
|
||||
}
|
||||
}
|
||||
|
||||
var req UpdateLeaderboardRequest
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInvalidInput, "invalid request body", err))
|
||||
}
|
||||
if err := h.validator.Validate(req); err != nil {
|
||||
return httputil.SendError(c, err)
|
||||
}
|
||||
completedAt, err := time.Parse(time.RFC3339, req.CompletedAt)
|
||||
if err != nil {
|
||||
return httputil.SendError(
|
||||
c,
|
||||
sharederrors.Wrap(sharederrors.CodeValidationFailed, "completed_at must be RFC3339", err),
|
||||
)
|
||||
}
|
||||
|
||||
entry, err := h.service.UpdateScore(c.Context(), app.UpdateScoreInput{
|
||||
SessionID: req.SessionID,
|
||||
PlayerID: req.PlayerID,
|
||||
PlayerName: req.PlayerName,
|
||||
TotalScore: req.TotalScore,
|
||||
QuestionsAsked: req.QuestionsAsked,
|
||||
QuestionsCorrect: req.QuestionsCorrect,
|
||||
HintsUsed: req.HintsUsed,
|
||||
DurationSeconds: req.DurationSeconds,
|
||||
CompletedAt: completedAt,
|
||||
CompletionType: req.CompletionType,
|
||||
})
|
||||
if err != nil {
|
||||
return h.sendMappedError(c, err)
|
||||
}
|
||||
h.recordRequestMetric("POST", "/leaderboard/update", fiber.StatusOK)
|
||||
return httputil.OK(c, entry)
|
||||
}
|
||||
|
||||
func (h *Handler) sendMappedError(c fiber.Ctx, err error) error {
|
||||
var domainErr *sharederrors.DomainError
|
||||
if errors.As(err, &domainErr) {
|
||||
return httputil.SendError(c, domainErr)
|
||||
}
|
||||
if h.logger != nil {
|
||||
h.logger.WithError(err).Error("leaderboard-service internal error")
|
||||
}
|
||||
return httputil.SendError(c, sharederrors.Wrap(sharederrors.CodeInternal, "internal error", err))
|
||||
}
|
||||
|
||||
func (h *Handler) recordRequestMetric(method, endpoint string, status int) {
|
||||
if h.metrics == nil {
|
||||
return
|
||||
}
|
||||
h.metrics.HTTPRequestsTotal.WithLabelValues(
|
||||
method, endpoint, strconv.Itoa(status), "leaderboard-service",
|
||||
).Inc()
|
||||
}
|
||||
|
||||
type authClaims struct {
|
||||
UserID string
|
||||
IsAdmin bool
|
||||
IsService bool
|
||||
}
|
||||
|
||||
func authClaimsFromContext(c fiber.Ctx) authClaims {
|
||||
roles := zitadel.GetUserRoles(c)
|
||||
claims := authClaims{UserID: zitadel.GetUserID(c)}
|
||||
for _, role := range roles {
|
||||
if role == "admin" {
|
||||
claims.IsAdmin = true
|
||||
}
|
||||
if role == "service" {
|
||||
claims.IsService = true
|
||||
}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func atoiWithDefault(v string, d int) int {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return d
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return d
|
||||
}
|
||||
return n
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package http
|
||||
|
||||
// UpdateLeaderboardRequest is POST /leaderboard/update payload.
|
||||
type UpdateLeaderboardRequest struct {
|
||||
SessionID string `json:"session_id" validate:"required,min=1,max=64"`
|
||||
PlayerID string `json:"player_id" validate:"required,min=1,max=128"`
|
||||
PlayerName string `json:"player_name" validate:"required,player_name"`
|
||||
TotalScore int `json:"total_score" validate:"min=0"`
|
||||
QuestionsAsked int `json:"questions_asked" validate:"min=0"`
|
||||
QuestionsCorrect int `json:"questions_correct" validate:"min=0"`
|
||||
HintsUsed int `json:"hints_used" validate:"min=0"`
|
||||
DurationSeconds int `json:"duration_seconds" validate:"min=0"`
|
||||
CompletedAt string `json:"completed_at" validate:"required"`
|
||||
CompletionType string `json:"completion_type" validate:"required,oneof=completed timed_out abandoned"`
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package http
|
||||
|
||||
import "github.com/gofiber/fiber/v3"
|
||||
|
||||
// RegisterRoutes registers leaderboard routes.
|
||||
func RegisterRoutes(app *fiber.App, h *Handler, authMiddleware fiber.Handler) {
|
||||
lb := app.Group("/leaderboard")
|
||||
lb.Get("/top10", h.GetTop10)
|
||||
lb.Get("/stats", h.GetStats)
|
||||
|
||||
protected := app.Group("/leaderboard")
|
||||
if authMiddleware != nil {
|
||||
protected.Use(authMiddleware)
|
||||
}
|
||||
protected.Get("/players/:id", h.GetPlayerRanking)
|
||||
protected.Post("/update", h.Update)
|
||||
}
|
||||
@ -0,0 +1,204 @@
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/adaptor"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
|
||||
applb "knowfoolery/backend/services/leaderboard-service/internal/application/leaderboard"
|
||||
domain "knowfoolery/backend/services/leaderboard-service/internal/domain/leaderboard"
|
||||
httpapi "knowfoolery/backend/services/leaderboard-service/internal/interfaces/http"
|
||||
sharedtypes "knowfoolery/backend/shared/domain/types"
|
||||
sharedmetrics "knowfoolery/backend/shared/infra/observability/metrics"
|
||||
"knowfoolery/backend/shared/infra/utils/validation"
|
||||
sharedhttpx "knowfoolery/backend/shared/testutil/httpx"
|
||||
)
|
||||
|
||||
type inMemoryRepo struct {
|
||||
entries []*domain.LeaderboardEntry
|
||||
stats map[string]*domain.PlayerStats
|
||||
}
|
||||
|
||||
func newInMemoryRepo() *inMemoryRepo {
|
||||
return &inMemoryRepo{
|
||||
entries: make([]*domain.LeaderboardEntry, 0),
|
||||
stats: map[string]*domain.PlayerStats{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *inMemoryRepo) EnsureSchema(ctx context.Context) error { return nil }
|
||||
func (r *inMemoryRepo) IngestEntry(
|
||||
ctx context.Context,
|
||||
entry *domain.LeaderboardEntry,
|
||||
) (*domain.LeaderboardEntry, bool, error) {
|
||||
for _, e := range r.entries {
|
||||
if e.SessionID == entry.SessionID {
|
||||
return e, true, nil
|
||||
}
|
||||
}
|
||||
cp := *entry
|
||||
cp.ID = "id-" + entry.SessionID
|
||||
cp.CreatedAt = time.Now().UTC()
|
||||
r.entries = append(r.entries, &cp)
|
||||
stats := r.stats[entry.PlayerID]
|
||||
if stats == nil {
|
||||
best := entry.DurationSeconds
|
||||
stats = &domain.PlayerStats{
|
||||
PlayerID: entry.PlayerID,
|
||||
PlayerName: entry.PlayerName,
|
||||
GamesPlayed: 1,
|
||||
GamesCompleted: 1,
|
||||
TotalScore: int64(entry.Score),
|
||||
BestScore: entry.Score,
|
||||
AvgScore: float64(entry.Score),
|
||||
AvgSuccessRate: entry.SuccessRate,
|
||||
TotalQuestions: int64(entry.QuestionsAsked),
|
||||
TotalCorrect: int64(entry.QuestionsCorrect),
|
||||
BestDurationSec: &best,
|
||||
}
|
||||
r.stats[entry.PlayerID] = stats
|
||||
}
|
||||
return &cp, false, nil
|
||||
}
|
||||
func (r *inMemoryRepo) ListTop(ctx context.Context, filter domain.TopFilter, limit int) ([]*domain.LeaderboardEntry, error) {
|
||||
if len(r.entries) < limit {
|
||||
limit = len(r.entries)
|
||||
}
|
||||
out := make([]*domain.LeaderboardEntry, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
out = append(out, r.entries[i])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (r *inMemoryRepo) GetPlayerStats(ctx context.Context, playerID string) (*domain.PlayerStats, error) {
|
||||
stats := r.stats[playerID]
|
||||
if stats == nil {
|
||||
return nil, domain.ErrPlayerNotFound
|
||||
}
|
||||
cp := *stats
|
||||
return &cp, nil
|
||||
}
|
||||
func (r *inMemoryRepo) GetPlayerRank(ctx context.Context, playerID string) (int64, error) {
|
||||
if _, ok := r.stats[playerID]; !ok {
|
||||
return 0, domain.ErrPlayerNotFound
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
func (r *inMemoryRepo) ListPlayerHistory(
|
||||
ctx context.Context,
|
||||
playerID string,
|
||||
pagination sharedtypes.Pagination,
|
||||
) ([]*domain.LeaderboardEntry, int64, error) {
|
||||
out := make([]*domain.LeaderboardEntry, 0)
|
||||
for _, e := range r.entries {
|
||||
if e.PlayerID == playerID {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, int64(len(out)), nil
|
||||
}
|
||||
func (r *inMemoryRepo) GetGlobalStats(ctx context.Context, filter domain.TopFilter) (*domain.GlobalStats, error) {
|
||||
return &domain.GlobalStats{
|
||||
TotalGames: int64(len(r.entries)),
|
||||
TotalPlayers: int64(len(r.stats)),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fakeState struct{}
|
||||
|
||||
func (s *fakeState) Get(ctx context.Context, key string) (string, bool) { return "", false }
|
||||
func (s *fakeState) Set(ctx context.Context, key, value string, ttl time.Duration) error {
|
||||
return nil
|
||||
}
|
||||
func (s *fakeState) Delete(ctx context.Context, keys ...string) error { return nil }
|
||||
|
||||
func setupApp(t *testing.T) *fiber.App {
|
||||
t.Helper()
|
||||
repo := newInMemoryRepo()
|
||||
svc := applb.NewService(repo, &fakeState{}, applb.Config{UpdateRequireAuth: true})
|
||||
metrics := sharedmetrics.NewMetrics(sharedmetrics.Config{
|
||||
ServiceName: "leaderboard-service-test",
|
||||
Enabled: true,
|
||||
Registry: prometheus.NewRegistry(),
|
||||
})
|
||||
h := httpapi.NewHandler(svc, validation.NewValidator(), nil, metrics, true, 20, 100)
|
||||
app := fiber.New()
|
||||
|
||||
auth := func(c fiber.Ctx) error {
|
||||
switch c.Get("Authorization") {
|
||||
case "Bearer player":
|
||||
c.Locals("user_id", "user-1")
|
||||
c.Locals("user_roles", []string{"player"})
|
||||
return c.Next()
|
||||
case "Bearer service":
|
||||
c.Locals("user_id", "svc")
|
||||
c.Locals("user_roles", []string{"service"})
|
||||
return c.Next()
|
||||
default:
|
||||
return c.SendStatus(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
httpapi.RegisterRoutes(app, h, auth)
|
||||
app.Get("/metrics", adaptor.HTTPHandler(sharedmetrics.Handler()))
|
||||
return app
|
||||
}
|
||||
|
||||
func TestUpdateAndTop10(t *testing.T) {
|
||||
app := setupApp(t)
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"session_id": "s1",
|
||||
"player_id": "user-1",
|
||||
"player_name": "Alice",
|
||||
"total_score": 10,
|
||||
"questions_asked": 12,
|
||||
"questions_correct": 9,
|
||||
"hints_used": 1,
|
||||
"duration_seconds": 200,
|
||||
"completed_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"completion_type": "completed",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/leaderboard/update", bytes.NewReader(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer service")
|
||||
resp := sharedhttpx.MustTest(t, app, req)
|
||||
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "update failed")
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/leaderboard/top10", nil)
|
||||
resp = sharedhttpx.MustTest(t, app, req)
|
||||
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "top10 failed")
|
||||
}
|
||||
|
||||
func TestPlayerAuthAndStats(t *testing.T) {
|
||||
app := setupApp(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/leaderboard/players/user-1", nil)
|
||||
req.Header.Set("Authorization", "Bearer player")
|
||||
resp := sharedhttpx.MustTest(t, app, req)
|
||||
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusNotFound, "expected not found before update")
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/leaderboard/players/user-2", nil)
|
||||
req.Header.Set("Authorization", "Bearer player")
|
||||
resp = sharedhttpx.MustTest(t, app, req)
|
||||
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusForbidden, "expected forbidden for other player")
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/leaderboard/stats", nil)
|
||||
resp = sharedhttpx.MustTest(t, app, req)
|
||||
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "stats failed")
|
||||
}
|
||||
|
||||
func TestMetricsEndpoint(t *testing.T) {
|
||||
app := setupApp(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||
resp := sharedhttpx.MustTest(t, app, req)
|
||||
sharedhttpx.AssertStatusAndClose(t, resp, http.StatusOK, "metrics failed")
|
||||
}
|
||||
@ -0,0 +1,389 @@
|
||||
# 2.4 Leaderboard Service (Port 8083) - Detailed Implementation Plan
|
||||
|
||||
## Summary
|
||||
Implement the Leaderboard Service as the read-optimized ranking and statistics service for game outcomes, consistent with implementation decisions used in `2.1` (Question Bank), `2.2` (User), and `2.3` (Game Session).
|
||||
|
||||
Runtime stack and conventions:
|
||||
- Fiber HTTP service with shared bootstrap and observability.
|
||||
- PostgreSQL persistence with `EnsureSchema(ctx)` startup DDL.
|
||||
- Redis for read-optimized ranking cache and top-10 acceleration.
|
||||
- Shared `backend/shared` packages for auth, errors, validation, logging, tracing, metrics, and readiness.
|
||||
|
||||
Scope boundary:
|
||||
- Modify only `backend/services/leaderboard-service/**`.
|
||||
- Do not modify `backend/services/*` other than leaderboard-service.
|
||||
- Do not modify `backend/shared/**`.
|
||||
|
||||
## Decisions Reused from 2.1, 2.2, and 2.3
|
||||
1. Service composition pattern:
|
||||
- `internal/infra/config.FromEnv()`
|
||||
- logger/metrics/tracer initialization in `cmd/main.go`
|
||||
- repository initialization + `EnsureSchema(ctx)` at startup
|
||||
- `/health`, `/ready`, `/metrics` registration
|
||||
- route registration via `internal/interfaces/http/routes.go`
|
||||
|
||||
2. Persistence and state pattern:
|
||||
- PostgreSQL as source of truth.
|
||||
- Redis as optional performance layer, non-fatal when unavailable.
|
||||
- service remains functional on PostgreSQL when Redis is down.
|
||||
|
||||
3. Error and transport pattern:
|
||||
- domain/application errors mapped via shared `httputil.SendError`.
|
||||
- standard response envelope style (`success`, `data`) used by existing services.
|
||||
|
||||
4. Inter-service integration approach:
|
||||
- HTTP adapters with application interfaces so transport can evolve later without domain changes.
|
||||
- explicit DTOs for upstream/downstream contracts.
|
||||
|
||||
5. Test pyramid:
|
||||
- unit tests for ranking/statistics logic.
|
||||
- HTTP integration tests with in-memory doubles/fakes.
|
||||
- optional DB-backed integration tests gated by environment.
|
||||
|
||||
## Objectives
|
||||
1. Provide public leaderboard query endpoints:
|
||||
- top 10 scores
|
||||
- player ranking and history
|
||||
- global statistics
|
||||
2. Provide internal score ingestion endpoint for completed sessions.
|
||||
3. Ensure deterministic ranking:
|
||||
- sort by score descending, then completion duration ascending, then completed_at ascending.
|
||||
4. Maintain historical score records for analytics and auditability.
|
||||
5. Deliver production-ready observability, readiness checks, and test coverage.
|
||||
|
||||
## API Endpoints
|
||||
- `GET /leaderboard/top10`
|
||||
- `GET /leaderboard/players/:id`
|
||||
- `GET /leaderboard/stats`
|
||||
- `POST /leaderboard/update` (internal command endpoint)
|
||||
|
||||
## Auth and Access Rules
|
||||
1. Query endpoints:
|
||||
- `GET /leaderboard/top10`, `GET /leaderboard/stats` are public read endpoints.
|
||||
- `GET /leaderboard/players/:id` requires auth; user can read own detailed history.
|
||||
- Admin role may read any player history.
|
||||
2. Update endpoint:
|
||||
- `POST /leaderboard/update` is internal-only and requires service/admin auth middleware.
|
||||
- Reject anonymous or non-privileged callers.
|
||||
|
||||
## Inter-Service Contracts
|
||||
|
||||
### Game Session dependency
|
||||
Purpose: ingest canonical final session outcomes.
|
||||
|
||||
Contract for `POST /leaderboard/update` request:
|
||||
- `session_id` (string, required)
|
||||
- `player_id` (string, required)
|
||||
- `player_name` (string, required)
|
||||
- `total_score` (int, required, >= 0)
|
||||
- `questions_asked` (int, required, >= 0)
|
||||
- `questions_correct` (int, required, >= 0)
|
||||
- `hints_used` (int, required, >= 0)
|
||||
- `duration_seconds` (int, required, >= 0)
|
||||
- `completed_at` (RFC3339 timestamp, required)
|
||||
- `completion_type` (`completed|timed_out|abandoned`, required)
|
||||
|
||||
Idempotency decision:
|
||||
- deduplicate on `session_id` (unique).
|
||||
- repeated update for same `session_id` returns success with existing persisted record.
|
||||
|
||||
### User Service dependency
|
||||
Purpose: optional profile hydration fallback for player display fields.
|
||||
|
||||
Decision:
|
||||
- leaderboard update request is authoritative for `player_name` to avoid hard runtime coupling.
|
||||
- optional future enrichment can call `GET /users/:id`, but is not required for step `2.4`.
|
||||
|
||||
## Domain Model
|
||||
Aggregates:
|
||||
- `LeaderboardEntry` (one per completed session)
|
||||
- `PlayerRankingSnapshot` (derived read model)
|
||||
|
||||
Value objects:
|
||||
- `Rank` (positive integer)
|
||||
- `SuccessRate` (0..100 percentage)
|
||||
- `CompletionType`
|
||||
|
||||
Domain services:
|
||||
- `RankingService` (ordering and tie-breaks)
|
||||
- `StatisticsService` (global aggregates)
|
||||
|
||||
Core invariants:
|
||||
1. Each `session_id` can be ingested once.
|
||||
2. Score cannot be negative.
|
||||
3. Questions counts cannot be negative.
|
||||
4. `questions_correct <= questions_asked`.
|
||||
5. Rank ordering rule is deterministic:
|
||||
- score desc
|
||||
- duration asc
|
||||
- completed_at asc
|
||||
6. Top10 response always returns at most 10 entries.
|
||||
|
||||
## Data Model (PostgreSQL)
|
||||
|
||||
### `leaderboard_entries`
|
||||
- `id UUID PRIMARY KEY`
|
||||
- `session_id VARCHAR(64) NOT NULL UNIQUE`
|
||||
- `player_id VARCHAR(128) NOT NULL`
|
||||
- `player_name VARCHAR(50) NOT NULL`
|
||||
- `score INT NOT NULL`
|
||||
- `questions_asked INT NOT NULL`
|
||||
- `questions_correct INT NOT NULL`
|
||||
- `hints_used INT NOT NULL DEFAULT 0`
|
||||
- `duration_seconds INT NOT NULL`
|
||||
- `success_rate NUMERIC(5,2) NOT NULL`
|
||||
- `completion_type VARCHAR(16) NOT NULL`
|
||||
- `completed_at TIMESTAMPTZ NOT NULL`
|
||||
- `created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
||||
|
||||
Indexes:
|
||||
- `(score DESC, duration_seconds ASC, completed_at ASC)`
|
||||
- `(player_id, completed_at DESC)`
|
||||
- `(completion_type, completed_at DESC)`
|
||||
- `(created_at DESC)`
|
||||
|
||||
### `leaderboard_player_stats`
|
||||
Pre-aggregated player read model for fast rank/profile reads.
|
||||
- `player_id VARCHAR(128) PRIMARY KEY`
|
||||
- `player_name VARCHAR(50) NOT NULL`
|
||||
- `games_played INT NOT NULL DEFAULT 0`
|
||||
- `games_completed INT NOT NULL DEFAULT 0`
|
||||
- `total_score BIGINT NOT NULL DEFAULT 0`
|
||||
- `best_score INT NOT NULL DEFAULT 0`
|
||||
- `avg_score NUMERIC(10,2) NOT NULL DEFAULT 0`
|
||||
- `avg_success_rate NUMERIC(5,2) NOT NULL DEFAULT 0`
|
||||
- `total_questions BIGINT NOT NULL DEFAULT 0`
|
||||
- `total_correct BIGINT NOT NULL DEFAULT 0`
|
||||
- `best_duration_seconds INT NULL`
|
||||
- `last_played_at TIMESTAMPTZ NULL`
|
||||
- `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`
|
||||
|
||||
Indexes:
|
||||
- `(best_score DESC, best_duration_seconds ASC, last_played_at ASC)`
|
||||
- `(last_played_at DESC)`
|
||||
|
||||
Note:
|
||||
- all write updates to `leaderboard_player_stats` happen transactionally with `leaderboard_entries` insert in the same repository method.
|
||||
|
||||
## Redis Usage
|
||||
Key prefix: `lb:`
|
||||
|
||||
Keys:
|
||||
- `lb:top10:v1` -> serialized top10 payload (TTL cache)
|
||||
- `lb:rank:{player_id}` -> cached player rank snapshot
|
||||
- `lb:stats:global:v1` -> cached global stats payload
|
||||
- `lb:zset:scores` -> sorted set score index (optional acceleration, non-authoritative)
|
||||
|
||||
Rules:
|
||||
1. PostgreSQL remains source of truth.
|
||||
2. On successful update ingestion:
|
||||
- invalidate `lb:top10:v1`
|
||||
- invalidate `lb:stats:global:v1`
|
||||
- invalidate `lb:rank:{player_id}`
|
||||
- best-effort Redis operations; failures logged and counted, not fatal.
|
||||
3. If Redis is unavailable:
|
||||
- query endpoints compute from PostgreSQL and still return success.
|
||||
- readiness check marks Redis as down but optional.
|
||||
|
||||
## Endpoint Behavior (Decision Complete)
|
||||
|
||||
### `POST /leaderboard/update`
|
||||
Input: final session outcome payload (see contract above).
|
||||
|
||||
Flow:
|
||||
1. authenticate internal caller.
|
||||
2. validate input fields and invariants.
|
||||
3. start transaction.
|
||||
4. check existing by `session_id`.
|
||||
5. if existing:
|
||||
- return existing normalized entry response (`200`, idempotent success).
|
||||
6. insert into `leaderboard_entries`.
|
||||
7. upsert/refresh `leaderboard_player_stats` aggregate.
|
||||
8. commit transaction.
|
||||
9. invalidate related Redis caches.
|
||||
10. emit structured log + metrics counter.
|
||||
|
||||
Output:
|
||||
- persisted leaderboard entry summary including computed `success_rate`.
|
||||
|
||||
### `GET /leaderboard/top10`
|
||||
Query params:
|
||||
- optional `completion_type` filter (`completed|timed_out|abandoned`)
|
||||
- optional `window` (`24h|7d|30d|all`, default `all`)
|
||||
|
||||
Flow:
|
||||
1. attempt Redis cache hit for matching key variant.
|
||||
2. on miss, query PostgreSQL ordered by ranking rule.
|
||||
3. compute rank values (1..N), cap at 10.
|
||||
4. cache result with short TTL.
|
||||
|
||||
Output:
|
||||
- ordered top list with rank, player_id, player_name, score, questions_asked, success_rate, duration_seconds, completed_at.
|
||||
|
||||
### `GET /leaderboard/players/:id`
|
||||
Auth:
|
||||
- self or admin.
|
||||
|
||||
Query params:
|
||||
- `page` (default 1)
|
||||
- `page_size` (default 20, max 100)
|
||||
|
||||
Flow:
|
||||
1. auth and ownership/admin check.
|
||||
2. fetch player aggregate from `leaderboard_player_stats`.
|
||||
3. fetch paginated history from `leaderboard_entries`.
|
||||
4. compute current global rank from ordering criteria against all players using `best_score` then tie-breakers.
|
||||
|
||||
Output:
|
||||
- player summary:
|
||||
- current_rank, games_played, best_score, avg_score, avg_success_rate, total_score
|
||||
- paginated history list.
|
||||
|
||||
### `GET /leaderboard/stats`
|
||||
Flow:
|
||||
1. attempt Redis cache hit.
|
||||
2. on miss, aggregate from PostgreSQL.
|
||||
|
||||
Returned stats:
|
||||
- `total_games`
|
||||
- `total_players`
|
||||
- `avg_score`
|
||||
- `avg_success_rate`
|
||||
- `max_score`
|
||||
- `score_p50`, `score_p90`, `score_p99`
|
||||
- `updated_at`
|
||||
|
||||
## Package Layout
|
||||
- `backend/services/leaderboard-service/cmd/main.go`
|
||||
- `backend/services/leaderboard-service/internal/infra/config/config.go`
|
||||
- `backend/services/leaderboard-service/internal/domain/leaderboard/`
|
||||
- `backend/services/leaderboard-service/internal/application/leaderboard/`
|
||||
- `backend/services/leaderboard-service/internal/infra/persistence/ent/`
|
||||
- `backend/services/leaderboard-service/internal/infra/state/`
|
||||
- `backend/services/leaderboard-service/internal/interfaces/http/`
|
||||
- `backend/services/leaderboard-service/tests/`
|
||||
|
||||
## Configuration
|
||||
Service-specific:
|
||||
- `LEADERBOARD_PORT` (default `8083`)
|
||||
- `LEADERBOARD_TOP_LIMIT` (default `10`)
|
||||
- `LEADERBOARD_PLAYER_HISTORY_DEFAULT_LIMIT` (default `20`)
|
||||
- `LEADERBOARD_PLAYER_HISTORY_MAX_LIMIT` (default `100`)
|
||||
- `LEADERBOARD_CACHE_TTL` (default `60s`)
|
||||
- `LEADERBOARD_UPDATE_REQUIRE_AUTH` (default `true`)
|
||||
|
||||
Optional integration:
|
||||
- `GAME_SESSION_BASE_URL` (default `http://localhost:8080`) for future backfill tooling
|
||||
- `UPSTREAM_HTTP_TIMEOUT` (default `3s`)
|
||||
|
||||
Shared:
|
||||
- `POSTGRES_*`, `REDIS_*`, `TRACING_*`, `METRICS_*`, `LOG_LEVEL`, `ZITADEL_*`
|
||||
|
||||
## Implementation Work Breakdown
|
||||
|
||||
### Workstream A - Bootstrap, config, wiring
|
||||
1. Add `internal/infra/config/config.go` env parsing.
|
||||
2. Wire logger/metrics/tracer in `cmd/main.go`.
|
||||
3. Initialize postgres + redis clients.
|
||||
4. Initialize repository and `EnsureSchema(ctx)`.
|
||||
5. Register `/health`, `/ready`, `/metrics`.
|
||||
6. Build auth middleware and register routes.
|
||||
|
||||
### Workstream B - Domain and application
|
||||
1. Define domain entities, value objects, and domain errors.
|
||||
2. Implement ranking and statistics calculation services.
|
||||
3. Implement application use-cases:
|
||||
- `UpdateScore`
|
||||
- `GetTop10`
|
||||
- `GetPlayerRanking`
|
||||
- `GetGlobalStats`
|
||||
|
||||
### Workstream C - Persistence
|
||||
1. Implement repository interfaces and SQL-backed repository.
|
||||
2. Add `EnsureSchema(ctx)` DDL for both tables and indexes.
|
||||
3. Implement transactional ingestion:
|
||||
- insert entry
|
||||
- upsert player stats
|
||||
4. Implement top10/history/stats query methods.
|
||||
|
||||
### Workstream D - HTTP interface
|
||||
1. Add request/response DTOs with validation tags.
|
||||
2. Implement handlers with shared error mapping.
|
||||
3. Apply ownership/admin checks for player history endpoint.
|
||||
4. Keep response envelope consistent with existing services.
|
||||
|
||||
### Workstream E - Cache and read optimization
|
||||
1. Add Redis cache adapter for top10/stats/rank snapshots.
|
||||
2. Implement cache keys, invalidation, and graceful fallback.
|
||||
3. Add counters for cache hit/miss and invalidation failures.
|
||||
|
||||
### Workstream F - Testing
|
||||
1. Unit tests:
|
||||
- ranking tie-break correctness
|
||||
- success-rate calculation
|
||||
- stats aggregate math
|
||||
- update idempotency behavior
|
||||
2. HTTP integration tests:
|
||||
- update + top10 happy path
|
||||
- duplicate update idempotency
|
||||
- player endpoint auth guards
|
||||
- stats endpoint and metrics availability
|
||||
3. Optional DB-backed tests (env-gated):
|
||||
- schema creation
|
||||
- transactional consistency
|
||||
- unique `session_id` constraint
|
||||
|
||||
## Error Handling Contract
|
||||
- `400`: invalid input or invariant violation
|
||||
- `401`: missing/invalid auth for protected endpoints
|
||||
- `403`: ownership/admin violation
|
||||
- `404`: player not found (for player ranking endpoint)
|
||||
- `409`: conflicting update (non-idempotent invalid duplicate payload scenario)
|
||||
- `500`: unexpected internal failures
|
||||
|
||||
## Observability
|
||||
1. Structured logs:
|
||||
- include `session_id`, `player_id`, endpoint, and operation outcome.
|
||||
- avoid PII-heavy payload logging.
|
||||
2. Metrics:
|
||||
- `leaderboard_updates_total{status}`
|
||||
- `leaderboard_top10_requests_total{cache}`
|
||||
- `leaderboard_player_requests_total{status}`
|
||||
- `leaderboard_stats_requests_total{cache}`
|
||||
- `leaderboard_update_latency_seconds`
|
||||
3. Tracing:
|
||||
- endpoint -> application -> repository/cache spans.
|
||||
- include attributes for cache hit/miss and DB query class.
|
||||
|
||||
## Delivery Sequence (3-4 Days)
|
||||
1. Day 1: bootstrap/config + schema + repository scaffolding.
|
||||
2. Day 2: `POST /leaderboard/update` transactional ingestion + cache invalidation.
|
||||
3. Day 3: query endpoints (`top10`, `players/:id`, `stats`) + auth checks.
|
||||
4. Day 4: tests, observability hardening, bugfix buffer.
|
||||
|
||||
## Verification Commands
|
||||
From `backend/services/leaderboard-service`:
|
||||
```bash
|
||||
go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
Optional workspace-level check from `backend`:
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
1. All four endpoints implemented and route protection rules enforced.
|
||||
2. Ranking order and tie-break behavior matches functional requirement.
|
||||
3. Update ingestion is idempotent by `session_id`.
|
||||
4. `/health`, `/ready`, `/metrics` functional with meaningful checks.
|
||||
5. Redis cache fallback behavior works when Redis is unavailable.
|
||||
6. `go test ./...` and `go vet ./...` pass for leaderboard-service.
|
||||
7. No code changes outside `backend/services/leaderboard-service/**`.
|
||||
|
||||
## Assumptions and Defaults
|
||||
1. Inter-service transport remains HTTP in phase 2.
|
||||
2. Leaderboard consistency can be eventual within seconds.
|
||||
3. `POST /leaderboard/update` is called by internal trusted workflow after session termination.
|
||||
4. No changes are made in other services or `backend/shared` during this step.
|
||||
Loading…
Reference in New Issue