文档修改

This commit is contained in:
Shen Wei
2026-04-29 15:43:49 +08:00
parent c3f9de5f9f
commit b2aadf771a
28 changed files with 7502 additions and 109 deletions

View File

@@ -762,15 +762,64 @@ class PropertyManager(ActiveManager):
## 八、数据量与性能预测
| 表名 | 预估行数 | 增长速度 | 分区策略 |
|------|---------|---------|---------|
|------|---------|---------|---------|
| `properties` | 89,000+ | 中速 | 暂不分区,建议 500k 后按 `created_at` RANGE 分区 |
| `follow_logs` | 200万+ | 高速(最高频写入) | `created_at` 月度 RANGE 分区 |
| `property_photos` | 500万+ | 高速 | `property_id` HASH 分区16分区 |
| `follow_logs` | 200万+ | 高速(最高频写入) | `PARTITION BY RANGE (created_at)` 月度分区 |
| `property_photos` | 500万+ | 高速 | `PARTITION BY RANGE (created_at)` 月度分区 |
| `permission_change_logs` | 100万+ | 中高速 | ✅ `PARTITION BY RANGE (operated_at)` 月度分区 |
| `login_attempts` | 500万+ | 高速(每次登录一条) | ✅ `PARTITION BY RANGE (attempted_at)` 月度分区 |
| `platform_audit_logs` | 10万+ | 低中速 | ✅ `PARTITION BY RANGE (created_at)` 月度分区 |
| `price_changes` | 50万 | 中速 | 无需分区 |
| `listing_histories` | 20万 | 低速 | 无需分区 |
| `clients` | 10万+ | 中速 | 暂不分区 |
| `viewings` | 100万 | 中速 | 无需分区 |
### 8.1 分区维护策略partition_maintenance_task
所有月度分区表统一由 **Celery Beat 定时任务** `partition_maintenance_task` 维护,每月 1 日凌晨 01:00UTC+8自动执行
```python
# apps/property/tasks.py及 permission/login/shared 各 App 对应任务)
@app.task(name="partition_maintenance_task")
def partition_maintenance_task():
"""
为下一个月预建所有分区表的分区。
- 检查是否已存在目标分区,幂等执行
- 失败时发送 Sentry 告警
"""
tables = [
("follow_logs", "created_at"),
("property_photos", "created_at"),
("permission_change_logs", "operated_at"),
("login_attempts", "attempted_at"),
("public.platform_audit_logs", "created_at"),
]
next_month = date.today().replace(day=1) + relativedelta(months=1)
month_start = next_month
month_end = next_month + relativedelta(months=1)
for table, _key in tables:
suffix = month_start.strftime("%Y_%m")
part_name = f"{table.replace('.', '_')}_{suffix}"
sql = f"""
CREATE TABLE IF NOT EXISTS {part_name}
PARTITION OF {table}
FOR VALUES FROM ('{month_start}') TO ('{month_end}');
"""
with connection.cursor() as cursor:
cursor.execute(sql)
```
**Celery Beat 配置**`celery.py`
```python
app.conf.beat_schedule["partition_maintenance_task"] = {
"task": "partition_maintenance_task",
"schedule": crontab(day_of_month=1, hour=1, minute=0), # 每月1日 01:00 UTC+8
}
```
> ⚠️ **注意**:每张分区表均保留一个 `_default` 默认分区作为兜底,防止任务失败时写入报错。`_default` 分区数据应在运维 SOP 中周期性检查(有数据则说明提前建分区失败)。
---
## 九、必须在开发启动前明确的数据架构决策

View File

@@ -107,6 +107,7 @@ Staff (员工)
| deleted_at | TIMESTAMPTZ | | 软删除 |
| created_by | UUID | FK→staff, SET NULL | |
| updated_by | UUID | FK→staff, SET NULL | |
| version | INTEGER | NOT NULL DEFAULT 1 | 乐观锁版本号;每次 UPDATE +1应用层检测 0 行受影响时抛 ConflictError |
**关键索引**
```sql

View File

@@ -204,6 +204,7 @@ CREATE INDEX idx_schools_name_trgm ON schools USING gin(name gin_trgm_ops);
| deleted_at | TIMESTAMPTZ | | 软删除 |
| created_by | UUID | FK→staff, SET NULL | |
| updated_by | UUID | FK→staff, SET NULL | |
| version | INTEGER | NOT NULL DEFAULT 1 | 乐观锁版本号;每次 UPDATE +1应用层检测 0 行受影响时抛 ConflictError |
**关键索引**
```sql

