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

15 KiB
Raw Blame History

معماری

این سند تصمیم‌های معماری پروژه و دلیل هر کدام را توضیح می‌دهد. برای اجرا و مرور سریع به 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) است، نه یک دیکشنری. سه سود:

  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 عمداً روی این دو حالت بالا نمی‌آید:

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 از قبل درست‌اند