DIY Rental Platform — Slice 1: Listings and Owner Onboarding
Slice 1 of a Malaysian rental marketplace: landlord signup, property listings, manual admin approval, and public browse — built inside the existing monorepo rather than greenfield. A 2026-08-24 design-review grilling session settled the architecture on the platform's shared Postgres database and Better Auth deployment, resolved a role-token naming collision with the existing org-owner role, and tightened the data model, storage, and state-machine details before implementation starts.
马来西亚租赁市场第 1 阶段:房东注册、房源发布、人工管理员审核、公开浏览——在现有 monorepo 内构建,而非从零开始。2026-08-24 的设计评审(15 个问题)确定了架构方向:共用平台的 Postgres 数据库与 Better Auth 部署,解决了与既有组织所有者角色的命名冲突,并在开始实现前 收紧了数据模型、存储与状态机的细节。
本文档存在的原因
plan.md 描述了一整家 PropTech 公司——无中介市场、法律文件自动化、电子签署、
租赁执行、水电费监控,以及带外勤人员的物业管理业务。即便是其中的"MVP"也包含六个子系统。
这对一份文档来说太大了,因此本文档只涵盖第 1 阶段;后续阶段会有各自的
文档、计划和开发周期。
| 阶段 | 内容 | 依赖 |
|---|---|---|
| 1(本文档) | 房东入驻、房源发布、管理员审核、公开浏览 | — |
| 2 | 看房预约、平台内消息、提醒 | 1 |
| 3 | 从要约到租赁协议、电子签署、文件存储 | 2 |
| 4 | 租金追踪、提醒阶梯、逾期通知 PDF | 3 |
plan.md 中的其余部分——物业管理运营、TNB 与水务 API、维修工单、律师事务所
升级、忠诚度计划、中介行为检测——都属于第 2 阶段或更晚,本文档不涉及设计。
第 1 阶段要证明什么
一位马来西亚业主能够创建账号、发布一个真实房源、通过所有权审核并看到它上线——而租客 能够找到它。仅此而已。如果业主都不愿意做这件事,后续阶段就不值得投入建设。
- 业主无需帮助即可完成房源提交
- 管理员能在面板中以业主能读懂的理由批准或拒绝
- 租客无需账号即可通过搜索和筛选找到房源
- 任何公开响应都绝不包含业主联系方式或所有权文件
范围
范围内
-
房东注册与登录:一个公开的
/owner/sign-up页面(使用 Better Auth 的signUp.email,无需邀请),外加一个精简的 rental 端点,将新账号提升为全局landlord角色——面板目前不存在自助注册路径,因此这是新工作,而非复用 - 创建和编辑房源:类型、地址、区域、租金、押金条款、装修状况、房屋规则、可入住日期、照片
- 所有权声明勾选框加证明文件上传(水电费账单、地税单,或买卖合同首页)
- 管理员审核队列:批准、附理由拒绝、暂停已上线房源、恢复被暂停房源
- 公开市场:按区域/价格/类型/装修状况浏览与筛选,房源详情页,"业主直发"标识
- 业主联系方式从不公开显示。详情页显示一个禁用状态、标注为"即将推出"的"预约看房"按钮
范围外(经决策排除)
看房预约、消息、要约、租赁协议、电子签署、付款、可退还的房源押金、租客账号、评分、中介 行为检测、物业管理运营、公用事业 API。第 1 阶段租客无需账号即可浏览—— 租客登录随第 2 阶段一起推出,那是第一个需要它的阶段。
架构
| 部分 | 位置 | 复用 |
|---|---|---|
| 后端模块 | backend/go/internal/modules/rental/(domain / application / adapter) | user(Better Auth)、storage、audit——不使用 notifications 或 organizations |
| 部署 | appProfiles 中新增 rental 项;共用 Postgres 数据库与共用 Better Auth(ADR-0055) | 现有配置与 profile 机制、现有数据库连接——无需新数据库 |
| 公开站点 | Cloudflare Workers 上的新 Astro 应用 frontend/astro/apps/rental,SSR | @astro/core、shared-ui |
| 房东控制台 | frontend/solidstart/apps/panel → 带 owner.tsx 布局的 /owner/* 路由 | 面板组件、Paraglide i18n、better-auth、AdminRoute 的客户端门禁模式 |
| 租赁管理端 | 同一面板代码库、同一部署,/admin/rental/* | 现有 admin.tsx 布局、审核队列模式,以及现有唯一的 GO_API_URL 代理(无需第二套部署) |
共用数据库,共用 Better Auth
rental 与其他所有模块运行在同一个 Postgres 数据库和同一个 Better Auth 部署中,仅在表
层面(rental_ 前缀,ADR-0021)进行隔离。
Go 后端目前没有按 profile 分配数据库的机制,而 ADR-0006 又将 Go 对 Better Auth 的读取 绑定到同一个共用 Postgres 实例——真正的物理隔离需要采用与 quiz 应用相同的独立模式。 rental 的管理端刻意复用同一批平台管理员,这只有在身份体系共用时才能成立。这 被接受为一种保守、与仓库现状一致的默认选择(ADR-0055)——而非已验证的模式,因为此前 没有任何模块服务过无组织的(B2C)用户。
已否决:独立数据库加独立 Better Auth 部署(quiz 模式,ADR-0054)——为了一个管理端刻意 想要共用管理员的功能,却要重复搭建身份基础设施。已否决:独立数据库加共用 Better Auth——在 ADR-0006 之下物理上不可行。
共用 Postgres + 共用 Better Auth
零新增基础设施。房东是 users 表中的普通行,拥有全局 landlord 角色且不属于任何组织。管理员即现有的平台管理员。
独立数据库 + 独立 Better Auth(quiz 模式)
能实现真正的 B2B/B2C 隔离,但会重复搭建身份基础设施,并破坏管理员的复用——quiz 的管理员和 rental 的管理员将完全是两批不同的人。
公开站点用 Astro,控制台用 SolidStart
房源页面需要搜索引擎收录(Cloudflare Workers 上的 Astro 直接提供);房东与管理员界面需要表格、表单和上传功能(SolidStart 面板已经构建并测试过这些)。
房东拥有自己的布局,而非独立应用
一个更轻量、面向消费者的 routes/owner.tsx 包裹 /owner/*,只是一个新文件,遵循面板既有的按文件夹分布局模式(routes/admin.tsx)。
它复制了现有的 AdminRoute 模式——客户端角色检查,真正的授权边界由 API 的行级 owner_user_id 检查(第 8 节)在服务端强制执行——因为这是面板目前唯一的按角色布局先例。
一次针对性的清理
rental 模型放入新的 migrator_rental.go,遵循既有的 migrator_morningroutine.go 先例,而不是继续让约 140KB 的 migrator.go 变得更大。
- 语言
- 上线时仅支持英文,通过与其他应用相同的 Paraglide 配置接入,以便日后无需重构即可加入马来文和中文。业主撰写的房源描述保持自由文本,使用业主输入时的原语言。
数据模型
四张表,通过新的 migrator_rental.go 中的 GORM AutoMigrate 创建。
| 表 | 关键列 |
|---|---|
rental_properties | owner_user_id、property_type、address_line、area/city/state/postcode、bedrooms、bathrooms、furnishing、has_parking、declared_owner_at、declared_ip |
rental_listings | property_id、status、monthly_rent、deposit_months、utility_deposit、tenancy_months、available_from、description、house_rules、slug、published_at、expires_at、review_reason、reviewed_by、reviewed_at |
rental_property_photos | property_id、storage_key、sort_order、is_cover |
rental_ownership_proofs | property_id、doc_type、storage_key、status、reviewed_by、reviewed_at |
为什么房产与挂牌是分开的
第 1 阶段将两者一并创建,所以合并会让今天的实现更短。它们保持分离,是因为一处房产会在每次租期结束后重新挂牌,而后续几乎所有功能——租约、付款、维修、物业管理——都挂在房产上,而不是挂在这条广告上。
现在合并会在日后迫使一次痛苦的迁移。
- 房东档案
- 无独立表——第 1 阶段沿用现有的
users记录已经足够 - 管理员操作
- 无独立表——审核字段保存在挂牌记录上;共用的
audit基础设施记录是谁做了什么
挂牌状态机
withdrawn 和 suspended 并非都是死胡同:暂停可以由管理员直接
恢复回 live。一旦某条挂牌到达 withdrawn,业主就可以为同一处
房产创建一条全新的挂牌(重新从 draft 开始)(参见第 5 节的基数规则)。这是
第 1 阶段唯一的真实业务逻辑——由覆盖每一种合法与非法状态迁移的表驱动测试来保证,包括
"恢复"以及"撤回后新建挂牌"这两种情形。
流程
A——业主发布房源
-
注册
新房东在公开的
/owner/sign-up页面注册(使用 Better Auth 的signUp.email,无需邀请);随后一个精简的 rental 端点将该账号提升为全局landlord角色。 -
填写房产表单
在
/owner/properties/new页面。 -
上传照片
使用公开存储前缀,通过一个直接调用共用存储接口的 rental 专属上传端点——而不是走组织范围的共用附件流程,因为房东没有所属组织。
-
上传所有权证明
使用私有前缀,通过 rental 专属上传端点;存储层永远不会为它返回公开 URL。
-
声明所有权
勾选所有权声明;
declared_owner_at和declared_ip在本次提交时被(重新)盖章。 -
提交
状态变为
pending_review。 -
管理员发现该房源
通过访问已筛选的审核队列——第 1 阶段不会发送任何通知(共用的
notifications模块要求有所属组织,而房东没有)。 -
批准或拒绝
管理员批准——状态变为
live,生成 slug,业主收到邮件通知。或拒绝——写入review_reason,业主看到理由并修改后重新提交(重新盖章声明)。
B——公开浏览,无需登录
GET /api/public/rental/listings?area=&min_rent=&max_rent=&type=&furnishing=&page=
GET /api/public/rental/listings/{slug}
仅返回 live 状态的挂牌。响应中不含业主姓名、电话、邮箱、精确的
address_line 或证明文件——绝不包含(只提供
area/city/postcode,以避免租客在预约看房之前就
直接上门)。Astro 会在服务端渲染 /property/{slug},并带上缓存头,让
Cloudflare 承接重复流量。
C——管理员审核
面板中的 /admin/rental/listings?status=pending_review,使用既有的审核队列
表格模式。可批准、附理由拒绝、暂停已上线房源,或将已暂停的房源恢复为
live。所有权证明文件通过一个经过身份验证的仅限管理员端点以流式方式查看——
绝不会是公开或签名 URL。如果另一条 rental_properties 记录与之共享同一个
规范化地址,审核界面会显示一条普通的警告横幅(一次读取时查询,无需新增表或通知)。
两个关键边界
存储隔离
| 内容 | 前缀 | 访问方式 |
|---|---|---|
| 房源照片 | public | 可被 CDN 缓存的公开 URL |
| 所有权证明 | private | 通过经过身份验证的仅限管理员端点以流式方式获取;共用存储接口只会返回公开 URL(PutObject),因此证明文件永远不会以 URL 形式暴露出去 |
不同的前缀,不同的访问路径,绝不混用。
鉴权
internal/api/http/middleware/better_auth_rbac.go 中的
RequireOrgRole 是组织范围的,需要上下文中存在组织 ID。B2C 房东没有所属组织,
因此rental 绝不能使用它。
| 端点分组 | 检查方式 |
|---|---|
| 房东 | AuthMiddleware + 来自 claims.Roles 的全局 landlord 角色 + 在用例中执行的行级检查(确认 owner_user_id 与会话用户一致) |
| 管理员 | AuthMiddleware + 现有的全局 admin/superadmin 平台角色——原样复用,不引入新角色值 |
| 公开 | OptionalAuthMiddleware 或无需鉴权 |
错误处理
| 情形 | 行为 |
|---|---|
| 上传了错误的文件类型或文件过大 | 在 API 层被拒绝,并给出清晰的错误信息。限制为最多 10 张照片、每张 5MB;证明文件接受 PDF、JPG、PNG |
| 未提供证明文件或未勾选声明就提交 | 在用例层被拦截,而不仅仅在表单层——API 才是信任边界 |
| 业主编辑一条已上线的挂牌 | 若地址、租金或证明文件发生变化,则回到 pending_review;若只是修改描述或照片,则保持 live |
| 同一地址被重复提交 | 标记给管理员,但不会被拦截——审核界面会显示一条列出同一规范化地址下其他房产的警告横幅(读取时查询,不改动模式,不发送通知)。同一单元内的不同房间是合理情况 |
| 管理员批准了一条已经批准过的挂牌 | 幂等操作——不会重复发邮件,也不会产生状态抖动 |
| rental API 不可用 | Astro 提供缓存的 CDN 副本;搜索页显示一个简单的"暂时不可用"状态 |
测试
领域层
状态机- 覆盖每一种合法与非法状态迁移的表驱动测试
- 包括
suspended → live恢复以及"撤回后新建挂牌"
鉴权
最高风险- 每个房东端点各一条测试,证明用户 B 无法读取或编辑用户 A 的房产
- 代码库中第一个覆盖无组织(landlord)用户的测试
公开 API 黄金测试
泄露警报- 断言公开挂牌响应的精确 JSON 结构
- 只要有人添加业主电话、邮箱、精确
address_line或证明文件字段就会失败
迁移测试
数据模式- 遵循既有的
*_migration_test.go模式
Playwright 主路径
端到端- 业主提交,管理员批准,房源出现在公开站点上
- 只有一条端到端测试,而非一整套
交付顺序
每一步都以某个可观察的结果收尾。先完成后端到第 5 步,意味着前端工作永远不会被 API 结构卡住。
- 后端骨架
模块骨架、配置 profile、迁移 → 表已存在,健康检查通过。
- 房产 + 挂牌 CRUD
带鉴权测试的房东 CRUD → API 可通过 curl 验证。
- 上传
照片与证明文件上传,两种存储前缀 → 文件落在正确的存储桶中。
- 生命周期
提交、状态机、管理员批准/拒绝/暂停/恢复 → 通过 API 走通完整生命周期。
- 公开读取 API
公开读取 API 与黄金测试 → 已上线的挂牌可查询,且不泄露任何信息。
- 房东界面
面板
/owner/*路由与布局 → 业主可在浏览器中发布房源。 - 管理员界面
面板
/admin/rental/*审核队列 → 管理员可在浏览器中批准。 - 公开站点
Astro 搜索、筛选、详情页、SEO 标签 → 租客能找到房源。
- 端到端与部署
Playwright 主路径测试,部署两个前端。
法律说明
本平台是一个工具提供方,而非房地产中介,也不是法律顾问。条款中必须声明:房源发布仅限 业主发布其自有房产。这一点从第 3 阶段起变得更加重要,届时系统会生成租赁协议,但相关 定位从上线之初就应写入条款中。