Skip to content

Архитектура проекта

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

Домены и маршрутизация

ДоменVPSUpstreamНазначение
partizap.ru1Nuxt :3001Production фронтенд
partizap.ru/api/1PHP-FPM sockProduction API (marketplace)
partizap.ru/connection/1Centrifugo :8002WebSocket prod
admin.partizap.ru1PHP-FPM (APP_MODE=admin)Production admin API (mTLS, см. admin-service.md)
dev.partizap.ru2Nuxt :3000Dev фронтенд
dev.partizap.ru/api/2PHP-FPM sockDev API (marketplace)
dev.partizap.ru/connection/2Centrifugo :8001WebSocket dev
dev-admin.partizap.ru2PHP-FPM (APP_MODE=admin)Dev admin API
gitlab.partizap.ru2GitLab :8081Git-репозитории
track.partizap.ru2YouTrack :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
СлойДиректорияОтветственность
Actionsapp/Actions/Один invocable класс на эндпоинт. Группы: Auth/, Store/, Vendor/, Admin/
Applicationapp/Application/Middleware, exceptions (AppException с errorCode + httpStatus), JsonResponder для единого формата ответов
Domainapp/Domain/Doctrine-сущности (PHP 8 attributes, SERIAL PK), интерфейсы репозиториев
Infrastructureapp/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Очереди
3Rate 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.phpPHP-DI определения
doctrine.phpEntityManager (attributes из app/Domain/Entity/)
redis.phpRedisConnectionFactory
logging.phpMonolog (file + stderr)
middleware.phpПорядок регистрации middleware
routes.phpВсе API-маршруты
cors.phpAllowed origins, methods, headers
migrations.phpDoctrine 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"] --> A
  • useApiClient() -- чистый 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. Cookie CSRF_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, проходит, а outbound CsrfMiddleware переписывает 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), автоимпорт.

Маршрутизация

МаршрутLayoutSSRMiddleware
/, /catalog, /product/:id, /seller/:iddefaultДа--
/info/* (how-to-buy, privacy, contacts, ...)defaultДа--
/auth/* (login, register, ...)defaultДаguest
/auth/verify-emaildefaultДаauth
/cabinet/**cabinetНет (SPA)auth
/admin/**adminНет (SPA)admin

Публичные страницы рендерятся на сервере для SEO/LCP. Кабинет и админка -- SPA (ssr: false в routeRules).

Доменная модель (Zod)

Zod-схемы -- источник правды для типов. Расположены в entities/*/model/*.schema.ts:

СущностьКлючевые типы
ProductProduct, ProductDetail, ProductForm, ProductImage, ProductSuggestion
UserUser, UserPhone, BusinessProfile
CategoryCategory (types: part, condition, attribute)
CarCarMake, CarModel, CarGeneration, CarModification
GeoRegion, City, District, MetroStation

Инфраструктура

Два VPS

VPS 1 (Production)VPS 2 (DevOps)
IP85.239.48.136 (публичный)192.168.0.5 (внутренний)
Бэкенд/var/www/partizap/production/var/www/partizap/development
ФронтендDocker :3001Docker :3000
CentrifugoDocker :8002Docker :8001
БДpartizap_prod (PgBouncer :6432 / PG :5432)partizap_dev (:5432)
Сервисы--GitLab CE :8081, YouTrack :8080

S3 Storage (Selectel)

Два бакета для изоляции prod/dev:

ProductionDevelopment
Бакетpartizap-prodpartizap-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 2192.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 -> e2e
  • main -- 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-email1515 минIPresetOnSuccess
POST /auth/verify-email301 часuserresetOnSuccess
POST /auth/login1015 минIP
POST /auth/register515 минIP
POST /auth/forgot-password515 минIP

Счётчики хранятся в Redis DB 3 (ключи {prefix}:rate_limit:*). resetOnSuccess: true — сбрасывает счётчик при статусе < 400.

CAPTCHA-гейт (Cloudflare Turnstile)

Защита от brute-force на POST /auth/verify-email:

  1. Первые 3 неудачные попытки — обычный ответ 422 invalid_code
  2. Начиная с 4-й попытки — ответ 422 captcha_required, фронт показывает CAPTCHA
  3. Клиент отправляет cf-turnstile-response в теле запроса
  4. Бэкенд валидирует токен через Cloudflare Siteverify API
  5. При успешном подтверждении кода — счётчик неудач сбрасывается

Счётчик неудач (VerificationBackoff) хранится в Redis DB 3. Rate limiter middleware — DDoS safety net поверх CAPTCHA-гейта.

Firewall (iptables)

VPS 2 (DevOps) — закрыт от внешнего доступа:

ПравилоНазначение
ACCEPT tcp:22 from 192.168.0.4SSH только от VPS 1
ACCEPT tcp:80,443 from 192.168.0.4HTTP/HTTPS только от VPS 1
ACCEPT established/relatedОтветы на исходящие соединения
DROP всё остальноеДефолтная политика INPUT

VPS 1 (Production) — NAT-шлюз:

ПравилоНазначение
DNAT :2222 → 192.168.0.5:22Git SSH проброс на VPS 2
MASQUERADE (eth1, :22)Подмена source IP для SSH-проброса
MASQUERADE (eth1, общий)NAT-выход в интернет для VPS 2

SSH-хардинг и fail2ban

Оба VPS:

  • PermitRootLogin prohibit-password (только ключи)
  • PasswordAuthentication no
  • MaxAuthTries 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, severityinfo

Фильтр в 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

Как работает

  1. SharedWorker на фронтенде держит единственное WebSocket-соединение с Centrifugo на все вкладки
  2. nginx проксирует /connection/ на Centrifugo
  3. При отправке сообщения: фронтенд -> REST API (PHP) -> сохранение в БД -> publish в Centrifugo -> доставка подписчикам через WebSocket
  4. Каждый чат -- отдельный канал Centrifugo
  5. Оптимистичные обновления на фронтенде: сообщение отображается до подтверждения сервера

Каналы

Именование каналов: chat:<conversation_id>. Авторизация подписки через бэкенд (проверка, что пользователь -- участник чата).

Ключевые технические решения

Детальное обоснование -- в ADR:

ADRРешениеСуть
ADR-001ORM & Query BuilderDoctrine ORM вместо raw SQL
ADR-002REST API StructureSlim 4, JSON API, группы маршрутов
ADR-003AuthenticationСессии + CSRF вместо JWT
ADR-004Database PatternsPostgreSQL, SERIAL PK, миграции
ADR-005Caching StrategyRedis: 4 DB, префиксы, TTL
ADR-006File & Image HandlingS3-совместимое хранилище
ADR-007Background JobsRedis-очереди
ADR-008Logging & MonitoringMonolog, health checks
ADR-009Search 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: сначала обновить спеку -> реализовать эндпоинт -> коммит в оба репозитория