Каноническая архитектура, Дизайн URL, Логика резолвера, Спецификация API и масштабируемости

Геоориентированная архитектура обнаружения для мультитенантных порталов. Определяет канонические URL-адреса, логику разрешения, стратегию кэширования и гео-модель чтения без привязки к CMS или рефакторинга базы данных. Разработано для стабильности SEO, масштабируемости и будущих расширений, таких как бронирование и карты.
Published:
Aleksandar Stajić
Updated: 20 февраля 2026 г. в 21:40
Каноническая архитектура, Дизайн URL, Логика резолвера, Спецификация API и масштабируемости

Illustration

Geo Discovery: каноническая архитектура, дизайн URL, логика резолвера, API и спецификация масштабируемости

Этот документ описывает поверхность географического поиска (страна/город/радиус) для нескольких порталов (multi-tenant), без немедленного рефакторинга базы данных и без жёсткой связи с маршрутизацией CMS (страница/пост). Он сохраняет стабильность SEO, остаётся дружественным к кэшу и оставляет пространство для бронирований, отзывов и карт, не превращая приложение в монолит.

Ограничения и не-цели

  • Multi-tenant: одна и та же кодовая база обслуживает несколько порталов. Tenant влияет на область контента, брендинг и иногда на источник данных.
  • Географический поиск должен поддерживать: страну, город и поиск по радиусу (вокруг точки).
  • Без немедленного рефакторинга базы данных: мы не можем прямо сейчас перестроить существующие таблицы в идеальную geo-схему.
  • Независимость от маршрутизации CMS: geo-страницы не являются «постами» или «страницами». Они не должны блокироваться конфликтами slug CMS.
  • Стабильность SEO: канонические URL не должны меняться при изменении фильтров или сортировки.
  • Дружественность к кэшу: CDN и серверный кэш должны иметь предсказуемые ключи. Избегать вариаций на пользователя.
  • Строгое разделение ответственности: discovery, CMS и резолвинг tenant — это отдельные модули с чёткими границами.
  • Не-цель (пока): идеальное геокодирование. Мы принимаем один геокодер, одну стратегию нормализации и сохраняем нормализованный результат.

Каноническая архитектура

Мы реализуем географический discovery как отдельный bounded context с минимальной публичной поверхностью: (1) URL → Resolver, (2) Resolver → Query Plan, (3) Query Plan → Data Providers, (4) Response → SEO и метаданные кэша. Маршруты CMS никогда не вызывают discovery. Discovery никогда не вызывает маршрутизацию CMS. Они разделяют только низкоуровневые утилиты (HTTP, кэш, tenant-контекст).

Ключевые компоненты

  • TenantContext: определяет tenant по заголовку Host (или по явному идентификатору портала во внутренних вызовах).
  • GeoResolver: парсит и валидирует geo-сегменты URL; формирует нормализованный GeoQuery.
  • GeoIndex (Read Model): отдельная таблица/коллекция, которая сопоставляет entityId → lat/lng + tenant scope + минимальные поля для поиска. Это позволяет избежать рефакторинга исходных таблиц БД.
  • Data Providers: подключаемые источники (например, существующие SQL-таблицы, WordPress API, сервис другого портала), скрытые за интерфейсом.
  • SEO Router: формирует канонический URL и метаданные (canonical, hreflang при необходимости, robots-флаги).
  • Слой кэширования: ключи CDN-кэша и серверного кэша с tenant-скоупом и версионированием.

Структура каталогов

apps/
  web/
    routes/
      discovery/                  # discovery entry points (not CMS)
    pages/
      [...cms].vue                # CMS catch-all (kept away from /discover)
  server/
    src/
      tenant/
        TenantContext.ts
        tenantConfig.ts
      discovery/
        geo/
          GeoResolver.ts
          GeoQuery.ts
          GeoCanonical.ts
          GeoController.ts
          providers/
            GeoProvider.ts
            SqlGeoProvider.ts
            RemoteGeoProvider.ts
          index/
            GeoIndexRepository.ts
            migrations/
      cache/
        Cache.ts
        cacheKeys.ts
      http/
        errors.ts
        requestContext.ts
