Files
cyberyan_linkedin_search/docs/ARCHITECTURE.md
T
mobiniqow a464e550c9 feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)
Full-stack implementation of the Cyberyan full-stack trial task.

Backend (Django 5 / DRF)
- Normalised PostgreSQL model (Profile, Company, School, Skill, Experience,
  Education) as the source of truth, with GIN + trigram indexes.
- Self-healing CSV reader: the supplied export carries an injected watermark
  column, missing columns, rows split by broken quoting and a duplicated
  header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean
  records out of 336 physical rows; only 10 rows parse correctly without it.
- Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL
  full-text search as a fallback selected by a single environment variable.
- Keyword search with per-field boosts, fuzziness and phrase boosting, six
  faceted filters (OR within a filter, AND across filters), an experience
  range, four sort orders, highlighting and facet aggregations.
- JWT auth with rotation + blacklist, three roles, PII masking, scoped rate
  limits, a single error envelope, OpenAPI schema and health probes.

Frontend (React 18 / TypeScript / Vite)
- Debounced search with type-ahead, faceted filter panel, result cards,
  profile drawer, URL-synced search state, automatic token refresh.
- Highlight snippets rendered without dangerouslySetInnerHTML.

Infrastructure
- Hardened multi-stage images (non-root, read-only rootfs, healthchecks).
- docker-compose for the full stack plus dev and production overlays.
- ELK: structured JSON logs shipped asynchronously to Logstash, routed per
  event type, with a composable index template and a Kibana setup script.

Quality
- 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc,
  GitHub Actions running lint, tests and both image builds.
- Persian documentation: README plus architecture, search, API, data,
  security and operations guides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
2026-09-03 08:13:50 +00:00

