# Архитектура «РестоМесто»

Документ фиксирует стек, версии и принятые архитектурные решения. Обновляется по мере
развития проекта. См. также [database-schema.md](database-schema.md),
[restoplace-api-contract.md](restoplace-api-contract.md), [deployment.md](deployment.md),
[assumptions.md](assumptions.md).

## Что это за проект

«РестоМесто» — публичный портал-агрегатор заведений, интегрированный с Restoplace. Пользователь
выбирает город, находит ресторан, смотрит список или карту, фильтрует по свободным столам,
бронирует стол, ведёт избранное и оставляет отзывы после посещения (ТЗ §1).

Ключевое ограничение, определяющее всю архитектуру: **Restoplace — источник истины** по составу
каталога, доступности столов и броням (ТЗ §2.1). «РестоМесто» не ведёт собственный учёт столов и
резервов. Локально хранятся только кеш каталога, дополнения карточки, медиа, аккаунты гостей,
отзывы, SEO и служебные журналы.

## Версии (зафиксировано на Этапе 1)

| Компонент | Версия | Примечание |
|---|---|---|
| PHP | 8.3.30 (ZTS, Windows) | Laravel 13 требует не ниже 8.3 |
| Laravel | 13.24 | `laravel/framework ^13.0` |
| MySQL | 8.4.3 | InnoDB, utf8mb4 |
| Node | 22.22 | сборка Vite |
| Composer | 2.9.4 | |
| Livewire | 4.3 | |
| Tailwind CSS | 4.x | через `@tailwindcss/vite` |
| Vite | 7.x | |
| Filament | 5.x | админка `/developer`, подключается на Этапе 7 |

Проверенные расширения PHP: `gd`, `exif`, `fileinfo`, `intl`, `bcmath`, `mbstring`, `curl`,
`openssl`, `pdo_mysql`, `sodium`, `zip`. `imagick` отсутствует — обработка изображений идёт на
`gd`.

## Стек

**Backend.** Laravel 13, PHP 8.3, MySQL 8.4 (InnoDB). Кеш, очереди, блокировки и rate limiting
работают на драйвере `database` — отдельный сервер кеша проекту не нужен (см.
[assumptions.md](assumptions.md), D1). Драйверы задаются окружением, логика к ним не привязана.
Планировщик — Laravel Scheduler.

**Frontend.** Blade (серверный рендеринг) + Livewire 4 + Alpine (поставляется с Livewire) +
Tailwind CSS 4 + Vite. Чистого клиентского SPA нет: публичные страницы обязаны отдавать
полноценный HTML без выполнения JavaScript (ТЗ §3.2, §18.1). Шрифт Manrope самохостится через
`@fontsource-variable/manrope` — внешние CDN несовместимы со строгим CSP и офлайн-режимом PWA.

**Админка `/developer`.** Filament 5 (Этап 7). Объём раздела по ТЗ §20 большой, и Filament даёт
таблицы, фильтры, формы и графики из коробки. Публичная часть и кабинеты гостя и ресторана
остаются на кастомном Blade по дизайну — Filament там не используется.

**Карты.** Яндекс.Карты через `MapProviderInterface`: домен не знает поставщика, замена на
OpenStreetMap + Leaflet возможна без правок бизнес-логики (ТЗ §2.3, §8.6).

**Вход гостя по звонку.** Обратный звонок через voicepassword.ru — та же схема и поставщик, что в
проекте `teeu`. Поставщик за интерфейсом, драйвер `manual` для разработки и тестов.

**Хранилище файлов.** Локально `local`/`public` диск Laravel Storage, в production —
S3-совместимое объектное хранилище (ТЗ §3.3).

## Модульный монолит