packages/
  shared/
    src/
      geo/
        normalize.ts
        haversine.ts
      validation/
        zod.ts

Дизайн URL (SEO Canonical)

Мы сохраняем канонический URL строго иерархическим и человекочитаемым. Фильтры и сортировка остаются в querystring, но НЕ влияют на canonical. Поиск по радиусу использует стабильную страницу «near» с canonical, основанным на округлённой координатной ячейке, а не на сырых lat/lng. Это предотвращает бесконечное количество вариантов URL и взрыв кэша. Это осознанный компромисс.

  • Страница страны: /discover/{countryCode} (пример: /discover/de)
  • Страница города: /discover/{countryCode}/{citySlug} (пример: /discover/de/munich)
  • Near (радиус): /discover/{countryCode}/near/{cellId} (пример: /discover/de/near/u281z), где cellId — короткий идентификатор в стиле geohash
  • Необязательные параметры запроса (не канонические): ?q=shih-tzu&category=pet-care&sort=rating&page=2
  • Tenant НЕ включён в путь. Tenant определяется хостом (portal-a.tld, portal-b.tld). Внутренние вызовы передают x-tenant-id.

Правила каноникализации

  • Страницы страны/города канонизируются на собственный чистый путь (без querystring).
  • Страницы near канонизируются на /discover/{country}/near/{cellId}. Canonical формируется на основе округлённой ячейки, а не входных координат.
  • page=1 исключается из canonical и из генерации внутренних ссылок.
  • Неподдерживаемые комбинации (например, несоответствие страны) возвращают 404, а не редирект. Цепочки редиректов ухудшали crawl budget при тестировании.

Логика резолвера

Вход резолвера — (tenant, pathname, query). Выход — нормализованный GeoQuery с query plan. Доступа к базе данных внутри резолвера нет. Это разделение оказалось критически важным позже и спасло нас от неприятной ошибки кэширования.

export type TenantId = string;

export type GeoScope =
  | { kind: 'country'; countryCode: string }
  | { kind: 'city'; countryCode: string; citySlug: string }
  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };

export type GeoFilters = {
  q?: string;
  category?: string;
  sort?: 'relevance' | 'rating' | 'distance';
  page: number;
  pageSize: number;
};

export type GeoQuery = {
  tenantId: TenantId;
  scope: GeoScope;
  filters: GeoFilters;
  canonicalPath: string;
  cacheKey: string;
};
import { z } from 'zod';
import type { GeoQuery, TenantId } from './GeoQuery';

const QuerySchema = z.object({
  q: z.string().trim().min(1).max(120).optional(),
  category: z.string().trim().min(1).max(60).optional(),
  sort: z.enum(['relevance', 'rating', 'distance']).optional(),
  page: z.coerce.number().int().min(1).max(200).default(1),
  pageSize: z.coerce.number().int().min(5).max(50).default(20)
});

const CountryCodeSchema = z.string().regex(/^[a-z]{2}$/i);
const CitySlugSchema = z.string().regex(/^[a-z0-9-]{2,80}$/i);
const CellIdSchema = z.string().regex(/^[a-z0-9]{4,12}$/i);

export function resolveGeo(
  tenantId: TenantId,
  pathname: string,
  query: Record<string, unknown>
): GeoQuery {
  const filters = QuerySchema.parse(query);
  const parts = pathname.split('/').filter(Boolean);

  if (parts[0] !== 'discover') {
    throw new Error('Not a discovery route');
  }

  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');

  let scope: GeoQuery['scope'];
  if (parts.length === 2) {
    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };
  } else if (parts[2] === 'near') {
    const cellId = CellIdSchema.parse(parts[3] ?? '');
    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));
    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };
  } else {
    const citySlug = CitySlugSchema.parse(parts[2] ?? '');
    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };
  }

  const canonicalPath = canonicalizePath(scope);
  const cacheKey = buildCacheKey(tenantId, scope, filters);

  return {
    tenantId,
    scope,
    filters: { ...filters, sort: filters.sort ?? 'relevance' },
    canonicalPath,
    cacheKey
  };
}

