Каталог услуг внутри Telegram выглядит как маленький сайт: карточки, переключатель филиалов, кнопка «Записаться». Из-за этого легко решить, что и работает он как маленький сайт, где браузер держит корзину, считает сумму и отправляет готовый заказ. Для каталога, в котором есть цена и право на запись, такая схема ненадёжна. Всё, что пришло с устройства, — это пожелание пользователя, а не факт. Мини-приложение может показать услугу, собрать выбор и красиво проанимировать переход между экранами. Но какая сейчас цена, открыт ли филиал, свободен ли слот и можно ли именно этому человеку создать заявку, решает сервер.

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

Три части, у каждой своя работа

Удобно разделить систему на три слоя и сразу договориться, за что отвечает каждый.

  • Витрина — страница Mini App. Она рисует интерфейс, хранит временное состояние экрана (выбранный филиал, открытая карточка, незавершённая форма) и отправляет серверу намерения: «хочу эту услугу в этом филиале в это время».
  • API приложения — ваш backend. Он проверяет, кто обращается, читает каталог, применяет правила и пишет заявку. Это единственное место, где принимаются решения.
  • Хранилище каталога — база данных. Она держит услуги, филиалы, цены, слоты и заявки, а также ограничивает собственными правами то, что приложению вообще позволено делать.

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

Кто за что отвечает в каталоге услуг
ВопросВитринаBackend
Как выглядит карточкаРешаетОтдаёт данные
Кто этот пользовательПередаёт исходную строку входаПроверяет и устанавливает
Сколько стоит услугаПоказывает то, что получилаОпределяет и фиксирует в заявке
Доступен ли филиал или слотПодсказываетПроверяет в момент создания заявки
Можно ли пользователю оформить заявкуСкрывает или показывает кнопкуРазрешает или отказывает
Что сказать при отказеОформляет сообщениеВозвращает причину и актуальные данные

Заметьте: у витрины нет колонки «решает» ни для цены, ни для прав. Скрытая кнопка — это удобство, а не защита. Любой, кто умеет отправить HTTP-запрос, может обойти экран и обратиться к API напрямую, поэтому сервер должен вести себя так, будто интерфейса не существует вовсе.

Как сервер узнаёт, с кем разговаривает

Когда Telegram открывает Mini App, он передаёт странице строку с данными запуска — initData. Внутри неё описан пользователь, время авторизации и подпись. Первое практическое правило: эту строку нужно брать в исходном виде и отправлять на ваш сервер, например в заголовке запроса, не разбирая и не пересобирая на клиенте. Проверка подписи выполняется на backend; подробный разбор самого алгоритма вынесен в отдельный материал, здесь важнее другое — какое место проверка занимает в архитектуре.

Второе правило касается объекта, который в JavaScript-клиенте выглядит удобно: initDataUnsafe. Документация Telegram прямо говорит, что данные нужно проверять на сервере, а initDataUnsafe нельзя считать доверенным (описание проверки данных Mini App). Название честное: его можно использовать, чтобы поздороваться по имени или подобрать язык интерфейса, но нельзя отправлять на сервер как доказательство «я пользователь с таким-то номером». Если backend принимает поле user_id из тела запроса и верит ему, то любой клиент подставит чужой номер, и вся остальная защита теряет смысл.

Третье правило — давность. В тех же данных есть auth_date, и по нему сервер может решать, насколько свежим считает вход (там же, в описании проверки). Какое окно давности выбрать, зависит от вашего сценария. Для каталога, где заявка не стоит денег в момент создания, окно можно держать щедрым. Если же оформление связано с чем-то более чувствительным, окно сужают. Это ваше решение, а не требование платформы: документация объясняет, что поле позволяет проверить давность, но число минут выбираете вы.

Есть две типовые схемы использования проверенного входа.

  1. Проверка на каждый запрос. Витрина добавляет initData к каждому обращению, сервер каждый раз проверяет подпись и давность. Просто, не требует хранения сессий, но при долгой работе приложения давность рано или поздно истечёт, и придётся просить пользователя открыть приложение заново.
  2. Собственная сессия. Сервер проверяет initData один раз при старте, создаёт свою короткоживущую сессию и дальше опирается на неё. Удобнее для длинных сценариев, но добавляется хранилище сессий и их срок жизни, которые надо продумать и охранять.

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

И ещё одна граница, о которой часто забывают. Подпись доказывает происхождение данных: эти данные сформировал Telegram для этого пользователя и этого бота. Она ничего не говорит о том, что пользователь вправе делать. Что заявка с номером 481 принадлежит именно ему, что услуга «Окрашивание» доступна в выбранном филиале, что этот человек не заблокирован за массовые отмены — всё это сервер выясняет по собственным таблицам. Проверка initData отвечает на вопрос «кто», а не «что ему можно».

