Как всё устроено, от А до Я
Одна страница про всю платформу: что где живёт, кто чем владеет, что за чем идёт и где сегодня одна и та же вещь лежит в двух местах. Каждый раздел сначала объясняется на пальцах, а под ним лежит блок «точно» с именами файлов, таблиц и маршрутов.
1. Что мы строим
Мы продаём заведению не программу, а рабочее место. У каждого заведения свой отдельный «мотор»: он принимает заявки от гостей, помнит брони, меню и комментарии из соцсетей. Все моторы одинаковые, отличаются настройкой. Мы же сидим за одним пультом, с которого видно все заведения сразу.
Заведение платит подписку и получает: приём заявок (бот в 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: бот, мини-приложение, внутренний API | 127.0.0.1:8080 |
venue-snacksb | Движок Quick SnacksB | только докер-сеть, порт наружу не публикуется |
hq-panel | Панель управления | 127.0.0.1:8090 |
booking-db | PostgreSQL The Cloud | докер-сеть |
hq-db | PostgreSQL панели и баз заведений (venue_<slug>) | 127.0.0.1:5433 |
booking-cloudflared | Туннель Cloudflare для thecloudbar.bar | наружу исходящим |
booking-autoheal | Перезапускает контейнер, потерявший здоровье | внутри |
Адреса
| Адрес | Что отдаёт | Как устроен |
|---|---|---|
thecloudbar.bar | Мини-приложение и страницы The Cloud | Cloudflare Tunnel, входящих портов у сервера нет |
hq.suom.best | Панель управления и приёмник вебхука Meta | nginx → 127.0.0.1:8090 |
panel.suom.best | Старая SMM-панель Quick SnacksB | nginx → 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. Как появляется новое заведение
Заведение это анкета плюс контейнер. Берём готовый профиль («бар со столами» или «кафе без брони»), заполняем анкету заведения, запускаем провижининг, и через минуту у заведения есть свой мотор, своя база и свой ключ к пульту. Кода при этом никто не пишет.
Конфигурация собирается из трёх слоёв. Каждый следующий перекрывает предыдущий, словари сливаются, а списки заменяются целиком.
config/defaults.json. То, что верно для любого заведения.config/presets/cafe_no_booking.json. Набор модулей и типичные настройки формата./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 Cloud | SnacksB |
|---|---|---|---|---|
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 ждёт решения персонала.
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 запросов в минуту6.3. Уведомление персоналу
У заведения с ботом уведомление уходит в Telegram. У заведения без бота сказать некуда, поэтому событие ложится в базу, и панель показывает его на экране смены блоком «Входящее». Раньше такое заведение теряло заявку молча.
Notifierрешает по модулям заведенияstaff_events → экран «Входящее»Точно
bot/services/notify.py: Notifier,
TelegramNotifier, PanelNotifier. Звать
tg_send напрямую из новых мест приёма заявок нельзя: заведение
без бота потеряет уведомление, и никто этого не заметит.
6.4. Комментарий из Instagram
В соцсеть ходит панель, потому что токен лежит у неё. Хранит комментарии движок, потому что это данные заведения. Правило заведения предлагает черновик ответа, отправляет человек. На жалобу черновик не подставляется вовсе: бодрый автоответ на «не вкусно» хуже, чем его отсутствие.
social_comments, импорт идемпотентныйТочно
- Хранение:
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. Два нажатия кнопки, два оператора и повторный запрос браузера получают отказ, а не второй пост в ленте.
draft/failed → publishing, одним запросом с условием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. Правка настроек заведения
Панель никогда не пишет конфигурацию сама. Она кладёт правку в очередь, отдельный процесс собирает кандидата, проверяет его образом самого заведения, записывает файл, перезапускает движок и следит за здоровьем. Стало плохо — откатывает. Веб-процесс физически не имеет прав на эти файлы.
venue_config_versions6.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 | Без него дамп базы панели не расшифровать. Копию держать вне сервера |
8. Деплой: как правка доезжает до гостя
Коммит в основную ветку и есть выкат. Раз в две минуты служба смотрит, не ушёл ли код вперёд задеплоенного, и если ушёл, пересобирает контейнер и проверяет здоровье. Грязное дерево она не трогает, поэтому незаконченную работу не теряет. Миграции базы применяются при старте контейнера.
master/healthhq-venues-refresh переставляет тег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. Дорожная карта
Веб-меню внутрь платформы, четыре шага
- Меню правится из панели. Слаг, бейдж, формат цены раздела в движке; правка позиции по API; импорт перестаёт затирать оперативные флаги; экран меню становится редактируемым.
- Сборщик переезжает в платформу. Читает данные движка и анкету
заведения вместо карты меню и своего
venue.json. - Публикация воркером. Очередь задач, каталог и домен на заведение, автопересборка после правки.
- Конвейер кадров. Обработка фото и фирменный фон как отдельный шаг. Проверить сможем после пополнения кредитов Gemini.
Остаток переезда Quick SnacksB
- Перенести стратегию (809 символов текста) и три черновика поста.
- Решить судьбу автоответа, перенести или отказаться осознанно.
- Переподписать вебхук Meta на
hq.suom.best. panel.suom.bestредиректом на новую панель.- Погасить
smm-panel.serviceиsmm-autopoll.timer, сохранив бэкап базы.
Заблокировано снаружи
| Что | Кто держит | Что делать |
|---|---|---|
| Отзывы Google | Google: 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). |