- packs (20 / 60 / 150 questions for 45,000 / 119,000 / 259,000 toman) never expire and give 7 / 30 / 60 days of unlimited hearts; old subscription plans stay for existing subscribers but are no longer on sale - credit ledger (subscription.QuestionCredit): +N per paid pack (once per order), -1 per tutor question that reaches the API; the free daily questions are spent first; questions stopped by the server filter, cached answers and failed answers cost nothing - unlimited hearts from packs live in HeartsPass, so buying a pack never rewrites a running subscription - Message.charge records how each question was paid; GET /api/ai/quota and the ask responses report free-left-today and credits - HEARTS_ENFORCED now defaults to on: zero hearts stops answering until refill or a pack Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
12 KiB
Python
240 lines
12 KiB
Python
from django.conf import settings
|
|
from django.core.cache import cache
|
|
from django.db import transaction
|
|
from django.db.models import F
|
|
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 IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.throttling import ScopedRateThrottle
|
|
from rest_framework.views import APIView
|
|
|
|
from payment.models import Order
|
|
from payment.services import create_order, reconcile_user_orders
|
|
|
|
from .models import Plan, Subscription
|
|
from .serializers import (
|
|
PlansListResponseSerializer,
|
|
PurchaseRequestSerializer,
|
|
PurchaseResponseSerializer,
|
|
SubscriptionStatusResponseSerializer,
|
|
)
|
|
from .services import credit_balance, has_active_subscription, hearts_pass_until
|
|
|
|
|
|
class GuestAwarePurchaseThrottle(ScopedRateThrottle):
|
|
"""همون الگوی GuestAwareAIThrottle/GuestAwareDiscountThrottle/etc: چرخشِ
|
|
حساب مهمان نباید bucket تازهی purchase بده — این endpoint حساسِ مالیه
|
|
(Subscription میسازه/آپدیت میکنه، create_order رو صدا میزنه). برای
|
|
مهمانها کلیدِ 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}
|
|
|
|
|
|
# پلنها تقریباً هیچوقت عوض نمیشوند و روی صفحهی اشتراک هر بار خوانده میشوند.
|
|
# با هر save/delete روی Plan کش باطل میشود (subscription/receivers.py).
|
|
PLANS_CACHE_KEY = 'subscription:plans:v3' # v3: بستههای سوال
|
|
PLANS_CACHE_TTL = 3600
|
|
|
|
|
|
class PlansListView(APIView):
|
|
"""GET /api/subscription/plans — list available plans."""
|
|
|
|
@extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)'])
|
|
def get(self, request):
|
|
def build():
|
|
return {
|
|
'plans': [
|
|
{
|
|
'id': str(p.id),
|
|
'title': p.title,
|
|
'price': p.price,
|
|
'currency': p.currency,
|
|
'popular': p.popular,
|
|
'discountPercent': p.discount_percent,
|
|
'durationDays': p.duration_days,
|
|
'features': p.features or [],
|
|
'badge': p.badge,
|
|
'aiDailyQuota': p.ai_daily_quota,
|
|
'kind': p.kind,
|
|
'questionCredits': p.question_credits,
|
|
'heartsDays': p.hearts_days,
|
|
}
|
|
for p in Plan.objects.filter(on_sale=True).order_by('kind', 'price')
|
|
],
|
|
'freeAiDailyQuota': settings.AI_DAILY_QUOTA_FREE,
|
|
}
|
|
|
|
data = cache.get_or_set(PLANS_CACHE_KEY, build, PLANS_CACHE_TTL)
|
|
return Response(PlansListResponseSerializer(data).data)
|
|
|
|
|
|
class PurchaseView(APIView):
|
|
"""POST /api/subscription/purchase — starts a purchase (Idempotency-Key
|
|
header supported). Creates/updates a pending Subscription, then calls
|
|
the payment app's service directly (in-process, not a separate HTTP call)
|
|
to create an Order."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
# این throttle مستقلِ Idempotency-Key است چون اون فقط جلوی تکرارِ *همون*
|
|
# کلید رو میگیره، نه حجمِ درخواستهای متفاوت. کاربرهای مهمان با هر
|
|
# deviceId یک pk تازه میگیرن، پس کلیدِ per-user ساده قابلدورزدنه؛
|
|
# GuestAwarePurchaseThrottle برای مهمانها بر اساسِ IP کلید میسازه
|
|
# (مثلِ GuestAwareAIThrottle/DiscountThrottle) و برای
|
|
# کاربرهای غیرمهمان رفتارِ per-user را حفظ میکنه.
|
|
throttle_scope = 'purchase'
|
|
throttle_classes = [GuestAwarePurchaseThrottle]
|
|
|
|
@extend_schema(
|
|
request=PurchaseRequestSerializer,
|
|
responses=PurchaseResponseSerializer,
|
|
parameters=[
|
|
OpenApiParameter(
|
|
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
|
|
description=(
|
|
'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. '
|
|
'در صورت عدم ارسال، یک کلید تصادفی ساخته میشود.'
|
|
),
|
|
),
|
|
],
|
|
tags=['Subscription (subscription)'],
|
|
)
|
|
def post(self, request):
|
|
serializer = PurchaseRequestSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
v = serializer.validated_data
|
|
user = request.user
|
|
|
|
# پلنی که دیگر فروخته نمیشود (اشتراکهای ماهانه/سالانه پس از آمدنِ بستهها) خریدنی نیست.
|
|
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
|
|
idempotency_key = request.headers.get('Idempotency-Key')
|
|
|
|
if plan.kind == 'pack':
|
|
# بسته به Subscription دست نمیزند: اعتبار و روزهای قلب بعد از پرداخت از روی
|
|
# خودِ سفارش داده میشوند (payment.services → subscription.services.grant_pack).
|
|
order, payment_url = create_order(
|
|
user_id=user.id, amount=plan.price, currency=plan.currency,
|
|
idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title,
|
|
)
|
|
return Response(PurchaseResponseSerializer({'orderId': str(order.id), 'paymentUrl': payment_url}).data)
|
|
|
|
# Three-phase flow so create_order never runs inside a DB transaction/lock.
|
|
# (create_order itself no longer calls Zarinpal — PaymentRedirectView
|
|
# does, on first visit — so this split is now defensive.) Phase 1 creates/updates
|
|
# the pending Subscription, phase 2 calls create_order and restores the
|
|
# prior state on failure, phase 3 attaches order_id atomically.
|
|
existing_order = None
|
|
if idempotency_key:
|
|
existing_order = Order.objects.filter(idempotency_key=idempotency_key).first()
|
|
|
|
prior_subscription = Subscription.objects.filter(user_id=user.id).first()
|
|
prior_snapshot = None
|
|
if prior_subscription is not None:
|
|
prior_snapshot = {
|
|
'status': prior_subscription.status,
|
|
'plan_id': prior_subscription.plan_id,
|
|
'order_id': prior_subscription.order_id,
|
|
}
|
|
|
|
# اشتراکِ فعال با باز کردنِ صفحهی خرید دست نمیخورد. پیشتر وضعیتش
|
|
# همینجا «pending» میشد و کاربرِ پولی اگر خرید را تمام نمیکرد
|
|
# مزیتش را از دست میداد. پلن و تمدید از خودِ سفارش میآیند
|
|
# (activate_subscription)، پس لازم نیست Subscription الان عوض شود.
|
|
currently_active = prior_subscription is not None and has_active_subscription(user.id)
|
|
|
|
if currently_active:
|
|
subscription = prior_subscription
|
|
subscription_is_new = False
|
|
prior_snapshot = None
|
|
elif existing_order is not None:
|
|
# Retry path: leave an existing subscription untouched, only
|
|
# create one if it's genuinely missing.
|
|
if prior_subscription is None:
|
|
# Fresh row: version starts at 1 to give it a CAS token.
|
|
subscription = Subscription.objects.create(
|
|
user_id=user.id, plan=plan, status='pending', version=1,
|
|
)
|
|
subscription_is_new = True
|
|
else:
|
|
subscription = prior_subscription
|
|
subscription_is_new = False
|
|
else:
|
|
subscription, subscription_is_new = Subscription.objects.update_or_create(
|
|
user_id=user.id,
|
|
defaults={'plan': plan, 'status': 'pending'},
|
|
)
|
|
# Two concurrent requests can reach an identical intermediate state,
|
|
# so version is bumped atomically here to give this write a unique
|
|
# CAS token the cleanup below can match against.
|
|
Subscription.objects.filter(pk=subscription.pk).update(version=F('version') + 1)
|
|
subscription.refresh_from_db(fields=['version'])
|
|
|
|
try:
|
|
order, payment_url = create_order(
|
|
user_id=user.id,
|
|
amount=plan.price,
|
|
currency=plan.currency,
|
|
subscription_id=subscription.id,
|
|
idempotency_key=idempotency_key,
|
|
plan_id=plan.id,
|
|
plan_title=plan.title,
|
|
)
|
|
except Exception:
|
|
# Guard by pk+status+plan_id+order_id+version so this cleanup only
|
|
# matches the exact row state phase 1 wrote; any concurrent writer
|
|
# (another request, a payment webhook, etc.) changes the match and
|
|
# the delete/update below becomes a no-op instead of clobbering it.
|
|
guard = {
|
|
'pk': subscription.pk,
|
|
'status': subscription.status,
|
|
'plan_id': subscription.plan_id,
|
|
'order_id': subscription.order_id,
|
|
'version': subscription.version,
|
|
}
|
|
with transaction.atomic():
|
|
if subscription_is_new:
|
|
Subscription.objects.filter(**guard).delete()
|
|
elif prior_snapshot is not None:
|
|
Subscription.objects.filter(**guard).update(**prior_snapshot)
|
|
raise
|
|
|
|
if subscription.order_id != order.id and not currently_active:
|
|
with transaction.atomic():
|
|
subscription.order_id = order.id
|
|
subscription.save(update_fields=['order_id'])
|
|
|
|
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
|
|
return Response(PurchaseResponseSerializer(data).data)
|
|
|
|
|
|
class SubscriptionStatusView(APIView):
|
|
"""GET /api/subscription/status — current subscription status."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
@extend_schema(responses=SubscriptionStatusResponseSerializer, tags=['Subscription (subscription)'])
|
|
def get(self, request):
|
|
# پرداختی که تأییدش در کالبک گم شده، اینجا دوباره سنجیده میشود.
|
|
reconcile_user_orders(request.user.id)
|
|
subscription = Subscription.objects.filter(user_id=request.user.id).first()
|
|
data = {
|
|
# وضعیتِ «active» با گذشتِ تاریخ خودبهخود عوض نمیشود؛ همان شرطی که
|
|
# قلبِ نامحدود را میدهد ملاک است، وگرنه اشتراکِ منقضی «فعال» دیده میشد.
|
|
'active': has_active_subscription(request.user.id),
|
|
'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None,
|
|
'expiresAt': subscription.expires_at if subscription else None,
|
|
'questionCredits': credit_balance(request.user.id),
|
|
'unlimitedHeartsUntil': hearts_pass_until(request.user.id),
|
|
}
|
|
return Response(SubscriptionStatusResponseSerializer(data).data)
|