Sync: expand data model and gitops notes

This commit is contained in:
2026-04-24 14:49:34 +08:00
parent 7550b4ee18
commit 75b9e25e68
13 changed files with 2418 additions and 318 deletions

View File

@@ -70,7 +70,59 @@
---
## 二、公共 SchemaShared / Public
## 二、领域概览Domain Overview
本节用业务语言描述系统的核心领域对象及其关系,作为各子模块数据模型的导读。
### 核心领域对象
| 领域对象 | 表/子文档 | 业务说明 |
|----------|-----------|----------|
| **Tenant租户** | `public.tenants` | 每家房产经纪公司对应一个租户数据完全隔离Schema-per-Tenant |
| **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` → §3.3 | 系统核心表,每套二手房源的完整档案,支持出售/出租/出售兼出租三态 |
| **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) | 系统/人工推荐的客源↔房源配对 |
### 领域关系快速导航
```
District (城区)
└─ BusinessArea (商圈)
└─ Complex (楼盘) ─── School (对口学校)
├─ Building (楼栋)
│ └─ RoomUnit (房号)
└─ Property (房源)
├─ PropertyContact (联系人/委托方)
├─ FollowLog (跟进日志)
├─ Viewing (带看记录) ──── Client (客源)
└─ Match (配对记录) ──────┘
OrgUnit (组织架构)
└─ Staff (员工/经纪人) ─── Property / Client / Viewing / Match
```
### 子文档索引
| 子文档 | 覆盖模块 | 状态 |
|--------|----------|------|
| [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 等) | ✅ 完成 |
| 本文档 §3.3§3.16 | 房源核心properties 及配套 12 张表)、系统设置 | ✅ 完成 |
---
## 三、公共 SchemaShared / Public
```sql
-- ============================================================
@@ -107,7 +159,7 @@ CREATE INDEX idx_domains_primary ON public.domains(tenant_id) WHERE is_primary =
---
## 、租户 SchemaTenant Schema
## 、租户 SchemaTenant Schema
以下所有表均在每个租户的独立 Schema 内创建。
@@ -115,190 +167,56 @@ CREATE INDEX idx_domains_primary ON public.domains(tenant_id) WHERE is_primary =
### 3.1 组织人事模块Organization & HR
```sql
-- ============================================================
-- 组织架构:公司 → 区域 → 门店 → 组
-- ============================================================
> **详细模型** → 见 [`DATA_MODEL_ORG.md`](./DATA_MODEL_ORG.md)
> 该文件为权威定义,包含完整字段、枚举、查询模式和禁止操作。
-- 组织节点表(树形结构,支持无限层级)
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
);
**核心表概览**(开发时以 DATA_MODEL_ORG.md 为准):
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 扩展,用于路径前缀查询
| 表名 | 说明 |
|------|------|
| `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安居客/中国网络经纪人等) |
-- 员工表
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;
```
**关键约束提示**
- `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
```sql
-- ============================================================
-- 行政区 → 商圈 → 楼盘/小区 → 楼栋
-- 注:楼盘数据是房源录入的基础底座,数据质量直接影响房源录入效率
-- ============================================================
> **详细模型** → 见 [`DATA_MODEL_COMPLEX.md`](./DATA_MODEL_COMPLEX.md)
> 本节仅作概览,开发时以 DATA_MODEL_COMPLEX.md 为权威定义。
-- 城市/行政区
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
);
**核心表概览**(开发时以 DATA_MODEL_COMPLEX.md 为准):
-- 商圈/板块
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);
```
| 表名 | 说明 | 关键字段 |
|------|------|----------|
| `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` |
---
@@ -1066,123 +984,30 @@ CREATE TABLE property_completeness (
### 3.17 客源管理Client Management
```sql
-- ============================================================
-- 客源:私客为核心,公客/成交客为后续版本
-- ============================================================
> **详细模型** → 见 [`DATA_MODEL_CLIENT.md`](./DATA_MODEL_CLIENT.md)
> 该文件为权威定义,包含完整字段、枚举、状态机、查询模式和禁止操作。
CREATE TABLE clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
**核心表概览**(开发时以 DATA_MODEL_CLIENT.md 为准):
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')),
| 表名 | 说明 |
|------|------|
| `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` | 意向学校(拆表,支持精确查询) |
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);
```
**关键约束提示**
- `client_contacts.phone_hash` 是重复客源检测的唯一依据,录入前必须查重
- `client_status_logs` **无 deleted_at**,不可删除
- 私客超时(配置天数内无跟进)→ Celery 自动转公(`transfer_to_public_type = 'auto'`
- 活跃度 `activity_level` 由 Celery 每日凌晨批量计算,不实时更新
---
@@ -1290,7 +1115,7 @@ CREATE INDEX idx_number_holder_approvals_status ON number_holder_approvals(statu
---
## 、关键索引汇总与查询优化策略
## 、关键索引汇总与查询优化策略
### 4.1 房源列表页核心查询分析
@@ -1372,7 +1197,7 @@ CREATE TRIGGER trg_update_last_followed
---
## 、Redis 缓存策略
## 、Redis 缓存策略
### 5.1 缓存 Key 规范
@@ -1422,7 +1247,7 @@ CREATE TRIGGER trg_update_last_followed
---
## 、Django Model 层设计要点
## 、Django Model 层设计要点
### 6.1 抽象基类
@@ -1500,7 +1325,7 @@ class PropertyManager(ActiveManager):
---
## 、数据量与性能预测
## 、数据量与性能预测
| 表名 | 预估行数 | 增长速度 | 分区策略 |
|------|---------|---------|---------|
@@ -1514,7 +1339,7 @@ class PropertyManager(ActiveManager):
---
## 、必须在开发启动前明确的数据架构决策
## 、必须在开发启动前明确的数据架构决策
| 决策项 | 推荐方案 | 风险 |
|-------|---------|------|

View File

@@ -0,0 +1,574 @@
# Fonrey — 客源管理数据模型DATA_MODEL_CLIENT
> **所属系统**: Fonrey 房产经纪管理系统
> **版本**: v1.0
> **日期**: 2026-04-24
> **关联模块**: `apps/client/` — 私客、公客、成交客、跟进记录、带看、智能配房
---
## 一、领域概览Domain Overview
### 核心概念
- **Client客源**:有购房/租房意向或历史成交记录的客户。核心实体与房源Property是系统业务闭环的两端。
- **客源类型**
- **私客private**:经纪人独占跟进的意向客户,是本期核心。
- **公客public**:私客超时未跟进或手动转公后,进入全公司共享客源池。
- **成交客transacted**:已完成购房/租房成交的客户,用于复购/转介绍跟进。
- **ClientContact联系人**:一个客源可有多个联系人,每个联系人有独立手机号。手机号加密存储,用于重复检测(「私客与成交客重复」)。
- **ClientRequirement需求信息**:购房/租房的详细偏好。一个客源可同时有「二手」「新房」「租房」三种需求类型(分别对应独立的需求记录)。
- **ClientFollowLog跟进日志**:经纪人与客户每次沟通的书面记录,是客源活跃度计算的数据来源。
- **Viewing带看记录**:与 Property 模块共享此表,记录经纪人带客户看房的过程。见主 DATA_MODEL.md 3.17 节。
- **ClientPropertyMatch智能配房**:系统按需求自动匹配的房源列表,分「录客配房」和「系统配房」两种来源。
- **ClientFavoriteFolder收藏夹**:经纪人自定义的客源分组收藏夹。
### 关键业务规则
1. **私客手机号唯一性**:录入联系人手机号时,系统通过 `phone_hash` 检测是否与现有私客/成交客/公客重复,并在列表顶部提示重复数量。
2. **活跃度计算**:系统根据「最后跟进日期」自动计算客源活跃度,分为:新配偶(新建)/ 7日活跃 / 30日活跃 / 90日活跃 / 即将过期 / 无效。具体阈值由运营配置。
3. **私客自动转公规则**:超过配置天数(如 30 天)无跟进记录,系统自动将私客标记为公客(`transfer_to_public_type = 'auto'`)。
4. **状态机**:客源状态有严格流转规则(见第四章),不可跳过转台。
5. **跟进目的枚举**:由 `lookup_items` 表维护,运营可配置,当前已知 23 项(见 Story 8
6. **号码查看审计**:查看联系人明文号码需记录 `client_follow_logs``log_type = 'sensitive_view'`),不可删除。
7. **需求类型独立存储**:同一客源可同时有「二手购房」「租房」两类需求,分别存储在独立需求记录中,由 `client_requirements.requirement_type` 区分。
---
## 二、实体关系
```
Client (客源主表)
├── 1:N ── ClientContact (联系人,多个号码)
├── 1:N ── ClientRequirement (需求信息,可多类型)
├── 1:N ── ClientFollowLog (跟进日志,高写入频率)
├── 1:N ── ClientViewing (带看预约)
├── 1:N ── ClientPropertyMatch (智能配房结果)
├── 1:1 ── ClientActivityCache (活跃度缓存,异步计算)
├── N:M ── ClientFavoriteFolder (通过 client_folder_items 关联)
└── 1:N ── ClientStatusLog (状态变更日志,不可删)
ClientFavoriteFolder
└── 1:N ── ClientFolderItem (收藏夹中的客源)
Staff (员工)
├── first_recorder_id → Client (首录人)
└── owner_id → Client (归属人)
```
---
## 三、Schema 定义
### 3.1 clients — 客源主表
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| client_no | VARCHAR(30) | UNIQUE, NOT NULL | 系统生成的客源编号,格式由运营配置(如 KY20260424001 |
| client_type | VARCHAR(20) | NOT NULL DEFAULT 'private' | `private`=私客 / `public`=公客 / `transacted`=成交客 |
| status | VARCHAR(20) | NOT NULL DEFAULT 'buying' | 见下方枚举 |
| grade | VARCHAR(5) | NOT NULL DEFAULT 'C' | `A_urgent`=A急迫 / `A` / `B`=较强 / `C`=一般 / `D`=较弱 / `E`=暂不关注 |
| property_usage | VARCHAR(30) | NOT NULL DEFAULT 'residential' | `residential`=住宅 / `villa`=别墅 / `commercial_residential`=商住 / `shop`=商铺 / `office`=写字楼 / `other`=其他 |
| buying_purpose | VARCHAR(20)[] | | 购房目的多选:`rigid`=刚需 / `investment`=投资 / `school_district`=学区 / `upgrade`=改善 / `commercial`=商用 / `other`=其他 |
| payment_method | VARCHAR(30) | | `full`=全额 / `mortgage`=商业贷款 / `mortgage_fund`=商贷+公积金 / `fund`=公积金 |
| properties_owned | VARCHAR(20) | | `none`=无 / `local_none`=本地无外地有 / `local_has`=本地有 |
| has_loan_record | BOOLEAN | | 有无贷款记录 |
| id_type | VARCHAR(20) | | 证件类型:`id_card` / `passport` / `hk_macao` / `other` |
| id_number_enc | BYTEA | | 证件号码AES 加密) |
| source | VARCHAR(50) | | 客户来源lookup_items 维护) |
| remarks | TEXT | | 备注最多200字 |
| is_starred | BOOLEAN | NOT NULL DEFAULT FALSE | 是否收藏(快速标记,详细收藏夹用 client_folder_items |
| is_pinned | BOOLEAN | NOT NULL DEFAULT FALSE | 是否置顶(列表顶部置顶) |
| is_big_value | BOOLEAN | NOT NULL DEFAULT FALSE | 是否大价值客户(影响筛选展示) |
| is_protected | BOOLEAN | NOT NULL DEFAULT FALSE | 是否保护客(影响转公逻辑) |
| prefers_new_house | BOOLEAN | | 偏好新房(用于筛选) |
| transfer_to_public_type | VARCHAR(20) | | 转公客方式:`manual`=手动转公 / `auto`=自动转公(超时) / `marketing_jump`=营销客跳公 / `resource_public`=资料客素公 |
| transferred_public_at | TIMESTAMPTZ | | 进入公客池时间 |
| invalid_reason | VARCHAR(30) | | 无效原因:`invalid_phone`=号码无效 / `peer_agent`=同行 / `ad`=广告推销 / `no_intent`=无意向 / `other` |
| invalidated_at | TIMESTAMPTZ | | 标记无效时间 |
| transacted_at | DATE | | 成交日期 |
| transacted_property_id | UUID | FK→properties, SET NULL | 成交关联的房源 |
| transacted_price | NUMERIC(12,2) | | 成交价格(万元) |
| transacted_type | VARCHAR(20) | | 成交类型:`bought`=我购 / `rented`=我租 |
| transacted_property_type | VARCHAR(20) | | 成交房源类型:`second_hand`=二手 / `new_house`=新房 |
| first_recorder_id | UUID | FK→staff, SET NULL | 首录人 |
| owner_id | UUID | FK→staff, SET NULL | 归属人(私客独占跟进人) |
| org_unit_id | UUID | FK→org_units, SET NULL | 归属部门(冗余,加速筛选) |
| activity_level | VARCHAR(20) | | `new_matched`=新配偶 / `active_7d` / `active_30d` / `active_90d` / `expiring` / `frozen` / `invalid`(异步计算)|
| last_active_at | TIMESTAMPTZ | | 最后有效跟进时间(触发器维护) |
| last_follow_at | TIMESTAMPTZ | | 最后跟进时间(冗余,列表排序用) |
| commission_date | DATE | | 委托日期 |
| entrust_count | SMALLINT | NOT NULL DEFAULT 1 | 委托次数(成交后再委托则累加) |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | 软删除 |
| created_by | UUID | FK→staff, SET NULL | |
| updated_by | UUID | FK→staff, SET NULL | |
**关键索引**
```sql
CREATE UNIQUE INDEX idx_clients_client_no ON clients(client_no) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_type_status ON clients(client_type, status) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_owner ON clients(owner_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_org_unit ON clients(org_unit_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_activity ON clients(activity_level, last_active_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_grade ON clients(grade) WHERE deleted_at IS NULL;
CREATE INDEX idx_clients_transferred_at ON clients(transferred_public_at DESC) WHERE client_type = 'public';
CREATE INDEX idx_clients_last_follow ON clients(last_follow_at DESC NULLS LAST) WHERE deleted_at IS NULL;
```
---
### 3.2 client_contacts — 联系人表
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| client_id | UUID | NOT NULL, FK→clients, CASCADE | |
| sort_order | SMALLINT | NOT NULL DEFAULT 0 | 联系人1为主联系人sort_order=0 |
| name | VARCHAR(50) | NOT NULL | 联系人姓名 |
| gender | VARCHAR(10) | NOT NULL DEFAULT 'male' | `male`=先生 / `female`=女士 |
| phone_enc | BYTEA | NOT NULL | AES-256-GCM 加密手机号电话1 |
| phone_hash | VARCHAR(64) | NOT NULL | SHA-256 哈希(重复检测) |
| phone_country_code | VARCHAR(10) | NOT NULL DEFAULT '+86' | 国际区号 |
| phone_is_invalid | BOOLEAN | NOT NULL DEFAULT FALSE | 是否被标记为无效号码 |
| phone2_enc | BYTEA | | 备用电话2 |
| phone2_hash | VARCHAR(64) | | |
| wechat | VARCHAR(100) | | 微信号 |
| qq | VARCHAR(20) | | QQ号 |
| remarks | VARCHAR(200) | | 联系人备注最多200字 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | 软删除(不影响客源本身) |
| created_by | UUID | FK→staff, SET NULL | |
**关键索引**
```sql
-- 关键:手机号哈希全局唯一索引(用于重复客源检测)
CREATE INDEX idx_client_contacts_phone_hash ON client_contacts(phone_hash) WHERE deleted_at IS NULL;
CREATE INDEX idx_client_contacts_phone2_hash ON client_contacts(phone2_hash) WHERE phone2_hash IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_client_contacts_client ON client_contacts(client_id) WHERE deleted_at IS NULL;
```
**业务注意**
- `sort_order = 0` 的联系人为主联系人,姓名用于客源姓名显示
- 手机号标记无效(`phone_is_invalid = TRUE`)时,不影响记录存在,但该号码不再参与重复检测
- 联系人软删除后客源仍保留,但若所有联系人均被删则客源实际上无有效号码
---
### 3.3 client_requirements — 需求信息表
一个客源可同时有多类需求(二手购房、新房、租房),每类需求独立一条记录。
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| client_id | UUID | NOT NULL, FK→clients, CASCADE | |
| requirement_type | VARCHAR(20) | NOT NULL | `second_hand`=二手 / `new_house`=新房 / `rental`=租房 |
| is_primary | BOOLEAN | NOT NULL DEFAULT TRUE | 是否为主需求(用于列表展示) |
| budget_min | NUMERIC(12,2) | | 最低预算(万元/元,依据需求类型) |
| budget_max | NUMERIC(12,2) | | 最高预算 |
| area_min | NUMERIC(8,2) | | 最小面积(㎡) |
| area_max | NUMERIC(8,2) | | 最大面积 |
| bedroom_counts | SMALLINT[] | | 可接受卧室数:如 [2,3](多选) |
| floor_preferences | VARCHAR(20)[] | | 楼层偏好多选:`no_first`=不要一层 / `low`=低楼层 / `mid`=中楼层 / `high`=高楼层 / `no_top`=不要顶层 |
| orientations | VARCHAR(10)[] | | 朝向多选:`east`/`south`/`west`/`north` |
| decorations | VARCHAR(10)[] | | 装修偏好多选(枚举同 properties.decoration |
| building_age_ranges | VARCHAR(20)[] | | 楼龄多选:`within_5y`/`5_10y`/`10_15y`/`15_20y`/`over_20y` |
| intent_district_ids | UUID[] | | 意向行政区 ID 数组 |
| intent_business_area_ids | UUID[] | | 意向商圈 ID 数组 |
| intent_complex_names | TEXT | | 意向小区文本逗号分隔最多500字 |
| transportation | VARCHAR(50) | | 交通要求最多50字 |
| intent_school_names | TEXT | | 意向学校(文本,逗号分隔) |
| school_enrollment_date | DATE | | 入学时间月份精度取该月1日存储 |
| traffic_preference | TEXT | | 交通备注 |
| requirement_notes | VARCHAR(200) | | 需求备注最多200字 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
**关键索引**
```sql
CREATE INDEX idx_client_requirements_client ON client_requirements(client_id);
CREATE INDEX idx_client_requirements_type ON client_requirements(requirement_type, client_id);
-- 智能配房时按预算/面积范围查询
CREATE INDEX idx_client_requirements_budget ON client_requirements(budget_min, budget_max);
CREATE INDEX idx_client_requirements_area ON client_requirements(area_min, area_max);
```
---
### 3.4 client_follow_logs — 客源跟进日志
> 与 `follow_logs`(房源跟进)结构类似,独立存储以避免跨模块混淆。
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| client_id | UUID | NOT NULL, FK→clients, CASCADE | |
| log_type | VARCHAR(30) | NOT NULL | 见下方枚举 |
| purpose | VARCHAR(50) | | 跟进目的lookup_items 维护23项 |
| content | TEXT | | 跟进内容最少6字最多500字 |
| log_tag | VARCHAR(50) | | 跟进标签:`has_recording`=有录音 / `has_photo`=有图片 / `not_satisfied`=对房源不满意 / `still_considering`=还在考虑 / `ready_to_deposit`=可交定金 |
| change_detail | JSONB | | 修改跟进专用,格式:`{"field": "grade", "old": "C", "new": "B", "label": "等级"}` |
| is_public | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE=仅本人及上级可见 |
| is_deletable | BOOLEAN | NOT NULL DEFAULT TRUE | 敏感信息查看类型为 FALSE不可删除 |
| operator_id | UUID | FK→staff, SET NULL | 操作人 |
| operator_snapshot | JSONB | | `{name, store_group, role}`(防止人员调动后显示异常) |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | 仅 is_deletable=TRUE 时可软删 |
**log_type 枚举**
```
written = 写入跟进(经纪人主动写)
modified = 修改跟进(字段变更自动生成)
sensitive_view= 敏感信息查看(查看号码等,不可删)
other = 其他跟进(系统自动:新增私客/状态变更等)
system = 系统日志
```
**关键索引**
```sql
CREATE INDEX idx_client_follow_logs_client_time ON client_follow_logs(client_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_client_follow_logs_type ON client_follow_logs(client_id, log_type, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_client_follow_logs_operator ON client_follow_logs(operator_id, created_at DESC) WHERE deleted_at IS NULL;
-- 不可删记录(合规审计)
CREATE INDEX idx_client_follow_sensitive ON client_follow_logs(client_id, created_at DESC) WHERE log_type = 'sensitive_view';
```
---
### 3.5 client_follow_log_attachments — 跟进附件
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| follow_log_id | UUID | NOT NULL, FK→client_follow_logs, CASCADE | |
| file_key | TEXT | NOT NULL | R2/S3 存储路径 |
| file_name | VARCHAR(255) | NOT NULL | |
| file_size | INTEGER | NOT NULL | bytes最大 20MB |
| file_type | VARCHAR(10) | CHECK | `bmp`/`jpg`/`png`/`gif` |
| has_location | BOOLEAN | NOT NULL DEFAULT FALSE | 是否含 GPS 位置信息 |
| sort_order | SMALLINT | NOT NULL DEFAULT 0 | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
---
### 3.6 client_viewings — 带看记录(客源侧视图)
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| client_id | UUID | NOT NULL, FK→clients, RESTRICT | |
| property_id | UUID | NOT NULL, FK→properties, RESTRICT | |
| viewing_type | VARCHAR(20) | NOT NULL DEFAULT 'viewing' | `appointment`=预约 / `viewing`=带看 / `revisit`=复看 / `empty`=空看 |
| agent_id | UUID | FK→staff, SET NULL | 主带看经纪人 |
| companion_ids | UUID[] | | 陪看人员 ID 数组最多5人 |
| cooperator_ids | UUID[] | | 合作带看人 ID 数组最多5人 |
| scheduled_at | TIMESTAMPTZ | | 预约时间 |
| viewing_start_at | TIMESTAMPTZ | | 实际带看开始时间 |
| viewing_end_at | TIMESTAMPTZ | | 结束时间 |
| situation | TEXT | | 带看情况必填≥6字 |
| client_intent | VARCHAR(20) | | 客户意向:`interested`=感兴趣 / `not_interested`=不感兴趣 / `negotiating`=谈判中 / `cancelled`=取消 |
| viewing_progress | SMALLINT | | 带看进度1=一看2=二看...,冗余字段,触发器维护) |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | |
| created_by | UUID | FK→staff, SET NULL | |
**关键索引**
```sql
CREATE INDEX idx_client_viewings_client ON client_viewings(client_id, viewing_start_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_client_viewings_property ON client_viewings(property_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_client_viewings_agent ON client_viewings(agent_id) WHERE deleted_at IS NULL;
```
---
### 3.7 client_property_matches — 智能配房
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| client_id | UUID | NOT NULL, FK→clients, CASCADE | |
| property_id | UUID | NOT NULL, FK→properties, CASCADE | |
| match_source | VARCHAR(20) | NOT NULL DEFAULT 'recorded' | `recorded`=录客配房(基于录入需求) / `system`=系统配房(算法推荐) |
| match_group | VARCHAR(30) | | 分组:`quality_layout`=优质户型 / `price_reduced`=降价 / `hot`=热门 / `newly_listed`=新上 |
| match_score | NUMERIC(5,2) | | 匹配度评分0-100 |
| match_reasons | JSONB | | 匹配原因详情,格式:`[{"key": "budget", "match": true}, ...]` |
| status | VARCHAR(20) | NOT NULL DEFAULT 'suggested' | `suggested`=待推送 / `shared`=已分享 / `rejected`=已反馈不合适 / `viewed`=客户已查看 |
| shared_at | TIMESTAMPTZ | | 分享时间 |
| feedback | VARCHAR(50) | | 反馈原因lookup_items 维护) |
| calculated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 配房计算时间 |
| created_by | UUID | FK→staff, SET NULL | |
**关键索引**
```sql
CREATE UNIQUE INDEX idx_client_matches_pair ON client_property_matches(client_id, property_id);
CREATE INDEX idx_client_matches_client ON client_property_matches(client_id, match_source, match_group);
CREATE INDEX idx_client_matches_status ON client_property_matches(client_id, status) WHERE status != 'rejected';
```
---
### 3.8 client_status_logs — 状态变更日志(不可删)
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| client_id | UUID | NOT NULL, FK→clients, RESTRICT | |
| change_type | VARCHAR(30) | NOT NULL | `status_change`=改状态 / `grade_change`=改等级 / `to_public`=转公客 / `to_transacted`=转成交 / `to_invalid`=转无效 / `owner_change`=改归属人 / `source_change`=改来源 |
| old_value | JSONB | | 变更前快照,格式:`{"status": "buying", "label": "求购"}` |
| new_value | JSONB | | 变更后快照 |
| reason | TEXT | | 变更理由改状态必填最多200字 |
| operator_id | UUID | NOT NULL, FK→staff, RESTRICT | |
| operated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| ⚠️ 无 deleted_at | — | — | 此表记录**不可删除** |
**关键索引**
```sql
CREATE INDEX idx_client_status_logs_client ON client_status_logs(client_id, operated_at DESC);
CREATE INDEX idx_client_status_logs_type ON client_status_logs(change_type, operated_at DESC);
```
---
### 3.9 client_favorite_folders — 私客收藏夹
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| staff_id | UUID | NOT NULL, FK→staff, CASCADE | 收藏夹所属经纪人 |
| name | VARCHAR(10) | NOT NULL | 收藏夹名称最多10字 |
| is_default | BOOLEAN | NOT NULL DEFAULT FALSE | 系统默认收藏夹 |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | |
```sql
CREATE INDEX idx_favorite_folders_staff ON client_favorite_folders(staff_id) WHERE deleted_at IS NULL;
-- 每个经纪人只能有一个默认收藏夹
CREATE UNIQUE INDEX idx_favorite_folders_default ON client_favorite_folders(staff_id) WHERE is_default = TRUE AND deleted_at IS NULL;
```
---
### 3.10 client_folder_items — 收藏夹中的客源
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| folder_id | UUID | NOT NULL, FK→client_favorite_folders, CASCADE | |
| client_id | UUID | NOT NULL, FK→clients, CASCADE | |
| added_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| PRIMARY KEY | (folder_id, client_id) | | |
```sql
CREATE INDEX idx_folder_items_client ON client_folder_items(client_id);
```
---
### 3.11 client_school_preferences — 意向学校(多对多)
> 单独拆表便于学校搜索,避免文本字段模糊查询。
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| requirement_id | UUID | NOT NULL, FK→client_requirements, CASCADE | |
| school_id | UUID | FK→schools, SET NULL | 从学校表选择,允许为 NULL自由输入 |
| school_name | VARCHAR(100) | NOT NULL | 学校名称(当 school_id 为 NULL 时为手动输入) |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE INDEX idx_school_prefs_requirement ON client_school_preferences(requirement_id);
```
---
## 四、枚举常量
### clients.status客源状态
```
buying = 求购(私客活跃态)
renting = 求租(私客活跃态)
buy_or_rent = 租购(私客活跃态)
suspended = 暂缓(暂时无需求,不计入活跃统计)
bought = 已购(成交客:我购)
rented_done = 已租(成交客:我租)
public = 公客(已转入公客池)
invalid = 无效(号码无效/无意向等)
```
**状态流转规则**
```
buying/renting/buy_or_rent
→ suspended (改状态操作,可逆)
→ public (手动转公 or 超时自动转公,不可逆)
→ bought/rented_done (转成交,不可逆)
→ invalid (转无效,需经理审批后可恢复)
```
### clients.grade等级
```
A_urgent = A(急迫)
A = A
B = B(较强)
C = C(一般,默认值)
D = D(较弱)
E = E(暂不关注)
```
### client_status_logs.change_type变更类型
```
status_change = 改状态(含改等级时同时改状态的情况)
grade_change = 改等级
to_public = 转公客manual=手动 or auto=自动)
to_transacted = 转成交(记录成交信息)
to_invalid = 转无效(含无效原因)
owner_change = 改归属人
source_change = 改来源
merge = 合并客源(被合并的记录保留日志)
```
### clients.activity_level活跃度分层系统计算
| 值 | 含义 | 触发条件(示例,以运营配置为准) |
|----|------|------|
| `new_matched` | 新配偶 | 录入后 3 天内 |
| `active_7d` | 7日活跃 | 最后跟进在 7 天内 |
| `active_30d` | 30日活跃 | 最后跟进在 30 天内 |
| `active_90d` | 90日活跃 | 最后跟进在 90 天内 |
| `expiring` | 即将过期 | 距自动转公还有 N 天 |
| `frozen` | 冻结(暂缓) | status = suspended |
| `invalid` | 无效 | status = invalid |
---
## 五、查询模式
### 5.1 私客列表页(求购 Tab核心查询
```sql
-- 典型:当前经纪人名下 + 求购状态 + 等级筛选 + 按最后跟进排序
SELECT c.id, c.status, c.grade, c.activity_level,
c.last_follow_at, c.commission_date, c.buying_purpose,
cc.name AS contact_name, -- JOIN 主联系人
s.name AS owner_name, ou.name AS org_unit_name,
COUNT(cpm.id) AS match_count -- 智能配房数量
FROM clients c
JOIN client_contacts cc ON cc.client_id = c.id AND cc.sort_order = 0 AND cc.deleted_at IS NULL
JOIN staff s ON s.id = c.owner_id
JOIN org_units ou ON ou.id = c.org_unit_id
LEFT JOIN client_property_matches cpm ON cpm.client_id = c.id AND cpm.status != 'rejected'
WHERE c.client_type = 'private'
AND c.owner_id = :current_staff_id -- 与我相关
AND c.status IN ('buying', 'buy_or_rent')
AND c.deleted_at IS NULL
GROUP BY c.id, cc.name, s.name, ou.name
ORDER BY c.last_follow_at DESC NULLS LAST
LIMIT 20 OFFSET :offset;
```
### 5.2 重复客源检测(录入/编辑时触发)
```sql
-- 手机号哈希碰撞检测(私客、成交客、公客三池同时检查)
SELECT c.id, c.client_type, c.status, c.client_no,
cc.name AS contact_name
FROM client_contacts cc
JOIN clients c ON cc.client_id = c.id
WHERE cc.phone_hash = :new_phone_hash
AND cc.deleted_at IS NULL
AND c.deleted_at IS NULL
AND c.status != 'invalid';
```
### 5.3 活跃度批量更新Celery 定时任务,每日凌晨执行)
```sql
-- 更新活跃度以7日活跃为例
UPDATE clients
SET activity_level = 'active_7d',
updated_at = NOW()
WHERE client_type = 'private'
AND status NOT IN ('invalid', 'public', 'bought', 'rented_done')
AND last_follow_at >= NOW() - INTERVAL '7 days'
AND deleted_at IS NULL;
```
### 5.4 私客自动转公超时无跟进Celery 定时任务)
```sql
-- 查询应自动转公的私客阈值由运营配置假设30天
SELECT id FROM clients
WHERE client_type = 'private'
AND status IN ('buying', 'renting', 'buy_or_rent')
AND last_follow_at < NOW() - INTERVAL '30 days'
AND is_protected = FALSE
AND deleted_at IS NULL;
-- 后续在 Application 层批量更新 client_type='public', transfer_to_public_type='auto'
```
---
## 六、触发器
### 6.1 last_follow_at 自动维护
```sql
-- 每次写入跟进日志时,自动更新 clients.last_follow_at
CREATE OR REPLACE FUNCTION update_client_last_follow()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.log_type = 'written' THEN
UPDATE clients
SET last_follow_at = NEW.created_at,
last_active_at = NEW.created_at,
updated_at = NOW()
WHERE id = NEW.client_id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_client_last_follow
AFTER INSERT ON client_follow_logs
FOR EACH ROW EXECUTE FUNCTION update_client_last_follow();
```
### 6.2 viewing_progress 自动维护
```sql
-- 每次新增带看记录时,自动更新 clients 的带看进度冗余字段
CREATE OR REPLACE FUNCTION update_client_viewing_progress()
RETURNS TRIGGER AS $$
BEGIN
UPDATE clients
SET updated_at = NOW()
WHERE id = NEW.client_id;
-- Application 层根据 COUNT(viewings) 计算具体进度
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_client_viewing_progress
AFTER INSERT ON client_viewings
FOR EACH ROW EXECUTE FUNCTION update_client_viewing_progress();
```
---
## 七、禁止操作
-**严禁硬删除 clients 记录**:无效/转公客/成交客均通过 status 和 soft delete 处理,历史跟进/带看依赖外键
-**严禁删除 client_status_logs**:状态变更为不可变审计日志
-**严禁删除 log_type='sensitive_view' 的跟进记录**:必须通过 `is_deletable=FALSE` 约束在应用层拦截
-**严禁明文存储联系人手机号**:必须走 `EncryptedPhoneField``phone_hash` 用于索引和重复检测
-**严禁跳过状态机流转**:如私客不可直接跳过「求购」变为「无效」而不生成 status log
-**严禁在没有 `client_type` 过滤的情况下查询客源列表**:私客/公客/成交客数据量均较大,必须按类型隔离查询
-**严禁查询 clients 时不带 `deleted_at IS NULL`**:软删除过滤必须存在

View File

@@ -0,0 +1,547 @@
# Fonrey — 楼盘与区域数据模型DATA_MODEL_COMPLEX
> **所属系统**: Fonrey 房产经纪管理系统
> **版本**: v1.0
> **日期**: 2026-04-24
> **关联模块**: `apps/complex/` — 楼盘/小区、楼栋、结构(楼层+房号)、区域、学校
---
## 一、领域概览Domain Overview
### 核心概念
- **Complex楼盘/小区)**:房源录入的基础底座。每套房源必须归属于某一楼盘。楼盘数据由运营/数据管理员集中维护,质量直接影响房源录入效率和搜索精度。
- **Building楼栋/单元)**楼盘下的物理楼栋是组织房源位置的第二级。一个楼盘可有多个楼栋如「1号楼」「2栋2单元」
- **RoomUnit房号/结构单元)**:楼栋内特定楼层的某个房间标识,是房源定位的最细粒度。支持「标准结构」(经运营标准化)和「非标结构」(未归一化)两类。
- **District城区/行政区)**:行政区划,如静安区、闵行区。
- **BusinessArea商圈/板块)**:商圈是区域内的细分市场区域,如「南京西路商圈」,一个楼盘可跨多个商圈。
- **School学校**:楼盘对口学校,是买家购房决策的核心关注点。一个楼盘可关联多所学校,一所学校可对口多个楼盘。
- **MetroLine / MetroStation地铁线路/站点)**:楼盘与最近地铁站的距离关系,用于通勤筛选。
### 关键业务规则
1. **楼盘名称不可在编辑页修改**:楼盘名称(`name`)变更须通过「合并楼盘」或「申请流程」处理,防止经纪人随意改名造成数据混乱。
2. **数据锁定机制**:楼盘有 4 类锁(楼栋锁/房号锁/信息锁/标准房号锁),锁定后对应数据只有管理员可解锁修改。
3. **非标结构处理**:未与标准结构关联的房号为「非标」,系统记录非标数量,引导运营逐步消除。
4. **搜索依赖全文检索**:楼盘名称、别名、地址需维护 `search_vector``tsvector`)以支持模糊搜索和联想补全。
5. **地理坐标优先级**:楼盘坐标是区域聚合展示(地图找房)的核心数据,完整度目标 ≥ 90%。
6. **学校关联影响房源**:从楼盘详情删除对口学校,会级联删除该楼盘下所有房源的对应学区标注。
---
## 二、实体关系
```
District (城区/行政区)
└── 1:N ── BusinessArea (商圈/板块)
└── N:M ── Complex (through complex_business_areas)
Complex (楼盘)
├── N:M ── BusinessArea (through complex_business_areas)
├── N:M ── School (through complex_schools)
├── N:M ── MetroStation (through complex_metro_stations, 附带距离)
├── 1:N ── Building (楼栋/单元)
│ └── 1:N ── RoomUnit (楼层+房号)
├── 1:N ── ComplexPhoto (楼盘照片:楼盘图/户型图/VR)
├── 1:N ── ComplexAttachment(附件)
├── 1:N ── ComplexPriceTrend(价格走势,月度)
└── 1:N ── ComplexAlias (别名)
MetroLine (地铁线路)
└── 1:N ── MetroStation (站点)
```
---
## 三、Schema 定义
### 3.1 districts — 城区/行政区
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| city | VARCHAR(50) | NOT NULL | 所属城市(支持多城市扩展,如「上海」「北京」) |
| name | VARCHAR(50) | NOT NULL | 行政区名称,如「静安区」 |
| short_name | VARCHAR(20) | | 简称,如「静安」 |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | 列表展示排序 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE=已停用(不在筛选项中展示) |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE UNIQUE INDEX idx_districts_city_name ON districts(city, name) WHERE is_active = TRUE;
```
---
### 3.2 business_areas — 商圈/板块
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| district_id | UUID | NOT NULL, FK→districts, RESTRICT | 所属城区 |
| name | VARCHAR(100) | NOT NULL | 商圈名称 |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | |
| latitude | NUMERIC(10,7) | | 商圈中心坐标(纬度) |
| longitude | NUMERIC(10,7) | | 商圈中心坐标(经度) |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE INDEX idx_business_areas_district ON business_areas(district_id) WHERE is_active = TRUE;
CREATE UNIQUE INDEX idx_business_areas_name ON business_areas(district_id, name);
```
---
### 3.3 metro_lines — 地铁线路
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| city | VARCHAR(50) | NOT NULL | 所属城市 |
| name | VARCHAR(50) | NOT NULL | 线路名如「1号线」 |
| color | VARCHAR(7) | | 线路颜色 HEX`#E3002B` |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
---
### 3.4 metro_stations — 地铁站点
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| metro_line_id | UUID | NOT NULL, FK→metro_lines, CASCADE | 所属线路 |
| name | VARCHAR(50) | NOT NULL | 站名 |
| latitude | NUMERIC(10,7) | | 站点坐标 |
| longitude | NUMERIC(10,7) | | |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | 沿线排序 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
```sql
CREATE INDEX idx_metro_stations_line ON metro_stations(metro_line_id) WHERE is_active = TRUE;
```
---
### 3.5 schools — 学校
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| district_id | UUID | FK→districts, SET NULL | 所属城区 |
| name | VARCHAR(100) | NOT NULL | 学校名称 |
| type | VARCHAR(20) | | 学校类型:`primary`=小学 / `middle`=初中 / `high`=高中 / `k9`=九年一贯制 / `k12`=十二年一贯制 |
| nature | VARCHAR(20) | | 学校性质:`public`=公立 / `private`=私立 / `international`=国际学校 |
| level | VARCHAR(20) | | 学校等级:`normal`=普通 / `key`=重点 / `top`=名校 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE INDEX idx_schools_district ON schools(district_id) WHERE is_active = TRUE;
CREATE INDEX idx_schools_name_trgm ON schools USING gin(name gin_trgm_ops);
```
---
### 3.6 complexes — 楼盘/小区(核心基础表)
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| name | VARCHAR(200) | NOT NULL | 标准楼盘名称,**不可在编辑页修改**(需走合并/申请流程) |
| district_id | UUID | FK→districts, SET NULL | 所属城区 |
| address | VARCHAR(500) | | 详细地址(不可在编辑页修改,需走纠错流程) |
| address_summary | VARCHAR(100) | | 概要地址如「海波路1000弄」可编辑 |
| latitude | NUMERIC(10,7) | | 楼盘坐标(纬度),完整度目标 ≥ 90% |
| longitude | NUMERIC(10,7) | | |
| **物业属性** | | | |
| property_usage_types | VARCHAR(20)[] | | 物业类型多选:`residential`/`villa`/`commercial_residential`/`commercial`/`office`/`other` |
| building_structure | VARCHAR(30) | | 楼栋结构枚举(运营维护):`unit_room`=单元-房号 / `other`=其他 |
| building_type | VARCHAR(20) | | 建筑类型:`slab`=板楼 / `tower`=塔楼 / `slab_tower`=板塔结合 |
| land_use_years | VARCHAR(30) | | 土地使用年限如「70年」 |
| built_year | SMALLINT | | 竣工年份(可多选,存最早竣工年) |
| built_years | SMALLINT[] | | 竣工年份多值(楼盘分期竣工) |
| ownership_category | VARCHAR(30)[] | | 权属类别多选(运营维护枚举) |
| total_units | INTEGER | | 单元总数 |
| total_households | INTEGER | | 总户数 |
| **建设信息** | | | |
| total_floor_area | NUMERIC(12,2) | | 小区总建筑面积 |
| plot_area | NUMERIC(12,2) | | 小区占地面积 |
| plot_ratio | NUMERIC(5,2) | | 容积率 |
| green_rate | NUMERIC(5,2) | | 绿化率(% |
| developer | VARCHAR(200) | | 开发商 |
| **物业信息** | | | |
| property_company | VARCHAR(200) | | 物业公司 |
| property_fee | NUMERIC(8,2) | | 物业费(元/m²/月) |
| property_phone | VARCHAR(30) | | 物业电话 |
| **停车** | | | |
| parking_total | INTEGER | | 车位总数 |
| parking_underground | INTEGER | | 地下车位数 |
| parking_ratio | VARCHAR(20) | | 停车位配比如「100:63」 |
| **配套** | | | |
| water_type | VARCHAR(10) | | `civil`=民水 / `commercial`=商水 |
| electricity_type | VARCHAR(10) | | `civil`=民电 / `commercial`=商电 |
| has_central_heating | BOOLEAN | | 是否统一供暖 |
| has_gas | BOOLEAN | | 是否有燃气 |
| remarks | TEXT | | 备注 |
| **锁定状态** | | | |
| lock_building | BOOLEAN | NOT NULL DEFAULT FALSE | 楼栋锁(锁定后不可增删楼栋) |
| lock_room | BOOLEAN | NOT NULL DEFAULT FALSE | 房号锁 |
| lock_info | BOOLEAN | NOT NULL DEFAULT FALSE | 信息锁(锁定后基本信息只读) |
| lock_standard_room | BOOLEAN | NOT NULL DEFAULT FALSE | 标准房号锁 |
| **全文检索** | | | |
| search_vector | TSVECTOR | | 由触发器自动维护name + alias + address |
| **状态** | | | |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE=已停用楼盘 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | 软删除 |
| created_by | UUID | FK→staff, SET NULL | |
| updated_by | UUID | FK→staff, SET NULL | |
**关键索引**
```sql
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 AND latitude IS NOT NULL;
CREATE INDEX idx_complexes_active ON complexes(is_active) WHERE deleted_at IS NULL;
```
---
### 3.7 complex_aliases — 楼盘别名
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| alias | VARCHAR(200) | NOT NULL | 别名最多20字/条,多别名多行存储) |
| is_system | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE=系统/标准别名只读FALSE=用户自定义 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| created_by | UUID | FK→staff, SET NULL | |
```sql
CREATE INDEX idx_complex_aliases_complex ON complex_aliases(complex_id);
CREATE INDEX idx_complex_aliases_alias_trgm ON complex_aliases USING gin(alias gin_trgm_ops);
```
---
### 3.8 complex_business_areas — 楼盘与商圈多对多
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| business_area_id | UUID | NOT NULL, FK→business_areas, CASCADE | |
| is_primary | BOOLEAN | NOT NULL DEFAULT FALSE | 主商圈(唯一)用于列表显示 |
| PRIMARY KEY | (complex_id, business_area_id) | | |
```sql
-- 主商圈只能有一个
CREATE UNIQUE INDEX idx_complex_biz_area_primary ON complex_business_areas(complex_id) WHERE is_primary = TRUE;
```
---
### 3.9 complex_schools — 楼盘与学校关联
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| school_id | UUID | NOT NULL, FK→schools, CASCADE | |
| zone_type | VARCHAR(30) | | 学区类型:`guaranteed`=对口 / `reference`=参考 / `lottery`=摇号 |
| PRIMARY KEY | (complex_id, school_id) | | |
```sql
CREATE INDEX idx_complex_schools_school ON complex_schools(school_id);
```
**业务注意**删除此关联记录时需同步清理对应房源的学区标注Application 层事务处理)
---
### 3.10 complex_metro_stations — 楼盘与地铁站关联
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| station_id | UUID | NOT NULL, FK→metro_stations, CASCADE | |
| distance_meters | INTEGER | | 步行距离(米) |
| PRIMARY KEY | (complex_id, station_id) | | |
```sql
CREATE INDEX idx_complex_metro_complex ON complex_metro_stations(complex_id);
CREATE INDEX idx_complex_metro_station ON complex_metro_stations(station_id);
```
---
### 3.11 buildings — 楼栋/单元
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| name | VARCHAR(50) | NOT NULL | 楼栋名如「1号楼」「A栋2单元」 |
| is_standard | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE=标准结构(经运营核准) |
| property_usage_type | VARCHAR(20) | | 物业类型(可与楼盘不同,如商住楼盘内有纯商铺楼栋) |
| built_year | SMALLINT | | 竣工年份 |
| total_floors | SMALLINT | | 总层数 |
| land_use_years | VARCHAR(30) | | 土地使用年限 |
| has_elevator | BOOLEAN | | 是否有电梯 |
| school_id | UUID | FK→schools, SET NULL | 关联对口学校(楼栋级别的学区差异) |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| created_by | UUID | FK→staff, SET NULL | |
```sql
CREATE INDEX idx_buildings_complex ON buildings(complex_id) WHERE is_active = TRUE;
CREATE UNIQUE INDEX idx_buildings_name ON buildings(complex_id, name) WHERE is_active = TRUE;
```
---
### 3.12 room_units — 房号/结构单元(楼层+房间号)
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| building_id | UUID | NOT NULL, FK→buildings, CASCADE | |
| floor | SMALLINT | NOT NULL | 楼层(实际层数,地下为负数) |
| floor_name | VARCHAR(20) | | 楼层名称展示如「1层」「B1层」 |
| room_no | VARCHAR(30) | NOT NULL | 房号如「01」「101」 |
| display_no | VARCHAR(50) | | 展示用完整房号如「3-1-101」 |
| is_standard | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE=已归一化为标准结构 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE=已拆除/不存在 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE INDEX idx_room_units_building ON room_units(building_id) WHERE is_active = TRUE;
CREATE UNIQUE INDEX idx_room_units_unique ON room_units(building_id, floor, room_no) WHERE is_active = TRUE;
```
---
### 3.13 complex_photos — 楼盘照片
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| category | VARCHAR(20) | NOT NULL | `complex`=楼盘图 / `layout`=户型图 / `vr`=VR全景 / `other`=其他 |
| file_key | TEXT | NOT NULL | R2/S3 路径 |
| thumbnail_key | TEXT | | 缩略图路径 |
| file_name | VARCHAR(255) | | |
| file_size | INTEGER | | bytes |
| 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() | |
| created_by | UUID | FK→staff, SET NULL | |
```sql
CREATE INDEX idx_complex_photos_complex ON complex_photos(complex_id);
CREATE INDEX idx_complex_photos_category ON complex_photos(complex_id, category);
CREATE UNIQUE INDEX idx_complex_photos_cover ON complex_photos(complex_id) WHERE is_cover = TRUE;
```
---
### 3.14 complex_attachments — 楼盘附件
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| file_key | TEXT | NOT NULL | |
| file_name | VARCHAR(255) | NOT NULL | |
| file_size | INTEGER | | |
| file_type | VARCHAR(50) | | MIME type |
| sort_order | SMALLINT | NOT NULL DEFAULT 0 | |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| created_by | UUID | FK→staff, SET NULL | |
---
### 3.15 complex_price_trends — 楼盘价格走势(月度)
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| complex_id | UUID | NOT NULL, FK→complexes, CASCADE | |
| record_month | DATE | NOT NULL | 月份统一存为该月1日如 2026-04-01 |
| avg_sale_price | NUMERIC(12,2) | | 月均售价(万元/套) |
| avg_unit_price | NUMERIC(10,2) | | 月均单价(元/m² |
| transaction_count | INTEGER | | 成交套数 |
| listing_count | INTEGER | | 当月挂牌套数 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
```sql
CREATE UNIQUE INDEX idx_complex_price_trend_month ON complex_price_trends(complex_id, record_month);
CREATE INDEX idx_complex_price_trend_complex ON complex_price_trends(complex_id, record_month DESC);
```
---
## 四、枚举常量
### complexes.building_type建筑类型
```
slab = 板楼
tower = 塔楼
slab_tower = 板塔结合
```
### complexes.water_type / electricity_type
```
civil = 民水/民电(住宅水电费率)
commercial = 商水/商电(商业水电费率,费用较高,影响买家决策)
```
### complex_schools.zone_type学区类型
```
guaranteed = 对口(直升)
reference = 参考(可能入读)
lottery = 摇号(通过摇号入学)
```
### buildings.is_standard / room_units.is_standard
```
TRUE = 已标准化(楼栋/房号已经运营核准,可用于精准房源定位)
FALSE = 非标(用户自输入,未核准,存在歧义风险)
```
---
## 五、查询模式
### 5.1 楼盘名称联想搜索(录入房源时的自动补全)
```sql
-- 使用全文检索向量,支持中文分词近似匹配
SELECT id, name, address_summary, district_id
FROM complexes
WHERE search_vector @@ plainto_tsquery('simple', :keyword)
OR name ILIKE :keyword_prefix -- 前缀精确匹配优先
AND deleted_at IS NULL
AND is_active = TRUE
ORDER BY
ts_rank(search_vector, plainto_tsquery('simple', :keyword)) DESC,
name
LIMIT 20;
```
### 5.2 楼盘列表(含房源数量统计)
```sql
SELECT
c.id, c.name, c.address, c.latitude, c.longitude,
d.name AS district_name,
ba.name AS primary_business_area,
COUNT(DISTINCT b.id) AS building_count,
COUNT(DISTINCT p.id) FILTER (WHERE p.status IN ('for_sale','for_sale_rent')) AS sale_count,
COUNT(DISTINCT p.id) FILTER (WHERE p.status IN ('for_rent','for_sale_rent')) AS rent_count
FROM complexes c
LEFT JOIN districts d ON d.id = c.district_id
LEFT JOIN complex_business_areas cba ON cba.complex_id = c.id AND cba.is_primary = TRUE
LEFT JOIN business_areas ba ON ba.id = cba.business_area_id
LEFT JOIN buildings b ON b.complex_id = c.id AND b.is_active = TRUE
LEFT JOIN properties p ON p.complex_id = c.id AND p.deleted_at IS NULL
WHERE c.deleted_at IS NULL
AND c.district_id = ANY(:district_ids) -- 区域筛选
GROUP BY c.id, d.name, ba.name
ORDER BY c.name
LIMIT 20 OFFSET :offset;
```
### 5.3 查询楼盘下的楼层-房号矩阵(结构管理)
```sql
-- 选中单元后,加载楼层×房号矩阵
SELECT
ru.floor,
ru.floor_name,
ru.room_no,
ru.display_no,
ru.is_standard,
p.id AS property_id, -- 如果该房号已有房源,关联显示
p.status AS property_status
FROM room_units ru
LEFT JOIN properties p ON p.building_id = ru.building_id
AND p.room_no = ru.room_no
AND p.floor = ru.floor
AND p.deleted_at IS NULL
WHERE ru.building_id = :building_id
AND ru.is_active = TRUE
ORDER BY ru.floor DESC, ru.room_no;
```
---
## 六、触发器
### 6.1 楼盘全文检索向量(含别名)
```sql
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.address_summary, '')), '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, address_summary, address
ON complexes
FOR EACH ROW EXECUTE FUNCTION update_complex_search_vector();
-- 别名变更时同步更新楼盘 search_vector
CREATE OR REPLACE FUNCTION update_complex_search_on_alias()
RETURNS TRIGGER AS $$
BEGIN
UPDATE complexes
SET search_vector = (
setweight(to_tsvector('simple', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('simple',
COALESCE((SELECT string_agg(alias, ' ') FROM complex_aliases WHERE complex_id = complexes.id), '')), 'B') ||
setweight(to_tsvector('simple', COALESCE(address_summary, '')), 'C') ||
setweight(to_tsvector('simple', COALESCE(address, '')), 'D')
),
updated_at = NOW()
WHERE id = COALESCE(NEW.complex_id, OLD.complex_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_complex_alias_search
AFTER INSERT OR UPDATE OR DELETE ON complex_aliases
FOR EACH ROW EXECUTE FUNCTION update_complex_search_on_alias();
```
---
## 七、禁止操作
-**严禁直接修改 complexes.name**:楼盘名称变更必须走「楼盘合并」流程或「管理员申请」,通过 Application 层拦截任何直接 UPDATE `name` 字段的操作
-**严禁硬删除 complexes 记录**:有房源关联的楼盘不可删除(`RESTRICT` 外键),已有房源的楼盘软删除后房源仍可正常访问
-**严禁删除 complex_schools 关联而不清理房源学区标注**:必须在同一事务中清理对应 `property.school_ids` 数据
-**严禁在楼盘坐标为 NULL 时将其用于地图聚合**:坐标为空时不参与地图展示,过滤条件:`WHERE latitude IS NOT NULL`
-**严禁在 lock_info=TRUE 时绕过 Application 层直接修改楼盘信息字段**:锁定状态必须在服务层检查,不依赖数据库约束
-**严禁在没有 deleted_at IS NULL 过滤的情况下查询 complexes**:楼盘软删除过滤必须存在

View File

@@ -0,0 +1,341 @@
# Fonrey — 组织人事数据模型DATA_MODEL_ORG
> **所属系统**: Fonrey 房产经纪管理系统
> **版本**: v1.0
> **日期**: 2026-04-24
> **关联模块**: `apps/org/` — 组织架构、员工档案、人事异动、账号体系
---
## 一、领域概览Domain Overview
### 核心概念
- **OrgUnit组织节点**:公司组织树的节点,类型涵盖事业部 / 大区 / 区域 / 片区 / 门店 / 店组 / 职能。所有业务数据(房源、客源)最终归属到门店或店组级节点。
- **Staff员工**:系统的核心操作人员,与 Django `auth_user` 绑定登录账号,与 `org_units` 绑定岗位归属。员工的组织归属直接影响数据可见范围。
- **StaffTransferLog人事异动记录**:记录员工从入职到离职的全生命周期状态变化。每次异动(入职/调动/离职/复职)自动生成一条不可删除的日志。
- **StaffAccount账号信息**:员工的多平台登录账号体系,包括 Fonrey 主账号 / 58安居客 / 中国网络经纪人等。
### 关键业务规则
1. **组织层级约束**:店组级部门 **必须** 挂在门店下;经纪人/店管的所属部门 **只能** 是门店或店组。
2. **经纪人定义**:职务类别为「置业顾问」的员工即为经纪人,受业务规则特殊约束。
3. **人员异动强制日志**:入职、调动、离职、复职等操作均自动生成 `staff_transfer_logs` 记录,不可删除。
4. **账号与员工联动**:员工离职后,对应的 `auth_user.is_active` 设为 `False`,不可登录;复职后由管理员手动恢复。
5. **手机号敏感字段**:员工手机号 AES-256-GCM 加密存储SHA-256 哈希用于唯一性校验,通讯录展示脱敏格式。
6. **数据归属继承**:员工调动时,名下房源/客源默认跟随员工到新部门;离职时可选择转移给指定账号。
---
## 二、实体关系
```
OrgUnit (树形自引用,物化路径)
├── 1:N ── Staff (员工归属一个部门)
│ │
│ ├── 1:1 ── auth_user (Django 登录账号)
│ ├── 1:N ── StaffTransferLog (人事异动记录)
│ ├── 1:N ── StaffRewardPunish (奖惩记录)
│ ├── 1:N ── StaffAccount (第三方账号绑定)
│ └── 1:N ── StaffRemark (管理员备注)
└── 1:1 ── OrgUnit.parent_id (自引用)
```
---
## 三、Schema 定义
### 3.1 org_units — 组织节点表
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| name | VARCHAR(100) | NOT NULL | 部门/组织名称 |
| type | VARCHAR(20) | NOT NULL, CHECK | 枚举:`company` / `division`(事业部) / `region`(大区) / `area`(区域) / `district`(片区) / `store`(门店) / `group`(店组) / `functional`(职能) |
| parent_id | UUID | FK→self, RESTRICT | 父节点,根节点为 NULL |
| path | TEXT | NOT NULL | 物化路径:`/root_id/.../self_id/`,用于子树查询 |
| depth | SMALLINT | NOT NULL DEFAULT 0 | 节点深度(根=0最大支持 8 层 |
| sort_order | INTEGER | NOT NULL DEFAULT 0 | 同级排序 |
| attribute | VARCHAR(10) | | 直营/加盟,枚举:`direct` / `franchise` |
| address_city | VARCHAR(50) | | 部门所在城市 |
| address_district | VARCHAR(50) | | 部门所在县区 |
| address_detail | VARCHAR(200) | | 详细地址 |
| latitude | NUMERIC(10,7) | | 坐标(部门定位针) |
| longitude | NUMERIC(10,7) | | 坐标 |
| manager_id | UUID | FK→staff.id, SET NULL | 部门负责人循环依赖Application 层维护) |
| established_at | DATE | | 成立时间 |
| phone | VARCHAR(30) | | 部门联系电话 |
| ext_start | INTEGER | | 分机号范围:起始 |
| ext_end | INTEGER | | 分机号范围:结束 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE = 已关闭部门,仍可在筛选中显示 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | 软删除 |
**关键索引**
```sql
CREATE INDEX idx_org_units_parent ON org_units(parent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_org_units_path_prefix ON org_units(path text_pattern_ops); -- 路径前缀查询
CREATE INDEX idx_org_units_type ON org_units(type) WHERE deleted_at IS NULL AND is_active = TRUE;
```
**业务注意**
- 查询某部门及所有下级:`WHERE path LIKE '/root_id/{target_id}/%'`
- 店组(`group`)的 `parent_id` 必须指向一个 `store` 节点,新增前需校验
---
### 3.2 staff — 员工表
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| org_unit_id | UUID | NOT NULL, FK→org_units | 当前所属组织节点(门店或店组) |
| user_id | INTEGER | UNIQUE, FK→auth_user | Django auth 登录账号 ID |
| name | VARCHAR(50) | NOT NULL | 真实姓名 |
| nickname | VARCHAR(50) | | 昵称(通讯录/显示名) |
| employee_no | VARCHAR(30) | UNIQUE | 员工工号,系统自动生成或手动录入 |
| role | VARCHAR(30) | NOT NULL, CHECK | 系统角色枚举:`agent`(经纪人) / `store_manager` / `area_manager` / `admin` / `operator` / `system` |
| job_title | VARCHAR(100) | | 职务名称,如「高级业务员」 |
| job_category | VARCHAR(50) | | 职务类别,如「置业顾问」(经纪人判定字段) |
| job_level | SMALLINT | | 职级(数字) |
| supervisor_id | UUID | FK→staff.id, SET NULL | 直属上级 |
| status | VARCHAR(20) | NOT NULL DEFAULT 'active' | `active`(在职) / `probation`(试用) / `resigned`(离职) / `frozen`(冻结) |
| phone_enc | BYTEA | | AES-256-GCM 加密手机号 |
| phone_hash | VARCHAR(64) | | SHA-256 哈希,用于唯一性索引 |
| phone_hide | BOOLEAN | NOT NULL DEFAULT FALSE | 通讯录是否隐藏手机号 |
| email | VARCHAR(255) | | 邮箱 |
| extension | VARCHAR(20) | | 分机号 |
| avatar_key | TEXT | | R2/S3 头像路径 |
| is_active | BOOLEAN | NOT NULL DEFAULT TRUE | FALSE 时账号不可登录(联动 auth_user.is_active |
| is_system_admin | BOOLEAN | NOT NULL DEFAULT FALSE | 是否为系统管理员(影响权限上限) |
| first_joined_at | DATE | | 首次入职日期(计算工龄起点) |
| rejoined_at | DATE | | 最近复职日期 |
| resigned_at | DATE | | 最近离职日期 |
| joined_count | SMALLINT | NOT NULL DEFAULT 1 | 累计入职次数 |
| industry_exp_years | SMALLINT | | 行业经验(年) |
| mentor_id | UUID | FK→staff.id, SET NULL | 师傅(带教员工) |
| business_type | VARCHAR(50) | | 业务类型 |
| bank_name | VARCHAR(100) | | 银行名称 |
| bank_account | VARCHAR(50) | | 银行卡号(内部财务用) |
| partner_no | VARCHAR(50) | | 联号 |
| recruit_by_id | UUID | FK→staff.id, SET NULL | 招聘人 |
| recruit_source | VARCHAR(50) | | 招聘来源 |
| referrer_id | UUID | FK→staff.id, SET NULL | 转介人 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | 软删除(离职员工仍保留记录) |
**关键索引**
```sql
CREATE UNIQUE INDEX idx_staff_employee_no ON staff(employee_no) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX idx_staff_phone_hash ON staff(phone_hash) WHERE deleted_at IS NULL;
CREATE INDEX idx_staff_org_unit ON staff(org_unit_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_staff_supervisor ON staff(supervisor_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_staff_status ON staff(status) WHERE deleted_at IS NULL;
```
**业务注意**
- `is_active = FALSE` 时对应 `auth_user.is_active` 同步设为 False通过 Django signal 实现
- 离职员工(`status = 'resigned'`)不可硬删除,保留档案以便房源/客源历史关联查询
- 经纪人判定:`job_category = '置业顾问'`,部分权限逻辑基于此字段
---
### 3.3 staff_personal_info — 员工个人信息扩展表
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| staff_id | UUID | UNIQUE, NOT NULL, FK→staff | 1:1 关系 |
| gender | VARCHAR(10) | | `male` / `female` / `unknown` |
| id_type | VARCHAR(20) | | 证件类型:`id_card`(身份证) / `passport` / `other` |
| id_number_enc | BYTEA | | 证件号码AES 加密) |
| id_number_hash | VARCHAR(64) | | SHA-256 哈希(实名认证比对用) |
| id_verified | BOOLEAN | NOT NULL DEFAULT FALSE | 是否实名认证通过 |
| id_verified_at | TIMESTAMPTZ | | 认证时间 |
| birthdate | DATE | | 出生日期 |
| native_place | VARCHAR(100) | | 籍贯 |
| domicile_type | VARCHAR(20) | | 户籍性质 |
| marital_status | VARCHAR(20) | | 婚姻状况 |
| political_status | VARCHAR(20) | | 政治面貌 |
| has_children | BOOLEAN | | 有无子女 |
| education_level | VARCHAR(20) | | 最高学历 |
| ethnicity | VARCHAR(20) | | 民族 |
| domicile_address | VARCHAR(200) | | 户口所在地 |
| residence_address | VARCHAR(200) | | 居住地址 |
| work_start_date | DATE | | 参加工作时间 |
| emergency_contact | VARCHAR(50) | | 紧急联系人 |
| emergency_phone_enc | BYTEA | | 紧急联系人电话(加密) |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| updated_by | UUID | FK→staff.id, SET NULL | |
---
### 3.4 staff_transfer_logs — 人事异动记录
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| staff_id | UUID | NOT NULL, FK→staff, RESTRICT | 被操作员工 |
| transfer_type | VARCHAR(30) | NOT NULL, CHECK | 枚举见下方 |
| old_value | JSONB | | 变动前的值快照,格式:`{"field": "org_unit_id", "value": "...", "label": "门店A"}` |
| new_value | JSONB | | 变动后的值快照 |
| transfer_date | DATE | NOT NULL | 异动生效日期(可以是过去日期) |
| remarks | VARCHAR(50) | | 备注最多50字 |
| operator_id | UUID | NOT NULL, FK→staff, RESTRICT | 操作人 |
| operated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 系统操作时间 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| ⚠️ 无 deleted_at | — | — | 异动记录**不可删除** |
**transfer_type 枚举**
```
onboard = 入职
transfer = 调动(含平调/晋升/降职)
resign = 离职
rejoin = 复职
supervisor_change = 上级变动
role_change = 角色变更
freeze = 账号冻结
unfreeze = 账号恢复
```
**关键索引**
```sql
CREATE INDEX idx_transfer_logs_staff ON staff_transfer_logs(staff_id, transfer_date DESC);
CREATE INDEX idx_transfer_logs_type ON staff_transfer_logs(transfer_type, operated_at DESC);
CREATE INDEX idx_transfer_logs_operator ON staff_transfer_logs(operator_id);
```
---
### 3.5 staff_reward_punish — 奖惩记录
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| staff_id | UUID | NOT NULL, FK→staff | |
| rp_date | DATE | NOT NULL | 奖惩日期 |
| category | VARCHAR(50) | NOT NULL | 奖惩类别(枚举由 lookup 表维护) |
| name | VARCHAR(100) | NOT NULL | 奖惩名称(与类别联动) |
| remarks | TEXT | | 备注 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| created_by | UUID | FK→staff.id, SET NULL | |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| deleted_at | TIMESTAMPTZ | | 软删除 |
---
### 3.6 staff_work_experiences / staff_educations / staff_trainings / staff_family_members
这四张表结构类似,均为 1:N 附属于 `staff`,存储员工档案中「工作经历」「教育经历」「培训经历」「家庭主要成员」信息。详见下方汇总:
| 表名 | 关键字段 |
|------|---------|
| `staff_work_experiences` | staff_id, company, job_title, start_date, end_date, reason, reference_name, reference_phone |
| `staff_educations` | staff_id, stage, school, major, start_date, end_date, enrollment_status, degree |
| `staff_trainings` | staff_id, training_name, training_date, certificate |
| `staff_family_members` | staff_id, relation(称谓), name, birthdate, occupation, work_unit, phone_enc |
---
### 3.7 staff_accounts — 员工第三方账号绑定
| 字段 | 类型 | 约束 | 业务说明 |
|------|------|------|----------|
| id | UUID | PK | |
| staff_id | UUID | NOT NULL, FK→staff | |
| platform | VARCHAR(30) | NOT NULL, CHECK | `fonrey`(主账号) / `58anjuke` / `cnreic`(中国网络经纪人) / `wechat_mp`(微信公众号) |
| account_no | VARCHAR(100) | | 账号/手机号 |
| is_real_name_match | BOOLEAN | | 实名信息一致性(中国网络经纪人专用) |
| is_bound | BOOLEAN | NOT NULL DEFAULT FALSE | 是否已绑定 |
| bound_at | TIMESTAMPTZ | | 绑定时间 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
---
## 四、枚举常量
### Staff.role系统角色
| 值 | 含义 | 数据可见范围默认 |
|----|------|----------------|
| `agent` | 一线经纪人 | 本人/本组 |
| `store_manager` | 店长 | 本门店 |
| `area_manager` | 区域经理 | 本区域 |
| `admin` | 系统管理员 | 全公司 |
| `operator` | 运营/行政 | 全公司(只读为主) |
| `system` | 系统账号(定时任务用) | — |
### Staff.status员工状态
```
active = 正式在职
probation = 试用期
resigned = 已离职(不可删除,保留档案)
frozen = 账号冻结(在职但无法登录)
```
### OrgUnit.type组织类型
```
company = 公司根节点(每个租户唯一)
division = 事业部
region = 大区
area = 区域
district = 片区
store = 门店(经纪人最小归属单位)
group = 店组(门店下的业务小组)
functional = 职能部门(行政/财务等)
```
---
## 五、查询模式
### 5.1 查询某部门及所有下级的在职员工
```sql
-- 利用物化路径高效查询子树
SELECT s.*
FROM staff s
JOIN org_units ou ON s.org_unit_id = ou.id
WHERE ou.path LIKE '/root_id/{target_org_unit_id}/%'
OR ou.id = '{target_org_unit_id}'
AND s.deleted_at IS NULL
AND s.status != 'resigned';
```
### 5.2 查询员工完整异动历史
```sql
SELECT stl.*,
s.name as operator_name,
ou.name as operator_org
FROM staff_transfer_logs stl
JOIN staff s ON stl.operator_id = s.id
JOIN org_units ou ON s.org_unit_id = ou.id
WHERE stl.staff_id = :staff_id
ORDER BY stl.transfer_date DESC, stl.operated_at DESC;
```
### 5.3 获取员工的直接上下级链
```sql
-- 直属上级
SELECT supervisor.* FROM staff
JOIN staff supervisor ON staff.supervisor_id = supervisor.id
WHERE staff.id = :staff_id AND supervisor.deleted_at IS NULL;
```
---
## 六、禁止操作
-**严禁硬删除 staff 记录**:离职员工需通过 `deleted_at + status = 'resigned'` 软删除,历史房源/跟进日志依赖 `staff.id` 外键
-**严禁删除 staff_transfer_logs**:异动记录为不可变审计日志
-**严禁直接修改 staff.user_id**:账号绑定关系变更需走专门的账号管理流程
-**严禁绕过组织层级约束**:店组不在门店下的数据操作需在 Application 层校验并拒绝
-**严禁明文存储员工手机号和证件号**:必须走 `EncryptedPhoneField` / `EncryptedIDField`

View File

@@ -0,0 +1,574 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2560 1980">
<defs>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&amp;display=swap');
text { font-family: 'JetBrains Mono', 'Noto Sans SC', 'PingFang SC', 'SF Mono', monospace; }
</style>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1e293b" stroke-width="0.5"/>
</pattern>
<!-- Arrow markers per color -->
<marker id="arrow-cyan" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#22d3ee"/>
</marker>
<marker id="arrow-emerald" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#34d399"/>
</marker>
<marker id="arrow-violet" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#a78bfa"/>
</marker>
<marker id="arrow-amber" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#fbbf24"/>
</marker>
<marker id="arrow-slate" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
</marker>
<marker id="arrow-orange" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#fb923c"/>
</marker>
</defs>
<!-- Background -->
<rect width="2560" height="1980" fill="#0f172a"/>
<rect width="2560" height="1980" fill="url(#grid)"/>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MODULE BOUNDARIES -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- Org Module boundary (cyan) -->
<rect x="30" y="60" width="310" height="680" rx="12" fill="none" stroke="#22d3ee" stroke-width="1" stroke-dasharray="8,4" opacity="0.6"/>
<text x="44" y="80" fill="#22d3ee" font-size="10" font-weight="600">ORG / HR</text>
<!-- Region+Complex Module boundary (emerald) -->
<rect x="360" y="60" width="680" height="1200" rx="12" fill="none" stroke="#34d399" stroke-width="1" stroke-dasharray="8,4" opacity="0.6"/>
<text x="374" y="80" fill="#34d399" font-size="10" font-weight="600">REGION &amp; COMPLEX</text>
<!-- Property Module boundary (violet) -->
<rect x="1060" y="60" width="720" height="1560" rx="12" fill="none" stroke="#a78bfa" stroke-width="1" stroke-dasharray="8,4" opacity="0.6"/>
<text x="1074" y="80" fill="#a78bfa" font-size="10" font-weight="600">PROPERTY</text>
<!-- Client Module boundary (amber) -->
<rect x="1800" y="60" width="730" height="1200" rx="12" fill="none" stroke="#fbbf24" stroke-width="1" stroke-dasharray="8,4" opacity="0.6"/>
<text x="1814" y="80" fill="#fbbf24" font-size="10" font-weight="600">CLIENT</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- CONNECTION LINES (drawn before boxes) -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- OrgUnit → Staff (1:N) -->
<line x1="185" y1="220" x2="185" y2="320" stroke="#22d3ee" stroke-width="1.2" marker-end="url(#arrow-cyan)"/>
<text x="193" y="275" fill="#22d3ee" font-size="8">1:N</text>
<!-- Staff → Property (1:N, via created_by) -->
<line x1="335" y1="370" x2="1060" y2="370" stroke="#22d3ee" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-cyan)"/>
<text x="690" y="362" fill="#22d3ee" font-size="8">created_by</text>
<!-- Staff → Client (1:N, via agent) -->
<line x1="335" y1="410" x2="1800" y2="410" stroke="#22d3ee" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-cyan)"/>
<text x="1060" y="402" fill="#22d3ee" font-size="8">agent_id</text>
<!-- District → BusinessArea (1:N) -->
<line x1="560" y1="225" x2="560" y2="320" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="568" y="277" fill="#34d399" font-size="8">1:N</text>
<!-- District → School (1:N) -->
<line x1="700" y1="175" x2="870" y2="175" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="775" y="167" fill="#34d399" font-size="8">1:N</text>
<!-- BusinessArea ↔ Complex (N:M via complex_business_areas) -->
<line x1="560" y1="420" x2="560" y2="500" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="568" y="464" fill="#34d399" font-size="8">N:M</text>
<!-- Complex → Complex_schools join label -->
<line x1="700" y1="570" x2="870" y2="400" stroke="#34d399" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-emerald)"/>
<text x="780" y="495" fill="#34d399" font-size="8">N:M</text>
<!-- Complex → Building (1:N) -->
<line x1="560" y1="700" x2="560" y2="790" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="568" y="749" fill="#34d399" font-size="8">1:N</text>
<!-- Building → RoomUnit (1:N) -->
<line x1="560" y1="980" x2="560" y2="1060" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="568" y="1024" fill="#34d399" font-size="8">1:N</text>
<!-- Complex → Property (1:N) -->
<line x1="720" y1="600" x2="1060" y2="300" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="885" y="445" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → PropertyContact (1:N) -->
<line x1="1300" y1="390" x2="1300" y2="490" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1308" y="444" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → FollowLog (1:N) -->
<line x1="1420" y1="300" x2="1570" y2="300" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1482" y="292" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → PropertyPhoto (1:N) -->
<line x1="1300" y1="670" x2="1300" y2="760" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1308" y="718" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → KeyManagement (1:N) -->
<line x1="1420" y1="550" x2="1570" y2="550" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1482" y="542" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → Commission (1:N) -->
<line x1="1420" y1="650" x2="1570" y2="750" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1490" y="695" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → Inspection (1:N) -->
<line x1="1300" y1="940" x2="1300" y2="1020" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1308" y="984" fill="#a78bfa" font-size="8">1:N</text>
<!-- Property → Marketing (1:1) -->
<line x1="1420" y1="870" x2="1570" y2="960" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1490" y="910" fill="#a78bfa" font-size="8">1:1</text>
<!-- Property → ListingHistory (1:N) -->
<line x1="1180" y1="390" x2="1080" y2="490" stroke="#a78bfa" stroke-width="1.2" marker-end="url(#arrow-violet)"/>
<text x="1100" y="435" fill="#a78bfa" font-size="8">1:N</text>
<!-- Client → ClientRequirement (1:N) -->
<line x1="2050" y1="220" x2="2050" y2="310" stroke="#fbbf24" stroke-width="1.2" marker-end="url(#arrow-amber)"/>
<text x="2058" y="269" fill="#fbbf24" font-size="8">1:N</text>
<!-- Client → ClientFollowLog (1:N) -->
<line x1="1930" y1="220" x2="1830" y2="310" stroke="#fbbf24" stroke-width="1.2" marker-end="url(#arrow-amber)"/>
<text x="1850" y="260" fill="#fbbf24" font-size="8">1:N</text>
<!-- Client → Viewing (1:N) -->
<line x1="2170" y1="220" x2="2270" y2="310" stroke="#fbbf24" stroke-width="1.2" marker-end="url(#arrow-amber)"/>
<text x="2210" y="260" fill="#fbbf24" font-size="8">1:N</text>
<!-- Client → Match (1:N) -->
<line x1="2050" y1="490" x2="2050" y2="580" stroke="#fbbf24" stroke-width="1.2" marker-end="url(#arrow-amber)"/>
<text x="2058" y="538" fill="#fbbf24" font-size="8">1:N</text>
<!-- Property → Viewing (1:N) -->
<line x1="1780" y1="340" x2="2270" y2="340" stroke="#fb923c" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-orange)"/>
<text x="2020" y="332" fill="#fb923c" font-size="8">1:N</text>
<!-- Property → Match (1:N) -->
<line x1="1780" y1="620" x2="1950" y2="620" stroke="#fb923c" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-orange)"/>
<text x="1845" y="612" fill="#fb923c" font-size="8">1:N</text>
<!-- MetroStation → Complex (N:M) -->
<line x1="620" y1="1280" x2="620" y2="1200" stroke="#34d399" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-emerald)"/>
<text x="628" y="1244" fill="#34d399" font-size="8">N:M</text>
<!-- MetroLine → MetroStation (1:N) -->
<line x1="430" y1="1280" x2="500" y2="1280" stroke="#34d399" stroke-width="1.2" marker-end="url(#arrow-emerald)"/>
<text x="452" y="1272" fill="#34d399" font-size="8">1:N</text>
<!-- ComplexPriceTrend → Complex -->
<line x1="870" y1="900" x2="720" y2="660" stroke="#34d399" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-emerald)"/>
<text x="790" y="800" fill="#34d399" font-size="8">1:N</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ORG MODULE -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- OrgUnit -->
<rect x="80" y="100" width="210" height="120" rx="6" fill="#0f172a"/>
<rect x="80" y="100" width="210" height="120" rx="6" fill="rgba(8,51,68,0.4)" stroke="#22d3ee" stroke-width="1.5"/>
<line x1="80" y1="128" x2="290" y2="128" stroke="#22d3ee" stroke-width="0.5" opacity="0.6"/>
<text x="185" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">org_units</text>
<text x="90" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="90" y="158" fill="#94a3b8" font-size="8">parent_id: uuid (FK→self)</text>
<text x="90" y="171" fill="#94a3b8" font-size="8">type: varchar(20)</text>
<text x="90" y="184" fill="#94a3b8" font-size="8">name, path, depth</text>
<text x="90" y="197" fill="#94a3b8" font-size="8">is_active: bool</text>
<!-- Staff -->
<rect x="80" y="320" width="210" height="150" rx="6" fill="#0f172a"/>
<rect x="80" y="320" width="210" height="150" rx="6" fill="rgba(8,51,68,0.4)" stroke="#22d3ee" stroke-width="1.5"/>
<line x1="80" y1="348" x2="290" y2="348" stroke="#22d3ee" stroke-width="0.5" opacity="0.6"/>
<text x="185" y="340" fill="white" font-size="11" font-weight="700" text-anchor="middle">staff</text>
<text x="90" y="365" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="90" y="378" fill="#94a3b8" font-size="8">FK org_unit_id</text>
<text x="90" y="391" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="90" y="404" fill="#94a3b8" font-size="8">phone_enc: text (AES)</text>
<text x="90" y="417" fill="#94a3b8" font-size="8">phone_hash: varchar(64)</text>
<text x="90" y="430" fill="#94a3b8" font-size="8">user_id: uuid (FK→auth)</text>
<text x="90" y="443" fill="#94a3b8" font-size="8">is_active, deleted_at</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- REGION + COMPLEX MODULE -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- District -->
<rect x="440" y="100" width="220" height="125" rx="6" fill="#0f172a"/>
<rect x="440" y="100" width="220" height="125" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="440" y1="128" x2="660" y2="128" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="550" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">districts</text>
<text x="450" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="450" y="158" fill="#94a3b8" font-size="8">city: varchar(50)</text>
<text x="450" y="171" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="450" y="184" fill="#94a3b8" font-size="8">short_name: varchar(20)</text>
<text x="450" y="197" fill="#94a3b8" font-size="8">sort_order, is_active</text>
<!-- BusinessArea -->
<rect x="440" y="320" width="240" height="135" rx="6" fill="#0f172a"/>
<rect x="440" y="320" width="240" height="135" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="440" y1="348" x2="680" y2="348" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="560" y="340" fill="white" font-size="11" font-weight="700" text-anchor="middle">business_areas</text>
<text x="450" y="365" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="450" y="378" fill="#94a3b8" font-size="8">FK district_id</text>
<text x="450" y="391" fill="#94a3b8" font-size="8">name: varchar(100)</text>
<text x="450" y="404" fill="#94a3b8" font-size="8">latitude, longitude</text>
<text x="450" y="417" fill="#94a3b8" font-size="8">sort_order, is_active</text>
<!-- School -->
<rect x="700" y="100" width="220" height="135" rx="6" fill="#0f172a"/>
<rect x="700" y="100" width="220" height="135" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="700" y1="128" x2="920" y2="128" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="810" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">schools</text>
<text x="710" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="710" y="158" fill="#94a3b8" font-size="8">FK district_id</text>
<text x="710" y="171" fill="#94a3b8" font-size="8">name: varchar(100)</text>
<text x="710" y="184" fill="#94a3b8" font-size="8">type: primary/middle/high</text>
<text x="710" y="197" fill="#94a3b8" font-size="8">nature: public/private</text>
<text x="710" y="210" fill="#94a3b8" font-size="8">level: normal/key/top</text>
<!-- Complex -->
<rect x="440" y="500" width="300" height="200" rx="6" fill="#0f172a"/>
<rect x="440" y="500" width="300" height="200" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="440" y1="528" x2="740" y2="528" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="590" y="520" fill="white" font-size="11" font-weight="700" text-anchor="middle">complexes</text>
<text x="450" y="545" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="450" y="558" fill="#94a3b8" font-size="8">name: varchar(200) [不可直接修改]</text>
<text x="450" y="571" fill="#94a3b8" font-size="8">FK district_id</text>
<text x="450" y="584" fill="#94a3b8" font-size="8">address, address_summary</text>
<text x="450" y="597" fill="#94a3b8" font-size="8">latitude, longitude</text>
<text x="450" y="610" fill="#94a3b8" font-size="8">lock_building/room/info: bool</text>
<text x="450" y="623" fill="#94a3b8" font-size="8">property_usage_types: varchar[]</text>
<text x="450" y="636" fill="#94a3b8" font-size="8">search_vector: tsvector</text>
<text x="450" y="649" fill="#94a3b8" font-size="8">developer, property_company</text>
<text x="450" y="662" fill="#94a3b8" font-size="8">deleted_at, created_by</text>
<text x="450" y="675" fill="#94a3b8" font-size="8">...</text>
<!-- complex_business_areas join table (small) -->
<rect x="440" y="465" width="220" height="30" rx="4" fill="#0f172a"/>
<rect x="440" y="465" width="220" height="30" rx="4" fill="rgba(6,78,59,0.2)" stroke="#34d399" stroke-width="1" stroke-dasharray="3,2"/>
<text x="550" y="484" fill="#34d399" font-size="8" text-anchor="middle">complex_business_areas (N:M) · is_primary</text>
<!-- complex_schools join table (small) -->
<rect x="700" y="310" width="210" height="30" rx="4" fill="#0f172a"/>
<rect x="700" y="310" width="210" height="30" rx="4" fill="rgba(6,78,59,0.2)" stroke="#34d399" stroke-width="1" stroke-dasharray="3,2"/>
<text x="805" y="329" fill="#34d399" font-size="8" text-anchor="middle">complex_schools · zone_type</text>
<!-- Building -->
<rect x="440" y="790" width="300" height="185" rx="6" fill="#0f172a"/>
<rect x="440" y="790" width="300" height="185" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="440" y1="818" x2="740" y2="818" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="590" y="810" fill="white" font-size="11" font-weight="700" text-anchor="middle">buildings</text>
<text x="450" y="835" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="450" y="848" fill="#94a3b8" font-size="8">FK complex_id</text>
<text x="450" y="861" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="450" y="874" fill="#94a3b8" font-size="8">is_standard: bool</text>
<text x="450" y="887" fill="#94a3b8" font-size="8">total_floors: smallint</text>
<text x="450" y="900" fill="#94a3b8" font-size="8">has_elevator: bool</text>
<text x="450" y="913" fill="#94a3b8" font-size="8">built_year: smallint</text>
<text x="450" y="926" fill="#94a3b8" font-size="8">property_usage_type</text>
<text x="450" y="939" fill="#94a3b8" font-size="8">is_active, created_at</text>
<text x="450" y="952" fill="#94a3b8" font-size="8">FK school_id</text>
<!-- RoomUnit -->
<rect x="440" y="1060" width="300" height="155" rx="6" fill="#0f172a"/>
<rect x="440" y="1060" width="300" height="155" rx="6" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<line x1="440" y1="1088" x2="740" y2="1088" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="590" y="1080" fill="white" font-size="11" font-weight="700" text-anchor="middle">room_units</text>
<text x="450" y="1105" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="450" y="1118" fill="#94a3b8" font-size="8">FK building_id</text>
<text x="450" y="1131" fill="#94a3b8" font-size="8">floor: smallint</text>
<text x="450" y="1144" fill="#94a3b8" font-size="8">floor_name: varchar(20)</text>
<text x="450" y="1157" fill="#94a3b8" font-size="8">room_no: varchar(30)</text>
<text x="450" y="1170" fill="#94a3b8" font-size="8">display_no: varchar(50)</text>
<text x="450" y="1183" fill="#94a3b8" font-size="8">is_standard: bool</text>
<text x="450" y="1196" fill="#94a3b8" font-size="8">is_active</text>
<!-- ComplexPriceTrend -->
<rect x="770" y="800" width="250" height="140" rx="6" fill="#0f172a"/>
<rect x="770" y="800" width="250" height="140" rx="6" fill="rgba(6,78,59,0.3)" stroke="#34d399" stroke-width="1.5"/>
<line x1="770" y1="828" x2="1020" y2="828" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="895" y="820" fill="white" font-size="11" font-weight="700" text-anchor="middle">complex_price_trends</text>
<text x="780" y="845" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="780" y="858" fill="#94a3b8" font-size="8">FK complex_id</text>
<text x="780" y="871" fill="#94a3b8" font-size="8">record_month: date</text>
<text x="780" y="884" fill="#94a3b8" font-size="8">avg_unit_price: numeric(10,2)</text>
<text x="780" y="897" fill="#94a3b8" font-size="8">avg_sale_price: numeric(12,2)</text>
<text x="780" y="910" fill="#94a3b8" font-size="8">transaction_count: int</text>
<text x="780" y="923" fill="#94a3b8" font-size="8">listing_count: int</text>
<!-- MetroLine -->
<rect x="370" y="1270" width="200" height="105" rx="6" fill="#0f172a"/>
<rect x="370" y="1270" width="200" height="105" rx="6" fill="rgba(6,78,59,0.3)" stroke="#34d399" stroke-width="1.5"/>
<line x1="370" y1="1298" x2="570" y2="1298" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="470" y="1290" fill="white" font-size="11" font-weight="700" text-anchor="middle">metro_lines</text>
<text x="380" y="1315" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="380" y="1328" fill="#94a3b8" font-size="8">city: varchar(50)</text>
<text x="380" y="1341" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="380" y="1354" fill="#94a3b8" font-size="8">color: varchar(7) [HEX]</text>
<!-- MetroStation -->
<rect x="580" y="1270" width="240" height="120" rx="6" fill="#0f172a"/>
<rect x="580" y="1270" width="240" height="120" rx="6" fill="rgba(6,78,59,0.3)" stroke="#34d399" stroke-width="1.5"/>
<line x1="580" y1="1298" x2="820" y2="1298" stroke="#34d399" stroke-width="0.5" opacity="0.6"/>
<text x="700" y="1290" fill="white" font-size="11" font-weight="700" text-anchor="middle">metro_stations</text>
<text x="590" y="1315" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="590" y="1328" fill="#94a3b8" font-size="8">FK metro_line_id</text>
<text x="590" y="1341" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="590" y="1354" fill="#94a3b8" font-size="8">latitude, longitude</text>
<text x="590" y="1367" fill="#94a3b8" font-size="8">sort_order</text>
<!-- complex_metro_stations join (small) -->
<rect x="580" y="1200" width="240" height="28" rx="4" fill="#0f172a"/>
<rect x="580" y="1200" width="240" height="28" rx="4" fill="rgba(6,78,59,0.2)" stroke="#34d399" stroke-width="1" stroke-dasharray="3,2"/>
<text x="700" y="1218" fill="#34d399" font-size="8" text-anchor="middle">complex_metro_stations · distance_meters</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- PROPERTY MODULE -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- Property -->
<rect x="1080" y="100" width="340" height="290" rx="6" fill="#0f172a"/>
<rect x="1080" y="100" width="340" height="290" rx="6" fill="rgba(76,29,149,0.4)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1080" y1="128" x2="1420" y2="128" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1250" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">properties</text>
<text x="1090" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1090" y="158" fill="#94a3b8" font-size="8">FK complex_id FK building_id</text>
<text x="1090" y="171" fill="#94a3b8" font-size="8">FK room_unit_id FK agent_id</text>
<text x="1090" y="184" fill="#94a3b8" font-size="8">listing_type: sale/rent/both</text>
<text x="1090" y="197" fill="#94a3b8" font-size="8">status: varchar(20)</text>
<text x="1090" y="210" fill="#94a3b8" font-size="8">sale_price: numeric(12,2)</text>
<text x="1090" y="223" fill="#94a3b8" font-size="8">rent_price: numeric(10,2)</text>
<text x="1090" y="236" fill="#94a3b8" font-size="8">floor, total_floors</text>
<text x="1090" y="249" fill="#94a3b8" font-size="8">area: numeric(8,2) [m²]</text>
<text x="1090" y="262" fill="#94a3b8" font-size="8">bedroom, living, bathroom</text>
<text x="1090" y="275" fill="#94a3b8" font-size="8">orientation, decoration</text>
<text x="1090" y="288" fill="#94a3b8" font-size="8">search_vector: tsvector</text>
<text x="1090" y="301" fill="#94a3b8" font-size="8">is_exclusive: bool</text>
<text x="1090" y="314" fill="#94a3b8" font-size="8">completeness_score: int</text>
<text x="1090" y="327" fill="#94a3b8" font-size="8">deleted_at, created_by</text>
<text x="1090" y="340" fill="#94a3b8" font-size="8">...</text>
<text x="1090" y="353" fill="#94a3b8" font-size="7">[89,000+ rows · partitioned by status]</text>
<text x="1090" y="370" fill="#94a3b8" font-size="7">UNIQUE (complex_id, building_id, floor, room_no)</text>
<!-- PropertyContact -->
<rect x="1080" y="490" width="280" height="155" rx="6" fill="#0f172a"/>
<rect x="1080" y="490" width="280" height="155" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1080" y1="518" x2="1360" y2="518" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1220" y="510" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_contacts</text>
<text x="1090" y="535" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1090" y="548" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="1090" y="561" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="1090" y="574" fill="#94a3b8" font-size="8">phone_enc: text (AES)</text>
<text x="1090" y="587" fill="#94a3b8" font-size="8">phone_hash: varchar(64)</text>
<text x="1090" y="600" fill="#94a3b8" font-size="8">role: owner/agent/tenant</text>
<text x="1090" y="613" fill="#94a3b8" font-size="8">is_primary: bool</text>
<!-- PropertyPhoto -->
<rect x="1080" y="760" width="280" height="155" rx="6" fill="#0f172a"/>
<rect x="1080" y="760" width="280" height="155" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1080" y1="788" x2="1360" y2="788" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1220" y="780" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_photos</text>
<text x="1090" y="805" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1090" y="818" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="1090" y="831" fill="#94a3b8" font-size="8">category: listing/vr/layout</text>
<text x="1090" y="844" fill="#94a3b8" font-size="8">file_key: text (R2/S3)</text>
<text x="1090" y="857" fill="#94a3b8" font-size="8">is_cover: bool</text>
<text x="1090" y="870" fill="#94a3b8" font-size="8">sort_order: smallint</text>
<text x="1090" y="883" fill="#94a3b8" font-size="8">width, height, file_size</text>
<!-- Inspection -->
<rect x="1080" y="1020" width="280" height="135" rx="6" fill="#0f172a"/>
<rect x="1080" y="1020" width="280" height="135" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1080" y1="1048" x2="1360" y2="1048" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1220" y="1040" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_inspections</text>
<text x="1090" y="1065" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1090" y="1078" fill="#94a3b8" font-size="8">FK property_id FK staff_id</text>
<text x="1090" y="1091" fill="#94a3b8" font-size="8">inspected_at: timestamptz</text>
<text x="1090" y="1104" fill="#94a3b8" font-size="8">status: pending/done</text>
<text x="1090" y="1117" fill="#94a3b8" font-size="8">notes: text</text>
<text x="1090" y="1130" fill="#94a3b8" font-size="8">attachments: jsonb</text>
<!-- FollowLog (property) -->
<rect x="1440" y="100" width="300" height="155" rx="6" fill="#0f172a"/>
<rect x="1440" y="100" width="300" height="155" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1440" y1="128" x2="1740" y2="128" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1590" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_follow_logs</text>
<text x="1450" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1450" y="158" fill="#94a3b8" font-size="8">FK property_id FK staff_id</text>
<text x="1450" y="171" fill="#94a3b8" font-size="8">log_type: call/visit/note...</text>
<text x="1450" y="184" fill="#94a3b8" font-size="8">content: text</text>
<text x="1450" y="197" fill="#94a3b8" font-size="8">sensitive_view: bool [不可删]</text>
<text x="1450" y="210" fill="#94a3b8" font-size="8">created_at, created_by</text>
<text x="1450" y="237" fill="#fb7185" font-size="7">⚠ NO DELETE (audit log)</text>
<!-- KeyManagement -->
<rect x="1440" y="490" width="300" height="120" rx="6" fill="#0f172a"/>
<rect x="1440" y="490" width="300" height="120" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1440" y1="518" x2="1740" y2="518" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1590" y="510" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_keys</text>
<text x="1450" y="535" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1450" y="548" fill="#94a3b8" font-size="8">FK property_id FK holder_id</text>
<text x="1450" y="561" fill="#94a3b8" font-size="8">key_no: varchar(50)</text>
<text x="1450" y="574" fill="#94a3b8" font-size="8">status: held/returned</text>
<text x="1450" y="587" fill="#94a3b8" font-size="8">taken_at, returned_at</text>
<!-- Commission -->
<rect x="1440" y="720" width="300" height="135" rx="6" fill="#0f172a"/>
<rect x="1440" y="720" width="300" height="135" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1440" y1="748" x2="1740" y2="748" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1590" y="740" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_commissions</text>
<text x="1450" y="765" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1450" y="778" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="1450" y="791" fill="#94a3b8" font-size="8">commission_type: exclusive/open</text>
<text x="1450" y="804" fill="#94a3b8" font-size="8">rate: numeric(5,4)</text>
<text x="1450" y="817" fill="#94a3b8" font-size="8">start_date, end_date</text>
<text x="1450" y="830" fill="#94a3b8" font-size="8">signed_at, document_key</text>
<!-- Marketing -->
<rect x="1440" y="940" width="300" height="120" rx="6" fill="#0f172a"/>
<rect x="1440" y="940" width="300" height="120" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1440" y1="968" x2="1740" y2="968" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1590" y="960" fill="white" font-size="11" font-weight="700" text-anchor="middle">property_marketing</text>
<text x="1450" y="985" fill="#fbbf24" font-size="8">PK id: uuid [1:1 property]</text>
<text x="1450" y="998" fill="#94a3b8" font-size="8">FK property_id (UNIQUE)</text>
<text x="1450" y="1011" fill="#94a3b8" font-size="8">title: varchar(200)</text>
<text x="1450" y="1024" fill="#94a3b8" font-size="8">highlights: text[]</text>
<text x="1450" y="1037" fill="#94a3b8" font-size="8">published_at, platforms: jsonb</text>
<!-- ListingHistory -->
<rect x="1080" y="1250" width="300" height="120" rx="6" fill="#0f172a"/>
<rect x="1080" y="1250" width="300" height="120" rx="6" fill="rgba(76,29,149,0.35)" stroke="#a78bfa" stroke-width="1.5"/>
<line x1="1080" y1="1278" x2="1380" y2="1278" stroke="#a78bfa" stroke-width="0.5" opacity="0.6"/>
<text x="1230" y="1270" fill="white" font-size="11" font-weight="700" text-anchor="middle">listing_histories</text>
<text x="1090" y="1295" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1090" y="1308" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="1090" y="1321" fill="#94a3b8" font-size="8">listed_at, delisted_at</text>
<text x="1090" y="1334" fill="#94a3b8" font-size="8">list_price: numeric(12,2)</text>
<text x="1090" y="1347" fill="#94a3b8" font-size="8">reason: varchar(50)</text>
<!-- Arrow from Property to ListingHistory -->
<line x1="1230" y1="390" x2="1230" y2="1250" stroke="#a78bfa" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-violet)"/>
<text x="1238" y="820" fill="#a78bfa" font-size="8">1:N</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- CLIENT MODULE -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- Client -->
<rect x="1870" y="100" width="320" height="250" rx="6" fill="#0f172a"/>
<rect x="1870" y="100" width="320" height="250" rx="6" fill="rgba(120,53,15,0.4)" stroke="#fbbf24" stroke-width="1.5"/>
<line x1="1870" y1="128" x2="2190" y2="128" stroke="#fbbf24" stroke-width="0.5" opacity="0.6"/>
<text x="2030" y="120" fill="white" font-size="11" font-weight="700" text-anchor="middle">clients</text>
<text x="1880" y="145" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1880" y="158" fill="#94a3b8" font-size="8">FK agent_id (staff)</text>
<text x="1880" y="171" fill="#94a3b8" font-size="8">client_type: private/public/closed</text>
<text x="1880" y="184" fill="#94a3b8" font-size="8">status: active/inactive/converted</text>
<text x="1880" y="197" fill="#94a3b8" font-size="8">name: varchar(50)</text>
<text x="1880" y="210" fill="#94a3b8" font-size="8">phone_enc: text (AES)</text>
<text x="1880" y="223" fill="#94a3b8" font-size="8">phone_hash: varchar(64)</text>
<text x="1880" y="236" fill="#94a3b8" font-size="8">activity_level: 1-5 (Celery daily)</text>
<text x="1880" y="249" fill="#94a3b8" font-size="8">is_protected: bool [防止转公客]</text>
<text x="1880" y="262" fill="#94a3b8" font-size="8">transfer_to_public_type: auto/manual</text>
<text x="1880" y="275" fill="#94a3b8" font-size="8">source: varchar(30)</text>
<text x="1880" y="288" fill="#94a3b8" font-size="8">deleted_at, created_by</text>
<text x="1880" y="301" fill="#94a3b8" font-size="8">...</text>
<text x="1880" y="325" fill="#94a3b8" font-size="7">[私客/公客/成交客 三态状态机]</text>
<!-- ClientRequirement -->
<rect x="1960" y="490" width="280" height="155" rx="6" fill="#0f172a"/>
<rect x="1960" y="490" width="280" height="155" rx="6" fill="rgba(120,53,15,0.35)" stroke="#fbbf24" stroke-width="1.5"/>
<line x1="1960" y1="518" x2="2240" y2="518" stroke="#fbbf24" stroke-width="0.5" opacity="0.6"/>
<text x="2100" y="510" fill="white" font-size="11" font-weight="700" text-anchor="middle">client_requirements</text>
<text x="1970" y="535" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1970" y="548" fill="#94a3b8" font-size="8">FK client_id</text>
<text x="1970" y="561" fill="#94a3b8" font-size="8">req_type: second_hand/new/rent</text>
<text x="1970" y="574" fill="#94a3b8" font-size="8">district_ids: uuid[]</text>
<text x="1970" y="587" fill="#94a3b8" font-size="8">price_min/max: numeric</text>
<text x="1970" y="600" fill="#94a3b8" font-size="8">area_min/max, bedrooms</text>
<text x="1970" y="613" fill="#94a3b8" font-size="8">school_ids: uuid[]</text>
<!-- ClientFollowLog -->
<rect x="1820" y="490" width="130" height="155" rx="6" fill="#0f172a"/>
<rect x="1820" y="490" width="130" height="155" rx="6" fill="rgba(120,53,15,0.35)" stroke="#fbbf24" stroke-width="1.5"/>
<line x1="1820" y1="518" x2="1950" y2="518" stroke="#fbbf24" stroke-width="0.5" opacity="0.6"/>
<text x="1885" y="510" fill="white" font-size="10" font-weight="700" text-anchor="middle">client_follow_logs</text>
<text x="1828" y="535" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1828" y="548" fill="#94a3b8" font-size="8">FK client_id</text>
<text x="1828" y="561" fill="#94a3b8" font-size="8">log_type</text>
<text x="1828" y="574" fill="#94a3b8" font-size="8">content: text</text>
<text x="1828" y="587" fill="#94a3b8" font-size="8">created_at</text>
<text x="1828" y="617" fill="#fb7185" font-size="7">⚠ NO DELETE</text>
<!-- Viewing -->
<rect x="2250" y="290" width="250" height="165" rx="6" fill="#0f172a"/>
<rect x="2250" y="290" width="250" height="165" rx="6" fill="rgba(120,53,15,0.35)" stroke="#fbbf24" stroke-width="1.5"/>
<line x1="2250" y1="318" x2="2500" y2="318" stroke="#fbbf24" stroke-width="0.5" opacity="0.6"/>
<text x="2375" y="310" fill="white" font-size="11" font-weight="700" text-anchor="middle">client_viewings</text>
<text x="2260" y="335" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="2260" y="348" fill="#94a3b8" font-size="8">FK client_id</text>
<text x="2260" y="361" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="2260" y="374" fill="#94a3b8" font-size="8">FK agent_id (staff)</text>
<text x="2260" y="387" fill="#94a3b8" font-size="8">viewed_at: timestamptz</text>
<text x="2260" y="400" fill="#94a3b8" font-size="8">feedback: text</text>
<text x="2260" y="413" fill="#94a3b8" font-size="8">rating: smallint</text>
<text x="2260" y="426" fill="#94a3b8" font-size="8">status: planned/done/cancelled</text>
<!-- Match -->
<rect x="1960" y="700" width="280" height="150" rx="6" fill="#0f172a"/>
<rect x="1960" y="700" width="280" height="150" rx="6" fill="rgba(120,53,15,0.35)" stroke="#fbbf24" stroke-width="1.5"/>
<line x1="1960" y1="728" x2="2240" y2="728" stroke="#fbbf24" stroke-width="0.5" opacity="0.6"/>
<text x="2100" y="720" fill="white" font-size="11" font-weight="700" text-anchor="middle">client_property_matches</text>
<text x="1970" y="745" fill="#fbbf24" font-size="8">PK id: uuid</text>
<text x="1970" y="758" fill="#94a3b8" font-size="8">FK client_id</text>
<text x="1970" y="771" fill="#94a3b8" font-size="8">FK property_id</text>
<text x="1970" y="784" fill="#94a3b8" font-size="8">match_type: system/manual</text>
<text x="1970" y="797" fill="#94a3b8" font-size="8">score: numeric(5,2)</text>
<text x="1970" y="810" fill="#94a3b8" font-size="8">status: pending/sent/viewed</text>
<text x="1970" y="823" fill="#94a3b8" font-size="8">created_at</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- LEGEND -->
<!-- ═══════════════════════════════════════════════════════════ -->
<rect x="30" y="1850" width="900" height="100" rx="8" fill="rgba(15,23,42,0.8)" stroke="#334155" stroke-width="1"/>
<text x="50" y="1873" fill="#94a3b8" font-size="9" font-weight="600">LEGEND</text>
<!-- Org -->
<rect x="50" y="1885" width="14" height="14" rx="2" fill="rgba(8,51,68,0.4)" stroke="#22d3ee" stroke-width="1.5"/>
<text x="70" y="1896" fill="#22d3ee" font-size="8">ORG / HR</text>
<!-- Complex -->
<rect x="155" y="1885" width="14" height="14" rx="2" fill="rgba(6,78,59,0.4)" stroke="#34d399" stroke-width="1.5"/>
<text x="175" y="1896" fill="#34d399" font-size="8">REGION &amp; COMPLEX</text>
<!-- Property -->
<rect x="310" y="1885" width="14" height="14" rx="2" fill="rgba(76,29,149,0.4)" stroke="#a78bfa" stroke-width="1.5"/>
<text x="330" y="1896" fill="#a78bfa" font-size="8">PROPERTY</text>
<!-- Client -->
<rect x="420" y="1885" width="14" height="14" rx="2" fill="rgba(120,53,15,0.4)" stroke="#fbbf24" stroke-width="1.5"/>
<text x="440" y="1896" fill="#fbbf24" font-size="8">CLIENT</text>
<!-- Relationship lines -->
<line x1="50" y1="1920" x2="90" y2="1920" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrow-slate)"/>
<text x="96" y="1924" fill="#94a3b8" font-size="8">Foreign Key (FK)</text>
<line x1="210" y1="1920" x2="250" y2="1920" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-slate)"/>
<text x="256" y="1924" fill="#94a3b8" font-size="8">Soft reference / optional FK</text>
<rect x="410" y="1913" width="14" height="14" rx="2" fill="rgba(15,23,42,0.5)" stroke="#34d399" stroke-width="1" stroke-dasharray="3,2"/>
<text x="430" y="1924" fill="#34d399" font-size="8">Join table (N:M)</text>
<text x="550" y="1924" fill="#fbbf24" font-size="8">PK Primary Key</text>
<text x="640" y="1924" fill="#fb7185" font-size="8">⚠ NO DELETE = append-only audit log</text>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- TITLE BLOCK -->
<!-- ═══════════════════════════════════════════════════════════ -->
<text x="1080" y="1930" fill="#475569" font-size="10" text-anchor="middle">Fonrey 房产经纪管理系统 — Entity Relationship Diagram · v1.0 · 2026-04-24 · Schema-per-Tenant (django-tenants)</text>
</svg>

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

