Каталог услуг внутри 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, и по нему сервер может решать, насколько свежим считает вход (там же, в описании проверки). Какое окно давности выбрать, зависит от вашего сценария. Для каталога, где заявка не стоит денег в момент создания, окно можно держать щедрым. Если же оформление связано с чем-то более чувствительным, окно сужают. Это ваше решение, а не требование платформы: документация объясняет, что поле позволяет проверить давность, но число минут выбираете вы.
Есть две типовые схемы использования проверенного входа.
- Проверка на каждый запрос. Витрина добавляет initData к каждому обращению, сервер каждый раз проверяет подпись и давность. Просто, не требует хранения сессий, но при долгой работе приложения давность рано или поздно истечёт, и придётся просить пользователя открыть приложение заново.
- Собственная сессия. Сервер проверяет 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-...'
}Ключ попытки генерируется один раз, когда пользователь выражает намерение, и при повторных отправках того же намерения остаётся прежним. Сам по себе новый случайный идентификатор ничего не гарантирует: если витрина при каждом нажатии создаёт новый ключ, дубли пройдут. Защиту от них даёт серверное правило, о котором ниже, — уникальность пары «пользователь, ключ» в таблице заявок.
Что делает сервер, шаг за шагом
- Проверяет личность. Берёт initData из заголовка, проверяет подпись и давность
auth_date. Если что-то не так — отвечает отказом и не читает тело запроса дальше. - Находит или создаёт пользователя по номеру из проверенных данных, а не из тела. Здесь же проверяется, не заблокирован ли он и не превышен ли лимит открытых заявок (допустим, не больше трёх — число условное, его определяет бизнес).
- Читает услугу и филиал из своей базы и убеждается, что обе записи активны и что услуга действительно предоставляется в этом филиале, то есть для пары есть строка в таблице цен.
- Берёт текущую цену из
service_prices. Клиентскую версию каталога сравнивает с текущей именно для этой услуги и филиала: если цена с тех пор менялась, это повод остановиться и сообщить об этом, а не молча взять новую сумму. - Занимает слот. Атомарно увеличивает счётчик занятости только если ещё есть место.
- Записывает заявку вместе со снимком цены, версией каталога и статусом.
Шаги 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 минут назад». Пользователь видит, что картина может устареть, и это честно. Запись — действие, которое меняет состояние на сервере. Здесь никаких «успешно» без ответа сервера быть не может.
Для записи без сети разумна такая последовательность.
- Витрина сохраняет намерение вместе с ключом и показывает статус «Отправляется», а не «Записано».
- При возвращении сети отправляет то же намерение с тем же ключом. Если первая попытка всё-таки дошла, ключ вернёт уже созданную заявку.
- Если ответ — 409, пользователь видит, что именно изменилось, и решает, подтверждать ли новую цену или другой слот. Автоматически «дожимать» устаревшую заявку нельзя: через час после намерения условия уже могли стать другими.
- Если давность 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.