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:
@@ -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) در هر سوال پیامهای امروزِ همه را
|
||||
|
||||
@@ -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='اعتبارِ بستههای سوال؛ منقضی نمیشود.')
|
||||
|
||||
@@ -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)
|
||||
@@ -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، مثلِ پلنهای فروشی
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user