Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only

- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
  question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
  company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
  verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 20:33:38 +03:30
parent 9c3adc3e60
commit 947f946909
39 changed files with 398 additions and 1174 deletions

View File

@@ -25,9 +25,9 @@ class Message(models.Model):
role = models.CharField(max_length=10, choices=ROLE_CHOICES)
text = models.TextField()
question_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به content.Question
# فقط برای پیامِ کاربر: این سوال از کجا پرداخت شد. «free» از سهمیه‌ی روزانه، «credit» از
# اعتبارِ بسته (subscription.QuestionCredit)، «none» هیچ (رد در صافیِ سرور یا جوابِ کش‌شده که
# به API نرسید). سهمیه‌ی رایگانِ امروز = تعدادِ «free»های امروز.
# فقط برای پیامِ کاربر: این سوال از کجا پرداخت شد. «free» از سهمیه‌ی روزانه، «none» هیچ (رد در صافیِ
# سرور یا جوابِ کش‌شده که به API نرسید). سهمیه‌ی امروز = تعدادِ «free»های امروز. «credit» (اعتبارِ بسته‌ی
# سوال) فقط روی پیام‌های پیش از subscription.0017 است؛ دیگر نوشته نمی‌شود ولی ردِ تاریخیِ آن پیام‌هاست.
CHARGE_CHOICES = [('free', 'رایگانِ روزانه'), ('credit', 'اعتبارِ بسته'), ('none', 'بی‌هزینه')]
charge = models.CharField(max_length=6, choices=CHARGE_CHOICES, blank=True, default='')
# ایندکس: سقفِ روزانه‌ی کلِ سامانه (_check_global_daily_cap) در هر سوال پیام‌های امروزِ همه را

View File

@@ -35,7 +35,6 @@ class AskResponseSerializer(serializers.Serializer):
answer = serializers.CharField()
suggestedFollowUps = serializers.ListField(child=serializers.CharField())
remainingQuota = serializers.IntegerField(help_text='سوال‌های رایگانِ باقیِ امروز.')
credits = serializers.IntegerField(required=False, help_text='اعتبارِ بسته‌های سوال (منقضی نمی‌شود).')
diagram = serializers.DictField(required=False, allow_null=True)
# تکه‌های درسیِ بازیابی‌شده از Qdrant که پاسخ بر پایه‌شون ساخته شده؛
# خالی یعنی retrieval انجام نشده (نبودِ subject/lessonNumber یا خاموش‌بودنِ RAG).
@@ -72,4 +71,3 @@ class FeedbackResponseSerializer(serializers.Serializer):
class QuotaResponseSerializer(serializers.Serializer):
freeDaily = serializers.IntegerField(help_text='سوال‌های رایگانِ هر روز برای این کاربر.')
remainingQuota = serializers.IntegerField(help_text='سوال‌های رایگانِ باقیِ امروز.')
credits = serializers.IntegerField(help_text='اعتبارِ بسته‌های سوال؛ منقضی نمی‌شود.')

View File