function canonicalizePath(scope: GeoQuery['scope']): string {
  switch (scope.kind) {
    case 'country': return `/discover/${scope.countryCode}`;
    case 'city': return `/discover/${scope.countryCode}/${scope.citySlug}`;
    case 'near': return `/discover/${scope.countryCode}/near/${scope.cellId}`;
  }
}

function buildCacheKey(
  tenantId: string,
  scope: GeoQuery['scope'],
  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }
): string {
  const q = filters.q ? `q=${filters.q}` : '';
  const c = filters.category ? `cat=${filters.category}` : '';
  const s = `sort=${filters.sort ?? 'relevance'}`;
  const p = `p=${filters.page}`;
  const ps = `ps=${filters.pageSize}`;

  const scopeKey =
    scope.kind === 'country'
      ? `country:${scope.countryCode}`
      : scope.kind === 'city'
        ? `city:${scope.countryCode}:${scope.citySlug}`
        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;

  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;
}

Пошаговый поток запроса

  1. Edge/CDN принимает запрос. Ключ кэша включает host + путь + ограниченные параметры запроса (q, category, sort, page, pageSize, r).
  2. Сервер приложения создаёт TenantContext из заголовка Host. Доступа к БД на этом этапе нет.
  3. GeoResolver парсит URL и формирует GeoQuery с canonicalPath и server cacheKey.
  4. GeoController строит QueryPlan и решает, какие provider’ы вызывать, исходя из конфигурации tenant и типа scope.
  5. Provider выполняет запрос read-model к GeoIndex (быстро), затем гидратирует результаты из существующего источника (SQL БД, CMS API) по entity ID.
  6. Сборщик ответа добавляет SEO-метаданные: canonical, robots, ссылки пагинации.
  7. Сервер устанавливает заголовки кэша (s-maxage + stale-while-revalidate). Тело ответа привязано к tenant и никогда не шарится между tenant’ами.

Стратегия без рефакторинга: Read Model GeoIndex

Мы не можем сразу перестроить существующие таблицы контента. Поэтому добавляем отдельный GeoIndex, который можно независимо пересобирать. Он хранит минимум, необходимый для эффективного discovery: tenantId, entityId, entityType, countryCode, citySlug, lat, lng и несколько полей фильтрации. Гидратация получает полный объект из текущего источника.

Компромисс: eventual consistency. Задержка пересборки индекса приемлема для discovery-страниц. SLA: обновления появляются в течение 15 минут. Если позже потребуется real-time (доступность бронирований), это будет другая поверхность и она не должна использовать discovery-кэш.

API-поверхность

Две поверхности: (A) HTML-страницы для SEO и пользователей, (B) JSON API для клиентского рендеринга и будущих расширений. Тот же resolver и тот же query plan. Разные presenters.

  • HTML: GET /discover/{country}, /discover/{country}/{city}, /discover/{country}/near/{cellId}
  • API: GET /api/discovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..
  • Admin (внутренний): POST /internal/geoindex/rebuild (защищён), POST /internal/geoindex/upsert (опционально, позже)
import type { IncomingMessage, ServerResponse } from 'http';
import { resolveGeo } from './GeoResolver';
import { runQueryPlan } from './GeoController';

export async function discoveryApi(req: IncomingMessage, res: ServerResponse) {
  const url = new URL(req.url ?? '', 'http://localhost');

  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');

  const scope = url.searchParams.get('scope') ?? 'country';
  const country = url.searchParams.get('country') ?? '';
  const city = url.searchParams.get('city');
  const cell = url.searchParams.get('cell');

  const pseudoPath =
    scope === 'city' && city
      ? `/discover/${country}/${city}`
      : scope === 'near' && cell
        ? `/discover/${country}/near/${cell}`
        : `/discover/${country}`;

  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));
  const result = await runQueryPlan(geoQuery);

  res.statusCode = 200;
  res.setHeader('content-type', 'application/json; charset=utf-8');
  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');

  res.end(JSON.stringify(result));
}

