a464e550c9
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
290 lines
15 KiB
Markdown
290 lines
15 KiB
Markdown
# معماری
|
||
|
||
<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>
|