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
15 KiB
معماری
این سند تصمیمهای معماری پروژه و دلیل هر کدام را توضیح میدهد. برای اجرا و مرور سریع به README مراجعه کنید.
۱. نمای کلی
┌────────────┐ 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
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) است، نه یک دیکشنری. سه سود:
- type checker اشتباه نام فیلد را در زمان کامپایل میگیرد، نه در زمان اجرا.
- تغییرناپذیری یعنی هیچ لایهای نمیتواند کوئری را وسط راه عوض کند.
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 عمداً روی این دو حالت بالا نمیآید:
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 از قبل درستاند |