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:
+412
@@ -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\"}"
|
||||
```
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user