规范化架构、URL 设计、解析器逻辑、API 与可扩展性规范

面向多租户门户的地理发现架构。定义了规范化 URL、解析器逻辑、缓存策略以及不依赖 CMS 耦合或数据库重构的地理读模型。该设计旨在确保 SEO 稳定性、高可扩展性,并支持未来的功能扩展,例如预订和地图。
Published:
Aleksandar Stajić
Updated: 2026年2月20日 21:40
规范化架构、URL 设计、解析器逻辑、API 与可扩展性规范

Illustration

地理发现:规范架构、URL设计、解析器逻辑、API与可扩展性规范

本文档规定了跨多个门户(多租户)的基于地理的发现界面(国家/城市/半径),无需强制立即进行数据库重构,也不与CMS路由(页面/文章)耦合。它保持SEO稳定,对缓存友好,并为预订/评论/地图功能留出空间,而不会将应用变成一个臃肿的整体。

约束与非目标

  • 多租户:同一代码库服务于多个门户。租户影响内容范围、品牌,有时还影响数据源。
  • 地理发现必须支持:国家、城市、半径搜索(围绕一个点)。
  • 无需立即进行数据库重构:我们现在无法将现有表重塑为完美的地理模式。
  • 独立于CMS路由:地理页面不是“文章”或“页面”。它们不能被CMS slug冲突所阻止。
  • SEO稳定性:规范URL在过滤器/排序选项更改时不得改变。
  • 缓存友好性:CDN + 服务器缓存应具有可预测的键。避免每个用户的变体。
  • 严格关注点分离:发现、CMS和租户解析是独立的模块,具有明确的边界。
  • 非目标(目前):完美的地理编码。我们接受一个地理编码器,一种标准化策略,并存储标准化结果。

规范架构

我们将地理发现实现为其自身的有界上下文,具有较小的公共接口:(1) URL -> 解析器,(2) 解析器 -> 查询计划,(3) 查询计划 -> 数据提供者,(4) 响应 -> SEO + 缓存元数据。CMS路由从不调用发现模块。发现模块从不调用CMS路由。它们仅共享低级实用程序(HTTP、缓存、租户上下文)。

关键组件

  • TenantContext:根据主机头(或内部调用中的显式门户ID)解析租户。
  • GeoResolver:解析 + 验证地理URL段;发出标准化的GeoQuery。
  • GeoIndex(读取模型):一个单独的表/集合,映射entityId -> 纬度/经度 + 租户范围 + 最小可搜索字段。这避免了重构源数据库表。
  • 数据提供者:可插拔的源(例如,现有的SQL表、WordPress API、另一个门户服务)。它们位于接口之后。
  • SEO路由器:生成规范URL和元数据(规范、hreflang(如果需要)、robots标志)。
  • 缓存层:CDN缓存键 + 具有租户范围键和版本控制的服务器端缓存。

文件夹结构

apps/
  web/
    routes/
      discovery/                  # 发现入口点(非CMS)
    pages/
      [...cms].vue                # CMS 通配符(远离 /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规范)

我们保持规范URL纯粹是层次化的且易于人类阅读。过滤器/排序保留在查询字符串中,但不会影响规范。半径搜索使用一个稳定的“near”页面,其规范基于一个四舍五入的坐标单元,而不是原始的纬度/经度。这防止了无限的URL变体。它也防止了缓存爆炸。这是一个有意的折衷。

  • 国家着陆页:/discover/{countryCode}(示例:/discover/de)
  • 城市着陆页:/discover/{countryCode}/{citySlug}(示例:/discover/de/munich)
  • 附近(半径):/discover/{countryCode}/near/{cellId}(示例:/discover/de/near/u281z),其中cellId是一个短的地理哈希标识符
  • 可选查询参数(非规范):?q=shih-tzu&category=pet-care&sort=rating&page=2
  • 租户不在路径中。租户是主机(portal-a.tld, portal-b.tld)。内部调用传递x-tenant-id。

规范规则

  • 国家/城市页面规范化为其自身的干净路径(无查询字符串)。
  • 附近页面规范化为 /discover/{country}/near/{cellId}。规范源自一个四舍五入的单元,而不是传入的坐标。
  • page=1 从规范和内部链接生成中省略。
  • 不支持的组合(例如,国家不匹配)返回404,而不是重定向。重定向链在测试中损害了爬取预算。

解析器逻辑

解析器输入是(租户,路径名,查询)。解析器输出是一个带有查询计划的标准化的GeoQuery。解析器内部没有数据库访问。这种分离后来很重要。它使我们避免了一个棘手的缓存错误。

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);

  // /discover/{country}
  // /discover/{country}/{city}
  // /discover/{country}/near/{cellId}
  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. 边缘/CDN接收请求。缓存键包括主机 + 路径 + 有界查询参数(q, category, sort, page, pageSize, r)。
  2. 应用服务器根据主机头创建TenantContext。尚未访问数据库。
  3. GeoResolver解析URL。生成带有canonicalPath和server cacheKey的GeoQuery。
  4. GeoController构建一个QueryPlan。它根据租户配置和范围类型决定要调用哪个提供者。
  5. 提供者针对GeoIndex(快速)执行读取模型查询。然后使用实体ID从现有的源数据库/API水合结果(无需重构)。
  6. 响应组装器附加SEO元数据:规范、robots、分页链接。
  7. 服务器设置缓存头(s-maxage + stale-while-revalidate)。正文是租户范围的,从不跨租户共享。

无重构策略:GeoIndex读取模型

我们现在无法重构现有的内容表。因此,我们添加一个单独的GeoIndex,可以独立重建。它只存储足够的信息以高效地进行发现:tenantId, entityId, entityType, countryCode, citySlug, lat, lng,以及一些过滤字段。水合操作从当前源(SQL行、CMS API等)获取完整对象。

