Sync: add infrastructure as code notes

This commit is contained in:
2026-04-24 19:58:02 +08:00
parent cc23df1883
commit e4f6f463cb
29 changed files with 2344 additions and 155 deletions

View File

@@ -1,8 +1,8 @@
# Fonrey 房产经纪管理系统 — DATA MODEL 设计文档
> **作者**: Backend Architect
> **版本**: v1.0
> **日期**: 2026-04-24
> **版本**: v1.3
> **日期**: 2026-04-24v1.1 修复 S1/S2/S4v1.2 扩展 public schemav1.3 §三 DDL 迁至 DATA_MODEL_PUBLIC.md本文改为索引
> **技术栈**: Django 4.x + PostgreSQL + django-tenants + Redis
> **设计目标**: 支撑 89,000+ 房源、多租户隔离、sub-100ms 查询、合规审计
@@ -13,18 +13,28 @@
### 1.1 多租户策略Schema-per-Tenant
```
┌─────────────────────────────────────────────────────────────┐
│ PostgreSQL Instance │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ │ public schema│ tenant_abc │tenant_xyz
│ │ (shared) │ │ schema │ │ schema │
│ │ │ │ │
│ │ - tenants │ │ - properties │ │ - properties │
│ │ - domains │ │ - clients │ │ - clients │
│ │ │ - complexes │ │ - complexes │
└──────────────┘ └──────────────┘ └──────────────┘
└─────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────
│ PostgreSQL Instance
│ ┌─────────────────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ public schema│ tenant_abc │ │ tenant_xyz
│ │ (平台运营层) │ │ schema │ │ schema │ │
│ │ │ │ │ │
│ │ - tenants │ │ - properties │ │ - properties │ │
│ │ - domains │ │ - clients │ │ - clients │ │
│ │ - tenant_status_logs │ │ - complexes │ │ - complexes │ │
│ - platform_admins │ │ - staff │ │ - staff │
│ │ - admin_mfa_devices │ │ - org_units │ │ - org_units │ │
│ │ - admin_sessions │ │ - ... │ │ - ... │ │
│ │ - ip_whitelist │ └──────────────┘ └──────────────┘ │
│ │ - platform_audit_logs │ │
│ │ - backup_schedules │ │
│ │ - backup_records │ │
│ │ - export_tasks │ │
│ │ - system_versions │ │
│ │ - upgrade_events │ │
│ └─────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
**选型理由**
@@ -76,9 +86,28 @@
### 核心领域对象
#### Public Schema平台运营层
| 领域对象 | 表 | 业务说明 |
|----------|-----|----------|
| **Tenant租户** | `public.tenants` | 每家房产经纪公司一条记录含状态机creating/active/suspended/pending_delete/deleted、套餐、联系人 |
| **Domain域名** | `public.domains` | 子域名↔租户映射,支持多域名绑定,子域名创建后不可修改 |
| **TenantStatusLog** | `public.tenant_status_logs` | 租户状态变更不可变审计append-only |
| **PlatformAdmin** | `public.platform_admins` | 平台管理员账号3 种角色:超级管理员/运营人员/只读审计员 |
| **AdminMfaDevice** | `public.admin_mfa_devices` | 管理员 TOTP 设备(强制启用) |
| **AdminSession** | `public.admin_sessions` | 登录会话30 分钟超时,支持强制登出) |
| **IpWhitelist** | `public.ip_whitelist` | 管理控制台 CIDR 白名单 |
| **PlatformAuditLog** | `public.platform_audit_logs` | 所有写操作+高危操作审计append-only建议月度分区 |
| **BackupSchedule** | `public.backup_schedules` | 全局/租户级定时备份计划(频率/保留数/存储目标) |
| **BackupRecord** | `public.backup_records` | 备份任务执行记录(自动/手动/升级前/恢复前) |
| **ExportTask** | `public.export_tasks` | 数据导出异步任务CSV/JSON/SQL Dump24h 下载链接) |
| **SystemVersion** | `public.system_versions` | 平台版本历史,唯一 current 版本约束 |
| **UpgradeEvent** | `public.upgrade_events` | 升级/回滚事件,含灰度租户维度进度快照 |
#### Tenant Schema租户业务层
| 领域对象 | 表/子文档 | 业务说明 |
|----------|-----------|----------|
| **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) | 行政区划,如「静安区」,是区域体系的顶层节点 |
@@ -114,6 +143,7 @@ OrgUnit (组织架构)
| 子文档 | 覆盖模块 | 状态 |
|--------|----------|------|
| [DATA_MODEL_PUBLIC.md](./DATA_MODEL_PUBLIC.md) | Public schema 平台运营层tenants, domains, platform_admins, admin_sessions, audit_logs, backup, export, upgrade 共 13 张表) | ✅ 完成 |
| [DATA_MODEL_ORG.md](./DATA_MODEL_ORG.md) | 组织人事org_units, staff, 异动/奖惩/教育/家庭等) | ✅ 完成 |
| [DATA_MODEL_COMPLEX.md](./DATA_MODEL_COMPLEX.md) | 楼盘/区域districts, business_areas, complexes, buildings, room_units, schools 等) | ✅ 完成 |
| [DATA_MODEL_CLIENT.md](./DATA_MODEL_CLIENT.md) | 客源管理clients, requirements, follow_logs, viewings, matches 等) | ✅ 完成 |
@@ -123,42 +153,41 @@ OrgUnit (组织架构)
## 三、公共 SchemaShared / Public
> **权威源**:完整 DDL 已迁至 [`DATA_MODEL_PUBLIC.md`](./DATA_MODEL_PUBLIC.md),本节仅保留摘要索引。
> **覆盖范围**`public` schema 存储平台运营层数据——租户注册、管理员账号、审计日志、备份/导出任务、版本升级记录(共 13 张表)。
> **设计依据**:系统管理模块 PRD`PRD/系统管理/系统管理模块PRD.md`)。
```sql
-- ============================================================
-- 文件: shared_schema.sql
-- 用途: django-tenants 公共 Schema存放租户注册信息
-- ============================================================
### 表清单(开发以 DATA_MODEL_PUBLIC.md 为准)
-- 租户表(每家房产公司一条记录)
CREATE TABLE public.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
schema_name VARCHAR(63) UNIQUE NOT NULL, -- PG schema 名,最长 63 字符
name VARCHAR(255) NOT NULL, -- 公司名称
short_name VARCHAR(100), -- 简称/品牌名
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
paid_until DATE, -- 订阅到期日
on_trial BOOLEAN NOT NULL DEFAULT TRUE,
extra JSONB NOT NULL DEFAULT '{}' -- 预留扩展字段
);
| 表名 | 说明 | 节 |
|------|------|----|
| `public.tenants` | 租户主表django-tenants 核心,状态机 6 态) | §2.1 |
| `public.domains` | 域名↔租户映射(支持多域名,子域名不可修改) | §2.1 |
| `public.tenant_status_logs` | 租户状态变更不可变审计日志append-only | §2.1 |
| `public.platform_admins` | 平台管理员账号super_admin/ops_operator/read_only_auditor | §2.2 |
| `public.admin_mfa_devices` | 管理员 TOTP MFA 设备(强制启用) | §2.2 |
| `public.admin_sessions` | 管理员登录会话30 min 滚动超时,支持强制登出) | §2.2 |
| `public.ip_whitelist` | 管理控制台 CIDR 白名单 | §2.2 |
| `public.platform_audit_logs` | 所有写操作+高危操作审计append-only建议月度分区 | §2.3 |
| `public.backup_schedules` | 全局/租户级定时备份计划NULL tenant_id = 全局默认) | §2.4 |
| `public.backup_records` | 备份任务执行记录auto/manual/pre_upgrade/pre_restore | §2.4 |
| `public.export_tasks` | 数据导出异步任务CSV/JSON/SQL Dump24h 下载链接) | §2.4 |
| `public.system_versions` | 平台版本历史,部分唯一索引保证唯一 current | §2.5 |
| `public.upgrade_events` | 升级/回滚事件,`tenant_progress` JSONB 快照各租户状态 | §2.5 |
-- 域名映射表(支持多域名绑定一个租户)
CREATE TABLE public.domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain VARCHAR(253) UNIQUE NOT NULL, -- 含子域名的完整域名
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_domains_tenant ON public.domains(tenant_id);
CREATE INDEX idx_domains_primary ON public.domains(tenant_id) WHERE is_primary = TRUE;
```
**关键约束提示**
- `tenant_status_logs` / `platform_audit_logs` **无 deleted_at**,禁止 UPDATE/DELETEappend-only
- `public.tenants.schema_name` 创建后**不可修改**
- `public.tenants` 不再使用 `is_active` boolean改用 6 态 `status` 枚举
- `platform_admins` 与租户 `staff` **完全独立**,不共享 auth 系统
- `system_versions` 通过部分唯一索引确保全局只有一个 `status='current'`
---
<!-- §三 DDL 已迁至 DATA_MODEL_PUBLIC.md v1.02026-04-24 -->
## 四、租户 SchemaTenant Schema
以下所有表均在每个租户的独立 Schema 内创建。

View File

@@ -0,0 +1,488 @@
# Fonrey — Public Schema 数据模型
> **作者**: Backend Architect
> **版本**: v1.0
> **日期**: 2026-04-24
> **权威源**: 本文件是 `public` schema 所有表的唯一权威定义
> **设计依据**: 系统管理模块 PRD`PRD/系统管理/系统管理模块PRD.md`
> **索引文档**: [`DATA_MODEL.md §三`](./DATA_MODEL.md)(仅保留摘要索引,开发以本文件为准)
---
## 一、概览
`public` schema 存储**平台运营层**数据,与各租户的业务 schema 完全隔离。
### 架构定位
```
PostgreSQL Instance
├── public schema平台运营层← 本文件覆盖
│ ├── tenants 租户注册与生命周期
│ ├── domains 域名路由
│ ├── tenant_status_logs 状态变更审计
│ ├── platform_admins 管理员账号
│ ├── admin_mfa_devices TOTP 设备
│ ├── admin_sessions 登录会话
│ ├── ip_whitelist 访问控制
│ ├── platform_audit_logs 操作审计
│ ├── backup_schedules 备份计划
│ ├── backup_records 备份记录
│ ├── export_tasks 数据导出
│ ├── system_versions 版本历史
│ └── upgrade_events 升级记录
├── tenant_abc schema租户业务层见各子文档
└── tenant_xyz schema
```
### 表清单
| 表名 | 说明 | 节 |
|------|------|----|
| `public.tenants` | 租户主表(每家房产公司一条记录) | §2.1 |
| `public.domains` | 域名↔租户映射(多域名支持) | §2.1 |
| `public.tenant_status_logs` | 租户状态变更不可变审计日志 | §2.1 |
| `public.platform_admins` | 平台管理员账号3 种角色) | §2.2 |
| `public.admin_mfa_devices` | 管理员 TOTP MFA 设备(强制启用) | §2.2 |
| `public.admin_sessions` | 管理员登录会话30 min 超时,支持强制登出) | §2.2 |
| `public.ip_whitelist` | 管理控制台 CIDR 白名单 | §2.2 |
| `public.platform_audit_logs` | 所有写操作+高危操作审计append-only建议月度分区 | §2.3 |
| `public.backup_schedules` | 全局/租户级定时备份计划 | §2.4 |
| `public.backup_records` | 备份任务执行记录(自动/手动/升级前/恢复前) | §2.4 |
| `public.export_tasks` | 数据导出异步任务CSV/JSON/SQL Dump | §2.4 |
| `public.system_versions` | 平台版本历史,唯一 current 约束 | §2.5 |
| `public.upgrade_events` | 升级/回滚事件,含灰度租户维度进度快照 | §2.5 |
---
## 二、DDL 定义
### 2.1 租户管理
```sql
-- ============================================================
-- 文件: shared_schema.sql
-- 用途: django-tenants 公共 Schema存放平台运营层数据
-- 设计依据: 系统管理模块 PRD v1.0
-- ============================================================
-- ────────────────────────────────────────────────────────────
-- 1. 租户管理
-- ────────────────────────────────────────────────────────────
-- 租户状态枚举(生命周期状态机,见 PRD §9.1
-- creating → active ←→ suspended → pending_delete → deleted
-- ↑ 硬删除直接到 deleted
-- 租户主表(每家房产经纪公司一条记录)
CREATE TABLE public.tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
schema_name VARCHAR(63) UNIQUE NOT NULL, -- PG schema 名,最长 63 字符,创建后不可修改
name VARCHAR(255) NOT NULL, -- 公司名称
short_name VARCHAR(100), -- 简称/品牌名
contact_name VARCHAR(100) NOT NULL, -- 主联系人姓名
contact_email VARCHAR(254) NOT NULL, -- 联系邮箱(接收通知/欢迎邮件)
region VARCHAR(100), -- 所在地区(省市,如「上海市」)
plan VARCHAR(20) NOT NULL DEFAULT 'basic'
CHECK (plan IN ('basic','professional','enterprise')),
-- 状态机
status VARCHAR(20) NOT NULL DEFAULT 'creating'
CHECK (status IN ('creating','active','suspended','pending_delete','deleted','failed')),
suspended_until TIMESTAMPTZ, -- NULL = 永久挂起,非 NULL = Celery Beat 定时恢复
suspended_reason VARCHAR(50)
CHECK (suspended_reason IN ('overdue','violation','requested','other')),
deleted_at TIMESTAMPTZ, -- 软删除时间戳;硬删除直接物理删除行
-- 订阅
paid_until DATE, -- 订阅到期日
on_trial BOOLEAN NOT NULL DEFAULT TRUE,
-- 灰度升级
is_canary BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE = 内测租户,参与灰度升级
-- 元数据
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID, -- 创建该租户的管理员 ID可 NULL初始化时
extra JSONB NOT NULL DEFAULT '{}' -- 预留扩展字段
);
CREATE INDEX idx_tenants_status ON public.tenants(status);
CREATE INDEX idx_tenants_suspended_until ON public.tenants(suspended_until)
WHERE status = 'suspended' AND suspended_until IS NOT NULL;
CREATE INDEX idx_tenants_canary ON public.tenants(is_canary) WHERE is_canary = TRUE;
CREATE INDEX idx_tenants_pending_delete ON public.tenants(deleted_at)
WHERE status = 'pending_delete';
-- 域名映射表(支持多域名绑定一个租户,子域名创建后不可修改)
CREATE TABLE public.domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
domain VARCHAR(253) UNIQUE NOT NULL, -- 含子域名的完整域名(如 abc.platform.com
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_domains_tenant ON public.domains(tenant_id);
CREATE UNIQUE INDEX idx_domains_primary ON public.domains(tenant_id) WHERE is_primary = TRUE;
-- 租户状态变更日志append-only不可删除
-- 记录所有 status 变更creating→active / active→suspended / suspended→active / →pending_delete / →deleted
CREATE TABLE public.tenant_status_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
from_status VARCHAR(20), -- NULL 表示初始创建
to_status VARCHAR(20) NOT NULL,
reason TEXT,
operator_id UUID, -- 操作管理员 IDNULL = 系统自动Celery
operator_name VARCHAR(100), -- 快照,防止管理员被删后失去可追溯性
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- 无 deleted_at无 UPDATEappend-only
);
CREATE INDEX idx_tenant_status_logs_tenant ON public.tenant_status_logs(tenant_id, created_at DESC);
```
### 2.2 平台管理员
```sql
-- ────────────────────────────────────────────────────────────
-- 2. 平台管理员
-- ────────────────────────────────────────────────────────────
-- 管理员账号(与租户 staff 完全独立,存于 public schema
CREATE TABLE public.platform_admins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(150) UNIQUE NOT NULL,
email VARCHAR(254) UNIQUE NOT NULL,
display_name VARCHAR(100) NOT NULL,
password_hash VARCHAR(255) NOT NULL, -- Django PBKDF2 / Argon2 哈希
role VARCHAR(20) NOT NULL
CHECK (role IN ('super_admin','ops_operator','read_only_auditor')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE, -- 首次登录前为 FALSE配置 TOTP 后变 TRUE
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES public.platform_admins(id) ON DELETE SET NULL
);
CREATE INDEX idx_platform_admins_role ON public.platform_admins(role) WHERE is_active = TRUE;
-- MFA 设备TOTP每管理员可注册多个设备但通常一个
CREATE TABLE public.admin_mfa_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id UUID NOT NULL REFERENCES public.platform_admins(id) ON DELETE CASCADE,
device_name VARCHAR(100) NOT NULL DEFAULT 'Authenticator App',
totp_secret VARCHAR(255) NOT NULL, -- Base32 加密存储
is_confirmed BOOLEAN NOT NULL DEFAULT FALSE, -- 首次验证通过后置 TRUE
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_admin_mfa_devices_admin ON public.admin_mfa_devices(admin_id)
WHERE is_confirmed = TRUE;
-- 管理员登录会话(支持强制登出)
CREATE TABLE public.admin_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id UUID NOT NULL REFERENCES public.platform_admins(id) ON DELETE CASCADE,
session_token VARCHAR(255) UNIQUE NOT NULL, -- 随机安全令牌
ip_address INET NOT NULL,
user_agent TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL, -- 默认 NOW() + 30 分钟,活动时滚动续期
revoked_at TIMESTAMPTZ, -- 强制登出时记录
revoked_by UUID REFERENCES public.platform_admins(id) ON DELETE SET NULL
);
CREATE INDEX idx_admin_sessions_admin ON public.admin_sessions(admin_id) WHERE is_active = TRUE;
CREATE INDEX idx_admin_sessions_expires ON public.admin_sessions(expires_at) WHERE is_active = TRUE;
-- IP 白名单(管理控制台访问限制)
CREATE TABLE public.ip_whitelist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cidr CIDR NOT NULL, -- 如 203.0.113.0/24 或 203.0.113.5/32
label VARCHAR(100), -- 备注,如「上海办公室」
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES public.platform_admins(id) ON DELETE SET NULL
);
CREATE INDEX idx_ip_whitelist_active ON public.ip_whitelist(cidr) WHERE is_active = TRUE;
```
### 2.3 审计日志append-only
```sql
-- ────────────────────────────────────────────────────────────
-- 3. 审计日志append-only
-- ────────────────────────────────────────────────────────────
-- 平台操作审计日志(所有写操作 + 高危操作,无 deleted_at无 UPDATE
CREATE TABLE public.platform_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
operator_id UUID, -- 管理员 IDNULL 表示系统自动操作
operator_name VARCHAR(100), -- 快照(防止账号删除后失去溯源)
action_type VARCHAR(50) NOT NULL,
-- CREATE_TENANT | SUSPEND_TENANT | RESUME_TENANT | DELETE_TENANT | HARD_DELETE_TENANT
-- RESTORE_DATA | TRIGGER_BACKUP | SYSTEM_UPGRADE | ROLLBACK
-- RESET_PASSWORD | CREATE_ADMIN | DEACTIVATE_ADMIN | FORCE_LOGOUT
-- UPDATE_IP_WHITELIST | UPDATE_BACKUP_SCHEDULE | EXPORT_DATA | ...
target_type VARCHAR(30) NOT NULL, -- Tenant | User | System | Backup | Admin
target_id VARCHAR(255), -- 操作对象 IDUUID 或其他)
target_name VARCHAR(255), -- 操作对象可读名称(快照)
payload_summary TEXT, -- 操作内容摘要(非敏感字段)
result VARCHAR(10) NOT NULL DEFAULT 'SUCCESS'
CHECK (result IN ('SUCCESS','FAILED')),
error_message TEXT,
ip_address INET,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- 无 deleted_at无 UPDATE建议按月 RANGE 分区
);
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);
CREATE INDEX idx_audit_logs_target ON public.platform_audit_logs(target_type, target_id, created_at DESC);
CREATE INDEX idx_audit_logs_created ON public.platform_audit_logs(created_at DESC);
```
### 2.4 备份与导出
```sql
-- ────────────────────────────────────────────────────────────
-- 4. 备份与导出
-- ────────────────────────────────────────────────────────────
-- 定时备份计划(全局策略 + 租户覆盖策略)
CREATE TABLE public.backup_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES public.tenants(id) ON DELETE CASCADE,
-- NULL = 全局默认计划;非 NULL = 该租户的独立计划(覆盖全局)
frequency VARCHAR(10) NOT NULL DEFAULT 'daily'
CHECK (frequency IN ('hourly','daily','weekly')),
scheduled_time TIME NOT NULL DEFAULT '02:00', -- 执行时间窗口UTC
retention_count INTEGER NOT NULL DEFAULT 10, -- 最多保留 N 个备份版本
storage_target VARCHAR(20) NOT NULL DEFAULT 'r2'
CHECK (storage_target IN ('local','s3','r2','gcs')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES public.platform_admins(id) ON DELETE SET NULL,
UNIQUE (tenant_id) -- 每个租户最多一条独立计划NULL tenant_id 用应用层保证全局唯一
);
-- 备份任务执行记录
CREATE TABLE public.backup_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
trigger_type VARCHAR(10) NOT NULL
CHECK (trigger_type IN ('auto','manual','pre_upgrade','pre_restore')),
status VARCHAR(15) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','in_progress','success','failed')),
storage_target VARCHAR(20) NOT NULL,
storage_path TEXT, -- R2/S3 存储路径
size_bytes BIGINT, -- 备份包大小
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
triggered_by UUID REFERENCES public.platform_admins(id) ON DELETE SET NULL,
upgrade_event_id UUID, -- 关联升级事件pre_upgrade 类型)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_backup_records_tenant ON public.backup_records(tenant_id, created_at DESC);
CREATE INDEX idx_backup_records_status ON public.backup_records(status)
WHERE status IN ('pending','in_progress');
-- 数据导出任务(异步 Celery 执行)
CREATE TABLE public.export_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
requested_by UUID REFERENCES public.platform_admins(id) ON DELETE SET NULL,
modules TEXT[] NOT NULL,
-- 'clients' | 'properties' | 'transactions' | 'system_config' | 'all'
format VARCHAR(10) NOT NULL
CHECK (format IN ('csv','json','sql_dump')),
status VARCHAR(15) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','in_progress','done','failed')),
storage_path TEXT, -- R2 临时目录路径
download_url TEXT, -- 带签名下载链接
expires_at TIMESTAMPTZ, -- 下载链接有效期(默认 24 小时)
size_bytes BIGINT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_export_tasks_tenant ON public.export_tasks(tenant_id, created_at DESC);
CREATE INDEX idx_export_tasks_status ON public.export_tasks(status)
WHERE status IN ('pending','in_progress');
```
### 2.5 版本升级管理
```sql
-- ────────────────────────────────────────────────────────────
-- 5. 版本升级管理
-- ────────────────────────────────────────────────────────────
-- 平台版本历史
CREATE TABLE public.system_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
version_number VARCHAR(50) UNIQUE NOT NULL, -- 如 v2.3.1
release_notes TEXT,
artifact_url TEXT, -- 制品库地址
status VARCHAR(15) NOT NULL DEFAULT 'previous'
CHECK (status IN ('current','previous','archived')),
released_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES public.platform_admins(id) ON DELETE SET NULL
);
CREATE UNIQUE INDEX idx_system_versions_current ON public.system_versions(status)
WHERE status = 'current'; -- 全局只允许一个 current 版本
-- 升级事件(每次执行升级或回滚对应一条记录)
CREATE TABLE public.upgrade_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
from_version_id UUID REFERENCES public.system_versions(id) ON DELETE SET NULL,
to_version_id UUID NOT NULL REFERENCES public.system_versions(id) ON DELETE RESTRICT,
event_type VARCHAR(10) NOT NULL
CHECK (event_type IN ('upgrade','rollback')),
strategy VARCHAR(10) NOT NULL DEFAULT 'full'
CHECK (strategy IN ('full','canary')), -- full = 全量canary = 灰度
status VARCHAR(15) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','health_check','in_progress','success','failed','rolled_back')),
-- 升级进度:每个租户的状态存为 JSONB 数组
-- [{tenant_id, tenant_name, status, started_at, completed_at, error}]
tenant_progress JSONB NOT NULL DEFAULT '[]',
-- 回滚触发条件
rollback_reason TEXT,
incident_report TEXT, -- 回滚后生成的事件报告
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
initiated_by UUID REFERENCES public.platform_admins(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_upgrade_events_status ON public.upgrade_events(status, created_at DESC);
```
---
## 三、关键约束与禁止操作
| 规则 | 说明 |
|------|------|
| `tenant_status_logs` append-only | 禁止 UPDATE / DELETE状态机变更只追加新行 |
| `platform_audit_logs` append-only | 禁止 UPDATE / DELETE建议按月 RANGE 分区 |
| `public.tenants.schema_name` 不可修改 | 创建后禁止 UPDATEPG schema 绑定 |
| `public.domains.domain` 不可修改 | 子域名创建后禁止 UPDATE |
| `system_versions` 唯一 current | `idx_system_versions_current` 部分唯一索引保证全局只有一个 `status='current'` |
| `backup_schedules.tenant_id` UNIQUE | 每个租户最多一条独立计划;`NULL` 全局计划由应用层保证唯一 |
| `platform_admins``staff` 完全独立 | 不共享表、不共享 auth 系统 |
| MFA 强制 | `platform_admins.mfa_enabled` 在首次 TOTP 确认后才变 TRUE登录流必须检查 |
| `admin_sessions` 30 分钟滚动超时 | 应用层每次活跃请求更新 `expires_at = NOW() + 30min` |
---
## 四、状态机
### 4.1 租户生命周期
```
creating ──(初始化完成)──► active
active ──(逾期/违规/申请)──► suspended ──(恢复条件满足)──► active
active ──(申请注销)──► pending_delete ──(30天后/管理员确认)──► deleted
suspended ──(申请注销)──► pending_delete
creating ──(初始化失败)──► failed
```
**字段映射**
- `status` 枚举:`creating | active | suspended | pending_delete | deleted | failed`
- `suspended_until = NULL`:永久挂起;`suspended_until IS NOT NULL`Celery Beat 定时自动恢复
- `deleted_at`:软删除时间戳;硬删除时物理删除整行
### 4.2 升级事件状态
```
pending → health_check → in_progress → success
└─► failed → rolled_back
```
---
## 五、查询模式
### 5.1 常用查询
```sql
-- 查询所有活跃租户
SELECT id, name, plan, paid_until
FROM public.tenants
WHERE status = 'active'
ORDER BY created_at DESC;
-- 查询即将到期的租户7 天内)
SELECT id, name, contact_email, paid_until
FROM public.tenants
WHERE status = 'active'
AND paid_until BETWEEN CURRENT_DATE AND CURRENT_DATE + 7;
-- 查询灰度租户canary 升级目标)
SELECT id, schema_name, name
FROM public.tenants
WHERE is_canary = TRUE AND status = 'active';
-- 查询某租户所有状态变更历史
SELECT from_status, to_status, reason, operator_name, created_at
FROM public.tenant_status_logs
WHERE tenant_id = $1
ORDER BY created_at DESC;
-- 查询待自动恢复的挂起租户Celery Beat 使用)
SELECT id, schema_name, name
FROM public.tenants
WHERE status = 'suspended'
AND suspended_until IS NOT NULL
AND suspended_until <= NOW();
-- 查询某管理员近 30 天的审计记录
SELECT action_type, target_type, target_name, result, created_at
FROM public.platform_audit_logs
WHERE operator_id = $1
AND created_at >= NOW() - INTERVAL '30 days'
ORDER BY created_at DESC;
-- 查询进行中的备份任务
SELECT br.id, t.name AS tenant_name, br.trigger_type, br.started_at
FROM public.backup_records br
JOIN public.tenants t ON t.id = br.tenant_id
WHERE br.status IN ('pending', 'in_progress')
ORDER BY br.created_at DESC;
```
### 5.2 禁止查询
| 禁止操作 | 原因 |
|----------|------|
| `UPDATE public.tenant_status_logs` | append-only 审计表 |
| `DELETE FROM public.platform_audit_logs` | append-only 审计表 |
| `UPDATE public.tenants SET schema_name = ...` | schema 名绑定 PG 物理 schema |
| `UPDATE public.domains SET domain = ...` | 域名路由不可变 |
---
## 六、版本历史
| 版本 | 日期 | 变更 |
|------|------|------|
| v1.0 | 2026-04-24 | 从 `DATA_MODEL.md §三` 独立拆分;内容等价于 v1.2 DATA_MODEL.md §三 |

View File

@@ -499,6 +499,247 @@
<mxCell id="e-prop-folder-lbl" connectable="0" parent="e-prop-folder" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#fb923c;" value="property_id" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- ═══════════════════════════════════════════════════════════════
PUBLIC SCHEMA SWIMLANE平台运营层
Color: slate-900 bg / cyan-400 accent (#0f172a / #22d3ee variant: #7dd3fc)
Positioned ABOVE existing content at y=-1160
════════════════════════════════════════════════════════════════ -->
<mxCell id="region-public" parent="1"
style="swimlane;startSize=36;fillColor=#0c1a2e;strokeColor=#7dd3fc;fontColor=#7dd3fc;fontSize=13;fontStyle=1;swimlaneLine=1;rounded=1;arcSize=2;"
value="PUBLIC SCHEMA平台运营层" vertex="1">
<mxGeometry height="560" width="3400" x="-860" y="-1200" as="geometry" />
</mxCell>
<!-- ── 1. tenants ── -->
<mxCell id="pub-tenants" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.tenants&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;schema_name: varchar(63) [UNIQUE, immutable]&#xa;name: varchar(255)&#xa;short_name: varchar(100)&#xa;contact_name / contact_email&#xa;region: varchar(100)&#xa;plan: basic/professional/enterprise&#xa;status: creating/active/suspended/&#xa; pending_delete/deleted/failed&#xa;suspended_until: timestamptz&#xa;suspended_reason: varchar(50)&#xa;deleted_at: timestamptz&#xa;paid_until: date&#xa;on_trial: bool&#xa;is_canary: bool&#xa;created_at / updated_at: timestamptz&#xa;created_by: uuid&#xa;extra: jsonb" vertex="1">
<mxGeometry height="310" width="290" x="30" y="50" as="geometry" />
</mxCell>
<!-- ── 2. domains ── -->
<mxCell id="pub-domains" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.domains&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;🔗 FK tenant_id → tenants&#xa;domain: varchar(253) [UNIQUE]&#xa;is_primary: bool [UNIQUE partial]&#xa;created_at: timestamptz&#xa;⚠ domain 创建后不可修改" vertex="1">
<mxGeometry height="135" width="250" x="340" y="50" as="geometry" />
</mxCell>
<!-- ── 3. tenant_status_logs ── -->
<mxCell id="pub-tenant-status-logs" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.tenant_status_logs&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;🔗 FK tenant_id → tenants&#xa;from_status: varchar(20) [NULL=初始]&#xa;to_status: varchar(20)&#xa;reason: text&#xa;operator_id: uuid [NULL=Celery]&#xa;operator_name: varchar(100) [快照]&#xa;created_at: timestamptz&#xa;⚠ append-only — NO UPDATE/DELETE" vertex="1">
<mxGeometry height="175" width="270" x="340" y="200" as="geometry" />
</mxCell>
<!-- ── 4. platform_admins ── -->
<mxCell id="pub-admins" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.platform_admins&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;username: varchar(150) [UNIQUE]&#xa;email: varchar(254) [UNIQUE]&#xa;display_name: varchar(100)&#xa;password_hash: varchar(255)&#xa;role: super_admin/ops_operator/&#xa; read_only_auditor&#xa;is_active: bool&#xa;mfa_enabled: bool [TOTP 确认后→TRUE]&#xa;last_login_at: timestamptz&#xa;created_at / updated_at: timestamptz&#xa;🔗 FK created_by → platform_admins" vertex="1">
<mxGeometry height="225" width="270" x="640" y="50" as="geometry" />
</mxCell>
<!-- ── 5. admin_mfa_devices ── -->
<mxCell id="pub-mfa" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.admin_mfa_devices&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;🔗 FK admin_id → platform_admins&#xa;device_name: varchar(100)&#xa;totp_secret: varchar(255) [加密]&#xa;is_confirmed: bool&#xa;created_at: timestamptz&#xa;last_used_at: timestamptz" vertex="1">
<mxGeometry height="150" width="250" x="930" y="50" as="geometry" />
</mxCell>
<!-- ── 6. admin_sessions ── -->
<mxCell id="pub-sessions" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.admin_sessions&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;🔗 FK admin_id → platform_admins&#xa;session_token: varchar(255) [UNIQUE]&#xa;ip_address: inet&#xa;user_agent: text&#xa;is_active: bool&#xa;created_at: timestamptz&#xa;expires_at: timestamptz [30min rolling]&#xa;revoked_at: timestamptz&#xa;🔗 FK revoked_by → platform_admins" vertex="1">
<mxGeometry height="190" width="255" x="930" y="220" as="geometry" />
</mxCell>
<!-- ── 7. ip_whitelist ── -->
<mxCell id="pub-ip" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.ip_whitelist&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;cidr: cidr [如 203.0.113.0/24]&#xa;label: varchar(100)&#xa;is_active: bool&#xa;created_at: timestamptz&#xa;🔗 FK created_by → platform_admins" vertex="1">
<mxGeometry height="135" width="240" x="640" y="295" as="geometry" />
</mxCell>
<!-- ── 8. platform_audit_logs ── -->
<mxCell id="pub-audit" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.platform_audit_logs&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;operator_id: uuid [NULL=系统]&#xa;operator_name: varchar(100) [快照]&#xa;action_type: varchar(50)&#xa;target_type: varchar(30)&#xa;target_id / target_name: varchar&#xa;payload_summary: text&#xa;result: SUCCESS/FAILED&#xa;error_message: text&#xa;ip_address: inet&#xa;created_at: timestamptz&#xa;⚠ append-only — 建议月度 RANGE 分区" vertex="1">
<mxGeometry height="225" width="265" x="1210" y="50" as="geometry" />
</mxCell>
<!-- ── 9. backup_schedules ── -->
<mxCell id="pub-bk-schedules" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.backup_schedules&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;🔗 FK tenant_id → tenants [NULL=全局]&#xa;frequency: hourly/daily/weekly&#xa;scheduled_time: time [UTC]&#xa;retention_count: int&#xa;storage_target: local/s3/r2/gcs&#xa;is_active: bool&#xa;created_at / updated_at: timestamptz&#xa;🔗 FK created_by → platform_admins&#xa;UNIQUE(tenant_id)" vertex="1">
<mxGeometry height="195" width="265" x="1510" y="50" as="geometry" />
</mxCell>
<!-- ── 10. backup_records ── -->
<mxCell id="pub-bk-records" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.backup_records&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;🔗 FK tenant_id → tenants&#xa;trigger_type: auto/manual/&#xa; pre_upgrade/pre_restore&#xa;status: pending/in_progress/&#xa; success/failed&#xa;storage_target / storage_path: text&#xa;size_bytes: bigint&#xa;started_at / completed_at&#xa;error_message: text&#xa;🔗 FK triggered_by → platform_admins&#xa;upgrade_event_id: uuid&#xa;created_at: timestamptz" vertex="1">
<mxGeometry height="245" width="265" x="1510" y="270" as="geometry" />
</mxCell>
<!-- ── 11. export_tasks ── -->
<mxCell id="pub-exports" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.export_tasks&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;🔗 FK tenant_id → tenants&#xa;🔗 FK requested_by → platform_admins&#xa;modules: text[]&#xa;format: csv/json/sql_dump&#xa;status: pending/in_progress/done/failed&#xa;storage_path / download_url: text&#xa;expires_at: timestamptz [24h]&#xa;size_bytes: bigint&#xa;started_at / completed_at&#xa;created_at: timestamptz" vertex="1">
<mxGeometry height="215" width="265" x="1210" y="300" as="geometry" />
</mxCell>
<!-- ── 12. system_versions ── -->
<mxCell id="pub-versions" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.system_versions&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;version_number: varchar(50) [UNIQUE]&#xa;release_notes: text&#xa;artifact_url: text&#xa;status: current/previous/archived&#xa; [UNIQUE partial: status='current']&#xa;released_at: timestamptz&#xa;🔗 FK created_by → platform_admins" vertex="1">
<mxGeometry height="165" width="265" x="1810" y="50" as="geometry" />
</mxCell>
<!-- ── 13. upgrade_events ── -->
<mxCell id="pub-upgrades" parent="region-public"
style="text;html=1;strokeColor=#7dd3fc;fillColor=#0c1a2e;align=left;verticalAlign=top;spacingLeft=8;spacingTop=4;overflow=hidden;rotatable=0;fontSize=10;fontFamily=monospace;fontColor=#e2e8f0;whiteSpace=pre;"
value="&lt;b&gt;public.upgrade_events&lt;/b&gt;&#xa;&lt;hr/&gt;&#xa;🔑 PK id: uuid&#xa;🔗 FK from_version_id → system_versions&#xa;🔗 FK to_version_id → system_versions&#xa;event_type: upgrade/rollback&#xa;strategy: full/canary&#xa;status: pending/health_check/&#xa; in_progress/success/failed/rolled_back&#xa;tenant_progress: jsonb [{tenant,status,...}]&#xa;rollback_reason: text&#xa;incident_report: text&#xa;started_at / completed_at&#xa;🔗 FK initiated_by → platform_admins&#xa;created_at: timestamptz" vertex="1">
<mxGeometry height="255" width="280" x="1810" y="240" as="geometry" />
</mxCell>
<!-- ── Internal edges (PUBLIC schema) ── -->
<!-- tenants → domains -->
<mxCell id="pe-tenant-domain" edge="1" parent="region-public" source="pub-tenants" target="pub-domains"
style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeColor=#7dd3fc;endArrow=ERmany;startArrow=ERone;fontSize=9;">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="pe-tenant-domain-lbl" connectable="0" parent="pe-tenant-domain"
style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#7dd3fc;" value="1:N" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- tenants → tenant_status_logs -->
<mxCell id="pe-tenant-statuslog" edge="1" parent="region-public" source="pub-tenants" target="pub-tenant-status-logs"
style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeColor=#7dd3fc;endArrow=ERmany;startArrow=ERone;fontSize=9;">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="320" y="295" />
<mxPoint x="320" y="288" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="pe-tenant-statuslog-lbl" connectable="0" parent="pe-tenant-statuslog"
style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#7dd3fc;" value="1:N append-only" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- platform_admins → admin_mfa_devices -->
<mxCell id="pe-admin-mfa" edge="1" parent="region-public" source="pub-admins" target="pub-mfa"
style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeColor=#7dd3fc;endArrow=ERmany;startArrow=ERone;fontSize=9;">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="pe-admin-mfa-lbl" connectable="0" parent="pe-admin-mfa"
style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#7dd3fc;" value="1:N" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- platform_admins → admin_sessions -->
<mxCell id="pe-admin-session" edge="1" parent="region-public" source="pub-admins" target="pub-sessions"
style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeColor=#7dd3fc;endArrow=ERmany;startArrow=ERone;fontSize=9;">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="905" y="235" />
<mxPoint x="905" y="315" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="pe-admin-session-lbl" connectable="0" parent="pe-admin-session"
style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#7dd3fc;" value="1:N" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- platform_admins → ip_whitelist (created_by) -->
<mxCell id="pe-admin-ip" edge="1" parent="region-public" source="pub-admins" target="pub-ip"
style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeColor=#7dd3fc;dashed=1;endArrow=ERmany;startArrow=ERone;fontSize=9;">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="755" y="280" />
<mxPoint x="755" y="363" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="pe-admin-ip-lbl" connectable="0" parent="pe-admin-ip"
style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#7dd3fc;" value="created_by" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- tenants → backup_schedules -->
<mxCell id="pe-tenant-bksched" edge="1" parent="region-public" source="pub-tenants" target="pub-bk-schedules"
style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeColor=#7dd3fc;dashed=1;endArrow=ERmany;startArrow=ERone;fontSize=9;">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="320" y="430" />
<mxPoint x="1642" y="430" />
<mxPoint x="1642" y="248" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="pe-tenant-bksched-lbl" connectable="0" parent="pe-tenant-bksched"
style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#7dd3fc;" value="1:N (NULL=全局)" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- tenants → backup_records -->
<mxCell id="pe-tenant-bkrec" edge="1" parent="region-public" source="pub-tenants" target="pub-bk-records"
style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeColor=#7dd3fc;dashed=1;endArrow=ERmany;startArrow=ERone;fontSize=9;">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="320" y="480" />
<mxPoint x="1642" y="480" />
<mxPoint x="1642" y="395" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="pe-tenant-bkrec-lbl" connectable="0" parent="pe-tenant-bkrec"
style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#7dd3fc;" value="1:N" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- tenants → export_tasks -->
<mxCell id="pe-tenant-export" edge="1" parent="region-public" source="pub-tenants" target="pub-exports"
style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeColor=#7dd3fc;dashed=1;endArrow=ERmany;startArrow=ERone;fontSize=9;">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="320" y="520" />
<mxPoint x="1342" y="520" />
<mxPoint x="1342" y="408" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="pe-tenant-export-lbl" connectable="0" parent="pe-tenant-export"
style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#7dd3fc;" value="1:N" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- system_versions → upgrade_events (from/to) -->
<mxCell id="pe-ver-upgrade" edge="1" parent="region-public" source="pub-versions" target="pub-upgrades"
style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeColor=#7dd3fc;endArrow=ERmany;startArrow=ERone;fontSize=9;">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="pe-ver-upgrade-lbl" connectable="0" parent="pe-ver-upgrade"
style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#7dd3fc;" value="from/to version" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<!-- upgrade_events → backup_records (upgrade_event_id) -->
<mxCell id="pe-upgrade-bkrec" edge="1" parent="region-public" source="pub-upgrades" target="pub-bk-records"
style="edgeStyle=orthogonalEdgeStyle;rounded=0;strokeColor=#7dd3fc;dashed=1;endArrow=ERmany;startArrow=ERone;fontSize=9;">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1808" y="392" />
<mxPoint x="1775" y="392" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="pe-upgrade-bkrec-lbl" connectable="0" parent="pe-upgrade-bkrec"
style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;fontSize=9;fontColor=#7dd3fc;" value="upgrade_event_id" vertex="1">
<mxGeometry relative="1" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>