View File

@@ -1,28 +1,44 @@
---
title: "GitOps"
type: concept
tags: [devops, gitops, infrastructure, git]
sources: [devops-culture-and-transformation-fostering-collaboration-agile-practices-and-innovation-linkedin, ctp-topic-32-using-atlantis-cicd-for-infrastructure-deployments]
last_updated: 2026-04-22
tags:
- DevOps
- CI/CD
- Kubernetes
- Infrastructure as Code
---
## Summary
GitOps is a DevOps methodology that uses Git as the single source of truth for managing infrastructure and application deployments. All desired state is stored in Git repositories, and automated tools (like ArgoCD or Flux) continuously reconcile the actual cluster state with the desired state defined in Git. It is identified as a key future trend in DevOps for managing both infrastructure and deployments declaratively.
## Definition
GitOps 是一种将软件开发的版本控制与协作原则应用于云原生基础设施和应用部署的方法论。核心思想:**使用 Git 作为单一事实来源Single Source of Truth声明系统的期望状态由自动化代理GitOps Controller持续协调实际状态向期望状态收敛。**
## Key Concepts
## Four Principles
1. **声明式配置Declarative Configuration**:所有基础设施和应用配置必须以声明式代码描述,而非命令式脚本
2. **版本控制Version Control**:所有配置存储在 Git 仓库中,享受完整的变更历史、代码审查和回滚能力
3. **CD 流程分离CD Process Separation**CI 专注构建和分析代码CD 专注部署,两者解耦增强安全性
4. **自修复协调器Automated Reconciliation**GitOps Controller 持续监控实际状态与 Git 声明状态,自动调和偏差
### Core Principles
1. **The entire system described declaratively** — All infrastructure and application configurations are stored as code
2. **The canonical desired state in Git** — Git is the source of truth; any change goes through Git workflow
3. **Approved changes automatically pulled into the system** — Automated agents detect drift and reconcile
## Key Benefits
- 开发者只需掌握 Git 即可完成安全部署
- 分钟级代码变更上线
- 零停机回滚Git 历史即回滚计划)
- Git 提交日志天然构成合规审计追踪
- 提高开发者生产力(使用熟悉的工具)
### Tools
- **ArgoCD** — Kubernetes-native GitOps controller
- **Flux** — GitOps toolkit for Kubernetes
- **Atlantis** — Terraform GitOps automation (mentioned in CTP topics)
## Pull Model vs Push Model
## Connections
- [[DevOps Culture]] — GitOps is an operational pattern emerging from DevOps culture
- [[Infrastructure as Code (IaC)]] — GitOps extends IaC with Git-centric workflows
- [[CI/CD Pipeline]] — GitOps can be considered a specialized CI/CD pattern
- [[Continuous Improvement (Kaizen)]] — GitOps enables continuous, auditable improvements
| | Pull Model推荐 | Push Model |
|---|---|---|
| 机制 | 部署代理主动监控 Git 和目标系统 | CI/CD 管道主动推送变更到目标 |
| 安全性 | 更高——系统状态不暴露给外部 | 较低——需外部访问目标系统 |
| 代表工具 | ArgoCD, Flux | Jenkins CI/CD, Terraform Cloud |
| 适用场景 | GitOps 核心模式 | 传统 CI/CD 扩展 |
## Relationship with IaC
GitOps 是 [[Infrastructure as Code]] 的部署编排层:
- **IaC**:定义"基础设施应该是什么样的"Terraform/Pulumi/HCL
- **GitOps**:定义"如何确保基础设施始终符合声明"ArgoCD/Flux/Atlantis
## Sources
- [[ctp-topic-33-an-introduction-to-gitops]] — GitOps 方法论入门Victor Etkin 主讲
- [[ctp-topic-32-using-atlantis-cicd-for-infrastructure-deployments]] — Atlantis 作为 GitOps 工具实践
- [[ctp-topic-9-ci-cd-with-gruntwork]] — Gruntwork CI/CD 与 GitOps 的关联

