feat(stack): full overhaul — hardening, ops tooling, monitoring, analytics, chatbot

Security: deep /api/health (DB+MinIO probes, 503 on failure) + /api/live;
rate-limited public forms with honeypots; real client IP behind Traefik
(--proxy-headers); TipTap URL sanitization on write (nh3); non-root
containers + slim .dockerignore; prod MinIO console bound to 127.0.0.1;
HSTS/nosniff on TLS routers; JWT 8h; no default credentials in code.

Reliability: Alembic migrations (create_all baseline); graceful shutdown
(exec uvicorn); hardened backups (checksums, sanity check, freshness gauge,
optional rclone off-site) + restore.sh + hourly db-backup sidecar (ops
profile); stack.sh ops tooling (deploy/logs/health/backup/restore/monitoring);
.gitattributes LF for shell scripts.

Orchestration: json-file log rotation, pinned images, per-service memory
limits, edge/internal networks, Traefik ping/accesslog/metrics + flood
limiter, conditional minio-setup, pgvector db image, one-shot db-init
(umami + aramesh_chatbot databases).

Chatbot: unified from site-chatbot branch (RAG + pgvector, SSE, ChatWidget).

Monitoring: Prometheus/Alertmanager/Loki/Promtail/Grafana + cAdvisor,
node/postgres/blackbox exporters, provisioned dashboards and 10 alert
rules; alerts delivered to Bale (tapi.bale.ai — Telegram-compatible).

Analytics: Umami on shared Postgres, build-time tracker injection, custom
events (contact/newsletter/phone/chat/lang) via frontend/src/analytics.ts.

