Платформа заведений карта устройства

Как всё устроено, от А до Я

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

1. Что мы строим

На пальцах

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

Заведение платит подписку и получает: приём заявок (бот в Telegram, соцсети, мини-приложение), работу с гостями в соцсетях, меню для гостя и экран смены для персонала. Что именно включено, решает набор модулей.

ГостьTelegram, Instagram, WhatsApp, QR со стола
Движок заведенияпринял заявку, положил в свою базу
Персоналуведомление в Telegram или экран смены в панели

Живых заведения два, и они специально разные: на них проверяется, что платформа не срослась с одним клиентом.

ЗаведениеЧто этоЧем отличается
The Cloud
кальян-бар, Нячанг
Полный профиль: гостевой бот @Bookings_The_Claud_bot, два зала со столами, мини-приложение, касса Poster на чтение. Всё, что связано со столами и бронью, живёт здесь.
Quick SnacksB
кафе, Нячанг
Лёгкий профиль: бота нет, заявки приходят из соцсетей, есть меню, входящие комментарии и контент-план. Проверяет, что платформа работает без брони и без бота вообще.

2. Три сущности и кто чем владеет

На пальцах

Движок это мотор заведения. Он стоит отдельным контейнером на каждое заведение, у него своя база, и все данные заведения лежат только там: брони, меню, комментарии, посты, план.

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

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

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

