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

@@ -45,25 +45,14 @@ class FamilyLinkInviteView(APIView):
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
# اگه دعوت pending برای همین شماره از قبل هست، به‌جای رکورد تکراری
# (مثل الگوی idempotency در payment/services.py) همون رو برمی‌گردونیم.
# دعوت pending موجود برای همین شماره، به‌جای رکورد تکراری، برگردانده می‌شود.
link = FamilyLink.objects.filter(
parent_user_id=request.user.id, child_phone=v['childPhone'], status='pending'
).first()
if not link:
# ⚛️🔒 اصلاح race condition: قبلاً یک IntegrityError فقط برای
# برخورد روی uniq_pending_family_invite_per_parent_child مدیریت
# می‌شد؛ اگه برخورد واقعاً روی invite_code بود (دو کاربر مختلف،
# کد ۶رقمی تصادفی یکسان، هم‌زمان — همون چیزی که چکِ
# pre-check بالا هم به‌خاطر TOCTOU نمی‌تونه صد-در-صد جلوش رو
# بگیره)، link پیدا نمی‌شد و exception دوباره raise می‌شد → ۵۰۰.
# حالا در یک retry loop، اگه برخورد مربوط به دعوتِ همین
# parent/child نبود (یعنی احتمالاً روی invite_code بوده)، یک کد
# جدید تولید و دوباره تلاش می‌کنیم؛ اگه بود، همون رکورد
# pending رو idempotent برمی‌گردونیم — دقیقاً همون الگویی که
# create_order در payment/services.py برای race روی
# idempotency_key استفاده می‌کنه.
# اگر برخورد روی invite_code باشد (نه دعوت همین parent/child)، کد جدید
# تولید و دوباره تلاش می‌شود؛ در غیر این صورت رکورد pending موجود برگردانده می‌شود.
for attempt in range(5):
code = get_random_string(6, allowed_chars='0123456789')
try:
@@ -80,7 +69,7 @@ class FamilyLinkInviteView(APIView):
).first()
if link is not None:
break
# برخورد روی invite_code بوده؛ کد جدید بساز و دوباره تلاش کن
# برخورد روی invite_code بوده؛ کد جدید ساخته و دوباره تلاش می‌شود.
continue
else:
raise IntegrityError(
@@ -106,30 +95,19 @@ class FamilyLinkAcceptView(APIView):
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
# ⚛️🔒 race condition: قبلاً get_object_or_404 بدون قفل انجام می‌شد —
# دو درخواستِ همزمان برای accept کردنِ همون invite code می‌تونستن هر
# دو link.status='pending' رو ببینن، هر دو accept رو کامل کنن، و
# دوبار notify() برای والد بفرستن. حالا خودِ ردیفِ FamilyLink داخل
# transaction.atomic قفل می‌شه (select_for_update)، پس درخواستِ دومِ
# همزمان تا commit شدنِ اولی معطل می‌مونه و بعدش status رو
# 'accepted' می‌بینه (نه 'pending')، پس دیگه match نمی‌کنه.
# ردیف FamilyLink زیر قفل خوانده می‌شود تا دو accept هم‌زمان روی همان
# invite_code هر دو کامل نشوند و دو بار notify() ارسال نشود.
with transaction.atomic():
link = get_object_or_404(
FamilyLink.objects.select_for_update(),
invite_code=v['inviteCode'], status='pending',
)
# ⚠️ اصلاح امنیتی: قبلاً هر کاربر لاگین‌کرده‌ای با دونستنِ inviteCode
# می‌تونست دعوتِ یک والد رو claim کنه، حتی اگه اون دعوت اصلاً برای
# شماره‌ی او نبود (مثلاً کد رو حدس بزنه یا از جای دیگه‌ای ببینه).
# حالا اگه دعوت برای یک شماره‌ی مشخص صادر شده باشه، فقط کاربری که
# شماره‌اش با child_phone یکی باشه می‌تونه اون رو accept کنه.
# اگر دعوت برای شماره‌ی مشخصی صادر شده باشد، فقط همان شماره می‌تواند accept کند.
if link.child_phone and link.child_phone != request.user.phone:
raise PermissionDenied('این دعوت متعلق به شماره‌ی دیگری است.')
# ⚛️ atomic: تغییر وضعیت لینک + ساختِ Notification یک واحدند —
# وگرنه ممکنه اتصال به‌عنوان accepted ثبت بشه ولی هیچ اعلانی برای
# والد ساخته نشه.
# اتمیک: تغییر وضعیت لینک + ساخت Notification یک واحد است.
link.child_user_id = request.user.id
link.status = 'accepted'
link.accepted_at = timezone.now()