Harden auth, AI tutor, payments, and subscriptions

This commit is contained in:
2026-08-22 04:42:36 +03:30
parent f945f960c9
commit cb18c93a3e
41 changed files with 2202 additions and 595 deletions

View File

@@ -57,19 +57,9 @@ class Question(models.Model):
class Choice(models.Model):
"""
⚠️ اصلاح نسبت به سند اولیه: دیاگرام معماری فقط فیلد `value` رو برای
Choice مشخص کرده بود و فیلد تشخیص گزینه‌ی درست (is_correct) رو نداشت
(این نکته توی سند models هم صراحتاً به‌عنوان یک ایراد/نقص علامت‌گذاری
شده بود: "یا این فیلد سهواً جا افتاده یا تشخیص جای دیگه‌ای انجام می‌شه").
چون بدون این فیلد امکان پیاده‌سازی POST /api/questions/{id}/answer
(که باید correct را برگردونه) وجود نداره، این فیلد اینجا اضافه شده.
اگه منطق تشخیص پاسخ درست قراره جای دیگه‌ای (سرویس content-authoring)
انجام بشه، این فیلد رو می‌شه بعداً حذف/جابه‌جا کرد.
"""
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='choices')
value = models.CharField(max_length=100)
is_correct = models.BooleanField(default=False) # <-- اصلاح/افزوده شده
is_correct = models.BooleanField(default=False)
def __str__(self):
return self.value
@@ -82,15 +72,9 @@ class UserLessonCompletion(models.Model):
stars = models.PositiveSmallIntegerField()
xp_earned = models.PositiveIntegerField(default=0)
coins_earned = models.PositiveIntegerField(default=0)
# ⚠️ افزوده شده برای رفع race condition: طبق طراحیِ فعلی، کاربر مجاز به
# تکرارِ یک درس هست (فقط بارِ اول xp/coins واقعی می‌گیره، نگاه کنید به
# is_first_completion در LessonCompleteView)، پس یک UniqueConstraint
# سختِ (user_id, lesson) درست نیست — جلوی retryِ مشروع رو هم می‌گیره.
# مشکلِ واقعی، دابل‌کلیک/رتریِ شبکه‌ایِ کلاینت روی *همون* submitِ
# تکمیل درسه که می‌تونست دو ردیف برای یک اتفاق بسازه. راه‌حل، همون
# الگوی Idempotency-Key هست که پروژه از قبل در payment/services.py
# برای create_order داره: کلاینت (اختیاری) یک کلید یکتا برای هر submit
# واقعی می‌فرسته و رتری‌ها با همون کلید idempotent می‌شن.
# تکرار درس مجاز است (فقط بار اول xp/coins واقعی می‌گیرد)، پس قید یکتایی
# سخت روی (user_id, lesson) درست نیست. idempotency_key فقط جلوی رکورد
# تکراریِ ناشی از دابل‌کلیک/رتری روی همان submit را می‌گیرد.
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -177,26 +161,13 @@ class UserAnswer(models.Model):
choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name='answers')
correct = models.BooleanField()
xp_earned = models.PositiveIntegerField(default=0)
# ⚠️ افزوده شده برای رفع idempotency در POST /api/questions/{id}/answer:
# قبلاً هیچ مکانیزمی برای تشخیصِ رتریِ شبکه‌ای/کلاینتِ همون submit وجود
# نداشت — یک retry دقیقاً همون درخواست رو دوباره می‌فرستاد و یک ردیفِ
# UserAnswer تکراری می‌ساخت و (در حالت پاسخ غلط) هارت رو دوبار کم
# می‌کرد. دقیقاً همون الگویی که UserLessonCompletion.idempotency_key
# برای LessonCompleteView داره: کلاینت (اختیاری) یک کلید یکتا برای هر
# submit واقعی می‌فرسته و رتری‌ها با همون کلید idempotent می‌شن؛ بدونِ
# کلید (مقدار None)، پاسخ‌های مشروعِ تکراری (کاربر واقعاً چند بار به یک
# سوال پاسخ می‌ده) همچنان مجازن.
# کلید اختیاری برای idempotency: رتری روی همان submit رکورد تکراری/کسر دوباره‌ی هارت نمی‌سازد.
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
# 🔒 قید یکتاییِ سطح DB: به‌اندازه‌ی کافی محدود (user_id +
# question + idempotency_key) تا کاربر و سوال رو از هم تفکیک
# کنه — یعنی همون کلید توسط کاربرِ دیگه یا برای سوالِ دیگه هیچ
# تداخلی نداره. condition تضمین می‌کنه این قید فقط وقتی
# idempotency_key واقعاً پر شده اعمال بشه، نه وقتی خالی/نال‌ه
# (وگرنه پاسخ‌های بدونِ کلید هم به اشتباه یکتا می‌شدن).
# قید فقط وقتی idempotency_key پر باشد اعمال می‌شود، تا پاسخ‌های بدون کلید یکتا نشوند.
models.UniqueConstraint(
fields=['user_id', 'question', 'idempotency_key'],
condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''),