Масштабируемость и производительность

  • Основная цель по производительности: отдавать HTML discovery из CDN для популярных geo-лендингов (страна/город).
  • Страницы near кэшируемы, но имеют больше вариантов (cellId + r + q + category + sort + page). Мы ограничиваем r и pageSize и строго валидируем фильтры.
  • Запросы к GeoIndex должны быть быстрыми: индексы по tenantId + countryCode + citySlug; для near — использовать cell buckets (совпадение по префиксу), затем уточнять расстояние в приложении.
  • Избегать дорогих SQL-haversine вычислений на больших наборах данных. Это выглядит просто. Это не так.
  • Гидратация батчится по списку entityId: один запрос на entityType, а не N+1.

Стратегия поиска по радиусу (страницы near)

Мы не выполняем полный радиусный скан по всем строкам. Вместо этого: (1) cellId сопоставляется с ограничивающим bucket’ом (префикс в стиле geohash), (2) получаем кандидатов из GeoIndex по префиксу bucket’а, (3) уточняем расстояние в приложении с помощью haversine, (4) сортируем и пагинируем. Это делает нагрузку на БД предсказуемой.

export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
  const R = 6371000;
  const toRad = (d: number) => (d * Math.PI) / 180;

  const dLat = toRad(b.lat - a.lat);
  const dLng = toRad(b.lng - a.lng);

  const lat1 = toRad(a.lat);
  const lat2 = toRad(b.lat);

  const sinDLat = Math.sin(dLat / 2);
  const sinDLng = Math.sin(dLng / 2);

  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;
  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));

  return R * c;
}

Модель кэширования (CDN + сервер)

Мы кэшируем на двух уровнях. CDN кэширует полный HTML/JSON для анонимного трафика. Серверный кэш хранит результаты providers, индексированные по GeoQuery.cacheKey. Ключ включает tenant, scope и ограниченные фильтры. Кэшировать нужно не всё. Доступность бронирований будет исключена позже.

  • Ключ CDN-кэша варьируется по заголовку Host — это граница tenant.
  • Ключ серверного кэша явно включает tenantId. Никогда не полагаться на неявный host в памяти процесса.
  • TTL кэша: страна/город — 30–60 минут на CDN (stale-while-revalidate включён). Страницы near — 5 минут.
  • Мы сохраняем ручной рычаг сброса кэша по tenant (суффикс версии в ключе кэша), используемый при миграциях и неудачных деплоях.

Детали стабильности SEO

  • Canonical-теги всегда указывают на чистый иерархический путь (без querystring).
  • Robots: при наличии неразрешённых параметров запроса (q/category/sort/page/pageSize/r) устанавливается noindex для блокировки мусорных параметров из внешних ссылок.
  • Пагинация: rel=next/prev генерируется только для страниц > 1 и если resultCount > pageSize.
  • Стабильная внутренняя перелинковка: UI всегда генерирует канонические пути; querystring используется только для пользовательских фильтров.

Будущие расширения без раздувания

Мы расширяем систему, добавляя providers и presenters, а не перегружая resolver. Бронирование, отзывы и карты живут на страницах сущностей или в отдельных API. Discovery остаётся поверхностью списка и фильтрации. Эта граница строго соблюдается при code review.

  • Бронирование: отдельные endpoints /api/booking. Discovery показывает бейджи доступности только если они закэшированы и не персонализированы.
  • Отзывы: отдельный сервис/provider отзывов. Discovery читает агрегированные рейтинги из GeoIndex (предварительно вычисленные).
  • Карты: тайлы и маркеры загружаются через /api/discovery/markers с агрессивным кэшированием; не встраивать в HTML-ответ, если это ухудшает TTFB.

Одна ошибка (и что мы изменили)

Мы выпустили первую версию с серверным ключом кэша, в который НЕ входил tenantId. Локально это «работало». На staging всё выглядело нормально. Затем — продакшн: портал A начал показывать листинги портала B на городских страницах. Один и тот же путь, разные tenant’ы. Коллизия кэша. Некрасиво.

