Files
nexus/Project/fonrey/DATA_MODEL/DATA_MODEL_PROPERTY.md
2026-04-28 16:39:52 +08:00

44 KiB
Raw Blame History

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_PROPERTY

权威定义:本文件是房源模块所有表结构的唯一权威来源。
主文档引用DATA_MODEL.md §3.3§3.16 为本文件的概览摘要,开发以本文件为准。
版本v1.0 | 日期2026-04-24


目录

  1. 模块说明
  2. 表清单
  3. 枚举值总览
  4. DDL 定义
  5. 触发器
  6. 查询模式参考
  7. 禁止操作

1 模块说明

房源Property 是 Fonrey 系统的核心领域对象,代表一套二手房源的完整档案。

核心业务规则:

规则 说明
多态交易状态 一套房源可同时处于出售、出租或租售三态(status
流通属性 公盘/私盘/特盘/封盘(attribute),控制可见范围
相关方体系 首录方/号码方/出售方/实买方,通过 staff_id 关联
联系人加密 业主/联系人手机号 AES-256-GCM 加密SHA-256 哈希索引
跟进日志不可删 sensitive_view 类型跟进 is_deletable=FALSE,合规强制
挂牌历史不可删 listing_historiesdeleted_atappend-only
调价记录不可删 price_changesdeleted_atappend-only
完成度异步计算 completeness_score 由 Celery 任务更新,不实时

2 表清单

# 表名 说明 是否可删除
1 properties 房源主表 软删除(deleted_at
2 property_contacts 业主/联系人(手机号加密) 软删除
3 listing_histories 挂牌历史快照 不可删除
4 price_changes 调价记录 不可删除
5 follow_logs 跟进日志6种类型 部分不可删(sensitive_view
6 follow_log_attachments 跟进附件(图片) 随日志联级
7 follow_log_recordings 跟进录音 随日志联级
8 property_keys 钥匙管理 软删除(is_active=FALSE
9 key_attachments 钥匙附件 随钥匙联级
10 commissions 委托管理 状态驱动(status='cancelled'
11 commission_attachments 委托附件 随委托联级
12 field_surveys 实勘管理 软删除
13 survey_photos 实勘照片(按空间分类) 随实勘联级
14 property_photos 房源图片(经纪人管理) 软删除
15 property_attachments 房源附件 直接删除
16 property_marketing 营销信息1:1 随房源联级
17 property_certificates 产证信息1:1 随房源联级
18 property_completeness 维护完成度快照1:1 随房源联级
19 property_tags 标签字典 系统标签不可删
20 property_tag_relations 房源↔标签多对多 随房源/标签联级
21 property_favorites 经纪人收藏房源 直接删除
22 property_protections 保护房设置1:1 随房源联级
23 number_holder_approvals 号码方变更审批 随房源联级

3 枚举值总览

property_type房源类型

说明
residential 住宅
villa 别墅
commercial_residential 商住
shop 商铺
office 写字楼
other 其他

status交易状态

说明
for_sale 出售
for_rent 出租
for_sale_rent 租售
suspended 暂缓
sold_elsewhere 他售
rented_elsewhere 他租
sold 成交
unlisted 未挂牌

attribute流通属性

说明
public 公盘
private 私盘
special 特盘
sealed 封盘

orientation朝向

说明
east
south
west 西
north
southeast 东南
northeast 东北
east_west 东西
south_north 南北
northwest 西北
southwest 西南

decoration装修情况

说明
rough 毛坯
plain 清水
simple 简装
medium 中装
fine 精装
luxury 豪装

grade等级

说明
A A急迫
B B较强
C C一般
D D较弱

follow_log.log_type跟进日志类型

说明 可删除
written 经纪人主动写入跟进
modified 字段变更自动生成
sensitive_op 敏感信息操作跟进
sensitive_view 敏感信息查看(查看号码等)
other 其他(钥匙/新增联系人等)
system 系统日志

4 DDL 定义

4.1 properties房源主表

-- ============================================================
-- 房源主表:系统最核心的表,全部筛选/排序/搜索围绕此表展开
-- 设计重点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')),

    -- ── 交易状态 ──
    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')),

    -- ── 流通属性 ──
    attribute       VARCHAR(20) NOT NULL DEFAULT 'public'
                    CHECK (attribute IN ('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')),
    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(10)
                    CHECK (grade IN ('A','B','C','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')),
    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字

    -- ── 相关方 ──
    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),               -- 房源来源渠道lookup_items 维护)

    -- ── 维护完成度冗余缓存Celery 定期重算)──
    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
);

-- ── 索引策略 ──

-- 核心列表过滤status + attribute + type
CREATE INDEX idx_properties_status_attr ON properties(status, attribute, property_type)
    WHERE deleted_at IS NULL;

-- 区域筛选
CREATE INDEX idx_properties_complex ON properties(complex_id)
    WHERE deleted_at IS NULL;

-- 售价排序
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');

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

-- 挂牌时间倒序
CREATE INDEX idx_properties_listed_at ON properties(listed_at DESC NULLS LAST)
    WHERE deleted_at IS NULL;

-- 超时跟进检测
CREATE INDEX idx_properties_last_followed ON properties(last_followed_at DESC NULLS LAST)
    WHERE deleted_at IS NULL;

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

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

-- 完成度排序
CREATE INDEX idx_properties_completeness ON properties(completeness_score)
    WHERE deleted_at IS NULL;

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

-- 相关方快速定位
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;

-- 列表默认排序status + listed_at
CREATE INDEX idx_properties_list_default ON properties(status, listed_at DESC NULLS LAST)
    WHERE deleted_at IS NULL;

-- 高频复合索引status + attribute + complex_id + sale_price
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 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;

4.3 listing_histories挂牌历史

-- ============================================================
-- 挂牌历史:记录房源每次上架的完整快照
-- 注意:无 deleted_at不可删除append-only合规要求
-- ============================================================

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,              -- {name, store_group, org_unit_name}

    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';

4.4 price_changes调价记录

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

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
    -- 无 deleted_at不可删除
);

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);