290 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# معماری
<div dir="rtl">
این سند تصمیم‌های معماری پروژه و دلیل هر کدام را توضیح می‌دهد. برای اجرا و مرور سریع به [README](../README.md) مراجعه کنید.
</div>
---
## ۱. نمای کلی
```
┌────────────┐ HTTPS ┌─────────────────┐ HTTP ┌──────────────────┐
│ مرورگر │───────────▶│ nginx (frontend)│───────────▶│ gunicorn + Django│
│ React SPA │◀───────────│ :8080 │◀───────────│ :8000 │
└────────────┘ └─────────────────┘ └───────┬──────────┘
سرو باندل + پراکسی /api │
│
┌────────────────────┬──────────────────────┬─────────────┴────────┐
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │ │ Elasticsearch │ │ Logstash │
│ منبع حقیقت │ │ cache/throttle│ │ ایندکس سرچ │ │ خط لوله لاگ │
└──────────────┘ └──────────────┘ └────────┬────────┘ └──────┬───────┘
│ │
└────────┬────────────┘
▼
┌───────────┐
│ Kibana │
└───────────┘
```
---
## ۲. مسیر یک درخواست سرچ
<div dir="rtl">
از لحظه‌ای که کاربر حرفی تایپ می‌کند تا لحظه‌ای که کارت نتیجه رندر می‌شود:
</div>
```
۱. کاربر تایپ می‌کند
│ 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
<div dir="rtl">
**تصمیم:** داده به‌صورت نرمال‌شده در Postgres ذخیره می‌شود و Elasticsearch فقط یک نسخه denormalize‌شده برای سرچ نگه می‌دارد.
**چرا:**
- ایندکس همیشه با `search_index --rebuild` از دیتابیس بازسازی می‌شود ⇒ از دست رفتن ایندکس یک حادثه عملیاتی است، نه از دست رفتن داده.
- تغییر mapping (که در Elasticsearch نیازمند reindex است) بدون ترس انجام می‌شود.
- محدودیت‌های یکتایی و ارجاعی (FK, unique) را می‌شود در جایی گذاشت که واقعاً تضمینشان می‌کند.
**هزینه:** دو نسخه از داده. با ۲۸۴ رکورد ناچیز است؛ در مقیاس بزرگ‌تر باید ایندکس‌کردن به یک صف پس‌زمینه (Celery) منتقل شود — که به‌خاطر interface تمیز `SearchBackend` تغییر کوچکی است.
</div>
### ۳.۲ لایه سرچ به‌عنوان 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: ...
```
<div dir="rtl">
دو پیاده‌سازی: `ElasticsearchBackend` و `PostgresBackend`. انتخاب با یک متغیر محیطی (`SEARCH_BACKEND`) انجام می‌شود و `get_search_backend()` با `functools.cache` نتیجه را نگه می‌دارد (استخر اتصال یک بار در هر پروسه ساخته شود، نه در هر درخواست).
**چه چیزی به دست می‌آید:**
| مزیت | توضیح |
|---|---|
| تست‌پذیری | کل تست‌سوییت بدون کلاستر Elasticsearch اجرا می‌شود |
| قابلیت مرور | ارزیاب می‌تواند پروژه را با یک Postgres تنها اجرا کند |
| تاب‌آوری | قطعی ES ⇒ سوییچ به حالت کم‌توان، نه خواب کامل محصول |
| فشار طراحی | interface مجبورمان می‌کند منطق سرچ از منطق HTTP جدا بماند |
هیچ‌جای ویو، سریالایزر یا فرانت‌اند نمی‌داند کدام موتور جواب داده — فقط فیلد `backend` در پاسخ آن را گزارش می‌کند (برای شفافیت عملیاتی).
</div>
### ۳.۳ ویوهای نازک، لایه‌های مشخص
<div dir="rtl">
| مسئولیت | کجا |
|---|---|
| اعتبارسنجی و نرمال‌سازی ورودی | `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()` کمتر از ۲۰ خط است. این عمدی است: ویو فقط ارکستراتور است.
</div>
### ۳.۴ dataclass تغییرناپذیر به‌جای dict
<div dir="rtl">
`SearchRequest` یک `@dataclass(frozen=True, slots=True)` است، نه یک دیکشنری. سه سود:
1. type checker اشتباه نام فیلد را در زمان کامپایل می‌گیرد، نه در زمان اجرا.
2. تغییرناپذیری یعنی هیچ لایه‌ای نمی‌تواند کوئری را وسط راه عوض کند.
3. `as_log_context()` روی همان شیء زندگی می‌کند ⇒ لاگ و رفتار هیچ‌وقت از هم دور نمی‌افتند.
</div>
### ۳.۵ چرا nginx جلوی هر دو؟
<div dir="rtl">
SPA و API از یک مبدأ (`localhost:8080`) سرو می‌شوند. نتیجه:
- **بدون CORS**: نه pre-flight، نه لیست مبدأ در پروداکشن، نه یک کلاس کامل از باگ‌ها.
- **CSP سخت‌گیرانه**: چون همه‌چیز self است، `default-src 'self'` کافی است.
- **یک نقطه برای هدرهای امنیتی** و کش‌کردن asset‌های هش‌دار.
در حالت توسعه (`make dev`) به‌جای nginx، پراکسی خود Vite همین کار را می‌کند.
</div>
---
## ۴. مدل داده
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Company │◀───────│ Experience │───────▶│ Profile │
│ (unique │ FK │ (سوابق شغلی)│ FK │ │
│ name) │ └──────────────┘ │ search_ │
└─────────────┘ │ vector │
▲ │ (tsvector) │
│ FK (current_company) └──────┬──────┘
└──────────────────────────────────────────────┤
│
┌─────────────┐ ┌──────────────┐ │
│ School │◀───────│ Education │───────────────┤
└─────────────┘ FK └──────────────┘ FK │
│
┌─────────────┐ ┌──────────────┐ │
│ Skill │◀───────│ ProfileSkill │───────────────┘
│ (slug uniq) │ FK │ (+position) │ FK
└─────────────┘ └──────────────┘
```
<div dir="rtl">
**ایندکس‌ها و دلیلشان:**
| ایندکس | برای چه کوئری‌ای |
|---|---|
| `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(ایندکس) می‌رسد.
</div>
---
## ۵. ساختار اپلیکیشن‌های جنگو
<div dir="rtl">
| اپ | مسئولیت | وابسته به |
|---|---|---|
| `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 داخل تابع).
</div>
---
## ۶. پیکربندی
<div dir="rtl">
تنظیمات به چهار ماژول تقسیم شده‌اند:
```
config/settings/
├── base.py مشترک؛ همه اسرار از متغیر محیطی
├── dev.py DEBUG، Browsable API، بدون HTTPS
├── prod.py HSTS، کوکی امن، و «fail fast» روی پیکربندی ناامن
└── test.py سریع، بدون سرویس خارجی، بک‌اند Postgres
```
`prod.py` عمداً روی این دو حالت **بالا نمی‌آید**:
</div>
```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.")
```
<div dir="rtl">
یک deploy که شکست می‌خورد بی‌نهایت بهتر از یک deploy است که با `DEBUG=True` بالا می‌آید.
</div>
---
## ۷. قابلیت اتکا
<div dir="rtl">
| سناریو | رفتار سیستم |
|---|---|
| Redis از دسترس خارج شود | `IGNORE_EXCEPTIONS=True` ⇒ کش بی‌صدا رد می‌شود، API سالم می‌ماند (throttle موقتاً سخت‌گیر نیست) |
| Logstash از دسترس خارج شود | ارسال لاگ async است ⇒ فقط یک warning، هیچ درخواستی بلاک نمی‌شود |
| Elasticsearch از دسترس خارج شود | `SearchBackendError` ⇒ خطای ۵۰۳ با پیام روشن؛ `/health/ready/` هم ۵۰۳ می‌دهد تا orchestrator ترافیک نفرستد. با `SEARCH_BACKEND=postgres` سرویس ادامه می‌دهد |
| یک ردیف دیتاست خراب باشد | ایمپورتر آن رکورد را لاگ و رد می‌کند، بقیه ایمپورت می‌شوند |
| reindex نیمه‌کاره بماند | ایندکس فیزیکی جدید ساخته و **بعد** alias جابه‌جا می‌شود ⇒ کاربر هیچ‌وقت ایندکس نیمه‌پر نمی‌بیند |
</div>
---
## ۸. اگر قرار بود مقیاس بگیرد
<div dir="rtl">
با ۲۸۴ رکورد بسیاری از این‌ها لازم نیست، ولی مسیر رشد از قبل باز گذاشته شده:
| محدودیت فعلی | قدم بعدی |
|---|---|
| ایندکس‌گذاری همزمان با ایمپورت | انتقال به 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 از قبل درست‌اند |
</div>