cleanup: trim bloated multi-line comments across the project

This commit is contained in:
2026-08-26 03:22:20 +03:30
parent 83435c5a5f
commit 683bd40169
27 changed files with 235 additions and 699 deletions

View File

@@ -56,47 +56,19 @@ class UserAdmin(admin.ModelAdmin):
list_filter = ('is_guest',)
actions = ['send_sms_to_selected']
# permissions=['change']: بدونِ این، جنگو این اکشن رو بر اساسِ
# has_change_permission فیلتر نمی‌کنه (فقط اکشن‌هایی که با پارامترِ
# permissions تعریف شده باشن فیلتر می‌شن)، یعنی کاربری که فقط
# view_user داره (مثلِ گروهِ Supervisor) می‌تونه با یک POSTِ مستقیم
# همین اکشن رو صدا بزنه و واقعاً پیامک بفرسته، حتی بدونِ اینکه دکمه‌ی
# اکشن رو در UI ببینه. با permissions=['change']، ModelAdmin.response_action
# خودش (get_actions -> _filter_actions_by_permissions) این اکشن رو
# فقط برای کاربرانِ دارایِ change_user در دسترس می‌ذاره؛ برای بقیه
# (مثلِ Supervisor) اصلاً در actions ثبت‌شده نیست، پس درخواستِ مستقیم
# هم بی‌اثر می‌مونه (پیامِ هشدارِ استانداردِ «اکشنی انتخاب نشده»).
# permissions=['change']: بدونِ این، کاربرِ فقط-view (گروهِ Supervisor)
# می‌تونست با POSTِ مستقیم این اکشن رو صدا بزنه بدونِ دیدنِ دکمه در UI؛
# این پارامتر باعث می‌شه ModelAdmin فقط برای change_user در دسترس بذارتش.
@admin.action(description='ارسال پیامک به کاربران انتخاب‌شده', permissions=['change'])
def send_sms_to_selected(self, request, queryset):
"""
قدمِ اولیه‌ی مینیمالِ فیچرِ «ارسالِ پیامک از ادمین»: یک اکشنِ ادمینِ
معمولی روی UserAdmin که یک صفحه‌ی میانیِ ساده (فرمِ متنِ پیامک) رو
نشون می‌ده و بعد از تأیید، دقیقاً به همون کاربرهایی که در changelist
انتخاب شدن، همون متن رو با accounts.services.send_sms موجود ارسال
می‌کنه.
"""اکشنِ ادمین برای ارسالِ پیامک به کاربرانِ انتخاب‌شده یا غیرفعال؛
گیرنده با فیلدِ «target» انتخاب می‌شه (cutoffِ «غیرفعال» رو خودِ ادمین
در فرم وارد می‌کنه، چیزی hardcode نیست).
گیرنده‌ها با فیلدِ «target» در فرم انتخاب می‌شن:
- 'selected' (پیش‌فرض): همون queryset که ادمین در changelist
انتخاب کرده — رفتارِ قبلی، بدون تغییر.
- 'inactive': کاربرانِ غیرفعال، با استفاده از
accounts.services.get_inactive_users(since) — cutoff (تاریخ)
رو خودِ ادمین در فرم وارد می‌کنه، هیچ عددِ روزی hardcode نشده.
عمداً محدود نگه داشته شده:
- هیچ مدلِ کمپین/بدنه‌ی جدیدی ساخته/persist نمی‌شه (نه رکوردی از
پیام، نه رکوردی از تحویل)؛ ارسال کاملاً بلادرنگه.
- هیچ زمان‌بندی، صفِ پس‌زمینه، یا فیلترِ هدف‌گیریِ دیگه‌ای (غیر از
selected/inactive) نیست.
- محافظتِ سقف: چون ارسال هنوز کاملاً synchronous و توی همون
request انجام می‌شه (نه صفِ پس‌زمینه)، اگر تعدادِ گیرنده‌ها از
settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشه، اکشن هیچ پیامکی
ارسال نمی‌کنه و فقط خطا نشون می‌ده — تا یک انتخاب/تاریخِ خیلی
بزرگ باعثِ تایم‌اوتِ request یا overload شدنِ پروایدر نشه.
- هیچ منطقِ provider تکرار نشده؛ send_sms عیناً همون تابعِ موجودیه
که OTP و campaigns هم ازش استفاده می‌کنن.
- کاربرانِ بدونِ شماره‌تلفن (مثلِ مهمان) به‌سادگی skip می‌شن.
- شکستِ ارسال برای یک کاربر (SMSDeliveryError) کلِ اکشن رو متوقف
نمی‌کنه؛ فقط شمارشِ failed رو زیاد می‌کنه و برای بقیه ادامه می‌ده.
ارسال کاملاً synchronous و توی همون requestه (نه صفِ پس‌زمینه)، پس
اگر گیرنده‌ها از settings.ADMIN_SMS_MAX_RECIPIENTS بیشتر باشن، اکشن
هیچ پیامکی نمی‌فرسته تا request تایم‌اوت نکنه یا provider overload نشه.
شکستِ ارسال برای یک کاربر بقیه را متوقف نمی‌کنه.
"""
if 'apply' in request.POST:
form = SendSMSForm(request.POST)
@@ -123,10 +95,8 @@ class UserAdmin(admin.ModelAdmin):
),
level=messages.ERROR,
)
# به‌جای return None، به رندرِ مشترکِ پایینِ تابع سقوط
# می‌کنیم تا فرم دوباره (بدونِ ارسالِ هیچ پیامکی) نشون
# داده بشه — دقیقاً همون الگویی که برای فرمِ نامعتبر
# استفاده می‌شه.
# سقوط به رندرِ مشترکِ پایین (نه return None) چون None از
# یک اکشنِ ادمین باعثِ ریدایرکتِ ۳۰۲ می‌شه، نه رندرِ مستقیم.
else:
sent_count = 0
failed_count = 0
@@ -158,12 +128,8 @@ class UserAdmin(admin.ModelAdmin):
f'{skipped_no_phone} کاربر بدونِ شماره‌تلفن رد شدن (پیامک ارسال نشد).',
level=messages.WARNING,
)
# توجه: قبلاً اینجا `return None` بود؛ حذف شد چون در
# جنگو، None برگردوندن از یک اکشنِ ادمین باعثِ یک
# ریدایرکتِ ۳۰۲ واقعی می‌شه (نه رندرِ مستقیمِ ۲۰۰)،
# درست برخلافِ چیزی که این کامنت قبلاً فرض کرده بود.
# حالا هم‌الگو با شاخه‌ی cap-exceeded، سقوط می‌کنیم به
# رندرِ مشترکِ پایینِ تابع.
# سقوط به رندرِ مشترکِ پایین (نه return None)، هم‌الگو با
# شاخه‌ی cap-exceeded.
else:
form = SendSMSForm()
@@ -195,38 +161,15 @@ class OTPCodeAdmin(admin.ModelAdmin):
list_filter = ('is_used',)
# ─────────────────────────────────────────────────────────────────────────
# صفحه‌ی «ناظر» (Supervisor)
# صفحه‌ی «ناظر» (Supervisor): یک ایندکسِ read-only از لینک‌ها به
# changelistهای موجود (کاربران/پرداخت‌ها/اشتراک‌ها)، داخلِ همون AdminSite
# پیش‌فرض — نه AdminSite جدا و بدون هیچ ModelAdmin جدید.
#
# یک صفحه‌ی خیلی کوچیک و *read-only* داخلِ همون AdminSiteِ پیش‌فرضِ جنگو
# (django.contrib.admin.site) — نه یک AdminSite جدا، و نه CRUDِ موازی.
# این صفحه صرفاً یک ایندکسِ گروهی از لینک‌هایی به همون changelistهای
# ModelAdmin موجودِ پروژه‌ست: کاربران (accounts.UserAdmin)، پرداخت‌ها
# (payment.OrderAdmin / payment.TransactionAdmin) و اشتراک‌ها
# (subscription.SubscriptionAdmin). هیچ منطقِ کسب‌وکار یا ModelAdmin
# جدیدی اینجا ساخته نمی‌شه.
#
# محدودیتِ واقعیِ read-only از طریقِ سیستمِ استانداردِ Group/Permission
# جنگو تأمین می‌شه، نه با پنهون‌کردنِ لینک:
# - هر لینک در این صفحه فقط وقتی نشون داده می‌شه که کاربر پرمیشنِ
# view_<model> مربوطه رو داشته باشه (request.user.has_perm(...)).
# - حتی اگه کسی مستقیماً URL تغییر/افزودن/حذف رو حدس بزنه، خودِ
# changelist/change/add/delete viewهای اون ModelAdminها (که اینجا هیچ
# override‌ای نشدن) طبقِ has_view_permission/has_change_permission/
# has_add_permission/has_delete_permission پیش‌فرضِ ModelAdmin — که
# مستقیماً از Permission مدلِ کاربر می‌خونن — چک می‌کنن؛ یعنی گروهِ
# Supervisor (که با دستورِ مدیریتیِ setup_supervisor_group فقط پرمیشنِ
# view_* می‌گیره) حتی با دسترسیِ مستقیم به URL هم نمی‌تونه add/change/
# delete کنه.
# - خودِ صفحه هم مثلِ هر صفحه‌ی دیگه‌ی Admin با admin.site.admin_view
# wrap شده، یعنی همون احرازِ هویت/is_staff استانداردِ Admin رو داره؛
# ولی is_staff به‌تنهایی برای *ورود به خودِ صفحه* کافی نیست — پایین‌تر
# قبل از هر کارِ دیگه‌ای عضویتِ کاربر در گروهِ Supervisor هم چک می‌شه
# (request.user.groups.filter(name=SUPERVISOR_GROUP_NAME))، وگرنه
# PermissionDenied (۴۰۳) بالا می‌ره. این یک سیستمِ پرمیشنِ جدید نیست؛
# فقط از همون گروهِ Supervisorِ موجود (که setup_supervisor_group
# می‌سازه) برای گیت‌کیپینگِ ورود به صفحه استفاده می‌کنه.
# ─────────────────────────────────────────────────────────────────────────
# read-only بودن با پنهون‌کردنِ لینک تضمین نمی‌شه، بلکه با پرمیشنِ استانداردِ
# جنگو: هر لینک فقط با view_<model> نشون داده می‌شه و حتی دسترسیِ مستقیم به
# URLِ change/add/delete هم توسطِ پرمیشنِ پیش‌فرضِ همون ModelAdminها رد می‌شه
# (گروهِ Supervisor فقط view_* داره). is_staff به‌تنهایی برای ورود کافی
# نیست؛ عضویت در گروهِ Supervisor هم چک می‌شه، وگرنه PermissionDenied.
SUPERVISOR_GROUP_NAME = 'Supervisor'

View File

@@ -104,12 +104,9 @@ def send_sms(*, phone: str, message: str) -> None:
)
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
# ⚠️ کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبار
# ناکافی، گیرنده‌ی نامعتبر و…) HTTP 200 برمی‌گردونه؛ نتیجه‌ی واقعی توی
# بدنه‌ی JSON، زیرِ return.status هست (200 یعنی موفق). صرفِ
# status_code == 200 «موفقیت» در نظر گرفته نمی‌شه؛ بدنه parse می‌شه و
# فقط return.status == 200 موفقیت محسوب می‌شه؛ هر پاسخِ ناقص/غیرمنتظره
# هم شکستِ تحویل در نظر گرفته می‌شه (نه موفقیت).
# کاوه‌نگار حتی برای شکست‌های سطحِ اپلیکیشن (کلیدِ نامعتبر، اعتبارِ ناکافی و…)
# HTTP 200 برمی‌گردونه؛ نتیجه‌ی واقعی توی body.return.status هست. فقط
# اون فیلد موفقیت رو تعیین می‌کنه، نه status_code.
try:
body = response.json()
provider_status = body['return']['status']
@@ -158,23 +155,10 @@ def send_otp_sms(*, phone: str, code: str) -> None:
raise OTPDeliveryError(str(exc)) from exc
# ─────────────────────────────────────────────────────────────────────────
# Targeting helpers (internal, read-only)
#
# هدف: صرفاً کوئری‌های قابل‌استفاده‌ی مجدد برای انتخاب زیرمجموعه‌ای از
# کاربران، تا فیچرهای آینده (مثلاً کمپین‌های پیامی) بدونِ تکرارِ منطقِ
# فیلتر، از همین توابع استفاده کنن. این ماژول:
# - هیچ پیامی نمی‌فرسته (نه SMS، نه notification).
# - هیچ جدول/مایگریشنِ جدیدی اضافه نمی‌کنه.
# - هیچ background job/endpoint‌ای نداره؛ فقط QuerySetِ User برمی‌گردونه.
# - هیچ رفتارِ موجودی رو تغییر نمی‌ده (فقط SELECT).
#
# ارجاع به subscription.Subscription عمداً با import محلی (داخلِ تابع) و
# بر اساسِ user_id انجام می‌شه — دقیقاً همون الگویی که payment/services.py
# و discount/services.py برای صداکردنِ subscription استفاده می‌کنن؛ چون
# accounts.User (طبقِ docstringِ خودش) هیچ‌وقت FK واقعی به مدل‌های اپ‌های
# دیگه نمی‌زنه.
# ─────────────────────────────────────────────────────────────────────────
# Targeting helpers (internal, read-only): کوئری‌های قابل‌استفاده‌ی مجدد برای
# انتخابِ زیرمجموعه‌ای از کاربران (مثلاً برای کمپین‌های پیامی)؛ فقط SELECT،
# بدونِ ارسالِ پیام. ارجاع به subscription.Subscription با import محلی و
# user_id انجام می‌شه چون accounts.User هیچ‌وقت FK واقعی به اپ‌های دیگه نمی‌زنه.
def get_all_users():
"""همه‌ی کاربران (مهمان و ثبت‌نام‌شده)."""

View File

@@ -837,27 +837,13 @@ class GuestSessionThrottleTests(TestCase):
}
)
def test_repeated_guest_session_requests_are_eventually_throttled(self):
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست دو مشکلِ جدا
# داشت:
# ۱) `type` رو نمی‌فرستاد (پایین‌تر فیکس شده) — قراردادِ واقعیِ
# API طبقِ README نیازش داره، پس هر درخواست قبل از throttle
# با 400 رد می‌شد.
# ۲) override_settings(REST_FRAMEWORK=...) به‌تنهایی رفتارِ
# ScopedRateThrottle رو تغییر نمی‌ده: DRF مقدارِ
# THROTTLE_RATES رو یک‌بار موقعِ import ماژولِ throttling
# به‌عنوانِ یک class attribute از api_settings.
# DEFAULT_THROTTLE_RATES می‌گیره (همون دیکشنریِ اصلیِ
# settings.py، با reference). override_settings یک دیکشنریِ
# *جدید* برای settings.REST_FRAMEWORK می‌سازه، اما این
# class attribute هنوز به همون دیکشنریِ قدیمی اشاره می‌کنه —
# سیگنالِ setting_changed فقط api_settings رو reload می‌کنه،
# نه این class attribute رو. نتیجه: نرخِ واقعیِ اعمال‌شده
# همیشه همون دیفالتِ 20/hour در settings.py می‌موند، نه
# 3/hour، پس ۵ درخواست هیچ‌وقت به سقف نمی‌رسیدن. برای اینکه
# این تست واقعاً نرخِ محدودشده رو بسنجه، مستقیماً همون
# دیکشنریِ زنده‌ی ScopedRateThrottle.THROTTLE_RATES رو
# patch.dict می‌کنیم (که override_settings بالا هم برای
# سازگاری/مستندسازیِ نیت نگه داشته می‌شه).
# override_settings(REST_FRAMEWORK=...) به‌تنهایی رفتارِ
# ScopedRateThrottle رو تغییر نمی‌ده: DRF مقدارِ THROTTLE_RATES رو
# یک‌بار موقعِ import، به‌عنوانِ class attribute، از دیکشنریِ اصلیِ
# settings.py می‌گیره؛ override_settings دیکشنریِ settings رو عوض
# می‌کنه ولی این class attribute هنوز به نسخه‌ی قدیمی اشاره می‌کنه.
# برای سنجیدنِ نرخِ واقعی، مستقیماً patch.dict روی همون دیکشنریِ
# زنده‌ی ScopedRateThrottle.THROTTLE_RATES لازمه.
with patch.dict(ScopedRateThrottle.THROTTLE_RATES, {'guest_session': '3/hour'}):
for i in range(3):
response = self.client.post(

View File

@@ -116,24 +116,10 @@ class RequestOTPView(APIView):
serializer.is_valid(raise_exception=True)
phone = serializer.validated_data['phone']
# حداقل فاصله‌ی زمانی بین دو درخواست برای همون شماره (burst
# protection). سقفِ ساعتیِ زیر جلویِ حجمِ کلی رو می‌گیره، اما به‌تنهایی
# جلویِ این رو نمی‌گیره که همون ۵ تلاشِ مجاز در عرضِ چند ثانیه/کمتر
# از یک ثانیه مصرف بشن (که هم به‌معنایِ flood پیامکی روی یک شماره‌ی
# مشخصه، هم می‌تونه با invalidate کردنِ پی‌درپیِ OTPهای قبلی
# (رفتارِ «فقط آخرین کد معتبره» که پایین‌تره) عملاً یک DoS روی
# کاربرِ واقعیِ اون شماره باشه). چک این cooldown *قبل* از شمارنده‌ی
# ساعتی انجام می‌شه تا یک درخواستِ بلاک‌شده توسطِ cooldown سهمیه‌ی
# ساعتی رو مصرف نکنه. از همون الگویِ اتمیکِ cache.add استفاده شده
# (دقیقاً همون مکانیزمِ زیر و مکانیزمِ LoginView).
#
# کلید cooldown به‌جای یک فلگِ ساده، زمانِ epoch انقضاء رو ذخیره
# می‌کنه؛ این‌طوری وقتی add شکست بخوره (کلید از قبل هست)، بدونِ
# هیچ خواندنِ ناامنی می‌شه دقیقاً محاسبه کرد چند ثانیه تا پایانِ
# cooldown مونده و اون رو هم در بدنه‌ی پاسخ (retryAfter) و هم در
# هدرِ استانداردِ Retry-After برگردوند — این‌طوری frontend
# countdown رو دقیقاً از رویِ همین پاسخِ backend شروع می‌کنه، نه
# از رویِ یک عددِ hardcode‌شده‌ی سمتِ کلاینت که مرجعِ امنیتی نیست.
# حداقل فاصله‌ی زمانی بین دو درخواستِ همون شماره (burst protection)؛
# قبل از شمارنده‌ی ساعتی چک می‌شه تا یک درخواستِ بلاک‌شده سهمیه‌ی
# ساعتی رو مصرف نکنه. کلید مقدارِ epoch انقضا رو ذخیره می‌کنه تا
# retryAfter/هدرِ Retry-After بدونِ خواندنِ اضافه محاسبه بشه.
phone_cooldown_key = f'otp_request_phone_cooldown:{phone}'
cooldown_seconds = settings.OTP_REQUEST_PHONE_COOLDOWN_SECONDS
now_epoch = timezone.now().timestamp()
@@ -161,13 +147,9 @@ class RequestOTPView(APIView):
headers={'Retry-After': str(retry_after)},
)
# محدودیت صدور OTP بر اساس شماره تلفن، مستقل از throttle IP-محورِ
# otp_request بالا. throttle IP-محور به‌تنهایی کافی نیست چون یک
# مهاجم می‌تونه درخواست‌های تکراری برای یک شماره رو بین چند IP
# مختلف پخش کنه و پیامک OTP رو flood کنه. الگوی cache.add/incr
# همون الگوی atomic زیرِ LoginView است: cache.add فقط وقتی کلید
# وجود نداره مقدار می‌ذاره (اتمیک روی سطح backend cache)، پس دو
# درخواستِ هم‌زمان نمی‌تونن هر دو از یک non-atomic check رد بشن.
# محدودیتِ صدورِ OTP بر اساسِ شماره، مستقل از throttleِ IP-محورِ بالا
# (وگرنه یه مهاجم می‌تونه بینِ چند IP پخش کنه). cache.add فقط وقتی
# کلید نیست مقدار می‌ذاره، پس اتمیکه.
phone_requests_key = f'otp_request_phone_attempts:{phone}'
if cache.add(phone_requests_key, 1, timeout=settings.OTP_REQUEST_PHONE_WINDOW_SECONDS):
attempt_count = 1
@@ -202,13 +184,9 @@ class RequestOTPView(APIView):
try:
send_otp_sms(phone=phone, code=code)
except OTPDeliveryError:
# ⚠️ فیکس: کاربر عملاً کد رو دریافت نکرده (ارسالِ پیامک شکست
# خورده)، پس cooldownِ ۱۲۰ثانیه‌ای که بالاتر برای همین درخواست
# ثبت شده نباید جلوی تلاشِ فوریِ بعدیِ کاربر رو بگیره؛ در غیرِ
# این‌صورت کاربر ۲ دقیقه معطل می‌مونه بدونِ اینکه اصلاً کدی
# گرفته باشه. سقفِ ساعتیِ per-phone (بالاتر) و throttle
# IP-محور دست‌نخورده می‌مونن — این‌ها محدودیتِ حجمِ کلی‌ان، نه
# cooldownِ بینِ-درخواستی، و منطقِ متفاوتی دارن.
# ارسال شکست خورده یعنی کاربر کدی نگرفته؛ cooldown رو پاک می‌کنیم
# تا مجبور نشه ۲ دقیقه بدونِ داشتنِ کد منتظر بمونه. سقفِ ساعتی/
# throttleِ IP دست‌نخورده می‌مونن (محدودیتِ حجم، نه این cooldown).
cache.delete(phone_cooldown_key)
raise

View File

@@ -63,24 +63,16 @@ class TrackConversionView(APIView):
خودِ کاربرِ تازه‌ارجاع‌شده (بعد از لاگین/ثبت‌نام) زده بشه، هویتش رو
از توکن احرازشده (request.user) می‌گیریم، نه از بدنه‌ی درخواست.
# ⚠️ اصلاح امنیتی: قبلاً این endpoint با AllowAny و یک
# referredUserId دلخواه از سمت کلاینت کار می‌کرد؛ یعنی هر کلاینتِ
# ناشناس می‌تونست برای هر کاربرِ واقعیِ دیگه‌ای (بدون اطلاع یا
# مشارکت خودش) به‌جای او Conversion بسازه، یا حتی برای فرار از
# قید unique روی referred_user_id، از قبل تمام userId های موجود
# رو برای یک کد افیلیتِ جعلی «اشغال» کنه و مسیر ارجاع واقعی رو
# برای همیشه مسدود کنه. با گرفتن هویت از request.user (نیازمند
# احراز هویت)، دیگه امکان جعل/انتساب ارجاع به کاربر دیگری وجود
# نداره؛ چک self-referral هم حفظ شده.
# هویت از request.user گرفته می‌شه (نه بدنه‌ی درخواست) چون یک
# referredUserIdِ کلاینت‌دار یعنی هر کلاینتِ ناشناس می‌تونست برای هر
# کاربرِ دیگه‌ای Conversion بسازه یا با اشغالِ همه‌ی userId ها (قیدِ
# unique رو دورزدن) مسیرِ ارجاعِ واقعی رو مسدود کنه.
"""
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: قبلاً این endpoint هیچ throttleای نداشت؛ referralCode
# نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe) و حدسِ درست یک
# Conversion واقعیِ متصل به کمیسیونِ affiliate می‌سازه. طبقِ همون
# الگویِ بقیه‌ی اپ‌ها، throttle_scope مجزا با GuestAwareAffiliateThrottle
# اضافه شد تا هم probe کردنِ کد رو کند کنه، هم چرخشِ حساب مهمان
# دیگه bucket تازه نده.
# referralCode نامعتبر ۴۰۴ می‌گیره (یک oracle قابل‌probe)، پس throttle
# مجزا هم probe رو کند می‌کنه هم (با GuestAwareAffiliateThrottle) جلویِ
# bucket تازه از چرخشِ حساب مهمان رو می‌گیره.
throttle_scope = 'affiliate_track'
throttle_classes = [GuestAwareAffiliateThrottle]

