> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked. # Fonrey 房产经纪管理系统 — DATA MODEL 设计文档 > **作者**: Backend Architect > **版本**: v1.5 > **日期**: 2026-04-30(v1.1 修复 S1/S2/S4;v1.2 扩展 public schema;v1.3 §三 DDL 迁至 DATA_MODEL_PUBLIC.md,本文改为索引;v1.4 补充 LOGIN/PERMISSION 子文档引用、领域对象、租户 Schema 章节、Redis 缓存策略;v1.5 新增 ADR 导航与治理联动) > **技术栈**: Django 4.x + PostgreSQL + django-tenants + Redis > **设计目标**: 支撑 89,000+ 房源、多租户隔离、sub-100ms 查询、合规审计 --- ## 变更历史 | 日期 | 变更人 | 变更内容 | |---|---|---| | 2026-04-30 | Atlas | 新增 ADR 导航与治理联动规则(数据模型变更需 ADR 可追溯) | | 2026-04-30 | Atlas | 补充“变更历史”章节(文档治理) | ## 一、架构决策总览 (Architecture Decision Records) ### 1.1 多租户策略:Schema-per-Tenant ``` ┌──────────────────────────────────────────────────────────────────────┐ │ PostgreSQL Instance │ │ │ │ ┌─────────────────────────┐ ┌──────────────────┐ ┌────────────┐ │ │ │ public schema │ │ tenant_abc │ │ tenant_xyz │ │ │ │ (平台运营层) │ │ schema │ │ schema │ │ │ │ │ │ │ │ │ │ │ │ - tenants │ │ - org_units │ │ (同左) │ │ │ │ - domains │ │ - staff │ │ │ │ │ │ - tenant_status_logs │ │ - complexes │ │ │ │ │ │ - platform_admins │ │ - properties │ │ │ │ │ │ - admin_mfa_devices │ │ - clients │ │ │ │ │ │ - admin_sessions │ │ - user_accounts │ │ │ │ │ │ - ip_whitelist │ │ - login_attempts │ │ │ │ │ │ - platform_audit_logs │ │ - permission_defs│ │ │ │ │ │ - backup_schedules │ │ - roles │ │ │ │ │ │ - backup_records │ │ - staff_roles │ │ │ │ │ │ - export_tasks │ │ - lookup_items │ │ │ │ │ │ - system_versions │ │ - ... │ │ │ │ │ │ - upgrade_events │ └──────────────────┘ └────────────┘ │ │ │ - enum_labels │ │ │ └─────────────────────────┘ │ └──────────────────────────────────────────────────────────────────────┘ ``` **选型理由**: - `django-tenants` 的 Schema 隔离提供最强的数据安全边界 - 房产经纪公司之间数据绝对不能互通(合规要求) - 每个 Schema 独立索引,避免全局锁竞争 - 支持按租户独立备份/恢复 ### 1.2 核心领域模型关系图 ``` [区域/商圈]──────────────────────────────┐ │ │ [学校管理] │ │ ▼ [楼盘/小区] ──── [楼栋] ─────────► [房源] ◄──── [挂牌历史] │ │ │ ┌────────┼────────┐ │ │ │ │ │ [联系人] [跟进日志] [维护完成度] │ │ │ │ ┌─────┘ ┌────┴──────┐ │ │ │ │ │ [电话查看] [钥匙] [委托] [实勘] │ [客源] ──── [配对记录] ──── [带看记录] │ [员工/组织] ──── [权限] ``` ### 1.3 关键设计原则 | 原则 | 决策 | | ----- | -------------------------------------- | | 主键类型 | `UUID v4`(跨环境安全,避免枚举攻击) | | 软删除 | 所有核心表含 `deleted_at`(历史可追溯) | | 时间戳 | 全部使用 `TIMESTAMPTZ`(含时区) | | 手机号存储 | AES-256-GCM 加密存储,建立 SHA-256 哈希索引 | | 审计字段 | `created_by`, `updated_by` 全表覆盖 | | 枚举值 | 业务枚举用 `VARCHAR` + CHECK,系统枚举用 lookup 表 | | 大文本 | `TEXT` 类型,不设长度(PG 内部优化) | | 金额 | `NUMERIC(12,2)` 万元精度,避免浮点误差 | --- ## 二、领域概览(Domain Overview) 本节用业务语言描述系统的核心领域对象及其关系,作为各子模块数据模型的导读。 ### 核心领域对象 #### Public Schema(平台运营层) | 领域对象 | 表 | 业务说明 | |----------|-----|----------| | **Tenant(租户)** | `public.tenants` | 每家房产经纪公司一条记录,含状态机(creating/active/suspended/pending_delete/deleted)、套餐、联系人 | | **Domain(域名)** | `public.domains` | 子域名↔租户映射,支持多域名绑定,子域名创建后不可修改 | | **TenantStatusLog** | `public.tenant_status_logs` | 租户状态变更不可变审计(append-only) | | **PlatformAdmin** | `public.platform_admins` | 平台管理员账号,3 种角色:超级管理员/运营人员/只读审计员 | | **AdminMfaDevice** | `public.admin_mfa_devices` | 管理员 TOTP 设备(强制启用) | | **AdminSession** | `public.admin_sessions` | 登录会话(30 分钟超时,支持强制登出) | | **IpWhitelist** | `public.ip_whitelist` | 管理控制台 CIDR 白名单 | | **PlatformAuditLog** | `public.platform_audit_logs` | 所有写操作+高危操作审计(append-only,建议月度分区) | | **BackupSchedule** | `public.backup_schedules` | 全局/租户级定时备份计划(频率/保留数/存储目标) | | **BackupRecord** | `public.backup_records` | 备份任务执行记录(自动/手动/升级前/恢复前) | | **ExportTask** | `public.export_tasks` | 数据导出异步任务(CSV/JSON/SQL Dump,24h 下载链接) | | **SystemVersion** | `public.system_versions` | 平台版本历史,唯一 current 版本约束 | | **UpgradeEvent** | `public.upgrade_events` | 升级/回滚事件,含灰度租户维度进度快照 | | **EnumLabel** | `public.enum_labels` | 固定枚举字典(英文 Key → 中文标签),所有租户共享,供前端下拉渲染、导出报表中文标签、日志快照使用 | #### Tenant Schema(租户业务层) | 领域对象 | 表/子文档 | 业务说明 | |----------|-----------|----------| | **OrgUnit(组织架构)** | `org_units` → [DATA_MODEL_ORG.md](./DATA_MODEL_ORG.md) | 树形组织架构(总部/区域/城市/大区/分公司/门店/团队/虚拟团队),物化路径存储,支持权限继承 | | **Staff(员工)** | `staff` → [DATA_MODEL_ORG.md](./DATA_MODEL_ORG.md) | 经纪人/店长/经理,绑定组织节点,手机号加密存储,与账号(登录)分离 | | **District(城区)** | `districts` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 行政区划,如「静安区」,是区域体系的顶层节点 | | **BusinessArea(商圈)** | `business_areas` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 商圈/板块,从属于城区,一个楼盘可归属多个商圈 | | **School(学校)** | `schools` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 对口学校数据库,是买家购房决策的核心参考,与楼盘多对多关联 | | **Complex(楼盘/小区)** | `complexes` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 房源录入的基础底座,维护楼盘标准名称/坐标/锁定状态/别名等 | | **Building(楼栋/单元)** | `buildings` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 楼盘下的物理楼栋,区分标准结构与非标结构 | | **RoomUnit(房号)** | `room_units` → [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 楼层+房间号,房源定位的最细粒度 | | **Property(房源)** | `properties` → [DATA_MODEL_PROPERTY.md](./DATA_MODEL_PROPERTY.md) | 系统核心表,每套二手房源的完整档案,支持出售/出租/出售兼出租三态 | | **Client(客源)** | `clients` → [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 买家/租客档案,分私客/公客/成交客,含活跃度评分与自动公客转换机制 | | **Viewing(带看)** | `client_viewings` → [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 经纪人带客户看房的完整记录 | | **Match(配对)** | `client_property_matches` → [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 系统/人工推荐的客源↔房源配对 | | **UserAccount(用户账号)** | `user_accounts` → [DATA_MODEL_LOGIN.md](./DATA_MODEL_LOGIN.md) | 系统登录主体,与员工档案 1:1 绑定,含账号锁定/密码历史/登录审计 | | **PermissionDef(权限定义)** | `permission_defs` → [DATA_MODEL_PERMISSION.md](./DATA_MODEL_PERMISSION.md) | 权限目录(约 300 条),驱动 Hybrid RBAC + Override 权限模型 | | **Role(业务角色)** | `roles` → [DATA_MODEL_PERMISSION.md](./DATA_MODEL_PERMISSION.md) | 权限模板,含 4 大类别(置业顾问/店管/总经/运营/自定义) | ### 领域关系快速导航 ``` District (城区) └─ BusinessArea (商圈) └─ Complex (楼盘) ─── School (对口学校) ├─ Building (楼栋) │ └─ RoomUnit (房号) └─ Property (房源) ├─ PropertyContact (联系人/委托方) ├─ FollowLog (跟进日志) ├─ Viewing (带看记录) ──── Client (客源) └─ Match (配对记录) ──────┘ OrgUnit (组织架构) └─ Staff (员工/经纪人) ─── Property / Client / Viewing / Match ``` ### 子文档索引 | 子文档 | 覆盖模块 | 状态 | |--------|----------|------| | [DATA_MODEL_PUBLIC.md](./DATA_MODEL_PUBLIC.md) | Public schema 平台运营层(tenants, domains, platform_admins, admin_sessions, audit_logs, backup, export, upgrade 共 13 张表) | ✅ 完成 | | [DATA_MODEL_ORG.md](./DATA_MODEL_ORG.md) | 组织人事(org_units, staff, 异动/奖惩/教育/家庭等) | ✅ 完成 | | [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 楼盘/区域(districts, business_areas, complexes, buildings, room_units, schools 等) | ✅ 完成 | | [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 客源管理(clients, requirements, follow_logs, viewings, matches 等) | ✅ 完成 | | [DATA_MODEL_PROPERTY.md](./DATA_MODEL_PROPERTY.md) | 房源管理(properties 及配套 22 张表,含跟进/钥匙/委托/实勘/营销/产证/完成度/标签/收藏/保护/号码方审批等) | ✅ 完成 | | [DATA_MODEL_LOGIN.md](./DATA_MODEL_LOGIN.md) | 登录与账号认证(user_accounts, login_attempts, password_reset_tokens, password_histories + Redis 登录缓存) | ✅ 完成 | | [DATA_MODEL_PERMISSION.md](./DATA_MODEL_PERMISSION.md) | 权限管理(permission_defs, roles, role_permissions, staff_roles, staff_permission_overrides, staff_data_scopes, permission_change_logs + Redis 权限缓存) | ✅ 完成 | | [ENUMS.md](./ENUMS.md) | 枚举字典(`public.enum_labels` 表设计 + 所有模块枚举定义 + 种子数据 SQL) | ✅ 完成 | | [ADR.md](../ADR.md) | 架构与需求决策记录(按日期/按模块/历史流水,append-only) | ✅ 完成 | --- ## 三、公共 Schema(Shared / Public) > **权威源**:完整 DDL 已迁至 [`DATA_MODEL_PUBLIC.md`](./DATA_MODEL_PUBLIC.md),本节仅保留摘要索引。 > **覆盖范围**:`public` schema 存储平台运营层数据——租户注册、管理员账号、审计日志、备份/导出任务、版本升级记录(共 13 张表)。 > **设计依据**:系统管理模块 PRD(`PRD/系统管理/系统管理模块PRD.md`)。 ### 表清单(开发以 DATA_MODEL_PUBLIC.md 为准) | 表名 | 说明 | 节 | |------|------|----| | `public.tenants` | 租户主表(django-tenants 核心,状态机 6 态) | §2.1 | | `public.domains` | 域名↔租户映射(支持多域名,子域名不可修改) | §2.1 | | `public.tenant_status_logs` | 租户状态变更不可变审计日志(append-only) | §2.1 | | `public.platform_admins` | 平台管理员账号(super_admin/ops_operator/read_only_auditor) | §2.2 | | `public.admin_mfa_devices` | 管理员 TOTP MFA 设备(强制启用) | §2.2 | | `public.admin_sessions` | 管理员登录会话(30 min 滚动超时,支持强制登出) | §2.2 | | `public.ip_whitelist` | 管理控制台 CIDR 白名单 | §2.2 | | `public.platform_audit_logs` | 所有写操作+高危操作审计(append-only,建议月度分区) | §2.3 | | `public.backup_schedules` | 全局/租户级定时备份计划(NULL tenant_id = 全局默认) | §2.4 | | `public.backup_records` | 备份任务执行记录(auto/manual/pre_upgrade/pre_restore) | §2.4 | | `public.export_tasks` | 数据导出异步任务(CSV/JSON/SQL Dump,24h 下载链接) | §2.4 | | `public.system_versions` | 平台版本历史,部分唯一索引保证唯一 current | §2.5 | | `public.upgrade_events` | 升级/回滚事件,`tenant_progress` JSONB 快照各租户状态 | §2.5 | | `public.enum_labels` | 固定枚举字典(英文 Key → 中文标签),所有租户共享 | §2.6 | **关键约束提示**: - `tenant_status_logs` / `platform_audit_logs` **无 deleted_at**,禁止 UPDATE/DELETE,append-only - `public.tenants.schema_name` 创建后**不可修改** - `public.tenants` 不再使用 `is_active` boolean,改用 6 态 `status` 枚举 - `platform_admins` 与租户 `staff` **完全独立**,不共享 auth 系统 - `system_versions` 通过部分唯一索引确保全局只有一个 `status='current'` --- ## 四、租户 Schema(Tenant Schema) 以下所有表均在每个租户的独立 Schema 内创建。 --- ### 3.1 组织人事模块(Organization & HR) > **详细模型** → 见 [`DATA_MODEL_ORG.md`](./DATA_MODEL_ORG.md) > 该文件为权威定义,包含完整字段、枚举、查询模式和禁止操作。 **核心表概览**(开发时以 DATA_MODEL_ORG.md 为准): | 表名 | 说明 | |------|------| | `org_units` | 组织树节点(公司/事业部/大区/区域/片区/门店/店组/职能),物化路径树 | | `staff` | 员工主表,含加密手机号、角色、在职状态、Django auth 绑定 | | `staff_personal_info` | 员工个人信息扩展(证件、学历、婚育等,1:1) | | `staff_transfer_logs` | 人事异动不可变审计日志(入职/调动/离职/复职等) | | `staff_reward_punish` | 奖惩记录 | | `staff_work_experiences` | 工作经历 | | `staff_educations` | 教育经历 | | `staff_trainings` | 培训经历 | | `staff_family_members` | 家庭成员 | | `staff_accounts` | 第三方平台账号绑定(58安居客/中国网络经纪人等) | **关键约束提示**: - `staff.phone_enc` AES-256-GCM 加密,`staff.phone_hash` SHA-256 用于唯一索引 - `staff_transfer_logs` **无 deleted_at**,不可删除 - `org_units` 路径查询:`WHERE path LIKE '/root/{target_id}/%'` - 员工离职:`status = 'resigned'` + `deleted_at` 软删除,记录永久保留 --- ### 3.2 区域与楼盘模块(Region & Complex Management) > **详细模型** → 见 [`DATA_MODEL_COMPLEX.md`](./DATA_MODEL_COMPLEX.md) > 本节仅作概览,开发时以 DATA_MODEL_COMPLEX.md 为权威定义。 **核心表概览**(开发时以 DATA_MODEL_COMPLEX.md 为准): | 表名 | 说明 | 关键字段 | |------|------|----------| | `districts` | 城区/行政区 | `city`, `name`, `short_name`, `sort_order` | | `business_areas` | 商圈/板块(从属于城区) | `district_id`, `name`, `latitude`, `longitude` | | `metro_lines` | 地铁线路 | `city`, `name`, `color` | | `metro_stations` | 地铁站点 | `metro_line_id`, `name`, `latitude`, `longitude` | | `schools` | 学校(对口学区) | `district_id`, `name`, `type`, `nature`, `level` | | `complexes` | 楼盘/小区(房源底座) | `name`, `district_id`, `address`, `latitude/longitude`, `lock_*`, `search_vector` | | `complex_aliases` | 楼盘别名(含系统别名/用户自定义别名) | `complex_id`, `alias`, `is_system` | | `complex_business_areas` | 楼盘↔商圈多对多(含主商圈标识) | `complex_id`, `business_area_id`, `is_primary` | | `complex_schools` | 楼盘↔学校关联(含学区类型) | `complex_id`, `school_id`, `zone_type` | | `complex_metro_stations` | 楼盘↔地铁站关联(含步行距离) | `complex_id`, `station_id`, `distance_meters` | | `buildings` | 楼栋/单元 | `complex_id`, `name`, `is_standard`, `total_floors` | | `room_units` | 房号/结构单元(楼层+房间号) | `building_id`, `floor`, `room_no`, `is_standard` | | `complex_photos` | 楼盘照片(楼盘图/户型图/VR) | `complex_id`, `category`, `file_key`, `is_cover` | | `complex_attachments` | 楼盘附件 | `complex_id`, `file_key`, `file_name` | | `complex_price_trends` | 楼盘价格走势(月度) | `complex_id`, `record_month`, `avg_unit_price` | --- ### 3.3 房源模块(Property Management) > **详细模型** → 见 [`DATA_MODEL_PROPERTY.md`](./DATA_MODEL_PROPERTY.md) > 本节仅作概览,开发时以 DATA_MODEL_PROPERTY.md 为权威定义。 **核心表概览**(开发时以 DATA_MODEL_PROPERTY.md 为准): | 表名 | 说明 | 关键字段 | | ------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | | `properties` | 房源主表(系统核心,89,000+ 数据量) | `status`, `attribute`, `property_type`, `complex_id`, `sale_price`, `area`, `grade`, `completeness_score`, `search_vector` | | `property_contacts` | 业主/联系人(手机号 AES 加密+哈希索引) | `property_id`, `phone_enc`, `phone_hash`, `identity`, `is_number_holder` | | `listing_histories` | 挂牌历史快照(不可删除) | `property_id`, `listing_type`, `status`, `sale_price`, `seller_agent_snapshot` | | `price_changes` | 调价记录(不可删除) | `property_id`, `old_sale_price`, `new_sale_price`, `change_reason`, `changed_by` | | `follow_logs` | 跟进日志(6种类型,最高写入频率) | `property_id`, `log_type`, `content`, `is_deletable`, `operator_id` | | `follow_log_attachments` | 跟进附件(图片) | `follow_log_id`, `file_key`, `file_type` | | `follow_log_recordings` | 跟进录音 | `follow_log_id`, `file_key`, `duration_seconds` | | `property_keys` | 钥匙管理(机械钥匙/密码) | `property_id`, `key_type`, `holder_id`, `is_active` | | `key_attachments` | 钥匙附件 | `key_id`, `file_key` | | `commissions` | 委托管理(独家/非独家) | `property_id`, `commission_type`, `period_start`, `status` | | `commission_attachments` | 委托附件(身份证/产证/委托书) | `commission_id`, `category`, `file_key` | | `field_surveys` | 实勘管理(GPS 打卡) | `property_id`, `status`, `gps_latitude`, `gps_longitude`, `created_by` | | `survey_photos` | 实勘照片(按空间分类) | `survey_id`, `category`, `file_key`, `is_vr_screenshot` | | `property_photos` | 房源图片(经纪人管理,封面唯一约束) | `property_id`, `category`, `is_cover`, `file_key` | | `property_attachments` | 房源附件 | `property_id`, `category`, `file_key` | | `property_marketing` | 营销信息(1:1,卖点/业主心态/介绍) | `property_id`, `marketing_title`, `core_selling_points` | | `property_certificates` | 产证信息(1:1) | `property_id`, `cert_no`, `owner_name`, `land_nature` | | `property_completeness` | 维护完成度快照(1:1,Celery 异步计算) | `property_id`, `total_score`, `score_survey`, `score_commission`, ... | | `property_tags` | 标签字典(系统预置+运营自定义) | `name`, `color`, `is_system` | | `property_tag_relations` | 房源↔标签多对多 | `property_id`, `tag_id` | | `property_favorites` | 经纪人收藏房源 | `staff_id`, `property_id` | | `property_protections` | 保护房设置(1:1) | `property_id`, `is_protected`, `start_at`, `end_at` | | `number_holder_approvals` | 号码方变更审批 | `property_id`, `applicant_id`, `status` | **关键约束提示**: - `property_contacts.phone_hash` 是重复房源检测的主要依据,录入前必须查重 - `listing_histories` / `price_changes` **无 deleted_at**,不可删除 - `follow_logs` 中 `is_deletable=FALSE`(`sensitive_view` 类型)不可软删 - `completeness_score` 只由 Celery 任务写入,Application 层禁止直接更新 - `last_followed_at` 由触发器 `trg_update_last_followed` 自动维护 - `property_photos.is_cover` 唯一约束:每套房源仅一张封面 --- ### 3.4 登录与账号认证(Login & Account) > **详细模型** → 见 [`DATA_MODEL_LOGIN.md`](./DATA_MODEL_LOGIN.md) > 该文件为权威定义,包含完整字段、状态机、Redis 缓存结构和禁止操作。 **核心表概览**(开发时以 DATA_MODEL_LOGIN.md 为准): | 表名 | 说明 | |------|------| | `user_accounts` | 账号主表(1:1 绑定 `org.Staff`),含加密手机号/哈希、状态机(active/locked/disabled)、初始密码标识 | | `login_attempts` | 登录审计日志(append-only,成功/失败均记录,无 FK 冗余存 username 保证历史完整) | | `password_reset_tokens` | 密码重置 Token(有效期 30 分钟,使用后立即标记 `is_used`) | | `password_histories` | 历史密码记录(保留最近 3 条,含初始密码,防止重复使用) | **关键约束提示**: - `user_accounts` 主键用 `BIGSERIAL`(非 UUID),登录审计场景 BigInt 更高效 - `user_accounts.phone_enc` AES-256-GCM 加密,`phone_hash` SHA-256 用于唯一索引 - **禁止物理删除** `user_accounts`,离职员工只能 `status=disabled` - 账号锁定(5 次密码错误)→ `status=locked`,`locked_until=NOW()+30min`;Redis 仅计数,实际锁定以 DB 为准 - Tenant Admin 的 `staff_id` 可为空(可无员工档案);普通员工 `staff_id` 必填且关联 active Staff - 员工离职(`org.Staff.status→resigned`)→ 应用层 Service 调用触发账号 `status→disabled`,**禁止循环 FK** - `password_reset_tokens` / `login_attempts` **无 deleted_at**,不可修改/删除 **Redis 辅助状态**(非持久化): | Key 格式 | TTL | 说明 | |----------|-----|------| | `captcha_token:{uuid}` | 3 分钟 | 滑块验证会话 Token | | `captcha_pass:{uuid}` | 3 分钟 | 一次性通过凭证(验证后立即删除) | | `login_fail:{tenant_id}:{username}` | 30 分钟 | 连续密码错误计数;≥5 触发锁定 | | `recover_email:{email}` | 1 小时 | 找回邮件发送次数上限 3 次 | | `tenant_verify_ip:{ip}` | 1 分钟 | Tenant 验证接口 IP 限流;≥10 次拒绝 | --- ### 3.5 权限管理(Permission & RBAC) > **详细模型** → 见 [`DATA_MODEL_PERMISSION.md`](./DATA_MODEL_PERMISSION.md) > 该文件为权威定义,包含完整字段、权限解析算法、`ScopeQueryBuilder` 实现和禁止操作。 **权限模型概述**:Hybrid RBAC + Individual Override,支持 `BOOLEAN / SCOPE / INTEGER` 三类权限值,多角色合并规则 OR / MAX。 **核心表概览**(开发时以 DATA_MODEL_PERMISSION.md 为准): | 表名 | 说明 | |------|------| | `permission_defs` | 权限目录(约 300 条,`PUBLIC Schema` 中 `shared_apps` 存储,所有租户共享),含模块/分组/值类型/默认值/上限类别 | | `roles` | 业务角色(每租户独立),5 种类别:`agent/store_manager/director/operator/custom`,含系统内置标识 | | `role_permissions` | 角色↔权限值(稀疏存储,仅存与 default_value 不同的项) | | `staff_roles` | 员工↔角色分配(N:M,含主角色标识 `is_primary`、有效期) | | `staff_permission_overrides` | 员工个人权限覆盖(稀疏存储,仅存与角色合并值不同的项),3 种 override_mode:REPLACE / RESTRICT / GRANT | | `staff_data_scopes` | 员工数据范围扩展(补充 SCOPE 权限之外的额外可读范围,如特殊跨门店授权) | | `permission_change_logs` | 权限变更不可变审计日志(append-only,禁止 UPDATE/DELETE) | **关键约束提示**: - `permission_defs` 位于 **Public Schema**(`shared_apps`),所有租户共享;`roles` 及其余表属租户 Schema - **禁止硬删除** `permission_defs`,改用 `is_active=FALSE` 下线;`code` 字段不可修改 - **禁止直接构造 Q 对象绕过 `ScopeQueryBuilder`**,会导致越权漏洞 - `permission_change_logs` **无 deleted_at**,禁止 UPDATE/DELETE - 员工权限解析:`is_system_admin=TRUE` → 短路返回全权限;否则多角色 OR/MAX 合并后叠加 Override - `StaffPermissionOverride` 保存前必须做差异对比,**禁止存与角色合并值相同的冗余记录**(稀疏存储) - `staff_roles.is_primary` 唯一约束通过 Signal 维护,**禁止绕过** **权限解析缓存**: | Cache Key | TTL | 失效触发 | |-----------|-----|---------| | `perm:v{VER}:{schema}:{staff_id}` | 3600s | Override / StaffRole 变更 | | `perm:v{VER}:{schema}:role:{role_id}:staff_ids` | 3600s | 角色权限变更 → Pipeline 批量失效 | | `perm:inconsistent:{schema}:{staff_id}` | 300s | 同上 | | `perm:defs:{schema}` | 86400s | PermissionDef 变更(低频) | | `perm:role_applied_count:{schema}:{role_id}` | 600s | StaffRole 变更 | > **版本号机制**:`CACHE_VERSION` 在 Django settings 中,升级 PermissionDef 结构时 bump,一键全局失效。 --- ### 3.17 客源管理(Client Management) > **详细模型** → 见 [`DATA_MODEL_CLIENT.md`](./DATA_MODEL_CLIENT.md) > 该文件为权威定义,包含完整字段、枚举、状态机、查询模式和禁止操作。 **核心表概览**(开发时以 DATA_MODEL_CLIENT.md 为准): | 表名 | 说明 | |------|------| | `clients` | 客源主表(私客/公客/成交客),含加密手机号哈希、活跃度、归属人 | | `client_contacts` | 联系人(1:N),手机号加密+哈希,支持多联系人 | | `client_requirements` | 需求信息(可多类型:二手/新房/租房),含预算/面积/商圈/朝向等偏好 | | `client_follow_logs` | 跟进日志(高写入频率,5种类型,敏感查看类型不可删) | | `client_follow_log_attachments` | 跟进附件(图片/录音,最大20MB) | | `client_viewings` | 带看/预约记录(1:N,含陪看人/合作带看人) | | `client_property_matches` | 智能配房结果(录客配房/系统配房,匹配度评分) | | `client_status_logs` | 状态变更不可变审计日志(改状态/改等级/转公/转成交/转无效等) | | `client_favorite_folders` | 私客收藏夹(经纪人自定义分组) | | `client_folder_items` | 收藏夹与客源的多对多关联 | | `client_school_preferences` | 意向学校(拆表,支持精确查询) | **关键约束提示**: - `client_contacts.phone_hash` 是重复客源检测的唯一依据,录入前必须查重 - `client_status_logs` **无 deleted_at**,不可删除 - 私客超时(配置天数内无跟进)→ Celery 自动转公(`transfer_to_public_type = 'auto'`) - 活跃度 `activity_level` 由 Celery 每日凌晨批量计算,不实时更新 --- ### 3.18 系统设置(System Settings) > **归属说明**: > - `lookup_categories` / `lookup_items` / `saved_filters` 为**跨模块**系统表,权威定义在本节。 > - `property_tags` / `property_tag_relations` / `property_favorites` / `property_protections` / `number_holder_approvals` 属房源模块配套表,**权威定义已迁至** [`DATA_MODEL_PROPERTY.md §4.19-§4.22`](./DATA_MODEL_PROPERTY.md),本节不再重复 DDL(修复 S1/S2)。 ```sql -- ============================================================ -- 枚举/选项管理:跟进目的、标签、来源渠道 等运营维护的枚举值 -- ============================================================ CREATE TABLE lookup_categories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code VARCHAR(50) UNIQUE NOT NULL, -- 如:follow_purpose, property_source name VARCHAR(100) NOT NULL, module VARCHAR(30) NOT NULL -- property/client/system ); CREATE TABLE lookup_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), category_id UUID NOT NULL REFERENCES lookup_categories(id) ON DELETE CASCADE, value VARCHAR(100) NOT NULL, label VARCHAR(100) NOT NULL, -- 显示文本 sort_order INTEGER NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, metadata JSONB NOT NULL DEFAULT '{}', -- 扩展属性 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_lookup_items_category ON lookup_items(category_id) WHERE is_active = TRUE; CREATE UNIQUE INDEX idx_lookup_items_value ON lookup_items(category_id, value); -- 筛选方案(保存的搜索条件,跨模块通用) CREATE TABLE saved_filters ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, module VARCHAR(20) NOT NULL DEFAULT 'property', filter_params JSONB NOT NULL, -- 完整筛选参数 JSON created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_saved_filters_staff ON saved_filters(staff_id, module); ``` **已迁出本节的表**(权威源见子文档): | 表名 | 权威定义位置 | |------|-------------| | `property_tags` | [`DATA_MODEL_PROPERTY.md §4.19`](./DATA_MODEL_PROPERTY.md) | | `property_tag_relations` | [`DATA_MODEL_PROPERTY.md §4.19`](./DATA_MODEL_PROPERTY.md) | | `property_favorites` | [`DATA_MODEL_PROPERTY.md §4.20`](./DATA_MODEL_PROPERTY.md) | | `property_protections` | [`DATA_MODEL_PROPERTY.md §4.21`](./DATA_MODEL_PROPERTY.md) | | `number_holder_approvals` | [`DATA_MODEL_PROPERTY.md §4.22`](./DATA_MODEL_PROPERTY.md) | --- ### 3.19 枚举字典(Enum Labels) > **权威定义** → 见 [`DATA_MODEL/ENUMS.md`](./ENUMS.md) > 本节为概览,开发时以 ENUMS.md 为准。 #### 表归属 `enum_labels` 位于 **Public Schema**(`shared_apps`),所有租户共享,**不属于任何租户 Schema**。 #### 核心表设计 ```sql CREATE TABLE enum_labels ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), domain VARCHAR(60) NOT NULL, -- 枚举域,格式:{模块}.{字段},如 client.status value VARCHAR(60) NOT NULL, -- 英文 Key(与数据库 CHECK 约束一致) label_zh VARCHAR(60) NOT NULL, -- 中文标签(前端展示用) sort_order SMALLINT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, remark TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE UNIQUE INDEX idx_enum_labels_domain_value ON enum_labels(domain, value); CREATE INDEX idx_enum_labels_domain ON enum_labels(domain, sort_order); ``` #### 覆盖的枚举域(domain 清单) | domain | 说明 | 对应表字段 | |--------|------|-----------| | `client.status` | 客源状态(8 态) | `clients.status` | | `client.grade` | 客源等级(5 档 + E) | `clients.grade` | | `client.requirement_type` | 需求类型(旧:`client.purpose_type`) | `client_requirements.requirement_type` | | `client.property_usage` | 房源用途偏好(旧:`client.usage`) | `client_requirements.property_usage` | | `client.orientation` | 朝向偏好 | `client_requirements.orientation` | | `client.payment_method` | 付款方式 | `clients.payment_method` | | `property.status` | 房源状态 | `properties.status` | | `property.attribute` | 房源属性(公/私/保护) | `properties.attribute` | | `property.property_type` | 房源类型(旧:`property.usage`) | `properties.property_type` | | `property.grade` | 房源等级(5 档) | `properties.grade` | | `property.listing_history.listing_type` | 挂牌类型(旧:`property.listing_type`) | `listing_histories.listing_type` | | `property.decoration` | 装修程度 | `properties.decoration` | | `property.orientation` | 朝向 | `properties.orientation` | | `property.commission.status` | 委托状态(旧:`commission.type`) | `commissions.status` | | `field_survey.status` | 实勘状态 | `field_surveys.status` | | `follow_log.log_type` | 跟进日志类型 | `follow_logs.log_type` | #### 重要约定 - `enum_labels.value` 必须与对应表的 `CHECK` 约束完全一致,**两者必须同步修改** - 新增枚举值流程:① 修改 DDL `CHECK` 约束 → ② 插入 `enum_labels` 种子数据 → ③ 更新 `ENUMS.md` - `is_active = FALSE` 仅停用前端展示,**不得修改或删除已有 `value`**(历史数据引用不可破坏) - 前端下拉渲染**统一从 `enum_labels` 读取**,禁止在前端代码中硬编码中文标签 #### 与 `lookup_items` 的区别 | 对比维度 | `enum_labels` | `lookup_items` | |---------|---------------|----------------| | 用途 | 固定枚举的中文标签映射 | 运营可配置的动态选项(如跟进目的、来源渠道) | | 修改权限 | 仅开发/DBA | 运营人员后台配置 | | Schema 位置 | Public Schema(共享) | Tenant Schema(每租户独立) | | 典型示例 | 客源状态、房源等级 | 跟进目的、客户来源渠道 | --- ## 五、关键索引汇总与查询优化策略 ### 4.1 房源列表页核心查询分析 ```sql -- 典型查询:出售状态 + 公盘 + 特定区域 + 价格区间 + 户型筛选 + 按挂牌日期排序 -- 优化方案:复合索引覆盖最高频维度组合 -- 高频组合索引(status + attribute,覆盖 90% 的列表查询) CREATE INDEX idx_properties_list_composite ON properties (status, attribute, complex_id, sale_price DESC NULLS LAST) WHERE deleted_at IS NULL; -- 与我相关查询(经纪人个人仪表板) CREATE INDEX idx_properties_my_properties ON properties (seller_agent_id, status, listed_at DESC NULLS LAST) WHERE deleted_at IS NULL; ``` ### 4.2 全文搜索触发器(自动维护 search_vector) ```sql -- 房源全文检索向量更新触发器 CREATE OR REPLACE FUNCTION update_property_search_vector() RETURNS TRIGGER AS $$ BEGIN NEW.search_vector := setweight(to_tsvector('simple', COALESCE(NEW.block_no, '') || ' ' || COALESCE(NEW.unit_no, '') || ' ' || COALESCE(NEW.room_no, '')), 'A') || setweight(to_tsvector('simple', COALESCE(NEW.remarks, '')), 'C'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_property_search_vector BEFORE INSERT OR UPDATE OF block_no, unit_no, room_no, remarks ON properties FOR EACH ROW EXECUTE FUNCTION update_property_search_vector(); -- 楼盘全文检索向量(含别名,提升模糊搜索精度) CREATE OR REPLACE FUNCTION update_complex_search_vector() RETURNS TRIGGER AS $$ BEGIN NEW.search_vector := setweight(to_tsvector('simple', COALESCE(NEW.name, '')), 'A') || setweight(to_tsvector('simple', COALESCE(NEW.alias, '')), 'B') || setweight(to_tsvector('simple', COALESCE(NEW.address, '')), 'C'); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_complex_search_vector BEFORE INSERT OR UPDATE OF name, alias, address ON complexes FOR EACH ROW EXECUTE FUNCTION update_complex_search_vector(); ``` ### 4.3 last_followed_at 自动维护触发器 ```sql -- 每次写入跟进日志时,自动更新 properties.last_followed_at CREATE OR REPLACE FUNCTION update_property_last_followed() RETURNS TRIGGER AS $$ BEGIN IF NEW.log_type = 'written' THEN UPDATE properties SET last_followed_at = NEW.created_at, updated_at = NOW() WHERE id = NEW.property_id; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_update_last_followed AFTER INSERT ON follow_logs FOR EACH ROW EXECUTE FUNCTION update_property_last_followed(); ``` --- ## 六、Redis 缓存策略 ### 5.1 缓存 Key 规范 ``` # 格式:{tenant_schema}:{module}:{entity}:{id}:{field} # TTL 单位:秒 # 房源详情(高频读取) {schema}:prop:detail:{property_id} TTL: 300 (5分钟) # 房源联系人(含解密号码,敏感,TTL 短) {schema}:prop:contacts:{property_id} TTL: 60 (1分钟) # 楼盘基础信息(低变更频率) {schema}:complex:base:{complex_id} TTL: 3600 (1小时) # 楼盘名称自动补全候选列表(联想搜索) {schema}:complex:autocomplete:{prefix} TTL: 600 (10分钟) # 员工信息(用于日志快照) {schema}:staff:base:{staff_id} TTL: 1800 (30分钟) # 枚举值/lookup(几乎不变) {schema}:lookup:{category_code} TTL: 86400 (24小时) # 登录模块(详见 DATA_MODEL_LOGIN.md §四) captcha_token:{uuid} TTL: 180 (3分钟) captcha_pass:{uuid} TTL: 180 (3分钟) login_fail:{tenant_id}:{username} TTL: 1800 (30分钟,连续失败计数) recover_email:{email} TTL: 3600 (1小时,发送次数限流) recover_reset:{account_id} TTL: 3600 (1小时,Token 生成次数限流) tenant_verify_ip:{ip} TTL: 60 (1分钟,IP 限流) # 权限模块(详见 DATA_MODEL_PERMISSION.md §六) perm:v{VER}:{schema}:{staff_id} TTL: 3600 (员工完整权限快照) perm:v{VER}:{schema}:role:{role_id}:staff_ids TTL: 3600 (角色→员工 ID 列表,批量失效用) perm:inconsistent:{schema}:{staff_id} TTL: 300 (权限不一致标记) perm:defs:{schema} TTL: 86400 (权限定义全量缓存) perm:role_applied_count:{schema}:{role_id} TTL: 600 (角色应用人数) # 标签列表 {schema}:tags:property TTL: 3600 # 维护完成度(Celery 计算后写入,详情页直接读 Redis) {schema}:prop:completeness:{property_id} TTL: 600 # 房源列表计数(筛选后总条数,避免 COUNT(*) 全扫) {schema}:prop:count:{filter_hash} TTL: 30 (短TTL,保证准确性) ``` ### 5.2 缓存失效策略 ```python # Django Signal 驱动的缓存失效(在 models.py 中注册) # 房源更新 → 失效详情缓存 + 完成度缓存 # 跟进日志新增 → 失效 last_followed_at 缓存 # 联系人更新 → 失效联系人缓存(立即) # 楼盘更新 → 失效楼盘缓存 + 相关房源缓存(批量) # 枚举更新 → 失效对应 lookup 缓存 ``` --- ## 七、Django Model 层设计要点 ### 6.1 抽象基类 ```python # models/base.py import uuid from django.db import models class UUIDPrimaryKeyModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) class Meta: abstract = True class TimeStampedModel(UUIDPrimaryKeyModel): created_at = models.DateTimeField(auto_now_add=True, db_index=False) updated_at = models.DateTimeField(auto_now=True) class Meta: abstract = True class SoftDeleteModel(TimeStampedModel): deleted_at = models.DateTimeField(null=True, blank=True, db_index=False) class Meta: abstract = True def soft_delete(self, deleted_by=None): from django.utils import timezone self.deleted_at = timezone.now() self.save(update_fields=['deleted_at', 'updated_at']) class AuditedModel(SoftDeleteModel): created_by = models.ForeignKey( 'staff.Staff', null=True, on_delete=models.SET_NULL, related_name='+', db_column='created_by' ) updated_by = models.ForeignKey( 'staff.Staff', null=True, on_delete=models.SET_NULL, related_name='+', db_column='updated_by' ) class Meta: abstract = True ``` ### 6.2 加密字段 Mixin ```python # utils/encryption.py # 手机号加密:AES-256-GCM + SHA-256 哈希索引 class EncryptedPhoneField: """ 存储时:phone → AES加密 → phone_enc (BYTEA) phone → SHA256 → phone_hash (VARCHAR 64) 查询时:phone_hash 走索引,phone_enc 解密展示 打码展示:前3位明文 + ******* + 后3位 """ pass ``` ### 6.3 Manager 过滤软删除 ```python class ActiveManager(models.Manager): def get_queryset(self): return super().get_queryset().filter(deleted_at__isnull=True) class PropertyManager(ActiveManager): def public(self): return self.get_queryset().filter(attribute='public') def mine(self, staff_id): return self.get_queryset().filter(seller_agent_id=staff_id) ``` --- ## 八、数据量与性能预测 | 表名 | 预估行数 | 增长速度 | 分区策略 | |------|---------|---------|---------| | `properties` | 89,000+ | 中速 | 暂不分区,建议 500k 后按 `created_at` RANGE 分区 | | `follow_logs` | 200万+ | 高速(最高频写入) | ✅ `PARTITION BY RANGE (created_at)` 月度分区 | | `property_photos` | 500万+ | 高速 | ✅ `PARTITION BY RANGE (created_at)` 月度分区 | | `permission_change_logs` | 100万+ | 中高速 | ✅ `PARTITION BY RANGE (operated_at)` 月度分区 | | `login_attempts` | 500万+ | 高速(每次登录一条) | ✅ `PARTITION BY RANGE (attempted_at)` 月度分区 | | `platform_audit_logs` | 10万+ | 低中速 | ✅ `PARTITION BY RANGE (created_at)` 月度分区 | | `price_changes` | 50万 | 中速 | 无需分区 | | `listing_histories` | 20万 | 低速 | 无需分区 | | `clients` | 10万+ | 中速 | 暂不分区 | | `viewings` | 100万 | 中速 | 无需分区 | ### 8.1 分区维护策略(partition_maintenance_task) 所有月度分区表统一由 **Celery Beat 定时任务** `partition_maintenance_task` 维护,每月 1 日凌晨 01:00(UTC+8)自动执行: ```python # apps/property/tasks.py(及 permission/login/shared 各 App 对应任务) @app.task(name="partition_maintenance_task") def partition_maintenance_task(): """ 为下一个月预建所有分区表的分区。 - 检查是否已存在目标分区,幂等执行 - 失败时发送 Sentry 告警 """ tables = [ ("follow_logs", "created_at"), ("property_photos", "created_at"), ("permission_change_logs", "operated_at"), ("login_attempts", "attempted_at"), ("public.platform_audit_logs", "created_at"), ] next_month = date.today().replace(day=1) + relativedelta(months=1) month_start = next_month month_end = next_month + relativedelta(months=1) for table, _key in tables: suffix = month_start.strftime("%Y_%m") part_name = f"{table.replace('.', '_')}_{suffix}" sql = f""" CREATE TABLE IF NOT EXISTS {part_name} PARTITION OF {table} FOR VALUES FROM ('{month_start}') TO ('{month_end}'); """ with connection.cursor() as cursor: cursor.execute(sql) ``` **Celery Beat 配置**(`celery.py`): ```python app.conf.beat_schedule["partition_maintenance_task"] = { "task": "partition_maintenance_task", "schedule": crontab(day_of_month=1, hour=1, minute=0), # 每月1日 01:00 UTC+8 } ``` > ⚠️ **注意**:每张分区表均保留一个 `_default` 默认分区作为兜底,防止任务失败时写入报错。`_default` 分区数据应在运维 SOP 中周期性检查(有数据则说明提前建分区失败)。 --- ## 九、必须在开发启动前明确的数据架构决策 | 决策项 | 推荐方案 | 风险 | |-------|---------|------| | 小区数据来源 | 预导入基础数据(安居客/链家 API)+ 支持手动新增兜底 | 高:影响录入体验 | | 私盘可见范围 | 录入人所在门店可见(综合业务需求) | 需与权限模块约定 | | 号码查看权限 | 角色级控制:经纪人限查自己相关房源,店长无限制 | 需合规确认 | | 重复房源主键 | 主键:手机号 hash;辅助:(小区+楼栋+单元+房号)组合 | 需双重校验 | | 跟进目的枚举 | 存 lookup_items 表,运营可维护 | 初始化数据需提前收集 | | 手机号加密算法 | AES-256-GCM,密钥存 Django settings(生产用 Vault) | 密钥管理需单独规划 | ## 十、文档治理与 ADR 联动规则 - 任何会影响数据模型边界的变更(新增/删除表、关键约束变更、索引策略调整、枚举口径变更)必须先在 `ADR.md` 记录为 `accepted`,再修改本文件与子文档。 - 若既有数据决策被替代,必须在 `ADR.md` 增加 `superseded` 记录并指向新 ADR,禁止直接覆盖旧结论。 - 提交 PR 时,凡涉及 `DATA_MODEL/*` 结构性变更,PR 描述必须包含 ADR ID(格式:`ADR-YYYYMMDD-XXX`)。 - `DATA_MODEL.md` 只维护全局索引与规则;表级 DDL 以各子文档为权威源,避免双写漂移。 --- *本文档为 Fonrey 系统 DATA MODEL v1.5,随 PRD 与 ADR 迭代同步更新。* *下一步建议:API 接口规范(URL 设计 + Request/Response Schema)*