Перейти к статье
Aqenify
← К проекту в портфолио

Инженерный кейс / Fullstack-разработка

Крошка Япошка: как я разработал систему доставки от витрины до деплоя

Разбор коммерческого проекта на Next.js и FastAPI: серверный расчёт заказов, PostgreSQL, Frontpad, Telegram, безопасность, кэширование и blue/green-деплой.

Деньги считает серверЦены, скидки, подарки и бонусы проверяются по PostgreSQL.
Сбои имеют сценарийПовторы заказов, ошибки интеграций и неопределённые ответы обработаны отдельно.
Релиз проходит проверкиHealthcheck, прогрев данных и переключение blue/green-пары.

Задача: связать покупателя, ресторан и учётную систему

«Крошка Япошка» — интернет-магазин доставки японской кухни. Я самостоятельно разработал его клиентскую и серверную части, административную панель, Telegram-бота и конфигурацию развёртывания. Работающий сайт ресторана и скриншоты интерфейсов дополняют этот разбор.

Покупателю нужны понятное меню, выбор размеров и добавок, корзина, оформление и история заказов. Ресторану — управление товарами, ценами, районами доставки, акциями, клиентами и статусами. Между этими сценариями находится критическая часть системы: состав заказа и сумма должны оставаться согласованными при изменениях каталога, повторах запроса и сбоях внешних сервисов.

Поэтому я выстроил проект вокруг нескольких инвариантов: итоговую стоимость определяет сервер; повтор оформления не создаёт новый заказ; ошибка интеграции не удаляет принятую заявку; доступ к заказу проверяется по владельцу. Эти правила определили архитектуру, модель данных и проверки.

Архитектура: явные границы ответственности

Система состоит из двух прикладных частей: Next.js обслуживает публичные страницы, интерфейсы кабинета и админки, а FastAPI — предметную логику, PostgreSQL и внешние интеграции. Это модульное приложение с отдельным веб-слоем и API. Его можно сопровождать без инфраструктуры большого набора микросервисов.

  1. Браузер. React отображает каталог, хранит состояние корзины и помогает заполнить форму.
  2. Next.js и BFF. Серверные маршруты того же домена проверяют запрос, добавляют доверенные служебные данные и обращаются к внутреннему API.
  3. FastAPI. Backend проверяет права, восстанавливает актуальный состав заказа, применяет бизнес-правила и сохраняет результат.
  4. PostgreSQL и постоянные медиа. Здесь находятся бизнес-данные, сессии, журналы операций и изображения, которые должны переживать смену контейнеров.
  5. Интеграции. Telegram и Frontpad получают данные после сохранения заказа; состояние доставки учитывается отдельно.

BFF — это серверный посредник между браузером и внутренним API. Благодаря ему браузеру не нужен ключ доступа к backend, а клиентская часть работает с одним origin. Исключение в маршрутизации составляют два адресных WebSocket-маршрута: reverse proxy направляет их к FastAPI, где также проверяются сессия и Origin.

Внутри frontend разделены компоненты отображения, hooks для координации поведения, схемы и чистые функции, серверные адаптеры и общие хранилища состояния. На backend отдельно расположены HTTP-маршруты, Pydantic-контракты, модули доступа к данным и адаптеры внешних сервисов. Например, каталог разделён на чтение, товары, категории, первичное заполнение и управление медиа. Такая декомпозиция позволяет менять один сценарий без необходимости переписывать весь слой.

Стек: какую задачу решает каждый инструмент

СлойТехнологииНазначение
Веб-приложениеNext.js 16.3, React 19.2, TypeScript 5App Router, серверная сборка страниц, BFF, интерфейсы витрины и управления
Интерфейс и формыBase UI, shadcn, Motion, Lucide, React Hook Form, Zod 4UI-примитивы, оформление, анимации, управление формами и проверка ввода
Клиентское состояниеZustand 5Общая корзина и тема с узкими подписками компонентов
Серверное приложениеPython 3.13, FastAPI, Pydantic 2, UvicornАсинхронное API, runtime-контракты и выполнение бизнес-операций
ДанныеPostgreSQL 17, asyncpgТранзакции, ограничения целостности, пул соединений и версионированные миграции
КэшValkey 8.1, асинхронный redis-pyОбщий восстанавливаемый кэш представлений для backend обеих версий
Интеграцииaiogram 3, aiohttp, Frontpad, SMS.ru, Mail.ru, MAXБот, ограниченные HTTP-вызовы, ресторанный учёт и подключаемые способы входа
ПоставкаBun, Node.js 22, Docker Compose, Caddy, GitHub Actions, GHCRЗависимости, production-сборка, HTTPS, контейнеры и управляемый выпуск версий

