The README had grown into a 295-line mixture of onboarding, an ops runbook, a content-authoring workflow, bulk question generation and a Docker log stack. A reader arriving at the repo could not tell in ten seconds what this service is, whether it is live, or where to start, and five of its claims no longer matched the code: - it said to export .env values by hand; settings.py has loaded .env through python-dotenv for a while now - it listed six variables as "everything the app actually reads"; .env.example has carried many more than that for some time - it called DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS and CACHE_URL required in production; all three now have working defaults, and the app boots without them on purpose so a partial .env cannot take the service down - it claimed there is no insecure SECRET_KEY fallback; there is one, under DEBUG - it required Python 3.12 while the project runs on 3.11 (Docker uses 3.12) The README now states what the service is, where it runs, how healthy it is and how to get it running in six commands, then links onward. Procedures moved to docs/operations.md, docs/content-authoring.md, docs/architecture.md and docs/docker.md, which is where someone follows a procedure rather than skims it. Nothing was dropped; the deploy one-liner now includes the separate sudo restart that deploy.sh cannot perform itself, and backup/restore is written down for the first time. Every number in the README was measured rather than quoted: content counts from the live /api/health and content/seed/curriculum.json, 55 endpoints from the generated OpenAPI schema, 481 tests from a full run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.4 KiB
معماری
تصمیمهایی که از خواندنِ کد واضح نیستند و دلیلشان.
احرازِ هویت، سیستمِ خودمان است
accounts.User عمداً AUTH_USER_MODEL نیست — یک مدلِ بیزنسیِ مستقل است.
ورود با توکنِ bearerِ امضاشده و stateless انجام میشود (django.core.signing،
بدونِ کتابخانهی JWT)، که در accounts/authentication.py صادر و تأیید میشود و
پشتوانهاش مدلِ accounts.Session است.
هدرِ نبود یا نامعتبر یعنی درخواستِ ناشناس، نه خطا. هر ویو با
permission_classes خودش تصمیم میگیرد که آیا ورود لازم است — یعنی یک
اندپوینتِ عمومی و یک اندپوینتِ خصوصی از یک مسیرِ احراز هویت رد میشوند و
اختلافشان فقط در یک جاست.
DJANGO_SECRET_KEY همین توکنها را امضا میکند. عوض شدنش یعنی باطل شدنِ
همهی نشستهای فعال.
اپها با قرارداد جدا شدهاند، نه با 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 و دور زدنِ محدودیت ممکن میشود.
پرداخت: هرگز مستقیم به درگاه
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 یا نامعتبر)، نه موفقیتِ ساختگی. همین قاعده برای
پیامک هم برقرار است.
دستیارِ هوشِ مصنوعی (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 توضیح داده شده.
ساختِ آزمون
اپ در سطحِ نود بازی میشود (جزیره روی نقشه)، ولی سوالها به درس وصلند — و درس اینجا ریزترین دانه است: یک نوعِ مسئله از کتاب، نه یک جلسهی کلاسی.
GET /api/nodes/{id}/quiz آزمون را از درسهای یک نود میسازد، حداکثر یک سوال
از هر درس. ترتیب با nodeId:userId seed میشود، پس رفرش همان آزمون را
برمیگرداند. questionCount جدا از requested گزارش میشود تا یک نودِ کمسوال
شبیهِ خطا به نظر نرسد.
دیتابیس
هنوز SQLite است (OPTIONS.timeout=20 تا قفلهای همزمانیِ نوشتن را تحمل کند).
مهاجرت به Postgres انجام نشده.
⚠️ .iterator() را دورِ حلقهای که مینویسد یا I/O بیرونی میزند نگذارید.
کرسرِ خواندن در تمامِ طولِ حلقه باز میماند و روی SQLite جدول را برای هر
نویسندهی همزمان قفل میکند — قفلی که تا پایانِ حلقه رها نمیشود و هیچ
retryای نجاتش نمیدهد. این یک بارْ ماهها بهشکلِ یک تستِ «flaky» ظاهر شد؛
باگِ واقعی بود، نه تصادفِ زمانبندی. بهجایش نتیجه را با list(...) مادی کنید.
لاگِ ساختاریافته
config.middleware.APIRequestLoggingMiddleware (آخرین middleware، نزدیکترین
به ویو) هر درخواستِ /api/ را بهصورتِ یک شیءِ JSON در هر خط لاگ میکند:
متد، مسیر، وضعیت، مدت، کاربر — بدونِ بدنه، هدر یا سکرت
(config/log_formatters.py).