Files
nexus/Project/fonrey/DATA_MODEL/DATA_MODEL.md
2026-04-24 05:36:42 +08:00

63 KiB
Raw Blame History

Fonrey 房产经纪管理系统 — DATA MODEL 设计文档

作者: Backend Architect
版本: v1.0
日期: 2026-04-24
技术栈: Django 4.x + PostgreSQL + django-tenants + Redis
设计目标: 支撑 89,000+ 房源、多租户隔离、sub-100ms 查询、合规审计


一、架构决策总览 (Architecture Decision Records)

1.1 多租户策略Schema-per-Tenant

┌─────────────────────────────────────────────────────────────┐
│  PostgreSQL Instance                                        │
│                                                             │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐     │
│  │ public schema│   │tenant_abc    │   │tenant_xyz    │     │
│  │  (shared)    │   │   schema     │   │   schema     │     │
│  │              │   │              │   │              │     │
│  │ - tenants    │   │ - properties │   │ - properties │     │
│  │ - domains    │   │ - clients    │   │ - clients    │     │
│  │              │   │ - complexes  │   │ - complexes  │     │
│  └──────────────┘   └──────────────┘   └──────────────┘     │
└─────────────────────────────────────────────────────────────┘

选型理由

  • 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) 万元精度,避免浮点误差

二、公共 SchemaShared / Public

-- ============================================================
-- 文件: shared_schema.sql
-- 用途: django-tenants 公共 Schema存放租户注册信息
-- ============================================================

-- 租户表(每家房产公司一条记录)
CREATE TABLE public.tenants (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    schema_name     VARCHAR(63) UNIQUE NOT NULL,  -- PG schema 名,最长 63 字符
    name            VARCHAR(255) NOT NULL,         -- 公司名称
    short_name      VARCHAR(100),                  -- 简称/品牌名
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    paid_until      DATE,                          -- 订阅到期日
    on_trial        BOOLEAN NOT NULL DEFAULT TRUE,
    extra           JSONB NOT NULL DEFAULT '{}'   -- 预留扩展字段
);