TypeScript связывает интерфейсы доменными типами, а Zod и Pydantic проверяют фактические данные на границах приложения. Это разные задачи: наличие TypeScript-типа само по себе не защищает сервер от произвольного HTTP-запроса.

Для persistence используется asyncpg и SQL с параметрами, поверх которых построен общий адаптер соединений и транзакций. Поэтому ограничения, блокировки и порядок финансовых операций выражены явно. Пул соединений ограничен, а команды имеют таймаут. Bun используется как менеджер пакетов; production-сборка Next.js выполняется под Node.js. Зависимости Python закреплены версиями, frontend имеет lockfile.

Оформление заказа: корректность при повторах и сбоях

Форма сначала проверяется в браузере, чтобы покупатель быстро увидел ошибку. BFF дополнительно ограничивает размер тела запроса, число позиций, количества и допустимые варианты. Окончательное решение принимает Python backend: он читает активные товары, размеры, добавки и настройки доставки из PostgreSQL.

Переданная браузером цена не становится источником истины. Backend восстанавливает названия и стоимость позиций, проверяет доступность выбранных модификаторов, добавляет положенные подарки, рассчитывает доставку, промокод и списание бонусов. Сохранённый заказ содержит канонический снимок покупки: последующая правка названия или цены товара не должна переписывать историю заказа.

Повторный запрос возвращает исходный заказ

Сеть может оборваться после записи в БД, но до получения ответа браузером. Кнопку оформления также можно нажать повторно. Для этого используется ключ идемпотентности — стабильный идентификатор одной попытки оформить конкретный состав заказа.

Backend сохраняет ключ вместе с отпечатком содержимого. Повтор с тем же ключом и теми же данными получает ранее созданный заказ. Использование ключа для другого содержимого отклоняется. Уникальный индекс в БД закрывает и конкурентный сценарий, когда два запроса приходят одновременно. При изменении тела заказа клиент формирует новый ключ.

Сохранение и внешняя доставка разделены

Сначала заказ фиксируется в PostgreSQL. После этого отдельно запускаются Telegram-уведомление и отправка в Frontpad. Сбой одной интеграции не откатывает принятую заявку. Интерфейс показывает успех и очищает корзину только после получения идентификатора заказа; при ошибке данные сохраняются для повторной попытки.

Надёжность оформления определяется тем, что система делает после потерянного ответа, повторного клика и недоступности внешнего сервиса. Для каждого из этих состояний здесь есть отдельное поведение.

Данные: каталог, деньги, бонусы и атрибуция

В PostgreSQL хранятся каталог, категории, размеры, дополнительные товары, заказы, клиентские идентичности, сессии, настройки, промокоды, отзывы, рекламные визиты и журналы программ лояльности. Большой исходный JSON-каталог нужен для первичного заполнения и встроенного fallback при отсутствии backend-конфигурации. После инициализации рабочий каталог управляется через БД.

Цены каталога и денежные остатки хранятся в целых копейках, вычисления backend используют Decimal. Это позволяет явно контролировать округление и сверять суммы при передаче во внешнюю систему.

Лояльность как набор учётных операций

Рублёвые бонусы отделены от реферальных баллов. Списание резервируется при создании заказа; начисление, сторно и возврат связаны с изменением статуса и выполняются транзакционно с ним. Повторное сохранение статуса не должно повторно начислять вознаграждение. Выход из оплаченного состояния требует пересчёта связанных операций.