```
app/
├── Domain/
│   ├── Admin/                 AdminUser, AuditLog, SystemSetting, роли
│   ├── Availability/          статусы и расчёт доступности
│   ├── Booking/               GuestBooking, статусы брони и оплаты
│   ├── Catalog/               Venue, VenueProfile, VenueContact, Cuisine, VenueFeature, рейтинг
│   ├── Cities/                City, определение текущего города
│   ├── GuestAuth/             Guest, соцаккаунты, попытки входа
│   ├── GuestCabinet/          избранные заведения и столы
│   ├── Legal/                 Consent — согласия и принятые документы
│   ├── Media/                 VenuePhoto, VenueLogo
│   ├── Notifications/         NotificationLog
│   ├── RestaurantCabinet/     RestaurantUser, доступы к адресам, коды входа
│   ├── RestoplaceIntegration/ контракты, DTO, HTTP- и fake-клиенты, журналы
│   ├── Reviews/               Review, версии, модерация, ответы, жалобы
│   └── Seo/                   шаблоны, переопределения, редиректы, статические страницы
├── Http/                      контроллеры, middleware
├── Providers/                 AppServiceProvider, DomainServiceProvider, RestoplaceServiceProvider
└── Support/                   PhoneNumber, EmailAddress, SlugGenerator, маскирование логов, очереди
```

Домены `GuestCabinet`, `Legal` и `Notifications` добавлены к списку из ТЗ §3.4: там приведён
пример разделения, а избранное, согласия и журнал уведомлений плохо ложатся в перечисленные
домены. Остальные названия совпадают с ТЗ.

Модели живут внутри доменов (`App\Domain\<Домен>\Models`), общей папки `App\Models` нет.
Laravel об этом не знает по умолчанию, поэтому `DomainServiceProvider`:

- переопределяет разрешение фабрик: `App\Domain\Catalog\Models\Venue` ↔
  `Database\Factories\Catalog\VenueFactory`;
- задаёт **morph map** с короткими именами (`venue`, `guest`, `review`, …) — перенос класса между
  доменами не должен ломать данные в БД;
- включает строгий режим Eloquent вне production: ленивая загрузка и обращение к незагруженному
  атрибуту падают на разработке, а не превращаются в N+1 на бою.

## Разделение зон и guard-ов

Общей таблицы `users` нет. Это три разные роли с разными способами входа, и смешивать их в одной
таблице значит смешивать и модели угроз:

| Guard | Кто | Вход |
|---|---|---|
| `guest` | посетитель портала | flash call по телефону, OAuth VK, OAuth Яндекс (ТЗ §11) |
| `restaurant` | сотрудник заведения | одноразовый код на контактный email адреса (ТЗ §15.1) |
| `admin` | администратор | email + пароль + 2FA (ТЗ §20.1) |

Права администратора — `spatie/laravel-permission` на guard `admin`, роли из
`App\Domain\Admin\Enums\AdminRole`. Права ресторана проверяются Policy по таблице
`restaurant_venue_accesses`: `venue_id` из формы или URL сам по себе ничего не доказывает
(ТЗ §15.2, §21.2).

## Интеграция с Restoplace

Внешний мир отделён интерфейсами (ТЗ §3.4). Домен работает только с ними:

```
RestoplaceCatalogClientInterface        список и карточка адресов
RestoplaceAvailabilityClientInterface   пакетная доступность, точные слоты, столы
RestoplaceBookingClientInterface        депозит, создание, чтение и отмена брони
RestoplaceWebhookVerifierInterface      проверка HMAC-подписи входящих событий
```

Реализации выбирает `RestoplaceServiceProvider` по `RESTOPLACE_DRIVER`:

- `fake` — `FakeRestoplaceClient` на контрактных fixtures (`tests/Fixtures/restoplace/`);
- `http` — `HttpRestoplaceClient`, боевой партнёрский API.

Все три контракта резолвятся в **один экземпляр**: fake-клиент держит состояние созданных броней,
и разные экземпляры сломали бы проверку идемпотентности.

Пока боевой партнёрский API не готов, разработка идёт на fixtures. Замена драйвера не требует
правок в бизнес-логике — это и есть проверка того, что абстракция не протекает (ТЗ §31).

