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