Каталог читается из базы, а не из клиента

Теперь пользователь вошёл, и витрине нужны услуги. Самый частый соблазн — положить каталог в статический JSON внутри приложения: быстро, не нужен backend для чтения. Для брошюры такое допустимо, для каталога с ценами — нет: цена в статическом файле устаревает в момент правки, а в клиенте она остаётся доступной для редактирования. Поэтому источник истины один — база на сервере, а клиент получает слепок из API.

Минимальная схема для одного бизнеса с несколькими филиалами может выглядеть так.

create table branches (
  id serial primary key,
  title text not null,
  is_active boolean not null default true
);

create table services (
  id serial primary key,
  title text not null,
  description text,
  duration_min int not null,
  is_active boolean not null default true
);

create table service_prices (
  service_id int not null references services(id),
  branch_id int not null references branches(id),
  price_minor int not null,
  catalog_version int not null,
  primary key (service_id, branch_id)
);

create table slots (
  id bigserial primary key,
  branch_id int not null references branches(id),
  starts_at timestamptz not null,
  capacity int not null,
  booked int not null default 0
);

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

Поле catalog_version нужно для того, о чём пойдёт речь дальше. Каждая правка цены увеличивает версию, а ответ API на чтение каталога возвращает вместе с услугами номер версии, по которой они собраны. Так клиент и сервер получают общий язык: «я показал тебе версию 17» — «а сейчас у меня 19».

Ответ на чтение для карточки может быть таким.

GET /api/catalog?branch=3

{
  'catalog_version': 17,
  'branch': {'id': 3, 'title': 'Центр'},
  'services': [
    {'id': 12, 'title': 'Стрижка и укладка', 'duration_min': 60, 'price_minor': 250000}
  ]
}

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

Права приложения в PostgreSQL: пусть база тоже говорит «нет»

Чаще всего backend подключается к базе под одной учётной записью, и нередко это та самая учётная запись, которая создавала таблицы. Тогда ошибка в коде или удачная SQL-инъекция получают власть над всей схемой, включая цены. Здесь пригодится то, что в PostgreSQL права на чтение, вставку, изменение и удаление — разные права (документация по привилегиям PostgreSQL). Их можно выдавать по отдельности и ровно в том объёме, который нужен приложению.

Для каталога услуг разумно завести хотя бы две роли.

  • Роль API приложения. Читает услуги, филиалы, цены и слоты. Вставляет и читает заявки. Меняет счётчик занятости слотов. Не может менять цены, услуги и филиалы и не может удалять заявки.
  • Роль управления каталогом. Используется панелью администратора или миграциями: правит услуги, цены, повышает версию каталога.
create role catalog_api login;

grant select on branches, services, service_prices to catalog_api;
grant select, update on slots to catalog_api;
grant select, insert on requests to catalog_api;
grant usage on all sequences in schema public to catalog_api;

Обратите внимание, что в этом наборе нет права update на таблицу service_prices. Если в коде API когда-нибудь появится ошибка, из-за которой цена может быть перезаписана, база просто откажет. Это не заменяет проверок в коде, зато делает последствия ошибки скромнее.

Отдельный нюанс — владелец объектов. В документации по привилегиям отмечено, что у владельца есть полномочия, которые не сводятся к выданным правам чтения и записи. Поэтому приложение не стоит подключать под ролью, владеющей таблицами: любые ограничения, настроенные для прикладной роли, в этом случае ничего не значили бы. Таблицы создаёт и меняет отдельная роль для миграций, а приложение остаётся обычным пользователем с выданными правами. Нюансы, как именно ограничиваются права и что будет при смене владельца, сверяйте с документацией той версии PostgreSQL, которую вы ставите.

Путь одной услуги: от карточки до заявки

Теперь пройдём весь путь на примере. Условная услуга — «Стрижка и укладка» в филиале «Центр», слот на четверг, 15:00. Пользователь открыл карточку и нажал «Записаться».

Что отправляет витрина

Запрос содержит намерение, а не итог. Витрина передаёт идентификатор услуги, идентификатор филиала, идентификатор слота, версию каталога, по которой пользователь видел цену, и ключ попытки. А вот чего в запросе нет: суммы, названия услуги, длительности и номера пользователя. Всё это сервер знает сам. Исходная строка initData идёт в заголовке запроса.

POST /api/requests
X-Telegram-Init-Data: <исходная строка initData>