View File

@@ -190,13 +190,39 @@ class UserAccount(AbstractBaseUser):
| 字段名 | 类型 | 约束 | 默认值 | 说明 |
|--------|------|------|--------|------|
| `id` | `BIGSERIAL` | `PRIMARY KEY` | — | 自增主键 |
| `id` | `BIGSERIAL` | `NOT NULL` | — | 自增主键(与 attempted_at 组成复合 PK |
| `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间(分区键,必须在复合主键中) |
| `username` | `VARCHAR(30)` | `NOT NULL` | — | 尝试登录的用户名(冗余存储,即使账号不存在也记录) |
| `ip_address` | `INET` | `NOT NULL` | — | 来源 IP 地址(支持 IPv4/IPv6 |
| `user_agent` | `TEXT` | `NULL` | `NULL` | 客户端 User-AgentElectron 版本信息) |
| `success` | `BOOLEAN` | `NOT NULL` | — | 是否登录成功 |
| `failure_reason` | `VARCHAR(30)` | `NULL` | `NULL` | 失败原因;可选值见下方枚举 |
| `attempted_at` | `TIMESTAMPTZ` | `NOT NULL` | `NOW()` | 尝试时间 |
> ⚠️ **分区说明**`login_attempts` 为高写入审计表,采用 `PARTITION BY RANGE (attempted_at)` 按月分区。主键为 `(id, attempted_at)` 复合主键(分区表规范:主键必须包含分区键)。
**DDL**
```sql
CREATE TABLE login_attempts (
id BIGSERIAL NOT NULL,
attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键
username VARCHAR(30) NOT NULL,
ip_address INET NOT NULL,
user_agent TEXT,
success BOOLEAN NOT NULL,
failure_reason VARCHAR(30)
CHECK (failure_reason IS NULL OR failure_reason IN (
'wrong_password','wrong_captcha','account_locked',
'account_disabled','tenant_not_found'
)),
PRIMARY KEY (id, attempted_at) -- 分区表主键必须包含分区键
) PARTITION BY RANGE (attempted_at);
CREATE TABLE login_attempts_2026_04 PARTITION OF login_attempts
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE login_attempts_2026_05 PARTITION OF login_attempts
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE login_attempts_default PARTITION OF login_attempts DEFAULT;
```
**`failure_reason` 枚举值**

View File

@@ -988,7 +988,8 @@ CREATE INDEX idx_data_scopes_expires ON staff_data_scopes(expires_at) WHERE expi
-- permission_change_logs (append-only, no deleted_at)
CREATE TABLE permission_change_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id UUID NOT NULL DEFAULT gen_random_uuid(),
operated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键(原 operated_at 前置)
target_type VARCHAR(30) NOT NULL
CHECK (target_type IN ('role','role_permission','staff_role','staff_override','staff_scope')),
target_id UUID NOT NULL,
@@ -1003,8 +1004,15 @@ CREATE TABLE permission_change_logs (
operator_ip INET,
user_agent TEXT,
reason TEXT NOT NULL DEFAULT '',
operated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
PRIMARY KEY (id, operated_at) -- 分区表主键必须包含分区键
) PARTITION BY RANGE (operated_at);
CREATE TABLE permission_change_logs_2026_04 PARTITION OF permission_change_logs
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE permission_change_logs_2026_05 PARTITION OF permission_change_logs
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE permission_change_logs_default PARTITION OF permission_change_logs DEFAULT;
CREATE INDEX idx_perm_log_staff ON permission_change_logs(staff_id, operated_at DESC) WHERE staff_id IS NOT NULL;
CREATE INDEX idx_perm_log_role ON permission_change_logs(role_id, operated_at DESC) WHERE role_id IS NOT NULL;
CREATE INDEX idx_perm_log_target ON permission_change_logs(target_type, target_id, operated_at DESC);

View File

@@ -310,7 +310,10 @@ CREATE TABLE properties (
updated_by UUID REFERENCES staff(id) ON DELETE SET NULL,
-- ── 全文检索向量 ──
search_vector TSVECTOR
search_vector TSVECTOR,
-- ── 乐观锁 ──
version INTEGER NOT NULL DEFAULT 1 -- 每次 UPDATE 必须 +1应用层检测 0 行受影响时抛 ConflictError
);
-- ── 索引策略 ──
@@ -520,7 +523,8 @@ CREATE INDEX idx_price_changes_time ON price_changes(property_id, changed_at DES
-- ============================================================
CREATE TABLE follow_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id UUID NOT NULL DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键,必须在最前声明
property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
log_type VARCHAR(30) NOT NULL
@@ -554,9 +558,19 @@ CREATE TABLE follow_logs (
-- 是否可删除sensitive_view = FALSE合规强制
is_deletable BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ -- 仅 is_deletable=TRUE 时可软删
);
deleted_at TIMESTAMPTZ, -- 仅 is_deletable=TRUE 时可软删
PRIMARY KEY (id, created_at) -- 分区表主键必须包含分区键
) PARTITION BY RANGE (created_at);
-- ── 按月自动建分区(由 partition_maintenance_task Celery 任务维护)──
-- 示例:初始建立当前月 + 下一个月的分区
CREATE TABLE follow_logs_2026_04 PARTITION OF follow_logs
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE follow_logs_2026_05 PARTITION OF follow_logs
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
-- 默认分区:兜底,防止超出已建分区范围导致写入失败
CREATE TABLE follow_logs_default PARTITION OF follow_logs DEFAULT;
-- 时间线展示(核心)
CREATE INDEX idx_follow_logs_property_time ON follow_logs(property_id, created_at DESC)
@@ -798,7 +812,8 @@ CREATE INDEX idx_survey_photos_category ON survey_photos(survey_id, category);
-- ============================================================
CREATE TABLE property_photos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id UUID NOT NULL DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键
property_id UUID NOT NULL REFERENCES properties(id) ON DELETE CASCADE,
category VARCHAR(20) NOT NULL
@@ -817,10 +832,17 @@ CREATE TABLE property_photos (
is_cover BOOLEAN NOT NULL DEFAULT FALSE,
sort_order SMALLINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES staff(id) ON DELETE SET NULL
);
created_by UUID REFERENCES staff(id) ON DELETE SET NULL,
PRIMARY KEY (id, created_at) -- 分区表主键必须包含分区键
) PARTITION BY RANGE (created_at);
CREATE TABLE property_photos_2026_04 PARTITION OF property_photos
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE property_photos_2026_05 PARTITION OF property_photos
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE property_photos_default PARTITION OF property_photos DEFAULT;
CREATE INDEX idx_property_photos_property ON property_photos(property_id);
CREATE INDEX idx_property_photos_cover ON property_photos(property_id)

View File

@@ -241,7 +241,8 @@ CREATE INDEX idx_ip_whitelist_active ON public.ip_whitelist(cidr) WHERE is_activ
-- 平台操作审计日志(所有写操作 + 高危操作,无 deleted_at无 UPDATE
CREATE TABLE public.platform_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id UUID NOT NULL DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键
operator_id UUID, -- 管理员 IDNULL 表示系统自动操作
operator_name VARCHAR(100), -- 快照(防止账号删除后失去溯源)
action_type VARCHAR(50) NOT NULL,
@@ -257,9 +258,16 @@ CREATE TABLE public.platform_audit_logs (
CHECK (result IN ('SUCCESS','FAILED')),
error_message TEXT,
ip_address INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- 无 deleted_at无 UPDATE建议按月 RANGE 分区
);
-- 无 deleted_at无 UPDATE按月 RANGE 分区
PRIMARY KEY (id, created_at) -- 分区表主键必须包含分区键
) PARTITION BY RANGE (created_at);
CREATE TABLE public.platform_audit_logs_2026_04 PARTITION OF public.platform_audit_logs
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE public.platform_audit_logs_2026_05 PARTITION OF public.platform_audit_logs
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE public.platform_audit_logs_default PARTITION OF public.platform_audit_logs DEFAULT;
CREATE INDEX idx_audit_logs_operator ON public.platform_audit_logs(operator_id, created_at DESC);
CREATE INDEX idx_audit_logs_action ON public.platform_audit_logs(action_type, created_at DESC);