Панель (пульт)люди, права, секреты, журнал, версии конфигураций
Шов /internal/*HTTP по докер-сети, свой секрет на каждое заведение
Движок (мотор)брони, меню, комментарии, посты, план, события персонала
Точно
  • Движок: /opt/cloud, FastAPI + aiogram, PostgreSQL в проде (SQLite в тестах). Правила брони, статусы, свипер, интейк.
  • Панель: /opt/hq, FastAPI + свой фронт без сборки, база hq-db. Хранит panel_users, venues, venue_secrets, venue_config_versions, audit_log.
  • Шов: роутер webapp/routers/internal.py с зависимостью на весь роутер: заголовок X-Internal-Secret, сравнение constant-time, нет секрета в движке — 503, не сошёлся — 403. У каждого заведения секрет свой, поэтому взлом одного движка не открывает соседний.
  • Автор действия уезжает движку заголовками X-Actor-Id и X-Actor-Name и попадает в action_log движка. До этого действия панели записывались туда безымянными.
  • Правило П-1: панель не хранит данные заведений и не считает их сама. Соблазн «положить простую заявку прямо в базу панели» ведёт к двум расходящимся реализациям одного правила через полгода.

3. Карта хозяйства

На пальцах

Всё крутится на одном сервере, на том же, где идёт работа. Наружу торчат четыре адреса, внутри работают семь контейнеров и несколько служб по расписанию. Ничего, кроме этого сервера, у платформы нет.

Контейнеры

ИмяЧто делаетКому виден
booking-botДвижок The Cloud: бот, мини-приложение, внутренний API127.0.0.1:8080
venue-snacksbДвижок Quick SnacksBтолько докер-сеть, порт наружу не публикуется
hq-panelПанель управления127.0.0.1:8090
booking-dbPostgreSQL The Cloudдокер-сеть
hq-dbPostgreSQL панели и баз заведений (venue_<slug>)127.0.0.1:5433
booking-cloudflaredТуннель Cloudflare для thecloudbar.barнаружу исходящим
booking-autohealПерезапускает контейнер, потерявший здоровьевнутри

Адреса

АдресЧто отдаётКак устроен
thecloudbar.barМини-приложение и страницы The CloudCloudflare Tunnel, входящих портов у сервера нет
hq.suom.bestПанель управления и приёмник вебхука Metanginx → 127.0.0.1:8090
panel.suom.bestСтарая SMM-панель Quick SnacksBnginx → 127.0.0.1:8095, гасим при переезде
menu.suom.bestВеб-меню по QR (боевое на /m/)статика из /opt/menu
img.suom.bestКартинки блюд и постовстатика из /opt/quicksnacks/_photos
docs.suom.bestЭта страницастатика из /opt/docs

Каталоги

ПутьЧто там
/opt/cloudКод движка. Один на все заведения.
/opt/hqКод панели, публикатор, инструменты, эта страница.
/opt/venues/<slug>/Слой заведения: venue.json, .env, docker-compose.yml. Веб-процесс панели прав на запись сюда не имеет.
/opt/quicksnacksХозяйство Quick SnacksB до платформы: старая панель, карта меню, фото, генератор веб-меню.
/opt/menu, /opt/docsКорни для nginx: наружу отдаётся только собранное, без исходников.

Службы по расписанию

ЮнитЧто делаетКак часто
cloud-autodeploy.timerНовый коммит в /opt/cloud → пересборка и выкат booking-bot~2 мин
hq-autodeploy.timerТо же для панели, плюс перезапуск публикатора~2 мин
hq-venues-refresh.timerПереводит заведения на свежий образ движкапо таймеру
hq-publisher.serviceДолгоживущий: применяет правки конфигураций из очередипостоянно
hq-backup.timer, hq-watchdog.timerРезервные копии и присмотр за панельюпо таймеру
smm-panel.service, smm-autopoll.timerСтарая панель SnacksB и её автоответы раз в 5 минутпока живы

4. Как появляется новое заведение

На пальцах

Заведение это анкета плюс контейнер. Берём готовый профиль («бар со столами» или «кафе без брони»), заполняем анкету заведения, запускаем провижининг, и через минуту у заведения есть свой мотор, своя база и свой ключ к пульту. Кода при этом никто не пишет.

Пресетформат заведения: бар со столами или кафе
Слой заведенияимя, часы, залы, столы, модули
Провижинингбаза, секрет, каталог, контейнер
Движок поднялсяпанель видит его в списке

Конфигурация собирается из трёх слоёв. Каждый следующий перекрывает предыдущий, словари сливаются, а списки заменяются целиком.

1. Дефолты платформыconfig/defaults.json. То, что верно для любого заведения.
2. Пресет форматаconfig/presets/cafe_no_booking.json. Набор модулей и типичные настройки формата.
3. Слой заведения/opt/venues/snacksb/venue.json. Всё своё: имя, контакты, залы, модули.
= Конфигурация, которую видит код движка.
Грабли, которые уже сработали. Списки заменяются целиком. Пресет кафе включает модуль menu, но если слой заведения перечисляет модули заново, действует только его список. Именно поэтому у SnacksB меню сначала оказалось выключенным, хотя в пресете оно есть.
Точно
  • Провижининг: python -m hq.tools.provision_venue — командная утилита, а не кнопка (П-18). Создаёт базу venue_<slug> на hq-db, каталог /opt/venues/<slug>/, секрет доступа в vault и контейнер venue-<slug> из образа cloud-bot.
  • Повторный провижининг не перевыпускает ключ доступа (П-19).
  • Лёгкий профиль не публикует порт наружу вообще (П-20).
  • Путь к слою задаётся переменной VENUE_CONFIG_PATH. Битый или отсутствующий слой роняет старт намеренно: раньше был фолбэк на встроенное заведение, и он молча подменял чужое заведение на The Cloud.
  • Проверить кандидата, ничего не записывая: python -m bot.venue --check <файл> или POST /internal/config/validate.

5. Модули: из чего собирается заведение

На пальцах

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

МодульЧто даётТребуетThe CloudSnacksB
booking_coreЗаявка как сущностьдада
booking_tablesЗалы, столы, слотыbooking_coreданет
telegram_botГостевой ботданет
booking_miniappМини-приложение персоналаcore + tables + botданет
poster_posКасса Poster, только чтениеbooking_tablesданет
social_inboxЗаявки и комментарии из соцсетейbooking_coreнетда
menuМеню и страница /menuнетда
contentКонтент-план и публикациинетда
reviewsОтзывынетнет
Точно

Зависимости описаны в MODULE_DEPENDENCIES (bot/venue.py) и проверяются на старте: ошибки собираются все сразу, чтобы не чинить конфиг по одной за перезапуск. По набору модулей подключаются роутеры, статика, поллер кассы и фоновые задачи; бот создаётся лениво (get_bot()), поэтому заведение без telegram_bot поднимается без токена.

Дерево модулей и пресеты панель спрашивает у движка (GET /internal/capabilities), а не хранит у себя (П-33): иначе новый модуль пришлось бы заводить в двух местах.

6. Потоки: что за чем идёт

6.1. Бронь из гостевого бота

На пальцах

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

8 шагов в ботестол → дата → время → гости → имя → телефон → комментарий → готово
Проверка занятостипод замком слота, в той же транзакции
EN_ROUTEперсоналу уходит уведомление
Двойная бронь. Раньше защитой служил asyncio.Lock, общий для двух циклов событий в двух потоках, то есть не защищавший вообще. Сейчас на PostgreSQL это транзакционный pg_advisory_xact_lock по ключу «стол + дата»: проверка конфликта и вставка атомарны. Любая новая операция, меняющая занятость слота, обязана идти под тем же замком.
Точно
  • Статусы: PENDING → CONFIRMED → EN_ROUTE → CANCELLED → COMPLETED (bot/database/models.py).
  • FSM: bot/handlers/booking.py. Замок: _SlotGuard в bot/services/booking/crud.py.
  • Свипер bot/services/booking/lifecycle.py: отменяет подвисшие PENDING (по умолчанию 15 минут, у SnacksB 180), закрывает вчерашние и завершившиеся, реагирует на закрытие стола в Poster. Ночные брони не автоотменяются, бизнес-день сдвинут на 04:00.
  • Операции над бронью идут одним сервисом bot/services/booking/actions.py — его же зовут мини-приложение и панель. Три копии одного правила разошлись бы через месяц.

6.2. Заявка из соцсетей

На пальцах

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

Соцсетьсообщение гостя
POST /api/social-bookсекрет X-Internal-Secret, 30 запросов в минуту
Общий интейксанитайз → проверка → запись
PENDINGперсонал принимает или отклоняет

6.3. Уведомление персоналу

На пальцах

У заведения с ботом уведомление уходит в Telegram. У заведения без бота сказать некуда, поэтому событие ложится в базу, и панель показывает его на экране смены блоком «Входящее». Раньше такое заведение теряло заявку молча.

Событиеновая заявка, отмена, ошибка
Notifierрешает по модулям заведения
Есть ботсообщение в Telegram
Бота нетstaff_events → экран «Входящее»
Точно

bot/services/notify.py: Notifier, TelegramNotifier, PanelNotifier. Звать tg_send напрямую из новых мест приёма заявок нельзя: заведение без бота потеряет уведомление, и никто этого не заметит.

6.4. Комментарий из Instagram

На пальцах

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

Instagramвебхук или кнопка «забрать»
Панельотбрасывает свои же комментарии
Движокsocial_comments, импорт идемпотентный
Экран «Входящее»черновик Тир-1, отвечает человек
Порядок важен: ответ уходит сначала в соцсеть и только потом отмечается в базе. Наоборот очередь опустела бы, а гость остался без ответа, и заметить это было бы нечем.
Точно
  • Хранение: bot/services/social/comments.py, таблица social_comments. Импорт идемпотентен по external_id и не трогает статус, поставленный человеком.
  • Приём событий: POST /webhook/meta/{slug} на панели. Fail-closed: нет секрета — 503, подпись не сошлась — 403, чужой слаг — 404, движок не принял — 503, чтобы Meta повторила. Подпись считается от сырого тела.
  • Правила ответа (Тир-1) живут в панели, слова ответа принадлежат заведению (П-39). Стоп-слово сильнее совпадения (П-40).

6.5. Публикация поста

На пальцах

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

1. Занятьdraft/failed → publishing, одним запросом с условием
2. Metaконтейнер, затем публикация: два шага, это её требование
3. Записатьpublished с номером публикации или failed с причиной
Цена ошибки решает порядок. У ответа на комментарий сначала соцсеть, потом статус. У публикации наоборот: состояние меняется до обращения наружу. Занятый и неопубликованный пост чинится одной кнопкой, а дубль в ленте не чинится ничем.
Точно
  • Движок: bot/services/social/posts.py, таблица social_posts, маршруты /internal/posts/*. Статус published правкой не ставится; опубликованный пост не правится и не удаляется.
  • Панель: POST /api/venues/{slug}/posts/{id}/publish. Ссылка на публикацию спрашивается отдельно и необязательно: пост уже в ленте. Движок не ответил после успешной публикации — панель говорит об этом прямо (503), иначе человек опубликует второй раз.
  • Застрявшее publishing снимается кнопкой с предупреждением сначала посмотреть ленту. Автоснятия по таймауту нет: таймаут не отличает «Meta не ответила» от «мы не услышали ответ».

6.6. Контент-план

На пальцах

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

6.7. Правка настроек заведения

На пальцах

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

1. Человек правит форму в панеливидно «было и стало»
2. Правка ложится в очередьvenue_config_versions
3. Публикатор проверяет кандидатаобразом заведения, до записи
4. Записал, перезапустил, проверил здоровье
5. Плохо → автооткат новой версиейоткат это новая версия, а не удаление свежей

6.8. Меню

На пальцах

Сегодня меню живёт двумя жизнями. Первая: карта меню (MENU_MAP.md) импортируется в базу заведения, движок отдаёт простую страницу /menu, панель показывает её только для чтения. Вторая: отдельный сборщик делает из той же карты красивое меню по QR с фотографиями, четырьмя языками и офлайном. Это и есть главный источник сегодняшней путаницы, разбор ниже.

Точно
  • Движок: bot/services/menu.py, таблица menu_items (цена целым числом минорных единиц плюс валюта в той же строке), импорт python -m bot.tools.import_menu <файл.md> — по умолчанию предпросмотр, замена по --apply. Непонятное импорт не угадывает, а называет поимённо.
  • Веб-меню: /opt/quicksnacks/menu/build.py → статика в /opt/menu, боевое по адресу menu.suom.best/m/. Данные берёт из MENU_MAP.md, состояние (стоп-лист, бейджи) держит в menu/data/*.json.

7. Секреты и деньги

На пальцах

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

Деньги считаются только целыми числами. У донга нет копеек, поэтому привычное «поделить на сто» ломается ровно на нашей валюте.

ЧтоГде лежитПочему там
Секрет доступа панели к движкуvault панели, свой на заведениеВзлом одного движка не открывает соседний
Токен Instagram и номер аккаунтаvault, паройРазнесённые, они однажды разъедутся, и запрос уйдёт чужим токеном
Секрет приложения Meta, слово подтверждения вебхукаvaultБез них приём событий закрыт наглухо
Ключ Gemini (черновики подписей)vault заведенияОплачивается заведением отдельно, поэтому не общий
Токен гостевого ботаvault, копия в .env заведенияДвижку он нужен при старте, кладёт туда публикатор
HQ_MASTER_KEYтолько /opt/hq/.envБез него дамп базы панели не расшифровать. Копию держать вне сервера
Правило про чужой текст. Клиент Graph API вычёркивает токен из сообщений об ошибке Meta перед тем, как они уедут в лог или на экран. Обычно токена там нет, но «обычно» это наблюдение, а не свойство.

8. Деплой: как правка доезжает до гостя

На пальцах

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

1. Коммит в master
2. Автодеплой пересобирает образ~2 минуты, проверка /health
3. Заведения переезжают на свежий образhq-venues-refresh переставляет тег
4. Миграции применились при старте
Грабли. Про третий шаг забывали, и заведение месяцами работало старым кодом. Проверять после правок движка: docker exec venue-<slug> python -c "...". И отдельно: .env автодеплой не подхватывает, правка переменной требует ручного docker compose up -d.

9. Правила, которые не нарушаем

ПравилоПочему
Панель не хранит данные заведенийИначе через полгода будет две расходящиеся реализации одного правила
Веб-процесс не пишет туда, откуда читает конфигурациюРовно этого не было в старой панели: обработчик OAuth переписывал .env собственного процесса
Модель предлагает, отправляет человекБодрый автоответ на жалобу хуже, чем молчание
Детерминированное вместо модели везде, где возможноПравила, публикация, проверка и откат должны быть предсказуемы
Fail-closed на каждом швеНет секрета — 503, плохая подпись — 403, битый конфиг — не применяется
Ошибка движка это данные, а не исключениеОдно лежащее заведение не должно ломать экран остальных
Необратимое подтверждается и называет предмет по имени«Отменить бронь» и «отменить бронь Ивана на 20:00» читаются по-разному
Чужую ошибку показываем дословно«Модель отказала» это повод открыть логи, а «кончились кредиты» это действие

10. Где путаница и как её убрать

На пальцах

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

Шесть мест, где сегодня двоится

ЧтоКак сейчасЧем грозитЦель
Панели Старая SMM-панель на panel.suom.best и новая на hq.suom.best, обе работают с SnacksB Правку делают не там; вебхук Meta смотрит в старую Одна панель. Старую гасим, домен ведёт на новую
Меню Три источника: MENU_MAP.md, menu_items в базе, menu/data/*.json у сборщика Стоп-лист и бейджи лежат в файлах сборщика: импорт карты их сотрёт, и в меню вернётся блюдо, которого нет на кухне menu_items в базе заведения — единственный источник. Карта становится входом импорта
Посты smm.sqlite3 у старой панели и social_posts в базе заведения Два черновика одного поста, непонятно, какой уйдёт в ленту Три оставшихся черновика переносим, старую таблицу оставляем в бэкапе
Картинки Всё в /opt/quicksnacks/_photos, отдаётся общим доменом img.suom.best Файлы одного заведения лежат в каталоге, названном по другому проекту; второму заведению класть некуда Хранилище на заведение, публикуется вместе с меню
Домены Четыре адреса, два из которых ведут в одну и ту же панель Вебхук зарегистрирован на одном, а работаем на другом: переезд без переподписки даёт тишину, а не ошибку hq.suom.best рабочий, panel.suom.best редиректом
Автоответы Старая панель отвечает гостям сама каждые 5 минут, новая только предлагает черновик После гашения старой ответы станут ручными, и это надо решить осознанно, а не обнаружить Решение принято отдельно: либо переносим автоответ, либо отвечаем руками

Как выглядит «убрано»

Источник истины по меню: база заведенияmenu_items: цена, состав, переводы, фото, стоп-лист, бейдж
Правка только через панельцена, стоп-лист, бейдж, фото — по одной позиции
Сборщик читает движок, а не картуанкета заведения даёт название, телефон, соцсети, языки
Публикует воркер, домен на заведениетем же швом, что и правка конфигурации
Важное следствие. Позиции нужен стабильный ключ (слаг), иначе повторный импорт карты потеряет привязку фото, бейджей и стоп-листа. И правило то же, что уже спасло нас на комментариях: импорт обновляет цены и составы, но не отменяет решение человека.

11. Дорожная карта

Веб-меню внутрь платформы, четыре шага

  1. Меню правится из панели. Слаг, бейдж, формат цены раздела в движке; правка позиции по API; импорт перестаёт затирать оперативные флаги; экран меню становится редактируемым.
  2. Сборщик переезжает в платформу. Читает данные движка и анкету заведения вместо карты меню и своего venue.json.
  3. Публикация воркером. Очередь задач, каталог и домен на заведение, автопересборка после правки.
  4. Конвейер кадров. Обработка фото и фирменный фон как отдельный шаг. Проверить сможем после пополнения кредитов Gemini.

Остаток переезда Quick SnacksB

Заблокировано снаружи

ЧтоКто держитЧто делать
Отзывы GoogleGoogle: Business Profile API отвечает квотой 0 запросов в минутуЖдать одобрения заявки
Черновики подписей модельюКончились предоплаченные кредиты GeminiПополнить в AI Studio, код готов
Директы InstagramНужны креды Meta со стороны The CloudЖдать

12. Словарь

СловоЧто значит
ДвижокПрограмма заведения. Свой контейнер и своя база на каждое заведение, код общий.
ПанельПульт управления всеми заведениями. Своих данных заведений не хранит.
Слой заведенияФайл venue.json с настройками конкретного заведения. Ложится поверх дефолтов и пресета.
ПресетФормат заведения: «бар со столами», «кафе без брони». Набор модулей и типичные настройки.
МодульВключаемая возможность: столы, бот, меню, контент, соцсети, касса.
ПубликаторОтдельный процесс, который применяет правки конфигураций. Единственный, у кого есть права на файлы заведений.
VaultСейф панели: секреты заведений, зашифрованные мастер-ключом.
ИнтейкОбщая дверь приёма заявки: проверка и запись в одном месте, откуда бы заявка ни пришла.
СвиперФоновая гигиена состояний: отменяет подвисшее, закрывает вчерашнее.
Тир-1Правила автоответа заведения: распознавание общее в коде, слова ответа принадлежат заведению.
Занятие постаПометка «этот пост уже публикуется». Защита от того, чтобы два нажатия положили в ленту два поста.
Р-NN, П-NNНомера принятых решений: Р это движок (/opt/cloud/docs/architecture_decisions.md), П это панель (/opt/hq/docs/architecture_decisions.md).