View File

@@ -54,15 +54,10 @@ class AskIdempotency(models.Model):
conversation = models.ForeignKey(Conversation, on_delete=models.CASCADE, related_name='+')
user_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
assistant_message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='+')
# ⚠️ اصلاح: قبلاً suggestedFollowUps هیچ‌جا ذخیره نمی‌شد و در مسیرِ
# replay (idempotent retry) دوباره با فراخوانیِ generate_tutor_answer
# بازسازی می‌شد. این فقط تا وقتی generate_tutor_answer یک stub خالی از
# اثر جانبیه بی‌ضرره؛ وقتی به یک AI provider واقعی وصل بشه، هر replay
# یک درخواستِ واقعیِ دیگه به provider می‌زنه (هزینه/تأخیرِ تکراری، و
# پیشنهادهای follow-up متفاوت در هر retry — دقیقاً برخلافِ هدفِ
# idempotency). حالا خروجیِ follow-up های همون درخواستِ اولِ موفق،
# همینجا ذخیره می‌شه تا replay بتونه بدونِ هیچ فراخوانیِ دیگری به
# generate_tutor_answer، همون مقدارِ اصلی رو برگردونه.
# ذخیره می‌شه تا replayِ idempotent بتونه بدونِ فراخوانیِ دوباره‌ی
# generate_tutor_answer همون مقدارِ اصلی رو برگردونه — وقتی provider
# واقعی جایگزینِ stub بشه، هر replay یعنی هزینه/تأخیرِ تکراری و
# follow-upهای متفاوت در هر retry، برخلافِ هدفِ idempotency.
suggested_follow_ups = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -139,11 +139,8 @@ class AskAIView(APIView):
"""POST /api/ai/ask — سوال از AI (استریم اختیاری؛ در این فاز استریم پیاده نشده)"""
permission_classes = [IsAuthenticated]
# ⚠️ افزوده شده: قبلاً این endpoint هیچ throttle مخصوصی نداشت؛ DAILY_AI_QUOTA
# فقط سقفِ روزانه است و جلوی burst کوتاه‌مدت (مثلاً چند ده درخواست در چند
# ثانیه به generate_tutor_answer) را نمی‌گیرد. throttle_scope مجزا
# (ai_ask) این burst را محدود می‌کند، بدون اینکه به کوتای روزانه/idempotency
# یا منطق AI دست بزند.
# DAILY_AI_QUOTA فقط سقفِ روزانه‌ست و جلوی burstِ کوتاه‌مدت رو نمی‌گیره؛
# این throttle_scope مجزا اون burst رو محدود می‌کنه.
throttle_scope = 'ai_ask'
throttle_classes = [GuestAwareAIThrottle]