权衡:最终一致性。索引重建延迟对于发现页面是可接受的。我们设置SLA:更新在15分钟内出现。如果我们以后需要实时性(预订可用性),那是一个不同的界面,不应重用发现缓存。

API接口

两个接口:(A) 用于SEO和用户的HTML页面,(B) 用于客户端渲染和未来扩展的JSON API。相同的解析器 + 相同的查询计划。不同的呈现器。

  • 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=..
  • 管理(内部):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');

  // 我们通过映射查询 -> 伪路径来重用相同的解析器。
  // 这保持了HTML和API之间的逻辑一致。
  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');
  // 缓存:在CDN公开,租户特定的键已在上游处理
  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');

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

可扩展性与性能

  • 主要性能目标:从CDN为流行的地理着陆页(国家/城市)提供缓存的发现HTML。
  • 附近页面可缓存,但有更多变体(cellId + r + q + category + sort + page)。我们限制r、pageSize,并严格验证过滤器。
  • GeoIndex查询必须快速:使用tenantId + countryCode + citySlug索引;对于附近搜索,使用单元桶(前缀匹配),然后在应用中进行距离细化。
  • 避免在大数据集上进行昂贵的SQL半正矢计算。它看起来简单。其实不然。
  • 水合操作按entityId列表批量进行。每个entityType一个查询,而不是N+1。

半径搜索策略(附近页面)

我们不对所有行进行完整的半径扫描。而是:(1) cellId映射到一个边界桶(类似地理哈希的前缀),(2) 通过桶前缀从GeoIndex获取候选,(3) 在应用中使用半正矢距离进行细化,(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。服务器缓存存储提供者结果,键为GeoQuery.cacheKey。该键包括租户、范围和有界过滤器。并非所有内容都应被缓存。以后的预订可用性将被排除在外。

  • CDN缓存键随主机头变化。这是租户边界。
  • 服务器缓存键明确包括tenantId。永远不要依赖进程内的隐式主机。
  • 缓存TTL:国家/城市页面在CDN为30–60分钟(启用stale-while-revalidate)。附近页面为5分钟。
  • 我们为每个租户保留一个手动清除杠杆(缓存键中的版本后缀)。在迁移和错误部署期间使用。

SEO稳定性细节

  • 规范标签:始终指向干净的层次化路径(无查询字符串)。
  • Robots:如果存在查询参数且不在白名单中(q/category/sort/page/pageSize/r),则设置noindex。这阻止来自外部链接的垃圾参数。
  • 分页:仅当页面 > 1 且 resultCount > pageSize 时生成 rel=next/prev。
  • 稳定的内部链接:UI链接始终发出规范路径;查询字符串仅用于用户选择的过滤器。

未来扩展(无臃肿)

我们通过添加提供者和呈现器来扩展,而不是通过将功能塞入解析器。预订、评论和地图挂载在实体页面或专用API上。发现仍然是一个列表和过滤界面。该边界在代码审查中强制执行。

  • 预订:单独的 /api/booking 端点。发现仅显示缓存的且非个性化的可用性徽章。
  • 评论:单独的评论服务/提供者。发现从GeoIndex(预计算)读取聚合的评分字段。
  • 地图:地图瓦片和标记从 /api/discovery/markers 获取,具有积极的缓存;如果破坏TTFB,则不嵌入到HTML响应中。

一个出错的地方(以及我们改变了什么)

我们发布了第一个版本,其服务器缓存键没有包含tenantId。它在本地“工作”。在暂存环境中看起来很好。然后到了生产环境。门户A开始在城市的页面上显示门户B的列表。相同的路径,不同的租户。缓存冲突。很糟糕。

修复很无聊但很严格:tenantId在GeoQuery中变为强制性的,并且缓存键构建移入解析器,因此无法跳过。我们还添加了一个运行时断言:如果水合的实体包含不同的tenantId,我们抛出异常并跳过缓存写入。这是故意制造噪音的。

查询计划与提供者契约

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是一个基于(租户配置 + 范围类型)的小型开关。示例:一些租户仅使用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而牺牲了完美的精度:附近页面通过cellId规范化。两个相距300米的用户可以登陆到同一个规范页面。可以接受。
  • 我们为了可缓存性而牺牲了新鲜度:发现页面不是实时的。索引更新可能延迟。
  • 我们现在避免了数据库重构,但我们需要支付额外的写入路径来维护GeoIndex。
  • 我们避免了与CMS路由的耦合,但我们现在拥有一个单独的路由命名空间(/discover)。这是有意的。这也是我们必须遵守的承诺。
  • 我们在应用代码中进行附近搜索的距离细化,以避免昂贵的数据库计算。这将CPU转移到应用层,更容易水平扩展。

推出计划(实用)

  1. 在功能标志后发布解析器 + 返回404的空页面。确认路由不与CMS通配符冲突。
  2. 为一个租户构建GeoIndex回填作业。验证计数与源数据。预期不匹配;记录它们。
  3. 仅启用国家页面。观察缓存命中率和爬取统计。
  4. 接下来启用城市页面。然后最后启用附近页面(它们是变体生成器)。
  5. 在我们看到稳定的缓存键后,添加JSON API消费者(地图标记、客户端过滤)。

验收清单

  • 多租户隔离:两个主机上的相同路径从不共享服务器缓存条目。
  • 规范URL不包括查询参数,并且在过滤器更改时保持稳定。
  • CMS slug冲突不影响 /discover 路由。
  • 附近页面不会生成无限的URL排列(有界的r、pageSize)。
  • 提供者契约返回确定性的分页和总计数。
  • GeoIndex重建可以在没有停机的情况下运行,并且不会长时间锁定源表。