Skip to content

ТЗ — Мультигород/региональность (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_regionsid, name, parent_idиерархия регион → город
cms_multicity_citiesid, region_id, name, slug, declensions (json), domain, is_default, geoip_codes (json)город, склонения, привязка к поддомену
cms_multicity_overridesid, 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_codesFormRequest, slug — транслитерация + уникальность в рамках региона
Форма переопределения (Filament)city_id, overridable_type, overridable_id, field, value, lock_versionFormRequest, 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, CityOverrideChangedpayload по таблице ниже

Настройки (группа multicity)

КлючТипДефолтaffectsPageCacheОписание
multicity.enabledbooltrueдаВключение регионального режима
multicity.routing_modestringpathдаСпособ маршрутизации: path или subdomain
multicity.default_city_idint1даГород по умолчанию при отсутствии определения
multicity.geoip_enabledbooltrueнетРезолвить город через контракт geo-provider (при наличии реализации)
multicity.selector_widget_enabledbooltrueнетПоказ виджета подтверждения города
multicity.geoip_kill_switchboolfalseнет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/citiespublicСписок городов присутствия для переключателя
POST/api/v1/multicity/selectpublicСохранение явного выбора города пользователем
GET/api/v1/admin/multicity/overridesadmin (multicity.view)Список переопределений по сущности (keyset-пагинация)
POST/api/v1/admin/multicity/overridesadmin (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 на базовый контент, а не пустая страница; переключение города не сбрасывает текущую страницу/фильтры без необходимости.

Крайние случаи и типовые баги

  1. Город не определён (нет cookie, гео-IP недоступен) → используется default_city_id, не редирект-цикл на страницу выбора города.
  2. Гео-детект по IP ошибается (VPN, мобильный оператор) → ручной выбор пользователя всегда приоритетнее гео-IP и запоминается в cookie до истечения TTL или явной смены.
  3. Поддомен vs подпапка: смена routing_mode на живом сайте меняет все внутренние ссылки → требует подтверждения (см. UX) и создания 301-редиректов со старой схемы URL на новую через SEO-базу ядра, не оставляет 404.
  4. hreflang/canonical между городами — города одного языка не размечаются hreflang (это не языковые версии, а дубли по географии) — конфликт разрешается canonical на «эталонный» город; hreflang остаётся только между локалями (/cms-v2/multilang). ⚠️ Противоречие: наивная реализация легко путает city-варианты с hreflang-вариантами — явно тестируется отсутствие hreflang между городами.
  5. Город без контента (override не задан ни для одного поля) → fallback на базовое значение сущности, не пустой блок и не ошибка рендера.
  6. Гонка резолва: два параллельных запроса одного нового посетителя (первая загрузка страницы + ajax) могут получить разный source до записи cookie → резолвер идемпотентен (повторный вызов в одном запросе даёт тот же city_id из RequestContext, не пересчитывается дважды).
  7. Выключение модуля посреди применения override (bulk-джоба) → батч останавливается штатно, применённые overrides остаются, повторный запуск не дублирует (upsert по (overridable_type, overridable_id, city_id, field)).
  8. Пустой/огромный справочник overrides: bulk-применение на категорию с 100k товаров → батчами через Bus::batch, без загрузки всех overrides в память разом.
  9. Противоречивые настройки: geoip_enabled=false и selector_widget_enabled=false одновременно → посетитель навсегда получает default_city_id без возможности сменить город; это валидное, но потенциально нежелательное состояние — ТЗ фиксирует его как допустимое, но требует предупреждения в Filament при сохранении такой комбинации.
  10. Конкурентное редактирование override двумя редакторами → lock_version, конфликт — 409 с человеческим сообщением, не «последний победил».
  11. Провайдер контракта geo-provider (cms/geoip) лёг, исчерпал квоту либо не установлен (0 реализаций) → та же деградация: geoip_kill_switch включается вручную либо автоматически по алерту, резолв мгновенно уходит в default_city_id, публичный сайт не замечает деградации (нет 500, нет задержки ≥2 с на запрос).
  12. Кеш сегментация: страница закеширована без 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 запрещён

Внутренняя база знаний студии