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.
Slice 2 要证明的是:供需双方无需中介也能对接——租客提出看房时间,房东在平台内确认,联系方式只有在那一刻才会公开,并在看房前 24 小时向双方发送提醒。同一天的一场"拷问式"评审会议在开工前解决了 17 个设计缺口——一个术语冲突、一个尚未构建的 slice 1 依赖、状态机可达性问题、缺失的终态状态,以及逐个转换的邮件/公开规则。
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_requestsid, listing_id, tenant_user_id, owner_user_id, status, proposed_times (jsonb 数组), confirmed_time, decline_reason, reminder_sent_at, created_at, updated_at。owner_user_id从房源反规范化而来,这样授权检查无需联表。status取值为pending、countered、confirmed、completed、declined、withdrawn、cancelled之一。rental_messagesid, request_id, sender_user_id, body, read_at, created_at。反提议(counter)和重新提议(re-propose)转换也会在这里插入一行——body保存一段服务端格式化的字符串,描述新提议的时间,作为普通线程文本渲染,与其他消息无异。没有kind列:线程不需要把一次提议渲染得和聊天消息不一样。rental_contactsuser_id(主键)、phone、created_at、updated_at。只有一个实质字段,因为 ADR-0006 禁止写入 Better Auth 的users表。phone在写入时被规范化为 E.164 格式(+60...)并校验;格式错误的值会在 API 层被拒绝——WhatsApp 是Sender接口已点名的下一个适配器,所以号码从一开始就以它日后需要的格式存储。
7. 请求状态机
countered 表示房东提出了不同的时间,球现在在租客这边。它只是一个枚举值,不是第二张表。proposed_times 是一个单一的当前状态字段——无论哪一方刚提出,都会覆盖它,而 status 会告诉读者当前桌面上放着的是谁的时间。反提议和重新提议都会额外向 rental_messages 插入一行,携带新的时间,这样即便字段本身只保存当前的提议,线程也能保留完整的协商历史。
declined 和 withdrawn 都可以从 pending 或 countered 到达——在确认之前,任何一方都可以在任意时点终止请求。请求在 pending 与 countered 之间可以往返多少次没有上限;一个非终态请求的限制规则已经限制了滥用,而真实的协商很少超过两三轮。
终态状态是 declined、withdrawn、cancelled 和 completed。pending、countered、confirmed 都是非终态,因此一个持有未结束或已确认请求的租客,在该请求结束之前无法为同一房源再开一个新请求。
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 — 租客申请看房
- 登录或注册
租客在 Astro 房源页点击"预约看房",进入面板——只复用 slice 1 的 Better Auth 注册页面,不复用其房东产权声明步骤。
- 电话门槛
如果还没有登记电话,会出现一个单字段表单写入
rental_contacts,并规范化为 E.164。这一限制在用例层强制执行,不仅仅在表单上。 - 提议
租客最多选择 3 个时间,并可选写一条初始消息。请求以
pending状态创建,第一条消息在同一事务中写入。 - 通知房东
房东在
/owner/requests看到它。系统发送一封邮件——"有人想看你的房源"——正文中不含任何租客细节。 - 确认
房东确认其中一个时间——如果房东没有登记电话则会被阻止。状态变为
confirmed,双方都能看到全名、电话和确切地址,双方都会收到一封带有该公开信息的邮件。 - 或反提议
房东以不同时间反提议。状态变为
countered,一条消息记录新的时间。不发邮件——反提议只是协商中的一步。 - 或拒绝
房东附理由拒绝。终态。系统只发送包含状态和理由的邮件——不含电话,不含地址。
- 重新提议
租客可以从
countered状态重新提议,请求回到pending,并新增一条消息记录新的时间。这个循环没有次数上限。 - 取消
任何一方都可以在看房发生之前取消一个已确认的看房。邮件会发送给双方,并可能提及地址——反正在确认时已经公开过了。
- 完成
如果没人取消,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 模块,可以独立交付。
- 联系人
rental_contacts及电话相关端点,规范化为 E.164 → 通过 curl 验证电话可写入并可读回 - 邮件发送器(解耦)
共享基础设施中的邮件发送器,加上适配器测试 → 一封真实邮件到达测试收件箱。不依赖 slice 1——可以在 slice 1 之前交付。
- 请求生命周期
看房请求的增删改查、包含
completed转换的状态机、授权测试 → 通过 curl 验证完整生命周期 - 公开规则
公开规则及黄金测试 → 确认前不泄露任何信息,且在
completed与cancelled状态下公开规则依然成立 - 消息
消息端点,包括反提议/重新提议产生的消息记录 → 通过 curl 验证线程可用
- 提醒 + 完成
提醒扫描、认领测试,以及完成扫描 → 提醒在 24 小时前触发,已过期的已确认请求会完成
- 租客面板
面板
/tenant/*布局及请求界面 → 租客可以在浏览器中完成预约 - 房东面板
面板
/owner/requests界面 → 房东可以在浏览器中完成确认 - 公开站点
Astro 的"预约看房"按钮正式上线 → 闭环完成
- 部署
Playwright 端到端测试,部署
后端先完成到第 6 步,这样前端工作就不会被 API 形状卡住——与 slice 1 的纪律一致。
13. 这为后续解锁了什么
Slice 3(要约 → 租赁合同 → 电子签名)需要三样只有在 slice 2 之后才存在的东西:一个绑定到具体房源的租客身份、双方之间的对话记录,以及一个可用于发送待签署文件的出站投递渠道。Slice 2 三者齐备。