Un classement = un sorted set Redis par saison, thème et zone. Postgres (season_scores) fait foi ; Redis est un
cache reconstruit à la demande. Les points viennent des réponses du fil (voir Progression).
Code : back/app/services/progress.py (add_season_points, rebuild_leaderboards, lb_key), back/app/routers/leaderboards.py (leaderboard), back/app/services/badges.py (close_season, month_report), back/app/services/analytics.py (rollup, rollup_loop), back/app/services/exclusions.py.
lb:{saison}:{thème}:{zone}
saison = "YYYY-MM" (UTC)
thème = identifiant de thème, ou "all" (général)
zone = "world" ou code pays (users.country_code, "FR" par défaut)
membre = users.id, score = points de la saison
Pas de TTL : les clés des saisons passées restent.
add_season_points(user, thème, points), appelé par register_answer :
si points ≤ 0 → rien
ensure_season(saison courante) # une fois par processus
pour cat dans (thème, "all") :
season_scores[user, saison, cat].score += points # Postgres
si user non exclu :
ZINCRBY lb:{saison}:{cat}:world points user
ZINCRBY lb:{saison}:{cat}:{pays} points user
4 ZINCRBY par réponse qui rapporte. Le commit Postgres vient après, à la fin de POST /answers.
rebuild_leaderboards(saison) :
season_scores de la saison, avec users.country_code.DEL de toutes les clés concernées (world et pays, chaque thème présent).ZADD de chaque ligne, sauf joueurs exclus.Déclencheurs :
| Quand | Où |
|---|---|
Lecture d'un classement dont la clé est vide (ZCARD = 0) |
routers/leaderboards.py |
| Exclusion ou réintégration d'un joueur (saison courante seulement) | exclusions.exclude, exclusions.restore |
GET /leaderboards?category=all&zone=world&season=&limit=50
| Paramètre | Valeurs |
|---|---|
category |
all ou un thème existant (sinon 404) |
zone |
world, fr, following |
season |
YYYY-MM, défaut saison courante |
limit |
1 à 200, défaut 50 |
clé = lb:{saison}:{category}:("FR" si zone = fr, sinon "world")
si ZCARD(clé) = 0 → rebuild_leaderboards(saison)
rows = ZREVRANGE clé 0 499 WITHSCORES # top 500
si zone = following :
rows = rows filtrées sur (suivis ∪ moi)
total = len(rows)
sinon total = ZCARD(clé)
my_rank = position dans rows, sinon ZREVRANK (pas pour following)
entries = rows[:limit], rang = index + 1
my_score = ZSCORE(clé, moi)
Le bloc season de la réponse (serialize.season_info) donne toujours le rang, le score et le percentile du
général monde : percentile = rang / ZCARD. Libellé « Top X % » : max(1, round(percentile × 100)) (top_label).
Égalités : pas de rang partagé ; Redis départage par ordre lexical des identifiants.
Table leaderboard_exclusions (une ligne par joueur, décidée dans la console, voir Triche).
| Effet | Où |
|---|---|
Points toujours ajoutés dans season_scores |
add_season_points |
| Pas d'écriture Redis | add_season_points |
| Retiré au rebuild | rebuild_leaderboards |
| Ni badge ni bilan de fin de mois | close_season, month_report |
restore supprime la ligne et reconstruit la saison courante : le joueur retrouve sa place avec tous ses points.
main.lifespan lance rollup_loop dans chaque worker. Chaque boucle :
toutes les ROLLUP_EVERY_S (600 s), au démarrage compris :
si SET analytics:rollup:lock NX EX 540 échoue → rien (un autre worker calcule)
agrégats, stats des questions, purge, modération
commit
close_finished_seasons()
DEL analytics:rollup:lock
close_finished_seasons : chaque saison closed = false et ends_at ≤ maintenant est close puis commitée.
La saison se clôt donc au plus ~10 minutes après le 1er du mois 00:00 UTC.
close_season commence par UPDATE seasons SET closed = true WHERE id = … AND closed = false. Si rowcount = 0, un
autre processus l'a déjà fait : rien en double.
| Badge | Source | Règle |
|---|---|---|
season_top1 / top10 / top50 / top100 |
ZREVRANGE lb:{s}:{cat}:world 0 99, pour all et chaque thème |
index 0 → top1 ; < 10 → top10 ; < 50 → top50 ; sinon top100 |
season_fr_top50 |
ZREVRANGE lb:{s}:all:FR 0 49 |
50 premiers |
season_following_top1 |
season_scores (all) |
voir ci-dessous |
season_id et category_id (all pour le général).season_scores (top 100, hors exclus).Détail des badges : Défis et badges.
month_report : pour chaque joueur de lb:{s}:all:world (repli Postgres : score > 0, hors exclus), dans l'ordre :
notifications (kind = month) : « {Mois} terminé » / « Tu finis {rang}e au classement du mois » ;· badge {nom} si le joueur a gagné un badge du général à cette clôture (le premier trouvé : monde, puis France, puis abonnements) ;month_end (type = leaderboard).users.country_code vaut FR par défaut et rien ne le modifie. Les clés :FR contiennent les mêmes joueurs que :world, et season_fr_top50 revient aux 50 premiers mondiaux.my_rank est null, total est faux.season_fr_top50.GET reconstruit toute la saison.GET /leaderboards?season=2020-01 appelle ensure_season : la ligne seasons créée a une fin passée et sera « close » au rollup suivant (sans badge, faute de scores).max() garde le premier du set, ordre non déterministe.followed_ids par joueur inscrit, dans une seule transaction.