Наружу клиенты отдают только типизированные DTO (`VenueData`, `AvailabilityResult`, `ReserveData`,
…). Массивы из внешнего API дальше границы интеграции не проходят: любое расхождение контракта
должно ломаться в одном месте, а не в глубине домена.

### Что запрещено архитектурой (ТЗ §2.3)

- хранить API-ключ каждого ресторана — используется единый партнёрский токен;
- передавать ключи Restoplace в браузер и делать запросы к API из JavaScript;
- считать реальную доступность столов только на своей стороне;
- создавать бронь без финальной проверки Restoplace;
- привязывать бизнес-логику к конкретному поставщику карт, телефонии, email или ИИ;
- жёстко удалять заведения после отключения;
- хранить секреты в Git;
- вызывать внешний API из Blade-шаблонов или контроллеров.

## Каталог (Этап 2)

**Синхронизация.** `SyncCatalogAction` обходит `GET /venues` постранично и передаёт каждый адрес
в `SyncVenueAction`, который применяет только поля Restoplace. Локальные дополнения — описание,
фото, кухни, особенности, контакты — лежат в отдельных таблицах и не перезаписываются (ТЗ §6.3).

Три правила защищают каталог от исчезновения:

1. Заведение деактивируется только по фактически полученным данным.
2. «Пропавшие из выдачи» адреса скрываются исключительно после **полностью успешного полного**
   обхода — при частичном сбое ничего не трогается.
3. Пустая полная выдача игнорируется: это почти наверняка сбой API, а не «все заведения разом
   отключились».

При падении обхода `window_to` не засчитывается, и следующий запуск повторяет окно целиком.

Запуск: инкрементально каждые 5 минут, полный обход раз в сутки, плюс webhook для оперативных
изменений. Вручную — `php artisan restoplace:sync`.

**Webhook.** Событие считается сигналом «данные изменились», а не источником данных: обработчик
перезапрашивает адрес и применяет его тем же кодом, что и обычная синхронизация. Это снимает
вопрос порядка доставки — последний запрос всегда даёт актуальное состояние. События старше
`source_updated_at` заведения пропускаются.

**Доступность.** `AvailabilityService` делает один пакетный запрос на экран каталога и кеширует
результат на 30–60 секунд. Неудачный ответ не кешируется — иначе один сбой прячет доступность на
весь TTL. Когда API недоступен, каталог продолжает работать со статусом «Доступность уточняется».

Порядок операций зависит от фильтров: если доступность влияет на состав выдачи (фильтр «только со
свободными столами» или сортировка по числу столов), она запрашивается по всему отфильтрованному
списку **до** пагинации; иначе — только по показанной странице.

**Фильтр «открыто сейчас»** считается в PHP, а не в SQL: у каждого адреса свой часовой пояс, а
смены через полночь в запросе выражаются нечитаемо.

**Slug** задаётся один раз при создании и не меняется при переименовании заведения: стабильность
URL важнее его свежести (ТЗ §18.2). Сменить slug может администратор — тогда со старого адреса
ставится 301 через таблицу `redirects`.

## Бронирование (Этап 3)

Порядок шагов в `CreateBookingAction` продиктован требованиями и не является
свободным (ТЗ §9.1–9.5):

1. **Точная повторная проверка доступности.** Пакетные данные каталога кешируются на десятки
   секунд и всегда предварительны; между просмотром и отправкой формы проходит время.
2. **Локальная запись создаётся до обращения к Restoplace.** Иначе при обрыве связи после
   успешного создания бронь существовала бы только у ресторана, и гость никогда бы её не увидел.
3. **Idempotency-Key = `local_booking_uuid`.** Повторная отправка формы возвращает ту же бронь,
   а не создаёт вторую.
4. **Финальное решение принимает Restoplace.** Стол занят — локальная запись помечается
   отклонённой, ложная бронь не создаётся, гостю показываются альтернативные слоты.

Статус брони берётся у Restoplace через `BookingStatusMapper` — и при создании, и при сверке.
Своя логика включается только для неизвестных значений: иначе два пути выводили бы статус по
разным правилам, и бронь «переключалась» бы сама собой. Депозит имеет приоритет: пока он не
внесён, бронь не подтверждена, что бы ни вернул источник.