Hygiene: CSV export for contact messages and subscribers; upload image
optimization (Pillow); CI script + GitHub-compatible workflow; audit.sh;
gen-env.sh for strong credentials.
This commit is contained in:
MMD
2026-09-19 00:24:57 +03:30
parent 805f4594d4
commit ce0735d775
75 changed files with 4287 additions and 153 deletions
+34
View File
@@ -1,4 +1,5 @@
# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید. # کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
# راه ساده‌تر: bash scripts/gen-env.sh — همهٔ رمزها را تصادفی و قوی می‌سازد.
# docker compose به‌طور خودکار این فایل را می‌خواند؛ بدون آن استک بالا نمی‌آید. # docker compose به‌طور خودکار این فایل را می‌خواند؛ بدون آن استک بالا نمی‌آید.
# --- رمزهای اصلی (اجباری) --- # --- رمزهای اصلی (اجباری) ---
@@ -14,10 +15,43 @@ ADMIN_PASSWORD=change-me-admin-password
# پورت ورود Traefik در توسعه (پیش‌فرض 5173) # پورت ورود Traefik در توسعه (پیش‌فرض 5173)
TRAEFIK_HTTP_PORT=5173 TRAEFIK_HTTP_PORT=5173
# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example # نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
# در تولید این مقدار هم پایهٔ پروب بیرون‌نگر مانیتورینگ است.
SITE_URL= SITE_URL=
# آدرس‌های مجاز CORS (لیست JSON) — پیش‌فرض فقط لوکال‌هاست # آدرس‌های مجاز CORS (لیست JSON) — پیش‌فرض فقط لوکال‌هاست
# CORS_ORIGINS=["http://localhost:5173"] # CORS_ORIGINS=["http://localhost:5173"]
# --- بیلد فرانت‌اند (در زمان npm run build روی هاست خوانده می‌شود — stack.sh پاس می‌دهد) ---
# اگر API روی مبدأ دیگری است (مثل توسعه ویندوز با پورت 19001) تنظیم کنید؛ هم‌مبدأ یعنی خالی
VITE_API_URL=
# اسکریپت ردیاب Umami — خالی یعنی آنالیتیکس خاموش؛ در تولید مثل:
# VITE_UMAMI_SRC=https://analytics.aramesh.example/script.js
VITE_UMAMI_SRC=
# شناسهٔ وب‌سایت از داشبورد Umami (اولین بار: وب‌سایت را در analytics.localhost بسازید)
VITE_UMAMI_WEBSITE_ID=
# --- چت‌بات (دستیار هوشمند) ---
# LLM سازگار با OpenAI — اجباری برای پاسخ‌گویی چت‌بات؛ خالی یعنی سرویس بالا می‌آید اما پاسخ نمی‌دهد
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=openai/gpt-oss-120b
# توکن محافظت از POST /chat/reindex — خالی یعنی فقط از شبکه داخلی
CHAT_REINDEX_TOKEN=
# --- مانیتورینگ (docker-compose.monitoring.yml) ---
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=change-me-grafana-password
# هشدارها به بله — توکن بات (همان شکل تلگرام؛ از BotFather بله) و شناسه چت/گروه
# شناسه چت: به بات یک پیام بدهید سپس:
# curl "https://tapi.bale.ai/bot<توکن>/getUpdates"
ALERT_BOT_TOKEN=
ALERT_CHAT_ID=0
# --- عملیات ---
# نگهداری پشتیبان‌های شبانه (روز) — scripts/backup.sh
RETENTION_DAYS=14
# انتشار پشتیبان خارج از سرور (rclone remote مثل storage:aramesh-backups) — خالی یعنی خاموش
OFFSITE_RCLONE_REMOTE=
# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) --- # --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
# DOMAIN=aramesh.example # DOMAIN=aramesh.example
# ACME_EMAIL=admin@aramesh.example # ACME_EMAIL=admin@aramesh.example
+7
View File
@@ -0,0 +1,7 @@
# اسکریپت‌های شل باید در لینوکس هم LF باشند — CRLF در bash می‌شکند
*.sh text eol=lf
*.yml text
*.yaml text
*.json text
Dockerfile text
*.py text
+47
View File
@@ -0,0 +1,47 @@
name: CI
# گیت سرور self-hosted شما (Gitea Actions / Forgejo) همین سینتکس را اجرا می‌کند؛
# روی GitHub هم بی‌تغییر کار می‌کند.
on:
push:
branches: [main]
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install backend deps
run: pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run backend tests
working-directory: backend
run: pytest -q
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Frontend typecheck/build
working-directory: frontend
run: |
npm ci
npm run build
images:
runs-on: ubuntu-latest
needs: tests
steps:
- uses: actions/checkout@v4
- name: Build images
run: docker compose -f docker-compose.yml build backend frontend chatbot
+7
View File
@@ -9,6 +9,12 @@ env/
# Environment # Environment
.env .env
# مانیتورینگ — کانفیگ رندرشدهٔ alertmanager (حاوی توکن بله است)
monitoring/alertmanager/alertmanager.rendered.yml
# تنظیمات محلی هر ماشین
docker-compose.override.yml
# پشتیبان‌گیری محلی # پشتیبان‌گیری محلی
backups/ backups/
@@ -22,6 +28,7 @@ dist/
.vscode/ .vscode/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.zcode/
# Database # Database
*.sqlite3 *.sqlite3
+162 -69
View File
@@ -3,15 +3,20 @@
مونوریپوی سایت گروه ساختمانی آرامش شامل: مونوریپوی سایت گروه ساختمانی آرامش شامل:
- **`frontend/`** — سایت React + Vite + Tailwind (تمام متن‌ها و تصاویر از API خوانده می‌شود) - **`frontend/`** — سایت React + Vite + Tailwind (تمام متن‌ها و تصاویر از API خوانده می‌شود)
- **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO - **`backend/`** — بک‌اند FastAPI + SQLAlchemy 2 + PostgreSQL (pgvector) + JWT + MinIO
- **`chatbot/`** — دستیار هوشمند (FastAPI + LangGraph + RAG با pgvector، پاسخ استریمی)
- **مانیتورینگ و آنالیتیکس** — Prometheus/Grafana/Loki و Umami به‌صورت لایه‌های compose
## معماری ## معماری
``` ```
Traefik (پورت ۵۱۷۳ — تنها نقطه ورود) Traefik (تنها نقطه ورود — 80/443 در تولید)
├── / → Caddy (فایل‌های استاتیک SPA) ├── / → Caddy (فایل‌های استاتیک SPA)
├── /api → FastAPI (بک‌اند) ├── /api → FastAPI (بک‌اند) + متریک /metrics فقط داخلی
└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی) ├── /media → MinIO (باکت aramesh-media)
├── /chat → چت‌بات (SSE استریم پاسخ)
├── grafana.* → Grafana (مانیتورینگ — لایه monitoring)
└── analytics.* → Umami (آنالیتیکس محصول)
``` ```
- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانت‌اند هاردکد نشده؛ - **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانت‌اند هاردکد نشده؛
@@ -19,107 +24,178 @@ Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره می‌شوند و با مسیر نسبی - **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره می‌شوند و با مسیر نسبی
`/media/...` برگردانده می‌شوند — بک‌اند ذخیره‌سازی بدون تغییر محتوای دیتابیس قابل تعویض است `/media/...` برگردانده می‌شوند — بک‌اند ذخیره‌سازی بدون تغییر محتوای دیتابیس قابل تعویض است
- مسیریابی همه‌چیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد - مسیریابی همه‌چیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
- شبکه‌های compose تفکیک شده‌اند: `edge` (سرویس‌های پشت Traefik) و `internal`
(دیتابیس/ذخیره‌سازی) — فرانت‌اند هیچ راه مستقیمی به دیتابیس ندارد
- **لاگ‌ها**: json-file با سقف ۱۰MB×۳ برای هر سرویس؛ علاوه بر آن Promtail همه را به
Loki می‌فرستد تا در Grafana جست‌وجو شوند
## ساختار ## ساختار
``` ```
├── frontend/ # سایت React ├── frontend/ # سایت React (بیلد روی هاست — ایمیج فقط سرو می‌کند)
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بک‌اند) │ ├── src/ # کد منبع (api.tsx = لایه اتصال به بک‌اند)
│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy │ ├── Caddyfile # سرو استاتیک + کش immutable برای /assets/*
│ └── Caddyfile # فقط سرو فایل‌های استاتیک (مسیریابی روی Traefik است) │ └── Dockerfile # Caddy — بدون root
├── backend/ # بک‌اند FastAPI ├── backend/ # بک‌اند FastAPI
│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py) │ ├── app/ # کد منبع (آپلود: app/api/admin.py — سئو: app/seo_pages.py)
│ ├── migrations/ # مهاجرت‌های نسخه‌دار Alembic
│ ├── tests/ # تست‌های API (pytest) │ ├── tests/ # تست‌های API (pytest)
│ ├── requirements.txt │ ├── alembic.ini
│ ├── Dockerfile │ └── Dockerfile # بدون root — خاموشی تمیز با exec
│ └── .env.example ├── chatbot/ # دستیار هوشمند (RAG + pgvector)
├── scripts/backup.sh # پشتیبان‌گیری دیتابیس و تصاویر ├── monitoring/ # پیکربندی Prometheus/Alertmanager/Loki/Promtail/Grafana
├── docker-compose.yml # اجرای کامل با یک دستور ├── scripts/
└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt │ ├── stack.sh # ابزار اصلی عملیات — deploy/logs/health/backup/restore/update
│ ├── backup.sh # پشتیبان کامل (DB + تصاویر) با چک‌سام و گزینه انتشار بیرونی
│ ├── restore.sh # بازگردانی پشتیبان دیتابیس
│ ├── gen-env.sh # ساخت .env با رمزهای قوی تصادفی
│ ├── audit.sh # ممیزی آسیب‌پذیری وابستگی‌ها و ایمیج‌ها
│ ├── migrate-media.py # انتقال یک‌باره تصاویر CDN خارجی به MinIO
│ └── ci.sh # تست + بیلد ایمیج (محلی یا هر CI)
├── docker-compose.yml # استک اصلی (شامل چت‌بات و Umami)
├── docker-compose.monitoring.yml # لایه مانیتورینگ
├── docker-compose.prod.yml # لایه تولید: TLS، دامنه، سخت‌سازی پورت‌ها
└── docker-compose.override.yml # تنظیمات محلی این ماشین (در گیت نیست)
``` ```
## اجرا با Docker (ساده‌ترین راه) ## اجرا با Docker
پیش‌نیاز: Docker Desktop. بیلد فرانت‌اند روی هاست انجام می‌شود (کانتینر فقط فایل‌های پیش‌نیاز: Docker Desktop. بیلد فرانت‌اند روی هاست انجام می‌شود (کانتینر فقط فایل‌های
`dist/` را سرو می‌کند)، پس اولین بار باید یک‌بار `npm run build` بزنید: `dist/` را سرو می‌کند) — `scripts/stack.sh` همین را به یک دستور تبدیل می‌کند:
```bash ```bash
cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمی‌آید cp .env.example .env # یا: bash scripts/gen-env.sh (رمزهای قوی تصادفی)
cd frontend && npm install && npm run build && cd .. cd frontend && npm install && cd ..
docker compose up -d --build bash scripts/stack.sh deploy # بیلد فرانت + بیلد و بالا آوردن استک + انتظار برای سلامت
``` ```
سایت: http://localhost:5173 — ورود مدیریت: `/admin/login` (کاربر/رمز از `ADMIN_*` در `.env`) - سایت: http://localhost:18081 (پورت از `TRAEFIK_HTTP_PORT` در `.env`) — ورود مدیریت: `/admin/login`
- دستورهای عملیات: `bash scripts/stack.sh` — `logs`، `ps`، `health`، `backup`، `restore`، `update`، `seed-force`
- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی) - مستندات API: http://localhost:19001/docs (با `docker-compose.override.yml` محلی)
- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env` - کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در `.env`
- دیتابیس PostgreSQL و آبجکت‌استور MinIO هر دو در کانتینر جدا با volume دائمی - دیتابیس PostgreSQL (تصویر pgvector)، آبجکت‌استور MinIO و کش مدل‌های چت‌بات هر یک
- بک‌اند هنگام شروع، خودش جداول را می‌سازد و **فقط جدول‌های خالی** را seed می‌کند — در کانتینر جدا با volume دائمی
ارتقای نسخه داده‌های فعلی را از بین نمی‌برد - بک‌اند هنگام شروع، جداول پایه را می‌سازد، **مهاجرت‌های Alembic** را اعمال می‌کند و
- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را می‌سازد، خواندن عمومی را **فقط جدول‌های خالی** را seed می‌کند — ارتقای نسخه داده‌های فعلی را از بین نمی‌برد
فعال می‌کند و تصاویر داخل `seed-media/` را در باکت می‌ریزد — پس یک کلون تازه هم - `minio-setup` باکت `aramesh-media` را می‌سازد و فقط **وقتی باکت خالی است** تصاویر
همان تصاویر سایت را دارد `seed-media/` را می‌ریزد — `db-init` هم دیتابیس‌های `umami` و `aramesh_chatbot` را
روی همان نمونهٔ Postgres می‌سازد
> نکته برای تازه‌واردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛ > نکته برای تازه‌واردها: هیچ فایل رمزدار در گیت نیست. فقط `.env.example` را کپی کنید؛
> فایل‌های `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند. > فایل‌های `.env`/`env` در `.gitignore` هستند و هرگز commit نشوند.
>
> پایهٔ API فرانت‌اند در زمان بیلد از `VITE_API_URL` در `.env` خوانده می‌شود —
> در توسعه ویندوزیِ این ماشین `VITE_API_URL=http://localhost:19001` بگذارید.
## انتشار واقعی (TLS) ## انتشار واقعی (TLS)
برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید: پیش‌نیاز: رکوردهای DNS (`example.com`، `analytics.example.com` و اگر مانیتورینگ فعال
است `grafana.example.com`) به این سرور، سپس در `.env`: `DOMAIN`، `ACME_EMAIL` و
`SITE_URL=https://example.com`:
```bash ```bash
# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com bash scripts/stack.sh deploy-prod
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
``` ```
- Traefik گواهی Let's Encrypt می‌گیرد و تمدید می‌کند؛ همه درخواست‌های HTTP به HTTPS هدایت می‌شوند - Traefik گواهی Let's Encrypt می‌گیرد و تمدید می‌کند؛ همه درخواست‌های HTTP به HTTPS هدایت می‌شوند
- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرس‌های OG مطلق شوند - **پورت‌های مدیریتی MinIO (۹۰۰۱ کنسول و ۱۹۰۰۰ API) در تولید فقط روی `127.0.0.1`
بسته می‌شوند** — دسترسی از راه دور فقط با تونل SSH:
`ssh -L 9001:127.0.0.1:9001 server`
- همه روترهای websecure هدرهای امنیتی می‌گیرند (HSTS + nosniff)
## اجرای محلی (توسعه) ### چک‌لیست رفتن به تولید
### بک‌اند 1. `bash scripts/gen-env.sh` — رمزهای قوی جدید (رمزهای `.env` توسعه را کپی نکنید)
2. DNS: رکوردهای دامنه و زیردامنه‌ها + باز بودن پورت‌های ۸۰/۴۴۳ در فایروال
3. `DOMAIN`، `ACME_EMAIL`، `SITE_URL` را در `.env` تنظیم کنید
4. اولین انتشار را روی یک زیردامنه آزمایشی انجام دهید (نقش staging) — `deploy-prod`
5. `python scripts/migrate-media.py` — انتقال یک‌باره تصاویر CDN خارجی به MinIO
6. پشتیبان‌گیری خودکار: `docker compose --profile ops up -d db-backup` (پشتیبان ساعتی)
+ `scripts/backup.sh` شبانه با cron/Task Scheduler
7. یک تمرین بازگردانی: `bash scripts/restore.sh backups/db-<زمان>.sql.gz` روی محیط آزمایشی
8. مانیتورینگ (بخش پایین) و سپس چت‌بات/آنالیتیکس را با متغیرهای LLM و Umami فعال کنید
## مانیتورینگ (Prometheus + Grafana + Loki)
```bash ```bash
cd backend bash scripts/stack.sh monitoring
python -m venv .venv # در تولید (سه فایل با هم):
.venv\Scripts\activate # ویندوز — در لینوکس: source .venv/bin/activate docker compose -f docker-compose.yml -f docker-compose.monitoring.yml -f docker-compose.prod.yml up -d
pip install -r requirements.txt
# فایل .env از روی .env.example بسازید و DATABASE_URL را تنظیم کنید
python -m app.seed # ساخت جداول + محتوای اولیه
uvicorn app.main:app --reload
``` ```
### فرانت‌اند - داشبورد: `grafana.localhost` (توسعه) یا `grafana.example.com` (تولید — برچسب‌های
روتر آن را در `docker-compose.override.yml` سرور بگذارید؛ نمونه در انتهای
`docker-compose.prod.yml`) — کاربر/رمز از `GRAFANA_ADMIN_*` در `.env`
- دو داشبورد آمادهٔ «زیرساخت» و «اپلیکیشن» به‌صورت code-provisioned سوار می‌شوند
- **هشدارها به بله**: در `.env` مقدار `ALERT_BOT_TOKEN` (بات بله — همان شکل API تلگرام،
از BotFather بله) و `ALERT_CHAT_ID` را بگذارید (شناسه چت: به بات یک پیام بدهید سپس
`curl "https://tapi.bale.ai/bot<توکن>/getUpdates"`) — قواعد آماده در
`monitoring/prometheus/alerts.yml`:
پایین‌بودن هر سرویس، شکست بررسی عمق سلامت، پرشدن دیسک، نرخ خطای 5xx، انقضای گواهی TLS،
**کهنه‌شدن پشتیبان** (سنجهٔ `backup_success_time` از `scripts/backup.sh`) و...
- **لاگ‌ها در Grafana**: Promtail لاگ همهٔ کانتینرهای استک را به Loki می‌فرستد —
برای خطاهای بک‌اند از داشبورد «اپلیکیشن» پنل لاگ را ببینید
- **سلامت واقعی**: `/api/health` اتصال DB و MinIO را واقعاً می‌آزماید (503 در خرابی)؛
پروب blackbox هم همین را از بیرون و نشانی `SITE_URL` را عمومی چک می‌کند
- مصرف منابع مجموع لایه حدود ۱ تا ۱.۵ گیگابایت RAM است؛ حذف هر سرویس بقیه را نمی‌شکند
```bash ## آنالیتیکس محصول (Umami)
cd frontend
npm install
npm run dev
```
در حالت توسعه، Vite درخواست‌های `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی می‌کند؛ استک شامل سرویس Umami است؛ داشبورد: `analytics.localhost` (توسعه) یا
پس استک Docker باید در حال اجرا باشد. آدرس بک‌اند در `frontend/src/config.ts` از `analytics.example.com` (تولید). بدون کوکی — بدون نیاز به بنر رضایت.
`VITE_API_URL` خوانده می‌شود — پیش‌فرض خالی یعنی درخواست‌های هم‌مبدأ.
راه‌اندازی (یک‌بار):
1. استک را بالا بیاورید و به `analytics.localhost` وارد شوید (پیش‌فرض `admin`/`umami`
— بلافاصله رمز را عوض کنید)
2. یک وب‌سایت با دامنهٔ سایت بسازید؛ «شناسه وب‌سایت» را در `.env` بگذارید:
`VITE_UMAMI_WEBSITE_ID=...` و آدرس اسکریپت: `VITE_UMAMI_SRC=https://analytics.example.com/script.js`
3. `bash scripts/stack.sh deploy` — اسکریپت ردیاب در `index.html` تزریق می‌شود
رخدادهای سفارشی که خودکار ارسال می‌شوند: ارسال فرم تماس (`contact-submit`)، عضویت
خبرنامه (`newsletter-signup`)، کلیک تلفن (`phone-click`)، باز کردن چت‌بات
(`chat-open`)، پرسش از چت‌بات (`chat-question`)، تغییر زبان (`lang-switch`) —
سرچشمه: `frontend/src/analytics.ts`.
## چت‌بات (دستیار هوشمند)
- RAG روی محتوای همان دیتابیس سایت: امبدینگ MiniLM چندزبانه + BM25 + رنکر — بردارها
در دیتابیس جدا `aramesh_chatbot` (pgvector) روی همان نمونهٔ Postgres
- مسیرها: `GET /chat/healthz`، `GET /chat/suggestions`، `GET /chat/stream` (SSE) —
رابط کاربری: ویجت شناور پایین صفحه (`ChatWidget.tsx`)
- راه‌اندازی: `LLM_BASE_URL`/`LLM_API_KEY`/`LLM_MODEL` در `.env` (هر LLM سازگار با
OpenAI) — بدون آن سرویس بالا می‌آید اما پاسخ نمی‌دهد
- اولین اجرا مدل‌ها را دانلود می‌کند (~۵۰۰MB در حجم `chatbot_hf`) — کند است، طبیعی است
- ایندکس هر ۱۰ دقیقه خودش با تغییرهای محتوا همگام می‌شود؛ همگام‌سازی فوری با
`POST /chat/reindex` و هدر `x-reindex-token` (از `CHAT_REINDEX_TOKEN`)
## امکانات بک‌اند ## امکانات بک‌اند
- **API عمومی**: برند، منو، هیرو، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم، - **API عمومی**: برند، منو، هیرو، خدمات، پروژه‌ها، مقالات، نظرات، سوالات متداول، تیم،
تایم‌لاین، آمار، دفاتر، مدل‌های سرمایه‌گذاری و... — همه در دیتابیس تایم‌لاین، آمار، دفاتر، مدل‌های سرمایه‌گذاری و... — همه در دیتابیس
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` - **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter` — هر دو با
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیش‌فرض: `admin` / `aramesh123`) محدودسازی نرخ (تماس: ۵ در ۱۵ دقیقه، خبرنامه: ۳ در ساعت برای هر IP) + دامنهٔ پنهان
و سپس CRUD کامل همه محتواها در `/api/admin/...` (honeypot) ضدربات + میان‌افزار محدودسازی سیلاب روی Traefik
- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (مقادیر از `ADMIN_*` در `.env` —
هیچ رمز پیش‌فرضی در کد وجود ندارد) و سپس CRUD کامل همه محتواها در `/api/admin/...`
- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است - **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد می‌شود - **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد می‌شود
(IP واقعی کلاینت با `--proxy-headers` از پشت Traefik درست خوانده می‌شود)
- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO - **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
ذخیره و URL نسبی `/media/...` برمی‌گردد ذخیره و URL نسبی `/media/...` برمی‌گردد؛ در همان لحظه تا ۲۰۰۰ پیکسل کوچک و دوباره
فشرده می‌شود (Pillow) تا سرعت صفحه حفظ شود
- **خروجی CSV** از نوار مدیریت: پیام‌های تماس و مشترکان خبرنامه (با BOM برای Excel)
- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند - **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسال‌شده عوض می‌شوند
- **ترتیب‌بندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}` - **ترتیب‌بندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
- همه محتوا در یک درخواست: `GET /api/site` (مقاله‌های پیش‌نویس در آن نمی‌آیند) - همه محتوا در یک درخواست: `GET /api/site` (مقاله‌های پیش‌نویس در آن نمی‌آیند)
- **تست‌ها**: `cd backend && pip install -r requirements-dev.txt && pytest tests` - **مهاجرت‌های اسکیما**: Alembic (`backend/migrations/`) — هر تغییر اسکیمای جدید باید
revision شود: `cd backend && alembic revision --autogenerate -m "..."` و پس از بازبینی
commit؛ در استارت کانتینر خودکار `upgrade head` اجرا می‌شود
- **تست‌ها**: `cd backend && .venv/Scripts/activate && pytest tests` (۲۴ تست — شامل
honeypot، محدودسازی نرخ و پاک‌سازی)
> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید > در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را قوی
> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید. > بگیرید (`bash scripts/gen-env.sh`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
## ویرایش زنده سایت (دوقلوی دیجیتال) ## ویرایش زنده سایت (دوقلوی دیجیتال)
@@ -157,7 +233,9 @@ npm run dev
**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام. **فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل می‌کند، پس فقط تگ‌های شناخته‌شده صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل می‌کند، پس فقط تگ‌های شناخته‌شده
(پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف) (پاراگراف، تیتر، لیست، نقل‌قول، تصویر، لینک) تولید می‌شوند. مقالات قدیمی (لیست پاراگراف)
در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند. در اولین اجرا به‌صورت خودکار به این فرمت تبدیل می‌شوند. در زمان ذخیره، آدرس‌های
`href/src` سند در سمت سرور پاک‌سازی می‌شوند (`app/sanitize.py`) تا اسکیم‌های خطرناک
مثل `javascript:` هرگز ذخیره نشوند.
## سئو ## سئو
@@ -179,24 +257,39 @@ npm run dev
دیتابیس داخل آن تزریق می‌کند (`app/seo_pages.py`) — بازدیدکننده‌های عادی همان SPA دیتابیس داخل آن تزریق می‌کند (`app/seo_pages.py`) — بازدیدکننده‌های عادی همان SPA
همیشگی را می‌بینند همیشگی را می‌بینند
## پشتیبان‌گیری ## پشتیبان‌گیری و بازگردانی
همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید: همه محتوای سایت در PostgreSQL و تصاویر در MinIO است:
```bash ```bash
bash scripts/backup.sh # در backups/ — نگهداری پیش‌فرض ۱۴ روز bash scripts/stack.sh backup # پشتیبان کامل: DB + تصاویر MinIO
docker compose --profile ops up -d db-backup # پشتیبان ساعتی خودکار DB (بدون cron میزبان)
``` ```
- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت) - خروجی: `backups/db-<زمان>.sql.gz` (فشرده + فایل `.sha256`) و `backups/images-<زمان>/`
- زمان‌بندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست - اسکریپت پشتیبان، خروجی خالی/شکسته را خطا می‌گیرد، سنجهٔ تازگی برای هشدار Grafana
- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza` می‌نویسد و اگر `OFFSITE_RCLONE_REMOTE` در `.env` تنظیم باشد، پشتیبان را به حافظهٔ
دیگری هم کپی می‌کند (rclone)
- **بازگردانی**: `bash scripts/stack.sh restore backups/db-<زمان>.sql.gz`
(احتیاط: محتوای فعلی جایگزین می‌شود — قبل از انتشار واقعی یک‌بار تمرین کنید!)
- زمان‌بندی پشتیبان کامل با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
## تست و CI
- `bash scripts/ci.sh` — تست‌های pytest + بیلد ایمیج‌ها + ممیزی وابستگی‌ها
- `bash scripts/audit.sh` — pip-audit / npm audit / trivy (هر کدام نصب بود)
- `.github/workflows/ci.yml` — همین مراحل؛ سینتکس GitHub Actions روی Gitea Actions/
Forgejo هم اجرا می‌شود
## نکات امنیتی ## نکات امنیتی
- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml` - مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در compose با سینتکس
با سینتکس `${VAR:?}` اجباری شده‌اند — بدون `.env` استک بالا نمی‌آید `${VAR:?}` اجباری شده‌اند — بدون `.env` استک بالا نمی‌آید؛ هیچ رمز پیش‌فرضی در کد نیست
- رمز عبور ادمین با bcrypt هش می‌شود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است - رمز عبور ادمین با bcrypt هش می‌شود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
- ورود با محدودیت تلاش محافظت می‌شود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429 - توکن‌های JWT ۸ ساعت اعتبار دارند (stateless — تغییر `SECRET_KEY` همه را خارج می‌کند)
- توکن‌های JWT امضا دارند و مدت اعتبارشان قابل تنظیم است - همهٔ کانتینرها **بدون root** اجرا می‌شوند؛ کانتکست بیلد هر ایمیج با `.dockerignore`
باریک است و لاگ‌ها سقف دارند
- فرم‌های عمومی ضدربات‌اند (honeypot + محدودسازی نرخ در بک‌اند + سقف سیلاب در Traefik)
- کنسول MinIO و API مستقیمش در تولید فقط از `127.0.0.1` در دسترس‌اند (تونل SSH)
- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است - تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
- مقاله‌های پیش‌نویس در هیچ انتهای عمومی نمی‌آیند و در HTML تزریق‌شده سئو هم افشا نمی‌شوند - مقاله‌های پیش‌نویس در هیچ انتهای عمومی نمی‌آیند و در HTML تزریق‌شده سئو هم افشا نمی‌شوند
+13
View File
@@ -0,0 +1,13 @@
# کانتکست بیلد باریک — .venv و کش‌های تست محلی جایی در تصویر ندارند
.venv/
venv/
tests/
.pytest_cache/
__pycache__/
**/__pycache__/
*.pyc
README.md
.env
.env.*
Dockerfile
.dockerignore
+12 -3
View File
@@ -1,3 +1,4 @@
# بک‌اند FastAPI — تصویر باریک و بدون root
FROM python:3.13-slim FROM python:3.13-slim
WORKDIR /app WORKDIR /app
@@ -9,8 +10,16 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY app ./app
COPY alembic.ini ./
COPY migrations ./migrations
# کاربر بدون دسترسی root — برنامه چیزی روی دیسک محلی نمی‌نویسد
RUN useradd --system --create-home appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 8000 EXPOSE 8000
# ساخت جداول پایه، مهاجرت‌های Alembic و seed (بدون حذف داده‌های موجود) و سپس
# ساخت جداول و seed (بدون حذف داده‌های موجود) و سپس اجرای سرور # اجرای سرور با exec — تا SIGTERM (docker stop) مستقیم به uvicorn برسد و خاموشی
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000"] # تمیز انجام شود؛ --proxy-headers برای دیدن IP واقعی کلاینت پشت Traefik
CMD ["sh", "-c", "python -m app.db_init && alembic upgrade head && python -m app.seed && exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*'"]
+40
View File
@@ -0,0 +1,40 @@
[alembic]
script_location = migrations
prepend_sys_path = .
# آدرس دیتابیس از DATABASE_URL (محیط) یا تنظیمات برنامه خوانده می‌شود — env.py
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+79 -1
View File
@@ -1,10 +1,13 @@
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند.""" """انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
import csv
import io
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
import boto3 import boto3
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, status
from PIL import Image, ImageOps
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -263,6 +266,33 @@ ALLOWED_IMAGE_TYPES = {
"image/avif": "avif", "image/avif": "avif",
} }
# بهینه‌سازی در زمان آپلود — ابعاد بزرگ و فشرده‌سازی ضعیف، اصلی‌ترین عامل کندی سایت است
MAX_DIMENSION = 2000
REENCODE_TYPES = {"image/jpeg", "image/png", "image/webp"}
def _optimize_image(data: bytes, content_type: str) -> tuple[bytes, str, int, int]:
"""تصویر را به حداکثر ۲۰۰۰ پیکسل محدود و دوباره فشرده می‌کند؛
اگر خروجی بزرگ‌تر از اصلی شد، همان اصلی برگردد. (gif/avif دست‌نخورده)"""
img = Image.open(io.BytesIO(data))
img = ImageOps.exif_transpose(img)
orig_w, orig_h = img.size
if max(img.size) > MAX_DIMENSION:
img.thumbnail((MAX_DIMENSION, MAX_DIMENSION), Image.LANCZOS)
buf = io.BytesIO()
if content_type == "image/jpeg":
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
img.save(buf, "JPEG", quality=82, optimize=True, progressive=True)
elif content_type == "image/png":
img.save(buf, "PNG", optimize=True)
else:
img.save(buf, "WEBP", quality=82, method=4)
optimized = buf.getvalue()
if len(optimized) < len(data):
return optimized, content_type, *img.size
return data, content_type, orig_w, orig_h
@router.post("/upload") @router.post("/upload")
async def upload_media( async def upload_media(
@@ -287,6 +317,14 @@ async def upload_media(
detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است", detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
) )
width = height = None
if content_type in REENCODE_TYPES:
try:
data, content_type, width, height = _optimize_image(data, content_type)
except Exception:
# تصویر خراب/ناشناخته — همان داده اصلی ذخیره می‌شود
pass
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}" key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
@@ -307,9 +345,49 @@ async def upload_media(
"key": key, "key": key,
"size": len(data), "size": len(data),
"content_type": content_type, "content_type": content_type,
"width": width,
"height": height,
} }
# ---------- خروجی CSV (برای تیم فروش) ----------
def _csv_response(filename: str, header: list[str], rows: list[list]) -> Response:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
writer.writerows(rows)
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
return Response(
# BOM ابتدای فایل — تا Excel متن فارسی را درست باز کند
content="\ufeff" + buf.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}-{stamp}.csv"'},
)
@router.get("/messages/export.csv")
def export_messages(db: Session = Depends(get_db)):
rows = [
[m.id, m.name, m.phone, m.note, "yes" if m.is_read else "no", m.created_at.isoformat()]
for m in db.query(models.ContactMessage).order_by(models.ContactMessage.created_at.desc()).all()
]
return _csv_response(
"contact-messages", ["id", "name", "phone", "note", "is_read", "created_at"], rows
)
@router.get("/subscribers/export.csv")
def export_subscribers(db: Session = Depends(get_db)):
rows = [
[s.id, s.email, "yes" if s.is_active else "no", s.created_at.isoformat()]
for s in db.query(models.Subscriber).order_by(models.Subscriber.created_at.desc()).all()
]
return _csv_response(
"newsletter-subscribers", ["id", "email", "is_active", "created_at"], rows
)
# پیام‌های تماس # پیام‌های تماس
@router.get("/messages", response_model=list[ContactMessageOut]) @router.get("/messages", response_model=list[ContactMessageOut])
def list_messages(unread_only: bool = False, db: Session = Depends(get_db)): def list_messages(unread_only: bool = False, db: Session = Depends(get_db)):
+40 -5
View File
@@ -1,17 +1,48 @@
"""انتهای فرم‌ها — ثبت پیام تماس و عضویت در خبرنامه.""" """انتهای فرم‌ها — ثبت پیام تماس و عضویت در خبرنامه.
from fastapi import APIRouter, Depends هر فرم یک دامنهٔ پنهان (honeypot) دارد: ربات‌ها همهٔ فیلدها را پُر می‌کنند؛
در آن حالت پاسخ موفقِ قلابی برمی‌گردد و چیزی ذخیره نمی‌شود.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db
from .. import models from .. import models
from ..database import get_db
from ..rate_limit import contact_limiter, newsletter_limiter
from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut from ..schemas import ContactIn, ContactMessageOut, NewsletterIn, SubscriberOut
router = APIRouter() router = APIRouter()
def _fake_contact(payload: ContactIn) -> ContactMessageOut:
"""پاسخ موفق بدون ذخیره — تا ربات متوجه تله نشود."""
return ContactMessageOut(
id=0,
name=payload.name.strip(),
phone=payload.phone.strip(),
note="",
is_read=True,
created_at=datetime.now(timezone.utc),
)
def _fake_subscriber(payload: NewsletterIn) -> SubscriberOut:
return SubscriberOut(
id=0,
email=str(payload.email).lower(),
is_active=True,
created_at=datetime.now(timezone.utc),
)
@router.post("/contact", response_model=ContactMessageOut, status_code=201) @router.post("/contact", response_model=ContactMessageOut, status_code=201)
def submit_contact(payload: ContactIn, db: Session = Depends(get_db)): def submit_contact(payload: ContactIn, request: Request, db: Session = Depends(get_db)):
contact_limiter.hit(request)
if payload.website:
return _fake_contact(payload)
message = models.ContactMessage( message = models.ContactMessage(
name=payload.name.strip(), name=payload.name.strip(),
phone=payload.phone.strip(), phone=payload.phone.strip(),
@@ -24,7 +55,11 @@ def submit_contact(payload: ContactIn, db: Session = Depends(get_db)):
@router.post("/newsletter", response_model=SubscriberOut, status_code=201) @router.post("/newsletter", response_model=SubscriberOut, status_code=201)
def subscribe(payload: NewsletterIn, db: Session = Depends(get_db)): def subscribe(payload: NewsletterIn, request: Request, db: Session = Depends(get_db)):
newsletter_limiter.hit(request)
if payload.website:
return _fake_subscriber(payload)
email = str(payload.email).lower() email = str(payload.email).lower()
subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first() subscriber = db.query(models.Subscriber).filter(models.Subscriber.email == email).first()
+42 -2
View File
@@ -1,8 +1,13 @@
"""انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت.""" """انتهای عمومی — خواندن محتوای سایت بدون نیاز به احراز هویت."""
import boto3
from botocore.config import Config as BotoConfig
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..config import settings
from ..database import get_db from ..database import get_db
from .. import models from .. import models
from ..schemas import ( from ..schemas import (
@@ -22,9 +27,44 @@ from ..schemas import (
router = APIRouter() router = APIRouter()
@router.get("/live")
def live():
"""زنده‌بودن پروسه — بدون وابستگی؛ برای restart gate استفاده نمی‌شود."""
return {"status": "ok"}
@router.get("/health") @router.get("/health")
def health(): def health(db: Session = Depends(get_db)):
return {"status": "ok", "app": "Aramesh Building Group API"} """بررسی عمق سلامت — اتصال DB و MinIO واقعاً آزموده می‌شود؛
در صورت خرابی هر وابستگی، 503 با جزئیات برمی‌گردد."""
checks: dict[str, str] = {}
try:
db.execute(text("SELECT 1"))
checks["database"] = "ok"
except Exception:
checks["database"] = "error"
try:
s3 = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name="us-east-1",
config=BotoConfig(connect_timeout=2, read_timeout=2, retries={"max_attempts": 1}),
)
s3.head_bucket(Bucket=settings.s3_bucket)
checks["storage"] = "ok"
except Exception:
checks["storage"] = "error"
if any(state != "ok" for state in checks.values()):
return JSONResponse(
status_code=503,
content={"status": "degraded", "checks": checks},
)
return {"status": "ok", "app": "Aramesh Building Group API", "checks": checks}
@router.get("/brand", response_model=BrandOut) @router.get("/brand", response_model=BrandOut)
+2 -1
View File
@@ -13,7 +13,8 @@ class Settings(BaseSettings):
database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api" database_url: str = "postgresql+psycopg://postgres:postgres@localhost:5432/arameshplaza_api"
secret_key: str = "dev-secret-change-me-in-production" secret_key: str = "dev-secret-change-me-in-production"
algorithm: str = "HS256" algorithm: str = "HS256"
access_token_expire_minutes: int = 1440 # ۸ ساعت — توکن stateless است و امکان ابطال ندارد؛ عمر کوتاه‌تر ریسک نشت را کم می‌کند
access_token_expire_minutes: int = 480
cors_origins: list[str] = ["*"] cors_origins: list[str] = ["*"]
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str = "aramesh123" admin_password: str = "aramesh123"
+11
View File
@@ -0,0 +1,11 @@
"""ساخت جداول پایه (baseline) — پیش از مهاجرت‌های Alembic.
روی دیتابیس تازه، همهٔ جدول‌ها از مدل‌های فعلی ساخته می‌شوند؛ روی دیتابیس موجود
بی‌اثر است (create_all جدول‌های موجود را دست نمی‌زند). از این‌جا به بعد تغییرات
اسکیما فقط با revisionهای Alembic اعمال می‌شوند.
"""
from . import models # noqa: F401 — ثبت مدل‌ها
from .database import Base, engine
Base.metadata.create_all(bind=engine)
+12 -2
View File
@@ -1,15 +1,17 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول from . import models # noqa: F401 — ثبت مدل‌ها پیش از ساخت جداول
from .api import admin, forms, public from .api import admin, forms, public
from .config import settings from .config import settings
from .database import Base, engine from .database import Base, engine
from .migrations import run_all as run_migrations
from .seo_pages import router as seo_router from .seo_pages import router as seo_router
# ساخت جداول پایه — تغییرات بعدی اسکیما فقط با revisionهای Alembic اعمال می‌شوند
# (مigrations/versions). اجرای اینجا برای اجرای مستقیم uvicorn در محیط توسعه است؛
# در کانتینر، CMD همین کار را قبل از alembic upgrade head انجام می‌دهد.
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_migrations()
app = FastAPI( app = FastAPI(
title="Aramesh Building Group API", title="Aramesh Building Group API",
@@ -29,6 +31,8 @@ app.add_middleware(
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# پاسخ‌های JSON /api — فشرده‌سازی وقتی بدنه قابل‌توجه است
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.include_router(public.router, prefix="/api", tags=["عمومی"]) app.include_router(public.router, prefix="/api", tags=["عمومی"])
app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"]) app.include_router(forms.router, prefix="/api", tags=["فرم‌ها"])
@@ -37,6 +41,12 @@ app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت
# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه # robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
app.include_router(seo_router, tags=["سئو"]) app.include_router(seo_router, tags=["سئو"])
# متریک‌های Prometheus — فقط Prometheus در شبکه داخلی اسکرپ می‌کند
# (Traefik مسیر /metrics را نمی‌شناسد، پس از بیرون در دسترس نیست)
from prometheus_fastapi_instrumentator import Instrumentator # noqa: E402
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
@app.get("/") @app.get("/")
def root(): def root():
+65 -14
View File
@@ -1,38 +1,74 @@
"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login. """محدودسازی نرخ درخواست — پنجرهٔ لغزان درون‌حافظه‌ای.
پیش‌فرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک می‌کند. برای ورود ادمین (بر پایه تلاش ناموفق) و فرم‌های عمومی (بر پایه هر ارسال)
شمارنده در حافظه همین پروسه است — با ری‌استارت بک‌اند پاک می‌شود و همین کافی است. استفاده می‌شود. شمارنده‌ها در حافظهٔ همین پروسه‌اند — با ری‌استارت پاک می‌شوند؛
به همین دلیل uvicorn تک‌کارگر نگه داشته شده است.
""" """
import time import time
from fastapi import HTTPException, Request, status from fastapi import HTTPException, Request, status
MAX_FAILED_ATTEMPTS = 5
WINDOW_SECONDS = 15 * 60
_failed: dict[str, list[float]] = {}
def client_ip(request: Request) -> str: def client_ip(request: Request) -> str:
"""IP واقعی کلاینت — پشت Traefik از X-Forwarded-For خوانده می‌شود
(uvicorn با --proxy-headers هم client.host را درست می‌کند)."""
forwarded = request.headers.get("x-forwarded-for") forwarded = request.headers.get("x-forwarded-for")
if forwarded: if forwarded:
return forwarded.split(",")[0].strip() return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown" return request.client.host if request.client else "unknown"
def _prune(key: str, now: float) -> list[float]: class RateLimiter:
recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS] """پنجرهٔ لغزان بر پایه کلید (عملاً IP) — رویدادها پس از پنجره منقضی می‌شوند."""
_failed[key] = recent
def __init__(self, max_events: int, window_seconds: int, message: str):
self.max_events = max_events
self.window_seconds = window_seconds
self.message = message
self._events: dict[str, list[float]] = {}
def _recent(self, key: str, now: float) -> list[float]:
recent = [t for t in self._events.get(key, []) if now - t < self.window_seconds]
self._events[key] = recent
return recent return recent
def _reject(self, recent: list[float]) -> None:
now = time.monotonic()
retry_after = max(1, int(self.window_seconds - (now - recent[0])))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=self.message,
headers={"Retry-After": str(retry_after)},
)
def hit(self, request: Request) -> None:
"""یک رویداد ثبت می‌کند؛ اگر سهمیه کلید پر باشد 429 برمی‌گرداند."""
key = client_ip(request)
now = time.monotonic()
recent = self._recent(key, now)
if len(recent) >= self.max_events:
self._reject(recent)
self._events.setdefault(key, []).append(now)
def clear(self, request: Request) -> None:
self._events.pop(client_ip(request), None)
# ورود ادمین: ۵ تلاش ناموفق در ۱۵ دقیقه — فقط شکست‌ها شمرده می‌شوند
_failed: dict[str, list[float]] = {}
LOGIN_MAX_FAILED_ATTEMPTS = 5
LOGIN_WINDOW_SECONDS = 15 * 60
def ensure_allowed(request: Request) -> None: def ensure_allowed(request: Request) -> None:
"""اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد می‌شود.""" """اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد می‌شود."""
key = client_ip(request) key = client_ip(request)
recent = _prune(key, time.monotonic()) now = time.monotonic()
if len(recent) >= MAX_FAILED_ATTEMPTS: recent = [t for t in _failed.get(key, []) if now - t < LOGIN_WINDOW_SECONDS]
retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0]))) _failed[key] = recent
if len(recent) >= LOGIN_MAX_FAILED_ATTEMPTS:
retry_after = max(1, int(LOGIN_WINDOW_SECONDS - (now - recent[0])))
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="تلاش‌های ناموفق زیاد است — بعداً دوباره امتحان کنید", detail="تلاش‌های ناموفق زیاد است — بعداً دوباره امتحان کنید",
@@ -47,3 +83,18 @@ def record_failure(request: Request) -> None:
def clear(request: Request) -> None: def clear(request: Request) -> None:
"""با ورود موفق، تاریخچه تلاش‌های ناموفق IP پاک می‌شود.""" """با ورود موفق، تاریخچه تلاش‌های ناموفق IP پاک می‌شود."""
_failed.pop(client_ip(request), None) _failed.pop(client_ip(request), None)
# فرم تماس: ۵ ارسال در ۱۵ دقیقه برای هر IP
contact_limiter = RateLimiter(
max_events=5,
window_seconds=15 * 60,
message="تعداد پیام‌های ارسالی زیاد است — کمی بعد دوباره امتحان کنید",
)
# خبرنامه: ۳ عضویت در ساعت برای هر IP
newsletter_limiter = RateLimiter(
max_events=3,
window_seconds=60 * 60,
message="تعداد درخواست‌ها زیاد است — کمی بعد دوباره امتحان کنید",
)
+58
View File
@@ -0,0 +1,58 @@
"""پاک‌سازی ورودی‌های غنی در زمان ذخیره.
بدنه مقاله‌ها سند TipTap (ProseMirror) است و فرانت‌اند همان را با
renderToHTMLString به HTML تبدیل و مستقیم تزریق می‌کند؛ متن گره‌ها در آن
مسیر escape می‌شود، اما آدرس‌های href/src عیناً وارد HTML می‌شوند — پس
اسکیم‌های خطرناک (javascript: و data: و ...) باید در سمت سرور حذف شوند.
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
# اسکیم‌های مجاز برای href/src — مسیرهای نسبی و لنگر هم بی‌اسکیم‌اند و می‌گذرند
SAFE_URL_SCHEMES = {"http", "https", "mailto", "tel"}
URL_ATTRS = ("href", "src")
def _clean_url(value: Any) -> str | None:
"""آدرس امن را برمی‌گرداند؛ در غیر این صورت None (مشخصه حذف می‌شود)."""
if not isinstance(value, str):
return value if value is not None else None
stripped = value.strip().replace("\n", "").replace("\r", "").replace("\t", "")
if not stripped:
return None
parsed = urlparse(stripped)
if parsed.scheme and parsed.scheme.lower() not in SAFE_URL_SCHEMES:
return None
return stripped
def _walk(node: Any) -> Any:
if isinstance(node, list):
return [_walk(item) for item in node]
if not isinstance(node, dict):
return node
cleaned: dict[str, Any] = {}
for key, value in node.items():
if key == "attrs" and isinstance(value, dict):
attrs = dict(value)
for attr in URL_ATTRS:
if attr in attrs:
cleaned_url = _clean_url(attrs[attr])
if cleaned_url is None:
attrs.pop(attr, None)
else:
attrs[attr] = cleaned_url
cleaned[key] = attrs
else:
cleaned[key] = _walk(value)
return cleaned
def sanitize_doc(doc: Any) -> Any:
"""سند TipTap را درجا پاک می‌کند (سایر ساختارها دست‌نخورده برمی‌گردند)."""
if not isinstance(doc, dict):
return doc
return _walk(doc)
+9 -2
View File
@@ -3,6 +3,8 @@ from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
from .sanitize import sanitize_doc
# ---------- پایه ---------- # ---------- پایه ----------
@@ -386,10 +388,14 @@ class ContactIn(BaseModel):
name: str = Field(min_length=3, max_length=128) name: str = Field(min_length=3, max_length=128)
phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$") phone: str = Field(pattern=r"^[\d۰-۹+\-\s]{8,15}$")
note: str = Field(default="", max_length=2000) note: str = Field(default="", max_length=2000)
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمی‌کند
website: str = Field(default="", max_length=128)
class NewsletterIn(BaseModel): class NewsletterIn(BaseModel):
email: EmailStr email: EmailStr
# honeypot — فیلد پنهان فرم؛ کاربر واقعی هرگز آن را پر نمی‌کند
website: str = Field(default="", max_length=128)
# ---------- ورودی‌های پنل مدیریت ---------- # ---------- ورودی‌های پنل مدیریت ----------
@@ -460,12 +466,13 @@ EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
def validate_doc_body(value: object) -> dict: def validate_doc_body(value: object) -> dict:
"""بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد.""" """بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد و
آدرس‌های href/src آن به اسکیم‌های امن محدود شوند."""
if not value: if not value:
return dict(EMPTY_DOC) return dict(EMPTY_DOC)
if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list): if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}") raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
return value return sanitize_doc(value)
class ArticleIn(BaseModel): class ArticleIn(BaseModel):
+49
View File
@@ -0,0 +1,49 @@
"""محیط Alembic — متادیتا از مدل‌های SQLAlchemy می‌آید و آدرس دیتابیس از تنظیمات."""
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app import models # noqa: F401 — ثبت مدل‌ها در متادیتا
from app.config import settings
from app.database import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_url() -> str:
return os.environ.get("DATABASE_URL", settings.database_url)
def run_migrations_offline() -> None:
context.configure(
url=get_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,25 @@
"""نقطه شروع زنجیره مهاجرت‌ها — جداول پایه با create_all ساخته می‌شوند.
ساخت اولیه ۲۹ جدول بر عهده Base.metadata.create_all است (در CMD کانتینر پیش از
این مهاجرت اجرا می‌شود و برای دیتابیس‌های موجود بی‌اثر است). از این نسخه به بعد
هر تغییر اسکیما باید یک revision واقعی Alembic باشد — نه create_all و نه
تغییر دستی — تا مسیر ارتقا و بازگشت آزمایش‌پذیر بماند.
Revision ID: 0001
Revises:
Create Date: 2026-09-18
"""
from typing import Sequence, Union
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+8
View File
@@ -11,3 +11,11 @@ bcrypt==4.2.1
email-validator==2.2.0 email-validator==2.2.0
boto3==1.35.0 boto3==1.35.0
python-multipart==0.0.18 python-multipart==0.0.18
# پاک‌سازی HTML سمت سرور (تیپ‌تپ و فیلدهای غنی)
nh3==0.2.20
# بهینه‌سازی تصویر در زمان آپلود (تغییر اندازه/فشرده‌سازی مجدد)
Pillow==11.1.0
# متریک‌های Prometheus برای مانیتورینگ (در /metrics داخلی)
prometheus-fastapi-instrumentator==7.0.0
# مهاجرت‌های نسخه‌دار اسکیما
alembic==1.14.0
+4
View File
@@ -74,8 +74,12 @@ def reset_rate_limiter():
from app import rate_limit from app import rate_limit
rate_limit._failed.clear() rate_limit._failed.clear()
rate_limit.contact_limiter._events.clear()
rate_limit.newsletter_limiter._events.clear()
yield yield
rate_limit._failed.clear() rate_limit._failed.clear()
rate_limit.contact_limiter._events.clear()
rate_limit.newsletter_limiter._events.clear()
@pytest.fixture() @pytest.fixture()
+95 -2
View File
@@ -8,12 +8,24 @@ DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
# ---------- عمومی ---------- # ---------- عمومی ----------
def test_health(client): def test_live(client):
res = client.get("/api/health") """liveness — پروسه زنده است، بدون وابستگی."""
res = client.get("/api/live")
assert res.status_code == 200 assert res.status_code == 200
assert res.json()["status"] == "ok" assert res.json()["status"] == "ok"
def test_health_deep(client):
"""بررسی عمق — DB (SQLite در تست) سالم است؛ MinIO در محیط تست در دسترس نیست
پس /api/health باید 503 و وضعیت degraded با جزئیات هر وابستگی بدهد."""
res = client.get("/api/health")
assert res.status_code == 503
body = res.json()
assert body["status"] == "degraded"
assert body["checks"]["database"] == "ok"
assert body["checks"]["storage"] == "error"
def test_site_hides_draft_articles(client): def test_site_hides_draft_articles(client):
res = client.get("/api/site") res = client.get("/api/site")
assert res.status_code == 200 assert res.status_code == 200
@@ -268,3 +280,84 @@ def test_en_backfill_fills_only_empty_fields(auth_client, client):
assert res.status_code == 200 assert res.status_code == 200
backfill_en_content() backfill_en_content()
assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name" assert client.get("/api/site").json()["brand"]["name_en"] == "Custom EN Name"
# ---------- محافظت فرم‌ها و پاک‌سازی ----------
def test_contact_honeypot_saves_nothing(client, auth_client):
"""دامنهٔ پنهان پرشده یعنی ربات — پاسخ موفقِ قلابی، بدون ذخیره."""
res = client.post(
"/api/contact",
json={"name": "ربات تستی", "phone": "09120000000", "website": "spam"},
)
assert res.status_code == 201
assert res.json()["id"] == 0 # رکورد قلابی — در دیتابیس نیست
assert auth_client.get("/api/admin/messages").json() == []
def test_contact_rate_limited(client, auth_client):
"""ارسال ششم در پنجرهٔ ۱۵ دقیقه‌ای رد می‌شود (۵ مجاز برای هر IP)."""
for _ in range(5):
res = client.post(
"/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"}
)
assert res.status_code == 201, res.text
res = client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
assert res.status_code == 429
assert "Retry-After" in res.headers
def test_newsletter_honeypot_saves_nothing(client, auth_client):
res = client.post(
"/api/newsletter", json={"email": "bot@example.com", "website": "spam"}
)
assert res.status_code == 201
assert res.json()["id"] == 0
assert auth_client.get("/api/admin/subscribers").json() == []
def test_message_and_subscriber_csv_export(auth_client):
auth_client.post("/api/contact", json={"name": "کاربر تماس", "phone": "09120000000"})
res = auth_client.get("/api/admin/messages/export.csv")
assert res.status_code == 200
assert res.headers["content-type"].startswith("text/csv")
assert "کاربر تماس" in res.text
auth_client.post("/api/newsletter", json={"email": "sale@example.com"})
res = auth_client.get("/api/admin/subscribers/export.csv")
assert res.status_code == 200
assert "sale@example.com" in res.text
def test_article_body_url_sanitization(auth_client):
"""اسکیم‌های خطرناک در href/src سند TipTap در زمان ذخیره حذف می‌شوند."""
res = auth_client.post(
"/api/admin/articles",
json={
"slug": "sanitized-post",
"title": "مقاله پاک‌سازی‌شده",
"status": "published",
"body": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"marks": [
{"type": "link", "attrs": {"href": "javascript:alert(1)"}},
],
"text": "لینک خطرناک",
}
],
}
],
},
},
)
assert res.status_code == 201, res.text
body = res.json()["body"]
marks = body["content"][0]["content"][0].get("marks", [])
assert all("href" not in m.get("attrs", {}) for m in marks)
+12
View File
@@ -0,0 +1,12 @@
# کانتکست بیلد باریک
__pycache__/
**/__pycache__/
*.pyc
.venv/
venv/
.env
.env.*
README.md
Dockerfile
.dockerignore
test_pure.py
+28
View File
@@ -0,0 +1,28 @@
# سرویس چت‌بات — این مقادیر را در .env ریشه پروژه بگذارید (نه اینجا)
# این فایل فقط مستندسازی متغیرهاست.
# --- اجباری ---
# دیتابیس سایت (فقط خواندن) — در داکر از docker-compose.yml تزریق می‌شود
# DATABASE_URL=postgresql+psycopg://aramesh:PASSWORD@db:5432/arameshplaza
# LLM سازگار با OpenAI — بدون این چت‌بات پاسخ نمی‌دهد
# مثال سرور داخلی: LLM_BASE_URL=http://172.16.22.21:4460/v1
# مثال سرویس ابری: LLM_BASE_URL=https://api.example.com/v1
LLM_BASE_URL=
LLM_API_KEY=
LLM_MODEL=openai/gpt-oss-120b
# --- اختیاری ---
# توکن محافظت از POST /chat/reindex — اگر خالی باشد مسیر ۴۰۳ می‌دهد
CHAT_REINDEX_TOKEN=
# ری‌رنکر را روی سرور کم‌رم خاموش کنید (RERANKER_ENABLED=false)
RERANKER_ENABLED=true
# وزن جست‌وجوی معنایی در ترکیب نمره‌ها (۰ تا ۱) — پیش‌فرض ۰.۵
ALPHA_DENSE=0.5
# تعداد سند نهایی به LLM — پیش‌فرض ۳
BEST_OF_K=3
# بازسازی خودکار ایندکس پس از تغییر محتوا (ثانیه، ۰ = خاموش)
INDEX_REFRESH_SECONDS=600
# محدودیت نرخ: حداکثر پیام در پنجره زمانی (پیش‌فرض ۲۰ پیام در ۳۰۰ ثانیه)
RATE_LIMIT_MESSAGES=20
RATE_LIMIT_WINDOW=300
+31
View File
@@ -0,0 +1,31 @@
# سرویس چت‌بات — پایتون + مدل‌های بازیابی (CPU)
FROM python:3.11-slim
WORKDIR /srv
# وابستگی‌های سیستمی برای numpy/scikit-learn
RUN apt-get update && apt-get install -y --no-install-recommends \
g++ \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /srv/chatbot/requirements.txt
RUN pip install --no-cache-dir -r /srv/chatbot/requirements.txt
COPY . /srv/chatbot/
# مدل امبدینگ/ری‌رنکر در /data کش می‌شود (والیوم داکر) — بردارها در pgvector می‌روند و فایل ایندکس جدا وجود ندارد
ENV HF_HOME=/data/hf \
HF_CACHE_DIR=/data/hf
VOLUME /data
# کاربر بدون دسترسی root — فقط /data باید نوشتنی باشد (کش مدل‌ها)
RUN useradd --system --create-home appuser \
&& chown -R appuser:appuser /srv/chatbot /data
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=5 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/chat/healthz')" || exit 1
CMD ["uvicorn", "chatbot.app:api", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"]
+121
View File
@@ -0,0 +1,121 @@
# دستیار هوشمند سایت (چت‌بات)
سرویس گفتگوی محتوای سایت «گروه ساختمانی آرامش» — بازدیدکننده به فارسی سؤال می‌پرسد و
دستیار **فقط از روی محتوای خود سایت** (مقاله‌ها، پروژه‌ها، خدمات، سؤالات متداول، اطلاعات تماس و…)
پاسخ می‌دهد؛ اگر پاسخ در محتوا نباشد، به‌جای ساخت جواب، صادقانه اعلام می‌کند.
این سرویس پورت خط لولهٔ پروژهٔ `llm-srerver-chatbot` (نسخه v13) با چهار تغییر اصلی برای این سایت است:
1. **پایگاه sparse به‌جای SPLADE از BM25 استفاده می‌کند** — SPLADE انگلیسی‌محور است و روی
فارسی عملاً بی‌فایده؛ BM25 با نرمال‌سازی فارسی (ی/ک عربی، اعراب، ارقام، نیم‌فاصله) جایگزین شده است.
2. **ذخیره‌ساز برداری pgvector است، نه FAISS** — بردارها در یک دیتابیس جدا (`aramesh_chatbot`)
روی همان نمونه Postgres نگهداری می‌شوند؛ همگام‌سازی افزایشی است (فقط سطرهای جدید/تغییریافته
امبد می‌شوند)، بکاپ‌گیری با خود دیتابیس است و فایل ایندکس جدا وجود ندارد.
3. **تأخیر کمتر** — مسیریابی `alpha` با LLM و گسترش چند‌پرسشی (multi-query) به‌صورت پیش‌فرض
خاموش‌اند (با متغیر محیطی قابل فعال‌سازی)؛ بازنویسی پرسش فقط وقتی تاریخچه گفتگو وجود دارد اجرا می‌شود.
4. **استریم واقعی توکن‌به‌توکن** با `astream_events` به‌جای دیف پیام‌های کامل.
## خط لوله هر پیام
```
پرسش کاربر
│
├─[اگر تاریخچه هست] بازنویسی پرسش با LLM (حل ضمیرها و اشاره‌ها: «اون دومی» → موضوع واقعی)
│
├─ بازیابی ترکیبی: pgvector (معنایی، کسینوسی روی HNSW — MiniLM چندزبانه)
│ + BM25 (کلیدواژه، متن نرمال‌شده فارسی)
│ نمره‌ها min-max → ترکیب α×معنایی + (1-α)×کلیدواژه
│
├─ برش خوشه‌ای (K-Means K=2 + silhouette ≥ 0.55) — مرز طبیعی کیفیت
│
├─ ری‌رنک با BAAI/bge-reranker-v2-m3 → نگه‌داشتن ۳ سند برتر
│
└─ تولید پاسخ فارسی با LLM (قواعد تکیه بر داده + معرفی صفحه مرتبط + پیام جایگزین مشخص)
```
ایندکس از محتوای PostgreSQL سایت ساخته می‌شود (`articles` منتشرشده، `projects`، `services`،
`faqs`، `brand`، `offices`، `investment_models`، `process_steps`، `why_us`، `team`،
`testimonials`، `timeline`، `story`). متن‌های بلند به قطعات ~۷۰۰ نویسه‌ای هم‌پوشان برش می‌خورند.
- **ایندکس برداری در pgvector**: جدول `chatbot_chunks` در دیتابیس اختصاصی
`aramesh_chatbot` (روی همان نمونه Postgres) با ستون `embedding vector(384)` و
ایندکس HNSW کسینوسی؛ هر قطعه `doc_id` پایدار و `content_hash` دارد و همگام‌سازی
فقط سطرهای جدید/تغییریافته را امبد می‌کند. دیتابیس و جدول در شروع سرویس خودکار ساخته می‌شوند.
- **بازسازی خودکار**: یک حلقهٔ پس‌زمینه هر `INDEX_REFRESH_SECONDS` ثانیه محتوا را چک
می‌کند و اگر ادمین چیزی را در پنل عوض کرده باشد، فقط تغییرات همگام می‌شود
(امبد اجباری همه: `POST /chat/reindex`).
- **ماندگاری گفتگو**: در حافظهٔ همان پروسه (MemorySaver لنگ‌گراف) — با ری‌استارت پاک می‌شود.
## متغیرهای محیطی (در `.env` ریشه پروژه)
| متغیر | پیش‌فرض | توضیح |
|---|---|---|
| `LLM_BASE_URL` | خالی | **اجباری** — نشانی سرویس LLM سازگار با OpenAI. بدون آن چت‌بات بالا می‌آید ولی پاسخ نمی‌دهد |
| `LLM_API_KEY` | خالی | کلید API (برای سرور داخلی `not-needed` بگذارید) |
| `LLM_MODEL` | `openai/gpt-oss-120b` | نام مدل |
| `CHAT_REINDEX_TOKEN` | خالی | توکن `POST /chat/reindex` — اگر خالی باشد آن مسیر ۴۰۳ می‌دهد |
| `CHATBOT_DB_NAME` | `aramesh_chatbot` | نام دیتابیس اختصاصی چت‌بات روی همان نمونه Postgres (بردارها اینجا می‌روند) |
| `EMBEDDING_DIM` | `384` | بعد بردار مدل امبدینگ — با تعویض مدل امبدینگ باید هماهنگ شود |
| `RERANKER_ENABLED` | `true` | روی سرور کم‌رم (زیر ~۴GB) `false` بگذارید |
| `ALPHA_DENSE` | `0.5` | وزن جست‌وجوی معنایی در ترکیب نمره‌ها (۰ تا ۱) |
| `BEST_OF_K` | `3` | تعداد سند نهایی که به LLM می‌رود |
| `INDEX_REFRESH_SECONDS` | `600` | بازسازی خودکار ایندکس پس از تغییر محتوا (۰ = خاموش) |
| `RATE_LIMIT_MESSAGES` / `RATE_LIMIT_WINDOW` | `20` / `300` | سقف پیام هر IP در پنجره زمانی (ثانیه) |
`DATABASE_URL` در داکر از `docker-compose.yml` تزریق می‌شود — دیتابیس سایت فقط خوانده
می‌شود و نوشتن (بردارها) فقط در دیتابیس جدا `aramesh_chatbot` انجام می‌گیرد.
## پیش‌نیاز pgvector
سرویس `db` استک از ایمیج `pgvector/pgvector:pg17` استفاده می‌کند (همان postgres:17 +
افزونهٔ vector). اگر Postgres سایت روی نمونهٔ دیگری است، باید افزونهٔ pgvector روی آن
فعال باشد؛ در غیر این صورت شروع چت‌بات با خطای واضح متوقف می‌شود.
## مسیرها
| مسیر | توضیح |
|---|---|
| `GET /chat/stream?message=…&thread_id=…` | استریم SSE توکن‌به‌توکن پاسخ (`{"content": …}` و در پایان `{"done": true}`) |
| `GET /chat/suggestions` | ۴ پیشنهاد پرسش (کش ۱۰ دقیقه؛ جایگزین: خود سؤالات متداول) |
| `GET /chat/eval?message=…` | مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابی‌شده با thread تازه |
| `GET /chat/healthz` | وضعیت ایندکس/LLM/تعداد اسناد |
| `POST /chat/reindex` | بازسازی اجباری ایندکس (هدر `X-Reindex-Token`) |
مسیرهای `GET /chat/{scope}/stream|eval|suggestions` هم برای سازگاری با اسکریپت
`eval_ragas.py` قدیمی موجود است — کافی است `--url` را به نشانی این سرویس بدهید.
## اجرا
با داکر (بخشی از استک اصلی است — `docker compose up -d --build`):
```bash
# در .env ریشه:
LLM_BASE_URL=http://172.16.22.21:4460/v1 # یا هر سرویس سازگار با OpenAI
LLM_API_KEY=not-needed
```
اولین بوت مدل‌ها را دانلود می‌کند (~۱.۵GB: امبدینگ + ری‌رنکر) و در والیوم
`chatbot_cache` کش می‌شود؛ بوت‌های بعدی چند ثانیه طول می‌کشد.
اجرای محلی بدون داکر:
```bash
cd chatbot
python -m venv .venv && source .venv/bin/activate # ویندوز: .venv\Scripts\activate
pip install -r requirements.txt
export DATABASE_URL="postgresql+psycopg://aramesh:PASS@localhost:5432/arameshplaza"
export LLM_BASE_URL="http://…" LLM_MODEL="openai/gpt-oss-120b"
uvicorn chatbot.app:api --port 8000 # از ریشه پوشه والد
```
## منابع مورد نیاز
- **رم**: با ری‌رنکر ~۴GB (مدل ۵۶۸M پارامتری روی CPU)؛ بدون ری‌رنکر ~۲GB
- **CPU**: امبدینگ و ری‌رنک روی CPU اجرا می‌شوند؛ GPU لازم نیست
- **شبکه**: دسترسی به PostgreSQL و به نشانی `LLM_BASE_URL`
## نکات امنیتی
- دیتابیس سایت فقط **خواندن**؛ نوشتن فقط در دیتابیس اختصاصی `aramesh_chatbot` انجام می‌شود
- محدودسازی نرخ per-IP فعال است (پیش‌فرض ۲۰ پیام در ۵ دقیقه)
- CORS باز است ولی کوکی/اعتبارنامه‌ای رد و بدل نمی‌شود؛ در تولید همه‌چیز هم‌مبدأ (پشت Traefik) است
+1
View File
@@ -0,0 +1 @@
"""سرویس چت‌بات سایت آرامش — بازیابی ترکیبی روی محتوای PostgreSQL."""
+284
View File
@@ -0,0 +1,284 @@
"""سرویس چت‌بات سایت آرامش — FastAPI با استریم SSE و بازیابی ترکیبی روی محتوای دیتابیس."""
import asyncio
import json
import logging
import time
import uuid
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from langchain_core.messages import AIMessage, HumanMessage
from . import content as content_mod
from . import pipeline
from . import vectorstore
from .config import settings
from .indexing import INDEX, sync_index
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
)
log = logging.getLogger("chatbot")
# --- چرخه حیات: ساخت ایندکس در شروع + بازسازی خودکار وقتی محتوا عوض شود ---
_refresh_task: asyncio.Task | None = None
@asynccontextmanager
async def lifespan(_app: FastAPI):
try:
await asyncio.to_thread(sync_index)
except Exception as e:
log.critical("Initial index sync failed: %s — chat will be unavailable until reindex", e)
global _refresh_task
_refresh_task = asyncio.create_task(_refresh_loop())
yield
_refresh_task.cancel()
async def _refresh_loop() -> None:
interval = settings.index_refresh_seconds
if interval <= 0:
return
while True:
await asyncio.sleep(interval)
try:
# همگام‌سازی افزایشی است — فقط سطرهای جدید/تغییریافته امبد می‌شوند
await asyncio.to_thread(sync_index)
except Exception as e:
log.warning("Background index refresh failed: %s", e)
api = FastAPI(
title="Aramesh Site Chatbot",
version="1.0.0",
description="دستیار گفتگوی محتوای سایت — بازیابی ترکیبی (pgvector + BM25) با ری‌رنکر BGE",
lifespan=lifespan,
)
api.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
# --- محدودسازی نرخ درخواست سبک در حافظه (پنجره لغزان per-IP) ---
_hits: dict[str, deque] = defaultdict(deque)
_last_sweep = 0.0
def _rate_limited(ip: str) -> bool:
global _last_sweep
now = time.time()
# پاک‌سازی دوره‌ای کلیدهای بی‌مصرف تا دیکشنری بی‌نهایت بزرگ نشود
if now - _last_sweep > settings.rate_limit_window:
stale = [k for k, q in _hits.items() if not q or now - q[-1] > settings.rate_limit_window]
for k in stale:
_hits.pop(k, None)
_last_sweep = now
window = _hits[ip]
while window and now - window[0] > settings.rate_limit_window:
window.popleft()
if len(window) >= settings.rate_limit_messages:
return True
window.append(now)
return False
def _client_ip(request: Request | None) -> str:
if request is None:
return "?"
forwarded = request.headers.get("x-forwarded-for")
return forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "?")
# --- کمکی‌های SSE ---
def _sse(payload: dict) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
def _chunk_text(chunk) -> str:
"""استخراج متن از قطعه استریم LLM (رشته یا لیست بخش‌ها)."""
content = getattr(chunk, "content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
return "".join(p.get("text", "") for p in content if isinstance(p, dict))
return ""
async def _stream_answer(message: str, thread_id: str):
if not INDEX.ready:
yield _sse({"error": "ایندکس محتوا هنوز آماده نیست؛ چند لحظه بعد دوباره تلاش کنید.", "done": True})
return
if pipeline.get_llm() is None:
yield _sse({"error": pipeline.LLM_UNAVAILABLE, "done": True})
return
config = {"configurable": {"thread_id": thread_id}}
inputs = {"messages": [HumanMessage(content=message)]}
try:
async for event in pipeline.graph.astream_events(inputs, config=config, version="v2"):
# فقط توکن‌های پاسخ نهایی — نه فراخوانی‌های کمکی (بازنویسی/گسترش پرسش)
if event["event"] == "on_chat_model_stream" and "generate" in (event.get("tags") or []):
text = _chunk_text(event["data"]["chunk"])
if text:
yield _sse({"content": text, "done": False})
yield _sse({"content": "", "done": True})
except Exception as e:
log.exception("Streaming failed")
yield _sse({"error": f"خطا در تولید پاسخ: {e}", "done": True})
# --- مسیرها ---
@api.get("/chat/healthz")
async def healthz():
return {
"status": "ok",
"index_ready": INDEX.ready,
"documents": len(INDEX.docs),
"vectors": await asyncio.to_thread(vectorstore.vector_count),
"llm_configured": bool(settings.llm_base_url),
"reranker_enabled": settings.reranker_enabled,
}
@api.get("/chat/suggestions")
async def suggestions():
return {"questions": _get_suggestions()}
@api.get("/chat/eval")
async def eval_question(message: str = Query(...)):
"""مخصوص ارزیابی RAGAS — پاسخ + اسناد بازیابی‌شده، با thread تازه."""
if not INDEX.ready:
return {"error": "index not ready", "question": message, "answer": "", "contexts": []}
if pipeline.get_llm() is None:
return {"error": "llm not configured", "question": message, "answer": "", "contexts": []}
thread_id = f"eval_{uuid.uuid4().hex[:10]}"
config = {"configurable": {"thread_id": thread_id}}
inputs = {"messages": [HumanMessage(content=message)]}
final_state = await pipeline.graph.ainvoke(inputs, config=config)
answer = ""
for msg in reversed(final_state.get("messages", [])):
if isinstance(msg, AIMessage):
answer = msg.content if isinstance(msg.content, str) else str(msg.content)
break
return {
"question": message,
"contextualized_query": final_state.get("contextualized_query") or message,
"answer": answer,
"contexts": final_state.get("documents", []),
"thread_id": thread_id,
}
@api.post("/chat/reindex")
async def reindex(request: Request):
"""همگام‌سازی اجباری (امبد دوباره همه سطرها) — با توکن محافظت می‌شود."""
if settings.reindex_token and request.headers.get("x-reindex-token") != settings.reindex_token:
raise HTTPException(status_code=403, detail="invalid reindex token")
stats = await asyncio.to_thread(sync_index, True)
return {"status": "ok", "documents": len(INDEX.docs), "stats": stats}
# سازگاری با eval_ragas.py قدیمی که آدرس را /chat/{username}/eval می‌سازد
@api.get("/chat/{scope}/stream")
async def stream_chat_scoped(scope: str, message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
return await stream_chat(message=message, thread_id=thread_id, request=request)
@api.get("/chat/{scope}/eval")
async def eval_scoped(scope: str, message: str = Query(...)):
return await eval_question(message=message)
@api.get("/chat/{scope}/suggestions")
async def suggestions_scoped(scope: str):
return {"questions": _get_suggestions()}
@api.get("/chat/stream")
async def stream_chat(message: str = Query(...), thread_id: str = Query(default=""), request: Request = None):
"""استریم توکن‌به‌توکن پاسخ با SSE."""
if _rate_limited(_client_ip(request)):
raise HTTPException(status_code=429, detail="تعداد پیام‌های شما زیاد است؛ کمی بعد دوباره تلاش کنید.")
thread_id = thread_id or f"t_{uuid.uuid4().hex[:10]}"
async def generate():
async for chunk in _stream_answer(message, thread_id):
yield chunk
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
# --- پیشنهاد پرسش‌ها (کش‌شده) ---
_suggestions_cache: tuple[float, list[str]] = (0.0, [])
def _get_suggestions() -> list[str]:
global _suggestions_cache
now = time.time()
if _suggestions_cache[1] and now - _suggestions_cache[0] < settings.suggestions_ttl:
return _suggestions_cache[1]
questions: list[str] = []
if INDEX.ready:
faqs = [d for d in INDEX.docs if d["type"] == "faq"][:8]
titles = [d["title"] for d in INDEX.docs if d["type"] == "article"][:10]
model = pipeline.get_llm()
if model and (faqs or titles):
material = "\n".join([d["text"][:200] for d in faqs] + titles)
try:
prompt = (
"بر اساس محتوای سایت «گروه ساختمانی آرامش» ۴ پرسش کوتاه و جذاب بنویس که بازدیدکننده "
"احتمالاً می‌پرسد. هر پرسش حداکثر ۱۰ کلمه و به فارسی.\n"
'فقط آرایه JSON رشته‌ای برگردان: ["پرسش ۱", "پرسش ۲", ...]\n\n'
f"محتوا:\n{material[:2000]}"
)
resp = model.invoke([HumanMessage(content=prompt)])
text = resp.content if isinstance(resp.content, str) else ""
if text.startswith("```"):
text = text.split("```")[1]
text = text[4:] if text.startswith("json") else text
import re
match = re.search(r"\[.*\]", text, re.DOTALL)
if match:
questions = [q.strip() for q in json.loads(match.group()) if isinstance(q, str) and q.strip()][:4]
except Exception as e:
log.warning("Suggestion generation failed: %s", e)
if not questions and faqs:
# جایگزین: خود پرسش‌های متداول
questions = [d["text"].split("\n")[0].removeprefix("پرسش: ") for d in faqs[:4]]
_suggestions_cache = (now, questions)
return questions
if __name__ == "__main__":
import uvicorn
uvicorn.run(api, host="0.0.0.0", port=8000)
+57
View File
@@ -0,0 +1,57 @@
"""تنظیمات سرویس چت‌بات — از متغیرهای محیطی یا فایل .env خوانده می‌شود."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
# دیتابیس سایت (فقط خواندن) — همان DATABASE_URL بک‌اند
database_url: str = "postgresql+psycopg://aramesh:aramesh@localhost:5432/arameshplaza"
# دیتابیس اختصاصی چت‌بات روی همان نمونه Postgres — بردارها اینجا نوشته می‌شوند
# تا دیتابیس سایت فقط‌خواندنی بماند؛ در شروع سرویس خودکار ساخته می‌شود
chatbot_db_name: str = "aramesh_chatbot"
# LLM سازگار با OpenAI — اجباری؛ بدون آن پاسخ‌گویی غیرفعال است
llm_base_url: str = ""
llm_api_key: str = ""
llm_model: str = "openai/gpt-oss-120b"
# مدل‌های بازیابی
embedding_model: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
# بعد بردار خروجی مدل امبدینگ (MiniLM-L12 = 384) — با تغییر مدل باید عوض شود
embedding_dim: int = 384
reranker_model: str = "BAAI/bge-reranker-v2-m3"
reranker_enabled: bool = True
reranker_device: str = "cpu"
# وزن جست‌وجوی معنایی در ترکیب نمره‌ها (۰ = فقط کلیدواژه، ۱ = فقط معنایی)
alpha_dense: float = 0.5
# تعداد سند نهایی که به LLM داده می‌شود
best_of_k: int = 3
# تعداد نتیجه هر پایگاه (معنایی و کلیدواژه) برای هر پرس‌وجو
retrieve_k: int = 12
# بازنویسی پرس‌وجو با تاریخچه گفتگو (فقط وقتی تاریخچه هست صدا زده می‌شود)
contextualize_enabled: bool = True
# گسترش پرس‌وجو به چند نوع — برای پیکره کوچک پیش‌فرض خاموش (تأخیر کمتر)
query_expansion: bool = False
# مسیر کش مدل‌ها (در داکر روی والیوم /data سوار می‌شود)
hf_cache_dir: str = "/data/hf"
# محدودسازی نرخ درخواست هر IP (تعداد پیام در پنجره زمانی به ثانیه)
rate_limit_messages: int = 20
rate_limit_window: int = 300
# توکن بازسازی ایندکس — اگر خالی باشد مسیر reindex فقط از داخل شبکه کار می‌کند
reindex_token: str = ""
# ماندگاری کش پیشنهادها و زمینه سایت (ثانیه)
suggestions_ttl: int = 600
site_context_ttl: int = 120
# بازسازی خودکار ایندکس وقتی محتوای دیتابیس عوض شود (۰ = خاموش)
index_refresh_seconds: int = 600
settings = Settings()
+315
View File
@@ -0,0 +1,315 @@
"""خواندن محتوای سایت از PostgreSQL و تبدیل آن به سندهای قابل ایندکس.
هر سند یک dict با کلیدهای text / type / title / url است. بدنه مقاله‌ها و
پروژه‌ها هم می‌تواند آرایه پاراگراف باشد و هم سند TipTap (JSON) — هر دو
پشتیبانی می‌شوند. مقاله‌های پیش‌نویس ایندکس نمی‌شوند.
"""
import logging
from typing import Any
from .persian import normalize
log = logging.getLogger(__name__)
# URL پایه صفحات — چت‌بات مسیر نسبی می‌دهد؛ فرانت‌اند روی همان دامنه است
_ARTICLE_URL = "/blog/{slug}"
_PROJECT_URL = "/projects/{slug}"
def _psycopg_dsn(database_url: str) -> str:
"""DATABASE_URL فرمت SQLAlchemy (postgresql+psycopg://) را به DSN psycopg3 تبدیل می‌کند."""
return database_url.replace("+psycopg", "").replace("+asyncpg", "")
def _fetch_rows(dsn: str, sql: str) -> list[dict[str, Any]]:
import psycopg
from psycopg.rows import dict_row
with psycopg.connect(dsn, row_factory=dict_row, connect_timeout=10) as conn:
cur = conn.execute(sql)
return cur.fetchall()
def tiptap_blocks(node: Any) -> list[str]:
"""متن بلوک‌به‌بلوک از سند TipTap (یا هر JSON تو در تو) بیرون می‌کشد."""
if not isinstance(node, dict):
return []
ntype = node.get("type")
if ntype in ("paragraph", "heading", "codeBlock"):
text = _inline_text(node).strip()
return [text] if text else []
blocks: list[str] = []
for child in node.get("content") or []:
blocks.extend(tiptap_blocks(child))
return blocks
def _inline_text(node: dict) -> str:
parts: list[str] = []
for child in node.get("content") or []:
ctype = child.get("type")
if ctype == "text":
parts.append(child.get("text", ""))
elif ctype == "hardBreak":
parts.append(" ")
elif ctype == "image":
alt = (child.get("attrs") or {}).get("alt") or ""
if alt:
parts.append(f"[تصویر: {alt}]")
else:
parts.append(_inline_text(child))
return "".join(parts)
def body_to_text(body: Any) -> str:
"""بدنه مقاله/پروژه (آرایه پاراگراف یا سند TipTap) → متن یکپارچه."""
if not body:
return ""
if isinstance(body, list):
# فرمت قدیمی: آرایه رشته‌ای پاراگراف‌ها
return "\n".join(str(p).strip() for p in body if str(p).strip())
blocks = tiptap_blocks(body)
return "\n".join(b for b in blocks if b)
def _doc(text: str, dtype: str, title: str, url: str) -> dict | None:
text = (text or "").strip()
if not text:
return None
return {"text": text, "type": dtype, "title": title, "url": url}
def _clean(value: Any) -> str:
return normalize(str(value or "")).strip()
def fetch_site_documents(database_url: str) -> list[dict]:
"""همه محتوای عمومی سایت را به سند تبدیل می‌کند (بدون وابستگی به ORM بک‌اند)."""
dsn = _psycopg_dsn(database_url)
docs: list[dict] = []
# --- برند و اطلاعات تماس (رکورد یگانه) ---
brand_rows = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
if brand_rows:
b = brand_rows[0]
contact = "\n".join(
filter(None, [
f"نام مجموعه: {_clean(b['name'])}",
f"تلفن تماس: {_clean(b['phone'])}",
f"ایمیل: {_clean(b['email'])}",
f"آدرس: {_clean(b['address'])}",
])
)
d = _doc(contact, "contact", "اطلاعات تماس", "/contact")
if d:
docs.append(d)
# --- دفاتر ---
for row in _fetch_rows(dsn, "SELECT city, address, hours FROM offices ORDER BY position"):
d = _doc(
f"دفتر {_clean(row['city'])} — آدرس: {_clean(row['address'])} — ساعت کاری: {_clean(row['hours'])}",
"office", f"دفتر {_clean(row['city'])}", "/contact",
)
if d:
docs.append(d)
# --- خدمات ---
for row in _fetch_rows(dsn, "SELECT title, desc FROM services ORDER BY position"):
d = _doc(
f"خدمت «{_clean(row['title'])}»: {_clean(row['desc'])}",
"service", _clean(row["title"]), "/services",
)
if d:
docs.append(d)
# --- پرسش‌های متداول ---
for row in _fetch_rows(dsn, "SELECT question, answer FROM faqs ORDER BY position"):
d = _doc(
f"پرسش: {_clean(row['question'])}\nپاسخ: {_clean(row['answer'])}",
"faq", _clean(row["question"]), "/faq",
)
if d:
docs.append(d)
# --- پروژه‌ها ---
for row in _fetch_rows(
dsn,
"SELECT slug, title, location, year, status, area, floors, units, client, summary, body FROM projects ORDER BY position",
):
meta = "\n".join(filter(None, [
f"مکان: {_clean(row['location'])}" if row["location"] else "",
f"سال: {_clean(row['year'])}" if row["year"] else "",
f"وضعیت: {'در حال ساخت' if row['status'] == 'progress' else 'تکمیل‌شده'}",
f"متراژ: {_clean(row['area'])}" if row["area"] else "",
f"طبقات: {_clean(row['floors'])}" if row["floors"] else "",
f"واحدها: {_clean(row['units'])}" if row["units"] else "",
f"کارفرما: {_clean(row['client'])}" if row["client"] else "",
]))
text = "\n".join(filter(None, [meta, _clean(row["summary"]), body_to_text(row["body"])]))
d = _doc(text, "project", _clean(row["title"]), _PROJECT_URL.format(slug=row["slug"]))
if d:
docs.append(d)
# --- مقاله‌ها (فقط منتشرشده) ---
for row in _fetch_rows(
dsn,
"SELECT slug, title, date, category, excerpt, body FROM articles WHERE status = 'published' ORDER BY position",
):
text = "\n".join(filter(None, [
f"دسته‌بندی: {_clean(row['category'])}" if row["category"] else "",
_clean(row["excerpt"]),
body_to_text(row["body"]),
]))
d = _doc(text, "article", _clean(row["title"]), _ARTICLE_URL.format(slug=row["slug"]))
if d:
docs.append(d)
# --- مدل‌های سرمایه‌گذاری ---
for row in _fetch_rows(dsn, "SELECT title, desc, points FROM investment_models ORDER BY position"):
points = "؛ ".join(_clean(p) for p in (row["points"] or []))
d = _doc(
"\n".join(filter(None, [
f"«{_clean(row['title'])}»: {_clean(row['desc'])}",
f"نکات: {points}" if points else "",
])),
"investment", _clean(row["title"]), "/investment",
)
if d:
docs.append(d)
# --- مراحل همکاری ---
for row in _fetch_rows(dsn, "SELECT step, title, desc FROM process_steps ORDER BY position"):
d = _doc(
f"مرحله {_clean(row['step'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
"process", _clean(row["title"]), "/investment",
)
if d:
docs.append(d)
# --- چرا ما / تیم / نظرسنجی / تایم‌لاین / داستان ما ---
for row in _fetch_rows(dsn, "SELECT title, desc FROM why_us ORDER BY position"):
d = _doc(f"{_clean(row['title'])}: {_clean(row['desc'])}", "about", _clean(row["title"]), "/about")
if d:
docs.append(d)
for row in _fetch_rows(dsn, "SELECT name, role, bio FROM team ORDER BY position"):
d = _doc(
f"{_clean(row['name'])} — {_clean(row['role'])}. {_clean(row['bio'])}",
"team", _clean(row["name"]), "/about",
)
if d:
docs.append(d)
for row in _fetch_rows(dsn, "SELECT name, role, quote FROM testimonials ORDER BY position"):
d = _doc(
f"نظر {_clean(row['name'])} ({_clean(row['role'])}): {_clean(row['quote'])}",
"testimonial", _clean(row["name"]), "/",
)
if d:
docs.append(d)
for row in _fetch_rows(dsn, "SELECT year, title, desc FROM timeline ORDER BY position"):
d = _doc(
f"سال {_clean(row['year'])} — {_clean(row['title'])}: {_clean(row['desc'])}",
"timeline", _clean(row["title"]), "/about",
)
if d:
docs.append(d)
story_rows = _fetch_rows(dsn, "SELECT title, paragraphs, quote FROM story LIMIT 1")
if story_rows and story_rows[0]:
s = story_rows[0]
paragraphs = "\n".join(_clean(p) for p in (s["paragraphs"] or []) if _clean(p))
text = "\n".join(filter(None, [_clean(s["title"]), paragraphs, _clean(s["quote"])]))
d = _doc(text, "about", _clean(s["title"]) or "درباره ما", "/about")
if d:
docs.append(d)
log.info("Fetched %d documents from site database", len(docs))
return docs
def chunk_documents(docs: list[dict], chunk_size: int = 700, overlap: int = 120) -> list[dict]:
"""متن‌های بلند (مقاله/پروژه) را به قطعات هم‌پوشان تقسیم می‌کند.
هر قطعه یک doc_id پایدار می‌گیرد («type:ترتیب در همان url:شماره قطعه») تا
همگام‌سازی افزایشی pgvector بتواند فقط سطرهای تغییر یافته را امبد کند.
"""
from langchain_text_splitters import RecursiveCharacterTextSplitter
from .vectorstore import chunk_content_hash
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=["\n", "۔", ".", "،", " ", ""],
)
# شماره ردیف هر سند در میان سندهای هم‌type و هم-url (برای FAQs که url یکسان دارند)
occurrence: dict[str, int] = {}
out: list[dict] = []
for doc in docs:
key = f"{doc['type']}|{doc['url']}"
occ = occurrence.get(key, 0)
occurrence[key] = occ + 1
pieces = splitter.split_text(doc["text"]) if len(doc["text"]) > chunk_size * 1.3 else [doc["text"]]
for i, piece in enumerate(pieces, 1):
chunk = {
"doc_id": f"{doc['type']}:{occ}:{i}",
"text": piece if len(pieces) == 1 else f"[{doc['title']} — بخش {i} از {len(pieces)}]\n{piece}",
"type": doc["type"],
"title": doc["title"],
"url": doc["url"],
}
chunk["content_hash"] = chunk_content_hash(chunk)
out.append(chunk)
log.info("Chunked %d documents into %d pieces", len(docs), len(out))
return out
def content_fingerprint(docs: list[dict]) -> str:
"""هش کل محتوا — فقط برای لاگ؛ تشخیص تغییر ردیفی در vectorstore انجام می‌شود."""
import hashlib
import json
payload = json.dumps(docs, ensure_ascii=False, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
# --- زمینه همیشه‌تازه سایت (بلوک کوتاه تماس/خدمات برای پرامپت) ---
_site_context_cache: tuple[float, str] | None = None
def fetch_site_context(database_url: str, ttl: int = 120) -> str:
"""نام برند، تماس و فهرست خدمات — با کش کوتاه‌مدت تا هر پیام یک کوئری سبک بزند."""
global _site_context_cache
import time as _time
now = _time.time()
if _site_context_cache and now - _site_context_cache[0] < ttl:
return _site_context_cache[1]
try:
dsn = _psycopg_dsn(database_url)
brand = _fetch_rows(dsn, "SELECT name, phone, email, address FROM brand LIMIT 1")
services = _fetch_rows(dsn, "SELECT title FROM services ORDER BY position")
lines: list[str] = []
if brand:
b = brand[0]
lines += [
f"نام مجموعه: {_clean(b['name'])}",
f"تلفن تماس: {_clean(b['phone'])}",
f"ایمیل: {_clean(b['email'])}",
f"آدرس: {_clean(b['address'])}",
]
if services:
lines.append("خدمات سایت: " + "، ".join(_clean(s["title"]) for s in services))
ctx = "\n".join(lines)
_site_context_cache = (now, ctx)
return ctx
except Exception as e:
log.warning("Site context fetch failed: %s", e)
return _site_context_cache[1] if _site_context_cache else ""
+20
View File
@@ -0,0 +1,20 @@
"""مدل امبدینگ — با لود تنبل تا اولین استفاده (برای تست‌های سبک، torch لود نمی‌شود)."""
import os
from functools import lru_cache
from .config import settings
@lru_cache(maxsize=1)
def get_embeddings():
from langchain_huggingface import HuggingFaceEmbeddings
os.makedirs(settings.hf_cache_dir, exist_ok=True)
return HuggingFaceEmbeddings(
model_name=settings.embedding_model,
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": False},
cache_folder=settings.hf_cache_dir,
)
+55
View File
@@ -0,0 +1,55 @@
"""ایندکس ترکیبی روی pgvector + BM25 در حافظه.
بردارها در pgvector (دیتابیس اختصاصی چت‌بات) به‌صورت افزایشی همگام می‌شوند —
فقط سطرهای جدید یا تغییر یافته امبد می‌شوند. BM25 چون سبک است در هر شروع
از روی همان سطرها ساخته می‌شود و نیازی به کش دیسکی ندارد.
"""
import logging
import time
from rank_bm25 import BM25Okapi
from . import content as content_mod
from .config import settings
from .persian import tokenize
from . import vectorstore
log = logging.getLogger(__name__)
class SiteIndex:
"""حالت در حافظهٔ بازیابی — سندها (برای BM25) و وضعیت آماده بودن."""
def __init__(self) -> None:
self.docs: list[dict] = []
self.bm25: BM25Okapi | None = None
@property
def ready(self) -> bool:
return self.bm25 is not None and bool(self.docs)
INDEX = SiteIndex()
def sync_index(force_rebuild: bool = False) -> dict:
"""همگام‌سازی کامل: محتوا → قطعه → pgvector (افزایشی) → ساخت BM25.
خروجی: آمار همگام‌سازی. خطای اتصال به دیتابیس بالا فرستاده می‌شود.
"""
start = time.time()
vectorstore.ensure_schema()
docs = content_mod.fetch_site_documents(settings.database_url)
chunks = content_mod.chunk_documents(docs)
stats = vectorstore.sync_chunks(chunks, force=force_rebuild)
INDEX.docs = vectorstore.fetch_all_chunks()
corpus_tokens = [tokenize(d["text"]) for d in INDEX.docs]
INDEX.bm25 = BM25Okapi(corpus_tokens) if corpus_tokens else None
log.info("Index ready in %.1fs (%d vectors, %d docs, %s)",
time.time() - start, len(INDEX.docs), len(chunks), stats)
return stats
+79
View File
@@ -0,0 +1,79 @@
"""نرمال‌سازی متن فارسی و توکن‌سازی برای جست‌وجوی کلیدواژه (BM25).
بدون وابستگی سنگین — فقط استانداردسازی نویسه‌ها، حذف اعراب و نیم‌فاصله‌های
مشکلی و شکستن کلمه‌های مرکب روی نیم‌فاصله برای بهبود بازیابی.
"""
import re
# نویسه‌های عربی که در متن فارسی اشتباهاً استفاده می‌شوند
_CHAR_MAP = {
"\u064a": "\u06cc", # ي → ی
"\u0643": "\u06a9", # ك → ک
"\u0623": "\u0627", # أ → ا
"\u0625": "\u0627", # إ → ا
"\u0671": "\u0627", # ٱ → ا
"\u0629": "\u0647", # ة → ه
"\u0624": "\u0648", # ؤ → و
"\u0626": "\u06cc", # ئ → ی
"\u0640": "", # ـ (کشیده) حذف
}
# اعراب و علامت‌های اضافی
_DIACRITICS = re.compile(r"[\u064b-\u0652\u0670\u0653-\u0655\u0640]")
# ارقام فارسی و عربی → ASCII
_DIGITS = str.maketrans({
"\u06f0": "0", "\u06f1": "1", "\u06f2": "2", "\u06f3": "3", "\u06f4": "4",
"\u06f5": "5", "\u06f6": "6", "\u06f7": "7", "\u06f8": "8", "\u06f9": "9",
"\u0660": "0", "\u0661": "1", "\u0662": "2", "\u0663": "3", "\u0664": "4",
"\u0665": "5", "\u0666": "6", "\u0667": "7", "\u0668": "8", "\u0669": "9",
})
# واژه‌های دستوری پرتکرار — در جست‌وجو بی‌معنا هستند
STOPWORDS = frozenset("""
و در به از که با این آن است هست بود بودن شد شدن شود کرد کردن کرده کند کنی
برای را های ها هایی هم نیز اما یا اگر تا بر روی زیر بین پس قبل بعد همه هر چه
چیزی کس کسی کجا چرا چگونه چطور چند یکی دو سه ما شما آنها ایشان من تو او
کدام کی چی چه‌قدر چقدر
می نخواهد خواهد باشد باشند هستیم هستند بودم بودیم دارد دارند داشته داد دهد
بله خیر آیا فقط نیز خیلی بسیار کمی دیگر دیگری همان همین وقتی زمانی وقتیکه
اساساً لطفاً میشه میکنه هستن هستم
""".split())
# علائم نگارشی فارسی/لاتین — قبل از توکن‌سازی حذف می‌شوند (نیم‌فاصله جزو اینها نیست)
_PUNCT_RE = re.compile(r"[،؛؟!,;:\?\"'()\[\]{}«»….*#@&%+=/\\|~^$\u066d\u066c\u066b]+")
# توکن: حروف/ارقام فارسی و لاتین + نیم‌فاصله در میان کلمه
_TOKEN_RE = re.compile(r"[\w\u0600-\u06ff\u200c]+")
def normalize(text: str) -> str:
"""نویسه‌های غیراستاندارد عربی/فارسی را به شکل یکدست فارسی می‌برد."""
if not text:
return ""
out = []
for ch in text:
out.append(_CHAR_MAP.get(ch, ch))
text = "".join(out)
text = _DIACRITICS.sub("", text)
text = text.translate(_DIGITS)
# فاصله‌های تکراری را یکی کن — نیم‌فاصله عمداً حفظ می‌شود (می‌شود)
text = re.sub(r"[ \t\r\n]+", " ", text)
return text.strip()
def tokenize(text: str) -> list[str]:
"""توکن‌های نرمال‌شده برای BM25 — کلمه‌های مرکب روی نیم‌فاصله هم جدا ایندکس می‌شوند."""
text = _PUNCT_RE.sub(" ", normalize(text).lower())
tokens = []
for raw in _TOKEN_RE.findall(text):
if raw in STOPWORDS or len(raw) < 2:
continue
tokens.append(raw)
# «می‌شود» → می + شود (فراخوانی بیشتر برای بازیابی)
if "\u200c" in raw:
for part in raw.split("\u200c"):
if len(part) >= 2 and part not in STOPWORDS:
tokens.append(part)
return tokens
+191
View File
@@ -0,0 +1,191 @@
"""گراف گفتگو (LangGraph) — پورت خط لوله v13 در سه گام:
۱) بازنویسی پرسش با تاریخچه (فقط وقتی تاریخچه هست — صرفه‌جویی در تأخیر)
۲) بازیابی ترکیبی با چند نوع‌واژه (اختیاری)
۳) تولید پاسخ فارسی با قواعد تکیه بر داده
"""
import logging
import re
import json as _json
from typing import Annotated, List, Optional
from typing_extensions import TypedDict
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from . import content as content_mod
from .config import settings
from .retrieval import retrieve_final
log = logging.getLogger(__name__)
FALLBACK_MESSAGE = (
"متأسفانه اطلاعات دقیقی در این باره در سایت ثبت نشده است. "
"برای راهنمایی بیشتر می‌توانید از صفحه «تماس با ما» با ما در ارتباط باشید."
)
LLM_UNAVAILABLE = (
"سرویس هوش مصنوعی هنوز پیکربندی نشده است. "
"متغیرهای LLM_BASE_URL و LLM_MODEL را در تنظیمات سرویس چت‌بات تنظیم کنید."
)
_llm = None
def get_llm() -> ChatOpenAI | None:
"""LLM تولید — اگر پیکربندی نشده باشد None برمی‌گرداند."""
global _llm
if _llm is None and settings.llm_base_url:
_llm = ChatOpenAI(
model=settings.llm_model,
base_url=settings.llm_base_url,
api_key=settings.llm_api_key or "not-needed",
temperature=0,
streaming=True,
)
return _llm
class GraphState(TypedDict):
messages: Annotated[List, add_messages]
documents: List[str]
contextualized_query: Optional[str]
# --- گام ۱: بازنویسی پرسش با تاریخچه ---
def contextualize_query(question: str, history: list) -> str:
"""ضمیرها و اشاره‌های پرسش را با تاریخچه گفتگو مشخص می‌کند («اون دومی» → موضوع واقعی)."""
model = get_llm()
if model is None or not history:
return question
try:
history_text = "\n".join(
f"{'کاربر' if isinstance(m, HumanMessage) else 'دستیار'}: {str(m.content)[:300]}"
for m in history[-6:]
)
prompt = (
"با توجه به تاریخچه گفتگو، پرسش کاربر را به یک پرسش مستقل و کامل فارسی بازنویسی کن.\n"
"- ضمیرها و اشاره‌ها (او، آن، همین، دومی، این یکی) را به موضوع واقعیِ ذکرشده در تاریخچه تبدیل کن.\n"
"- اگر پرسش از قبل مستقل است، عیناً همان را برگردان.\n"
"- فقط متن پرسش بازنویسی‌شده را برگردان؛ هیچ توضیحی ننویس.\n\n"
f"تاریخچه گفتگو:\n{history_text}\n\n"
f"پرسش کاربر: {question}\n\n"
"پرسش مستقل:"
)
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
rewritten = (resp.content or "").strip() if isinstance(resp.content, str) else ""
if not rewritten or len(rewritten) > len(question) * 4:
return question
if rewritten != question:
log.info("Contextualized: '%s' → '%s'", question[:60], rewritten[:60])
return rewritten
except Exception as e:
log.warning("Contextualization failed: %s", e)
return question
def expand_query(query: str) -> list[str]:
"""گسترش پرسش به چند نوع‌واژه (اختیاری — پیش‌فرض خاموش برای پیکره کوچک)."""
model = get_llm()
if model is None:
return [query]
try:
prompt = (
"برای جست‌وجو در محتوای سایت یک گروه ساختمانی، ۳ نوع‌واژه جایگزین برای این پرسش بساز: "
"بازنویسی معنایی، استخراج کلیدواژه، گسترش با مفاهیم مرتبط.\n"
'فقط JSON برگردان: {"variants": ["۱", "۲", "۳"]}\n\n'
f"پرسش: {query}"
)
resp = model.invoke([HumanMessage(content=prompt)], config={"tags": ["utility"]})
text = resp.content if isinstance(resp.content, str) else ""
match = re.search(r'\{.*"variants".*\}', text, re.DOTALL)
if match:
variants = [v for v in _json.loads(match.group()).get("variants", []) if v and v != query][:3]
if variants:
log.info("Query variants: %s", [query] + variants)
return [query] + variants
except Exception as e:
log.warning("Query expansion failed: %s", e)
return [query]
# --- گام ۲: بازیابی ---
def retrieve_node(state: GraphState) -> dict:
messages = state.get("messages", [])
if not messages:
return {"documents": []}
question = messages[-1].content
history = messages[:-1]
query = contextualize_query(question, history) if settings.contextualize_enabled else question
variants = expand_query(query) if settings.query_expansion else [query]
docs = retrieve_final(query, variants)
doc_texts = [f"{d['text']}\n(منبع: {d['title']} — {d['url']})" for d in docs]
log.info("Retrieved %d documents for '%s'", len(doc_texts), question[:60])
return {
"documents": doc_texts,
"contextualized_query": query if query != question else None,
}
# --- گام ۳: تولید پاسخ ---
def build_system_prompt(documents: list[str], contextualized_query: str | None) -> str:
site_ctx = content_mod.fetch_site_context(settings.database_url, settings.site_context_ttl)
docs_block = "\n\n———\n\n".join(documents) if documents else "(سند مرتبطی یافت نشد)"
reminder = (
f"\n--- یادآوری ---\nپرسش کاربر پس از بازنویسی این است: «{contextualized_query}»\n\n"
if contextualized_query else ""
)
return (
"تو دستیار گفتگوی هوشمند سایت «گروه ساختمانی آرامش» هستی و فقط به فارسی پاسخ می‌دهی.\n"
"دانش تو اکیداً محدود به اطلاعات زیر است.\n\n"
f"--- اطلاعات پایه سایت (همیشه معتبر) ---\n{site_ctx}\n\n"
f"--- اسناد بازیابی‌شده مرتبط با پرسش ---\n{docs_block}\n"
f"{reminder}"
"--- قوانین پاسخ ---\n"
"۱. فقط بر اساس اطلاعات بالا پاسخ بده؛ به‌هیچ‌وجه از دانش بیرونی استفاده نکن و چیزی نساز.\n"
"۲. پاسخ کوتاه، دقیق و مفید باشد (معمولاً ۲ تا ۵ جمله) و لحن حرفه‌ای و محترمانه داشته باشد.\n"
f"۳. اگر اطلاعات کافی در اسناد نیست، فقط همین جمله را بنویس: «{FALLBACK_MESSAGE}»\n"
"۴. اگر سند مسیر صفحه مرتبط دارد، در پایان پاسخ آن را معرفی کن (مثلاً: جزئیات کامل در صفحه /blog/... ).\n"
"۵. درباره قیمت، زمان دقیق یا هر تعهدی فقط چیزی را بگو که در داده‌ها آمده است."
)
def generate_node(state: GraphState) -> dict:
messages = state.get("messages", [])
question = messages[-1].content if messages else ""
model = get_llm()
if model is None:
return {"messages": [AIMessage(content=LLM_UNAVAILABLE)]}
documents = state.get("documents") or []
system = build_system_prompt(documents, state.get("contextualized_query"))
log.info("Generating answer with %d docs (system prompt %d chars)", len(documents), len(system))
history = messages[:-1]
reply = model.invoke(
[SystemMessage(content=system), *history, HumanMessage(content=question)],
config={"tags": ["generate"]},
)
return {"messages": [reply]}
# --- گراف ---
memory = MemorySaver()
workflow = StateGraph(GraphState)
workflow.add_node("retrieve", retrieve_node)
workflow.add_node("generate", generate_node)
workflow.set_entry_point("retrieve")
workflow.add_edge("retrieve", "generate")
workflow.add_edge("generate", END)
graph = workflow.compile(checkpointer=memory)
+20
View File
@@ -0,0 +1,20 @@
# سرویس چت‌بات — بازیابی ترکیبی روی محتوای سایت
fastapi>=0.115
uvicorn[standard]>=0.30
pydantic-settings>=2.3
# گراف گفتگو و LLM
langgraph>=0.2.60
langchain>=0.3
langchain-openai>=0.2
langchain-text-splitters>=0.3
# بازیابی — بردارها در pgvector می‌روند؛ BM25 و خوشه‌بندی در حافظه
sentence-transformers>=3.0
langchain-huggingface>=0.1
rank-bm25>=0.2.2
scikit-learn>=1.4
numpy>=1.26
# دیتابیس (فقط خواندن)
psycopg[binary]>=3.1
+50
View File
@@ -0,0 +1,50 @@
"""مدل ری‌رنکر BGE — با لود تنبل و امتیازدهی دسته‌ای."""
import logging
import os
from functools import lru_cache
from .config import settings
log = logging.getLogger(__name__)
class _CrossEncoder:
"""غلاف سبک روی AutoModelForSequenceClassification — API شبیه rank_bm25."""
def __init__(self) -> None:
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
os.makedirs(settings.hf_cache_dir, exist_ok=True)
self._torch = torch
self.tokenizer = AutoTokenizer.from_pretrained(settings.reranker_model, cache_dir=settings.hf_cache_dir)
self.model = AutoModelForSequenceClassification.from_pretrained(
settings.reranker_model, cache_dir=settings.hf_cache_dir
)
device = settings.reranker_device if settings.reranker_device == "cuda" and torch.cuda.is_available() else "cpu"
self.model.to(device)
self.model.eval()
self.device = device
log.info("Reranker loaded on %s", device)
def score(self, pairs: list[list[str]], batch_size: int = 16) -> list[float]:
torch = self._torch
out: list[float] = []
with torch.no_grad():
for i in range(0, len(pairs), batch_size):
inputs = self.tokenizer(
pairs[i:i + batch_size],
padding=True,
truncation=True,
max_length=512,
return_tensors="pt",
).to(self.device)
logits = self.model(**inputs).logits.squeeze(-1)
out.extend(logits.cpu().tolist())
return out
@lru_cache(maxsize=1)
def get_reranker() -> _CrossEncoder:
return _CrossEncoder()
+165
View File
@@ -0,0 +1,165 @@
"""بازیابی ترکیبی — پورت خط لوله v13 با سه تغییر برای این سایت:
۱) پایگاه sparse به‌جای SPLADE (انگلیسی‌محور و بی‌فایده روی فارسی) از BM25
با نرمال‌سازی فارسی استفاده می‌کند.
۲) جست‌وجوی معنایی به‌جای FAISS با pgvector و فاصله کسینوسی در SQL انجام
می‌شود (همگام‌سازی افزایشی، فیلترپذیر، بدون فایل ایندکس جدا).
۳) مسیریابی alpha با LLM حذف شده و وزن ثابت تنظیم‌پذیر است — برای پیکرهٔ
کوچک سایت، هزینهٔ یک فراخوانی LLM اضافی به‌ازای هر پیام نمی‌ارزد.
بقیهٔ منطق (نرمال‌سازی نمره، فیوژن وزن‌دار، برش خوشه‌بندی، ری‌رنک) عین v13 است.
"""
import logging
import time
from . import vectorstore
from .config import settings
from .indexing import INDEX
from .persian import tokenize
log = logging.getLogger(__name__)
def dense_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
"""جست‌وجوی معنایی pgvector — خروجی: (سند، شباهت کسینوسی)."""
return vectorstore.dense_search(query, top_k)
def keyword_retrieve(query: str, top_k: int) -> list[tuple[dict, float]]:
"""جست‌وجوی کلیدواژه BM25 روی متن نرمال‌شده فارسی."""
if INDEX.bm25 is None:
return []
try:
tokens = tokenize(query)
if not tokens:
return []
scores = INDEX.bm25.get_scores(tokens)
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
return [(INDEX.docs[i], float(scores[i])) for i in ranked if scores[i] > 0]
except Exception as e:
log.error("BM25 retrieval failed: %s", e)
return []
def _minmax(scores: list[float]) -> list[float]:
if not scores:
return []
lo, hi = min(scores), max(scores)
if hi == lo:
return [0.5] * len(scores)
return [(s - lo) / (hi - lo) for s in scores]
def hybrid_retrieve(query: str, alpha: float | None = None, top_k: int | None = None) -> list[tuple[dict, float]]:
"""ترکیب وزن‌دار نمره معنایی و کلیدواژه؛ خروجی مرتب‌شده بر اساس نمره ترکیبی."""
alpha = settings.alpha_dense if alpha is None else alpha
top_k = top_k or settings.retrieve_k
dense = dense_retrieve(query, top_k)
keyword = keyword_retrieve(query, top_k)
log.info("Retrieval '%s': dense=%d, keyword=%d", query[:60], len(dense), len(keyword))
if not dense and not keyword:
return []
dense_norm = _minmax([s for _, s in dense])
keyword_norm = _minmax([s for _, s in keyword])
pool: dict = {} # doc_id → {doc, dense_max, kw_max}
for (doc, _), norm in zip(dense, dense_norm):
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
entry["dense_max"] = max(entry["dense_max"], norm)
for (doc, _), norm in zip(keyword, keyword_norm):
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "dense_max": 0.0, "kw_max": 0.0})
entry["kw_max"] = max(entry["kw_max"], norm)
combined = [
(entry["doc"], alpha * entry["dense_max"] + (1 - alpha) * entry["kw_max"])
for entry in pool.values()
]
combined.sort(key=lambda x: x[1], reverse=True)
return combined
def cluster_cutoff(scored: list[tuple[dict, float]], silhouette_threshold: float = 0.55) -> list[tuple[dict, float]]:
"""برش خوشه‌ای v13 — مرز طبیعی کیفیت به‌جای top-k دلخواه.
روی نمره‌های ۱بعدی K-Means با K=2 می‌زند؛ اگر جدایی خوشه‌ها معنادار
باشد (silhouette ≥ 0.55) فقط خوشهٔ بالا را نگه می‌دارد.
"""
import numpy as np
n = len(scored)
if n < 4:
return scored
try:
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
scores = np.array([s for _, s in scored], dtype=float).reshape(-1, 1)
kmeans = KMeans(n_clusters=2, random_state=42, n_init=10)
labels = kmeans.fit_predict(scores)
sil = silhouette_score(scores, labels)
if sil < silhouette_threshold:
log.info("Clustering: silhouette=%.3f < %.2f — keeping all %d docs", sil, silhouette_threshold, n)
return scored
centers = kmeans.cluster_centers_.flatten()
good_label = int(np.argmax(centers))
cutoff = int(np.sum(labels == good_label))
cutoff = max(cutoff, 1)
log.info("Clustering: silhouette=%.3f — cutting at %d/%d docs", sil, cutoff, n)
return scored[:cutoff]
except Exception as e:
log.warning("Clustering failed (%s) — keeping all docs", e)
return scored
def rerank(query: str, scored: list[tuple[dict, float]], top_k: int) -> list[tuple[dict, float]]:
"""ری‌رنک کراس‌انکودر BGE — مرتب‌سازی نهایی بر اساس شباهت واقعی پرسش/سند."""
if not scored:
return []
if not settings.reranker_enabled:
return scored[:top_k]
try:
from .reranker import get_reranker
start = time.time()
pairs = [[query, doc["text"]] for doc, _ in scored]
scores = get_reranker().score(pairs)
rescored = sorted(
((doc, float(s)) for (doc, _), s in zip(scored, scores)),
key=lambda x: x[1],
reverse=True,
)
log.info("Reranked %d docs in %.2fs — top scores: %s",
len(scored), time.time() - start,
[f"{s:.2f}" for _, s in rescored[:3]])
return rescored[:top_k]
except Exception as e:
log.error("Reranking failed (%s) — falling back to fusion order", e)
return scored[:top_k]
def retrieve_final(query: str, query_variants: list[str] | None = None) -> list[dict]:
"""خط کامل بازیابی برای یک پرسش: هر نوع → فیوژن → برش خوشه → ری‌رنک → K نهایی."""
variants = query_variants or [query]
pool: dict = {}
for variant in variants:
for doc, score in hybrid_retrieve(variant):
entry = pool.setdefault(doc["doc_id"], {"doc": doc, "score": 0.0})
entry["score"] = max(entry["score"], score)
combined = sorted(((e["doc"], e["score"]) for e in pool.values()),
key=lambda x: x[1], reverse=True)
if not combined:
return []
filtered = cluster_cutoff(combined)
# ری‌رنک روی نتیجه‌های برش‌خورده (سقف retrieve_k تا تأخیر کنترل شود)
rerank_candidates = filtered[: settings.retrieve_k]
final = rerank(query, rerank_candidates, settings.best_of_k)
log.info("Final retrieval: %d docs (from %d candidates, %d variants)",
len(final), len(combined), len(variants))
return [doc for doc, _ in final]
+70
View File
@@ -0,0 +1,70 @@
"""تست‌های خالص (بدون مدل/دیتابیس) برای ماژول‌های چت‌بات — اجرا: python test_pure.py"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from chatbot.content import chunk_documents, tiptap_blocks, body_to_text
from chatbot.vectorstore import chunk_content_hash, chatbot_dsn
from chatbot.persian import normalize, tokenize
docs = [
{"text": "پاراگراف کوتاه خدمت الف", "type": "service", "title": "الف", "url": "/services"},
{"text": "سؤال اول؟", "type": "faq", "title": "س۱", "url": "/faq"},
{"text": "سؤال دوم؟", "type": "faq", "title": "س۲", "url": "/faq"},
{"text": "آ" * 2000, "type": "article", "title": "مقاله بلند", "url": "/blog/long"},
]
chunks = chunk_documents(docs)
ids = [c["doc_id"] for c in chunks]
assert len(ids) == len(set(ids)), f"duplicate ids: {ids}"
faq_ids = sorted(c["doc_id"] for c in chunks if c["type"] == "faq")
assert faq_ids == ["faq:0:1", "faq:1:1"], faq_ids
art = [c for c in chunks if c["type"] == "article"]
assert len(art) >= 2 and art[1]["doc_id"] == "article:0:2", [x["doc_id"] for x in art]
assert "بخش 2 از" in art[1]["text"] and "بخش 1 از" in art[0]["text"]
c = chunks[0]
assert c["content_hash"] == chunk_content_hash(c)
c2 = dict(c, title="عنوان عوض شد")
# hash فیلد کپی‌شده است — باید با محاسبه دوباره مقایسه شود، نه با فیلد قدیمی
assert chunk_content_hash(c2) != c["content_hash"]
chunks2 = chunk_documents(docs)
assert [x["doc_id"] for x in chunks2] == ids
assert [x["content_hash"] for x in chunks2] == [x["content_hash"] for x in chunks]
settings_dsn = chatbot_dsn()
assert settings_dsn.endswith("/aramesh_chatbot"), settings_dsn
assert "+psycopg" not in settings_dsn
# نرمال‌سازی و توکن‌سازی فارسی
assert normalize("كامل يك ۱۲۳") == "کامل یک 123"
t = tokenize("کدام شرکت ساختمانی خوب در تهران؟")
assert "شرکت" in t and "تهران" in t and "کدام" not in t, t
assert "می\u200cسازیم" in tokenize("می\u200cسازیم") and "سازیم" in tokenize("می\u200cسازیم")
# استخراج متن TipTap و فرمت قدیمی
tiptap_doc = {
"type": "doc",
"content": [
{"type": "heading", "attrs": {"level": 2}, "content": [{"type": "text", "text": "معرفی پروژه"}]},
{"type": "paragraph", "content": [
{"type": "text", "text": "این برج در تهران "},
{"type": "text", "marks": [{"type": "bold"}], "text": "ساخته شد"},
{"type": "hardBreak"},
{"type": "image", "attrs": {"alt": "نمای بیرونی"}},
]},
{"type": "bulletList", "content": [
{"type": "listItem", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "۲۴ طبقه"}]}]},
]},
{"type": "paragraph", "content": [{"type": "image", "attrs": {"alt": ""}}]},
],
}
blocks = tiptap_blocks(tiptap_doc)
assert blocks == ["معرفی پروژه", "این برج در تهران ساخته شد [تصویر: نمای بیرونی]", "۲۴ طبقه"], blocks
assert body_to_text(["الف", "ب"]) == "الف\nب"
assert body_to_text(None) == ""
print(f"ALL PURE TESTS OK — {len(chunks)} chunks from {len(docs)} docs")
+211
View File
@@ -0,0 +1,211 @@
"""ذخیره‌ساز برداری pgvector — جایگزین FAISS در مهاجرت به pgvector.
بردارها در یک دیتابیس جدا (aramesh_chatbot) روی همان نمونه Postgres سایت
نگهداری می‌شوند تا دیتابیس سایت فقط‌خواندنی بماند. همگام‌سازی افزایشی است:
فقط سطرهای جدید/تغییریافته امبد می‌شوند (کلید: doc_id پایدار + hash محتوا).
"""
import hashlib
import json
import logging
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from .config import settings
log = logging.getLogger(__name__)
def _strip_driver(dsn: str) -> str:
return dsn.replace("+psycopg", "").replace("+asyncpg", "")
def chatbot_dsn() -> str:
"""DSN دیتابیس اختصاصی چت‌بات — از DATABASE_URL با تعویض نام دیتابیس ساخته می‌شود."""
parts = urlsplit(_strip_driver(settings.database_url))
return urlunsplit(parts._replace(path=f"/{settings.chatbot_db_name}"))
def _admin_dsn() -> str:
"""DSN دیتابیس postgres برای ساخت دیتابیس چت‌بات (فقط یک‌بار)."""
parts = urlsplit(_strip_driver(settings.database_url))
return urlunsplit(parts._replace(path="/postgres"))
def ensure_schema() -> None:
"""ساخت دیتابیس و جدول بردارها — idempotent و در هر شروع اجرا می‌شود."""
import psycopg
from psycopg import errors as pg_errors
try:
with psycopg.connect(_admin_dsn(), autocommit=True, connect_timeout=10) as conn:
try:
conn.execute(f'CREATE DATABASE "{settings.chatbot_db_name}"')
log.info("Created chatbot database '%s'", settings.chatbot_db_name)
except pg_errors.DuplicateDatabase:
pass
except Exception as e:
raise RuntimeError(f"Cannot reach Postgres to ensure chatbot database: {e}") from e
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS chatbot_chunks (
doc_id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
model TEXT NOT NULL,
content JSONB NOT NULL,
embedding vector({settings.embedding_dim}) NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS chatbot_chunks_embedding_hnsw
ON chatbot_chunks USING hnsw (embedding vector_cosine_ops)
"""
)
def sync_chunks(chunks: list[dict], force: bool = False) -> dict:
"""همگام‌سازی افزایشی قطعات با جدول pgvector.
- سطرهایی که دیگر وجود ندارند حذف می‌شوند
- سطرهای جدید یا تغییر یافته (hash یا مدل امبدینگ عوض شده) امبد و upsert می‌شوند
- force=True همه سطرها را از نو امبد می‌کند
خروجی: آمار عملیات برای لاگ.
"""
import psycopg
from psycopg.types.json import Jsonb
from .embeddings import get_embeddings
model = settings.embedding_model
wanted = {c["doc_id"]: c for c in chunks}
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
existing: dict[str, str] = {}
if not force:
for row in conn.execute("SELECT doc_id, content_hash, model FROM chatbot_chunks").fetchall():
if row["model"] == model:
existing[row["doc_id"]] = row["content_hash"]
else:
conn.execute("DELETE FROM chatbot_chunks")
to_delete = [doc_id for doc_id in existing if doc_id not in wanted]
if to_delete:
conn.execute(
"DELETE FROM chatbot_chunks WHERE doc_id = ANY(%s)",
[to_delete],
)
to_upsert = [
c for c in chunks
if existing.get(c["doc_id"]) != c["content_hash"]
]
inserted = updated = 0
if to_upsert:
vectors = get_embeddings().embed_documents([c["text"] for c in to_upsert])
for chunk, vec in zip(to_upsert, vectors):
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
conn.execute(
"""
INSERT INTO chatbot_chunks (doc_id, content_hash, model, content, embedding)
VALUES (%s, %s, %s, %s, %s::vector)
ON CONFLICT (doc_id) DO UPDATE SET
content_hash = EXCLUDED.content_hash,
model = EXCLUDED.model,
content = EXCLUDED.content,
embedding = EXCLUDED.embedding
""",
(chunk["doc_id"], chunk["content_hash"], model, Jsonb(chunk), literal),
)
if chunk["doc_id"] in existing:
updated += 1
else:
inserted += 1
stats = {"inserted": inserted, "updated": updated, "deleted": len(to_delete),
"unchanged": len(wanted) - len(to_upsert)}
log.info("Vector sync: %s", stats)
return stats
def fetch_all_chunks() -> list[dict]:
"""همه قطعات ذخیره‌شده (برای ساخت BM25 در حافظه)."""
import psycopg
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=10) as conn:
rows = conn.execute(
"SELECT doc_id, content_hash, content FROM chatbot_chunks ORDER BY doc_id"
).fetchall()
return [
{
"doc_id": r["doc_id"],
"content_hash": r["content_hash"],
**(r["content"] if isinstance(r["content"], dict) else json.loads(r["content"])),
}
for r in rows
]
def vector_count() -> int:
"""تعداد بردارهای ذخیره‌شده — برای healthz (خطا → ۰)."""
try:
import psycopg
with psycopg.connect(chatbot_dsn(), autocommit=True, connect_timeout=5) as conn:
return int(conn.execute("SELECT count(*) FROM chatbot_chunks").fetchone()[0])
except Exception:
return 0
def dense_search(query: str, top_k: int) -> list[tuple[dict, float]]:
"""جست‌وجوی معنایی SQL با فاصله کسینوسی — خروجی: (سند، شباهت).
خطا (مثلاً دیتابیس هنوز آماده نیست) → فهرست خالی تا بازیابی فقط با BM25 ادامه یابد.
"""
try:
import psycopg
from psycopg.rows import dict_row as _dict_row
from .embeddings import get_embeddings
vec = get_embeddings().embed_query(query)
literal = "[" + ",".join(f"{x:.7f}" for x in vec) + "]"
with psycopg.connect(chatbot_dsn(), row_factory=_dict_row, connect_timeout=10) as conn:
rows = conn.execute(
f"""
SELECT doc_id, content, 1 - (embedding <=> %s::vector) AS sim
FROM chatbot_chunks
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(literal, literal, top_k),
).fetchall()
return [(_row_to_doc(r), float(r["sim"])) for r in rows]
except Exception as e:
log.warning("Dense search failed (%s) — falling back to keyword only", e)
return []
def _row_to_doc(row: Any) -> dict:
content = row["content"] if isinstance(row["content"], dict) else json.loads(row["content"])
return {
"doc_id": row["doc_id"],
"text": content.get("text", ""),
"type": content.get("type", ""),
"title": content.get("title", ""),
"url": content.get("url", ""),
}
def chunk_content_hash(chunk: dict) -> str:
"""هش محتوای قطعه — تغییر متن یا متادیتا یعنی سطر باید به‌روز شود."""
payload = json.dumps(
{k: chunk[k] for k in ("text", "type", "title", "url")},
ensure_ascii=False, sort_keys=True,
)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
+185
View File
@@ -0,0 +1,185 @@
# لایه مانیتورینگ — Prometheus، Grafana، Loki، Alertmanager و صادرکننده‌ها
#
# توسعه: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
# تولید: سه فایل با هم: base + monitoring + prod (بخش مانیتورینگ README)
#
# داشبورد: grafana.localhost (توسعه) — رمز از GRAFANA_ADMIN_PASSWORD در .env
# هشدارها: Prometheus → Alertmanager → تلگرام (ALERT_TELEGRAM_* در .env)
# منابع: مجموعاً حدود ۱ تا ۱.۵ گیگابایت RAM — با حذف هر سرویس، بقیه سرپا می‌مانند.
x-default-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
prometheus:
image: prom/prometheus:v2.53.0
restart: unless-stopped
logging: *default-logging
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --storage.tsdb.retention.size=2GB
volumes:
- ./monitoring/prometheus:/etc/prometheus:ro
- prometheus_data:/prometheus
networks: [monitoring, edge]
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 5s
retries: 5
start_period: 15s
deploy:
resources:
limits:
memory: 768M
alertmanager:
image: prom/alertmanager:v0.27.0
restart: unless-stopped
logging: *default-logging
command:
- --config.file=/etc/alertmanager/alertmanager.rendered.yml
- --storage.path=/alertmanager
# فایل رندرشدهٔ alertmanager.rendered.yml را scripts/stack.sh از قالب
# alertmanager.yml با مقادیر .env (ALERT_BOT_TOKEN/ALERT_CHAT_ID) می‌سازد —
# Alertmanager خودش متغیر محیطی را در کانفیگ جایگزین نمی‌کند
volumes:
- ./monitoring/alertmanager:/etc/alertmanager:ro
- alertmanager_data:/alertmanager
networks: [monitoring]
deploy:
resources:
limits:
memory: 128M
# منابع هر کانتینر — روی Linux سرور کامل کار می‌کند؛ روی Docker Desktop ویندوز
# ممکن است محدود باشد (حذفش بقیه استک را نمی‌شکند)
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
restart: unless-stopped
logging: *default-logging
privileged: true
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
networks: [monitoring]
deploy:
resources:
limits:
memory: 192M
# منابع میزبان + سنجهٔ تازگی پشتیبان (backups/*.prom نوشتهٔ scripts/backup.sh)
node-exporter:
image: quay.io/prometheus/node-exporter:v1.8.2
restart: unless-stopped
logging: *default-logging
command:
- --path.rootfs=/host
- --collector.textfile.directory=/textfile
volumes:
- /:/host:ro
- ./backups:/textfile:ro
networks: [monitoring]
deploy:
resources:
limits:
memory: 64M
postgres-exporter:
image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0
restart: unless-stopped
logging: *default-logging
environment:
DATA_SOURCE_NAME: postgresql://aramesh:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}@db:5432/arameshplaza?sslmode=disable
networks: [internal, monitoring]
deploy:
resources:
limits:
memory: 64M
blackbox-exporter:
image: prom/blackbox-exporter:v0.25.0
restart: unless-stopped
logging: *default-logging
networks: [monitoring]
deploy:
resources:
limits:
memory: 64M
# لاگ همهٔ کانتینرها — جست‌وجو در Grafana؛ خطاهای بک‌اند از همین‌جا دیده می‌شوند
loki:
image: grafana/loki:3.1.1
restart: unless-stopped
logging: *default-logging
command: -config.file=/etc/loki/loki-config.yml
volumes:
- ./monitoring/loki:/etc/loki:ro
- loki_data:/loki
networks: [monitoring]
deploy:
resources:
limits:
memory: 512M
promtail:
image: grafana/promtail:3.1.1
restart: unless-stopped
logging: *default-logging
command: -config.file=/etc/promtail/promtail-config.yml
volumes:
- ./monitoring/promtail:/etc/promtail:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- promtail_positions:/positions
networks: [monitoring]
depends_on: [loki]
deploy:
resources:
limits:
memory: 128M
grafana:
image: grafana/grafana:11.1.0
restart: unless-stopped
logging: *default-logging
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?GRAFANA_ADMIN_PASSWORD را در .env تنظیم کنید}
GF_USERS_ALLOW_SIGN_UP: "false"
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
networks: [monitoring, edge]
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/api/health || wget -q --spider http://localhost:3000/api/health"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
labels:
- traefik.enable=true
- traefik.http.routers.grafana.rule=Host(`grafana.localhost`)
- traefik.http.routers.grafana.entrypoints=web
- traefik.http.services.grafana.loadbalancer.server.port=3000
deploy:
resources:
limits:
memory: 384M
networks:
monitoring:
volumes:
prometheus_data:
alertmanager_data:
loki_data:
grafana_data:
promtail_positions:
+63 -9
View File
@@ -1,15 +1,18 @@
# لایه تولید — TLS با Let's Encrypt و انتشار روی پورت‌های استاندارد ۸۰/۴۴۳. # لایه تولید — TLS با Let's Encrypt و انتشار روی پورت‌های استاندارد ۸۰/۴۳.
# پیش‌نیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ به‌علاوه رکورد DNS دامنه به این سرور. # پیش‌نیاز: متغیرهای DOMAIN و ACME_EMAIL در .env؛ به‌علاوه رکورد DNS دامنه به این سرور.
# #
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build # bash scripts/stack.sh deploy-prod
# (معادل: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build)
# #
# همه درخواست‌های HTTP به HTTPS هدایت می‌شوند و مسیریابی‌ها فقط روی دامنه DOMAIN پاسخ می‌دهند. # همه درخواست‌های HTTP به HTTPS هدایت می‌شوند و مسیریابی‌ها فقط روی دامنه DOMAIN پاسخ می‌دهند.
# برای داشبوردها: analytics.DOMAIN (آنالیتیکس) و grafana.DOMAIN (مانیتورینگ، با فایل monitoring).
services: services:
traefik: traefik:
command: !override command: !override
- --providers.docker=true - --providers.docker=true
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
- --entrypoints.web.http.redirections.entrypoint.to=websecure - --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https - --entrypoints.web.http.redirections.entrypoint.scheme=https
@@ -18,6 +21,9 @@ services:
- --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true - --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
- --ping=true
- --accesslog=true
- --metrics.prometheus=true
ports: !override ports: !override
- "80:80" - "80:80"
- "443:443" - "443:443"
@@ -25,6 +31,23 @@ services:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- letsencrypt_data:/letsencrypt - letsencrypt_data:/letsencrypt
# MinIO کنسول (رمز root) و API مستقیمش فقط از localhost سرور در دسترس باشند —
# از راه دور با تونل SSH: ssh -L 9001:127.0.0.1:9001 server
minio:
ports: !override
- "127.0.0.1:9001:9001"
- "127.0.0.1:19000:9000"
labels:
- traefik.enable=true
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
- traefik.http.routers.media.entrypoints=websecure
- traefik.http.routers.media.tls.certresolver=le
- traefik.http.routers.media.priority=100
- traefik.http.routers.media.middlewares=media-rewrite,sec-headers
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
- traefik.http.services.media.loadbalancer.server.port=9000
backend: backend:
environment: environment:
# سایت هم‌مبدأ است — CORS لازم نیست # سایت هم‌مبدأ است — CORS لازم نیست
@@ -35,16 +58,11 @@ services:
- traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`) - traefik.http.routers.api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=websecure - traefik.http.routers.api.entrypoints=websecure
- traefik.http.routers.api.tls.certresolver=le - traefik.http.routers.api.tls.certresolver=le
- traefik.http.routers.api.middlewares=api-flood,sec-headers
- traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`)) - traefik.http.routers.seo.rule=Host(`${DOMAIN}`) && (PathPrefix(`/blog/`) || Path(`/blog`) || PathPrefix(`/projects/`) || Path(`/projects`) || Path(`/en`) || PathPrefix(`/en/`) || PathPrefix(`/robots.txt`) || PathPrefix(`/sitemap.xml`))
- traefik.http.routers.seo.entrypoints=websecure - traefik.http.routers.seo.entrypoints=websecure
- traefik.http.routers.seo.tls.certresolver=le - traefik.http.routers.seo.tls.certresolver=le
- traefik.http.routers.seo.middlewares=sec-headers
minio:
labels:
- traefik.enable=true
- traefik.http.routers.media.rule=Host(`${DOMAIN}`) && PathPrefix(`/media`)
- traefik.http.routers.media.entrypoints=websecure
- traefik.http.routers.media.tls.certresolver=le
frontend: frontend:
labels: labels:
@@ -52,6 +70,42 @@ services:
- traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`) - traefik.http.routers.web.rule=Host(`${DOMAIN}`) && PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=websecure - traefik.http.routers.web.entrypoints=websecure
- traefik.http.routers.web.tls.certresolver=le - traefik.http.routers.web.tls.certresolver=le
- traefik.http.routers.web.middlewares=sec-headers
# تعریف یک‌بارهٔ میان‌افزار هدرهای امنیتی — روی همهٔ روترهای websecure استفاده می‌شود
- traefik.http.middlewares.sec-headers.headers.stsSeconds=31536000
- traefik.http.middlewares.sec-headers.headers.stsIncludeSubdomains=true
- traefik.http.middlewares.sec-headers.headers.stsPreload=true
- traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true
chatbot:
labels:
- traefik.enable=true
- traefik.http.routers.chat.rule=Host(`${DOMAIN}`) && PathPrefix(`/chat`)
- traefik.http.routers.chat.entrypoints=websecure
- traefik.http.routers.chat.tls.certresolver=le
- traefik.http.routers.chat.priority=100
- traefik.http.routers.chat.middlewares=api-flood,sec-headers
umami:
labels:
- traefik.enable=true
- traefik.http.routers.analytics.rule=Host(`analytics.${DOMAIN}`)
- traefik.http.routers.analytics.entrypoints=websecure
- traefik.http.routers.analytics.tls.certresolver=le
- traefik.http.routers.analytics.middlewares=sec-headers
- traefik.http.services.analytics.loadbalancer.server.port=3000
# داشبورد Grafana (docker-compose.monitoring.yml) در تولید با TLS نیاز به روتر
# Host(`grafana.DOMAIN`) دارد — چون آن سرویس فقط با فایل monitoring بالا می‌آید،
# برچسب‌هایش را در override سرور بگذارید (README، بخش مانیتورینگ):
# docker-compose.override.yml روی سرور:
# services:
# grafana:
# labels:
# - traefik.http.routers.grafana.rule=Host(`grafana.YOURDOMAIN`)
# - traefik.http.routers.grafana.entrypoints=websecure
# - traefik.http.routers.grafana.tls.certresolver=le
# - traefik.http.services.grafana.loadbalancer.server.port=3000
volumes: volumes:
letsencrypt_data: letsencrypt_data:
+217 -20
View File
@@ -1,71 +1,117 @@
# مقادیر محرمانه از فایل .env ریشه خوانده می‌شوند — از .env.example کپی کنید: # مقادیر محرمانه از فایل .env ریشه خوانده می‌شوند — از .env.example کپی کنید:
# cp .env.example .env # cp .env.example .env (یا: bash scripts/gen-env.sh)
# برای انتشار با TLS، روی این فایل docker-compose.prod.yml را هم سوار کنید (README). # برای انتشار با TLS، روی این فایل docker-compose.prod.yml هم سوار شود (README):
# bash scripts/stack.sh deploy-prod
#
# لایه‌های اختیاری:
# docker-compose.monitoring.yml — Prometheus/Grafana/Loki (پروفایل زیرساخت)
# پروفایل ops — پشتیبان ساعتی دیتابیس
# پروفایل tools — ابزار mc برای پشتیبان‌گیری دستی
x-default-logging: &default-logging
# بدون این، لاگ json-file بی‌مرز رشد می‌کند و دیسک سرور را پر می‌کند
driver: json-file
options:
max-size: "10m"
max-file: "3"
services: services:
# نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند) و /media (MinIO) # نقطه ورود واحد — مسیریابی / (سایت استاتیک)، /api (بک‌اند)، /media (MinIO)،
# /chat (چت‌بات)، / (آنالیتیکس روی analytics.localhost — پایین همین فایل)
traefik: traefik:
image: traefik:v3 image: traefik:v3.3
restart: unless-stopped restart: unless-stopped
logging: *default-logging
command: command:
- --providers.docker=true - --providers.docker=true
- --providers.docker.exposedbydefault=false - --providers.docker.exposedbydefault=false
# فقط کانتینرهای همین استک — با استک‌های دیگر هم‌میزبان تداخل نام روتر پیش نمی‌آید # فقط کانتینرهای همین استک — با استک‌های دیگر هم‌میزبان تداخل نام روتر پیش نمی‌آید
- --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`) - --providers.docker.constraints=Label(`com.docker.compose.project`, `aramesh-backend`)
- --entrypoints.web.address=:80 - --entrypoints.web.address=:80
# healthcheck داخلی + متریک‌ها برای Prometheus + لاگ دسترسی
- --ping=true
- --accesslog=true
- --metrics.prometheus=true
ports: ports:
- "${TRAEFIK_HTTP_PORT:-5173}:80" - "${TRAEFIK_HTTP_PORT:-5173}:80"
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
networks: [edge]
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
deploy:
resources:
limits:
memory: 256M
db: db:
image: postgres:17-alpine # pgvector/pgvector:pg17 = همان PostgreSQL 17 + افزونهٔ vector برای چت‌بات
image: pgvector/pgvector:pg17
restart: unless-stopped restart: unless-stopped
logging: *default-logging
environment: environment:
POSTGRES_USER: aramesh POSTGRES_USER: aramesh
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
POSTGRES_DB: arameshplaza POSTGRES_DB: arameshplaza
volumes: volumes:
- db_data:/var/lib/postgresql/data - db_data:/var/lib/postgresql/data
shm_size: 256m
networks: [internal]
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"] test: ["CMD-SHELL", "pg_isready -U aramesh -d arameshplaza"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 12 retries: 12
start_period: 10s
deploy:
resources:
limits:
memory: 512M
minio: minio:
image: minio/minio:latest image: minio/minio:RELEASE.2025-04-22T22-12-26Z
restart: unless-stopped restart: unless-stopped
logging: *default-logging
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
volumes: volumes:
- minio_data:/data - minio_data:/data
networks: [edge, internal]
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 12 retries: 12
start_period: 10s
ports: ports:
- "9001:9001" # کنسول MinIO — در محیط واقعی با فایروال ببندید # در تولید، docker-compose.prod.yml این‌ها را فقط روی 127.0.0.1 می‌بندد —
- "19000:9000" # دسترسی مستقیم/لوکال به MinIO (اختیاری — تصاویر از مسیر /media سرو می‌شوند) # کنسول رمز root دارد و نباید به اینترنت باز باشد
- "9001:9001"
- "19000:9000"
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.media.rule=PathPrefix(`/media`) - traefik.http.routers.media.rule=PathPrefix(`/media`)
- traefik.http.routers.media.entrypoints=web - traefik.http.routers.media.entrypoints=web
- traefik.http.routers.media.priority=100 - traefik.http.routers.media.priority=100
- traefik.http.routers.media.middlewares=media-rewrite
- traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*) - traefik.http.middlewares.media-rewrite.replacepathregex.regex=^/media/(.*)
- traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1 - traefik.http.middlewares.media-rewrite.replacepathregex.replacement=/aramesh-media/$$1
- traefik.http.routers.media.middlewares=media-rewrite
- traefik.http.services.media.loadbalancer.server.port=9000 - traefik.http.services.media.loadbalancer.server.port=9000
deploy:
resources:
limits:
memory: 512M
# یک‌بار اجرا می‌شود: ساخت باکت، بازگذاشتن خواندن عمومی و پیش‌بارگذاری تصویرهای seed. # یک‌بار اجرا می‌شود: ساخت باکت، بازگذاشتن خواندن عمومی و پیش‌بارگذاری تصویرهای seed
# تصویرهای داخل seed-media/ (که با پیشوند /media در دیتابیس ثبت‌اند) روی یک حجم تازه # فقط وقتی باکت خالی است — تا هر deploy، کپی تکراری تصویرها را تکرار نکند.
# وارد باکت می‌شوند تا هر نسخه تازه‌کلون/بیلد، همان عکس‌ها را داشته باشد؛
# روی حجم موجود، mc cp فایل‌های تکراری را رد می‌کند و داده‌تازه را نمی‌خورد.
minio-setup: minio-setup:
image: minio/mc:latest image: minio/mc:RELEASE.2025-04-08T15-39-49Z
depends_on: depends_on:
minio: minio:
condition: service_healthy condition: service_healthy
@@ -76,19 +122,49 @@ services:
volumes: volumes:
- ./seed-media:/seed-media:ro - ./seed-media:/seed-media:ro
restart: "no" restart: "no"
networks: [internal]
logging: *default-logging
entrypoint: > entrypoint: >
/bin/sh -c " /bin/sh -c "
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD && mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb --ignore-existing local/$$MINIO_BUCKET && mc mb --ignore-existing local/$$MINIO_BUCKET &&
mc anonymous set download local/$$MINIO_BUCKET && mc anonymous set download local/$$MINIO_BUCKET &&
echo 'minio-setup: bucket ready' && COUNT=$$(mc ls --recursive local/$$MINIO_BUCKET | wc -l) &&
cd /seed-media && mc mirror --overwrite . local/$$MINIO_BUCKET/ && if [ \"$$COUNT\" -eq 0 ]; then
echo 'minio-setup: seed media mirrored' echo 'minio-setup: bucket ready — mirroring seed media';
mc mirror --overwrite /seed-media local/$$MINIO_BUCKET/;
else
echo \"minio-setup: bucket ready — $$COUNT object(s) already present, seed mirror skipped\";
fi
"
# یک‌بار اجرا می‌شود: دیتابیس‌های جانبی روی همان نمونهٔ Postgres
# (umami برای آنالیتیکس، aramesh_chatbot برای بردارهای چت‌بات)
db-init:
image: postgres:17-alpine
restart: "no"
depends_on:
db:
condition: service_healthy
environment:
PGHOST: db
PGUSER: aramesh
PGPASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
networks: [internal]
logging: *default-logging
entrypoint: >
/bin/sh -c "
for DBN in umami aramesh_chatbot; do
psql -tAc \"SELECT 1 FROM pg_database WHERE datname='$$DBN'\" | grep -q 1 ||
psql -c \"CREATE DATABASE $$DBN\";
echo \"db-init: $$DBN ready\";
done
" "
backend: backend:
build: ./backend build: ./backend
restart: unless-stopped restart: unless-stopped
logging: *default-logging
environment: environment:
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید} SECRET_KEY: ${SECRET_KEY:?SECRET_KEY را در .env تنظیم کنید}
@@ -106,11 +182,14 @@ services:
FRONTEND_DIST: /srv/frontend FRONTEND_DIST: /srv/frontend
volumes: volumes:
- ./frontend/dist:/srv/frontend:ro - ./frontend/dist:/srv/frontend:ro
networks: [edge, internal]
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
minio: minio:
condition: service_healthy condition: service_healthy
db-init:
condition: service_completed_successfully
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"]
interval: 15s interval: 15s
@@ -122,6 +201,12 @@ services:
- traefik.http.routers.api.rule=PathPrefix(`/api`) - traefik.http.routers.api.rule=PathPrefix(`/api`)
- traefik.http.routers.api.entrypoints=web - traefik.http.routers.api.entrypoints=web
- traefik.http.routers.api.priority=100 - traefik.http.routers.api.priority=100
# لایه دوم محافظت از فرم‌ها — سیلاب درخواست را پیش از رسیدن به بک‌اند می‌بُرد؛
# محدودسازی دقیق هر فرم در خود بک‌اند است (rate_limit.py)
- traefik.http.routers.api.middlewares=api-flood
- traefik.http.middlewares.api-flood.ratelimit.average=50
- traefik.http.middlewares.api-flood.ratelimit.period=1s
- traefik.http.middlewares.api-flood.ratelimit.burst=150
- traefik.http.services.api.loadbalancer.server.port=8000 - traefik.http.services.api.loadbalancer.server.port=8000
# robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA # robots.txt و sitemap.xml و صفحات وبلاگ/پروژه با متای سئو — اولویت بالاتر از SPA
# مسیرهای /en/... هم همه به بک‌اند می‌روند تا متای سئوی انگلیسی و html انگلیسی تزریق شود # مسیرهای /en/... هم همه به بک‌اند می‌روند تا متای سئوی انگلیسی و html انگلیسی تزریق شود
@@ -129,29 +214,106 @@ services:
- traefik.http.routers.seo.entrypoints=web - traefik.http.routers.seo.entrypoints=web
- traefik.http.routers.seo.priority=50 - traefik.http.routers.seo.priority=50
- traefik.http.routers.seo.service=api - traefik.http.routers.seo.service=api
# /metrics فقط برای Prometheus — روتری تعریف نمی‌شود، از بیرون در دسترس نیست
deploy:
resources:
limits:
memory: 768M
# فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است # فقط سرو فایل‌های استاتیک SPA — بیلد روی هاست (npm run build) انجام شده است
frontend: frontend:
build: ./frontend build: ./frontend
restart: unless-stopped restart: unless-stopped
logging: *default-logging
depends_on: depends_on:
- backend backend:
- minio condition: service_healthy
networks: [edge]
healthcheck: healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"] test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 5s
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.web.rule=PathPrefix(`/`) - traefik.http.routers.web.rule=PathPrefix(`/`)
- traefik.http.routers.web.entrypoints=web - traefik.http.routers.web.entrypoints=web
- traefik.http.routers.web.priority=1 - traefik.http.routers.web.priority=1
- traefik.http.services.web.loadbalancer.server.port=80 - traefik.http.services.web.loadbalancer.server.port=80
deploy:
resources:
limits:
memory: 64M
# دستیار هوشمند — RAG روی محتوای سایت با pgvector
# اولین اجرا مدل‌ها را دانلود می‌کند (~۵۰۰MB روی حجم chatbot_hf) — کند است، طبیعی است
chatbot:
build: ./chatbot
restart: unless-stopped
logging: *default-logging
environment:
DATABASE_URL: postgresql+psycopg://aramesh:${POSTGRES_PASSWORD}@db:5432/arameshplaza
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODEL: ${LLM_MODEL:-openai/gpt-oss-120b}
CHAT_REINDEX_TOKEN: ${CHAT_REINDEX_TOKEN:-}
volumes:
- chatbot_hf:/data
networks: [edge, internal]
depends_on:
db:
condition: service_healthy
db-init:
condition: service_completed_successfully
labels:
- traefik.enable=true
- traefik.http.routers.chat.rule=PathPrefix(`/chat`)
- traefik.http.routers.chat.entrypoints=web
- traefik.http.routers.chat.priority=100
- traefik.http.routers.chat.middlewares=api-flood
- traefik.http.services.chat.loadbalancer.server.port=8000
deploy:
resources:
limits:
memory: 2G
# آنالیتیکس محصول (Umami) — بازدید، ارجاع، رخدادهای سفارشی (فرم/تلفن/چت‌بات)
# داشبورد: analytics.localhost (توسعه) یا analytics.DOMAIN (تولید)
umami:
image: ghcr.io/umami-software/umami:postgresql-v2.17.0
restart: unless-stopped
logging: *default-logging
environment:
DATABASE_URL: postgresql://aramesh:${POSTGRES_PASSWORD}@db:5432/umami
# اسکریپت ردیاب به ارجاع‌دهندهٔ واقعی نیاز دارد — پشت Traefik
CLIENT_IP_HEADER: x-forwarded-for
DISABLE_TELEMETRY: 1
networks: [edge, internal]
depends_on:
db:
condition: service_healthy
db-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/heartbeat || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
labels:
- traefik.enable=true
- traefik.http.routers.analytics.rule=Host(`analytics.localhost`)
- traefik.http.routers.analytics.entrypoints=web
- traefik.http.services.analytics.loadbalancer.server.port=3000
deploy:
resources:
limits:
memory: 512M
# ابزار پشتیبان‌گیری — فقط با پروفایل tools اجرا می‌شود (scripts/backup.sh این کار را می‌کند) # ابزار پشتیبان‌گیری — فقط با پروفایل tools اجرا می‌شود (scripts/backup.sh این کار را می‌کند)
mc: mc:
image: minio/mc:latest image: minio/mc:RELEASE.2025-04-08T15-39-49Z
profiles: ["tools"] profiles: ["tools"]
depends_on: depends_on:
minio: minio:
@@ -159,7 +321,42 @@ services:
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-aramesh}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD را در .env تنظیم کنید}
networks: [internal]
# پشتیبان ساعتی خودکار دیتابیس — بدون نیاز به cron میزبان؛ فقط با پروفایل ops
# docker compose --profile ops up -d db-backup
# RPO را از ۲۴ ساعت (cron شبانه) به ۱ ساعت می‌رساند؛ scripts/backup.sh برای
# پشتیبان کامل (دیتابیس + تصاویر MinIO) سر جایش است.
db-backup:
image: prodrigestivill/postgres-backup-local:17
profiles: ["ops"]
restart: unless-stopped
logging: *default-logging
depends_on:
db:
condition: service_healthy
environment:
POSTGRES_HOST: db
POSTGRES_DB: arameshplaza
POSTGRES_USER: aramesh
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD را در .env تنظیم کنید}
SCHEDULE: "@hourly"
BACKUP_NUM_KEEP: "48"
BACKUP_DIR: /backups
volumes:
- backups:/backups
networks: [internal]
deploy:
resources:
limits:
memory: 128M
networks:
edge: # سرویس‌هایی که Traefik به آن‌ها مسیریابی می‌کند
internal: # دیتابیس و ذخیره‌سازی — فرانت‌اند راهی به این شبکه ندارد
volumes: volumes:
db_data: db_data:
minio_data: minio_data:
chatbot_hf:
backups:
+4
View File
@@ -0,0 +1,4 @@
# فقط Caddyfile و dist/ به کانتکست بیلد می‌روند — node_modules و سورس عظیم‌اند و لازم نیستند
*
!Caddyfile
!dist
+11 -2
View File
@@ -1,12 +1,21 @@
{
# پنل مدیریت Caddy لازم نیست — سطح حمله کمتر
admin off
}
:80 { :80 {
root * /srv root * /srv
encode gzip encode zstd gzip
# مسیریابی SPA — مسیرهای ناشناخته به index.html برمی‌گردند # مسیریابی SPA — مسیرهای ناشناخته به index.html برمی‌گردند
try_files {path} /index.html try_files {path} /index.html
file_server file_server
# کش بلندمدت برای فایل‌های هش‌دار بیلد # فایل‌های هش‌دار بیلد: کش بلندمدت بی‌رحم
@assets path /assets/* @assets path /assets/*
header @assets Cache-Control "public, immutable, max-age=31536000" header @assets Cache-Control "public, immutable, max-age=31536000"
# بقیه (از جمله index.html و fallback اسپا): بدون کش مرورگر تا هر استقرار فوری دیده شود
@notassets not path /assets/*
header @notassets Cache-Control "no-cache"
} }
+4 -1
View File
@@ -5,6 +5,9 @@
FROM caddy:2-alpine FROM caddy:2-alpine
COPY Caddyfile /etc/caddy/Caddyfile COPY Caddyfile /etc/caddy/Caddyfile
COPY dist /srv COPY --chown=caddy:caddy dist /srv
# بدون root — فقط فایل‌های استاتیک سرو می‌شوند و TLS را Traefik تمام می‌کند
USER caddy
EXPOSE 80 EXPOSE 80
+10
View File
@@ -24,6 +24,16 @@
document.documentElement.setAttribute('dir', 'ltr'); document.documentElement.setAttribute('dir', 'ltr');
} }
</script> </script>
<script type="module">
// تحلیل رفتار (Umami) — در زمان بیلد تزریق می‌شود؛ VITE_UMAMI_SRC خالی یعنی خاموش
if (import.meta.env.VITE_UMAMI_SRC) {
const s = document.createElement('script');
s.defer = true;
s.src = import.meta.env.VITE_UMAMI_SRC;
s.setAttribute('data-website-id', import.meta.env.VITE_UMAMI_WEBSITE_ID ?? '');
document.head.appendChild(s);
}
</script>
<script type="module" src="/src/index.tsx"></script> <script type="module" src="/src/index.tsx"></script>
</body> </body>
</html> </html>
+2
View File
@@ -5,6 +5,7 @@ import { Preloader } from './components/Preloader';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Footer } from './components/Footer'; import { Footer } from './components/Footer';
import { FloatingContact } from './components/FloatingContact'; import { FloatingContact } from './components/FloatingContact';
import { ChatWidget } from './components/ChatWidget';
import { Home } from './pages/Home'; import { Home } from './pages/Home';
import { About } from './pages/About'; import { About } from './pages/About';
import { ServicesPage } from './pages/ServicesPage'; import { ServicesPage } from './pages/ServicesPage';
@@ -106,6 +107,7 @@ function AppChrome() {
<AnimatedRoutes /> <AnimatedRoutes />
{!isAdminPage && <Footer />} {!isAdminPage && <Footer />}
{!isAdminPage && <FloatingContact />} {!isAdminPage && <FloatingContact />}
{!isAdminPage && <ChatWidget />}
{isAuthenticated && !isAdminPage && <AdminBar />} {isAuthenticated && !isAdminPage && <AdminBar />}
</> </>
); );
+22
View File
@@ -188,6 +188,28 @@ export function AdminBar() {
رمز رمز
</button> </button>
<button
type="button"
onClick={() => void adminApi.downloadCsv('messages').catch((e) => {
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی پیام‌ها', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
})}
title="دانلود پیام‌های فرم تماس به‌صورت CSV"
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
پیام‌ها (CSV)
</button>
<button
type="button"
onClick={() => void adminApi.downloadCsv('subscribers').catch((e) => {
void Swal.fire({ ...SWAL_BASE, icon: 'error', title: 'خروجی خبرنامه', text: e instanceof Error ? e.message : 'خطا', confirmButtonText: 'باشه' })
})}
title="دانلود مشترکان خبرنامه به‌صورت CSV"
className="border border-ivory/20 px-3 py-1.5 text-xs font-light text-ivory/70 transition-colors duration-200 hover:border-copper hover:text-copper"
>
خبرنامه (CSV)
</button>
<button <button
type="button" type="button"
onClick={logout} onClick={logout}
+20
View File
@@ -94,4 +94,24 @@ export const adminApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }), body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}), }),
// خروجی CSV — فایل تولیدی بک‌اند (با BOM برای Excel) مستقیم دانلود می‌شود
downloadCsv: async (kind: 'messages' | 'subscribers'): Promise<void> => {
const path = kind === 'messages' ? 'messages/export.csv' : 'subscribers/export.csv'
const token = getToken()
const res = await fetch(`${API_URL}/api/admin/${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new HttpError(res.status, 'دریافت خروجی CSV ناموفق بود')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const disposition = res.headers.get('Content-Disposition') ?? ''
a.download = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `${kind}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
},
} }
+17
View File
@@ -0,0 +1,17 @@
// لایهٔ نازک تحلیل رفتار (Umami) — اگر اسکریپت ردیاب بارگیری نشده باشد
// (VITE_UMAMI_SRC خالی)، همهٔ فراخوانی‌ها بی‌اثر و ارزان‌اند.
declare global {
interface Window {
umami?: { track: (event?: string, data?: Record<string, unknown>) => void }
}
}
/** رخداد سفارشی — نام رخداد در داشبورد Umami نمایش داده می‌شود. */
export function track(event: string, data?: Record<string, unknown>) {
try {
window.umami?.track(event, data)
} catch {
// تحلیل رفتار هرگز نباید مسیر اصلی را بشکند
}
}
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useRef, useState } from 'react';
import { BotIcon, SendIcon, SparklesIcon, XIcon } from 'lucide-react';
import { track } from '../analytics';
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
streaming?: boolean;
error?: boolean;
};
type SseFrame = {
content?: string;
error?: string;
done?: boolean;
};
/** شناسه گفتگو برای هر نشست — در sessionStorage می‌ماند تا رفرش صفحه تاریخچه را نگه دارد */
function getThreadId(): string {
const KEY = 'chat_thread_id';
let id = sessionStorage.getItem(KEY);
if (!id) {
id = `t_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
sessionStorage.setItem(KEY, id);
}
return id;
}
/** خواندن فریم‌های SSE از پاسخ fetch (استریم توکن‌به‌توکن) */
async function readSse(response: Response, onFrame: (frame: SseFrame) => void) {
const reader = response.body?.getReader();
if (!reader) throw new Error('پاسخ استریمی ندارد');
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data: ')) {
try {
onFrame(JSON.parse(trimmed.slice(6)) as SseFrame);
} catch {
// فریم ناقص — نادیده بگیر
}
}
}
}
}
export function ChatWidget() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const scrollEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const assistantIndexRef = useRef<number | null>(null);
// پیشنهاد پرسش‌ها فقط یک بار برای هر بازشدن اول
useEffect(() => {
if (!open || suggestions.length > 0) return;
fetch('/chat/suggestions')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d) => setSuggestions((d.questions ?? []).slice(0, 4)))
.catch(() => setSuggestions([]));
}, [open, suggestions.length]);
useEffect(() => {
scrollEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, open]);
const sendMessage = async (text: string) => {
const question = text.trim();
if (!question || isLoading) return;
track('chat-question')
setInput('');
setIsLoading(true);
setMessages((prev) => {
const next: ChatMessage[] = [
...prev,
{ role: 'user', content: question },
{ role: 'assistant', content: '', streaming: true },
];
assistantIndexRef.current = next.length - 1;
return next;
});
const patchAssistant = (patch: Partial<ChatMessage>) => {
setMessages((prev) => {
const idx = assistantIndexRef.current;
if (idx === null || !prev[idx]) return prev;
const updated = [...prev];
updated[idx] = { ...updated[idx], ...patch };
return updated;
});
};
try {
const params = new URLSearchParams({ message: question, thread_id: getThreadId() });
const response = await fetch(`/chat/stream?${params.toString()}`, {
headers: { Accept: 'text/event-stream' },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let accumulated = '';
await readSse(response, (frame) => {
if (frame.error) {
patchAssistant({ content: frame.error, streaming: false, error: true });
return;
}
if (frame.content) {
accumulated += frame.content;
patchAssistant({ content: accumulated });
}
if (frame.done) patchAssistant({ content: accumulated || 'پاسخی دریافت نشد.', streaming: false });
});
patchAssistant({ streaming: false });
} catch (err) {
patchAssistant({
content: 'سرویس گفتگو در دسترس نیست؛ لطفاً کمی بعد دوباره تلاش کنید.',
streaming: false,
error: true,
});
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
return (
<>
{/* دکمه شناور — گوشه راست‌پایین (دکمه تماس سمت چپ است) */}
<button
type="button"
onClick={() => {
setOpen((v) => {
if (!v) track('chat-open');
return !v;
});
}}
aria-label={open ? 'بستن گفتگو' : 'گفتگو با دستیار هوشمند'}
className="group fixed bottom-8 right-8 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-ivory text-ink shadow-[0_10px_30px_rgba(0,0,0,0.45)] transition-transform duration-200 ease-lux hover:scale-105">
<span className="absolute inset-0 rounded-full border border-ivory/60 opacity-0 transition-transform duration-300 ease-lux group-hover:scale-125 group-hover:opacity-100" />
{open ? <XIcon className="h-5 w-5" /> : <BotIcon className="h-5 w-5" />}
</button>
{/* پنل گفتگو */}
{open && (
<div
className="fixed bottom-24 right-4 z-40 flex h-[min(560px,72vh)] w-[min(380px,calc(100vw-2rem))] flex-col overflow-hidden rounded-2xl border border-copper/30 bg-char shadow-lux sm:right-8">
{/* سربرگ */}
<div className="flex items-center gap-3 border-b border-ivory/10 bg-ink px-4 py-3">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-copper/15 text-copper">
<SparklesIcon className="h-4 w-4" />
</span>
<div className="flex-1">
<p className="text-sm font-bold text-ivory">دستیار هوشمند آرامش</p>
<p className="text-[11px] font-light text-ivory/50">پاسخ از محتوای همین سایت</p>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="بستن"
className="rounded-full p-1.5 text-ivory/50 transition-colors hover:bg-ivory/10 hover:text-ivory">
<XIcon className="h-4 w-4" />
</button>
</div>
{/* پیام‌ها */}
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4">
{messages.length === 0 && (
<div className="mt-6 text-center">
<BotIcon className="mx-auto h-8 w-8 text-copper/60" />
<p className="mt-3 text-sm font-light leading-7 text-ivory/60">
سلام! هر سؤالی درباره خدمات، پروژه‌ها و مقالات
<span className="font-bold text-ivory"> گروه ساختمانی آرامش </span>
دارید بپرسید.
</p>
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-[13px] leading-6 ${
msg.role === 'user'
? 'rounded-bl-md bg-copper text-char'
: msg.error
? 'rounded-br-md border border-red-400/30 bg-red-400/10 text-red-200'
: 'rounded-br-md border border-ivory/10 bg-ink text-ivory'
}`}>
{msg.content || (msg.streaming ? '…' : '')}
{msg.streaming && msg.content && (
<span className="ms-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-copper align-middle" />
)}
</div>
</div>
))}
<div ref={scrollEndRef} />
</div>
{/* پیشنهاد پرسش‌ها */}
{suggestions.length > 0 && messages.length === 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-ivory/10 px-3 py-2.5">
{suggestions.map((q) => (
<button
key={q}
type="button"
onClick={() => sendMessage(q)}
className="rounded-full border border-copper/30 px-2.5 py-1 text-[11px] text-copper transition-colors hover:bg-copper hover:text-char">
{q}
</button>
))}
</div>
)}
{/* ورودی */}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(input);
}}
className="flex items-center gap-2 border-t border-ivory/10 bg-ink px-3 py-3">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="پرسش خود را بنویسید…"
disabled={isLoading}
className="flex-1 rounded-full border border-ivory/15 bg-char px-4 py-2 text-[13px] text-ivory placeholder:text-ivory/30 focus:border-copper/60 focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
aria-label="ارسال"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-copper text-char transition-transform hover:scale-105 disabled:bg-ivory/15 disabled:text-ivory/40 disabled:hover:scale-100">
<SendIcon className="h-4 w-4 -scale-x-100" />
</button>
</form>
</div>
)}
</>
);
}
+16 -2
View File
@@ -9,12 +9,13 @@ import { useFieldSaver } from '../admin/AdminContext'
import { InlineEdit } from './admin/InlineEdit' import { InlineEdit } from './admin/InlineEdit'
import { SingletonPill } from '../admin/Editable' import { SingletonPill } from '../admin/Editable'
import { API_URL } from '../config' import { API_URL } from '../config'
import { track } from '../analytics'
export function ContactSection() { export function ContactSection() {
const { brand, contact_section: content } = useSite() const { brand, contact_section: content } = useSite()
const { t } = useLang() const { t } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const [form, setForm] = useState({ name: '', phone: '', note: '' }) const [form, setForm] = useState({ name: '', phone: '', note: '', website: '' })
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
@@ -79,7 +80,7 @@ export function ContactSection() {
const res = await fetch(`${API_URL}/api/contact`, { const res = await fetch(`${API_URL}/api/contact`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim() }), body: JSON.stringify({ name: nameValue, phone: phoneValue, note: form.note.trim(), website: form.website }),
}) })
if (!res.ok) { if (!res.ok) {
@@ -90,6 +91,7 @@ export function ContactSection() {
return return
} }
track('contact-submit')
setSent(true) setSent(true)
} catch { } catch {
showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText')) showErrorAlert(t('connectionErrorTitle'), t('connectionErrorText'))
@@ -152,6 +154,7 @@ export function ContactSection() {
<a <a
href={brand.phone_href} href={brand.phone_href}
dir="ltr" dir="ltr"
onClick={() => track('phone-click', { from: 'contact-section' })}
className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper" className="mt-1 block text-sm font-light text-ivory/70 transition-colors hover:text-copper"
> >
<InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} /> <InlineEdit value={brand.phone} onSave={(v) => saveBrand('phone', v)} />
@@ -208,6 +211,17 @@ export function ContactSection() {
</div> </div>
) : ( ) : (
<form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2"> <form onSubmit={submit} noValidate className="grid gap-6 md:grid-cols-2">
{/* honeypot — خارج از دید؛ فقط ربات‌ها آن را پر می‌کنند */}
<input
type="text"
name="website"
value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
/>
<div> <div>
<label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90"> <label htmlFor="contact-name" className="block text-sm font-medium text-ivory/90">
<InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} /> <InlineEdit value={content.name_label} onSave={(v) => saveContent('name_label', v)} />
@@ -2,6 +2,7 @@ import React from 'react';
import { PhoneIcon } from 'lucide-react'; import { PhoneIcon } from 'lucide-react';
import { useSite } from '../api'; import { useSite } from '../api';
import { useLang } from '../i18n/lang'; import { useLang } from '../i18n/lang';
import { track } from '../analytics';
export function FloatingContact() { export function FloatingContact() {
const { brand } = useSite(); const { brand } = useSite();
@@ -11,6 +12,7 @@ export function FloatingContact() {
<a <a
href={brand.phone_href} href={brand.phone_href}
aria-label={t('callBrand')} aria-label={t('callBrand')}
onClick={() => track('phone-click', { from: 'floating' })}
className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95" className="group fixed bottom-8 ltr:right-8 rtl:left-8 z-40 flex h-14 w-14 items-center justify-center rounded-full border border-copper/50 bg-char/80 text-copper backdrop-blur-xl shadow-[inset_0_1px_1px_rgba(255,255,255,0.1),0_12px_35px_rgba(0,0,0,0.5),0_0_20px_rgba(197,143,92,0.25)] transition-all duration-500 ease-lux hover:scale-110 hover:border-copper hover:bg-copper hover:text-char hover:shadow-[0_0_35px_rgba(197,143,92,0.7)] active:scale-95"
> >
{/* پالس هاله نوری در پس‌زمینه مانند رادار */} {/* پالس هاله نوری در پس‌زمینه مانند رادار */}
+4 -1
View File
@@ -1,6 +1,7 @@
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { LanguagesIcon } from 'lucide-react' import { LanguagesIcon } from 'lucide-react'
import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang' import { counterpartPath, useLang, writeLangCookie } from '../i18n/lang'
import { track } from '../analytics'
/** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */ /** دکمه تغییر زبان — بین نسخه فارسی و /en انگلیسی همان صفحه جابه‌جا می‌شود */
export function LangToggle({ className = '' }: { className?: string }) { export function LangToggle({ className = '' }: { className?: string }) {
@@ -10,7 +11,9 @@ export function LangToggle({ className = '' }: { className?: string }) {
const target = counterpartPath(pathname, lang) const target = counterpartPath(pathname, lang)
const switchLang = () => { const switchLang = () => {
writeLangCookie(lang === 'fa' ? 'en' : 'fa') const next = lang === 'fa' ? 'en' : 'fa'
track('lang-switch', { to: next })
writeLangCookie(next)
// پرش خودکار بازدید بعدی با انتخاب دستی همین‌جا گره نمی‌خورد // پرش خودکار بازدید بعدی با انتخاب دستی همین‌جا گره نمی‌خورد
sessionStorage.setItem('langAutoRedirect', '1') sessionStorage.setItem('langAutoRedirect', '1')
navigate(target) navigate(target)
+1 -1
View File
@@ -43,7 +43,7 @@ export function Faq({ hideHeading = false }: FaqProps) {
type="button" type="button"
onClick={() => setOpen(isOpen ? null : i)} onClick={() => setOpen(isOpen ? null : i)}
aria-expanded={isOpen} aria-expanded={isOpen}
className="flex w-full items-center justify-between gap-6 py-6 text-right transition-colors duration-200 ease-lux hover:text-copper" className="flex w-full items-center justify-between gap-6 py-6 text-start transition-colors duration-200 ease-lux hover:text-copper"
> >
<span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}> <span className={`text-base font-bold md:text-lg ${isOpen ? 'text-copper' : 'text-ivory'}`}>
{faq.question} {faq.question}
+15 -1
View File
@@ -9,12 +9,14 @@ import { useFieldSaver } from '../../admin/AdminContext'
import { InlineEdit } from '../admin/InlineEdit' import { InlineEdit } from '../admin/InlineEdit'
import { SingletonPill } from '../../admin/Editable' import { SingletonPill } from '../../admin/Editable'
import { API_URL } from '../../config' import { API_URL } from '../../config'
import { track } from '../../analytics'
export function Newsletter() { export function Newsletter() {
const { newsletter } = useSite() const { newsletter } = useSite()
const { t } = useLang() const { t } = useLang()
const save = useFieldSaver() const save = useFieldSaver()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [website, setWebsite] = useState('')
const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle') const [status, setStatus] = useState<'idle' | 'error' | 'done'>('idle')
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
@@ -75,7 +77,7 @@ export function Newsletter() {
const res = await fetch(`${API_URL}/api/newsletter`, { const res = await fetch(`${API_URL}/api/newsletter`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: emailValue }), body: JSON.stringify({ email: emailValue, website }),
}) })
if (!res.ok) { if (!res.ok) {
@@ -87,6 +89,7 @@ export function Newsletter() {
return return
} }
track('newsletter-signup')
setStatus('done') setStatus('done')
setEmail('') setEmail('')
} catch { } catch {
@@ -117,6 +120,17 @@ export function Newsletter() {
<Reveal delay={0.08}> <Reveal delay={0.08}>
<form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate> <form onSubmit={submit} className="flex flex-col gap-3.5 sm:flex-row" noValidate>
{/* honeypot — خارج از دید؛ فقط ربات‌ها آن را پر می‌کنند */}
<input
type="text"
name="website"
value={website}
onChange={(e) => setWebsite(e.target.value)}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="pointer-events-none absolute -left-[9999px] h-0 w-0 opacity-0"
/>
<div className="flex-1"> <div className="flex-1">
<label htmlFor="newsletter-email" className="sr-only"> <label htmlFor="newsletter-email" className="sr-only">
{newsletter.placeholder} {newsletter.placeholder}
+1 -1
View File
@@ -20,7 +20,7 @@ export function Services() {
return ( return (
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title"> <section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
<div className="mx-auto max-w-site px-6 lg:px-10"> <div className="mx-auto max-w-site px-6 lg:px-10">
<Reveal className="max-w-2xl text-right"> <Reveal className="max-w-2xl text-start">
<h2 id="services-title" className="text-section font-bold text-ivory"> <h2 id="services-title" className="text-section font-bold text-ivory">
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} /> <InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
+27 -5
View File
@@ -130,21 +130,42 @@ html[lang='en'] .article-body h3 {
} }
.article-body h2 { .article-body h2 {
font-size: 16px; font-size: 16px;
padding-inline-start: 0.85rem; /* نوار مسی ندارد: نوار عمودی مسی نشانه اختصاصی نقل‌قول است و تیتر فقط با
border-inline-start: 3px solid #d5874c; اندازه و وزن متمایز می‌شود تا با نقل‌قول اشتباه گرفته نشود. */
} }
.article-body h3 { .article-body h3 {
font-size: 14px; font-size: 14px;
} }
/* متن مقاله در هر دو زبان هم‌تراز (justify) است: لبه‌های راست و چپ یکدست می‌مانند.
text-align: justify با direction درست، در فارسی و انگلیسی هر دو کار می‌کند. */
[data-dir='rtl'] .article-body { [data-dir='rtl'] .article-body {
direction: rtl; direction: rtl;
text-align: right; text-align: justify;
text-justify: inter-word;
} }
[data-dir='ltr'] .article-body { [data-dir='ltr'] .article-body {
direction: ltr; direction: ltr;
text-align: left; text-align: justify;
text-justify: inter-word;
font-size: 12px; font-size: 12px;
} }
/* تیترها و فهرست‌ها هم‌تراز نمی‌شوند — فقط متن پاراگراف تراز می‌شود
(تیتر کوتاه با justify کشیده و بدقلق می‌شود و فهرست نقطه‌گذاری‌اش به‌هم می‌ریزد). */
[data-dir='rtl'] .article-body h1,
[data-dir='rtl'] .article-body h2,
[data-dir='rtl'] .article-body h3,
[data-dir='rtl'] .article-body li,
[data-dir='rtl'] .article-body figcaption {
text-align: right;
}
[data-dir='ltr'] .article-body h1,
[data-dir='ltr'] .article-body h2,
[data-dir='ltr'] .article-body h3,
[data-dir='ltr'] .article-body li,
[data-dir='ltr'] .article-body figcaption {
text-align: left;
}
[data-dir='ltr'] .article-body h1 { [data-dir='ltr'] .article-body h1 {
font-size: 16px; font-size: 16px;
} }
@@ -259,7 +280,8 @@ html[lang='en'] .article-body h3 {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
margin-top: 0.6rem; margin-top: 0.6rem;
text-align: right; /* تراز از جهت بلوک پیروی می‌کند — در مقاله انگلیسی چپ و در فارسی راست */
text-align: start;
} }
[data-wysiwyg] .article-figure-node.is-selected .article-figure { [data-wysiwyg] .article-figure-node.is-selected .article-figure {
outline: 1px dashed rgba(213, 135, 76, 0.8); outline: 1px dashed rgba(213, 135, 76, 0.8);
+24
View File
@@ -0,0 +1,24 @@
# Alertmanager — تحویل هشدارها به بله (API بات بله همان شکل تلگرام است)
# توکن و شناسه چت از .env (ALERT_BOT_TOKEN / ALERT_CHAT_ID) — این فایل قالب است؛
# نسخهٔ رندرشده (alertmanager.rendered.yml) را scripts/stack.sh می‌سازد و در گیت نمی‌رود.
# تا وقتی توکن/چت واقعی تنظیم نشده، ارسال شکست می‌خورد و هشدارها در
# http://<prometheus>:9090/alerts دیده می‌شوند.
global:
resolve_timeout: 5m
route:
receiver: bale
group_by: [alertname, job]
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receivers:
- name: bale
telegram_configs:
# تنها تفاوت با تلگرام: نشانی API بات بله
- api_url: https://tapi.bale.ai
bot_token: "${ALERT_BOT_TOKEN}"
chat_id: ${ALERT_CHAT_ID}
send_resolved: true
@@ -0,0 +1,102 @@
{
"uid": "aramesh-app",
"title": "آرامش — اپلیکیشن و ترافیک",
"tags": ["aramesh"],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"panels": [
{
"type": "timeseries",
"title": "درخواست‌ها بر ثانیه (هر روتر Traefik)",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "sum(rate(traefik_service_requests_total[5m])) by (service)",
"legendFormat": "{{ service }}"
}
],
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
},
{
"type": "timeseries",
"title": "خطاهای 5xx (بک‌اند)",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "sum(rate(http_request_duration_seconds_count{status=~\"5..\"}[5m]))",
"legendFormat": "5xx/s"
}
],
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
},
{
"type": "timeseries",
"title": "تأخیر بک‌اند — p50 و p95",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "histogram_quantile(0.5, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p50"
},
{
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95"
}
],
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
},
{
"type": "stat",
"title": "سلامت /api/health (پروب عمق)",
"gridPos": { "h": 8, "w": 4, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{ "expr": "probe_success{job=\"blackbox-internal-health\"}", "legendFormat": "health" }
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "1": { "text": "سالم", "color": "green" }, "0": { "text": "خراب", "color": "red" } } }
],
"min": 0,
"max": 1
},
"overrides": []
}
},
{
"type": "stat",
"title": "تازگی آخرین پشتیبان (ثانیه)",
"gridPos": { "h": 8, "w": 4, "x": 16, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{ "expr": "time() - backup_success_time", "legendFormat": "age" }
],
"fieldConfig": { "defaults": { "unit": "s", "thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 72000 }, { "color": "red", "value": 93600 } ] } }, "overrides": [] }
},
{
"type": "timeseries",
"title": "اتصال‌های Postgres",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{ "expr": "sum(pg_stat_activity_count)", "legendFormat": "اتصال‌ها" }
],
"fieldConfig": { "defaults": { "unit": "short" }, "overrides": [] }
},
{
"type": "logs",
"title": "لاگ زندهٔ سرویس‌ها (Loki)",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 },
"datasource": { "type": "loki", "uid": "loki" },
"targets": [
{ "expr": "{service=~\"backend|traefik|chatbot|umami\"}" }
]
}
]
}
@@ -0,0 +1,74 @@
{
"uid": "aramesh-infra",
"title": "آرامش — زیرساخت (میزبان و کانتینرها)",
"tags": ["aramesh"],
"timezone": "browser",
"schemaVersion": 39,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"panels": [
{
"type": "timeseries",
"title": "CPU میزبان",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "CPU %"
}
],
"fieldConfig": { "defaults": { "unit": "percent", "max": 100 }, "overrides": [] }
},
{
"type": "timeseries",
"title": "حافظه میزبان",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{ "expr": "node_memory_MemTotal_bytes", "legendFormat": "کل" },
{ "expr": "node_memory_MemAvailable_bytes", "legendFormat": "آزاد" }
],
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }
},
{
"type": "timeseries",
"title": "دیسک — درصد پرشدگی",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "100 * (1 - (node_filesystem_avail_bytes{fstype!~\"tmpfs|overlay|squashfs\"} / node_filesystem_size_bytes{fstype!~\"tmpfs|overlay|squashfs\"}))",
"legendFormat": "{{ mountpoint }}"
}
],
"fieldConfig": { "defaults": { "unit": "percent", "max": 100 }, "overrides": [] }
},
{
"type": "timeseries",
"title": "مصرف RAM هر کانتینر",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "topk(10, container_memory_usage_bytes{image!=\"\", container!=\"\"})",
"legendFormat": "{{ container }}"
}
],
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }
},
{
"type": "timeseries",
"title": "CPU هر کانتینر",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 },
"datasource": { "type": "prometheus", "uid": "prom" },
"targets": [
{
"expr": "topk(10, rate(container_cpu_usage_seconds_total{image!=\"\", container!=\"\"}[5m]))",
"legendFormat": "{{ container }}"
}
],
"fieldConfig": { "defaults": { "unit": "percentunit" }, "overrides": [] }
}
]
}
@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: aramesh
folder: Aramesh
type: file
disableDeletion: false
updateIntervalSeconds: 30
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,17 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
uid: prom
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
- name: Loki
type: loki
uid: loki
access: proxy
url: http://loki:3100
editable: false
+30
View File
@@ -0,0 +1,30 @@
# Loki — ذخیرهٔ لاگ‌ها روی دیسک (۱۴ روز)
auth_enabled: false
server:
http_listen_port: 3100
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 336h
+118
View File
@@ -0,0 +1,118 @@
# قواعد هشدار — از Prometheus به Alertmanager (تلگرام) می‌روند.
# آستانه‌ها محافظه‌کارانه‌اند؛ پس از یک هفته داده، دقیق‌تر تنظیم کنید.
groups:
- name: aramesh-availability
rules:
- alert: ServiceDown
expr: up == 0
for: 2m
labels:
severity: critical
annotations:
summary: "{{ $labels.job }} پاسخ نمی‌دهد"
description: "سرویس {{ $labels.job }} ({{ $labels.instance }}) حداقل ۲ دقیقه پایین است."
- alert: BackendHealthFailing
expr: probe_success{job="blackbox-internal-health"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "بررسی عمق سلامت بک‌اند شکست می‌خورد"
description: "‏/api/health اتصال DB یا MinIO را آزموده و 503 برگردانده — وابستگی‌ها را ببینید."
- alert: PublicSiteDown
expr: probe_success{job="blackbox-public"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "سایت از مسیر عمومی در دسترس نیست"
description: "پروب {{ $labels.instance }} ناموفق است — Traefik/TLS/DNS را بررسی کنید."
- name: aramesh-resources
rules:
- alert: HostDiskAlmostFull
expr: |
1 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay|squashfs"}) > 0.85
for: 5m
labels:
severity: warning
annotations:
summary: "دیسک میزبان پر می‌شود"
description: "پرشدن بیش از ۸۵٪ روی {{ $labels.mountpoint }} — پشتیبان‌ها و لاگ‌ها را بررسی کنید."
- alert: HostMemoryPressure
expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "RAM میزبان رو به تمام‌شدن است"
description: "کمتر از ۱۰٪ حافظه باقی مانده است."
- alert: ContainerMemoryNearLimit
expr: |
container_memory_usage_bytes{image!="", container!="", container_spec_memory_limit_bytes > 0}
/ container_spec_memory_limit_bytes{container!="", container_spec_memory_limit_bytes > 0} > 0.9
for: 10m
labels:
severity: warning
annotations:
summary: "کانتینر {{ $labels.container }} به سقف حافظه نزدیک است"
- name: aramesh-app
rules:
- alert: Http5xxRateHigh
expr: |
sum(rate(http_request_duration_seconds_count{status=~"5.."}[5m])) by (instance)
/ sum(rate(http_request_duration_seconds_count[5m])) by (instance) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "نرخ خطای 5xx بک‌اند بالاست"
- alert: Proxy5xxRateHigh
expr: |
sum(rate(traefik_service_requests_total{code=~"5.."}[5m])) by (service)
/ sum(rate(traefik_service_requests_total[5m])) by (service) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "نرخ خطای 5xx روی سرویس {{ $labels.service }} پشت Traefik بالاست"
- alert: HighBackendLatency
expr: histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le)) > 2
for: 10m
labels:
severity: warning
annotations:
summary: "p95 تأخیر بک‌اند بالای ۲ ثانیه است"
- alert: PostgresTooManyConnections
expr: sum(pg_stat_activity_count) > 80
for: 5m
labels:
severity: warning
annotations:
summary: "تعداد اتصال‌های Postgres نزدیک سقف است"
- alert: TlsCertExpiringSoon
expr: traefik_tls_certs_not_after - time() < 1209600
labels:
severity: warning
annotations:
summary: "گواهی TLS کمتر از ۱۴ روز اعتبار دارد"
description: "تمدید خودکار Let's Encrypt کار نکرده — ACME/TLS را بررسی کنید."
- alert: BackupStale
expr: time() - backup_success_time > 93600
for: 30m
labels:
severity: critical
annotations:
summary: "آخرین پشتیبان موفق بیش از ۲۶ ساعت پیش است"
description: "کرون/اسکریپت پشتیبان‌گیری یا سرویس db-backup خفته — فوراً بررسی کنید."
+87
View File
@@ -0,0 +1,87 @@
# تنظیمات Prometheus — اسکرپ همهٔ سرویس‌های استک از شبکه‌های داخلی
global:
scrape_interval: 30s
evaluation_interval: 30s
alerting:
alertmanagers:
- static_configs:
- targets: ["alertmanager:9093"]
rule_files:
- /etc/prometheus/alerts.yml
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
# بک‌اند FastAPI — /metrics با instrumentator (فقط از شبکه داخلی)
- job_name: backend
static_configs:
- targets: ["backend:8000"]
# پراکسی — RPS هر روتر، کدهای وضعیت، انقضای گواهی TLS
- job_name: traefik
static_configs:
- targets: ["traefik:8080"]
# منابع کانتینرها و میزبان
- job_name: cadvisor
static_configs:
- targets: ["cadvisor:8080"]
- job_name: node
static_configs:
- targets: ["node-exporter:9100"]
- job_name: postgres
static_configs:
- targets: ["postgres-exporter:9187"]
# سلامت مستقیم سرویس‌ها — up == 0 یعنی آن سرویس پایین است
- job_name: frontend
metrics_path: /
static_configs:
- targets: ["frontend:80"]
- job_name: minio
metrics_path: /minio/health/live
static_configs:
- targets: ["minio:9000"]
- job_name: umami
metrics_path: /api/heartbeat
static_configs:
- targets: ["umami:3000"]
- job_name: chatbot
metrics_path: /chat/healthz
static_configs:
- targets: ["chatbot:8000"]
# پروب بیرون‌نگر — سلامت واقعی /api/health (بخش عمق: DB و MinIO را می‌آزماید)
# و در تولید، آدرس عمومی سایت (SITE_URL از .env — پشتیبانی متغیر محیطی Prometheus)
- job_name: blackbox-internal-health
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- http://backend:8000/api/health
relabel_configs: &blackbox_relabel
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: blackbox-exporter:9115
- job_name: blackbox-public
metrics_path: /probe
params:
module: [http_2xx]
static_configs:
- targets:
- ${SITE_URL:-http://frontend/}
relabel_configs: *blackbox_relabel
+27
View File
@@ -0,0 +1,27 @@
# Promtail — لاگ همهٔ کانتینرهای استک آرامش را از Docker socket می‌خواند
server:
http_listen_port: 9080
positions:
filename: /positions/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
# فقط کانتینرهای همین استک — نه بقیهٔ کانتینرهای میزبان
filters:
- name: label
values: ["com.docker.compose.project=aramesh-backend"]
relabel_configs:
- source_labels: ["__meta_docker_container_name"]
regex: "/(.*)"
target_label: container
- source_labels: ["__meta_docker_container_label_com_docker_compose_service"]
target_label: service
- source_labels: ["__meta_docker_container_log_stream"]
target_label: stream
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# ممیزی وابستگی‌ها — آسیب‌پذیری‌های شناخته‌شده در پکیج‌های پایتون/نود و ایمیج‌های داکر.
# bash scripts/audit.sh
# ابزارها اگر نصب نباشند رد می‌شوند (پیام هشدار)؛ خروجی nonzero یعنی مشکل پیدا شد.
set -uo pipefail
cd "$(dirname "$0")/.."
STATUS=0
echo "== pip-audit (بک‌اند) =="
if command -v pip-audit >/dev/null 2>&1; then
(cd backend && pip-audit -r requirements.txt --progress-spinner off) || STATUS=1
else
echo "رد شد: pip-audit نصب نیست (pip install pip-audit)"
fi
echo "== npm audit (فرانت‌اند) =="
if [ -d frontend/node_modules ]; then
(cd frontend && npm audit --omit=dev) || STATUS=1
else
echo "رد شد: frontend/node_modules نیست (npm install)"
fi
echo "== trivy (ایمیج‌های داکر) =="
if command -v trivy >/dev/null 2>&1; then
for img in aramesh-backend-backend aramesh-backend-frontend aramesh-backend-chatbot; do
if docker image inspect "$img" >/dev/null 2>&1; then
trivy image --severity HIGH,CRITICAL "$img" || STATUS=1
fi
done
else
echo "رد شد: trivy نصب نیست"
fi
if [ "$STATUS" -eq 0 ]; then
echo "✔ ممیزی تمام شد — مشکل شناخته‌شده‌ای پیدا نشد."
else
echo "✗ ممیزی مشکل پیدا کرد — خروجی بالا را بررسی کنید." >&2
fi
exit "$STATUS"
+32 -3
View File
@@ -4,8 +4,12 @@
# اجرا: bash scripts/backup.sh # اجرا: bash scripts/backup.sh
# زمان‌بندی: cron (لینوکس) یا Task Scheduler (ویندوز)، مثلا هر شب ساعت ۳: # زمان‌بندی: cron (لینوکس) یا Task Scheduler (ویندوز)، مثلا هر شب ساعت ۳:
# 0 3 * * * cd /path/to/aramesh-backend && bash scripts/backup.sh >> backups/backup.log 2>&1 # 0 3 * * * cd /path/to/aramesh-backend && bash scripts/backup.sh >> backups/backup.log 2>&1
# یا سرویس db-backup در پروفایل ops (پشتیبان ساعتی DB بدون cron میزبان).
# #
# نگهداری: فایل‌های قدیمی‌تر از RETENTION_DAYS (پیش‌فرض ۱۴) حذف می‌شوند. # نگهداری: فایل‌های قدیمی‌تر از RETENTION_DAYS (پیش‌فرض ۱۴) حذف می‌شوند.
# انتشار خارج از سرور: اگر OFFSITE_RCLONE_REMOTE تنظیم شده باشد (در .env، مثل
# OFFSITE_RCLONE_REMOTE=storage:aramesh-backups)، بعد از هر پشتیبان rclone copy انجام می‌شود.
# تازگی پشتیبان: backups/backup_freshness.prom برای هشدار «پشتیبان کهنه» در Grafana نوشته می‌شود.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -14,7 +18,7 @@ RETENTION_DAYS="${RETENTION_DAYS:-14}"
STAMP="$(date +%Y%m%d-%H%M%S)" STAMP="$(date +%Y%m%d-%H%M%S)"
mkdir -p backups mkdir -p backups
# متغیرهای .env (برای MINIO_ROOT_*) در دسترس قرار می‌گیرند # متغیرهای .env (برای MINIO_ROOT_* و OFFSITE_RCLONE_REMOTE) در دسترس قرار می‌گیرند
if [ -f .env ]; then if [ -f .env ]; then
set -a set -a
# shellcheck disable=SC1091 # shellcheck disable=SC1091
@@ -26,7 +30,18 @@ echo "[backup] $STAMP"
# ۱) دیتابیس — همه متن‌ها، مقاله‌ها و تنظیمات سایت # ۱) دیتابیس — همه متن‌ها، مقاله‌ها و تنظیمات سایت
DB_FILE="backups/db-${STAMP}.sql.gz" DB_FILE="backups/db-${STAMP}.sql.gz"
docker compose exec -T db pg_dump -U aramesh arameshplaza | gzip > "$DB_FILE" # --exit-on-error: اگر pg_dump وسط کار خطا بگیرد، پشتیبان ناقص تولید نمی‌شود
docker compose exec -T db pg_dump --exit-on-error -U aramesh arameshplaza | gzip > "$DB_FILE"
# پشتیبان خالی/کوچک یعنی خرابی — زودتر بفهمیم تا «روز نیاز»
MIN_DB_BYTES=1024
DB_BYTES=$(wc -c < "$DB_FILE" | tr -d ' ')
if [ "$DB_BYTES" -lt "$MIN_DB_BYTES" ]; then
echo "[backup] ✗ پشتیبان دیتابیس شکسته است (${DB_BYTES} بایت)" >&2
rm -f "$DB_FILE"
exit 1
fi
sha256sum "$DB_FILE" > "${DB_FILE}.sha256"
echo "[backup] db: $DB_FILE ($(du -h "$DB_FILE" | cut -f1))" echo "[backup] db: $DB_FILE ($(du -h "$DB_FILE" | cut -f1))"
# ۲) تصاویر MinIO — باکت aramesh-media # ۲) تصاویر MinIO — باکت aramesh-media
@@ -36,9 +51,23 @@ docker compose run --rm -v "$(pwd)/backups:/backups" --entrypoint sh mc -c "
" "
echo "[backup] images: backups/images-${STAMP}" echo "[backup] images: backups/images-${STAMP}"
# ۳) پاک‌سازی نسخه‌های قدیمی‌تر # ۳) انتشار خارج از سرور (اختیاری) — هر شب کپی به حافظهٔ دیگری
if [ -n "${OFFSITE_RCLONE_REMOTE:-}" ]; then
if command -v rclone >/dev/null 2>&1; then
rclone copy backups "${OFFSITE_RCLONE_REMOTE}" --max-age 48h
echo "[backup] offsite: ${OFFSITE_RCLONE_REMOTE}"
else
echo "[backup] هشدار: OFFSITE_RCLONE_REMOTE تنظیم است اما rclone نصب نیست" >&2
fi
fi
# ۴) پاک‌سازی نسخه‌های قدیمی‌تر
find backups -name 'db-*.sql.gz' -mtime "+${RETENTION_DAYS}" -delete find backups -name 'db-*.sql.gz' -mtime "+${RETENTION_DAYS}" -delete
find backups -name 'db-*.sql.gz.sha256' -mtime "+${RETENTION_DAYS}" -delete
find backups -maxdepth 1 -type d -name 'images-*' -mtime "+${RETENTION_DAYS}" -exec rm -rf {} + find backups -maxdepth 1 -type d -name 'images-*' -mtime "+${RETENTION_DAYS}" -exec rm -rf {} +
echo "[backup] پاک‌سازی نسخه‌های قدیمی‌تر از ${RETENTION_DAYS} روز انجام شد" echo "[backup] پاک‌سازی نسخه‌های قدیمی‌تر از ${RETENTION_DAYS} روز انجام شد"
# ۵) سنجهٔ تازگی پشتیبان — Prometheus آن را می‌خواند؛ کهنه‌شدن یعنی کرون خفتی است
printf 'backup_success_time %s\n' "$(date +%s)" > backups/backup_freshness.prom
echo "[backup] ✔ تمام شد" echo "[backup] ✔ تمام شد"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# تست‌ها + بیلد ایمیج‌ها — قابل اجرا محلی یا در هر CI (Gitea Actions/GitLab CI سازگار است)
# bash scripts/ci.sh
set -euo pipefail
cd "$(dirname "$0")/.."
echo "== تست‌های بک‌اند =="
if [ -d backend/.venv ]; then
# shellcheck disable=SC1091
source backend/.venv/bin/activate 2>/dev/null || source backend/.venv/Scripts/activate
(cd backend && pytest -q)
elif command -v pytest >/dev/null 2>&1; then
(cd backend && pytest -q)
else
echo "رد شد: pytest در دسترس نیست (backend/.venv را بسازید)"
fi
echo "== بیلد ایمیج‌های داکر =="
docker compose -f docker-compose.yml build backend frontend chatbot
echo "== ممیزی وابستگی‌ها =="
bash scripts/audit.sh || true
echo "✔ CI تمام شد"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# ساخت .env با مقادیر قوی تصادفی — پیش از اولین استقرار تولید یک‌بار اجرا کنید.
#
# bash scripts/gen-env.sh # فایل .env می‌سازد (موجود را بازنویسی نمی‌کند)
# bash scripts/gen-env.sh --force # .env موجود را بازنویسی می‌کند (احتیاط!)
#
# توجه: تغییر SECRET_KEY همه ادمین‌ها را از پنل خارج می‌کند؛ تغییر MINIO_ROOT_PASSWORD
# فقط با همگام‌سازی S3_SECRET_KEY بک‌اند اعتبار دارد.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ -f .env ] && [ "${1:-}" != "--force" ]; then
echo "خطا: .env از قبل موجود است. برای بازنویسی: bash scripts/gen-env.sh --force" >&2
exit 1
fi
rand() { openssl rand -"$1" 2>/dev/null || head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n'; }
SECRET_KEY="$(rand hex 48)"
POSTGRES_PASSWORD="$(rand hex 16)"
MINIO_ROOT_PASSWORD="$(rand hex 16)"
ADMIN_PASSWORD="$(rand hex 12)"
cat > .env <<EOF
# تولیدشده با scripts/gen-env.sh — این فایل هرگز به گیت نمی‌رود.
# پیش از انتشار واقعی، ADMIN_PASSWORD را به مقداری که به یاد می‌سپارید اما قوی است تغییر دهید:
# docker compose exec backend python -c "from app.auth import hash_password; print(hash_password('رمز-جدید'))"
# سپس در پنل مدیریت از تغییر رمز (change-password) استفاده کنید.
SECRET_KEY=${SECRET_KEY}
POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
MINIO_ROOT_USER=aramesh
MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}
ADMIN_USERNAME=admin
ADMIN_PASSWORD=${ADMIN_PASSWORD}
TRAEFIK_HTTP_PORT=18081
SITE_URL=
# --- فقط تولید ---
# DOMAIN=example.com
# ACME_EMAIL=you@example.com
# --- تحلیل رفتار (Umami) ---
# VITE_API_URL=
# VITE_UMAMI_SRC=
# VITE_UMAMI_WEBSITE_ID=
# --- چت‌بات ---
# LLM_BASE_URL=
# LLM_API_KEY=
# LLM_MODEL=openai/gpt-oss-120b
# CHAT_REINDEX_TOKEN=
# --- مانیتورینگ ---
# GRAFANA_ADMIN_PASSWORD=
# ALERT_BOT_TOKEN=
# ALERT_CHAT_ID=
EOF
echo "✔ .env ساخته شد با مقادیر قوی تصادفی."
echo " ADMIN_PASSWORD فعلی: ${ADMIN_PASSWORD}"
echo " (آن را امن نگه دارید — پس از ورود به پنل، رمز را عوض کنید.)"
+13 -1
View File
@@ -27,11 +27,23 @@ import uuid
from collections import defaultdict from collections import defaultdict
from io import BytesIO from io import BytesIO
# اگر .env ریشه هست، متغیرهایش (ADMIN_PASSWORD و ...) خوانده می‌شوند
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_ENV_FILE = os.path.join(_REPO_ROOT, ".env")
if os.path.exists(_ENV_FILE):
with open(_ENV_FILE, encoding="utf-8") as _f:
for _line in _f:
_line = _line.strip()
if _line and not _line.startswith("#") and "=" in _line:
_k, _, _v = _line.partition("=")
os.environ.setdefault(_k.strip(), _v.strip())
import requests import requests
API = os.environ.get("VITE_API_URL", "http://localhost:5173") API = os.environ.get("VITE_API_URL", "http://localhost:5173")
USER = os.environ.get("ADMIN_USERNAME", "admin") USER = os.environ.get("ADMIN_USERNAME", "admin")
PASS = os.environ.get("ADMIN_PASSWORD", "aramesh123") # رمز پیش‌فرض ندارد — از .env یا متغیر محیطی خوانده می‌شود؛ نبودش یعنی توقف با خطا
PASS = os.environ["ADMIN_PASSWORD"]
FORCE = "--force" in sys.argv FORCE = "--force" in sys.argv
print(f"API بک‌اند: {API}") print(f"API بک‌اند: {API}")
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# بازگردانی پشتیبان دیتابیس — از بازگردانی در محیط واقعی قبلش تمرین کنید!
#
# bash scripts/restore.sh backups/db-20260918-030000.sql.gz
#
# احتیاط: محتوای فعلی دیتابیس با محتوای پشتیبان جایگزین می‌شود.
# تصاویر MinIO هم اگر پوشه images-* همان زمان را دارید:
# docker compose run --rm -v "$(pwd)/backups:/backups" --entrypoint sh mc -c \
# "mc alias set local http://minio:9000 \"\$MINIO_ROOT_USER\" \"\$MINIO_ROOT_PASSWORD\" && \
# mc mirror --overwrite /backups/images-20260918-030000 local/aramesh-media"
set -euo pipefail
cd "$(dirname "$0")/.."
if [ $# -ne 1 ]; then
echo "استفاده: bash scripts/restore.sh backups/db-<زمان>.sql.gz" >&2
exit 1
fi
SQL_GZ="$1"
if [ ! -f "$SQL_GZ" ]; then
echo "خطا: فایل ${SQL_GZ} پیدا نشد" >&2
exit 1
fi
# راستی‌آزمایی چک‌سام اگر کنار فایل هست
if [ -f "${SQL_GZ}.sha256" ]; then
sha256sum -c "${SQL_GZ}.sha256"
fi
echo "[restore] هشدار: محتوای فعلی arameshplaza جایگزین می‌شود. ۵ ثانیه فرصت لغو (Ctrl+C)…"
sleep 5
gunzip -c "$SQL_GZ" | docker compose exec -T db psql -v ON_ERROR_STOP=1 -U aramesh -d arameshplaza
echo "[restore] ✔ بازگردانی تمام شد — حالا: docker compose restart backend"
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# ابزار orchestration استک — یک نقطه ورود برای همه عملیات رایج.
#
# bash scripts/stack.sh deploy # بیلد فرانت‌اند (روی هاست) + بیلد و بالا آوردن استک
# bash scripts/stack.sh deploy-prod # همین، با لایه TLS/دامنه (docker-compose.prod.yml)
# bash scripts/stack.sh logs [svc] # دنبال‌کردن لاگ (همه یا یک سرویس)
# bash scripts/stack.sh ps # وضعیت سرویس‌ها
# bash scripts/stack.sh health # وضعیت healthcheck همه سرویس‌ها
# bash scripts/stack.sh backup # پشتیبان‌گیری کامل (scripts/backup.sh)
# bash scripts/stack.sh restore FILE # بازگردانی دیتابیس (scripts/restore.sh)
# bash scripts/stack.sh update # کشیدن ایمیج‌های پین‌شده و بالا آوردن مجدد
# bash scripts/stack.sh monitoring # لایه مانیتورینگ (Prometheus/Grafana/Loki/بله)
# bash scripts/stack.sh seed-force # seed مجدد محتوا (--force — داده‌های جدول‌ها ریست می‌شود)
#
# متغیرهای بیلد فرانت‌اند (VITE_API_URL، VITE_UMAMI_SRC، VITE_UMAMI_WEBSITE_ID)
# از .env ریشه خوانده و به npm run build پاس می‌شوند — متغیرهای Vite در زمان بیلد
# روی هاست معنا دارند، نه در ایمیج Caddy.
set -euo pipefail
cd "$(dirname "$0")/.."
DEV_FILES=(-f docker-compose.yml)
[ -f docker-compose.override.yml ] && DEV_FILES+=(-f docker-compose.override.yml)
# compose dev ... | compose prod ... — انتخاب فایل‌های لایه‌ای
compose() {
local mode="$1"
shift
if [ "$mode" = "prod" ]; then
docker compose -f docker-compose.yml -f docker-compose.prod.yml "$@"
else
docker compose "${DEV_FILES[@]}" "$@"
fi
}
load_env_vars() {
# فقط متغیرهای بیلد فرانت‌اند — بدون خروجی
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
fi
}
render_alertmanager() {
# قالب alertmanager.yml با مقادیر .env رندر می‌شود — Alertmanager متغیر محیطی
# را در کانفیگ جایگزین نمی‌کند؛ خروجی (rendered) در گیت نمی‌آید.
load_env_vars
local tpl="monitoring/alertmanager/alertmanager.yml"
local out="monitoring/alertmanager/alertmanager.rendered.yml"
local bot="${ALERT_BOT_TOKEN:-0000000000:PLACEHOLDER-TOKEN}"
local chat="${ALERT_CHAT_ID:-0}"
sed -e "s|\${ALERT_BOT_TOKEN}|${bot}|g" \
-e "s|\${ALERT_CHAT_ID}|${chat}|g" \
"$tpl" > "$out"
if [ -n "${ALERT_BOT_TOKEN:-}" ]; then
echo "[stack] alertmanager config rendered (bale token set)"
else
echo "[stack] alertmanager config rendered (no bale token — alerts will not deliver)"
fi
}
build_frontend() {
load_env_vars
echo "[stack] بیلد فرانت‌اند روی هاست…"
(cd frontend \
&& VITE_API_URL="${VITE_API_URL:-}" \
VITE_UMAMI_SRC="${VITE_UMAMI_SRC:-}" \
VITE_UMAMI_WEBSITE_ID="${VITE_UMAMI_WEBSITE_ID:-}" \
npm run build)
if [ ! -f frontend/dist/index.html ]; then
echo "[stack] ✗ بیلد فرانت‌اند خروجی نداد (frontend/dist)" >&2
exit 1
fi
}
wait_healthy() {
local mode="$1"
echo "[stack] منتظر سلامت سرویس‌ها (حداکثر ۱۸۰ ثانیه)…"
local deadline=$((SECONDS + 180))
local unhealthy=""
while [ $SECONDS -lt $deadline ]; do
unhealthy=$(compose "$mode" ps --format '{{.Service}} {{.Health}}' 2>/dev/null \
| awk '$2=="starting" || $2=="unhealthy" {print $1":"$2}')
if [ -z "$unhealthy" ]; then
echo "[stack] ✔ همه سرویس‌ها سالم‌اند"
return 0
fi
sleep 5
done
echo "[stack] ✗ برخی سرویس‌ها سالم نشدند: ${unhealthy:-نامشخص}" >&2
compose "$mode" ps
return 1
}
do_deploy() {
local mode="$1"
build_frontend
echo "[stack] بیلد و بالا آوردن استک…"
compose "$mode" up -d --build
wait_healthy "$mode"
docker system prune -f >/dev/null 2>&1 || true
echo "[stack] ✔ استقرار تمام شد"
if [ -z "${VITE_UMAMI_WEBSITE_ID:-}" ]; then
echo "[stack] یادآوری: VITE_UMAMI_WEBSITE_ID در .env خالی است — تحلیل رفتار غیرفعال می‌ماند."
fi
}
case "${1:-}" in
deploy)
do_deploy dev
;;
deploy-prod)
load_env_vars
if [ -z "${DOMAIN:-}" ] || [ -z "${ACME_EMAIL:-}" ]; then
echo "[stack] ✗ برای deploy-prod، DOMAIN و ACME_EMAIL باید در .env تنظیم باشند" >&2
exit 1
fi
do_deploy prod
;;
logs)
shift || true
compose dev logs -f --tail=100 "$@"
;;
ps)
compose dev ps
;;
health)
compose dev ps --format '{{.Service}}: {{.Status}}'
;;
backup)
bash scripts/backup.sh
;;
restore)
shift
bash scripts/restore.sh "$@"
;;
update)
compose dev pull
compose dev up -d
wait_healthy dev
;;
monitoring)
# لایه مانیتورینگ — Prometheus/Grafana/Loki/Alertmanager (+ رندر کانفیگ بله)
render_alertmanager
echo "[stack] بالا آوردن لایه مانیتورینگ…"
docker compose "${DEV_FILES[@]}" -f docker-compose.monitoring.yml up -d --build
wait_healthy dev
echo "[stack] ✔ مانیتورینگ بالا آمد — داشبورد: http://grafana.localhost:${TRAEFIK_HTTP_PORT:-5173}"
;;
seed-force)
compose dev run --rm backend python -m app.seed --force
;;
*)
grep '^# ' "$0" | sed 's/^# //'
exit 1
;;
esac