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
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
# معماری
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user