Files
MathApp/discount/views.py
Zareian 42783f9ba4 tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown,
with rag/services/mathtext.py converting whatever still slips through; full
step-by-step solutions with a check before the final answer; three tones by
grade; the last 6 messages of the conversation go to the model and follow-ups
are no longer served from another user's cache; book solutions the checker
disputes are kept out of the context; a questionId gives the tutor the
practice question and the student's wrong answer. /api/health reports when
the AI credit runs out. rag_model_benchmark compares chat models on verified
book problems (run on the server; credit ran out mid-run).

Map: only lessons that have questions count toward finishing an island,
lessonIds[0] / nextLessonId is always the next lesson to play, and a finished
island stays finished. The Seyar characters (sigma/delta/pi) are seeded.

Audit fixes, each reproduced before fixing:
- the plan is stored on the order, so paying a monthly order after starting
  a yearly checkout no longer grants a year
- opening checkout no longer demotes an active subscription; renewals extend
  from the current expiry; expired subscriptions are not reported active
- one discount code per order and only before the gateway; a 100% code
  completes the purchase; lost Zarinpal confirmations are re-verified
- the unused /api/payment/webhook "mark paid" endpoint is removed
- XP counts only answers recorded on the server
- 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout
- a guest who signs up keeps their account and progress (test phones excluded)
- affiliate commission on the first real payment; guests excluded
- parents with several children no longer get a 500
- SQLite runs transactions IMMEDIATE, since it ignores select_for_update
- admin login is rate-limited; display names drop phone numbers and links;
  userQuestion is capped at 2000 characters
- server-side heart enforcement exists behind HEARTS_ENFORCED (off until the
  frontend handles 403 no_hearts)

Not done on purpose: rotating the server's secret key and moving to Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 06:24:13 +03:30

