Тема
ТЗ — Мультигород/региональность (cms/multicity)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: gulaev-dev (
gulaev-dev/src/app/Domains/Geo/), er (er/project/src/app/Domain/Geo/) Статус: ТЗ к разработке
🔄 Ревизия (корп-MVP, волна B, 15.07.2026). Решение №3 (репозиторий полигона,
docs/corporate-mvp/01-decisions.md): URL-стратегия — путь/{city}/…сейчас, поддомен — заготовка без тестов; каноническое имя настройки —multicity.url_strategy(path|subdomain), упоминанияrouting_modeниже читать какurl_strategy. Волной B реализован срез: справочник регионов/городов со склонениями (трейт ядраHasDeclensions), живая реализация контракта ядраRegionContext(перекрывает Null-биндинг), глобальныйResolveCityMiddleware(срез префикса/{city}/до роутинга; приоритет: префикс → cookie → дефолт), кеш-измерениеcity, API cities/select, Filament-ресурсы. Вне среза (по этому ТЗ, следующие волны): overrides per-город, гео-IP (geo-provider), виджет подтверждения города, town→hub 301, sitemap-раздел городов, legacy-импорт.
Назначение и возможности
Региональность сайта: справочник городов/регионов, определение текущего города посетителя и региональная подмена контента, цен, телефонов и наличия. Города — это измерение выдачи и кеша, а не языковая локаль (см. /cms-v2/multilang).
- Справочник городов/регионов с иерархией (регион → город) и склонениями (
HasDeclensions) - Определение города: гео-IP по умолчанию + явный выбор пользователя (cookie), приоритет выбора над гео-IP
- Маршрутизация региона: поддомен (
spb.example.ru) либо префикс пути (/spb/) — выбор конфигом - Контент/цены/телефоны/наличие с переопределением per-город поверх базового значения
- Виджет «выбор города» с подтверждением при расхождении гео-IP и сохранённого выбора
city_idкак явное измерение ключа кеша ядра (см./cms-v2/phase0-performance)- Автоматическая генерация региональных ЧПУ и мета без хардкода города в шаблонах
- Список городов присутствия для sitemap и переключателя
Зависимости и выключение
requires: ядро (склонения, page-cache измерения) · suggests: cms/geoip (провайдер контракта geo-provider) · provides: city-context
Поведение при выключении: сайт работает в режиме одного (дефолтного) города — все региональные переопределения игнорируются, показывается базовый контент/цена/телефон, без ошибок и без потери данных переопределений. Гео-IP резолвится через контракт geo-provider (DI, канал 3, ревизия ядра 14.07.2026, п.9); при отсутствии реализации (0 реализаций контракта либо cms/geoip выключен) — та же деградация: город по умолчанию + ручной выбор, без 500.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_multicity_regions | id, name, parent_id | иерархия регион → город |
cms_multicity_cities | id, region_id, name, slug, declensions (json), domain, is_default, geoip_codes (json) | город, склонения, привязка к поддомену |
cms_multicity_overrides | id, city_id, overridable_type, overridable_id, field, value, lock_version | точечное переопределение поля сущности per-город |
FK constrained() + index(); declensions/geoip_codes — JSONB → GIN; индекс (overridable_type, overridable_id, city_id) под горячий путь разрешения overrides; lock_version — optimistic lock на overrides (конкурентная правка одного поля разными редакторами).
ПДн-паспорт: справочники городов/регионов и overrides — не ПДн. Выбор пользователя хранится в cookie на стороне клиента (см. «Безопасность»), сервер хранит только агрегированную статистику выбора без привязки к личности; при явном запросе «забыть меня» модуль декларирует «ПДн не храню» — cookie стирается на клиенте штатным способом браузера/согласия.
Входные и выходные данные
Входы:
| Источник | Поля | Чем валидируется |
|---|---|---|
| Публичный запрос (гео-IP резолвер) | ip → geoip_codes | резолв реализации контракта geo-provider через DI (канал 3), таймаут ≤2 с + fallback на default_city_id |
| Явный выбор города (форма/виджет, посетитель) | city_id | публичный whitelisted эндпоинт: city_id сверяется со справочником cms_multicity_cities, неизвестный id → 422 |
| Форма справочника городов (Filament) | name, slug, region_id, declensions, domain, geoip_codes | FormRequest, slug — транслитерация + уникальность в рамках региона |
| Форма переопределения (Filament) | city_id, overridable_type, overridable_id, field, value, lock_version | FormRequest, overridable_type — whitelist поддерживаемых сущностей, value — валидация по типу поля владельца |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
RequestContext ядра | разрешённый city_id текущего запроса | внутренний контракт city-context (provides) |
| Посетитель | контент/цена/телефон/наличие с учётом города | Blade-рендер через overrides-резолвер |
cms/seo-filter, cms/popups (потребители контракта) | city_id для сегментации кеша/показа | вызов city-context (канал 3) |
| Sitemap ядра | список городов присутствия | раздел sitemap по городам |
| Filament admin | список overrides по сущности | keyset-JSON через API |
| Подписчики событий | CityResolved, CityOverrideChanged | payload по таблице ниже |
Настройки (группа multicity)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
multicity.enabled | bool | true | да | Включение регионального режима |
multicity.routing_mode | string | path | да | Способ маршрутизации: path или subdomain |
multicity.default_city_id | int | 1 | да | Город по умолчанию при отсутствии определения |
multicity.geoip_enabled | bool | true | нет | Резолвить город через контракт geo-provider (при наличии реализации) |
multicity.selector_widget_enabled | bool | true | нет | Показ виджета подтверждения города |
multicity.geoip_kill_switch | bool | false | нет | Kill-switch: аварийное отключение резолва через geo-provider (моментальный fallback на default_city_id) без выключения модуля |
Лимиты и квоты внешнего гео-IP API — зона ответственности провайдера контракта (cms/geoip), не cms/multicity: модуль-потребитель контракта их не декларирует (та же логика, что у cms/session). При исчерпании лимита провайдером резолв уходит в fallback default_city_id — так же, как при geoip_kill_switch.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/multicity/cities | public | Список городов присутствия для переключателя |
| POST | /api/v1/multicity/select | public | Сохранение явного выбора города пользователем |
| GET | /api/v1/admin/multicity/overrides | admin (multicity.view) | Список переопределений по сущности (keyset-пагинация) |
| POST | /api/v1/admin/multicity/overrides | admin (multicity.manage) | Создание/правка переопределения per-город (lock_version — 409 при конфликте) |
Компоненты
Виджеты: селектор города с подтверждением гео-IP. Filament: справочник городов/регионов со склонениями, редактор переопределений полей per-город. Команды: cms:multicity:sitemap-cities --json.
Демо-контент: MulticityDemoSeeder создаёт регион, 3 города (в т.ч. is_default) со склонениями и 2–3 override-примера на демо-сущностях — галерея /_gallery показывает селектор города и подмену контента без ручного ввода.
Фронтенд-бюджет: виджет селектора — лёгкий компонент без внешних зависимостей, модальное подтверждение показывается лениво (только при расхождении гео-IP/cookie), не блокирует первый рендер (не создаёт CLS).
Эксплуатация (ранбук): метрики multicity_geoip_lookups_total, multicity_geoip_fallback_total (сколько раз ушли в дефолт), multicity_overrides_applied_total; алерт — доля fallback выше порога (провайдер деградировал).
| Симптом | Что проверить / команда |
|---|---|
| Все посетители видят один город | проверить multicity.enabled, geoip_kill_switch, статус резолва geo-provider |
| Overrides не применяются | проверить индекс (overridable_type, overridable_id, city_id) и кеш-тег multicity:overrides |
| Расхождение цены/контента между городами | сверить приоритет резолва (override → базовое значение), нет ли orphan override на удалённую сущность |
| Гео-метки перестали проставляться | статус резолва geo-provider, включён ли cms/geoip; 0 реализаций контракта — ожидаемая деградация, не баг |
Бэкап/рестор: cms_multicity_* — в обычном бэкапе БД. После рестора кеш-тег multicity:overrides инвалидируется автоматически по journal; актуальность гео-справочника (коды стран/городов провайдера) — зона ответственности cms/geoip (провайдера контракта geo-provider), не cms/multicity.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
CityResolved | определён город текущего визита | city_id, source (geoip|cookie|default) |
CityOverrideChanged | изменено региональное переопределение | city_id, overridable_type, overridable_id, field |
Предоставляет контракт city-context (разрешённый город) для cms/popups, cms/seo-filter и др. Собственных FilterBus-фильтров нет.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Гео-провайдер (cms/geoip и аналоги) | provides-контракт geo-provider (канал 3, ревизия ядра 14.07.2026, п.9) | out | резолв активной реализации через DI, синхронный запрос IP→гео с таймаутом ≤2 с + fallback default_city_id; 0 реализаций — деградация |
RequestContext ядра | provides-контракт (3) city-context | ядро/модули → multicity | все модули читают текущий city_id без парсинга URL самостоятельно |
cms/seo-filter, cms/popups (потребители) | сервис-вызов (4, requires) | потребитель → multicity | сегментация кеша/показа по city_id |
| Владелец переопределяемой сущности (каталог, страницы, ...) | сервис-вызов (внутримодульно, чтение через репозитории) | multicity → владелец | резолвер overrides читает базовое значение сущности перед наложением override |
очередь multicity | очередь (5) | multicity → воркер | генерация sitemap-раздела по городам |
Фоновая работа
Очередь multicity: генерация sitemap-раздела по городам. Гео-лукап по IP — синхронный резолв контракта geo-provider (канал 3) на горячем пути с таймаутом ≤2 с и graceful fallback (см. выше), не через очередь. Внешние вызовы модуля — только из очереди, кроме этого синхронного резолва на горячем пути.
Производительность и кеш
Ожидаемые объёмы: десятки–сотни городов, тысячи–десятки тысяч overrides на крупном каталоге (город × товар/страница). Горячий путь — резолв города (cookie-чтение, 0 запросов к БД при наличии cookie) и наложение overrides при рендере листинга: один запрос по индексу (overridable_type, overridable_id, city_id) с батч-загрузкой для листинга (whereIn, не N+1 на карточку).
Тег кеша multicity:overrides, инвалидируется CityOverrideChanged. city_id — обязательное измерение ключа page-cache ядра: без него overrides одного города протекли бы в кеш другого — контрактный тест на утечку кеша между городами обязателен.
Безопасность
Выбор города — публичный whitelisted эндпоинт с валидацией city_id по справочнику; переопределения — только FormRequest в админке; гео-IP не хранит точный IP сверх нужд геолокации (обезличивается/усекается после резолва). Права: multicity.view, multicity.manage.
Матрица ролей:
| Действие | Администратор | Менеджер | Редактор | Studio |
|---|---|---|---|---|
Просмотр overrides (view) | ✅ | ✅ | ✅ | ✅ |
Правка overrides контента (manage) | ✅ | ✅ | ✅ (без цены) | ✅ |
| Правка региональных цен | ✅ | ✅ | — | ✅ |
Справочник городов/регионов, routing_mode, geoip_kill_switch | ✅ | — | — | ✅ |
UX-требования
Админ: пустое состояние справочника городов — «Городов нет — сайт работает как одногородский» со ссылкой «Добавить город»; массовое применение override на список сущностей (например все товары категории); человеческая ошибка при попытке override несуществующего поля сущности («Поле X недоступно для переопределения у типа Y»); подтверждение перед сменой routing_mode (меняет все внутренние ссылки сайта).
Посетитель: модальное подтверждение города показывается один раз за сессию, выбор запоминается (не переспрашивает на каждой странице); при отсутствии контента для города — явный fallback на базовый контент, а не пустая страница; переключение города не сбрасывает текущую страницу/фильтры без необходимости.
Крайние случаи и типовые баги
- Город не определён (нет cookie, гео-IP недоступен) → используется
default_city_id, не редирект-цикл на страницу выбора города. - Гео-детект по IP ошибается (VPN, мобильный оператор) → ручной выбор пользователя всегда приоритетнее гео-IP и запоминается в cookie до истечения TTL или явной смены.
- Поддомен vs подпапка: смена
routing_modeна живом сайте меняет все внутренние ссылки → требует подтверждения (см. UX) и создания 301-редиректов со старой схемы URL на новую через SEO-базу ядра, не оставляет 404. - hreflang/canonical между городами — города одного языка не размечаются hreflang (это не языковые версии, а дубли по географии) — конфликт разрешается canonical на «эталонный» город; hreflang остаётся только между локалями (
/cms-v2/multilang). ⚠️ Противоречие: наивная реализация легко путает city-варианты с hreflang-вариантами — явно тестируется отсутствие hreflang между городами. - Город без контента (override не задан ни для одного поля) → fallback на базовое значение сущности, не пустой блок и не ошибка рендера.
- Гонка резолва: два параллельных запроса одного нового посетителя (первая загрузка страницы + ajax) могут получить разный
sourceдо записи cookie → резолвер идемпотентен (повторный вызов в одном запросе даёт тот жеcity_idизRequestContext, не пересчитывается дважды). - Выключение модуля посреди применения override (bulk-джоба) → батч останавливается штатно, применённые overrides остаются, повторный запуск не дублирует (upsert по
(overridable_type, overridable_id, city_id, field)). - Пустой/огромный справочник overrides: bulk-применение на категорию с 100k товаров → батчами через
Bus::batch, без загрузки всех overrides в память разом. - Противоречивые настройки:
geoip_enabled=falseиselector_widget_enabled=falseодновременно → посетитель навсегда получаетdefault_city_idбез возможности сменить город; это валидное, но потенциально нежелательное состояние — ТЗ фиксирует его как допустимое, но требует предупреждения в Filament при сохранении такой комбинации. - Конкурентное редактирование override двумя редакторами →
lock_version, конфликт — 409 с человеческим сообщением, не «последний победил». - Провайдер контракта
geo-provider(cms/geoip) лёг, исчерпал квоту либо не установлен (0 реализаций) → та же деградация:geoip_kill_switchвключается вручную либо автоматически по алерту, резолв мгновенно уходит вdefault_city_id, публичный сайт не замечает деградации (нет 500, нет задержки ≥2 с на запрос). - Кеш сегментация: страница закеширована без
city_idв ключе (баг конфигурации ядра) → контрактный тест обязан ловить утечку override одного города в ответ для другого — это критичный дефект, не «крайний случай для приёмки одним пунктом».
Донорский код
| Что взять | Путь |
|---|---|
| Домен геолокации, определение города, склонения | gulaev-dev/src/app/Domains/Geo/ |
| Домен региональности, переопределения контента | er/project/src/app/Domain/Geo/ |
Legacy-импорт: cms:multicity:import-legacy --source=<профиль> — маппинг старого справочника городов/регионов и региональных переопределений (например прежней инсталляции gulaev-dev/er) на cms_multicity_* по ключу external_id; идемпотентен, --dry-run с отчётом расхождений (новые/изменённые/пропущенные города и overrides). Прогон на копии донорских данных — часть приёмки.
Тесты и приёмка
- [ ] Контрактный тест: гео-IP и явный выбор дают корректный приоритет разрешения города
- [ ]
city_idучаствует в ключе page-cache — переопределения не утекают между городами - [ ] При выключении модуля сайт отдаёт дефолтный контент без 500 и без потери overrides
- [ ] Склонения города (
HasDeclensions) корректно применяются в текстах шаблонов - [ ] Права
multicity.view/multicity.manageразграничивают чтение и правку overrides - [ ] Нет N+1 при разрешении overrides для листинга сущностей одного типа
- [ ] hreflang не генерируется по городам (только по локалям, см.
/cms-v2/multilang) - [ ]
geoip_kill_switchмгновенно переключает резолвер наdefault_city_idбез 500 - [ ] Конкурентная правка одного override двумя редакторами отдаёт 409, не молчаливую перезапись
- [ ]
cms:multicity:import-legacy --dry-runстроит корректный отчёт расхождений на копии донора - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API; тестовая БД только
multicity_test,migrate:freshзапрещён