make the README an entry point and move procedures into docs/

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>
This commit is contained in:
2026-09-23 01:12:04 +03:30
parent 5aa10eb7b7
commit 5492788bf8
5 changed files with 578 additions and 302 deletions

98
docs/architecture.md Normal file
View File

@@ -0,0 +1,98 @@
# معماری
تصمیم‌هایی که از خواندنِ کد واضح نیستند و دلیلشان.
## احرازِ هویت، سیستمِ خودمان است
`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`](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`).