English

DIY Rental Platform — Slice 2: Viewings, Messaging, and Reminders

Slice 2 of the Malaysian rental marketplace: tenants propose viewing times, owners confirm or counter inside a per-request message thread, contact details reveal only on confirmation, and a 24-hour reminder fires by email. A same-day grilling session resolved 17 design gaps — a terminology collision with the platform's multi-tenancy vocabulary, the fact that slice 1 is still unbuilt, state-machine reachability, a missing terminal status for lapsed viewings, and per-transition email and reveal rules — before implementation starts.

  • Predecessor Slice 1 — Listings and Owner Onboarding (2026-08-24-rental-platform-slice1-listings-design)
  • ADRs 0006 · 0054 · 0055
  • Status Approved

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

Slice 2 要证明的是:供需双方无需中介也能对接——租客提出看房时间,房东在平台内确认,联系方式只有在那一刻才会公开,并在看房前 24 小时向双方发送提醒。同一天的一场"拷问式"评审会议在开工前解决了 17 个设计缺口——一个术语冲突、一个尚未构建的 slice 1 依赖、状态机可达性问题、缺失的终态状态,以及逐个转换的邮件/公开规则。

  • 状态设计已批准,可进入实施计划
  • 依赖于Slice 1 — 房源与房东入驻(尚未构建,后端 0/14、前端 0/23 任务完成)
  • 相关 ADRADR-0006(Better Auth 只读消费者)、ADR-0054(独立 quiz 鉴权,已否决的模式)、ADR-0055(rental 共享平台数据库/鉴权)
  • 评审17 个问题,均于 2026-08-24 通过

1. Slice 2 要证明什么

租客找到一个房源,申请看房,在平台内与房东约定时间,双方如约到场——由平台而非 WhatsApp 来保存这份记录。

Slice 1 证明了供给端:房东愿意上架房源。Slice 2 要证明的是无需中介即可实现供需对接。如果租客不愿意通过平台预约,后续任何 slice 都不值得开发。

成功标准

  • 租客无需在平台外联系房东即可预约看房
  • 房东在面板中确认时间,双方都会收到邮件
  • 联系方式和确切地址只有在确认之后才会出现,绝不提前
  • 看房前 24 小时,双方都会收到提醒

2. 代码库目前还没有的东西

探索过程中发现三个缺口,每一个都影响了本设计。

3. 范围