4.5 follow_logs跟进日志

-- ============================================================
-- 跟进日志:系统最高写入频率的表
-- 6 种类型written / modified / sensitive_op / sensitive_view / other / system
-- sensitive_view 类型is_deletable=FALSE合规不可删
-- ============================================================

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')),

    -- 写入跟进专用字段
    purpose         VARCHAR(50),               -- 跟进目的lookup_items 维护)
    content         TEXT,                      -- 最少6字最多500字

    -- AI 辅助判断标签
    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": "售价"}

    -- 系统显示标签
    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}

    -- 是否可删除sensitive_view = 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');

4.6 follow_log_attachments跟进附件

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,             -- Cloudflare R2 存储路径
    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);

4.7 follow_log_recordings跟进录音

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,             -- Cloudflare R2 存储路径
    duration_seconds INTEGER,                 -- 录音时长(秒)
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_follow_recordings_log ON follow_log_recordings(follow_log_id);

4.8 property_keys钥匙管理

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;

4.9 key_attachments钥匙附件

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()
);

CREATE INDEX idx_key_attachments_key ON key_attachments(key_id);

4.10 commissions委托管理

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,      -- 独家委托/非独家委托lookup_items 维护)
    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,                     -- {name, store_group}

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

    -- 委托人(产权人)信息
    owner_type      VARCHAR(20) NOT NULL DEFAULT 'owner'
                    CHECK (owner_type IN ('owner','authorized_third')),
    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,                 -- 证件号 AES-256-GCM 加密

    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';

4.11 commission_attachments委托附件

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);

4.12 field_surveys实勘管理

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';

4.13 survey_photos实勘照片

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,             -- Cloudflare R2 路径
    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);

4.14 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;

4.15 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);

4.16 property_marketing营销信息

-- 1:1 扩展表,营销标题/核心卖点/业主心态/户型介绍/小区介绍
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
);

4.17 property_certificates产证信息

-- 1:1 扩展表,存储产权证相关信息
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
);

4.18 property_completeness维护完成度

-- ============================================================
-- 维护完成度快照1:1
-- 各维度得分由 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,

    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()
);