View File

@@ -4,6 +4,9 @@
- [Overview](overview.md) — living synthesis
## Sources
- [2026-04-24] [Public Cloud Learning Sessions - Ollie Workflow and The Demand Process - 20240416](sources/public-cloud-learning-sessions-ollie-workflow-and-the-demand-process-20240416-16.md)
- [2026-04-24] [CTP Topic 33 An Introduction to GitOps](sources/ctp-topic-33-an-introduction-to-gitops.md)
- [2026-04-24] [CTP Topic 3 Deploy and maintain infrastructure](sources/ctp-topic-3-deploy-and-maintain-infrastructure.md)
- [2026-04-24] [CTP Topic 9 CI CD with Gruntwork](sources/ctp-topic-9-ci-cd-with-gruntwork.md)
- [2026-04-24] [CTP Topic 32 Using Atlantis CICD for Infrastructure Deployments](sources/ctp-topic-32-using-atlantis-cicd-for-infrastructure-deployments.md)
- [2026-04-24] [CTP Topic 2 Git](sources/ctp-topic-2-git.md)
@@ -411,9 +414,6 @@
- [2026-04-19] [ctp-topic-13-cloud-finops-micro-focus-policies-best-practices-to-optimize-the-co](sources/ctp-topic-13-cloud-finops-micro-focus-policies-best-practices-to-optimize-the-co.md) — (expected: wiki/sources/ctp-topic-13-cloud-finops-micro-focus-policies-best-practices-to-optimize-the-co.md — source missing)
- [2026-04-19] [ctp-topic-15-working-with-renovatebot](sources/ctp-topic-15-working-with-renovatebot.md) — (expected: wiki/sources/ctp-topic-15-working-with-renovatebot.md — source missing)
- [2026-04-19] [ctp-topic-56-automated-infrastructure-testing](sources/ctp-topic-56-automated-infrastructure-testing.md) — (expected: wiki/sources/ctp-topic-56-automated-infrastructure-testing.md — source missing)
- [2026-04-19] [public-cloud-learning-sessions-ollie-workflow-and-the-demand-process-20240416-16](sources/public-cloud-learning-sessions-ollie-workflow-and-the-demand-process-20240416-16.md) — (expected: wiki/sources/public-cloud-learning-sessions-ollie-workflow-and-the-demand-process-20240416-16.md — source missing)
- [2026-04-19] [ctp-topic-33-an-introduction-to-gitops](sources/ctp-topic-33-an-introduction-to-gitops.md) — (expected: wiki/sources/ctp-topic-33-an-introduction-to-gitops.md — source missing)
- [2026-04-19] [ctp-topic-3-deploy-and-maintain-infrastructure](sources/ctp-topic-3-deploy-and-maintain-infrastructure.md) — (expected: wiki/sources/ctp-topic-3-deploy-and-maintain-infrastructure.md — source missing)
- [Your-AI-Isn-t-Stupid---It-Just-Needs-a-Better-Harness--Lychee-Technology-Engineering-Blog](sources/Your-AI-Isn-t-Stupid---It-Just-Needs-a-Better-Harness--Lychee-Technology-Engineering-Blog.md) — (expected: wiki/sources/Your-AI-Isn-t-Stupid---It-Just-Needs-a-Better-Harness--Lychee-Technology-Engineering-Blog.md — source missing)
- [Expose-hermes-agent-as-an-OpenAI-compatible-API-for-any-frontend](sources/Expose-hermes-agent-as-an-OpenAI-compatible-API-for-any-frontend.md) — (expected: wiki/sources/Expose-hermes-agent-as-an-OpenAI-compatible-API-for-any-frontend.md — source missing)
- [zk-steward](sources/zk-steward.md) — (expected: wiki/sources/zk-steward.md — source missing)

