Content ------- Every node in grades 8, 9, 10, 11 and 12 now holds a full four-question quiz — 121 more questions, filling the two حسابان دوازدهم nodes in فصل ۵: کاربردهای مشتق that were empty and the two ریاضی هشتم nodes that were short. The repo's own seed guard was already failing on those two empty nodes; it passes now. Grades 3, 6 and 7 are the other team's books and were left alone. 39 of their 145 nodes still hold fewer than four questions, but none is empty, so every node on the map still plays. An audit over all 1,593 questions in grades 8–12 found no structural fault: no question with the wrong number of options, no question with zero or two correct answers, no empty or duplicated option, no leftover LaTeX, and no two identical questions inside one lesson. Contract -------- Three endpoints were missing or malformed in the OpenAPI schema, which matters more than it looks: that schema is the contract the frontend generates its client from, so a view missing there is a view the other repo cannot call. - NotificationMarkReadView was dropped from the schema entirely, because spectacular could not guess a request body for its PATCH and gives up on the whole view rather than the one field. It declares request=None. - PaymentRedirectView was dropped for the same reason; it answers with a 302 and no JSON body, which it now says. - /api/health and /api/health/ collided on operationId and were emitted as health_retrieve and health_retrieve_2. The slash variant exists only for a monitor that calls it that way, so it is excluded from the schema. `spectacular` now runs clean — no errors, no warnings. Tidying ------- Condensed the two longest comment blocks (18 and 14 lines) in rag_verify_solutions and settings, keeping the measurement and the rule and dropping the retelling. README gains the bulk question-authoring workflow; the playbook's subagent section now points at the real tool instead of describing the steps by hand. 458 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
363 lines
18 KiB
Python
363 lines
18 KiB
Python
from urllib.parse import urlsplit
|
||
|
||
from django.conf import settings
|
||
from django.db import transaction
|
||
from django.http import HttpResponseRedirect
|
||
from django.shortcuts import get_object_or_404
|
||
from drf_spectacular.types import OpenApiTypes
|
||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
from . import bazaar, zarinpal
|
||
from .models import Order
|
||
from .permissions import HasInternalServiceKey, HasWebhookSecret
|
||
from .serializers import (
|
||
BazaarVerifyRequestSerializer,
|
||
BazaarVerifyResponseSerializer,
|
||
CreateOrderRequestSerializer,
|
||
CreateOrderResponseSerializer,
|
||
OrderDetailResponseSerializer,
|
||
WebhookRequestSerializer,
|
||
WebhookResponseSerializer,
|
||
ZarinpalCallbackResponseSerializer,
|
||
)
|
||
from .services import apply_order_payment_result, create_order
|
||
|
||
|
||
def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
|
||
"""
|
||
HttpResponseRedirect معمولی فقط اسکیمِ http/https/ftp رو قبول میکنه؛
|
||
ZARINPAL_REDIRECT_URL ممکنه یک deep link با اسکیمِ سفارشیِ اپ باشه
|
||
(مثلاً tadrisino://...). چون این URL از settings میاد (نه از ورودیِ
|
||
کاربر)، اضافهکردنِ اسکیمش به allowed_schemes امن است.
|
||
"""
|
||
scheme = urlsplit(url).scheme
|
||
redirect_cls = HttpResponseRedirect
|
||
if scheme and scheme not in redirect_cls.allowed_schemes:
|
||
redirect_cls = type('ConfiguredSchemeRedirect', (HttpResponseRedirect,), {
|
||
'allowed_schemes': [*HttpResponseRedirect.allowed_schemes, scheme],
|
||
})
|
||
return redirect_cls(url)
|
||
|
||
|
||
class GuestAwareBazaarThrottle(ScopedRateThrottle):
|
||
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
|
||
حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازهی bazaar_verify
|
||
بده، چون هر درخواستِ پذیرفتهشده یه فراخوانیِ سرور-به-سرور به بازاره.
|
||
برای مهمانها کلیدِ cache بر اساسِ IP هست، نه pk."""
|
||
|
||
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}
|
||
|
||
|
||
class CreateOrderView(APIView):
|
||
"""
|
||
POST /api/payment/orders — ساخت سفارش (فراخوانیشده از اپ Subscription)
|
||
طبق سند API، این یک فراخوانی سرور-به-سرور از اپ Subscriptionه، نه از
|
||
کلاینت. در عمل subscription/views.py مستقیماً تابع create_order رو
|
||
import و صدا میزنه، پس این endpoint برای جریان عادی از بیرون صدا زده
|
||
نمیشه؛ برای همین بهجای AllowAny یک shared-secret داخلی
|
||
(HasInternalServiceKey، هدر X-Internal-Key) لازمه، وگرنه هر کلاینتی
|
||
میتونست با یک userId دلخواه سفارش بسازه.
|
||
"""
|
||
|
||
permission_classes = [HasInternalServiceKey]
|
||
|
||
# ⚠️ افزوده شده: هدرهای موردنیاز (X-Internal-Key, Idempotency-Key) قبلاً
|
||
# مستند نشده بودن و از Swagger UI اصلاً قابل ستکردن/تستکردن نبودن.
|
||
@extend_schema(
|
||
request=CreateOrderRequestSerializer,
|
||
responses=CreateOrderResponseSerializer,
|
||
parameters=[
|
||
OpenApiParameter(
|
||
'X-Internal-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True,
|
||
description='کلید مخفیِ فراخوانی داخلی/سرور-به-سرور (settings.INTERNAL_SERVICE_KEY).',
|
||
),
|
||
OpenApiParameter(
|
||
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
|
||
description=(
|
||
'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. '
|
||
'در صورت عدم ارسال، یک کلید تصادفی ساخته میشود.'
|
||
),
|
||
),
|
||
],
|
||
tags=['Payment (payment)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = CreateOrderRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
|
||
order, payment_url = create_order(
|
||
user_id=int(v['userId']),
|
||
amount=v['amount'],
|
||
currency=v.get('currency') or 'IRT',
|
||
subscription_id=int(v['subscriptionId']) if v.get('subscriptionId') else None,
|
||
idempotency_key=request.headers.get('Idempotency-Key'),
|
||
provider=v.get('provider') or 'gateway',
|
||
bazaar_product_sku=v.get('bazaarProductSku') or '',
|
||
)
|
||
data = {
|
||
'orderId': str(order.id), 'status': order.status,
|
||
'paymentUrl': payment_url, 'provider': order.provider,
|
||
}
|
||
return Response(CreateOrderResponseSerializer(data).data)
|
||
|
||
|
||
class PaymentWebhookView(APIView):
|
||
"""
|
||
POST /api/payment/webhook — وبهوکِ عمومیِ shared-secret (نه مخصوصِ زرینپال).
|
||
|
||
جریانِ واقعیِ زرینپال از PaymentRedirectView/ZarinpalCallbackView رد
|
||
میشه (زرینپال خودش webhookِ سرور-به-سرور نمیزنه، فقط مرورگرِ کاربر رو
|
||
ریدایرکت میکنه). این endpoint برای تاییدِ دستی/تستی یا درگاههای دیگهای
|
||
که واقعاً webhook POST میزنن نگه داشته شده؛ محافظتشده با
|
||
HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک
|
||
orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه.
|
||
همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/
|
||
notify تکراری نمیسازه.
|
||
"""
|
||
|
||
permission_classes = [HasWebhookSecret]
|
||
# این endpoint فقط با مقایسهی هدرِ ثابتِ X-Webhook-Secret محافظت میشه،
|
||
# پس throttle لازمه تا حدسِ سکرت (یا fuzzِ orderId) محدود بمونه. چون
|
||
# caller خودِ درگاهه نه یک کاربرِ اپ، از ScopedRateThrottleِ IP-محور
|
||
# استفاده شده، نه الگویِ GuestAware*Throttle که نیازمندِ request.user است.
|
||
throttle_scope = 'payment_webhook'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
# ⚠️ افزوده شده: مستندسازی هدر X-Webhook-Secret که قبلاً در Swagger UI
|
||
# قابل تنظیم/تست نبود.
|
||
@extend_schema(
|
||
request=WebhookRequestSerializer,
|
||
responses=WebhookResponseSerializer,
|
||
parameters=[
|
||
OpenApiParameter(
|
||
'X-Webhook-Secret', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True,
|
||
description='کلید مخفیِ وبهوک درگاه پرداخت (settings.PAYMENT_WEBHOOK_SECRET).',
|
||
),
|
||
],
|
||
tags=['Payment (payment)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = WebhookRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
gateway_ref_id = v.get('gatewayRefId', '')
|
||
|
||
with transaction.atomic():
|
||
order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId'])
|
||
# ⚠️ اصلاح امنیتی/صحت داده (قبلی): هر بار فراخوانی webhook (حتی
|
||
# تکراری از سمت درگاه) یک Transaction جدید میساخت. حالا این
|
||
# idempotency + اثرات جانبی (فعالسازی اشتراک/notify/statuslog)
|
||
# داخل apply_order_payment_result مشترکه (هم اینجا هم بازار).
|
||
apply_order_payment_result(
|
||
order=order,
|
||
gateway=v.get('gateway') or 'zarinpal',
|
||
gateway_ref_id=gateway_ref_id,
|
||
new_status=v['status'],
|
||
event='gateway_webhook',
|
||
)
|
||
|
||
return Response(WebhookResponseSerializer({'received': True}).data)
|
||
|
||
|
||
class PaymentRedirectView(APIView):
|
||
"""
|
||
GET /api/payment/orders/{id}/pay — همان paymentUrlی که
|
||
CreateOrderResponseSerializer برای provider='gateway' برمیگرداند؛
|
||
کلاینت (مرورگر/وبویو) این را باز میکند.
|
||
|
||
فراخوانیِ واقعیِ PaymentRequest به زرینپال عمداً همینجاست، نه در
|
||
create_order: create_order از subscription صدا زده میشود و نباید
|
||
موفقیتِ ساختِ سفارش به دسترسبودنِ زرینپال گره بخورد. اولین بار که این
|
||
URL باز شود authority ساخته و روی سفارش ذخیره میشود؛ بازکردنِ دوباره
|
||
(مثلاً دکمهی back مرورگر) همان authority قبلی را دوباره استفاده
|
||
میکند، نه یک PaymentRequest تازه.
|
||
"""
|
||
|
||
permission_classes = [AllowAny]
|
||
throttle_scope = 'payment_pay'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(
|
||
responses={302: OpenApiTypes.NONE},
|
||
description='به درگاهِ زرینپال ریدایرکت میکند؛ بدنهی JSON ندارد.',
|
||
tags=['Payment (payment)'],
|
||
)
|
||
def get(self, request, order_id):
|
||
order = get_object_or_404(Order, pk=order_id, provider='gateway')
|
||
|
||
if order.status != 'pending':
|
||
# سفارش از قبل نتیجه گرفته (paid/failed)؛ چیزی برای پرداختِ تازه نیست.
|
||
return HttpResponseRedirect(settings.PUBLIC_BASE_URL or '/')
|
||
|
||
if not order.gateway_authority:
|
||
result = zarinpal.request_payment(order)
|
||
# ⚠️ محافظت در برابرِ race: اگه بینِ خواندنِ بالا و اینجا یک
|
||
# درخواستِ همزمانِ دیگر authority ساخته باشد، همان مقدارِ اول را
|
||
# نگه میداریم، نه این result تازه را (فقط وقتی هنوز خالی است آپدیت میشود).
|
||
Order.objects.filter(pk=order.pk, gateway_authority='').update(
|
||
gateway_authority=result.authority,
|
||
)
|
||
order.refresh_from_db(fields=['gateway_authority'])
|
||
|
||
return HttpResponseRedirect(f'{settings.ZARINPAL_STARTPAY_BASE}/{order.gateway_authority}')
|
||
|
||
|
||
class ZarinpalCallbackView(APIView):
|
||
"""
|
||
GET /api/payment/zarinpal/callback — کالبکی که زرینپال بعدِ پرداخت،
|
||
مرورگرِ کاربر رو بهش ریدایرکت میکنه (Authority, Status در query string).
|
||
|
||
برخلافِ PaymentWebhookView (یک POSTِ سرور-به-سرور با سکرتِ مشترک)،
|
||
اینجا caller خودِ مرورگرِ کاربره، نه زرینپال مستقیماً — پس هیچ
|
||
هدر/سکرتی برای اعتمادکردن نیست. امنیت از verify_payment میاد (یک
|
||
فراخوانیِ سرور-به-سرور با Authority+amount): حتی اگه کاربر Status=OK
|
||
رو دستی توی URL جعل کنه، بدونِ تاییدِ واقعیِ زرینپال چیزی paid نمیشه.
|
||
"""
|
||
|
||
permission_classes = [AllowAny]
|
||
throttle_scope = 'payment_webhook'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(
|
||
parameters=[
|
||
OpenApiParameter('Authority', OpenApiTypes.STR, required=True),
|
||
OpenApiParameter('Status', OpenApiTypes.STR, required=True),
|
||
],
|
||
responses=ZarinpalCallbackResponseSerializer,
|
||
tags=['Payment (payment)'],
|
||
)
|
||
def get(self, request):
|
||
authority = request.query_params.get('Authority', '')
|
||
status_param = request.query_params.get('Status', '')
|
||
order = get_object_or_404(Order, gateway_authority=authority, provider='gateway')
|
||
|
||
if status_param != 'OK':
|
||
new_status, gateway_ref_id = 'failed', ''
|
||
else:
|
||
result = zarinpal.verify_payment(
|
||
authority=authority, amount=order.amount, currency=order.currency,
|
||
)
|
||
new_status = 'paid' if result.is_valid else 'failed'
|
||
gateway_ref_id = result.ref_id
|
||
|
||
with transaction.atomic():
|
||
locked_order = get_object_or_404(Order.objects.select_for_update(), pk=order.pk)
|
||
apply_order_payment_result(
|
||
order=locked_order, gateway='zarinpal', gateway_ref_id=gateway_ref_id,
|
||
new_status=new_status, event='zarinpal_callback',
|
||
)
|
||
order.refresh_from_db(fields=['status'])
|
||
|
||
if settings.ZARINPAL_REDIRECT_URL:
|
||
return _redirect_to_configured_url(
|
||
f'{settings.ZARINPAL_REDIRECT_URL}?orderId={order.id}&status={order.status}'
|
||
)
|
||
data = {'orderId': str(order.id), 'status': order.status}
|
||
return Response(ZarinpalCallbackResponseSerializer(data).data)
|
||
|
||
|
||
class BazaarVerifyPurchaseView(APIView):
|
||
"""
|
||
POST /api/payment/bazaar/verify — تایید پرداخت درونبرنامهای بازار
|
||
|
||
برخلاف درگاه معمولی که با webhook خودِ درگاه تایید میشه، در پرداخت
|
||
درونبرنامهای بازار هیچ webhookی نیست: کلاینت اندروید بعد از خرید از
|
||
SDK بازار (IInAppBillingService)، یک purchaseToken میگیره و باید همون
|
||
رو برای این endpoint بفرسته تا بکاند با فراخوانی سرور-به-سرور به API
|
||
رسمی بازار (bazaar.verify_purchase)، صحتش رو تایید کنه — مشابه دقیقاً
|
||
همون چیزی که سند رسمی بازار توصیه میکنه: «هیچوقت فقط به گفتهی کلاینت
|
||
اعتماد نکنید، حتماً سمت سرور verify کنید».
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
# این endpoint یک فراخوانیِ سرور-به-سرورِ واقعی به API بازار (verify_purchase)
|
||
# تریگر میکنه، برای همین throttle_scope مجزا و نرخِ سختگیرانهتر (۱۰/hour)
|
||
# داره. کاربرهای مهمان با هر deviceId یک pk تازه میگیرن، پس
|
||
# GuestAwareBazaarThrottle برای اونها بر اساسِ IP کلید میسازه تا چرخشِ
|
||
# حساب bucket تازه نگیره؛ برای کاربرهای غیرمهمان رفتارِ per-user حفظ میشه.
|
||
throttle_scope = 'bazaar_verify'
|
||
throttle_classes = [GuestAwareBazaarThrottle]
|
||
|
||
@extend_schema(
|
||
request=BazaarVerifyRequestSerializer,
|
||
responses=BazaarVerifyResponseSerializer,
|
||
tags=['Payment (payment)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = BazaarVerifyRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
|
||
# verify_purchase (تا ~۲۰ ثانیه، دو درخواستِ پشتسرهم) عمداً *قبل* از
|
||
# بازشدنِ atomic/lock صدا زده میشه، نه داخلش — وگرنه ردیفِ Order (و هر
|
||
# تراکنشِ دیگهای روی همون Order/Subscription) تا برگشتِ پاسخِ بازار
|
||
# قفل میموند. لاک فقط برای اعمالِ نتیجه (چند میلیثانیه) گرفته میشه.
|
||
order_lookup = get_object_or_404(
|
||
Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar',
|
||
)
|
||
|
||
result = bazaar.verify_purchase(
|
||
product_sku=order_lookup.bazaar_product_sku or v['productSku'],
|
||
purchase_token=v['purchaseToken'],
|
||
)
|
||
|
||
with transaction.atomic():
|
||
order = get_object_or_404(
|
||
Order.objects.select_for_update(),
|
||
pk=v['orderId'], user_id=request.user.id, provider='bazaar',
|
||
)
|
||
|
||
new_status = 'paid' if result.is_valid else 'failed'
|
||
order.bazaar_purchase_token = v['purchaseToken']
|
||
order.save(update_fields=['bazaar_purchase_token'])
|
||
|
||
apply_order_payment_result(
|
||
order=order,
|
||
gateway='bazaar',
|
||
gateway_ref_id=v['purchaseToken'],
|
||
new_status=new_status,
|
||
event='bazaar_verify',
|
||
)
|
||
|
||
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
|
||
return Response(BazaarVerifyResponseSerializer(data).data)
|
||
|
||
|
||
class OrderDetailView(APIView):
|
||
"""GET /api/payment/orders/{id} — وضعیت سفارش"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(responses=OrderDetailResponseSerializer, tags=['Payment (payment)'])
|
||
def get(self, request, order_id):
|
||
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
|
||
data = {
|
||
'orderId': str(order.id),
|
||
'status': order.status,
|
||
'amount': order.amount,
|
||
'transactions': [
|
||
{
|
||
'id': str(t.id),
|
||
'gateway': t.gateway,
|
||
'status': t.status,
|
||
'paidAt': t.paid_at,
|
||
}
|
||
for t in order.transactions.all()
|
||
],
|
||
}
|
||
return Response(OrderDetailResponseSerializer(data).data)
|