- technical-handbook: question packs instead of subscriptions, real hearts with the new wait screen (screenshot), math-only tutor and what is charged (new 9-7), admin cancel/refund, Bazaar planId, account deletion, 59 endpoints, 779 tests, 14 apps, status and open decisions. - Diagrams redrawn: use-case (buy a pack, refunds), data model (QuestionCredit, HeartsPass, Profile.birth_date), pack payment sequence. - The PDF is now built from the Markdown by docs/build_handbook.py (pandoc + Chrome, font in docs/assets); the hand-made HTML it used to come from was not in the repo and had been lost. - architecture, operations, security-before-production (item 13 closed), ai-cost-and-guardrails and README follow the same changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
30 KiB
معماری
تصمیمهایی که از خواندنِ کد واضح نیستند و دلیلشان.
احرازِ هویت، سیستمِ خودمان است
accounts.User عمداً AUTH_USER_MODEL نیست — یک مدلِ بیزنسیِ مستقل است.
ورود با توکنِ bearerِ امضاشده و stateless انجام میشود (django.core.signing،
بدونِ کتابخانهی JWT)، که در accounts/authentication.py صادر و تأیید میشود و
پشتوانهاش مدلِ accounts.Session است.
هدرِ نبود یا نامعتبر یعنی درخواستِ ناشناس، نه خطا. هر ویو با
permission_classes خودش تصمیم میگیرد که آیا ورود لازم است — یعنی یک
اندپوینتِ عمومی و یک اندپوینتِ خصوصی از یک مسیرِ احراز هویت رد میشوند و
اختلافشان فقط در یک جاست.
DJANGO_SECRET_KEY همین توکنها را امضا میکند. عوض شدنش یعنی باطل شدنِ
همهی نشستهای فعال.
توکنِ access فقط user_idِ امضاشده است و به Session وصل نیست. خروج
(POST /api/auth/logout) refreshِ همان دستگاه را باطل میکند و
User.tokens_valid_after را میگذارد؛ هر توکنِ access که پیش از آن لحظه صادر شده
رد میشود (دقتِ ثانیهای). دستگاههای دیگر با refreshِ خودشان بیصدا توکنِ تازه
میگیرند. پیش از ۲۰۲۶-۰۹-۲۵ خروج وجود نداشت.
کدِ ورود ۶ رقمی است و هر کد بعد از ۵ حدسِ غلط باطل میشود
(OTPCode.failed_attempts، شمارش در دیتابیس، نه در کشِ per-worker).
مهمانی که با شماره وارد میشود همان کاربر میماند. اگر درخواستِ ورود توکنِ
مهمان را همراه دارد و آن شماره هنوز حسابی ندارد، به همان ردیفِ مهمان شماره داده
میشود؛ امتیاز، استریک، جزیرهها و گفتگوها سرِ جایشان میمانند. شمارهای که از قبل
حساب دارد ادغام نمیشود (حسابِ موجود باز میشود و مهمان دستنخورده میماند)، و
TEST_LOGIN_PHONES هرگز مهمانی را جذب نمیکنند تا حسابهای دمو عوض نشوند.
نامِ نمایشی در جدولِ رتبهبندی به بچههای دیگر نشان داده میشود؛ شماره، لینک
و آیدی هنگامِ ذخیره از آن حذف میشود (accounts.serializers.clean_display_name).
پنلِ /admin/ برای هر IP در ۱۵ دقیقه حداکثر ADMIN_LOGIN_MAX_ATTEMPTS (۱۰) تلاشِ
ورود میپذیرد (config.middleware.AdminLoginThrottleMiddleware).
اپها با قرارداد جدا شدهاند، نه با ForeignKey
هر اپ یک مسئولیت دارد و مدلهای خودش را نگه میدارد. ارجاعِ بینِ اپها با
user_id یا object_id + label انجام میشود، نه با FK واقعی.
دلیلش این است که هر اپ بعداً قابلِ جدا شدن بماند. FK بینِ اپها اضافه نکنید — الگوی ارجاعِ شل را دنبال کنید.
محدودیتِ نرخ در یک جا تعریف میشود
هر اندپوینتِ حساس یک throttle_scope دارد و نرخش در
REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] داخلِ config/settings.py است.
اندپوینتِ جدیدِ حساس یعنی هر دو: یک throttle_scope روی ویو و یک ردیفِ
متناظر در تنظیمات.
کاربرانِ مهمان با IP محدود میشوند. NUM_PROXIES تعیین میکند چند hop از
X-Forwarded-For قابلِ اعتماد است و باید با تعدادِ واقعیِ پروکسیها یکی
باشد، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن میشود.
روی پروداکشن این عدد ۲ است (پیشفرضِ کد از ۲۰۲۶-۰۹-۲۴): ArvanCloud جلوی
سرور است و بعد nginxِ خودِ ماشین. تا آن روز ۱ بود و جنگو IPِ آروان را IPِ کاربر
میدید؛ یعنی همهی مهمانها یک سهمِ مشترک داشتند. clientIp در /api/health
نشان میدهد سرور صدازننده را با چه IPای میشناسد — باید IPِ عمومیِ خودتان باشد.
⚠️ سرور روی پورتِ ۸۰ مستقیم هم (بدونِ آروان) جواب میدهد. کسی که IPِ سرور را
بداند میتواند از آن راه XFF جعل کند و محدودیتِ IP را دور بزند. ورودِ OTP جدا
به ازای هر شماره هم محدود است، پس این راه به حدسِ کد نمیرسد. بستنِ کاملش (پورتِ
۸۰ فقط برای رنجهای آروان، یا set_real_ip_from در nginx) کارِ ادمینِ سرور است.
پرداخت: هرگز مستقیم به درگاه
create_order هیچوقت زرینپال را صدا نمیزند. paymentUrl به اندپوینتِ خودمان
اشاره میکند (GET /api/payment/orders/{id}/pay)، که در اولین بازدید
PaymentRequest را میزند، Order.gateway_authority را ذخیره میکند و به StartPay
میفرستد. این کار ساختِ سفارش را به در دسترس بودنِ زرینپال وابسته نمیکند.
بازگشتِ کاربر به GET /api/payment/zarinpal/callback میرسد، که همیشه
سرور-به-سرور با payment/zarinpal.py::verify_payment دوباره تأیید میکند. یک
Status=OK جعلی در query string بهتنهایی هیچ کاری نمیکند.
هم payment/zarinpal.py و هم payment/bazaar.py وقتی اعتبارنامه ندارند
fail-closed میشوند (503 یا نامعتبر)، نه موفقیتِ ساختگی. همین قاعده برای
پیامک هم برقرار است.
قاعدههایی که بازبینیِ ۲۰۲۶-۰۹-۲۵ اضافه کرد (هرکدام با تست در
payment/tests_audit_fixes.py):
| قاعده | چون پیشتر… |
|---|---|
پلن روی سفارش است (Order.plan_id) و فعالسازی از همان خوانده میشود |
پلن روی Subscription بود؛ شروعِ خریدِ سالانه و پرداختِ سفارشِ ماهانه، اشتراکِ سالانه میداد |
| باز کردنِ صفحهی خرید اشتراکِ فعال را دست نمیزند | وضعیت همان لحظه pending میشد و کاربرِ پولی مزیتش را از دست میداد |
| تمدید از تاریخِ انقضای فعلی حساب میشود | تمدیدِ زودهنگام روزهای باقیمانده را پاک میکرد |
subscription/status اشتراکِ منقضی را active: false میدهد |
فقط ستونِ status دیده میشد |
| یک کدِ تخفیف برای هر سفارش، و فقط پیش از رفتن به درگاه | کدها جمع میشدند؛ و تخفیف بعد از گرفتنِ authority یعنی پرداختِ مبلغِ قبلی و ردِ verify با مبلغِ جدید |
کدِ ۱۰۰٪ خرید را همانجا تمام میکند (gateway='discount') |
سفارشِ صفرتومانی را هیچ درگاهی نمیپذیرد |
پرداختِ تأییدنشده دوباره سنجیده میشود: هر بار که اپ subscription/status یا جزئیاتِ سفارش را میخواند (حداکثر دقیقهای یک بار برای هر سفارش) و با manage.py payment_reconcile |
اگر زرینپال موقعِ کالبک جواب نمیداد، پول کم شده بود و سفارش برای همیشه pending میماند. فقط به سمتِ paid، هرگز failed |
/api/payment/webhook حذف شد |
یک «این سفارش پرداخت شد»ِ عمومی بود پشتِ یک رمزِ مشترک، که زرینپال اصلاً صدایش نمیزد |
برنامهی معرفی (affiliate)
کاربرِ واردشده با شماره کدِ معرف میگیرد (GET /api/affiliate/me) و کاربرِ
تازه کدِ دیگری را ثبت میکند (POST /api/affiliate/track). پورسانت فقط روی
اولین پرداختِ واقعیِ کاربرِ معرفیشده پس از ثبتِ کد حساب میشود. تازه بودنِ
کاربر سنجیده نمیشود: مشتریِ قدیمی هم میتواند بعداً کد ثبت کند و تمدیدش پورسانت
بسازد (تصمیمِ محصولیِ باز، بازبینیِ ۲۰۲۶-۰۹-۲۵)
(affiliate.services.record_paid_order، از داخلِ تأییدِ پرداخت):
commission_amount = مبلغِ پرداختی × commission_rate و Conversion paid میشود.
مهمان نه کد میگیرد نه معرفیشده حساب میشود (۴۰۳). تسویهی پورسانت با معرف هنوز
دستی و از پنلِ ادمین است.
دستیارِ هوشِ مصنوعی (RAG)
POST /api/ai/ask پاسخ را از محتوای واقعیِ کتاب میسازد: embedding سوال →
جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تکههای استفادهشده در
usedChunks برمیگردند، پس پاسخ قابلِ ردیابی به منبع است.
- نیازمندِ
OPENAI_API_KEYوQDRANT_API_KEY. بدونِ کلید، همان پاسخِ stubِ قبلی برمیگردد (سازگاریِ عقبرو). "stream": trueپاسخ را بهصورتِ SSE توکنبهتوکن میفرستد؛ قراردادِ JSON بدونِ این فلگ دستنخورده است.python manage.py rag_catalogفهرستِ زندهی collectionهای Qdrant را نشان میدهد (فقطخواندنی).- افزودنِ محتوای خودمان:
POST /api/rag/ingest/textو/api/rag/ingest/pdf(داخلی، با هدرِX-Internal-Key).
کیفیتِ محتوایی که این لایه میخواند در
content-authoring.md توضیح داده شده.
شکلِ پاسخ (پرامپتِ tutor-v3-stepwise-plain، ۲۰۲۶-۰۹-۲۵)
| چه | چرا |
|---|---|
متنِ ساده، بدونِ LaTeX/markdown؛ rag/services/mathtext.py هرچه از دستِ مدل در برود را به یونیکد برمیگرداند (x²، √3/2، ≤) |
اپ پاسخ را خام نشان میدهد. همهی جوابهای ذخیرهشدهی پایههای ۸ تا ۱۲ پر از \frac و ### بودند |
| حلِ کامل، مرحلهبهمرحله، هر مرحله یک خط، وارسی پیش از «✅ جواب»، و یک تمرینِ مشابه | تصمیمِ صاحبِ پروژه. وارسی به این خاطر است که جوابی مشتقِ x³−6x²+9x را 3x²−12x نوشته بود |
| لحنِ سهسطحی (دبستان / متوسطهی اول / دبیرستان) | «پایهی سوم» در پرامپت بهتنهایی لحن را عوض نمیکرد |
| نمادِ اعداد تابعِ پایه: دبستان رقمِ فارسی و «/» مثلِ کتاب برای ممیز، کسر با کلمه («۳ بر ۴»)؛ از هفتم به بعد ممیز با نقطه و «/» فقط برای کسر | در کتابهای ایران «۲/۵» یعنی دو و نیم، نه کسر |
| حافظهی گفتگو: ۶ پیامِ آخر به مدل میرود و سوالِ قبلیِ کاربر به جستجو اضافه میشود | «نفهمیدم» برای مدل هیچ مرجعی نداشت |
| کش فقط برای پیامِ اولِ گفتگو، و کلیدش نسخهی پرامپت را دارد | «نفهمیدم»ِ یک نفر جوابِ کششدهی گفتگوی دیگری را میگرفت |
راهحلی که rag_verify_solutions ردش کرده (disagrees) به مدل نمیرسد؛ فقط روش و صورتِ مسئله |
حدودِ ۱۳٪ تکههای کتابهای دهم تا دوازدهم اینطورند و دستیار غلطِ کتاب را تکرار میکرد |
انتخابِ مدل: OPENAI_CHAT_MODEL برای همه، و OPENAI_CHAT_MODEL_UPPER
(اختیاری) فقط برای دهم تا دوازدهم. مدلهای reasoning (o-series، gpt-5) بدونِ
temperature و با OPENAI_CHAT_REASONING_EFFORT صدا زده میشوند. انتخاب با
python manage.py rag_model_benchmark سنجیده میشود: مسئلههای تأییدشدهی کتاب،
از مسیرِ واقعیِ retrieval + پرامپت، با داورِ ثابت و هزینهی واقعی از
/dashboard/billing/usage. LessonChunk فقط در دیتابیسِ محلی است و GapGPT فقط
از ایران جواب میدهد، پس نمونه با --export-sample محلی ساخته و روی سرور با
--sample-file اجرا میشود. --resume مدلهای تمامشده را دوباره اجرا نمیکند.
هزینهی هر پاسخ از usage.costِ خودِ GapGPT خوانده میشود، و --context-cache بخشهای
بازیابیشده را ذخیره میکند تا چند اجرا (چند مدل یا چند نسخهی پرامپت) دقیقاً یک context ببینند.
انتخابِ فعلی: gpt-4.1-mini (۲۰۲۶-۰۹-۲۶). دورِ اول ۱۴ مدل روی ۳۰ مسئله: پنج مدل
بیخطا بودند و 4.1-mini ارزانترینشان بود. دورِ دوم 4o-mini در برابرِ 4.1-mini روی ۱۰۲ مسئله
با سه نسخهی پرامپت: ۹۵–۹۶ در برابرِ ۸۵–۹۰. نوسانِ 4o-mini با پرامپتِ یکسان تا ۵ مسئله بود،
پس این فاصله واقعی است. خطاهای 4o-mini بیشتر حسابِ ساده بود (۸۴۵۱ − ۷۲۳۴ = ۱۳۱۷). هر پاسخ
۰٫۱۲ سنت است، ۲٫۹ برابرِ 4o-mini. داورِ خودکار (gpt-4.1) حدودِ ۷٪ اشتباه داوری کرد (مثلاً
3¹⁴ و (−3)¹⁴ را متفاوت دانست)، پس نتیجهها دستی تصحیح شد.
«/» در دبستان (پرامپتِ tutor-v5-slash-in-question). در کتابهای دبستان «/» هم ممیز
است هم کسر، چون کسرِ دوطبقه در استخراج صاف میشود. جملهی «در کتاب / ممیز است، نه کسر» که
برای دبیرستان نوشته شده بود به همه میرفت، و 4.1-mini با آن ۱/۷ + ۱/۸ را ۳/۵ حساب میکرد.
حالا برای دبستان آن جمله عوض شده و قاعدهی تشخیص (صفر پیش از «/» یا واحد = ممیز؛ کلمهی
«کسر» یا بینشانه = کسر) کنارِ خودِ سوال میآید. همین قاعده در انتهای پرامپتِ سیستم اثری
نداشت. نتیجه: مسئلههای «/»ِ 4.1-mini از ۱۱ به ۱۴ از ۱۷. سهتا هنوز غلط است. در سه اجرای بعدیِ v5
عدد بینِ ۱۱ و ۱۳ نوسان کرد، پس اثرِ واقعی کمتر از یک اجراست.
کسر در جوابِ دبستان هنوز گاهی «۵/۶» نوشته میشود، نه «۵ بر ۶» (در ۳ تا ۵ از ۷ جوابِ کسری «بر» آمد). کد نمیتواند a/b را تبدیل کند، چون ممکن است ممیز باشد. آزمایشِ v6 (۲۰۲۶-۰۹-۲۶) یک خطِ «کسر را در پاسخ با بر بنویس» را کنارِ قاعدهی خواندن گذاشت: نمادگذاری کمی بهتر شد (۴–۵ از ۷) ولی درستی کمی بدتر (مسئلههای «/» ۱۰–۱۱ در برابرِ ۱۱–۱۳؛ ۱/۷ + ۱/۸ در هر سه اجرا ۳/۵ شد، با v5 در هر سه درست). جوابِ غلط از نمادِ نادرست بدتر است، پس v6 منتشر نشد. راهِ بعدی احتمالاً در فرانت است (دکمهی کسر در ورودی، یا نمایشِ کسرِ دوطبقه)، نه در پرامپت.
تمام شدنِ اعتبار دیده میشود. هر گذارِ provider بینِ ok و
quota_exhausted یک ردیف در statuslog است (ai_tutor/provider_status.py) و
/api/health آن را در integrations.aiProvider و warnings نشان میدهد.
پیشتر openai: true فقط یعنی «کلید تنظیم شده».
شکل در جواب (پرامپتِ tutor-v10-shape-anchor، ۲۰۲۶-۰۹-۲۹)
مدل HTML یا SVG نمینویسد. در پایانِ جوابِ شکلدار یک بلوکِ کوتاهِ <diagram>{JSON}</diagram> میگذارد
که فقط توصیف است («مثلثی با AB = 6، AC = 8 و زاویهی قائمه در A»). فرانت (src/diagram/) از روی
آن SVG میسازد و هر عددی را که قابلِ حساب است خودش حساب میکند: رأسهای مثلث با قانونِ سینوس و
کسینوس، ریشه و رأسِ نمودار با نصفکردنِ بازه، مماس با مشتقِ عددی، رأسهای چندضلعیِ منتظم.
| لایه | کجا | کار |
|---|---|---|
| تشخیص | diagram.hint_for / follow_up_hint_for / asked_hint_for |
کنارِ سوال فقط بخشِ مربوطِ کاتالوگ میرود: کلمهی موضوعی (یا نامِ درس)، سوالِ شکلدارِ قبلی، یا درخواستِ صریحِ «شکلش رو بکش» (کلِ کاتالوگ). سوالِ حسابی هیچ |
| جدا کردن | diagram.StreamSplitter / extract |
بلوک پیش از mathtext از متن جدا میشود؛ JSON هیچوقت به حباب نمیرسد |
| تعمیر | diagram._repair / _loads / _normalize |
لغزشهای واقعیِ مدل: رقمِ فارسی، 2*pi، -2 * 3.14، گیومهی جاافتاده، کلیدِ تکراری، "kind":"trapezoid" |
| وارسی | diagram.validate |
فهرستِ سفیدِ نوع و کلید و رنگ، عددِ محدود، برچسبِ کوتاه؛ تکهی خراب حذف، شکلِ بیمحتوا None |
| نگهبانِ معنا | diagram.fits |
ساعتِ دیواری فقط برای سوالی که واقعاً دربارهی خواندنِ ساعت است |
| ذخیره | Message.diagram |
در doneِ SSE و تاریخچه میآید |
سنجش (۶۴ مسئلهی تصادفیِ کتاب، ۸ از هر پایه، وارسیِ چشمیِ تکتک): ۴۰ درست، ۱۲ قابلِ قبول، ۷ غلط،
۵ بیشکل؛ برآوردِ ۹۵٪ برای شکلِ درست ۵۰–۷۳٪ و برای شکلِ غلط ۵–۲۱٪. هزینهی اضافهی شکل با نرخِ
رسمی ۰٫۰۰۰۳ تا ۰٫۰۰۰۸ دلار در هر جواب؛ کارِ سرور حدودِ ۰٫۴ میلیثانیه. آزموده نشده: مدلهای دیگر.
برای تستِ مسیر بیخرج، tools/fake_openai.py جای GapGPT مینشیند.
ساختِ آزمون
اپ در سطحِ نود بازی میشود (جزیره روی نقشه)، ولی سوالها به درس وصلند — و درس اینجا ریزترین دانه است: یک نوعِ مسئله از کتاب، نه یک جلسهی کلاسی.
GET /api/nodes/{id}/quiz آزمون را از درسهای یک نود میسازد، حداکثر یک سوال
از هر درس. ترتیب با nodeId:userId seed میشود، پس رفرش همان آزمون را
برمیگرداند. questionCount جدا از requested گزارش میشود تا یک نودِ کمسوال
شبیهِ خطا به نظر نرسد.
پایانِ آزمون، پایانِ جزیره است. POST /api/nodes/{id}/quiz/complete با
{correctCount, totalQuestions} جزیره را تمام میکند و جزیرهی بعدی روی نقشه باز
میشود — با هر نمرهای؛ نمره فقط ستاره و امتیاز را تعیین میکند (تصمیمِ صاحبِ
پروژه، ۲۰۲۶-۰۹-۲۴: ساده نگهش داریم). پاسخ nextNodeId را هم میدهد. امتیاز و
سکه فقط بارِ اول. ردیفها در UserNodeCompletionاند؛ _node_status نودی را که
آزمونش تمام شده completed میبیند، و مسیرِ قدیمیِ «همهی درسها تکتک» هم هنوز
کار میکند.
جزیره با درسها هم تمام میشود، ولی فقط درسهای سوالدار شمرده میشوند
(۲۰۲۶-۰۹-۲۵). ۵۸۵ درس هنوز سوال ندارند و اپ برایشان دکمهی شروع ندارد؛ با شرطِ
«همهی درسها» ۱۸۱ جزیره از ۲۶۹ هرگز تمام نمیشد. هر نود در /api/home
nextLessonId دارد (اولین درسِ سوالدارِ تمامنشده) و عضوِ اولِ lessonIds
همیشه همان است — فرانت با کلیک روی جزیره lessonIds[0] را باز میکند و پیشتر
هر بار درسِ اول را میگرفت. جزیرهای که یک بار تمام شد (NodeProgress یا
آزمون) تمام میماند، حتی اگر بعداً درسی سوالِ تازه بگیرد؛ سوالِ تازه کشِ نقشه را
باطل میکند.
progressPercent در /api/home برای همان پایهای است که نقشهاش نشان داده
میشود: درسهای سوالدارِ تمامشده بهاضافهی درسهای جزیرههای تمامشده، تقسیم
بر درسهای سوالدارِ آن پایه. (قبلاً مخرج همهی ۲٬۶۱۷ درسِ همهی پایهها بود.)
دستیار و سوالِ تمرینی
اگر اپ در POST /api/ai/ask فیلدِ questionId را بفرستد، دستیار صورتِ همان
سوال، گزینهها، جوابِ درست و آخرین جوابِ خودِ دانشآموز را میبیند
(rag.services.generation.question_focus) و اگر غلط بوده، اول میگوید اشتباه در
کدام مرحله بوده. این همان «کمک در جایی که بچهها گیر میکنند» است. این
جوابها کش نمیشوند. userQuestion حداکثر ۲۰۰۰ نویسه است.
دستیار بدونِ درس
صفحهی «از پی بپرس» از نوارِ پایین باز میشود و lessonId ندارد. در این حالت
/api/ai/ask کتاب را از پایهی ذخیرهشدهی خودِ کاربر درمیآورد
(rag.services.lesson_context.resolve_from_grade) و در کلِ همان کتاب جستجو
میکند؛ قبلاً retrieval رد میشد و جواب عمومی بود. lessonId اگر بیاید همچنان
دقیقتر است، چون فصل را هم معلوم میکند.
استریک و جدولِ رتبهبندی، زنده از فعالیت
تا ۲۰۲۶-۰۹-۲۴ هیچجای اپ StreakRecord نمینوشت و GET /api/leaderboard فقط
جدولی را میخواند که با اجرای دستیِ rebuild_leaderboard پر میشد؛ یعنی برای
کاربرِ واقعی «روز پیاپی» همیشه صفر بود و XPاش هیچوقت در جدول دیده نمیشد.
- استریک (
progress/services.py): پاسخ به سوال، پایانِ درس و پایانِ جزیره امروز را (به وقتِ تهران) روزِ فعال ثبت میکنند. نمایش باeffective_current_streakاست: اگر آخرین روزِ فعال نه امروز است نه دیروز، صفر نشان داده میشود. خطای استریک هرگز پاسخ یا تکمیل را خراب نمیکند. - رتبهبندی (
progress/leaderboard.py): زنده حساب و ۶۰ ثانیه کش میشود.all_timeازUserStats.xp،weeklyاز XPی درسها و جزیرههای ۷ روزِ اخیر.rebuild_leaderboardهم از همین تابع میخواند و فقط جدولِ ادمین را پر میکند.
قلبها، مثلِ دولینگو
هر پاسخِ غلط یک قلب کم میکند (اشتراکِ فعال یا روزهای قلبِ بستهی سوال = قلبِ نامحدود). هر ۵ ساعت یک
قلب برمیگردد (HEART_REFILL_INTERVAL) و ۲۴ ساعت بعد از اولین قلبی که از
حالتِ پر کم شد، همه یکجا پر میشوند (HEART_FULL_REFILL_AFTER، فیلدِ
full_refill_at). GET /api/user/hearts هر دو زمان را میدهد: nextRefillAt
و fullRefillAt. تا ۲۰۲۶-۰۹-۲۴ هر قلب ۲۴ ساعت بود؛ تایمرِ قدیمیِ بلندتر از ۵
ساعت در اولین خواندن کوتاه میشود.
با صفر قلب، بکاند جواب را رد میکند (۴۰۳ با code: "no_hearts"، nextRefillAt و
fullRefillAt). HEARTS_ENFORCED از ۲۰۲۶-۱۰-۰۲ پیشفرض روشن است و فرانت برای این پاسخ صفحهی
«قلبهات تموم شد» را با شمارشِ معکوس دارد (src/components/NoHeartsScreen.tsx). از همان روز:
- چکِ قلب داخلِ همان تراکنشی است که قلب را کم میکند. پیشتر بیرونش بود و دو جوابِ غلطِ همزمان با یک قلبِ باقیمانده هر دو پذیرفته میشدند (با دو thread بازتولید شد).
- پایانِ درس یا جزیره بیقلب و بیجواب دادنِ سوالهایش ۴۰۳ میگیرد؛ پیشتر
quiz/completeاجازه میداد با قلبِ صفر جزیرهبهجزیره جلو رفت. کسی که آخرین قلبش را روی آخرین سوال از دست داده، همه را جواب داده و جزیره را تمام میکند. - قلبی که در انتظار برگشته پیش از جواب ذخیره میشود؛ پیشتر پاسخِ جوابِ درست «۰ قلب» میگفت و
اپ دانشآموز را دوباره به صفحهی انتظار میبرد.
تستها:
content/tests_hearts_gate.py.
امتیاز فقط برای جوابِ ثبتشده است. lessons/{id}/complete و
nodes/{id}/quiz/complete عددِ correctCountِ کلاینت را به تعدادِ سوالهای
متمایزی که کاربر واقعاً درست جواب داده و در UserAnswer ثبت شده محدود میکنند؛
پیشتر صدا زدنِ مستقیمِ این دو endpoint، بیجواب دادن، امتیاز و جدولِ رتبهبندی را
پر میکرد. اپِ فعلی هر سوال را پیش از پایان ثبت میکند، پس برایش فرقی نکرد.
دیتابیس
هنوز SQLite است (OPTIONS.timeout=20 تا قفلهای همزمانیِ نوشتن را تحمل کند).
مهاجرت به Postgres انجام نشده.
transaction_mode: IMMEDIATE (۲۰۲۶-۰۹-۲۵): هر atomic() قفلِ نوشتن را از
اول میگیرد. SQLite از SELECT … FOR UPDATE پشتیبانی نمیکند و جنگو
select_for_update را رویش بیصدا نادیده میگیرد؛ یعنی تا آن روز همهی
«قفلِ ردیف»ها در پرداخت، سقفِ کدِ تخفیف، قلب و سهمیهی دستیار عملاً قفل نبودند.
حالا کلِ تراکنشها سریالیاند (درشت ولی درست). فراخوانیهای شبکهای (AI،
زرینپال) بیرون از atomic() هستند و باید بمانند. بعد از Postgres این گزینه
برداشته میشود.
تستها روی فایلِ .test-db.sqlite3 اجرا میشوند، نه دیتابیسِ حافظهای: آن یکی
shared-cache است و قفلش جدولبهجدول و بیانتظار است، رفتاری که روی فایلِ واقعی
پیش نمیآید.
idِ هر پایه همان شمارهی پایه است (سوم=۳، هشتم=۸، دوازدهم=۱۲) و در فیلدِ
Grade.number هم هست. پایهی تازه id را از شمارهاش میگیرد، نه از شمارندهی
دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده میشود. پایهی
بیشماره ([DEMO]/[SEED]) idِ بالای ۱۰۰ میگیرد. همهجا — API، ادمین، فیلترِ
سوالها — پایهها بهترتیبِ شمارهاند. تا migrationِ content.0008 id ترتیبِ
واردشدنِ کتابها بود (نهم=۱، هشتم=۵).
⚠️ .iterator() را دورِ حلقهای که مینویسد یا I/O بیرونی میزند نگذارید.
کرسرِ خواندن در تمامِ طولِ حلقه باز میماند و روی SQLite جدول را برای هر
نویسندهی همزمان قفل میکند — قفلی که تا پایانِ حلقه رها نمیشود و هیچ
retryای نجاتش نمیدهد. این یک بارْ ماهها بهشکلِ یک تستِ «flaky» ظاهر شد؛
باگِ واقعی بود، نه تصادفِ زمانبندی. بهجایش نتیجه را با list(...) مادی کنید.
لاگِ ساختاریافته
config.middleware.APIRequestLoggingMiddleware (آخرین middleware، نزدیکترین
به ویو) هر درخواستِ /api/ را بهصورتِ یک شیءِ JSON در هر خط لاگ میکند:
متد، مسیر، وضعیت، مدت، کاربر — بدونِ بدنه، هدر یا سکرت
(config/log_formatters.py).