View File

@@ -1,3 +1,22 @@
## [2026-04-24] ingest | Public Cloud Learning Sessions - Ollie Workflow and The Demand Process - 20240416
- Source file: Cloud & DevOps/Public-Cloud-Learning-Sessions/06_CI_CD_GitOps/public-cloud-learning-sessions-ollie-workflow-and-the-demand-process-20240416-16.md
- Status: ✅ 成功摄入
- Summary: Oli 工作流(超大规模云厂商支出审批三级工作流)+ 需求管理自动化端到端流程ITIL 框架、Octane/Qixi 提交入口、主服务目录嵌入 SMACs、"机器做机器能做的事"理念)
- Concepts identified: [[Demand-Management]], [[ITIL-Service-Management]], [[FinOps]], [[SMACs]]
- Entities identified: [[Tom-Bice]], [[FPNA-Team]], [[MUI]], [[Shannon]], [[Octane]], [[Qixi]]
- Source page: wiki/sources/public-cloud-learning-sessions-ollie-workflow-and-the-demand-process-20240416-16.md
- Notes: entities 和 concepts 目录均为空(无历史页面);未满足 ≥2 次出现条件,不新建独立页面,以 wikilink 形式记录于 Source pageindex.md 已更新overview.md Cloud Transformation 章节已补充(置于 ctp-topic-57 后);已建立与 ctp-topic-57Backlog 管理管道、ctp-topic-65价值量化、public-cloud-learning-sessions-applicable-business-analysis-techniques-20240109需求分析前置技法、ctp-topic-4敏捷实践的连接关系
- Conflicts: (暂无)
- Source file: Cloud & DevOps/Public-Cloud-Learning-Sessions/06_CI_CD_GitOps/ctp-topic-3-deploy-and-maintain-infrastructure.md
- Status: ✅ 成功摄入
- Summary: Landing Zone 环境下通过 Terraform/Terragrunt 实现基础设施部署与维护的完整方法论;核心区分 Service Module业务视角与 Regular Module技术视角的分层抽象Terragrunt HCL 版本锁定Service Catalog 三级复用(单账户→产品团队→跨团队)
- Concepts identified: [[Service Module]], [[Service Catalog]], [[Terragrunt]], [[Infrastructure as Code]], [[Terraform Module]]
- Entities identified: [[Gruntwork]], [[AWS Landing Zone]]
- Source page: wiki/sources/ctp-topic-3-deploy-and-maintain-infrastructure.md
- Notes: 已建立与 ctp-topic-1Gruntwork LZ 架构、ctp-topic-9CI/CD with Gruntwork、ctp-topic-32Atlantis CI/CD、ctp-topic-33GitOps 入门、ctp-topic-39EKS Atlantis 约束差异的连接关系Service Module/Service Catalog 仅出现 1 次,不满足 ≥2 次建页条件,以 wikilink 形式记录于 Source pageindex.md 已更新overview.md Cloud Transformation & DevOps 章节已更新
- Conflicts: 与 [[ctp-topic-39-implementing-eks-in-the-aws-lab-landing-zone]] 存在 Atlantis EKS 支持约束差异Topic 3 通用原则 vs Topic 39 具体实践)
## [2026-04-24] ingest | CTP Topic 9 CI CD with Gruntwork
- Source file: Cloud & DevOps/Public-Cloud-Learning-Sessions/06_CI_CD_GitOps/ctp-topic-9-ci-cd-with-gruntwork.md
- Status: ✅ 成功摄入
@@ -2354,3 +2373,19 @@
- 新增 Entity 页面Jay-Comer.md
- 新增 Concept 页面OpenTelemetry.md
- 冲突检测:与 ctp-topic-54-esm-saas-log-analyticsELK 日志、ctp-topic-67CTP Topic 67 OpenTelemetry互补无冲突
## [2026-04-25] ingest | CTP Topic 33 An Introduction to GitOps
- Source file: Cloud & DevOps/Public-Cloud-Learning-Sessions/06_CI_CD_GitOps/ctp-topic-33-an-introduction-to-gitops.md
- Status: ✅ 成功摄入
- Summary: GitOps 方法论入门——将软件开发原则应用于部署流程;四大原则(声明式配置 + 版本控制 + CD 流程分离 + 自修复协调器Pull 模型优于 Push 模型幂等平台Kubernetes是 CD 顺利运行的必要条件Git 提交日志即合规审计追踪
- Concepts created: [[GitOps]]
- Entities identified: [[Victor Etkin]]
- Source page: wiki/sources/ctp-topic-33-an-introduction-to-gitops.md
- Notes:
- 新增 1 个 Source Page
- 新增 1 个 Concept 页面GitOps.md覆盖四大原则、Pull vs Push 模型、与 IaC 关系)
- index.md 更新:新增条目于 CI_CD_GitOps 分类
- overview.md 更新:新增条目于 Cloud Transformation & DevOps 章节GitOps 知识链路
- Key Entities 中提及的 Victor Etkin 仅出现 1 次,不满足 ≥2 次条件,以 wikilink 形式记录于 Source page
- Key Concepts 中 Kubernetes/Atlantis 已有 wikilink 指向其他 Source page
- 冲突检测:与 ctp-topic-39Atlantis 不支持 EKS存在 Atlantis + Kubernetes 实践约束差异,已记录于 Source page Contradictions

