Postgres here allows 60 connections, but each of the 96 gunicorn threads held its own persistent one: at 100 concurrent users (local Postgres with the same limit) 21% of requests were 500 "remaining connection slots are reserved". Now each worker has a psycopg pool (8), so the same load gives 0 errors and 714 req/s (was 460). - A full pool answers 503 + Retry-After (DRF handler and middleware), not 500. - The AI tutor and the OTP SMS call return their connection before the network wait. - statement_timeout 30 s, idle-in-transaction 60 s; bad pool settings and the old DB_CONN_MAX_AGE stop startup instead of failing every request. - /api/health dbPool: per-worker pool stats and workers x pool + 6 vs max_connections. - Indexes on the columns global counters filter by time (weekly leaderboard, global SMS cap, global AI cap); the leaderboard recomputes once per expiry. - Worker count moves into gunicorn.conf.py (4 on a shared 8-core box). - security doc: Redis item closed (server uses our own Redis since 2026-10-08). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
6.6 KiB
Python
122 lines
6.6 KiB
Python
import hashlib
|
||
|
||
from django.db import models
|
||
|
||
|
||
class User(models.Model):
|
||
"""
|
||
کاربر اصلی سیستم: ثبتنامشده با شمارهی موبایل (تنها راه ورود).
|
||
توجه: این مدل، AUTH_USER_MODEL جنگو نیست؛ یک مدل بیزنسی مستقله که
|
||
با TadrisinoTokenAuthentication سفارشی احراز هویت میشه.
|
||
|
||
grade_id فقط یک ارجاع سبک (بدون FK واقعی) به content.Grade هست؛
|
||
این اپ عمداً هیچوقت FK واقعی به مدل اپ دیگه نمیزنه.
|
||
"""
|
||
phone = models.CharField(max_length=15, unique=True, null=True, blank=True, db_index=True)
|
||
name = models.CharField(max_length=100, blank=True)
|
||
avatar = models.CharField(max_length=100, blank=True)
|
||
grade_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Grade
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
# آخرین زمانِ فعالیتِ کاربر (لاگین موفق یا رفرش توکن موفق). فقط برای هدفگیریِ آیندهی کمپینهای پیامی نگهداری
|
||
# میشه؛ در حال حاضر هیچ منطقِ دیگهای به این فیلد وابسته نیست.
|
||
last_activity_at = models.DateTimeField(null=True, blank=True)
|
||
# access tokenهای صادرشده پیش از این لحظه رد میشوند (خروج از حساب). توکنِ
|
||
# access فقط user_id امضاشده است و به Session وصل نیست؛ بدونِ این، خروج
|
||
# فقط refresh را باطل میکرد و توکنِ فعلی تا یک ساعت کار میکرد.
|
||
tokens_valid_after = models.DateTimeField(null=True, blank=True)
|
||
|
||
class Meta:
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
return self.name or self.phone or f"user-{self.pk}"
|
||
|
||
@property
|
||
def is_authenticated(self):
|
||
# این مدل AUTH_USER_MODEL نیست؛ این property فقط برای سازگاری با
|
||
# rest_framework.permissions.IsAuthenticated لازمه.
|
||
return True
|
||
|
||
|
||
class Profile(models.Model):
|
||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||
# سالِ تولدِ شمسی. از ۲۰۲۶-۱۰-۰۱ از birth_date پر میشود؛ برای کاربرهای
|
||
# قدیمی که فقط سال دادهاند تنها منبع است.
|
||
birth_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||
# تاریخِ تولدِ کامل (میلادی ذخیره میشود، در API شمسی است؛ accounts/jalali.py).
|
||
birth_date = models.DateField(null=True, blank=True)
|
||
bio = models.CharField(max_length=200, blank=True)
|
||
|
||
def __str__(self):
|
||
return f"profile-{self.user_id}"
|
||
|
||
|
||
def hash_refresh_token(raw: str) -> str:
|
||
"""آنچه در Session.refresh_token ذخیره و با آن جستوجو میشود؛ خودِ توکن فقط دستِ کلاینت است."""
|
||
return hashlib.sha256(raw.encode()).hexdigest()
|
||
|
||
|
||
class Session(models.Model):
|
||
"""
|
||
یک ورود. توکنِ دسترسی ذخیره نمیشود (امضاشده و stateless است، accounts/authentication.py).
|
||
|
||
`refresh_token` هشِ SHA-256ِ توکنِ تمدید است، نه خودش (hash_refresh_token): هر کس دیتابیس،
|
||
یک بکاپ یا پنلِ ادمین را ببیند با آن نمیتواند بهجای کاربر وارد شود. توکن ۶۴ نویسهی تصادفی
|
||
است، پس هشِ سریعِ بینمک کافی است.
|
||
"""
|
||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions')
|
||
refresh_token = models.CharField(max_length=200, unique=True)
|
||
expires_at = models.DateTimeField()
|
||
revoked = models.BooleanField(default=False)
|
||
|
||
def __str__(self):
|
||
return f"session-{self.pk}-user{self.user_id}"
|
||
|
||
|
||
class OTPCode(models.Model):
|
||
phone = models.CharField(max_length=15, db_index=True)
|
||
code = models.CharField(max_length=6)
|
||
# ایندکس: سقفِ سراسریِ پیامکِ روزانه در هر درخواستِ کد کدهای ۲۴ ساعتِ اخیر را میشمارد
|
||
# (accounts/views.py)؛ ردیفها پاک نمیشوند، پس بیایندکس این شمارش با هر ورود کندتر میشد.
|
||
expires_at = models.DateTimeField(db_index=True)
|
||
is_used = models.BooleanField(default=False)
|
||
# تلاشهای غلط روی همین کد. در دیتابیس شمرده میشود، نه در کشِ LocMem که
|
||
# برای هر worker جداست؛ بعد از OTP_MAX_FAILED_ATTEMPTS کد باطل میشود.
|
||
failed_attempts = models.PositiveSmallIntegerField(default=0)
|
||
|
||
def __str__(self):
|
||
return f"otp-{self.phone}"
|
||
|
||
|
||
class LoginEvent(models.Model):
|
||
"""
|
||
سابقهی ماندگارِ ورود و خروج، برای پرسشهایی مثلِ «این کاربر کی آمد، کی رفت».
|
||
|
||
چرا جدول و نه فقط لاگ: لاگِ stdout با ریاستارت یا چرخشِ journald از بین میرود؛ این ردیفها
|
||
در دیتابیس و بکاپ میمانند. `user_id` ارجاعِ سبک است (نه FK، مثلِ بقیهی پروژه)، و ردیف با حذفِ
|
||
حساب نمیماند مگر در KEPT_ON_ACCOUNT_DELETE. شمارهی کامل برای ادمینها ذخیره میشود
|
||
(تصمیمِ مالک، ۲۰۲۶-۱۰-۰۸)؛ در سیاستِ حریمِ خصوصی باید بیاید.
|
||
"""
|
||
LOGIN_OK = 'login_ok'
|
||
LOGIN_FAILED = 'login_failed' # کدِ غلط یا منقضی
|
||
LOGIN_BLOCKED = 'login_blocked' # سقفِ تلاشِ شماره (429)
|
||
LOGOUT = 'logout'
|
||
ACCOUNT_DELETED = 'account_deleted'
|
||
EVENT_CHOICES = [
|
||
(LOGIN_OK, 'ورود موفق'), (LOGIN_FAILED, 'ورود ناموفق'), (LOGIN_BLOCKED, 'ورود مسدود'),
|
||
(LOGOUT, 'خروج'), (ACCOUNT_DELETED, 'حذف حساب'),
|
||
]
|
||
|
||
event = models.CharField(max_length=20, choices=EVENT_CHOICES, db_index=True)
|
||
user_id = models.IntegerField(null=True, blank=True, db_index=True)
|
||
phone = models.CharField(max_length=15, blank=True, db_index=True)
|
||
ip = models.GenericIPAddressField(null=True, blank=True)
|
||
user_agent = models.CharField(max_length=300, blank=True)
|
||
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
||
|
||
class Meta:
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
return f"{self.event} user={self.user_id} {self.created_at:%Y-%m-%d %H:%M}"
|