Перейти к содержанию

01. Архитектура системы

1.1. Контекст

                    ┌───────────────────────────────┐
                    │        iiko Transport         │
                    │   api-ru.iiko.services        │
                    └───────────────────────────────┘
                       ▲ импорт          ▲ заказы (этап 2)
                       │ (pull)          │ + webhooks
                       │                 │
┌──────────┐      ┌────┴─────────────────┴────────────────────────┐
│ Витрина  │─────▶│                                               │
│ (сайт,   │      │            Монолит (Kotlin + Spring)          │
│  моб.    │◀─────│                                               │
│  прилож.)│      │  ┌─────────┐ ┌─────────┐ ┌──────┐ ┌────────┐  │
└──────────┘      │  │iiko-sync│ │ catalog │ │media │ │ admin  │  │
                  │  └─────────┘ └─────────┘ └──────┘ └────────┘  │
┌──────────┐      │  ┌─────────┐ ┌─────────┐ ┌──────┐ ┌────────┐  │
│ Админка  │─────▶│  │ order*  │ │ pricing*│ │crm*  │ │platform│  │
│ React+   │◀─────│  └─────────┘ └─────────┘ └──────┘ └────────┘  │
│ AntD     │      │             * — следующие этапы               │
└──────────┘      └───────────────┬───────────────┬───────────────┘
                                  │               │
                          ┌───────▼──────┐  ┌─────▼──────┐
                          │  PostgreSQL  │  │   Redis    │
                          └──────────────┘  └────────────┘
                                  │
                          ┌───────▼──────┐
                          │  S3 / MinIO  │  (медиа)
                          └──────────────┘

1.2. Почему монолит и что это значит на практике