**Оплата.** Депозит принимает Restoplace, портал только уводит гостя по платёжной ссылке. При
возврате на страницу брони статус перезапрашивается: webhook может не успеть, а показывать
«ожидает оплаты» сразу после оплаты нельзя.

**Страница брони** доступна по `local_booking_uuid` без авторизации: гость мог бронировать без
аккаунта, и ссылка из подтверждения — единственное, что у него есть. Сверх им же введённых данных
страница ничего не показывает.

**Отмена** выполняется через API Restoplace. Локальная запись обновляется только после его
ответа: пометить бронь отменённой у себя, пока она жива у ресторана, — значит подставить и гостя,
и заведение.

**Сверка (`ReconcileBookingsJob`, каждые 10 минут)** добирает то, что не пришло webhook-ом,
закрывает брони с истёкшим сроком оплаты и переводит завершившиеся визиты в `visited` — именно это
состояние открывает право на отзыв.

## Вход гостя и кабинет (Этап 4)

Вход и регистрация — один сценарий (ТЗ §11.1). Способов три, и все они сходятся в
`GuestAccountService`: найти или создать аккаунт, проверить блокировку, отметить вход, подобрать
прежние брони, зафиксировать согласия. Разошедшиеся ветки входа означали бы, что часть аккаунтов
создаётся без согласий или без истории.

**Обратный звонок.** `FlashCallProviderInterface` с драйверами `manual` (код фиксирован, звонок не
совершается) и `voicepassword`. Код задаёт поставщик — это последние цифры звонившего номера, — а
у нас он существует только внутри одного метода и уходит в базу хешем. Новый звонок гасит прежние
коды: два действующих кода одновременно расширяют окно перебора.

**OAuth.** `SocialAuthProviderInterface`, реализации VK ID и Яндекс, плюс драйвер `fake` для
разработки. Поток — Authorization Code + PKCE, `state` живёт в сессии между переходом и колбэком.
Различия провайдеров (`device_id` у VK, схема `OAuth` вместо `Bearer` у Яндекса) не выходят за
границу интеграции: наружу отдаётся `SocialUserData`.

**Объединение аккаунтов (ТЗ §11.4).** Единственный ключ — подтверждённый телефон. Порядок поиска:
привязанный социальный аккаунт → подтверждённый провайдером телефон → новый аккаунт. Email в
объединении не участвует: провайдеры не дают признака подтверждения, а ошибочное слияние отдаёт
постороннему чужие брони. Занятый номер не переносится между аккаунтами — гостю предлагается
войти по нему.

**Кабинет.** Профиль, «Мои бронирования» и избранное. Бронирования показываются из локальной
записи, а не запросом к Restoplace на каждый просмотр: актуальность обеспечивают webhook и сверка.
Избранное — обычные формы POST, без JavaScript; состояние избранного для карточек отдаёт
`GuestFavorites`, который загружает список один раз и только если о нём спросили.

### Кеш и сериализация

Laravel 13 по умолчанию запрещает десериализацию классов из кеша
(`cache.serializable_classes => false`) — защита от gadget-chain при утечке `APP_KEY`. Поэтому в
кеш кладутся **массивы**, а не DTO: у всех DTO есть пара `fromArray()` / `toArray()`.

Ослаблять эту настройку не стоит: закешированный объект вернулся бы как `__PHP_Incomplete_Class`,
и кеш молча не работал бы вовсе — ошибка, которую легко не заметить месяцами.

## Отзывы (Этап 5)

**Право на отзыв** проверяется одним кодом в трёх местах — при показе кнопки, при открытии формы
и при сохранении (`ReviewEligibilityService`). Разные ответы означали бы форму, которая
открывается и не отправляется. Условия из ТЗ §13.1: визит по броне через «РестоМесто»
состоялся, бронь не отменена, окно 90 дней не истекло, отзыва по этой броне ещё нет.

