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
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
# موتور جستجوی دیتاست لینکدین
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user