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

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
}
}