@@ -1,73 +0,0 @@
"""
اعتبارِ بسته زیرِ درخواستِ هم‌زمان (بازبینیِ ۲۰۲۶-۱۰-۰۲).
پیش از این فقط ادعا بود که قفلِ ردیفِ کاربر و `transaction_mode: IMMEDIATE`ِ SQLite جلوی
خرجِ دوباره‌ی یک اعتبار را می‌گیرند. این‌جا دو thread واقعی، با دو اتصالِ جدا به دیتابیسِ فایلیِ
تست، هم‌زمان می‌پرسند. جوابِ دستیار ساختگی است و ۰٫۴ ثانیه طول می‌کشد تا دو درخواست واقعاً روی
هم بیفتند. فقط روی SQLite سنجیده شده؛ بعد از مهاجرت به Postgres دوباره باید سبز شود.
"""
import threading
import time
from unittest.mock import patch
from django.core.cache import cache
from django.db import connection
from django.test import TransactionTestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from subscription.models import QuestionCredit
from subscription.services import credit_balance
from .models import Message
def _slow_answer(*args, **kwargs):
time.sleep(0.4)
return 'جواب', ['بیشتر توضیح بده']
@override_settings(AI_DAILY_QUOTA_FREE=0)
class ConcurrentCreditTests(TransactionTestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120007201')
def tearDown(self):
cache.clear()
def race(self, *headers):
codes = []
barrier = threading.Barrier(len(headers))
def ask(extra):
try:
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
barrier.wait()
codes.append(client.post('/api/ai/ask', {'userQuestion': '۲ + ۲؟'}, format='json', **extra).status_code)
finally:
connection.close()
with patch('rag.services.config.rag_is_configured', return_value=False), \
patch('ai_tutor.views.generate_tutor_answer', side_effect=_slow_answer):
threads = [threading.Thread(target=ask, args=(h,)) for h in headers]
for t in threads:
t.start()
for t in threads:
t.join()
return sorted(codes)
def test_two_questions_at_once_with_one_credit_get_one_answer(self):
QuestionCredit.objects.create(user_id=self.user.id, delta=1, reason='grant')
self.assertEqual(self.race({}, {}), [200, 429])
self.assertEqual(credit_balance(self.user.id), 0)
self.assertEqual(Message.objects.filter(role='assistant').count(), 1)
def test_the_same_idempotency_key_twice_at_once_costs_one_credit(self):
QuestionCredit.objects.create(user_id=self.user.id, delta=2, reason='grant')
key = {'HTTP_IDEMPOTENCY_KEY': 'same-key'}
self.assertEqual(self.race(key, key), [200, 200])
self.assertEqual(credit_balance(self.user.id), 1)
self.assertEqual(Message.objects.filter(role='user').count(), 1)

View File

@@ -50,4 +50,4 @@ class DailyQuotaTests(TestCase):
def test_gifted_subscription_without_plan_gets_the_default(self):
Subscription.objects.create(user_id=self.user.id, plan=None, status='active', expires_at=None)
self.assertEqual(ai_daily_quota(self.user.id), 15)
self.assertEqual(ai_daily_quota(self.user.id), 4) # DEFAULT_SUBSCRIBER_AI_QUOTA، مثلِ پلن‌های فروشی

View File

@@ -50,24 +50,18 @@ def _cache_grade(grade_title: str, age: int | None) -> str:
def quota_state(user_id: int) -> dict:
"""
{'remainingQuota', 'credits'}: سوال‌های رایگانِ باقیِ امروز و اعتبارِ بسته، هر بار تازه از
دیتابیس. همه‌ی پاسخ‌ها از همین می‌خوانند تا شمارنده‌ی اپ هیچ‌وقت از موجودیِ واقعی جدا نیفتد.
{'remainingQuota'}: سوال‌های باقیِ امروز، هر بار تازه از دیتابیس. همه‌ی پاسخ‌ها از همین می‌خوانند تا
شمارنده‌ی اپ هیچ‌وقت از موجودیِ واقعی جدا نیفتد.
"""
from subscription.services import credit_balance
used_free = Message.objects.filter(
conversation__user_id=user_id, role='user', created_at__date=timezone.localdate(), charge='free',
).count()
return {'remainingQuota': max(daily_quota(user_id) - used_free, 0), 'credits': credit_balance(user_id)}
return {'remainingQuota': max(daily_quota(user_id) - used_free, 0)}
def _uncharge(user_message) -> None:
"""سوالی که به API نرسید (جوابِ کش‌شده): نه از سهمیه کم شود نه از اعتبار."""
from subscription.services import refund_credit
if user_message.charge == 'credit':
refund_credit(user_message.id)
if user_message.charge in ('free', 'credit'):
"""سوالی که به API نرسید (جوابِ کش‌شده): از سهمیه کم نشود."""
if user_message.charge == 'free':
user_message.charge = 'none'
user_message.save(update_fields=['charge'])
@@ -178,14 +172,14 @@ class AIDailyCapReached(APIException):
def _check_global_daily_cap() -> None:
"""
سقفِ روزانه‌ی کلِ سامانه (AI_GLOBAL_DAILY_MAX): سوال‌های امروز که به مدل رفته‌اند یا با کش
برگشته‌اند ولی هنوز «free»/«credit» مانده‌اند. در دیتابیس شمرده می‌شود، پس بینِ workerها
برگشته‌اند ولی هنوز «free» مانده‌اند. در دیتابیس شمرده می‌شود، پس بینِ workerها
مشترک است. داخلِ همان تراکنشِ IMMEDIATEِ فازِ ۱ صدا زده می‌شود؛ روی Postgres دو درخواستِ
هم‌زمان ممکن است هر دو از لبه رد شوند (چند سوال بیشتر از سقف، نه بیشتر).
"""
from django.conf import settings
start = timezone.localtime().replace(hour=0, minute=0, second=0, microsecond=0)
used = Message.objects.filter(role='user', created_at__gte=start, charge__in=('free', 'credit')).count()
used = Message.objects.filter(role='user', created_at__gte=start, charge='free').count()
if used >= settings.AI_GLOBAL_DAILY_MAX:
logger.error('AI global daily cap reached: cap=%s used=%s', settings.AI_GLOBAL_DAILY_MAX, used)
raise AIDailyCapReached()
@@ -331,17 +325,16 @@ class AskAIView(APIView):
# را ثبت می‌کند. اگر فاز ۲/۳ شکست بخورد، رزرو فاز ۱ آزاد می‌شود.
# محافظِ دستیار، لایه‌ی ۱ (rag/services/guardrails.py): ترفندِ آشکار برای عوض کردنِ
# قواعد، یا جمله‌ای درباره‌ی آسیب به خود، اصلاً به مدل نمی‌رسد؛ پس هزینه‌ای ندارد و از
# سهمیه و اعتبار هم کم نمی‌شود. سقفِ burst (throttle_scope=ai_ask) جلوی تکرارش را می‌گیرد.
# سهمیه هم کم نمی‌شود. سقفِ burst (throttle_scope=ai_ask) جلوی تکرارش را می‌گیرد.
from rag.services import guardrails
from subscription.services import credit_balance, spend_credit
screened = guardrails.screen(v['userQuestion'])
with transaction.atomic():
AccountUser.objects.select_for_update().get(pk=user.id)
# هر سوالی که به API می‌رسد پول است: اول از سهمیه‌ی رایگانِ امروز، بعد از اعتبارِ بسته
# (۲۰۲۶-۱۰-۰۲). قفلِ ردیفِ کاربر و IMMEDIATEِ SQLite جلوی خرجِ دوباره‌ی یک اعتبار را می‌گیرند.
# هر سوالی که به API می‌رسد پول است و از سقفِ روزانه کم می‌شود (رایگان: AI_DAILY_QUOTA_FREE،
# مشترک: Plan.ai_daily_quota). قفلِ ردیفِ کاربر جلوی دو سوالِ هم‌زمان روی آخرین سهم را می‌گیرد.
today = timezone.localdate()
used_today = Message.objects.filter(
conversation__user_id=user.id, role='user', created_at__date=today, charge='free',
@@ -353,14 +346,12 @@ class AskAIView(APIView):
charge = 'none'
elif used_today < quota:
charge = 'free'
elif credit_balance(user.id) > 0:
charge = 'credit'
else:
from subscription.services import has_active_subscription
n = str(quota).translate(str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹'))
raise Throttled(detail=(
# بسته‌ی سوال از subscription.0016 فروخته نمی‌شود؛ مشترک تا فردا صبر می‌کند، بی‌اشتراک اشتراک می‌خرد.
# مشترک تا فردا صبر می‌کند، بی‌اشتراک اشتراک می‌خرد (سوالِ جدا فروخته نمی‌شود).
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد. فردا دوباره می‌تونی بپرسی.'
if has_active_subscription(user.id) else
f'سوال‌های رایگانِ امروز ({n} سوال) تمام شد. با خریدِ اشتراک هر روز سوالِ بیشتری بپرس.'
@@ -397,8 +388,6 @@ class AskAIView(APIView):
question_id=raw_question_id if _is_db_ref_id(raw_question_id) else None,
charge=charge,
)
if charge == 'credit':
spend_credit(user.id, user_message.id)
# قفل ردیف کاربر اینجا آزاد شده؛ فراخوانی AI بیرون از هر تراکنشی اجرا می‌شود.
# لایه‌ی RAG (اپ rag) اینجا context را می‌سازد: پرسش → embedding →
# جستجوی Qdrant (محدود به subject+grade+lessonNumber) → چند خلاصه.
@@ -588,7 +577,6 @@ class AskAIView(APIView):
'answer': answer_text,
'suggestedFollowUps': follow_ups,
'remainingQuota': state['remainingQuota'],
'credits': state['credits'],
'diagram': assistant_message.diagram,
'usedChunks': [
{'title': c.get('title', ''), 'summary': c.get('summary', '')}
@@ -791,10 +779,7 @@ class AskAIView(APIView):
def _release_reserved_slot(self, conversation, user_message, conversation_is_new):
"""پاک‌کردن رزرو فاز ۱ وقتی فاز بعدی شکست می‌خورد، تا نه پیام یتیم بماند و نه کوتا مصرف‌شده حساب شود."""
from subscription.services import refund_credit
with transaction.atomic():
refund_credit(user_message.id)
if conversation_is_new:
conversation.delete()
else:
@@ -829,7 +814,6 @@ class AskAIView(APIView):
'answer': to_plain(existing.assistant_message.text),
'suggestedFollowUps': existing.suggested_follow_ups,
'remainingQuota': state['remainingQuota'],
'credits': state['credits'],
'diagram': existing.assistant_message.diagram,
}
return AskResponseSerializer(data)
@@ -895,8 +879,8 @@ class MessageFeedbackView(APIView):
class QuotaView(APIView):
"""
GET /api/ai/quota — «امروز X سوالِ رایگان + Y سوالِ خریده‌شده». اپ پیش از اولین سوال این را
نشان می‌دهد؛ بعد از هر جواب همین عددها در خودِ پاسخ هم می‌آیند (remainingQuota، credits).
GET /api/ai/quota — «امروز X سوال». اپ پیش از اولین سوال این را نشان می‌دهد؛ بعد از هر جواب همین عدد
در خودِ پاسخ هم می‌آید (remainingQuota).
"""
permission_classes = [IsAuthenticated]