Различаются завершение выполнения заказа и подтверждение оплаты. В выручку и начисления входят заказы в статусе «Оплачен». На сайте выбирается способ оплаты при получении — наличные или карта; платёжный шлюз и онлайн-эквайринг в этом проекте не реализованы.

Промокоды и подарки проверяются повторно на сервере

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

Рекламная атрибуция учитывает первый и последний переход, UTM-источники и ссылки блогеров. Служебные данные атрибуции подписываются сервером. Телефонные и IP-признаки в отдельных антифрод-механизмах представлены хэшами или HMAC-значениями; сами контактные данные, необходимые ресторану для заказа, всё равно требуют защищённого хранения и ограниченного доступа.

Frontpad: согласование двух систем и неопределённые ответы

Интеграция с ресторанной системой требует больше, чем переслать JSON. Нужно сохранить смысл заказа: выбрать правильный артикул размера пиццы, передать количество модификаторов, бесплатные допы, подарки, доставку и скидку.

Сопоставление товара с Frontpad учитывает выбранный размер. Добавки передаются как модификаторы; их количество умножается на количество родительского товара. Для платных и подарочных позиций необходимы сопоставления, а платная доставка имеет отдельный артикул. Frontpad получает доверенный серверный снимок заказа.

Промокод и локальные бонусы передаются одной суммовой скидкой. Цены позиций сохраняются до её применения: сумма товаров и доставки за вычетом этой скидки должна совпадать с серверной суммой к оплате. Баланс бонусов сайта не смешивается с балансом Frontpad.

Почему нельзя безусловно повторять отправку

Если внешний сервис принял заказ, но ответ потерялся, повтор создания может породить дубль. API создания Frontpad не предоставляет используемый сайтом ключ идемпотентности, поэтому гарантию локального оформления нельзя автоматически распространить на внешнюю систему.

Я реализовал сохраняемые состояния отправки и атомарный захват попытки. У неё есть уникальный токен и срок владения — пять минут. Завершение проверяет принадлежность попытки; восстановление обрабатывает просроченные отправки, сохраняя живые операции другого экземпляра. Поздний успешный ответ своей попытки может сохранить удалённый ID даже после восстановления состояния.

При неопределённом результате повтор блокируется, а администратор видит необходимость сверки. Это осознанный выбор в пользу сохранности учётных данных. Автоматической сверки с поиском такого заказа во Frontpad в текущей реализации нет.

Обратные события и границы API

Защищённый webhook принимает статусы Frontpad и сопоставляет их внутренним состояниям заказа. Проверяется индивидуальный токен, производный от секрета интеграции. Запросы могут приходить в нескольких форматах; разбор допускает особенности внешнего отправителя, а результат проходит внутренние правила обработки.

Поддерживается пересылка проверенных событий дополнительным обработчикам с ограниченными повторами временных ошибок. При этом публичный API Frontpad, используемый проектом, не даёт операции изменения статуса или удаления заказа. Для записи таких изменений обратно необходим внешний адаптер с поддерживаемым доступом. В интерфейсе и документации это ограничение отделено от реально работающей входящей синхронизации.

Безопасность: доверие проверяется на каждой границе

Защита состоит из нескольких механизмов, которые решают разные задачи: сетевое ограничение backend, серверные права, безопасные сессии, CSRF-проверки, валидация и квоты запросов.

Раздельные сессии и права

Клиентские и административные сессии независимы. Это случайные непрозрачные токены с абсолютным сроком действия и серверным отзывом; в БД хранятся их хэши. В production cookies используют HttpOnly, Secure и SameSite=Strict. Авторизация не определяется телефоном или Telegram ID, присланным браузером.

Администраторы входят по логину и паролю. Пароли дополнительных учётных записей хэшируются scrypt с индивидуальной солью; сравнение использует механизм постоянного времени. Владелец управляет учётными записями и выдаёт права на разделы. Действия проверяются backend, а актуальные права читаются из БД при проверке сессии. Telegram-привязка администратора служит для уведомлений.

Изменяющие административные запросы требуют CSRF-проверки и проверки same-origin. Внутренний API дополнительно защищён серверным ключом, который не передаётся в публичный JavaScript. Маршруты BFF ограничивают доступные операции, чтобы не превращаться в произвольный прокси к backend.

