Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks

- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and
  curriculum import need add/delete; content browser needs view_question; guest device ids are
  shown to superusers only.
- Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps
  Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted
  (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles.
- Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409;
  orders snapshot credits/hearts/days at creation (payment.0011).
- Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker
  refuses the answer (not stored, not cached); a disconnect saves the partial answer; only
  KNOWN_COLLECTIONS are searched.
- Infra: secret-key file is created atomically (workers no longer split keys); no default
  INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc
  pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400;
  ticket text capped at 5000.
- docs/security-before-production.md: remaining open items 24-31 and the guard tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 04:52:25 +03:30
parent 645baf846b
commit a973c41db2
36 changed files with 944 additions and 85 deletions

View File

@@ -100,8 +100,10 @@ BAZAAR_REFRESH_TOKEN=
# ═══════════════════════════════════════════════════════════════════════
# ۶) اختیاری — پیش‌فرض‌ها معمولاً کافی‌اند
# ═══════════════════════════════════════════════════════════════════════
# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی روی سرور = آن مسیرها بسته‌اند.
# کلیدِ مسیرهای داخلیِ سرور-به-سرور؛ خالی = آن مسیرها بسته‌اند (در DEBUG هم پیش‌فرضی ندارد).
# INTERNAL_SERVICE_KEY=
# مهمان‌های نمایشی که با «حذفِ حساب» پاک نمی‌شوند (با کاما). پیش‌فرض: frontend-test-device.
# DEMO_GUEST_DEVICE_IDS=
# با صفر قلب جلوی جواب دادن گرفته شود؟ پیش‌فرض روشن (از ۲۰۲۶-۱۰-۰۲)؛ ۰ خاموشش می‌کند.
# HEARTS_ENFORCED=1
# سقف‌های کدِ ورود: هر شماره در روز، و کلِ سامانه در روز (ضدِ SMS pumping).

2
.gitignore vendored
View File

@@ -10,7 +10,7 @@ data/textbooks/*
!data/textbooks/README.md
# کلیدِ امضای تولیدشده‌ی خودکار (هرگز کامیت نشود)
.django_secret_key
.django_secret_key*
# خروجیِ collectstatic — تولیدی است، در هر دیپلوی از نو ساخته می‌شود.
staticfiles/

View File

@@ -65,7 +65,9 @@ default hosts/CORS/cache/secret key (`/api/health` warns); `LocMemCache` without
OAuth path beside the Pishkhan token (server still uses OAuth); `birthYear` alone in
`PATCH /api/user/profile` (frontend type still allows it); `totalQuestions` accepted but
ignored (frontend sends it); orders with no `plan_id` from before 2026-09-25 (none
paid on the server on 2026-10-03).
paid on the server on 2026-10-03); orders without the `question_credits`/`hearts_days`/
`duration_days` snapshot (`payment.0011`) grant and refund from the plan's current values;
`DEMO_GUEST_DEVICE_IDS` defaults to `frontend-test-device` (remove with the demo data).
## Before any real user: read `docs/security-before-production.md`

View File

@@ -49,8 +49,27 @@ class SendSMSForm(forms.Form):
return cleaned_data
class DeviceIdForSuperuserOnly:
"""
شناسه‌ی دستگاه برای مهمان تنها رمزِ ورود است (`POST /api/auth/session`)؛ پس فقط ابرکاربر
آن را در فهرست، جستجو و فرم می‌بیند، نه کارمندی با اجازه‌ی دیدن (گروهِ Supervisor).
"""
def get_list_display(self, request):
cols = super().get_list_display(request)
return cols if request.user.is_superuser else tuple(c for c in cols if c != 'device_id')
def get_search_fields(self, request):
cols = super().get_search_fields(request)
return cols if request.user.is_superuser else tuple(c for c in cols if c != 'device_id')
def get_exclude(self, request, obj=None):
exclude = tuple(super().get_exclude(request, obj) or ())
return exclude if request.user.is_superuser else exclude + ('device_id',)
@admin.register(User)
class UserAdmin(admin.ModelAdmin):
class UserAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
list_display = ('id', 'public_id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at')
search_fields = ('name', 'phone', 'device_id')
@@ -165,7 +184,7 @@ class ProfileAdmin(admin.ModelAdmin):
@admin.register(Session)
class SessionAdmin(admin.ModelAdmin):
class SessionAdmin(DeviceIdForSuperuserOnly, admin.ModelAdmin):
list_display = ('id', 'user', 'device_id', 'expires_at', 'revoked')
list_filter = ('revoked',)

View File

@@ -68,15 +68,20 @@ def plan(user_ids):
return counts
def delete_users(user_ids, keep=()):
"""keep: برچسبِ مدل‌هایی که نمی‌مانند پاک شوند (مثلاً 'payment.Order' برای حذفِ حسابِ خودِ کاربر)."""
def delete_users(user_ids, keep=(), delete_otp_codes=True):
"""
keep: برچسبِ مدل‌هایی که نباید پاک شوند (مثلاً 'payment.Order' برای حذفِ حسابِ خودِ کاربر).
delete_otp_codes: این دستور (کارِ ادمین) کدهای شماره را پاک می‌کند تا آن شماره از صفر شروع کند؛
حذفِ حساب به دستِ خودِ کاربر این کار را نمی‌کند تا سقفِ پیامکِ شماره دور زده نشود.
"""
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()
if delete_otp_codes:
OTPCode.objects.filter(phone__in=phones).delete()
User.objects.filter(pk__in=user_ids).delete()

View File

@@ -5,16 +5,23 @@ from rest_framework import serializers
from .phone import normalize_phone
# لینک، آیدیِ شبکه‌ی اجتماعی، و هر رشته‌ی ۵ رقمی یا بیشتر (شماره‌ی تلفن با
# فاصله/خط‌تیره هم) — ارقامِ فارسی و عربی هم.
# فاصله/خط‌تیره هم) — ارقامِ فارسی و عربی هم. این نام در رتبه‌بندی به بچه‌های دیگر نشان داده می‌شود.
_CONTACT = re.compile(
r'(https?://\S+|www\.\S+|\S+\.(?:com|ir|org|net|me)\S*|@\w+'
r'|(?:[0-9۰-۹٠-٩][\s\-.]?){5,})',
r'|(?:[0-9۰-۹٠-٩][\s\-._/\\|,،]*){5,})',
re.IGNORECASE,
)
# «ig: name»، «insta name»، «آیدی name» و مانندش: کلیدواژه و هر چه بعدش آمده حذف می‌شود.
_HANDLE = re.compile(
r'(?:\b(?:ig|insta(?:gram)?|tele(?:gram)?|tg|id|whats ?app|rubika|eitaa|bale)\b'
r'|اینستا\S*|(?:تلگرام|تل|آیدی|ایدی|واتساپ|روبیکا|ایتا)(?!\w)|بله\s*:)\s*[::]?.*',
re.IGNORECASE,
)
def clean_display_name(value: str) -> str:
return ' '.join(_CONTACT.sub(' ', value or '').split())[:30]
value = _HANDLE.sub(' ', value or '')
return ' '.join(_CONTACT.sub(' ', value).split())[:30]
class IranMobileField(serializers.CharField):

View File

@@ -134,8 +134,10 @@ def _kavenegar_post(*, phone: str, method: str, payload: dict) -> None:
'phone_suffix=%s method=%s', _phone_suffix(phone), method,
)
raise SMSDeliveryUncertain('پاسخِ سرویسِ پیامک دیر رسید.')
except requests.RequestException:
logger.exception('SMS request to provider failed: phone_suffix=%s', _phone_suffix(phone))
except requests.RequestException as exc:
# فقط نوعِ خطا: متنِ خطای requests آدرسِ کامل را دارد و کلیدِ API بخشی از همان آدرس است.
logger.error('SMS request to provider failed: phone_suffix=%s method=%s error=%s',
_phone_suffix(phone), method, type(exc).__name__)
raise SMSDeliveryError('ارسال پیامک با خطا مواجه شد.')
if response.status_code != 200:

View File

@@ -0,0 +1,179 @@
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (هویت و حساب). هر تست رفتاری را نگه می‌دارد که پیش‌تر غلط بود.
"""
import logging
import socket
from unittest import mock
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.cache import cache
from django.core.management import call_command
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 config.log_formatters import JSONLogFormatter
def _bearer(token):
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
return c
def _otp_row(phone, code='482913'):
OTPCode.objects.create(phone=phone, code=code, expires_at=timezone.now() + timezone.timedelta(minutes=5))
return code
def _guest(device):
return APIClient().post('/api/auth/session', {'type': 'guest', 'deviceId': device}, format='json').json()
class GuestUpgradeRevokesEarlierSessionsTests(TestCase):
"""کسی که پیش از ثبتِ شماره با شناسه‌ی دستگاه نشست گرفته بود، بعد از آن به حسابِ شماره‌دار نمی‌رسید."""
def setUp(self):
cache.clear()
def test_pre_upgrade_tokens_stop_working(self):
attacker = _guest('shared-device')
victim = _guest('shared-device')
login = _bearer(victim['token']).post('/api/auth/login',
{'phone': '09121230001', 'otp': _otp_row('09121230001')},
format='json')
self.assertEqual(login.status_code, 200)
self.assertEqual(login.json()['userId'], victim['userId'])
refresh = APIClient().post('/api/auth/refresh', {'refreshToken': attacker['refreshToken']}, format='json')
self.assertEqual(refresh.status_code, 401)
# توکنِ دسترسیِ پیش از ثبت هم (اگر همان ثانیه صادر نشده باشد) رد می‌شود؛ نشستِ تازه کار می‌کند.
self.assertEqual(_bearer(login.json()['token']).get('/api/user/profile').status_code, 200)
fresh = APIClient().post('/api/auth/refresh', {'refreshToken': login.json()['refreshToken']}, format='json')
self.assertEqual(fresh.status_code, 200)
class AccountDeletionKeepsLimitsTests(TestCase):
def setUp(self):
cache.clear()
from discount.models import DiscountCode
from subscription.models import Plan
self.plan = Plan.objects.create(title='pack20', price=45000, kind='pack',
question_credits=20, hearts_days=7, duration_days=0)
DiscountCode.objects.create(code='FREE1', type='percent', value=100, max_uses=1)
def _guest_redeems(self, device):
s = _guest(device)
c = _bearer(s['token'])
p = c.post('/api/subscription/purchase', {'planId': str(self.plan.id)}, format='json')
return c, c.post('/api/discount/redeem', {'code': 'FREE1', 'orderId': p.json()['orderId']}, format='json')
def test_single_use_code_stays_used_after_delete(self):
c1, first = self._guest_redeems('g-1')
self.assertEqual(first.status_code, 200)
self.assertEqual(c1.post('/api/user/delete', {'confirm': 'حذف'}, format='json').status_code, 200)
_, again = self._guest_redeems('g-2')
self.assertEqual(again.status_code, 400)
@override_settings(DEBUG=False, OTP_REQUEST_PHONE_COOLDOWN_SECONDS=0)
def test_per_phone_sms_cap_survives_delete(self):
phone = '09121230009'
user = User.objects.create(phone=phone, is_guest=False)
for i in range(10):
OTPCode.objects.create(phone=phone, code=f'{i:06d}', is_used=True,
expires_at=timezone.now() - timezone.timedelta(minutes=i + 1))
with mock.patch('accounts.views.send_otp_sms'):
OTPCode.objects.create(phone=phone, code='777777',
expires_at=timezone.now() + timezone.timedelta(minutes=5))
d = _bearer(issue_access_token(user.id)).post('/api/user/delete', {'otp': '777777'}, format='json')
self.assertEqual(d.status_code, 200)
cache.clear()
r = APIClient().post('/api/auth/otp/request', {'phone': phone}, format='json')
self.assertEqual(r.status_code, 429)
def test_public_demo_guest_cannot_be_deleted(self):
demo = User.objects.create(device_id='frontend-test-device', is_guest=True, name='demo')
s = _guest('frontend-test-device')
self.assertEqual(int(s['userId']), demo.id)
r = _bearer(s['token']).post('/api/user/delete', {'confirm': 'حذف'}, format='json')
self.assertEqual(r.status_code, 403)
self.assertTrue(User.objects.filter(pk=demo.id).exists())
class DisplayNameContactFilterTests(TestCase):
def test_contacts_are_removed_and_names_kept(self):
from accounts.serializers import clean_display_name as clean
for sample in ('0912/123/4567', '۰۹۱۲/۱۲۳/۴۵۶۷', '0912 _123_ 4567', 'ig: ali_kid_2014', 'آیدی: kid2014'):
self.assertEqual(clean(sample), '', sample)
for name in ('علی رضایی', 'آیدین', 'Sara 2014', 'ایتالیا', 'تلما'):
self.assertEqual(clean(name), name)
class _Capture(logging.Handler):
def __init__(self):
super().__init__()
self.lines = []
self.setFormatter(JSONLogFormatter())
def emit(self, record):
self.lines.append(self.format(record))
FAKE_KEY = '4E6F74415265616C4B6176656E656761724B6579414243444546'
@override_settings(KAVENEGAR_API_KEY=FAKE_KEY, KAVENEGAR_OTP_TEMPLATE='SMSVerify')
class KavenegarKeyNotLoggedTests(TestCase):
"""کلیدِ کاوه‌نگار بخشی از آدرس است و متنِ خطای requests آدرس را دارد."""
def test_connection_error_log_has_no_key(self):
from accounts import services
cap = _Capture()
log = logging.getLogger('accounts.services')
log.addHandler(cap)
def no_dns(*args, **kwargs):
raise socket.gaierror(socket.EAI_NONAME, 'network disabled in tests')
try:
with mock.patch('socket.getaddrinfo', no_dns), self.assertRaises(services.SMSDeliveryError):
services.send_verify_lookup(phone='09120000000', template='SMSVerify', token='123456')
finally:
log.removeHandler(cap)
out = '\n'.join(cap.lines)
self.assertIn('SMS request to provider failed', out)
self.assertNotIn(FAKE_KEY, out)
class SupervisorDoesNotSeeDeviceIdsTests(TestCase):
"""شناسه‌ی دستگاه برای مهمان رمزِ ورود است؛ کارمندِ فقط‌خواندنی آن را نمی‌بیند."""
def setUp(self):
call_command('setup_supervisor_group', verbosity=0)
self.guest = User.objects.create(device_id='secret-device-123', is_guest=True)
from accounts.models import Session
Session.objects.create(user=self.guest, refresh_token='h' * 64, device_id='secret-device-123',
expires_at=timezone.now() + timezone.timedelta(days=1))
staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True)
staff.groups.add(Group.objects.get(name='Supervisor'))
# Supervisor نشست‌ها را نمی‌بیند؛ این‌جا اجازه‌ی دیدن داده می‌شود تا ستونِ آن صفحه هم سنجیده شود.
from django.contrib.auth.models import Permission
staff.user_permissions.add(Permission.objects.get(codename='view_session', content_type__app_label='accounts'))
self.client.force_login(staff)
def test_hidden_in_list_search_and_form(self):
listing = self.client.get('/admin/accounts/user/')
self.assertEqual(listing.status_code, 200)
self.assertNotContains(listing, 'secret-device-123')
# متنِ جستجو در خودِ صفحه تکرار می‌شود؛ نتیجه نباید همان مهمان باشد.
search = self.client.get('/admin/accounts/user/', {'q': 'secret-device-123'})
self.assertNotContains(search, f'/admin/accounts/user/{self.guest.pk}/change/')
form = self.client.get(f'/admin/accounts/user/{self.guest.pk}/change/')
self.assertNotContains(form, 'secret-device-123')
sessions = self.client.get('/admin/accounts/session/')
self.assertNotContains(sessions, 'secret-device-123')

View File

@@ -362,7 +362,11 @@ class LoginView(APIView):
# جزیره‌ها و گفتگوها همه با همین user_id ذخیره شده‌اند. پیش‌تر یک
# کاربرِ تازه و خالی ساخته می‌شد و پیشرفتِ مهمان جا می‌ماند.
guest.phone = phone
guest.save(update_fields=['phone'])
# هر نشستی که پیش از این با شناسه‌ی دستگاه گرفته شده بود (گوشیِ مشترک، کسی که
# شناسه را دیده) نباید به حسابِ شماره‌دار برسد؛ فقط نشستِ همین ورود می‌ماند.
guest.tokens_valid_after = timezone.now()
guest.save(update_fields=['phone', 'tokens_valid_after'])
Session.objects.filter(user=guest, revoked=False).update(revoked=True)
user, created = guest, True
elif user is None:
user, created = User.objects.get_or_create(
@@ -426,7 +430,10 @@ DELETE_CONFIRM_WORD = 'حذف'
# سوابقِ پرداخت سندِ مالی‌اند و با حذفِ حساب نمی‌روند (تراکنش‌ها با سفارش می‌مانند).
# Conversion هم می‌ماند: ردیفِ «کاربرِ معرفی‌شده» سابقه‌ی پورسانتِ *معرف* است، نه داده‌ی این
# کاربر؛ تا ۲۰۲۶-۱۰-۰۳ با حذفِ حسابِ معرفی‌شده پورسانتِ معرف هم پاک می‌شد.
KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion')
# Redemption و UserOffer هم می‌مانند: سقفِ مصرفِ کدِ تخفیف ردیف‌های Redemption را می‌شمارد، پس پاک
# شدنشان یک کدِ یک‌بارمصرف را با «بساز، بخر، حذف کن» دوباره آزاد می‌کرد.
KEPT_ON_ACCOUNT_DELETE = ('payment.Order', 'statuslog.StatusChangeLog', 'affiliate.Conversion',
'discount.Redemption', 'discount.UserOffer')
class DeleteAccountView(APIView):
@@ -439,7 +446,7 @@ class DeleteAccountView(APIView):
بتواند حساب را برای همیشه پاک کند.
- مهمان شماره ندارد؛ باید کلمه‌ی «حذف» را تایپ کرده باشد. حسابش فقط به همین دستگاه وصل است.
- داده در همه‌ی اپ‌ها پاک می‌شود (delete_users)، جز سفارش‌ها و تراکنش‌هایشان که سندِ مالی‌اند.
- حساب‌های دمو (TEST_LOGIN_PHONES) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند.
- حساب‌های دمو (TEST_LOGIN_PHONES و مهمان‌های DEMO_GUEST_DEVICE_IDS) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند.
- بعد از حذف، توکن‌های همین کاربر کار نمی‌کنند چون کاربری با آن شناسه نمانده.
"""
@@ -460,7 +467,8 @@ class DeleteAccountView(APIView):
v = serializer.validated_data
user = request.user
if user.phone and user.phone in settings.TEST_LOGIN_PHONES:
if (user.phone and user.phone in settings.TEST_LOGIN_PHONES) or (
user.is_guest and user.device_id in settings.DEMO_GUEST_DEVICE_IDS):
return Response({'detail': 'حسابِ نمایشی را نمی‌شود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN)
if user.phone:
if not v.get('otp'):
@@ -474,7 +482,8 @@ class DeleteAccountView(APIView):
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)
# کدهای پیامکیِ شماره می‌مانند: سقفِ روزانه‌ی پیامکِ هر شماره از همین ردیف‌ها شمرده می‌شود.
delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE, delete_otp_codes=False)
return Response(DeleteAccountResponseSerializer({'success': True}).data)

View File

@@ -0,0 +1,138 @@
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (دستیار). مدل و Qdrant همه ساختگی‌اند.
"""
import json
from types import SimpleNamespace
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from ai_tutor.models import Message
from rag.services.clients import reset_clients_cache
RAG_ON = dict(OPENAI_API_KEY='t', QDRANT_API_KEY='t', RAG_FORCE_MOCK=False, AI_DAILY_QUOTA_FREE=3)
ANSWER = '۱) دو به‌علاوه‌ی دو می‌شود چهار.\n✅ جواب: ۴\n'
OVERFLOW_DIAGRAM = '<diagram>{"kind":"numberline","min":1' + '0' * 400 + '*2,"max":5}</diagram>'
DEEP_DIAGRAM = '<diagram>' + '[' * 3000 + ']' * 3000 + '</diagram>'
class FakeOpenAI:
def __init__(self, reply):
self.reply = reply
self.calls = 0
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
self.embeddings = SimpleNamespace(
create=lambda **k: SimpleNamespace(data=[SimpleNamespace(embedding=[0.01] * 1536)]))
def _create(self, **kw):
self.calls += 1
if kw.get('stream'):
parts = [self.reply[i:i + 25] for i in range(0, len(self.reply), 25)]
events = [SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=p))], usage=None)
for p in parts]
events.append(SimpleNamespace(choices=[], usage=SimpleNamespace(prompt_tokens=900, completion_tokens=300)))
return iter(events)
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=self.reply))],
usage=SimpleNamespace(prompt_tokens=900, completion_tokens=300))
def sse(resp):
out = []
for block in b''.join(resp.streaming_content).decode().split('\n\n'):
ev = data = None
for line in block.strip().splitlines():
if line.startswith('event: '):
ev = line[7:]
elif line.startswith('data: '):
data = json.loads(line[6:])
if ev:
out.append((ev, data))
return out
@override_settings(**RAG_ON)
class _Base(TestCase):
def setUp(self):
cache.clear()
reset_clients_cache()
self.user = User.objects.create(phone='09120009901', is_guest=False)
self.c = APIClient()
self.c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def ask(self, q, **extra):
return self.c.post('/api/ai/ask', {'userQuestion': q, **extra}, format='json')
def remaining(self):
return self.c.get('/api/ai/quota').data['remainingQuota']
class BrokenDiagramDoesNotMakeAnswersFreeTests(_Base):
"""پارسرِ شکل OverflowError/RecursionError را بالا می‌داد و جوابِ استریم‌شده رایگان و بی‌ثبت می‌ماند."""
def test_stream_and_json_answers_are_charged(self):
for diagram in (OVERFLOW_DIAGRAM, DEEP_DIAGRAM):
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER + diagram)):
events = sse(self.ask(f'دو به‌علاوه‌ی دو؟ {len(diagram)}', stream=True))
self.assertEqual(events[-1][0], 'done')
self.assertIsNone(events[-1][1]['diagram'])
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER + OVERFLOW_DIAGRAM)):
self.assertEqual(self.ask('دو به‌علاوه‌ی دو؟').status_code, 200)
self.assertEqual(self.remaining(), 0)
self.assertEqual(Message.objects.filter(role='assistant').count(), 3)
@override_settings(RAG_ANSWER_CACHE_TTL=300)
class LateOffTopicMarkerTests(_Base):
def test_rest_is_not_streamed_stored_or_cached(self):
from rag.services.guardrails import OFF_TOPIC_MARKER, REFUSAL
reply = ('۱) این یک جمله‌ی ریاضیِ بلند برای عبور از چهل نویسه است.\n'
f'{OFF_TOPIC_MARKER} متنِ غیرِ ریاضی: یک انشای کامل درباره‌ی فوتبال.\n')
fake = FakeOpenAI(reply)
q = 'سوال درباره‌ی چیزی'
with patch('rag.services.clients.get_openai_client', return_value=fake):
streamed = ''.join(d['delta'] for e, d in sse(self.ask(q, stream=True)) if e == 'chunk')
other = User.objects.create(phone='09120009902', is_guest=False)
c2 = APIClient()
c2.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(other.id)}')
second = c2.post('/api/ai/ask', {'userQuestion': q}, format='json')
self.assertNotIn('فوتبال', streamed)
self.assertIn(REFUSAL, streamed)
self.assertEqual(Message.objects.filter(role='assistant').first().text, REFUSAL)
self.assertNotIn('فوتبال', second.data['answer'])
self.assertEqual(fake.calls, 2) # جوابِ ردشده در کش نرفت
class DisconnectMidStreamTests(_Base):
def test_partial_answer_is_saved(self):
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI(ANSWER * 10)):
resp = self.ask('سوال', stream=True)
it = iter(resp.streaming_content)
next(it)
next(it)
resp._iterator.close() # همان GeneratorExit که بستنِ صفحه می‌سازد
saved = Message.objects.filter(role='assistant')
self.assertEqual(saved.count(), 1)
self.assertTrue(saved.first().text.endswith('…'))
self.assertEqual(self.remaining(), 2)
class UnknownCollectionIsNotSearchedTests(_Base):
def test_client_chosen_collection_is_ignored(self):
seen = []
def fake_search(collection, vec, **kw):
seen.append(collection)
return [{'score': 0.9, 'payload': {'title': 'doc', 'text': 'PAYLOAD-OF-UNRELATED-COLLECTION'}}]
with patch('rag.services.clients.get_openai_client', return_value=FakeOpenAI('پاسخ')), \
patch('rag.services.retrieval.search', side_effect=fake_search):
r = self.ask('هر چیزی', subject='any', grade='other collection')
self.assertEqual(r.status_code, 200)
self.assertEqual(seen, [])
self.assertEqual(r.data['usedChunks'], [])

View File

@@ -152,7 +152,9 @@ def _is_db_ref_id(value) -> bool:
"""
if value in (None, ''):
return False
return str(value).strip().lstrip('-').isdigit()
# isascii: '²'.isdigit() هم True است ولی int('²') خطا می‌دهد.
text = str(value).strip().lstrip('-')
return text.isascii() and text.isdigit()
def _db_ref_or_none(value):
@@ -606,7 +608,7 @@ class AskAIView(APIView):
event: error data: {"detail": "..."}
فاز ۱ (رزرو کوتا + conversation + user_message) قبلاً در post() انجام شده.
"""
from rag.services import answer_cache
from rag.services import answer_cache, guardrails
from rag.services.generation import (
PROMPT_VERSION, chat_model_for, default_follow_ups, stream_answer,
)
@@ -654,12 +656,18 @@ class AskAIView(APIView):
stream_result: dict = {}
started = time.monotonic()
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, age=age,
):
parts.append(delta)
yield _sse('chunk', {'delta': delta})
try:
for delta in stream_answer(
v['userQuestion'], used_chunks or None, effective_grade,
history=history, focus=focus, result=stream_result, age=age,
):
parts.append(delta)
yield _sse('chunk', {'delta': delta})
except GeneratorExit:
# کاربر صفحه را وسطِ جواب بست. سوال خرج می‌ماند (مدل هزینه‌اش را گرفته)، ولی
# همان بخشی که ساخته شد ثبت می‌شود تا گفتگو سوالِ بی‌جواب نداشته باشد.
self._save_partial_answer(conversation, user, v, ''.join(parts))
raise
provider_status.record_success()
answer_text = ''.join(parts).strip() or 'متأسفم، الان نتونستم پاسخِ مناسبی بسازم.'
follow_ups = default_follow_ups()
@@ -678,6 +686,7 @@ class AskAIView(APIView):
}
if stream_result.get('refused'):
used_chunks = []
answer_text = guardrails.REFUSAL
elif cacheable:
answer_cache.set(cache_key, {
'answer': answer_text, 'followUps': follow_ups,
@@ -752,6 +761,20 @@ class AskAIView(APIView):
response['X-Accel-Buffering'] = 'no'
return response
@staticmethod
def _save_partial_answer(conversation, user, v, text):
logger.info('stream closed by client mid-answer: user_id=%s conversation_id=%s chars=%s',
user.id, conversation.id, len(text))
if not text.strip():
return
try:
Message.objects.create(
conversation=conversation, role='assistant', text=text.strip() + ' …',
question_id=_db_ref_or_none(v.get('questionId')),
)
except Exception:
logger.exception('saving partial answer failed: conversation_id=%s', conversation.id)
def _release_reserved_slot(self, conversation, user_message, conversation_is_new):
"""پاک‌کردن رزرو فاز ۱ وقتی فاز بعدی شکست می‌خورد، تا نه پیام یتیم بماند و نه کوتا مصرف‌شده حساب شود."""
from subscription.services import refund_credit

View File

@@ -53,21 +53,49 @@ if not SECRET_KEY:
else:
import secrets
import time
# سه workerِ gunicorn هم‌زمان به این‌جا می‌رسند. فایل فقط یک بار و اتمی ساخته می‌شود
# (O_EXCL، و نوشتن در فایلِ موقت + rename)، و بقیه همان را می‌خوانند؛ در غیرِ این صورت هر
# worker کلیدِ خودش را می‌ساخت و توکن فقط روی یکی از آن‌ها معتبر بود. سنجیده با سه
# پروسه‌ی هم‌زمان، نه با gunicornِ واقعی.
_key_file = BASE_DIR / '.django_secret_key'
try:
SECRET_KEY = _key_file.read_text().strip()
except OSError:
SECRET_KEY = ''
if not SECRET_KEY:
SECRET_KEY = secrets.token_urlsafe(64)
_lock_file = BASE_DIR / '.django_secret_key.lock'
SECRET_KEY = ''
for _attempt in range(50):
try:
_key_file.write_text(SECRET_KEY)
_key_file.chmod(0o600)
SECRET_KEY = _key_file.read_text().strip()
except FileNotFoundError:
SECRET_KEY = ''
except OSError as exc:
raise ImproperlyConfigured(f'خواندنِ {_key_file} ممکن نشد ({exc}).') from exc
if SECRET_KEY:
break
try:
_fd = os.open(_lock_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
time.sleep(0.1) # پروسه‌ی دیگری در حالِ ساختنِ کلید است
continue
except OSError as exc:
raise ImproperlyConfigured(
f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). '
f'DJANGO_SECRET_KEY ست نیست و ساختنِ {_key_file} هم ممکن نشد ({exc}). '
'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.'
) from exc
try:
os.close(_fd)
if not _key_file.exists():
_tmp = BASE_DIR / '.django_secret_key.tmp'
_tmp_fd = os.open(_tmp, os.O_CREAT | os.O_TRUNC | os.O_WRONLY, 0o600)
with os.fdopen(_tmp_fd, 'w') as fh:
fh.write(secrets.token_urlsafe(64))
os.replace(_tmp, _key_file)
finally:
_lock_file.unlink(missing_ok=True)
if not SECRET_KEY:
raise ImproperlyConfigured(
f'کلیدِ امضا ساخته نشد: {_lock_file} مانده است. اگر هیچ پروسه‌ای در حالِ اجرا نیست، '
'آن را پاک کنید و دوباره راه‌اندازی کنید.'
)
def _env_list(name: str) -> list[str]:
return [v.strip() for v in os.environ.get(name, '').split(',') if v.strip()]
@@ -607,6 +635,12 @@ SPECTACULAR_SETTINGS = {
'filter': True,
'persistAuthorization': True,
},
# نسخه‌ی ثابت، نه @latest: این صفحه‌ها روی همان دامنه‌ی /admin/ اجرا می‌شوند، و نسخه‌ی منتشرشده
# در npm عوض‌شدنی نیست؛ با @latest هر نسخه‌ی آلوده‌ی تازه‌ی این بسته‌ها در مرورگرِ کارمند اجرا می‌شد.
# SRI ندارد. ارتقا یعنی عوض کردنِ همین دو عدد.
'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1',
'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/favicon-32x32.png',
'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@2.5.4',
}
# اعتبار توکن دسترسی (ثانیه) و رفرش‌توکن (روز)
@@ -702,6 +736,11 @@ def _parse_test_login_phones(raw: str) -> dict[str, str]:
TEST_LOGIN_PHONES = _parse_test_login_phones(os.environ.get('TEST_LOGIN_PHONES', ''))
# مهمان‌های نمایشی که با حذفِ حساب پاک نمی‌شوند (همان کاری که TEST_LOGIN_PHONES برای شماره‌ها می‌کند).
# پیش‌فرض: حسابِ مهمانِ تستِ فرانت (کاربرِ ۶ روی سرور)، که شناسه‌اش در ریپو و مستندات آمده و هر کسی
# می‌تواند با آن وارد شود. با پاک شدنِ داده‌ی دمو پیش از کاربرِ واقعی، این پیش‌فرض هم برداشته شود.
DEMO_GUEST_DEVICE_IDS = _env_list('DEMO_GUEST_DEVICE_IDS') or ['frontend-test-device']
# -----------------------------------------------------------------------
# OTP SMS delivery (accounts/services.py: send_otp_sms)
# -----------------------------------------------------------------------
@@ -745,10 +784,9 @@ ADMIN_LOGIN_MAX_ATTEMPTS = 10
# POST /api/payment/orders باید فقط سرور-به-سرور (از subscription) صدا زده شود؛
# کلاینت باید هدر X-Internal-Key برابر همین مقدار بفرستد. بدون env در
# پروداکشن مقدار خالی می‌ماند و HasInternalServiceKey صراحتاً رد می‌کند.
INTERNAL_SERVICE_KEY = os.environ.get(
'INTERNAL_SERVICE_KEY',
'dev-internal-service-key-change-me' if DEBUG else '',
)
# هیچ پیش‌فرضی، حتی در DEBUG: رشته‌ی پیش‌فرضِ داخلِ ریپو هر لپ‌تاپِ توسعه‌ای را که روی شبکه
# باز است به ingest در Qdrant راه می‌داد.
INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '')
# -----------------------------------------------------------------------

View File

@@ -13,6 +13,7 @@ from django.views import defaults as django_default_views
from drf_spectacular.utils import extend_schema
from config.health import HealthView
from rest_framework.throttling import ScopedRateThrottle
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
@@ -46,6 +47,13 @@ def handler500(request):
return django_default_views.server_error(request)
class ThrottledSchemaView(SpectacularAPIView):
"""بی‌ورود و بی‌کش: هر بار ~۵۶ms CPU و ۱۱۰KB؛ بی‌throttle چند ده درخواست در ثانیه workerها را پر می‌کرد."""
throttle_classes = [ScopedRateThrottle]
throttle_scope = 'read'
urlpatterns = [
path('admin/', admin.site.urls),
@@ -67,7 +75,7 @@ urlpatterns = [
path('api/health/', HealthViewSlashAlias.as_view(), name='health-slash'),
# OpenAPI schema + Swagger UI + Redoc
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/schema/', ThrottledSchemaView.as_view(), name='schema'),
# قالبِ خودمان فقط یک پوسته‌ی راست‌به‌چپ روی قالبِ drf-spectacular است؛
# بدونش متنِ فارسی در یک بلوکِ LTR رندر می‌شود و نقطه‌ی جمله و ترتیبِ
# ستون‌های جدول جابه‌جا می‌شوند. توضیح در خودِ قالب.

View File

@@ -1,6 +1,7 @@
import json
from django.contrib import admin, messages
from django.core.exceptions import PermissionDenied
from django.db.models import Count, Q
from django.http import HttpResponse
from django.shortcuts import redirect, render
@@ -54,6 +55,9 @@ class GradeAdmin(admin.ModelAdmin):
return response
def import_curriculum_view(self, request):
# admin_view فقط is_staff را می‌سنجد؛ این صفحه پایه/فصل/درس می‌سازد و متنِ درس را عوض می‌کند.
if not (self.has_add_permission(request) and self.has_change_permission(request)):
raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
@@ -178,6 +182,9 @@ class QuestionAdmin(admin.ModelAdmin):
return response
def import_view(self, request):
# admin_view فقط is_staff را می‌سنجد؛ «جایگزینی» سوال‌ها و جواب‌های دانش‌آموزان به آن‌ها را پاک می‌کند.
if not self.has_add_permission(request):
raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
@@ -185,6 +192,8 @@ class QuestionAdmin(admin.ModelAdmin):
return redirect(request.path)
replace = request.POST.get('replace') == 'on'
if replace and not self.has_delete_permission(request):
raise PermissionDenied
try:
rows = parse(uploaded.read())
result = import_questions(rows, replace=replace)
@@ -238,7 +247,7 @@ class QuestionReportAdmin(admin.ModelAdmin):
autocomplete_fields = ['question']
actions = ['mark_resolved']
@admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده')
@admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده', permissions=['change'])
def mark_resolved(self, request, queryset):
updated = queryset.update(status='resolved', resolved_at=timezone.now())
self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS)

View File

@@ -10,9 +10,12 @@
گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط — یعنی همان سیگنالی که می‌گوید کدام
سوال ایراد دارد یا بیش از حد سخت است.
فقط برای کارمندی که اجازه‌ی دیدنِ سوال دارد، چون کلیدِ جواب‌ها را نشان می‌دهد.
وابستگیِ تازه‌ای اضافه نمی‌کند: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.
"""
from django.contrib.admin.views.decorators import staff_member_required
from django.contrib.auth.decorators import permission_required
from django.db.models import Count, Q
from django.http import JsonResponse
from django.shortcuts import render
@@ -22,6 +25,7 @@ from .models import Grade, Lesson, Node, Question, Unit
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def browser_view(request):
return render(request, 'admin/content/browser.html', {
'title': 'مرورِ محتوا',
@@ -30,6 +34,7 @@ def browser_view(request):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def units_json(request, grade_id: int):
rows = (
Unit.objects.filter(grade_id=grade_id)
@@ -41,6 +46,7 @@ def units_json(request, grade_id: int):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def nodes_json(request, unit_id: int):
rows = (
Node.objects.filter(unit_id=unit_id)
@@ -52,6 +58,7 @@ def nodes_json(request, unit_id: int):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def lessons_json(request, node_id: int):
rows = (
Lesson.objects.filter(node_id=node_id)
@@ -63,6 +70,7 @@ def lessons_json(request, node_id: int):
@staff_member_required
@permission_required('content.view_question', raise_exception=True)
def questions_json(request, lesson_id: int):
"""
هر سوال با دو سیگنال: گزارشِ بازِ کاربران، و درصدِ پاسخِ غلط.

View File

@@ -0,0 +1,80 @@
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (محتوا، پنل و ورودی‌ها). هر تست رفتاری را نگه می‌دارد که
پیش‌تر غلط بود.
"""
import io
import json
from django.contrib.auth.models import Group, User as AuthUser
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from content.models import Choice, Grade, Lesson, Node, Question, QuestionReport, Unit, UserAnswer
class SupervisorCannotEditContentTests(TestCase):
"""admin_view فقط is_staff را می‌سنجد؛ صفحه‌های ورودِ فایل اجازه‌ی مدل را جدا چک نمی‌کردند."""
def setUp(self):
cache.clear()
call_command('setup_supervisor_group', stdout=io.StringIO())
sup = AuthUser.objects.create_user('sup', 's@x.c', 'pw12345!', is_staff=True)
sup.groups.add(Group.objects.get(name='Supervisor'))
self.client.force_login(sup)
grade = Grade.objects.create(title='G', icon='', color='', number=55)
unit = Unit.objects.create(grade=grade, title='U', order=1)
node = Node.objects.create(unit=unit, title='N', order=1)
self.lesson = Lesson.objects.create(node=node, title='L', intro_text='x')
self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='p?')
ok = Choice.objects.create(question=self.q, value='1', is_correct=True)
Choice.objects.create(question=self.q, value='2', is_correct=False)
self.student = User.objects.create(phone='09120009999', is_guest=False)
UserAnswer.objects.create(user_id=self.student.id, question=self.q, choice=ok, correct=True)
def test_question_import_with_replace_is_refused(self):
f = io.BytesIO(json.dumps([{
'lessonId': self.lesson.id, 'prompt': 'HACKED', 'type': 'multiple_choice',
'choices': ['a', 'b'], 'correct': 0,
}]).encode())
f.name = 'q.json'
r = self.client.post('/admin/content/question/import/', {'file': f, 'replace': 'on'})
self.assertEqual(r.status_code, 403)
self.assertEqual(list(Question.objects.filter(lesson=self.lesson).values_list('prompt', flat=True)), ['p?'])
self.assertEqual(UserAnswer.objects.filter(user_id=self.student.id).count(), 1)
def test_curriculum_import_and_content_browser_are_refused(self):
self.assertEqual(self.client.get('/admin/content/grade/import-curriculum/').status_code, 403)
self.assertEqual(self.client.get('/admin/content/question/import/').status_code, 403)
self.assertEqual(self.client.get(f'/admin/content/question/browse/questions/{self.lesson.id}/').status_code, 403)
def test_mark_resolved_action_is_refused(self):
report = QuestionReport.objects.create(question=self.q, reason='wrong_answer', user_id=self.student.id)
self.client.post('/admin/content/questionreport/', {'action': 'mark_resolved', '_selected_action': [report.pk]})
report.refresh_from_db()
self.assertNotEqual(report.status, 'resolved')
class NonAsciiDigitsAre400Tests(TestCase):
"""'²'.isdigit() هم True است ولی int('²') خطا می‌دهد؛ این ورودی‌ها ۵۰۰ می‌دادند."""
def setUp(self):
cache.clear()
user = User.objects.create(device_id='digits', is_guest=True)
self.app = APIClient()
self.app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def test_character_and_unit_ids(self):
self.assertEqual(self.app.post('/api/characters/select', {'characterId': 'abc'}, format='json').status_code, 400)
self.assertEqual(self.app.post('/api/characters/select', {'characterId': '²'}, format='json').status_code, 400)
self.assertEqual(self.app.get('/api/progress/nodes', {'unit': '²'}).status_code, 400)
def test_ticket_text_is_capped(self):
r = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5001}, format='json')
self.assertEqual(r.status_code, 400)
ok = self.app.post('/api/support/bug-reports', {'title': 't', 'description': 'x' * 5000}, format='json')
self.assertIn(ok.status_code, (200, 201))

View File

@@ -741,7 +741,7 @@ class NodeProgressListView(APIView):
qs = NodeProgress.objects.filter(user_id=request.user.id).select_related('node')
unit_id = request.query_params.get('unit')
if unit_id:
if not unit_id.isdigit():
if not (unit_id.isascii() and unit_id.isdigit()):
raise ValidationError({'unit': 'شناسه‌ی فصل باید عدد باشد.'})
qs = qs.filter(node__unit_id=int(unit_id))

View File

@@ -27,8 +27,10 @@ services:
# idempotent است (مطابقِ همون workflow ای که در README برای اجرای
# محلی/غیرِ Docker هم توصیه شده).
command: sh -c "python manage.py migrate --noinput && python manage.py runserver 0.0.0.0:8000"
# فقط روی خودِ لپ‌تاپ: این پشته DEBUG=1 دارد (کدِ 0000 برای هر شماره) و Grafana بی‌رمز است؛
# روی 0.0.0.0 هر همسایه‌ی وای‌فای به آن‌ها می‌رسید.
ports:
- "8000:8000"
- "127.0.0.1:8000:8000"
environment:
# این مقادیر فقط برای اجرای محلیِ Docker/دِمو هستن؛ برای پروداکشن
# واقعی جایگزین بشن (به .env.example نگاه کنید).
@@ -53,7 +55,7 @@ services:
image: grafana/loki:2.9.6
container_name: tadrisino_loki
ports:
- "3100:3100"
- "127.0.0.1:3100:3100"
command: -config.file=/etc/loki/loki-config.yml
volumes:
- ./docker/loki/loki-config.yml:/etc/loki/loki-config.yml:ro
@@ -71,7 +73,7 @@ services:
- --storage.path=/var/lib/alloy/data
- /etc/alloy/config.alloy
ports:
- "12345:12345" # اختیاری: UI دیباگِ Alloy (وضعیتِ کامپوننت‌ها)
- "127.0.0.1:12345:12345" # اختیاری: UI دیباگِ Alloy (وضعیتِ کامپوننت‌ها)
volumes:
- ./docker/alloy/config.alloy:/etc/alloy/config.alloy:ro
- django_logs:/var/log/django:ro # همون لاگ‌های Django رو می‌خونه (read-only)
@@ -86,12 +88,12 @@ services:
image: grafana/grafana:10.4.3
container_name: tadrisino_grafana
ports:
- "3000:3000"
- "127.0.0.1:3000:3000"
environment:
# فقط برای دسترسیِ محلی/دِمو ساده شده (بدونِ لاگین)؛ برای هر چیزِ
# عمومی/پروداکشن حتماً anonymous access رو خاموش و پسورد ست کنید.
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
volumes:
- ./docker/grafana/provisioning:/etc/grafana/provisioning:ro

View File

@@ -16,7 +16,7 @@ docker compose up --build
| سرویس | آدرس | توضیح |
|---|---|---|
| اپ Django | `http://localhost:8000` | |
| Grafana | `http://localhost:3000` | دسترسیِ anonymous، فقط محیطِ محلی |
| Grafana | `http://localhost:3000` | دسترسیِ anonymous فقط با نقشِ Viewer، فقط روی خودِ لپ‌تاپ (`127.0.0.1`) |
| Loki | `http://localhost:3100` | ذخیره‌سازیِ لاگ، در معرضِ کاربر نیست |
| Alloy | `http://localhost:12345` | رابطِ دیباگِ اختیاری |

View File

@@ -112,7 +112,8 @@ curl https://mathapi.tadrisino2.ir/api/health
| `OPENAI_API_KEY` · `QDRANT_API_KEY` | `POST /api/ai/ask` همان پاسخِ stub را می‌دهد |
| `TEST_LOGIN_PHONES` | هیچ حسابِ تستی باز نیست (بخشِ بعد) |
| `HEARTS_ENFORCED` | روشن (از ۲۰۲۶-۱۰-۰۲): با صفر قلب نه جواب پذیرفته می‌شود نه پایانِ درس/جزیره‌ی بی‌جواب. `0` خاموشش می‌کند |
| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بسته‌اند |
| `INTERNAL_SERVICE_KEY` | endpointهای داخلیِ ingestِ RAG و ساختِ سفارشِ سرور-به-سرور بسته‌اند (در DEBUG هم؛ پیش‌فرض ندارد) |
| `DEMO_GUEST_DEVICE_IDS` | فقط `frontend-test-device` با «حذفِ حساب» پاک نمی‌شود. با پاک شدنِ داده‌ی دمو، پیش‌فرض هم از `config/settings.py` برداشته شود |
**اتصال به بازار — روشِ جدید (ترجیحی):** در پیشخانِ بازار روی برنامه ← «API پیشخان
بازار» ← «دریافت توکن جدید» (از حسابِ ناشر، نه کارپرداز). همان را در `.env` به‌عنوانِ

View File

@@ -1,6 +1,6 @@
# پیش از پروداکشنِ واقعی: چیزهایی که عمداً باز مانده‌اند
**آخرین بازبینی با کد و سرورِ زنده: ۲۰۲۶-۱۰-۰۴ (۱۲ مهر ۱۴۰۵).**
**آخرین بازبینی با کد: ۲۰۲۶-۱۰-۰۵ (۱۳ مهر ۱۴۰۵)؛ با سرورِ زنده: ۲۰۲۶-۱۰-۰۴.**
این پروژه تا این تاریخ در حالتِ **دمو و ارائه‌ی کارآموزی** است، نه محصولِ نهایی. چند چیز
عمداً باز گذاشته شده تا توسعه‌دهنده‌ها، فرانت‌کار و ارائه کار کنند. هر کدام برای یک سرورِ
@@ -38,6 +38,14 @@
| ۲۰ | یونیتِ systemd `DJANGO_DEBUG=1` و کلیدِ ناامن دارد؛ فقط یک خطِ `.env` جلوی DEBUG را گرفته | بالا (نهفته) | ادمینِ سرور |
| ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت |
| ۲۳ | سقفِ دستیار برای مهمان‌ها بر اساسِ IP مشترک است؛ کلاسِ پشتِ یک IP بعد از ۲۰ سوال در ساعت ۴۲۹ می‌گیرد | متوسط (دسترس‌پذیری) | تصمیمِ محصولی |
| ۲۴ | سقفِ روزانه‌ی ۳۰۰ پیامکِ کلِ سامانه را یک ناشناس پر می‌کند و ورود با پیامک برای همه بسته می‌شود | بالا (دسترس‌پذیری، هزینه) | تصمیمِ طراحی |
| ۲۵ | با دانستنِ شماره‌ی کسی، ورودِ او تا ۲۴ ساعت بسته می‌شود | متوسط | تصمیمِ طراحی |
| ۲۶ | پایانِ درس و جزیره به گفته‌ی کلاینت است (جزیره‌ی قفل، قلبِ صفر، گزارشِ والدین) | متوسط | کد، بعد از ارائه |
| ۲۷ | کلیدِ جوابِ هر سوال با یک حسابِ دوم درمی‌آید؛ امتیازِ هر جوابِ درست دو بار داده می‌شود | کم | تصمیمِ محصولی |
| ۲۸ | استفاده‌ی دوباره از توکنِ تمدیدِ دزدیده‌شده تشخیص داده نمی‌شود؛ «خروج از همه‌ی دستگاه‌ها» نیست | کم | کد، بعد از ارائه |
| ۲۹ | پیشنهادِ ثبت‌نام با حذفِ حساب و ورودِ دوباره با همان شماره دوباره داده می‌شود | کم | کد |
| ۳۰ | چند مسیرِ خواندنی (از جمله بانکِ سوال، بی‌ورود) throttle ندارند | کم | کد، بعد از ارائه |
| ۳۱ | لپ‌تاپِ توسعه با `mobile-preview` روی شبکه‌ی محلی باز است و در DEBUG کدِ `0000` را می‌پذیرد | کم | لپ‌تاپِ توسعه |
---
@@ -82,7 +90,9 @@
- **خطر:** داده‌ی ساختگی در آمار و جدولِ رتبه‌بندیِ واقعی؛ یک حسابِ عمومی که هر کس
می‌تواند با آن کار کند (ثبتِ تیکت، گفتگو با AI به هزینه‌ی ما).
- **رفع:** پیش از کاربرِ واقعی پاک شوند. `purge_demo_data` این‌ها را **نمی‌گیرد** (از مسیرِ
واقعیِ viewها ساخته شده‌اند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ.
واقعیِ viewها ساخته شده‌اند)؛ باید با شناسه پاک شوند، پیش از آن بکاپ. همراهِ آن پیش‌فرضِ
`DEMO_GUEST_DEVICE_IDS` در `config/settings.py` (که حذفِ این مهمان را با `POST /api/user/delete`
می‌بندد) هم برداشته شود.
- **چطور بسنجید:** `POST /api/auth/session` با `frontend-test-device` باید `isNewUser: true` بدهد.
## ۴. `/api/health` عمومی و پرجزئیات
@@ -107,8 +117,12 @@
(`X-Internal-Key`) را فهرست می‌کند.
- **خطر:** شناساییِ کاملِ سطحِ حمله بدونِ زحمت.
- **چرا باز مانده:** قراردادِ مشترک با فرانت همین‌جاست؛ فرانت‌کار و ارائه به آن نیاز دارند.
- **رفع:** در پروداکشن `SERVE_PERMISSIONS` در `SPECTACULAR_SETTINGS` را به
`IsAdminUser` (staffِ جنگو) محدود کنید، یا مسیر را در nginx به IPهای تیم ببندید.
- **رفع:** مسیر را در nginx به IPهای تیم ببندید، یا در `SPECTACULAR_SETTINGS` هر دوِ
`SERVE_PERMISSIONS = ['rest_framework.permissions.IsAdminUser']` و
`SERVE_AUTHENTICATION = ['rest_framework.authentication.SessionAuthentication']` را بگذارید.
`IsAdminUser` به‌تنهایی کار نمی‌کند: احرازِ پیش‌فرضِ DRF فقط توکنِ Bearer است، پس ادمینِ واردشده
به پنل هم ۴۰۱ می‌گیرد (آزموده شد). اسکریپتِ Swagger/ReDoc نسخه‌ی ثابت دارد (نه `@latest`)، چون
روی همان دامنه‌ی `/admin/` اجرا می‌شود.
- **چطور بسنجید:** `curl -o /dev/null -w "%{http_code}" …/api/schema/` باید ۴۰۱/۴۰۳ بدهد.
## ۷. پنلِ ادمین روی دامنه‌ی عمومی
@@ -148,7 +162,9 @@
- **چیست:** `POST /api/auth/session` با هر `deviceId` یا حسابِ مهمانِ همان دستگاه را
برمی‌گرداند یا می‌سازد (`accounts/views.py::GuestSessionView`). هیچ رازِ دیگری در کار نیست.
- **خطر:** هر کس `deviceId`ِ یک مهمان را بداند (از `localStorage`، لاگ، اسکرین‌شات) وارد
حسابِ او می‌شود. بعد از ثبت‌نام با شماره این راه بسته می‌شود (مهمان دیگر مهمان نیست).
حسابِ او می‌شود. بعد از ثبت‌نام با شماره این راه بسته می‌شود (مهمان دیگر مهمان نیست) و همه‌ی
نشست‌هایی که پیش از آن گرفته شده بودند باطل می‌شوند. شناسه‌ی دستگاه در پنل فقط برای ابرکاربر
نمایش داده می‌شود.
- **رفع:** برای مهمان هم یک رازِ تصادفیِ سمتِ سرور صادر شود و ورودِ دوباره با همان راز باشد،
نه با `deviceId`ِ خام.
@@ -234,6 +250,72 @@
سقفِ سراسریِ `AI_GLOBAL_DAILY_MAX` (۳۰۰۰ در روز، حدودِ ۶ دلار) هزینه را جدا محدود
می‌کند، پس می‌شود سطل را per-user کرد و فقط ساختِ مهمان را محدود نگه داشت. تا تصمیم، دست نخورد.
## ۲۴. پر کردنِ سقفِ سراسریِ پیامک
- **چیست:** `OTP_SMS_GLOBAL_DAILY_MAX` (۳۰۰ در ۲۴ ساعت) جلوی خرجِ بی‌سقفِ پیامک را می‌گیرد. هر
ناشناسی با درخواستِ کد برای شماره‌های تصادفی آن را پر می‌کند؛ از یک IP بی‌جعل ساعتی ۲۰ درخواست،
یعنی روزی ۴۸۰، پس یک نفر سقف را همیشه پر نگه می‌دارد.
- **بازتولید شد:** ۳۰۰ درخواست پذیرفته و ۳۰۰ پیامک فرستاده شد؛ بعد درخواستِ کاربرِ واقعی ۵۰۳.
- **خطر:** هیچ ثبت‌نامِ تازه، ورودِ دوباره یا حذفِ حسابی ممکن نیست؛ هزینه‌ی ۳۰۰ پیامک در روز.
- **رفع (تصمیمِ طراحی):** بعد از گذشتن از یک حد، CAPTCHA یا مانعِ مشابه پیش از ارسال؛ شمارشِ سقف
بر اساسِ IP/شبکه؛ هشدار در نیمه‌ی سقف.
## ۲۵. بستنِ ورودِ یک شماره‌ی مشخص
- **چیست:** ۱۰ درخواستِ کد در ~۲ ساعت سقفِ روزانه‌ی آن شماره را پر می‌کند (صاحبش تا ۲۴ ساعت کد
نمی‌گیرد)، و ۵ حدسِ غلطِ هر کسی کدِ تازه‌ی او را باطل می‌کند. هر دو بازتولید شد.
- **رفع (تصمیمِ طراحی):** کد به درخواست‌کننده گره بخورد (درخواستِ کد یک شناسه‌ی موقت برگرداند که
ورود لازمش دارد) و حدسِ غلط برای هر درخواست‌کننده جدا شمرده شود.
## ۲۶. پایانِ درس و جزیره به گفته‌ی کلاینت
- **چیست:** `POST /api/nodes/{id}/quiz/complete` و `POST /api/lessons/{id}/complete` تعدادِ سوال و
جوابِ درست را از کلاینت می‌گیرند. بازتولید شد: با قلبِ صفر و `totalQuestions=1` جزیره تمام
می‌شود؛ جزیره‌ی قفل با `correctCount=0` تمام می‌شود؛ درس و جزیره بی‌هیچ جوابی تمام می‌شوند، پس
`GET /api/user/weekly-report` پیشرفتِ ۱۰۰٪ نشان می‌دهد بی‌آن‌که سوالی جواب داده شده باشد.
- **چرا باز مانده:** رفتارِ آزمون در فرانت به همین شکل است؛ عوض کردنش پیش از ارائه جریانِ نقشه را
به خطر می‌اندازد.
- **رفع:** تعدادِ سوالِ آزمون را سرور تعیین کند (همان `/quiz` با seed)، همان تعداد جوابِ ثبت‌شده
لازم باشد، و جزیره‌ی قبلی تمام شده باشد.
## ۲۷. کلیدِ جواب با حسابِ دوم، امتیازِ دوباره
- **چیست:** جوابِ غلط `correctOptionId` را برمی‌گرداند و برای همه یکی است؛ پایانِ درس و پایانِ
جزیره هر کدام جدا امتیاز می‌دهند. یک مهمانِ دورریختنی کلید را درمی‌آورد و حسابِ اصلی همه را درست
می‌زند (۴ سوال، ۸۰ امتیاز). رتبه‌بندی و آمارِ درصدِ غلط آلوده می‌شوند.
- **رفع (تصمیمِ محصولی):** امتیازِ هر سوال یک بار؛ گزینه‌ی درست فقط بعد از پایانِ آزمون.
## ۲۸. توکنِ تمدیدِ دزدیده‌شده
- **چیست:** استفاده‌ی دوباره از یک توکنِ تمدیدِ باطل‌شده فقط ۴۰۱ می‌گیرد؛ اگر دزد اول تمدید کند،
صاحبِ حساب بیرون می‌افتد و دزد می‌ماند. «خروج از همه‌ی دستگاه‌ها» هم نیست.
- **چرا باز مانده:** باطل کردنِ کلِ زنجیره با استفاده‌ی دوباره، حساب‌های دمو را که در ارائه چند
نفر هم‌زمان با آن‌ها کار می‌کنند بیرون می‌اندازد.
- **رفع:** شناسه‌ی خانواده روی `Session`؛ با استفاده‌ی دوباره کلِ خانواده باطل شود؛ گزینه‌ی خروج از همه‌جا.
## ۲۹. پیشنهادِ ثبت‌نام بعد از حذفِ حساب
- **چیست:** پیشنهادِ خودکارِ ثبت‌نام به `user_id` گره خورده است؛ حذفِ حساب و ورودِ دوباره با همان
شماره یک کاربرِ تازه می‌سازد و پیشنهاد دوباره داده می‌شود. فقط وقتی اثر دارد که یک Offerِ
ثبت‌نام فعال باشد.
- **رفع:** گره زدنِ پیشنهادِ ثبت‌نام به هشِ شماره.
## ۳۰. مسیرهای خواندنیِ بی‌throttle
- **چیست:** `lessons/{id}/questions` و `lessons/{id}/intro` (بی‌ورود)، `progress/nodes`،
`user/hearts`، `notifications/{id}/read`، `support/tickets/{id}` و چند مسیرِ دیگر `throttle_scope`
ندارند؛ کلِ بانکِ سوال بی‌ورود قابلِ کپی است. (`/api/schema/` سقفِ `read` دارد.)
- **چرا باز مانده:** سقفِ مهمان‌ها بر اساسِ IP است (مورد ۲۳)؛ افزودنِ سقف به این مسیرها برای کلاسی
که پشتِ یک وای‌فای با مهمان کار می‌کند ۴۲۹ می‌سازد. همراهِ تصمیمِ مورد ۲۳ بسته شود.
## ۳۱. لپ‌تاپِ توسعه روی شبکه‌ی محلی
- **چیست:** `mobile-preview/serve.py` عمداً روی کلِ شبکه گوش می‌دهد (برای گوشیِ روی همان وای‌فای) و
`/api/` را به runserverِ محلی می‌فرستد؛ در DEBUG کدِ `0000` هر شماره‌ای را وارد می‌کند و کلیدِ
GapGPTِ لپ‌تاپ همان حسابِ سرور است. (`docker compose` فقط روی `127.0.0.1` باز است و کلیدِ سرویسِ
داخلی دیگر پیش‌فرض ندارد.)
- **رفع:** روی شبکه‌ی عمومی (کافه، مدرسه) اجرا نشود؛ برای توسعه کلیدهای جدای GapGPT و Qdrant.
---
## حفاظ‌هایی که هست (تا دوباره باز نشوند)
@@ -249,7 +331,19 @@
| جدولِ رتبه‌بندی فقط برای کاربرِ واردشده | `progress/tests.py` (`LeaderboardPeriodValidationTests`) |
| شماره‌ی موبایل نرمال می‌شود؛ cooldown، سقفِ روزانه‌ی هر شماره و سقفِ کلِ پیامک در دیتابیس شمرده می‌شوند | `accounts/tests_otp_limits.py` |
| OTPِ ۶ رقمی با ۵ حدس برای هر کد؛ خروج از حساب توکن‌های قبلی را باطل می‌کند | `accounts/tests_audit_fixes.py` |
| قلبِ صفر جوابِ سوال و پایانِ درس/جزیره را می‌بندد؛ چکش داخلِ تراکنش است | `content/tests_hearts_gate.py` |
| قلبِ صفر جوابِ سوال را می‌بندد؛ چکش داخلِ تراکنش است (پایانِ جزیره را فقط کامل نمی‌بندد، مورد ۲۶) | `content/tests_hearts_gate.py` |
| `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسه‌ی زمان‌ثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
| دیتابیس و بکاپ‌ها فقط برای صاحبِ سرویس خواندنی‌اند (مرحله‌ی «مجوزِ فایل‌های داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد |
| کتابخانه‌ها بدونِ آسیب‌پذیریِ شناخته‌شده (`urllib3>=2.8.0`، `python-dotenv 1.2.2`) | پایگاهِ OSV روی `pip freeze` |
| actionهای لغو/بازپرداختِ سفارش، علامتِ «بررسی‌شده» و لینکِ بازار اجازه‌ی تغییر می‌خواهند؛ کارمندِ فقط‌خواندنی (Supervisor) اجرایشان نمی‌کند | `payment/tests_security_review.py`، `content/tests_security_review.py` |
| ورودِ فایلِ سوال/برنامه‌ی درسی اجازه‌ی افزودن (و برای جایگزینی، حذف) می‌خواهد؛ مرورگرِ محتوا اجازه‌ی دیدنِ سوال | `content/tests_security_review.py` |
| شناسه‌ی دستگاهِ مهمان فقط برای ابرکاربر در پنل دیده می‌شود؛ ثبتِ شماره نشست‌های قبلیِ مهمان را باطل می‌کند | `accounts/tests_security_review.py` |
| حذفِ حساب سابقه‌ی کدِ تخفیف، پیشنهادها و کدهای پیامکیِ شماره را نگه می‌دارد؛ مهمانِ نمایشی حذف نمی‌شود | `accounts/tests_security_review.py` |
| صافیِ اطلاعاتِ تماس در نامِ نمایشی (شماره با هر جداکننده، آیدیِ شبکه‌ی اجتماعی) | `accounts/tests_security_review.py` |
| کلیدِ کاوه‌نگار و توکنِ خریدِ بازار در لاگِ خطای شبکه نمی‌آیند | `accounts/tests_security_review.py` |
| برگشت از زرین‌پال بی‌`Authority` ۴۰۰؛ خریدِ دومِ بازار روی سفارشِ پرداخت‌شده ۴۰۹؛ آنچه داده یا پس گرفته می‌شود از خودِ سفارش است، نه پلنِ فعلی | `payment/tests_security_review.py` |
| شکلِ خرابِ مدل جواب را رایگان نمی‌کند؛ علامتِ «خارج از ریاضی» در هر جای استریم جواب را رد می‌کند و در کش نمی‌رود؛ بستنِ صفحه جوابِ ناقص را ثبت می‌کند؛ collectionِ بیرون از `KNOWN_COLLECTIONS` جستجو نمی‌شود | `ai_tutor/tests_security_review.py` |
| ورودیِ عددیِ غیرِ ASCII (مثلِ `²`) ۴۰۰ است نه ۵۰۰؛ متنِ تیکت/گزارشِ باگ حداکثر ۵۰۰۰ نویسه | `content/tests_security_review.py` |
| `INTERNAL_SERVICE_KEY` در هیچ حالتی پیش‌فرض ندارد | `payment/tests.py` (`test_debug_mode_has_no_default_internal_key`) |
| کلیدِ امضای خودساخته (`.django_secret_key`) یک بار و اتمی ساخته می‌شود؛ سه workerِ هم‌زمان یک کلید دارند | دستی: سه پروسه‌ی هم‌زمان که `config.settings` را import می‌کنند، بی‌فایلِ کلید؛ ۲۰ از ۲۰ یک کلید |
| فرانت: شماره‌ی سفارشِ برگشتی از درگاه فقط عدد است، مسیرها encode می‌شوند و «پرداخت موفق» فقط از پاسخِ سرور می‌آید؛ دو تبِ هم‌زمان با تمدیدِ توکن خارج نمی‌شوند | مخزنِ فرانت: `src/app/launchParams.ts`، `src/api/http.ts` |

View File

@@ -25,7 +25,7 @@ class OrderAdmin(admin.ModelAdmin):
inlines = [TransactionInline]
actions = ['cancel_pending', 'refund']
@admin.action(description='لغوِ سفارشِ در انتظار')
@admin.action(description='لغوِ سفارشِ در انتظار', permissions=['change'])
def cancel_pending(self, request, queryset):
from .services import OrderStateError, cancel_pending_order
@@ -39,7 +39,7 @@ class OrderAdmin(admin.ModelAdmin):
if done:
self.message_user(request, f'{done} سفارش لغو شد.', messages.SUCCESS)
@admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداخت‌شده)')
@admin.action(description='بازپرداخت (پس گرفتنِ اعتبار/اشتراکِ سفارشِ پرداخت‌شده)', permissions=['change'])
def refund(self, request, queryset):
from django.template.response import TemplateResponse
@@ -97,7 +97,7 @@ class BazaarCredentialAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
return False
@admin.action(description='ساختِ لینکِ اتصال به بازار')
@admin.action(description='ساختِ لینکِ اتصال به بازار', permissions=['change'])
def make_authorize_link(self, request, queryset):
url = bazaar.start_authorization()
self.message_user(request, format_html(

View File

@@ -295,8 +295,9 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/'
try:
resp = requests.get(url, headers=_auth_headers(), timeout=10)
except requests.RequestException:
logger.exception('Bazaar verify-purchase request failed')
except requests.RequestException as exc:
# فقط نوعِ خطا: متنِ خطا آدرس را دارد و توکنِ خریدِ کاربر بخشی از آن است.
logger.error('Bazaar verify-purchase request failed: error=%s', type(exc).__name__)
raise BazaarAPIError()
try:
return resp.status_code, resp.json()

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.17 on 2026-10-05 01:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payment', '0010_order_cancel_refund'),
]
operations = [
migrations.AddField(
model_name='order',
name='duration_days',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='order',
name='hearts_days',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='order',
name='question_credits',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='transaction',
name='gateway_ref_id',
field=models.CharField(blank=True, max_length=300),
),
]

View File

@@ -30,6 +30,12 @@ class Order(models.Model):
# پیش از ۲۰۲۶-۱۰-۰۱ این دو را ندارند (null / خالی).
list_price = models.PositiveIntegerField(null=True, blank=True)
plan_title = models.CharField(max_length=50, blank=True)
# آنچه این سفارش فروخته، در لحظه‌ی ساخت (create_order). پرداخت و بازپرداخت همین‌ها را می‌دهند و
# پس می‌گیرند، نه عددهای فعلیِ پلن: پیش‌تر اگر ادمین پلن را بینِ ساختِ سفارش و پرداخت عوض می‌کرد،
# خریدارِ بسته‌ی ۴۵ هزار تومانی ۱۵۰ سوال می‌گرفت. null یعنی سفارشِ بی‌پلن یا ساخته‌شده پیش از این ستون.
question_credits = models.PositiveIntegerField(null=True, blank=True)
hearts_days = models.PositiveIntegerField(null=True, blank=True)
duration_days = models.PositiveIntegerField(null=True, blank=True)
currency = models.CharField(max_length=10, default='IRT')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
provider = models.CharField(max_length=10, choices=PROVIDER_CHOICES, default='gateway')
@@ -57,7 +63,8 @@ class Order(models.Model):
class Transaction(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='transactions')
gateway = models.CharField(max_length=30, default='zarinpal')
gateway_ref_id = models.CharField(max_length=100, blank=True)
# ۳۰۰ مثلِ Order.bazaar_purchase_token: توکنِ خریدِ بازار همین‌جا می‌نشیند.
gateway_ref_id = models.CharField(max_length=300, blank=True)
status = models.CharField(max_length=30)
paid_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -100,12 +100,18 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing)
return existing, payment_url
from subscription.models import Plan
plan = Plan.objects.filter(pk=plan_id).first() if plan_id else None
try:
with db_transaction.atomic():
order = Order.objects.create(
user_id=user_id,
subscription_id=subscription_id,
plan_id=plan_id,
question_credits=plan.question_credits if plan else None,
hearts_days=plan.hearts_days if plan else None,
duration_days=plan.duration_days if plan else None,
amount=amount,
list_price=amount,
plan_title=plan_title[:50],
@@ -206,7 +212,8 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
if newly_paid:
if order.subscription_id:
from subscription.services import activate_subscription
activate_subscription(order.subscription_id, plan_id=order.plan_id)
activate_subscription(order.subscription_id, plan_id=order.plan_id,
duration_days=order.duration_days)
elif order.plan_id:
# بسته‌ی سوال: اعتبار و روزهای قلبِ نامحدود، بی‌دست زدن به اشتراک.
from subscription.services import grant_pack
@@ -314,7 +321,7 @@ def refund_order(order: Order, *, staff: str = '') -> dict:
if order.subscription_id:
sub = Subscription.objects.select_for_update().filter(pk=order.subscription_id).first()
days = plan.duration_days if plan else 30
days = order.duration_days if order.duration_days is not None else (plan.duration_days if plan else 30)
if sub is not None and sub.expires_at is not None:
sub.expires_at = max(sub.expires_at - timezone.timedelta(days=days), now)
if sub.expires_at <= now:
@@ -331,12 +338,13 @@ def refund_order(order: Order, *, staff: str = '') -> dict:
if take:
QuestionCredit.objects.create(user_id=order.user_id, delta=-take, reason='refund',
order_id=order.id, note=f'بازپرداختِ سفارشِ {order.id}')
if plan is not None and plan.hearts_days:
hearts_days = order.hearts_days if order.hearts_days is not None else (plan.hearts_days if plan else 0)
if hearts_days:
hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first()
if hp is not None:
hp.expires_at = max(hp.expires_at - timezone.timedelta(days=plan.hearts_days), now)
hp.expires_at = max(hp.expires_at - timezone.timedelta(days=hearts_days), now)
hp.save(update_fields=['expires_at'])
summary['hearts_days'] = plan.hearts_days
summary['hearts_days'] = hearts_days
from affiliate.models import Conversion
Conversion.objects.filter(order_id=order.id, status='paid').update(

View File

@@ -46,7 +46,7 @@ class SecretDefaultsFailClosedTests(TestCase):
)
self.assertTrue(HasInternalServiceKey().has_permission(internal_request, None))
def test_debug_mode_local_fallback_still_valid(self):
def test_debug_mode_has_no_default_internal_key(self):
# settings.py این مقادیر رو یک‌بار موقعِ import محاسبه می‌کنه، نه
# چیزی که override_settings بعداً toggle کنه؛ پس برای تستِ این
# منطق باید خودِ ماژول رو reload کرد.
@@ -63,10 +63,8 @@ class SecretDefaultsFailClosedTests(TestCase):
with patch.dict(os.environ, clean_env, clear=True):
importlib.reload(settings_module)
self.assertTrue(settings_module.DEBUG)
self.assertEqual(
settings_module.INTERNAL_SERVICE_KEY,
'dev-internal-service-key-change-me',
)
# رشته‌ی پیش‌فرضِ داخلِ ریپو هر لپ‌تاپِ توسعه‌ی بازِ روی شبکه را به ingest راه می‌داد.
self.assertEqual(settings_module.INTERNAL_SERVICE_KEY, '')
finally:
# همیشه ماژول رو به حالتِ واقعیِ محیطِ تست برمی‌گردونیم تا روی
# بقیه‌ی تست‌ها اثر نذاره.

View File

@@ -0,0 +1,120 @@
"""
حفاظ‌های بازبینیِ امنیتیِ پیش از تحویل (پرداخت). هر تست رفتاری را نگه می‌دارد که پیش‌تر غلط بود.
"""
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.core.cache import cache
from django.core.management import call_command
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from discount.models import DiscountCode
from subscription.models import Plan
from subscription.services import credit_balance
from .bazaar import BazaarPurchaseResult
from .models import Order, Transaction
from .services import apply_order_payment_result
def client_for(user):
c = APIClient()
c.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return c
class _Base(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120009901', is_guest=False)
self.app = client_for(self.user)
self.pack = Plan.objects.get(kind='pack', question_credits=20)
def tearDown(self):
cache.clear()
def pack_order(self, **extra):
r = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), **extra}, format='json')
return Order.objects.get(pk=r.json()['orderId'])
class SupervisorCannotRunMoneyActionsTests(_Base):
"""گروهِ Supervisor فقط view_order دارد؛ actionِ بی‌permissions برای هر بیننده‌ی فهرست اجرا می‌شد."""
def setUp(self):
super().setUp()
call_command('setup_supervisor_group', verbosity=0)
staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True)
staff.groups.add(Group.objects.get(name='Supervisor'))
self.client.force_login(staff)
def test_refund_is_refused(self):
order = self.pack_order()
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='R1', new_status='paid', event='t')
self.client.post('/admin/payment/order/', {'action': 'refund', '_selected_action': [order.pk], 'confirm': 'yes'})
order.refresh_from_db()
self.assertEqual(order.status, 'paid')
self.assertEqual(credit_balance(self.user.id), 20)
def test_cancel_is_refused(self):
order = Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-c1')
self.client.post('/admin/payment/order/', {'action': 'cancel_pending', '_selected_action': [order.pk]})
order.refresh_from_db()
self.assertEqual(order.status, 'pending')
class CallbackWithoutAuthorityTests(_Base):
def test_missing_authority_is_400_and_touches_no_order(self):
DiscountCode.objects.create(code='SEC50', type='percent', value=50, max_uses=10)
order = self.pack_order()
self.assertEqual(self.app.post('/api/discount/redeem', {'code': 'SEC50', 'orderId': str(order.id)},
format='json').status_code, 200)
Order.objects.create(user_id=self.user.id, amount=1000, idempotency_key='sec-b') # دومین سفارشِ بازنشده
resp = APIClient().get('/api/payment/zarinpal/callback', {'Status': 'NOK'})
self.assertEqual(resp.status_code, 400)
order.refresh_from_db()
self.assertEqual(order.status, 'pending')
class PlanEditedBetweenOrderAndPaymentTests(_Base):
def test_buyer_gets_what_the_order_sold(self):
order = self.pack_order()
Plan.objects.filter(pk=self.pack.pk).update(price=order.amount * 5, question_credits=150, hearts_days=60)
apply_order_payment_result(order=order, gateway='zarinpal', gateway_ref_id='P1', new_status='paid', event='t')
self.assertEqual(credit_balance(self.user.id), 20)
self.assertEqual(order.question_credits, 20)
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
class BazaarSecondTokenOnPaidOrderTests(_Base):
def setUp(self):
super().setUp()
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-pack20')
def test_second_token_is_409_and_stays_usable(self):
order = self.pack_order(paymentMethod='bazaar')
valid = BazaarPurchaseResult(is_valid=True, purchase_state=0)
with patch('payment.bazaar.verify_purchase', return_value=valid):
first = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json')
again = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-1'}, format='json')
second = self.app.post('/api/payment/bazaar/verify',
{'orderId': str(order.id), 'purchaseToken': 'TOKEN-2'}, format='json')
self.assertEqual(first.json()['status'], 'paid')
self.assertEqual(again.status_code, 200) # همان توکن دوباره: بی‌اثر و بی‌خطا
self.assertEqual(second.status_code, 409)
self.assertFalse(Transaction.objects.filter(gateway_ref_id='TOKEN-2').exists())
r2 = self.app.post('/api/subscription/purchase', {'planId': str(self.pack.id), 'paymentMethod': 'bazaar'},
format='json', HTTP_IDEMPOTENCY_KEY='sec-new')
with patch('payment.bazaar.verify_purchase', return_value=valid):
fresh = self.app.post('/api/payment/bazaar/verify',
{'orderId': r2.json()['orderId'], 'purchaseToken': 'TOKEN-2'}, format='json')
self.assertEqual(fresh.json()['status'], 'paid')
self.assertEqual(credit_balance(self.user.id), 40)

View File

@@ -7,6 +7,7 @@ from django.utils.html import escape
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
@@ -107,7 +108,7 @@ class CreateOrderView(APIView):
if v.get('planId'):
from subscription.models import Plan
plan = Plan.objects.filter(pk=v['planId']).first() if str(v['planId']).isdigit() else None
plan = Plan.objects.filter(pk=v['planId']).first() if (str(v['planId']).isascii() and str(v['planId']).isdigit()) else None
if plan is None:
raise ValidationError({'planId': 'پلنی با این شناسه وجود ندارد.'})
@@ -225,6 +226,10 @@ class ZarinpalCallbackView(APIView):
def get(self, request):
authority = request.query_params.get('Authority', '')
status_param = request.query_params.get('Status', '')
# بی‌Authority، جستجو هر سفارشی را می‌گرفت که صفحه‌ی پرداختش هنوز باز نشده (authority خالی):
# یک NOKِ ناشناس آن را «ناموفق» می‌کرد و با دو سفارش ۵۰۰ می‌داد.
if not authority:
raise ValidationError({'Authority': 'لازم است.'})
order = get_object_or_404(Order, gateway_authority=authority, provider='gateway')
if status_param != 'OK':
@@ -338,6 +343,8 @@ class BazaarVerifyPurchaseView(APIView):
# می‌شد؛ یعنی توکنِ خریدِ ارزان‌ترین محصول سفارشِ گران‌ترین پلن را «paid» می‌کرد.
if not order_lookup.bazaar_product_sku:
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
return self._second_purchase_response()
result = bazaar.verify_purchase(
product_sku=order_lookup.bazaar_product_sku,
purchase_token=v['purchaseToken'],
@@ -349,6 +356,8 @@ class BazaarVerifyPurchaseView(APIView):
pk=v['orderId'], user_id=request.user.id, provider='bazaar',
)
if self._second_purchase_on_paid_order(order, v['purchaseToken']):
return self._second_purchase_response()
new_status = 'paid' if result.is_valid else 'failed'
order.bazaar_purchase_token = v['purchaseToken']
order.save(update_fields=['bazaar_purchase_token'])
@@ -367,6 +376,20 @@ class BazaarVerifyPurchaseView(APIView):
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
return Response(BazaarVerifyResponseSerializer(data).data)
@staticmethod
def _second_purchase_on_paid_order(order, purchase_token: str) -> bool:
"""
خریدِ دیگری برای سفارشی که با یک خریدِ دیگر پرداخت شده. پیش‌تر پاسخ «paid» بود، چیزی داده
نمی‌شد و توکنِ دوم هم برای هر سفارشِ بعدی سوخته بود؛ کاربر دو بار پول داده بود.
"""
return (order.status == 'paid' and bool(order.bazaar_purchase_token)
and order.bazaar_purchase_token != purchase_token)
@staticmethod
def _second_purchase_response():
return Response({'detail': 'این سفارش با خریدِ دیگری پرداخت شده است؛ برای این خرید سفارشِ تازه بساز.'},
status=status.HTTP_409_CONFLICT)
class OrderDetailView(APIView):
"""GET /api/payment/orders/{id} — وضعیت سفارش"""

View File

@@ -255,7 +255,10 @@ class SelectCharacterView(APIView):
def post(self, request):
serializer = SelectCharacterRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
character = get_object_or_404(Character, pk=serializer.validated_data['characterId'])
character_id = serializer.validated_data['characterId']
if not (character_id.isascii() and character_id.isdigit()):
raise ValidationError({'characterId': 'شناسه‌ی کاراکتر باید عدد باشد.'})
character = get_object_or_404(Character, pk=int(character_id))
stats, _ = UserStats.objects.get_or_create(user_id=request.user.id)
if stats.xp < character.unlock_xp:

View File

@@ -1053,8 +1053,20 @@ def _normalize(obj):
return obj
# بزرگ‌ترین شکلِ معتبرِ سنجش‌ها چند کیلوبایت بود؛ بیش از این نشانه‌ی خروجیِ خراب یا دست‌کاری‌شده است.
MAX_DIAGRAM_CHARS = 8000
def parse(body: str) -> dict | None:
"""
شکلِ معتبر یا None. هیچ خطایی بیرون نمی‌رود: شکل اختیاری است، و خطایی که از این‌جا بالا می‌رفت
(OverflowError از عددِ ۴۰۰ رقمی، RecursionError از JSONِ خیلی تودرتو) در استریم کلِ جواب را
«شکست» حساب می‌کرد و سوال را رایگان و بی‌ثبت می‌گذاشت، در حالی که متن به کاربر رسیده بود.
"""
body = (body or '').strip()
if len(body) > MAX_DIAGRAM_CHARS:
logger.warning('diagram: بلوکِ شکل %s نویسه بود (سقف %s)، دور ریخته شد', len(body), MAX_DIAGRAM_CHARS)
return None
if body.startswith('```'):
body = body.strip('`').removeprefix('json').strip()
try:
@@ -1062,6 +1074,9 @@ def parse(body: str) -> dict | None:
except (json.JSONDecodeError, TypeError, ValueError):
logger.info('diagram: JSONِ نامعتبر از مدل، دور ریخته شد')
return None
except Exception:
logger.exception('diagram: خطای پیش‌بینی‌نشده در خواندنِ شکل، دور ریخته شد')
return None
class StreamSplitter:

View File

@@ -402,7 +402,15 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
if not clean:
continue
if released:
yield clean.replace(guardrails.OFF_TOPIC_MARKER, '')
if guardrails.OFF_TOPIC_MARKER in clean:
# علامت دیر آمد: بخشِ پیش از آن رفته است، ولی بقیه نمی‌رود و جواب «ردشده» حساب می‌شود
# تا نه ذخیره شود نه در کش برود (پیش‌تر کاربرِ بعدی همین متن را از کش می‌گرفت).
head = clean.split(guardrails.OFF_TOPIC_MARKER, 1)[0]
if head.strip():
yield head
refused = True
continue
yield clean
continue
held += clean
if guardrails.OFF_TOPIC_MARKER in held:
@@ -420,7 +428,7 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
result['diagram'] = None if refused else diagram_mod.fits(
f'{question}\n{focus}\n{_previous_question(history)}', splitter.diagram)
if refused:
yield guardrails.REFUSAL
yield ('\n\n' if released else '') + guardrails.REFUSAL
return
if not released and held.strip():
yield held

View File

@@ -10,7 +10,9 @@ import logging
from .config import build_collection_name, resolve_grade_title, retrieval_top_k
from .embedding import embed_text
from .known_collections import filter_key_for_subject, is_math_subject, resolve_math_chapter
from .known_collections import (
KNOWN_COLLECTIONS, filter_key_for_subject, is_math_subject, resolve_math_chapter,
)
from .qdrant_store import search
logger = logging.getLogger(__name__)
@@ -29,6 +31,12 @@ def resolve_target(subject: str, grade_title: str, grade_id, lesson_number):
return None, None, None
collection = build_collection_name(subject, grade_title)
if collection not in KNOWN_COLLECTIONS:
# subject و grade می‌توانند از بدنه‌ی درخواست بیایند؛ بی‌این چک هر کاربری از هر collectionِ
# Qdrantِ مشترک (از جمله مالِ تیمِ دیگر) متن می‌خواند و در usedChunks پس می‌گرفت.
# نتیجه همان رفتارِ collectionِ ناموجود است: جوابِ بی‌context.
logger.warning('rag.retrieve: collection %r در KNOWN_COLLECTIONS نیست، جستجو نشد', collection)
return None, None, None
filter_key = filter_key_for_subject(subject)
if is_math_subject(subject):
filter_value = resolve_math_chapter(subject, grade_title, lesson_number)

View File

@@ -65,7 +65,8 @@ class _FakeQdrant:
return SimpleNamespace(points=pts)
@override_settings(**RAG_ON)
# «علوم تجربی سوم» در KNOWN_COLLECTIONS هست؛ collectionِ ناشناخته اصلاً جستجو نمی‌شود.
@override_settings(**{**RAG_ON, 'RAG_GRADE_TITLE_OVERRIDES': {1: 'سوم'}})
class AskAIRagPathTests(TestCase):
def setUp(self):
cache.clear()
@@ -129,6 +130,7 @@ class IngestTextTests(TestCase):
reset_clients_cache()
self.client = APIClient()
@override_settings(INTERNAL_SERVICE_KEY='test-internal-key')
def test_ingest_text_creates_chunks(self):
qdrant = _FakeQdrant()
reply = '{"summary": "خلاصه‌ی این تکه."}'
@@ -142,7 +144,7 @@ class IngestTextTests(TestCase):
'text': 'الف' * 900,
},
format='json',
HTTP_X_INTERNAL_KEY='dev-internal-service-key-change-me',
HTTP_X_INTERNAL_KEY='test-internal-key',
)
self.assertEqual(resp.status_code, 200, resp.content)
self.assertGreater(resp.data['chunksCreated'], 0)

View File

@@ -76,15 +76,19 @@ def grant_pack(order) -> bool:
plan = Plan.objects.filter(pk=order.plan_id, kind='pack').first()
if plan is None:
return False
# عددها از خودِ سفارش (همان که فروخته شد)؛ سفارشِ بی‌این ستون‌ها (پیش از آن‌ها، یا ساخته‌شده بیرون
# از create_order) عددهای فعلیِ پلن را می‌گیرد، همان رفتارِ پیشین.
credits = order.question_credits if order.question_credits is not None else plan.question_credits
hearts_days = order.hearts_days if order.hearts_days is not None else plan.hearts_days
try:
with transaction.atomic():
QuestionCredit.objects.create(user_id=order.user_id, delta=plan.question_credits,
QuestionCredit.objects.create(user_id=order.user_id, delta=credits,
reason='purchase', order_id=order.id, note=plan.title[:100])
if plan.hearts_days:
if hearts_days:
now = timezone.now()
hp = HeartsPass.objects.select_for_update().filter(user_id=order.user_id).first()
start = hp.expires_at if hp and hp.expires_at > now else now
until = start + timezone.timedelta(days=plan.hearts_days)
until = start + timezone.timedelta(days=hearts_days)
HeartsPass.objects.update_or_create(user_id=order.user_id, defaults={'expires_at': until})
except IntegrityError:
return False
@@ -111,7 +115,7 @@ def ai_daily_quota(user_id: int) -> int:
return max(quotas) if quotas else settings.AI_DAILY_QUOTA_FREE
def activate_subscription(subscription_id: int, plan_id: int | None = None):
def activate_subscription(subscription_id: int, plan_id: int | None = None, duration_days: int | None = None):
"""
بعد از تاییدِ پرداخت صدا زده می‌شود (کال‌بکِ زرین‌پال، تاییدِ بازار).
@@ -136,7 +140,9 @@ def activate_subscription(subscription_id: int, plan_id: int | None = None):
# باید دیده شود. پلن را پاک نکنید؛ on_sale=False کنید.
logger.error('activate_subscription: پلنِ %s برای اشتراکِ %s پیدا نشد', plan_id, subscription_id)
plan = plan or subscription.plan
duration_days = plan.duration_days if plan else 30
# duration_days از سفارش (عددِ لحظه‌ی خرید)؛ None برای سفارشِ پیش از آن ستون.
if duration_days is None:
duration_days = plan.duration_days if plan else 30
now = timezone.now()
still_running = (
old_status == 'active' and subscription.expires_at is not None

View File

@@ -3,7 +3,8 @@ from rest_framework import serializers
class BugReportCreateRequestSerializer(serializers.Serializer):
title = serializers.CharField(max_length=150)
description = serializers.CharField()
# سقف: پیش‌تر هر متن تا حدِ بدنه‌ی درخواست (~۲٫۵ مگابایت) پذیرفته و ذخیره می‌شد.
description = serializers.CharField(max_length=5000)
screenshotUrl = serializers.URLField(required=False, allow_blank=True)
@@ -50,7 +51,7 @@ class TicketDetailResponseSerializer(serializers.Serializer):
class TicketMessageCreateRequestSerializer(serializers.Serializer):
text = serializers.CharField()
text = serializers.CharField(max_length=5000)
class TicketMessageCreateResponseSerializer(serializers.Serializer):