Исправление было скучным, но строгим: tenantId стал обязательным в GeoQuery, а построение ключа кэша было перенесено в resolver, чтобы его нельзя было пропустить. Мы также добавили runtime-assertion: если гидратированные сущности содержат другой tenantId, мы выбрасываем ошибку и пропускаем запись в кэш. Намеренно шумно.

Query Plan и контракт providers

import type { GeoQuery } from '../GeoQuery';

export type GeoHit = {
  entityId: string;
  entityType: 'place' | 'service' | 'listing';
  lat: number;
  lng: number;
  citySlug?: string;
  countryCode: string;
  score?: number;
  distanceMeters?: number;
};

export type GeoResult = {
  hits: GeoHit[];
  total: number;
  page: number;
  pageSize: number;
};

export interface GeoProvider {
  search(query: GeoQuery): Promise<GeoResult>;
}

QueryPlan — это простой свитч на основе конфигурации tenant и типа scope. Например: одни tenant’ы используют только SQL; другие — удалённый сервис портала. Одинаковый вход GeoQuery. Одинаковый выход GeoResult. Это делает выкатывание предсказуемым.

SQL GeoIndex: минимальная схема

CREATE TABLE geo_index (
  tenant_id     TEXT NOT NULL,
  entity_id     TEXT NOT NULL,
  entity_type   TEXT NOT NULL,
  country_code  TEXT NOT NULL,
  city_slug     TEXT,
  lat           DOUBLE PRECISION NOT NULL,
  lng           DOUBLE PRECISION NOT NULL,
  cell_prefix   TEXT NOT NULL,
  category      TEXT,
  rating_avg    DOUBLE PRECISION,
  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),
  PRIMARY KEY (tenant_id, entity_id)
);

CREATE INDEX geo_index_country_city
  ON geo_index (tenant_id, country_code, city_slug);

CREATE INDEX geo_index_cell_prefix
  ON geo_index (tenant_id, country_code, cell_prefix);

Компромиссы (явные)

  • Мы жертвуем идеальной точностью ради стабильных URL: страницы near канонизируются по cellId. Два пользователя на расстоянии 300 м могут попасть на одну каноническую страницу. Допустимо.
  • Мы жертвуем свежестью ради кэшируемости: discovery-страницы не real-time. Обновления индекса могут задерживаться.
  • Мы избегаем рефакторинга БД сейчас, но платим дополнительным путём записи для поддержки GeoIndex.
  • Мы избегаем жёсткой связи с маршрутизацией CMS, но теперь владеем отдельным namespace маршрутов (/discover). Это осознанно и это обещание, которое нужно соблюдать.
  • Мы уточняем расстояние в коде приложения для near-поиска, чтобы избежать дорогой математики в БД, перенося нагрузку CPU на слой приложения, который проще масштабировать горизонтально.

План внедрения (практический)

  1. Задеплоить resolver и пустые страницы, возвращающие 404, под feature flag. Убедиться, что маршрутизация не конфликтует с CMS catch-all.
  2. Построить job для backfill GeoIndex для одного tenant. Сверить количества с исходными данными. Ожидать расхождения и логировать их.
  3. Включить только страницы стран. Наблюдать за cache hit ratio и crawl-статистикой.
  4. Затем включить страницы городов. Страницы near — последними (они генерируют варианты).
  5. После стабилизации ключей кэша добавить потребителей JSON API (маркеры карт, клиентская фильтрация).

Чек-лист приёмки

  • Мульти-tenant изоляция: один и тот же путь на двух хостах никогда не разделяет серверный кэш.
  • Канонические URL не содержат query-параметров и остаются стабильными при изменении фильтров.
  • Конфликты CMS slug не влияют на маршруты /discover.
  • Страницы near не генерируют неограниченных перестановок URL (ограничены r и pageSize).
  • Контракт provider’а возвращает детерминированную пагинацию и согласованные total.
  • Пересборка GeoIndex может выполняться без downtime и без длительной блокировки исходных таблиц.