diff --git a/accounts/admin.py b/accounts/admin.py index 2e2debf..d26d9c2 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -51,8 +51,23 @@ class SendSMSForm(forms.Form): @admin.register(User) class UserAdmin(admin.ModelAdmin): - list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at') + list_display = ('id', 'public_id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at') search_fields = ('name', 'phone', 'device_id') + + @admin.display(description='شناسه‌ی نمایشی') + def public_id(self, obj): + from .public_id import encode + return encode(obj.pk) + + def get_search_results(self, request, queryset, search_term): + """«User482917» (همان که کاربر در پروفایلش می‌بیند) هم کاربر را پیدا می‌کند.""" + from .public_id import decode + + results, may_have_duplicates = super().get_search_results(request, queryset, search_term) + user_id = decode(search_term) + if user_id: + results |= queryset.filter(pk=user_id) + return results, may_have_duplicates list_filter = ('is_guest',) actions = ['send_sms_to_selected'] diff --git a/accounts/jalali.py b/accounts/jalali.py index 2607efb..fd519a5 100644 --- a/accounts/jalali.py +++ b/accounts/jalali.py @@ -92,3 +92,15 @@ def parse(value: str) -> date: def to_string(d: date) -> str: jy, jm, jd = from_gregorian(d) return f'{jy:04d}-{jm:02d}-{jd:02d}' + + +def age_of(birth_date: date | None, birth_year: int | None, today: date | None = None) -> int | None: + """سنِ کامل از تاریخِ تولد؛ برای کاربری که فقط سالِ شمسی داده، تقریبی (سال منهای سال).""" + from django.utils import timezone + + today = today or timezone.localdate() + if birth_date: + return today.year - birth_date.year - ((today.month, today.day) < (birth_date.month, birth_date.day)) + if birth_year: + return from_gregorian(today)[0] - birth_year + return None diff --git a/accounts/management/commands/delete_users.py b/accounts/management/commands/delete_users.py index 38799cb..c76301d 100644 --- a/accounts/management/commands/delete_users.py +++ b/accounts/management/commands/delete_users.py @@ -54,10 +54,13 @@ def plan(user_ids): return counts -def delete_users(user_ids): +def delete_users(user_ids, keep=()): + """keep: برچسبِ مدل‌هایی که نمی‌مانند پاک شوند (مثلاً 'payment.Order' برای حذفِ حسابِ خودِ کاربر).""" phones = list(User.objects.filter(pk__in=user_ids).exclude(phone=None).values_list('phone', flat=True)) with transaction.atomic(): for model, column in user_scoped_models(): + if model._meta.label in keep: + continue model.objects.filter(**{f'{column}__in': user_ids}).delete() OTPCode.objects.filter(phone__in=phones).delete() User.objects.filter(pk__in=user_ids).delete() @@ -74,6 +77,8 @@ class Command(BaseCommand): parser.add_argument('--yes', action='store_true', help='واقعاً پاک کن.') parser.add_argument('--include-paid', action='store_true', help='کاربرِ دارای سفارشِ پرداخت‌شده را هم پاک کن.') + parser.add_argument('--skip-paid', action='store_true', + help='کاربرِ دارای سفارشِ پرداخت‌شده را کنار بگذار و بقیه را پاک کن.') def handle(self, *args, **options): phones = [normalize_phone(p) or p for p in options['phones']] @@ -93,7 +98,12 @@ class Command(BaseCommand): from payment.models import Order paid = set(Order.objects.filter(user_id__in=[u.pk for u in users], status='paid') .values_list('user_id', flat=True)) - if paid and not options['include_paid']: + if paid and options['skip_paid']: + self.stdout.write(f'کنار گذاشته شدند (سفارشِ پرداخت‌شده دارند): {sorted(paid)}') + users = [u for u in users if u.pk not in paid] + if not users: + raise CommandError('بعد از کنار گذاشتنِ پرداخت‌کننده‌ها کسی نماند.') + elif paid and not options['include_paid']: raise CommandError(f'کاربرهای {sorted(paid)} سفارشِ پرداخت‌شده دارند؛ با --include-paid پاک می‌شوند.') ids = [u.pk for u in users] diff --git a/accounts/public_id.py b/accounts/public_id.py new file mode 100644 index 0000000..be64955 --- /dev/null +++ b/accounts/public_id.py @@ -0,0 +1,32 @@ +""" +شناسه‌ی نمایشیِ کاربر («User482917») به‌جای شماره‌ی ردیفِ دیتابیس. + +شناسه‌ی خام («۱۰») هم کوچک و بی‌اعتبار دیده می‌شد، هم می‌گفت اپ چند کاربر دارد. این شکل +شش‌رقمی و ثابت است و از خودِ id ساخته می‌شود، پس ستونی در دیتابیس لازم ندارد: + + code = 100000 + (id × 7919) mod 900000 + +۷۹۱۹ با ۹۰۰۰۰۰ هیچ مقسومِ علیهِ مشترکی ندارد، پس تا ۹۰۰ هزار کاربر هیچ دو کاربری یک کد +نمی‌گیرند و کد به id برمی‌گردد (`decode`)؛ پنلِ ادمین با همین کاربر را پیدا می‌کند. این رمز +نیست: هر کس فرمول را بداند id را درمی‌آورد. فقط برای نمایش و پشتیبانی است، نه دسترسی. +""" +import re + +PREFIX = 'User' +_MOD = 900_000 +_MUL = 7919 +_INV = pow(_MUL, -1, _MOD) +_PATTERN = re.compile(rf'^\s*(?:{PREFIX})?\s*(\d{{6}})\s*$', re.IGNORECASE) + + +def encode(user_id: int) -> str: + return f'{PREFIX}{100_000 + (user_id * _MUL) % _MOD}' + + +def decode(code: str) -> int | None: + """«User482917» یا «482917» → id؛ None اگر شکلش این نیست.""" + match = _PATTERN.match(code or '') + if not match: + return None + user_id = ((int(match.group(1)) - 100_000) * _INV) % _MOD + return user_id or None diff --git a/accounts/serializers.py b/accounts/serializers.py index 3303bcb..036def7 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -159,9 +159,10 @@ class RequestOTPResponseSerializer(serializers.Serializer): class ProfileResponseSerializer(serializers.Serializer): """پاسخ GET /api/user/profile — فقط داده‌ی لازم برای تب Profile اپ موبایل.""" - # شناسه‌ی کاربر همان شماره‌ی ثبتِ اوست (کاربرِ چندم)؛ اپ آن را زیرِ نام نشان می‌دهد - # تا در پشتیبانی بشود حساب را بی‌شماره‌ی تلفن پیدا کرد. userId = serializers.CharField() + # شناسه‌ی نمایشی («User482917»، accounts/public_id.py)؛ اپ آن را زیرِ نام نشان می‌دهد + # و پشتیبانی با همین در پنلِ ادمین حساب را پیدا می‌کند. + publicId = serializers.CharField() phone = serializers.CharField(allow_null=True) name = serializers.CharField() avatar = serializers.CharField() @@ -172,6 +173,17 @@ class ProfileResponseSerializer(serializers.Serializer): bio = serializers.CharField() +class DeleteAccountRequestSerializer(serializers.Serializer): + # کاربرِ شماره‌دار: کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره). + otp = serializers.CharField(max_length=6, required=False, allow_blank=True) + # مهمان شماره‌ای ندارد که کد بگیرد؛ باید همین کلمه را تایپ کرده باشد. + confirm = serializers.CharField(max_length=10, required=False, allow_blank=True) + + +class DeleteAccountResponseSerializer(serializers.Serializer): + success = serializers.BooleanField() + + class LogoutRequestSerializer(serializers.Serializer): refreshToken = serializers.CharField(required=False, allow_blank=True) diff --git a/accounts/tests_birth_date.py b/accounts/tests_birth_date.py index 3476341..f8adb28 100644 --- a/accounts/tests_birth_date.py +++ b/accounts/tests_birth_date.py @@ -88,3 +88,17 @@ class ProfileBirthDateApiTests(TestCase): def test_parent_phone_is_gone(self): self.assertNotIn('parentPhone', self.client.get('/api/user/profile').json()) + + +class PublicIdTests(TestCase): + def test_profile_shows_a_six_digit_code_that_maps_back_to_the_user(self): + from accounts.public_id import decode + + user = User.objects.create(device_id='public-id-device', is_guest=True) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') + code = client.get('/api/user/profile').json()['publicId'] + self.assertRegex(code, r'^User\d{6}$') + self.assertEqual(decode(code), user.id) + self.assertEqual(decode(code[4:]), user.id) + self.assertIsNone(decode('User12')) diff --git a/accounts/tests_delete_account.py b/accounts/tests_delete_account.py new file mode 100644 index 0000000..1c3b9d7 --- /dev/null +++ b/accounts/tests_delete_account.py @@ -0,0 +1,66 @@ +""" +POST /api/user/delete — حذفِ حسابِ خود (۲۰۲۶-۱۰-۰۲). راه‌های شکستن: حذف با توکنِ تنها +(بی‌کد)، حدسِ کد، حذفِ حسابِ دمو، و پاک شدنِ سندِ مالی. +""" +from django.core.cache import cache +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import OTPCode, User +from payment.models import Order +from progress.models import UserStats + + +def _client(user): + client = APIClient() + client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}') + return client + + +class DeleteAccountTests(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create(phone='09121115001', is_guest=False) + UserStats.objects.create(user_id=self.user.id, xp=50) + self.client = _client(self.user) + + def code(self, value='482913'): + OTPCode.objects.create(phone=self.user.phone, code=value, + expires_at=timezone.now() + timezone.timedelta(minutes=5)) + return value + + def delete(self, **body): + return self.client.post('/api/user/delete', body, format='json') + + def test_a_token_alone_is_not_enough(self): + self.assertEqual(self.delete().status_code, 400) + self.assertTrue(User.objects.filter(pk=self.user.pk).exists()) + + def test_a_wrong_code_deletes_nothing(self): + self.code() + self.assertEqual(self.delete(otp='111111').status_code, 400) + self.assertTrue(User.objects.filter(pk=self.user.pk).exists()) + + def test_the_right_code_deletes_everything_but_payment_records(self): + Order.objects.create(user_id=self.user.id, amount=99000, status='paid', idempotency_key='k') + response = self.delete(otp=self.code()) + self.assertEqual(response.status_code, 200, response.content) + self.assertFalse(User.objects.filter(pk=self.user.pk).exists()) + self.assertFalse(UserStats.objects.filter(user_id=self.user.pk).exists()) + self.assertTrue(Order.objects.filter(user_id=self.user.pk).exists()) + # توکنِ قبلی دیگر به هیچ کاربری نمی‌رسد. + self.assertIn(self.client.get('/api/user/profile').status_code, (401, 403)) + + def test_guest_confirms_by_typing_the_word(self): + guest = User.objects.create(device_id='guest-del', is_guest=True) + client = _client(guest) + self.assertEqual(client.post('/api/user/delete', {'confirm': 'باشه'}, format='json').status_code, 400) + self.assertEqual(client.post('/api/user/delete', {'confirm': 'حذف'}, format='json').status_code, 200) + self.assertFalse(User.objects.filter(pk=guest.pk).exists()) + + @override_settings(TEST_LOGIN_PHONES={'09121115001': '4321'}) + def test_demo_accounts_cannot_be_deleted(self): + self.assertEqual(self.delete(otp='4321').status_code, 403) + self.assertTrue(User.objects.filter(pk=self.user.pk).exists()) diff --git a/accounts/tests_delete_users.py b/accounts/tests_delete_users.py index 1aa1a78..c08b113 100644 --- a/accounts/tests_delete_users.py +++ b/accounts/tests_delete_users.py @@ -57,3 +57,9 @@ class DeleteUsersTests(TestCase): self.assertFalse(User.objects.filter(pk=self.doomed.pk).exists()) self.assertTrue(User.objects.filter(pk=self.kept.pk).exists()) self.assertTrue(User.objects.filter(pk=guest.pk).exists()) + + def test_skip_paid_keeps_the_payer_and_deletes_the_rest(self): + Order.objects.create(user_id=self.doomed.id, amount=1000, status='paid', idempotency_key='k2') + self.run_command('--ids', str(self.doomed.pk), str(self.kept.pk), '--yes', '--skip-paid') + self.assertTrue(User.objects.filter(pk=self.doomed.pk).exists()) + self.assertFalse(User.objects.filter(pk=self.kept.pk).exists()) diff --git a/accounts/urls.py b/accounts/urls.py index 919345f..863e460 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -1,6 +1,7 @@ from django.urls import path from .views import ( + DeleteAccountView, GuestSessionView, LoginView, LogoutView, @@ -18,4 +19,5 @@ urlpatterns = [ path('auth/logout', LogoutView.as_view(), name='auth-logout'), path('user/grade', SetUserGradeView.as_view(), name='user-grade'), path('user/profile', UserProfileView.as_view(), name='user-profile'), + path('user/delete', DeleteAccountView.as_view(), name='user-delete'), ] diff --git a/accounts/views.py b/accounts/views.py index a4341fb..5a753b8 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -19,6 +19,8 @@ from .authentication import invalidate_user_cache, issue_access_token from .models import OTPCode, Profile, Session, User from .services import OTPDeliveryError, send_otp_sms from .serializers import ( + DeleteAccountRequestSerializer, + DeleteAccountResponseSerializer, GuestSessionRequestSerializer, GuestSessionResponseSerializer, LoginRequestSerializer, @@ -279,6 +281,57 @@ class RequestOTPView(APIView): return Response(RequestOTPResponseSerializer(data).data) +def verify_phone_otp(phone: str, otp: str) -> Response | None: + """ + کدِ پیامکیِ این شماره را می‌سنجد و مصرف می‌کند؛ None یعنی درست بود، وگرنه پاسخِ خطا. + + مشترکِ ورود (LoginView) و حذفِ حساب (DeleteAccountView): هر دو باید همان سقفِ تلاشِ + شماره، همان قفلِ ردیف و همان باطل شدنِ کد پس از چند حدسِ غلط را داشته باشند. پیش از + ۲۰۲۶-۱۰-۰۲ این منطق فقط داخلِ LoginView بود. + """ + # محدودیت تلاش بر اساس شماره تلفن (مستقل از throttle IP-محورِ هر view). + phone_attempts_key = f'otp_login_phone_attempts:{phone}' + if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS): + attempt_count = 1 + else: + try: + attempt_count = cache.incr(phone_attempts_key) + except ValueError: + # بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن. + cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS) + attempt_count = 1 + + if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS: + # هیچ سکرتی (OTP، token) لاگ نمی‌شه؛ از شماره فقط ۴ رقمِ آخر. + logger.warning( + 'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s', + phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS, + ) + return Response( + {'detail': 'تعداد تلاش‌های ورود برای این شماره بیش از حد مجاز است.'}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + # قفل ردیف OTP جلوی مصرف دوباره‌ی یک کد یک‌بارمصرف را در درخواست‌های هم‌زمان می‌گیرد. + with transaction.atomic(): + test_code = settings.TEST_LOGIN_PHONES.get(phone, '') + if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP: + return None + if test_code and secrets.compare_digest(test_code, otp): + # شماره‌ی تستِ تیم. compare_digest تا نتیجه از روی زمانِ پاسخ قابلِ استنتاج نباشه. + logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:]) + return None + otp_obj = OTPCode.objects.select_for_update().filter( + phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now() + ).order_by('-id').first() + if otp_obj: + otp_obj.is_used = True + otp_obj.save(update_fields=['is_used']) + return None + _count_failed_attempt(phone) + return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST) + + class LoginView(APIView): """POST /api/auth/login — ورود با OTP""" @@ -296,57 +349,11 @@ class LoginView(APIView): phone = serializer.validated_data['phone'] otp = serializer.validated_data['otp'] - # محدودیت تلاش ورود بر اساس شماره تلفن (مستقل از throttle IP-محور بالا). - phone_attempts_key = f'otp_login_phone_attempts:{phone}' - if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS): - attempt_count = 1 - else: - try: - attempt_count = cache.incr(phone_attempts_key) - except ValueError: - # بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن. - cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS) - attempt_count = 1 + error = verify_phone_otp(phone, otp) + if error is not None: + return error - if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS: - # ⚠️ افزوده شده: لاگ هشدارِ امنیتی برای رصد/آلارمِ brute-force روی - # این endpoint. هیچ سکرتی (OTP، access/refresh token) لاگ نمی‌شه؛ - # شماره‌ی تلفن هم کامل نوشته نمی‌شه، فقط ۴ رقم آخرش (برای - # correlation در تحلیل/پشتیبانی، بدون افشای کامل PII). - logger.warning( - 'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s', - phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS, - ) - return Response( - {'detail': 'تعداد تلاش‌های ورود برای این شماره بیش از حد مجاز است.'}, - status=status.HTTP_429_TOO_MANY_REQUESTS, - ) - - # قفل ردیف OTP جلوی مصرف دوباره‌ی یک کد یک‌بارمصرف را در درخواست‌های هم‌زمان می‌گیرد. with transaction.atomic(): - valid = False - test_code = settings.TEST_LOGIN_PHONES.get(phone, '') - if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP: - valid = True - elif test_code and secrets.compare_digest(test_code, otp): - # شماره‌ی تستِ تیم. compare_digest تا نتیجه‌ی مقایسه از روی زمانِ - # پاسخ قابلِ استنتاج نباشه. سقفِ تلاشِ همین شماره بالاتر چک شده، - # پس حدسِ کد با brute-force بسته‌ست. - logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:]) - valid = True - else: - otp_obj = OTPCode.objects.select_for_update().filter( - phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now() - ).order_by('-id').first() - if otp_obj: - otp_obj.is_used = True - otp_obj.save(update_fields=['is_used']) - valid = True - - if not valid: - _count_failed_attempt(phone) - return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST) - user = User.objects.filter(phone=phone).first() created = False guest = _upgradable_guest(request, phone) if user is None else None @@ -415,6 +422,60 @@ def _upgradable_guest(request, phone: str): return User.objects.filter(pk=user.pk, is_guest=True, phone__isnull=True).first() +DELETE_CONFIRM_WORD = 'حذف' +# سوابقِ پرداخت سندِ مالی‌اند و با حذفِ حساب نمی‌روند (تراکنش‌ها با سفارش می‌مانند). +KEPT_ON_ACCOUNT_DELETE = ('payment.Order',) + + +class DeleteAccountView(APIView): + """ + POST /api/user/delete — حذفِ همیشگیِ حسابِ خودِ کاربر و همه‌ی داده‌اش. + + چرا این‌طور (۲۰۲۶-۱۰-۰۲): + - کاربرِ شماره‌دار باید کدِ پیامکیِ تازه بدهد (همان verify_phone_otpِ ورود، با همان سقفِ حدس). + توکنِ access به‌تنهایی کافی نیست: گوشیِ بازمانده دستِ یک بچه‌ی دیگر یا توکنِ دزدیده‌شده نباید + بتواند حساب را برای همیشه پاک کند. + - مهمان شماره ندارد؛ باید کلمه‌ی «حذف» را تایپ کرده باشد. حسابش فقط به همین دستگاه وصل است. + - داده در همه‌ی اپ‌ها پاک می‌شود (delete_users)، جز سفارش‌ها و تراکنش‌هایشان که سندِ مالی‌اند. + - حساب‌های دمو (TEST_LOGIN_PHONES) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند. + - بعد از حذف، توکن‌های همین کاربر کار نمی‌کنند چون کاربری با آن شناسه نمانده. + """ + + permission_classes = [IsAuthenticated] + throttle_scope = 'account_delete' + throttle_classes = [ScopedRateThrottle] + + @extend_schema( + request=DeleteAccountRequestSerializer, + responses=DeleteAccountResponseSerializer, + tags=['Identity & Profile (accounts)'], + ) + def post(self, request): + from .management.commands.delete_users import delete_users + + serializer = DeleteAccountRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + v = serializer.validated_data + user = request.user + + if user.phone and user.phone in settings.TEST_LOGIN_PHONES: + return Response({'detail': 'حسابِ نمایشی را نمی‌شود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN) + if user.phone: + if not v.get('otp'): + return Response({'detail': 'برای حذفِ حساب، کدی را که به شماره‌ات پیامک شد وارد کن.'}, + status=status.HTTP_400_BAD_REQUEST) + error = verify_phone_otp(user.phone, v['otp']) + if error is not None: + return error + elif (v.get('confirm') or '').strip() != DELETE_CONFIRM_WORD: + return Response({'detail': f'برای حذفِ حساب کلمه‌ی «{DELETE_CONFIRM_WORD}» را بنویس.'}, + status=status.HTTP_400_BAD_REQUEST) + + logger.info('Account deleted by its owner: user_id=%s guest=%s', user.id, user.is_guest) + delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE) + return Response(DeleteAccountResponseSerializer({'success': True}).data) + + class LogoutView(APIView): """ POST /api/auth/logout — خروج از حساب. @@ -530,10 +591,11 @@ class SetUserGradeView(APIView): def _profile_payload(user, profile) -> dict: - from . import jalali + from . import jalali, public_id return { 'userId': str(user.id), + 'publicId': public_id.encode(user.id), 'phone': user.phone, 'name': user.name, 'avatar': user.avatar, diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 4c92a78..2243069 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -28,6 +28,22 @@ from .serializers import ( logger = logging.getLogger(__name__) +def _learner_age(user) -> int | None: + """سنِ کاربر از پروفایلش (accounts)؛ لحنِ دستیار برای ۱۸+ بزرگسالانه است.""" + from accounts.jalali import age_of + from accounts.models import Profile + + profile = Profile.objects.filter(user_id=user.id).only('birth_date', 'birth_year').first() + return age_of(profile.birth_date, profile.birth_year) if profile else None + + +def _cache_grade(grade_title: str, age: int | None) -> str: + """پایه در کلیدِ کشِ جواب، با لحن: جوابِ بزرگسالانه به کودک سرو نشود و برعکس.""" + from rag.services.generation import tone_for + + return f'{grade_title}|adult' if tone_for(grade_title, age) == 'adult' else grade_title + + def daily_quota(user_id: int) -> int: """سقفِ سوالِ روزانه‌ی این کاربر؛ رایگان یا از پلنِ اشتراکش (subscription.services).""" from subscription.services import ai_daily_quota @@ -368,6 +384,7 @@ class AskAIView(APIView): from rag.services import guardrails screened = guardrails.screen(v['userQuestion']) + age = _learner_age(user) # --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندی‌شده. # قراردادِ JSONِ غیراستریم دست‌نخورده می‌ماند؛ Swagger که stream @@ -381,7 +398,7 @@ class AskAIView(APIView): user_message=user_message, conversation_is_new=conversation_is_new, idempotency_key=idempotency_key, used_today=used_today, subject=subject, grade_id=grade_id, grade_title=grade_title, - lesson_number=lesson_number, screened=screened, quota=quota, + lesson_number=lesson_number, screened=screened, quota=quota, age=age, ) used_chunks: list[dict] = [] @@ -404,7 +421,7 @@ class AskAIView(APIView): focus = _question_focus(v, user) cacheable = answer_cache.cacheable(history) and not focus _col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number) - cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title) + cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], _cache_grade(grade_title, age)) cached = answer_cache.get(cache_key) if cacheable else None if cached is not None: answer_text = cached['answer'] @@ -426,7 +443,7 @@ class AskAIView(APIView): answer_text, follow_ups, generation_meta = generate_answer( v['userQuestion'], used_chunks or None, retrieval_meta.get('gradeTitle') or grade_title or '', - history=history, focus=focus, + history=history, focus=focus, age=age, ) provider_status.record_success() # ردِ «غیرِ ریاضی» نه منبعِ کتاب نشان می‌دهد نه کش می‌شود. @@ -508,7 +525,8 @@ class AskAIView(APIView): def _streaming_response(self, *, request, v, user, conversation, user_message, conversation_is_new, idempotency_key, used_today, - subject, grade_id, grade_title, lesson_number, screened=None, quota=None): + subject, grade_id, grade_title, lesson_number, screened=None, quota=None, + age=None): """ نسخه‌ی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی پاسخ توکن‌به‌توکن استریم می‌شه. رویدادها: @@ -531,7 +549,7 @@ class AskAIView(APIView): generation_meta: dict = {} answer_from_cache = False _col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number) - cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title) + cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], _cache_grade(grade_title, age)) try: history = _conversation_history(conversation, user_message) focus = _question_focus(v, user) @@ -566,7 +584,7 @@ class AskAIView(APIView): effective_grade = retrieval_meta.get('gradeTitle') or grade_title or '' for delta in stream_answer( v['userQuestion'], used_chunks or None, effective_grade, - history=history, focus=focus, result=stream_result, + history=history, focus=focus, result=stream_result, age=age, ): parts.append(delta) yield _sse('chunk', {'delta': delta}) diff --git a/config/settings.py b/config/settings.py index 2abb44e..0ccd451 100644 --- a/config/settings.py +++ b/config/settings.py @@ -475,6 +475,8 @@ REST_FRAMEWORK = { 'discount_validate': '20/hour', 'discount_redeem': '10/hour', 'user_profile_write': '60/hour', + # حذفِ حساب با کدِ پیامکی؛ سقفِ حدسِ کد جدا در verify_phone_otp است. + 'account_delete': '5/hour', 'affiliate_track': '10/hour', 'payment_webhook': '60/hour', 'payment_pay': '30/hour', diff --git a/rag/services/generation.py b/rag/services/generation.py index 229b0b0..1a10c32 100644 --- a/rag/services/generation.py +++ b/rag/services/generation.py @@ -39,7 +39,7 @@ _RAW_MARKUP = re.compile(r'\\[A-Za-z(\[]|\$|\*\*|^\s*#{1,6}\s|^\s*\|\s*:?-{3,}', # بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها # در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه # کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود. -PROMPT_VERSION = 'tutor-v11-math-only' +PROMPT_VERSION = 'tutor-v12-age-tone' _PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'} _MIDDLE = {'هفتم', 'هشتم', 'نهم'} @@ -65,9 +65,24 @@ def grade_tier(grade_title: str) -> str: return '' -def _persona(grade_title: str) -> tuple[str, str]: - """(توصیفِ معلم، توصیفِ دانش‌آموز) متناسب با پایه.""" +# از این سن به بعد لحن بزرگسالانه است، هر پایه‌ای که انتخاب شده باشد (سینیور، ۲۰۲۶-۱۰-۰۲: +# «ممکن است یک بی‌سواد بخواهد کلاسِ ابتدایی بردارد»). پایه فقط محتوا و نمادگذاری را تعیین می‌کند. +ADULT_AGE = 18 + + +def tone_for(grade_title: str, age: int | None) -> str: + """کلیدِ _TONE: 'adult' برای بزرگسال، وگرنه سطحِ پایه.""" + if age is not None and age >= ADULT_AGE: + return 'adult' + return grade_tier(grade_title) + + +def _persona(grade_title: str, age: int | None = None) -> tuple[str, str]: + """(توصیفِ معلم، توصیفِ یادگیرنده) متناسب با پایه و سن.""" grade = (grade_title or '').strip() + if tone_for(grade, age) == 'adult': + level = f'ریاضیِ پایه‌ی {grade}' if grade else 'ریاضی' + return 'معلمِ ریاضیِ بزرگسالان', f'بزرگسالی که {level} را یاد می‌گیرد' tier = grade_tier(grade) if tier in ('middle', 'upper'): return f'معلمِ ریاضیِ پایه‌ی {grade}', f'دانش‌آموزِ پایه‌ی {grade}' @@ -98,6 +113,15 @@ _TONE = { '- شرط‌ها را صریح چک کن: دامنه، مخرجِ مخالفِ صفر، زیرِ رادیکالِ نامنفی، ' 'علامتِ عبارت، واحدِ زاویه.' ), + 'adult': ( + 'با یک بزرگسال حرف می‌زنی؛ شاید سال‌ها از درس دور بوده، شاید تازه سواد آموخته، شاید ' + 'فقط می‌خواهد یک مبحث را یاد بگیرد. محترمانه، صبور و بی‌قضاوت.\n' + '- هرگز لحنِ کودکانه نداشته باش: نه «عزیزم» و «آفرین کوچولو»، نه شکلک، نه مثالِ ' + 'شکلات و اسباب‌بازی.\n' + '- مثال‌ها از زندگیِ بزرگسال: خرید و تخفیف، قبض، اجاره، دستمزد، آشپزی، مسافت و زمان.\n' + '- هیچ پیش‌دانسته‌ای فرض نکن: هر اصطلاح را اولین بار با زبانِ ساده معنی کن و هر مرحله ' + 'فقط یک کار باشد، حتی اگر مسئله ساده است.' + ), '': 'دوستانه، روشن و دقیق حرف بزن.', } @@ -149,18 +173,19 @@ _METHOD = ( ) -def system_prompt(grade_title: str = '') -> str: +def system_prompt(grade_title: str = '', age: int | None = None) -> str: """ قبلاً «معلمِ دبستانی» ثابت بود؛ بعد فقط نامِ پایه در آن آمد. حالا لحن، روش (حلِ کامل + وارسی) و قالب (متنِ ساده) هر سه صریح‌اند. """ - teacher, student = _persona(grade_title) + teacher, student = _persona(grade_title, age) + # نمادگذاریِ عدد از کتابِ همان پایه است، نه سنِ خواننده. numbers = _NUMBERS['primary' if grade_tier(grade_title) == 'primary' else 'default'] return ( f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} ' f'با یک {student} حرف می‌زنی. همیشه فارسی جواب بده.\n\n' f'{guardrails.SCOPE_RULE}\n\n' - f'{_TONE[grade_tier(grade_title)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}' + f'{_TONE[tone_for(grade_title, age)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}' f'\n\n{diagram_mod.DIAGRAM_RULE}' ) @@ -245,7 +270,7 @@ def completion_kwargs(model: str) -> dict: def build_messages(question: str, context_chunks: list[dict] | None, grade_title: str = '', history: list[dict] | None = None, - focus: str = '') -> list[dict]: + focus: str = '', age: int | None = None) -> list[dict]: """ history: [{'role': 'user'|'assistant', 'text': ...}, ...] به ترتیبِ زمان. @@ -253,7 +278,7 @@ def build_messages(question: str, context_chunks: list[dict] | None, دکمه‌ی پیشنهادی نشان می‌دهد) برای مدل هیچ معنایی نداشت. """ contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')] - messages = [{'role': 'system', 'content': system_prompt(grade_title)}] + messages = [{'role': 'system', 'content': system_prompt(grade_title, age)}] for turn in (history or [])[-HISTORY_TURNS:]: role = 'assistant' if turn.get('role') == 'assistant' else 'user' text = (turn.get('text') or '').strip() @@ -273,7 +298,8 @@ def _previous_question(history) -> str: def generate_answer(question: str, context_chunks: list[dict] | None, grade_title: str = '', history: list[dict] | None = None, - model: str | None = None, focus: str = '') -> tuple[str, list[str], dict]: + model: str | None = None, focus: str = '', + age: int | None = None) -> tuple[str, list[str], dict]: """ context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[]. خروجی: (answer, follow_ups, meta) @@ -284,7 +310,7 @@ def generate_answer(question: str, context_chunks: list[dict] | None, `model` فقط برای مقایسه‌ی مدل‌ها (`rag_model_benchmark`) است. """ model = model or chat_model_for(grade_title) - messages = build_messages(question, context_chunks, grade_title, history, focus) + messages = build_messages(question, context_chunks, grade_title, history, focus, age) client = clients.get_openai_client() started = time.monotonic() @@ -332,7 +358,7 @@ def generate_answer(question: str, context_chunks: list[dict] | None, def stream_answer(question: str, context_chunks: list[dict] | None, grade_title: str = '', history: list[dict] | None = None, - focus: str = '', result: dict | None = None) -> Iterator[str]: + focus: str = '', result: dict | None = None, age: int | None = None) -> Iterator[str]: """ `result` (اختیاری) بعد از پایانِ استریم کلیدِ 'diagram' را می‌گیرد: شکلِ اعتبارسنجی‌شده یا None. بلوکِ شکل هیچ‌وقت به‌صورتِ متن yield نمی‌شود. @@ -347,7 +373,7 @@ def stream_answer(question: str, context_chunks: list[dict] | None, client = clients.get_openai_client() stream = client.chat.completions.create( model=model, - messages=build_messages(question, context_chunks, grade_title, history, focus), + messages=build_messages(question, context_chunks, grade_title, history, focus, age), stream=True, # آخرین تکه‌ی استریم usage را می‌آورد؛ بدونِ این، هزینه‌ی جواب‌های اپ ثبت نمی‌شد. stream_options={'include_usage': True}, diff --git a/rag/tests_guardrails.py b/rag/tests_guardrails.py index 299b631..4cf21f1 100644 --- a/rag/tests_guardrails.py +++ b/rag/tests_guardrails.py @@ -138,3 +138,23 @@ class GuardrailThroughTheApiTests(TestCase): self.assertEqual(response.json()['answer'], guardrails.REFUSAL) self.assertEqual(response.json()['usedChunks'], []) self.assertEqual(Message.objects.get(role='assistant').text, guardrails.REFUSAL) + + +class AgeToneTests(SimpleTestCase): + """لحن از سن، محتوا از پایه: بزرگسالی که سومِ دبستان برداشته لحنِ کودکانه نمی‌گیرد.""" + + def test_adult_in_a_primary_grade_gets_the_adult_tone_but_the_books_notation(self): + from .services.generation import _NUMBERS, _TONE + + prompt = system_prompt('سوم', age=40) + self.assertIn(_TONE['adult'], prompt) + self.assertNotIn(_TONE['primary'], prompt) + self.assertIn(_NUMBERS['primary'], prompt) + self.assertIn('بزرگسال', prompt.split('\n')[0]) + + def test_child_or_unknown_age_keeps_the_grade_tone(self): + from .services.generation import _TONE + + self.assertIn(_TONE['primary'], system_prompt('سوم', age=8)) + self.assertIn(_TONE['primary'], system_prompt('سوم')) + self.assertIn(_TONE['upper'], system_prompt('دوازدهم', age=17)) diff --git a/subscription/migrations/0007_plans_priced_by_ai_cost.py b/subscription/migrations/0007_plans_priced_by_ai_cost.py new file mode 100644 index 0000000..8470755 --- /dev/null +++ b/subscription/migrations/0007_plans_priced_by_ai_cost.py @@ -0,0 +1,66 @@ +""" +قیمتِ موقتِ پلن‌ها بر اساسِ هزینه‌ی دستیار (مهرِ ۱۴۰۵). + +هر سوال از دستیار حدودِ ۶۰۰ تومان هزینه‌ی API دارد (docs/ai-cost-and-guardrails.md). +اشتراکِ سالانه‌ی ۲۹۹ هزار تومانی (۰۰۰۵) فقط ~۴۰ سوال در ماه را پوشش می‌داد، در حالی که +مشترک تا ۴۵۰ سوال در ماه می‌توانست بپرسد. این قیمت‌ها را صاحبِ پروژه «موقت و معقول» +خواست، تا تصمیمِ نهاییِ مدیرعامل: + +- ماهانه ۹۹٬۰۰۰ تومان، سالانه ۹۹۰٬۰۰۰ تومان (قیمتِ ۱۰ ماه، دو ماه هدیه)، هر دو ۵ سوال در روز. +- کاربرِ معمولی (یک‌سومِ سقف، ~۵۰ سوال در ماه) ~۳۰ هزار تومان هزینه دارد: ~۳۶٪ِ درآمدِ خالصِ + ماهانه پس از سهمِ ۱۵٪یِ بازار، و ~۴۳٪ در سالانه. کاربری که هر روز سقف را پر کند (۱۵۰ سوال، + ~۹۰ هزار تومان) در پلنِ ماهانه از درآمدش بیشتر خرج می‌کند؛ این سقف با درآمدِ واقعی بازبینی شود. + +ویژگی‌ها فقط چیزی است که کد واقعاً به مشترک می‌دهد. درس‌ها، آزمون‌ها و گزارشِ والدین برای همه +رایگان‌اند و این‌جا نیامده‌اند. سقفِ سوال را خودِ اپ از ai_daily_quota می‌سازد و در features نیست. + +قاعده: پلنِ سالانه فقط وقتی عوض می‌شود که هنوز همان قیمتِ پیش‌فرضِ ۲۹۹ هزار تومانِ ۰۰۰۵ را +دارد، یعنی کسی در ادمین ویرایشش نکرده. پلنِ ماهانه فقط وقتی ساخته می‌شود که هیچ پلنِ ۳۰ روزه‌ای +وجود ندارد. +""" +from django.db import migrations + +YEARLY_TITLE = 'اشتراک یک‌ساله' +YEARLY = { + 'price': 990_000, + 'discount_percent': 17, + 'popular': True, + 'badge': 'دو ماه هدیه', + 'ai_daily_quota': 5, + 'features': ['قلبِ نامحدود؛ هیچ‌وقت وسطِ تمرین متوقف نمی‌شی', 'دوازده ماه، به قیمتِ ده ماه'], +} +MONTHLY = { + 'title': 'اشتراک ماهانه', + 'price': 99_000, + 'currency': 'IRT', + 'discount_percent': 0, + 'popular': False, + 'duration_days': 30, + 'badge': '', + 'ai_daily_quota': 5, + 'features': ['قلبِ نامحدود؛ هیچ‌وقت وسطِ تمرین متوقف نمی‌شی', 'هر وقت خواستی تمدید کن'], +} + + +def apply_prices(apps, schema_editor): + Plan = apps.get_model('subscription', 'Plan') + Plan.objects.filter(title=YEARLY_TITLE, price=299_000).update(**YEARLY) + if not Plan.objects.filter(duration_days=30).exists(): + Plan.objects.create(**MONTHLY) + + +def undo(apps, schema_editor): + Plan = apps.get_model('subscription', 'Plan') + Plan.objects.filter(title=MONTHLY['title'], subscriptions__isnull=True).delete() + Plan.objects.filter(title=YEARLY_TITLE, price=YEARLY['price']).update(price=299_000, ai_daily_quota=15) + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscription', '0006_plan_ai_daily_quota'), + ] + + operations = [ + migrations.RunPython(apply_prices, undo), + ] diff --git a/subscription/serializers.py b/subscription/serializers.py index 3573587..c328a45 100644 --- a/subscription/serializers.py +++ b/subscription/serializers.py @@ -11,10 +11,13 @@ class PlanSerializer(serializers.Serializer): durationDays = serializers.IntegerField(required=False) features = serializers.ListField(child=serializers.CharField()) badge = serializers.CharField(allow_blank=True) + aiDailyQuota = serializers.IntegerField(help_text='سوال در روز از دستیار برای مشترکِ این پلن.') class PlansListResponseSerializer(serializers.Serializer): plans = PlanSerializer(many=True) + # تا کارتِ پلن بتواند بگوید «۵ سوال در روز (رایگان: ۳)» بی‌آن‌که عدد در فرانت هاردکد شود. + freeAiDailyQuota = serializers.IntegerField() class PurchaseRequestSerializer(serializers.Serializer): diff --git a/subscription/tests.py b/subscription/tests.py index 9197148..271fb2d 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -547,15 +547,36 @@ class PlanCardContractTests(TestCase): class YearlyPlanSeedTests(TestCase): - """migrationِ 0005: سرورِ بی‌پلن یک پلنِ یک‌ساله می‌گیرد.""" + """migrationهای 0005 و 0007: سرورِ بی‌پلن پلنِ سالانه و ماهانه با قیمتِ ۱۴۰۵-۰۷ می‌گیرد.""" def test_the_yearly_plan_exists_after_migrations(self): plan = Plan.objects.get(title='اشتراک یک‌ساله') self.assertEqual(plan.duration_days, 365) - self.assertEqual(plan.price, 299_000) + self.assertEqual((plan.price, plan.ai_daily_quota), (990_000, 5)) self.assertEqual(plan.currency, 'IRT') self.assertTrue(plan.features) + def test_a_monthly_plan_is_created_next_to_it(self): + plan = Plan.objects.get(duration_days=30) + self.assertEqual((plan.price, plan.ai_daily_quota), (99_000, 5)) + + def test_repricing_leaves_an_admin_edited_yearly_plan_alone(self): + import importlib + from django.apps import apps as global_apps + reprice = importlib.import_module('subscription.migrations.0007_plans_priced_by_ai_cost') + Plan.objects.filter(title='اشتراک یک‌ساله').update(price=450_000, ai_daily_quota=9) + reprice.apply_prices(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) + + def test_plans_api_reports_each_quota_and_the_free_one(self): + from django.core.cache import cache + cache.clear() + data = self.client.get('/api/subscription/plans').json() + self.assertEqual(data['freeAiDailyQuota'], 3) + self.assertEqual({p['aiDailyQuota'] for p in data['plans']}, {5}) + def test_it_is_served_to_the_app(self): from django.core.cache import cache cache.clear() diff --git a/subscription/views.py b/subscription/views.py index 7677f15..fd5de0f 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.cache import cache from django.db import transaction from django.db.models import F @@ -42,7 +43,7 @@ class GuestAwarePurchaseThrottle(ScopedRateThrottle): # پلن‌ها تقریباً هیچ‌وقت عوض نمی‌شوند و روی صفحه‌ی اشتراک هر بار خوانده می‌شوند. # با هر save/delete روی Plan کش باطل می‌شود (subscription/receivers.py). -PLANS_CACHE_KEY = 'subscription:plans:v1' +PLANS_CACHE_KEY = 'subscription:plans:v2' # v2: aiDailyQuota و freeAiDailyQuota PLANS_CACHE_TTL = 3600 @@ -64,9 +65,11 @@ class PlansListView(APIView): 'durationDays': p.duration_days, 'features': p.features or [], 'badge': p.badge, + 'aiDailyQuota': p.ai_daily_quota, } - for p in Plan.objects.all() - ] + for p in Plan.objects.order_by('duration_days', 'price') + ], + 'freeAiDailyQuota': settings.AI_DAILY_QUOTA_FREE, } data = cache.get_or_set(PLANS_CACHE_KEY, build, PLANS_CACHE_TTL)