""" GET /api/health — «آیا این سرور واقعاً سالم است؟» چرا لازم بود: تا امروز تنها راهِ فهمیدنِ اینکه دیپلوی درست نشسته یا نه، این بود که کسی از بیرون اپ را دستی امتحان کند و اگر چیزی غلط بود به آدمی که به سرور دسترسی دارد پیام بدهد. یعنی خرابی را همیشه آدم کشف می‌کرد، نه ابزار. این اندپوینت همان بررسی‌ها را از بیرون و بدونِ دسترسیِ SSH ممکن می‌کند. مهم‌ترین بررسی «مهاجرت‌های اعمال‌نشده» است: شایع‌ترین حالتِ خرابِ دیپلوی این است که کد pull شده ولی migrate اجرا نشده — اپ بالا می‌آید و بعد زیرِ بارِ واقعی با خطای ستونِ ناموجود می‌شکند. عمداً هیچ سکرتی برنمی‌گرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا. """ import os from datetime import timedelta from django.conf import settings from django.core.cache import cache from django.db import connection from django.db.migrations.executor import MigrationExecutor from drf_spectacular.utils import extend_schema from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView def _check_database() -> dict: try: with connection.cursor() as cursor: cursor.execute('SELECT 1') cursor.fetchone() return {'ok': True, 'engine': connection.vendor} except Exception as exc: # noqa: BLE001 return {'ok': False, 'error': type(exc).__name__} def _check_migrations() -> dict: """مهاجرتِ اعمال‌نشده یعنی دیپلوی نیمه‌کاره است.""" try: executor = MigrationExecutor(connection) targets = executor.loader.graph.leaf_nodes() pending = executor.migration_plan(targets) # migration_plan یک فهرست از (migration, backwards) می‌دهد؛ خودِ # Migration فیلدهای app_label و name دارد (نه key). return { 'ok': not pending, 'pending': [f'{migration.app_label}.{migration.name}' for migration, _ in pending], } except Exception as exc: # noqa: BLE001 return {'ok': False, 'error': type(exc).__name__} def _check_cache() -> dict: """با DEBUG=False کشِ مشترک اجباری است؛ throttle بدونش بی‌اثر می‌شود.""" try: cache.set('health:probe', 'ok', 10) return {'ok': cache.get('health:probe') == 'ok', 'backend': settings.CACHES['default']['BACKEND'].rsplit('.', 1)[-1]} except Exception as exc: # noqa: BLE001 return {'ok': False, 'error': type(exc).__name__} def _check_content() -> dict: """ محتوا روی این سرور هست یا نه — بدونِ این، اپ سالم به نظر می‌رسد ولی کاربر صفحه‌ی خالی می‌بیند. """ try: from content.models import Grade, Lesson, Question grades = Grade.objects.count() lessons = Lesson.objects.count() questions = Question.objects.count() return { 'ok': grades > 0 and lessons > 0, 'grades': grades, 'lessons': lessons, 'questions': questions, } except Exception as exc: # noqa: BLE001 return {'ok': False, 'error': type(exc).__name__} def _traffic() -> dict: """ آیا کسی واقعاً به این سرور وصل می‌شود؟ تا امروز راهی نبود که از بیرون بفهمیم فرانت به سرور رسیده یا نه — باید منتظر می‌ماندیم کسی بگوید. این سه عدد همان سیگنال‌اند. فقط شمارش و زمان، بدونِ هیچ اطلاعاتِ شخصی. """ try: from django.utils import timezone from accounts.models import User since = timezone.now() - timedelta(hours=24) last = User.objects.order_by('-last_activity_at').values_list( 'last_activity_at', flat=True, ).first() return { 'users': User.objects.count(), 'activeLast24h': User.objects.filter(last_activity_at__gte=since).count(), 'lastActivityAt': last.isoformat() if last else None, } except Exception as exc: # noqa: BLE001 return {'error': type(exc).__name__} def _check_integrations() -> dict: """ فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد. `aiProvider` وضعیتِ واقعیِ آخرین تماس است: کلیدِ تنظیم‌شده با اعتبارِ تمام‌شده هم `openai: true` بود. """ return { 'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')), 'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')), 'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')), 'bazaar': _bazaar_configured(), 'aiProvider': _ai_provider_state(), 'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''), 'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None, } def _bazaar_configured() -> bool: try: from payment.bazaar import is_configured return is_configured() except Exception: return False def _ai_provider_state() -> dict: try: from statuslog.models import StatusChangeLog row = StatusChangeLog.objects.filter(label='ai_tutor.Provider').values( 'new_status', 'created_at').first() except Exception as exc: # noqa: BLE001 return {'error': type(exc).__name__} if row is None: return {'state': 'ok', 'since': None} return {'state': row['new_status'], 'since': row['created_at'].isoformat()} def _offsite_backup() -> dict: """ کپیِ زنده‌ی دیتابیس روی سرورِ دیگر (`ops/pgbackup/`، شرح در docs/operations.md «بکاپ»). آن سرور با `pg_receivewal` از اسلاتِ تکثیرِ `OFFSITE_WAL_SLOT` هر تغییر را لحظه‌ای می‌گیرد؛ این‌جا از سمتِ همین دیتابیس دیده می‌شود که وصل است و چقدر عقب است. ست‌نبودنِ اسلات روی Postgres یعنی کپیِ بیرونی نداریم و هشدار می‌شود. base backupِ روزانه‌ی آن سرور از این‌جا دیده نمی‌شود (journalِ همان‌جا). """ if connection.vendor != 'postgresql': return {'state': 'not_postgres'} slot = settings.OFFSITE_WAL_SLOT if not slot: return {'state': 'not_configured'} try: with connection.cursor() as cursor: cursor.execute( 'SELECT active, wal_status, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) ' 'FROM pg_replication_slots WHERE slot_name = %s', [slot], ) row = cursor.fetchone() except Exception as exc: # noqa: BLE001 return {'state': 'error', 'error': type(exc).__name__} return _slot_state(row) def _slot_state(row) -> dict: if row is None: return {'state': 'slot_missing'} active, wal_status, lag = row lag = int(lag or 0) if wal_status == 'lost': # سقفِ max_slot_wal_keep_size خورده: دیسکِ این سرور امن ماند ولی زنجیره‌ی WAL آن‌جا بریده است. state = 'lost' elif not active: state = 'disconnected' elif lag > settings.OFFSITE_WAL_MAX_LAG_BYTES: state = 'lagging' else: state = 'ok' return {'state': state, 'lagBytes': lag} # اتصال‌هایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و # migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests # عوض می‌کند و تا بسته شدنِ قبلی هم‌زمان بالاست (یک pool). DB_CONNECTION_RESERVE = 6 def _db_pool() -> dict: """ poolِ اتصالِ همین worker (config/settings.py) و بودجه‌ی کلِ اتصال‌ها در برابرِ max_connections. آمارِ pool مالِ workerی است که این درخواست را جواب داد، نه همه؛ `requestsErrors` یعنی چند بار درخواستی تا DB_POOL_TIMEOUT اتصال نگرفت و ۵۰۳ شد (از بالا آمدنِ این worker). بودجه از GUNICORN_WORKERS (gunicorn.conf.py، post_fork) حساب می‌شود؛ بیرون از gunicorn (runserver) ۱ است. """ if connection.vendor != 'postgresql': return {'state': 'not_postgres'} pool = connection.pool if pool is None: return {'state': 'off'} try: with connection.cursor() as cursor: cursor.execute( "SELECT current_setting('max_connections')::int - current_setting('superuser_reserved_connections')::int, " '(SELECT count(*) FROM pg_stat_activity WHERE datname = current_database())' ) allowed, in_use = cursor.fetchone() except Exception as exc: # noqa: BLE001 return {'state': 'error', 'error': type(exc).__name__} stats = pool.get_stats() workers = int(os.environ.get('GUNICORN_WORKERS', '1')) budget = workers * pool.max_size + DB_CONNECTION_RESERVE return { 'state': 'ok' if budget <= allowed else 'over_budget', 'workers': workers, 'poolMaxSize': pool.max_size, 'budget': budget, 'maxConnections': allowed, 'connectionsInUse': in_use, 'thisWorker': { 'size': stats.get('pool_size', 0), 'available': stats.get('pool_available', 0), 'waiting': stats.get('requests_waiting', 0), 'requests': stats.get('requests_num', 0), 'requestsQueued': stats.get('requests_queued', 0), 'requestsErrors': stats.get('requests_errors', 0), }, } def _db_pool_warnings(pool: dict) -> list[str]: if pool.get('state') == 'over_budget': return [ f"بودجه‌ی اتصالِ دیتابیس ({pool['workers']} worker × {pool['poolMaxSize']} + {DB_CONNECTION_RESERVE} رزرو " f"= {pool['budget']}) از سقفِ Postgres ({pool['maxConnections']}) بیشتر است: زیرِ بار درخواست‌ها ۵۰۰ می‌گیرند. " 'DB_POOL_MAX_SIZE یا تعدادِ workerها را کم کنید.' ] if pool.get('state') == 'error': return ['وضعیتِ poolِ دیتابیس خوانده نشد.'] return [] _OFFSITE_MESSAGES = { 'not_configured': 'کپیِ زنده‌ی دیتابیس بیرون از این سرور تنظیم نشده (OFFSITE_WAL_SLOT).', 'slot_missing': 'اسلاتِ تکثیرِ OFFSITE_WAL_SLOT در Postgres نیست؛ سرورِ بکاپ هیچ‌وقت وصل نشده یا اسلات حذف شده.', 'disconnected': 'سرورِ بکاپ الان وصل نیست؛ تغییرات روی همین سرور جمع می‌شوند تا سقفِ max_slot_wal_keep_size.', 'lagging': 'سرورِ بکاپ از دیتابیس عقب است.', 'lost': 'سرورِ بکاپ آن‌قدر قطع بود که زنجیره‌ی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.', 'error': 'وضعیتِ کپیِ زنده‌ی دیتابیس خوانده نشد.', } def _offsite_warnings(offsite: dict) -> list[str]: msg = _OFFSITE_MESSAGES.get(offsite['state']) return [msg] if msg else [] def _warnings() -> list[str]: """ چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند. اینجا هستند چون تنها راهِ دیدنشان تا امروز این بود که کسی تنظیماتِ سرور را از نزدیک بخواند — و ما به سرور دسترسی نداریم. """ out = [] if settings.DEBUG: out.append( 'DEBUG روشن است: صفحه‌های خطا کلِ تنظیمات و traceback را نشان می‌دهند، ' 'و کدِ ورودِ آزمایشیِ 0000 برای هر شماره‌ای پذیرفته می‌شود. ' 'روی سرورِ عمومی باید خاموش باشد.' ) if getattr(settings, 'DEV_MASTER_OTP', None) is not None: out.append('کدِ یک‌سانِ ورود (DEV_MASTER_OTP) فعال است — هر کسی می‌تواند به‌جای هر کاربری وارد شود.') test_phones = getattr(settings, 'TEST_LOGIN_PHONES', {}) if test_phones: # نه خودِ شماره و نه کد برنمی‌گردد؛ فقط تعداد، تا کسی که تنظیماتِ سرور # را ندیده هم بداند چند حسابِ تست با کدِ ثابت باز است و بتواند ببنددشان. # تعداد عمداً گفته نمی‌شود: این صفحه عمومی است و «۷ حسابِ کدِ ثابت» برای # مهاجم یعنی هدفِ مشخص برای حدس زدن. out.append( 'شماره‌های تستِ ورود با کدِ ثابت فعال‌اند (TEST_LOGIN_PHONES). ' 'برای توسعه لازم است؛ پیش از تحویلِ نهایی به کاربرانِ واقعی حذفشان کنید.' ) if not settings.DEBUG and settings.SECRET_KEY.startswith('django-insecure'): # بزرگ‌ترین ریسکِ باز (docs/security-before-production.md): این کلید در گیت است و هر # کس ریپو را ببیند برای هر کاربری توکن می‌سازد. فقط بله/خیر، نه خودِ کلید. out.append( 'کلیدِ امضای توکن همان کلیدِ کامیت‌شده در ریپو است (django-insecure): هر کس ریپو را ' 'ببیند می‌تواند به‌جای هر کاربری وارد شود. راهِ رفع در docs/security-before-production.md.' ) if _ai_provider_state().get('state') == 'quota_exhausted': out.append( 'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها ' '«در دسترس نیست» می‌دهد تا حساب شارژ شود.' ) backend = settings.CACHES['default']['BACKEND'] if not settings.DEBUG and 'LocMem' in backend: out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.') if not getattr(settings, 'CORS_ALLOWED_ORIGINS', None): out.append('هیچ origin ای برای CORS مجاز نیست — مرورگر همه‌ی درخواست‌های فرانت را بلاک می‌کند.') return out from payment.permissions import HasInternalServiceKey # noqa: E402 class HealthView(APIView): """ GET /api/health — وضعیتِ این نسخه‌ی مستقرشده. ۲۰۰ یعنی سالم، ۵۰۳ یعنی چیزی واقعاً خراب است (دیتابیس، کش، یا مهاجرتِ اعمال‌نشده). نبودِ محتوا خرابی حساب نمی‌شود ولی گزارش می‌شود. """ permission_classes = [AllowAny] authentication_classes = [] throttle_classes = [ScopedRateThrottle] throttle_scope = 'health' @extend_schema(tags=['System'], responses={200: dict, 503: dict}) def get(self, request): checks = { 'database': _check_database(), 'migrations': _check_migrations(), 'cache': _check_cache(), 'content': _check_content(), } critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache')) status = 200 if critical_ok else 503 # عموم فقط ok/error می‌بینند؛ جزئیات (هشدارها، ترافیک، سرویس‌های وصل‌شده، IP) پشتِ هدرِ # X-Internal-Key. کد ۲۰۰/۵۰۳ عمومی می‌ماند تا پایشِ بیرونی کار کند. اگر INTERNAL_SERVICE_KEY # در .env نباشد، هیچ‌کس جزئیات را نمی‌بیند (نه اینکه همه ببینند). if not HasInternalServiceKey().has_permission(request, self): return Response({'status': 'ok' if critical_ok else 'error'}, status=status) offsite = _offsite_backup() db_pool = _db_pool() warnings = _warnings() + _offsite_warnings(offsite) + _db_pool_warnings(db_pool) payload = { 'status': 'ok' if critical_ok else 'error', 'debug': bool(settings.DEBUG), 'warnings': warnings, 'checks': checks, 'integrations': _check_integrations(), 'traffic': _traffic(), 'offsiteBackup': offsite, 'dbPool': db_pool, 'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])), # IPای که throttle برای همین درخواست می‌بیند. باید IPِ عمومیِ خودِ # صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط # است و همه‌ی مهمان‌ها یک سهمِ مشترک دارند. فقط IPِ خودِ صدازننده # را به خودش برمی‌گرداند، پس چیزی لو نمی‌دهد. 'clientIp': ScopedRateThrottle().get_ident(request), } return Response(payload, status=status)