254 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.db import IntegrityError, transaction
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
from .models import DiscountCode, Redemption
from .serializers import (
RedeemOfferCodeRequestSerializer,
RedeemRequestSerializer,
RedeemResponseSerializer,
UserOfferResponseSerializer,
ValidateResponseSerializer,
)
from .services import redeem_offer_code
class GuestAwareDiscountThrottle(ScopedRateThrottle):
"""
همان مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) و
GuestAwareFamilyThrottle (family/views.py) اینجا هم هست.
ScopedRateThrottle استاندارد برای درخواستِ احرازهویت‌شده از
request.user.pk به‌عنوانِ کلیدِ throttle استفاده می‌کنه؛ چون
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
می‌سازه، یک مهاجم می‌تونست با ساختنِ پی‌درپیِ حساب‌های مهمان (تا سقفِ
guest_session=20/hour)، برای هر حساب یک bucket تازه از throttle_scope
'discount_redeem' بگیره. ریسکِ این endpoint از بقیه هم بیشتره، چون
DiscountCode یک رکوردِ سراسری با max_uses مشترکِ بینِ همه‌ی کاربرهاست؛
یک حدسِ درست صرفاً اطلاعات لو نمی‌ده، بلکه یک Redemption واقعی
می‌سازه و مبلغِ سفارش رو کم می‌کنه — یعنی throttle دورزده‌شده به
مهاجم guessهای واقعاً بیشتری روی یک کدِ سراسری می‌ده.
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
cache از pk کاربر به IP کلاینت (get_ident) تغییر می‌کنه، پس همه‌ی
حساب‌های مهمانِ ساخته‌شده از یک IP یک bucket مشترک دارن و چرخشِ حساب
دیگه bucket تازه نمی‌ده. رفتارِ throttle برای کاربرهای غیرمهمان
(ثبت‌نام‌شده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی می‌مونه؛ منطقِ
lookup/redemption/order دست‌نخورده‌ست.
"""
def get_cache_key(self, request, view):
user = request.user
if user and user.is_authenticated and getattr(user, 'is_guest', False):
ident = f'guest-ip:{self.get_ident(request)}'
elif user and user.is_authenticated:
ident = user.pk
else:
ident = self.get_ident(request)
return self.cache_format % {'scope': self.scope, 'ident': ident}
def _is_usable(discount: DiscountCode) -> bool:
return bool(
discount.redemptions.count() < discount.max_uses
and (discount.expires_at is None or discount.expires_at >= timezone.now())
)
class ValidateDiscountView(APIView):
"""GET /api/discount/validate?code= — اعتبارسنجی کد پیش از خرید"""
# این ویو مثل RedeemDiscountView به IsAuthenticated نیاز دارد؛ در غیر این
# صورت کلاینت بدون احراز هویت می‌تواند کدهای تخفیف را probe کند.
permission_classes = [IsAuthenticated]
# این endpoint type/value کد رو برمی‌گردونه، پس بدونِ throttle یک
# کاربر می‌تونست بی‌نهایت کد رو probe کنه؛ نرخ کمی بالاتر از
# offer_redeem چون این یک چکِ read-only پیش از خریده. GuestAwareDiscountThrottle
# مشترک (هم اینجا هم RedeemDiscountView/RedeemOfferCodeView) برای
# مهمان‌ها کلید رو بر اساسِ IP می‌سازه تا چرخشِ حساب bucket تازه نده.
throttle_scope = 'discount_validate'
throttle_classes = [GuestAwareDiscountThrottle]
@extend_schema(
parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)],
responses=ValidateResponseSerializer,
tags=['Discount (discount)'],
)
def get(self, request):
code = request.query_params.get('code', '')
discount = DiscountCode.objects.filter(code=code).first()
if not discount or not _is_usable(discount):
return Response(ValidateResponseSerializer({'valid': False}).data)
data = {'valid': True, 'code': discount.code, 'type': discount.type, 'value': discount.value}
return Response(ValidateResponseSerializer(data).data)
class RedeemDiscountView(APIView):
"""
POST /api/discount/redeem — ثبت مصرف کد هنگام خرید
شرط‌های usable بودن (max_uses/expires_at) دوباره در لحظه‌ی redeem زیر
select_for_update چک می‌شود تا از over-redemption هم‌زمان جلوگیری شود.
هر کاربر برای یک کد حداکثر یک‌بار می‌تواند redeem کند (قید یکتایی مدل).
discountAmount با یک خواندنِ مستقیم از payment.models.Order پر می‌شود
(بدون FK واقعی، مطابق قرارداد پروژه برای ارجاع بین اپ‌ها).
"""
permission_classes = [IsAuthenticated]
# همون oracleِ probe مثلِ ValidateDiscountView، ولی ریسکش بیشتره: حدسِ
# درست یه کدِ واقعی رو مصرف می‌کنه (سهمِ max_uses مشترک) و مبلغِ سفارش
# رو واقعاً کم می‌کنه، ضمن اینکه هر تلاش دو select_for_update و یه
# تراکنشه. GuestAwareDiscountThrottle برای مهمان‌ها کلید رو بر اساسِ IP
# می‌سازه تا چرخشِ حساب bucket تازه نده.
throttle_scope = 'discount_redeem'
throttle_classes = [GuestAwareDiscountThrottle]
@extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer,
tags=['Discount (discount)'])
def post(self, request):
serializer = RedeemRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
order_id = int(v['orderId'])
from payment.models import Order
# سفارش باید متعلق به همان کاربر احرازهویت‌شده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران).
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
try:
with transaction.atomic():
discount = get_object_or_404(
DiscountCode.objects.select_for_update(), code=v['code']
)
if not _is_usable(discount):
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'})
if discount.redemptions.filter(user_id=request.user.id).exists():
raise ValidationError({'code': 'شما قبلاً این کد را استفاده کرده‌اید.'})
# سفارش زیر قفل خوانده می‌شود و چک pending بودن *بعد* از قفل روی همان
# ردیف انجام می‌شود، تا با یک webhook هم‌زمان که سفارش را paid کرده race نکند.
order_locked = Order.objects.select_for_update().get(pk=order.id)
if order_locked.status != 'pending':
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
# بعد از رفتن به درگاه، authorityِ زرین‌پال با مبلغِ قبلی گرفته شده و
# دوباره استفاده می‌شود؛ تخفیف در این لحظه یعنی کاربر مبلغِ قبلی را
# می‌پردازد ولی verify با مبلغِ جدید رد می‌شود — پول کم می‌شود و
# اشتراکی فعال نمی‌شود.
if order_locked.gateway_authority:
raise ValidationError({'code': 'کدِ تخفیف را پیش از رفتن به درگاهِ پرداخت وارد کنید.'})
# یک کد برای هر سفارش: کدهای درصدی روی هم جمع می‌شدند.
if Redemption.objects.filter(order_id=order_locked.id).exists():
raise ValidationError({'code': 'روی این سفارش قبلاً کدِ تخفیف اعمال شده است.'})
if discount.type == 'percent':
discount_amount = order_locked.amount * discount.value // 100
else:
discount_amount = min(discount.value, order_locked.amount)
Redemption.objects.create(
discount_code=discount,
user_id=request.user.id,
order_id=order_id,
)
# مبلغ سفارش واقعاً کم می‌شود تا مرحله‌ی پرداخت مبلغ تخفیف‌خورده را ببیند.
order_locked.amount = max(order_locked.amount - discount_amount, 0)
order_locked.save(update_fields=['amount'])
# سفارشِ صفرتومانی را هیچ درگاهی نمی‌پذیرد؛ کدِ ۱۰۰٪ یعنی خرید
# همین‌جا تمام است (همان مسیرِ پرداختِ واقعی: اشتراک، اعلان، پورسانت).
if order_locked.amount == 0:
from payment.services import apply_order_payment_result
apply_order_payment_result(
order=order_locked, gateway='discount',
gateway_ref_id=f'discount-{discount.id}-order-{order_locked.id}',
new_status='paid', event='discount_full',
)
except IntegrityError:
# fallback برای رقابت هم‌زمان روی قید یکتایی، حتی اگر قفل select_for_update رد شود.
raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'})
order.refresh_from_db(fields=['status', 'amount'])
data = {'success': True, 'discountAmount': discount_amount,
'orderStatus': order.status, 'amount': order.amount}
return Response(RedeemResponseSerializer(data).data)
class RedeemOfferCodeView(APIView):
"""
POST /api/discount/offers/redeem — اعمال آفر (اشتراک رایگان/محدود) با کد،
برخلاف auto_assign_on_signup که خودکارِ هنگام ثبت‌نام‌ه، این برای زمانی‌ه
که سینیور بخواد یک آفر رو بعداً (نه فقط هنگام ثبت‌نام) با یک کد به کاربر
بده — مثلاً یک کمپین تبلیغاتی.
"""
permission_classes = [IsAuthenticated]
# بدونِ throttle، کدهای آفر قابلِ probe بودن؛ اینجا حساس‌تره چون
# Offer.code سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ همون
# کاربر رو می‌گیره — یک حسابِ مهمانِ چرخشی می‌تونست همون آفرِ سراسری رو
# دوباره بگیره. GuestAwareDiscountThrottle برای مهمان‌ها کلید رو بر
# اساسِ IP می‌سازه تا چرخشِ حساب bucket تازه نده.
throttle_scope = 'offer_redeem'
throttle_classes = [GuestAwareDiscountThrottle]
@extend_schema(
request=RedeemOfferCodeRequestSerializer, responses=UserOfferResponseSerializer,
tags=['Discount (discount)'],
)
def post(self, request):
serializer = RedeemOfferCodeRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user_offer = redeem_offer_code(user_id=request.user.id, code=serializer.validated_data['code'])
data = {
'id': user_offer.id,
'offerName': user_offer.offer.name,
'kind': user_offer.offer.kind,
'status': user_offer.status,
'grantedAt': user_offer.granted_at,
'expiresAt': user_offer.expires_at,
}
return Response(UserOfferResponseSerializer(data).data)
class MyOffersView(APIView):
"""GET /api/discount/offers/mine — لیست آفرهایی که کاربر گرفته (خودکار یا با کد)"""
permission_classes = [IsAuthenticated]
@extend_schema(responses=UserOfferResponseSerializer(many=True), tags=['Discount (discount)'])
def get(self, request):
from .models import UserOffer
user_offers = UserOffer.objects.filter(user_id=request.user.id).select_related('offer')
data = [
{
'id': uo.id,
'offerName': uo.offer.name,
'kind': uo.offer.kind,
'status': uo.status,
'grantedAt': uo.granted_at,
'expiresAt': uo.expires_at,
}
for uo in user_offers
]
return Response(UserOfferResponseSerializer(data, many=True).data)