View File

@@ -47,15 +47,10 @@ class CampaignDeliveryAdmin(admin.ModelAdmin):
readonly_fields = fields
def has_view_permission(self, request, obj=None):
# صریحاً True: نباید به fallbackِ مبتنی‌بر codename پرمیشنِ
# پیش‌فرضِ Django (has_perm('view_...') or has_perm('change_...'))
# تکیه کنیم. بدونِ این override، یک کاربرِ staff که پرمیشنِ
# view/change روی این مدل رو نداره، حتی برای دیدنِ صفحه‌ی
# جزئیاتِ read-only هم 403 می‌گیره — یعنی مدل عملاً "view-only"
# نیست بلکه کاملاً غیرقابلِ‌دسترسه. این متد مستقل از
# has_change_permission (که پایین همیشه False برمی‌گردونه)
# تضمین می‌کنه که دیدن همیشه مجازه، صرفِ‌نظر از پرمیشن‌های
# دیتابیسی.
# صریحاً True: بدونِ override، fallbackِ پیش‌فرضِ Django به کاربرِ
# staffِ بدونِ پرمیشنِ view/change حتی اجازه‌ی دیدنِ این صفحه‌ی
# read-only رو هم نمی‌ده — یعنی مدل عملاً غیرقابلِ‌دسترس می‌شه، نه
# فقط view-only.
return True
def has_add_permission(self, request):

View File