View File

@@ -39,12 +39,16 @@ Key concepts: [[Recursive Self-Optimization]], [[Generator Space]], [[Self-Refer
**[[multi-source-tech-news-digest]]**AI Agent 驱动的多源科技新闻自动聚合与投递系统——四层数据管道整合 46 个 RSS 源、44 个 Twitter/X KOL 账号、19 个 GitHub Releases 仓库和 4 个 Brave Search 主题,覆盖 109+ 信息源通过标题相似度去重和多维度质量评分priority source +3, multi-source +5, recency +2, engagement +1生成精选简报支持 Discord/Email/Telegram 三通道投递30 秒内通过自然语言添加自定义来源。属 [[Daily-YouTube-Digest]] / [[Daily Reddit Digest]] 同款 Cron Job + AI 摘要模式的不同垂直场景(前者视频,后者 Reddit 社区,本方案文字新闻)。
### Cloud Transformation & DevOps
Git 是云转型计划中 DevOps 与 CI/CD 流水线的基础技能。**[[ctp-topic-2-git]]**CTP Topic 2作为 CI/CD/GitOps 系列的开篇,涵盖 Git 版本控制系统基础概念与实践,与 [[ctp-topic-9-ci-cd-with-gruntwork]]Gruntwork CI/CD和 [[ctp-topic-33-an-introduction-to-gitops]]GitOps 入门)构成完整的学习链路。**[[ctp-topic-9-ci-cd-with-gruntwork]]**CTP Topic 9聚焦 CI/CD 与 Gruntwork 在 AWS Landing Zone 中的实践,基于 Gruntwork 参考架构通过 Terraform/Terragrunt 实现基础设施自动化交付(⚠️ 视频待 Whisper 转录后补充详细内容)
Git 是云转型计划中 DevOps 与 CI/CD 流水线的基础技能。**[[ctp-topic-2-git]]**CTP Topic 2作为 CI/CD/GitOps 系列的开篇,涵盖 Git 版本控制系统基础概念与实践,与 [[ctp-topic-9-ci-cd-with-gruntwork]]Gruntwork CI/CD和 [[ctp-topic-33-an-introduction-to-gitops]]GitOps 入门)构成完整的学习链路。**[[ctp-topic-3-deploy-and-maintain-infrastructure]]**CTP Topic 3深入 Landing Zone 环境下的基础设施部署方法论——核心区分Service Module业务视角满足业务需求的一组模块组合与 Regular Module技术视角单一技术构建块Terragrunt HCL 文件通过版本锁定而非 master 分支引用模块Service Catalog 支持三级复用(单账户→产品团队→跨团队)。类 OO 继承原则抽象层次越高配置选项越少。Terragrunt 在运行前预取所有依赖,通过缓存目录管理克隆仓库。属 IaC 模块化治理的基础原则层,与 [[ctp-topic-9-ci-cd-with-gruntwork]]CI/CD 实践)和 [[ctp-topic-32-using-atlantis-cicd-for-infrastructure-deployments]]Atlantis 工具)共同构成完整的 IaC 知识链路。注意:[[ctp-topic-39-implementing-eks-in-the-aws-lab-landing-zone]] 提到 Atlantis 当前不支持 EKS 部署,两者存在实践约束差异,需通过 Jenkins + Terragrunt 替代
**[[ctp-topic-9-ci-cd-with-gruntwork]]**CTP Topic 9聚焦 CI/CD 与 Gruntwork 在 AWS Landing Zone 中的实践,基于 Gruntwork 参考架构通过 Terraform/Terragrunt 实现基础设施自动化交付(⚠️ 视频待 Whisper 转录后补充详细内容)。
Cloud Transformation Programme (CTP) materials cover AWS landing zones, EKS, Terraform, GitOps, FinOps, observability, security, and enterprise architecture. Key themes: 3 Lines of Defence framework, ITSM, container hardening, backup & DR strategies. DevOps culture focuses on four pillars: Collaboration, Automation (CI/CD, IaC), Continuous Improvement (Kaizen), and Customer-Centricity. Agile practices (Scrum, Kanban) are symbiotic with DevOps. Emerging trends: DevSecOps, GitOps, Serverless DevOps, AI/ML-driven automation, and Edge Computing DevOps.
**[[ctp-topic-32-using-atlantis-cicd-for-infrastructure-deployments]]**CTP Topic 32Atlantis 替代 Jenkins 用于 Terraform IaC 部署——针对当前 Jenkins 流水线初始化慢(多次代码克隆/顺序测试/ECS 预配置和架构复杂持续叠加功能导致脆弱的双重痛点Atlantis 提供 PR 评论式协作模型,开发者直接在 GitHub PR 上评论 `atlantis plan`/`apply` 即可触发变更,无需独立账号;每个 Landing Zone 共享账户部署单台 EC2 实例,通过 GitHub Enterprise Webhook 接收通知,服务账号负责评论/合并/关闭 PR跨账户访问通过在各账户部署的 IAM 角色实现;并行构建支持多模块并发 plan/apply锁定机制防止多 PR 同时操作同一模块产生冲突。Atlantis 在 merge 前即应用变更,确保代码与基础设施始终同步。属 [[GitOps]] 工具实践层,与 [[ctp-topic-33-an-introduction-to-gitops]]GitOps 概念)和 [[ctp-topic-9-ci-cd-with-gruntwork]]Gruntwork CI/CD共同构成完整链路。注意[[ctp-topic-39-implementing-eks-in-the-aws-lab-landing-zone]] 提到 Atlantis 当前不支持 EKS 部署,两者存在实践约束差异。
**[[ctp-topic-33-an-introduction-to-gitops]]**CTP Topic 33Victor Etkin 讲解 GitOps 方法论入门——GitOps 将软件开发原则应用于部署流程解决部署失败和配置不一致问题。四大原则声明式配置、版本控制、CD 流程分离、自修复协调器;核心工具仅需 Git。GitOps Controller 持续比对 Git 声明的期望状态与系统实际状态自动调和偏差。Pull 模型(代理同时监控 Git 和目标系统)比 Push 模型安全性更高,是 GitOps 推荐模式。CI 专注代码构建和分析CD 专注二进制部署,两者解耦增强安全性。幂等平台(如 Kubernetes是 CD 流程顺利运行的必要条件。Git 提交日志天然构成合规审计追踪。属 [[GitOps]] 概念层核心来源,与 [[ctp-topic-32-using-atlantis-cicd-for-infrastructure-deployments]]Atlantis 工具)和 [[ctp-topic-2-git]]Git 基础)共同构成 CI/CD/GitOps 完整知识链路。
**[[ctp-topic-21-supply-chain-security-in-micro-focus]]**CTP Topic 21Micro Focus 产品安全小组 Shlomi Ben-Hur 主讲的软件供应链安全新方法——核心议题在云转型背景下软件供应链安全已成为企业安全战略的重中之重。供应链产品层面涵盖源码管理SCM、构建组件CI、制品库到最终交付系统CD的所有环节Micro Focus 内部存在 17 种不同 SCM 工具的极高多样性。主要驱动因素SolarWinds 攻击事件(通过渗透构建过程注入恶意代码)、美国网络安全行政命令、以及向 AWS/SaaS 迁移带来的开放性风险。核心转变:从过去 99% 关注研发安全(代码扫描/渗透测试)转向全生命周期安全防护;供应链安全成为 SDL安全开发生命周期的第五大支柱强调必须同时确保 CI 过程(构建环境/自动化服务器)和 CD 过程(交付系统)的完整性,防止黑客在任何环节篡改二进制文件。属 [[Supply Chain Security供应链安全]] 在 [[Micro Focus]] 云转型场景的核心实践,与 [[DevSecOps]](开发安全运维一体化)高度关联。
**[[ctp-topic-24-micro-focus-product-privacy-framework]]**CTP Topic 24Micro Focus 产品隐私框架在云转型中的应用——PSAC产品安全顾问委员会与法律顾问合作将 GDPR/CCPA 等晦涩法律条款翻译为约 110 项低级别技术要求;隐私框架是 STLC安全开发生命周期中 13 个安全与隐私轨道之一;通过五类需求(架构类/文档类/法律类/实现类/SAS 运营类和成熟度模型0-4 级)评估产品隐私合规状态;通过"蜘蛛图"直观展示产品在安全去标识化、被遗忘权、数据可移植性等 KPI 上的合规现状;最终产出标准化《产品隐私设置文档》,确保客户获得一致的隐私信息参考。属 [[Product Privacy Framework产品隐私框架]] 在 [[Micro Focus]] 云转型场景的核心实践,与 [[Micro Focus Security Development Life Cycle (STLC) Overview]]STLC 整体架构)直接关联。
@@ -155,6 +159,8 @@ Key concepts: [[Process]], [[Value]], [[Value-Stream]], [[Value-Adding]], [[Wast
**[[ctp-topic-57-product-backlog-managing-demand]]**CTP Topic 57CTP 产品待办列表Backlog需求管理完整管道——①需求提交通过 SMACs 启动计时器和追踪)→ ②双周评审会议Matthew Chapman/David Grant/Brendan评估理解度、价值和优先级约20题评估问卷判断简洁性、成本和野心程度 → ③Octane 特性化(带任务列表)→ ④Sprint 规划提前6个 Sprint50% 新需求 / 50% 支持+技术债)→ ⑤Prerequisite Phase新产品组入职介绍会议→AWS账户创建→解决方案设计→GitHub仓库→防火墙标签产品团队约2小时1-2周→ ⑥SRE 构建账号并交接(提供控制台/GitHub访问详情→ ⑦2周 Hyper Care 支持。现有产品组通过 SMACs/邮件/Teams 请求支持Teams 频道连接产品组、SRE工程师、解决方案架构师和交付经理。核心理念**透明化需求管道,确保所有工作以同一标准评估**。属 [[AWS-Landing-Zone]] 需求治理层的核心补充,与 [[ctp-topic-20-program-demand-process-flow]]Gate Process 和 POC 入职)、[[ctp-topic-4-using-agile-to-run-the-cloud-transformation-program]](敏捷实践)、[[ctp-topic-30-managing-change]](变更管理与 SRE 协作)共同构成完整的 CTP 治理知识体系。
**[[public-cloud-learning-sessions-ollie-workflow-and-the-demand-process-20240416-16]]**Public Cloud Learning SessionsOli Workflow超大规模云厂商支出审批工作流与需求管理端到端流程——涵盖两大部分**Oli 工作流审批机制**:所有超大规模云厂商支出无论金额均需 MUI 或 Shannon 书面审批Oli 系统由 Tom Bice 领导的 FinOps 团队接管,正在集成到 SMACs 平台提议的三阶段审批工作流FinOps 可行性验证→云服务技术可行性验证→FPNA 团队预算可用性验证Oli 系统提供飞行中 CSV 报告追踪状态/申请人/成本中心/月成本。② **OpenText 需求管理全链路**ITIL 框架下服务战略→设计→过渡→运营→持续改进五阶段主服务目录Combined Cloud Products Master Catalog将嵌入 SMACsOctane 和 Qixi 是两大需求提交入口ADM/ITOM 需求规划会议捕获所需内容、数量和发布版本;核心理念:**"机器做机器能做的事",目标 80% 场景业务单元自助完成需求选择**。属 [[Demand-Management]] 和 [[FinOps]] 在 OpenText 云转型场景的核心实践,与 [[ctp-topic-57-product-backlog-managing-demand]]Backlog 管理管道)共同构成完整的需求治理知识体系。
**[[ctp-topic-65-tracing-the-value-delivered-in-cloud-transformation]]**CTP Topic 65云转型中的价值交付量化框架——提供系统性衡量、捕获和优先排序云转型业务价值的方法论。核心内容①基础概念——过程Process将输入转化为产出/成果,成果分硬性(时间/成本/质量)和软性(健康/安全Lean 识别三类活动增值活动、价值赋能活动、浪费价值Value由客户决定体现为公平回报②价值流Value Stream分为运营价值流OVS面向客户和开发价值流DVS内部产品③收益量化框架——涵盖财务、生产力、质量和体验四个维度聚焦收入增长、成本降低、风险改善和服务可获得市场规模SOM④WSJF 优先级排序——通过 Cost of Delay / Size of Job 比值对工作排序,实现"最小投入尽早交付最大价值";延迟成本 = 业务价值 + 时间紧迫性 + 风险与机会;⑤功能级价值拆解——可按单一功能归属、均分或不均匀分配(基于触达/影响/努力等标准)。属 [[AWS-Landing-Zone]] 价值治理层的核心方法论,与 [[ctp-topic-30-managing-change]](变更管理)和 [[ctp-topic-20-program-demand-process-flow]](需求流程)共同构成完整的 CTP 治理知识体系。
**[[ctp-topic-20-program-demand-process-flow-and-poc-onboarding]]**CTP Topic 20云转型计划的程序需求流程与 POC 入职流程——Sergio 和 Damian 主讲。核心内容①需求来源——主要由业务案例如数据中心关闭、高层管理人员战略优先级及产品路线图驱动②Gate Process——Gate 0 评估准入、Gate 1 负责 Design Authority 审批、Gate 3 作为启动迁移的最终准入③POC 目的——不仅验证架构和技术可行性,还包括让团队熟悉基于 Gruntwork 的新一代 Landing Zone④新环境特点——强调 IaCTerraform/Terragrunt自动化部署严禁手动构建⑤PCG 团队——平台控制组,负责提供云环境支持、安全策略制定及协助产品组进行 POC⑥成功标准——POC 成功标准必须在启动前明确定义。属 CTP 治理知识体系入口,与 [[ctp-topic-65]](价值量化)、[[ctp-topic-57]](需求管理)、[[ctp-topic-30]](变更管理)共同构成完整的治理框架链条。

View File

@@ -0,0 +1,62 @@
---
title: "CTP Topic 3 Deploy and maintain infrastructure"
type: source
tags:
- IaC
- Deployment
- CI/CD
- CTP
- Terraform
- Terragrunt
date: 2026-04-14
---
## Source File
- [[Cloud & DevOps/Public-Cloud-Learning-Sessions/06_CI_CD_GitOps/ctp-topic-3-deploy-and-maintain-infrastructure.md]]
## Summary用中文描述
- 核心主题AWS Landing Zone 环境下通过 Terraform/Terragrunt 实现基础设施部署与维护的完整方法论
- 问题域:多账户、多产品团队环境下 IaC 模块化复用、服务目录治理、Terragrunt 依赖管理
- 方法/机制Service Module业务视角vs Regular Module技术视角的分层抽象Terragrunt HCL 引用特定版本模块Service Catalog 三级复用单账户→产品团队→跨团队Terragrunt 缓存目录预取依赖
- 结论/价值:模块化 IaC 实现独立发布周期和可维护性Service 层抽象减少配置复杂度,越高层抽象选项越少(类 OO 继承);推荐使用专用 Service Catalog 而非相对路径引用
## Key Claims用中文描述
- Product Team 在 Landing Zone 中部署基础设施时,跨越多个账户(如 Artifactory 账户、AD 账户),涉及多个 Git 仓库协同
- Service Module 由 main.tf 文件引用其他仓库模块组合而成,满足特定业务需求(如 AD 服务、DNS 服务)
- Service 抽象层次高于 Regular Module提供更少配置选项但更易使用
- Terragrunt 优于直接引用 master 分支target 特定版本确保环境一致性
- 复用层次:单账户使用 → 产品团队 Service Catalog → Terraform Service Catalog跨团队
- Terragrunt 在运行前预取所有引用,通过缓存目录存储克隆的仓库
## Key Quotes
> "A service is a business requirement, while a regular module is a technical requirement." — 核心区分Service 解决业务问题Module 解决技术问题
> "When deploying infrastructure, Terragrunt HCL files are used to reference these services, targeting specific versions rather than the master branch." — 版本控制优于分支引用
> "The higher up the chain, the less configuration options are available, similar to an object-oriented approach." — 抽象层次与配置灵活性的反向关系
## Key Concepts
- [[Landing Zone落地区]]:云环境的基础账户结构和资源隔离框架,产品团队在此之上部署工作负载
- [[Service Module服务模块]]:满足业务需求的一组 Terraform 模块组合,相较于技术模块提供更高级抽象
- [[Service Catalog服务目录]]:可复用模块的集中管理库,支持三级复用(账户/产品团队/跨团队)
- [[Terragrunt]]Terraform 的薄包装层,支持依赖管理、缓存和版本锁定
- [[Terraform Module]]Terraform 的可复用配置单元,版本化管理
- [[Infrastructure as CodeIaC]]:通过代码管理和配置基础设施的实践
## Key Entities
- [[AWS Landing Zone]]AWS 多账户环境框架,是本文档讨论的基础部署上下文
- [[Gruntwork]]:提供 Terraform 模块参考架构的公司,本文多次引用其作为最佳实践模型
- [[Product Team]]:在 Landing Zone 中部署工作负载的业务团队,拥有独立的账户集合
## Connections
- [[ctp-topic-1-gruntwork-landing-zone-architecture]] ← foundational ← [[ctp-topic-3-deploy-and-maintain-infrastructure]]
- [[ctp-topic-9-ci-cd-with-gruntwork]] ← related ← [[ctp-topic-3-deploy-and-maintain-infrastructure]]
- [[ctp-topic-32-using-atlantis-cicd-for-infrastructure-deployments]] ← related ← [[ctp-topic-3-deploy-and-maintain-infrastructure]]
- [[ctp-topic-33-an-introduction-to-gitops]] ← extends ← [[ctp-topic-3-deploy-and-maintain-infrastructure]]
## Contradictions
- 与 [[ctp-topic-39-implementing-eks-in-the-aws-lab-landing-zone]] 的 Atlantis 部分存在约束差异:
- 冲突点Atlantis 对 EKS 部署的支持能力
- 当前观点Topic 3Terragrunt 可用于所有基础设施部署,包括 EKS
- 对方观点Topic 39Atlantis 当前不支持 EKS 部署,需通过 Jenkins + Terragrunt 模块替代
- 评估Topic 39 提供更具体的实践经验Topic 3 提供通用原则,两者约束条件不同不构成直接矛盾

View File

@@ -0,0 +1,62 @@
---
title: "CTP Topic 33 An Introduction to GitOps"
type: source
tags:
- GitOps
- CI/CD
- Kubernetes
- DevOps
- Infrastructure as Code
date: 2026-04-14
---
## Source File
- [[Cloud & DevOps/Public-Cloud-Learning-Sessions/06_CI_CD_GitOps/ctp-topic-33-an-introduction-to-gitops.md]]
## Summary用中文描述
- 核心主题GitOps 方法论入门——将软件开发原则应用于部署流程,实现声明式基础设施自动化交付
- 问题域:解决部署失败、配置不一致、手动操作风险等传统 CI/CD 问题
- 方法/机制:四大原则(声明式配置 + 版本控制 + CD 流程分离 + 自修复协调器)+ Pull/Push 两种部署模型
- 结论/价值:开发者只需掌握 Git 即可完成安全部署代码变更分钟级上线Git 日志即审计追踪
## Key Claims用中文描述
- GitOps 四大原则使部署过程完全自动化,代码变更可在数分钟内安全部署上线
- Pull 模型比 Push 模型更适合 GitOps——部署代理同时监控 Git 和目标系统,提供额外安全层
- 幂等Idempotent平台如 Kubernetes是 CD 流程顺利执行的必要条件
- GitOps 是 DevOps 的逻辑演进Git 提交日志天然构成合规审计追踪
- CI 与 CD 应解耦——CI 专注构建和分析代码CD 专注部署二进制文件,解耦增强安全性
## Key Quotes
> "The only tool a developer needs to know is Git." — Victor Etkin
> "GitOps uses Git workflows, CD pipelines, and infrastructure as code. Observability is crucial for ensuring the desired and actual states align."
> "An IDEMPOTENT operation is one that can be applied multiple times without changing the result beyond the initial application."
> "GitOps is a logical evolution of DevOps, simplifying adoption and enhancing portability. Git commit logs become audit trails, streamlining compliance."
## Key Concepts
- [[GitOps]]将软件开发原则Git 版本控制 + Pull Request 协作应用于基础设施和应用部署的方法论核心是通过声明式配置描述期望状态GitOps 控制器自动协调实际状态向期望状态收敛
- [[Idempotent Deployment幂等部署]]:同一操作可重复执行而结果不变的特性,是 GitOps CD 流程顺利运行的必要前提Kubernetes 是典型的幂等平台
- [[Pull Model]]GitOps 推荐部署模型——部署代理持续监控 Git 仓库和目标系统状态,检测到差异时自动从 Git 拉取变更并应用,天然提供额外安全层(系统状态不暴露给外部)
- [[Push Model]]CI/CD 管道主动推送变更到目标系统的部署模式,相比 Pull 模型安全性较低但实现更简单
- [[Declarative Configuration声明式配置]]:通过代码描述"系统应该是什么状态"而非"如何一步步到达该状态",是 GitOps 和 Infrastructure as Code 的核心原则
- [[Infrastructure as Code基础设施即代码]]:用代码管理基础设施的实践,与 GitOps 高度协同,共同构成自动化部署的基础
- [[GitOps Controller]]:运行在目标环境中的自动化代理,持续比对 Git 中声明的期望状态与系统实际状态,自动调和偏差,无需人工干预
## Key Entities
- [[Victor Etkin]]GitOps 入门视频主讲人,阐述 GitOps 四大原则及 Pull 模型优势
- [[Weaveworks]]GitOps 概念的提出者和早期推广者(视频背景知识)
- [[Kubernetes]]GitOps 最常用的部署目标平台,其声明式 API 和自修复机制与 GitOps 高度契合
- [[Atlantis]]:基于 Pull Request 的 Terraform IaC 自动化工具(参见 [[ctp-topic-32-using-atlantis-cicd-for-infrastructure-deployments]]),属 GitOps 工具实践层
## Connections
- [[ctp-topic-2-git]] ← foundational_skill ← [[GitOps]]Git 版本控制是 GitOps 的基础工具)
- [[ctp-topic-9-ci-cd-with-gruntwork]] ← extends ← [[GitOps]]CI/CD 是 GitOps 的核心组件)
- [[ctp-topic-32-using-atlantis-cicd-for-infrastructure-deployments]] ← implements ← [[GitOps]]Atlantis 是 GitOps 工具实践)
- [[GitOps]] ← complements ← [[DevOps]]GitOps 是 DevOps 的逻辑演进)
- [[Amazon EKS]] ← platform ← [[GitOps]]K8s 是 GitOps 最常用目标平台)
- [[GitOps]] ← extends ← [[Infrastructure as Code]]GitOps 是 IaC 的部署编排层)
## Contradictions
- 与 [[ctp-topic-39-implementing-eks-in-the-aws-lab-landing-zone]] 存在实践约束差异:
- 冲突点Atlantis 当前不支持 EKS 部署
- 当前观点Topic 33Kubernetes 是 GitOps 的主要应用场景
- 对方观点Topic 39Atlantis 需通过 Jenkins + Terragrunt 替代方案处理 EKS 工作负载