Несколько способов входа в один кабинет

Клиентский вход поддерживает Telegram и подключаемые Mail.ru OAuth, MAX и SMS.ru. Каждый провайдер сначала подтверждает внешнюю идентичность, затем выдаётся обычная внутренняя сессия. Пара «провайдер — подтверждённый пользователь» связывается с customer ID.

Для Telegram и MAX применяются ограниченные по времени одноразовые challenges. OAuth-поток Mail.ru связывает state с HttpOnly cookie. MAX webhook проверяет серверный секрет. SMS-код хранится как HMAC, имеет срок действия и ограничение попыток; подтверждение кода и выдача сессии выполняются атомарно. Отдельные квоты по IP и телефону хранятся в PostgreSQL. Реальная доступность каждого метода зависит от настройки и подключения провайдера владельцем.

Ограничение запросов и приватность событий

Общий Next.js Proxy ограничивает HTTP BFF- и media-запросы; оформление и вход имеют более строгие квоты. WebSocket-подключения также ограничены и требуют допустимого Origin. Reverse proxy перезаписывает доверенный IP-заголовок: прямой публичный доступ к внутренним портам нарушил бы эту модель.

Клиентские события группируются по внутреннему customer ID. Поэтому смена статуса публикуется владельцу заказа, а не всем подключённым посетителям. Маркетинговое согласие по умолчанию выключено и включается явным действием клиента; служебное уведомление о выполнении заказа обрабатывается отдельно от рекламы.

Медиа: безопасный импорт и сохранность файлов

Изображения каталога и оформления сохраняются в постоянном хранилище, которое не исчезает при замене приложения. Backend ограничивает файл восемью мегабайтами, проверяет поддерживаемый формат и признаки завершённости JPEG, PNG, GIF или WebP. Контроль фактических байтов дополняет проверку заявленного MIME-типа.

Импорт по внешней ссылке создаёт риск SSRF — попытки заставить сервер обратиться во внутреннюю сеть. Поэтому допускаются только HTTP(S), стандартные порты и публичные IP-адреса. DNS-адреса проверяются также резолвером, передающим их сетевому соединению, а переходы на другие адреса проходят собственный контроль. Скачивание ограничено размером и временем.

Одинаковое содержимое не дублируется. Перед удалением старого локального изображения backend проверяет ссылки из каталога, настроек и сохранённых заказов. Это защищает исторические снимки покупки от удаления файла при обычном редактировании товара. При этом проверка формата не заявляется как полноценное антивирусное сканирование.

Производительность и SEO: контролировать объём работы

Большой каталог нельзя без необходимости включать целиком в начальный ответ. При первом открытии API отдаёт товары первой активной категории и данные остальных категорий; их содержимое загружается по запросу. Отдельная карточка и индекс URL имеют собственные узкие запросы.

В админке каталог использует серверную пагинацию. Для заказов применяются cursor-пагинация и оконный рендеринг: наличие длинной истории не должно означать тысячи одновременно смонтированных карточек. Поиск отменяет устаревшие запросы, а загрузка кода и данных разделов откладывается до их открытия.

Zustand-компоненты подписываются на необходимые части состояния. Тяжёлые редакторы загружаются по требованию, публичные компоненты витрины не монтируются внутри админки, а ввод одного поля настроек изолирован от перерисовки всего редактора. Эти решения уменьшают объём вычислений и DOM, особенно на слабых устройствах.

Поисковые страницы являются частью каталога

Категории и товары имеют отдельные короткие URL, настраиваемые title, description и keywords. Slug товаров и категорий используют единое уникальное пространство, а сервер разрешает конфликты. Публичные страницы получают абсолютный canonical; sitemap формируется по облегчённому индексу каталога.

Основное содержимое страниц формируется на сервере. SEO-тексты товара и категории используют управляемые шаблоны с названием, ценой, составом, городом и условиями доставки. Это позволяет владельцу поддерживать контент вместе с каталогом. Само наличие SEO-механизмов не является доказательством роста позиций: поисковые результаты оцениваются отдельной аналитикой.

Два уровня кэширования с разной ответственностью