@@ -96,24 +96,12 @@ class CampaignDelivery(models.Model):
ordering = ['-created_at']
constraints = [
# جلوگیریِ race condition: اگه execute_campaign() برای همون
# campaign هم‌زمان (دو پراسس/ریکوئستِ موازی) اجرا بشه، هر دو
# می‌تونن قبل از اینکه هرکدوم رکوردی بسازن از چکِ
# already_sent رد بشن (TOCTOU) و برای یک کاربر دو بار
# send_sms/notify صدا زده بشه. این unique constraint دقیقاً
# همون چیزی رو در سطحِ دیتابیس تضمین می‌کنه که چکِ اپلیکیشن‌
# لایه فرض می‌کرد ولی گارانتی نمی‌داد: برای هر
# (campaign, user_id, channel) در آنِ واحد حداکثر یک رکوردِ
# «فعال یا موفق» (pending یا sent) می‌تونه وجود داشته باشه.
# وقتی دو تلاشِ هم‌زمان می‌خوان برای همون کاربر رکورد pending
# بسازن، دومی روی این constraint با IntegrityError شکست
# می‌خوره و در execute_campaign() به‌عنوانِ «این کاربر توسطِ
# یک اجرای دیگه claim شده» skip می‌شه — پس send_sms/notify
# دوباره صدا زده نمی‌شه.
#
# status=failed عمداً از این constraint مستثنا شده تا رفتارِ
# retryِ فعلی (ساختنِ یک رکوردِ pending جدید بعد از یک تلاشِ
# ناموفق) دست‌نخورده بمونه: یک رکوردِ failed دیگه «فعال» حساب
# نمی‌شه، پس جای رکوردِ جدید رو برای تلاشِ بعدی باز می‌ذاره.
# campaign هم‌زمان اجرا بشه، این constraint تضمین می‌کنه حداکثر
# یک رکوردِ «فعال یا موفق» (pending/sent) به‌ازای هر
# (campaign, user_id, channel) باشه؛ تلاشِ دومِ هم‌زمان با
# IntegrityError شکست می‌خوره و به‌عنوانِ claim‌شده skip می‌شه.
# status=failed عمداً مستثناست تا retry (ساختِ pendingِ جدید
# بعد از شکست) دست‌نخورده بمونه.
UniqueConstraint(
fields=['campaign', 'user_id', 'channel'],
condition=Q(status__in=['pending', 'sent']),

View File

@@ -39,12 +39,10 @@ def _retry_on_sqlite_lock(fn, *, retries=5, base_delay=0.05):
raise
time.sleep(base_delay * (attempt + 1))
# نوعِ نوتیفیکیشنِ ثبت‌شده برای اعلان‌های ناشی از کمپین. عمداً در
# notifications.models.Notification.TYPE_CHOICES اضافه نشده چون آن
# choices فقط رویدادهایی‌ان که notifications به‌عنوانِ event-listener
# بهشون گوش می‌ده (طبق داکیومنتِ همون فایل)؛ این‌جا notify() مستقیماً و
# بدونِ رویداد صدا زده می‌شه، پس تغییرِ آن مدل لازم نیست — Notification.type
# یک CharField معمولیه و مقداردهی‌اش خارج از choices هم مجاز است.
# عمداً به notifications.models.Notification.TYPE_CHOICES اضافه نشده: آن
# choices فقط رویدادهاییه که notifications به‌عنوانِ listener گوش می‌ده،
# اینجا notify() مستقیم صدا زده می‌شه؛ Notification.type یک CharField
# معمولیه، مقداردهیِ خارج از choices مجازه.
CAMPAIGN_NOTIFICATION_TYPE = 'message_campaign'

View File

@@ -227,21 +227,11 @@ REST_FRAMEWORK = {
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.ScopedRateThrottle',
],
# ⚠️ اضافه شده (امنیتی): بدونِ NUM_PROXIES، DRF (SimpleRateThrottle.get_ident)
# هدرِ X-Forwarded-For رو - اگه در درخواست وجود داشته باشه - عیناً و
# بدون‌چک به‌عنوانِ IP کلاینت قبول می‌کنه؛ یعنی هر کلاینتِ ناشناس می‌تونست
# با فرستادنِ یک X-Forwarded-For دلخواه/تصادفی در هر درخواست، برای
# هر GuestAware*Throttle (content/ai_tutor/subscription/payment/discount/
# support/family/affiliate) یک IP-bucket تازه بگیره - دقیقاً همون
# bypassِ چرخشِ حساب مهمان که این throttle-classها قرار بود ببندن، فقط
# از طریقِ هدر به‌جای deviceId جدید.
# با NUM_PROXIES=1 (پیش‌فرض؛ برای دیپلویِ پشتِ یک لایه‌ی پراکسی/LB که
# خودش X-Forwarded-For رو ست/rewrite می‌کنه، نه پاس‌دادنِ مقدارِ
# کلاینت)، DRF از انتهای زنجیره‌ی XFF دقیقاً همون تعداد hop رو
# می‌خونه. اگه دیپلویِ پروداکشن تعداد لایه‌ی پراکسیِ دیگه‌ای داره
# (مثلاً Cloudflare + Nginx = ۲ لایه)، این مقدار باید با env
# NUM_PROXIES مطابقِ همون تعداد ست بشه، وگرنه یا IP واقعی اشتباه
# خونده می‌شه یا (اگه صفر باشه) دوباره همون مشکلِ بالا برمی‌گرده.
# بدونِ NUM_PROXIES، DRF هدرِ X-Forwarded-For رو بدون‌چک به‌عنوانِ IP
# کلاینت قبول می‌کنه؛ یعنی هر کلاینت می‌تونست با XFF دلخواه، bucket
# تازه از GuestAware*Throttleها بگیره (bypassِ چرخشِ حساب مهمان). این
# مقدار باید دقیقاً با تعدادِ لایه‌ی پراکسی/LB جلوی اپ یکی باشه (مثلاً
# Cloudflare+Nginx=۲)، وگرنه IP واقعی اشتباه خونده می‌شه.
'NUM_PROXIES': int(os.environ.get('NUM_PROXIES', '1')),
'DEFAULT_THROTTLE_RATES': {
'otp_request': '5/hour',
@@ -259,9 +249,6 @@ REST_FRAMEWORK = {
'discount_redeem': '10/hour',
'family_invite_accept': '10/hour',
'family_invite_create': '5/hour',
# ⚠️ اضافه شده: چهار endpointی که در ممیزیِ throttleِ امروز بدونِ
# throttle_scope پیدا شدن (نگاه کنید به توضیحاتِ throttle_scope
# مربوطه در accounts/views.py، affiliate/views.py، payment/views.py).
'user_profile_write': '60/hour',
'affiliate_track': '10/hour',
'payment_webhook': '60/hour',
@@ -292,13 +279,9 @@ SPECTACULAR_SETTINGS = {
ACCESS_TOKEN_TTL_SECONDS = 3600
REFRESH_TOKEN_TTL_DAYS = 30
# سقفِ تعدادِ گیرنده برای اکشنِ ادمینِ send_sms_to_selected (هم برای
# هدف‌گیریِ 'selected' و هم 'inactive'). این اکشن هنوز کاملاً بلادرنگ و
# synchronous توی همون request اجرا می‌شه (بدون صفِ پس‌زمینه)، پس این سقف
# صرفاً جلویِ یک ارسالِ ناخواسته‌ی خیلی بزرگ (که می‌تونه request رو تایم‌اوت
# کنه یا پروایدرِ پیامک رو overload کنه) رو می‌گیره؛ اگر تعدادِ گیرنده‌ها
# بیشتر از این باشه، اکشن هیچ پیامکی ارسال نمی‌کنه و از ادمین می‌خواد
# بازه/انتخاب رو محدودتر کنه.
# سقفِ گیرنده‌ی اکشنِ ادمینِ send_sms_to_selected؛ این اکشن synchronous توی
# همون requestه (بدون صفِ پس‌زمینه)، پس این سقف جلویِ تایم‌اوت/overloadِ
# provider رو می‌گیره — بیشتر از سقف یعنی هیچ پیامکی ارسال نمی‌شه.
ADMIN_SMS_MAX_RECIPIENTS = int(os.environ.get('ADMIN_SMS_MAX_RECIPIENTS', 200))
# محدودیت تلاش ورود بر اساس شماره تلفن، مستقل از throttle IP-محورِ otp_login.
@@ -311,15 +294,9 @@ OTP_LOGIN_PHONE_WINDOW_SECONDS = 3600
OTP_REQUEST_PHONE_MAX_ATTEMPTS = 5
OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
# حداقل فاصله‌ی زمانی بین دو درخواستِ صدورِ OTP برای همون شماره. این مستقل
# از سقفِ ساعتیِ بالاست: سقفِ ساعتی جلویِ حجمِ کلی رو می‌گیره اما جلویِ
# burst رو نمی‌گیره (مثلاً هر ۵ تلاشِ مجاز در عرضِ کمتر از یک ثانیه). این
# cooldown دقیقاً همون burst رو هدف می‌گیره؛ نه جایگزینِ سقفِ ساعتیه، نه
# باهاش redundant — هر کدوم یک بُعدِ متفاوتِ سوءاستفاده رو می‌بندن.
# ⚠️ تصمیمِ محصول/امنیت: کاربر واقعاً باید هر ۲ دقیقه بتونه دوباره OTP
# بگیره؛ enforcement سمتِ سرور هم باید دقیقاً همین مقدار باشه — countdown
# سمتِ فرانت صرفاً UX هست و نباید مرجعِ امنیتی باشه، پس این مقدار همیشه
# باید با چیزی که فرانت به کاربر نشون می‌ده یکی باشه.
# حداقل فاصله بین دو درخواستِ OTP برای همون شماره؛ مستقل از سقفِ ساعتیِ بالا
# (که جلویِ burst رو نمی‌گیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
@@ -368,14 +345,10 @@ PAYMENT_WEBHOOK_SECRET = os.environ.get(
# -----------------------------------------------------------------------
# Bazaar In-App Billing (پرداخت درون‌برنامه‌ای کافه‌بازار — REST API v2)
# -----------------------------------------------------------------------
# طبق داکیومنت رسمی (developers.cafebazaar.ir/fa/guidelines/in-app-billing)،
# صحت‌سنجی پرداخت بازار یک OAuth2 سرور-به-سرور‌ه: یک بار با CLIENT_ID/
# CLIENT_SECRET و کد بازگشتی از پنل devapi، REFRESH_TOKEN گرفته می‌شه (یک‌بار،
# دستی)؛ از اون به بعد بک‌اند با همین REFRESH_TOKEN، ACCESS_TOKEN تازه
# می‌گیره و با اون به endpoint وضعیت خرید/اشتراک درخواست می‌زنه.
# ⚠️ کافه‌بازار اواخر ۱۴۰۳ روش صحت‌سنجی رو تغییر داد؛ قبل از اتصال واقعی،
# حتماً آخرین مسیر endpointها رو از همون صفحه‌ی رسمی/پنل devapi خودتون
# تایید کنید (BAZAAR_API_BASE پایینی رو بر همون اساس ست کنید).
# صحت‌سنجی OAuth2 سرور-به-سروره: REFRESH_TOKEN یک‌بار دستی از پنل devapi
# گرفته می‌شه، بعد بک‌اند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه می‌گیره.
# قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار
# این روش رو قبلاً تغییر داده.
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app')
BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
@@ -388,18 +361,11 @@ BAZAAR_API_BASE = os.environ.get('BAZAAR_API_BASE', 'https://pardakht.cafebazaar
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# -----------------------------------------------------------------------
# Logging (آماده‌سازیِ لاگ‌های موجود برای جمع‌آوریِ متمرکز — Loki/ELK-style)
# Logging — بدونِ یک LOGGING صریح، loggerهای موجودِ اپ (config.middleware،
# accounts.services، ...) فقط به rootِ پیش‌فرض می‌رسیدن که برای INFO عملاً
# خروجی‌ای نداره. خروجی: یک JSON در هر خط روی stdout، سازگار با
# Promtail/Filebeat برای جمع‌آوریِ بعدی توسطِ Loki/ELK.
# -----------------------------------------------------------------------
# ⚠️ افزوده شده: پروژه از قبل چندین logger (config.middleware،
# accounts.services، ai_tutor.views، ...) با logging.getLogger(__name__)
# داشت، اما بدونِ یک LOGGING صریح در settings.py، اینا فقط به rootِ
# پیش‌فرضِ کتابخانه‌ی logging می‌رسیدن (که برای سطحِ INFO عملاً هیچ خروجی‌ای
# تولید نمی‌کنه). این تنظیم، بدونِ اضافه/حذفِ هیچ loggerِ اپلیکیشنی یا
# eventِ لاگی، همون پیام‌های موجود رو روی stdout و به‌صورتِ یک JSON در هر
# خط (فرمتِ سازگار با agentِ جمع‌آوریِ بیرونی مثلِ Promtail/Filebeat که
# بعداً می‌تونه به Loki/ELK بفرسته) چاپ می‌کنه. هیچ appِ جدید، ذخیره‌سازیِ
# دیتابیسی، API/UIِ لاگ، یا وابستگیِ Elasticsearch/Loki/Grafana به خودِ
# کدِ Django اضافه نشده.
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
@@ -420,16 +386,9 @@ LOGGING = {
},
}
# ⚠️ افزوده شده (یکپارچه‌سازیِ Docker + Promtail/Loki/Grafana): وقتی
# env var LOG_DIR ست شده باشه (فقط داخلِ docker-compose.yml، روی یک
# ولومِ مشترک با کانتینرِ promtail)، همون خطوطِ JSON که از قبل روی
# stdout چاپ می‌شدن، *علاوه* بر stdout در یک فایل هم نوشته می‌شن؛
# Promtail همون فایل رو tail می‌کنه و به Loki می‌فرسته.
# این فقط یک هندلرِ خروجیِ اضافه با همون formatter (json) هست — نه
# پیام‌های لاگ، نه محافظت‌های حساسیتیِ موجود (بدونِ OTP/token/رمزعبور و
# غیره در پیام‌ها)، و نه هیچ loggerِ اپلیکیشنی تغییر نمی‌کنه.
# وقتی LOG_DIR ست نباشه (یعنی اجرای عادیِ خارج از Docker، طبق README)،
# رفتار دقیقاً همون چیزیه که از قبل بود: فقط stdout.
# وقتی LOG_DIR ست باشه (فقط داخلِ docker-compose.yml، روی ولومِ مشترک با
# promtail)، همون خطوطِ JSON علاوه بر stdout در یک فایل هم نوشته می‌شن تا
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
LOG_DIR = os.environ.get('LOG_DIR', '')
if LOG_DIR:

View File

@@ -52,15 +52,9 @@ class SecretKeyFailClosedTests(SimpleTestCase):
)
def test_production_configured_secret_key_used(self):
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): این تست فقط رفتارِ
# SECRET_KEY رو می‌سنجه، اما چون CACHE_URL رو ست نمی‌کرد، وقتی
# فیکسِ جداگانه‌ی CACHE_URL fail-closed (پایین‌ترِ همین فایل، نگاه
# کنید به CacheUrlFailClosedTests) اضافه شد، همین import/reload با
# DEBUG=False و بدونِ CACHE_URL به‌جای رسیدن به assertion، با
# ImproperlyConfigured مربوط به CACHE_URL (نه SECRET_KEY) fail
# می‌شد — یک false negative روی چیزی که این تست اصلاً قرار نیست
# بسنجه. یک CACHE_URL معتبر هم ست می‌کنیم تا این تست فقط دقیقاً
# همون چیزی که اسمش می‌گه (SECRET_KEY) رو بسنجه.
# CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون
# ImproperlyConfiguredِ CACHE_URL رخ می‌ده، نه رسیدن به assertionِ
# SECRET_KEY که این تست واقعاً می‌سنجه.
settings_module = self._reload_settings_with_env(
env_overrides={
'DJANGO_DEBUG': '',
@@ -196,24 +190,11 @@ class APIRequestLoggingMiddlewareTests(TestCase):
self.assertEqual(records, [])
def test_unhandled_exception_from_view_is_logged_as_error_and_reraised(self):
# 🔒 اثبات فیکسِ این دور: اگه self.get_response(request) خودش یک
# exceptionِ دست‌نخورده raise کنه، میان‌افزار باید همچنان یک خطِ
# لاگِ سطحِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ جزئیاتِ
# حساس) ثبت کنه و بعد دقیقاً همون exceptionِ اصلی رو دوباره raise
# کنه — بدونِ اینکه هیچ رفتارِ Django/DRF یا exceptionِ دیگه‌ای رو
# تغییر بده. مستقیماً خودِ میان‌افزار (نه کلِ چرخه‌ی
# exception-handlingِ Django) تست می‌شه، چون همون لایه‌ای‌ست که این
# فیکس روش اعمال شده.
#
# ⚠️ اصلاح (باگِ خودِ تست، نه اپلیکیشن): قبلاً این تست فقط
# record.getMessage() رو چک می‌کرد. getMessage() خودش داخلی
# msg %% args رو انجام می‌ده، پس اگه شمارِ آرگومان‌ها با
# placeholderهای رشته جور نباشه، همون‌جا با یک exception (نه یک
# پیامِ نادرست) fail می‌شد و اصلاً به assertion نمی‌رسید. برای اینکه
# این تست واقعاً *صحتِ formatting* رو بسنجه (نه فقط اینکه یک‌جوری
# fail نشه)، حالا با logging.Formatter().format(record) دقیقاً
# همون مسیری که یک handlerِ واقعی (کنسول/فایل) طی می‌کنه رو صدا
# می‌زنیم و روی متنِ نهاییِ فرمت‌شده assert می‌کنیم.
# میان‌افزار باید یک خطِ ERROR (متد/مسیر/status=500/مدت/user_id، بدونِ
# جزئیاتِ حساس) ثبت کنه و بعد همون exception رو دوباره raise کنه.
# با logging.Formatter().format(record) دقیقاً همون مسیری که یک
# handlerِ واقعی طی می‌کنه رو صدا می‌زنیم (نه فقط getMessage()، که
# یه mismatchِ %%-args رو با exceptionِ خودش می‌پوشونه، نه fail واقعی).
request = RequestFactory().get('/api/some/failing/path')
def failing_get_response(inner_request):

View File

@@ -12,13 +12,8 @@ from .models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, U
class LessonCompleteImpossibleCountTests(TestCase):
"""
🔒 تستِ رگرسیونِ فیکسِ امنیتی: LessonCompleteView قبلاً correctCount رو
بدونِ مقایسه با تعدادِ واقعیِ سوال‌های درس می‌پذیرفت، پس یک کلاینتِ
مخرب می‌تونست با correctCount بزرگ‌تر از تعدادِ واقعیِ سوال‌ها،
xp/coins متورم بگیره. این تست هم ردِ درخواست‌های غیرممکن رو، هم
رفتارِ صحیحِ تکمیل با مقادیرِ معتبر رو تضمین می‌کنه.
"""
"""رگرسیون: correctCount باید با تعدادِ واقعیِ سوال‌های درس مقایسه بشه، وگرنه
کلاینتِ مخرب می‌تونه xp/coins رو متورم کنه."""
def setUp(self):
self.client = APIClient()
@@ -67,14 +62,9 @@ class LessonCompleteImpossibleCountTests(TestCase):
class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
"""
🔒 تستِ رگرسیونِ فیکسِ دوم: قبلاً ratio از totalQuestions ارسالی‌ی
خودِ کلاینت محاسبه می‌شد، نه از تعدادِ واقعیِ سوال‌های درس. یعنی حتی
بعد از سقف‌زدنِ correctCount، کلاینت می‌تونست با totalQuestions=1
(به‌جای ۳ سوالِ واقعی)، ratio رو مصنوعاً ۱۰۰٪ کنه و ۳ ستاره/coins
کامل بگیره درحالی‌که فقط ۱ از ۳ سوال رو درست جواب داده. حالا ratio
همیشه بر اساسِ actual_question_count محاسبه می‌شه.
"""
"""رگرسیون: ratio باید از actual_question_count محاسبه بشه نه از totalQuestیِ
ارسالیِ کلاینت، وگرنه حتی بعدِ سقف‌زدنِ correctCount کلاینت می‌تونه با
totalQuestions دروغین ratio رو مصنوعاً ۱۰۰٪ کنه."""
def setUp(self):
self.client = APIClient()
@@ -114,13 +104,8 @@ class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
class QuestionAnswerThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی QuestionAnswerView
(throttle_scope='question_answer'): Idempotency-Key فقط جلوی retry با
همون کلید رو می‌گیره، نه حجمِ بالای درخواست‌های *متفاوت* که هرکدوم
یک UserAnswer جدید می‌سازن و منطقِ هارت/NodeProgress رو اجرا می‌کنن.
ScopedRateThrottle با pk کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP.
"""
"""محدودیتِ per-user روی QuestionAnswerView (throttle_scope='question_answer')؛
Idempotency-Key فقط retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت."""
def setUp(self):
from django.core.cache import cache
@@ -242,14 +227,8 @@ class QuestionAnswerThrottleTests(TestCase):
class LessonCompleteThrottleTests(TestCase):
"""
🔒 اثبات محدودیتِ جدیدِ per-user روی LessonCompleteView
(throttle_scope='lesson_complete'): Idempotency-Key فقط جلوی retry با
همون کلید رو می‌گیره، نه حجمِ بالای درخواست‌های *متفاوت* که هرکدوم
یک UserLessonCompletion جدید می‌سازن و سیگنالِ post_save
(NodeProgress) + به‌روزرسانیِ UserStats رو اجرا می‌کنن.
ScopedRateThrottle با pk کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP.
"""
"""محدودیتِ per-user روی LessonCompleteView (throttle_scope='lesson_complete')؛
Idempotency-Key فقط retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت."""
def setUp(self):
from django.core.cache import cache
@@ -361,17 +340,9 @@ class LessonCompleteThrottleTests(TestCase):
class QuestionAnswerGuestThrottleBypassTests(TestCase):
"""
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='question_answer' برای
هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم
می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle
تازه بگیره و منطقِ هارت/NodeProgress رو بی‌محدودیت اجرا کنه. حالا
GuestAwareContentThrottle برای کاربرهای مهمان کلید رو بر اساسِ IP
(همون get_ident که guest_session هم استفاده می‌کنه) می‌سازه، پس چرخشِ
حساب مهمان دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرِ
غیرمهمان (per-user، مثلِ قبل) دست‌نخورده مونده.
"""
"""اثباتِ فیکسِ throttle: چرخشِ حساب مهمان (هر deviceId یه pk تازه) نباید
bucket تازه‌ی question_answer بده — GuestAwareContentThrottle برای
مهمان‌ها کلید رو بر اساسِ IP می‌سازه، نه user.pk."""
def setUp(self):
from django.core.cache import cache
@@ -446,17 +417,9 @@ class QuestionAnswerGuestThrottleBypassTests(TestCase):
class LessonCompleteGuestThrottleBypassTests(TestCase):
"""
🔒 اثبات فیکسِ امنیتی: قبلاً کلیدِ throttle_scope='lesson_complete' برای
هر کاربرِ احرازهویت‌شده (مهمان یا نه) user.pk بود. چون GuestSessionView
با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم
می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، هر بار یک bucket throttle
تازه بگیره و UserLessonCompletion/xp/coins/progress رو بی‌محدودیت
تولید کنه. حالا GuestAwareContentThrottle برای کاربرهای مهمان کلید رو
بر اساسِ IP می‌سازه، پس چرخشِ حساب مهمان دیگه bucket تازه نمی‌ده.
رفتارِ throttle برای کاربرِ غیرمهمان (per-user، مثلِ قبل) دست‌نخورده
مونده.
"""
"""اثباتِ فیکسِ throttle: چرخشِ حساب مهمان نباید bucket تازه‌ی
lesson_complete بده — GuestAwareContentThrottle برای مهمان‌ها کلید رو
بر اساسِ IP می‌سازه، نه user.pk."""
def setUp(self):
from django.core.cache import cache

View File

@@ -26,24 +26,11 @@ from .serializers import (
class GuestAwareContentThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده
از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، برای هر
حساب یک bucket تازه از throttle_scope 'question_answer'/'lesson_complete'
بگیره و محدودیتِ واقعی رو دور بزنه — درحالی‌که هر دو endpoint اثرِ
جانبیِ ماندگار دارن (UserAnswer + کسرِ هارت، یا UserLessonCompletion +
xp/coins/progress).
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی
حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب
دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ
answer/heart/progress/completion/idempotency دست‌نخورده‌ست.
مشترک با GuestAwareAIThrottle/GuestAwareFamilyThrottle/GuestAwareDiscountThrottle
و مشابه‌هاش: چون GuestSessionView با هر deviceId جدید یک pk تازه می‌سازه،
throttleِ استانداردِ بر اساسِ user.pk با چرخشِ حسابِ مهمان دور زده می‌شه —
درحالی‌که answer/complete اثرِ جانبیِ ماندگار (هارت، xp/coins/progress) دارن.
برای همین فقط کاربرهای مهمان با IP کلید می‌خورن؛ کاربرهای غیرمهمان مثلِ قبل بر اساسِ pk.
"""
def get_cache_key(self, request, view):
@@ -165,18 +152,9 @@ class QuestionAnswerView(APIView):
# userId همیشه از request.user.id گرفته می‌شود، نه ورودی کلاینت.
permission_classes = [IsAuthenticated]
# Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ بالای
# درخواست‌های *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
# UserAnswer جدید می‌سازن و منطقِ هارت/NodeProgress رو اجرا می‌کنن —
# حتی وقتی هارتِ کاربر صفر شده. ScopedRateThrottle وقتی request.user
# احرازهویت‌شده باشه با pk کاربر کلید می‌خوره (نه فقط IP)، پس تغییرِ
# IP این محدودیت رو دور نمی‌زنه.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwareContentThrottleِ مشترک استفاده می‌شه که برای کاربرهای
# مهمان کلید رو بر اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ
# per-user قبلی رو دست‌نخورده نگه می‌داره.
# Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ درخواست‌های
# *متفاوت* که هرکدوم UserAnswer جدید می‌سازن و هارت/NodeProgress رو تغییر می‌دن؛
# برای همون GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا).
throttle_scope = 'question_answer'
throttle_classes = [GuestAwareContentThrottle]
@@ -274,20 +252,12 @@ class QuestionAnswerView(APIView):
class LessonCompleteView(APIView):
"""POST /api/lessons/{id}/complete — اتمام درس + آمار جدید"""
# ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده می‌شد که به
# کلاینت اجازه می‌داد به‌جای هر کاربر دیگری xp/coins ثبت کنه. حالا auth
# اجباریه و همیشه از request.user.id استفاده می‌شه.
# userId همیشه از request.user.id گرفته می‌شود (نه ورودیِ کلاینت)، وگرنه کلاینت
# می‌تونست به‌جای هر کاربرِ دیگری xp/coins ثبت کنه.
permission_classes = [IsAuthenticated]
# Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره، نه حجمِ بالای
# درخواست‌های *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
# UserLessonCompletion جدید می‌سازن و سیگنالِ post_save (NodeProgress) +
# به‌روزرسانیِ UserStats رو اجرا می‌کنن. ScopedRateThrottle با pk
# کاربرِ احرازهویت‌شده کلید می‌خوره، نه IP.
# ⚠️ اصلاح شده: همون مشکلِ چرخشِ حسابِ مهمان (نگاه کنید به
# GuestAwareContentThrottle بالا) اینجا هم بود؛ حالا از همون کلاسِ
# مشترک استفاده می‌شه. نرخ هم از ۶۰/min به ۱۰/hour تغییر کرد چون این
# endpoint رکوردِ completion ماندگار می‌سازه و xp/coins/progress رو
# واقعاً تغییر می‌ده (برخلافِ question_answer که فرکانسِ طبیعی‌اش بالاتره).
# Idempotency-Key فقط جلوی retry با همون کلید رو می‌گیره؛ برای همینه که
# GuestAwareContentThrottle لازمه (نگاه کنید به تعریفش بالا). نرخ ۱۰/hour
# (نه ۶۰/min مثلِ question_answer) چون این endpoint رکوردِ ماندگار می‌سازه.
throttle_scope = 'lesson_complete'
throttle_classes = [GuestAwareContentThrottle]
@@ -352,21 +322,12 @@ class LessonCompleteView(APIView):
}
return Response(LessonCompleteResponseSerializer(data).data)
# ⚛️ atomic: UserLessonCompletion.create پایین‌تر یک سیگنال post_save
# رو تریگر می‌کنه (content/receivers.py → NodeProgress + احتمالاً
# StatusChangeLog)؛ به‌علاوه‌ی get_or_create/save روی UserStats.
# بدون atomic، اگه save روی stats fail کنه، تکمیلِ درس ثبت شده ولی
# xp/coins/progress_percent به‌روز نشده — یعنی چون تکمیل هر درس
# یک‌بارمصرفه، کاربر دیگه هیچ‌وقت اون xp رو نمی‌گیره.
#
# ⚛️🔒 race condition: چکِ «is_first_completion» قبلاً قبل از atomic
# و بدون قفل انجام می‌شد — دو درخواستِ همزمان (دابل‌کلیک/رتریِ
# کلاینت) برای تکمیلِ همون درس می‌تونستن هر دو ببینن که هنوز کامل
# نشده و هر دو xp/coins کامل بگیرن (دوبرابر شدنِ جایزه). حالا ردیفِ
# UserStats کاربر قفل می‌شه (select_for_update) و چکِ
# is_first_completion داخلِ همون قفل، بعد از لاک‌شدن، دوباره انجام
# می‌شه — یعنی درخواستِ دومِ همزمان، حالا که اولی commit شده، به‌درستی
# می‌بینه که دیگه first completion نیست.
# atomic لازمه چون create پایین‌تر سیگنالِ post_save رو تریگر می‌کنه
# (NodeProgress) و UserStats هم آپدیت می‌شه؛ بدون اون، fail شدنِ save
# روی stats یعنی تکمیل ثبت شده ولی xp/coins یک‌بارمصرف از دست رفته.
# ردیفِ UserStats قفل می‌شه (select_for_update) و چکِ is_first_completion
# داخلِ همون قفل انجام می‌شه تا دو درخواستِ همزمان (دابل‌کلیک) هردو
# جایزه‌ی کاملِ اول رو نگیرن.
try:
with transaction.atomic():
UserStats.objects.get_or_create(user_id=user_id)

View File

@@ -27,18 +27,11 @@ def apply_offer(*, user_id: int, offer: Offer, source: str) -> UserOffer | None:
if not offer.is_active:
return None
# ⚠️ اصلاح: قبلاً هر IntegrityError داخل کل بلوکِ atomic (که شاملِ
# grant_free_subscription هم می‌شد) به‌عنوان «duplicate UserOffer»
# تفسیر می‌شد و None برمی‌گردوند — یعنی اگه یک IntegrityError واقعیِ
# دیگه (مثلاً داخل grant_free_subscription) اتفاق می‌افتاد، خطا قورت
# داده می‌شد و caller فکر می‌کرد «کاربر قبلاً این آفر رو گرفته»، در
# حالی که اصلاً چنین چیزی نبود. حالا فقط IntegrityError ناشی از
# UniqueConstraintِ خودِ UserOffer.create (duplicate واقعی) گرفته
# می‌شه — با یک savepoint جداگانه (atomic تودرتو) که فقط همون create
# رو rollback می‌کنه، نه کل تراکنش رو — و خطاهای دیگه (مثلاً از داخل
# grant_free_subscription) عادی propagate می‌شن. atomic بودنِ کلِ
# عملیات (UserOffer + Subscription + expires_at) هم دست‌نخورده باقی
# مونده، دقیقاً طبق همون دلیلی که در docstring بالا توضیح داده شده.
# فقط IntegrityErrorِ ناشیِ از UniqueConstraintِ خودِ UserOffer.create
# (duplicate واقعی) اینجا گرفته می‌شه، با یک savepoint مجزا که فقط
# همون create رو rollback می‌کنه — وگرنه یک IntegrityErrorِ دیگه (مثلاً
# داخلِ grant_free_subscription) قورت داده می‌شد و caller اشتباهاً فکر
# می‌کرد کاربر قبلاً این آفر رو گرفته.
with transaction.atomic():
try:
with transaction.atomic(): # savepoint مجزا فقط برای create

View File

@@ -102,12 +102,7 @@ class RedeemOfferCodeThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
def test_throttle_is_scoped_per_user_not_shared_globally(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه یک حسابِ مهمانِ
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حساب‌های
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
# RedeemOfferCodeGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که باید
# دقیقاً مثلِ قبل دست‌نخورده بمونه.
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
other_user = User.objects.create(is_guest=False, name='', phone='+989120000701')
other_token = issue_access_token(other_user.id)
@@ -299,12 +294,7 @@ class ValidateDiscountThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
def test_throttle_is_scoped_per_user_not_shared_globally(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه یک حسابِ مهمانِ
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حساب‌های
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
# ValidateDiscountGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که باید
# دقیقاً مثلِ قبل دست‌نخورده بمونه.
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
other_user = User.objects.create(is_guest=False, name='', phone='+989120000731')
other_token = issue_access_token(other_user.id)
@@ -387,12 +377,7 @@ class RedeemDiscountThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
def test_throttle_is_scoped_per_user_not_shared_globally(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه یک حسابِ مهمانِ
# دومِ متفاوت)، چون طبقِ فیکسِ GuestAwareDiscountThrottle حساب‌های
# مهمان دیگه bucket جداگانه ندارن (نگاه کنید به
# RedeemDiscountGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که باید
# دقیقاً مثلِ قبل دست‌نخورده بمونه.
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
other_user = User.objects.create(is_guest=False, name='', phone='+989120000301')
other_token = issue_access_token(other_user.id)
other_order = Order.objects.create(

View File

@@ -69,21 +69,11 @@ class ValidateDiscountView(APIView):
# صورت کلاینت بدون احراز هویت می‌تواند کدهای تخفیف را probe کند.
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: این endpoint خودِ type/value کد رو هم برمی‌گردونه (نه
# فقط valid/invalid)، پس بدونِ throttle یک کاربرِ احرازهویت‌شده می‌تونست
# بی‌نهایت کدهای تخفیف رو probe کنه و جزئیاتشون رو یاد بگیره. طبقِ
# همون الگویِ ScopedRateThrottle که برای RedeemOfferCodeView
# (offer_redeem) اضافه شد: چون کاربر احرازهویت‌شده، کلید بر اساسِ pk
# کاربره (نه IP). نرخ کمی بالاتر از offer_redeem انتخاب شده چون این
# یک چکِ read-only پیش از خریده (ممکنه کاربر چند کد رو قبل از تصمیمِ
# نهایی امتحان کنه)، نه خودِ مصرف کد.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از همون
# GuestAwareDiscountThrottleِ مشترک (که RedeemDiscountView/
# RedeemOfferCodeView هم استفاده می‌کنن) استفاده می‌شه که برای
# کاربرهای مهمان کلید رو بر اساسِ IP می‌سازه و برای کاربرهای
# غیرمهمان رفتارِ per-user قبلی رو دست‌نخورده نگه می‌داره.
# این endpoint type/value کد رو برمی‌گردونه، پس بدونِ throttle یک
# کاربر می‌تونست بی‌نهایت کد رو probe کنه؛ نرخ کمی بالاتر از
# offer_redeem چون این یک چکِ read-only پیش از خریده. GuestAwareDiscountThrottle
# مشترک (هم اینجا هم RedeemDiscountView/RedeemOfferCodeView) برای
# مهمان‌ها کلید رو بر اساسِ IP می‌سازه تا چرخشِ حساب bucket تازه نده.
throttle_scope = 'discount_validate'
throttle_classes = [GuestAwareDiscountThrottle]
@@ -117,23 +107,11 @@ class RedeemDiscountView(APIView):
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: این endpoint هم دقیقاً همون oracle ای که باعث شد
# ValidateDiscountView و RedeemOfferCodeView throttle بگیرن رو داره —
# کدِ ناموجود ۴۰۴ می‌گیره، کدِ موجود-ولی-نامعتبر ۴۰۰ می‌گیره، پس بدونِ
# throttle یک کاربرِ احرازهویت‌شده می‌تونست کدهای تخفیف رو روی سفارشِ
# خودش probe/brute-force کنه. اینجا ریسک از اون دوتا هم بیشتره، چون
# حدسِ درست صرفاً اطلاعات لو نمی‌ده؛ یک کدِ واقعی رو مصرف می‌کنه
# (سهمِ max_uses مشترک بین همه‌ی کاربرها رو می‌خوره) و باعثِ افتِ
# واقعیِ مبلغِ سفارش می‌شه؛ ضمناً هر تلاش دو تا select_for_update و یک
# تراکنشِ دیتابیسی اجرا می‌کنه که گرون‌تر از GET سبکِ validate است.
# طبقِ همون الگو، ScopedRateThrottle با کلیدِ per-user (نه IP) اضافه شد.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwareDiscountThrottle استفاده می‌شه که برای کاربرهای مهمان
# کلید رو بر اساسِ IP می‌سازه (دقیقاً همون الگویِ GuestAwareAIThrottle
# و GuestAwareFamilyThrottle) و برای کاربرهای غیرمهمان رفتارِ per-user
# قبلی رو دست‌نخورده نگه می‌داره.
# همون oracleِ probe مثلِ ValidateDiscountView، ولی ریسکش بیشتره: حدسِ
# درست یه کدِ واقعی رو مصرف می‌کنه (سهمِ max_uses مشترک) و مبلغِ سفارش
# رو واقعاً کم می‌کنه، ضمن اینکه هر تلاش دو select_for_update و یه
# تراکنشه. GuestAwareDiscountThrottle برای مهمان‌ها کلید رو بر اساسِ IP
# می‌سازه تا چرخشِ حساب bucket تازه نده.
throttle_scope = 'discount_redeem'
throttle_classes = [GuestAwareDiscountThrottle]
@@ -200,24 +178,11 @@ class RedeemOfferCodeView(APIView):
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: کدهای نامعتبر فقط یک ValidationError ساده (۴۰۰) برمی‌گردونن
# و هیچ throttleای اینجا نبود؛ یعنی یک کاربرِ احرازهویت‌شده می‌تونست
# بدونِ هیچ محدودیتی کدهای آفر رو حدس بزنه (offer-code probing). طبقِ
# همون الگویِ ScopedRateThrottle که در بقیه‌ی اکشن‌هایِ کم‌تکرار/حساسِ
# پروژه استفاده شده (مثلِ purchase در subscription، support_create در
# support)، اینجا هم یک throttle_scope مجزا اضافه شد.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود — به‌خصوص اینجا
# حساس‌تره چون Offer.code سراسری/یکتاست و redeem_offer_code() فقط
# جلوی تکرارِ *همون* کاربر رو می‌گیره، نه کاربرهای مهمانِ جدید (یک
# حسابِ مهمانِ چرخشی می‌تونست همون آفرِ سراسری رو دوباره از
# apply_offer() بگیره). حالا از همون GuestAwareDiscountThrottle که
# برای RedeemDiscountView اضافه شد استفاده می‌شه (دقیقاً همون الگویِ
# GuestAwareAIThrottle، GuestAwareFamilyThrottle و
# GuestAwarePurchaseThrottle) که برای کاربرهای مهمان کلید رو بر
# اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ per-user قبلی رو
# دست‌نخورده نگه می‌داره.
# بدونِ throttle، کدهای آفر قابلِ probe بودن؛ اینجا حساس‌تره چون
# Offer.code سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ همون
# کاربر رو می‌گیره — یک حسابِ مهمانِ چرخشی می‌تونست همون آفرِ سراسری رو
# دوباره بگیره. GuestAwareDiscountThrottle برای مهمان‌ها کلید رو بر
# اساسِ IP می‌سازه تا چرخشِ حساب bucket تازه نده.
throttle_scope = 'offer_redeem'
throttle_classes = [GuestAwareDiscountThrottle]

View File

@@ -58,11 +58,7 @@ class FamilyLinkAcceptThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
def test_throttle_is_scoped_per_user_not_shared_globally(self):
# این تست مخصوصِ کاربرهای *غیرمهمان* (ثبت‌نام‌شده) است — طبقِ فیکسِ
# GuestAwareFamilyThrottle، حساب‌های مهمان دیگه bucket جداگانه
# ندارن (نگاه کنید به FamilyLinkAcceptGuestThrottleBypassTests پایین‌تر)،
# اما رفتارِ per-user برای کاربرهایِ ثبت‌نام‌شده باید دقیقاً مثلِ
# قبل دست‌نخورده بمونه.
# کاربرِ غیرمهمان (نه یک حسابِ مهمانِ دوم، که دیگه bucket جدا نداره).
other_user = User.objects.create(
is_guest=False, name='', phone='+989120000099',
)
@@ -241,12 +237,8 @@ class FamilyLinkInviteThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.parent که
# مهمان است)، چون طبقِ فیکسِ GuestAwareFamilyThrottle حالا کلیدِ
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# FamilyLinkInviteGuestThrottleBypassTests پایین‌تر) — این تست
# فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین
# می‌کنه.
# کاربرِ غیرمهمان (نه self.parent که مهمانه)، چون کلیدِ throttleِ
# مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
registered_parent = User.objects.create(is_guest=False, name='', phone='+989120001300')
registered_token = issue_access_token(registered_parent.id)

View File

@@ -67,17 +67,10 @@ class FamilyLinkInviteView(APIView):
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttle مخصوصی نداشت، درحالی‌که
# یک FamilyLink ماندگار می‌سازه و یک کدِ دعوتِ ۶رقمیِ تازه تولید می‌کنه.
# بدونِ throttle، یک کاربرِ احرازهویت‌شده می‌تونست با فرستادنِ
# پی‌درپیِ شماره‌های مختلف، تعدادِ زیادی FamilyLink/کدِ دعوت بسازه.
# طبقِ همون الگویِ GuestAwareFamilyThrottleِ مشترک (که
# FamilyLinkAcceptView هم استفاده می‌کنه): برای کاربرهای مهمان کلید
# بر اساسِ IP و برای کاربرهای غیرمهمان بر اساسِ user.pk است، پس
# چرخشِ حسابِ مهمان نمی‌تونه bucket تازه بگیره. یک throttle_scope
# مجزا ('family_invite_create'، نه 'family_invite_accept') استفاده
# شده چون تولید و مصرفِ کدِ دعوت باید مستقل از هم قابل‌تنظیم بمونن؛
# منطقِ generation/authorization دست‌نخورده‌ست.
# بدونِ throttle، یک کاربر می‌تونست با فرستادنِ پی‌درپیِ شماره‌های مختلف
# تعدادِ زیادی FamilyLink بسازه. GuestAwareFamilyThrottle برای مهمان‌ها
# کلید رو بر اساسِ IP می‌سازه؛ scopeِ جدا از family_invite_accept چون
# تولید و مصرفِ کدِ دعوت باید مستقل قابل‌تنظیم باشن.
throttle_scope = 'family_invite_create'
throttle_classes = [GuestAwareFamilyThrottle]
@@ -131,15 +124,9 @@ class FamilyLinkAcceptView(APIView):
permission_classes = [IsAuthenticated]
# ⚠️ اضافه شده: inviteCode یک رازِ عددیِ ۶رقمی تولیدشده‌ی سرور است
# (family/views.py::FamilyLinkInviteView)، و این endpoint دقیقاً همون
# کد رو با select_for_update() جست‌وجو می‌کنه؛ بدونِ throttle، یک
# کاربرِ احرازهویت‌شده می‌تونست بدونِ هیچ محدودیتی فضای ۶رقمی رو
# brute-force کنه. طبقِ همون الگویِ ScopedRateThrottle که برای بقیه‌ی
# اندپوینت‌هایِ حدس‌پذیر پروژه اضافه شد، اینجا هم یک scope مجزا اضافه
# شد (نه scope مشترک با discount/offer) تا نرخِ محدودسازی مستقل از اون‌ها
# قابل‌تنظیم باشه. چون DRF throttle check قبل از dispatch به post() در
# APIView.initial() اجرا می‌شه، حدس‌های بلاک‌شده اصلاً به select_for_update()
# inviteCode یک رازِ عددیِ ۶رقمیه؛ بدونِ throttle یه کاربر می‌تونست فضای
# ۶رقمی رو brute-force کنه. throttle در APIView.initial() قبل از
# post() اجرا می‌شه، پس حدس‌های بلاک‌شده اصلاً به select_for_update()
# نمی‌رسن.
throttle_scope = 'family_invite_accept'
throttle_classes = [GuestAwareFamilyThrottle]

View File

@@ -8,12 +8,9 @@ class Order(models.Model):
"""
STATUS_CHOICES = [('pending', 'در انتظار'), ('paid', 'پرداخت‌شده'), ('failed', 'ناموفق')]
# دو مدل پرداخت طبق درخواست سینیور: 'gateway' (زرین‌پال/زیبال و مشابه) و
# 'bazaar' (پرداخت درون‌برنامه‌ای اپلیکیشن بازار). این دو منطق پرداخت و
# صحت‌سنجی کاملاً متفاوتی دارن (webhook درگاه در برابر verify سرور-به-
# سرور با access_token بازار)؛ برای همین provider جداست، نه فقط یک مقدار
# دیگه در Transaction.gateway (که فقط برای گزارش‌گیریِ درگاه واقعی زرین‌پال/
# زیبال باقی می‌مونه).
# 'gateway' و 'bazaar' منطق و صحت‌سنجیِ کاملاً متفاوتی دارن (webhook در
# برابرِ verifyِ سرور-به-سرور با access_token)؛ برای همین provider جداست
# از Transaction.gateway (که فقط برای گزارش‌گیریِ درگاهِ واقعیه).
PROVIDER_CHOICES = [('gateway', 'درگاه پرداخت'), ('bazaar', 'پرداخت درون‌برنامه‌ای بازار')]
user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User
@@ -48,18 +45,11 @@ class Transaction(models.Model):
class Meta:
ordering = ['created_at']
constraints = [
# 🔒 یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای
# غیرخالی: این قید سطح DB تضمین می‌کنه یک gateway_ref_id مشخص
# (مثلاً Authority زرین‌پال یا purchaseToken بازار) در کل جدول
# Transaction فقط می‌تونه متعلق به یک Order باشه — هم جلوی
# duplicate/idempotency برای همون Order رو می‌گیره (چون در کل
# جدول حداکثر یک ردیف با این (gateway, ref) می‌تونه وجود داشته
# باشه) و هم جلوی reuse/replay همون ref برای یک Order دیگه رو
# (نگاه کنید به apply_order_payment_result). این قید، قیدِ
# قدیمی‌ترِ (order, gateway_ref_id, status) رو هم در برمی‌گیره
# (چون سخت‌گیرانه‌تره)، برای همین دیگه لازم نیست جدا نگه داشته
# بشه. gateway_ref_id__gt='' یعنی رفرنس‌های خالی (که provider
# واقعی هنوز رفرنسی برنگردونده) مشمول این قید نمی‌شن.
# یکتاییِ سراسریِ (gateway, gateway_ref_id) برای refهای غیرخالی:
# یک ref مشخص (مثلاً Authorityِ زرین‌پال) فقط می‌تونه متعلقِ یک
# Order باشه — هم جلوی duplicate رو می‌گیره هم جلوی reuse/replay
# برای Orderِ دیگه (نگاه کنید به apply_order_payment_result).
# gateway_ref_id__gt='' یعنی refِ خالی مشمول نیست.
models.UniqueConstraint(
fields=['gateway', 'gateway_ref_id'],
condition=models.Q(gateway_ref_id__gt=''),

View File

@@ -56,12 +56,9 @@ class SecretDefaultsFailClosedTests(TestCase):
self.assertTrue(HasWebhookSecret().has_permission(webhook_request, None))
def test_debug_mode_local_fallback_still_valid(self):
# settings.py این مقادیر رو یک‌بار موقعِ import، بر اساسِ DEBUG واقعیِ
# همون پروسه محاسبه می‌کنه (نه چیزی که override_settings در تست
# می‌تونه بعداً toggle کنه). برای تستِ صریحِ همین منطق، ماژولِ
# config.settings رو با DJANGO_DEBUG=1 و بدونِ ست‌بودنِ
# INTERNAL_SERVICE_KEY/PAYMENT_WEBHOOK_SECRET دوباره reload می‌کنیم
# و مطمئن می‌شیم دیفالتِ محلیِ قبلیِ dev هنوز برقراره.
# settings.py این مقادیر رو یک‌بار موقعِ import محاسبه می‌کنه، نه
# چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این
# منطق باید خودِ ماژول رو reload کرد.
from config import settings as settings_module
env_overrides = {'DJANGO_DEBUG': '1'}
@@ -227,12 +224,8 @@ class BazaarVerifyPurchaseThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است)، چون طبقِ فیکسِ GuestAwareBazaarThrottle حالا کلیدِ
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# BazaarVerifyPurchaseGuestThrottleBypassTests پایین‌تر) — این تست
# فقط رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین
# می‌کنه.
# کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
# مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000751')
registered_token = issue_access_token(registered_user.id)

View File

@@ -23,25 +23,10 @@ from .services import apply_order_payment_result, create_order
class GuestAwareBazaarThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده
از request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان، برای هر
حساب یک bucket تازه از throttle_scope 'bazaar_verify' بگیره — درحالی‌که
هر درخواستِ پذیرفته‌شده یک فراخوانیِ سرور-به-سرور به API بازار رو
تریگر می‌کنه.
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی
حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب
دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ
verify/order ownership/transaction/apply-payment دست‌نخورده‌ست.
"""
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازه‌ی bazaar_verify
بده، چون هر درخواستِ پذیرفته‌شده یه فراخوانیِ سرور-به-سرور به بازاره.
برای مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk."""
def get_cache_key(self, request, view):
user = request.user
@@ -121,14 +106,10 @@ class PaymentWebhookView(APIView):
"""
permission_classes = [HasWebhookSecret]
# ⚠️ اضافه شده: این endpoint قبلاً هیچ throttleای نداشت، درحالی‌که فقط
# با مقایسه‌ی یک هدرِ ثابت (X-Webhook-Secret) محافظت می‌شه؛ بدونِ
# throttle، تعدادِ نامحدودی تلاش برای حدسِ این سکرت (یا fuzz کردنِ
# orderId بعد از افشای احتمالیِ سکرت) ممکن بود، درحالی‌که یک حدسِ
# درست باعثِ paid شدنِ واقعیِ سفارش و فعال‌سازیِ اشتراک می‌شه. چون
# این caller یک کاربرِ اپ نیست (خودِ درگاهِ پرداخته)، از یک
# ScopedRateThrottleِ سادهٔ IP-محور استفاده شده، نه الگویِ
# GuestAware*Throttle (که نیازمندِ request.user است).
# این endpoint فقط با مقایسه‌ی هدرِ ثابتِ X-Webhook-Secret محافظت می‌شه،
# پس throttle لازمه تا حدسِ سکرت (یا fuzzِ orderId) محدود بمونه. چون
# caller خودِ درگاهه نه یک کاربرِ اپ، از ScopedRateThrottleِ IP-محور
# استفاده شده، نه الگویِ GuestAware*Throttle که نیازمندِ request.user است.
throttle_scope = 'payment_webhook'
throttle_classes = [ScopedRateThrottle]
@@ -182,19 +163,11 @@ class BazaarVerifyPurchaseView(APIView):
"""
permission_classes = [IsAuthenticated]
# ⚠️ افزوده شده: این endpoint یک فراخوانیِ سرور-به-سرور به API بازار
# (verify_purchase) رو تریگر می‌کنه؛ بدون throttle مخصوص، یک کاربر
# می‌تونست با ارسال مکرر purchaseToken نامعتبر، بار زیادی روی سرویس
# بازار و بک‌اند بندازه. throttle_scope مجزا (bazaar_verify) جدا از
# نرخ‌های دیگه (مثل otp_*) تنظیم شده.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwareBazaarThrottle استفاده می‌شه که برای کاربرهای مهمان کلید
# رو بر اساسِ IP می‌سازه و برای کاربرهای غیرمهمان رفتارِ per-user
# قبلی رو دست‌نخورده نگه می‌داره. نرخ هم از ۲۰/hour به ۱۰/hour
# سخت‌گیرانه‌تر شد چون هر درخواستِ پذیرفته‌شده یک تماسِ سرور-به-سرورِ
# واقعی با بازار می‌زنه.
# این endpoint یک فراخوانیِ سرور-به-سرورِ واقعی به API بازار (verify_purchase)
# تریگر می‌کنه، برای همین throttle_scope مجزا و نرخِ سخت‌گیرانه‌تر (۱۰/hour)
# داره. کاربرهای مهمان با هر deviceId یک pk تازه می‌گیرن، پس
# GuestAwareBazaarThrottle برای اون‌ها بر اساسِ IP کلید می‌سازه تا چرخشِ
# حساب bucket تازه نگیره؛ برای کاربرهای غیرمهمان رفتارِ per-user حفظ می‌شه.
throttle_scope = 'bazaar_verify'
throttle_classes = [GuestAwareBazaarThrottle]
@@ -208,13 +181,10 @@ class BazaarVerifyPurchaseView(APIView):
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
# ⚛️🔒 اصلاح: فراخوانی HTTP به API بازار (تا ~۲۰ ثانیه، دو درخواست
# پشت‌سرهم برای refresh token + verify) قبلاً *داخل* transaction.atomic
# و زیر select_for_update روی Order انجام می‌شد؛ یعنی ردیف Order (و
# هر تراکنش دیگه‌ای که بخواد همون Order یا Subscription مرتبط رو لمس
# کنه) تا برگشتِ پاسخ بازار قفل می‌موند. حالا verify_purchase قبل از
# باز شدن atomic/lock صدا زده می‌شه؛ لاک فقط برای اعمال نتیجه (چند
# میلی‌ثانیه) گرفته می‌شه.
# verify_purchase (تا ~۲۰ ثانیه، دو درخواستِ پشت‌سرهم) عمداً *قبل* از
# بازشدنِ atomic/lock صدا زده می‌شه، نه داخلش — وگرنه ردیفِ Order (و هر
# تراکنشِ دیگه‌ای روی همون Order/Subscription) تا برگشتِ پاسخِ بازار
# قفل می‌موند. لاک فقط برای اعمالِ نتیجه (چند میلی‌ثانیه) گرفته می‌شه.
order_lookup = get_object_or_404(
Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar',
)

View File

@@ -14,31 +14,21 @@ class Plan(models.Model):
price = models.PositiveIntegerField()
currency = models.CharField(max_length=10, default='IRT')
discount_percent = models.PositiveSmallIntegerField(default=0)
popular = models.BooleanField(default=False) # <-- اصلاح/افزوده شده
# ⚠️ اصلاح دیگه: سند طراحی هیچ فیلد duration‌ای برای Plan نداره، در
# نتیجه‌ی این نبود، activate_subscription قبلاً مدت اشتراک رو با یک
# قاعده‌ی stub (چک کردن کلمه‌ی «سالانه»/yearly در عنوان) حدس می‌زد که
# شکننده و وابسته به متن title بود. duration_days اینجا به‌عنوان منبع
# صریح و قابل‌اتکای مدت اشتراک اضافه شده؛ پیش‌فرض ۳۰ روز.
duration_days = models.PositiveIntegerField(default=30) # <-- اصلاح/افزوده شده
popular = models.BooleanField(default=False)
# منبعِ صریحِ مدتِ اشتراک؛ بدونش activate_subscription مجبور بود مدت رو
# از رویِ متنِ title (مثلاً کلمه‌ی «سالانه») حدس بزنه.
duration_days = models.PositiveIntegerField(default=30)
def __str__(self):
return self.title
class Subscription(models.Model):
"""
⚠️ اصلاح نسبت به سند اولیه: STATUS_CHOICES در سند فقط شامل
active/expired/canceled بود. اما در جریان POST /api/subscription/purchase
یک Subscription قبل از تایید پرداخت (که با payment webhook میاد) ساخته
می‌شه؛ بدون یک وضعیت میانی، راهی برای نمایش «سفارش ثبت شده ولی هنوز
پرداخت تایید نشده» وجود نداره. برای همین وضعیت pending اضافه و به‌عنوان
پیش‌فرض ساخت (به‌جای active در سند) تنظیم شده؛ فعال‌سازی واقعی توسط
subscription.services.activate_subscription بعد از webhook پرداخت انجام
می‌شه.
"""
"""وضعیتِ pending برای فاصله‌ی بینِ ساختنِ Subscription (در purchase) و
تاییدِ پرداخت (webhook)؛ فعال‌سازیِ واقعی توسطِ
subscription.services.activate_subscription انجام می‌شه."""
STATUS_CHOICES = [
('pending', 'در انتظار پرداخت'), # <-- اصلاح/افزوده شده
('pending', 'در انتظار پرداخت'),
('active', 'فعال'),
('expired', 'منقضی'),
('canceled', 'لغوشده'),
@@ -50,19 +40,11 @@ class Subscription(models.Model):
started_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(null=True, blank=True)
order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order
# 🔒 اصلاحِ رگرسیونِ concurrency در PurchaseView.post: چون Subscription
# فقط یک ردیف در ازای هر user_id داره، دو درخواستِ هم‌زمانِ خریدِ همون
# کاربر (مثلاً با همون plan) می‌تونن دقیقاً به یک وضعیتِ میانیِ یکسان
# برسن (status='pending', همون plan_id, order_id=None) — یعنی مقایسه‌ی
# مقادیرِ فیلدها (که قبلاً به‌عنوانِ CAS guard در cleanup استفاده می‌شد)
# نمی‌تونه بینِ «ردیفِ نوشته‌شده توسطِ خودِ همین درخواست» و «ردیفِ
# نوشته‌شده توسطِ یک درخواستِ هم‌زمانِ دیگه» تمایز بذاره. version یک
# شمارنده‌ی صریحه که PurchaseView.post هر بار که واقعاً روی این ردیف
# می‌نویسه (با UPDATE اتمیکِ version=F('version')+1، بدونِ نیاز به
# select_for_update/نگه‌داشتنِ قفل) افزایشش می‌ده؛ پس حتی وقتی محتوای
# semantic فیلدهای دیگه بینِ دو نویسنده‌ی هم‌زمان یکسانه، version همیشه
# بینِ نوشته‌های مجزا فرق می‌کنه و می‌تونه به‌عنوانِ CAS token قابل‌اتکا
# برای cleanup استفاده بشه.
# چون Subscription فقط یک ردیف در ازای هر user_id داره، دو خریدِ هم‌زمانِ
# همون کاربر می‌تونن به وضعیتِ میانیِ کاملاً یکسانی برسن (مقایسه‌ی
# فیلدها کافی نیست برای CAS). version با UPDATEِ اتمیکِ
# version=F('version')+1 (بدونِ select_for_update) هر نوشتنِ واقعی رو
# از نوشتنِ هم‌زمانِ دیگری تمیز می‌ده.
version = models.PositiveIntegerField(default=0)
def __str__(self):

View File

@@ -362,12 +362,9 @@ class PurchaseThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است)، چون طبقِ فیکسِ GuestAwarePurchaseThrottle حالا کلیدِ
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# PurchaseGuestThrottleBypassTests پایین‌تر) — این تست فقط رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه که
# باید دقیقاً مثلِ قبل دست‌نخورده بمونه.
# کاربرِ غیرمهمان لازمه، نه self.user، چون کلیدِ bucketِ مهمان‌ها
# عمداً بر اساسِ IP است (نگاه کنید به PurchaseGuestThrottleBypassTests)
# و این تست رفتارِ per-user برای ثبت‌نام‌شده‌ها را می‌سنجد.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000501')
registered_token = issue_access_token(registered_user.id)

View File

@@ -21,27 +21,10 @@ from .serializers import (
class GuestAwarePurchaseThrottle(ScopedRateThrottle):
"""
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle
(discount/views.py) اینجا هم هست. ScopedRateThrottle استاندارد برای
درخواستِ احرازهویت‌شده از request.user.pk به‌عنوانِ کلیدِ throttle
استفاده می‌کنه؛ چون GuestSessionView با هر deviceId جدید یک کاربرِ
مهمانِ تازه (pk جدید) می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ
حساب‌های مهمان (تا سقفِ guest_session=20/hour)، برای هر حساب یک
bucket تازه از throttle_scope 'purchase' بگیره — یعنی سقفِ ۱۰/ساعتِ
درخواستِ purchase عملاً با چرخشِ حساب مهمان چند برابر می‌شد. این
endpoint حساسِ مالیه: هر درخواستِ پذیرفته‌شده وارد جریانِ purchase
می‌شه، یک Subscription می‌سازه/آپدیت می‌کنه و create_order() رو صدا
می‌زنه (که در پروداکشن با درگاهِ واقعی جایگزینِ stub فعلی می‌شه).
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی
حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب
دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ
purchase/subscription/order/Idempotency-Key/plan-validation دست‌نخورده‌ست.
"""
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
حساب مهمان نباید bucket تازه‌ی purchase بده — این endpoint حساسِ مالیه
(Subscription می‌سازه/آپدیت می‌کنه، create_order رو صدا می‌زنه). برای
مهمان‌ها کلیدِ cache بر اساسِ IP هست، نه pk."""
def get_cache_key(self, request, view):
user = request.user
@@ -85,20 +68,12 @@ class PurchaseView(APIView):
to create an Order."""
permission_classes = [IsAuthenticated]
# scope کیدهای cache رو با pk کاربرِ احرازهویت‌شده می‌سازه (نه فقط IP)؛
# چون Idempotency-Key به‌تنهایی جلوی حجمِ بالای درخواست‌های *متفاوت*
# (با کلیدهای idempotency متفاوت) رو نمی‌گیره، این throttle مستقل و
# مکملِ اون محافظته. ScopedRateThrottle خودش وقتی request.user
# احرازهویت‌شده باشه (is_authenticated=True) به‌جای IP از
# request.user.pk به‌عنوانِ ident استفاده می‌کنه، پس تغییرِ IP از
# همون اکانت این محدودیت رو دور نمی‌زنه.
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
# جدید یک pk تازه می‌گیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
# user.pk) توسطِ چرخشِ حساب مهمان قابل‌دورزدن بود. حالا از
# GuestAwarePurchaseThrottle استفاده می‌شه که برای کاربرهای مهمان
# کلید رو بر اساسِ IP می‌سازه (دقیقاً همون الگویِ GuestAwareAIThrottle،
# GuestAwareFamilyThrottle و GuestAwareDiscountThrottle) و برای
# کاربرهای غیرمهمان رفتارِ per-user قبلی رو دست‌نخورده نگه می‌داره.
# این throttle مستقلِ Idempotency-Key است چون اون فقط جلوی تکرارِ *همون*
# کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت. کاربرهای مهمان با هر
# deviceId یک pk تازه می‌گیرن، پس کلیدِ per-user ساده قابل‌دورزدنه؛
# GuestAwarePurchaseThrottle برای مهمان‌ها بر اساسِ IP کلید می‌سازه
# (مثلِ GuestAwareAIThrottle/FamilyThrottle/DiscountThrottle) و برای
# کاربرهای غیرمهمان رفتارِ per-user را حفظ می‌کنه.
throttle_scope = 'purchase'
throttle_classes = [GuestAwarePurchaseThrottle]

View File

@@ -54,12 +54,8 @@ class BugReportCreateThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است)، چون طبقِ فیکسِ GuestAwareSupportThrottle حالا کلیدِ
# bucket برای کاربرهای مهمان عمداً بر اساسِ IP است (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر) — این تست فقط
# رفتارِ per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه
# که باید دقیقاً مثلِ قبل دست‌نخورده بمونه.
# کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
# مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000901')
registered_token = issue_access_token(registered_user.id)
@@ -131,11 +127,8 @@ class TicketCreateThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان
# عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه.
# کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
# مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000902')
registered_token = issue_access_token(registered_user.id)
@@ -208,11 +201,8 @@ class TicketMessageCreateThrottleTests(TestCase):
self.assertEqual(blocked_response.status_code, 429)
def test_changing_client_ip_does_not_bypass_the_user_limit(self):
# ⚠️ توجه: از یک کاربرِ *غیرمهمان* استفاده می‌شه (نه self.user که
# مهمان است) — طبقِ فیکسِ GuestAwareSupportThrottle، کاربرهای مهمان
# عمداً بر اساسِ IP کلید می‌خورن (نگاه کنید به
# SupportCreateGuestThrottleBypassTests پایین‌تر)؛ این تست رفتارِ
# per-user (نه IP) برای کاربرهایِ ثبت‌نام‌شده رو تضمین می‌کنه.
# کاربرِ غیرمهمان (نه self.user که مهمانه)، چون کلیدِ throttleِ
# مهمان‌ها عمداً بر اساسِ IP است؛ این تست فقط رفتارِ per-user رو می‌سنجه.
registered_user = User.objects.create(is_guest=False, name='', phone='+989120000903')
registered_token = issue_access_token(registered_user.id)
registered_ticket = Ticket.objects.create(

View File

@@ -60,12 +60,9 @@ class BugReportCreateView(APIView):
"""POST /api/support/bug-reports — ثبت گزارش باگ"""
permission_classes = [IsAuthenticated]
# هیچ مکانیزمِ idempotency ای اینجا نیست، پس بدونِ throttle یک کاربرِ
# احرازهویت‌شده می‌تونه تعدادِ نامحدودی BugReport با درخواست‌های
# پرتکرار بسازه. ScopedRateThrottle با pk کاربر کلید می‌خوره (نه IP).
# ⚠️ اصلاح شده: چون کاربرهای مهمان با هر deviceId جدید یک pk تازه
# می‌گیرن، از GuestAwareSupportThrottle استفاده می‌شه تا چرخشِ حساب
# مهمان دیگه bucket تازه ندهد (نگاه کنید به تعریفِ کلاس بالا).
# بدونِ idempotency/throttle، یک کاربر می‌تونست تعدادِ نامحدودی BugReport
# بسازه؛ GuestAwareSupportThrottle برای مهمان‌ها کلید رو بر اساسِ IP
# می‌سازه تا چرخشِ حساب bucket تازه نده.
throttle_scope = 'support_create'
throttle_classes = [GuestAwareSupportThrottle]