English

DIY Rental Platform — Public Marketplace (Astro)

The anonymous, SEO-critical public face of the Malaysian rental marketplace: a new Astro 7 app at rent.gremlin.my that server-renders search and listing pages, reads Go directly for anonymous public data, and ships no island framework at all. A 22-question grilling session on 2026-08-26 settled the domain, the indexing rules, image handling, and the Seeker-to-Tenant handoff into the panel — and uncovered three gaps in slice 1's schema that must be fixed before its public API contract freezes.

  • Predecessor Slice 1 — Listings and Owner Onboarding; Slice 2 — Viewings, Messaging, and Reminders
  • ADRs 0052 · 0054 · 0055
  • New ADRs 0056 · 0057
  • Status Approved

View source markdown ↗ generated by claude-opus-5 · diagrams mermaid

摘要

这是马来西亚租房市场对外公开的门面:一个部署在 rent.gremlin.my 的全新 Astro 7 应用,匿名访问、以 SEO 为核心,所有页面都在服务端渲染,公开数据直接读取 Go,并且完全不引入任何 island 框架。切片 1 证明房东愿意挂牌,切片 2 证明双方愿意成交;没有这个应用,两者都只是内部工具。

  • 域名 rent.gremlin.mypanel.gremlin.my
  • 技术栈 Astro 7 + Cloudflare 适配器,零集成
  • 受众 Seeker(寻租者)——未登录,来自 Google 或 WhatsApp
  • 覆盖范围 切片 1 第 8 步、切片 2 第 9 步——无 OpenSpec 变更
  • 新增 ADR 0056(产品边界)、0057(slug 不可变)
  • 硬依赖 四项契约必须赶在切片 1 的 fixture 冻结前落地
一位 Seeker——没有账号的人——通过 Google 找到一条马来西亚租房信息,浏览并筛选 而页面不刷新,然后点击进入预约看房。

成功标准

  • 房源详情页可被索引,分享到 WhatsApp 时能正确渲染卡片
  • 筛选不刷新页面,且每个结果集都有可分享的 URL
  • 任何公开页面都不会渲染房东姓名、电话、邮箱或详细街道地址
  • 即使 API 挂掉,站点仍能提供可用的页面
  • 曾被索引或分享过的 URL 永远有效——slug 永不改变(ADR-0057)
  • 点击"Arrange Viewing"会进入该条房源的预约表单,即使 Seeker 中途必须注册账号

架构概览

请求路径与交接。浏览器永远不会调用 API——只有 Worker 会。虚线那条边是唯一的转化时刻, 它跨入了 panel。
请求路径与交接。浏览器永远不会调用 API——只有 Worker 会。虚线那条边是唯一的转化时刻, 它跨入了 panel。

数据流

browser ──► Cloudflare edge (cached HTML)
              └─► Astro SSR on Workers
                    └─► GET {RENTAL_API_URL}/api/public/rental/listings?…
                        RENTAL_API_URL = https://api.kokweng.net   (origin only)

只有一个模块 src/lib/rental-api.ts,导出 searchListings(params)getListing(slug)。它负责路径常量、查询串拼装、请求超时和响应类型。任何页面都不直接 调用 fetch

应用结构

apps/rental/
  astro.config.mjs            ASTRO_ADAPTER pattern, copied from quiz; no integrations
  src/
    layouts/Layout.astro      ClientRouter + prefetch
    pages/
      index.astro
      property/[slug].astro
      sitemap.xml.ts
      404.astro
      unavailable.astro       rendered for the 503 degraded state
    components/
      FilterForm.astro        <form method="GET"> + inline requestSubmit() on change
      ListingCard.astro
      PhotoGallery.astro      CSS scroll-snap + anchors, no JavaScript
    lib/
      rental-api.ts           the only module that calls fetch
      areas.ts                allowlist of indexable areas
      image.ts                /cdn-cgi/image URL builder

页面包括 /(筛选表单 + 结果)、/property/{slug}(房源详情)、 /sitemap.xml,以及静态的 /robots.txt。筛选条件完全存放在查询串中: ?area=&min_rent=&max_rent=&type=&furnishing=&page=

