# معماری
این سند تصمیم‌های معماری پروژه و دلیل هر کدام را توضیح می‌دهد. برای اجرا و مرور سریع به [README](../README.md) مراجعه کنید.
--- ## ۱. نمای کلی ``` ┌────────────┐ HTTPS ┌─────────────────┐ HTTP ┌──────────────────┐ │ مرورگر │───────────▶│ nginx (frontend)│───────────▶│ gunicorn + Django│ │ React SPA │◀───────────│ :8080 │◀───────────│ :8000 │ └────────────┘ └─────────────────┘ └───────┬──────────┘ سرو باندل + پراکسی /api │ │ ┌────────────────────┬──────────────────────┬─────────────┴────────┐ ▼ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ │ PostgreSQL │ │ Redis │ │ Elasticsearch │ │ Logstash │ │ منبع حقیقت │ │ cache/throttle│ │ ایندکس سرچ │ │ خط لوله لاگ │ └──────────────┘ └──────────────┘ └────────┬────────┘ └──────┬───────┘ │ │ └────────┬────────────┘ ▼ ┌───────────┐ │ Kibana │ └───────────┘ ``` --- ## ۲. مسیر یک درخواست سرچ
از لحظه‌ای که کاربر حرفی تایپ می‌کند تا لحظه‌ای که کارت نتیجه رندر می‌شود:
``` ۱. کاربر تایپ می‌کند │ useDebouncedValue(300ms) → یک درخواست به‌جای شش تا ▼ ۲. useSearchFilters وضعیت را در URL می‌نویسد (?q=python&skills=django) │ URL منبع حقیقت UI است ⇒ نتیجه قابل اشتراک و مقاوم به refresh ▼ ۳. TanStack Query با کلید ['search', filters] کش را چک می‌کند │ اگر تازه بود، اصلاً درخواستی نمی‌رود ▼ ۴. axios: هدر Bearer + رهگیر ۴۰۱ (چرخش خودکار توکن) ▼ ۵. nginx: /api/... → backend:8000 (هم‌مبدأ ⇒ بدون CORS) ▼ ۶. RequestIDMiddleware: X-Request-ID می‌سازد/می‌پذیرد ▼ ۷. SearchRequestSerializer: اعتبارسنجی و نرمال‌سازی کوئری‌استرینگ │ خروجی: یک SearchRequest تغییرناپذیر (frozen dataclass) ▼ ۸. get_search_backend() → ElasticsearchBackend یا PostgresBackend ▼ ۹. build_query(request) → بدنه کوئری → اجرا → SearchResult ▼ ۱۰. SearchResponseSerializer: قالب یکسان پاسخ ▼ ۱۱. audit.search: یک رویداد JSON → Logstash → Elasticsearch → Kibana ▼ ۱۲. AccessLogMiddleware: خط لاگ دسترسی با duration_ms و user_id ``` --- ## ۳. تصمیم‌های کلیدی ### ۳.۱ PostgreSQL منبع حقیقت، Elasticsearch یک projection
**تصمیم:** داده به‌صورت نرمال‌شده در Postgres ذخیره می‌شود و Elasticsearch فقط یک نسخه denormalize‌شده برای سرچ نگه می‌دارد. **چرا:** - ایندکس همیشه با `search_index --rebuild` از دیتابیس بازسازی می‌شود ⇒ از دست رفتن ایندکس یک حادثه عملیاتی است، نه از دست رفتن داده. - تغییر mapping (که در Elasticsearch نیازمند reindex است) بدون ترس انجام می‌شود. - محدودیت‌های یکتایی و ارجاعی (FK, unique) را می‌شود در جایی گذاشت که واقعاً تضمینشان می‌کند. **هزینه:** دو نسخه از داده. با ۲۸۴ رکورد ناچیز است؛ در مقیاس بزرگ‌تر باید ایندکس‌کردن به یک صف پس‌زمینه (Celery) منتقل شود — که به‌خاطر interface تمیز `SearchBackend` تغییر کوچکی است.
### ۳.۲ لایه سرچ به‌عنوان Strategy ```python class SearchBackend(ABC): name: str def search(self, request: SearchRequest) -> SearchResult: ... def suggest(self, prefix: str, limit: int = 8) -> list[str]: ... def ping(self) -> bool: ... ```
دو پیاده‌سازی: `ElasticsearchBackend` و `PostgresBackend`. انتخاب با یک متغیر محیطی (`SEARCH_BACKEND`) انجام می‌شود و `get_search_backend()` با `functools.cache` نتیجه را نگه می‌دارد (استخر اتصال یک بار در هر پروسه ساخته شود، نه در هر درخواست). **چه چیزی به دست می‌آید:** | مزیت | توضیح | |---|---| | تست‌پذیری | کل تست‌سوییت بدون کلاستر Elasticsearch اجرا می‌شود | | قابلیت مرور | ارزیاب می‌تواند پروژه را با یک Postgres تنها اجرا کند | | تاب‌آوری | قطعی ES ⇒ سوییچ به حالت کم‌توان، نه خواب کامل محصول | | فشار طراحی | interface مجبورمان می‌کند منطق سرچ از منطق HTTP جدا بماند | هیچ‌جای ویو، سریالایزر یا فرانت‌اند نمی‌داند کدام موتور جواب داده — فقط فیلد `backend` در پاسخ آن را گزارش می‌کند (برای شفافیت عملیاتی).
### ۳.۳ ویوهای نازک، لایه‌های مشخص
| مسئولیت | کجا | |---|---| | اعتبارسنجی و نرمال‌سازی ورودی | `apps/search/serializers.py` | | منطق رتبه‌بندی و فیلتر | `apps/search/backends/*.py` | | قالب پاسخ | `SearchResponseSerializer` | | ماسک‌کردن داده حساس | `apps/profiles/serializers.py` + `privacy.py` | | قالب خطا | `apps/core/exceptions.py` | | لاگ و همبستگی درخواست | `apps/core/middleware.py` + `logging.py` | کل `SearchView.get()` کمتر از ۲۰ خط است. این عمدی است: ویو فقط ارکستراتور است.
### ۳.۴ dataclass تغییرناپذیر به‌جای dict
`SearchRequest` یک `@dataclass(frozen=True, slots=True)` است، نه یک دیکشنری. سه سود: 1. type checker اشتباه نام فیلد را در زمان کامپایل می‌گیرد، نه در زمان اجرا. 2. تغییرناپذیری یعنی هیچ لایه‌ای نمی‌تواند کوئری را وسط راه عوض کند. 3. `as_log_context()` روی همان شیء زندگی می‌کند ⇒ لاگ و رفتار هیچ‌وقت از هم دور نمی‌افتند.
### ۳.۵ چرا nginx جلوی هر دو؟
SPA و API از یک مبدأ (`localhost:8080`) سرو می‌شوند. نتیجه: - **بدون CORS**: نه pre-flight، نه لیست مبدأ در پروداکشن، نه یک کلاس کامل از باگ‌ها. - **CSP سخت‌گیرانه**: چون همه‌چیز self است، `default-src 'self'` کافی است. - **یک نقطه برای هدرهای امنیتی** و کش‌کردن asset‌های هش‌دار. در حالت توسعه (`make dev`) به‌جای nginx، پراکسی خود Vite همین کار را می‌کند.
--- ## ۴. مدل داده ``` ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ Company │◀───────│ Experience │───────▶│ Profile │ │ (unique │ FK │ (سوابق شغلی)│ FK │ │ │ name) │ └──────────────┘ │ search_ │ └─────────────┘ │ vector │ ▲ │ (tsvector) │ │ FK (current_company) └──────┬──────┘ └──────────────────────────────────────────────┤ │ ┌─────────────┐ ┌──────────────┐ │ │ School │◀───────│ Education │───────────────┤ └─────────────┘ FK └──────────────┘ FK │ │ ┌─────────────┐ ┌──────────────┐ │ │ Skill │◀───────│ ProfileSkill │───────────────┘ │ (slug uniq) │ FK │ (+position) │ FK └─────────────┘ └──────────────┘ ```
**ایندکس‌ها و دلیلشان:** | ایندکس | برای چه کوئری‌ای | |---|---| | `GIN(search_vector)` | سرچ full-text در بک‌اند Postgres | | `GIN(seniority_levels)` | فیلتر `seniority_levels__overlap` | | `(location_country, job_title_role)` | ترکیب پرتکرار «کشور + خانواده شغلی» | | `GIN(full_name gin_trgm_ops)` | شباهت trigram برای تحمل غلط تایپی | | `GIN(name gin_trgm_ops)` روی Skill | `icontains` در type-ahead مهارت‌ها | | `profile_count` روی Skill | مرتب‌سازی پیشنهاد فیلترها | **ستون‌های denormalize‌شده:** `skills_text`، `companies_text`، `education_text`. دلیل: `SearchVector` جنگو نمی‌تواند از رابطه many-to-many عبور کند. با تخت‌کردن این متن‌ها هنگام ایمپورت، به‌روزرسانی کل tsvector یک `UPDATE` تکی می‌شود و زمان کوئری از O(join) به O(ایندکس) می‌رسد.
--- ## ۵. ساختار اپلیکیشن‌های جنگو
| اپ | مسئولیت | وابسته به | |---|---|---| | `core` | لاگ ساخت‌یافته، middleware، قالب خطا، صفحه‌بندی، throttle، health | — | | `accounts` | مدل کاربر، نقش‌ها، JWT | `core` | | `profiles` | مدل دامنه، خواننده CSV، ایمپورتر، ماسک PII، API پروفایل | `core` | | `search` | ایندکس ES، بک‌اندهای سرچ، API سرچ | `core`, `profiles` | جهت وابستگی یک‌طرفه است: `search → profiles → core`. هیچ import حلقوی‌ای وجود ندارد (تنها استثنا `core.views` است که برای health-check بک‌اند سرچ را import می‌کند — به‌صورت lazy داخل تابع).
--- ## ۶. پیکربندی
تنظیمات به چهار ماژول تقسیم شده‌اند: ``` config/settings/ ├── base.py مشترک؛ همه اسرار از متغیر محیطی ├── dev.py DEBUG، Browsable API، بدون HTTPS ├── prod.py HSTS، کوکی امن، و «fail fast» روی پیکربندی ناامن └── test.py سریع، بدون سرویس خارجی، بک‌اند Postgres ``` `prod.py` عمداً روی این دو حالت **بالا نمی‌آید**:
```python if DEBUG: raise RuntimeError("DJANGO_DEBUG must be false when using production settings.") if len(SECRET_KEY) < 50: raise RuntimeError("DJANGO_SECRET_KEY must be at least 50 characters in production.") ```
یک deploy که شکست می‌خورد بی‌نهایت بهتر از یک deploy است که با `DEBUG=True` بالا می‌آید.
--- ## ۷. قابلیت اتکا
| سناریو | رفتار سیستم | |---|---| | Redis از دسترس خارج شود | `IGNORE_EXCEPTIONS=True` ⇒ کش بی‌صدا رد می‌شود، API سالم می‌ماند (throttle موقتاً سخت‌گیر نیست) | | Logstash از دسترس خارج شود | ارسال لاگ async است ⇒ فقط یک warning، هیچ درخواستی بلاک نمی‌شود | | Elasticsearch از دسترس خارج شود | `SearchBackendError` ⇒ خطای ۵۰۳ با پیام روشن؛ `/health/ready/` هم ۵۰۳ می‌دهد تا orchestrator ترافیک نفرستد. با `SEARCH_BACKEND=postgres` سرویس ادامه می‌دهد | | یک ردیف دیتاست خراب باشد | ایمپورتر آن رکورد را لاگ و رد می‌کند، بقیه ایمپورت می‌شوند | | reindex نیمه‌کاره بماند | ایندکس فیزیکی جدید ساخته و **بعد** alias جابه‌جا می‌شود ⇒ کاربر هیچ‌وقت ایندکس نیمه‌پر نمی‌بیند |
--- ## ۸. اگر قرار بود مقیاس بگیرد
با ۲۸۴ رکورد بسیاری از این‌ها لازم نیست، ولی مسیر رشد از قبل باز گذاشته شده: | محدودیت فعلی | قدم بعدی | |---|---| | ایندکس‌گذاری همزمان با ایمپورت | انتقال به Celery + صف؛ `SearchBackend` تغییری نمی‌کند | | facetها در بک‌اند Postgres در پایتون شمرده می‌شوند | جایگزینی با `unnest` + `GROUP BY` یا فقط اتکا به ES | | صفحه‌بندی `from/size` | `search_after` برای اسکرول عمیق | | یک نود Elasticsearch | کلاستر چندنودی + replica؛ `number_of_replicas` در `INDEX_SETTINGS` | | به‌روزرسانی کامل ایندکس | ایندکس افزایشی با سیگنال‌های مدل (`index_profile` از قبل نوشته شده) | | بدون CDN | سرو asset‌های هش‌دار از CDN؛ هدرهای cache از قبل درست‌اند |