Первый уровень — ISR Next.js для публичных страниц. Они сохраняются без периодического интервала устаревания, а изменения каталога и связанного контента вызывают инвалидирование через административный BFF. Следующий запрос строит актуальную версию. Главная и sitemap обновляются вместе с каталогом.

Второй уровень — общий Valkey для повторяющихся чтений backend. Каталог и настройки имеют TTL до 300 секунд, операционные данные и аналитика — 15 секунд. Ключи различают фильтры, страницы, аргументы и версию формата. Проверка сессии и прав выполняется до чтения кэша; секреты и сессии туда не помещаются.

Инвалидирование использует поколения групп данных. Завершившаяся запись меняет случайный токен поколения. Старое чтение, начавшееся до изменения, может завершиться, но его результат записывается в старое поколение. Следующие запросы используют новое. Изменения из HTTP, бота, webhook и фоновых задач проходят через те же методы persistence и сбрасывают связанные представления.

Создание заказа, проверка цен, лимиты и финансовые операции обращаются к PostgreSQL напрямую. Кэш ускоряет чтение представлений и не становится доверенным источником для списания денег или определения прав.

При отказе работающего Valkey приложение после короткого таймаута читает БД. Неудачные инвалидирования повторяются перед возобновлением чтения кэша этим процессом. Другие процессы при сбое могут видеть прежние представления до успешного сброса или истечения TTL. Это ограничение согласованности описано явно; первоначальный запуск Compose, в отличие от уже работающего backend, ожидает healthcheck Valkey.

Админка и события: управление продуктом без правок исходников

Панель объединяет каталог, заказы, клиентов, промокоды, программы лояльности, отзывы, рекламную статистику, рассылки, Frontpad и настройки сайта. Владелец меняет тему, цвет, логотипы, баннеры, истории, тексты, контакты и стоимость доставки по районам.

Глобальный поиск учитывает права, русские синонимы, небольшие опечатки и текст в неверной раскладке. Потенциально разрушительная команда может открыть и подсветить действие, но не подтверждает его за администратора. Это пример решения на стыке удобства и защиты от случайной операции.

Заказы обновляются через WebSocket. Админский канал сообщает об операционных изменениях, клиентский — о заказах владельца сессии. Текущее состояние при этом хранится в БД; поток событий помогает интерфейсу быстро реагировать, но не заменяет постоянное хранилище.

Telegram выполняет несколько независимых функций: подтверждение входа, привязка гостевого заказа одноразовой ссылкой, уведомления сотрудникам и клиентам, рассылки по согласованной аудитории. Планировщик атомарно захватывает рассылку перед отправкой и повторно применяет условие маркетингового согласия.

Деплой: проверка кандидата перед переключением трафика

Production-конфигурация включает внешний HTTPS reverse proxy, стабильный внутренний шлюз Caddy, две пары Next.js/FastAPI, PostgreSQL, общий Valkey и постоянные volumes. Обычно трафик принимает одна цветовая пара; во время выпуска старая и новая кратковременно работают одновременно.

Путь изменения от репозитория до сервера

  1. GitHub Actions запускает проверки frontend, production-сборку, компиляцию и тесты Python. Для интеграционных сценариев CI поднимает отдельные PostgreSQL и Valkey.
  2. Отдельные задания собирают frontend- и backend-образы и публикуют их в GHCR с тегом commit SHA.
  3. Deploy job зависит от успешных проверок и сборки образов. Самостоятельный runner вызывает ограниченную серверную команду развёртывания.
  4. Неактивная пара запускается как кандидат. Backend проходит инициализацию данных и миграции, проверяется доступность БД, прогреваются основные API-запросы.
  5. Защищённый внутренний маршрут сбрасывает fallback-кэш, сформированный во время CI-сборки без рабочего backend. Затем прогреваются публичные страницы уже с настоящими данными.
  6. После проверок шлюз переключается на кандидата. Скрипт проверяет стабильные адреса и обрабатывает ошибку переключения с возвратом предыдущей пары.