关键决策

市场站点用哪个域名?

rent.gremlin.my——沿用平台已有 zone 下的子域名。

账号下有两个 zone:gremlin.my(quiz)和 kokweng.netapi.auth.panel.)。选择 kokweng.net 意味着市场站点和 panel 共享同一个可注册域名,因此"Arrange Viewing"的交接以及注册后的返回都属于 同站(same-site),不需要携带任何跨域状态。

暂缓:启用一个独立的 .my 域名,对马来西亚的 Seeker 来说更可信。但 SEO 权重会累积在 最先上线的那个域名上——如果产品日后值得一个独立域名,要趁站点还没有值得保留的排名时尽早迁移。

走 Bun 代理,还是直接调用 Go?

匿名读取直接由 SSR 调用 Go,作为例外记录在 ADR-0056 中。

AGENTS.md 要求前端请求走 3100 端口的 Bun,以便转发认证 cookie 并规避跨域问题。 这两点对匿名的服务端到服务端读取都不适用。走代理只会多一跳,还让一个公开的营销站点依赖 Bun 的可用性。

已否决:机械地遵守现有规则。这里记录为有据可查的例外,而不是无声的绕过,让下一位读者看到的是一个 决策,而不是一处违规。

用哪个 island 框架?

完全不用。筛选由一段五行的内联脚本自动提交。

这个站点几乎不需要客户端状态——筛选是 GET 表单,导航交给 ClientRouter,相册是 CSS scroll-snap。两个独立的 Astro 应用 quizplans 都是零集成。 纯表单会迫使 Seeker 每改一项就按一次搜索,所以 FilterForm.astrochange 时调用 form.requestSubmit()——这是渐进增强,没有运行时、没有依赖、 没有 hydration。

已否决:Qwik(platform 里带着约 10 行打包器 workaround,还锁定在 beta 版)和 Solid (会为这块区域再引入第二个 island 框架而毫无收益)。如果将来某处确实需要状态,答案是 Qwik,因为 shared-ui 已经有表单控件——加上 qwik() 只是 一行配置,不改动任何页面。

已选择

服务端渲染 + ClientRouter

浏览器调用 API
从不
整页 CDN 缓存
可以
索引表现
Hydration JS

ClientRouter 拦截链接点击和表单提交,取回下一页的 HTML 并替换 DOM——同时保留滚动位置和焦点。platform 已经在用。

已否决

由 island 在客户端 fetch

浏览器调用 API
会——需要 CORS
整页 CDN 缓存
被削弱
索引表现
较弱
Hydration JS
每个搜索页都有

代价是公开端点要处理 CORS、到处都是 hydration JavaScript,以及更弱的索引与缓存表现——而这三点恰恰是这个应用赖以存在的强项。

仓库中既有的结论

从探查 frontend/astro 和切片 1 的 schema 得到五项发现,其中三项修正了切片 1 的设计。

platform 旁新建应用需要一份 ADR

政策

AGENTS.md 规定,只有"当某份 ADR 记录了产品与数据边界"时,独立部署的产品才可以与 platform 应用并存,quiz 应用是唯一已获批的例外(ADR-0054)。因此这项工作从 ADR-0056 开始, 而不是从建一个目录开始。

所有前端 API 调用本应经过 Bun

已取例外

浏览器和 SSR 都调用 3100 端口的 Bun,再由它代理 8080 上的 Go;直接调用 Go 被标为错误做法。 但这两条理由对匿名公开读取都不成立。

另有值得记录的偏差:AGENTS.md 声称 automationbulkexportsaireportsnotificationsleave 都有代理。而网关的 routes/index.ts 实际上只代理了 mobile/sync 和 fieldforce。

这里的 island 是 Qwik,而 @astro/core 是空的

修正切片 1

切片 1 的设计没有说明适用哪个 island 框架,还声称复用 @astro/core——而 frontend/astro/packages/core/空的。只有 shared-ui 存在,里面是两个 Astro 组件和七个 Qwik 表单控件。