**Модерация.** Отзыв сохраняется сразу и уходит в очередь `ai` отдельной задачей: гость не должен
ждать ответа языковой модели, а её недоступность не должна превращать отправку в ошибку.
Провайдер за `ReviewModerationProviderInterface` отвечает только на вопрос «нарушает ли текст
правила»; статус назначает домен (`ApplyModerationDecisionAction`). Поэтому исходов три:

- одобрено с достаточной уверенностью — `published`;
- нарушение — `rejected`;
- ИИ недоступен, отключён или не уверен — `manual_review`.

Автоматически публиковать непроверенное нельзя: одно оскорбление на странице заведения обходится
дороже, чем задержка на разбор (ТЗ §13.3, §13.4).

**Версии.** `reviews.rating` и `reviews.body` хранят опубликованное содержимое, правка живёт в
`review_versions` до решения модерации (ТЗ §13.5). Публичный запрос остаётся тривиальным, а текст
на странице не подменяется в обход проверки. Задача модерации идемпотентна: по разобранной версии
повтор ничего не делает.

**Рейтинг** пересчитывается целиком после каждого решения — инкремент расходится с реальностью
после первой же потерянной задачи или скрытия отзыва администратором.

**Уведомления** проходят через `NotificationDispatcher`, потому что каждое должно попасть в
`notification_logs`: иначе на вопрос «дошло ли письмо» ответа не будет. Сбой отправки не
пробрасывается — падение почтового сервера не должно откатывать опубликованный отзыв. Контактный
email заведения не показывается публично и маскируется в журнале (ТЗ §9, §24).

## Кабинет заведения (Этап 6)

**Вход** — одноразовый код на контактный email адреса, без пароля (ТЗ §15.1). Аккаунт создаётся
при первом успешном входе: право на кабинет даёт не заявка, а запись в Restoplace. Ответ на
запрос кода одинаков для любого адреса — иначе форма позволяет перебором выяснить, какие
заведения подключены.

**Права** — `restaurant_venue_accesses` не самостоятельный список, а кеш совпадения
`venues.source_email_hash` с email пользователя. Пересчитывается при входе **и при синхронизации
адреса**: сессия вошедшего живёт дольше обхода каталога, и без отзыва в момент синхронизации
бывший управляющий сохранял бы доступ (ТЗ §15.2, §21.2). Правило живёт в одном месте —
`VenuePolicy`, вызывается через `Gate::forUser()`.

**Карточка** пишется только в локальные таблицы (`venue_profiles`, `venue_contacts`, связи с
кухнями и особенностями). Поля `venues` принадлежат Restoplace, и правка их из кабинета была бы
затёрта следующей синхронизацией (ТЗ §2.1, §6.3).

**Медиа** (ТЗ §16). Файл принимается как есть и обрабатывается в очереди `images`: держать
HTTP-запрос на конвертации мегапиксельной фотографии нельзя. `ImageProcessor` работает на GD и
собирает файл заново из пикселей — поэтому EXIF, геометки и ICC-профили исходника не переносятся
в принципе. Оригинал удаляется после успешной обработки; при неудаче остаётся, иначе повтор
невозможен. Путь строится по UUID: имя файла, пришедшее из формы, в него не попадает.

## Админка и SEO (Этап 7)

**Панель `/developer`** — Filament 5 на guard `admin` (решение заказчика D11). Двухфакторная
авторизация обязательна и держится на штатном механизме Filament поверх уже существующих
зашифрованных колонок `admin_users` (ТЗ §20.1). Шрифт подключается локальным провайдером:
Filament по умолчанию тянет Inter из Google Fonts, а внешние CDN запрещены (ТЗ §10.2, §21).

Права выводятся из одного префикса в `AdminResource`, поэтому список в `AdminRole` и проверки в
панели не могут разойтись; суперадминистратор проходит через `Gate::before`.

