[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"portal-settings:stajic:zh":3,"public-menus:all":38,"post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:zh":221,"related:post:canonical-architecture-url-design-resolver-logic-api-scalability-specification:zh:1":929},{"statusCode":4,"data":5,"message":37},200,{"tenantId":6,"lang":7,"defaultLang":8,"siteUrl":9,"contactEmail":10,"brandName":11,"logoUrl":12,"siteName":11,"siteDescription":13,"ogImage":10,"robotsIndex":14,"socialLinks":10,"reservedSlugs":10,"seoPolicy":15},"stajic","zh","de","https:\u002F\u002Fstajic.de",null,"Stajic Platform","\u002FLogo_Planet.svg","Stajic Portal",true,{"branding":16,"relatedContent":17,"crossDomainLinks":18},{"logoUrl":12},{"enabled":14},[19,22,25,28,31,34],{"url":20,"label":21,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Ffigure.rocks","figure.rocks",{"url":23,"label":24,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Floving.rocks","loving.rocks",{"url":26,"label":27,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.com","bazify.com",{"url":29,"label":30,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.de","bazify.de",{"url":32,"label":33,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.at","bazify.at",{"url":35,"label":36,"isActive":14,"showInFooter":14,"includeInSameAs":14},"https:\u002F\u002Fbazify.ba","bazify.ba","Portal settings resolved",[39,45],{"id":40,"name":41,"location":42,"isActive":14,"isDefault":43,"items":44},1,"main-navigation","header",false,[],{"id":46,"name":47,"location":48,"isActive":14,"isDefault":14,"items":49},4,"main-menu","sidebar",[50,66,79,93,103,118,133],{"id":51,"title":52,"url":60,"target":61,"icon":62,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":64,"portfolioId":10,"children":65},"item-18",{"de":53,"en":54,"es":55,"fr":56,"it":54,"ru":57,"sr":58,"zh":59},"Startseite","Home","Inicio","Accueil","Главная","Почетна","首页","\u002Ffull-stack-web-developer-munich-performance-seo-and-maintainable-builds","_self","i-lucide-home","page",111,[],{"id":67,"title":68,"url":75,"target":61,"icon":76,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":77,"portfolioId":10,"children":78},"item-22",{"de":69,"en":69,"es":70,"fr":69,"it":71,"ru":72,"sr":73,"zh":74},"Vision","Visión","Visione","Видение","Визија","想象","\u002Fueber-uns-webdesign-muenchen-webaplikation","i-lucide-eye",113,[],{"id":80,"title":81,"url":89,"target":61,"icon":90,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":91,"portfolioId":10,"children":92},"item-19",{"de":82,"en":83,"es":84,"fr":83,"it":85,"ru":86,"sr":87,"zh":88},"Leistungen","Services","Servicios","Servizi","Услуги","Услуге","服务","\u002Fservices-dienstleistungen-muenchen","i-lucide-wrench",116,[],{"id":94,"title":95,"url":99,"target":61,"icon":100,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":101,"portfolioId":10,"children":102},"item-23",{"de":96,"en":96,"es":96,"fr":96,"it":96,"ru":97,"sr":97,"zh":98},"Blog","Блог","博客","\u002Fblog","i-lucide-book-open",112,[],{"id":104,"title":105,"url":114,"target":61,"icon":115,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":116,"portfolioId":10,"children":117},"item-32",{"de":106,"en":107,"es":108,"fr":109,"it":110,"ru":111,"sr":112,"zh":113},"Neue Technologien","New Technologies","Nuevas tecnologías","Nouvelles technologies","Nuove tecnologie","Новые технологии","Нове технологије","新技术！","\u002Fneue-webtechnologien","i-lucide-sparkles",122,[],{"id":119,"title":120,"url":129,"target":61,"icon":130,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":131,"portfolioId":10,"children":132},"item-20",{"de":121,"en":122,"es":123,"fr":124,"it":125,"ru":126,"sr":127,"zh":128},"Kontakt","Contact us!","Contacto","Contact","Contatto","Контакт","Контактирајте нас","联系我们！","\u002Fcontact","i-lucide-mail",115,[],{"id":134,"title":135,"url":144,"target":61,"icon":145,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":146,"portfolioId":10,"children":147},"item-21",{"de":136,"en":137,"es":138,"fr":139,"it":140,"ru":141,"sr":142,"zh":143},"Unsere Arbeit","Our Work","Nuestro trabajo","Nos réalisations","I nostri lavori","Наши работы","Наши радови","文件夹","\u002Fportfolio","i-lucide-briefcase",114,[148,161,175,191,206],{"id":149,"title":150,"url":144,"target":61,"icon":159,"isActive":14,"type":63,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":146,"portfolioId":10,"children":160},"item-24",{"de":151,"en":152,"es":153,"fr":154,"it":155,"ru":156,"sr":157,"zh":158},"Alle Projekte","All Projects","Todos los proyectos","Tous les projets","Tutti i progetti","Все проекты","Сви пројекти","所有项目","i-lucide-grid-3x3",[],{"id":162,"title":163,"url":171,"target":61,"icon":172,"isActive":14,"type":173,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":10,"children":174},"item-29",{"de":164,"en":165,"es":166,"fr":167,"it":168,"ru":169,"sr":170,"zh":143},"Local Roots, Global Reach","Local Roots - Global Reach","Empresa local ","Entreprise locale","Azienda locale","Местная компания","Локално предузеће глобално тржиште","\u002Fportfolio\u002Flocal-roots-global-reach-communication-media-systems-for-modern-business","i-lucide-globe-2","custom",[],{"id":176,"title":177,"url":186,"target":61,"icon":187,"isActive":14,"type":188,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":189,"children":190},"menu-2916d060-8681-482a-af9a-c107a8bc30d8",{"de":178,"en":179,"es":180,"fr":181,"it":182,"ru":183,"sr":184,"zh":185},"Von Rohdaten zu verifizierbarem KI-Kontext: Eine praktische Architektur für agentenbereite Informationen","From Raw Data to Verifiable AI Context:","De datos sin procesar a contexto de IA verificable: una arquitectura práctica para información lista para agentes","Des données brutes au contexte d'IA vérifiable : une architecture pratique pour des informations prêtes pour les agents","Dai dati grezzi a un contesto IA verificabile: Un'architettura pratica per informazioni pronte per gli agenti","От необработанных данных к верифицируемому контексту ИИ: практическая архитектура информации, готовой для агентов","Od sirovih podataka do proverljivog AI konteksta: Praktična arhitektura za informacije spremne za agente","从原始数据到可验证的 AI 上下文：面向智能体就绪信息的实用架构","\u002Fportfolio\u002Ffrom-raw-data-to-verifiable-ai-context-a-practical-architecture-for-agent-ready-information","i-lucide-database","portfolio",22,[],{"id":192,"title":193,"url":202,"target":61,"icon":203,"isActive":14,"type":188,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":204,"children":205},"menu-e2061cd7-ff53-49d5-a0e8-3d49090577c0",{"de":194,"en":195,"es":196,"fr":197,"it":198,"ru":199,"sr":200,"zh":201},"Präsenzplattformen — Unternehmenskommunikation","Presence Platforms — Enterprise Communication","Plataformas de presencia — Comunicación empresarial","Plateformes de présence — Communication d'entreprise","Presence Platforms — Comunicazione aziendale","Платформы присутствия — Корпоративные коммуникации","Presence platforme — Poslovna komunikacija","Presence 平台 — 企业通信","\u002Fportfolio\u002Fpresence-platforms-enterprise-communication-that-wins-without-competing","i-lucide-badge-percent",21,[],{"id":207,"title":208,"url":217,"target":61,"icon":218,"isActive":14,"type":188,"productId":10,"categoryId":10,"shopCategoryId":10,"articleId":10,"pageId":10,"portfolioId":219,"children":220},"menu-a781582e-e90d-418e-b8c9-b070bc37c37e",{"de":209,"en":210,"es":211,"fr":212,"it":213,"ru":214,"sr":215,"zh":216},"Maximierung des POS-ROI durch Konsistenz","Maximizing POS ROI Through Consistent","Maximizar el ROI del POS a través de constante","Maximiser le ROI du PDV grâce à la cohérence","Massimizzare il ROI del POS attraverso costante","Максимизация ROI POS за счет последовательного","Maksimizacija POS ROI-ja kroz dosledno","通过持续最大化 POS 投资回报率","\u002Fportfolio\u002Ffrom-vision-to-value-maximizing-pos-roi-through-consistent-ci-creation-and-strategic-branding","i-lucide-briefcase-business",11,[],{"statusCode":4,"data":222,"message":928},{"id":223,"title":224,"slug":225,"content":226,"contentJson":227,"excerpt":526,"featuredImage":527,"featuredImageAlt":528,"featuredImageCaption":10,"featuredImageTitle":10,"featuredImageCopyright":10,"featuredImageAuthor":10,"featuredImageSourceUrl":10,"featuredImageLicense":10,"featuredImageIsAiGenerated":43,"status":529,"publishedAt":530,"createdAt":531,"updatedAt":532,"seoLocalePaths":533,"categories":542,"author":563,"translations":568},"383","规范化架构、URL 设计、解析器逻辑、API 与可扩展性规范","canonical-architecture-url-design-resolver-logic-api-scalability-specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"地理发现：规范架构、URL设计、解析器逻辑、API与可扩展性规范\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"本文档规定了跨多个门户（多租户）的基于地理的发现界面（国家\u002F城市\u002F半径），无需强制立即进行数据库重构，也不与CMS路由（页面\u002F文章）耦合。它保持SEO稳定，对缓存友好，并为预订\u002F评论\u002F地图功能留出空间，而不会将应用变成一个臃肿的整体。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"约束与非目标\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"多租户：同一代码库服务于多个门户。租户影响内容范围、品牌，有时还影响数据源。\",\"地理发现必须支持：国家、城市、半径搜索（围绕一个点）。\",\"无需立即进行数据库重构：我们现在无法将现有表重塑为完美的地理模式。\",\"独立于CMS路由：地理页面不是“文章”或“页面”。它们不能被CMS slug冲突所阻止。\",\"SEO稳定性：规范URL在过滤器\u002F排序选项更改时不得改变。\",\"缓存友好性：CDN + 服务器缓存应具有可预测的键。避免每个用户的变体。\",\"严格关注点分离：发现、CMS和租户解析是独立的模块，具有明确的边界。\",\"非目标（目前）：完美的地理编码。我们接受一个地理编码器，一种标准化策略，并存储标准化结果。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"规范架构\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"我们将地理发现实现为其自身的有界上下文，具有较小的公共接口：(1) URL -> 解析器，(2) 解析器 -> 查询计划，(3) 查询计划 -> 数据提供者，(4) 响应 -> SEO + 缓存元数据。CMS路由从不调用发现模块。发现模块从不调用CMS路由。它们仅共享低级实用程序（HTTP、缓存、租户上下文）。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"关键组件\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"TenantContext：根据主机头（或内部调用中的显式门户ID）解析租户。\",\"GeoResolver：解析 + 验证地理URL段；发出标准化的GeoQuery。\",\"GeoIndex（读取模型）：一个单独的表\u002F集合，映射entityId -> 纬度\u002F经度 + 租户范围 + 最小可搜索字段。这避免了重构源数据库表。\",\"数据提供者：可插拔的源（例如，现有的SQL表、WordPress API、另一个门户服务）。它们位于接口之后。\",\"SEO路由器：生成规范URL和元数据（规范、hreflang（如果需要）、robots标志）。\",\"缓存层：CDN缓存键 + 具有租户范围键和版本控制的服务器端缓存。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"文件夹结构\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-folder\",\"data\":{\"code\":\"apps\u002F\\n  web\u002F\\n    routes\u002F\\n      discovery\u002F                  # 发现入口点（非CMS）\\n    pages\u002F\\n      [...cms].vue                # CMS 通配符（远离 \u002Fdiscover）\\n  server\u002F\\n    src\u002F\\n      tenant\u002F\\n        TenantContext.ts\\n        tenantConfig.ts\\n      discovery\u002F\\n        geo\u002F\\n          GeoResolver.ts\\n          GeoQuery.ts\\n          GeoCanonical.ts\\n          GeoController.ts\\n          providers\u002F\\n            GeoProvider.ts\\n            SqlGeoProvider.ts\\n            RemoteGeoProvider.ts\\n          index\u002F\\n            GeoIndexRepository.ts\\n            migrations\u002F\\n      cache\u002F\\n        Cache.ts\\n        cacheKeys.ts\\n      http\u002F\\n        errors.ts\\n        requestContext.ts\\npackages\u002F\\n  shared\u002F\\n    src\u002F\\n      geo\u002F\\n        normalize.ts\\n        haversine.ts\\n      validation\u002F\\n        zod.ts\\n\",\"language\":\"text\"},\"type\":\"code\"},{\"id\":\"h-url\",\"data\":{\"text\":\"URL设计（SEO规范）\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"我们保持规范URL纯粹是层次化的且易于人类阅读。过滤器\u002F排序保留在查询字符串中，但不会影响规范。半径搜索使用一个稳定的“near”页面，其规范基于一个四舍五入的坐标单元，而不是原始的纬度\u002F经度。这防止了无限的URL变体。它也防止了缓存爆炸。这是一个有意的折衷。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"国家着陆页：\u002Fdiscover\u002F{countryCode}（示例：\u002Fdiscover\u002Fde）\",\"城市着陆页：\u002Fdiscover\u002F{countryCode}\u002F{citySlug}（示例：\u002Fdiscover\u002Fde\u002Fmunich）\",\"附近（半径）：\u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId}（示例：\u002Fdiscover\u002Fde\u002Fnear\u002Fu281z），其中cellId是一个短的地理哈希标识符\",\"可选查询参数（非规范）：?q=shih-tzu&category=pet-care&sort=rating&page=2\",\"租户不在路径中。租户是主机（portal-a.tld, portal-b.tld）。内部调用传递x-tenant-id。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"规范规则\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"国家\u002F城市页面规范化为其自身的干净路径（无查询字符串）。\",\"附近页面规范化为 \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}。规范源自一个四舍五入的单元，而不是传入的坐标。\",\"page=1 从规范和内部链接生成中省略。\",\"不支持的组合（例如，国家不匹配）返回404，而不是重定向。重定向链在测试中损害了爬取预算。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"解析器逻辑\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"解析器输入是（租户，路径名，查询）。解析器输出是一个带有查询计划的标准化的GeoQuery。解析器内部没有数据库访问。这种分离后来很重要。它使我们避免了一个棘手的缓存错误。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"code-types\",\"data\":{\"code\":\"export type TenantId = string;\\n\\nexport type GeoScope =\\n  | { kind: 'country'; countryCode: string }\\n  | { kind: 'city'; countryCode: string; citySlug: string }\\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\\n\\nexport type GeoFilters = {\\n  q?: string;\\n  category?: string;\\n  sort?: 'relevance' | 'rating' | 'distance';\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport type GeoQuery = {\\n  tenantId: TenantId;\\n  scope: GeoScope;\\n  filters: GeoFilters;\\n  canonicalPath: string;\\n  cacheKey: string;\\n};\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"code-resolver\",\"data\":{\"code\":\"import { z } from 'zod';\\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\\n\\nconst QuerySchema = z.object({\\n  q: z.string().trim().min(1).max(120).optional(),\\n  category: z.string().trim().min(1).max(60).optional(),\\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\\n  page: z.coerce.number().int().min(1).max(200).default(1),\\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\\n});\\n\\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\\n\\nexport function resolveGeo(\\n  tenantId: TenantId,\\n  pathname: string,\\n  query: Record\u003Cstring, unknown>\\n): GeoQuery {\\n  const filters = QuerySchema.parse(query);\\n  const parts = pathname.split('\u002F').filter(Boolean);\\n\\n  \u002F\u002F \u002Fdiscover\u002F{country}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\\n  if (parts[0] !== 'discover') {\\n    throw new Error('Not a discovery route');\\n  }\\n\\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\\n\\n  let scope: GeoQuery['scope'];\\n  if (parts.length === 2) {\\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\\n  } else if (parts[2] === 'near') {\\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\\n    \u002F\u002F 半径不在路径中，但有边界。\\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\\n  } else {\\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\\n  }\\n\\n  const canonicalPath = canonicalizePath(scope);\\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\\n\\n  return {\\n    tenantId,\\n    scope,\\n    filters: {\\n      ...filters,\\n      sort: filters.sort ?? 'relevance'\\n    },\\n    canonicalPath,\\n    cacheKey\\n  };\\n}\\n\\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\\n  switch (scope.kind) {\\n    case 'country':\\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\\n    case 'city':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\\n    case 'near':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\\n  }\\n}\\n\\nfunction buildCacheKey(\\n  tenantId: string,\\n  scope: GeoQuery['scope'],\\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\\n): string {\\n  \u002F\u002F 注意：规范忽略查询字符串，但缓存不忽略。\\n  \u002F\u002F 但我们保持其有界且明确。\\n  const q = filters.q ? `q=${filters.q}` : '';\\n  const c = filters.category ? `cat=${filters.category}` : '';\\n  const s = `sort=${filters.sort ?? 'relevance'}`;\\n  const p = `p=${filters.page}`;\\n  const ps = `ps=${filters.pageSize}`;\\n\\n  const scopeKey =\\n    scope.kind === 'country'\\n      ? `country:${scope.countryCode}`\\n      : scope.kind === 'city'\\n        ? `city:${scope.countryCode}:${scope.citySlug}`\\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\\n\\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-flow\",\"data\":{\"text\":\"逐步请求流程\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"边缘\u002FCDN接收请求。缓存键包括主机 + 路径 + 有界查询参数（q, category, sort, page, pageSize, r）。\",\"应用服务器根据主机头创建TenantContext。尚未访问数据库。\",\"GeoResolver解析URL。生成带有canonicalPath和server cacheKey的GeoQuery。\",\"GeoController构建一个QueryPlan。它根据租户配置和范围类型决定要调用哪个提供者。\",\"提供者针对GeoIndex（快速）执行读取模型查询。然后使用实体ID从现有的源数据库\u002FAPI水合结果（无需重构）。\",\"响应组装器附加SEO元数据：规范、robots、分页链接。\",\"服务器设置缓存头（s-maxage + stale-while-revalidate）。正文是租户范围的，从不跨租户共享。\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"无重构策略：GeoIndex读取模型\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"我们现在无法重构现有的内容表。因此，我们添加一个单独的GeoIndex，可以独立重建。它只存储足够的信息以高效地进行发现：tenantId, entityId, entityType, countryCode, citySlug, lat, lng，以及一些过滤字段。水合操作从当前源（SQL行、CMS API等）获取完整对象。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"权衡：最终一致性。索引重建延迟对于发现页面是可接受的。我们设置SLA：更新在15分钟内出现。如果我们以后需要实时性（预订可用性），那是一个不同的界面，不应重用发现缓存。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"API接口\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"两个接口：(A) 用于SEO和用户的HTML页面，(B) 用于客户端渲染和未来扩展的JSON API。相同的解析器 + 相同的查询计划。不同的呈现器。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"list-api\",\"data\":{\"items\":[\"HTML：GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\",\"API：GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..\",\"管理（内部）：POST \u002Finternal\u002Fgeoindex\u002Frebuild（受保护），POST \u002Finternal\u002Fgeoindex\u002Fupsert（可选，以后）\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"code-api-handler\",\"data\":{\"code\":\"import type { IncomingMessage, ServerResponse } from 'http';\\nimport { resolveGeo } from '.\u002FGeoResolver';\\nimport { runQueryPlan } from '.\u002FGeoController';\\n\\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\\n\\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\\n\\n  \u002F\u002F 我们通过映射查询 -> 伪路径来重用相同的解析器。\\n  \u002F\u002F 这保持了HTML和API之间的逻辑一致。\\n  const scope = url.searchParams.get('scope') ?? 'country';\\n  const country = url.searchParams.get('country') ?? '';\\n  const city = url.searchParams.get('city');\\n  const cell = url.searchParams.get('cell');\\n\\n  const pseudoPath =\\n    scope === 'city' && city\\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\\n      : scope === 'near' && cell\\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\\n        : `\u002Fdiscover\u002F${country}`;\\n\\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\\n  const result = await runQueryPlan(geoQuery);\\n\\n  res.statusCode = 200;\\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\\n  \u002F\u002F 缓存：在CDN公开，租户特定的键已在上游处理\\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\\n\\n  res.end(JSON.stringify(result));\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-scalability\",\"data\":{\"text\":\"可扩展性与性能\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"主要性能目标：从CDN为流行的地理着陆页（国家\u002F城市）提供缓存的发现HTML。\",\"附近页面可缓存，但有更多变体（cellId + r + q + category + sort + page）。我们限制r、pageSize，并严格验证过滤器。\",\"GeoIndex查询必须快速：使用tenantId + countryCode + citySlug索引；对于附近搜索，使用单元桶（前缀匹配），然后在应用中进行距离细化。\",\"避免在大数据集上进行昂贵的SQL半正矢计算。它看起来简单。其实不然。\",\"水合操作按entityId列表批量进行。每个entityType一个查询，而不是N+1。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"半径搜索策略（附近页面）\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"我们不对所有行进行完整的半径扫描。而是：(1) cellId映射到一个边界桶（类似地理哈希的前缀），(2) 通过桶前缀从GeoIndex获取候选，(3) 在应用中使用半正矢距离进行细化，(4) 排序 + 分页。这使数据库负载可预测。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"code-haversine\",\"data\":{\"code\":\"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\\n  const R = 6371000;\\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\\n\\n  const dLat = toRad(b.lat - a.lat);\\n  const dLng = toRad(b.lng - a.lng);\\n\\n  const lat1 = toRad(a.lat);\\n  const lat2 = toRad(b.lat);\\n\\n  const sinDLat = Math.sin(dLat \u002F 2);\\n  const sinDLng = Math.sin(dLng \u002F 2);\\n\\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\\n\\n  return R * c;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-cache\",\"data\":{\"text\":\"缓存模型（CDN + 服务器）\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"我们在两个层进行缓存。CDN为匿名流量缓存完整的HTML\u002FJSON。服务器缓存存储提供者结果，键为GeoQuery.cacheKey。该键包括租户、范围和有界过滤器。并非所有内容都应被缓存。以后的预订可用性将被排除在外。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"CDN缓存键随主机头变化。这是租户边界。\",\"服务器缓存键明确包括tenantId。永远不要依赖进程内的隐式主机。\",\"缓存TTL：国家\u002F城市页面在CDN为30–60分钟（启用stale-while-revalidate）。附近页面为5分钟。\",\"我们为每个租户保留一个手动清除杠杆（缓存键中的版本后缀）。在迁移和错误部署期间使用。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"SEO稳定性细节\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"规范标签：始终指向干净的层次化路径（无查询字符串）。\",\"Robots：如果存在查询参数且不在白名单中（q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr），则设置noindex。这阻止来自外部链接的垃圾参数。\",\"分页：仅当页面 > 1 且 resultCount > pageSize 时生成 rel=next\u002Fprev。\",\"稳定的内部链接：UI链接始终发出规范路径；查询字符串仅用于用户选择的过滤器。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"未来扩展（无臃肿）\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"我们通过添加提供者和呈现器来扩展，而不是通过将功能塞入解析器。预订、评论和地图挂载在实体页面或专用API上。发现仍然是一个列表和过滤界面。该边界在代码审查中强制执行。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"预订：单独的 \u002Fapi\u002Fbooking 端点。发现仅显示缓存的且非个性化的可用性徽章。\",\"评论：单独的评论服务\u002F提供者。发现从GeoIndex（预计算）读取聚合的评分字段。\",\"地图：地图瓦片和标记从 \u002Fapi\u002Fdiscovery\u002Fmarkers 获取，具有积极的缓存；如果破坏TTFB，则不嵌入到HTML响应中。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"一个出错的地方（以及我们改变了什么）\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"我们发布了第一个版本，其服务器缓存键没有包含tenantId。它在本地“工作”。在暂存环境中看起来很好。然后到了生产环境。门户A开始在城市的页面上显示门户B的列表。相同的路径，不同的租户。缓存冲突。很糟糕。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"修复很无聊但很严格：tenantId在GeoQuery中变为强制性的，并且缓存键构建移入解析器，因此无法跳过。我们还添加了一个运行时断言：如果水合的实体包含不同的tenantId，我们抛出异常并跳过缓存写入。这是故意制造噪音的。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"查询计划与提供者契约\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-provider\",\"data\":{\"code\":\"import type { GeoQuery } from '..\u002FGeoQuery';\\n\\nexport type GeoHit = {\\n  entityId: string;\\n  entityType: 'place' | 'service' | 'listing';\\n  lat: number;\\n  lng: number;\\n  citySlug?: string;\\n  countryCode: string;\\n  score?: number;\\n  distanceMeters?: number;\\n};\\n\\nexport type GeoResult = {\\n  hits: GeoHit[];\\n  total: number;\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport interface GeoProvider {\\n  search(query: GeoQuery): Promise\u003CGeoResult>;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"p-queryplan\",\"data\":{\"text\":\"QueryPlan是一个基于（租户配置 + 范围类型）的小型开关。示例：一些租户仅使用SQL；其他租户使用远程门户服务。相同的GeoQuery输入。相同的GeoResult输出。这使得推出可预测。\",\"level\":1},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"SQL GeoIndex：最小模式\",\"level\":3},\"type\":\"header\"},{\"id\":\"code-sql\",\"data\":{\"code\":\"CREATE TABLE geo_index (\\n  tenant_id     TEXT NOT NULL,\\n  entity_id     TEXT NOT NULL,\\n  entity_type   TEXT NOT NULL,\\n  country_code  TEXT NOT NULL,\\n  city_slug     TEXT,\\n  lat           DOUBLE PRECISION NOT NULL,\\n  lng           DOUBLE PRECISION NOT NULL,\\n  cell_prefix   TEXT NOT NULL,\\n  category      TEXT,\\n  rating_avg    DOUBLE PRECISION,\\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\\n  PRIMARY KEY (tenant_id, entity_id)\\n);\\n\\nCREATE INDEX geo_index_country_city\\n  ON geo_index (tenant_id, country_code, city_slug);\\n\\nCREATE INDEX geo_index_cell_prefix\\n  ON geo_index (tenant_id, country_code, cell_prefix);\\n\",\"language\":\"sql\"},\"type\":\"code\"},{\"id\":\"h-tradeoffs\",\"data\":{\"text\":\"权衡（明确）\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"我们为了稳定的URL而牺牲了完美的精度：附近页面通过cellId规范化。两个相距300米的用户可以登陆到同一个规范页面。可以接受。\",\"我们为了可缓存性而牺牲了新鲜度：发现页面不是实时的。索引更新可能延迟。\",\"我们现在避免了数据库重构，但我们需要支付额外的写入路径来维护GeoIndex。\",\"我们避免了与CMS路由的耦合，但我们现在拥有一个单独的路由命名空间（\u002Fdiscover）。这是有意的。这也是我们必须遵守的承诺。\",\"我们在应用代码中进行附近搜索的距离细化，以避免昂贵的数据库计算。这将CPU转移到应用层，更容易水平扩展。\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"推出计划（实用）\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"在功能标志后发布解析器 + 返回404的空页面。确认路由不与CMS通配符冲突。\",\"为一个租户构建GeoIndex回填作业。验证计数与源数据。预期不匹配；记录它们。\",\"仅启用国家页面。观察缓存命中率和爬取统计。\",\"接下来启用城市页面。然后最后启用附近页面（它们是变体生成器）。\",\"在我们看到稳定的缓存键后，添加JSON API消费者（地图标记、客户端过滤）。\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"验收清单\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"多租户隔离：两个主机上的相同路径从不共享服务器缓存条目。\",\"规范URL不包括查询参数，并且在过滤器更改时保持稳定。\",\"CMS slug冲突不影响 \u002Fdiscover 路由。\",\"附近页面不会生成无限的URL排列（有界的r、pageSize）。\",\"提供者契约返回确定性的分页和总计数。\",\"GeoIndex重建可以在没有停机的情况下运行，并且不会长时间锁定源表。\"],\"style\":\"unordered\"},\"type\":\"list\"}],\"version\":\"2.29.1\"}",{"time":228,"blocks":229,"version":525},1769827200000,[230,234,239,244,258,262,266,271,281,285,291,295,299,308,312,320,324,328,333,337,341,353,357,361,365,369,373,380,384,388,397,401,405,409,413,417,425,429,437,441,445,452,456,460,464,468,472,476,480,485,489,498,502,511,515],{"id":231,"data":232,"type":42},"h1",{"text":233,"level":40},"地理发现：规范架构、URL设计、解析器逻辑、API与可扩展性规范",{"id":235,"data":236,"type":238},"p-scope",{"text":237,"level":40},"本文档规定了跨多个门户（多租户）的基于地理的发现界面（国家\u002F城市\u002F半径），无需强制立即进行数据库重构，也不与CMS路由（页面\u002F文章）耦合。它保持SEO稳定，对缓存友好，并为预订\u002F评论\u002F地图功能留出空间，而不会将应用变成一个臃肿的整体。","paragraph",{"id":240,"data":241,"type":42},"h-goals",{"text":242,"level":243},"约束与非目标",2,{"id":245,"data":246,"type":257},"list-constraints",{"items":247,"style":256},[248,249,250,251,252,253,254,255],"多租户：同一代码库服务于多个门户。租户影响内容范围、品牌，有时还影响数据源。","地理发现必须支持：国家、城市、半径搜索（围绕一个点）。","无需立即进行数据库重构：我们现在无法将现有表重塑为完美的地理模式。","独立于CMS路由：地理页面不是“文章”或“页面”。它们不能被CMS slug冲突所阻止。","SEO稳定性：规范URL在过滤器\u002F排序选项更改时不得改变。","缓存友好性：CDN + 服务器缓存应具有可预测的键。避免每个用户的变体。","严格关注点分离：发现、CMS和租户解析是独立的模块，具有明确的边界。","非目标（目前）：完美的地理编码。我们接受一个地理编码器，一种标准化策略，并存储标准化结果。","unordered","list",{"id":259,"data":260,"type":42},"h-arch",{"text":261,"level":243},"规范架构",{"id":263,"data":264,"type":238},"p-arch",{"text":265,"level":40},"我们将地理发现实现为其自身的有界上下文，具有较小的公共接口：(1) URL -> 解析器，(2) 解析器 -> 查询计划，(3) 查询计划 -> 数据提供者，(4) 响应 -> SEO + 缓存元数据。CMS路由从不调用发现模块。发现模块从不调用CMS路由。它们仅共享低级实用程序（HTTP、缓存、租户上下文）。",{"id":267,"data":268,"type":42},"h-components",{"text":269,"level":270},"关键组件",3,{"id":272,"data":273,"type":257},"list-components",{"items":274,"style":256},[275,276,277,278,279,280],"TenantContext：根据主机头（或内部调用中的显式门户ID）解析租户。","GeoResolver：解析 + 验证地理URL段；发出标准化的GeoQuery。","GeoIndex（读取模型）：一个单独的表\u002F集合，映射entityId -> 纬度\u002F经度 + 租户范围 + 最小可搜索字段。这避免了重构源数据库表。","数据提供者：可插拔的源（例如，现有的SQL表、WordPress API、另一个门户服务）。它们位于接口之后。","SEO路由器：生成规范URL和元数据（规范、hreflang（如果需要）、robots标志）。","缓存层：CDN缓存键 + 具有租户范围键和版本控制的服务器端缓存。",{"id":282,"data":283,"type":42},"h-folder",{"text":284,"level":243},"文件夹结构",{"id":286,"data":287,"type":290},"code-folder",{"code":288,"language":289},"apps\u002F\n  web\u002F\n    routes\u002F\n      discovery\u002F                  # 发现入口点（非CMS）\n    pages\u002F\n      [...cms].vue                # CMS 通配符（远离 \u002Fdiscover）\n  server\u002F\n    src\u002F\n      tenant\u002F\n        TenantContext.ts\n        tenantConfig.ts\n      discovery\u002F\n        geo\u002F\n          GeoResolver.ts\n          GeoQuery.ts\n          GeoCanonical.ts\n          GeoController.ts\n          providers\u002F\n            GeoProvider.ts\n            SqlGeoProvider.ts\n            RemoteGeoProvider.ts\n          index\u002F\n            GeoIndexRepository.ts\n            migrations\u002F\n      cache\u002F\n        Cache.ts\n        cacheKeys.ts\n      http\u002F\n        errors.ts\n        requestContext.ts\npackages\u002F\n  shared\u002F\n    src\u002F\n      geo\u002F\n        normalize.ts\n        haversine.ts\n      validation\u002F\n        zod.ts\n","text","code",{"id":292,"data":293,"type":42},"h-url",{"text":294,"level":243},"URL设计（SEO规范）",{"id":296,"data":297,"type":238},"p-url",{"text":298,"level":40},"我们保持规范URL纯粹是层次化的且易于人类阅读。过滤器\u002F排序保留在查询字符串中，但不会影响规范。半径搜索使用一个稳定的“near”页面，其规范基于一个四舍五入的坐标单元，而不是原始的纬度\u002F经度。这防止了无限的URL变体。它也防止了缓存爆炸。这是一个有意的折衷。",{"id":300,"data":301,"type":257},"list-url",{"items":302,"style":256},[303,304,305,306,307],"国家着陆页：\u002Fdiscover\u002F{countryCode}（示例：\u002Fdiscover\u002Fde）","城市着陆页：\u002Fdiscover\u002F{countryCode}\u002F{citySlug}（示例：\u002Fdiscover\u002Fde\u002Fmunich）","附近（半径）：\u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId}（示例：\u002Fdiscover\u002Fde\u002Fnear\u002Fu281z），其中cellId是一个短的地理哈希标识符","可选查询参数（非规范）：?q=shih-tzu&category=pet-care&sort=rating&page=2","租户不在路径中。租户是主机（portal-a.tld, portal-b.tld）。内部调用传递x-tenant-id。",{"id":309,"data":310,"type":42},"h-canonical-rules",{"text":311,"level":270},"规范规则",{"id":313,"data":314,"type":257},"list-canonical",{"items":315,"style":256},[316,317,318,319],"国家\u002F城市页面规范化为其自身的干净路径（无查询字符串）。","附近页面规范化为 \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}。规范源自一个四舍五入的单元，而不是传入的坐标。","page=1 从规范和内部链接生成中省略。","不支持的组合（例如，国家不匹配）返回404，而不是重定向。重定向链在测试中损害了爬取预算。",{"id":321,"data":322,"type":42},"h-resolver",{"text":323,"level":243},"解析器逻辑",{"id":325,"data":326,"type":238},"p-resolver",{"text":327,"level":40},"解析器输入是（租户，路径名，查询）。解析器输出是一个带有查询计划的标准化的GeoQuery。解析器内部没有数据库访问。这种分离后来很重要。它使我们避免了一个棘手的缓存错误。",{"id":329,"data":330,"type":290},"code-types",{"code":331,"language":332},"export type TenantId = string;\n\nexport type GeoScope =\n  | { kind: 'country'; countryCode: string }\n  | { kind: 'city'; countryCode: string; citySlug: string }\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\n\nexport type GeoFilters = {\n  q?: string;\n  category?: string;\n  sort?: 'relevance' | 'rating' | 'distance';\n  page: number;\n  pageSize: number;\n};\n\nexport type GeoQuery = {\n  tenantId: TenantId;\n  scope: GeoScope;\n  filters: GeoFilters;\n  canonicalPath: string;\n  cacheKey: string;\n};\n","ts",{"id":334,"data":335,"type":290},"code-resolver",{"code":336,"language":332},"import { z } from 'zod';\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\n\nconst QuerySchema = z.object({\n  q: z.string().trim().min(1).max(120).optional(),\n  category: z.string().trim().min(1).max(60).optional(),\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\n  page: z.coerce.number().int().min(1).max(200).default(1),\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\n});\n\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\n\nexport function resolveGeo(\n  tenantId: TenantId,\n  pathname: string,\n  query: Record\u003Cstring, unknown>\n): GeoQuery {\n  const filters = QuerySchema.parse(query);\n  const parts = pathname.split('\u002F').filter(Boolean);\n\n  \u002F\u002F \u002Fdiscover\u002F{country}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\n  if (parts[0] !== 'discover') {\n    throw new Error('Not a discovery route');\n  }\n\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\n\n  let scope: GeoQuery['scope'];\n  if (parts.length === 2) {\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\n  } else if (parts[2] === 'near') {\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\n    \u002F\u002F 半径不在路径中，但有边界。\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\n  } else {\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\n  }\n\n  const canonicalPath = canonicalizePath(scope);\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\n\n  return {\n    tenantId,\n    scope,\n    filters: {\n      ...filters,\n      sort: filters.sort ?? 'relevance'\n    },\n    canonicalPath,\n    cacheKey\n  };\n}\n\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\n  switch (scope.kind) {\n    case 'country':\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\n    case 'city':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\n    case 'near':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\n  }\n}\n\nfunction buildCacheKey(\n  tenantId: string,\n  scope: GeoQuery['scope'],\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\n): string {\n  \u002F\u002F 注意：规范忽略查询字符串，但缓存不忽略。\n  \u002F\u002F 但我们保持其有界且明确。\n  const q = filters.q ? `q=${filters.q}` : '';\n  const c = filters.category ? `cat=${filters.category}` : '';\n  const s = `sort=${filters.sort ?? 'relevance'}`;\n  const p = `p=${filters.page}`;\n  const ps = `ps=${filters.pageSize}`;\n\n  const scopeKey =\n    scope.kind === 'country'\n      ? `country:${scope.countryCode}`\n      : scope.kind === 'city'\n        ? `city:${scope.countryCode}:${scope.citySlug}`\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\n\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\n}\n",{"id":338,"data":339,"type":42},"h-flow",{"text":340,"level":243},"逐步请求流程",{"id":342,"data":343,"type":257},"list-flow",{"items":344,"style":352},[345,346,347,348,349,350,351],"边缘\u002FCDN接收请求。缓存键包括主机 + 路径 + 有界查询参数（q, category, sort, page, pageSize, r）。","应用服务器根据主机头创建TenantContext。尚未访问数据库。","GeoResolver解析URL。生成带有canonicalPath和server cacheKey的GeoQuery。","GeoController构建一个QueryPlan。它根据租户配置和范围类型决定要调用哪个提供者。","提供者针对GeoIndex（快速）执行读取模型查询。然后使用实体ID从现有的源数据库\u002FAPI水合结果（无需重构）。","响应组装器附加SEO元数据：规范、robots、分页链接。","服务器设置缓存头（s-maxage + stale-while-revalidate）。正文是租户范围的，从不跨租户共享。","ordered",{"id":354,"data":355,"type":42},"h-read-model",{"text":356,"level":243},"无重构策略：GeoIndex读取模型",{"id":358,"data":359,"type":238},"p-read-model",{"text":360,"level":40},"我们现在无法重构现有的内容表。因此，我们添加一个单独的GeoIndex，可以独立重建。它只存储足够的信息以高效地进行发现：tenantId, entityId, entityType, countryCode, citySlug, lat, lng，以及一些过滤字段。水合操作从当前源（SQL行、CMS API等）获取完整对象。",{"id":362,"data":363,"type":238},"p-tradeoffs-read-model",{"text":364,"level":40},"权衡：最终一致性。索引重建延迟对于发现页面是可接受的。我们设置SLA：更新在15分钟内出现。如果我们以后需要实时性（预订可用性），那是一个不同的界面，不应重用发现缓存。",{"id":366,"data":367,"type":42},"h-api",{"text":368,"level":243},"API接口",{"id":370,"data":371,"type":238},"p-api",{"text":372,"level":40},"两个接口：(A) 用于SEO和用户的HTML页面，(B) 用于客户端渲染和未来扩展的JSON API。相同的解析器 + 相同的查询计划。不同的呈现器。",{"id":374,"data":375,"type":257},"list-api",{"items":376,"style":256},[377,378,379],"HTML：GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}","API：GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..","管理（内部）：POST \u002Finternal\u002Fgeoindex\u002Frebuild（受保护），POST \u002Finternal\u002Fgeoindex\u002Fupsert（可选，以后）",{"id":381,"data":382,"type":290},"code-api-handler",{"code":383,"language":332},"import type { IncomingMessage, ServerResponse } from 'http';\nimport { resolveGeo } from '.\u002FGeoResolver';\nimport { runQueryPlan } from '.\u002FGeoController';\n\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\n\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\n\n  \u002F\u002F 我们通过映射查询 -> 伪路径来重用相同的解析器。\n  \u002F\u002F 这保持了HTML和API之间的逻辑一致。\n  const scope = url.searchParams.get('scope') ?? 'country';\n  const country = url.searchParams.get('country') ?? '';\n  const city = url.searchParams.get('city');\n  const cell = url.searchParams.get('cell');\n\n  const pseudoPath =\n    scope === 'city' && city\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\n      : scope === 'near' && cell\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\n        : `\u002Fdiscover\u002F${country}`;\n\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\n  const result = await runQueryPlan(geoQuery);\n\n  res.statusCode = 200;\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\n  \u002F\u002F 缓存：在CDN公开，租户特定的键已在上游处理\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\n\n  res.end(JSON.stringify(result));\n}\n",{"id":385,"data":386,"type":42},"h-scalability",{"text":387,"level":243},"可扩展性与性能",{"id":389,"data":390,"type":257},"list-scale",{"items":391,"style":256},[392,393,394,395,396],"主要性能目标：从CDN为流行的地理着陆页（国家\u002F城市）提供缓存的发现HTML。","附近页面可缓存，但有更多变体（cellId + r + q + category + sort + page）。我们限制r、pageSize，并严格验证过滤器。","GeoIndex查询必须快速：使用tenantId + countryCode + citySlug索引；对于附近搜索，使用单元桶（前缀匹配），然后在应用中进行距离细化。","避免在大数据集上进行昂贵的SQL半正矢计算。它看起来简单。其实不然。","水合操作按entityId列表批量进行。每个entityType一个查询，而不是N+1。",{"id":398,"data":399,"type":42},"h-near-strategy",{"text":400,"level":270},"半径搜索策略（附近页面）",{"id":402,"data":403,"type":238},"p-near",{"text":404,"level":40},"我们不对所有行进行完整的半径扫描。而是：(1) cellId映射到一个边界桶（类似地理哈希的前缀），(2) 通过桶前缀从GeoIndex获取候选，(3) 在应用中使用半正矢距离进行细化，(4) 排序 + 分页。这使数据库负载可预测。",{"id":406,"data":407,"type":290},"code-haversine",{"code":408,"language":332},"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\n  const R = 6371000;\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\n\n  const dLat = toRad(b.lat - a.lat);\n  const dLng = toRad(b.lng - a.lng);\n\n  const lat1 = toRad(a.lat);\n  const lat2 = toRad(b.lat);\n\n  const sinDLat = Math.sin(dLat \u002F 2);\n  const sinDLng = Math.sin(dLng \u002F 2);\n\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\n\n  return R * c;\n}\n",{"id":410,"data":411,"type":42},"h-cache",{"text":412,"level":243},"缓存模型（CDN + 服务器）",{"id":414,"data":415,"type":238},"p-cache",{"text":416,"level":40},"我们在两个层进行缓存。CDN为匿名流量缓存完整的HTML\u002FJSON。服务器缓存存储提供者结果，键为GeoQuery.cacheKey。该键包括租户、范围和有界过滤器。并非所有内容都应被缓存。以后的预订可用性将被排除在外。",{"id":418,"data":419,"type":257},"list-cache",{"items":420,"style":256},[421,422,423,424],"CDN缓存键随主机头变化。这是租户边界。","服务器缓存键明确包括tenantId。永远不要依赖进程内的隐式主机。","缓存TTL：国家\u002F城市页面在CDN为30–60分钟（启用stale-while-revalidate）。附近页面为5分钟。","我们为每个租户保留一个手动清除杠杆（缓存键中的版本后缀）。在迁移和错误部署期间使用。",{"id":426,"data":427,"type":42},"h-seo",{"text":428,"level":243},"SEO稳定性细节",{"id":430,"data":431,"type":257},"list-seo",{"items":432,"style":256},[433,434,435,436],"规范标签：始终指向干净的层次化路径（无查询字符串）。","Robots：如果存在查询参数且不在白名单中（q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr），则设置noindex。这阻止来自外部链接的垃圾参数。","分页：仅当页面 > 1 且 resultCount > pageSize 时生成 rel=next\u002Fprev。","稳定的内部链接：UI链接始终发出规范路径；查询字符串仅用于用户选择的过滤器。",{"id":438,"data":439,"type":42},"h-extensions",{"text":440,"level":243},"未来扩展（无臃肿）",{"id":442,"data":443,"type":238},"p-extensions",{"text":444,"level":40},"我们通过添加提供者和呈现器来扩展，而不是通过将功能塞入解析器。预订、评论和地图挂载在实体页面或专用API上。发现仍然是一个列表和过滤界面。该边界在代码审查中强制执行。",{"id":446,"data":447,"type":257},"list-extensions",{"items":448,"style":256},[449,450,451],"预订：单独的 \u002Fapi\u002Fbooking 端点。发现仅显示缓存的且非个性化的可用性徽章。","评论：单独的评论服务\u002F提供者。发现从GeoIndex（预计算）读取聚合的评分字段。","地图：地图瓦片和标记从 \u002Fapi\u002Fdiscovery\u002Fmarkers 获取，具有积极的缓存；如果破坏TTFB，则不嵌入到HTML响应中。",{"id":453,"data":454,"type":42},"h-went-wrong",{"text":455,"level":243},"一个出错的地方（以及我们改变了什么）",{"id":457,"data":458,"type":238},"p-wrong",{"text":459,"level":40},"我们发布了第一个版本，其服务器缓存键没有包含tenantId。它在本地“工作”。在暂存环境中看起来很好。然后到了生产环境。门户A开始在城市的页面上显示门户B的列表。相同的路径，不同的租户。缓存冲突。很糟糕。",{"id":461,"data":462,"type":238},"p-fix",{"text":463,"level":40},"修复很无聊但很严格：tenantId在GeoQuery中变为强制性的，并且缓存键构建移入解析器，因此无法跳过。我们还添加了一个运行时断言：如果水合的实体包含不同的tenantId，我们抛出异常并跳过缓存写入。这是故意制造噪音的。",{"id":465,"data":466,"type":42},"h-query-plan",{"text":467,"level":243},"查询计划与提供者契约",{"id":469,"data":470,"type":290},"code-provider",{"code":471,"language":332},"import type { GeoQuery } from '..\u002FGeoQuery';\n\nexport type GeoHit = {\n  entityId: string;\n  entityType: 'place' | 'service' | 'listing';\n  lat: number;\n  lng: number;\n  citySlug?: string;\n  countryCode: string;\n  score?: number;\n  distanceMeters?: number;\n};\n\nexport type GeoResult = {\n  hits: GeoHit[];\n  total: number;\n  page: number;\n  pageSize: number;\n};\n\nexport interface GeoProvider {\n  search(query: GeoQuery): Promise\u003CGeoResult>;\n}\n",{"id":473,"data":474,"type":238},"p-queryplan",{"text":475,"level":40},"QueryPlan是一个基于（租户配置 + 范围类型）的小型开关。示例：一些租户仅使用SQL；其他租户使用远程门户服务。相同的GeoQuery输入。相同的GeoResult输出。这使得推出可预测。",{"id":477,"data":478,"type":42},"h-sql",{"text":479,"level":270},"SQL GeoIndex：最小模式",{"id":481,"data":482,"type":290},"code-sql",{"code":483,"language":484},"CREATE TABLE geo_index (\n  tenant_id     TEXT NOT NULL,\n  entity_id     TEXT NOT NULL,\n  entity_type   TEXT NOT NULL,\n  country_code  TEXT NOT NULL,\n  city_slug     TEXT,\n  lat           DOUBLE PRECISION NOT NULL,\n  lng           DOUBLE PRECISION NOT NULL,\n  cell_prefix   TEXT NOT NULL,\n  category      TEXT,\n  rating_avg    DOUBLE PRECISION,\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\n  PRIMARY KEY (tenant_id, entity_id)\n);\n\nCREATE INDEX geo_index_country_city\n  ON geo_index (tenant_id, country_code, city_slug);\n\nCREATE INDEX geo_index_cell_prefix\n  ON geo_index (tenant_id, country_code, cell_prefix);\n","sql",{"id":486,"data":487,"type":42},"h-tradeoffs",{"text":488,"level":243},"权衡（明确）",{"id":490,"data":491,"type":257},"list-tradeoffs",{"items":492,"style":256},[493,494,495,496,497],"我们为了稳定的URL而牺牲了完美的精度：附近页面通过cellId规范化。两个相距300米的用户可以登陆到同一个规范页面。可以接受。","我们为了可缓存性而牺牲了新鲜度：发现页面不是实时的。索引更新可能延迟。","我们现在避免了数据库重构，但我们需要支付额外的写入路径来维护GeoIndex。","我们避免了与CMS路由的耦合，但我们现在拥有一个单独的路由命名空间（\u002Fdiscover）。这是有意的。这也是我们必须遵守的承诺。","我们在应用代码中进行附近搜索的距离细化，以避免昂贵的数据库计算。这将CPU转移到应用层，更容易水平扩展。",{"id":499,"data":500,"type":42},"h-rollout",{"text":501,"level":243},"推出计划（实用）",{"id":503,"data":504,"type":257},"list-rollout",{"items":505,"style":352},[506,507,508,509,510],"在功能标志后发布解析器 + 返回404的空页面。确认路由不与CMS通配符冲突。","为一个租户构建GeoIndex回填作业。验证计数与源数据。预期不匹配；记录它们。","仅启用国家页面。观察缓存命中率和爬取统计。","接下来启用城市页面。然后最后启用附近页面（它们是变体生成器）。","在我们看到稳定的缓存键后，添加JSON API消费者（地图标记、客户端过滤）。",{"id":512,"data":513,"type":42},"h-acceptance",{"text":514,"level":243},"验收清单",{"id":516,"data":517,"type":257},"list-acceptance",{"items":518,"style":256},[519,520,521,522,523,524],"多租户隔离：两个主机上的相同路径从不共享服务器缓存条目。","规范URL不包括查询参数，并且在过滤器更改时保持稳定。","CMS slug冲突不影响 \u002Fdiscover 路由。","附近页面不会生成无限的URL排列（有界的r、pageSize）。","提供者契约返回确定性的分页和总计数。","GeoIndex重建可以在没有停机的情况下运行，并且不会长时间锁定源表。","2.29.1","面向多租户门户的地理发现架构。定义了规范化 URL、解析器逻辑、缓存策略以及不依赖 CMS 耦合或数据库重构的地理读模型。该设计旨在确保 SEO 稳定性、高可扩展性，并支持未来的功能扩展，例如预订和地图。","\u002Fuploads\u002F2026\u002F01\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification-1769890763607-7rghbp.webp","canonical-architecture-url-design-resolver-logic-api-scalability-specification-1769890763607-7rghbp","PUBLISHED","2026-01-31T06:12:00.000Z","2026-01-31T20:12:05.337Z","2026-02-20T20:40:40.678Z",{"en":534,"de":535,"sr":536,"es":537,"fr":538,"it":539,"ru":540,"zh":541},"\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fde\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fsr\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fes\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Ffr\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fit\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fru\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification","\u002Fzh\u002Fblog\u002Fcanonical-architecture-url-design-resolver-logic-api-scalability-specification",[543,547,551,555,559],{"id":544,"name":545,"slug":546},108,"交付评估","delivery-assessment",{"id":548,"name":549,"slug":550},107,"评估","assessments",{"id":552,"name":553,"slug":554},45,"数字平台参考模型","digital-platform",{"id":556,"name":557,"slug":558},66,"内容运营","content-ops",{"id":560,"name":561,"slug":562},79,"安全加固作战手册","security-hardening",{"id":564,"login":565,"email":566,"displayName":567},"20","rooth8233","","Aleksandar Stajić",[569,802],{"lang":570,"title":571,"content":572,"contentJson":573,"excerpt":801},"en","Canonical Architecture, URL Design, Resolver Logic, API & Scalability Specification","{\"time\":1769827200000,\"blocks\":[{\"id\":\"h1\",\"data\":{\"text\":\"Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec\",\"level\":1},\"type\":\"header\"},{\"id\":\"p-scope\",\"data\":{\"text\":\"This document specifies the geo-based discovery surface (country\u002Fcity\u002Fradius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page\u002Fpost). It keeps SEO stable, stays cache-friendly, and leaves room for booking\u002Freviews\u002Fmaps without turning the app into a blob.\"},\"type\":\"paragraph\"},{\"id\":\"h-goals\",\"data\":{\"text\":\"Constraints and Non-Goals\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-constraints\",\"data\":{\"items\":[\"Multi-tenant: same codebase serves multiple portals. Tenant affects content scope, branding, and sometimes data source.\",\"Geo discovery must support: country, city, radius search (around a point).\",\"No immediate database refactoring: we cannot reshape existing tables into a perfect geo schema right now.\",\"Independence from CMS routing: geo pages are not \\\"posts\\\" or \\\"pages\\\". They cannot be blocked by CMS slug conflicts.\",\"SEO stability: canonical URLs must not change when filters\u002Fsort options change.\",\"Cache friendliness: CDN + server cache should have predictable keys. Avoid per-user variation.\",\"Strict separation of concerns: discovery, CMS, and tenant resolution are separate modules with explicit boundaries.\",\"Non-goal (for now): perfect geocoding. We accept one geocoder, one normalization strategy, and we store the normalized result.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-arch\",\"data\":{\"text\":\"Canonical Architecture\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-arch\",\"data\":{\"text\":\"We implement geo discovery as its own bounded context with a small public surface: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO + Cache metadata. CMS routes never call into discovery. Discovery never calls CMS routing. They share only low-level utilities (HTTP, caching, tenant context).\"},\"type\":\"paragraph\"},{\"id\":\"h-components\",\"data\":{\"text\":\"Key Components\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-components\",\"data\":{\"items\":[\"TenantContext: resolves tenant from host header (or explicit portal id in internal calls).\",\"GeoResolver: parses + validates geo URL segments; emits a normalized GeoQuery.\",\"GeoIndex (Read Model): a separate table\u002Fcollection that maps entityId -> lat\u002Flng + tenant scope + minimal searchable fields. This avoids refactoring source DB tables.\",\"Data Providers: pluggable sources (e.g., existing SQL tables, WordPress API, another portal service). They are behind an interface.\",\"SEO Router: produces canonical URL and meta (canonical, hreflang if needed, robots flags).\",\"Caching Layer: CDN cache keys + server-side cache with tenant-scoped keys and versioning.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-folder\",\"data\":{\"text\":\"Folder Structure\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-folder\",\"data\":{\"code\":\"apps\u002F\\n  web\u002F\\n    routes\u002F\\n      discovery\u002F                  # discovery entry points (not CMS)\\n    pages\u002F\\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\\n  server\u002F\\n    src\u002F\\n      tenant\u002F\\n        TenantContext.ts\\n        tenantConfig.ts\\n      discovery\u002F\\n        geo\u002F\\n          GeoResolver.ts\\n          GeoQuery.ts\\n          GeoCanonical.ts\\n          GeoController.ts\\n          providers\u002F\\n            GeoProvider.ts\\n            SqlGeoProvider.ts\\n            RemoteGeoProvider.ts\\n          index\u002F\\n            GeoIndexRepository.ts\\n            migrations\u002F\\n      cache\u002F\\n        Cache.ts\\n        cacheKeys.ts\\n      http\u002F\\n        errors.ts\\n        requestContext.ts\\npackages\u002F\\n  shared\u002F\\n    src\u002F\\n      geo\u002F\\n        normalize.ts\\n        haversine.ts\\n      validation\u002F\\n        zod.ts\\n\",\"language\":\"text\"},\"type\":\"code\"},{\"id\":\"h-url\",\"data\":{\"text\":\"URL Design (SEO Canonical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-url\",\"data\":{\"text\":\"We keep the canonical URL purely hierarchical and human-readable. Filters\u002Fsort stay in querystring but do NOT affect the canonical. Radius search uses a stable \\\"near\\\" page with a canonical based on a rounded coordinate cell, not the raw lat\u002Flng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.\"},\"type\":\"paragraph\"},{\"id\":\"list-url\",\"data\":{\"items\":[\"Country landing: \u002Fdiscover\u002F{countryCode} (example: \u002Fdiscover\u002Fde)\",\"City landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (example: \u002Fdiscover\u002Fde\u002Fmunich)\",\"Near (radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (example: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) where cellId is a short geohash-ish identifier\",\"Optional query params (non-canonical): ?q=shih-tzu&category=pet-care&sort=rating&page=2\",\"Tenant is NOT in the path. Tenant is the host (portal-a.tld, portal-b.tld). Internal calls pass x-tenant-id.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-canonical-rules\",\"data\":{\"text\":\"Canonical Rules\",\"level\":3},\"type\":\"header\"},{\"id\":\"list-canonical\",\"data\":{\"items\":[\"Country\u002Fcity pages canonicalize to their own clean path (no querystring).\",\"Near pages canonicalize to \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. The canonical is derived from a rounded cell, not the incoming coordinates.\",\"page=1 is omitted from canonical and from internal link generation.\",\"Unsupported combinations (e.g., country mismatch) return 404, not a redirect. Redirect chains were hurting crawl budget in testing.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-resolver\",\"data\":{\"text\":\"Resolver Logic\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-resolver\",\"data\":{\"text\":\"Resolver input is (tenant, pathname, query). Resolver output is a normalized GeoQuery with a query plan. No DB access inside the resolver. That separation mattered later. It saved us from a nasty caching bug.\"},\"type\":\"paragraph\"},{\"id\":\"code-types\",\"data\":{\"code\":\"export type TenantId = string;\\n\\nexport type GeoScope =\\n  | { kind: 'country'; countryCode: string }\\n  | { kind: 'city'; countryCode: string; citySlug: string }\\n  | { kind: 'near'; countryCode: string; cellId: string; radiusMeters: number };\\n\\nexport type GeoFilters = {\\n  q?: string;\\n  category?: string;\\n  sort?: 'relevance' | 'rating' | 'distance';\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport type GeoQuery = {\\n  tenantId: TenantId;\\n  scope: GeoScope;\\n  filters: GeoFilters;\\n  canonicalPath: string;\\n  cacheKey: string;\\n};\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"code-resolver\",\"data\":{\"code\":\"import { z } from 'zod';\\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\\n\\nconst QuerySchema = z.object({\\n  q: z.string().trim().min(1).max(120).optional(),\\n  category: z.string().trim().min(1).max(60).optional(),\\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\\n  page: z.coerce.number().int().min(1).max(200).default(1),\\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\\n});\\n\\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\\n\\nexport function resolveGeo(\\n  tenantId: TenantId,\\n  pathname: string,\\n  query: Record\u003Cstring, unknown>\\n): GeoQuery {\\n  const filters = QuerySchema.parse(query);\\n  const parts = pathname.split('\u002F').filter(Boolean);\\n\\n  \u002F\u002F \u002Fdiscover\u002F{country}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\\n  if (parts[0] !== 'discover') {\\n    throw new Error('Not a discovery route');\\n  }\\n\\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\\n\\n  let scope: GeoQuery['scope'];\\n  if (parts.length === 2) {\\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\\n  } else if (parts[2] === 'near') {\\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\\n    \u002F\u002F radius is NOT in the path, but is bounded.\\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\\n  } else {\\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\\n  }\\n\\n  const canonicalPath = canonicalizePath(scope);\\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\\n\\n  return {\\n    tenantId,\\n    scope,\\n    filters: {\\n      ...filters,\\n      sort: filters.sort ?? 'relevance'\\n    },\\n    canonicalPath,\\n    cacheKey\\n  };\\n}\\n\\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\\n  switch (scope.kind) {\\n    case 'country':\\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\\n    case 'city':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\\n    case 'near':\\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\\n  }\\n}\\n\\nfunction buildCacheKey(\\n  tenantId: string,\\n  scope: GeoQuery['scope'],\\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\\n): string {\\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\\n  \u002F\u002F But we keep it bounded and explicit.\\n  const q = filters.q ? `q=${filters.q}` : '';\\n  const c = filters.category ? `cat=${filters.category}` : '';\\n  const s = `sort=${filters.sort ?? 'relevance'}`;\\n  const p = `p=${filters.page}`;\\n  const ps = `ps=${filters.pageSize}`;\\n\\n  const scopeKey =\\n    scope.kind === 'country'\\n      ? `country:${scope.countryCode}`\\n      : scope.kind === 'city'\\n        ? `city:${scope.countryCode}:${scope.citySlug}`\\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\\n\\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-flow\",\"data\":{\"text\":\"Step-by-Step Request Flow\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-flow\",\"data\":{\"items\":[\"Edge\u002FCDN receives request. Cache key includes host + path + bounded query params (q, category, sort, page, pageSize, r).\",\"App server creates TenantContext from Host header. No DB yet.\",\"GeoResolver parses URL. Produces GeoQuery with canonicalPath and server cacheKey.\",\"GeoController builds a QueryPlan. It decides which provider(s) to hit based on tenant config and scope kind.\",\"Provider executes read-model query against GeoIndex (fast). Then hydrates results from the existing source DB\u002FAPI using entity IDs (no refactor required).\",\"Response assembler attaches SEO metadata: canonical, robots, pagination links.\",\"Server sets cache headers (s-maxage + stale-while-revalidate). Body is tenant-scoped, never shared across tenants.\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-read-model\",\"data\":{\"text\":\"No-Refactor Strategy: GeoIndex Read Model\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-read-model\",\"data\":{\"text\":\"We cannot restructure existing content tables right now. So we add a separate GeoIndex that we can rebuild independently. It stores just enough to do discovery efficiently: tenantId, entityId, entityType, countryCode, citySlug, lat, lng, and a few filter fields. Hydration fetches the full object from the current source (SQL rows, CMS API, etc.).\"},\"type\":\"paragraph\"},{\"id\":\"p-tradeoffs-read-model\",\"data\":{\"text\":\"Trade-off: eventual consistency. Index rebuild lag is acceptable for discovery pages. We set SLA: updates appear within 15 minutes. If we need real-time later (booking availability), that’s a different surface and should not reuse the discovery cache.\"},\"type\":\"paragraph\"},{\"id\":\"h-api\",\"data\":{\"text\":\"API Surface\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-api\",\"data\":{\"text\":\"Two surfaces: (A) HTML pages for SEO and users, (B) JSON API for client rendering and future extensions. Same resolver + same query plan. Different presenters.\"},\"type\":\"paragraph\"},{\"id\":\"list-api\",\"data\":{\"items\":[\"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\",\"API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..\",\"Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"code-api-handler\",\"data\":{\"code\":\"import type { IncomingMessage, ServerResponse } from 'http';\\nimport { resolveGeo } from '.\u002FGeoResolver';\\nimport { runQueryPlan } from '.\u002FGeoController';\\n\\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\\n\\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\\n\\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\\n  \u002F\u002F This keeps logic aligned between HTML and API.\\n  const scope = url.searchParams.get('scope') ?? 'country';\\n  const country = url.searchParams.get('country') ?? '';\\n  const city = url.searchParams.get('city');\\n  const cell = url.searchParams.get('cell');\\n\\n  const pseudoPath =\\n    scope === 'city' && city\\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\\n      : scope === 'near' && cell\\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\\n        : `\u002Fdiscover\u002F${country}`;\\n\\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\\n  const result = await runQueryPlan(geoQuery);\\n\\n  res.statusCode = 200;\\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\\n\\n  res.end(JSON.stringify(result));\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-scalability\",\"data\":{\"text\":\"Scalability & Performance\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-scale\",\"data\":{\"items\":[\"Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country\u002Fcity).\",\"Near pages are cacheable but have more variants (cellId + r + q + category + sort + page). We bound r, pageSize, and validate filters strictly.\",\"GeoIndex query must be fast: use tenantId + countryCode + citySlug indexes; for near use cell buckets (prefix match) then refine by distance in app.\",\"Avoid expensive SQL haversine over large datasets. It looks simple. It is not.\",\"Hydration is batched by entityId list. One query per entityType, not N+1.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-near-strategy\",\"data\":{\"text\":\"Radius Search Strategy (Near Pages)\",\"level\":3},\"type\":\"header\"},{\"id\":\"p-near\",\"data\":{\"text\":\"We do not run a full radius scan over all rows. Instead: (1) cellId maps to a bounding bucket (geohash-ish prefix), (2) fetch candidates from GeoIndex by bucket prefix, (3) refine in application with haversine distance, (4) sort + paginate. This keeps DB load predictable.\"},\"type\":\"paragraph\"},{\"id\":\"code-haversine\",\"data\":{\"code\":\"export function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {\\n  const R = 6371000;\\n  const toRad = (d: number) => (d * Math.PI) \u002F 180;\\n\\n  const dLat = toRad(b.lat - a.lat);\\n  const dLng = toRad(b.lng - a.lng);\\n\\n  const lat1 = toRad(a.lat);\\n  const lat2 = toRad(b.lat);\\n\\n  const sinDLat = Math.sin(dLat \u002F 2);\\n  const sinDLng = Math.sin(dLng \u002F 2);\\n\\n  const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng;\\n  const c = 2 * Math.asin(Math.min(1, Math.sqrt(h)));\\n\\n  return R * c;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"h-cache\",\"data\":{\"text\":\"Caching Model (CDN + Server)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-cache\",\"data\":{\"text\":\"We cache at two layers. CDN caches the full HTML\u002FJSON for anonymous traffic. Server cache stores provider results keyed by GeoQuery.cacheKey. The key includes tenant, scope, and bounded filters. Not everything should be cached. Booking availability later will be excluded.\"},\"type\":\"paragraph\"},{\"id\":\"list-cache\",\"data\":{\"items\":[\"CDN cache key varies by Host header. That is the tenant boundary.\",\"Server cache key includes tenantId explicitly. Never rely on implicit host in-process.\",\"Cache TTLs: country\u002Fcity 30–60 minutes at CDN (stale-while-revalidate enabled). near pages 5 minutes.\",\"We keep a manual bust lever per tenant (version suffix in cache key). Used during migrations and bad deploys.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-seo\",\"data\":{\"text\":\"SEO Stability Details\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-seo\",\"data\":{\"items\":[\"Canonical tags: always point to the clean hierarchical path (no querystring).\",\"Robots: if query params are present and not whitelisted (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), set noindex. This blocks garbage params from external links.\",\"Pagination: rel=next\u002Fprev generated only for pages > 1 and if resultCount > pageSize.\",\"Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-extensions\",\"data\":{\"text\":\"Future Extensions Without Bloat\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-extensions\",\"data\":{\"text\":\"We extend by adding providers and presenters, not by stuffing features into the resolver. Booking, reviews, and maps hang off entity pages or dedicated APIs. Discovery remains a list-and-filter surface. That boundary is enforced in code review.\"},\"type\":\"paragraph\"},{\"id\":\"list-extensions\",\"data\":{\"items\":[\"Booking: separate \u002Fapi\u002Fbooking endpoints. Discovery only shows availability badges if cached and non-personal.\",\"Reviews: separate review service\u002Fprovider. Discovery reads aggregated rating fields from GeoIndex (precomputed).\",\"Maps: map tiles and markers fetched from \u002Fapi\u002Fdiscovery\u002Fmarkers with aggressive caching; not embedded into the HTML response if it breaks TTFB.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-went-wrong\",\"data\":{\"text\":\"One Thing That Went Wrong (and What We Changed)\",\"level\":2},\"type\":\"header\"},{\"id\":\"p-wrong\",\"data\":{\"text\":\"We shipped the first version with a server cache key that did NOT include tenantId. It “worked” in local. In staging it looked fine. Then production. Portal A started showing Portal B listings on city pages. Same path, different tenant. Cache collision. Ugly.\"},\"type\":\"paragraph\"},{\"id\":\"p-fix\",\"data\":{\"text\":\"Fix was boring but strict: tenantId became mandatory in GeoQuery, and cache key building moved into the resolver so it can’t be skipped. We also added a runtime assertion: if hydrated entities contain a different tenantId, we throw and skip cache write. That’s noisy on purpose.\"},\"type\":\"paragraph\"},{\"id\":\"h-query-plan\",\"data\":{\"text\":\"Query Plan and Provider Contract\",\"level\":2},\"type\":\"header\"},{\"id\":\"code-provider\",\"data\":{\"code\":\"import type { GeoQuery } from '..\u002FGeoQuery';\\n\\nexport type GeoHit = {\\n  entityId: string;\\n  entityType: 'place' | 'service' | 'listing';\\n  lat: number;\\n  lng: number;\\n  citySlug?: string;\\n  countryCode: string;\\n  score?: number;\\n  distanceMeters?: number;\\n};\\n\\nexport type GeoResult = {\\n  hits: GeoHit[];\\n  total: number;\\n  page: number;\\n  pageSize: number;\\n};\\n\\nexport interface GeoProvider {\\n  search(query: GeoQuery): Promise\u003CGeoResult>;\\n}\\n\",\"language\":\"ts\"},\"type\":\"code\"},{\"id\":\"p-queryplan\",\"data\":{\"text\":\"QueryPlan is a small switch on (tenant config + scope kind). Example: some tenants use only SQL; others use a remote portal service. Same GeoQuery input. Same GeoResult output. That makes rollout predictable.\"},\"type\":\"paragraph\"},{\"id\":\"h-sql\",\"data\":{\"text\":\"SQL GeoIndex: Minimal Schema\",\"level\":3},\"type\":\"header\"},{\"id\":\"code-sql\",\"data\":{\"code\":\"CREATE TABLE geo_index (\\n  tenant_id     TEXT NOT NULL,\\n  entity_id     TEXT NOT NULL,\\n  entity_type   TEXT NOT NULL,\\n  country_code  TEXT NOT NULL,\\n  city_slug     TEXT,\\n  lat           DOUBLE PRECISION NOT NULL,\\n  lng           DOUBLE PRECISION NOT NULL,\\n  cell_prefix   TEXT NOT NULL,\\n  category      TEXT,\\n  rating_avg    DOUBLE PRECISION,\\n  updated_at    TIMESTAMP NOT NULL DEFAULT NOW(),\\n  PRIMARY KEY (tenant_id, entity_id)\\n);\\n\\nCREATE INDEX geo_index_country_city\\n  ON geo_index (tenant_id, country_code, city_slug);\\n\\nCREATE INDEX geo_index_cell_prefix\\n  ON geo_index (tenant_id, country_code, cell_prefix);\\n\",\"language\":\"sql\"},\"type\":\"code\"},{\"id\":\"h-tradeoffs\",\"data\":{\"text\":\"Trade-offs (Explicit)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-tradeoffs\",\"data\":{\"items\":[\"We trade perfect precision for stable URLs: near pages canonicalize by cellId. Two users 300m apart can land on the same canonical page. Fine.\",\"We trade freshness for cacheability: discovery pages are not real-time. Index updates can lag.\",\"We avoid DB refactor now, but we do pay an extra write path to maintain GeoIndex.\",\"We avoid coupling to CMS routing, but we now own a separate route namespace (\u002Fdiscover). That’s intentional. It’s also a promise we must keep.\",\"We do distance refinement in app code for near searches to avoid expensive DB math. That shifts CPU to the app tier, which is easier to scale horizontally.\"],\"style\":\"unordered\"},\"type\":\"list\"},{\"id\":\"h-rollout\",\"data\":{\"text\":\"Rollout Plan (Practical)\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-rollout\",\"data\":{\"items\":[\"Ship resolver + empty pages returning 404 behind a feature flag. Confirm routing doesn’t collide with CMS catch-all.\",\"Build GeoIndex backfill job for one tenant. Validate counts vs source data. Expect mismatches; log them.\",\"Enable country pages only. Watch cache hit ratio and crawl stats.\",\"Enable city pages next. Then near pages last (they are the variant generator).\",\"After we see stable cache keys, add JSON API consumers (maps markers, client filtering).\"],\"style\":\"ordered\"},\"type\":\"list\"},{\"id\":\"h-acceptance\",\"data\":{\"text\":\"Acceptance Checklist\",\"level\":2},\"type\":\"header\"},{\"id\":\"list-acceptance\",\"data\":{\"items\":[\"Multi-tenant isolation: same path on two hosts never shares server cache entries.\",\"Canonical URLs do not include query params and remain stable across filter changes.\",\"CMS slug conflicts do not affect \u002Fdiscover routes.\",\"Near pages do not generate unbounded URL permutations (bounded r, pageSize).\",\"Provider contract returns deterministic paging and total counts.\",\"GeoIndex rebuild can run without downtime and without locking source tables for long periods.\"],\"style\":\"unordered\"},\"type\":\"list\"}],\"version\":\"2.29.1\"}",{"time":228,"blocks":574,"version":525},[575,578,581,584,595,598,601,604,613,616,619,622,625,633,636,643,646,649,651,654,657,667,670,673,676,679,682,688,691,694,702,705,708,710,713,716,723,726,733,736,739,745,748,751,754,757,759,762,765,767,770,778,781,789,792],{"id":231,"data":576,"type":42},{"text":577,"level":40},"Geo Discovery: Canonical Architecture, URL Design, Resolver Logic, API & Scalability Spec",{"id":235,"data":579,"type":238},{"text":580},"This document specifies the geo-based discovery surface (country\u002Fcity\u002Fradius) across multiple portals (multi-tenant), without forcing immediate DB refactoring, and without coupling to CMS routing (page\u002Fpost). It keeps SEO stable, stays cache-friendly, and leaves room for booking\u002Freviews\u002Fmaps without turning the app into a blob.",{"id":240,"data":582,"type":42},{"text":583,"level":243},"Constraints and Non-Goals",{"id":245,"data":585,"type":257},{"items":586,"style":256},[587,588,589,590,591,592,593,594],"Multi-tenant: same codebase serves multiple portals. Tenant affects content scope, branding, and sometimes data source.","Geo discovery must support: country, city, radius search (around a point).","No immediate database refactoring: we cannot reshape existing tables into a perfect geo schema right now.","Independence from CMS routing: geo pages are not \"posts\" or \"pages\". They cannot be blocked by CMS slug conflicts.","SEO stability: canonical URLs must not change when filters\u002Fsort options change.","Cache friendliness: CDN + server cache should have predictable keys. Avoid per-user variation.","Strict separation of concerns: discovery, CMS, and tenant resolution are separate modules with explicit boundaries.","Non-goal (for now): perfect geocoding. We accept one geocoder, one normalization strategy, and we store the normalized result.",{"id":259,"data":596,"type":42},{"text":597,"level":243},"Canonical Architecture",{"id":263,"data":599,"type":238},{"text":600},"We implement geo discovery as its own bounded context with a small public surface: (1) URL -> Resolver, (2) Resolver -> Query Plan, (3) Query Plan -> Data Providers, (4) Response -> SEO + Cache metadata. CMS routes never call into discovery. Discovery never calls CMS routing. They share only low-level utilities (HTTP, caching, tenant context).",{"id":267,"data":602,"type":42},{"text":603,"level":270},"Key Components",{"id":272,"data":605,"type":257},{"items":606,"style":256},[607,608,609,610,611,612],"TenantContext: resolves tenant from host header (or explicit portal id in internal calls).","GeoResolver: parses + validates geo URL segments; emits a normalized GeoQuery.","GeoIndex (Read Model): a separate table\u002Fcollection that maps entityId -> lat\u002Flng + tenant scope + minimal searchable fields. This avoids refactoring source DB tables.","Data Providers: pluggable sources (e.g., existing SQL tables, WordPress API, another portal service). They are behind an interface.","SEO Router: produces canonical URL and meta (canonical, hreflang if needed, robots flags).","Caching Layer: CDN cache keys + server-side cache with tenant-scoped keys and versioning.",{"id":282,"data":614,"type":42},{"text":615,"level":243},"Folder Structure",{"id":286,"data":617,"type":290},{"code":618,"language":289},"apps\u002F\n  web\u002F\n    routes\u002F\n      discovery\u002F                  # discovery entry points (not CMS)\n    pages\u002F\n      [...cms].vue                # CMS catch-all (kept away from \u002Fdiscover)\n  server\u002F\n    src\u002F\n      tenant\u002F\n        TenantContext.ts\n        tenantConfig.ts\n      discovery\u002F\n        geo\u002F\n          GeoResolver.ts\n          GeoQuery.ts\n          GeoCanonical.ts\n          GeoController.ts\n          providers\u002F\n            GeoProvider.ts\n            SqlGeoProvider.ts\n            RemoteGeoProvider.ts\n          index\u002F\n            GeoIndexRepository.ts\n            migrations\u002F\n      cache\u002F\n        Cache.ts\n        cacheKeys.ts\n      http\u002F\n        errors.ts\n        requestContext.ts\npackages\u002F\n  shared\u002F\n    src\u002F\n      geo\u002F\n        normalize.ts\n        haversine.ts\n      validation\u002F\n        zod.ts\n",{"id":292,"data":620,"type":42},{"text":621,"level":243},"URL Design (SEO Canonical)",{"id":296,"data":623,"type":238},{"text":624},"We keep the canonical URL purely hierarchical and human-readable. Filters\u002Fsort stay in querystring but do NOT affect the canonical. Radius search uses a stable \"near\" page with a canonical based on a rounded coordinate cell, not the raw lat\u002Flng. That prevents infinite URL variants. It also prevents cache explosion. It’s a deliberate compromise.",{"id":300,"data":626,"type":257},{"items":627,"style":256},[628,629,630,631,632],"Country landing: \u002Fdiscover\u002F{countryCode} (example: \u002Fdiscover\u002Fde)","City landing: \u002Fdiscover\u002F{countryCode}\u002F{citySlug} (example: \u002Fdiscover\u002Fde\u002Fmunich)","Near (radius): \u002Fdiscover\u002F{countryCode}\u002Fnear\u002F{cellId} (example: \u002Fdiscover\u002Fde\u002Fnear\u002Fu281z) where cellId is a short geohash-ish identifier","Optional query params (non-canonical): ?q=shih-tzu&category=pet-care&sort=rating&page=2","Tenant is NOT in the path. Tenant is the host (portal-a.tld, portal-b.tld). Internal calls pass x-tenant-id.",{"id":309,"data":634,"type":42},{"text":635,"level":270},"Canonical Rules",{"id":313,"data":637,"type":257},{"items":638,"style":256},[639,640,641,642],"Country\u002Fcity pages canonicalize to their own clean path (no querystring).","Near pages canonicalize to \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}. The canonical is derived from a rounded cell, not the incoming coordinates.","page=1 is omitted from canonical and from internal link generation.","Unsupported combinations (e.g., country mismatch) return 404, not a redirect. Redirect chains were hurting crawl budget in testing.",{"id":321,"data":644,"type":42},{"text":645,"level":243},"Resolver Logic",{"id":325,"data":647,"type":238},{"text":648},"Resolver input is (tenant, pathname, query). Resolver output is a normalized GeoQuery with a query plan. No DB access inside the resolver. That separation mattered later. It saved us from a nasty caching bug.",{"id":329,"data":650,"type":290},{"code":331,"language":332},{"id":334,"data":652,"type":290},{"code":653,"language":332},"import { z } from 'zod';\nimport type { GeoQuery, TenantId } from '.\u002FGeoQuery';\n\nconst QuerySchema = z.object({\n  q: z.string().trim().min(1).max(120).optional(),\n  category: z.string().trim().min(1).max(60).optional(),\n  sort: z.enum(['relevance', 'rating', 'distance']).optional(),\n  page: z.coerce.number().int().min(1).max(200).default(1),\n  pageSize: z.coerce.number().int().min(5).max(50).default(20)\n});\n\nconst CountryCodeSchema = z.string().regex(\u002F^[a-z]{2}$\u002Fi);\nconst CitySlugSchema = z.string().regex(\u002F^[a-z0-9-]{2,80}$\u002Fi);\nconst CellIdSchema = z.string().regex(\u002F^[a-z0-9]{4,12}$\u002Fi);\n\nexport function resolveGeo(\n  tenantId: TenantId,\n  pathname: string,\n  query: Record\u003Cstring, unknown>\n): GeoQuery {\n  const filters = QuerySchema.parse(query);\n  const parts = pathname.split('\u002F').filter(Boolean);\n\n  \u002F\u002F \u002Fdiscover\u002F{country}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002F{city}\n  \u002F\u002F \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}\n  if (parts[0] !== 'discover') {\n    throw new Error('Not a discovery route');\n  }\n\n  const countryCode = CountryCodeSchema.parse(parts[1] ?? '');\n\n  let scope: GeoQuery['scope'];\n  if (parts.length === 2) {\n    scope = { kind: 'country', countryCode: countryCode.toLowerCase() };\n  } else if (parts[2] === 'near') {\n    const cellId = CellIdSchema.parse(parts[3] ?? '');\n    \u002F\u002F radius is NOT in the path, but is bounded.\n    const radiusMeters = Math.min(50000, Math.max(500, Number(query['r'] ?? 5000)));\n    scope = { kind: 'near', countryCode: countryCode.toLowerCase(), cellId, radiusMeters };\n  } else {\n    const citySlug = CitySlugSchema.parse(parts[2] ?? '');\n    scope = { kind: 'city', countryCode: countryCode.toLowerCase(), citySlug: citySlug.toLowerCase() };\n  }\n\n  const canonicalPath = canonicalizePath(scope);\n  const cacheKey = buildCacheKey(tenantId, scope, filters);\n\n  return {\n    tenantId,\n    scope,\n    filters: {\n      ...filters,\n      sort: filters.sort ?? 'relevance'\n    },\n    canonicalPath,\n    cacheKey\n  };\n}\n\nfunction canonicalizePath(scope: GeoQuery['scope']): string {\n  switch (scope.kind) {\n    case 'country':\n      return `\u002Fdiscover\u002F${scope.countryCode}`;\n    case 'city':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002F${scope.citySlug}`;\n    case 'near':\n      return `\u002Fdiscover\u002F${scope.countryCode}\u002Fnear\u002F${scope.cellId}`;\n  }\n}\n\nfunction buildCacheKey(\n  tenantId: string,\n  scope: GeoQuery['scope'],\n  filters: { q?: string; category?: string; sort?: string; page: number; pageSize: number }\n): string {\n  \u002F\u002F Note: canonical ignores querystring, cache does not.\n  \u002F\u002F But we keep it bounded and explicit.\n  const q = filters.q ? `q=${filters.q}` : '';\n  const c = filters.category ? `cat=${filters.category}` : '';\n  const s = `sort=${filters.sort ?? 'relevance'}`;\n  const p = `p=${filters.page}`;\n  const ps = `ps=${filters.pageSize}`;\n\n  const scopeKey =\n    scope.kind === 'country'\n      ? `country:${scope.countryCode}`\n      : scope.kind === 'city'\n        ? `city:${scope.countryCode}:${scope.citySlug}`\n        : `near:${scope.countryCode}:${scope.cellId}:r${scope.radiusMeters}`;\n\n  return `geo:v1:tenant=${tenantId}:${scopeKey}:${[q, c, s, p, ps].filter(Boolean).join('&')}`;\n}\n",{"id":338,"data":655,"type":42},{"text":656,"level":243},"Step-by-Step Request Flow",{"id":342,"data":658,"type":257},{"items":659,"style":352},[660,661,662,663,664,665,666],"Edge\u002FCDN receives request. Cache key includes host + path + bounded query params (q, category, sort, page, pageSize, r).","App server creates TenantContext from Host header. No DB yet.","GeoResolver parses URL. Produces GeoQuery with canonicalPath and server cacheKey.","GeoController builds a QueryPlan. It decides which provider(s) to hit based on tenant config and scope kind.","Provider executes read-model query against GeoIndex (fast). Then hydrates results from the existing source DB\u002FAPI using entity IDs (no refactor required).","Response assembler attaches SEO metadata: canonical, robots, pagination links.","Server sets cache headers (s-maxage + stale-while-revalidate). Body is tenant-scoped, never shared across tenants.",{"id":354,"data":668,"type":42},{"text":669,"level":243},"No-Refactor Strategy: GeoIndex Read Model",{"id":358,"data":671,"type":238},{"text":672},"We cannot restructure existing content tables right now. So we add a separate GeoIndex that we can rebuild independently. It stores just enough to do discovery efficiently: tenantId, entityId, entityType, countryCode, citySlug, lat, lng, and a few filter fields. Hydration fetches the full object from the current source (SQL rows, CMS API, etc.).",{"id":362,"data":674,"type":238},{"text":675},"Trade-off: eventual consistency. Index rebuild lag is acceptable for discovery pages. We set SLA: updates appear within 15 minutes. If we need real-time later (booking availability), that’s a different surface and should not reuse the discovery cache.",{"id":366,"data":677,"type":42},{"text":678,"level":243},"API Surface",{"id":370,"data":680,"type":238},{"text":681},"Two surfaces: (A) HTML pages for SEO and users, (B) JSON API for client rendering and future extensions. Same resolver + same query plan. Different presenters.",{"id":374,"data":683,"type":257},{"items":684,"style":256},[685,686,687],"HTML: GET \u002Fdiscover\u002F{country}, \u002Fdiscover\u002F{country}\u002F{city}, \u002Fdiscover\u002F{country}\u002Fnear\u002F{cellId}","API: GET \u002Fapi\u002Fdiscovery?scope=country|city|near&country=..&city=..&cell=..&r=..&q=..&category=..&sort=..&page=..","Admin (internal): POST \u002Finternal\u002Fgeoindex\u002Frebuild (protected), POST \u002Finternal\u002Fgeoindex\u002Fupsert (optional, later)",{"id":381,"data":689,"type":290},{"code":690,"language":332},"import type { IncomingMessage, ServerResponse } from 'http';\nimport { resolveGeo } from '.\u002FGeoResolver';\nimport { runQueryPlan } from '.\u002FGeoController';\n\nexport async function discoveryApi(req: IncomingMessage, res: ServerResponse) {\n  const url = new URL(req.url ?? '', 'http:\u002F\u002Flocalhost');\n\n  const tenantId = String(req.headers['x-tenant-id'] ?? 'default');\n\n  \u002F\u002F We reuse the same resolver by mapping query -> a pseudo-path.\n  \u002F\u002F This keeps logic aligned between HTML and API.\n  const scope = url.searchParams.get('scope') ?? 'country';\n  const country = url.searchParams.get('country') ?? '';\n  const city = url.searchParams.get('city');\n  const cell = url.searchParams.get('cell');\n\n  const pseudoPath =\n    scope === 'city' && city\n      ? `\u002Fdiscover\u002F${country}\u002F${city}`\n      : scope === 'near' && cell\n        ? `\u002Fdiscover\u002F${country}\u002Fnear\u002F${cell}`\n        : `\u002Fdiscover\u002F${country}`;\n\n  const geoQuery = resolveGeo(tenantId, pseudoPath, Object.fromEntries(url.searchParams.entries()));\n  const result = await runQueryPlan(geoQuery);\n\n  res.statusCode = 200;\n  res.setHeader('content-type', 'application\u002Fjson; charset=utf-8');\n  \u002F\u002F cache: public at CDN, tenant-specific key already handled upstream\n  res.setHeader('cache-control', 'public, s-maxage=300, stale-while-revalidate=600');\n\n  res.end(JSON.stringify(result));\n}\n",{"id":385,"data":692,"type":42},{"text":693,"level":243},"Scalability & Performance",{"id":389,"data":695,"type":257},{"items":696,"style":256},[697,698,699,700,701],"Primary performance goal: serve cached discovery HTML from CDN for popular geo landings (country\u002Fcity).","Near pages are cacheable but have more variants (cellId + r + q + category + sort + page). We bound r, pageSize, and validate filters strictly.","GeoIndex query must be fast: use tenantId + countryCode + citySlug indexes; for near use cell buckets (prefix match) then refine by distance in app.","Avoid expensive SQL haversine over large datasets. It looks simple. It is not.","Hydration is batched by entityId list. One query per entityType, not N+1.",{"id":398,"data":703,"type":42},{"text":704,"level":270},"Radius Search Strategy (Near Pages)",{"id":402,"data":706,"type":238},{"text":707},"We do not run a full radius scan over all rows. Instead: (1) cellId maps to a bounding bucket (geohash-ish prefix), (2) fetch candidates from GeoIndex by bucket prefix, (3) refine in application with haversine distance, (4) sort + paginate. This keeps DB load predictable.",{"id":406,"data":709,"type":290},{"code":408,"language":332},{"id":410,"data":711,"type":42},{"text":712,"level":243},"Caching Model (CDN + Server)",{"id":414,"data":714,"type":238},{"text":715},"We cache at two layers. CDN caches the full HTML\u002FJSON for anonymous traffic. Server cache stores provider results keyed by GeoQuery.cacheKey. The key includes tenant, scope, and bounded filters. Not everything should be cached. Booking availability later will be excluded.",{"id":418,"data":717,"type":257},{"items":718,"style":256},[719,720,721,722],"CDN cache key varies by Host header. That is the tenant boundary.","Server cache key includes tenantId explicitly. Never rely on implicit host in-process.","Cache TTLs: country\u002Fcity 30–60 minutes at CDN (stale-while-revalidate enabled). near pages 5 minutes.","We keep a manual bust lever per tenant (version suffix in cache key). Used during migrations and bad deploys.",{"id":426,"data":724,"type":42},{"text":725,"level":243},"SEO Stability Details",{"id":430,"data":727,"type":257},{"items":728,"style":256},[729,730,731,732],"Canonical tags: always point to the clean hierarchical path (no querystring).","Robots: if query params are present and not whitelisted (q\u002Fcategory\u002Fsort\u002Fpage\u002FpageSize\u002Fr), set noindex. This blocks garbage params from external links.","Pagination: rel=next\u002Fprev generated only for pages > 1 and if resultCount > pageSize.","Stable internal linking: UI links always emit canonical paths; querystring only for user-selected filters.",{"id":438,"data":734,"type":42},{"text":735,"level":243},"Future Extensions Without Bloat",{"id":442,"data":737,"type":238},{"text":738},"We extend by adding providers and presenters, not by stuffing features into the resolver. Booking, reviews, and maps hang off entity pages or dedicated APIs. Discovery remains a list-and-filter surface. That boundary is enforced in code review.",{"id":446,"data":740,"type":257},{"items":741,"style":256},[742,743,744],"Booking: separate \u002Fapi\u002Fbooking endpoints. Discovery only shows availability badges if cached and non-personal.","Reviews: separate review service\u002Fprovider. Discovery reads aggregated rating fields from GeoIndex (precomputed).","Maps: map tiles and markers fetched from \u002Fapi\u002Fdiscovery\u002Fmarkers with aggressive caching; not embedded into the HTML response if it breaks TTFB.",{"id":453,"data":746,"type":42},{"text":747,"level":243},"One Thing That Went Wrong (and What We Changed)",{"id":457,"data":749,"type":238},{"text":750},"We shipped the first version with a server cache key that did NOT include tenantId. It “worked” in local. In staging it looked fine. Then production. Portal A started showing Portal B listings on city pages. Same path, different tenant. Cache collision. Ugly.",{"id":461,"data":752,"type":238},{"text":753},"Fix was boring but strict: tenantId became mandatory in GeoQuery, and cache key building moved into the resolver so it can’t be skipped. We also added a runtime assertion: if hydrated entities contain a different tenantId, we throw and skip cache write. That’s noisy on purpose.",{"id":465,"data":755,"type":42},{"text":756,"level":243},"Query Plan and Provider Contract",{"id":469,"data":758,"type":290},{"code":471,"language":332},{"id":473,"data":760,"type":238},{"text":761},"QueryPlan is a small switch on (tenant config + scope kind). Example: some tenants use only SQL; others use a remote portal service. Same GeoQuery input. Same GeoResult output. That makes rollout predictable.",{"id":477,"data":763,"type":42},{"text":764,"level":270},"SQL GeoIndex: Minimal Schema",{"id":481,"data":766,"type":290},{"code":483,"language":484},{"id":486,"data":768,"type":42},{"text":769,"level":243},"Trade-offs (Explicit)",{"id":490,"data":771,"type":257},{"items":772,"style":256},[773,774,775,776,777],"We trade perfect precision for stable URLs: near pages canonicalize by cellId. Two users 300m apart can land on the same canonical page. Fine.","We trade freshness for cacheability: discovery pages are not real-time. Index updates can lag.","We avoid DB refactor now, but we do pay an extra write path to maintain GeoIndex.","We avoid coupling to CMS routing, but we now own a separate route namespace (\u002Fdiscover). That’s intentional. It’s also a promise we must keep.","We do distance refinement in app code for near searches to avoid expensive DB math. That shifts CPU to the app tier, which is easier to scale horizontally.",{"id":499,"data":779,"type":42},{"text":780,"level":243},"Rollout Plan (Practical)",{"id":503,"data":782,"type":257},{"items":783,"style":352},[784,785,786,787,788],"Ship resolver + empty pages returning 404 behind a feature flag. Confirm routing doesn’t collide with CMS catch-all.","Build GeoIndex backfill job for one tenant. Validate counts vs source data. Expect mismatches; log them.","Enable country pages only. Watch cache hit ratio and crawl stats.","Enable city pages next. Then near pages last (they are the variant generator).","After we see stable cache keys, add JSON API consumers (maps markers, client filtering).",{"id":512,"data":790,"type":42},{"text":791,"level":243},"Acceptance Checklist",{"id":516,"data":793,"type":257},{"items":794,"style":256},[795,796,797,798,799,800],"Multi-tenant isolation: same path on two hosts never shares server cache entries.","Canonical URLs do not include query params and remain stable across filter changes.","CMS slug conflicts do not affect \u002Fdiscover routes.","Near pages do not generate unbounded URL permutations (bounded r, pageSize).","Provider contract returns deterministic paging and total counts.","GeoIndex rebuild can run without downtime and without locking source tables for long periods.","Geo-based discovery architecture for multi-tenant portals. Defines canonical URLs, resolver logic, caching strategy, and a geo read-model without CMS coupling or database refactoring. Designed for SEO stability, scalability, and future extensions like booking and maps.",{"lang":7,"title":224,"content":226,"contentJson":803,"excerpt":526},{"time":228,"blocks":804,"version":525},[805,807,809,811,814,816,818,820,823,825,827,829,831,834,836,839,841,843,845,847,849,852,854,856,858,860,862,865,867,869,872,874,876,878,880,882,885,887,890,892,894,897,899,901,903,905,907,909,911,913,915,918,920,923,925],{"id":231,"data":806,"type":42},{"text":233,"level":40},{"id":235,"data":808,"type":238},{"text":237,"level":40},{"id":240,"data":810,"type":42},{"text":242,"level":243},{"id":245,"data":812,"type":257},{"items":813,"style":256},[248,249,250,251,252,253,254,255],{"id":259,"data":815,"type":42},{"text":261,"level":243},{"id":263,"data":817,"type":238},{"text":265,"level":40},{"id":267,"data":819,"type":42},{"text":269,"level":270},{"id":272,"data":821,"type":257},{"items":822,"style":256},[275,276,277,278,279,280],{"id":282,"data":824,"type":42},{"text":284,"level":243},{"id":286,"data":826,"type":290},{"code":288,"language":289},{"id":292,"data":828,"type":42},{"text":294,"level":243},{"id":296,"data":830,"type":238},{"text":298,"level":40},{"id":300,"data":832,"type":257},{"items":833,"style":256},[303,304,305,306,307],{"id":309,"data":835,"type":42},{"text":311,"level":270},{"id":313,"data":837,"type":257},{"items":838,"style":256},[316,317,318,319],{"id":321,"data":840,"type":42},{"text":323,"level":243},{"id":325,"data":842,"type":238},{"text":327,"level":40},{"id":329,"data":844,"type":290},{"code":331,"language":332},{"id":334,"data":846,"type":290},{"code":336,"language":332},{"id":338,"data":848,"type":42},{"text":340,"level":243},{"id":342,"data":850,"type":257},{"items":851,"style":352},[345,346,347,348,349,350,351],{"id":354,"data":853,"type":42},{"text":356,"level":243},{"id":358,"data":855,"type":238},{"text":360,"level":40},{"id":362,"data":857,"type":238},{"text":364,"level":40},{"id":366,"data":859,"type":42},{"text":368,"level":243},{"id":370,"data":861,"type":238},{"text":372,"level":40},{"id":374,"data":863,"type":257},{"items":864,"style":256},[377,378,379],{"id":381,"data":866,"type":290},{"code":383,"language":332},{"id":385,"data":868,"type":42},{"text":387,"level":243},{"id":389,"data":870,"type":257},{"items":871,"style":256},[392,393,394,395,396],{"id":398,"data":873,"type":42},{"text":400,"level":270},{"id":402,"data":875,"type":238},{"text":404,"level":40},{"id":406,"data":877,"type":290},{"code":408,"language":332},{"id":410,"data":879,"type":42},{"text":412,"level":243},{"id":414,"data":881,"type":238},{"text":416,"level":40},{"id":418,"data":883,"type":257},{"items":884,"style":256},[421,422,423,424],{"id":426,"data":886,"type":42},{"text":428,"level":243},{"id":430,"data":888,"type":257},{"items":889,"style":256},[433,434,435,436],{"id":438,"data":891,"type":42},{"text":440,"level":243},{"id":442,"data":893,"type":238},{"text":444,"level":40},{"id":446,"data":895,"type":257},{"items":896,"style":256},[449,450,451],{"id":453,"data":898,"type":42},{"text":455,"level":243},{"id":457,"data":900,"type":238},{"text":459,"level":40},{"id":461,"data":902,"type":238},{"text":463,"level":40},{"id":465,"data":904,"type":42},{"text":467,"level":243},{"id":469,"data":906,"type":290},{"code":471,"language":332},{"id":473,"data":908,"type":238},{"text":475,"level":40},{"id":477,"data":910,"type":42},{"text":479,"level":270},{"id":481,"data":912,"type":290},{"code":483,"language":484},{"id":486,"data":914,"type":42},{"text":488,"level":243},{"id":490,"data":916,"type":257},{"items":917,"style":256},[493,494,495,496,497],{"id":499,"data":919,"type":42},{"text":501,"level":243},{"id":503,"data":921,"type":257},{"items":922,"style":352},[506,507,508,509,510],{"id":512,"data":924,"type":42},{"text":514,"level":243},{"id":516,"data":926,"type":257},{"items":927,"style":256},[519,520,521,522,523,524],"Post erfolgreich abgerufen",{"items":930,"source":966,"manualIds":967,"manualMatchedIds":968},[931,938,945,952,959],{"id":932,"slug":933,"title":934,"excerpt":935,"featuredImage":936,"publishedAt":937},"361","model-view-controller-mvc","模型-视图-控制器（MVC）：现代Web应用的结构支柱","模型-视图-控制器（通常简称为MVC）依然是软件开发中最经久不衰的架构模式之一。它为团队提供了一种实用的方法，将业务逻辑、展示层和用户交互分离，从而使应用程序更易于构建、扩展、测试和维护。本文阐述了MVC是什么、为何至今仍具重要性、它在当今Web技术栈中的定位，以及它如何与更广泛的平台架构、交付质量、迁移策略和运维成熟度相连接。","\u002Fuploads\u002F2026\u002F03\u002Fmodel-view-controller-mvc-1774872805793-0bjubu.webp","2023-04-12T12:57:00.000Z",{"id":939,"slug":940,"title":941,"excerpt":942,"featuredImage":943,"publishedAt":944},"456","zbt-z8102ax-hardware-packaging-review","ZBT Z8102AX 硬件与包装评测：强劲路由器，薄弱包装","ZBT Z8102AX 作为一款纤薄黑色金属5G OpenWrt路由器，配备多个天线接口、双SIM卡槽、USB、LAN\u002FWAN端口及实用配件套装，给人留下扎实的第一印象。硬件设计实用且专业，但包装显然是薄弱环节。","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-02-1781620590938-y33j4b.webp","2026-06-16T04:40:00.000Z",{"id":946,"slug":947,"title":948,"excerpt":949,"featuredImage":950,"publishedAt":951},"454","zbt-z8102ax-rm500u-ea-5g-modem-test","Quectel RM500U-EA在ZBT Z8102AX中：5G频段、o2德国及实际信号表现","ZBT Z8102AX 使用移远 RM500U-EA 调制解调器实现 4G 和 5G 连接。在首次实际测试中，该路由器成功连接至德国 o2 网络，使用 LTE Band 3 和 NR n28 频段。调制解调器工作正常，但更深层次的诊断功能如 RSRP、RSRQ、SINR、频段锁定及小区行为仍需进一步测试。","\u002Fuploads\u002F2026\u002F06\u002Fopenwrt-router-review-dual-sim-06-1781620597879-qay2sx.webp","2026-06-16T08:39:00.000Z",{"id":953,"slug":954,"title":955,"excerpt":956,"featuredImage":957,"publishedAt":958},"381","enterprise-grade-multi-tenant-architecture-for-an-international-platform","企业级多租户架构，适用于国际平台","Loving Rocks 是一款企业级婚礼平台，采用真正的多租户架构设计，实现租户间数据库隔离，并内置国际化支持，以确保全球可扩展性、安全性及长期运营稳定性。","\u002Fuploads\u002F2026\u002F01\u002Fenterprise-grade-multi-tenant-architecture-for-an-international-platform-1769789121298-b6v7ak.webp","2026-01-30T12:04:00.000Z",{"id":960,"slug":961,"title":962,"excerpt":963,"featuredImage":964,"publishedAt":965},"471","how-to-know-whether-an-ai-agent-actually-used-the-right-evidence","如何判断一个AI智能体是否真正使用了正确的证据","AI代理可以引用来源，却仍然使用错误的证据。本文介绍一种实用方法，用于核查主张支持、来源权威性、适用性、出处，以及证据是否实际影响了答案。","\u002Fuploads\u002F2026\u002F09\u002Fhow-to-know-whether-an-ai-agent-actually-used-the-right-evidence-1790351317188-o5z9ve.webp","2026-09-25T11:47:00.000Z","fallback",[],[]]