切片 1 schema 中的三处缺口

有时限
  • rental_property_photos 没有存图片尺寸——想做到零 CLS 的页面,根本没有数据可用于设置 width/height
  • area 是自由文本字段,不是枚举也不是引用表,房东会自己造出各种写法
  • rental_listings 完全没有 title 字段,因此页面标题和 slug 都必须由结构化字段拼装

Astro 6 已落后一个大版本

版本偏差

platformquizplans 装的都是 6.4.8;最新是 7.2.7, @astrojs/cloudflare 为 14.2.5、@astrojs/node 为 11.1.4。Qwik 根本没有 稳定的 2.x——最新是 2.0.0-beta.41,而 platform 一直跑在 2.0.0-beta.32 上。

范围

范围之内

  • 新应用 frontend/astro/apps/rental,开发用 node 适配器、构建用 Cloudflare,沿用 quizplatform 已有的 ASTRO_ADAPTER 模式
  • ADR-0056,记录产品边界与直连 Go 的例外
  • 搜索页:地区、价格区间、房产类型、家具配置
  • 房源详情页 /property/{slug},含照片、租赁条款、房屋规则和"Direct from Owner"标识
  • "Arrange Viewing"链接进入切片 2 在 panel 中的预约入口路由
  • SEO:每条房源的标题与描述、Open Graph、canonical URL、JSON-LD、sitemap.xmlrobots.txt
  • 缓存头,让 Cloudflare 承接重复流量,包含 stale-if-error
  • API 不可用时的降级状态
  • 通过 /cdn-cgi/image 做边缘图片压缩,避免 5MB 的房东照片原样下发
  • 一份精选的可索引地区白名单,同时用于生成筛选下拉框
  • Cloudflare Web Analytics,外加一个"Arrange Viewing"点击事件

明确排除在外

任何需要登录的页面、Seeker 账号体系、收藏与心愿单、地图视图、房贷计算器、中介房源、手工撰写的 地区落地页、评价或评分展示、英语以外的语言、GA4 或任何基于 cookie 的分析,以及任何 Bun 代理。

刻意不处理

缓存清除
下架一条房源后,其公开页面最多还会保持热缓存五分钟。不实现任何 purge 调用。
slug 枚举接口
sitemap 通过现有的分页 API 逐页翻取。
升级已有的三个 Astro 应用
在有人主动升级之前,它们继续留在 6。
area 规范化
白名单在不改动切片 1 schema 的前提下控制住了影响范围。引用表是后续的升级路径。
slug 重定向
slug 不可变(ADR-0057),所以既没有历史表也没有 301 处理。正因如此,不做才是安全的。

缓存

响应缓存头
房源详情public, s-maxage=300, stale-while-revalidate=3600, stale-if-error=86400
搜索结果public, s-maxage=60, stale-while-revalidate=300, stale-if-error=86400
降级或报错no-store

搜索结果的窗口更短,因为筛选组合会让缓存条目成倍增长,而每一条重新生成的成本都很低。真正值得长期 保留的是详情页。

SEO 与索引规则

  • 每条房源的标题与描述由房源数据拼装——例如"3-bedroom condo for rent in Petaling Jaya — RM2,000/month"——描述取自房东填写的文本并截断。
  • Open Graph 标签携带封面照片,让 WhatsApp 分享能渲染成卡片。在这个市场,这是实实在在的流量来源。
  • 每个详情页加上 JSON-LD RealEstateListing
  • sitemap.xml 由在架房源生成,缓存一小时。
  • robots.txt 全部允许,并指向 sitemap。

切片 1 的公开 API 是分页的,且无法一次性取出全部在架 slug,因此 sitemap 路由会逐页翻取直到取尽, 并设一个硬上限。以当前的数据量而言,那就是一次请求。如果房源数量真的涨到几千条,升级路径是在 Go 侧提供一个专门的 slug 接口。

图片

