DIY Rental Platform — Slice 3: Offers, Tenancy Agreements, and E-Signature
Slice 3 is where the Malaysian rental platform stops being a noticeboard and becomes the place the deal closes: a tenant with a confirmed viewing offers terms, the landlord counters or accepts, and the platform issues a lawyer-reviewed tenancy agreement that both parties sign inside the app. A grilling session on 2026-08-26 settled 23 open questions and produced three ADRs — documents frozen and content-addressed at issue, click-to-sign under the Electronic Commerce Act 2006, and one blank-filling template under the Legal Profession Act 1976 s.37.
第三阶段是租赁平台从「告示板」变成「真正成交场所」的分水岭:看过房的租客提出条件,房东还价或接受, 平台随即生成一份经律师审阅的租约,双方在应用内完成签署。普通案件不需要中介,不需要律师, 也没有任何资金流经平台。
概览
- 依赖
- 第一阶段(房源列表,已合并于
58c47ceb)与第二阶段(看房,仅完成设计) - 新增表
rental_offers、rental_tenancy_agreements、rental_agreement_signatures- 修改的表
rental_contacts新增full_name、nric、address_line- 新增依赖库
- 无 ——
github.com/phpdave11/gofpdf已被发票模块使用 - 新增角色令牌
- 无 —— 仅使用行级校验,与第一、第二阶段一致
- 术语表
CONTEXT.md— Offer、Tenancy Agreement、Issue、Frozen Document、Void、Special Conditions、NRIC
成功标准
- 已确认看房的租客可以提出条件,房东可以还价或接受。
- 房东接受后,平台生成一份用真实数据填充的租约 PDF。
- 双方在平台内签署,签署后的 PDF 双方均可下载。
- 签署文件具备防篡改证据:每一次签署都记录所签文件的哈希值。
- 房源以
rented状态离开公开市场页面。 - 没有任何资金流经平台。
范围与非目标
范围内
第三阶段- 基于已确认看房请求的报价与还价
- 当事人资料:身份证姓名、身份证号码、通信地址
- 由一份固定的、经律师审阅的模板生成租约
- 双方点击式签署,并留存证据链
- 冻结文件与签署文件,仅限双方下载
- 新增终结状态
rented
范围外
经决策排除- 印花税与 LHDN 盖印
- 任何形式的资金 —— 押金、支付网关、第三方托管
- 租客背景审查与信用调查
- 条款选择器、续约、提前终止
- 交房物品清单
- 手写签名图像;DSA 1997 认证签名
- 第三方电子签署服务商
已知缺口
已接受- 租约未盖印,盖印前不可在法庭举证
- 身份证号码由用户自行申报,未经核验
- 不支持编辑 —— 变更即作废并重新签发
- 报价没有有效期,不会自动拒绝
- 每方仅一位签署人;联名租客延后处理
- 账号删除后记录仍会保留
每项非目标被排除的原因
- 盖印
- 租约在双方之间有效,但未盖印前不可在法庭举证。自动化意味着接入 LHDN 并走它自己的审批流程 —— 那是一个项目,不是一个功能。平台会在界面上明确说明这一点。
- 身份核验
- 身份证号码由用户自行申报,租客可以随便输入。第一阶段已将背景审查排除在外,而在当前业务量下核验带来的摩擦过大。一旦出现欺诈,这是第一个要补上的功能。
- 编辑已签发的租约
- 不支持编辑。变更意味着作废并重新签发一份 —— 纸质流程也是这么做的。
- 报价有效期
- 没有计时器,也不会自动拒绝。「每次看房只有一份有效报价」的规则已经限制了混乱程度,而过期报价首先是产品问题,其次才是数据问题。
- 未看房即报价
- 报价必须基于一次已确认的看房请求 —— 这直接复用第二阶段的判定条件,而不是另立规则。
- 联名租客
- 每方仅一位签署人。签署记录本来就存放在以
(agreement_id, signer_user_id)为键、带signer_role的独立表中,而完成判定是从租约行推导出「应签署人集合」,而不是数到二 —— 所以日后增加联名租客只需改这段推导,不需要数据迁移。 - 数据保留与账号删除
- 租约、签署记录与两份 PDF 都是法律记录:予以保留,且不会随用户删除账号而删除。只有
rental_contacts是可删除的个人资料行。真正的数据保留策略应归属于第一个处理全平台账号删除的阶段。
关键决策
在签发时冻结文件,而不是按需渲染
房东接受报价时,PDF 只渲染一次、存储并计算哈希。该对象永不改变;双方签署的正是这个文件。ADR-0058。
双方在不同时间签署是常态。如果在第一次与第二次签署之间模板或渲染代码发生变化,那么两位当事人签的就是不同的文件, 「我到底签了什么?」这个问题将无法可靠回答。冻结正是审计链条价值的来源;没有它,链条记录的是针对一个移动目标的签名。
已否决:从 terms 按需渲染(更省事,但渲染器一变,保证立刻失效);按需渲染并维护模板注册表
(修复了正确性问题,代价是要维护注册表,以及渲染代码必须与自己的历史缺陷保持兼容)。
点击式签署,不用手写签名,也不用 PKI
每方输入身份证姓名与号码,勾选同意后提交。平台记录 user_id、时间戳、IP、User-Agent,以及其所见文件的 SHA-256。ADR-0059。
这在《2006 年电子商务法》下是有效的,该法并未将租约排除在适用范围之外。让证据链有说服力的不是签名的外观, 而是能够证明「哪份文件、由哪个账号、在什么时间」被签署。
已否决:手写签名图像(看着熟悉,但没有额外法律效力,前端成本却是真的);DSA 1997 认证签名 (证据力更强,但每一方都要持有 CA 证书,这会让注册流程直接夭折);DocuSign、Zoho Sign 之类的第三方服务 (开发快,但按每份租约收费,并且会把文件流程 —— 连同双方的身份证号码 —— 移出平台)。
一份固定模板,外加自由填写的特别条款
平台只在一份经律师审阅过的表格中填空,不起草任何定制条款。ADR-0060。
根据《1976 年法律专业法》第 37 条,在标准表格中填空与起草在性质上有实质区别,而前者是更安全的一侧。 条款选择器看起来像功能,读起来却像起草:一旦平台代客户在多种法律措辞之间做选择,它所做之事的性质就变了。
暂时否决:基于律师审阅条款库的条款选择器 —— 等 special_conditions 显示出房东反复输入什么内容之后,
这是很自然的后续补充。这个自由文本字段就是收集证据的研究工具。
报价挂在看房请求之下
rental_offers.request_id 是一个外键,并带部分唯一索引 —— 仅在 status IN ('pending','countered') 时唯一 —— 且该请求必须满足 confirmed_time IS NOT NULL。
这一次买到三样东西:rental_messages 对话可以直接延续,因此不存在第二个线程概念;
双方已经互相持有联系方式;而「没看房就不能报价」这条规则无需另写代码即被强制执行。
已否决:request_id 完全唯一。那将意味着每次看房只能报价一次 ——
房东若嫌 RM 1,600 太低而点了拒绝,租客就被永久挡住,无法回头出价 RM 1,750;
唯一的出路是等第二阶段的 24 小时清扫、为已看过的房子再约一次看房,并请房东再确认一遍。
架构
internal/modules/rental/ 中的模块是真实代码,不是计划。第一阶段已交付六边形分层、
module.go 中的函数选项式装配、fn_audit_trail() 触发器注册(ADR-0002),
以及一个刻意不提供签名 URL 的 storage.Storage。
第三阶段只是继续填充同样的形状,在装配层不发明任何新东西。
| 层 | 文件 |
|---|---|
domain/entity/ | offer.go、agreement.go —— 状态转移函数与校验,纯函数 |
application/port/ | 在 ports.go 中新增 AgreementStore 与 AgreementRenderer;Mailer 新增九个方法 |
application/usecase/ | offer.go、agreement.go、signing.go |
adapter/inbound/http/ | offer_handler.go、agreement_handler.go |
adapter/outbound/pdf/ | renderer.go —— 使用 gofpdf,对齐发票模块的 PDF 适配器 |
HTTP 接口
POST /api/rental/viewing-requests/:id/offers tenant creates an offer
GET /api/rental/offers/:id either party
POST /api/rental/offers/:id/counter landlord
POST /api/rental/offers/:id/reoffer tenant
POST /api/rental/offers/:id/accept landlord → issues the agreement
POST /api/rental/offers/:id/decline landlord
POST /api/rental/offers/:id/withdraw tenant
GET /api/rental/agreements/:id either party, NRIC masked
GET /api/rental/agreements/:id/document frozen PDF, streamed
GET /api/rental/agreements/:id/signed-document composite, lazily generated
POST /api/rental/agreements/:id/sign either party
POST /api/rental/agreements/:id/void either party, reason required
GET /api/rental/me/agreements list, for the panel's landing view
counter 与 reoffer 是两条独立路由而不是一个 PATCH,
因为它们的授权规则与状态转移都不同;合并它们只会产生一个「根据调用者分支」的处理函数。
报价始终通过其看房请求访问,本身没有列表接口,
这让「报价挂在看房请求之下」不仅体现在数据结构里,也体现在 API 形态上。
邮件
Mailer 上共九个方法。每一个都携带指向授权 Go 处理函数的链接,绝不附带 PDF 附件。
| 事件 | 收件人 |
|---|---|
| 报价已创建 | 房东 |
| 还价 | 对方 |
| 还价后再报价 | 房东 |
| 报价被拒绝 | 租客,附带 decline_reason |
| 租约已签发 | 双方 |
| 已记录第一份签署 | 尚未签署的一方 |
| 租约已完成 | 双方 |
| 租约已作废 | 双方,注明由谁作废及原因 |
| 落选报价被自动拒绝 | 每一位落选租客 |
撤回报价不发送任何邮件 —— 线程里已经显示了,而且房东并没有损失什么。
数据模型
三张新表,一张现有表新增三列。全部写在 migrator_rental.go 中。
rental_offers
id、request_id、listing_id、tenant_user_id、
landlord_user_id、monthly_rent、start_date、
term_months、security_deposit_months、
utility_deposit_months、advance_rent_months、
special_conditions、status、decline_reason、
last_actor_user_id、created_at、updated_at
CREATE UNIQUE INDEX idx_rental_offers_live
ON rental_offers (request_id)
WHERE status IN ('pending', 'countered');
landlord_user_id- 反规范化存储,使行级授权检查无需 join。第二阶段的
rental_viewing_requests对同一个人使用同一个列名;第一阶段已上线的rental_properties.owner_user_id保留原名,因为它记录的是谁拥有该房产 —— 这与谁是某次租约中的房东是不同的关系。 special_conditions- 自由文本,与其他条件一样属于可协商项。还价的一方可以修改它,每次修改都会落入消息线程。
decline_reason- 可为空:房东拒绝时可选填;完成时的清扫自动拒绝落选报价时,会写入一段服务端格式化的文字。刻意不设
withdraw_reason。 - 押金默认值
- 马来西亚惯例 —— 2 个月押金、0.5 个月水电押金、1 个月预付租金 —— 与租金一样可协商。绝不存储马币金额:它们是
monthly_rent × months,在渲染时计算。 - 校验
start_date不得早于今天;term_months介于 1 至 36 之间。
rental_tenancy_agreements
id、offer_id(唯一)、listing_id、property_id、
landlord_user_id、tenant_user_id、template_version、
terms(jsonb)、document_key、document_sha256、
signed_document_key、status、issued_at、
completed_at、voided_at、void_reason、
created_at、updated_at
CREATE UNIQUE INDEX idx_rental_agreements_live_listing
ON rental_tenancy_agreements (listing_id)
WHERE status <> 'voided';
document_key 指向冻结的未签署 PDF,其值就是该文件字节的 SHA-256。
signed_document_key 指向最终的合成文件,同样采用内容寻址,
在有人第一次下载之前为 null。
rental_agreement_signatures
id、agreement_id、signer_user_id、signer_role
(landlord 或 tenant)、full_name、nric、
document_sha256、signed_at、ip、user_agent
- 在
(agreement_id, signer_user_id)上唯一 —— 这个约束就是防重复签署的全部防线,而不是应用逻辑。 document_sha256按每次签署存储,因为它记录的是签署人实际看到的哈希。若两者出现不一致,说明文件被动过,这就从「无法追究」变成了「可被检测」。full_name与nric是签署人在签署那一刻所声明的内容 —— 这与其个人资料当前的值是不同的事实。full_name存储的是原样输入的内容,而不是用于比对的归一化形式。ip与user_agent只存在于此处。合成 PDF 不会打印它们。
rental_contacts 与身份证号码处理
新增三列:full_name、nric、address_line。
full_name 是身份证上印刷的姓名,未必等于 users.name。
这是一个真实的列,而不是 Better Auth 数据的副本;何况 ADR-0006 本来就禁止回写 users。
身份证号码属于《2010 年个人资料保护法》下的敏感个人资料。它以明文存储 ——
整个数据库只有一个 Postgres,租赁模块里也没有别的东西加密 —— 但每一个 API 响应都会将其遮蔽为
******-**-**34。出生日期同样被遮蔽:身份证号码的结构是 YYMMDD-PB-###G,
保留前六位就等于公开完整出生日期,而出生日期本身也是个人资料。最后两位已足以让本人确认号码是自己的。
完整值只出现在生成的 PDF 内部,而 PDF 绝不会作为邮件附件发送。 一边遮蔽 API、一边把同一个号码寄进两个邮箱,等于把整套防护措施全部作废。
审计触发器
rental_offers 与 rental_tenancy_agreements 会挂上
fn_audit_trail() 触发器,与现有的 trg_audit_rental_properties
注册在一起(ADR-0002)。rental_agreement_signatures 不挂:
那些行本来就是只追加的,触发器只会重复一遍。
状态机
报价
countered 会就地覆盖条件,status 说明当前摆在桌面上的是哪一方的条件。
每一次还价与再报价都会插入一行携带新条件的 rental_messages,
因此即使列上只保存当前立场,线程仍保留完整的谈判过程。
租约
房源 —— 对第一阶段的唯一改动
rented,在租约完成时进入。EventComplete 可从三个来源触发,而不只是 live。为什么用 rented 而不复用 withdrawn,以及为什么不加 reserved 状态
withdrawn 在技术上可行且零成本,因为公开市场页面按 live 过滤,两个值都会被排除。
否决它的理由是:房东自己的面板届时会把一处成功租出去的房产显示为「已撤回」。
多一个枚举值换来的是诚实的数据。
在等待签署期间房源也保持 live —— 不新增 reserved 状态。
其他租客仍可以为一处已经八九不离十的房产预约看房和报价,而房东如果尝试接受,只会拿到 409;
防重复出租的防线已经覆盖了正确性。第二个枚举值买到的是一个可逆状态和两条额外转移,
只为让面板上的标签更诚实 —— 而面板完全可以免费地写上「租约签署中」。
公开市场页面不需要任何改动:它本来就对任何非 live 的房源返回 404 并显示「该房源已不可租」页面,
因此 rented 会自动从公开页面消失。日后重新出租也不受影响 ——
第一阶段本来就把「每次租期结束」视为重新挂牌,而 rented 对
「每处房产至多一条非终结房源」规则而言属于终结状态,所以业主是创建一条新房源,而不是复用旧的。
授权
| 动作 | 校验 |
|---|---|
| 创建或撤回报价 | 已登录,且会话用户是某个满足 confirmed_time IS NOT NULL 的请求上的 tenant_user_id |
| 接受、还价或拒绝报价 | 已登录,且会话用户是 landlord_user_id |
| 读取报价或租约、下载任一 PDF | 已登录,且会话用户是双方之一 |
| 签署 | 以上条件,且该用户尚无签署记录行 |
| 作废 | 任一方,且仅在 awaiting_signatures 期间 |
| 管理员 | 无 —— 第三阶段没有管理员接口,与第二阶段一致 |
| 公开房源 API | 不变 —— 第一阶段的黄金测试仍在守护它 |
不新增角色令牌,不使用 RequireOrgRole,不改动 Better Auth。仅使用行级校验,与第一、第二阶段完全一致。
流程
A —— 租客报价
- 租客从一次已确认的看房请求进入提出报价。
- 若租客尚未填写
full_name、nric或address_line,先用一个简短表单收集。 - 表单预填房源的要价与 2 / 0.5 / 1 的押金惯例。特别条款初始为空。
- 提交后创建一行状态为
pending的rental_offers。 - 一行描述条件(包含特别条款)的
rental_messages进入现有线程。 - 向房东发送邮件。
还价与再报价在同一行上重复第 4 至 6 步,由 last_actor_user_id 记录当前是哪一方的条件。
B —— 房东接受,签发租约
| 漂移来源 | 固定为 |
|---|---|
| PDF 创建日期 | issued_at —— 绝不用 now(),而 gofpdf 默认就盖当前时间 |
| 日期格式化 | 固定的 Asia/Kuala_Lumpur,使宿主机的 TZ 无法改变字节 |
| 字体 | 提交进仓库的字体文件,绝不使用系统字体路径 |
| 签署顺序(合成件) | signed_at,并以 signer_role 作为并列时的次序依据 —— Go 的 map 迭代顺序是随机的 |
C —— 签署
任一方都可以先签;顺序无关紧要。
-
下载 PDF
由一个 Go 处理函数提供,它会校验调用者是双方之一 —— 不存在公开 URL,因为
storage.Storage根本没有签名 URL 的接口。 -
输入、勾选、提交
签署人输入身份证姓名与号码,并勾选同意。
-
与快照比对,然后写入
用例校验输入值,然后插入一行携带
document_sha256、IP 与 User-Agent 的签署记录。 -
唯一索引拒绝第二次尝试
防线是
(agreement_id, signer_user_id),而不是应用逻辑。
D —— 完成
当每一位应签署人都有记录行时,签署插入操作即完成该租约。
应签署人集合由租约自身的 landlord_user_id 与 tenant_user_id 推导得出 ——
而不是硬编码的「数到二」—— 因此日后加入联名租客只需改这段推导,其他一概不动。
完成的那次插入还会设置 completed_at、清扫落选报价,并在同一事务内把房源移到 rented。
签署页会打印每一方的姓名、其所声明的身份证号码、带时区的时间戳,以及文件的 SHA-256。 它不会打印 IP 地址或 User-Agent。这些留在数据库里,若日后发生争议再行提供。 证据无论如何都在;而把 IP 打印进一份双方可能转发给中介或群聊的文件里, 等于把对方的一部分网络身份交出去,却换不到任何法律上的好处。
错误处理
| 情形 | 行为 |
|---|---|
| 对一个从未确认的请求报价 | 403 —— 与联系方式披露所用的判定条件相同 |
| 租客尚未填写当事人资料就报价 | 422,并列出缺失字段 |
| 同一请求上的第二份有效报价 | 409,响应体中带上现有报价 —— 由部分唯一索引拦截 |
| 在终结报价之后于同一请求上再报价 | 允许 —— 作为新行,这正是被拒绝的报价得以挽回的方式 |
| 身份证号码格式错误,或前六位不是真实日期 | 写入 rental_contacts 时返回 422 |
start_date 早于今天,或 term_months 超出 1–36 | 422 |
| 房东尚未填写当事人资料就接受 | 422,并列出缺失字段 |
| 接受时租客资料缺失(竞态) | 由签发内部的防御性复查返回 422 |
| 对已有未作废租约的房源执行接受 | 409 —— 防重复出租索引,在签发事务内被触发 |
| 签发时 PDF 渲染或存储写入失败 | 不写入任何内容;报价不变;502 |
| 上传之后签发事务失败 | 回滚;孤儿对象采用内容寻址,因此重试复用同一个键 |
| 存在未作废租约时撤回房源 | 409 |
房源处于 pending_review 或 suspended 时完成 | 成功 —— EventComplete 接受全部三个来源 |
| 第二份签署并发到达两次 | 唯一索引拒绝其一;失败者得到 409,而不是重复完成 |
| 输入的姓名或身份证号码与快照不符 | 归一化之后返回 422;不写入签署记录行 |
| 对已作废或已完成的租约签署 | 409 |
| 已存在任何签署时作废却未填原因 | 422 |
| 双方在同一时刻下载合成件 | 两边都生成;字节相同、键相同;不报错 |
| 下载时合成 PDF 生成失败 | 502;租约仍为已完成状态,下一次下载会重试 |
测试
- 状态机
- 对报价与租约的每一条合法与非法转移做表驱动测试,包括
accepted的终结规则与对称作废。 - 报价挽回
- 拒绝一份报价后,断言同一
request_id上可以插入新报价;断言第二份有效报价被部分唯一索引拒绝。 - 黄金 PDF,两个时区
- 用固定的
terms快照在两个不同TZ值下各渲染一次,断言 SHA-256 相同。只渲染一次的测试会通过,而最可能出问题的地方却查不出来。 - 合成件确定性
- 对合成件做同样的测试,其创建日期固定为
completed_at,签署按signed_at排序。 - 并发
- 同时触发两次签署;断言恰好一次完成、一次房源转移、一个 409。
- 重复出租
- 并发接受同一房源上的两份报价;断言恰好签发一份租约,另一位调用者得到 409。
- 签发原子性
- 在流程 B 的第 4 步注入存储失败;断言没有租约行且报价未变,再断言重试写入的是同一个存储键。
- 完成时的房源转移
- 分别在房源处于
live、pending_review与suspended时完成租约;断言三种情况都变为rented。断言存在未作废租约时EventWithdraw返回 409。 - 签署比对
- 仅大小写或空格不同的姓名可以成功签署;仅横线不同的身份证号码可以成功签署;真正不同的值被拒绝且不写入任何行。
- 身份证遮蔽
- 一个黄金测试断言没有任何接口会返回未遮蔽的身份证号码,包括出生日期的任何一位数字,与第一阶段公开房源 API 的黄金测试同一精神。
- 无附件
- 断言每一次邮件调用都携带链接,且不含 PDF 正文。
- 作废证据
- 在一份签署之后作废;断言原因为必填、签署记录行仍在,且双方都收到通知。
- 授权
- 第三位已登录用户在每一个接口上都被拒绝。
交付顺序
rental_contacts新列、身份证号码写入时归一化与校验、身份证遮蔽rental_offers表、部分唯一索引,以及报价状态机- 报价接口、租客资料卡口、线程消息、特别条款
- PDF 渲染器、流程 B 中的确定性固定项,以及双时区黄金测试
- 租约签发 —— 先渲染上传,再执行流程 B 中的短事务
- 签署、归一化比对、按推导签署人集合完成、落选报价清扫
- 房源
rented状态、来自三个来源的EventComplete、撤回拦截(改动第一阶段) - 首次下载时生成合成 PDF
- 邮件 —— 九个方法,仅链接
- 面板界面
风险
| 风险 | 缓解措施 |
|---|---|
| 《1976 年法律专业法》第 37 条 —— 为报酬起草法律文件 | 一份经律师审阅的模板;平台只填空,绝不起草定制条款 |
| 身份证号码由用户自行申报且未经核验 | 在当前业务量下接受;一旦出现欺诈,身份核验是第一个要补的功能 |
| 租约未盖印,不可在法庭举证 | 在签发时与已完成的租约上都清楚说明,而不是藏进小字条款 |
| 身份证号码以明文存储 | 在每个 API 响应中遮蔽并有黄金测试覆盖;完整值只存在于 PDF 中 |
邮件仍是 log.Printf 桩实现 | 真实传输由第二阶段负责;若其延期,本阶段的每条通知都是静默的 |
| 每份租约两个对象 | 接受 —— 冻结对象是法律记录,合成件只是随时可以重新生成的便利产物 |
| 同一房产跨两条房源的重复出租 | 未解决。要堵上需要租期结束日期,由第四阶段负责 |
full_name 或 nric 中的错字在签发时被冻结 | 写入时校验并归一化;签署比对归一化后的值,因此单纯的格式差异绝不会把人锁在门外。真正的错字仍需作废并重新签发 |
| 账号删除后租约与身份证号码仍被保留 | 刻意如此 —— 它们是法律记录。全平台的数据保留策略由第一个处理账号删除的阶段负责 |
| 渲染器必须保持逐字节确定,否则存储键会碎裂 | 固定四处漂移来源,并由双时区黄金测试断言 |
这一阶段解锁了什么
第四阶段 —— 租金收缴追踪、催缴阶梯、逾期通知 PDF —— 需要一份当事人明确、租金明确、起始日期明确、
租期明确的租约。一行已完成的 rental_tenancy_agreements 正是这份记录。
这里构建的 PDF 渲染器也正是第四阶段逾期通知所要复用的;
而本阶段留下的「同一房产租期重叠」缺口,将是租期结束日期首先要堵上的问题。