333 lines
13 KiB
Python
333 lines
13 KiB
Python
from fastapi import APIRouter, Request, HTTPException
|
|
from auth import require_auth
|
|
from database import get_pool
|
|
from config import ADMIN_IDS
|
|
|
|
router = APIRouter(prefix="/leaderboard", tags=["leaderboard"])
|
|
|
|
LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]
|
|
|
|
|
|
@router.get("")
|
|
async def get_leaderboard(request: Request, limit: int = 50):
|
|
"""Рейтинг игроков — быстрое чтение из кэша"""
|
|
telegram_id, _ = await require_auth(request)
|
|
|
|
if limit < 1:
|
|
limit = 1
|
|
elif limit > 100:
|
|
limit = 100
|
|
|
|
pool = await get_pool()
|
|
async with pool.acquire() as conn:
|
|
user = await conn.fetchrow(
|
|
"SELECT 1, current_level FROM users WHERE telegram_id = $1", telegram_id
|
|
)
|
|
if not user:
|
|
raise HTTPException(401, "User not registered")
|
|
|
|
current_level = user["current_level"] or "A1"
|
|
|
|
# Быстрое чтение из MATERIALIZED VIEW
|
|
rows = await conn.fetch("""
|
|
SELECT * FROM leaderboard_cache
|
|
ORDER BY total_score DESC
|
|
LIMIT $1
|
|
""", limit)
|
|
|
|
leaderboard = []
|
|
for i, row in enumerate(rows):
|
|
leaderboard.append({
|
|
"rank": i + 1,
|
|
"telegram_id": row["telegram_id"],
|
|
"username": row["username"] or f"Player{row['telegram_id']}",
|
|
"total_score": row["total_score"],
|
|
"wordsearch_score": row["wordsearch_score"],
|
|
"sentence_score": row["sentence_score"],
|
|
"wordquiz_score": row["wordquiz_score"],
|
|
"words_guessed": row["words_guessed"],
|
|
"bubblewords_score": row["bubblewords_score"],
|
|
"bubble_levels_completed": row["bubble_levels_completed"],
|
|
"levels_completed": 0,
|
|
"sentences_completed": 0,
|
|
})
|
|
|
|
# Место пользователя
|
|
user_rank = await conn.fetchval("""
|
|
SELECT COUNT(*) + 1 FROM leaderboard_cache
|
|
WHERE total_score > (
|
|
SELECT COALESCE(total_score, 0) FROM leaderboard_cache WHERE telegram_id = $1
|
|
)
|
|
""", telegram_id)
|
|
|
|
# Персональная статистика пользователя
|
|
my_row = await conn.fetchrow("""
|
|
SELECT * FROM leaderboard_cache WHERE telegram_id = $1
|
|
""", telegram_id)
|
|
|
|
if my_row:
|
|
my_wordsearch = my_row["wordsearch_score"]
|
|
my_sentence = my_row["sentence_score"]
|
|
my_wordquiz = my_row["wordquiz_score"]
|
|
my_words_guessed = my_row["words_guessed"]
|
|
my_bubblewords = my_row["bubblewords_score"]
|
|
my_bubble_levels = my_row["bubble_levels_completed"]
|
|
else:
|
|
my_wordsearch = 0
|
|
my_sentence = 0
|
|
my_wordquiz = 0
|
|
my_words_guessed = 0
|
|
my_bubblewords = 0
|
|
my_bubble_levels = 0
|
|
|
|
# Считаем НАПРЯМУЮ из основных таблиц
|
|
my_wordsearch_levels = await conn.fetchval(
|
|
"SELECT COUNT(DISTINCT level_id) FROM user_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
|
|
telegram_id
|
|
) or 0
|
|
|
|
my_sentences_completed = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM sentence_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
|
|
telegram_id
|
|
) or 0
|
|
|
|
# Если пользователя нет в кэше — считаем очки из основных таблиц
|
|
if not my_row:
|
|
my_wordsearch = await conn.fetchval(
|
|
"SELECT COALESCE(SUM(score), 0) FROM user_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
|
|
telegram_id
|
|
) or 0
|
|
|
|
my_sentence = await conn.fetchval(
|
|
"SELECT COALESCE(SUM(score), 0) FROM sentence_progress WHERE user_id = $1 AND completed_at IS NOT NULL",
|
|
telegram_id
|
|
) or 0
|
|
|
|
my_wordquiz = await conn.fetchval(
|
|
"SELECT COALESCE(total_score, 0) FROM word_quiz_progress WHERE user_id = $1",
|
|
telegram_id
|
|
) or 0
|
|
|
|
my_words_guessed = await conn.fetchval(
|
|
"SELECT COALESCE(words_guessed, 0) FROM word_quiz_progress WHERE user_id = $1",
|
|
telegram_id
|
|
) or 0
|
|
|
|
my_bubblewords = await conn.fetchval(
|
|
"SELECT COALESCE(total_score, 0) FROM bubble_words_progress WHERE user_id = $1",
|
|
telegram_id
|
|
) or 0
|
|
|
|
my_bubble_levels = await conn.fetchval(
|
|
"SELECT COALESCE(levels_completed, 0) FROM bubble_words_progress WHERE user_id = $1",
|
|
telegram_id
|
|
) or 0
|
|
|
|
my_total = my_wordsearch + my_sentence + my_wordquiz + my_bubblewords
|
|
my_position = user_rank or 1
|
|
|
|
# 🔧 ФИКС N+1 (19.09.2026): 48 запросов → 5.
|
|
# Было: цикл по 6 уровням × 8 запросов = 48 round-trip к БД.
|
|
# Стало: 5 агрегированных запросов + сборка level_stats в Python.
|
|
|
|
# 1. Справочники (не зависят от юзера) — одним запросом
|
|
ref_row = await conn.fetchrow("""
|
|
SELECT
|
|
(SELECT COUNT(*) FROM levels WHERE level = 'A1') AS a1_levels,
|
|
(SELECT COUNT(*) FROM levels WHERE level = 'A2') AS a2_levels,
|
|
(SELECT COUNT(*) FROM levels WHERE level = 'B1') AS b1_levels,
|
|
(SELECT COUNT(*) FROM levels WHERE level = 'B2') AS b2_levels,
|
|
(SELECT COUNT(*) FROM levels WHERE level = 'C1') AS c1_levels,
|
|
(SELECT COUNT(*) FROM levels WHERE level = 'C2') AS c2_levels,
|
|
(SELECT COUNT(*) FROM sentences WHERE level = 'A1') AS a1_sent,
|
|
(SELECT COUNT(*) FROM sentences WHERE level = 'A2') AS a2_sent,
|
|
(SELECT COUNT(*) FROM sentences WHERE level = 'B1') AS b1_sent,
|
|
(SELECT COUNT(*) FROM sentences WHERE level = 'B2') AS b2_sent,
|
|
(SELECT COUNT(*) FROM sentences WHERE level = 'C1') AS c1_sent,
|
|
(SELECT COUNT(*) FROM sentences WHERE level = 'C2') AS c2_sent
|
|
""")
|
|
|
|
# 2. Очки и прогресс по всем уровням сразу — 4 запроса с GROUP BY
|
|
ws_rows = await conn.fetch("""
|
|
SELECT level,
|
|
COALESCE(SUM(score), 0) AS score,
|
|
COUNT(*) AS completed
|
|
FROM user_progress
|
|
WHERE user_id = $1 AND completed_at IS NOT NULL
|
|
GROUP BY level
|
|
""", telegram_id)
|
|
ws_by_level = {r["level"]: r for r in ws_rows}
|
|
|
|
s_rows = await conn.fetch("""
|
|
SELECT level,
|
|
COALESCE(SUM(score), 0) AS score,
|
|
COUNT(*) AS completed
|
|
FROM sentence_progress
|
|
WHERE user_id = $1 AND completed_at IS NOT NULL
|
|
GROUP BY level
|
|
""", telegram_id)
|
|
s_by_level = {r["level"]: r for r in s_rows}
|
|
|
|
wq_rows = await conn.fetch("""
|
|
SELECT level, COALESCE(total_score, 0) AS score
|
|
FROM word_quiz_progress
|
|
WHERE user_id = $1
|
|
""", telegram_id)
|
|
wq_by_level = {r["level"]: r for r in wq_rows}
|
|
|
|
bw_rows = await conn.fetch("""
|
|
SELECT level, COALESCE(total_score, 0) AS score
|
|
FROM bubble_words_progress
|
|
WHERE user_id = $1
|
|
""", telegram_id)
|
|
bw_by_level = {r["level"]: r for r in bw_rows}
|
|
|
|
# 3. Собираем level_stats в Python — без единого запроса
|
|
level_stats = {}
|
|
for lvl in LEVELS:
|
|
lvl_lower = lvl.lower()
|
|
|
|
ws_score = ws_by_level.get(lvl, {}).get("score", 0) or 0
|
|
ws_completed = ws_by_level.get(lvl, {}).get("completed", 0) or 0
|
|
s_score = s_by_level.get(lvl, {}).get("score", 0) or 0
|
|
s_completed = s_by_level.get(lvl, {}).get("completed", 0) or 0
|
|
wq_score = wq_by_level.get(lvl, {}).get("score", 0) or 0
|
|
bw_score = bw_by_level.get(lvl, {}).get("score", 0) or 0
|
|
|
|
total_ws_levels = ref_row[f"{lvl_lower}_levels"] or 0
|
|
total_sentences = ref_row[f"{lvl_lower}_sent"] or 0
|
|
|
|
total_items = total_ws_levels + total_sentences
|
|
completed_items = ws_completed + s_completed
|
|
progress_percent = round((completed_items / total_items * 100), 1) if total_items > 0 else 0
|
|
|
|
level_stats[lvl] = {
|
|
"total_score": ws_score + s_score + wq_score + bw_score,
|
|
"progress_percent": progress_percent,
|
|
"wordsearch_score": ws_score,
|
|
"sentence_score": s_score,
|
|
"wordquiz_score": wq_score,
|
|
"bubblewords_score": bw_score,
|
|
}
|
|
|
|
# 🔧 ИСПРАВЛЕНО: вызов достижений ВНЕ блока async with pool.acquire()
|
|
from routers.achievements import check_and_award_achievements
|
|
if my_position <= 3:
|
|
await check_and_award_achievements(telegram_id, "rank_reached", my_position)
|
|
|
|
return {
|
|
"leaderboard": leaderboard,
|
|
"current_level": current_level,
|
|
"my_rank": {
|
|
"rank": my_position,
|
|
"telegram_id": telegram_id,
|
|
"total_score": my_total,
|
|
"wordsearch_score": my_wordsearch,
|
|
"wordsearch_levels": my_wordsearch_levels,
|
|
"sentence_score": my_sentence,
|
|
"sentences_completed": my_sentences_completed,
|
|
"wordquiz_score": my_wordquiz,
|
|
"words_guessed": my_words_guessed,
|
|
"bubblewords_score": my_bubblewords,
|
|
"bubble_levels_completed": my_bubble_levels,
|
|
},
|
|
"level_stats": level_stats,
|
|
}
|
|
|
|
|
|
@router.get("/admin/stats")
|
|
async def get_admin_stats(request: Request):
|
|
"""Админ-статистика: только для ADMIN_IDS"""
|
|
telegram_id, _ = await require_auth(request)
|
|
|
|
if telegram_id not in ADMIN_IDS:
|
|
raise HTTPException(403, "Access denied")
|
|
|
|
pool = await get_pool()
|
|
async with pool.acquire() as conn:
|
|
total_users = await conn.fetchval("SELECT COUNT(*) FROM users") or 0
|
|
|
|
new_today = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM users WHERE created_at::date = CURRENT_DATE"
|
|
) or 0
|
|
|
|
online_now = await conn.fetchval("""
|
|
SELECT COUNT(DISTINCT user_id) FROM (
|
|
SELECT user_id FROM user_progress WHERE completed_at > NOW() - INTERVAL '30 minutes'
|
|
UNION
|
|
SELECT user_id FROM sentence_progress WHERE completed_at > NOW() - INTERVAL '30 minutes'
|
|
UNION
|
|
SELECT user_id FROM daily_logins WHERE login_date = CURRENT_DATE
|
|
) active
|
|
""") or 0
|
|
|
|
ws_players = await conn.fetchval(
|
|
"SELECT COUNT(DISTINCT user_id) FROM user_progress WHERE completed_at IS NOT NULL"
|
|
) or 0
|
|
|
|
s_players = await conn.fetchval(
|
|
"SELECT COUNT(DISTINCT user_id) FROM sentence_progress WHERE completed_at IS NOT NULL"
|
|
) or 0
|
|
|
|
wq_players = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM word_quiz_progress WHERE words_guessed > 0"
|
|
) or 0
|
|
|
|
bw_players = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM bubble_words_progress WHERE levels_completed > 0"
|
|
) or 0
|
|
|
|
# 🔧 ИСПРАВЛЕНО: 'open_flashcards' → 'open_teacher_menu'
|
|
# Раньше метрика всегда была 0, потому что такого кода достижения больше нет
|
|
teacher_menu_opened = await conn.fetchval("""
|
|
SELECT COUNT(*) FROM user_achievements ua
|
|
JOIN achievements a ON ua.achievement_id = a.id
|
|
WHERE a.code = 'open_teacher_menu'
|
|
""") or 0
|
|
|
|
# Количество пользователей по уровням
|
|
level_counts = {}
|
|
for lvl in LEVELS:
|
|
count = await conn.fetchval(
|
|
"SELECT COUNT(*) FROM users WHERE current_level = $1", lvl
|
|
) or 0
|
|
level_counts[lvl] = count
|
|
|
|
return {
|
|
"total_users": total_users,
|
|
"new_today": new_today,
|
|
"online_now": online_now,
|
|
"wordsearch_players": ws_players,
|
|
"sentence_players": s_players,
|
|
"wordquiz_players": wq_players,
|
|
"bubblewords_players": bw_players,
|
|
"teacher_menu_opened": teacher_menu_opened,
|
|
"level_counts": level_counts,
|
|
}
|
|
|
|
|
|
@router.get("/top")
|
|
async def get_top_players(request: Request):
|
|
"""Топ-3 игрока для главного экрана — из кэша"""
|
|
telegram_id, _ = await require_auth(request)
|
|
|
|
pool = await get_pool()
|
|
async with pool.acquire() as conn:
|
|
rows = await conn.fetch("""
|
|
SELECT telegram_id, username, total_score
|
|
FROM leaderboard_cache
|
|
ORDER BY total_score DESC
|
|
LIMIT 3
|
|
""")
|
|
|
|
top3 = []
|
|
medals = ["🥇", "🥈", "🥉"]
|
|
for i, row in enumerate(rows):
|
|
top3.append({
|
|
"rank": i + 1,
|
|
"medal": medals[i],
|
|
"username": row["username"] or f"Player{row['telegram_id']}",
|
|
"total_score": row["total_score"],
|
|
})
|
|
|
|
return {"top3": top3} |