diff --git a/.env.example b/.env.example index b37e34e..cc53c45 100644 --- a/.env.example +++ b/.env.example @@ -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 # ═══════════════════════════════════════════════════════════════════════ # ۵) پرداخت diff --git a/ai_tutor/tests.py b/ai_tutor/tests.py index 7aa1943..a0eb341 100644 --- a/ai_tutor/tests.py +++ b/ai_tutor/tests.py @@ -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 سنجیده شود. diff --git a/config/settings.py b/config/settings.py index adcacdd..adc0042 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 را بی‌سقف بالا ببرد. ۳۰۰۰ با ~۰٫۲ سنت برای هر سوال یعنی حدودِ ۶ diff --git a/content/serializers.py b/content/serializers.py index d7dfd94..a13f0f7 100644 --- a/content/serializers.py +++ b/content/serializers.py @@ -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) diff --git a/content/tests_finish_requires_answers.py b/content/tests_finish_requires_answers.py index efbf687..8f74903 100644 --- a/content/tests_finish_requires_answers.py +++ b/content/tests_finish_requires_answers.py @@ -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) diff --git a/content/tests_flow.py b/content/tests_flow.py index 99b30be..5929d18 100644 --- a/content/tests_flow.py +++ b/content/tests_flow.py @@ -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( diff --git a/content/tests_node_completion.py b/content/tests_node_completion.py index 26ed292..812f687 100644 --- a/content/tests_node_completion.py +++ b/content/tests_node_completion.py @@ -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): diff --git a/content/tests_paywall.py b/content/tests_paywall.py new file mode 100644 index 0000000..5970fa5 --- /dev/null +++ b/content/tests_paywall.py @@ -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)) diff --git a/content/views.py b/content/views.py index ad36c7a..83f3745 100644 --- a/content/views.py +++ b/content/views.py @@ -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() diff --git a/docs/ai-cost-and-guardrails.md b/docs/ai-cost-and-guardrails.md index 7ffadf2..71495fb 100644 --- a/docs/ai-cost-and-guardrails.md +++ b/docs/ai-cost-and-guardrails.md @@ -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`). diff --git a/docs/security-before-production.md b/docs/security-before-production.md index 8c1f83e..31d86fb 100644 --- a/docs/security-before-production.md +++ b/docs/security-before-production.md @@ -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 در چند ساعت تمام می‌شود (اعتبار یک بار در ۲۰۲۶-۰۹-۲۵ diff --git a/docs/technical-handbook.md b/docs/technical-handbook.md index b1cfb2e..f6c6863 100644 --- a/docs/technical-handbook.md +++ b/docs/technical-handbook.md @@ -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`. diff --git a/subscription/migrations/0013_monthly_plans_2026_10.py b/subscription/migrations/0013_monthly_plans_2026_10.py new file mode 100644 index 0000000..6fb2387 --- /dev/null +++ b/subscription/migrations/0013_monthly_plans_2026_10.py @@ -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)] diff --git a/subscription/serializers.py b/subscription/serializers.py index a324ccd..f174a5d 100644 --- a/subscription/serializers.py +++ b/subscription/serializers.py @@ -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='سوال‌های خریده‌شده‌ی باقی‌مانده (منقضی نمی‌شوند).') diff --git a/subscription/tests.py b/subscription/tests.py index b0b18fe..16430e6 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -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) diff --git a/subscription/views.py b/subscription/views.py index e336519..246a188 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -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),