**Журнал действий** ведёт наблюдатель модели, а не вызовы из экранов: одну и ту же запись правят
из ресурса, из массового действия и из консоли, и три места записи однажды разъедутся (ТЗ §20.3).
Отсюда требование: любая модель, способная стать объектом действия, должна быть в morph map —
иначе `getMorphClass()` уронит само действие, которое журналируется.

**Контроль очередей** — раздел `failed_jobs` с повтором через штатный `queue:retry`. Это и есть
обещанная замена Horizon, от которого отказались вместе с Redis (решение заказчика D2): нужен был
не мониторинг Redis, а ответ на «что упало» и «как повторить».

**Метаданные страниц** собирает `SeoResolver`: шаблон типа страницы плюс индивидуальное
переопределение, и заполненное переопределение всегда сильнее (ТЗ §18.3). Обратный порядок
означал бы, что выставленный вручную Title однажды молча заменится сгенерированным. Пустая строка
в переопределении считается «не задано» — администратор, стерший поле, ждёт возврата к шаблону.

**Микроразметка** выводит только то, что есть на странице: `AggregateRating` — при наличии
опубликованных отзывов, часы работы — при заполненном графике (ТЗ §18.5). Разметка, не
соответствующая видимому содержимому, — прямой путь к санкциям поисковика.

**`sitemap.xml`** строится из базы и кешируется на час. В него попадает только то, что отдаёт 200
и открыто для индексации: иначе робот тратит краулинговый бюджет на страницы, которые сам же
отбросит.

## Работа со временем

В базе всё в UTC (`APP_TIMEZONE=UTC`). У каждого адреса свой `timezone`; отображение и запросы
бронирования идут в часовом поясе конкретного заведения. Считать, что все заведения в одном поясе,
нельзя — во fixtures специально есть адрес с `Asia/Vladivostok` (ТЗ §7.3, §25.3).

Даты в приложении — `CarbonImmutable` (`Date::use()` в `AppServiceProvider`): неявная мутация
«того же» объекта времени при расчёте слотов обходится слишком дорого.

## Очереди

Имена очередей собраны в `App\Support\Queues`, приоритет — от броней к синхронизации:

```
bookings → webhooks → notifications → ai → images → sync → default
```

Разделение логическое и не привязано к драйверу. Контроль очередей — раздел админки на Этапе 7:
список задач, `failed_jobs`, ручной повтор (ТЗ §23, §24).

## Безопасность (реализовано на Этапе 1)

- `AssignRequestId` — X-Request-ID на каждый запрос, проброшен в логи и в вызовы Restoplace.
- `SecurityHeaders` — `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`,
  `Permissions-Policy`, HSTS по HTTPS. CSP вводится на Этапе 8, когда определён итоговый набор
  источников (карты, шрифты, Vite в dev).
- `ContentSecurityPolicy` (Этап 8) — `script-src` без `'unsafe-inline'`: инлайновых скриптов в
  шаблонах нет, поэтому внедрённый в разметку скрипт не выполнится. `'unsafe-eval'` остаётся —
  этого требует Alpine; подробности и цена решения в [assumptions.md](assumptions.md), T5.
- `SensitiveDataMasker` — единственная точка маскирования: токены, OTP, OAuth-токены, полные
  телефоны и платёжные ссылки не попадают ни в логи, ни в `external_api_logs`, ни в `audit_logs`.
- Контактные email заведений и ресторанных аккаунтов шифруются в базе (`encrypted` cast), поиск —
  по HMAC-hash нормализованного значения.
- Rate limiting настроен для входа гостя, входа ресторана, создания брони, отправки отзыва,
  webhook и входа администратора.
- HTML, введённый в админке, выводится через `symfony/html-sanitizer` с allowlist тегов, атрибутов
  и схем ссылок: даже аккаунт контент-менеджера не должен превращаться в XSS на публичной
  странице.

## Порядок реализации

Следуем этапам ТЗ §28. Закрыты этапы 1–7 (основание, каталог, бронирование, гостевой кабинет,
отзывы, кабинет заведения, админка и SEO); остался 8 — стабилизация.