Такой порядок уменьшает риск показать пользователю пустой каталог из сборочного окружения или переключить трафик на ещё не готовое приложение. Blue/green здесь — управляемая смена релиза на одном сервере. Она не обеспечивает независимость от отказа этого сервера и не даёт основания обещать абсолютное отсутствие перерывов, в том числе для WebSocket-соединений.

Один владелец фоновой работы

Две backend-версии не должны одновременно выполнять Telegram polling и планировщик рассылок. Кандидат остаётся в standby до активации, а PostgreSQL advisory lock выдаёт владение фоновыми циклами одному процессу. Это отдельная задача от готовности HTTP API: контейнер может пройти healthcheck, ещё не получив право обслуживать singleton-задачи.

Воспроизводимость и ограничение привилегий

Next.js упакован многоэтапной сборкой в standalone-образ; frontend и backend работают от непривилегированных пользователей. Прикладные контейнеры имеют read-only корневую файловую систему, сброшенные capabilities и запрет повышения привилегий. Запись разрешена в выделенные временные области и persistent storage.

PostgreSQL и Valkey не публикуют порты наружу; адреса шлюза на хосте привязаны к loopback. Env-файлы и серверные секреты отделены от клиентской сборки. В CI право публикации пакетов выдаётся только заданию образов, а deploy имеет право чтения. Временные credentials загрузки образов удаляются после выполнения.

Откат приложения выбирает предыдущую пару образов, но совместимость схемы БД оценивается отдельно. Для несовместимых изменений предусмотрены резервная копия и восстановление данных; автоматический откат образа сам по себе не отменяет миграцию. Исторический перенос из SQLite в PostgreSQL реализован транзакционно и оставляет исходную базу для контролируемого перехода.

Проверки и эксплуатация

В репозитории есть проверки финансовых правил, подарков и промокодов, попыток Frontpad, сессий, паролей, SMS, клиентских событий, импорта изображений, кэширования и порядка деплоя. Интеграционные сценарии используют настоящую тестовую PostgreSQL; для Valkey проверяются TTL и инвалидирование между клиентами.

Ценные регрессии здесь связаны с инвариантами: повтор не дублирует заказ, нулевая цена остаётся нулевой, скидка применяется один раз, истёкшая попытка не затирает чужой Frontpad ID, смена прав не зависит от TTL кэша. Тесты порядка деплоя проверяют прогрев до переключения и сохранение активной версии при ошибке кандидата.

Healthcheck API проверяет PostgreSQL. Недоступность Telegram не блокирует запуск HTTP: polling восстанавливается в фоне с увеличивающейся задержкой. На этот период Telegram-вход может быть недоступен, но каталог и приём заказов продолжают обслуживаться. Состояния внешней доставки сохраняются отдельно, а административные действия имеют аудит.

Осознанные компромиссы и следующий этап развития

Я рассматриваю ограничения как входные данные для следующего этапа. Текущая система рассчитана на одну активную пару приложений, с кратким перекрытием при blue/green-деплое. Для перехода к нескольким постоянно работающим backend потребуется дополнительная инфраструктура.

  • Межпроцессные события. Сейчас WebSocket-hub находится в памяти backend. Для нескольких экземпляров нужен общий канал доставки, например брокер или PostgreSQL LISTEN/NOTIFY, и корректное восстановление состояния после переподключения.
  • Долговечная доставка интеграций. Состояния отправок хранятся в БД, но текущие фоновые задачи не заявляют гарантию exactly-once. При росте требований логичным развитием станет transactional outbox с отдельным worker и правилами сверки внешнего результата.
  • Медиа и несколько серверов. Общий volume подходит текущей схеме. При распределении по узлам изображения нужно вынести в доступное всем экземплярам объектное хранилище, при необходимости дополнив CDN.

Эти изменения имеют смысл при соответствующей нагрузке и требованиях бизнеса. Модульные границы, серверные контракты и отдельные адаптеры позволяют развивать систему постепенно.

Нужен разработчик, который свяжет интерфейс, бизнес-логику и эксплуатацию?

Обсудить работу в команде →Посмотреть скриншоты проекта ↗

Обсудим задачу

Пришлите описание проекта, макеты или техническое задание. Обсудим объём работы и формат сотрудничества