You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
244 lines
6.5 KiB
Go
244 lines
6.5 KiB
Go
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
|
|
}
|
|
}
|