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

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 + структура пакетов модуль = пакет верхнего уровня под com.panam; всё, что не в корне пакета модуля, — приватная реализация, недоступная другим модулям
@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 — прямая заготовка под это: выделение модуля сводится к замене внутрипроцессных событий на брокер.

1.3. Модули

Модуль Ответственность Схема БД
platform конфигурация, безопасность, ошибки, аудит, планировщик, кеш, миграции platform
iiko HTTP-клиент iiko, аутентификация/токены, синхронизация, зеркало данных iiko
catalog витринный каталог: категории, товары, варианты, модификаторы, публикация catalog
media загрузка, хранение, обработка изображений, ренditions media
admin админский HTTP-API, RBAC, представления для админки
storefront публичное API витрины, сборка и отдача снапшота меню
order (этап 2) корзина, заказ, отправка в iiko, статусы ordering

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

storefront ──▶ catalog ──▶ iiko(api) ──▶ platform
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 — заметно для парсинга 19 МБ JSON
Фреймворк 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 генерируется из кода, отдаётся в /api/docs
Логи 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 КБ. Сырой ответ iiko на те же данные — 19 МБ; наружу такое отдавать нельзя. - Поддержка 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. - Секреты iiko (apiLogin) — в переменных окружения/секрет-хранилище, в БД не хранятся открыто.

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

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

Окружение iiko Назначение
local WireMock со слепком response.json разработка без сети
dev тестовый apiLogin iiko интеграционные проверки
prod боевой apiLogin

Слепок response.json (19 МБ) кладётся в тестовые ресурсы как фикстура импорта — на нём же гоняются нагрузочные проверки парсинга и сборки снапшота.

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 при изменении пресета — там же.