Files
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

574 lines
30 KiB
Markdown
Raw Permalink 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">
یک وب‌اپلیکیشن کامل برای **جستجو و فیلتر روی دیتاست پروفایل‌های لینکدین**؛ بک‌اند Django/DRF، فرانت‌اند React/TypeScript، موتور جستجو Elasticsearch (با fallback روی PostgreSQL Full-Text Search) و مانیتورینگ با استک ELK — همه به‌صورت داکرایز‌شده و قابل اجرا با یک دستور.
> این پروژه پاسخ تسک آزمایشی «متخصص فول‌استک» مؤسسه Cyberyan است.
</div>
---
## فهرست
<div dir="rtl">
1. [اجرای سریع](#اجرای-سریع)
2. [معماری کلی](#معماری-کلی)
3. [منطق سرچ و فیلترها](#منطق-سرچ-و-فیلترها)
4. [ساختار ذخیره‌سازی داده](#ساختار-ذخیرهسازی-داده)
5. [کیفیت دیتاست ورودی](#کیفیت-دیتاست-ورودی)
6. [API](#api)
7. [امنیت](#امنیت)
8. [مانیتورینگ و لاگ (ELK)](#مانیتورینگ-و-لاگ-elk)
9. [تست و کیفیت کد](#تست-و-کیفیت-کد)
10. [ساختار پروژه](#ساختار-پروژه)
11. [مستندات تکمیلی](#مستندات-تکمیلی)
</div>
---
## اجرای سریع
<div dir="rtl">
**پیش‌نیاز:** فقط Docker و Docker Compose (نسخه ۲ به بالا). هیچ چیز دیگری لازم نیست.
</div>
```bash
git clone <repo> && cd cyberyan-linkedin-search
cp .env.example .env # مقادیر پیش‌فرض برای اجرای محلی آماده است
docker compose up --build # یا: make up
```
<div dir="rtl">
اولین اجرا حدود ۳ تا ۵ دقیقه طول می‌کشد (بیلد ایمیج‌ها + بالا آمدن Elasticsearch). کانتینر بک‌اند به‌صورت خودکار:
1. منتظر آماده شدن Postgres و Elasticsearch می‌ماند،
2. مایگریشن‌ها را اجرا می‌کند،
3. دیتاست CSV را ایمپورت می‌کند (۲۸۴ پروفایل یکتا)،
4. ایندکس Elasticsearch را می‌سازد،
5. سه کاربر نمونه می‌سازد.
سپس:
| سرویس | آدرس |
|---|---|
| وب‌اپلیکیشن (SPA) | <http://localhost:8080> |
| مستندات تعاملی API (Swagger) | <http://localhost:8080/api/docs/> |
| مستندات ReDoc | <http://localhost:8080/api/redoc/> |
| پنل ادمین جنگو | <http://localhost:8080/admin/> |
| Kibana | <http://localhost:5601> |
**کاربران نمونه** (از `.env` خوانده می‌شوند):
| ایمیل | رمز | نقش | دسترسی |
|---|---|---|---|
| `admin@example.com` | `Admin-Passw0rd!` | admin | همه‌چیز + بازسازی ایندکس + دیدن اطلاعات تماس |
| `analyst@example.com` | `Analyst-Passw0rd!` | analyst | سرچ + دیدن اطلاعات تماس |
| `viewer@example.com` | `Viewer-Passw0rd!` | viewer | سرچ، اطلاعات تماس **ماسک‌شده** |
> در محیط واقعی حتماً `DJANGO_SECRET_KEY`، `POSTGRES_PASSWORD` و رمزهای نمونه را عوض کنید. فایل `.env.example` دستور تولید کلید امن را هم دارد.
### دستورهای پرکاربرد
</div>
```bash
make help # فهرست همه دستورها
make up # بالا آوردن کل استک
make dev # اجرا با hot-reload (Vite روی :5173، runserver روی :8000)
make down # خاموش کردن
make clean # خاموش کردن + پاک کردن volumeها
make logs # دیدن لاگ همه سرویس‌ها
make seed # ایمپورت مجدد دیتاست + بازسازی ایندکس
make reindex # فقط بازسازی ایندکس Elasticsearch
make test # اجرای تست‌های بک‌اند
make lint # lint + type-check هر دو بخش
make kibana-setup # ساخت data view لاگ‌ها در Kibana
```
<div dir="rtl">
### اجرا بدون Elasticsearch
اگر می‌خواهید سبک‌تر اجرا کنید (مثلاً برای مرور سریع کد)، کافی است `SEARCH_BACKEND=postgres` را در `.env` بگذارید. در این حالت سرچ روی Full-Text Search خود PostgreSQL اجرا می‌شود و کل API و فرانت‌اند بدون هیچ تغییری کار می‌کنند. دلیل این طراحی در [بخش سرچ](#منطق-سرچ-و-فیلترها) توضیح داده شده است.
</div>
---
## معماری کلی
```
┌──────────────────────────────────────────────┐
مرورگر ────────▶│ nginx (frontend) :8080 │
│ • سرو کردن باندل React │
│ • پراکسی /api → backend (بدون CORS) │
└───────────────┬──────────────────────────────┘
│
┌───────────────▼──────────────────────────────┐
│ Django + DRF + gunicorn :8000 │
│ ┌────────────────────────────────────────┐ │
│ │ apps.accounts JWT / نقش‌ها │ │
│ │ apps.profiles مدل دامنه + ایمپورتر │ │
│ │ apps.search لایه سرچ (Strategy) │ │
│ │ apps.core لاگ، خطا، سلامت، throttle│ │
│ └────────────────────────────────────────┘ │
└───┬───────────┬──────────────┬───────────────┘
│ │ │
┌─────────▼──┐ ┌─────▼──────┐ ┌────▼──────────┐
│ PostgreSQL │ │ Redis │ │ Elasticsearch │
│ منبع حقیقت │ │ cache + │ │ ایندکس سرچ │
│ + FTS │ │ throttle │ └────┬──────────┘
└────────────┘ └────────────┘ │
│ │
│ لاگ JSON (async TCP) │
└────────▶ Logstash ───────┘
│
Kibana :5601
```
<div dir="rtl">
**اصول طراحی:**
- **PostgreSQL منبع حقیقت است، Elasticsearch یک projection دورریختنی.** ایندکس همیشه از دیتابیس قابل بازسازی است (`make reindex`)، پس از دست رفتنش فاجعه نیست.
- **لایه سرچ پشت یک interface است** (`SearchBackend`). دو پیاده‌سازی دارد: Elasticsearch و PostgreSQL. ویو، سریالایزر و فرانت‌اند اصلاً نمی‌دانند کدام موتور جواب داده است.
- **ویوها نازک‌اند.** اعتبارسنجی در سریالایزر، منطق رتبه‌بندی در بک‌اند سرچ، ماسک‌کردن داده حساس در لایه سریالایز.
- **هیچ اطلاعات تماسی وارد Elasticsearch نمی‌شود.** ایمیل و شماره تلفن فقط در Postgres می‌مانند.
جزئیات کامل: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
</div>
---
## منطق سرچ و فیلترها
<div dir="rtl">
### یک درخواست، سه بخش
هر درخواست سرچ از سه چیز تشکیل شده است:
| بخش | نقش | تأثیر روی امتیاز |
|---|---|---|
| `q` (کلیدواژه) | چه کسی «مرتبط» است | ✅ امتیاز می‌دهد |
| فیلترها | چه کسی اصلاً «واجد شرایط» است | ❌ فقط حذف می‌کند |
| `sort` / صفحه‌بندی | ترتیب و برش نتایج | — |
این تفکیک عمدی است: در Elasticsearch فیلترها در **filter context** قرار می‌گیرند، پس کش می‌شوند و روی scoring اثر نمی‌گذارند.
### کلیدواژه چطور امتیاز می‌گیرد
</div>
```jsonc
{
"bool": {
"must": [{ // باید جایی مچ شود
"multi_match": {
"query": "product manager",
"type": "best_fields",
"fields": ["full_name^6", "job_title^4", "skills^3.5", "company^2.5",
"headline^2", "past_companies^1.5", "industry^1.5",
"majors^1.5", "education", "location_name", "summary", "interests"],
"minimum_should_match": "2<70%", // یک‌کلمه‌ای: اجباری. چندکلمه‌ای: ۷۰٪ کافی است
"fuzziness": "AUTO" // «pyhton» هم «python» را پیدا می‌کند
}
}],
"should": [{ // فقط امتیاز اضافه می‌کند
"multi_match": { "type": "phrase", "fields": ["full_name^8", "job_title^6",
"skills^5", "company^4", "headline^3"], "boost": 4 }
}],
"filter": [ /* فیلترهای ساختاریافته - بدون امتیاز */ ]
}
}
```
<div dir="rtl">
سه ایده پشت این کوئری:
1. **بوست هر فیلد یک تصمیم محصولی است.** وقتی کاربر «سارا» را جستجو می‌کند، منظورش یک آدم است نه کسی که در خلاصه‌اش کلمه «سارا» آمده — پس `full_name^6` در برابر `summary` بدون بوست.
2. **`fuzziness: AUTO`** غلط تایپی را می‌بخشد، ولی `prefix_length: 1` جلوی انفجار عبارت‌های بی‌ربط را می‌گیرد.
3. **بوست عبارت دقیق (phrase)** باعث می‌شود «product manager» به‌عنوان یک عبارت، بالاتر از پروفایلی بیاید که این دو کلمه را جدا و بی‌ربط دارد.
### فیلترها
| پارامتر | مثال | معنی |
|---|---|---|
| `skills` | `skills=python,django` | مهارت‌ها |
| `roles` | `roles=engineering` | خانواده شغلی |
| `seniority` | `seniority=senior,manager` | سطح ارشدیت |
| `countries` | `countries=united states` | کشور |
| `industries` | `industries=banking` | صنعت کارفرمای فعلی |
| `companies` | `companies=garver` | نام کارفرمای فعلی |
| `min_experience` / `max_experience` | `min_experience=5` | بازه سال‌های تجربه |
**قاعده ترکیب:** مقادیر **داخل** یک فیلتر با OR و فیلترهای **مختلف** با AND ترکیب می‌شوند.
</div>
```
skills=python,django & countries=germany
⇒ (python OR django) AND (germany)
```
<div dir="rtl">
این دقیقاً همان چیزی است که کاربر از یک پنل فیلتر انتظار دارد: تیک زدن مهارت دوم نتایج را **بیشتر** می‌کند، ولی تیک زدن یک کشور نتایج را **محدودتر**.
### Facetها (شمارنده کنار هر فیلتر)
هر پاسخ سرچ، به‌همراه نتایج، شمارش aggregation هر فیلتر را هم برمی‌گرداند. یعنی کاربر **قبل از کلیک** می‌داند «germany» چند نتیجه دارد و بن‌بست نمی‌خورد. مقادیر انتخاب‌شده حتی اگر شمارش صفر داشته باشند در پنل می‌مانند تا کاربر بتواند تیکشان را بردارد.
### fallback روی PostgreSQL
بک‌اند دوم از `tsvector` وزن‌دار استفاده می‌کند که ایمپورتر آن را می‌سازد:
| وزن | فیلدها |
|---|---|
| A | نام کامل |
| B | عنوان شغلی، مهارت‌ها، شرکت‌ها |
| C | صنعت، تحصیلات، موقعیت مکانی |
| D | خلاصه |
به‌علاوه یک pass با `pg_trgm` روی نام، تا غلط تایپی (`jhon` → `john`) هم کار کند.
**چرا اصلاً این بک‌اند دوم وجود دارد؟** چون یک API سرچ که بدون Elasticsearch اصلاً بالا نمی‌آید شکننده است: تست‌ها به کلاستر نیاز پیدا می‌کنند، مرورکننده کد برای اجرای پروژه به کلاستر نیاز دارد، و یک قطعی ES کل محصول را می‌خواباند. با یک متغیر محیطی، سیستم روی حالت کم‌توان‌تر ولی سالم می‌رود.
توضیح کامل‌تر همراه با مثال کوئری‌ها: [`docs/SEARCH.md`](docs/SEARCH.md)
</div>
---
## ساختار ذخیره‌سازی داده
<div dir="rtl">
دیتاست خام، هر آدم را در یک ردیف بسیار پهن CSV نگه می‌دارد که چند ستونش خودشان لیست دیکشنری سریالایزشده‌اند. ذخیره کردن همان بلوب، فیلتر و aggregation را غیرممکن می‌کند؛ پس داده به پنج جدول نرمال شده است:
</div>
```
Company ◀──── Experience ────▶ Profile ◀──── Education ────▶ School
│
└──── ProfileSkill ────▶ Skill
```
<div dir="rtl">
| جدول | نکته طراحی |
|---|---|
| `Profile` | ایندکس GIN روی `search_vector` و `seniority_levels`؛ ایندکس ترکیبی روی (کشور، خانواده شغلی) |
| `Skill` | کلید یکتا `slug` است؛ «Team Building» و «team building» یک رکورد می‌شوند. `profile_count` denormalize شده تا مرتب‌سازی فیلترها ارزان باشد |
| `Company` / `School` | یکتا بر اساس نام، با کش درون‌حافظه‌ای در ایمپورتر (بدون N+1) |
| `ProfileSkill` | جدول واسط با `position` تا ترتیب اصلی مهارت‌ها حفظ شود |
سه ستون متنی denormalize‌شده (`skills_text`، `companies_text`، `education_text`) روی `Profile` نگهداری می‌شوند، چون `SearchVector` جنگو نمی‌تواند از رابطه many-to-many عبور کند. نتیجه: به‌روزرسانی کل `tsvector` با **یک** کوئری `UPDATE` انجام می‌شود.
ایمپورتر **idempotent** است: کلید طبیعی، شناسه لینکدین (و در نبودش URL پروفایل) است، پس اجرای دوباره‌اش به‌جای تکرار داده، آن را به‌روز می‌کند.
</div>
---
## کیفیت دیتاست ورودی
<div dir="rtl">
فایل CSV داده‌شده یک export واقعی و خراب است، نه یک CSV تمیز. اگر مستقیم به `csv.DictReader` بدهید، بی‌سروصدا داده‌ی غلط تولید می‌کند. چهار نقص مستقل در فایل هست:
| نقص | نشانه |
|---|---|
| **واترمارک تزریق‌شده** | خیلی از ردیف‌ها با یک ستون اضافه شروع می‌شوند: `H:\New folder\leak\...@BreachedData1` — همه‌چیز یک خانه به راست می‌لغزد |
| **ستون‌های غایب** | اکثر رکوردها به‌جای مقدار خالی، فیلد اختیاری (مثل `job_title_role`) را اصلاً ننوشته‌اند — بقیه مقادیر به چپ می‌لغزند |
| **کوتیشن شکسته** | چند مقدار `summary` کوتیشن escape‌نشده دارند و یک رکورد را بین چند خط فیزیکی می‌شکنند |
| **دو export به هم چسبیده** | خط هدر یک بار دیگر وسط فایل تکرار شده و حدود ۴۰ نفر تکراری‌اند |
اثر عملی: با خواندن ساده، فقط **۱۰ ردیف از ۳۲۳** کاملاً سالم‌اند.
### راهکار: خواندن خودترمیم‌گر
هر ستون یک **امضا** دارد (`linkedin_url` باید شامل `linkedin.com/in/` باشد، `skills` باید یک literal لیست پایتون باشد، `location_geo` باید شبیه `lat,lon` باشد و…). ماژول [`apps/profiles/dataset.py`](backend/apps/profiles/dataset.py) هر جفت (ستون، مقدار) را با این امضاها امتیاز می‌دهد و بعد یک **الگوریتم هم‌ترازی به‌سبک Needleman–Wunsch** اجرا می‌کند که دو عمل ویرایشی مجاز دارد:
- *رد کردن ستون* — رکورد اصلاً این فیلد را ندارد؛
- *دور انداختن مقدار* — یک تکه سرگردان که به هیچ ستونی تعلق ندارد.
هم‌ترازی با بیشترین امتیاز، رکورد بازسازی‌شده است. سپس هر مقدار دوباره اعتبارسنجی می‌شود و هر چیزی که هنوز امضایش را رد نکند **دور انداخته می‌شود** — ذخیره نکردن بهتر از ذخیره کردن شماره تلفن در ستون ایمیل است.
</div>
```
$ docker compose exec backend python manage.py import_profiles --truncate
{
"profiles_created": 284, "skills": 2376, "companies": 1371, "schools": 610,
"experiences": 2128, "educations": 870,
"physical_rows": 336, "duplicate_headers": 1, "repaired_rows": 16,
"orphan_fragments": 12, "duplicate_records": 39, "discarded_values": 175,
"records": 284, "elapsed_seconds": 15.4
}
```
<div dir="rtl">
**غنی‌سازی از داده تودرتو:** وقتی فیلدهای سطح‌بالا غایب‌اند، ایمپورتر همان اطلاعات را از داخل بلوب `experience` بیرون می‌کشد. نتیجه:
| فیلد | فقط ستون سطح‌بالا | بعد از غنی‌سازی |
|---|---|---|
| `job_title_role` | ۱۰۸ | **۱۸۴** |
| کارفرمای فعلی | ۲۱۹ | **۲۶۵** |
هفت تست اختصاصی (`tests/test_dataset_repair.py`) هر کدام یکی از این نقص‌ها را بازتولید می‌کنند تا رگرسیون در منطق ترمیم، بی‌صدا رد نشود.
جزئیات کامل: [`docs/DATA.md`](docs/DATA.md)
</div>
---
## API
<div dir="rtl">
پایه: `/api/v1/` — مستندات تعاملی و همیشه به‌روز: <http://localhost:8080/api/docs/>
| متد | مسیر | توضیح | دسترسی |
|---|---|---|---|
| `POST` | `/auth/register/` | ثبت‌نام (همیشه نقش `viewer`) | عمومی |
| `POST` | `/auth/login/` | گرفتن access/refresh token | عمومی |
| `POST` | `/auth/refresh/` | چرخش توکن | عمومی |
| `POST` | `/auth/logout/` | بلاک‌لیست کردن refresh token | لاگین |
| `GET` | `/auth/me/` | هویت و سطح دسترسی فعلی | لاگین |
| `GET` | **`/search/`** | **سرچ کلیدواژه‌ای + فیلتر + facet** | لاگین |
| `GET` | `/search/suggest/` | پیشنهاد تایپ‌آهسته (autocomplete) | لاگین |
| `POST` | `/search/reindex/` | بازسازی ایندکس | فقط admin |
| `GET` | `/profiles/` | لیست ساده پروفایل‌ها | لاگین |
| `GET` | `/profiles/{id}/` | پروفایل کامل + سوابق | لاگین |
| `GET` | `/profiles/skills/` | دیکشنری مهارت‌ها برای type-ahead | لاگین |
| `GET` | `/health/live/` | liveness probe | عمومی |
| `GET` | `/health/ready/` | readiness (چک DB و موتور سرچ) | عمومی |
### نمونه
</div>
```bash
# ۱) لاگین
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login/ \
-H 'Content-Type: application/json' \
-d '{"email":"analyst@example.com","password":"Analyst-Passw0rd!"}' | jq -r .access)
# ۲) سرچ: کلیدواژه + دو فیلتر + مرتب‌سازی
curl -s -G http://localhost:8080/api/v1/search/ \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode 'q=leadership' \
--data-urlencode 'skills=management,training' \
--data-urlencode 'countries=united states' \
--data-urlencode 'min_experience=10' \
--data-urlencode 'sort=-experience' | jq
```
```jsonc
{
"count": 41, "page": 1, "page_size": 20, "total_pages": 3,
"took_ms": 12, "backend": "elasticsearch",
"results": [{
"id": 118, "score": 14.82,
"full_name": "joseph holland",
"headline": "recruiting manager at garver",
"job_title_role": "human_resources",
"seniority_levels": ["manager"],
"location_country": "united states",
"years_experience": 12,
"skills": ["recruiting", "leadership", "human resources"],
"highlight": { "skills": ["<mark>leadership</mark>"] }
}],
"facets": {
"skills": [{ "key": "leadership", "count": 41 }, { "key": "management", "count": 33 }],
"country": [{ "key": "united states", "count": 41 }]
}
}
```
<div dir="rtl">
### قالب یکسان خطاها
هر خطا — اعتبارسنجی، دسترسی، rate limit یا خطای پیش‌بینی‌نشده — دقیقاً یک شکل دارد:
</div>
```json
{
"error": {
"code": "validation_error",
"message": "The request payload failed validation.",
"details": { "min_experience": ["min_experience cannot be greater than max_experience."] },
"request_id": "9f2c1a7e8b4d4c0e"
}
}
```
<div dir="rtl">
`request_id` همان مقداری است که در هدر `X-Request-ID` پاسخ برمی‌گردد؛ کپی کردنش در Kibana، کل ردپای آن درخواست را نشان می‌دهد.
مرجع کامل: [`docs/API.md`](docs/API.md) — اسکیمای OpenAPI: [`docs/openapi.yaml`](docs/openapi.yaml)
</div>
---
## امنیت
<div dir="rtl">
| لایه | اقدام |
|---|---|
| احراز هویت | JWT با عمر کوتاه (۱۵ دقیقه) + refresh چرخشی + بلاک‌لیست بعد از چرخش |
| رمز عبور | هش Argon2، حداقل ۱۰ کاراکتر، اعتبارسنج‌های استاندارد جنگو |
| مجوزدهی | پیش‌فرض **deny**؛ سه نقش (admin / analyst / viewer)؛ بازسازی ایندکس فقط admin |
| حریم خصوصی | ایمیل و تلفن برای نقش `viewer` **ماسک** می‌شود و **اصلاً وارد Elasticsearch نمی‌شود** |
| Rate limiting | سقف جدا برای anon / user / search / login (روی IP برای مسیرهای اعتبارسنجی) |
| ورودی | همه پارامترها از سریالایزر رد می‌شوند؛ سقف طول کوئری، تعداد فیلترها و اندازه صفحه |
| هدرها | HSTS، `nosniff`، `X-Frame-Options: DENY`، Referrer-Policy، CSP سخت‌گیرانه در nginx |
| XSS | snippetهای highlight **بدون** `dangerouslySetInnerHTML` رندر می‌شوند |
| اسرار | همه از متغیر محیطی؛ `settings/prod.py` اگر کلید ضعیف یا `DEBUG=true` ببیند بالا نمی‌آید |
| کانتینر | کاربر غیر‌root، `no-new-privileges`، فایل‌سیستم read-only برای nginx، بیلد چندمرحله‌ای |
| شبکه | Postgres/Redis/Elasticsearch روی شبکه‌ی `internal` داکر؛ فقط SPA و Kibana منتشر می‌شوند |
| CI | `bandit`، `pip-audit`، `npm audit` و `manage.py check --deploy` |
توضیح تصمیم‌ها و trade-offها (از جمله اینکه چرا احراز هویت Elasticsearch در حالت dev خاموش است): [`docs/SECURITY.md`](docs/SECURITY.md)
</div>
---
## مانیتورینگ و لاگ (ELK)
<div dir="rtl">
هر لاگ یک شیء JSON است، نه یک خط متن. هر رکورد `request_id` درخواستی که تولیدش کرده را حمل می‌کند، پس یک کلیک کاربر تا انتها قابل ردیابی است.
</div>
```json
{"@timestamp":"2026-09-03T04:00:08Z","level":"INFO","logger":"audit.search",
"event":"search_executed","request_id":"bb3c21c5","user_id":2,
"backend":"postgres","result_count":80,"took_ms":31,
"query":"leadership","filters":{"skills":["management"],"countries":["united states"]},
"sort":"relevance","page":1,"page_size":3}
```
<div dir="rtl">
مسیر: **Django → (TCP غیرمسدودکننده) → Logstash → Elasticsearch → Kibana**
- ارسال با `python-logstash-async` انجام می‌شود: بافر درون‌حافظه‌ای و ترد جدا، پس قطعی Logstash هیچ‌وقت یک ترد درخواست را بلاک نمی‌کند (این رفتار در همین ریپو تست شده: با خاموش بودن Logstash فقط یک warning می‌آید و API سالم می‌ماند).
- pipeline لاگ‌ها را بر اساس نوع رویداد به ایندکس‌های جدا مسیردهی می‌کند: `app-logs-search-*`، `app-logs-audit-*`، `app-logs-error-*`، `app-logs-app-*` — پس «هر چیز قدیمی‌تر از ۳۰ روز را پاک کن» یک دستور حذف ایندکس است.
- سرچ کندتر از ۵۰۰ms برچسب `slow_search` و سرچ بدون نتیجه برچسب `zero_results` می‌گیرد.
- یک index template قالب‌بندی فیلدها را قفل می‌کند تا Kibana مجبور به حدس زدن نوع داده نشود.
`make kibana-setup` را اجرا کنید تا data view ساخته شود، بعد در Discover:
</div>
```
event: "search_executed" and result_count: 0 # کوئری‌هایی که چیزی پیدا نکردند
event: "search_executed" and took_ms > 200 # سرچ‌های کند
level: "ERROR" # خطاها با stack trace
request_id: "bb3c21c5..." # ردیابی یک درخواست خاص
```
<div dir="rtl">
جزئیات و runbook: [`docs/OPERATIONS.md`](docs/OPERATIONS.md)
</div>
---
## تست و کیفیت کد
```bash
make test # ۶۱ تست بک‌اند
make lint # ruff + black + mypy + eslint + tsc
make audit # bandit + pip-audit + npm audit
```
<div dir="rtl">
| فایل تست | چه چیزی را تضمین می‌کند |
|---|---|
| `test_dataset_repair.py` | هر چهار نقص واقعی فایل CSV درست ترمیم می‌شوند |
| `test_importer.py` | نرمال‌سازی، غنی‌سازی از داده تودرتو، idempotent بودن |
| `test_query_builder.py` | کوئری Elasticsearch (تابع خالص — بدون نیاز به کلاستر) |
| `test_search_api.py` | قرارداد کامل API: کلیدواژه، OR/AND فیلترها، facet، مرتب‌سازی، صفحه‌بندی، خطاها |
| `test_auth_api.py` | ثبت‌نام، لاگین، سطح دسترسی، ماسک شدن PII بر اساس نقش |
| `test_indexing.py` | داکیومنت ایندکس با mapping هم‌خوان است و **هرگز** PII حمل نمی‌کند |
| `test_privacy.py` | توابع ماسک‌کردن هیچ‌وقت مقدار اصلی را لو نمی‌دهند |
تست‌ها روی بک‌اند PostgreSQL اجرا می‌شوند و به Elasticsearch نیاز ندارند؛ pipeline گیت‌هاب (`.github/workflows/ci.yml`) همین‌ها را به‌علاوه بیلد هر دو ایمیج داکر اجرا می‌کند.
</div>
---
## ساختار پروژه
```
.
├── backend/
│ ├── config/settings/{base,dev,prod,test}.py تنظیمات تفکیک‌شده بر اساس محیط
│ ├── apps/
│ │ ├── core/ لاگ ساخت‌یافته، middleware، قالب خطا، throttle، health
│ │ ├── accounts/ کاربر سفارشی، نقش‌ها، JWT
│ │ ├── profiles/ مدل دامنه، خواننده خودترمیم CSV، ایمپورتر، ماسک PII
│ │ └── search/ types، ایندکس ES، دو بک‌اند سرچ، ویو و سریالایزر
│ ├── tests/ ۶۱ تست
│ ├── Dockerfile بیلد چندمرحله‌ای، کاربر غیر‌root
│ └── entrypoint.sh انتظار برای وابستگی‌ها، migrate، seed
├── frontend/
│ ├── src/
│ │ ├── api/ کلاینت axios با refresh خودکار توکن
│ │ ├── components/ SearchBar، FilterPanel، FacetGroup، ResultCard، Drawer
│ │ ├── hooks/ debounce، همگام‌سازی وضعیت سرچ با URL
│ │ └── pages/ Login، Search
│ ├── Dockerfile بیلد Node → سرو با nginx
│ └── nginx.conf پراکسی API + هدرهای امنیتی + CSP
├── elk/
│ └── logstash/ pipeline، تنظیمات، index template
├── data/ دیتاست خام CSV
├── docs/ معماری، API، سرچ، داده، امنیت، عملیات، openapi.yaml
├── docker-compose.yml استک کامل
├── docker-compose.dev.yml overlay توسعه (hot reload)
├── docker-compose.prod.yml overlay پروداکشن (auth روی ES، محدودیت منابع)
└── Makefile
```
---
## مستندات تکمیلی
<div dir="rtl">
| سند | محتوا |
|---|---|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | تصمیم‌های معماری، مسیر یک درخواست، الگوی Strategy در لایه سرچ |
| [`docs/SEARCH.md`](docs/SEARCH.md) | منطق سرچ و فیلتر، آنالایزرها، رتبه‌بندی، مقایسه دو بک‌اند |
| [`docs/API.md`](docs/API.md) | مرجع کامل endpointها با نمونه درخواست/پاسخ |
| [`docs/DATA.md`](docs/DATA.md) | تحلیل کیفیت دیتاست و الگوریتم ترمیم |
| [`docs/SECURITY.md`](docs/SECURITY.md) | مدل تهدید، لایه‌های دفاعی، trade-offها |
| [`docs/OPERATIONS.md`](docs/OPERATIONS.md) | اجرا، ELK، runbook خطاهای رایج |
</div>