View File

@@ -0,0 +1,58 @@
---
title: "Public Cloud Learning Sessions - Ollie Workflow and The Demand Process - 20240416"
type: source
tags:
- Workflow
- Demand-Process
- FinOps
- ITIL
- SMACs
date: 2024-04-16
---
## Source File
- [[Cloud & DevOps/Public-Cloud-Learning-Sessions/06_CI_CD_GitOps/public-cloud-learning-sessions-ollie-workflow-and-the-demand-process-20240416-16.md]]
## Summary用中文描述
- 核心主题Oli 工作流(超大规模云厂商支出审批流程)与需求管理全链路
- 问题域:云转型过程中的 FinOps 支出审批治理、需求提交与自动化履约
- 方法/机制ITIL 服务管理框架下的三级审批工作流FinOps 可行性→云服务技术可行性→FPNA 预算可用性),以及 OpenText 端到端需求管理流程Octane/Qixi 提交 → 主服务目录 → SMACs 嵌入 → 自动化履约)
- 结论/价值:所有超大规模云厂商支出(含工程实验室和商业工作负载)无论金额均需 MUI/Shannon 书面审批;推动"机器做机器能做的事",目标是 80% 场景业务单元自助完成需求提交
## Key Claims用中文描述
- 所有超大规模云厂商支出(含工程实验室和商业工作负载空间)无论金额,均需 MUI 或 Shannon 书面审批
- Oli 工作流由 Tom Bice 领导的 FinOps 团队接管,正在集成到 SMACs 平台
- 提议的三阶段工作流FinOps 可行性验证 → 云服务技术可行性验证 → FPNA 团队预算可用性验证
- Oli 系统提供飞行中 CSV 报告,追踪工作流状态、申请人、成本中心、月成本及当前步骤
- ITIL 框架将业务流程分为服务战略、设计、过渡、运营、持续改进五个阶段
- 主服务目录Combined Cloud Products Master Catalog将嵌入 SMACs目标是 80% 场景下业务单元可自助选择所需服务
- ADM 和 ITOM 需求规划会议记录所需内容、数量和发布版本
## Key Quotes
> "If justification details are not provided, requests are subject to immediate rejection." — Oli 请求提交规范
> "Machines should do what machines can do, enabling an automated fulfillment process." — OpenText 需求管理核心理念
> "The goal is for business units to self-select what they need 80% of the time." — 需求管理自动化目标
## Key Concepts
- [[Demand-Management需求管理]]:平衡需求与可用容量的必要手段,是 ITIL 服务过渡阶段的关键活动
- [[ITIL-Service-Management]]将业务流程分为服务战略、服务设计、服务过渡、服务运营、持续服务改进五阶段Oli 工作流对应请求履约的第一阶段
- [[SMACs]]Social、Mobile、Analytics、Cloud 的技术栈组合Oli 工作流正在集成到 SMACs 平台
- [[FinOps]]财务运营Tom Bice 团队负责 Oli 工作流接管,重点关注云支出的可视性与优化
- [[Product-Backlog]]产品待办列表Oli 工作流产生的请求经审批后进入 Backlog 管理
## Key Entities
- [[Tom-Bice]]FinOps 团队负责人,正在接管 Oli 工作流并集成到 SMACs
- [[FPNA-Team]]:财务规划与分析团队,负责工作流第三阶段——预算可用性验证
- [[MUI]]:超大规模云厂商支出审批人之一(与 Shannon 共同审批所有云支出请求)
- [[Shannon]]:超大规模云厂商支出审批人之一(与 MUI 共同审批所有云支出请求)
- [[Octane]]:超大规模云厂商 SaaS 产品需求管理平台,业务单元可直接向其提交需求
- [[Qixi]]Oli 需求提交流程的前端接口之一,业务单元通过其提交需求
## Connections
- [[ctp-topic-57-product-backlog-managing-demand]] ← extends ← 本文档Oli 工作流审批通过的请求进入产品 Backlog 管理管道)
- [[ctp-topic-65-tracing-the-value-delivered-in-cloud-transformation]] ← depends_on ← 本文档(需求管理是价值交付量化框架的前置管道)
- [[public-cloud-learning-sessions-applicable-business-analysis-techniques-20240109]] ← related_to ← 本文档BOSCARD 框架是需求分析的前置技法)
- [[ctp-topic-4-using-agile-to-run-the-cloud-transformation-program]] ← related_to ← 本文档Kanban 敏捷实践为需求流转提供方法论支撑)
## Contradictions
- 无已知冲突页面