在边缘压缩
lib/image.ts 在 R2 公开照片 URL 之上拼出 /cdn-cgi/image/width=…,format=auto,…。没有构建步骤、没有图片流水线、没有依赖。
显式尺寸
每个 <img> 都带 widthheight,取自新增的照片尺寸字段,让浏览器预留正确的位置,把 CLS 保持为零。
srcset
一小组固定宽度,避免手机下载桌面尺寸的图片。
懒加载
除 LCP 图片外,其余一律 loading="lazy"——/ 上第一张卡片的照片,以及详情页的封面照片除外。对 LCP 图片做懒加载,恰恰会拖慢被测量的那一次绘制。
退路
如果该 zone 上 /cdn-cgi/image 实际不可用(交付第 1 步验证):改为在切片 1 的上传环节限制图片尺寸,其余保持不变。

本设计对切片 1 的要求

#要求原因
1 rental_property_photos 增加 widthheight 字段,在上传时从图片头信息读取写入,并在公开响应中暴露 零 CLS 渲染。切片 1 在上传时本来就要读取文件以执行 5MB 和文件类型限制,所以那一刻尺寸信息已经在手上
2 公开搜索响应增加 areas facet——所有在架房源中出现过的去重地区,与当前筛选条件无关 筛选下拉框取白名单 ∪ 在架地区。搜索接口是分页的,第 1 页上的地区并不等于数据中的地区。在既有响应上加一个 facet 不产生额外请求,还能沿用现有缓存头
3 公开路径为 /api/public/rental/…,不带版本号 已在 ADR-0056 中钉住,两侧不会各走各的
4 按 ADR-0057 的 slug 规则:仅当 slug 为 null 时、在首次转为 live 时生成;构成为 {bedrooms}-bedroom-{property_type}-{area}-{suffix};始终带 4 字符后缀;slugify 后为空的部分丢弃;最坏情况下只保留后缀;永远不因 slug 而导致审核失败 /property/{slug} 正是 Google 索引的对象,也是人们粘贴到 WhatsApp 里的东西

Arrange Viewing 的交接

这是整个产品唯一的转化时刻,也是 Seeker 变成 Tenant 的地方。按定义每个 Seeker 都未登录, 所以这一次点击必然会撞上注册墙。

交接流程,含缓存过期那条分支。slug 必须在注册往返过程中存活;一旦丢失,Seeker 会落在一个空列表 前,而他来时惦记的那套房子已无从找回。
交接流程,含缓存过期那条分支。slug 必须在注册往返过程中存活;一旦丢失,Seeker 会落在一个空列表 前,而他来时惦记的那套房子已无从找回。

本应用这一侧

  • 按钮文案保持为 "Arrange Viewing"。曾考虑过:"Sign up to arrange viewing"——已否决, 它等于在一个本该激发兴趣的页面上预先张贴阻力。点击事件才能告诉我们,这份阻力究竟有没有真的损害转化。
  • 它链接到 https://panel.gremlin.my/tenant/book/{slug}——指向具体那条房源,而不是一个列表。

切片 2 必须补上的部分

  1. 一个 /tenant/book/{slug} 路由

    切片 2 目前只有 /tenant/requests,那是一个列表——把一个匿名访客丢在那里,就等于弄丢了他专程而来的那套房子。

  2. slug 要熬过注册往返

    注册完成后,Seeker 必须回到那个确切的路由。共享同一个可注册域名,所以这属于同站状态,不是跨域问题。

  3. 一个"已不可用"状态

    该路由在加载时解析 slug;当它已无法解析时,显示"This property is no longer available"并给出返回 rent.gremlin.my 的链接——绝不能是裸的 404。这正是"不做缓存清除"这一取舍的可见症状;设计接受了这种滞后,而这里就是处理它的地方。

隐私边界与错误处理

