Files

574 lines
30 KiB
Markdown
Raw Permalink Normal View History

# موتور جستجوی دیتاست لینکدین
<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>