Implemented '2.4 Leaderboard Service (Port 8083)'
parent
3cc74867a7
commit
80003d19ca
@ -1,22 +1,124 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"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/serviceboot"
|
||||||
|
"knowfoolery/backend/shared/infra/utils/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := serviceboot.Config{
|
serviceCfg := lbconfig.FromEnv()
|
||||||
AppName: "Know Foolery - Leaderboard Service",
|
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",
|
ServiceSlug: "leaderboard",
|
||||||
PortEnv: "LEADERBOARD_PORT",
|
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)
|
authMiddleware := buildAuthMiddleware(serviceCfg)
|
||||||
serviceboot.RegisterHealth(app, cfg.ServiceSlug)
|
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))
|
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/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/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/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/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/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/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-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
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/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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
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/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
||||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
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/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=
|
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