本文档的所有 DDL 均已在本机 PostgreSQL 17.11 真实执行通过,并通过 15 项数据验证 + 并发压测。
实测对象统计:86 张表 / 248 个索引 / 191 个外键 / 66 个 CHECK 约束 / 1 个排他约束
设计的起点不是「有哪些表」,而是数据性质分层。租赁系统最常见的失败,
是把生命周期完全不同的数据塞进同一张表。
| 层 | 数据 | 载体 | 写入特征 |
|---|---|---|---|
| 主数据 | 租户、楼盘、房间、租客、设备 | PostgreSQL | 低频、强一致 |
| 事务数据 | 合同、账单、收款、工单 | PostgreSQL + 分区 | 高频、有状态机 |
| 时序数据 | IoT 遥测、AR 行为、Agent 日志 | ClickHouse(旁路) | 极高频、只追加 |
| 非结构化 | 图片、视频、AR 模型 | 对象存储 + 元数据表 | 大对象、内容寻址 |
这条分层线是整个设计的红线:时序数据进主库 = 主库迟早被压垮;
文件字节进数据库 = 备份恢复必然失控。
| 表 | 作用 | 关键设计 |
|---|---|---|
tenant | 租户 | isolation_mode 预留隔离升级通道 |
tenant_config | 租户配置 | 键值化,避免为差异加列 |
user_account | 系统账号 | 与租客严格分开(见下方设计债说明) |
role / permission / role_permission | RBAC | 支持平台内置 + 租户自定义 |
user_tenant_binding / user_role_binding | 绑定 | 一人可服务多租户 |
user_account.email 索引 | — | 部分索引(仅非空) |
data_scope_rule | 数据权限 | 按楼盘收窄可见范围 |
⚠️ 最常见的设计债:把「系统操作者」和「租客」合成一张 user 表。
后果:租客表里混进几百个员工账号,权限模型与租赁关系彻底纠缠,
后期拆分要动几十张表的外键。本设计从第一天就分开:user_account(能登录后台的人) vstenant_person(签合同的人)。
层级:property → building → unit → floor → room → bed
| 表 | 作用 | 关键设计 |
|---|---|---|
room | 核心:可出租最小单元 | status 五态机、extra_attrs JSONB 扩展 |
room_status_log | 状态变更留痕 | 审计必需 |
room_calendar | 短租间夜库存 | (room_id, biz_date) 唯一 + 条件更新防超卖 |
bed | 床位 | 合租场景 |
rate_plan / rate_policy | 价格方案与策略 | 结构化 JSONB 表达连住折扣等 |
parking_space | 车位 | 也是可租资产 |
amenity / room_amenity | 配套设施 | 字典 + 关联 |
为什么 room 是核心而不是 building?
一切租赁、账单、设备、AR 场景最终都挂在房间上。
若以楼栋为主轴,收租和水电就无处安放。
| 表 | 作用 | 关键设计 |
|---|---|---|
tenant_person | 租客自然人 | 全局实体,不做租户隔离 |
tenant_org | 企业租客 | 统一社会信用代码唯一 |
guarantor | 担保人 | — |
credit_profile | 信用档案 | 黑名单、逾期统计 |
contact_record | 沟通记录 | 为 Agent 准备语料 |
tenant_person 的三个关键字段设计:
id_no_enc text -- 证件号加密存储(应用层加密)
id_no_hash varchar(128) -- 不可逆哈希,用于全局查重
为什么这样设计? 既要全局查重(防重复身份、支持跨公司风控),
又要不泄露明文(合规要求)。哈希解的正是这个矛盾:
数据库能判断「是不是同一个人」,但拿不到证件号本身。
| 表 | 作用 | 关键设计 |
|---|---|---|
contract | 合同主表 | 七态机,只增不改 |
contract_party | 当事人 | 支持共租人、责任分摊比例 |
contract_room | 合同-房间 | 一合同多房间,各自租金 |
contract_version | 版本快照 | 不可变 JSONB 全量快照 |
contract_term | 条款 | 结构化,按版本号隔离 |
contract_change | 变更单 | 续租/换房/改价/提前退租 |
contract_deposit | 押金 | 应收/实收/退还/扣除四账 |
checkin_record | 交割 | 水电底数 + 物品清单 JSONB |
核心原则:合同只增不改。
任何变更都产生 contract_change + 新增 contract_version 快照。
如果直接 UPDATE contract,那么「2026 年 3 月那份合同长什么样」这个
法律问题就永远无法回答 —— 这在租赁纠纷中是致命的。
| 表 | 作用 | 关键设计 |
|---|---|---|
bill | 账单 | 六态机 + idempotency_key 防重 |
bill_item | 明细 | 计量类含起止读数 |
payment | 收款流水 | out_trade_no/trade_no 防重复入账 |
payment_allocation | 核销明细 | 多对多桥梁(见下) |
meter / meter_reading | 仪表与抄表 | 留在 PG,因参与计费 |
refund / invoice | 退款/发票 | — |
dunning_record | 催缴 | 支持 Agent 自动催收 |
late_fee_rule | 滞纳金规则 | 阶梯可配置 |
## 🔑 本节最重要的设计:
payment_allocation
新人最常犯的致命错误:给bill加一个payment_id字段。
这样做的后果:
- 租客一次性付 3 个月房租(1 笔钱 → 3 张账单)→ 无法表达
- 租客分 2 次付清 1 张账单(2 笔钱 → 1 张账单)→ 无法表达
- 租客付了 2500,账单 3000(部分核销)→ 无法表达
租赁业务的真实情况是:payment与bill是多对多。
必须用payment_allocation连接,并记录每笔核销金额。
这张表还有alloc_type='reversal'用于冲正 —— 收错钱要能回滚,
而回滚不能删除原记录(审计要求)。
| 表 | 作用 | 关键设计 |
|---|---|---|
work_order | 工单 | 八态机 + SLA 时限快照 |
work_order_flow | 流转留痕 | 唯一的过程真相 |
work_order_item | 材料/工时 | — |
dispatch_record | 派单 | 支持抢单/派单/自动 |
staff | 服务人员 | 技能标签 + 负载(派单依据) |
sla_rule | SLA 规则 | — |
inspection_task / inspection_record | 巡检 | 异常自动生成工单 |
为什么必须有 work_order_flow?
只更新 work_order.status 会丢失「谁在几点做了什么」。
而 SLA 考核、责任划分、客诉复盘全部依赖过程记录。
work_order_flow 还记录了每一步的 duration_min,
这使「平均响应时长」这类指标可以随时算出,无需埋点。
| 表 | 作用 | 关键设计 |
|---|---|---|
device | 设备主数据 | — |
device_model | 型号能力定义 | capabilities JSONB |
device_binding | 设备-房间绑定 | 含生效时间段,退租自动解绑 |
device_shadow | 当前状态快照 | 一设备一行,高频覆盖写 |
device_command | 指令下发 | request_id 幂等 |
scene / scene_action | 场景联动 | — |
access_grant | 门锁临时权限 | 保洁/维修/看房的时效密码 |
device_shadow(影子)与遥测必须分开。
| | device_shadow | 遥测历史 |
| --- | --- | --- |
| 内容 | 此刻状态 | 历史轨迹 |
| 行数 | 固定(= 设备数) | 无限增长 |
| 查询 | 点查 O(1) | 时间范围聚合 |
| 载体 | PostgreSQL | ClickHouse |
混在一起的后果:查「这台空调现在开着吗」变成在百亿行里做全表扫描。
| 表 | 作用 | 关键设计 |
|---|---|---|
media_asset | 媒体元数据 | 只存元数据,字节在对象存储 |
media_variant | 多规格派生 | 缩略图/多清晰度/全景瓦片 |
media_binding | 多态绑定 | 一媒体可绑房间/合同/工单 |
ar_scene | AR 场景 | 一房间多场景/多期 |
ar_scene_version | 场景版本 | 重新拍摄保留历史 |
ar_scene_node | 场景分区 | 客厅/卧室,含首视角相机 |
ar_hotspot | AR 热点 | 可关联真实 device_id |
ar_camera_path | 相机轨迹 | 漫游路线 |
vr_session | 看房行为 | 停留时长、热点点击 |
AR 看房的数据本质是三块,必须分开存:
## 🔑 为什么锚点必须进数据库,不能烧进模型文件?
如果热点坐标只存在 glTF 里,那么「点击客厅的空调热点,显示它
真实的运行状态和本月耗电」这个杀手级联动就做不了 ——
因为模型文件是静态的,无法查询后端。
本设计的ar_hotspot带device_id/room_id/target_node_id,
使热点成为活的业务入口:看房时点热点 → 查device_shadow→
展示"这台空调当前 26℃,本月耗电 45 度"。
这是把 AR 从「炫技」变成「销售工具」的关键设计。
| 表 | 作用 | 关键设计 |
|---|---|---|
agent_definition | Agent 定义 | 角色 + 工具集 + 模型配置 |
agent_tool | 工具注册表 | JSON Schema 供 function calling |
agent_session | 会话 | 关联业务上下文 |
agent_message | 消息 | token 用量、时延 |
agent_tool_call | 工具调用审计 | 入参出参全记录 |
agent_memory | 长期记忆 | 结构化 + vector(1024) |
knowledge_doc / knowledge_chunk | 知识库 | pgvector + HNSW |
prompt_template | 提示词版本化 | — |
Agent 落地的三个数据要点:
agent_tool_call 记录入参出参、是否 is_write、谁 confirmed_by 确认。没有这张表,
Agent 误删数据、误退款将无法追责,业务方也不敢让它碰写操作。
agent_tool.need_confirm 字段 +agent_tool_call.confirmed_by 构成了「AI 提议、人批准」的安全链路。
知识库百万切片以内用 HNSW 索引完全够用,省掉一套独立向量库的运维。
CREATE INDEX idx_knowledge_chunk_embedding ON knowledge_chunk
USING hnsw (embedding vector_cosine_ops) WITH (m=16, ef_construction=64);
维度按 1024 设定,与本机
Qwen3-Embedding-4B对齐。
换嵌入模型必须同步改维度并重建索引 —— 这个约束要写进团队规范。
数据库设计的好坏,不在于"表建出来了",而在于能不能挡住错误数据。
以下四项是本设计最硬的部分,且都经过真实测试。
CREATE TABLE room_occupancy (
room_id bigint,
period daterange,
...
EXCLUDE USING gist (room_id WITH =, period WITH &&)
);
实测结果:
重叠插入 → conflicting key value violates exclusion constraint
"room_occupancy_room_id_period_excl"
Key (room_id, period)=(1, [2026-12-01,2027-06-30))
conflicts with (1, [2026-11-01,2027-10-31))
不重叠插入 → 成功
为什么不能只靠应用层校验?
两个中介同时给同一房间签合同,应用层"查一下有没有占用"在并发下必然穿透。
只有数据库的排他约束能在时序层面杜绝。
UPDATE room_calendar
SET sold_qty = sold_qty + p_qty
WHERE room_id = ? AND biz_date >= ? AND biz_date < ?
AND is_closed = false
AND sold_qty + p_qty <= total_qty -- ← 原子的余量判断
RETURNING 1;
-- 影响行数不足 → 抛异常,绝不静默成功
🔑 关键点:必须主动检查影响行数并报错。
实测反向对照实验证明:即使条件更新兜住了数据(sold_qty 没超),
如果不检查影响行数,业务层会以为下单成功(因为 UPDATE 不报错),
而库存其实没扣到。这种"静默失败"比超卖更难排查。
本设计的 fn_hold_room_nights 显式断言:
IF v_affected <> v_days THEN
RAISE EXCEPTION '库存不足:需要 % 天,实际可售 % 天', v_days, v_affected;
END IF;
并发压测实测结果(20 并发抢 1 间库存):
成功抢占数 : 1 (期望 1)
失败数 : 19 (期望 19)
最终已售数 : 1 (期望 1)
✅ 未发生超卖
账单的 amount_paid 不能由应用层手动累加(漏更新就永远对不上账)。
本设计用触发器从 payment_allocation 自动汇总:
CREATE TRIGGER trg_alloc_sync_bill
AFTER INSERT OR UPDATE OR DELETE ON payment_allocation
FOR EACH ROW EXECUTE FUNCTION trg_sync_bill_paid();
同时自动推进账单状态机(pending → partial → paid),
并维护 payment.allocated_amount / unallocated_amount。
实测结果:
初始 : 应收 3000, 已收 0, status=pending
收 2000 : 应收 3000, 已收 2000, status=partial ✅
再收 1000: 应收 3000, 已收 3000, status=paid, paid_at 非空 ✅
-- 超额核销拦截(实测:账单 3000,再核销 500 → 累计 3500)
ERROR: 核销超额:账单应收 3000.00,减免 0.00,本次累计核销 3500.00
-- 重复支付交易号
ERROR: duplicate key value violates unique constraint "uq_payment_trade_no"
-- 账单重复生成(幂等键)
ERROR: duplicate key value violates unique constraint "uq_bill_idempotency"
判断标准:单表预估超 5000 万行且以时间为主要查询维度 → 按月 RANGE 分区。
| 表 | 年增行数 | 处置 | 分区键 |
|---|---|---|---|
bill | 3000 万~1 亿 | ✅ 月分区 | period_start |
payment | 3000 万 | ✅ 月分区 | paid_at |
payment_allocation | 5000 万 | ✅ 月分区 | allocated_at |
room_calendar | 3.65 亿 | ✅ 月分区 | biz_date |
agent_message | 1 亿+ | ✅ 月分区 + 归档 | created_at |
vr_session | 1 亿+ | ✅ 月分区 | started_at |
device_telemetry | 万亿级 | ❌ 不进 PG | ClickHouse |
分区表的三个必须注意的点:
例如 bill 的 UNIQUE (tenant_id, bill_no) 在按 period_start 分区后,
需改为 UNIQUE (tenant_id, bill_no, period_start)。
而逾期扫描只需要扫未缴的:
CREATE INDEX idx_bill_unpaid ON bill (tenant_id, due_date)
WHERE status IN ('pending','partial','overdue');
这个索引体积可能只有全量索引的 5%,但覆盖了最高频的运维查询。
pg_partman 自动建分区,不要手写。本机未安装,需由运维会话统一部署(按权责边界,基础服务归运维)。
索引不是越多越好 —— 每个索引都在拖慢写入。本设计只建三类索引:
| 类型 | 例子 | 为什么建 |
|---|---|---|
| 租户外键组合 | (tenant_id, status) | RLS + 列表查询的绝对主力 |
| 部分索引 | WHERE status='effective' | 只索引有价值的那部分 |
| GIN 索引 | gin (extra_attrs)、gin_trgm_ops | JSONB 检索、中文模糊搜索 |
中文模糊搜索的实际做法(本机已验证扩展可用):
CREATE INDEX idx_property_name_trgm ON property USING gin (property_name gin_trgm_ops);
-- 支持:SELECT * FROM property WHERE property_name LIKE '%花园%';
注意:
pg_trgm对中文按 3-gram 切分,效果弱于英文。
数据量大后建议房源搜索走 Elasticsearch,PG 只做兜底。
| # | 验证项 | 预期 | 实测 |
|---|---|---|---|
| 1 | 短租正常占 3 晚 | 成功 | ✅ |
| 2 | 重叠日期再占 | 报错拦截 | ✅ 库存不足:需要3天,可售1天 |
| 3 | 空闲日期可继续卖 | 成功 | ✅ |
| 4 | 账单初始态 | pending/0 | ✅ |
| 5 | 部分核销 | partial/2000 | ✅ |
| 6 | 付清 | paid/3000/有支付时间 | ✅ |
| 7 | 收款单核销汇总 | 2000/0、1000/0 | ✅ |
| 8 | 超额核销 | 报错拦截 | ✅ 累计3500 > 应收3000 |
| 9 | 重复支付交易号 | 唯一索引拦截 | ✅ |
| 10 | 账单幂等键重复 | 唯一索引拦截 | ✅ |
| 11 | 房间首次占用 | 成功 | ✅ |
| 12 | 重叠占用 | EXCLUDE 拦截 | ✅ |
| 13 | 不重叠占用 | 成功 | ✅ |
| 14 | 出租率视图 | 正确计算 | ✅ |
| 15 | 账单账龄视图 | 正确计算 | ✅ |
| 16 | 20 并发抢 1 库存 | 恰好 1 成功 | ✅ 1 成功 / 19 失败 / 未超卖 |
| 17 | RLS 租户隔离 | 各自只见己方 | ✅ |
| 18 | RLS 未设上下文 | 返回 0 行 | ✅ 安全失败 |
| 19 | RLS 注入尝试 | 被拦截 | ✅ 类型检查拦下 |
设计过程中真实踩到并修正的两处:
初版写 CHECK (lessee_person_id IS NOT NULL OR lessee_org_id IS NOT NULL),
导致草稿合同无法保存(起草时还没定承租人)。
修正为:仅非草稿状态强制要求承租主体。
这个 bug 是跑真实数据时暴露的 —— 纸面设计不会发现。
fn_hold_room_nights(bigint, unknown, unknown, integer) 找不到匹配函数,
需显式 ::date / ::smallint 转换。
已写入使用示例,避免团队重复踩坑。
| 文件 | 内容 | 状态 |
|---|---|---|
DDL/01-平台租户房源.sql | 域 0-2 | ✅ 执行通过 |
DDL/02-租客合同收租.sql | 域 3-5 | ✅ 执行通过 |
DDL/03-工单IoT媒体AR-Agent.sql | 域 6-9 | ✅ 执行通过 |
DDL/04-约束与并发保障.sql | 排他约束/触发器/视图 | ✅ 执行通过 |
DDL/05-真实数据验证.sql | 15 个数据用例 | ✅ 全部通过 |
DDL/06-并发防超卖压测.sh | 20 并发压测 | ✅ 未超卖 |
DDL/07-反向对照实验.sh | 错误写法对照 | ✅ 已完成 |
DDL/08-多租户RLS落地.sql | RLS 生成器 | ✅ 已验证 |
执行顺序:01 → 02 → 03 → 04 →(05 验证)→ 08 按需