Paywall: first unit of each grade free; 1/3/6-month subscriptions; AI sold separately
- content: unit_is_free/has_unit_access; questions, node quiz, answer and lesson/node complete return 402 subscription_required outside the first unit without an active subscription. /api/home units carry requiresSubscription. - subscription.0013: monthly 498k, quarterly 798k, six-month 1,298k toman, unlimited hearts, 3 AI questions/day; packs stay on sale for more questions. - AI_DAILY_QUOTA_FREE default 3 -> 2. - /api/subscription/status adds planTitle, planDurationDays, aiDailyQuota. - tests: content/tests_paywall.py; existing unlock-order tests get a subscription. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,7 +85,7 @@ QDRANT_API_KEY=
|
||||
# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (پیشفرض ۳). سقفِ مشترکان روی
|
||||
# هر پلن در ادمین است (ai_daily_quota). حسابِ هزینه: docs/ai-cost-and-guardrails.md
|
||||
# AI_DAILY_QUOTA_FREE=3
|
||||
# AI_DAILY_QUOTA_FREE=2
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۵) پرداخت
|
||||
|
||||
@@ -444,6 +444,8 @@ class AskAIViewThrottleTests(TestCase):
|
||||
HTTP_IDEMPOTENCY_KEY=idempotency_key,
|
||||
)
|
||||
|
||||
# سقفِ روزانه (پیشفرض ۲) نباید پیش از throttle برسد؛ این تست فقط throttle را میسنجد.
|
||||
@override_settings(AI_DAILY_QUOTA_FREE=10)
|
||||
def test_requests_within_rate_succeed_and_excess_is_throttled(self):
|
||||
# هر درخواست idempotency-key متفاوت دارد تا هر بار واقعاً از کوتای
|
||||
# روزانه مصرف کند (نه یک replay ساده)، و صرفاً throttle سنجیده شود.
|
||||
|
||||
@@ -877,8 +877,9 @@ OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1')
|
||||
# docs/architecture.md. پیشفرض همینجاست تا دیپلوی به ویرایشِ دستیِ .env نیاز نداشته باشد.
|
||||
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4.1-mini')
|
||||
# سقفِ سوالِ روزانه از دستیار برای کاربرِ بدونِ اشتراک (مشترکان: Plan.ai_daily_quota).
|
||||
# ۳ پیشنهادِ docs/ai-cost-and-guardrails.md است؛ تا ۲۰۲۶-۱۰-۰۱ برای همه ۱۵ بود.
|
||||
AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '3'))
|
||||
# ۲ از ۲۰۲۶-۱۰-۱۰ (تصمیمِ مالک): دستیار جدا از اشتراک فروخته میشود (بستهی سوال)، پس سهمِ رایگان فقط
|
||||
# برای آشنایی است. پیش از آن ۳، و تا ۲۰۲۶-۱۰-۰۱ برای همه ۱۵ بود.
|
||||
AI_DAILY_QUOTA_FREE = int(os.environ.get('AI_DAILY_QUOTA_FREE', '2'))
|
||||
# سقفِ روزانهی *کلِ سامانه* برای سوالهایی که به مدل میرسند (رایگان یا با اعتبار)، همان الگوی
|
||||
# OTP_SMS_GLOBAL_DAILY_MAX. سقفهای بالا برای هر کاربر و هر IPاند؛ کسی با چند IP و مهمانهای
|
||||
# تازه میتوانست خرجِ GapGPT را بیسقف بالا ببرد. ۳۰۰۰ با ~۰٫۲ سنت برای هر سوال یعنی حدودِ ۶
|
||||
|
||||
@@ -51,6 +51,7 @@ class UnitWithNodesSerializer(serializers.Serializer):
|
||||
id = serializers.CharField()
|
||||
title = serializers.CharField()
|
||||
blurb = serializers.CharField(allow_blank=True)
|
||||
requiresSubscription = serializers.BooleanField(help_text='این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است).')
|
||||
nodes = NodeStatusSerializer(many=True)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
(با correctCount=0)؛ نقشه هم وضعیتِ «باز» را از روی درسهای تمامشده حساب میکرد، پس تمامکردنِ
|
||||
درسِ جزیرهی قفل آن را باز میکرد. فقط قلبِ صفر جلوی بیجوابتمامکردن را میگرفت.
|
||||
"""
|
||||
from subscription.models import Subscription
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
@@ -127,6 +128,9 @@ class FinishWithoutAnswersTests(_World, TestCase):
|
||||
|
||||
def test_the_normal_path_still_works_in_order(self):
|
||||
"""جزیرهی اول، بعد دوم، بعد سوم؛ و اولین جزیرهی فصلِ دیگر از اول باز است."""
|
||||
# فصلِ دوم اشتراک میخواهد (content.views.unit_is_free)؛ این تست ترتیبِ باز شدن را میسنجد، نه پرداخت.
|
||||
Subscription.objects.create(user_id=self.user.id, status='active', expires_at=None)
|
||||
|
||||
self.assertEqual(self.statuses(), {'یک': 'unlocked', 'دو': 'locked', 'سه': 'locked', 'الف': 'unlocked'})
|
||||
for node in (self.n1, self.n2, self.n3, self.m1):
|
||||
record_answers(self.user.id, node=node)
|
||||
|
||||
@@ -273,8 +273,9 @@ class NodeQuizTests(TestCase):
|
||||
مبحثی که هنوز چهار سوال ندارد نباید وانمود کند دارد — فرانت باید
|
||||
بتواند تفاوتِ «آزمونِ کوتاه» و «خطا» را بفهمد.
|
||||
"""
|
||||
# پایهی جدا تا فصلِ اولِ (رایگانِ) همان پایه باشد؛ پرداخت اینجا موضوع نیست.
|
||||
thin_unit = Unit.objects.create(
|
||||
grade=self.node.unit.grade, title='فصل ۵', order=2)
|
||||
grade=Grade.objects.create(title='پایه دهم', icon='', color=''), title='فصل ۵', order=1)
|
||||
thin = Node.objects.create(unit=thin_unit, title='کمسوال', order=1)
|
||||
lesson = Lesson.objects.create(node=thin, title='تنها درس')
|
||||
question = Question.objects.create(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
میکند، ولی جزیره فقط وقتی «تمام» حساب میشد که تکتکِ درسهایش (۸ تا ۱۳
|
||||
درس) جدا تمام شده باشند؛ دانشآموز بعد از جزیرهی اولِ هر فصل گیر میکرد.
|
||||
"""
|
||||
from subscription.models import Subscription
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
|
||||
@@ -30,6 +31,8 @@ class NodeQuizCompleteTests(TestCase):
|
||||
self.other_unit_first = self._node(self.next_unit, 'فصلِ بعد', 1, lessons=2)
|
||||
|
||||
self.user = User.objects.create(phone='09120000009', grade_id=self.grade.id)
|
||||
# فصلِ دوم اشتراک میخواهد (content.views.unit_is_free)؛ این تست ترتیبِ باز شدن را میسنجد، نه پرداخت.
|
||||
Subscription.objects.create(user_id=self.user.id, status='active', expires_at=None)
|
||||
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
|
||||
|
||||
def _node(self, unit, title, order, lessons):
|
||||
|
||||
70
content/tests_paywall.py
Normal file
70
content/tests_paywall.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
فصلِ اولِ هر پایه رایگان است و بقیه اشتراکِ زمانی میخواهند (۲۰۲۶-۱۰-۱۰).
|
||||
بازیکردنِ فصلِ قفل از هر مسیری (سوالهای درس، آزمونِ جزیره، جواب، پایانِ درس/جزیره) ۴۰۲ میگیرد.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from accounts.authentication import issue_access_token
|
||||
from accounts.models import User
|
||||
from subscription.models import Subscription
|
||||
|
||||
from .models import Choice, Grade, Lesson, Node, Question, Unit
|
||||
|
||||
|
||||
class PaywallTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||||
self.free = self._node(Unit.objects.create(grade=grade, title='فصل ۱', order=1))
|
||||
self.paid = self._node(Unit.objects.create(grade=grade, title='فصل ۲', order=2))
|
||||
self.user = User.objects.create(phone='09120007901', grade_id=grade.id)
|
||||
self.client = APIClient()
|
||||
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
|
||||
|
||||
def _node(self, unit):
|
||||
node = Node.objects.create(unit=unit, title=unit.title, order=1)
|
||||
lesson = Lesson.objects.create(node=node, title='درس')
|
||||
q = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
|
||||
self.right = Choice.objects.create(question=q, value='۱', is_correct=True)
|
||||
node.q, node.choice, node.lesson = q, self.right, lesson
|
||||
return node
|
||||
|
||||
def _every_route(self, node):
|
||||
return [
|
||||
self.client.get(f'/api/lessons/{node.lesson.id}/questions').status_code,
|
||||
self.client.get(f'/api/nodes/{node.id}/quiz').status_code,
|
||||
self.client.post(f'/api/questions/{node.q.id}/answer', {'selectedOptionId': str(node.choice.id)},
|
||||
format='json').status_code,
|
||||
self.client.post(f'/api/nodes/{node.id}/quiz/complete', {'correctCount': 1, 'totalQuestions': 1},
|
||||
format='json').status_code,
|
||||
]
|
||||
|
||||
def test_the_second_unit_is_closed_without_a_subscription(self):
|
||||
self.assertEqual(self._every_route(self.paid), [402, 402, 402, 402])
|
||||
home = self.client.get('/api/home').json()
|
||||
self.assertEqual([u['requiresSubscription'] for u in home['units']], [False, True])
|
||||
|
||||
def test_the_first_unit_is_free(self):
|
||||
self.assertNotIn(402, self._every_route(self.free))
|
||||
|
||||
def test_an_active_subscription_opens_everything_and_an_expired_one_does_not(self):
|
||||
sub = Subscription.objects.create(user_id=self.user.id, status='active',
|
||||
expires_at=timezone.now() + timezone.timedelta(days=3))
|
||||
self.assertNotIn(402, self._every_route(self.paid))
|
||||
self.assertEqual([u['requiresSubscription'] for u in self.client.get('/api/home').json()['units']],
|
||||
[False, False])
|
||||
sub.expires_at = timezone.now() - timezone.timedelta(minutes=1)
|
||||
sub.save()
|
||||
self.assertEqual(self.client.get(f'/api/nodes/{self.paid.id}/quiz').status_code, 402)
|
||||
|
||||
def test_status_reports_plan_title_and_quota(self):
|
||||
from subscription.models import Plan
|
||||
plan = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
|
||||
Subscription.objects.create(user_id=self.user.id, plan=plan, status='active',
|
||||
expires_at=timezone.now() + timezone.timedelta(days=10))
|
||||
body = self.client.get('/api/subscription/status').json()
|
||||
self.assertEqual((body['active'], body['planTitle'], body['planDurationDays'], body['aiDailyQuota']),
|
||||
(True, 'سهماهه', 90, 3))
|
||||
@@ -263,6 +263,36 @@ def node_is_unlocked(user_id, node) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def unit_is_free(unit_id: int, grade_id: int) -> bool:
|
||||
"""
|
||||
فصلِ رایگان: فقط اولین فصلِ هر پایه (به ترتیبِ `Unit.order`، همان ترتیبِ نقشه). بقیهی فصلها
|
||||
اشتراکِ زمانی میخواهند (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰: «هر کاربر یک درس را ببرد جلو،
|
||||
برای بقیه اشتراک بخرد»؛ مالکِ پروژه «درس» را «فصل» گرفت).
|
||||
"""
|
||||
tree = _grade_tree(grade_id) or []
|
||||
return bool(tree) and tree[0]['id'] == str(unit_id)
|
||||
|
||||
|
||||
def has_unit_access(user_id, unit_id: int, grade_id: int) -> bool:
|
||||
from subscription.services import has_active_subscription
|
||||
if unit_is_free(unit_id, grade_id):
|
||||
return True
|
||||
return user_id is not None and has_active_subscription(user_id)
|
||||
|
||||
|
||||
def subscription_required_response() -> Response:
|
||||
# ۴۰۲ و نه ۴۰۳: فرانت از روی همین کد به صفحهی اشتراک میرود، نه پیامِ «قفل است».
|
||||
return Response(
|
||||
{'detail': 'این فصل با اشتراک باز میشود. فصلِ اولِ هر پایه رایگان است.',
|
||||
'code': 'subscription_required'},
|
||||
status=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
)
|
||||
|
||||
|
||||
def _user_id_or_none(request):
|
||||
return request.user.id if getattr(request.user, 'is_authenticated', False) else None
|
||||
|
||||
|
||||
def _completion_body(title, stars, xp):
|
||||
"""
|
||||
متنِ اعلانِ «تمام کردی». عددها فارسی، چون اپ متنِ اعلان را همانطور که
|
||||
@@ -363,8 +393,10 @@ class HomeView(APIView):
|
||||
).values_list('node_id', flat=True)
|
||||
)
|
||||
|
||||
from subscription.services import has_active_subscription
|
||||
subscribed = user_id is not None and has_active_subscription(user_id)
|
||||
units_payload = []
|
||||
for unit in tree:
|
||||
for index, unit in enumerate(tree):
|
||||
node_payload = []
|
||||
unlock_next = True # اولین نودِ قفلنشده در هر واحد، باز میمونه
|
||||
for node in unit['nodes']:
|
||||
@@ -395,6 +427,9 @@ class HomeView(APIView):
|
||||
'id': unit['id'],
|
||||
'title': unit['title'],
|
||||
'blurb': unit.get('blurb', ''),
|
||||
# فقط فصلِ اولِ هر پایه رایگان است (`unit_is_free`)؛ true یعنی همین کاربر برای بازیکردنش
|
||||
# اشتراک لازم دارد. وضعیتِ جزیرهها عوض نمیشود تا پیشرفتِ کسی که اشتراکش تمام شد گم نشود.
|
||||
'requiresSubscription': index > 0 and not subscribed,
|
||||
'nodes': node_payload,
|
||||
})
|
||||
|
||||
@@ -459,7 +494,9 @@ class LessonQuestionsView(APIView):
|
||||
|
||||
@extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)'])
|
||||
def get(self, request, lesson_id):
|
||||
lesson = get_object_or_404(Lesson, pk=lesson_id)
|
||||
lesson = get_object_or_404(Lesson.objects.select_related('node__unit'), pk=lesson_id)
|
||||
if not has_unit_access(_user_id_or_none(request), lesson.node.unit_id, lesson.node.unit.grade_id):
|
||||
return subscription_required_response()
|
||||
questions = lesson.questions.prefetch_related('choices').all()
|
||||
data = {'questions': [
|
||||
{
|
||||
@@ -501,7 +538,10 @@ class QuestionAnswerView(APIView):
|
||||
tags=['Curriculum (content)'],
|
||||
)
|
||||
def post(self, request, question_id):
|
||||
question = get_object_or_404(Question, pk=question_id)
|
||||
question = get_object_or_404(Question.objects.select_related('lesson__node__unit'), pk=question_id)
|
||||
unit = question.lesson.node.unit
|
||||
if not has_unit_access(request.user.id, unit.id, unit.grade_id):
|
||||
return subscription_required_response()
|
||||
serializer = AnswerRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
selected_option_id = serializer.validated_data['selectedOptionId']
|
||||
@@ -677,6 +717,8 @@ class LessonCompleteView(APIView):
|
||||
# بیامتیاز؛ فقط قفل بودنِ جزیرهاش بررسی میشود.
|
||||
# جزیرهی قفل را با تمامکردنِ درسهایش هم نمیشود باز کرد (وضعیتِ «باز» از روی
|
||||
# درسهای تمامشده هم حساب میشود، پس بدونِ این قفل از همین راه دور زده میشد).
|
||||
if not has_unit_access(user_id, lesson.node.unit_id, lesson.node.unit.grade_id):
|
||||
return subscription_required_response()
|
||||
if not node_is_unlocked(user_id, lesson.node):
|
||||
return node_locked_response()
|
||||
|
||||
@@ -885,6 +927,8 @@ class NodeQuizView(APIView):
|
||||
node = get_object_or_404(
|
||||
Node.objects.select_related('unit'), pk=node_id,
|
||||
)
|
||||
if not has_unit_access(_user_id_or_none(request), node.unit_id, node.unit.grade_id):
|
||||
return subscription_required_response()
|
||||
try:
|
||||
size = int(request.query_params.get('size', self.DEFAULT_SIZE))
|
||||
except (TypeError, ValueError):
|
||||
@@ -1033,6 +1077,8 @@ class NodeQuizCompleteView(APIView):
|
||||
return respond(existing, UserStats.objects.filter(user_id=user_id).first())
|
||||
|
||||
# قفل: جزیرهی قفل را نمیشود تمام کرد (تا ۲۰۲۶-۱۰-۰۷ میشد، با correctCount=0).
|
||||
if not has_unit_access(user_id, node.unit_id, node.unit.grade_id):
|
||||
return subscription_required_response()
|
||||
if not node_is_unlocked(user_id, node):
|
||||
return node_locked_response()
|
||||
|
||||
|
||||
@@ -51,8 +51,9 @@ Message.objects.filter(role='assistant', created_at__gte=since).values('conversa
|
||||
|
||||
پلنهای اشتراکی پاک نشدند (`on_sale=False`) تا مشترکِ فعلی سقفش را نگه دارد.
|
||||
|
||||
- **ترتیبِ خرج:** اول `AI_DAILY_QUOTA_FREE` سوالِ رایگانِ همان روز (پیشفرض ۳)، بعد اعتبارِ بسته؛ مشترکِ
|
||||
قدیمی سقفِ `Plan.ai_daily_quota`ِ خودش را دارد.
|
||||
- **ترتیبِ خرج:** اول سقفِ روزانه (بیاشتراک `AI_DAILY_QUOTA_FREE`، پیشفرض ۲؛ مشترک `Plan.ai_daily_quota`،
|
||||
برای یک/سه/ششماهه ۳)، بعد اعتبارِ بسته. اشتراک محتوا و قلب میفروشد؛ سوالِ بیشتر از دستیار فقط با بسته.
|
||||
بدترین حالتِ مشترکِ ششماهه با سقفِ ۳: ~۶۳ هزار تومان در ماه، ۲۹٪ِ سهمِ ماهانهی قیمت.
|
||||
- **چه چیزی خرج میشود:** هر سوالی که به API برسد، از جمله ردِ «غیرِ ریاضی» توسطِ مدل. ردِ صافیِ سرور،
|
||||
جوابِ کششده و جوابی که ساخته نشد چیزی کم نمیکنند. `Message.charge` روی هر پیامِ کاربر میگوید
|
||||
سوال از کجا پرداخت شد (`free` / `credit` / `none`).
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| ۸ | سرور بدونِ ArvanCloud روی پورتِ ۸۰ هم جواب میدهد | متوسط | ادمینِ سرور |
|
||||
| ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقفهای مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis |
|
||||
| ۱۱ | کدِ OTP بیهش در دیتابیس و در فهرستِ پنل | کم | کد |
|
||||
| ۱۲ | هزینهی دستیارِ AI با ساختِ شمارهی تازه (هر شماره ۳ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
|
||||
| ۱۲ | هزینهی دستیارِ AI با ساختِ شمارهی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
|
||||
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر؛ بکاپِ پیش از migrate خودکار است ولی بیرون از ماشین نیست | کم (داده) | زیرساخت |
|
||||
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
|
||||
| ۱۶ | فرانت توکن را در `localStorage` نگه میدارد | کم | فرانت |
|
||||
@@ -106,8 +106,8 @@
|
||||
|
||||
## ۱۲. هزینهی دستیارِ AI
|
||||
|
||||
- **چیست:** هر کاربر روزی ۳ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`)
|
||||
و بعد فقط با اعتبارِ خریدهشده؛ ۲۰ در ساعت. هر شمارهی تازه ۳ سؤالِ رایگان میگیرد و ساختنِ شماره با سقفِ پیامک
|
||||
- **چیست:** هر کاربر روزی ۲ سؤالِ رایگان دارد (`AI_DAILY_QUOTA_FREE`)
|
||||
و بعد فقط با اعتبارِ خریدهشده؛ ۲۰ در ساعت. هر شمارهی تازه ۲ سؤالِ رایگان میگیرد و ساختنِ شماره با سقفِ پیامک
|
||||
(بند ۲۴) محدود است. سقفِ
|
||||
روزانهی کلِ سامانه (`AI_GLOBAL_DAILY_MAX`، ۳۰۰۰) خرجِ کل را محدود میکند. طولِ سؤال حداکثر ۲۰۰۰ نویسه است.
|
||||
- **خطر:** با چند IP، اعتبارِ GapGPT در چند ساعت تمام میشود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵
|
||||
|
||||
@@ -700,7 +700,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
|
||||
| انتخابِ مدلِ جواب: ۱۰۲ مسئلهی تصحیحشدهی دستی | `gpt-4.1-mini` : ۹۵–۹۶ درست · `gpt-4o-mini` : ۸۵–۹۰ |
|
||||
| شکلها: ۶۴ مسئلهی تصادفیِ کتاب، وارسیِ چشمیِ تکتک | ۴۰ درست، ۱۲ قابلِ قبول، ۷ غلط، ۵ بیشکل — حدودِ یکی از پنج هنوز ایراد دارد؛ برای همین شکل همیشه کنارِ حلِ متنی میآید |
|
||||
| سرعت | جوابِ کامل با شکل ~۳۰ ثانیه؛ اندازهگیری شد که در خودِ GapGPT است («فقط بگو سلام» = ۷ ثانیه) |
|
||||
| سقفها | ۳ سؤالِ رایگان در روز، بعد از اعتبارِ بسته؛ ۲۰ در ساعت؛ مهمانها بر اساسِ IP (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود) |
|
||||
| سقفها | ۲ سؤالِ رایگان در روز (مشترک ۳)، بعد از اعتبارِ بسته؛ ۲۰ در ساعت؛ مهمانها بر اساسِ IP (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود) |
|
||||
|
||||
### ۹-۷ فقط ریاضی، و چه چیزی هزینه دارد
|
||||
|
||||
@@ -708,7 +708,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
|
||||
|
||||
| قاعدهی خرج | |
|
||||
|---|---|
|
||||
| ترتیب | اول ۳ سوالِ رایگانِ همان روز، بعد اعتبارِ بسته ( `Message.charge` : `free` / `credit` / `none` ) |
|
||||
| ترتیب | اول سقفِ روزانه (۲ رایگان، مشترک ۳)، بعد اعتبارِ بسته ( `Message.charge` : `free` / `credit` / `none` ) |
|
||||
| خرج میشود | هر سوالی که به مدل برسد، از جمله ردِ «غیرِ ریاضی» توسطِ مدل |
|
||||
| خرج نمیشود | ردِ صافیِ سرور، جوابِ کششده، جوابی که ساخته نشد، و تکرارِ درخواست با همان `Idempotency-Key` |
|
||||
| همزمانی | دو سوالِ همزمان با یک اعتبار فقط یک جواب میگیرند؛ با دو thread واقعی آزموده میشود ( `ai_tutor/tests_credit_concurrency.py` ) |
|
||||
@@ -881,7 +881,7 @@ fetch ← `npm ci` ← بررسیِ نوعها ← build در `dist.next` ←
|
||||
| `ZARINPAL_MERCHANT_ID` · `BAZAAR_*` | پرداخت بسته میماند (۵۰۳). |
|
||||
| `CACHE_URL` | کشِ جدا برای هر worker؛ `/api/health` هشدار میدهد. |
|
||||
| `HEARTS_ENFORCED` · `TEST_LOGIN_PHONES` | روشن (قلب واقعی؛ `0` خاموشش میکند) · هیچ حسابِ آزمایشی. |
|
||||
| `AI_DAILY_QUOTA_FREE` | ۳ سوالِ رایگان در روز. |
|
||||
| `AI_DAILY_QUOTA_FREE` | ۲ سوالِ رایگان در روز (پیشفرض). |
|
||||
| `DJANGO_DEBUG` | خاموش — و **هرگز روی سرور روشن نشود** (کدِ ورودِ همگانیِ `0000` را فعال میکند). |
|
||||
|
||||
مقدارهای `.env` با پیشفرضهای کد ادغام میشوند؛ یک `.env` ِ ناقص سرویس را نمیخواباند. شرحِ کامل در `.env.example` و `docs/operations.md`.
|
||||
|
||||
44
subscription/migrations/0013_monthly_plans_2026_10.py
Normal file
44
subscription/migrations/0013_monthly_plans_2026_10.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
اشتراکِ زمانی برمیگردد (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰): فصلِ اولِ هر پایه رایگان است و
|
||||
بقیهی فصلها با اشتراک باز میشوند (content.views.unit_is_free). بستههای سوال کنارِ اشتراک میمانند.
|
||||
|
||||
قیمتها از سینیور: یکماهه ۴۹۸، سهماهه ۷۹۸، ششماهه ۱٬۲۹۸ هزار تومان.
|
||||
|
||||
اشتراک «محتوا و قلب» میفروشد، نه دستیار (تصمیمِ مالک، ۲۰۲۶-۱۰-۱۰): سقفِ دستیار برای مشترک ۳ سوال در
|
||||
روز است و بیشتر از آن با بستهی سوال خریده میشود. حساب: هر سوال دستِ بالا ~۷۰۰ تومان
|
||||
(docs/ai-cost-and-guardrails.md). با سقفِ ۸، مشترکِ ششماههای که هر روز سقف را پر کند ~۱۶۸ هزار تومان در
|
||||
ماه هزینه داشت، یعنی ۷۸٪ِ سهمِ ماهانهی قیمت (۲۱۶ هزار)، و با ۱۵٪ِ کافهبازار ~۷٪ میماند. با سقفِ ۳ همین
|
||||
بدترین حالت ~۶۳ هزار تومان است: ۲۹٪ِ ششماهه، ۲۴٪ِ سهماهه، ۱۳٪ِ یکماهه. این بدترین حالت است، نه
|
||||
میانگین؛ مصرفِ واقعی سنجیده نشده. سقف از ادمین عوض میشود (Plan.ai_daily_quota).
|
||||
|
||||
پلنهای اشتراکیِ قدیمی (ماهانه/سالانه) دست نمیخورند و on_sale=False میمانند.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
FEATURES = ['همهی فصلها و جزیرهها باز', 'قلبِ نامحدود', '۳ سوال در روز از دستیارِ هوشمند']
|
||||
PLANS = [
|
||||
{'title': 'یکماهه', 'price': 498_000, 'duration_days': 30, 'badge': '', 'popular': False},
|
||||
{'title': 'سهماهه', 'price': 798_000, 'duration_days': 90, 'badge': 'محبوبترین', 'popular': True},
|
||||
{'title': 'ششماهه', 'price': 1_298_000, 'duration_days': 180, 'badge': 'بهصرفهترین', 'popular': False},
|
||||
]
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
Plan = apps.get_model('subscription', 'Plan')
|
||||
# بستهها هم «محبوبترین» داشتند؛ دو کارتِ «محبوبترین» در یک صفحه معنا ندارد.
|
||||
Plan.objects.filter(kind='pack').update(popular=False)
|
||||
for plan in PLANS:
|
||||
if not Plan.objects.filter(kind='subscription', on_sale=True, duration_days=plan['duration_days']).exists():
|
||||
Plan.objects.create(kind='subscription', currency='IRT', discount_percent=0, ai_daily_quota=3,
|
||||
features=FEATURES, on_sale=True, question_credits=0, hearts_days=0, **plan)
|
||||
|
||||
|
||||
def undo(apps, schema_editor):
|
||||
Plan = apps.get_model('subscription', 'Plan')
|
||||
Plan.objects.filter(kind='subscription', on_sale=True, subscriptions__isnull=True,
|
||||
title__in=[p['title'] for p in PLANS]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('subscription', '0012_plan_bazaar_sku')]
|
||||
operations = [migrations.RunPython(seed, undo)]
|
||||
@@ -43,7 +43,10 @@ class PurchaseResponseSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class SubscriptionStatusResponseSerializer(serializers.Serializer):
|
||||
active = serializers.BooleanField(help_text='اشتراکِ زمانی (ماهانه/سالانهی قدیمی)؛ نه بسته.')
|
||||
active = serializers.BooleanField(help_text='اشتراکِ زمانی فعال (یک/سه/ششماهه)؛ نه بسته.')
|
||||
planTitle = serializers.CharField(allow_null=True, help_text='نامِ پلنِ اشتراکِ فعال؛ null بیاشتراک.')
|
||||
planDurationDays = serializers.IntegerField(allow_null=True, help_text='طولِ دورهی پلنِ فعال به روز.')
|
||||
aiDailyQuota = serializers.IntegerField(help_text='سقفِ سوالِ روزانهی همین کاربر از دستیار.')
|
||||
planId = serializers.CharField(allow_null=True)
|
||||
expiresAt = serializers.DateTimeField(allow_null=True)
|
||||
questionCredits = serializers.IntegerField(help_text='سوالهای خریدهشدهی باقیمانده (منقضی نمیشوند).')
|
||||
|
||||
@@ -486,7 +486,7 @@ class YearlyPlanSeedTests(TestCase):
|
||||
self.assertTrue(plan.features)
|
||||
|
||||
def test_a_monthly_plan_is_created_next_to_it(self):
|
||||
plan = Plan.objects.get(duration_days=30)
|
||||
plan = Plan.objects.get(duration_days=30, on_sale=False)
|
||||
self.assertEqual((plan.price, plan.ai_daily_quota), (179_000, 10))
|
||||
|
||||
def test_repricing_leaves_an_admin_edited_yearly_plan_alone(self):
|
||||
@@ -498,16 +498,20 @@ class YearlyPlanSeedTests(TestCase):
|
||||
importlib.import_module('subscription.migrations.0008_paid_quota_ten_a_day').apply(global_apps, None)
|
||||
plan = Plan.objects.get(title='اشتراک یکساله')
|
||||
self.assertEqual((plan.price, plan.ai_daily_quota), (450_000, 9))
|
||||
self.assertEqual(Plan.objects.filter(duration_days=30).count(), 1)
|
||||
self.assertEqual(Plan.objects.filter(duration_days=30, on_sale=False).count(), 1)
|
||||
|
||||
def test_only_question_packs_are_on_sale(self):
|
||||
def test_packs_and_new_subscriptions_are_on_sale(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
data = self.client.get('/api/subscription/plans').json()
|
||||
self.assertEqual({p['kind'] for p in data['plans']}, {'pack'})
|
||||
self.assertEqual([p['questionCredits'] for p in data['plans']], [20, 60, 150])
|
||||
self.assertEqual([p['heartsDays'] for p in data['plans']], [7, 30, 60])
|
||||
self.assertEqual(data['freeAiDailyQuota'], 3)
|
||||
packs = [p for p in data['plans'] if p['kind'] == 'pack']
|
||||
subs = [p for p in data['plans'] if p['kind'] == 'subscription']
|
||||
self.assertEqual([p['questionCredits'] for p in packs], [20, 60, 150])
|
||||
self.assertEqual([p['heartsDays'] for p in packs], [7, 30, 60])
|
||||
# subscription.0013: یک/سه/ششماهه با سقفِ ۳ سوال در روز.
|
||||
self.assertEqual([(p['durationDays'], p['price'], p['aiDailyQuota']) for p in subs],
|
||||
[(30, 498_000, 3), (90, 798_000, 3), (180, 1_298_000, 3)])
|
||||
self.assertEqual(data['freeAiDailyQuota'], 2)
|
||||
# پلنهای اشتراکی پاک نشدهاند؛ فقط فروخته نمیشوند.
|
||||
self.assertFalse(Plan.objects.get(title='اشتراک یکساله').on_sale)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from .serializers import (
|
||||
PurchaseResponseSerializer,
|
||||
SubscriptionStatusResponseSerializer,
|
||||
)
|
||||
from .services import credit_balance, has_active_subscription, hearts_pass_until
|
||||
from .services import ai_daily_quota, credit_balance, has_active_subscription, hearts_pass_until
|
||||
|
||||
|
||||
# پلنها تقریباً هیچوقت عوض نمیشوند و روی صفحهی اشتراک هر بار خوانده میشوند.
|
||||
@@ -104,8 +104,8 @@ class PurchaseView(APIView):
|
||||
idempotency_key = request.headers.get('Idempotency-Key')
|
||||
|
||||
if v['paymentMethod'] == 'bazaar':
|
||||
# فقط بسته: اشتراکها دیگر فروخته نمیشوند و مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز
|
||||
# با خریدِ واقعی آزموده نشده. SKU از پلن، نه از کلاینت.
|
||||
# فقط بسته: مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز با خریدِ واقعی آزموده نشده، پس
|
||||
# اشتراک فعلاً فقط از زرینپال فروخته میشود. SKU از پلن، نه از کلاینت.
|
||||
if plan.kind != 'pack':
|
||||
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بستههای سوال است.'})
|
||||
if not plan.bazaar_sku:
|
||||
@@ -229,11 +229,17 @@ class SubscriptionStatusView(APIView):
|
||||
def get(self, request):
|
||||
# پرداختی که تأییدش در کالبک گم شده، اینجا دوباره سنجیده میشود.
|
||||
reconcile_user_orders(request.user.id)
|
||||
subscription = Subscription.objects.filter(user_id=request.user.id).first()
|
||||
subscription = Subscription.objects.select_related('plan').filter(user_id=request.user.id).first()
|
||||
active = has_active_subscription(request.user.id)
|
||||
data = {
|
||||
# وضعیتِ «active» با گذشتِ تاریخ خودبهخود عوض نمیشود؛ همان شرطی که
|
||||
# قلبِ نامحدود را میدهد ملاک است، وگرنه اشتراکِ منقضی «فعال» دیده میشد.
|
||||
'active': has_active_subscription(request.user.id),
|
||||
'active': active,
|
||||
'planTitle': subscription.plan.title if active and subscription.plan else None,
|
||||
# طولِ دورهی پلن، برای نوارِ «چقدر مانده» در پروفایل. تمدیدِ زودتر از موعد روزها را روی هم
|
||||
# میگذارد، پس روزِ مانده میتواند از این بیشتر باشد؛ فرانت نوار را در ۱۰۰٪ میبندد.
|
||||
'planDurationDays': subscription.plan.duration_days if active and subscription.plan else None,
|
||||
'aiDailyQuota': ai_daily_quota(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),
|
||||
|
||||
Reference in New Issue
Block a user