Legacy распался на 5 сервисов, из которых два независимо импортировали одно и то же меню из iiko (site/app/Models/Import/* и menu/internal/domain/handlers/imports/*) в две разные схемы БД с разной моделью. Это главная цена того разделения: расхождение данных между сайтом и API меню, двойная поддержка, двойные баги.

Монолит здесь — один процесс и одна база, но с внутренними модулями и явными границами. Границы держит Spring Modulith — это не «ещё одна библиотека», а именно тот инструмент, который делает модульность проверяемой, а не декларативной.

Что даёт Spring Modulith

Возможность Как используем
@ApplicationModule + структура пакетов модуль = прямой подпакет ru.panampizza.monolith; всё, что не опубликовано через @NamedInterface, — приватная реализация, недоступная другим модулям
@NamedInterface явный публичный контракт модуля (iiko::api, catalog::api); остальное закрыто на уровне компиляции проверок
ApplicationModules.verify() один JUnit-тест валит сборку при нарушении границ или цикле зависимостей — заменяет ручные правила ArchUnit
Event Publication Registry транзакционный outbox из коробки: событие сохраняется в event_publication в той же транзакции, обработчик помечает выполнение, незавершённые переигрываются при рестарте
@ApplicationModuleTest тест модуля с поднятием только его контекста и заглушками соседей — быстрые интеграционные тесты импорта и каталога
Documenter автогенерация C4-диаграмм модулей и таблицы зависимостей в CI — документация не расходится с кодом
Observability трассировка на границах модулей: видно, сколько занял catalog внутри обработки события импорта

Ключевой выигрыш для этого проекта — Event Publication Registry. Схема «импорт закоммитил → опубликовал событие → каталог пересобрал снапшот» без outbox теряет пересборку при падении процесса между коммитом и обработкой, и витрина остаётся со старым меню до следующего импорта. Modulith закрывает это без ручной таблицы outbox и своего диспетчера.

Правила, которые Modulith не проверяет и которые остаются на ревью: - модули не ходят в чужие таблицы напрямую — только через API модуля-владельца (SQL-запросы к чужим схемам ловятся отдельным ArchUnit-правилом на пакеты jOOQ); - модуль iiko не знает про catalog ни в каком виде.

ArchUnit остаётся, но в узкой роли: слои внутри модуля (см. ниже), правила именования, запрет @Transactional на приватных методах, запрет обращения к схеме чужого модуля. Проверку границ между модулями делает Modulith.

Требование к дизайну: iiko и публичное API витрины (storefront) должны быть выделяемы в отдельный процесс без переписывания домена (нагрузка на витрину и на импорт разная по профилю). Модульная структура Modulith — прямая заготовка под это: выделение модуля сводится к замене внутрипроцессных событий на брокер.

Внутренняя структура модуля

Внутри модуля код раскладывается по слоям, зависимости идут только вниз:

<модуль>/
├── api/              публичный контракт (@NamedInterface)
├── application/      сценарии — реализации интерфейсов api, транзакции, оркестрация
│   └── <функция>/    подпакет на бизнес-функцию (auth, session, address, …)
├── domain/           сущности, инварианты, репозитории, доменные операции
└── infrastructure/   криптография, клиенты внешних API, адаптеры к чужим api, *Properties
  • application → domain, infrastructure, infrastructure → domain; domain не зависит от внешних слоёв.
  • Сценарии в разных application/<функция> не зависят друг от друга: общая логика опускается в domain, общий маппинг лежит в корне application. Поэтому граф зависимостей внутри модуля остаётся звездой вокруг домена, а не паутиной.
  • Репозитории Spring Data лежат в domain — прагматичный компромисс вместо отдельных портов.
  • Правила проверяются ArchUnit-тестом модуля (<Модуль>ArchitectureTests).

Структура введена пилотом на модуле customer (см. модуль customer), остальные модули пока плоские.

1.3. Модули

Модуль Ответственность Схема БД
platform конфигурация, безопасность, ошибки, аудит, планировщик, кеш, миграции platform
iiko HTTP-клиент iiko, аутентификация/токены, синхронизация, зеркало данных iiko
catalog витринный каталог: категории, товары, варианты, модификаторы, публикация catalog
media загрузка, хранение, обработка изображений, ренditions media
franchise франчайзи и их юридические лица, статусы и реквизиты franchise
restaurant ресторан, его неизменяемая привязка к организации iiko и жизненный цикл restaurant
iam учётные записи сотрудников ГК и ФЧ, роли, приглашения, сессии iam
customer учётная запись покупателя сети, вход по коду, refresh-сессии, access JWT customer
notifications провайдеры доставки сообщений за общим интерфейсом (SMS через smsc.ru, Telegram и MAX через их Bot API) —
dadata клиент сервиса подсказок DaData (подсказки имени покупателя) —
admin HTTP-API Админки ГК, представления для админки —
franchiseoffice HTTP-API Личного кабинета ФЧ, проверка владения объектами —
storefront публичное API витрины, сборка и отдача снапшота меню —
order (этап 2) корзина, заказ, отправка в iiko, статусы ordering

Модель франшизы и зависимости модулей franchise, restaurant, iiko описаны в модели франчайзинга, доступ сотрудников — в модели административного доступа, распределение ответственности — в документе Франшиза. Реализованные модули описаны в разделе «Модули» технической документации.

Правила зависимостей:

storefront ──▶ catalog ──▶ iiko(api) ──▶ platform
storefront ──▶ customer(api)
storefront ──▶ dadata(api)
customer ──▶ notifications(api)
admin ──▶ catalog, media, iiko(api)
iiko ──▶ platform
catalog ──▶ media(api), platform
  • iiko не знает о catalog. Импорт не имеет права трогать витринные данные.
  • catalog читает зеркало iiko через iiko.api (read-only порты), но не пишет в него.
  • Обратная связь — только событиями: IikoMenuImported, IikoStopListChanged, IikoItemsAppeared.

1.4. Технологический стек

Слой Выбор Обоснование
Язык / рантайм Kotlin 2.x, JDK 25 (LTS) virtual threads без ограничений pinning, generational ZGC, compact object headers — полезно для разбора многомегабайтных меню
Фреймворк Spring Boot 4.x (Spring Framework 7) web mvc на виртуальных потоках; не webflux — код проще, а профиль нагрузки IO-bound
Модульность Spring Modulith границы модулей, event publication registry (outbox), модульные тесты, автодокументация
БД PostgreSQL 18 jsonb для сырья iiko, генерируемые колонки, partial index
Доступ к данным Spring Data JPA (админ CRUD) + jOOQ (витрина, импорт) CRUD удобен на JPA, тяжёлые выборки и bulk-upsert — на jOOQ
Миграции Flyway версионные SQL-миграции, без auto-ddl
Кеш Redis снапшот меню, стоп-листы, rate limit, ShedLock
Планировщик Spring Scheduling + ShedLock защита от двойного запуска на нескольких инстансах
Объектное хранилище S3-совместимое (MinIO on-prem / Yandex Object Storage) картинки и их производные
Обработка изображений imgscalr / thumbnailator + webp (libwebp через cwebp или imageio-webp) генерация ренditions при загрузке
HTTP-клиент Spring RestClient + Resilience4j таймауты, ретраи, circuit breaker для iiko
Документация API springdoc-openapi OpenAPI 3.1 генерируется из кода, отдаётся по группам в /v3/api-docs/{admin,franchise,storefront}, Swagger UI — /swagger-ui.html
Логи Logback + JSON encoder, MDC (traceId, syncRunId)
Метрики Micrometer → Prometheus
Тесты JUnit 5, Testcontainers (Postgres, Redis), WireMock (iiko), ApplicationModules.verify(), ArchUnit
Сборка Gradle (Kotlin DSL), Docker multi-stage, базовый образ на JDK 25

Точные версии Boot / Modulith / Kotlin фиксируются в первый день работы над каркасом: связка «JDK 25 + Boot 4 + Modulith» проверяется на пустом проекте до того, как на неё ляжет код. Если на момент старта Modulith под выбранную версию Boot ещё не выпущен — берём предыдущий минорный Boot; версия фреймворка не является определяющим решением, а вот отказ от Modulith означал бы ручной outbox и ручные правила границ.

Фронтенд админки:

Слой Выбор
База React 18 + TypeScript, Vite
UI Ant Design 5
Состояние сервера TanStack Query
Формы React Hook Form + Zod (или antd Form + Zod-резолвер)
Роутинг React Router 6
Таблицы antd Table + серверная пагинация/фильтрация
DnD dnd-kit (сортировка категорий и товаров)
Клиент API сгенерированный из OpenAPI (openapi-typescript + typed fetch)
Загрузка картинок antd Upload + кроп (react-easy-crop)

Админка — отдельное SPA, собирается независимо, раздаётся nginx, ходит в /api/admin/v1. Не встраивается в Spring-шаблоны (это была боль Orchid: верстка админки внутри бэкенда).

1.5. Нефункциональные требования

Производительность витрины - GET /api/v1/menu — p95 ≤ 80 мс при отдаче из снапшота, ≤ 400 мс при холодном кеше. - Полный снапшот меню одной организации ≈ 300–700 КБ JSON (после нормализации модификаторов), gzip/br ≈ 60–120 КБ. Сырой v3-ответ iiko на те же данные — 5,4 МБ; наружу такое отдавать нельзя. - Поддержка ETag / If-None-Match и revision в ответе — витрина не перекачивает меню без изменений.

Синхронизация - Меню: раз в 5 минут инкрементально по revision, полная — раз в сутки ночью. - Стоп-листы: раз в 30–60 секунд + обработка webhook, задержка появления стоп-листа на витрине ≤ 60 секунд. - Организации/терминалы/ценовые категории: раз в час. - Импорт атомарен для потребителя: витрина не должна увидеть полупримененное меню.

Доступность - Недоступность iiko не роняет витрину: каталог отдаётся из последнего успешного снапшота, в админке — явный индикатор «данные устарели, последняя успешная синхронизация N минут назад». - Ошибка импорта не удаляет данные. Удаление позиций — только по успешно завершённому полному импорту (см. 03-iiko-sync, «Мягкое удаление»).

Устойчивость к изменениям и ошибкам на стороне iiko

Практика эксплуатации: сотрудники случайно удаляют или пересоздают блюда в iiko. Для витрины это означает исчезновение позиции, к которой привязана карточка, а при пересоздании — появление того же по смыслу блюда с новым itemId. Система обязана переживать это без потери контента и без молчаливой пропажи товара с сайта.

Механизм — снимок привязки: в момент, когда контент-менеджер связывает вариант товара с позицией iiko, сохраняется слепок состояния этой позиции (id, sku, название, учётная группа, категория внешнего меню, цена, вес). Снимок обновляется при каждом успешном импорте, пока позиция жива, и замораживается, когда позиция пропала.

Что это даёт:

  1. Карточка не «обнуляется»: даже если блюда в iiko больше нет, в админке видно, к чему она была привязана, каким было название и цена, и с какого момента позиция пропала.
  2. Автоматический подбор замены: при пересоздании блюда в iiko совпадает sku (и/или название и учётная группа) — система предлагает перепривязать вариант на новый itemId одним действием.
  3. Разделение «пропало навсегда» и «мигнуло»: позиция, отсутствовавшая один импорт и вернувшаяся, не порождает инцидента; отсутствие дольше настраиваемого порога — порождает.
  4. Точная диагностика: на вопрос «почему товара нет на сайте» отвечает не догадка, а запись инцидента с датой, прежним состоянием и виновным импортом.

Правила поведения: - удаление позиции в iiko никогда не удаляет ни вариант, ни товар; - товар снимается с публикации автоматически только когда не осталось ни одного живого варианта, с записью причины и уведомлением в админку; - если у товара с вариантами пропал один из размеров — товар остаётся опубликованным, вариант отдаётся как недоступный.

Модель данных — 02-catalog-domain.md §2.4 (catalog.variant_binding, catalog.binding_incident), поведение импорта — 03-iiko-sync.md §3.4.

Безопасность - Админка: JWT (access 15 мин + refresh в httpOnly cookie), RBAC по правам, аудит всех изменяющих операций. - Публичное API — только чтение, без персональных данных, rate limit по IP. - Секреты юрлиц франчайзи (apiLogin iiko, ключи ЮKassa, доступ к ОФД) вводит франчайзи в Личном кабинете. Они хранятся в БД, зашифрованные мастер-ключом из Secret, не возвращаются через API, не пишутся в логи и не видны сотрудникам ГК. В переменных окружения — только мастер-ключ и глобальные сервисы ГК.

Наблюдаемость - Каждый запуск синхронизации — запись в iiko.sync_run со статистикой (создано/обновлено/ удалено/пропущено, длительность, ревизия). - Алерты: импорт меню не завершался успешно > 30 мин; стоп-листы не обновлялись > 5 мин; доля ошибок запросов к iiko > 5%.

1.6. Окружения и данные

Окружение iiko Назначение
local WireMock со снимком ответа POST /api/menu/v3/by_id разработка без сети
dev тестовый apiLogin iiko в тестовом подключении интеграционные проверки
prod боевые apiLogin юрлиц франчайзи в их подключениях

Снимок ответа POST /api/menu/v3/by_id (5,4 МБ) получается запросом к iiko и кладётся в тестовые ресурсы как фикстура импорта вместе с задачей, которая пишет тесты импорта. На нём же гоняются нагрузочные проверки парсинга и сборки снапшота.

1.7. Отношение к legacy: проектируем с нуля

Каталог не мигрируется. Схема legacy не является ориентиром.

Обоснование — в самой схеме. Товары там разложены по таблицам dish_pizza, dish_snacks, dish_drinks, dish_non_food, каждая со своим набором колонок и своими таблицами связей (dish_pizza_ingredients, dish_pizza_sauces, dish_pizza_toppings, dish_pizza_dough, dish_snack_sauces, dish_drink_toppings — и так далее, суммарно десятки таблиц под то, что является одним понятием «товар»). Тип товара стал структурой БД: добавление категории требовало миграции, модели, репозитория, экрана и ветки в сборке меню. Позиции из реальной выгрузки — мерч, разливное пиво, бакалея, посуда — в эту типизацию не помещаются вовсе.

Плюс два параллельных дерева категорий (dictionary_categories и dictionary_site_categories) и дублирующий импорт iiko в двух сервисах с разными моделями.

Переносить такие данные означало бы наследовать разбиение по типам вместе с ними. Поэтому:

Что Решение
Категории каталога создаются заново в новой админке
Карточки товаров, привязки к iiko, сортировка клиент пересобирает в новой админке; помогает экран «Новые позиции iiko», где непривязанные блюда лежат готовой очередью с автоподсказкой вариантов
Изображения товаров загружаются заново — нарезка по новым пресетам (см. 07-media.md), старые файлы нарезаны неверно и переносить их смысла нет
Заказы не мигрируются, по решению клиента удаляются
Пользователи единственное, что мигрируется — вместе с модулем клиентов на следующем этапе, не в этапе каталога
Slug'и не наследуются автоматически; список URL, которые важно сохранить для SEO, собирается отдельно и задаётся руками при заведении категорий/товаров

Единственное, для чего legacy используется в проектировании, — разбор ошибок: каждое решение в этих документах сверено с тем, как было сделано раньше и почему это не работало. Данные из legacy в новую систему не переносятся.

Что нужно проверить до старта: какие URL каталога участвуют в поисковой выдаче и платном трафике. Это единственное реальное последствие пересборки с нуля — потеря соответствия старым адресам. Решается списком редиректов на nginx, но список нужно составить заранее.

1.8. Изображения — отдельное ТЗ

Обработка изображений вынесена в самостоятельный документ 07-media.md, потому что в эксплуатации это оказалось источником постоянных дефектов: клиент загружал картинку, а на витрине она оказывалась обрезанной не по центру, размытой или в неверных пропорциях.

Первым пунктом этого ТЗ идёт работа, которую нельзя сделать в одиночку и нельзя пропустить: инвентаризация всех мест, где на витрине и в админке показываются изображения, с точными размерами и пропорциями. Пока эта таблица не заполнена, набор пресетов нарезки задать невозможно — а именно расхождение «загрузили одно, показали в другом соотношении» и порождало кривые картинки.

Пресеты не подбираются разработчиком «на глаз» и не выводятся из старых файлов: они собираются с фронтенда витрины (макеты, реальные CSS-размеры блоков, точки перелома адаптива) и фиксируются в конфигурации. Правила валидации при загрузке (минимальное разрешение, пропорции, запрет апскейла), точка фокуса при кропе и перегенерация всех ренditions при изменении пресета — там же.