{
  'service_id': 12,
  'branch_id': 3,
  'slot_id': 9041,
  'seen_catalog_version': 17,
  'client_key': 'b3c2e1f0-...'
}

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

Что делает сервер, шаг за шагом

  1. Проверяет личность. Берёт initData из заголовка, проверяет подпись и давность auth_date. Если что-то не так — отвечает отказом и не читает тело запроса дальше.
  2. Находит или создаёт пользователя по номеру из проверенных данных, а не из тела. Здесь же проверяется, не заблокирован ли он и не превышен ли лимит открытых заявок (допустим, не больше трёх — число условное, его определяет бизнес).
  3. Читает услугу и филиал из своей базы и убеждается, что обе записи активны и что услуга действительно предоставляется в этом филиале, то есть для пары есть строка в таблице цен.
  4. Берёт текущую цену из service_prices. Клиентскую версию каталога сравнивает с текущей именно для этой услуги и филиала: если цена с тех пор менялась, это повод остановиться и сообщить об этом, а не молча взять новую сумму.
  5. Занимает слот. Атомарно увеличивает счётчик занятости только если ещё есть место.
  6. Записывает заявку вместе со снимком цены, версией каталога и статусом.

Шаги 5 и 6 выполняются в одной транзакции своей базы, чтобы не получилось занятого слота без заявки или заявки без занятого слота.

begin;

update slots
   set booked = booked + 1
 where id = 9041 and branch_id = 3 and booked < capacity
returning id;

-- если строка не вернулась, слот занят: rollback и ответ 409

insert into requests
  (telegram_user_id, service_id, branch_id, slot_id,
   price_minor, catalog_version, status, client_key)
values
  (:uid, 12, 3, 9041, :price, :version, 'new', :client_key);

commit;

Условие booked < capacity внутри самого update важнее, чем может показаться. Если сначала прочитать число свободных мест, а потом отдельной командой увеличить счётчик, два одновременных запроса увидят одно и то же свободное место, и последний слот окажется проданным дважды. Когда проверка и изменение сливаются в одну команду, база сама упорядочивает обращения.

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

Почему цена хранится в заявке

Снимок цены в строке заявки нужен не для красоты. Через месяц владелец поменяет прайс, а клиент придёт и спросит: «Мне ведь обещали по другой цене». Если цена в заявке — ссылка на текущий прайс, ответа нет. Если же в строке записаны сумма и версия каталога на момент создания, сервер может показать и цифру, и то, по какому состоянию каталога она получена. Здесь же становится понятно, зачем нужна версия: она связывает заявку с конкретным срезом услуг.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Когда карточка успела устареть

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

Вернёмся к нашему примеру. Пользователь смотрел карточку при версии 17, а в обед цена на стрижку в филиале «Центр» выросла, и версия стала 19. Если сервер молча создаст заявку по новой цене, человек увидит одну сумму, а согласится на другую. Если молча создаст по старой — бизнес теряет деньги, а пользователь получает цену, которой уже нет. Поэтому правильный ответ — отказ с понятным кодом, например 409, и тело ответа с актуальными данными.

HTTP 409
{
  'reason': 'price_changed',
  'current': {'service_id': 12, 'branch_id': 3, 'price_minor': 270000, 'catalog_version': 19}
}

Витрина получает причину и текущую цену, показывает её на экране и предлагает подтвердить запись заново. Следующая попытка идёт с новой версией, и сервер её принимает. Заметьте, что слово «подтвердить» тут существенно: согласие на сумму выражает человек, а не код.

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

Полезно относиться к этим отказам не как к ошибкам, а как к штатным ветвям сценария. В каталоге, где цены меняются, ответ «цена другая» — это нормальная работа системы, а не сбой. Если витрина показывает такой ответ как «что-то пошло не так», пользователь не понимает, что делать, и уходит.

Повторное открытие и дубли

Теперь второй неприятный сценарий. Пользователь нажал «Записаться», сеть качнулась, ответ не пришёл, он закрыл приложение и открыл заново. Что произошло: заявка создана или нет? С точки зрения пользователя — неизвестно. С точки зрения сервера — известно точно, поскольку он либо записал строку, либо нет.

Как раз для этого в таблице заявок нужна уникальность пары «пользователь, ключ попытки».

alter table requests
  add constraint requests_user_key_uniq
  unique (telegram_user_id, client_key);

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

