Appearance
Архитектура проекта
Partizap -- маркетплейс автозапчастей для Санкт-Петербурга и Ленинградской области. Монолитный бэкенд на PHP Slim 4, фронтенд на Nuxt 4, real-time чат через Centrifugo.
Обзор системы
mermaid
graph TB
Browser["Браузер"]
subgraph VPS1["VPS 1 -- Production (85.239.48.136)"]
Nginx1["nginx<br/>SSL termination<br/>routing"]
PHPProd["PHP-FPM 8.3<br/>(partizap-prod)"]
NuxtProd["Nuxt 4 (Docker)<br/>:3001"]
CentProd["Centrifugo (Docker)<br/>:8002"]
PG["PostgreSQL<br/>partizap_prod<br/>PgBouncer :6432"]
Redis["Redis<br/>4 DB"]
end
subgraph VPS2["VPS 2 -- DevOps (192.168.0.5, internal)"]
Nginx2["nginx"]
PHPDev["PHP-FPM 8.3<br/>(partizap-dev)"]
NuxtDev["Nuxt 4 (Docker)<br/>:3000"]
CentDev["Centrifugo (Docker)<br/>:8001"]
PGDev["PostgreSQL<br/>partizap_dev"]
RedisDev["Redis"]
GitLab["GitLab CE :8081"]
YouTrack["YouTrack :8080"]
end
Browser -->|HTTPS| Nginx1
Nginx1 -->|"partizap.ru"| NuxtProd
Nginx1 -->|"/api/"| PHPProd
Nginx1 -->|"/connection/"| CentProd
Nginx1 -->|"dev/gitlab.partizap.ru"| VPS2
PHPProd --> PG
PHPProd --> Redis
Nginx2 -->|"dev.partizap.ru"| NuxtDev
Nginx2 -->|"/api/"| PHPDev
Nginx2 -->|"/connection/"| CentDev
PHPDev --> PGDev
PHPDev --> RedisDevСетевая топология
VPS 2 не имеет публичного IP. VPS 1 обеспечивает:
- NAT (iptables MASQUERADE) -- VPS 2 выходит в интернет через VPS 1
- Reverse proxy -- nginx на VPS 1 проксирует
dev.partizap.ru,gitlab.partizap.ru,track.partizap.ruна VPS 2 - SSH jump -- доступ к VPS 2:
ssh -J root@85.239.48.136 root@192.168.0.5 - Git SSH -- порт 2222 на VPS 1 пробрасывается на порт 22 VPS 2
Домены и маршрутизация
| Домен | VPS | Upstream | Назначение |
|---|---|---|---|
partizap.ru | 1 | Nuxt :3001 | Production фронтенд |
partizap.ru/api/ | 1 | PHP-FPM sock | Production API (marketplace) |
partizap.ru/connection/ | 1 | Centrifugo :8002 | WebSocket prod |
admin.partizap.ru | 1 | PHP-FPM (APP_MODE=admin) | Production admin API (mTLS, см. admin-service.md) |
dev.partizap.ru | 2 | Nuxt :3000 | Dev фронтенд |
dev.partizap.ru/api/ | 2 | PHP-FPM sock | Dev API (marketplace) |
dev.partizap.ru/connection/ | 2 | Centrifugo :8001 | WebSocket dev |
dev-admin.partizap.ru | 2 | PHP-FPM (APP_MODE=admin) | Dev admin API |
gitlab.partizap.ru | 2 | GitLab :8081 | Git-репозитории |
track.partizap.ru | 2 | YouTrack :8080 | Задачи |
Admin API вынесен в отдельный сервис (
admin.partizap.ru/dev-admin.partizap.ru). Фронтенд-e2e для админки временно отключены (DEV-300), пока admin-фронт не адаптирован к новому домену.
Бэкенд
PHP 8.3, Slim 4, Doctrine ORM, PostgreSQL, Redis.
Слои (DDD)
mermaid
graph LR
Request["HTTP Request"] --> Actions
Actions --> Application
Application --> Domain
Domain --> Infrastructure
Infrastructure --> DB["PostgreSQL / Redis / S3"]
style Actions fill:#4CAF50,color:#fff
style Application fill:#2196F3,color:#fff
style Domain fill:#FF9800,color:#fff
style Infrastructure fill:#9C27B0,color:#fff| Слой | Директория | Ответственность |
|---|---|---|
| Actions | app/Actions/ | Один invocable класс на эндпоинт. Группы: Auth/, Store/, Vendor/, Admin/ |
| Application | app/Application/ | Middleware, exceptions (AppException с errorCode + httpStatus), JsonResponder для единого формата ответов |
| Domain | app/Domain/ | Doctrine-сущности (PHP 8 attributes, SERIAL PK), интерфейсы репозиториев |
| Infrastructure | app/Infrastructure/ | Doctrine-реализации репозиториев, RedisConnectionFactory, логирование |
Формат ответов API
Успех:
json
{ "data": { ... }, "meta": { ... } }Ошибка:
json
{ "error": { "code": "VALIDATION_ERROR", "message": "..." } }Маршруты и авторизация
| Группа | Middleware | Доступ |
|---|---|---|
/store/* | -- | Публичный (каталог, товары, справочники) |
/auth/* | -- | Публичный (login, register, logout, me) |
/vendor/* | AuthMiddleware | Авторизованные пользователи |
/admin/* | AuthMiddleware + AdminMiddleware | Администраторы |
GET /health | -- | Диагностика (DB + Redis) |
Middleware-стек
ErrorHandler → CORS → SecurityHeaders → CSRF → RateLimit(global) → JsonContentType → Timing → BodyParsing → RoutingНа уровне маршрутов добавляются route-specific middleware (AuthMiddleware, RateLimitMiddleware per-endpoint и т.д.).
DI-контейнер
PHP-DI с компиляцией (var/cache/) при APP_DEBUG=false. Все зависимости определены в config/container.php: Logger (Monolog), EntityManager (Doctrine/PostgreSQL), RedisConnectionFactory, репозитории (интерфейс -> Doctrine-реализация).
Redis -- 4 базы данных
| DB | Назначение |
|---|---|
| 0 | Сессии |
| 1 | Кэш (справочники: категории, авто, гео) |
| 2 | Очереди |
| 3 | Rate limiting |
Префиксы: prod: на production, dev: на develop. Кэш справочных данных наполняется при первом запросе и требует ручного сброса при изменении данных.
Сессии
Сессии хранятся в Redis DB 0 через SessionManager. Время жизни определяется SESSION_LIFETIME в .env (по умолчанию 7200 = 2 часа).
Без "Запомнить меня":
- Cookie: session cookie (
expires=0) — умирает при закрытии браузера - Redis TTL:
SESSION_LIFETIME(2 часа)
С "Запомнить меня":
- Cookie: persistent, 30 дней
- Redis TTL: 30 дней
Общие механизмы:
- Sliding window:
AuthMiddlewareвызываетtouch()на каждом аутентифицированном запросе. Когда Redis TTL падает ниже половиныSESSION_LIFETIME, он продлевается до полного значения. Сессия истекает после бездействия, а не после фиксированного времени от логина. - Fingerprint: нормализованный
User-Agent— только семейство браузера и мажорная версия (Chrome/136,Firefox/128,Safari/17). Патч-обновления браузера и изменения IP (WiFi → мобильная сеть, VPN) не приводят к разлогину. Смена семейства или мажорной версии уничтожает сессию. Реализация:SessionManager::computeFingerprint(). - Regenerate при login + transparent migration:
session_regenerate_id(false)создаёт новый SID, а старая сессия получает 30-секундный grace window. Параллельно в Redis пишется указательsession_migration:{old_sid} → {new_sid, fp}, привязанный к fingerprint текущего запроса. Когда другая вкладка того же браузера приходит с pre-regenerate cookie (например, in-flight запрос во время логина),SessionManager::start()доsession_start()следует по этому указателю (до 5 хопов — покрывает цепочку password → TOTP в админке), подменяетsession_id()на новый и вкладка автоматически попадает в post-login сессию. Проверка fingerprint закрывает возможную session-fixation регрессию: третья сторона с чужим pre-regenerate SID не сможет клеймить post-login сессию без совпадающего браузерного fingerprint. - DB
UserSession.expires_at: соответствует реальному TTL сессии (2 часа или 30 дней), а не всегда 30 дней.
Админ-сессии (Redis DB 4, cookie PARTIZAP_ADMIN_SESSION):
- Дублируются в таблицу
admin_sessionsдля аудита и управления активными сессиями. Запись создаётся вLoginAction::completeLogin(), еёidкладётся в$_SESSION['admin_session_db_id']. AdminMiddlewareдержит собственный sliding-window поверхAdminSession.expires_at: если с последнего touch прошло больше 5 минут, DB-запись продлевается черезextendExpiration($sessionLifetime)— Redis TTL иexpires_atвсегда синхронизированы. Проверка просрочки работает сначала по кешу в$_SESSION, потом по DB, что избегает лишнего SELECT на каждый запрос.- Индексы
idx_user_sessions_expires_atиidx_admin_sessions_expires_atподдерживают дешёвый GC-скан.
Очистка истёкших сессий:
bash
./bin/console app:clean-expired-sessionsКоманда удаляет строки из user_sessions и admin_sessions, где expires_at < NOW(). За счёт sliding-window touch() активные сессии не попадают под удаление. Без регулярного запуска таблицы растут линейно с числом логинов.
Least-privilege split и graceful skip (DEV-353)
Маркетплейс и админка запускаются под разными PostgreSQL ролями (partizap_{dev,prod} и partizap_admin_{dev,prod}), гранты разделены по принципу least privilege: маркетплейс-роль не видит admin_sessions, админ-роль не видит user_sessions. Одна команда не может почистить обе таблицы из-под одной роли.
Решение: команда ловит SQLSTATE[42501] (insufficient_privilege) и молча пропускает недоступную таблицу. Каждое развёртывание (маркетплейс и админка) запускает свою копию по cron — каждая чистит свою таблицу, другая пропускается с note.
Типичный вывод на маркетплейс-деплое:
user sessions: 3 deleted
admin sessions: skipped (no privilege)
! [NOTE] Skipped admin sessions: current DB role has no DELETE privilege on this table.
[OK] Cleanup done.Cron-расписание — файлы deploy/cron/partizap-session-gc.{dev,prod} в server-репо, установлены в /etc/cron.d/partizap-session-gc на обоих VPS. Запускаются от www-data (тот же runtime-user, что и partizap-worker-*.service; serpens — deploy/ops-user, не для scheduled runtime jobs):
cron
# VPS 2 — dev
0 * * * * www-data cd /var/www/partizap/development && /usr/bin/php bin/console app:clean-expired-sessions 2>&1 | /usr/bin/logger -t partizap-session-gc-marketplace-dev
5 * * * * www-data cd /var/www/partizap/admin-development && /usr/bin/php bin/console app:clean-expired-sessions 2>&1 | /usr/bin/logger -t partizap-session-gc-admin-dev
# VPS 1 — prod
0 * * * * www-data cd /var/www/partizap/production && /usr/bin/php bin/console app:clean-expired-sessions 2>&1 | /usr/bin/logger -t partizap-session-gc-marketplace-prod
5 * * * * www-data cd /var/www/partizap/admin-production && /usr/bin/php bin/console app:clean-expired-sessions 2>&1 | /usr/bin/logger -t partizap-session-gc-admin-prodЗапуск ежечасно, маркетплейс и админка разведены на 5 минут. Вывод идёт в syslog с тегами по деплою:
bash
journalctl -t partizap-session-gc-marketplace-dev --since '1 hour ago'
journalctl -t partizap-session-gc-admin-prod --since '1 hour ago'WARNING
session.gc_maxlifetime должен совпадать с SESSION_LIFETIME. PHP-дефолт 1440с (24 мин) приводит к тому, что Redis удаляет сессию раньше, чем истекает cookie — пользователь получает неожиданный logout.
Конфигурация
Каждый файл config/*.php возвращает фабричное замыкание:
| Файл | Что настраивает |
|---|---|
app.php | Загрузка .env, валидация переменных |
container.php | PHP-DI определения |
doctrine.php | EntityManager (attributes из app/Domain/Entity/) |
redis.php | RedisConnectionFactory |
logging.php | Monolog (file + stderr) |
middleware.php | Порядок регистрации middleware |
routes.php | Все API-маршруты |
cors.php | Allowed origins, methods, headers |
migrations.php | Doctrine Migrations |
База данных
PostgreSQL, 28 таблиц. Ключевые иерархии:
- Авто: Make -> Model -> Generation -> Modification (4 уровня для точного подбора совместимости)
- Гео: Region -> City -> District + MetroStation
- Категории: self-referencing дерево (parent_id)
ORM: Doctrine с PHP 8 attributes, SERIAL integer PK (не UUID). Миграции через Doctrine Migrations.
Фронтенд
Nuxt 4.3 (Vue 3.5), Nuxt UI v3, Tailwind CSS 4, Pinia, Zod.
FSD-архитектура (Feature-Sliced Design)
mermaid
graph BT
shared["shared<br/>UI-компоненты, API-клиент, схемы"]
entities["entities<br/>product, user, category, car, geo"]
features["features<br/>auth, ymm-select, geo-select, image-upload, catalog-filters, ..."]
widgets["widgets<br/>header, footer, catalog-filters, product-list"]
pages["pages<br/>index, catalog, product, auth/*, cabinet/*, admin/*"]
app["app<br/>composables, plugins, middleware, stores, layouts"]
shared --> entities
entities --> features
features --> widgets
widgets --> pages
pages --> appИмпорты строго снизу вверх. Кросс-импорты между слайсами запрещены (enforce ESLint).
API-клиент
mermaid
graph LR
A["shared/api/client.ts<br/>useApiClient()"] -->|"без auth"| B["HTTP-запрос"]
C["app/composables/useApi.ts<br/>useApi()"] -->|"+ auth (cookie, CSRF)"| B
D["pages / features / widgets"] --> C
E["shared layer"] --> AuseApiClient()-- чистый HTTP-клиент без бизнес-логики (shared слой)useApi()-- обертка с авторизацией: сессионная cookie + CSRF-токен (app слой)
Endpoint factory (DEV-305). Все URL API-эндпоинтов собираются через типизированные фабрики в app/entities/<entity>/api/endpoints.ts (например, vendorProductEndpoints.imagesOrder(productId), categoryEndpoints.suggest()). Хардкод template-string URL в composables/features/pages/stores/tests запрещён. Тесты импортируют ту же фабрику для проверки контракта. Исключение — E2E-тесты tests/e2e/, работающие против развёрнутого API.
Аутентификация на фронтенде
- Сессии -- HTTP-only cookie
PARTIZAP_SESSION(не JWT) - CSRF -- per-session токен: генерируется один раз при создании сессии, хранится в
$_SESSION. CookieCSRF_TOKEN(читаемая) -> заголовокX-CSRF-TOKENна каждом мутирующем запросе. Токен пересоздаётся при login (session_regenerate_id). Не ротируется на каждый запрос — это предотвращает race condition при параллельных POST (OWASP Synchronizer Token Pattern). При регенерации старый токен сохраняется какcsrf_token_previousс тем же 30-секундным grace window, что и session-migration (DEV-349):CsrfTokenManager::validateToken()принимает его, пока grace не истёк, так что мутирующий запрос, отправленный клиентом сразу после логина со старым CSRF cookie, проходит, а outboundCsrfMiddlewareпереписывает cookie на актуальный токен для следующих запросов. - SSR -- cookie forwarding через
useRequestHeaders(['cookie']) - Гидрация -- плагин
auth.tsвызываетGET /auth/meпри инициализации приложения
Stores (Pinia)
| Store | Ответственность |
|---|---|
useAuthStore() | Состояние пользователя, login/register/logout, isAdmin |
useGeoStore() | Текущий город/регион, список регионов |
useFavoritesStore() | ID избранных товаров (Set), toggle |
Все stores используют functional style (Setup Stores), автоимпорт.
Маршрутизация
| Маршрут | Layout | SSR | Middleware |
|---|---|---|---|
/, /catalog, /product/:id, /seller/:id | default | Да | -- |
/info/* (how-to-buy, privacy, contacts, ...) | default | Да | -- |
/auth/* (login, register, ...) | default | Да | guest |
/auth/verify-email | default | Да | auth |
/cabinet/** | cabinet | Нет (SPA) | auth |
/admin/** | admin | Нет (SPA) | admin |
Публичные страницы рендерятся на сервере для SEO/LCP. Кабинет и админка -- SPA (ssr: false в routeRules).
Доменная модель (Zod)
Zod-схемы -- источник правды для типов. Расположены в entities/*/model/*.schema.ts:
| Сущность | Ключевые типы |
|---|---|
| Product | Product, ProductDetail, ProductForm, ProductImage, ProductSuggestion |
| User | User, UserPhone, BusinessProfile |
| Category | Category (types: part, condition, attribute) |
| Car | CarMake, CarModel, CarGeneration, CarModification |
| Geo | Region, City, District, MetroStation |
Инфраструктура
Два VPS
| VPS 1 (Production) | VPS 2 (DevOps) | |
|---|---|---|
| IP | 85.239.48.136 (публичный) | 192.168.0.5 (внутренний) |
| Бэкенд | /var/www/partizap/production | /var/www/partizap/development |
| Фронтенд | Docker :3001 | Docker :3000 |
| Centrifugo | Docker :8002 | Docker :8001 |
| БД | partizap_prod (PgBouncer :6432 / PG :5432) | partizap_dev (:5432) |
| Сервисы | -- | GitLab CE :8081, YouTrack :8080 |
S3 Storage (Selectel)
Два бакета для изоляции prod/dev:
| Production | Development | |
|---|---|---|
| Бакет | partizap-prod | partizap-dev |
| CDN-домен | api.partizap.ru | (дефолтный Selectel) |
| Base URL изображений | https://api.partizap.ru/... | https://39787932-a448-4450-8c57-9d8d2a62475f.selstorage.ru/... |
api.partizap.ru привязан только к partizap-prod. Dev-бакет использует дефолтный домен Selectel Object Storage.
URL-ы изображений хранятся в БД (таблицы product_images, users.avatar_url, messages.image_url). При миграции CDN-домена необходимо обновить URL-ы через SQL REPLACE() и сбросить Redis-кеш.
SSL
nginx на VPS 1 терминирует SSL для всех доменов *.partizap.ru. VPS 2 работает по HTTP, трафик между серверами идет по внутренней сети.
Client IP и TRUSTED_PROXIES
ClientIpResolver (app/Application/Service/) определяет реальный IP клиента и доверяет заголовкам X-Real-IP / X-Forwarded-For только если REMOTE_ADDR входит в список TRUSTED_PROXIES из .env. В остальных случаях возвращается REMOTE_ADDR, что исключает спуфинг IP произвольным заголовком.
| Окружение | Что PHP видит в REMOTE_ADDR | Значение TRUSTED_PROXIES |
|---|---|---|
| Prod (VPS 1) | Реальный IP клиента (nginx сам является edge-сервером) | 127.0.0.1,::1 — заголовки не консультируются, резолвер просто возвращает REMOTE_ADDR |
| Dev (VPS 2) | 192.168.0.4 — внутренний IP VPS 1, т.к. VPS 1 nginx reverse-proxies в VPS 2 | 192.168.0.4,127.0.0.1,::1 — без этого резолвер вернул бы IP реверс-прокси для всех запросов |
При добавлении нового промежуточного прокси (CDN, sidecar) его IP нужно внести в TRUSTED_PROXIES на затронутом VPS, иначе резолвер проигнорирует X-Real-IP и будет писать в аудит/лockout IP прокси вместо клиента.
CI/CD
mermaid
graph LR
subgraph Backend
B1["push develop"] --> B2["git pull на VPS 2"]
B3["MR develop→main"] --> B4["git pull на VPS 1"]
end
subgraph Frontend
F1["push develop"] --> F2["lint + typecheck + test → build → deploy dev"]
F3["push main"] --> F4["semantic-release → tag"]
F5["tag v*"] --> F6["build → deploy prod"]
end
subgraph Nginx
N1["push main"] -->|"main/** changed"| N2["deploy VPS 1"]
N1 -->|"dev/** changed"| N3["deploy VPS 2"]
endБэкенд: ручной деплой (git pull). Feature branch -> MR в develop -> MR в main.
Фронтенд: GitLab CI, три пайплайна:
develop-- validate (lint, typecheck, test) -> build -> deploy -> e2emain-- semantic-release (auto-versioning)tag v*-- build -> deploy production
Nginx: GitLab CI автоматически деплоит при пуше в main.
Безопасность
WAF (ModSecurity + OWASP CRS)
На обоих VPS установлен ModSecurity 3 с OWASP Core Rule Set 3.3.5 в режиме DetectionOnly — логирует подозрительные запросы, но не блокирует.
| Параметр | Значение |
|---|---|
| Движок | ModSecurity 3 (nginx module) |
| Правила | OWASP CRS 3.3.5 |
| Режим | DetectionOnly (логирование без блокировки) |
| Лог | /var/log/modsec_audit.log |
| Ротация | logrotate daily, 14 дней хранения, compress |
WAF включён для продуктовых доменов (partizap.ru, dev.partizap.ru, docs.partizap.ru). Для DevOps-сервисов отключён:
| Домен | WAF |
|---|---|
| partizap.ru | Включён |
| dev.partizap.ru | Включён |
| docs.partizap.ru | Включён |
| gitlab.partizap.ru | Отключён — API GitLab генерирует массовые false positives |
| track.partizap.ru | Отключён — YouTrack API несовместим с CRS-правилами |
Конфигурация: nginx репо, директория modsecurity/.
Rate Limiting
Двухуровневая система ограничения запросов:
Глобальный лимит (middleware на все /api/* маршруты):
| Лимит | Окно | Ключ |
|---|---|---|
| 100 запросов | 1 минута | IP |
Per-endpoint лимиты (route-specific middleware):
| Эндпоинт | Лимит | Окно | Ключ | Особенности |
|---|---|---|---|---|
POST /auth/verify-email | 15 | 15 мин | IP | resetOnSuccess |
POST /auth/verify-email | 30 | 1 час | user | resetOnSuccess |
POST /auth/login | 10 | 15 мин | IP | — |
POST /auth/register | 5 | 15 мин | IP | — |
POST /auth/forgot-password | 5 | 15 мин | IP | — |
Счётчики хранятся в Redis DB 3 (ключи {prefix}:rate_limit:*). resetOnSuccess: true — сбрасывает счётчик при статусе < 400.
CAPTCHA-гейт (Cloudflare Turnstile)
Защита от brute-force на POST /auth/verify-email:
- Первые 3 неудачные попытки — обычный ответ
422 invalid_code - Начиная с 4-й попытки — ответ
422 captcha_required, фронт показывает CAPTCHA - Клиент отправляет
cf-turnstile-responseв теле запроса - Бэкенд валидирует токен через Cloudflare Siteverify API
- При успешном подтверждении кода — счётчик неудач сбрасывается
Счётчик неудач (VerificationBackoff) хранится в Redis DB 3. Rate limiter middleware — DDoS safety net поверх CAPTCHA-гейта.
Firewall (iptables)
VPS 2 (DevOps) — закрыт от внешнего доступа:
| Правило | Назначение |
|---|---|
| ACCEPT tcp:22 from 192.168.0.4 | SSH только от VPS 1 |
| ACCEPT tcp:80,443 from 192.168.0.4 | HTTP/HTTPS только от VPS 1 |
| ACCEPT established/related | Ответы на исходящие соединения |
| DROP всё остальное | Дефолтная политика INPUT |
VPS 1 (Production) — NAT-шлюз:
| Правило | Назначение |
|---|---|
| DNAT :2222 → 192.168.0.5:22 | Git SSH проброс на VPS 2 |
| MASQUERADE (eth1, :22) | Подмена source IP для SSH-проброса |
| MASQUERADE (eth1, общий) | NAT-выход в интернет для VPS 2 |
SSH-хардинг и fail2ban
Оба VPS:
PermitRootLogin prohibit-password(только ключи)PasswordAuthentication noMaxAuthTries 3- fail2ban на sshd (5 попыток / 10 мин → бан 1 час)
Конфигурация: server репо, deploy/fail2ban/ и deploy/sshd/.
Admin-панель: mTLS
Домены admin.partizap.ru и dev-admin.partizap.ru требуют клиентский сертификат (mTLS). Без .p12 сертификата в браузере nginx возвращает 403.
Логирование и алертинг
WARNING
ADR-008 описывает стек Pino/Loki/Prometheus/Grafana — это от первоначального Node.js проекта. Ниже описана фактическая реализация на PHP.
Уровень приложения: Monolog → Sentry
| Компонент | Технология |
|---|---|
| Логгер | Monolog 3 |
| Транспорт (prod) | Sentry PHP SDK (sentry/sentry, handler в Monolog) |
| Транспорт (dev) | stderr + файл (var/log/app.log) |
| Уровень | warning+ → Sentry, debug+ → файл |
Monolog настроен в config/logging.php. На production и development ошибки уровня warning и выше уходят в Sentry (SENTRY_DSN из .env). Sentry проект: partizap-backend.
Аудит vendor-действий: VendorAuditMiddleware логирует все POST/PUT/DELETE запросы в /vendor/* (user_id, method, path, IP, status) через отдельный Monolog channel.
Health check: GET /health — проверяет PostgreSQL и Redis, возвращает статус каждого компонента. Используется для мониторинга доступности.
Уровень инфраструктуры: WAF-мониторинг → Sentry
Скрипт modsec-monitor.sh парсит лог ModSecurity и отправляет события в Sentry:
| Режим | Расписание | Что делает | Sentry level |
|---|---|---|---|
alert | Ежечасно | Считает WAF-триггеры за час. При превышении порога (50) — алерт | warning |
summary | Ежедневно 06:00 | Дайджест за 24ч: топ-10 правил, URI, severity | info |
Фильтр в Sentry: logger:modsecurity или тег component:waf.
Fallback: syslog (auth.warning/auth.info) + локальный отчёт /var/log/modsec_daily_report.log.
Конфигурация: /etc/modsec-monitor.env (содержит SENTRY_DSN, порог, имя сервера). Скрипт и cron в nginx репо, modsecurity/.
Диагностика
Бэкенд включает страницу диагностики (/admin/diagnostics) — 14 табов: статус сервисов, Redis, PostgreSQL, PHP info, Sentry connectivity, disk usage и т.д. Подробнее: server/docs/diagnostics.md.
Поток данных
Типичный API-запрос
mermaid
sequenceDiagram
participant B as Браузер
participant N as Nginx (VPS 1)
participant P as PHP-FPM
participant A as Action
participant R as Repository
participant DB as PostgreSQL
B->>N: HTTPS GET /api/store/products
N->>P: FastCGI (php-fpm sock)
P->>A: __invoke($request, $response)
A->>R: findAll($criteria)
R->>DB: SELECT ...
DB-->>R: ResultSet
R-->>A: Entity[]
A-->>P: JsonResponder::success($data)
P-->>N: JSON response
N-->>B: HTTPS responseЗапрос с авторизацией
mermaid
sequenceDiagram
participant B as Браузер
participant N as Nginx
participant P as PHP-FPM
participant MW as Middleware Stack
participant A as Action
B->>N: POST /api/vendor/products<br/>Cookie: PARTIZAP_SESSION<br/>X-CSRF-TOKEN: ...
N->>P: FastCGI
P->>MW: ErrorHandler → CORS → SecurityHeaders → JsonContentType
MW->>MW: AuthMiddleware: проверка сессии в Redis DB 0
MW->>A: Action::__invoke()
A-->>B: { data: ... }Загрузка страницы (SSR)
mermaid
sequenceDiagram
participant B as Браузер
participant N as Nginx
participant Nuxt as Nuxt (SSR)
participant API as PHP API
B->>N: GET /catalog
N->>Nuxt: proxy :3001
Nuxt->>API: internal fetch /api/store/products<br/>(cookie forwarding)
API-->>Nuxt: JSON
Nuxt-->>N: HTML (hydrated)
N-->>B: HTML + JS bundle
Note over B: Vue hydration → SPA навигация далееReal-time: Centrifugo
Чат реализован через Centrifugo -- отдельный WebSocket-сервер.
Архитектура
mermaid
graph TB
subgraph Browser["Браузер"]
SW["SharedWorker<br/>(единое WS-соединение)"]
Tab1["Вкладка 1"]
Tab2["Вкладка 2"]
end
subgraph Server
Cent["Centrifugo<br/>:8002 / :8001"]
PHP["PHP API"]
PG["PostgreSQL"]
end
Tab1 <-->|"postMessage"| SW
Tab2 <-->|"postMessage"| SW
SW <-->|"WebSocket<br/>/connection/"| Cent
PHP -->|"publish API"| Cent
PHP --> PGКак работает
- SharedWorker на фронтенде держит единственное WebSocket-соединение с Centrifugo на все вкладки
- nginx проксирует
/connection/на Centrifugo - При отправке сообщения: фронтенд -> REST API (PHP) -> сохранение в БД -> publish в Centrifugo -> доставка подписчикам через WebSocket
- Каждый чат -- отдельный канал Centrifugo
- Оптимистичные обновления на фронтенде: сообщение отображается до подтверждения сервера
Каналы
Именование каналов: chat:<conversation_id>. Авторизация подписки через бэкенд (проверка, что пользователь -- участник чата).
Ключевые технические решения
Детальное обоснование -- в ADR:
| ADR | Решение | Суть |
|---|---|---|
| ADR-001 | ORM & Query Builder | Doctrine ORM вместо raw SQL |
| ADR-002 | REST API Structure | Slim 4, JSON API, группы маршрутов |
| ADR-003 | Authentication | Сессии + CSRF вместо JWT |
| ADR-004 | Database Patterns | PostgreSQL, SERIAL PK, миграции |
| ADR-005 | Caching Strategy | Redis: 4 DB, префиксы, TTL |
| ADR-006 | File & Image Handling | S3-совместимое хранилище |
| ADR-007 | Background Jobs | Redis-очереди |
| ADR-008 | Logging & Monitoring | Monolog, health checks |
| ADR-009 | Search Engine | Стратегия поиска |
WARNING
ADR написаны для изначального Node.js стека. При расхождениях с текущей реализацией на PHP/Slim 4 -- актуальная информация в MVP-дизайне и CLAUDE.md бэкенда.
Spec-Driven Development
Проект использует OpenAPI-спецификацию как единый источник правды для API-контракта.
- Спецификация:
partizap-docs/specs/openapi.yaml - Портал документации: docs.partizap.ru
- Workflow: сначала обновить спеку -> реализовать эндпоинт -> коммит в оба репозитория