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:
2026-09-03 08:13:10 +00:00
commit a464e550c9
133 changed files with 17019 additions and 0 deletions
+412
View File
@@ -0,0 +1,412 @@
# مرجع API
<div dir="rtl">
پایه: `/api/v1/` · نسخه‌بندی از طریق namespace آدرس‌ها · اسکیمای ماشین‌خوان: [`openapi.yaml`](openapi.yaml)
مستندات تعاملی (همیشه به‌روز، از روی کد تولید می‌شود):
- Swagger UI — <http://localhost:8080/api/docs/>
- ReDoc — <http://localhost:8080/api/redoc/>
</div>
---
## ۱. قراردادهای عمومی
### ۱.۱ احراز هویت
```http
Authorization: Bearer <access_token>
```
<div dir="rtl">
سیاست پیش‌فرض **deny** است: هر endpoint که صریحاً عمومی اعلام نشده، لاگین می‌خواهد.
</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"
}
}
```
| `code` | HTTP | معنی |
|---|---|---|
| `validation_error` | 400 | ورودی نامعتبر (`details` فیلد به فیلد) |
| `authentication_failed` / `not_authenticated` | 401 | توکن نبود یا منقضی شده |
| `permission_denied` | 403 | نقش کافی نیست |
| `not_found` | 404 | منبع وجود ندارد |
| `method_not_allowed` | 405 | متد پشتیبانی نمی‌شود |
| `throttled` | 429 | سقف نرخ رد شده |
| `search_unavailable` | 503 | موتور سرچ در دسترس نیست |
| `internal_error` | 500 | خطای پیش‌بینی‌نشده (جزئیات فقط در لاگ) |
### ۱.۳ هدرها
| هدر | جهت | توضیح |
|---|---|---|
| `X-Request-ID` | ورودی (اختیاری) | اگر بفرستید همان برمی‌گردد، اگر نه تولید می‌شود |
| `X-Request-ID` | خروجی | کلید ردیابی درخواست در Kibana |
### ۱.۴ سقف نرخ
| گروه | پیش‌فرض | کلید |
|---|---|---|
| ناشناس | ۳۰/دقیقه | IP |
| کاربر لاگین‌کرده | ۳۰۰/دقیقه | user id |
| سرچ | ۱۲۰/دقیقه | user id |
| لاگین/ثبت‌نام | ۱۰/دقیقه | **IP** (چون نام کاربری را مهاجم انتخاب می‌کند) |
---
## ۲. احراز هویت
### `POST /api/v1/auth/register/` — عمومی
```jsonc
// درخواست
{ "email": "me@example.com", "full_name": "Me",
"password": "Str0ng-Passw0rd!", "password_confirm": "Str0ng-Passw0rd!" }
// 201
{ "id": 4, "email": "me@example.com", "full_name": "Me",
"role": "viewer", "can_view_pii": false, "date_joined": "2026-09-03T04:00:00Z" }
```
<div dir="rtl">
ثبت‌نام همیشه نقش `viewer` می‌دهد؛ ارتقا یک اقدام صریح ادمین است. ایمیل نرمال (lowercase) می‌شود. رمز از اعتبارسنج‌های جنگو رد می‌شود (حداقل ۱۰ کاراکتر، غیرعددی، غیرمعمول، غیرمشابه ایمیل).
</div>
### `POST /api/v1/auth/login/` — عمومی
```jsonc
// درخواست
{ "email": "analyst@example.com", "password": "Analyst-Passw0rd!" }
// 200
{ "access": "eyJhbGciOi...", // عمر ۱۵ دقیقه
"refresh": "eyJhbGciOi...", // عمر ۷ روز، چرخشی
"user": { "id": 2, "email": "analyst@example.com", "role": "analyst",
"can_view_pii": true, ... } }
```
### `POST /api/v1/auth/refresh/` — عمومی
```jsonc
{ "refresh": "..." } → { "access": "...", "refresh": "..." }
```
<div dir="rtl">
توکن refresh **چرخشی** است: هر بار یک جفت جدید می‌گیرید و توکن قبلی بلاک‌لیست می‌شود. اگر یک توکن دزدیده و استفاده شود، استفاده بعدی کاربر واقعی رد می‌شود — یعنی نشت قابل تشخیص است.
</div>
### `POST /api/v1/auth/logout/` — لاگین لازم
```jsonc
{ "refresh": "..." } → 205 No Content
```
### `GET /api/v1/auth/me/` — لاگین لازم
```jsonc
{ "id": 2, "email": "analyst@example.com", "full_name": "Demo Analyst",
"role": "analyst", "can_view_pii": true, "date_joined": "..." }
```
---
## ۳. سرچ
### `GET /api/v1/search/` — لاگین لازم
<div dir="rtl">
**پارامترها**
| نام | نوع | پیش‌فرض | توضیح |
|---|---|---|---|
| `q` | string | `""` | کلیدواژه؛ حداکثر ۲۰۰ کاراکتر. از عبارت داخل گیومه و `-حذف` پشتیبانی می‌کند |
| `skills` | لیست | — | مهارت‌ها (OR داخلی) |
| `roles` | لیست | — | خانواده شغلی، مثل `engineering` |
| `seniority` | لیست | — | `senior`, `manager`, `director`, `vp`, `cxo`, ... |
| `countries` | لیست | — | کشور محل سکونت |
| `industries` | لیست | — | صنعت کارفرمای فعلی |
| `companies` | لیست | — | نام کارفرمای فعلی |
| `min_experience` | int | — | ۰ تا ۷۰ |
| `max_experience` | int | — | ۰ تا ۷۰ |
| `sort` | enum | `relevance` | `relevance` \| `-experience` \| `experience` \| `name` |
| `page` | int | `1` | |
| `page_size` | int | `20` | حداکثر ۱۰۰ |
| `facets` | bool | `true` | `false` یعنی aggregation اجرا نشود |
لیست‌ها هر دو نگارش را می‌پذیرند: `?skills=a,b` یا `?skills=a&skills=b`. حداکثر ۲۰ مقدار در هر فیلتر.
**پاسخ ۲۰۰**
</div>
```jsonc
{
"count": 41,
"page": 1,
"page_size": 20,
"total_pages": 3,
"took_ms": 12,
"backend": "elasticsearch", // یا "postgres"
"results": [
{
"id": 118,
"score": 14.82,
"full_name": "joseph holland",
"headline": "recruiting manager at garver",
"job_title": "recruiting manager",
"job_title_role": "human_resources",
"seniority_levels": ["manager"],
"company": "garver",
"industry": "civil engineering",
"location_name": "denton, texas, united states",
"location_country": "united states",
"years_experience": 12,
"linkedin_url": "linkedin.com/in/joeyholland",
"skills": ["recruiting", "leadership", "human resources", "training"],
"highlight": {
"skills": ["<mark>leadership</mark>"],
"job_title": ["recruiting <mark>manager</mark>"]
}
}
],
"facets": {
"skills": [{ "key": "leadership", "count": 41 }, { "key": "management", "count": 33 }],
"job_title_role": [{ "key": "human_resources", "count": 12 }],
"seniority": [{ "key": "manager", "count": 29 }],
"country": [{ "key": "united states", "count": 41 }],
"industry": [{ "key": "banking", "count": 6 }],
"company": [{ "key": "garver", "count": 2 }]
}
}
```
<div dir="rtl">
**نکته‌ها**
- `score` فقط با `sort=relevance` معنادار است؛ در مرتب‌سازی‌های دیگر نمایش داده نمی‌شود.
- `highlight` فقط وقتی `q` داده شده باشد پر می‌شود و شامل تگ `<mark>` است. آن را به‌عنوان HTML خام رندر نکنید — کامپوننت `Highlight` در فرانت‌اند نشان می‌دهد چطور امن رندر شود.
- `facets` روی مجموعه فیلترشده فعلی محاسبه می‌شود، نه کل دیتاست.
- `backend` برای شفافیت عملیاتی است: مشخص می‌کند کدام موتور جواب داده.
**خطاها**
</div>
```jsonc
// 400 - بازه نامعتبر
{"error":{"code":"validation_error","message":"The request payload failed validation.",
"details":{"min_experience":["min_experience cannot be greater than max_experience."]},
"request_id":"..."}}
// 400 - صفحه‌بندی عمیق
{"error":{"code":"validation_error","details":{"page":["Cannot page beyond 10000 results."]}, ...}}
// 429
{"error":{"code":"throttled","message":"Request was throttled. Expected available in 42 seconds.", ...}}
// 503 - موتور سرچ در دسترس نیست
{"error":{"code":"search_unavailable","message":"The search backend is currently unavailable.", ...}}
```
### `GET /api/v1/search/suggest/` — لاگین لازم
```
?q=mar&limit=8 → { "suggestions": ["maria frontend", "mark heinrich", ...] }
```
<div dir="rtl">
از فیلد edge n-gram روی نام و مهارت استفاده می‌کند. `limit` بین ۱ تا ۲۰.
</div>
### `POST /api/v1/search/reindex/` — فقط نقش admin
```jsonc
// 202
{ "alias": "linkedin-profiles",
"index": "linkedin-profiles-20260903040012",
"indexed": 284, "errors": 0,
"replaced": ["linkedin-profiles-20260901101500"] }
```
<div dir="rtl">
بازسازی بدون قطعی: ایندکس فیزیکی جدید ساخته می‌شود، پر می‌شود، و بعد alias به‌صورت اتمیک جابه‌جا می‌شود. کاربر هیچ‌وقت ایندکس نیمه‌پر نمی‌بیند.
</div>
---
## ۴. پروفایل‌ها
### `GET /api/v1/profiles/` — لاگین لازم
<div dir="rtl">
لیست ساده و بدون رتبه‌بندی، با صفحه‌بندی استاندارد. برای مرور و بررسی دیتاست مفید است؛ سرچ رتبه‌بندی‌شده در `/search/` است.
</div>
```jsonc
{ "count": 284, "page": 1, "page_size": 20, "total_pages": 15,
"next": "http://.../profiles/?page=2", "previous": null,
"results": [ /* همان شکل کارت نتیجه */ ] }
```
### `GET /api/v1/profiles/{id}/` — لاگین لازم
```jsonc
{
"id": 118,
"full_name": "joseph holland",
"headline": "recruiting manager at garver",
"current_company": { "id": 42, "name": "garver", "industry": "civil engineering",
"size": "501-1000", "founded": 1919, "location_name": "..." },
"summary": "Celebrating its 100th year, Garver is an employee-owned ...",
"years_experience": 12,
"connections": 3761,
"interests": ["guitar", "aviation", "hiking"],
"languages": ["english"],
"skills": [{ "name": "recruiting", "slug": "recruiting" }, ...],
"experiences": [
{ "id": 903, "title": "recruiting manager", "company_name": "garver",
"start_date": "2019-10", "end_date": "", "is_primary": true,
"company": { "id": 42, "name": "garver", ... } }
],
"educations": [
{ "id": 210, "school_name": "university of north texas",
"degrees": ["bachelors", "bachelor of arts"],
"majors": ["politics", "international studies"],
"start_date": "2007", "end_date": "2008" }
],
"emails": [{ "address": "j3*******@yahoo.com", "type": "personal" }],
"phone_numbers": ["+1940*****28"],
"pii_masked": true
}
```
<div dir="rtl">
**`pii_masked`** به فرانت‌اند می‌گوید آیا مقادیر تماس ماسک شده‌اند یا نه، تا بتواند یک پیام روشن نشان دهد به‌جای اینکه کاربر فکر کند داده خراب است.
| نقش | `emails` / `phone_numbers` |
|---|---|
| `viewer` | ماسک‌شده (`j3*******@yahoo.com`) |
| `analyst` | کامل |
| `admin` | کامل |
</div>
### `GET /api/v1/profiles/skills/` — لاگین لازم
```
?q=py&limit=50 → [{ "id": 12, "name": "python", "slug": "python", "profile_count": 8 }, ...]
```
<div dir="rtl">
دیکشنری مهارت‌ها، مرتب بر اساس محبوبیت. فرانت‌اند از آن برای type-ahead فیلتر مهارت استفاده می‌کند بدون اینکه برای هر کلید به Elasticsearch بزند.
</div>
---
## ۵. سلامت
### `GET /api/v1/health/live/` — عمومی
```jsonc
{ "status": "ok", "service": "linkedin-search-api" }
```
<div dir="rtl">
فقط می‌گوید پروسه زنده است. مناسب liveness probe.
</div>
### `GET /api/v1/health/ready/` — عمومی
```jsonc
// 200
{ "status": "ok",
"checks": { "database": { "healthy": true, "detail": "reachable" },
"search": { "healthy": true, "detail": "elasticsearch reachable" } } }
// 503
{ "status": "degraded",
"checks": { "database": { "healthy": true, "detail": "reachable" },
"search": { "healthy": false, "detail": "Elasticsearch did not answer a ping." } } }
```
<div dir="rtl">
وقتی هر وابستگی‌ای خراب باشد کد ۵۰۳ برمی‌گرداند تا orchestrator ترافیک را به این نمونه نفرستد.
</div>
---
## ۶. یک جریان کامل با curl
```bash
BASE=http://localhost:8080/api/v1
# ۱) ثبت‌نام (اختیاری — کاربران نمونه از قبل هستند)
curl -s -X POST $BASE/auth/register/ -H 'Content-Type: application/json' \
-d '{"email":"me@example.com","full_name":"Me",
"password":"Str0ng-Passw0rd!","password_confirm":"Str0ng-Passw0rd!"}' | jq
# ۲) لاگین
TOKENS=$(curl -s -X POST $BASE/auth/login/ -H 'Content-Type: application/json' \
-d '{"email":"analyst@example.com","password":"Analyst-Passw0rd!"}')
ACCESS=$(echo "$TOKENS" | jq -r .access)
REFRESH=$(echo "$TOKENS" | jq -r .refresh)
# ۳) هویت
curl -s $BASE/auth/me/ -H "Authorization: Bearer $ACCESS" | jq
# ۴) سرچ با کلیدواژه و دو فیلتر
curl -s -G $BASE/search/ -H "Authorization: Bearer $ACCESS" \
--data-urlencode 'q=leadership' \
--data-urlencode 'skills=management,training' \
--data-urlencode 'countries=united states' \
--data-urlencode 'sort=-experience' | jq '{count, backend, took_ms,
top: [.results[:3][] | {full_name, headline, years_experience}]}'
# ۵) جزئیات اولین نتیجه
ID=$(curl -s -G $BASE/search/ -H "Authorization: Bearer $ACCESS" \
--data-urlencode 'q=leadership' | jq -r '.results[0].id')
curl -s $BASE/profiles/$ID/ -H "Authorization: Bearer $ACCESS" | jq '{full_name, pii_masked, emails}'
# ۶) چرخش توکن
curl -s -X POST $BASE/auth/refresh/ -H 'Content-Type: application/json' \
-d "{\"refresh\":\"$REFRESH\"}" | jq
# ۷) خروج
curl -s -o /dev/null -w '%{http_code}\n' -X POST $BASE/auth/logout/ \
-H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' \
-d "{\"refresh\":\"$REFRESH\"}"
```
+289
View File
@@ -0,0 +1,289 @@
# معماری
<div dir="rtl">
این سند تصمیم‌های معماری پروژه و دلیل هر کدام را توضیح می‌دهد. برای اجرا و مرور سریع به [README](../README.md) مراجعه کنید.
</div>
---
## ۱. نمای کلی
```
┌────────────┐ HTTPS ┌─────────────────┐ HTTP ┌──────────────────┐
│ مرورگر │───────────▶│ nginx (frontend)│───────────▶│ gunicorn + Django│
│ React SPA │◀───────────│ :8080 │◀───────────│ :8000 │
└────────────┘ └─────────────────┘ └───────┬──────────┘
سرو باندل + پراکسی /api │
│
┌────────────────────┬──────────────────────┬─────────────┴────────┐
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │ │ Elasticsearch │ │ Logstash │
│ منبع حقیقت │ │ cache/throttle│ │ ایندکس سرچ │ │ خط لوله لاگ │
└──────────────┘ └──────────────┘ └────────┬────────┘ └──────┬───────┘
│ │
└────────┬────────────┘
▼
┌───────────┐
│ Kibana │
└───────────┘
```
---
## ۲. مسیر یک درخواست سرچ
<div dir="rtl">
از لحظه‌ای که کاربر حرفی تایپ می‌کند تا لحظه‌ای که کارت نتیجه رندر می‌شود:
</div>
```
۱. کاربر تایپ می‌کند
│ 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
<div dir="rtl">
**تصمیم:** داده به‌صورت نرمال‌شده در Postgres ذخیره می‌شود و Elasticsearch فقط یک نسخه denormalize‌شده برای سرچ نگه می‌دارد.
**چرا:**
- ایندکس همیشه با `search_index --rebuild` از دیتابیس بازسازی می‌شود ⇒ از دست رفتن ایندکس یک حادثه عملیاتی است، نه از دست رفتن داده.
- تغییر mapping (که در Elasticsearch نیازمند reindex است) بدون ترس انجام می‌شود.
- محدودیت‌های یکتایی و ارجاعی (FK, unique) را می‌شود در جایی گذاشت که واقعاً تضمینشان می‌کند.
**هزینه:** دو نسخه از داده. با ۲۸۴ رکورد ناچیز است؛ در مقیاس بزرگ‌تر باید ایندکس‌کردن به یک صف پس‌زمینه (Celery) منتقل شود — که به‌خاطر interface تمیز `SearchBackend` تغییر کوچکی است.
</div>
### ۳.۲ لایه سرچ به‌عنوان Strategy
```python
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: ...
```
<div dir="rtl">
دو پیاده‌سازی: `ElasticsearchBackend` و `PostgresBackend`. انتخاب با یک متغیر محیطی (`SEARCH_BACKEND`) انجام می‌شود و `get_search_backend()` با `functools.cache` نتیجه را نگه می‌دارد (استخر اتصال یک بار در هر پروسه ساخته شود، نه در هر درخواست).
**چه چیزی به دست می‌آید:**
| مزیت | توضیح |
|---|---|
| تست‌پذیری | کل تست‌سوییت بدون کلاستر Elasticsearch اجرا می‌شود |
| قابلیت مرور | ارزیاب می‌تواند پروژه را با یک Postgres تنها اجرا کند |
| تاب‌آوری | قطعی ES ⇒ سوییچ به حالت کم‌توان، نه خواب کامل محصول |
| فشار طراحی | interface مجبورمان می‌کند منطق سرچ از منطق HTTP جدا بماند |
هیچ‌جای ویو، سریالایزر یا فرانت‌اند نمی‌داند کدام موتور جواب داده — فقط فیلد `backend` در پاسخ آن را گزارش می‌کند (برای شفافیت عملیاتی).
</div>
### ۳.۳ ویوهای نازک، لایه‌های مشخص
<div dir="rtl">
| مسئولیت | کجا |
|---|---|
| اعتبارسنجی و نرمال‌سازی ورودی | `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()` کمتر از ۲۰ خط است. این عمدی است: ویو فقط ارکستراتور است.
</div>
### ۳.۴ dataclass تغییرناپذیر به‌جای dict
<div dir="rtl">
`SearchRequest` یک `@dataclass(frozen=True, slots=True)` است، نه یک دیکشنری. سه سود:
1. type checker اشتباه نام فیلد را در زمان کامپایل می‌گیرد، نه در زمان اجرا.
2. تغییرناپذیری یعنی هیچ لایه‌ای نمی‌تواند کوئری را وسط راه عوض کند.
3. `as_log_context()` روی همان شیء زندگی می‌کند ⇒ لاگ و رفتار هیچ‌وقت از هم دور نمی‌افتند.
</div>
### ۳.۵ چرا nginx جلوی هر دو؟
<div dir="rtl">
SPA و API از یک مبدأ (`localhost:8080`) سرو می‌شوند. نتیجه:
- **بدون CORS**: نه pre-flight، نه لیست مبدأ در پروداکشن، نه یک کلاس کامل از باگ‌ها.
- **CSP سخت‌گیرانه**: چون همه‌چیز self است، `default-src 'self'` کافی است.
- **یک نقطه برای هدرهای امنیتی** و کش‌کردن asset‌های هش‌دار.
در حالت توسعه (`make dev`) به‌جای nginx، پراکسی خود Vite همین کار را می‌کند.
</div>
---
## ۴. مدل داده
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Company │◀───────│ Experience │───────▶│ Profile │
│ (unique │ FK │ (سوابق شغلی)│ FK │ │
│ name) │ └──────────────┘ │ search_ │
└─────────────┘ │ vector │
▲ │ (tsvector) │
│ FK (current_company) └──────┬──────┘
└──────────────────────────────────────────────┤
│
┌─────────────┐ ┌──────────────┐ │
│ School │◀───────│ Education │───────────────┤
└─────────────┘ FK └──────────────┘ FK │
│
┌─────────────┐ ┌──────────────┐ │
│ Skill │◀───────│ ProfileSkill │───────────────┘
│ (slug uniq) │ FK │ (+position) │ FK
└─────────────┘ └──────────────┘
```
<div dir="rtl">
**ایندکس‌ها و دلیلشان:**
| ایندکس | برای چه کوئری‌ای |
|---|---|
| `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(ایندکس) می‌رسد.
</div>
---
## ۵. ساختار اپلیکیشن‌های جنگو
<div dir="rtl">
| اپ | مسئولیت | وابسته به |
|---|---|---|
| `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 داخل تابع).
</div>
---
## ۶. پیکربندی
<div dir="rtl">
تنظیمات به چهار ماژول تقسیم شده‌اند:
```
config/settings/
├── base.py مشترک؛ همه اسرار از متغیر محیطی
├── dev.py DEBUG، Browsable API، بدون HTTPS
├── prod.py HSTS، کوکی امن، و «fail fast» روی پیکربندی ناامن
└── test.py سریع، بدون سرویس خارجی، بک‌اند Postgres
```
`prod.py` عمداً روی این دو حالت **بالا نمی‌آید**:
</div>
```python
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.")
```
<div dir="rtl">
یک deploy که شکست می‌خورد بی‌نهایت بهتر از یک deploy است که با `DEBUG=True` بالا می‌آید.
</div>
---
## ۷. قابلیت اتکا
<div dir="rtl">
| سناریو | رفتار سیستم |
|---|---|
| Redis از دسترس خارج شود | `IGNORE_EXCEPTIONS=True` ⇒ کش بی‌صدا رد می‌شود، API سالم می‌ماند (throttle موقتاً سخت‌گیر نیست) |
| Logstash از دسترس خارج شود | ارسال لاگ async است ⇒ فقط یک warning، هیچ درخواستی بلاک نمی‌شود |
| Elasticsearch از دسترس خارج شود | `SearchBackendError` ⇒ خطای ۵۰۳ با پیام روشن؛ `/health/ready/` هم ۵۰۳ می‌دهد تا orchestrator ترافیک نفرستد. با `SEARCH_BACKEND=postgres` سرویس ادامه می‌دهد |
| یک ردیف دیتاست خراب باشد | ایمپورتر آن رکورد را لاگ و رد می‌کند، بقیه ایمپورت می‌شوند |
| reindex نیمه‌کاره بماند | ایندکس فیزیکی جدید ساخته و **بعد** alias جابه‌جا می‌شود ⇒ کاربر هیچ‌وقت ایندکس نیمه‌پر نمی‌بیند |
</div>
---
## ۸. اگر قرار بود مقیاس بگیرد
<div dir="rtl">
با ۲۸۴ رکورد بسیاری از این‌ها لازم نیست، ولی مسیر رشد از قبل باز گذاشته شده:
| محدودیت فعلی | قدم بعدی |
|---|---|
| ایندکس‌گذاری همزمان با ایمپورت | انتقال به 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 از قبل درست‌اند |
</div>
+326
View File
@@ -0,0 +1,326 @@
# دیتاست: تحلیل کیفیت و ترمیم
<div dir="rtl">
فایل `data/linkedin_profiles.csv` یک export واقعی و خراب است. این سند نشان می‌دهد دقیقاً چه مشکلی دارد، چرا خواندن ساده جواب نمی‌دهد، و ماژول [`apps/profiles/dataset.py`](../backend/apps/profiles/dataset.py) چطور آن را ترمیم می‌کند.
</div>
---
## ۱. آنچه در فایل هست
<div dir="rtl">
| سنجه | مقدار |
|---|---|
| خطوط فیزیکی (بدون هدر) | ۳۳۶ |
| ستون‌های هدر | ۷۷ |
| رکوردهای منطقی بعد از بازسازی | ۳۲۳ |
| رکوردهای یکتا بعد از حذف تکراری | **۲۸۴** |
| ردیف‌هایی که با خواندن ساده کاملاً سالم‌اند | **۱۰** |
آن عدد آخر مسئله را خلاصه می‌کند: `csv.DictReader` روی این فایل **کار می‌کند** — بدون خطا، بدون هشدار — و ۹۷٪ رکوردها را غلط تحویل می‌دهد.
</div>
---
## ۲. چهار نقص
### ۲.۱ واترمارک تزریق‌شده
<div dir="rtl">
بسیاری از ردیف‌ها یک ستون اضافه در ابتدا دارند:
</div>
```
H:\New folder\leak\international\linkedin\@BreachedData1 Lin...,joseph holland,joseph,...
└──────────────────── ستون شبح ────────────────────────────┘ └─ داده واقعی از اینجا شروع می‌شود
```
<div dir="rtl">
نتیجه: کل ردیف یک خانه به راست می‌لغزد. `full_name` مقدار واترمارک را می‌گیرد، `first_name` نام کامل را، و به همین ترتیب تا آخر.
**ترمیم:** هر ستون ابتدایی که شامل `@BreachedData` یا `H:\New folder` باشد حذف می‌شود.
### ۲.۲ ستون‌های غایب (لغزش به چپ)
</div>
```
هدر: ..., job_title, job_title_role, job_title_levels, job_company_id, ...
ردیف: ..., "assistant process engineer", "[]", "aerospace-alu...", ...
▲
مقدار job_title_levels است، نه job_title_role
```
<div dir="rtl">
رکورد `job_title_role` را ندارد و به‌جای نوشتن مقدار خالی، آن را کلاً ننوشته است. همه‌چیز بعدش یک خانه به چپ می‌لغزد. این نقص در **بیشتر** ردیف‌ها وجود دارد و گاهی چند فیلد پشت هم غایب‌اند.
اثرش با یک نمونه واقعی:
</div>
```
naive: linkedin_connections = "55,000-70,000" ← این بازه حقوق است!
inferred_salary = "18.0" ← این سال تجربه است!
repaired:linkedin_connections = 32
inferred_salary = "55,000-70,000"
inferred_years_exp = 18
```
### ۲.۳ کوتیشن شکسته (یک رکورد در چند خط)
<div dir="rtl">
چند مقدار `summary` یک `"` فرار‌نشده دارند (`... Leadership and Management."`). پارسر CSV آن را پایان فیلد می‌فهمد و بقیه رکورد به خط بعد می‌ریزد. نتیجه: ردیف‌هایی با ۶، ۹، ۳۴ یا ۳۶ ستون که ادامه رکورد قبلی‌اند.
### ۲.۴ دو export به هم چسبیده
خط هدر یک بار دیگر در خط ۲۵۳ تکرار شده و ۳۹ نفر دقیقاً تکراری‌اند. یعنی فایل حاصل `cat export1.csv export2.csv` است.
</div>
```
توزیع تعداد ستون در ردیف‌های فیزیکی:
77 ستون → ۲۸۳ ردیف (ولی اکثرشان با لغزش داخلی)
78 ستون → ۲۵ ردیف (واترمارک‌دار)
100-133 → ۱۵ ردیف (summary شکسته + واترمارک)
6-47 → ۱۳ ردیف (تکه‌های ادامه رکورد)
```
---
## ۳. الگوریتم ترمیم
### ۳.۱ ایده
<div dir="rtl">
هر ستون یک **امضا** دارد — یک قاعده ساده و قابل بررسی درباره اینکه مقدار معتبرش چه شکلی است:
</div>
```python
FIELD_VALIDATORS = {
"linkedin_url": _contains("linkedin.com/in/"),
"gender": _one_of({"male", "female"}),
"linkedin_id": _matches(r"\d{3,20}"),
"location_geo": _matches(r"-?\d+\.\d+,-?\d+\.\d+"),
"job_company_size": _matches(r"(1-10|11-50|51-200|201-500|...)"),
"skills": _list_of_str, # literal لیست پایتون
"experience": _list_of_dicts("company"), # لیست دیکشنری با کلید company
"version_status": _dict_with("status"),
... # ۴۶ ستون امضا دارند
}
```
<div dir="rtl">
ستون‌هایی که امضا ندارند (نام، عنوان شغلی، صنعت، خلاصه) هر متنی را می‌پذیرند.
سپس مسئله این می‌شود: **بهترین انطباق بین دنباله مقادیر ردیف و دنباله ستون‌های هدر چیست؟** این دقیقاً مسئله هم‌ترازی دنباله است.
### ۳.۲ هم‌ترازی به‌سبک Needleman–Wunsch
</div>
```
ستون‌های هدر →
full_name first_name gender linkedin_url job_title_role ...
مقادیر "jane doe" ↘
ردیف "jane" ↘
↓ "female" ↘
"linkedin.com/in/j" ↘
"['senior']" ⤵ (skip column)
job_title_levels ↘
```
```python
# سه حرکت مجاز در هر خانه جدول برنامه‌نویسی پویا
match = dp[i-1][j-1] + score(column[j], value[i]) # این مقدار به این ستون
skip_column = dp[i][j-1] - 0.5 # رکورد این فیلد را ندارد
drop_value = dp[i-1][j] - 3.5 (یا -0.3 اگر مقدار خالی) # تکه سرگردان
```
<div dir="rtl">
**وزن‌ها و منطقشان:**
| رویداد | امتیاز | چرا |
|---|---|---|
| تطابق امضا | **+۳** | قوی‌ترین شاهد؛ باید بر هر ترکیبی از ویرایش‌ها بچربد |
| نقض امضا | **−۴** | «شماره تلفن در ستون ایمیل» باید گران‌تر از حذف باشد |
| متن آزاد (بدون امضا) | +۰٫۳ | پاداش کوچک برای مصرف کردن مقدار |
| مقدار خالی | +۰٫۱۵ | خنثی؛ هر جایی می‌نشیند |
| رد کردن ستون | −۰٫۵ | ارزان: در این فایل خیلی رایج است |
| دور انداختن مقدار غیرخالی | **−۳٫۵** | گران: از دست دادن داده آخرین راه‌حل است |
| دور انداختن مقدار خالی | −۰٫۳ | padding انتهای ردیف بی‌ارزش است |
پیچیدگی O(n×m) با n≈۷۷ و m=۷۷ ⇒ حدود ۶۰۰۰ خانه در هر ردیف. کل فایل در حدود ۱۵ ثانیه (که بیشترش صرف نوشتن در دیتابیس می‌شود، نه هم‌ترازی).
### ۳.۳ خط لوله کامل
</div>
```
۱. حذف ستون واترمارک از ابتدای هر ردیف
▼
۲. رد کردن خط هدرِ تکراری وسط فایل
▼
۳. تشخیص شروع رکورد:
«full_name و first_name وجود دارند و full_name با first_name شروع می‌شود»
ردیفی که شروع رکورد نیست ⇒ ادامه رکورد قبلی است
▼
۴. الحاق تکه‌های ادامه به رکورد قبلی — فقط اگر رکورد قبلی خودش ناقص باشد.
وگرنه به‌عنوان «تکه یتیم» شمرده و کنار گذاشته می‌شود (۱۲ مورد).
دلیل: چسباندن یک تکه به رکوردی که کامل است، داده یک نفر را به نام
نفر دیگر ثبت می‌کند — بدترین حالت ممکن.
▼
۵. هم‌ترازی DP
▼
۶. اعتبارسنجی دوم: هر مقداری که هنوز امضایش را رد نکند حذف می‌شود
(۱۷۵ مقدار در کل فایل)
▼
۷. حذف تکراری بر اساس linkedin_id → linkedin_url → full_name (۳۹ مورد)
```
<div dir="rtl">
**چرا مرحله ۶ لازم است؟** هم‌ترازی بهترین انطباق ممکن را پیدا می‌کند، ولی «بهترین» همیشه «درست» نیست. مثال واقعی: در یک رکورد، ستون `work_email` مقدار `"* We are focussed on providing consumer nutritional products"` گرفته بود. اعتبارسنجی دوم (`باید شامل @ باشد`) آن را دور می‌اندازد. **ذخیره نکردن بهتر از ذخیره کردن داده اشتباه است.**
</div>
---
## ۴. گزارش ایمپورت
```bash
$ docker compose exec backend python manage.py import_profiles --truncate
```
```json
{
"profiles_created": 284,
"profiles_updated": 0,
"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.41
}
```
<div dir="rtl">
هر عدد قابل ردیابی است. یک ایمپورت که «۲۸۴ رکورد» می‌گوید ولی نمی‌گوید چند مقدار دور ریخته، قابل اعتماد نیست.
</div>
---
## ۵. غنی‌سازی از داده تودرتو
<div dir="rtl">
ستون‌های سطح‌بالا اغلب غایب‌اند، ولی همان اطلاعات معمولاً داخل بلوب `experience` هست:
</div>
```python
[{'company': {'name': 'garver', 'size': '501-1000', 'industry': 'civil engineering', ...},
'title': {'name': 'recruiting manager', 'role': 'human_resources',
'sub_role': 'recruiting', 'levels': ['manager']},
'is_primary': True, 'start_date': '2019-10', 'end_date': None}]
```
<div dir="rtl">
`ProfileImporter._derive_current_position()` و `_resolve_current_company()` وقتی ستون سطح‌بالا خالی است، از رکورد `is_primary` استفاده می‌کنند:
| فیلد | فقط ستون سطح‌بالا | با غنی‌سازی | بهبود |
|---|---|---|---|
| `job_title_role` | ۱۰۸ | **۱۸۴** | ‎+۷۰٪ |
| کارفرمای فعلی | ۲۱۹ | **۲۶۵** | ‎+۲۱٪ |
| `job_title` | ۲۴۸ | **۲۵۹** | ‎+۴٪ |
این تفاوت مستقیماً روی کیفیت فیلترها اثر می‌گذارد: فیلتر «خانواده شغلی» با ۱۰۸ رکورد تقریباً بی‌فایده است، با ۱۸۴ رکورد کاربردی می‌شود.
</div>
---
## ۶. نرمال‌سازی
<div dir="rtl">
| مورد | قاعده |
|---|---|
| مهارت‌ها | slug (`slugify`) کلید یکتاست ⇒ «Team Building» و «team building» یک رکورد |
| مهارت‌های تکراری در یک پروفایل | حذف، با حفظ ترتیب اصلی در `ProfileSkill.position` |
| سقف مهارت | ۶۰ مهارت اول (بلندترین لیست ۵۰+ مورد دارد) |
| اعداد | `"18.0"` → `18` با بازه معتبر (`0 ≤ years ≤ 70`)؛ خارج از بازه ⇒ `None` |
| تاریخ | `"2019-10"` و `"2008"` هر دو نگه داشته می‌شوند + سال استخراج‌شده در `start_year`/`end_year` برای فیلتر |
| شرکت/دانشگاه | یکتا بر اساس نام، با کش درون‌حافظه‌ای در ایمپورتر ⇒ بدون N+1 |
| GPA | فقط اگر بین ۰ و ۱۰ باشد |
</div>
---
## ۷. امنیت پارس کردن
<div dir="rtl">
ستون‌های تودرتو با نگارش `repr` پایتون نوشته شده‌اند (`'` به‌جای `"` و `None` به‌جای `null`)، پس `json.loads` کار نمی‌کند.
از `ast.literal_eval` استفاده شده، **نه** `eval`. تفاوت حیاتی است: `literal_eval` فقط ساختارهای داده ثابت (عدد، رشته، لیست، دیکشنری، `None`، `True`/`False`) را می‌پذیرد و هیچ کدی اجرا نمی‌کند. یک فایل CSV مخرب نمی‌تواند از این مسیر کد اجرا کند.
همه فراخوانی‌ها در `_safe_literal()` بسته‌بندی شده‌اند که `ValueError`، `SyntaxError`، `MemoryError` و `RecursionError` را می‌گیرد و `None` برمی‌گرداند.
</div>
---
## ۸. تست‌ها
<div dir="rtl">
`backend/tests/test_dataset_repair.py` هفت تست دارد که هر کدام یکی از نقص‌های واقعی را در کوچک‌ترین شکل ممکن بازتولید می‌کنند:
| تست | تضمین |
|---|---|
| `test_clean_row_is_read_verbatim` | ردیف سالم دست‌نخورده می‌ماند |
| `test_leading_watermark_column_is_stripped` | ستون شبح حذف می‌شود |
| `test_missing_column_does_not_shift_the_rest_of_the_row` | ستون غایب باعث لغزش بقیه نمی‌شود |
| `test_values_that_fail_their_signature_are_discarded` | مقدار نامعتبر ذخیره نمی‌شود |
| `test_repeated_header_and_duplicate_person_are_removed` | هدر و رکورد تکراری حذف می‌شوند |
| `test_row_split_by_a_stray_quote_is_reassembled` | رکورد شکسته دوباره ساخته می‌شود |
| `test_empty_file_yields_no_records` | فایل خالی کرش نمی‌کند |
بدون این تست‌ها، یک تغییر کوچک در وزن‌های هم‌ترازی می‌تواند بی‌صدا کیفیت داده را خراب کند — و هیچ‌کس تا ماه‌ها متوجه نشود.
</div>
---
## ۹. مسیر جایگزین
<div dir="rtl">
اگر فردا یک export تمیز برسد، هیچ چیز خراب نمی‌شود: ردیف سالم امتیاز کامل می‌گیرد و هم‌ترازی دقیقاً همان `dict(zip(header, row))` را تولید می‌کند. الگوریتم ترمیم روی داده سالم بی‌اثر است — فقط روی داده خراب فعال می‌شود.
</div>
+420
View File
@@ -0,0 +1,420 @@
# عملیات: اجرا، مانیتورینگ و عیب‌یابی
---
## ۱. اجرا
### ۱.۱ حالت کامل (پیش‌فرض)
```bash
cp .env.example .env
docker compose up --build # یا: make up
```
<div dir="rtl">
ترتیب بالا آمدن سرویس‌ها با `depends_on: condition: service_healthy` کنترل می‌شود:
</div>
```
postgres ──┐
redis ─────┼──▶ backend ──▶ frontend
elasticsearch ─┴──▶ logstash
└──▶ kibana
```
<div dir="rtl">
اولین اجرا ۳ تا ۵ دقیقه طول می‌کشد. `entrypoint.sh` در کانتینر بک‌اند:
</div>
```
منتظر postgres (حداکثر ۱۲۰ ثانیه)
منتظر elasticsearch (حداکثر ۱۸۰ ثانیه)
python manage.py migrate --noinput
python manage.py seed_users # اگر SEED_USERS=true
python manage.py import_profiles # اگر SEED_DATASET=true و دیتابیس خالی باشد
python manage.py search_index --rebuild
exec gunicorn ...
```
<div dir="rtl">
ایمپورت **idempotent** است و فقط وقتی اجرا می‌شود که جدول پروفایل خالی باشد؛ ری‌استارت کانتینر داده را دوباره وارد نمی‌کند.
### ۱.۲ حالت توسعه (hot reload)
</div>
```bash
make dev
# SPA (Vite) → http://localhost:5173
# API → http://localhost:8000/api/docs/
```
<div dir="rtl">
سورس‌ها bind-mount می‌شوند: تغییر در پایتون باعث ری‌استارت `runserver` و تغییر در TSX باعث HMR می‌شود.
### ۱.۳ حالت پروداکشن
</div>
```bash
make prod
# = docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
<div dir="rtl">
تفاوت‌ها: احراز هویت Elasticsearch روشن، Kibana منتشر نمی‌شود، seed خاموش، محدودیت CPU/RAM، دو نمونه بک‌اند.
### ۱.۴ اجرا بدون Elasticsearch
</div>
```bash
echo "SEARCH_BACKEND=postgres" >> .env
docker compose up -d postgres redis backend frontend
```
<div dir="rtl">
کل API و فرانت‌اند بدون تغییر کار می‌کنند؛ فیلد `backend` در پاسخ سرچ `"postgres"` می‌شود.
</div>
---
## ۲. لاگ ساخت‌یافته
### ۲.۱ شکل رکوردها
```json
{"@timestamp":"2026-09-03T04:00:08.574Z","level":"INFO","logger":"audit.search",
"message":"search_executed","service":"linkedin-search-api","environment":"production",
"request_id":"bb3c21c555a54ae0a6dafcd12ea1a98c","event":"search_executed",
"user_id":2,"backend":"elasticsearch","result_count":80,"took_ms":31,
"query":"leadership","filters":{"skills":["management"],"countries":["united states"],
"roles":[],"seniority":[],"industries":[],"companies":[],
"min_experience":null,"max_experience":null},
"sort":"relevance","page":1,"page_size":3}
```
<div dir="rtl">
| لاگر | رویدادها |
|---|---|
| `apps.access` | `http_request` — متد، مسیر، وضعیت، `duration_ms`، IP، user id |
| `audit.search` | `search_executed` — کوئری، فیلترها، تعداد نتیجه، زمان، موتور |
| `audit.auth` | `login_succeeded`، `logout`، `user_registered` |
| `apps.errors` | `unhandled_exception` با stack trace |
| `gunicorn.access` | خط دسترسی وب‌سرور (JSON) |
### ۲.۲ مسیر انتقال
</div>
```
Django logger
│ JSONFormatter → یک شیء JSON در هر رکورد
│ RequestIDFilter → افزودن request_id از ContextVar
├──▶ stdout (همیشه؛ قابل دیدن با docker compose logs)
└──▶ AsynchronousLogstashHandler
│ بافر درون‌حافظه‌ای + ترد جدا
▼
Logstash :5959 (codec: json_lines)
│ غنی‌سازی، برچسب‌گذاری، حذف فیلدهای حساس
▼
Elasticsearch app-logs-{stream}-{YYYY.MM.dd}
▼
Kibana
```
<div dir="rtl">
**چرا async؟** اگر Logstash از دسترس خارج شود، یک هندلر همگام ترد درخواست را بلاک می‌کند و کل API کند می‌شود. با `python-logstash-async` رکوردها در حافظه صف می‌شوند و یک ترد جدا آن‌ها را می‌فرستد. این رفتار عملاً آزموده شده: با Logstash خاموش، تنها اثرش یک warning در stdout است.
### ۲.۳ مسیردهی به ایندکس‌ها
</div>
| ایندکس | محتوا |
|---|---|
| `app-logs-search-*` | رویدادهای `search_executed` |
| `app-logs-audit-*` | ورود، خروج، ثبت‌نام |
| `app-logs-error-*` | `ERROR` و `CRITICAL` |
| `app-logs-app-*` | بقیه |
<div dir="rtl">
یک ایندکس در روز برای هر جریان. نتیجه: «همه لاگ‌های قدیمی‌تر از ۳۰ روز را پاک کن» یک حذف ایندکس است، نه یک `delete_by_query` سنگین.
**برچسب‌های خودکار:** `slow_search` (بیش از ۵۰۰ms) و `zero_results`.
</div>
---
## ۳. Kibana
```bash
make kibana-setup # ساخت data view «Application logs»
open http://localhost:5601
```
### کوئری‌های مفید در Discover
```
event: "search_executed" and result_count: 0
→ کاربران چه چیزی می‌خواستند که پیدا نکردند؟ (بهترین سیگنال برای بهبود سرچ)
event: "search_executed" and took_ms > 200
→ کدام ترکیب فیلترها کند است؟
event: "search_executed" and filters.skills: *
→ کدام مهارت‌ها بیشترین جستجو را دارند؟
level: "ERROR"
→ خطاها با stack trace کامل
request_id: "bb3c21c555a54ae0a6dafcd12ea1a98c"
→ کل ردپای یک درخواست خاص (شامل خط دسترسی و رویداد ممیزی)
http_status >= 400 and http_path: "/api/v1/search/"
→ درخواست‌های ناموفق سرچ
```
<div dir="rtl">
**چطور `request_id` را پیدا کنم؟** هدر `X-Request-ID` در تب Network مرورگر، یا فیلد `request_id` داخل بدنه هر پاسخ خطا.
### داشبوردی که ارزش ساختن دارد
| ویجت | کوئری |
|---|---|
| نرخ سرچ | `event: "search_executed"` — Date histogram |
| صدک ۹۵ زمان پاسخ | `took_ms` — Percentiles |
| نرخ نتیجه‌ی صفر | `result_count: 0` تقسیم بر کل |
| پرجستجوترین مهارت‌ها | `filters.skills` — Terms |
| نرخ خطا | `http_status >= 500` |
| کاربران فعال | `user_id` — Unique count |
</div>
---
## ۴. عملیات روزمره
```bash
make ps # وضعیت کانتینرها + healthcheck
make logs # لاگ زنده همه سرویس‌ها
docker compose logs -f backend # فقط API
make seed # ایمپورت مجدد کامل + بازسازی ایندکس
make reindex # فقط بازسازی ایندکس از روی Postgres
make index-status # وضعیت alias و تعداد داکیومنت
make users # ساخت/به‌روزرسانی کاربران نمونه
make superuser # ساخت superuser تعاملی
make shell # شل جنگو
make test # تست‌ها داخل کانتینر
make lint # lint + type-check
make audit # اسکن امنیتی وابستگی‌ها
make schema # نوشتن docs/openapi.yaml
```
### بررسی سلامت
```bash
curl -s localhost:8080/api/v1/health/live/ | jq
curl -s localhost:8080/api/v1/health/ready/ | jq
```
---
## ۵. عیب‌یابی
<div dir="rtl">
### Elasticsearch بالا نمی‌آید
**علامت:** کانتینر `elasticsearch` مدام ری‌استارت می‌شود؛ در لاگ: `max virtual memory areas vm.max_map_count [65530] is too low`.
**علت:** Elasticsearch به `vm.max_map_count ≥ 262144` نیاز دارد.
</div>
```bash
sudo sysctl -w vm.max_map_count=262144 # موقت
echo 'vm.max_map_count=262144' | sudo tee -a /etc/sysctl.conf # دائمی
```
<div dir="rtl">
روی Docker Desktop (مک/ویندوز) معمولاً از قبل تنظیم است.
### Elasticsearch به‌خاطر حافظه کشته می‌شود (exit 137)
`ES_JAVA_OPTS` را در `.env` کم کنید:
</div>
```env
ES_JAVA_OPTS=-Xms256m -Xmx256m
LS_JAVA_OPTS=-Xms192m -Xmx192m
```
<div dir="rtl">
یا اگر رم کم است، Kibana و Logstash را بالا نیاورید:
</div>
```bash
docker compose up -d postgres redis elasticsearch backend frontend
```
<div dir="rtl">
### بک‌اند با «DJANGO_SECRET_KEY is required» بالا نمی‌آید
`.env` ساخته نشده یا کلید خالی است:
</div>
```bash
cp .env.example .env
python -c "import secrets; print(secrets.token_urlsafe(64))" # مقدار را در .env بگذارید
```
<div dir="rtl">
### سرچ ۵۰۳ می‌دهد
</div>
```bash
curl -s localhost:8080/api/v1/health/ready/ | jq # کدام وابستگی خراب است؟
docker compose logs elasticsearch | tail -50
make index-status # آیا ایندکس اصلاً وجود دارد؟
make reindex # بازسازی
```
<div dir="rtl">
راه‌حل موقت: `SEARCH_BACKEND=postgres` در `.env` و `docker compose restart backend`.
### سرچ نتیجه نمی‌دهد ولی داده هست
</div>
```bash
make index-status
# {"alias": "linkedin-profiles", "exists": true, "documents": 284}
```
<div dir="rtl">
اگر `documents: 0` بود، ایمپورت انجام شده ولی ایندکس ساخته نشده: `make reindex`.
### لاگ‌ها در Kibana دیده نمی‌شوند
</div>
```bash
docker compose logs logstash | tail -30 # آیا pipeline بالا آمده؟
docker compose exec backend env | grep LOGSTASH # LOGSTASH_ENABLED=true ؟
curl -s localhost:5601/api/status | jq .status.overall
./scripts/kibana_setup.sh # آیا data view ساخته شده؟
```
<div dir="rtl">
Logstash حدود ۶۰ ثانیه طول می‌کشد تا آماده شود؛ لاگ‌های قبل از آن در بافر می‌مانند و بعد ارسال می‌شوند.
### فرانت‌اند «Network Error» می‌دهد
</div>
```bash
docker compose ps backend # سالم است؟
docker compose exec frontend wget -qO- http://backend:8000/api/v1/health/live/
```
<div dir="rtl">
اگر با `npm run dev` بیرون از داکر کار می‌کنید، مطمئن شوید مبدأ `http://localhost:5173` در `CORS_ALLOWED_ORIGINS` هست.
### مایگریشن روی `pg_trgm` شکست می‌خورد
مایگریشن `0002_postgres_extensions` نیاز به دسترسی ساخت extension دارد. در ایمیج رسمی Postgres کاربر پیش‌فرض superuser است؛ در دیتابیس مدیریت‌شده باید ادمین یک بار اجرا کند:
</div>
```sql
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS unaccent;
```
---
## ۶. پشتیبان‌گیری و بازیابی
```bash
# پشتیبان
docker compose exec -T postgres pg_dump -U linkedin linkedin | gzip > backup-$(date +%F).sql.gz
# بازیابی
gunzip -c backup-2026-09-03.sql.gz | docker compose exec -T postgres psql -U linkedin linkedin
make reindex # ایندکس از Postgres بازسازی می‌شود
```
<div dir="rtl">
از Elasticsearch **پشتیبان نمی‌گیریم**. ایندکس یک projection دورریختنی است و همیشه از Postgres قابل بازسازی است — این دقیقاً دلیل انتخاب آن معماری بود.
</div>
---
## ۷. سنجه‌های عملکرد (دیتاست ۲۸۴ رکوردی)
<div dir="rtl">
| عملیات | زمان |
|---|---|
| ایمپورت کامل (خواندن + ترمیم + نوشتن) | ‎~۱۵ ثانیه |
| بازسازی ایندکس | ‎~۲ ثانیه |
| سرچ با Elasticsearch | ‎~۵–۱۵ms |
| سرچ با PostgreSQL | ‎~۱۵–۳۵ms |
| جزئیات پروفایل | ‎~۲۰ms |
| بیلد ایمیج بک‌اند (بدون کش) | ‎~۹۰ ثانیه |
| بیلد ایمیج فرانت‌اند (بدون کش) | ‎~۶۰ ثانیه |
اعداد سرچ روی همین دیتاست و ماشین توسعه اندازه‌گیری شده‌اند؛ فیلد `took_ms` در هر پاسخ همین را گزارش می‌کند و در Kibana قابل نمودار کردن است.
</div>
---
## ۸. چک‌لیست پیش از استقرار
<div dir="rtl">
- [ ] `DJANGO_SECRET_KEY` تصادفی و ≥۵۰ کاراکتر
- [ ] `POSTGRES_PASSWORD` و `ELASTIC_PASSWORD` عوض شده
- [ ] رمزهای کاربران نمونه عوض یا `SEED_USERS=false`
- [ ] `DJANGO_DEBUG=false` و `DJANGO_SETTINGS_MODULE=config.settings.prod`
- [ ] `DJANGO_ALLOWED_HOSTS` روی دامنه واقعی
- [ ] `DJANGO_ADMIN_URL` روی یک مسیر غیرقابل‌حدس
- [ ] HTTPS در لبه + `SECURE_SSL_REDIRECT=true`
- [ ] `SEED_DATASET=false`
- [ ] overlay پروداکشن فعال (احراز هویت ES)
- [ ] پشتیبان‌گیری خودکار Postgres
- [ ] سیاست نگهداری لاگ (ILM) روی `app-logs-*`
- [ ] `docker compose exec backend python manage.py check --deploy` بدون خطا
</div>
+370
View File
@@ -0,0 +1,370 @@
# منطق سرچ و فیلترها
<div dir="rtl">
این سند دقیقاً توضیح می‌دهد یک کوئری چطور به نتیجه تبدیل می‌شود: چه چیزی امتیاز می‌گیرد، چه چیزی فقط حذف می‌کند، و چرا.
</div>
---
## ۱. آناتومی یک درخواست
<div dir="rtl">
| بخش | نقش | امتیاز می‌دهد؟ |
|---|---|---|
| `q` | تعیین می‌کند چه کسی **مرتبط** است | ✅ |
| فیلترها | تعیین می‌کنند چه کسی **واجد شرایط** است | ❌ |
| `sort` | ترتیب نهایی | ❌ |
| `page` / `page_size` | برش | ❌ |
این تفکیک در Elasticsearch به‌صورت مستقیم به `must`/`should` (بخش امتیازدهی) در برابر `filter` (بخش حذف) نگاشت می‌شود. مزیت عملی: کلاز‌های `filter` توسط ES کش می‌شوند و هزینه محاسبه امتیاز ندارند.
</div>
---
## ۲. ایندکس Elasticsearch
### ۲.۱ آنالایزرها
```jsonc
"english_text": { // برای همه فیلدهای متنی
"tokenizer": "standard",
"filter": ["lowercase", "asciifolding",
"english_possessive", "english_stop", "english_stemmer"]
}
"autocomplete_index": { "filter": [..., "edge_ngram(2,15)"] } // زمان ایندکس
"autocomplete_search": { "filter": ["lowercase", "asciifolding"] } // زمان کوئری
```
<div dir="rtl">
سه نکته:
1. **`asciifolding`** باعث می‌شود «José» با «jose» پیدا شود.
2. **`english_stemmer`** «engineering»، «engineer» و «engineers» را یکی می‌کند.
3. **آنالایزر متفاوت برای ایندکس و سرچ در autocomplete** حیاتی است: اگر عبارت کاربر هم n-gram شود، «jo» با هر چیزی که حرف «j» دارد مچ می‌شود. با `search_analyzer` جدا، فقط پیشوندها مچ می‌شوند.
### ۲.۲ چرا هر فیلد دو نسخه دارد
</div>
```jsonc
"skills": {
"type": "text", "analyzer": "english_text", // برای سرچ متنی
"fields": {
"keyword": { "type": "keyword",
"normalizer": "lowercase_normalizer" }, // برای فیلتر و facet
"autocomplete": { ... } // برای type-ahead
}
}
```
<div dir="rtl">
متن آنالایزشده برای فیلتر دقیق و شمارش aggregation قابل استفاده نیست — «project management» به دو توکن شکسته می‌شود و در facet دو ردیف جدا می‌سازد. زیرفیلد `keyword` مقدار خام (فقط lowercase و ascii-folded) را نگه می‌دارد.
### ۲.۳ `dynamic: strict`
mapping عمداً strict است. اگر داکیومنتی فیلدی داشته باشد که در mapping نیست، Elasticsearch کل داکیومنت را رد می‌کند — به‌جای اینکه بی‌صدا نوعش را حدس بزند و بعداً یک facet را خراب کند. یک تست (`test_document_only_contains_mapped_fields`) این قرارداد را قفل کرده است.
### ۲.۴ چه چیزی ایندکس **نمی‌شود**
ایمیل، شماره تلفن و آدرس خیابان **هرگز** وارد ایندکس نمی‌شوند. دلیل صرفاً «حریم خصوصی به‌طور کلی» نیست: اگر ایمیل ایندکس شود، هر کاربری می‌تواند با یک سرچ حدسی وجود یک آدرس ایمیل را تأیید کند (oracle attack). این داده‌ها فقط در Postgres می‌مانند و در لایه سریالایزر ماسک می‌شوند.
</div>
---
## ۳. کوئری کلیدواژه
```python
# apps/search/backends/elastic.py
SEARCH_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",
]
PHRASE_FIELDS = ["full_name^8", "job_title^6", "skills^5", "company^4", "headline^3"]
```
### ۳.۱ چهار پارامتر کلیدی
| پارامتر | مقدار | چرا |
|---|---|---|
| `type` | `best_fields` | یک پروفایل که «python» را قوی در `skills` دارد، بهتر از پروفایلی است که آن را ضعیف در چند فیلد پخش کرده |
| `minimum_should_match` | `2<70%` | کوئری یک‌کلمه‌ای: مچ اجباری. کوئری سه‌کلمه‌ای: ۷۰٪ کافی است، پس «senior python django» کسی را که فقط دوتایش را دارد هم برمی‌گرداند |
| `fuzziness` | `AUTO` | فاصله ویرایشی متناسب با طول کلمه؛ «pyhton» → «python» |
| `prefix_length` | `1` | حرف اول باید دقیق باشد؛ جلوی انفجار کاندیدها و نتایج بی‌ربط را می‌گیرد |
### ۳.۲ بوست عبارت
<div dir="rtl">
کلاز `should` (با `boost: 4`) هیچ‌وقت نتیجه‌ای را حذف نمی‌کند، فقط امتیاز اضافه می‌کند. اثرش:
| کوئری | بدون بوست عبارت | با بوست عبارت |
|---|---|---|
| `product manager` | کسی که «product» در عنوان و «manager» در خلاصه دارد ممکن است اول بیاید | کسی که عنوانش دقیقاً «product manager» است اول می‌آید |
`slop: 1` اجازه می‌دهد یک کلمه فاصله بیفتد، پس «product marketing manager» هم بوست می‌گیرد ولی کمتر.
</div>
---
## ۴. فیلترها
### ۴.۱ نگاشت پارامتر به فیلد ایندکس
```python
FILTER_FIELDS = {
"skills": "skills.keyword",
"roles": "job_title_role",
"seniority": "seniority_levels",
"countries": "location_country",
"industries": "industry.keyword",
"companies": "company.keyword",
}
```
<div dir="rtl">
هر کدام به یک کلاز `terms` تبدیل می‌شوند و بازه تجربه به یک `range`.
### ۴.۲ قاعده OR/AND
</div>
```
?skills=python,django&countries=germany&min_experience=5
⇒ filter: [
{ terms: { "skills.keyword": ["python", "django"] } }, ← OR داخلی
{ terms: { "location_country": ["germany"] } }, ← AND با قبلی
{ range: { "years_experience": { "gte": 5 } } } ← AND با قبلی
]
```
<div dir="rtl">
**چرا این قاعده؟** چون با مدل ذهنی کاربر از یک پنل فیلتر می‌خواند:
- تیک زدن مهارت دومِ **همان دسته** = «یا این یا آن» ⇒ نتایج بیشتر می‌شوند.
- تیک زدن یک کشور در دسته **دیگر** = «و همچنین» ⇒ نتایج کمتر می‌شوند.
اگر مهارت‌ها هم AND می‌شدند، کاربر با تیک زدن مهارت دوم معمولاً به صفر نتیجه می‌رسید و فکر می‌کرد سیستم خراب است.
### ۴.۳ هر دو نگارش کوئری‌استرینگ پشتیبانی می‌شوند
</div>
```
?skills=python,django # جدا با کاما
?skills=python&skills=django # پارامتر تکراری
```
<div dir="rtl">
`CommaSeparatedField.get_value()` در حالت دوم از `QueryDict.getlist` استفاده می‌کند (متد `get` فقط آخرین مقدار را برمی‌گرداند — یک باگ کلاسیک). هر دو نگارش نرمال، lowercase، یکتا و به حداکثر ۲۰ مقدار محدود می‌شوند.
</div>
---
## ۵. Facetها
```jsonc
"aggs": {
"skills": { "terms": { "field": "skills.keyword", "size": 25 } },
"job_title_role": { "terms": { "field": "job_title_role", "size": 25 } },
"seniority": { "terms": { "field": "seniority_levels","size": 25 } },
"country": { "terms": { "field": "location_country","size": 25 } },
"industry": { "terms": { "field": "industry.keyword","size": 25 } },
"company": { "terms": { "field": "company.keyword", "size": 25 } }
}
```
<div dir="rtl">
facetها روی **مجموعه نتایج فیلترشده فعلی** محاسبه می‌شوند، نه روی کل دیتاست. یعنی وقتی کاربر «engineering» را انتخاب کرده، شمارش کشورها فقط مهندس‌ها را می‌شمارد.
دو جزئیات UX که در فرانت‌اند رعایت شده‌اند:
1. **مقدار انتخاب‌شده هیچ‌وقت ناپدید نمی‌شود** — حتی اگر شمارشش صفر شود، وگرنه کاربر نمی‌تواند تیکش را بردارد.
2. **رشته خالی از facet حذف می‌شود** — دیتاست پر از فیلدهای خالی است و یک باکت بی‌نام فقط سردرگمی می‌سازد.
`facets=false` را می‌شود فرستاد تا aggregation اجرا نشود (برای صفحات بعدی که پنل فیلتر تغییری نکرده).
</div>
---
## ۶. مرتب‌سازی
| `sort` | Elasticsearch | PostgreSQL |
|---|---|---|
| `relevance` (پیش‌فرض) | `["_score", {"id": "asc"}]` | `-rank, full_name, id` |
| `-experience` | `years_experience desc, missing _last` | `years_experience desc nulls last` |
| `experience` | `years_experience asc, missing _last` | `years_experience asc nulls last` |
| `name` | `full_name.keyword asc` | `full_name asc` |
<div dir="rtl">
**نکته `missing: _last`:** ۶۴ پروفایل از ۲۸۴ سال تجربه ندارند. بدون این تنظیم، مرتب‌سازی صعودی همه‌شان را اول می‌گذارد و صفحه اول پر از داده ناقص می‌شود.
**نکته tie-break:** همه‌جا `id` به‌عنوان معیار آخر اضافه شده تا صفحه‌بندی پایدار باشد؛ بدون آن، دو رکورد با امتیاز برابر می‌توانند بین صفحه ۱ و ۲ جابه‌جا شوند و یکی دوبار دیده و دیگری اصلاً دیده نشود.
</div>
---
## ۷. Highlight
```jsonc
"highlight": {
"pre_tags": ["<mark>"], "post_tags": ["</mark>"],
"fields": {
"full_name": { "number_of_fragments": 0 }, // کل مقدار
"job_title": { "number_of_fragments": 0 },
"skills": { "number_of_fragments": 3 },
"summary": { "fragment_size": 160, "number_of_fragments": 1 }
}
}
```
<div dir="rtl">
`number_of_fragments: 0` یعنی «کل فیلد را برگردان، نه یک تکه» — برای نام و عنوان که کوتاه‌اند درست است. برای `summary` یک قطعه ۱۶۰ کاراکتری کافی است.
**نکته امنیتی:** snippet برگشتی شامل تگ HTML است ولی محتوایش داده کاربر است. فرانت‌اند آن را با `dangerouslySetInnerHTML` رندر **نمی‌کند**؛ کامپوننت `Highlight` رشته را روی تگ‌های `<mark>` می‌شکند و بقیه را به‌عنوان text node رندر می‌کند. نتیجه یکسان، بدون سطح حمله XSS.
</div>
---
## ۸. بک‌اند PostgreSQL
### ۸.۱ tsvector وزن‌دار
```python
Profile.objects.update(search_vector=(
SearchVector("full_name", weight="A", config="english")
+ SearchVector("job_title", weight="B", config="english")
+ SearchVector("skills_text", weight="B", config="english")
+ SearchVector("companies_text", weight="B", config="english")
+ SearchVector("industry", weight="C", config="english")
+ SearchVector("education_text", weight="C", config="english")
+ SearchVector("location_name", weight="C", config="english")
+ SearchVector("summary", weight="D", config="english")
))
```
<div dir="rtl">
وزن‌های پیش‌فرض PostgreSQL: A=1.0، B=0.4، C=0.2، D=0.1 — همان سلسله‌مراتبی که در ES با بوست فیلدها ساخته شد.
### ۸.۲ تحمل غلط تایپی
</div>
```python
.annotate(
text_rank = SearchRank(F("search_vector"), query),
name_similarity= TrigramSimilarity("full_name", term),
)
.annotate(rank=Greatest(F("text_rank"), <name_similarity اگر ≥ 0.3>))
.filter(Q(search_vector=query) | Q(name_similarity__gte=0.3))
```
<div dir="rtl">
`Greatest` یعنی یک تطابق قوی نام به‌اندازه یک تطابق قوی متنی ارزش دارد. آستانه ۰٫۳ تجربی است: پایین‌تر نویز می‌آورد، بالاتر «jhon» را رد می‌کند.
### ۸.۳ نگارش کوئری
`search_type="websearch"` استفاده شده، پس کاربر همان نحوی را دارد که از موتورهای جستجو می‌شناسد:
</div>
```
python django دو کلمه، هر دو لازم (AND)
"product manager" عبارت دقیق
python or ruby یکی از دو
python -junior شامل python، بدون junior
```
### ۸.۴ تفاوت‌های آگاهانه با Elasticsearch
<div dir="rtl">
| موضوع | Elasticsearch | PostgreSQL |
|---|---|---|
| چند کلمه | ۷۰٪ کافی است (بازیابی بالاتر) | همه لازم‌اند (دقت بالاتر) |
| غلط تایپی | روی همه فیلدها | فقط روی نام |
| highlight | highlighter بومی | جایگزینی توکن سمت پایتون |
| facet | aggregation | شمارش در پایتون روی مجموعه فیلترشده |
| مقیاس | میلیون‌ها رکورد | تا حدود ده‌ها هزار رکورد راحت |
این تفاوت‌ها پنهان نشده‌اند: پاسخ API فیلد `backend` دارد و UI هم موتور فعال را نشان می‌دهد. برای دیتاستی با ۲۸۴ رکورد، تفاوت عملاً محسوس نیست — که دقیقاً چیزی است که یک fallback خوب را ممکن می‌کند.
</div>
---
## ۹. محافظ‌ها
<div dir="rtl">
| محدودیت | مقدار | چرا |
|---|---|---|
| طول `q` | ۲۰۰ کاراکتر | کوئری غول‌پیکر = مصرف CPU |
| تعداد مقدار در هر فیلتر | ۲۰ | جلوگیری از `terms` با هزاران مقدار |
| `page_size` | حداکثر ۱۰۰ | جلوگیری از پاسخ چندمگابایتی |
| `page × page_size` | حداکثر ۱۰٬۰۰۰ | صفحه‌بندی عمیق برای انسان بی‌فایده و برای موتور گران است — رد می‌شود، نه اینکه بی‌صدا کوتاه شود |
| rate limit سرچ | ۱۲۰/دقیقه | سرچ گران‌ترین endpoint است |
همه در `SearchRequestSerializer` اعمال می‌شوند، یعنی **قبل** از اینکه چیزی به موتور برسد.
</div>
---
## ۱۰. نمونه‌های عملی
```bash
# ساده‌ترین حالت: همه، مرتب بر اساس نام
GET /api/v1/search/?sort=name
# کلیدواژه
GET /api/v1/search/?q=recruiting
# کلیدواژه + دو فیلتر (خواسته حداقلی صورت تسک)
GET /api/v1/search/?q=leadership&skills=management&roles=human_resources
# چند مقدار در یک فیلتر → OR
GET /api/v1/search/?skills=python,django,react
# بازه تجربه + مرتب‌سازی
GET /api/v1/search/?min_experience=10&max_experience=20&sort=-experience
# صفحه دوم، بدون محاسبه دوباره facetها
GET /api/v1/search/?q=engineer&page=2&facets=false
# تحمل غلط تایپی
GET /api/v1/search/?q=jhon # john را پیدا می‌کند
# type-ahead
GET /api/v1/search/suggest/?q=mar&limit=5
```
+309
View File
@@ -0,0 +1,309 @@
# امنیت
<div dir="rtl">
این سند مدل تهدید، لایه‌های دفاعی و — مهم‌تر — **trade-offهایی که آگاهانه پذیرفته شده‌اند** را توضیح می‌دهد. یک سند امنیتی که فقط فهرست کارهای خوب باشد، نصف حقیقت را می‌گوید.
</div>
---
## ۱. مدل تهدید
<div dir="rtl">
| دارایی | چرا ارزشمند است |
|---|---|
| اطلاعات تماس ۲۸۴ نفر واقعی | ایمیل، موبایل، آدرس خیابان — داده شخصی قابل سوءاستفاده |
| کل دیتاست | ارزش تجاری؛ استخراج انبوه (scraping) تهدید اصلی است |
| حساب‌های کاربری | دسترسی به داده بالا |
| زیرساخت | Postgres، Elasticsearch، Kibana |
| مهاجم | توان | دفاع اصلی |
|---|---|---|
| ناشناس از اینترنت | درخواست HTTP | احراز هویت اجباری، rate limit، هدرهای امنیتی |
| کاربر لاگین‌کرده با نیت بد | API معتبر | ماسک PII، سقف نرخ، صفحه‌بندی محدود، لاگ ممیزی |
| مهاجم روی شبکه | شنود | HTTPS در لبه، شبکه داخلی داکر |
| فایل ورودی مخرب | محتوای CSV | `literal_eval` به‌جای `eval`، اعتبارسنجی امضا |
</div>
---
## ۲. احراز هویت
<div dir="rtl">
| مورد | پیاده‌سازی | دلیل |
|---|---|---|
| هش رمز | **Argon2id** (اول در `PASSWORD_HASHERS`) | توصیه OWASP؛ مقاوم در برابر GPU |
| سیاست رمز | ≥۱۰ کاراکتر + ۴ اعتبارسنج جنگو | جلوی رمزهای فهرست‌شده را می‌گیرد |
| عمر access token | ۱۵ دقیقه | پنجره سوءاستفاده از توکن دزدیده‌شده کوتاه |
| عمر refresh token | ۷ روز، **چرخشی** | هر refresh یک جفت جدید می‌دهد |
| بلاک‌لیست | `ROTATE_REFRESH_TOKENS` + `BLACKLIST_AFTER_ROTATION` | استفاده دوباره از توکن قدیمی رد می‌شود ⇒ نشت **قابل تشخیص** است |
| الگوریتم | HS256 با کلید جدا (`JWT_SIGNING_KEY`) | چرخاندن کلید JWT بدون بی‌اعتبار کردن session جنگو |
| کلیدهای داخل توکن | فقط `user_id`، `role`، `email` | payload کوچک؛ هیچ داده حساسی در توکنی که کلاینت می‌بیند نیست |
### چرا `sessionStorage` و نه کوکی؟
توکن‌ها در `sessionStorage` نگه داشته می‌شوند:
- ✅ به‌صورت خودکار به هیچ درخواست cross-site پیوست نمی‌شوند ⇒ **کل کلاس حملات CSRF منتفی است** و API به محافظت CSRF برای JSON نیاز ندارد.
- ✅ با بستن تب پاک می‌شوند.
- ⚠️ در برابر XSS آسیب‌پذیرند — به‌همین دلیل CSP سخت‌گیرانه است، هیچ‌جا `dangerouslySetInnerHTML` استفاده نشده، و React به‌صورت پیش‌فرض خروجی را escape می‌کند.
جایگزین «کوکی `HttpOnly` + `SameSite=Strict`» در برابر XSS بهتر است ولی CSRF را برمی‌گرداند و برای SPA روی دامنه دیگر پیچیده می‌شود. برای این معماری (SPA و API هم‌مبدأ، بدون کوکی) انتخاب فعلی سازگارتر است. در یک سیستم با ریسک بالاتر، کوکی `HttpOnly` + محافظت CSRF گزینه درست‌تر است.
</div>
---
## ۳. مجوزدهی
```python
class Role(models.TextChoices):
ADMIN = "admin" # همه‌چیز + بازسازی ایندکس + PII
ANALYST = "analyst" # سرچ + PII
VIEWER = "viewer" # سرچ، PII ماسک‌شده
```
<div dir="rtl">
| قاعده | جای اعمال |
|---|---|
| **deny by default** — هر endpoint لاگین می‌خواهد مگر صریحاً باز باشد | `DEFAULT_PERMISSION_CLASSES = (IsAuthenticated,)` |
| ثبت‌نام همیشه `viewer` می‌دهد | `RegisterSerializer.create()` |
| بازسازی ایندکس فقط `admin` | `IsAdminRole` روی `ReindexView` |
| دیدن PII فقط `analyst`/`admin` | `User.can_view_pii` + `PIIMixin` |
`role` یک فیلد ساده است نه گروه جنگو. با سه نقش، این انتخاب باعث می‌شود مجوزها را بشود در یک نگاه خواند و در یک خط تست کرد — و payload توکن کوچک بماند.
</div>
---
## ۴. حریم خصوصی داده
### ۴.۱ ماسک‌کردن
```
joseph.holland@garverusa.com → jo************@garverusa.com
+19402058928 → +1940*****28
```
<div dir="rtl">
ماسک در لایه **سریالایز** انجام می‌شود، پس مقدار خام هیچ‌وقت از دیتابیس برای کاربر بی‌مجوز خارج نمی‌شود. تست `test_privacy.py` تضمین می‌کند بخش محلی ایمیل و میانه شماره واقعاً پنهان می‌مانند (و برای بخش محلی یک‌کاراکتری، کل آن پنهان می‌شود).
### ۴.۲ PII در Elasticsearch نیست
این تصمیم مهم‌تر از ماسک‌کردن است. اگر ایمیل ایندکس شود، هر کاربر لاگین‌کرده می‌تواند با سرچ حدسی (`q=john.smith@acme.com`) وجود یک آدرس را **تأیید** کند، بدون اینکه هیچ‌وقت آن را ببیند. این یک oracle است و ماسک‌کردن جلویش را نمی‌گیرد.
راه‌حل: `build_document()` اصلاً این فیلدها را نمی‌سازد و `test_document_never_carries_contact_details` آن را قفل کرده است.
### ۴.۳ لاگ‌ها
pipeline لاگ‌استش فیلدهای `password`، `authorization`، `emails` و `phone_numbers` را حذف می‌کند — یک شبکه ایمنی برای وقتی که کسی به‌اشتباه چیزی را لاگ کند. لاگ ممیزی سرچ، **کوئری و فیلترها** را ثبت می‌کند (که برای تحلیل رفتار لازم است) ولی هیچ داده تماسی را.
</div>
---
## ۵. اعتبارسنجی ورودی
<div dir="rtl">
| ورودی | محافظ |
|---|---|
| `q` | حداکثر ۲۰۰ کاراکتر |
| هر فیلتر | حداکثر ۲۰ مقدار، هر مقدار ۱۲۸ کاراکتر |
| `page_size` | حداکثر ۱۰۰ |
| `page × page_size` | حداکثر ۱۰٬۰۰۰ (رد می‌شود، نه اینکه بی‌صدا کوتاه شود) |
| بدنه درخواست | `DATA_UPLOAD_MAX_MEMORY_SIZE = 5MB`، `MAX_NUMBER_FIELDS = 1000` |
| `X-Request-ID` | حداکثر ۶۴ کاراکتر و فقط alphanumeric/`-`/`_`؛ وگرنه دور ریخته و تولید می‌شود |
| خط درخواست HTTP | `limit_request_line = 8190` در gunicorn |
**تزریق SQL:** همه کوئری‌ها از ORM جنگو می‌گذرند. تنها SQL خام پروژه دو `UPDATE` بدون پارامتر کاربر در `_refresh_skill_counters()` است.
**تزریق کوئری Elasticsearch:** ورودی کاربر همیشه به‌عنوان **مقدار** در ساختار کوئری قرار می‌گیرد، هیچ‌وقت به‌عنوان JSON الحاق‌شده. `query_string` (که نحو قابل سوءاستفاده دارد) استفاده نشده — فقط `multi_match` و `terms`.
</div>
---
## ۶. هدرها و سیاست‌ها
<div dir="rtl">
**Django (تنظیمات پروداکشن)**
</div>
```python
SECURE_HSTS_SECONDS = 31_536_000 # یک سال
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
X_FRAME_OPTIONS = "DENY"
SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = CSRF_COOKIE_SAMESITE = "Strict"
```
<div dir="rtl">
**nginx**
</div>
```nginx
Content-Security-Policy: default-src 'self'; script-src 'self';
style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self';
font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
server_tokens off;
```
<div dir="rtl">
`'unsafe-inline'` فقط برای `style-src` باقی مانده (React برای چند استایل inline در JSX از آن استفاده می‌کند). `script-src` کاملاً `'self'` است، یعنی هیچ اسکریپت inline یا خارجی اجرا نمی‌شود.
**CORS:** چون SPA و API هم‌مبدأ هستند، در پروداکشن `CORS_ALLOW_ALL_ORIGINS = False` و لیست مبدأ خالی می‌ماند. CORS فقط برای حالت توسعه (`npm run dev` روی پورت ۵۱۷۳) لازم است.
</div>
---
## ۷. کانتینرها و شبکه
<div dir="rtl">
| اقدام | جزئیات |
|---|---|
| بیلد چندمرحله‌ای | کامپایلر و ابزار بیلد در ایمیج نهایی نیستند |
| کاربر غیر‌root | `app` (uid 10001) در بک‌اند، `nginx` در فرانت‌اند |
| `no-new-privileges` | روی هر دو سرویس اپلیکیشن |
| فایل‌سیستم read-only | فرانت‌اند، با tmpfs برای `/tmp`، `/var/run`، `/var/cache/nginx` |
| بدون shell در مسیر حساس | `useradd --shell /usr/sbin/nologin` |
| healthcheck | هر سرویس؛ `depends_on: condition: service_healthy` |
| سقف لاگ | `max-size: 10m`, `max-file: 3` ⇒ لاگ دیسک را پر نمی‌کند |
**توپولوژی شبکه**
</div>
```
┌─ frontend-net ──────────────┐ ┌─ backend-net (internal: true) ─────────┐
│ frontend ⟷ backend │ │ postgres redis elasticsearch │
│ ▲ │ │ logstash backend │
└─────┼───────────────────────┘ └────────────────────────────────────────┘
│ :8080 منتشرشده ▲ هیچ مسیری به میزبان ندارد
میزبان │
┌─ elk-net ─────────────┐
│ elasticsearch │
│ logstash kibana │
└───────────────────────┘
▲ kibana فقط روی 127.0.0.1:5601
```
<div dir="rtl">
`backend-net` با `internal: true` تعریف شده: کانتینرهای روی آن هیچ مسیری به بیرون ندارند. Postgres و Redis هیچ پورتی منتشر نمی‌کنند. Kibana فقط به `127.0.0.1` بایند شده، نه `0.0.0.0`.
</div>
---
## ۸. مدیریت اسرار
<div dir="rtl">
- هیچ رمزی در کد نیست؛ همه از متغیر محیطی خوانده می‌شوند.
- `.env` در `.gitignore` و `.dockerignore` است؛ فقط `.env.example` نگهداری می‌شود.
- `docker-compose.yml` برای متغیرهای حیاتی از نگارش «اجباری» استفاده می‌کند:
`${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}` — بدون آن، استک اصلاً بالا نمی‌آید.
- `settings/prod.py` روی کلید کوتاه‌تر از ۵۰ کاراکتر یا `DEBUG=True` **کرش می‌کند**.
- hook `detect-private-key` در pre-commit جلوی کامیت کلید خصوصی را می‌گیرد.
</div>
---
## ۹. Trade-offهای پذیرفته‌شده
<div dir="rtl">
### ۹.۱ احراز هویت Elasticsearch در حالت پیش‌فرض خاموش است
**واقعیت:** در `docker-compose.yml` مقدار `xpack.security.enabled: "false"` است.
**چرا:** روشن کردنش نیازمند مدیریت گواهی، تنظیم رمز کاربر `kibana_system` و یک کانتینر setup است. برای پروژه‌ای که هدفش «با یک دستور اجرا شود» است، این چهار نقطه شکست اضافه می‌کند — و رایج‌ترین دلیلی است که یک تسک آزمایشی روی ماشین ارزیاب بالا نمی‌آید.
**چه چیزی جبرانش می‌کند:** کلاستر روی شبکه `internal` داکر است و هیچ پورتی منتشر نمی‌کند. از خارج کانتینرها به‌هیچ‌وجه در دسترس نیست.
**راه درست برای پروداکشن:** overlay آماده است —
</div>
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
<div dir="rtl">
که `xpack.security.enabled=true` می‌کند، به هر سه کلاینت (بک‌اند، Logstash، Kibana) اعتبارنامه می‌دهد، پورت Kibana را برمی‌دارد و محدودیت منابع اعمال می‌کند.
### ۹.۲ TLS بین کانتینرها نیست
ترافیک داخلی شبکه داکر رمز نشده است. در یک استقرار واقعی این کار به service mesh یا load balancer داخلی سپرده می‌شود، نه به هر سرویس به‌صورت جداگانه. HTTPS در **لبه** (nginx یا load balancer بالادست) الزامی است و `SECURE_SSL_REDIRECT` + HSTS در تنظیمات پروداکشن آن را تحمیل می‌کنند.
### ۹.۳ دیتاست داخل ایمیج است
فایل CSV در ایمیج بک‌اند کپی می‌شود تا `docker compose up` بدون هیچ مرحله دستی داده داشته باشد. در پروداکشن، داده باید از منبع خارجی (S3، دیتابیس مبدأ) بیاید و ایمیج فقط کد باشد. برای یک تسک ارزیابی، سادگی اجرا مهم‌تر است — و `SEED_DATASET=false` در overlay پروداکشن این رفتار را خاموش می‌کند.
### ۹.۴ رمزهای نمونه در `.env.example`
رمزهای کاربران دمو در فایل نمونه هستند تا ارزیاب بتواند بلافاصله لاگین کند. این فایل **نمونه** است، نه `.env` واقعی، و README صریحاً هشدار می‌دهد که قبل از هر استقرار عوض شوند.
</div>
---
## ۱۰. امنیت در CI
```yaml
- bandit -q -c pyproject.toml -r apps config # تحلیل ایستای امنیتی پایتون
- pip-audit -r requirements/base.txt # CVE در وابستگی‌های پایتون
- npm audit --omit=dev # CVE در وابستگی‌های فرانت‌اند
- python manage.py check --deploy # چک‌لیست استقرار خود جنگو
```
<div dir="rtl">
ruff هم قواعد `S` (bandit) را در lint معمولی اجرا می‌کند، پس مشکلات رایج قبل از commit گرفته می‌شوند.
</div>
---
## ۱۱. اگر فردا مسئولش می‌شدم
<div dir="rtl">
فهرست کارهایی که برای یک استقرار واقعی لازم‌اند و اینجا انجام نشده‌اند — چون صادق بودن درباره‌شان بهتر از وانمود کردن است:
1. احراز هویت Elasticsearch را روشن و TLS داخلی را اضافه کن (overlay آماده است).
2. اسرار را به یک مدیر راز (Vault / AWS Secrets Manager) منتقل کن؛ متغیر محیطی برای اسرار بلندمدت کافی نیست.
3. احراز هویت دومرحله‌ای برای نقش `admin`.
4. قفل حساب بعد از N تلاش ناموفق (الان فقط rate limit روی IP هست).
5. سیاست نگهداری داده: حذف خودکار لاگ‌های قدیمی‌تر از ۳۰ روز با ILM.
6. اسکن ایمیج‌ها (Trivy) در CI.
7. امضای ایمیج و SBOM.
8. هشدار روی الگوهای مشکوک: یک کاربر که در یک ساعت ۱۰٬۰۰۰ پروفایل باز می‌کند (داده‌اش در لاگ ممیزی هست، فقط قاعده هشدار لازم است).
</div>
+1055
View File
File diff suppressed because it is too large Load Diff