4.19 property_tags / property_tag_relations标签

-- 标签字典(系统预置 + 运营自定义)
CREATE TABLE property_tags (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        VARCHAR(50) NOT NULL,
    color       VARCHAR(7),                    -- HEX 颜色,如 #FF5733
    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);

4.20 property_favorites收藏

-- 经纪人收藏房源(快速访问)
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);

4.21 property_protections保护房

-- 1:1标记某套房源是否受保护防止被他人抢单/公盘化)
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()
);

4.22 number_holder_approvals号码方审批

-- 号码方变更审批流:经纪人申请,上级审批
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';
CREATE INDEX idx_number_holder_approvals_property ON number_holder_approvals(property_id);

5 触发器

5.1 房源全文搜索向量自动维护

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();

5.2 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();

6 查询模式参考

6.1 房源列表页(高频)

-- 出售公盘,按挂牌时间倒序
SELECT p.id, p.status, p.attribute, p.sale_price, p.area,
       p.bedroom_count, p.floor, p.total_floors, p.completeness_score,
       p.listed_at
FROM   properties p
WHERE  p.status = 'for_sale'
  AND  p.attribute = 'public'
  AND  p.deleted_at IS NULL
ORDER  BY p.listed_at DESC NULLS LAST
LIMIT  20 OFFSET 0;
-- 命中索引idx_properties_list_default

6.2 区域筛选(楼盘 + 商圈下钻)

-- 某商圈下所有出售房源
SELECT p.*
FROM   properties p
JOIN   complexes c ON c.id = p.complex_id
JOIN   complex_business_areas cba ON cba.complex_id = c.id
WHERE  cba.business_area_id = :business_area_id
  AND  p.status IN ('for_sale','for_sale_rent')
  AND  p.deleted_at IS NULL
ORDER  BY p.listed_at DESC NULLS LAST;
-- 命中索引idx_properties_complex + complex_business_areas 索引

6.3 与我相关(经纪人仪表板)

SELECT * FROM properties
WHERE  seller_agent_id = :my_staff_id
  AND  status NOT IN ('sold','unlisted')
  AND  deleted_at IS NULL
ORDER  BY listed_at DESC NULLS LAST;
-- 命中索引idx_properties_my_properties

6.4 重复房源检测(录入前必查)

-- 通过联系人手机号哈希检测
SELECT pc.property_id, p.status, p.deleted_at
FROM   property_contacts pc
JOIN   properties p ON p.id = pc.property_id
WHERE  pc.phone_hash = SHA256(:input_phone)
  AND  pc.deleted_at IS NULL;
-- 命中索引idx_contacts_phone_hash

6.5 跟进日志时间线(详情页)

-- 房源详情页跟进 Tab全部类型时间倒序
SELECT fl.*, fla.file_key, fla.file_name
FROM   follow_logs fl
LEFT JOIN follow_log_attachments fla ON fla.follow_log_id = fl.id
WHERE  fl.property_id = :property_id
  AND  fl.deleted_at IS NULL
ORDER  BY fl.created_at DESC;
-- 命中索引idx_follow_logs_property_time

7 禁止操作

操作 影响表 原因
DELETE FROM listing_histories listing_histories 挂牌历史不可删除,合规审计要求
DELETE FROM price_changes price_changes 调价记录不可删除
UPDATE follow_logs SET deleted_at = NOW() WHERE is_deletable = FALSE follow_logs 敏感信息查看类型日志不可删除
直接修改 properties.completeness_score properties 完成度分数只由 Celery 任务计算更新,禁止 Application 层直接写入
直接修改 properties.last_followed_at properties 由触发器 trg_update_last_followed 自动维护
删除 property_tags WHERE is_system = TRUE property_tags 系统预置标签不可删除
明文存储手机号 property_contacts 手机号必须 AES-256-GCM 加密后存 phone_enc,同时更新 phone_hash

DATA_MODEL_PROPERTY.md — Fonrey 房产经纪管理系统房源模块数据模型 v1.0
下一步API 接口规范(房源模块 URL 设计 + Request/Response Schema