-- 域名映射表(支持多域名绑定一个租户)
CREATE TABLE public.domains (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    domain      VARCHAR(253) UNIQUE NOT NULL,      -- 含子域名的完整域名
    tenant_id   UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
    is_primary  BOOLEAN NOT NULL DEFAULT FALSE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_domains_tenant ON public.domains(tenant_id);
CREATE INDEX idx_domains_primary ON public.domains(tenant_id) WHERE is_primary = TRUE;

三、租户 SchemaTenant Schema

以下所有表均在每个租户的独立 Schema 内创建。


3.1 组织人事模块Organization & HR

-- ============================================================
-- 组织架构:公司 → 区域 → 门店 → 组
-- ============================================================

-- 组织节点表(树形结构,支持无限层级)
CREATE TABLE org_units (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            VARCHAR(100) NOT NULL,
    type            VARCHAR(20) NOT NULL
                    CHECK (type IN ('company','region','store','group')),
    parent_id       UUID REFERENCES org_units(id) ON DELETE RESTRICT,
    path            TEXT NOT NULL,             -- 物化路径:/root_id/parent_id/self_id/
    depth           SMALLINT NOT NULL DEFAULT 0,
    sort_order      INTEGER NOT NULL DEFAULT 0,
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at      TIMESTAMPTZ
);

CREATE INDEX idx_org_units_parent ON org_units(parent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_org_units_path ON org_units USING gist(path gist_trgm_ops);
-- 注gist_trgm_ops 需要 pg_trgm 扩展,用于路径前缀查询

-- 员工表
CREATE TABLE staff (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    org_unit_id     UUID NOT NULL REFERENCES org_units(id) ON DELETE RESTRICT,
    name            VARCHAR(50) NOT NULL,
    phone_hash      VARCHAR(64),               -- SHA-256 哈希,用于唯一性校验
    phone_enc       BYTEA,                     -- AES-256-GCM 加密后的手机号
    email           VARCHAR(255),
    role            VARCHAR(30) NOT NULL
                    CHECK (role IN ('agent','store_manager','admin','operator','system')),
    job_title       VARCHAR(100),              -- 职务描述
    avatar_key      TEXT,                      -- R2/S3 存储路径
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    joined_at       DATE,
    left_at         DATE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at      TIMESTAMPTZ,

    -- 关联 Django auth user用于登录认证
    user_id         INTEGER UNIQUE             -- FK to django auth_user
);

CREATE INDEX idx_staff_org ON staff(org_unit_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_staff_role ON staff(role) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_staff_phone_hash ON staff(phone_hash) WHERE deleted_at IS NULL;

3.2 区域与楼盘模块Region & Complex Management

-- ============================================================
-- 行政区 → 商圈 → 楼盘/小区 → 楼栋
-- 注:楼盘数据是房源录入的基础底座,数据质量直接影响房源录入效率
-- ============================================================

-- 城市/行政区
CREATE TABLE districts (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        VARCHAR(50) NOT NULL,
    city        VARCHAR(50) NOT NULL DEFAULT '',
    sort_order  INTEGER NOT NULL DEFAULT 0,
    is_active   BOOLEAN NOT NULL DEFAULT TRUE
);

-- 商圈/板块
CREATE TABLE business_areas (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    district_id UUID NOT NULL REFERENCES districts(id) ON DELETE RESTRICT,
    name        VARCHAR(100) NOT NULL,
    sort_order  INTEGER NOT NULL DEFAULT 0,
    is_active   BOOLEAN NOT NULL DEFAULT TRUE
);

CREATE INDEX idx_business_areas_district ON business_areas(district_id);

-- 地铁线路
CREATE TABLE metro_lines (
    id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name    VARCHAR(50) NOT NULL,
    color   VARCHAR(7)                         -- 线路颜色 HEX
);

-- 地铁站
CREATE TABLE metro_stations (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    metro_line_id   UUID NOT NULL REFERENCES metro_lines(id) ON DELETE CASCADE,
    name            VARCHAR(50) NOT NULL,
    sort_order      INTEGER NOT NULL DEFAULT 0
);

-- 学校
CREATE TABLE schools (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    district_id UUID REFERENCES districts(id) ON DELETE SET NULL,
    name        VARCHAR(100) NOT NULL,
    type        VARCHAR(20)                    -- 小学/初中/高中/九年一贯制 等
);

CREATE INDEX idx_schools_district ON schools(district_id);

-- 楼盘/小区(核心基础表)
CREATE TABLE complexes (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name                VARCHAR(200) NOT NULL,
    alias               VARCHAR(200),          -- 别名/曾用名
    district_id         UUID REFERENCES districts(id) ON DELETE SET NULL,
    business_area_id    UUID REFERENCES business_areas(id) ON DELETE SET NULL,
    address             VARCHAR(500),
    latitude            NUMERIC(10,7),
    longitude           NUMERIC(10,7),

    -- 楼盘物理属性
    developer           VARCHAR(200),          -- 开发商
    property_company    VARCHAR(200),          -- 物业公司
    property_fee        NUMERIC(8,2),          -- 物业费 元/㎡/月
    green_rate          NUMERIC(5,2),          -- 绿化率 %
    plot_ratio          NUMERIC(5,2),          -- 容积率
    built_year          SMALLINT,              -- 竣工年份
    ownership_years     VARCHAR(20),           -- 产权年限枚举

    -- 配套信息
    has_elevator        BOOLEAN,
    parking_info        TEXT,                  -- 车位情况描述

    -- 全文检索向量(定期更新)
    search_vector       TSVECTOR,

    is_active           BOOLEAN NOT NULL DEFAULT TRUE,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at          TIMESTAMPTZ,
    created_by          UUID REFERENCES staff(id) ON DELETE SET NULL
);

CREATE INDEX idx_complexes_district ON complexes(district_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_complexes_name_trgm ON complexes USING gin(name gin_trgm_ops);
CREATE INDEX idx_complexes_search ON complexes USING gin(search_vector);
CREATE INDEX idx_complexes_geo ON complexes(latitude, longitude) WHERE deleted_at IS NULL;

-- 楼盘与商圈多对多(一个楼盘可跨多个商圈)
CREATE TABLE complex_business_areas (
    complex_id      UUID NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
    business_area_id UUID NOT NULL REFERENCES business_areas(id) ON DELETE CASCADE,
    PRIMARY KEY (complex_id, business_area_id)
);

-- 楼盘与学校关联
CREATE TABLE complex_schools (
    complex_id  UUID NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
    school_id   UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
    school_zone VARCHAR(50),                   -- 学区情况:对口/参考等
    PRIMARY KEY (complex_id, school_id)
);

-- 楼盘与地铁站关联
CREATE TABLE complex_metro_stations (
    complex_id      UUID NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
    station_id      UUID NOT NULL REFERENCES metro_stations(id) ON DELETE CASCADE,
    distance_meters INTEGER,                   -- 步行距离(米)
    PRIMARY KEY (complex_id, station_id)
);

-- 楼栋
CREATE TABLE buildings (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    complex_id      UUID NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
    name            VARCHAR(50) NOT NULL,      -- 楼栋名,如"1号楼"
    total_floors    SMALLINT NOT NULL,
    has_elevator    BOOLEAN,
    building_type   VARCHAR(30),               -- 楼型:板楼/塔楼/板塔结合
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_buildings_complex ON buildings(complex_id);

3.3 房源核心模块Property Core

-- ============================================================
-- 房源主表:系统最核心的表,全部筛选/排序/搜索围绕此表展开
-- 设计重点89,000+ 数据量,复合索引策略,分区预留
-- ============================================================

CREATE TABLE properties (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- ── 基础分类 ──
    property_type   VARCHAR(20) NOT NULL
                    CHECK (property_type IN ('residential','villa','commercial_residential',
                                             'shop','office','other')),
    -- residential=住宅, villa=别墅, commercial_residential=商住,
    -- shop=商铺, office=写字楼, other=其他

    -- ── 交易状态 ──
    status          VARCHAR(20) NOT NULL DEFAULT 'for_sale'
                    CHECK (status IN ('for_sale','for_rent','for_sale_rent',
                                      'suspended','sold_elsewhere','rented_elsewhere',
                                      'sold','unlisted')),
    -- for_sale=出售, for_rent=出租, for_sale_rent=租售,
    -- suspended=暂缓, sold_elsewhere=他售, rented_elsewhere=他租,
    -- sold=成交, unlisted=未挂牌

    -- ── 流通属性 ──
    attribute       VARCHAR(20) NOT NULL DEFAULT 'public'
                    CHECK (attribute IN ('public','private','special','sealed')),
    -- public=公盘, private=私盘, special=特盘, sealed=封盘
    private_reason  TEXT,                      -- 私盘/封盘必填说明

    -- ── 位置信息 ──
    complex_id      UUID NOT NULL REFERENCES complexes(id) ON DELETE RESTRICT,
    building_id     UUID REFERENCES buildings(id) ON DELETE SET NULL,
    block_no        VARCHAR(30),               -- 栋/幢/弄号
    unit_no         VARCHAR(30),               -- 单元号
    room_no         VARCHAR(30),               -- 房号/门牌号
    floor           SMALLINT NOT NULL,         -- 所在楼层
    total_floors    SMALLINT NOT NULL,         -- 总楼层
    CONSTRAINT chk_floor CHECK (floor > 0 AND floor <= total_floors),

    -- ── 户型 ──
    bedroom_count   SMALLINT NOT NULL DEFAULT 0,    -- 室
    living_room_count SMALLINT NOT NULL DEFAULT 0,  -- 厅
    bathroom_count  SMALLINT NOT NULL DEFAULT 0,    -- 卫
    kitchen_count   SMALLINT NOT NULL DEFAULT 0,    -- 厨
    balcony_count   SMALLINT NOT NULL DEFAULT 0,    -- 阳台数

    -- ── 面积 ──
    area            NUMERIC(8,2) NOT NULL,          -- 建筑面积 m²
    inner_area      NUMERIC(8,2),                   -- 套内面积 m²编辑时填写

    -- ── 价格 ──
    sale_price      NUMERIC(12,2),                  -- 挂牌售价(万元)
    sale_bottom_price NUMERIC(12,2),                -- 售底价(万元,内部可见)
    sale_record_price NUMERIC(12,2),                -- 备案/核验价(万元)
    rent_price      NUMERIC(10,2),                  -- 挂牌租价(元/月)

    -- ── 基础物理属性 ──
    orientation     VARCHAR(10)
                    CHECK (orientation IN ('east','south','west','north',
                                          'southeast','northeast','east_west',
                                          'south_north','northwest','southwest')),
    decoration      VARCHAR(10)
                    CHECK (decoration IN ('rough','plain','simple','medium',
                                          'fine','luxury')),
    -- rough=毛坯, plain=清水, simple=简装, medium=中装, fine=精装, luxury=豪装

    has_elevator    BOOLEAN,
    built_year      SMALLINT,

    -- ── 用途 ──
    usage_type      VARCHAR(30),               -- 住宅/商住/商业/普通住宅/花园洋房 等
    usage_subtype   VARCHAR(30),               -- 细分用途

    -- ── 商铺专属 ──
    shop_frontage   NUMERIC(6,2),              -- 开间(米)
    shop_depth      NUMERIC(6,2),              -- 进深(米)
    shop_height     NUMERIC(6,2),              -- 层高(米)
    shop_location   VARCHAR(20)
                    CHECK (shop_location IS NULL OR
                           shop_location IN ('street','mall','residential',
                                            'ground_floor','complex')),

    -- ── 房屋状态 ──
    house_status    VARCHAR(20)
                    CHECK (house_status IN ('owner_occupied','vacant',
                                           'tenant_occupied','unknown')),
    viewing_time    VARCHAR(20)
                    CHECK (viewing_time IN ('anytime','by_appointment','inconvenient')),

    -- ── 等级与标签 ──
    grade           VARCHAR(5)
                    CHECK (grade IN ('A_urgent','A','B','C','D')),
    -- A_urgent=A(急迫), A=A, B=B(较强), C=C(一般), D=D

    -- ── 交易属性 ──
    ownership_years VARCHAR(30),               -- 房本年限不满2年/满2年/满5年 等
    ownership_years_detail VARCHAR(20),        -- 满五/不满五
    ownership_nature VARCHAR(20)
                    CHECK (ownership_nature IS NULL OR
                           ownership_nature IN ('commercial','reform_housing',
                                               'collective','economic')),
    -- commercial=商品房, reform_housing=房改房, collective=集资房, economic=经济适用房
    is_only_house   BOOLEAN,                   -- 唯一住房
    payment_method  VARCHAR(30)
                    CHECK (payment_method IS NULL OR
                           payment_method IN ('full','mortgage','installment','advance')),
    tax_included    VARCHAR(10)
                    CHECK (tax_included IS NULL OR
                           tax_included IN ('each_party','net','inclusive')),
    has_mortgage    BOOLEAN,
    has_loan        BOOLEAN,
    has_seal        BOOLEAN,
    has_restriction BOOLEAN,
    original_price  NUMERIC(12,2),             -- 原购价(万元)
    sale_reason     TEXT,                      -- 售房原因最多200字

    -- ── 营销备注 ──
    remarks         TEXT,                      -- 房源备注最多500字

    -- ── 相关方(冗余存储 UUID完整信息查 staff 表)──
    first_recorder_id UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 首录方
    number_holder_id  UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 号码方
    seller_agent_id   UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 出售方
    buyer_agent_id    UUID REFERENCES staff(id) ON DELETE SET NULL,  -- 实买方

    -- ── 来源 ──
    source          VARCHAR(50),               -- 房源来源渠道(由运营维护枚举)

    -- ── 维护完成度(冗余缓存,定期重算)──
    completeness_score SMALLINT NOT NULL DEFAULT 0,  -- 0-100 分

    -- ── 时间轨迹 ──
    listed_at       TIMESTAMPTZ,               -- 最近一次挂牌时间
    last_followed_at TIMESTAMPTZ,              -- 最后跟进时间(冗余,加速排序)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at      TIMESTAMPTZ,
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL,
    updated_by      UUID REFERENCES staff(id) ON DELETE SET NULL,

    -- ── 全文检索向量 ──
    search_vector   TSVECTOR
);

-- ── 索引策略(针对高频查询路径设计)──

-- 1. 最核心的列表页:按状态 + 属性 + 类型过滤
CREATE INDEX idx_properties_status_attr ON properties(status, attribute, property_type)
    WHERE deleted_at IS NULL;

-- 2. 区域筛选(通过 complex 表 JOIN 优化)
CREATE INDEX idx_properties_complex ON properties(complex_id)
    WHERE deleted_at IS NULL;

-- 3. 价格排序(出售最常用)
CREATE INDEX idx_properties_sale_price ON properties(sale_price DESC NULLS LAST)
    WHERE deleted_at IS NULL AND status IN ('for_sale','for_sale_rent');

-- 4. 面积区间筛选
CREATE INDEX idx_properties_area ON properties(area)
    WHERE deleted_at IS NULL;

-- 5. 挂牌日期倒序(最新挂牌)
CREATE INDEX idx_properties_listed_at ON properties(listed_at DESC NULLS LAST)
    WHERE deleted_at IS NULL;

-- 6. 最后跟进日期(超时未跟进功能)
CREATE INDEX idx_properties_last_followed ON properties(last_followed_at DESC NULLS LAST)
    WHERE deleted_at IS NULL;

-- 7. 户型筛选
CREATE INDEX idx_properties_bedroom ON properties(bedroom_count)
    WHERE deleted_at IS NULL;

-- 8. 等级筛选
CREATE INDEX idx_properties_grade ON properties(grade)
    WHERE deleted_at IS NULL;

-- 9. 完成度排序(引导补全信息)
CREATE INDEX idx_properties_completeness ON properties(completeness_score)
    WHERE deleted_at IS NULL;

-- 10. 全文搜索
CREATE INDEX idx_properties_search ON properties USING gin(search_vector);

-- 11. 与我相关(相关方快速定位)
CREATE INDEX idx_properties_seller_agent ON properties(seller_agent_id)
    WHERE deleted_at IS NULL;
CREATE INDEX idx_properties_number_holder ON properties(number_holder_id)
    WHERE deleted_at IS NULL;

-- 12. 复合索引:列表默认排序(状态 + 挂牌时间)
CREATE INDEX idx_properties_list_default ON properties(status, listed_at DESC NULLS LAST)
    WHERE deleted_at IS NULL;

3.4 房源联系人Property Contacts

-- ============================================================
-- 业主/联系人:手机号加密存储,哈希值支持重复检测
-- 安全要点:任何查看明文号码的行为均触发审计日志
-- ============================================================

CREATE TABLE property_contacts (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,

    name            VARCHAR(50) NOT NULL,
    gender          VARCHAR(10) NOT NULL DEFAULT 'male'
                    CHECK (gender IN ('male','female')),
    identity        VARCHAR(20) NOT NULL DEFAULT 'contact'
                    CHECK (identity IN ('owner','contact','subletter',
                                       'tenant','agent','corporate')),
    -- owner=业主, contact=联系人, subletter=二房东, tenant=租客,
    -- agent=代理人, corporate=企业法人

    -- 手机号:加密存储 + 哈希索引(重复检测用)
    phone_enc       BYTEA NOT NULL,            -- AES-256-GCM 加密
    phone_hash      VARCHAR(64) NOT NULL,      -- SHA-256(phone) 用于去重查询
    phone2_enc      BYTEA,
    phone2_hash     VARCHAR(64),

    wechat          VARCHAR(100),              -- 微信号(相对不敏感,可明文)
    qq              VARCHAR(20),
    remarks         TEXT,

    -- 是否为号码方(关联审批流)
    is_number_holder BOOLEAN NOT NULL DEFAULT FALSE,
    number_holder_approved_at TIMESTAMPTZ,    -- 审批通过时间

    sort_order      INTEGER NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at      TIMESTAMPTZ,
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL,
    updated_by      UUID REFERENCES staff(id) ON DELETE SET NULL
);

CREATE INDEX idx_contacts_property ON property_contacts(property_id)
    WHERE deleted_at IS NULL;

-- 关键:手机号哈希全局索引(用于重复房源检测)
CREATE INDEX idx_contacts_phone_hash ON property_contacts(phone_hash)
    WHERE deleted_at IS NULL;
CREATE INDEX idx_contacts_phone2_hash ON property_contacts(phone2_hash)
    WHERE phone2_hash IS NOT NULL AND deleted_at IS NULL;

3.5 挂牌历史Listing History

-- ============================================================
-- 挂牌历史:记录房源每次上架的完整快照
-- 设计重点:不可删除(合规),仅追加
-- ============================================================

CREATE TABLE listing_histories (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE RESTRICT,

    listing_type    VARCHAR(20) NOT NULL
                    CHECK (listing_type IN ('for_sale','for_rent')),
    status          VARCHAR(20) NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active','ended')),

    -- 价格快照
    sale_price      NUMERIC(12,2),
    rent_price      NUMERIC(10,2),
    sale_unit_price NUMERIC(10,2),            -- 元/m²计算字段

    -- 交易信息快照
    ownership_years VARCHAR(30),
    is_only_house   BOOLEAN,
    tax_included    VARCHAR(10),
    sale_reason     TEXT,

    -- 经纪人快照
    seller_agent_id UUID REFERENCES staff(id) ON DELETE SET NULL,
    seller_agent_snapshot JSONB,              -- 存储经纪人姓名+门店(防止变更后丢失)

    started_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ended_at        TIMESTAMPTZ,

    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
    -- 注:无 deleted_at此表记录不可删除
);

CREATE INDEX idx_listing_histories_property ON listing_histories(property_id);
CREATE INDEX idx_listing_histories_active ON listing_histories(property_id)
    WHERE status = 'active';

3.6 调价记录Price Change Log

-- ============================================================
-- 调价记录:支持折线图展示,不可删除
-- ============================================================

CREATE TABLE price_changes (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id         UUID NOT NULL REFERENCES properties(id) ON DELETE RESTRICT,

    old_sale_price      NUMERIC(12,2),
    new_sale_price      NUMERIC(12,2),
    old_bottom_price    NUMERIC(12,2),
    new_bottom_price    NUMERIC(12,2),
    old_record_price    NUMERIC(12,2),
    new_record_price    NUMERIC(12,2),
    old_rent_price      NUMERIC(10,2),
    new_rent_price      NUMERIC(10,2),

    change_reason       TEXT NOT NULL,         -- 最多200字
    changed_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    changed_by          UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT
);

CREATE INDEX idx_price_changes_property ON price_changes(property_id);
CREATE INDEX idx_price_changes_time ON price_changes(property_id, changed_at DESC);

3.7 跟进日志Follow-up Logs

-- ============================================================
-- 跟进日志:系统最高写入频率的表,按 property_id 分区预留
-- 6 种类型:写入跟进/修改跟进/敏感信息跟进/敏感信息查看/其他跟进/系统日志
-- ============================================================

CREATE TABLE follow_logs (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,

    log_type        VARCHAR(30) NOT NULL
                    CHECK (log_type IN ('written','modified','sensitive_op',
                                       'sensitive_view','other','system')),
    -- written=写入跟进(经纪人主动写)
    -- modified=修改跟进(字段变更自动生成)
    -- sensitive_op=敏感信息跟进(相关方保护变更)
    -- sensitive_view=敏感信息查看(查看号码等)
    -- other=其他跟进(钥匙/新增联系人等)
    -- system=系统日志

    -- 写入跟进专用字段
    purpose         VARCHAR(50),               -- 跟进目的(由运营维护枚举值)
    content         TEXT,                      -- 跟进内容最少6字最多500字
    ai_tag          VARCHAR(20)
                    CHECK (ai_tag IS NULL OR ai_tag IN ('ai_for_sale','ai_not_for_sale')),

    -- 修改跟进专用字段
    change_detail   JSONB,
    -- 格式:{"field": "sale_price", "old": 850, "new": 800, "label": "售价"}
    -- 支持多字段同时变更

    -- 系统标签(显示在日志时间线上的 tag
    log_tag         VARCHAR(50),
    -- 如:查看号码/图片下载/改状态/改价格/改等级/修改相关方 等

    -- 可见性控制
    is_public       BOOLEAN NOT NULL DEFAULT TRUE,
    -- FALSE = 仅本人及上级可见

    -- 操作人
    operator_id     UUID REFERENCES staff(id) ON DELETE SET NULL,
    operator_snapshot JSONB,                   -- {name, role, org_unit_name, store_group}

    -- 是否可删除(敏感信息查看类型 = FALSE
    is_deletable    BOOLEAN NOT NULL DEFAULT TRUE,

    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at      TIMESTAMPTZ               -- 仅 is_deletable=TRUE 时才能软删
);

-- 核心索引:时间线展示
CREATE INDEX idx_follow_logs_property_time ON follow_logs(property_id, created_at DESC)
    WHERE deleted_at IS NULL;

-- 按类型过滤6个 Tab 查询)
CREATE INDEX idx_follow_logs_type ON follow_logs(property_id, log_type, created_at DESC)
    WHERE deleted_at IS NULL;

-- 操作员过滤(跟进日志搜索功能)
CREATE INDEX idx_follow_logs_operator ON follow_logs(operator_id, created_at DESC)
    WHERE deleted_at IS NULL;

-- 不可删除类型专用索引(合规审计)
CREATE INDEX idx_follow_logs_sensitive ON follow_logs(property_id, created_at DESC)
    WHERE log_type IN ('sensitive_view','sensitive_op');

-- 跟进日志附件(一条跟进可附多张图)
CREATE TABLE follow_log_attachments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    follow_log_id   UUID NOT NULL REFERENCES follow_logs(id) ON DELETE CASCADE,
    file_key        TEXT NOT NULL,             -- R2/S3 存储路径
    file_name       VARCHAR(255) NOT NULL,
    file_size       INTEGER NOT NULL,          -- bytes
    file_type       VARCHAR(10)
                    CHECK (file_type IN ('bmp','jpg','png','svg','gif')),
    sort_order      SMALLINT NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_follow_attachments_log ON follow_log_attachments(follow_log_id);

-- 跟进录音(独立存储,支持音频文件)
CREATE TABLE follow_log_recordings (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    follow_log_id   UUID NOT NULL REFERENCES follow_logs(id) ON DELETE CASCADE,
    file_key        TEXT NOT NULL,
    duration_seconds INTEGER,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

3.8 钥匙管理Key Management

CREATE TABLE property_keys (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,

    key_type        VARCHAR(20) NOT NULL
                    CHECK (key_type IN ('mechanical','password')),

    -- 钥匙持有方
    holder_id       UUID REFERENCES staff(id) ON DELETE SET NULL,
    holder_snapshot JSONB,                     -- {name, store_group}(防人员变动丢失)
    storage_unit_id UUID REFERENCES org_units(id) ON DELETE SET NULL,  -- 保管部门

    -- 他司钥匙标记
    is_other_agency BOOLEAN NOT NULL DEFAULT FALSE,
    other_agency_info VARCHAR(30),             -- 他司信息最多30字

    remarks         TEXT,                      -- 备注最多200字

    is_active       BOOLEAN NOT NULL DEFAULT TRUE,  -- FALSE = 钥匙已归还/失效
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL
);

CREATE INDEX idx_property_keys_property ON property_keys(property_id)
    WHERE is_active = TRUE;

-- 钥匙附件
CREATE TABLE key_attachments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    key_id          UUID NOT NULL REFERENCES property_keys(id) ON DELETE CASCADE,
    file_key        TEXT NOT NULL,
    file_name       VARCHAR(255) NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

3.9 委托管理Commission Management

CREATE TABLE commissions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,

    commission_type VARCHAR(50) NOT NULL,      -- 独家委托/非独家委托(运营维护枚举)
    period_start    DATE NOT NULL,
    period_end      DATE,
    is_open_ended   BOOLEAN NOT NULL DEFAULT FALSE,  -- 无固定结束日期

    -- 委托方(负责经纪人)
    agent_id        UUID REFERENCES staff(id) ON DELETE SET NULL,
    agent_snapshot  JSONB,

    -- 签约方式(选择后动态展示委托书模板)
    signing_method  VARCHAR(50),

    -- 委托人(产权人)信息
    owner_type      VARCHAR(20) NOT NULL DEFAULT 'owner'
                    CHECK (owner_type IN ('owner','authorized_third')),
    -- 从 property_contacts 中选择
    property_owner_contact_id UUID REFERENCES property_contacts(id) ON DELETE SET NULL,
    owner_name      VARCHAR(50),               -- 产权人姓名
    owner_id_type   VARCHAR(20),               -- 证件类型:身份证/护照 等
    owner_id_number VARCHAR(50),               -- 证件号码(加密存储)
    owner_id_number_enc BYTEA,

    remarks         TEXT,                      -- 备注最多200字

    -- 状态
    status          VARCHAR(20) NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active','expired','cancelled')),

    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL
);

CREATE INDEX idx_commissions_property ON commissions(property_id);
CREATE INDEX idx_commissions_active ON commissions(property_id)
    WHERE status = 'active';

-- 委托附件(身份证/房产证/委托书 等)
CREATE TABLE commission_attachments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    commission_id   UUID NOT NULL REFERENCES commissions(id) ON DELETE CASCADE,
    category        VARCHAR(20) NOT NULL
                    CHECK (category IN ('id_card','property_cert',
                                       'commission_letter','other')),
    file_key        TEXT NOT NULL,
    file_name       VARCHAR(255) NOT NULL,
    file_size       INTEGER,
    sort_order      SMALLINT NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_commission_attachments_commission ON commission_attachments(commission_id);

3.10 实勘管理Field Survey

CREATE TABLE field_surveys (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,

    status          VARCHAR(10) NOT NULL DEFAULT 'draft'
                    CHECK (status IN ('draft','submitted')),

    -- GPS 定位
    gps_latitude    NUMERIC(10,7),
    gps_longitude   NUMERIC(10,7),
    gps_accuracy    NUMERIC(6,2),              -- 精度(米)

    description     TEXT,                      -- 实勘说明最多200字

    submitted_at    TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by      UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT
);

CREATE INDEX idx_field_surveys_property ON field_surveys(property_id);
CREATE INDEX idx_field_surveys_submitted ON field_surveys(property_id)
    WHERE status = 'submitted';

-- 实勘照片(按空间分类)
CREATE TABLE survey_photos (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    survey_id       UUID NOT NULL REFERENCES field_surveys(id) ON DELETE CASCADE,
    category        VARCHAR(20) NOT NULL
                    CHECK (category IN ('layout','living_room','dining_room',
                                       'bedroom','bathroom','kitchen',
                                       'entrance','balcony','study',
                                       'indoor_other','outdoor')),
    file_key        TEXT NOT NULL,             -- R2/S3 路径
    thumbnail_key   TEXT,                      -- 缩略图路径
    file_size       INTEGER,
    width           INTEGER,
    height          INTEGER,
    sort_order      SMALLINT NOT NULL DEFAULT 0,
    is_vr_screenshot BOOLEAN NOT NULL DEFAULT FALSE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_survey_photos_survey ON survey_photos(survey_id);
CREATE INDEX idx_survey_photos_category ON survey_photos(survey_id, category);

3.11 房源图片管理Property Photos

-- ============================================================
-- 房源图片:与实勘照片分离存储,经纪人自主上传和管理
-- 封面限1张全景类型单独处理
-- ============================================================

CREATE TABLE property_photos (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,

    category        VARCHAR(20) NOT NULL
                    CHECK (category IN ('cover','entrance','living_room',
                                       'dining_room','bedroom','bathroom',
                                       'kitchen','balcony','study',
                                       'indoor_other','outdoor','panorama')),

    file_key        TEXT NOT NULL,             -- R2/S3 原图路径
    thumbnail_key   TEXT,                      -- 缩略图路径Cloudflare Images 生成)
    file_name       VARCHAR(255),
    file_size       INTEGER,
    width           INTEGER,
    height          INTEGER,

    is_cover        BOOLEAN NOT NULL DEFAULT FALSE,
    sort_order      SMALLINT NOT NULL DEFAULT 0,

    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL
);

CREATE INDEX idx_property_photos_property ON property_photos(property_id);
CREATE INDEX idx_property_photos_cover ON property_photos(property_id)
    WHERE is_cover = TRUE;
CREATE INDEX idx_property_photos_category ON property_photos(property_id, category);

-- 唯一约束:每个房源只能有一张封面
CREATE UNIQUE INDEX idx_property_photos_unique_cover
    ON property_photos(property_id)
    WHERE is_cover = TRUE;

3.12 房源附件Property Attachments

CREATE TABLE property_attachments (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,

    category        VARCHAR(20) NOT NULL DEFAULT 'other'
                    CHECK (category IN ('id_card','property_cert',
                                       'commission_letter','other')),

    file_key        TEXT NOT NULL,
    file_name       VARCHAR(255) NOT NULL,
    file_size       INTEGER NOT NULL,
    file_type       VARCHAR(50),               -- MIME type

    sort_order      SMALLINT NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL
);

CREATE INDEX idx_property_attachments_property ON property_attachments(property_id);
CREATE INDEX idx_property_attachments_category ON property_attachments(property_id, category);

3.13 房源营销信息Property Marketing

CREATE TABLE property_marketing (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id         UUID NOT NULL UNIQUE REFERENCES properties(id) ON DELETE CASCADE,

    marketing_title     VARCHAR(30),           -- 营销标题 0-30字
    core_selling_points TEXT,                  -- 核心卖点最多200字
    owner_attitude      TEXT,                  -- 业主心态最多200字
    layout_description  TEXT,                  -- 户型介绍最多200字
    complex_description TEXT,                  -- 小区介绍最多200字

    -- AI 生成标记
    ai_generated_points BOOLEAN NOT NULL DEFAULT FALSE,
    ai_generated_attitude BOOLEAN NOT NULL DEFAULT FALSE,

    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_by          UUID REFERENCES staff(id) ON DELETE SET NULL
);

3.14 产证信息Property Certificate

CREATE TABLE property_certificates (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id         UUID NOT NULL UNIQUE REFERENCES properties(id) ON DELETE CASCADE,

    owner_name          VARCHAR(100),
    owner_id_number     VARCHAR(50),           -- 身份证号/统一社会信用代码
    owner_cert_type     VARCHAR(20),           -- 身份证/护照/营业执照
    property_location   VARCHAR(500),          -- 房屋坐落产权证书上的地址最多50字

    -- 产证状态
    cert_status         VARCHAR(30),
    cert_no             VARCHAR(100),          -- 产证号
    first_registered_at DATE,                  -- 首次登记时间
    ownership_nature    VARCHAR(30),
    land_nature         VARCHAR(30),           -- 土地性质

    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_by          UUID REFERENCES staff(id) ON DELETE SET NULL
);

3.15 楼盘基本信息扩展Complex Property Info

-- 补充:楼盘与房源通过 complex_id 关联,楼盘信息首次填写后修改需走楼盘管理系统
-- 楼盘价格走势(用于楼盘详情页展示)
CREATE TABLE complex_price_trends (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    complex_id      UUID NOT NULL REFERENCES complexes(id) ON DELETE CASCADE,
    record_month    DATE NOT NULL,             -- 月份取该月1日存储
    avg_sale_price  NUMERIC(10,2),             -- 月均售价(万元/套)
    avg_unit_price  NUMERIC(10,2),             -- 月均单价(元/m²
    transaction_count INTEGER,                 -- 成交套数
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_complex_price_trend_month
    ON complex_price_trends(complex_id, record_month);

3.16 维护完成度评分Completeness Scoring

-- ============================================================
-- 维护完成度:不直接存完整计算明细(减少宽表),
-- 以触发器/Celery 任务异步更新 properties.completeness_score
-- 此表存储各维度的得分快照,供详情页展示
-- ============================================================

CREATE TABLE property_completeness (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id         UUID NOT NULL UNIQUE REFERENCES properties(id) ON DELETE CASCADE,

    -- 各维度得分(满分见 PRD 8.2
    score_core_info     SMALLINT NOT NULL DEFAULT 0,   -- 重点信息 满分8
    score_attachment    SMALLINT NOT NULL DEFAULT 0,   -- 附件 满分8
    score_survey        SMALLINT NOT NULL DEFAULT 0,   -- 实勘 满分16
    score_vr            SMALLINT NOT NULL DEFAULT 0,   -- VR 满分8
    score_key           SMALLINT NOT NULL DEFAULT 0,   -- 钥匙 满分10
    score_commission    SMALLINT NOT NULL DEFAULT 0,   -- 委托 满分10
    score_verification  SMALLINT NOT NULL DEFAULT 0,   -- 验证 满分7
    score_follow_up     SMALLINT NOT NULL DEFAULT 0,   -- 跟进 满分8
    score_viewing       SMALLINT NOT NULL DEFAULT 0,   -- 带看 满分8
    score_other         SMALLINT NOT NULL DEFAULT 0,   -- 其他 满分7
    total_score         SMALLINT NOT NULL DEFAULT 0,   -- 总分 0-100

    calculated_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

3.17 客源管理Client Management

-- ============================================================
-- 客源:私客为核心,公客/成交客为后续版本
-- ============================================================

CREATE TABLE clients (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    client_type     VARCHAR(20) NOT NULL DEFAULT 'private'
                    CHECK (client_type IN ('private','public','transacted')),
    status          VARCHAR(20) NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active','converted_public',
                                     'transacted','invalid')),

    name            VARCHAR(50) NOT NULL,
    gender          VARCHAR(10)
                    CHECK (gender IN ('male','female','unknown')),

    -- 手机号加密存储
    phone_enc       BYTEA NOT NULL,
    phone_hash      VARCHAR(64) NOT NULL,
    phone2_enc      BYTEA,
    phone2_hash     VARCHAR(64),

    -- 购房需求
    purpose         VARCHAR(10) NOT NULL
                    CHECK (purpose IN ('buy','rent')),
    budget_min      NUMERIC(12,2),
    budget_max      NUMERIC(12,2),
    area_min        NUMERIC(8,2),
    area_max        NUMERIC(8,2),
    bedroom_needs   SMALLINT[],                -- 可接受的卧室数量数组

    -- 意向区域(存 district/business_area ID 数组)
    district_ids    UUID[],
    business_area_ids UUID[],

    -- 活跃度分层(由系统计算)
    activity_level  VARCHAR(10)
                    CHECK (activity_level IN ('hot','warm','cold','frozen')),
    last_active_at  TIMESTAMPTZ,

    -- 负责经纪人
    agent_id        UUID REFERENCES staff(id) ON DELETE SET NULL,
    org_unit_id     UUID REFERENCES org_units(id) ON DELETE SET NULL,

    source          VARCHAR(50),
    remarks         TEXT,

    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at      TIMESTAMPTZ,
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL
);

CREATE INDEX idx_clients_agent ON clients(agent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_phone_hash ON clients(phone_hash) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_status ON clients(status, client_type) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_activity ON clients(activity_level, last_active_at DESC)
    WHERE deleted_at IS NULL;

-- 客源跟进日志(复用结构,单独表避免与房源日志混合)
CREATE TABLE client_follow_logs (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    client_id       UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
    purpose         VARCHAR(50),
    content         TEXT,
    log_tag         VARCHAR(50),
    is_public       BOOLEAN NOT NULL DEFAULT TRUE,
    operator_id     UUID REFERENCES staff(id) ON DELETE SET NULL,
    operator_snapshot JSONB,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    deleted_at      TIMESTAMPTZ
);

CREATE INDEX idx_client_logs_client ON client_follow_logs(client_id, created_at DESC)
    WHERE deleted_at IS NULL;

-- 智能配房记录(客源 ↔ 房源 匹配)
CREATE TABLE client_property_matches (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    client_id       UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
    match_score     NUMERIC(5,2),              -- 匹配度评分
    match_reason    JSONB,                     -- 匹配原因详情
    status          VARCHAR(20) NOT NULL DEFAULT 'suggested'
                    CHECK (status IN ('suggested','shared','viewing','rejected')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by      UUID REFERENCES staff(id) ON DELETE SET NULL
);

CREATE UNIQUE INDEX idx_client_property_match
    ON client_property_matches(client_id, property_id);

-- 带看记录
CREATE TABLE viewings (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE RESTRICT,
    client_id       UUID NOT NULL REFERENCES clients(id) ON DELETE RESTRICT,
    agent_id        UUID REFERENCES staff(id) ON DELETE SET NULL,

    viewing_type    VARCHAR(20) NOT NULL DEFAULT 'first'
                    CHECK (viewing_type IN ('first','revisit','empty','interview')),
    -- first=带看, revisit=复看, empty=空看, interview=面访

    scheduled_at    TIMESTAMPTZ,
    completed_at    TIMESTAMPTZ,
    result          VARCHAR(20)
                    CHECK (result IN ('interested','not_interested',
                                     'negotiating','cancelled')),
    remarks         TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_viewings_property ON viewings(property_id);
CREATE INDEX idx_viewings_client ON viewings(client_id);

3.18 系统设置System Settings

-- ============================================================
-- 枚举/选项管理:跟进目的、标签、来源渠道 等运营维护的枚举值
-- ============================================================

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 property_tags (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        VARCHAR(50) NOT NULL,
    color       VARCHAR(7),                    -- HEX 颜色
    is_system   BOOLEAN NOT NULL DEFAULT FALSE, -- 系统预置标签不可删除
    sort_order  INTEGER NOT NULL DEFAULT 0,
    is_active   BOOLEAN NOT NULL DEFAULT TRUE
);

-- 房源 ↔ 标签 多对多
CREATE TABLE property_tag_relations (
    property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
    tag_id      UUID NOT NULL REFERENCES property_tags(id) ON DELETE CASCADE,
    PRIMARY KEY (property_id, tag_id)
);

CREATE INDEX idx_property_tags_property ON property_tag_relations(property_id);
CREATE INDEX idx_property_tags_tag ON property_tag_relations(tag_id);

-- 收藏(经纪人收藏房源)
CREATE TABLE property_favorites (
    staff_id    UUID NOT NULL REFERENCES staff(id) ON DELETE CASCADE,
    property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (staff_id, property_id)
);

CREATE INDEX idx_property_favorites_staff ON property_favorites(staff_id);

-- 保护房设置
CREATE TABLE property_protections (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL UNIQUE REFERENCES properties(id) ON DELETE CASCADE,
    is_protected    BOOLEAN NOT NULL DEFAULT FALSE,
    reason          TEXT,
    start_at        TIMESTAMPTZ,
    end_at          TIMESTAMPTZ,
    set_by          UUID REFERENCES staff(id) ON DELETE SET NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 筛选方案(保存的搜索条件)
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);

-- 号码方修改审批
CREATE TABLE number_holder_approvals (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    property_id     UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
    contact_id      UUID NOT NULL REFERENCES property_contacts(id) ON DELETE CASCADE,
    applicant_id    UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT,
    approver_id     UUID REFERENCES staff(id) ON DELETE SET NULL,
    status          VARCHAR(20) NOT NULL DEFAULT 'pending'
                    CHECK (status IN ('pending','approved','rejected')),
    remarks         TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    decided_at      TIMESTAMPTZ
);

CREATE INDEX idx_number_holder_approvals_status ON number_holder_approvals(status)
    WHERE status = 'pending';

四、关键索引汇总与查询优化策略

4.1 房源列表页核心查询分析

-- 典型查询:出售状态 + 公盘 + 特定区域 + 价格区间 + 户型筛选 + 按挂牌日期排序
-- 优化方案:复合索引覆盖最高频维度组合

-- 高频组合索引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

-- 房源全文检索向量更新触发器
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 自动维护触发器

-- 每次写入跟进日志时,自动更新 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小时)

# 标签列表
{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 缓存失效策略

# Django Signal 驱动的缓存失效(在 models.py 中注册)

# 房源更新 → 失效详情缓存 + 完成度缓存
# 跟进日志新增 → 失效 last_followed_at 缓存
# 联系人更新 → 失效联系人缓存(立即)
# 楼盘更新 → 失效楼盘缓存 + 相关房源缓存(批量)
# 枚举更新 → 失效对应 lookup 缓存

六、Django Model 层设计要点

6.1 抽象基类

# 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

# 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 过滤软删除

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万+ 高速(最高频写入) created_at 月度 RANGE 分区
property_photos 500万+ 高速 property_id HASH 分区16分区
price_changes 50万 中速 无需分区
listing_histories 20万 低速 无需分区
clients 10万+ 中速 暂不分区
viewings 100万 中速 无需分区

八、Django App 结构建议

fonrey/
├── apps/
│   ├── tenants/          # django-tenants 配置
│   ├── org/              # 组织人事org_units, staff
│   ├── region/           # 区域管理districts, business_areas, metro
│   ├── complex/          # 楼盘管理complexes, buildings, schools
│   ├── property/         # 房源核心properties + 所有子表)
│   │   ├── models/
│   │   │   ├── property.py        # Property 主表
│   │   │   ├── contact.py         # PropertyContact
│   │   │   ├── follow_log.py      # FollowLog
│   │   │   ├── key.py             # PropertyKey
│   │   │   ├── commission.py      # Commission
│   │   │   ├── survey.py          # FieldSurvey
│   │   │   ├── photo.py           # PropertyPhoto
│   │   │   ├── attachment.py      # PropertyAttachment
│   │   │   ├── marketing.py       # PropertyMarketing
│   │   │   └── completeness.py    # PropertyCompleteness
│   │   ├── services/
│   │   │   ├── completeness.py    # 完成度计算服务
│   │   │   ├── duplicate.py       # 重复房源检测
│   │   │   └── search.py          # 搜索/筛选服务
│   │   └── tasks.py               # Celery 异步任务
│   ├── client/           # 客源管理
│   ├── settings/         # 系统设置lookup, tags
│   └── permissions/      # 权限管理
├── shared/               # 公共 Schema Appdjango-tenants shared_apps
└── core/
    ├── models/base.py    # 抽象基类
    ├── encryption.py     # 手机号加密
    └── cache.py          # Redis 缓存工具

九、必须在开发启动前明确的数据架构决策

决策项 推荐方案 风险
小区数据来源 预导入基础数据(安居客/链家 API+ 支持手动新增兜底 高:影响录入体验
私盘可见范围 录入人所在门店可见(综合业务需求) 需与权限模块约定
号码查看权限 角色级控制:经纪人限查自己相关房源,店长无限制 需合规确认
重复房源主键 主键:手机号 hash辅助小区+楼栋+单元+房号)组合 需双重校验
跟进目的枚举 存 lookup_items 表,运营可维护 初始化数据需提前收集
手机号加密算法 AES-256-GCM密钥存 Django settings生产用 Vault 密钥管理需单独规划

本文档为 Fonrey 系统 DATA MODEL v1.0,随 PRD 迭代同步更新。 下一步建议API 接口规范URL 设计 + Request/Response Schema