tutor answers step by step in plain text, islands finish, and the audit's money and login holes are closed
Tutor (prompt tutor-v3-stepwise-plain): plain text instead of LaTeX/markdown, with rag/services/mathtext.py converting whatever still slips through; full step-by-step solutions with a check before the final answer; three tones by grade; the last 6 messages of the conversation go to the model and follow-ups are no longer served from another user's cache; book solutions the checker disputes are kept out of the context; a questionId gives the tutor the practice question and the student's wrong answer. /api/health reports when the AI credit runs out. rag_model_benchmark compares chat models on verified book problems (run on the server; credit ran out mid-run). Map: only lessons that have questions count toward finishing an island, lessonIds[0] / nextLessonId is always the next lesson to play, and a finished island stays finished. The Seyar characters (sigma/delta/pi) are seeded. Audit fixes, each reproduced before fixing: - the plan is stored on the order, so paying a monthly order after starting a yearly checkout no longer grants a year - opening checkout no longer demotes an active subscription; renewals extend from the current expiry; expired subscriptions are not reported active - one discount code per order and only before the gateway; a 100% code completes the purchase; lost Zarinpal confirmations are re-verified - the unused /api/payment/webhook "mark paid" endpoint is removed - XP counts only answers recorded on the server - 6-digit OTP, each code dies after 5 wrong guesses; POST /api/auth/logout - a guest who signs up keeps their account and progress (test phones excluded) - affiliate commission on the first real payment; guests excluded - parents with several children no longer get a 500 - SQLite runs transactions IMMEDIATE, since it ignores select_for_update - admin login is rate-limited; display names drop phone numbers and links; userQuestion is capped at 2000 characters - server-side heart enforcement exists behind HEARTS_ENFORCED (off until the frontend handles 403 no_hearts) Not done on purpose: rotating the server's secret key and moving to Postgres. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -108,14 +108,33 @@ def _traffic() -> dict:
|
||||
|
||||
|
||||
def _check_integrations() -> dict:
|
||||
"""فقط «تنظیم شده یا نه» — هیچ کلیدی برنمیگردد."""
|
||||
"""
|
||||
فقط «تنظیم شده یا نه» — هیچ کلیدی برنمیگردد. `aiProvider` وضعیتِ واقعیِ
|
||||
آخرین تماس است: کلیدِ تنظیمشده با اعتبارِ تمامشده هم `openai: true` بود.
|
||||
"""
|
||||
return {
|
||||
'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')),
|
||||
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
|
||||
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
|
||||
'aiProvider': _ai_provider_state(),
|
||||
'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''),
|
||||
'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None,
|
||||
}
|
||||
|
||||
|
||||
def _ai_provider_state() -> dict:
|
||||
try:
|
||||
from statuslog.models import StatusChangeLog
|
||||
|
||||
row = StatusChangeLog.objects.filter(label='ai_tutor.Provider').values(
|
||||
'new_status', 'created_at').first()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {'error': type(exc).__name__}
|
||||
if row is None:
|
||||
return {'state': 'ok', 'since': None}
|
||||
return {'state': row['new_status'], 'since': row['created_at'].isoformat()}
|
||||
|
||||
|
||||
def _warnings() -> list[str]:
|
||||
"""
|
||||
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناکاند.
|
||||
@@ -136,10 +155,17 @@ def _warnings() -> list[str]:
|
||||
if test_phones:
|
||||
# نه خودِ شماره و نه کد برنمیگردد؛ فقط تعداد، تا کسی که تنظیماتِ سرور
|
||||
# را ندیده هم بداند چند حسابِ تست با کدِ ثابت باز است و بتواند ببنددشان.
|
||||
# تعداد عمداً گفته نمیشود: این صفحه عمومی است و «۷ حسابِ کدِ ثابت» برای
|
||||
# مهاجم یعنی هدفِ مشخص برای حدس زدن.
|
||||
out.append(
|
||||
f'{len(test_phones)} شمارهی تستِ ورود با کدِ ثابت تعریف شده (TEST_LOGIN_PHONES). '
|
||||
'شمارههای تستِ ورود با کدِ ثابت فعالاند (TEST_LOGIN_PHONES). '
|
||||
'برای توسعه لازم است؛ پیش از تحویلِ نهایی به کاربرانِ واقعی حذفشان کنید.'
|
||||
)
|
||||
if _ai_provider_state().get('state') == 'quota_exhausted':
|
||||
out.append(
|
||||
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همهی کاربرها '
|
||||
'«در دسترس نیست» میدهد تا حساب شارژ شود.'
|
||||
)
|
||||
backend = settings.CACHES['default']['BACKEND']
|
||||
if not settings.DEBUG and 'LocMem' in backend:
|
||||
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
|
||||
|
||||
@@ -110,3 +110,42 @@ class APINoStoreMiddleware:
|
||||
if request.path.startswith(_LOGGED_PATH_PREFIX) and not response.has_header('Cache-Control'):
|
||||
response['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
|
||||
class AdminLoginThrottleMiddleware:
|
||||
"""
|
||||
سقفِ تلاشِ ورود به /admin/ برای هر IP (ADMIN_LOGIN_MAX_ATTEMPTS در ۱۵ دقیقه).
|
||||
|
||||
پنلِ ادمین روی دامنهی عمومی است و فرمِ ورودِ جنگو هیچ محدودیتی ندارد؛
|
||||
یعنی رمزِ ادمین (که همهی محتوا و پرداختها را کنترل میکند) بیسقف قابلِ
|
||||
حدس زدن بود. شمارش در کشِ پیشفرض است: تا CACHE_URL (Redis) تنظیم نشده،
|
||||
برای هر worker جدا — یعنی سقفِ واقعی تا سه برابر. IP با همان منطقِ
|
||||
NUM_PROXIESِ throttleهای DRF خوانده میشود.
|
||||
"""
|
||||
|
||||
WINDOW_SECONDS = 15 * 60
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if request.method == 'POST' and request.path.startswith('/admin/login'):
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
from rest_framework.throttling import BaseThrottle
|
||||
|
||||
key = f'admin_login:{BaseThrottle().get_ident(request)}'
|
||||
cache.add(key, 0, self.WINDOW_SECONDS)
|
||||
try:
|
||||
attempts = cache.incr(key)
|
||||
except ValueError:
|
||||
cache.set(key, 1, self.WINDOW_SECONDS)
|
||||
attempts = 1
|
||||
if attempts > getattr(settings, 'ADMIN_LOGIN_MAX_ATTEMPTS', 10):
|
||||
logger.warning('admin login throttled: ident=%s attempts=%s', key, attempts)
|
||||
return HttpResponse(
|
||||
'تلاشِ ورود بیش از حد مجاز است؛ ۱۵ دقیقهی دیگر دوباره امتحان کنید.',
|
||||
status=429, content_type='text/plain; charset=utf-8',
|
||||
)
|
||||
return self.get_response(request)
|
||||
|
||||
@@ -219,6 +219,8 @@ MIDDLEWARE = [
|
||||
# ۴۰۰) هدرِ CORS نمیگیرند و در مرورگر بهشکلِ خطای مبهمِ شبکه دیده میشوند.
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
# سقفِ حدسِ رمزِ /admin/ برای هر IP (config/middleware.py).
|
||||
'config.middleware.AdminLoginThrottleMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
@@ -269,11 +271,25 @@ DATABASES = {
|
||||
# اینکه فوراً OperationalError('database table is locked') بده.
|
||||
'OPTIONS': {
|
||||
'timeout': 20,
|
||||
# هر atomic() قفلِ نوشتن را از اول میگیرد (BEGIN IMMEDIATE).
|
||||
# SQLite از SELECT ... FOR UPDATE پشتیبانی نمیکند و جنگو
|
||||
# select_for_update را رویش بیصدا نادیده میگیرد؛ پس محافظهای
|
||||
# «قفلِ ردیف» در پرداخت، تخفیف، قلب و سهمیهی دستیار بدونِ این
|
||||
# عملاً قفلی نداشتند. با IMMEDIATE کلِ تراکنش سریالی میشود (قفلِ
|
||||
# کلِ دیتابیس، نه ردیف) — روی SQLite درشتتر ولی درست. هزینه:
|
||||
# تراکنشها پشتِ هم صف میکشند؛ فراخوانیهای شبکهای (AI، زرینپال)
|
||||
# عمداً بیرون از atomic() هستند. بعد از Postgres این را بردارید.
|
||||
'transaction_mode': 'IMMEDIATE',
|
||||
},
|
||||
# کانکشنِ DB بهازای هر request باز/بسته نشه؛ تا این تعداد ثانیه
|
||||
# persistent بمونه. با SQLite بیاثرِ عملی ولی بیضرره؛ بعد از
|
||||
# مهاجرت به Postgres جلوی هزینهی TCP+auth در هر request رو میگیره.
|
||||
'CONN_MAX_AGE': int(os.environ.get('DB_CONN_MAX_AGE', '60')),
|
||||
# تستها روی فایل، مثلِ پروداکشن. دیتابیسِ پیشفرضِ تست در حافظه با
|
||||
# shared-cache است که قفلش جدولبهجدول و بیانتظار است («table is
|
||||
# locked») — رفتاری که روی فایلِ واقعی پیش نمیآید؛ تستِ همزمانیِ
|
||||
# campaigns با BEGIN IMMEDIATE فقط همینجا میافتاد.
|
||||
'TEST': {'NAME': str(BASE_DIR / '.test-db.sqlite3')},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,6 +600,9 @@ OTP_REQUEST_PHONE_WINDOW_SECONDS = 3600
|
||||
# (که جلویِ burst رو نمیگیره، مثلاً ۵ تلاش در کمتر از یک ثانیه). این مقدار
|
||||
# باید با countdownِ فرانت یکی باشه — countdown صرفاً UX است، نه مرجعِ امنیتی.
|
||||
OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
|
||||
# هر کدِ ورود بعد از این تعداد حدسِ غلط باطل میشود (شمارش در دیتابیس).
|
||||
# با کدِ ۶رقمی یعنی شانسِ حدس زدنِ هر کد ۵ در یک میلیون.
|
||||
OTP_MAX_FAILED_ATTEMPTS = 5
|
||||
|
||||
# در محیط dev، این کد OTP همیشه معتبر است تا بدون SMS واقعی تست شود.
|
||||
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
|
||||
@@ -640,6 +659,15 @@ OTP_SMS_TEMPLATE = os.environ.get('OTP_SMS_TEMPLATE', 'کد ورود شما به
|
||||
# میشه که نباید فوراً بهعنوان «رهاکردن درس» حساب بشه.
|
||||
NODE_PROGRESS_ABANDON_DAYS = 14
|
||||
|
||||
# قلبِ صفر جلوی جواب دادن را در سرور بگیرد؟ (content.views.QuestionAnswerView)
|
||||
# خاموش تا وقتی فرانت برای ۴۰۳ِ `no_hearts` صفحهی «قلبها تمام شد» و زمانِ
|
||||
# nextRefillAt را نشان دهد؛ روشن کردنش پیش از آن، دانشآموزِ بیقلب را به
|
||||
# صفحهی خطای عمومی میبرد. تا آن موقع «قلبِ نامحدودِ» اشتراک فقط در اپ است.
|
||||
HEARTS_ENFORCED = os.environ.get('HEARTS_ENFORCED', '0') == '1'
|
||||
|
||||
# تلاشِ ورودِ ادمین در ۱۵ دقیقه برای هر IP.
|
||||
ADMIN_LOGIN_MAX_ATTEMPTS = 10
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Payment security
|
||||
# -----------------------------------------------------------------------
|
||||
@@ -651,13 +679,6 @@ INTERNAL_SERVICE_KEY = os.environ.get(
|
||||
'dev-internal-service-key-change-me' if DEBUG else '',
|
||||
)
|
||||
|
||||
# POST /api/payment/webhook باید از درگاه پرداخت بیاید، نه یک کلاینت دلخواه.
|
||||
# فعلاً یک shared-secret ساده (X-Webhook-Secret) استفاده میشود؛ در اتصال
|
||||
# واقعی باید با بررسی امضای HMAC درگاه جایگزین شود.
|
||||
PAYMENT_WEBHOOK_SECRET = os.environ.get(
|
||||
'PAYMENT_WEBHOOK_SECRET',
|
||||
'dev-webhook-secret-change-me' if DEBUG else '',
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Bazaar In-App Billing (پرداخت درونبرنامهای کافهبازار — REST API v2)
|
||||
@@ -724,7 +745,14 @@ if ZARINPAL_REDIRECT_URL == '-':
|
||||
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
|
||||
OPENAI_BASE_URL = os.environ.get('OPENAI_BASE_URL', 'https://api.gapgpt.app/v1')
|
||||
OPENAI_CHAT_MODEL = os.environ.get('OPENAI_CHAT_MODEL', 'gpt-4o-mini')
|
||||
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4o')
|
||||
# اختیاری: مدلِ جدا فقط برای پایههای دهم تا دوازدهم (حسابان/مثلثات). خالی یعنی
|
||||
# همهی پایهها OPENAI_CHAT_MODEL. انتخابش با `rag_model_benchmark` سنجیده میشود.
|
||||
OPENAI_CHAT_MODEL_UPPER = os.environ.get('OPENAI_CHAT_MODEL_UPPER', '')
|
||||
# فقط برای مدلهای reasoning (o-series/gpt-5): low | medium | high؛ خالی = پیشفرضِ مدل.
|
||||
OPENAI_CHAT_REASONING_EFFORT = os.environ.get('OPENAI_CHAT_REASONING_EFFORT', 'low')
|
||||
# gpt-4.1-mini در سنجشِ ۲۰۲۶-۰۹-۲۰ هم ارزانتر و هم دقیقتر از gpt-4o بود
|
||||
# (docs/extraction-playbook.md)؛ پیشفرض همان است تا به .env وابسته نباشد.
|
||||
OPENAI_VISION_MODEL = os.environ.get('OPENAI_VISION_MODEL', 'gpt-4.1-mini')
|
||||
# نباید تغییر کنه مگر اینکه collectionهای Qdrant هم با همون مدلِ جدید بازساخته بشن.
|
||||
OPENAI_EMBEDDING_MODEL = os.environ.get('OPENAI_EMBEDDING_MODEL', 'text-embedding-3-small')
|
||||
OPENAI_EMBEDDING_DIM = int(os.environ.get('OPENAI_EMBEDDING_DIM', '1536'))
|
||||
|
||||
Reference in New Issue
Block a user