""" امتیازهای جدولِ رتبه‌بندی، مستقیم از فعالیتِ واقعی. تا ۲۰۲۶-۰۹-۲۴ `GET /api/leaderboard` فقط جدولِ LeaderboardEntry را می‌خواند که تنها با اجرای دستیِ `rebuild_leaderboard` پر می‌شد؛ XPی که کاربرِ تازه می‌گرفت هیچ‌وقت در جدول دیده نمی‌شد. حالا view همین تابع را صدا می‌زند و نتیجه را کوتاه‌مدت کش می‌کند، و دستورِ rebuild هم از همین تابع می‌خواند تا دو منبعِ عدد از هم جدا نیفتند. - `all_time`: همان `UserStats.xp` که در `/api/user/summary` هم دیده می‌شود. - `weekly`: XPی درس‌ها و جزیره‌هایی که در `days` روزِ اخیر تمام شده‌اند — همان دو جایی که `UserStats.xp` زیاد می‌شود. `UserAnswer.xp_earned` عمداً شمرده نمی‌شود چون به `UserStats.xp` اضافه نمی‌شود. """ from collections import defaultdict from django.core.cache import cache from django.db.models import Sum from django.utils import timezone PERIODS = ('weekly', 'all_time') def compute_scores(period, days=7, limit=50, include_zero=False): """[(user_id, xp), ...] مرتب: XPی بیشتر جلوتر، در تساوی user_id کوچک‌تر. پیش‌فرض فقط XPی مثبت؛ include_zero برای دستورِ rebuild است.""" if period == 'all_time': from progress.models import UserStats qs = UserStats.objects.all() if include_zero else UserStats.objects.filter(xp__gt=0) rows = list( qs.order_by('-xp', 'user_id') .values_list('user_id', 'xp')[:limit] ) return [(uid, xp) for uid, xp in rows] if period != 'weekly': return [] from content.models import UserLessonCompletion, UserNodeCompletion since = timezone.now() - timezone.timedelta(days=days) totals = defaultdict(int) for model in (UserLessonCompletion, UserNodeCompletion): for row in ( model.objects.filter(created_at__gte=since) .values('user_id').annotate(total=Sum('xp_earned')) ): totals[row['user_id']] += row['total'] or 0 scores = [(uid, xp) for uid, xp in totals.items() if include_zero or xp > 0] scores.sort(key=lambda p: (-p[1], p[0])) return scores[:limit] # کوتاه تا XPی تازه زود دیده شود، بلند تا هزار کاربرِ هم‌زمان هر بار جدول را از نو حساب نکنند. LEADERBOARD_CACHE_TTL = 60 # نسخه‌ی کهنه فقط برای لحظه‌ای است که کسی دیگر در حالِ ساختنِ نسخه‌ی تازه است. _STALE_TTL = 24 * 3600 _LOCK_TTL = 30 def cached_scores(period): """ compute_scores با کش، بی «ازدحامِ لحظه‌ی انقضا»: با `get_or_set` ساده، وقتی کش منقضی می‌شد هر درخواستی که در همان لحظه می‌رسید خودش کلِ جدول را از نو حساب می‌کرد. حالا فقط درخواستی که قفل را بگیرد حساب می‌کند و بقیه آخرین نسخه را (حداکثر چند ثانیه کهنه‌تر) می‌گیرند. اگر هیچ نسخه‌ای نیست (اولین بار یا کشِ خالی‌شده) خودشان حساب می‌کنند، چون چیزِ دیگری برای نشان دادن نیست. کش Redisِ مشترکِ workerهاست؛ با قطعیِ Redis (config/cache.py) هر درخواست مستقیم حساب می‌کند. """ fresh_key, stale_key, lock_key = (f'leaderboard:{period}', f'leaderboard:{period}:stale', f'leaderboard:{period}:lock') scores = cache.get(fresh_key) if scores is not None: return scores if cache.add(lock_key, 1, _LOCK_TTL): try: scores = compute_scores(period) cache.set(fresh_key, scores, LEADERBOARD_CACHE_TTL) cache.set(stale_key, scores, _STALE_TTL) finally: cache.delete(lock_key) return scores stale = cache.get(stale_key) return stale if stale is not None else compute_scores(period)