Но ключ работает, только пока витрина его помнит. Поэтому её собственная задача — хранить ключ вместе с незавершённым намерением до получения окончательного ответа. Если приложение закрыли, а при следующем открытии намерения уже нет, дубль может появиться, ведь ключ будет новым. Страховку здесь даёт второй механизм: при открытии витрина всегда запрашивает с сервера список «Мои заявки» и показывает его первым. Человек видит, что запись уже есть, и повторять её не нужно. Список строится по проверенной личности, а не по номеру из запроса, так что чужие заявки в нём появиться не могут.

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

Нет сети: что можно показать, а что нельзя

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

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

Для записи без сети разумна такая последовательность.

  1. Витрина сохраняет намерение вместе с ключом и показывает статус «Отправляется», а не «Записано».
  2. При возвращении сети отправляет то же намерение с тем же ключом. Если первая попытка всё-таки дошла, ключ вернёт уже созданную заявку.
  3. Если ответ — 409, пользователь видит, что именно изменилось, и решает, подтверждать ли новую цену или другой слот. Автоматически «дожимать» устаревшую заявку нельзя: через час после намерения условия уже могли стать другими.
  4. Если давность initData вышла, сервер откажет, и витрина просит открыть приложение заново. Намерение при этом не теряется, пока оно хранится в приложении, но подтверждение придётся получить свежим входом.

Хранить ожидающее намерение на устройстве допустимо, но помните, что это тот же пользовательский ввод, а значит, недоверенный: после возврата сети сервер всё равно перепроверит все условия с нуля. Накопленная на клиенте очередь — не ордер на выполнение, а список просьб.

Сколько ресурсов нужно такому backend

Серверная часть каталога услуг — это небольшой HTTP-сервис, PostgreSQL и обратный прокси с сертификатом. Нагрузка зависит от того, сколько человек открывает каталог одновременно и как устроены ваши запросы, и без замеров называть число пользователей на ту или иную конфигурацию не стоит. Но логика выбора понятна. Каталог читается намного чаще, чем пишется, так что кэш ответа каталога (например, на время, в которое умещается ожидаемая задержка правок) снимает основную часть нагрузки на чтение, а запись заявок остаётся лёгкой.

В VPS MAATRIX тарифные линейки начинаются с Ba: 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe. Для первых экспериментов с каталогом и тестовой базой такой конфигурации обычно хватает для знакомства со схемой, а для рабочего запуска лучше смотреть на Uab (2 vCPU, 4 ГБ, 80 ГБ) или выше и ориентироваться на замеры своей нагрузки. Эти цифры описывают выделенные ресурсы, а не обещание обслужить заданное число людей. Из публичного предложения также следует, что есть NVMe-диски, процессоры AMD EPYC и root-доступ: последний нужен, чтобы самому поставить PostgreSQL, настроить роли из примера выше и выбрать конфигурацию прокси. Локации в предложении перечислены такие: UK, US, RU и FR. Выбор зависит от того, где находятся ваши пользователи и какие у вас требования к размещению данных; юридическую сторону для вашей страны и вашего бизнеса нужно оценивать отдельно. Оплата картой РФ и через СБП, а сервер создаётся автоматически через пару минут после оплаты, что удобно, если окружение нужно поднять быстро.

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

Пройти путь глазами пользователя и глазами сервера

Хорошая проверка готовности каталога — прогнать один и тот же сценарий дважды: как человек и как бухгалтер. Человек открывает приложение, видит карточку, выбирает слот и нажимает кнопку. Бухгалтер потом спрашивает: «На каком основании записана эта сумма?» Если на оба вопроса есть внятные ответы, архитектура сделана правильно.

Вот короткий набор проверок, который стоит выполнить на тестовой базе до реального запуска.

  • Отправить запрос на создание заявки без заголовка initData и с испорченной строкой: сервер обязан отказать, не создав ничего.
  • Отправить в теле чужой номер пользователя: сервер должен проигнорировать его и использовать номер из проверенных данных.
  • Подставить в запрос другую цену: заявка должна получить цену из базы.
  • Изменить цену в базе и отправить намерение со старой версией каталога: ответ должен быть 409 с актуальной суммой.
  • Занять последний слот двумя одновременными запросами: успешным должен стать ровно один.
  • Отправить то же намерение дважды с одним ключом: заявка одна, второй ответ возвращает её же.
  • Попробовать от имени прикладной роли обновить таблицу цен напрямую: база должна ответить отказом.

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

Заявка, которая умеет объяснять себя

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

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

Если после записи у вас появится шаг, где человек предъявляет что-то физически — например, подтверждение при входе, — логика проверки на сервере остаётся той же: клиент предъявляет, сервер сверяет. Пример такого сценария разобран в материале про регистрацию, билеты и проверку QR-кодов в Telegram Mini App.