场景行为
API 超时或不可达,页面已缓存Cloudflare 通过 stale-if-error 提供旧副本;Seeker 看到的是一个正常的站点
API 超时或不可达,页面从未缓存"Temporarily unavailable"页面,HTTP 503,no-store
未知 slug404 页面,带一个返回搜索的链接
房源已不在架走同一条 404 路径——API 只返回在架房源
非法筛选值,例如 min_rent=abc忽略并视为未设置。绝不返回 500
页码超出范围空状态,不是错误
无结果朴素的空状态,提示放宽筛选条件
Seeker 点击"Arrange Viewing",而该房源在过去 5 分钟内被下架由 panel 一侧处理,不在这里。本应用按设计就无从知晓:它没有 purge

度量

本设计中没有任何其他部分能告诉你它是否奏效,而缺了这一点,你就无法区分是房源供给的问题,还是租客 需求的问题。

  • Cloudflare Web Analytics——一个 script 标签,免费,不使用 cookie,因此不产生 PDPA 同意义务,也不需要同意横幅。
  • 在"Arrange Viewing"点击上加一个自定义事件,这才是真正关键的数字:它能区分"没人来"和"有人来但不愿注册",也决定了"先渲染预约表单、之后再要求注册"这个方案是否值得去做。
  • GA4 按决策排除在外。它会引入这个站点本来没有的 cookie 同意义务。

如果 Cloudflare Web Analytics 实际上不支持自定义事件,退路是把这项缺失的度量记为已知缺口, 而不是为了一个数字引入更重的分析栈。

测试

  • rental-api.ts 单元测试——查询串拼装、响应解析、超时行为和错误结构,针对一个 stub 服务。沿用既有的 apps/quiz/src/middleware.test.ts 先例。
  • 泄露测试——渲染一个房源详情页,断言 HTML 中不出现电话、邮箱、房东姓名或详细 address_line
  • 契约漂移测试——把 rental-api.ts 加入切片 1 公开 golden fixture 的客户端清单,断言 client ⊆ Go(ADR-0052 模式)。
  • 索引规则单元测试——针对判定 index 还是 noindex, follow 的那个函数,覆盖:裸 /、仅白名单地区、仅非白名单地区、地区加任意第二个筛选,以及 page=2。这条规则在渲染结果中看不出来,很容易被悄悄改坏,正因如此才值得一个测试。
  • 一条 Playwright 主流程——进入 /、按地区筛选、打开一条房源,确认"Arrange Viewing"指向 panel.gremlin.my/tenant/book/{slug}

刻意不做:视觉回归、Lighthouse CI、跨浏览器矩阵。

交付顺序

  1. 搭建 apps/rental

    Astro 7 + Cloudflare 适配器,部署到 rent.gremlin.my。同时充当上述四合一探路。

  2. ADR-0056 与 ADR-0057

    已完成——两份都写在实现之前,而不是实现过程中。

  3. rental-api.ts 及其单元测试

    基于 stub 构建。

  4. 搜索页

    筛选表单、结果列表、空状态。

  5. ClientRouter 与 prefetch 的布局

    实现无刷新导航。

  6. 房源详情页

    照片相册、泄露测试。

  7. SEO

    标题、Open Graph、canonical 规则、对非白名单筛选加 noindex、JSON-LD、robots.txtsitemap.xml

  8. 缓存头与降级状态
  9. "Arrange Viewing"链接进入 panel
  10. Playwright 主流程,部署

待验证项与开放问题

本设计提出的全部 22 个问题,已在 2026-08-26 的一次拷问(grilling)中全部敲定,结论都已并入上面 各章节。剩下的不是问题,而是一份交付第 1 步需要验证的清单,每一项都已写明退路。

待验证失败时的退路
Astro 7 中 ClientRouter 会拦截 GET 表单提交用一个调用 navigate() 的小 Qwik island
Cloudflare 遵守 stale-if-error未缓存请求维持原有的 503 页面
该 zone 上已启用 /cdn-cgi/image 转换改为在切片 1 的上传环节限制图片尺寸
rent.gremlin.my 的自定义域名绑定与 DNS 记录能正常签发
两个 Astro 大版本给 Turbo 和共享类型带来多少摩擦shared-ui 只有两个 Astro 组件,且由使用方应用自行编译,因此暴露面看起来很小