范围内

  • 租客身份 — 任何已登录用户都可以预约看房。没有 tenant 角色,不改动 Better Auth 架构。注册流程只复用 slice 1 的 Better Auth 注册页面和会话逻辑——绝不复用其房东产权声明步骤,那一步不适用于租客。
  • 看房请求 — 租客最多提出 3 个时间;房东确认其中一个、以不同时间反提议,或附理由拒绝
  • 消息线程 — 看房请求本身就是线程。异步,轮询获取。
  • 确认后公开联系方式 — 确认前双方只能看到名字;确认后双方可看到全名、电话,以及 slice 1 刻意对公众隐藏的确切 address_line
  • 联系人表rental_contacts,以 user_id 为键,存储电话号码。房东和租客共用同一张表。
  • 提醒 — 确认看房前 24 小时,向双方发送
  • 可用的邮件发送器 — 以共享基础设施取代占位实现,位于一个渠道接口之后,方便日后加入 WhatsApp
  • 租客面板区域routes/tenant.tsx/tenant/requests,对应 slice 1 的 /owner/*

范围外(决策所定)

要约、租赁合同、电子签名、付款、押金、评分、失约记录、租客审核与信用调查、收藏房源、WhatsApp 投递、实时聊天、日历与可预约时段、看房结果记录。

刻意不处理的事项

  • 失约。 没有任何记录能说明看房是否真的发生了。这是一个真实的问题,但目前没有数据可供设计参考。
  • 垃圾信息与滥用。 预约需要已登录账号加一个已登记的电话号码,在目前的量级下这已构成足够的门槛。当真的有人滥用时才会加入限流与封锁。
  • 已读回执、输入提示、逐条未读计数。 每条消息一个 read_at 列,就是整个功能。
  • 时区。 一切均以 Asia/Kuala_Lumpur 为准,以 UTC 存储。
  • 消息中提前泄露联系方式。 消息是自由文本,不做电话/地址过滤。接受但不做缓解:公开规则保护的是一个安全属性(一旦看房是真实的,双方能互相联系上),提前泄露只会强化这个属性,绝不会削弱它。
  • 管理员对请求和消息的可见性。 Slice 2 中没有管理员端点。极少数早期投诉通过直接访问数据库处理,与 slice 1 在其他地方依赖的轻量工具一致。

4. 决策

看房如何预约?

先请求后确认,而非发布可预约时段——租客提出 2–3 个时间,房东接受其中一个。

这符合马来西亚人已经在用 WhatsApp 约看房的方式,也避免要求房东维护一份他们不会维护的日历。

已否决:发布可预约时段模型——需要三张表和并发锁,才能提供一个目前没人要求的便利。

是否需要通用聊天功能?

看房请求本身就是线程——租客的提议是第一条消息,房东的回复是第二条。

只需一张表,无需单独的"开始聊天"流程,而且每个对话天然就挂在一个真实房源和一个真实意图之下。

已否决:自由形式的房源咨询——它们会产生没有明确意图的线程,并立刻需要自己的垃圾信息与滥用处理机制。

租客需要新角色吗?

不设 tenant 角色。任何已登录用户都可以预约;授权完全基于 tenant_user_id 的行级检查。

users.role 是单值字段,而在本地,一个房东同时也想租房是很常见的情况——一个 tenant 令牌会把房东挡在预约之外。

users.role 改为多值字段才是正确的长期方案,但它会涉及 Better Auth 的架构、buildRoles,以及每一处 RequireOrgRole 调用点——这是一个远超 slice 2 范围的横切改动。

租客在哪里预约?

租客界面放在面板中(routes/tenant.tsx),而非公开的 Astro 站点——与 slice 1 对房东的决策一致。

Astro 公开站点保持完全匿名和可被 CDN 缓存。

已否决:直接在 Astro 中做需要登录的预约(quiz 应用的模式,ADR-0054)——这会在 Cloudflare Workers 上增加第二个鉴权入口,并让浏览页面失去整页缓存。接受的代价:租客需要切换到第二个域名去登录。

现在用邮件还是 WhatsApp?

先用邮件上线,放在一个 Sender 接口之后,日后 WhatsApp 作为第二个适配器接入。

WhatsApp Business API 需要 Meta 认证和已批准的模板——在任何一行功能代码上线之前,需要数周的外部流程。

这个接口目前只有一个实现,按项目"不为单一用途做抽象"的规则本应算违规;但这是刻意买下的成本,因为 WhatsApp 是一个已被明确点名的下一个适配器,不是空想的可能性。

5. 架构

部分位置新建或复用
后端扩展 slice 1 的 rental 模块——在 internal/modules/rental/ 内新增领域概念复用:模块、profile 条目、路由分组、AuthMiddleware。无需新注册任何东西。
邮件新建 internal/shared/infrastructure/email/——一个 Sender 接口,一个 Resend HTTP 适配器新建。与 scheduler/storage/messaging/ 并列。
提醒任务scheduler.RunPeriodic原样复用。
迁移追加到 slice 1 的 migrator_rental.go复用。
租客界面面板 routes/tenant.tsx/tenant/requests新建布局文件,复制自 slice 1 的 owner.tsx
房东界面slice 1 已有的 owner.tsx 下新增 /owner/requests复用布局。
公开站点/property/{slug} 上的"预约看房"按钮不再禁用,改为链接到面板Astro 保持匿名和 CDN 缓存。

现有的 notification_sender.go 占位实现保持不变。让它指向新发送器是另一个独立改动,不属于本设计范围。Rental 仍然不使用 notifications 模块:它需要一个组织,而房东或租客都没有组织——这一约束延续自 slice 1,本设计不做改动。

6. 数据模型

三张表,追加到 migrator_rental.go

rental_viewing_requests
id, listing_id, tenant_user_id, owner_user_id, status, proposed_times (jsonb 数组), confirmed_time, decline_reason, reminder_sent_at, created_at, updated_atowner_user_id 从房源反规范化而来,这样授权检查无需联表。status 取值为 pending、countered、confirmed、completed、declined、withdrawn、cancelled 之一。
rental_messages
id, request_id, sender_user_id, body, read_at, created_at。反提议(counter)和重新提议(re-propose)转换也会在这里插入一行——body 保存一段服务端格式化的字符串,描述新提议的时间,作为普通线程文本渲染,与其他消息无异。没有 kind 列:线程不需要把一次提议渲染得和聊天消息不一样。
rental_contacts
user_id(主键)、phonecreated_atupdated_at。只有一个实质字段,因为 ADR-0006 禁止写入 Better Auth 的 users 表。phone 在写入时被规范化为 E.164 格式(+60...)并校验;格式错误的值会在 API 层被拒绝——WhatsApp 是 Sender 接口已点名的下一个适配器,所以号码从一开始就以它日后需要的格式存储。

7. 请求状态机

看房请求状态机——pending/countered 协商循环,之后 confirmed 分支为 cancelled 或 completed。
看房请求状态机——pending/countered 协商循环,之后 confirmed 分支为 cancelled 或 completed。

countered 表示房东提出了不同的时间,球现在在租客这边。它只是一个枚举值,不是第二张表。proposed_times 是一个单一的当前状态字段——无论哪一方刚提出,都会覆盖它,而 status 会告诉读者当前桌面上放着的是谁的时间。反提议和重新提议都会额外向 rental_messages 插入一行,携带新的时间,这样即便字段本身只保存当前的提议,线程也能保留完整的协商历史。

declinedwithdrawn 都可以从 pendingcountered 到达——在确认之前,任何一方都可以在任意时点终止请求。请求在 pendingcountered 之间可以往返多少次没有上限;一个非终态请求的限制规则已经限制了滥用,而真实的协商很少超过两三轮。

终态状态是 declinedwithdrawncancelledcompletedpendingcounteredconfirmed 都是非终态,因此一个持有未结束或已确认请求的租客,在该请求结束之前无法为同一房源再开一个新请求。

8. 授权

端点分组检查
创建请求AuthMiddleware——任何已登录用户,外加已登记的电话号码
读取、发消息,或对请求做操作AuthMiddleware 加上会话用户是 tenant_user_id 或 owner_user_id 二者之一
仅房东可执行的操作(确认、反提议、拒绝)上述条件,并且会话用户是 owner_user_id
房东确认请求上述条件,并且 rental_contacts 中有已登记的电话号码——对应租客创建请求时的电话门槛
管理员无——slice 2 中没有管理员端点;极少数早期投诉通过直接访问数据库处理
公开房源 API不变——slice 1 的黄金测试仍然对其把关

没有新角色令牌,没有 RequireOrgRole,不改动 Better Auth。这是 slice 1 已确立的同一套行级模式,只是现在一行数据的两端是两个无组织用户。

9. 流程

流程 A — 租客申请看房

  1. 登录或注册

    租客在 Astro 房源页点击"预约看房",进入面板——只复用 slice 1 的 Better Auth 注册页面,不复用其房东产权声明步骤。

  2. 电话门槛

    如果还没有登记电话,会出现一个单字段表单写入 rental_contacts,并规范化为 E.164。这一限制在用例层强制执行,不仅仅在表单上。

  3. 提议

    租客最多选择 3 个时间,并可选写一条初始消息。请求以 pending 状态创建,第一条消息在同一事务中写入。

  4. 通知房东

    房东在 /owner/requests 看到它。系统发送一封邮件——"有人想看你的房源"——正文中不含任何租客细节。

  5. 确认

    房东确认其中一个时间——如果房东没有登记电话则会被阻止。状态变为 confirmed,双方都能看到全名、电话和确切地址,双方都会收到一封带有该公开信息的邮件。

  6. 或反提议

    房东以不同时间反提议。状态变为 countered,一条消息记录新的时间。不发邮件——反提议只是协商中的一步。

  7. 或拒绝

    房东附理由拒绝。终态。系统只发送包含状态和理由的邮件——不含电话,不含地址。

  8. 重新提议

    租客可以从 countered 状态重新提议,请求回到 pending,并新增一条消息记录新的时间。这个循环没有次数上限。

  9. 取消

    任何一方都可以在看房发生之前取消一个已确认的看房。邮件会发送给双方,并可能提及地址——反正在确认时已经公开过了。

  10. 完成

    如果没人取消,15 分钟扫描任务会在 confirmed_time 超过 24 小时后把请求转为 completed。不发邮件——这是一次静默的后台转换。

流程 B — 线程

GET /api/v1/rental/requests/{id} 返回请求及其消息。POST /api/v1/rental/requests/{id}/messages 追加一条消息。页面打开时按固定间隔轮询——没有 websocket,没有 server-sent events。两个端点执行同样的"双方之一"行级检查。反提议和重新提议(流程 A)也写入同一张 rental_messages 表,与自由文本聊天一样——body 保存一段服务端格式化的字符串,没有单独的 kind 列来区分它们。

流程 C — 提醒

scheduler.RunPeriodic 每 15 分钟运行一次——刻意不用 RunPeriodicNow,这样一次部署不会让所有副本同时扫描。

UPDATE rental_viewing_requests
   SET reminder_sent_at = now()
 WHERE status = 'confirmed'
   AND confirmed_time > now()
   AND confirmed_time <= now() + interval '24 hours'
   AND reminder_sent_at IS NULL
RETURNING id, ...

先认领后发送。正是这一点让扫描任务能够在多副本之间安全运行,而无需锁表或任务队列。这里的权衡是真实且被接受的:如果之后邮件发送失败,那条提醒就会丢失而不会重试。同样的窄时间窗也适用于取消与之竞争的情况——出于同样的理由被接受,一条针对刚被取消的看房发出的多余提醒只是无害的噪音,并不属于这个权衡真正要防范的重复发送或漏发问题。

流程 D — 完成

与流程 C 共用同一个 scheduler.RunPeriodic 15 分钟节拍,多一条查询:

UPDATE rental_viewing_requests
   SET status = 'completed'
 WHERE status = 'confirmed'
   AND confirmed_time < now() - interval '24 hours'
RETURNING id

不做任何出席或失约的判断——它只是关闭一个已过期的预约,让"每对只能有一个非终态请求"的规则不再挡住租客再次预约同一房源。不发邮件;由于请求已经经历过 confirmed,在 completed 状态下联系方式仍然保持公开。

10. 错误处理

情况行为
租客没有登记电话在用例层阻止请求,直到 rental_contacts 中有一条记录
房东确认时没有登记电话阻止确认,直到 rental_contacts 中有一条记录——对应租客的门槛
电话号码格式错误在 API 层拒绝——先规范化为 E.164
租客对同一房源重复预约阻止——每对(房源,租客)只能有一个非终态请求
租客预约自己的房源阻止——tenant_user_id 不能等于 owner_user_id
请求进行中时房源被暂停或下架已有请求保持不变;新请求被拒绝
房东确认一个已经确认过的请求幂等——不会重复发邮件,状态也不会变动
提议的时间在过去在 API 层拒绝
提议的时间超过 3 个在 API 层拒绝
邮件发送失败记录日志;请求状态不受影响。投递操作永远不与状态变更处于同一事务中。

刻意不处理:失约记录、消息编辑与删除,以及消息中的附件。

11. 测试

  • 领域测试对每一次转换做表驱动测试,包括 pending↔countered 循环(含其消息记录)、从任一状态拒绝/撤回,以及确认后取消
  • 授权测试用户 C 无法读取或发消息到 A↔B 的请求——第一个涉及一行数据两端都是无组织用户的测试
  • 公开信息黄金测试覆盖每个状态:pending/countered/declined/withdrawn 绝不携带电话或 address_line;confirmed/completed/cancelled 始终携带——slice 2 的泄露警报
  • 提醒认领测试两次并发扫描,恰好只有一次胜出
  • 完成扫描测试一个 confirmed_time 已超过 24 小时的已确认请求会变为 completed,从而解除对新请求的阻挡
  • 邮件适配器测试针对一个桩 HTTP 服务器进行。CI 中不做真实发送。
  • Playwright 端到端测试租客发起请求,房东确认,双方都能看到电话号码

12. 交付顺序

每一步结束时都应有可观察的结果。除第 2 步外,其余每一步都依赖 slice 1 先上线——第 2 步不依赖 rental 模块,可以独立交付。

  1. 联系人

    rental_contacts 及电话相关端点,规范化为 E.164 → 通过 curl 验证电话可写入并可读回

  2. 邮件发送器(解耦)

    共享基础设施中的邮件发送器,加上适配器测试 → 一封真实邮件到达测试收件箱。不依赖 slice 1——可以在 slice 1 之前交付。

  3. 请求生命周期

    看房请求的增删改查、包含 completed 转换的状态机、授权测试 → 通过 curl 验证完整生命周期

  4. 公开规则

    公开规则及黄金测试 → 确认前不泄露任何信息,且在 completedcancelled 状态下公开规则依然成立

  5. 消息

    消息端点,包括反提议/重新提议产生的消息记录 → 通过 curl 验证线程可用

  6. 提醒 + 完成

    提醒扫描、认领测试,以及完成扫描 → 提醒在 24 小时前触发,已过期的已确认请求会完成

  7. 租客面板

    面板 /tenant/* 布局及请求界面 → 租客可以在浏览器中完成预约

  8. 房东面板

    面板 /owner/requests 界面 → 房东可以在浏览器中完成确认

  9. 公开站点

    Astro 的"预约看房"按钮正式上线 → 闭环完成

  10. 部署

    Playwright 端到端测试,部署

后端先完成到第 6 步,这样前端工作就不会被 API 形状卡住——与 slice 1 的纪律一致。

13. 这为后续解锁了什么

Slice 3(要约 → 租赁合同 → 电子签名)需要三样只有在 slice 2 之后才存在的东西:一个绑定到具体房源的租客身份、双方之间的对话记录,以及一个可用于发送待签署文件的出站投递渠道。Slice 2 三者齐备。