iPhone / Android (Flutter) Navigateur (console) Navigateur (site, wiki)
│ HTTPS + WS /ws │ HTTPS │ HTTPS
▼ ▼ ▼
┌───────────────────────────── caddy (80, 443) ─────────────────────────────┐
│ api.slidzapp.com → api:8000 console.slidzapp.com → admin:80 │
│ slidzapp.com → site:80 wiki.slidzapp.com → wiki:3000 │
└───────────────────────────────────────────────────────────────────────────┘
│ │ nginx relaie /api /auth /avatars
▼ ▼
api (uvicorn, API_WORKERS processus) ◄─────┘
│ │
▼ ▼
postgres redis (AOF) backup ──► ./backups (pg_dump + avatars)
(slidz, wiki)
Un seul VPS OVHcloud, Docker Compose. Fichiers : dépôt deploy (docker-compose.yml, Caddyfile). Le serveur n'a
aucune source : les images viennent de GHCR. Détails : Déploiement.
deploy/docker-compose.yml)| Service | Image | Rôle | Dépend de |
|---|---|---|---|
caddy |
caddy:2-alpine |
seul port public (80, 443), TLS Let's Encrypt | api, admin, site, wiki |
api |
ghcr.io/slidzapp/back:${SLIDZ_TAG} |
alembic upgrade head && uvicorn app.main:app --workers ${API_WORKERS:-4} |
postgres (sain), redis (démarré) |
admin |
ghcr.io/slidzapp/admin:${SLIDZ_TAG} |
nginx : SPA + relais /api/, /auth/, /avatars/ vers API_UPSTREAM |
— |
site |
ghcr.io/slidzapp/site:${SLIDZ_TAG} |
nginx : site statique, app-ads.txt, apple-app-site-association, liens /u/, /g/, /q/ |
— |
wiki |
ghcr.io/requarks/wiki:2.5 |
Wiki.js, base wiki |
postgres (sain) |
postgres |
postgres:16-alpine |
bases slidz et wiki, healthcheck pg_isready (5 s) |
— |
redis |
redis:7-alpine |
--appendonly yes, volume redis_data |
— |
backup |
postgres:16-alpine |
boucle shell : pg_dump + archive des avatars |
postgres |
Volumes : pg_data, redis_data, media (avatars, monté sur /code/media dans api), caddy_data, caddy_config.
Secrets : ./secrets monté en lecture seule sur /run/secrets/slidz dans api.
| Domaine | Cible |
|---|---|
slidzapp.com |
site:80 |
www.slidzapp.com |
redirection 301 vers slidzapp.com |
api.slidzapp.com |
api:8000 |
console.slidzapp.com |
admin:80 |
wiki.slidzapp.com |
wiki:3000 |
uvicorn --workers N lance N processus indépendants. Rien n'est partagé en mémoire entre eux. Conséquences :
services/ads.py, _keys), saisons connues (services/progress.py,_known_seasons), sockets ouverts (services/realtime.py, sockets).back/app/main.py, lifespan)conteneur api : alembic upgrade head (une fois, avant uvicorn)
└─ chaque worker : lifespan
├─ startup_lock() : pg_advisory_lock(734001) → un worker à la fois
│ ├─ seed() compte officiel @slidz (+ démo si SEED_DEMO)
│ ├─ ensure_themes() thèmes de référence
│ ├─ ensure_badges() catalogue de badges
│ ├─ ensure_season() ligne de la saison du mois
│ ├─ ADMIN_PSEUDOS is_admin = vrai pour chaque pseudo listé
│ └─ si SEED_DEMO et base sans événements : seed_demo_activity + rollup(31 jours, force)
└─ lance 4 tâches asyncio
Le verrou de démarrage n'existe que sur PostgreSQL (rien en SQLite, pour les tests).
| Tâche | Code | Fréquence | Exclusion entre workers |
|---|---|---|---|
| Agrégats analytics, stats par question, paliers de parties, purge des événements, remise en file des questions, clôture de saison | services/analytics.py (rollup_loop → rollup) |
au démarrage puis toutes les 600 s (ROLLUP_EVERY_S) |
verrou Redis analytics:rollup:lock (SET NX, TTL 540 s, supprimé à la fin) ; clôture de saison : UPDATE seasons … WHERE closed = false |
| Clôture des parties en différé + rappel avant la fin | services/matches.py (sweep_loop → sweep, remind) |
60 s (SWEEP_EVERY_S) |
finish() : UPDATE matches … WHERE status = 'running' ; rappel : UPDATE match_players … WHERE reminded_at IS NULL RETURNING |
| Parties en direct : échéances | services/live.py (live_loop → tick) |
0,5 s (TICK_S) |
move() : UPDATE matches conditionnel sur status, phase, current_round |
| Relais temps réel | services/realtime.py (relay) |
permanent (PSUBSCRIBE rt:user:*) |
aucune : chaque worker livre à ses propres sockets |
Une tâche qui lève une exception est journalisée et reprend au passage suivant. Voir aussi
Temps réel.
PostgreSQL est la source de vérité. Redis ne garde que du dérivé, du transitoire ou des compteurs.
| Où | Quoi |
|---|---|
PostgreSQL slidz |
38 tables (back/app/models.py) : comptes, questions, réponses, XP (xp_events), saisons et scores (season_scores), badges, défis, parties, messages, signalements, événements bruts (events, 30 jours), agrégats (daily_stats, question_stats), pubs (ad_rewards), abonnements (subscription_events), appareils push (devices), liste d'attente |
PostgreSQL wiki |
Wiki.js |
Disque (media) |
photos de profil /code/media/avatars, servies par StaticFiles sur /avatars |
| Clé Redis | Type | Rôle | Durée | Code |
|---|---|---|---|---|
lb:<AAAA-MM>:<thème\|all>:<world\|FR…> |
sorted set | classements de saison | sans TTL ; reconstruit depuis season_scores si vide |
services/progress.py (lb_key, add_season_points, rebuild_leaderboards) |
feed:<user_id> |
chaîne JSON | état de rythme du fil (servies, thèmes, formats, série de ratés, découverte) | 30 jours (SKILL_TTL_S) |
services/skill.py |
rt:user:<user_id> |
canal pub/sub | nouvelles vers les sockets /ws |
— | services/realtime.py |
analytics:rollup:lock |
chaîne | verrou du rollup | 540 s | services/analytics.py |
waitlist:rate:<ip> |
compteur | limite d'inscriptions à la liste d'attente | 60 s | routers/waitlist.py |
push:follow:<a>:<b> |
compteur | une notification « nouvel abonné » par paire et par 24 h | 86 400 s | routers/users.py |
Perte de Redis : les classements se reconstruisent à la première lecture, l'état du fil repart de zéro, aucune donnée
de jeu n'est perdue. Détails des classements : Classements.
back/alembic/versions/ : 12 migrations (0001_initial à 0012_drop_penalty).api, avant uvicorn : une mise en ligne migre toute seule.&&) ; le conteneur redémarre en boucle (restart: unless-stopped).back/app/)| Dossier / fichier | Contenu |
|---|---|
main.py |
lifespan, montage des routeurs, /health, /config |
config.py |
Settings (pydantic-settings) : toutes les variables d'environnement et leurs défauts |
db.py, redis.py |
moteur SQLAlchemy async (asyncpg), client Redis, over_limit() (compteur à fenêtre) |
deps.py |
DB, CurrentUser (jeton Bearer + en-tête X-Timezone), OptionalUser, Admin (is_admin) |
security.py |
bcrypt, JWT HS256 (JWT_SECRET, JWT_DAYS = 30) |
models.py, schemas.py |
tables SQLAlchemy, modèles Pydantic d'entrée et de sortie |
routers/ |
une route = validation + appel de service : auth, feed, answers, me, users, creation, leaderboards, search, matches, realtime, events, groups, safety, ads, subscriptions, waitlist, themes ; console : admin, moderation, reports_admin (préfixe /api/admin) |
services/ |
la logique : feed, skill, verify (correction des réponses), progress (XP, saisons), badges, matches, live, realtime, push, analytics, subscription_stats, storekit, ads, moderation, cheat, app_icons… |
cli.py |
python -m app.cli : make-admin, revoke-admin, rollup --days N, recheck-all, waitlist-export, import-questions |
certs/AppleRootCA-G3.cer |
racine Apple pour StoreKit |
Contrats lisibles par Claude : back/*.md (ANALYTICS.md, SUBSCRIPTION.md, ADS.md, QUIZ.md, PUSH.md…).
app/lib/)| Fichier / dossier | Contenu |
|---|---|
main.dart |
démarrage, navigation, flags --dart-define |
session.dart |
état global (ChangeNotifier) : compte, /config, progression, boîte, conversations |
models.dart |
modèles JSON (dont AppConfig, défauts tant que /config n'a pas répondu) |
api/api.dart |
client HTTP ; adresse : SERVER, sinon https://api.slidzapp.com en release |
api/realtime.dart |
socket /ws, reconnexion au premier plan |
api/analytics.dart |
file d'événements, envoi par paquets |
api/ads.dart, api/purchases.dart, api/app_icon.dart |
AdMob, StoreKit (in_app_purchase), icônes iOS |
api/push.dart, api/store.dart |
Firebase Messaging, cache JSON local |
screens/ |
auth, feed, create, inbox, leaderboard, match, profile, search, themes |
l10n/ |
textes (app_fr.arb) |
admin/)React 19 + Vite + Tailwind + Recharts + TanStack Query/Table. src/routes.tsx liste les pages et leur portée de filtre
(all, period, none). src/api.ts appelle /api/admin/* avec le jeton ; ?mock=1 ou VITE_MOCK=1 branche
src/mock.ts (données inventées). Image : build Vite puis nginx:alpine avec nginx.conf.template (résolveur Docker
127.0.0.11, revalidé toutes les 10 s, pour suivre un conteneur api recréé ; proxy_read_timeout 150 s pour l'avis
de Claude).
| Dépôt | Vérification | Publication (push sur main seulement) |
|---|---|---|
back |
pytest -q (Python 3.12) |
ghcr.io/slidzapp/back:latest et :<sha> |
admin |
npm ci && npm run build |
ghcr.io/slidzapp/admin:latest et :<sha> |
site |
script Python : liens internes des .html |
ghcr.io/slidzapp/site:latest et :<sha> |
app |
flutter analyze + flutter test (Flutter 3.44.4) |
rien (builds faits à la main) |
Publication avec GITHUB_TOKEN (packages: write). Le serveur tire avec un jeton personnel read:packages.
CLAUDE.md dit « agrégés chaque heure » : le code tourne toutes les 10 minutes (ROLLUP_EVERY_S = 600).CLAUDE.md dit la production « servie par le serveur maison (ssh prod) » : la production est sur le VPS OVHcloudCLAUDE.md dit « une seule migration initiale 0001 » : il y en a 12 (0001 à 0012).CLAUDE.md dit « Badges, Slidz+ et pubs derrière des flags (à faux) » : ADS_ENABLED et SUBSCRIPTION_ENABLED sontlive_loop tourne dans chaque worker : avec 4 workers, 8 requêtes SELECT par seconde sur matches, même sans partie.secrets/ est monté dans api, y compris la clé git du wiki (secrets/wiki_git/), dont l'API n'aCode : back/app/main.py (lifespan, startup_lock), back/app/services/analytics.py (rollup, rollup_loop),
back/app/services/matches.py (sweep_loop, finish), back/app/services/live.py (live_loop, move),
back/app/services/realtime.py (relay), back/app/services/progress.py (lb_key), back/app/redis.py,
deploy/docker-compose.yml, deploy/Caddyfile, admin/nginx.conf.template, */.github/workflows/ci.yml