1107 lines
60 KiB
Markdown
1107 lines
60 KiB
Markdown
> **For AI assistants**: Read this entire file before writing any code. All decisions here are final. Do not suggest alternatives unless asked.
|
||
# Fonrey — Public Schema 数据模型
|
||
|
||
> **作者**: Backend Architect
|
||
> **版本**: v1.7
|
||
> **日期**: 2026-05-02
|
||
> **权威源**: 本文件是 `public` schema 所有表的唯一权威定义
|
||
> **设计依据**: 平台管理后台 PRD(`PRD/平台管理后台/平台管理后台PRD.md`)
|
||
> **索引文档**: [`DATA_MODEL.md §三`](./DATA_MODEL.md)(仅保留摘要索引,开发以本文件为准)
|
||
|
||
---
|
||
|
||
## 变更历史
|
||
|
||
| 日期 | 变更人 | 变更内容 |
|
||
|---|---|---|
|
||
| 2026-05-02 | Atlas | 对齐平台管理后台 PRD v1.0:`tenants` 增加 License 授权用户数上限字段 `license_user_limit`(含约束、索引、查询示例);`suspended_reason` 增加 `license_expired`;审计动作补充 `UPDATE_LICENSE_USER_LIMIT` 与 `AUTO_SUSPEND_LICENSE_EXPIRED`;`domains` 标注为统一 `tenant=` 路由下的兼容保留;同步更新元信息与版本历史。 |
|
||
| 2026-05-02 | Atlas | v1.7 架构师审核修复:`platform_audit_logs.result` 枚举值改小写;`export_tasks.status` 枚举值 `done`→`success`;补充附录 M 数据迁移脚本。 |
|
||
| 2026-04-30 | Atlas | 补充“变更历史”章节(文档治理) |
|
||
|
||
## 一、概览
|
||
|
||
`public` schema 存储**平台运营层**数据,与各租户的业务 schema 完全隔离。
|
||
|
||
### 架构定位
|
||
|
||
```
|
||
PostgreSQL Instance
|
||
│
|
||
├── public schema(平台运营层)← 本文件覆盖
|
||
│ ├── tenants 租户注册与生命周期(含 feature_flags JSONB)
|
||
│ ├── 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 升级事件(A/B/C 分级 + B 类分批编排)
|
||
│ ├── client_releases 客户端版本发布
|
||
│ ├── client_heartbeats 客户端启动心跳(活跃统计 + 版本分布)
|
||
│ ├── feature_flag_definitions Feature Flag 定义(C 类灰度控制平面)
|
||
│ └── feature_flag_change_log Feature Flag 变更历史(append-only)
|
||
│
|
||
├── tenant_abc schema(租户业务层,见各子文档)
|
||
└── tenant_xyz schema
|
||
```
|
||
|
||
### 表清单
|
||
|
||
| 表名 | 说明 | 节 |
|
||
|------|------|----|
|
||
| `public.tenants` | 租户主表(每家房产公司一条记录) | §2.1 |
|
||
| `public.domains` | 域名↔租户映射(兼容保留;主路径已切换统一 `tenant=` 参数路由) | §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` | 升级/回滚事件,A/B/C 升级类型分级,B 类按租户分批编排 + 健康门控 | §2.5 |
|
||
| `public.client_releases` | Windows 客户端发布版本,含安装包 URL、SHA256、强制更新标记 | §2.6 |
|
||
| `public.client_heartbeats` | 客户端启动心跳(活跃统计 + 版本分布支撑) | §2.6 |
|
||
| `public.feature_flag_definitions` | Feature Flag 全局定义(C 类运行时灰度控制平面) | §2.7 |
|
||
| `public.tenants.feature_flags` | 租户级 Flag 显式覆盖(`tenants` 表新增 JSONB 列) | §2.7 |
|
||
| `public.feature_flag_change_log` | Feature Flag 变更历史(append-only) | §2.7 |
|
||
|
||
---
|
||
|
||
## 二、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 字符,创建后不可修改;命名规则:t_{uuid前8位},如 t_3f2a1b4c
|
||
tenant_code CHAR(12) UNIQUE NOT NULL, -- 对外暴露的 12 位纯数字识别码,如 202500010001;用户登录时输入,由平台运营在注册时生成;创建后不可修改
|
||
name VARCHAR(255) NOT NULL, -- 公司名称
|
||
short_name VARCHAR(100), -- 简称/品牌名
|
||
contact_name VARCHAR(100) NOT NULL, -- 主联系人姓名
|
||
contact_phone CHAR(11) NOT NULL, -- 联系人手机号(11位纯数字);系统开通租户时自动以此手机号创建 Tenant Admin 登录账号;必填
|
||
contact_email VARCHAR(254), -- 联系邮箱(接收通知/欢迎邮件;选填,为空时 Tenant Admin 无法自助找回密码)
|
||
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','license_expired')),
|
||
deleted_at TIMESTAMPTZ, -- 软删除时间戳;硬删除直接物理删除行
|
||
|
||
-- 订阅
|
||
paid_until DATE, -- 订阅到期日(License 到期日期)
|
||
license_user_limit INTEGER NOT NULL DEFAULT 50
|
||
CHECK (license_user_limit > 0), -- License 授权用户数上限(含 Tenant Admin + Agent)
|
||
on_trial BOOLEAN NOT NULL DEFAULT TRUE,
|
||
|
||
-- 灰度升级
|
||
is_canary BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE = 内测租户,参与灰度升级
|
||
|
||
-- Feature Flag 租户级覆盖(详见 §2.7)
|
||
-- 格式: {"flag_key": true|false, ...}
|
||
-- 业务代码只读;写入由 apps.admin_console 经 feature_flag_change_log 审计
|
||
feature_flags JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||
|
||
-- 元数据
|
||
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 INDEX idx_tenants_license_user_limit ON public.tenants(license_user_limit);
|
||
CREATE INDEX idx_tenants_paid_until_active ON public.tenants(paid_until)
|
||
WHERE status = 'active';
|
||
-- Feature Flag 租户级覆盖的 GIN 索引(详见 §2.7)
|
||
-- 同时在 §2.7 中以 ALTER TABLE ADD COLUMN IF NOT EXISTS 兜底,便于按模块独立部署
|
||
CREATE INDEX idx_tenants_feature_flags_gin ON public.tenants USING gin (feature_flags);
|
||
|
||
-- 域名映射表(兼容保留:主路径已切换统一 `tenant=` 参数路由;子域名创建后不可修改)
|
||
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, -- 操作管理员 ID;NULL = 系统自动(Celery)
|
||
operator_name VARCHAR(100), -- 快照,防止管理员被删后失去可追溯性
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
-- 无 deleted_at,无 UPDATE,append-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 NOT NULL DEFAULT gen_random_uuid(),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 分区键
|
||
operator_id UUID, -- 管理员 ID;NULL 表示系统自动操作
|
||
operator_name VARCHAR(100), -- 快照(防止账号删除后失去溯源)
|
||
action_type VARCHAR(50) NOT NULL,
|
||
-- CREATE_TENANT | SUSPEND_TENANT | RESUME_TENANT | DELETE_TENANT | HARD_DELETE_TENANT
|
||
-- UPDATE_LICENSE | UPDATE_LICENSE_USER_LIMIT | AUTO_SUSPEND_LICENSE_EXPIRED
|
||
-- 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), -- 操作对象 ID(UUID 或其他)
|
||
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,
|
||
-- 无 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);
|
||
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','success','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 版本
|
||
|
||
-- 升级事件(每次执行升级或回滚对应一条记录)
|
||
-- 设计依据: 系统管理技术文档 §8.5–§8.6(A/B/C 升级类型分级 + B 类分批编排)
|
||
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')),
|
||
|
||
-- 升级类型(决定本表大部分字段是否生效;详见技术文档 §8.5)
|
||
-- A_app = 应用代码升级(蓝绿全量切换,灰度名单/批次字段无效)
|
||
-- B_schema = 租户 Schema 迁移(按租户分批,批次字段全部生效)
|
||
-- C_feature = Feature Flag 灰度(不在本表编排,详见 §2.7;本字段保留以便统一记录)
|
||
upgrade_type VARCHAR(16) NOT NULL DEFAULT 'B_schema'
|
||
CHECK (upgrade_type IN ('A_app','B_schema','C_feature')),
|
||
|
||
strategy VARCHAR(10) NOT NULL DEFAULT 'full'
|
||
CHECK (strategy IN ('full','canary')), -- full = 全量,canary = 灰度
|
||
|
||
-- 升级编排状态机(与技术文档 §8.6.1 一致)
|
||
-- draft → pre_check → pre_backup → batch_running ⇄ batch_done → succeeded
|
||
-- ↓ 任一批失败/门控不通过
|
||
-- halted → rollback_running → rolled_back
|
||
-- ↓ 整体失败
|
||
-- failed
|
||
status VARCHAR(20) NOT NULL DEFAULT 'draft'
|
||
CHECK (status IN (
|
||
'draft','pre_check','pre_backup','batch_running','batch_done',
|
||
'halted','succeeded','failed','rollback_running','rolled_back'
|
||
)),
|
||
halted_reason TEXT, -- halted 状态时记录失败指标快照(健康门控不通过原因)
|
||
|
||
-- 灰度名单(B_schema 类型生效;A_app 类型必须为空数组)
|
||
-- [tenant_id, ...]
|
||
gray_tenant_ids JSONB NOT NULL DEFAULT '[]',
|
||
|
||
-- 批次编排参数(B_schema 类型生效)
|
||
batch_size INTEGER NOT NULL DEFAULT 5
|
||
CHECK (batch_size BETWEEN 1 AND 100), -- 每批包含的租户数
|
||
batch_concurrency INTEGER NOT NULL DEFAULT 2
|
||
CHECK (batch_concurrency BETWEEN 1 AND 20), -- 批内并发执行的租户数
|
||
batch_interval_seconds INTEGER NOT NULL DEFAULT 300
|
||
CHECK (batch_interval_seconds BETWEEN 0 AND 86400), -- 批间观察窗口(秒)
|
||
failure_policy VARCHAR(16) NOT NULL DEFAULT 'halt_batch'
|
||
CHECK (failure_policy IN ('halt_batch','continue')),
|
||
-- halt_batch = 任一租户失败立即中断当前批,进入 halted 等待人工
|
||
-- continue = 其他租户继续,仅标记失败(适用低风险变更)
|
||
current_batch_no INTEGER NOT NULL DEFAULT 0, -- 已完成批次序号
|
||
total_batch_count INTEGER, -- 总批次数 = ceil(len(gray_tenant_ids)/batch_size)
|
||
|
||
-- 健康门控阈值(覆盖默认值;默认值见技术文档 §8.6.5)
|
||
-- {"error_rate_5xx_5m": 0.005, "p95_latency_5m": 2000,
|
||
-- "celery_queue_pending": 1000, "sentry_new_issues_5m": 5}
|
||
health_gate_config JSONB NOT NULL DEFAULT '{}',
|
||
|
||
-- 升级前全局备份引用(B_schema 必须;恢复兜底用)
|
||
pre_backup_record_id UUID REFERENCES public.backup_records(id) ON DELETE SET NULL,
|
||
|
||
-- 单租户进度快照
|
||
-- 数组格式(保留向后兼容):
|
||
-- [{tenant_id, tenant_name, status, started_at, completed_at,
|
||
-- snapshot_id, batch_no, error}]
|
||
-- status 取值:pending | running | success | failed | rolled_back
|
||
tenant_progress JSONB NOT NULL DEFAULT '[]',
|
||
|
||
-- 回滚相关
|
||
rollback_reason TEXT,
|
||
incident_report TEXT, -- 回滚后生成的事件报告(halted 转 rolled_back 时写)
|
||
|
||
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(),
|
||
|
||
-- 数据完整性约束:A_app 类型不允许带灰度名单(蓝绿全量切换)
|
||
CONSTRAINT chk_app_upgrade_no_gray
|
||
CHECK (upgrade_type <> 'A_app' OR gray_tenant_ids = '[]'::jsonb)
|
||
);
|
||
|
||
CREATE INDEX idx_upgrade_events_status ON public.upgrade_events(status, created_at DESC);
|
||
CREATE INDEX idx_upgrade_events_type ON public.upgrade_events(upgrade_type, created_at DESC);
|
||
-- 用于「halted 状态需要超管处理」的实时告警查询
|
||
CREATE INDEX idx_upgrade_events_halted ON public.upgrade_events(created_at DESC)
|
||
WHERE status = 'halted';
|
||
```
|
||
|
||
### 2.6 客户端发布管理
|
||
|
||
```sql
|
||
-- ────────────────────────────────────────────────────────────
|
||
-- 6. 客户端发布管理
|
||
-- ────────────────────────────────────────────────────────────
|
||
-- 设计依据: 客户端发布管理模块 PRD §5.3
|
||
-- 说明: 本表属于 shared_apps(public schema),所有租户共享同一套客户端版本,
|
||
-- 不做多租户隔离。
|
||
|
||
-- 客户端版本发布表
|
||
CREATE TABLE public.client_releases (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
||
-- 版本标识
|
||
version VARCHAR(20) UNIQUE NOT NULL,
|
||
-- SemVer 格式,如 '1.2.3';由应用层校验格式,数据库层仅保证唯一性
|
||
platform VARCHAR(20) NOT NULL DEFAULT 'win32'
|
||
CHECK (platform IN ('win32')),
|
||
-- 当前仅支持 Windows;后续支持 darwin / linux 时扩展 CHECK
|
||
arch VARCHAR(10) NOT NULL DEFAULT 'x64'
|
||
CHECK (arch IN ('x64', 'arm64')),
|
||
|
||
-- 版本类型与状态
|
||
release_type VARCHAR(10) NOT NULL DEFAULT 'normal'
|
||
CHECK (release_type IN ('normal', 'force')),
|
||
-- normal = 普通更新,提示但可延后;force = 强制升级,不可跳过
|
||
status VARCHAR(10) NOT NULL DEFAULT 'draft'
|
||
CHECK (status IN ('draft', 'published', 'archived')),
|
||
-- draft = 草稿,不对外生效;published = 已发布,客户端可感知;archived = 已下线
|
||
|
||
-- 兼容性约束
|
||
min_required_version VARCHAR(20),
|
||
-- 低于该版本的客户端将被强制要求升级,NULL = 无最低版本限制
|
||
-- 由应用层在查询时比较 SemVer,数据库层不做运算
|
||
|
||
-- 安装包(EXE)
|
||
download_url TEXT NOT NULL,
|
||
-- Cloudflare R2 公开 URL,格式:
|
||
-- https://download.fonrey.com/releases/v{version}/fonrey-setup-{version}-win.exe
|
||
checksum_sha256 VARCHAR(64) NOT NULL,
|
||
-- 安装包 SHA256 十六进制字符串(64 位),客户端下载完成后校验
|
||
file_size_bytes BIGINT,
|
||
-- 安装包字节大小,用于前端展示下载大小
|
||
|
||
-- 便携版(ZIP,可选)
|
||
portable_url TEXT,
|
||
-- 无需安装的 ZIP 版本,供无管理员权限的企业环境使用
|
||
portable_checksum_sha256 VARCHAR(64),
|
||
|
||
-- 更新内容
|
||
release_notes TEXT NOT NULL,
|
||
-- 对外展示的更新日志,Markdown 格式,最多 2000 字
|
||
internal_notes TEXT,
|
||
-- 内部技术说明,不对外展示
|
||
|
||
-- 统计
|
||
download_count INTEGER NOT NULL DEFAULT 0,
|
||
-- 该版本安装包被下载次数,由应用层在每次下载时原子递增
|
||
|
||
-- 时间与操作人
|
||
published_at TIMESTAMPTZ,
|
||
-- 首次设为 published 时记录,后续状态变更不更新此字段
|
||
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,
|
||
published_by UUID REFERENCES public.platform_admins(id) ON DELETE SET NULL
|
||
);
|
||
|
||
-- 只允许一条 published 记录(同平台+架构下的当前生效版本)
|
||
CREATE UNIQUE INDEX idx_client_releases_published
|
||
ON public.client_releases(platform, arch)
|
||
WHERE status = 'published';
|
||
|
||
-- 快速查找草稿/已发布版本(管理后台列表查询)
|
||
CREATE INDEX idx_client_releases_status ON public.client_releases(status, created_at DESC);
|
||
|
||
-- 按版本号快速定位(客户端更新检测时传入 current_version 查询)
|
||
CREATE INDEX idx_client_releases_version ON public.client_releases(version);
|
||
|
||
-- ────────────────────────────────────────────────────────────
|
||
-- 客户端启动心跳表(活跃统计 + 版本分布数据支撑)
|
||
-- ────────────────────────────────────────────────────────────
|
||
-- 设计依据: 客户端发布管理模块 PRD §5.5 Story 5(客户端版本分布查询)
|
||
-- 上报模型: 客户端 App 每次启动时 Upsert 一条记录(by tenant_id + device_id)
|
||
-- 归属说明: 本表属于 shared_apps(public schema)。虽然 tenant_id 字段做了租户归属,
|
||
-- 但 device_id 由客户端首次安装时生成(本机 UUID),跨租户全局唯一管理;
|
||
-- 将本表放在 public schema 便于平台运营做全量版本分布、活跃数统计,
|
||
-- 避免跨所有 tenant schema 做 UNION ALL。
|
||
-- 隐私说明: 不收集任何业务数据,仅用于平台运营版本管理与升级决策。
|
||
|
||
CREATE TABLE public.client_heartbeats (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
||
-- 归属
|
||
tenant_id UUID NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
|
||
-- 心跳上报时所属租户(由客户端登录态决定,从 Tenant Code 解析得到);
|
||
-- 是"按租户统计安装数 / 活跃数 / 版本分布"的核心维度字段,
|
||
-- 配合 idx_client_heartbeats_tenant_lastseen 索引支撑租户维度聚合查询;
|
||
-- 租户被硬删除时心跳一并清理(CASCADE);
|
||
-- 同一物理设备登录不同租户视作多条独立记录(Upsert 锚点为 (tenant_id, device_id))
|
||
device_id UUID NOT NULL,
|
||
-- 客户端首次安装时本机生成并持久化的设备 UUID;
|
||
-- 卸载重装会变化(视作新设备);同一设备换租户登录视作不同记录
|
||
user_id UUID,
|
||
-- 上次启动时登录的用户 ID(指向 tenant schema 的 staff.id);
|
||
-- 不加外键约束(跨 schema 无法外键),仅作排查辅助;
|
||
-- 未登录或退出后启动允许为 NULL
|
||
|
||
-- 版本与环境
|
||
client_version VARCHAR(20) NOT NULL,
|
||
-- 上报时客户端 SemVer 版本号,如 '1.2.3'
|
||
platform VARCHAR(20) NOT NULL DEFAULT 'win32'
|
||
CHECK (platform IN ('win32')),
|
||
arch VARCHAR(10) NOT NULL DEFAULT 'x64'
|
||
CHECK (arch IN ('x64', 'arm64')),
|
||
os_version VARCHAR(80),
|
||
-- 操作系统版本字符串,如 'Windows 10 22H2';可选
|
||
|
||
-- 时间戳
|
||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
-- 该 (tenant_id, device_id) 组合首次心跳时间,Upsert 时不更新
|
||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
-- 最近一次启动心跳时间,Upsert 时刷新为 NOW()
|
||
launch_count INTEGER NOT NULL DEFAULT 1,
|
||
-- 累计启动次数,Upsert 时 +1,用于排查异常高频启动
|
||
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
||
-- 同一租户下同一设备唯一一条记录(Upsert 锚点)
|
||
CONSTRAINT uq_client_heartbeats_tenant_device UNIQUE (tenant_id, device_id)
|
||
);
|
||
|
||
-- 活跃数统计("最近 24h 内有心跳")核心索引
|
||
CREATE INDEX idx_client_heartbeats_last_seen
|
||
ON public.client_heartbeats(last_seen_at DESC);
|
||
|
||
-- 版本分布统计:按版本聚合活跃设备数
|
||
CREATE INDEX idx_client_heartbeats_version_lastseen
|
||
ON public.client_heartbeats(client_version, last_seen_at DESC);
|
||
|
||
-- 租户维度排查:某租户下所有活跃设备
|
||
CREATE INDEX idx_client_heartbeats_tenant_lastseen
|
||
ON public.client_heartbeats(tenant_id, last_seen_at DESC);
|
||
```
|
||
|
||
### 2.7 Feature Flag 灰度体系
|
||
|
||
```sql
|
||
-- ────────────────────────────────────────────────────────────
|
||
-- 7. Feature Flag 灰度体系
|
||
-- ────────────────────────────────────────────────────────────
|
||
-- 设计依据: 系统管理技术文档 §8.7(C 类升级 / 运行时功能灰度)
|
||
-- 说明: 与 §2.5 upgrade_events 的 B 类(schema 迁移)配合使用,
|
||
-- 负责「行为切换」的运行时灰度。详见技术文档 §8.7.6 四步发布流程。
|
||
|
||
-- 全局 Flag 注册表(控制平面)
|
||
CREATE TABLE public.feature_flag_definitions (
|
||
key VARCHAR(64) PRIMARY KEY,
|
||
-- 命名规范:snake_case,业务前缀 + 功能名 + 版本号
|
||
-- 例:'property_ai_description_v2' / 'search_ranking_algo_2026q2'
|
||
description TEXT NOT NULL, -- 业务说明(必填,便于审计回溯)
|
||
default_value BOOLEAN NOT NULL DEFAULT FALSE,
|
||
-- 未在 tenants.feature_flags 显式覆盖、且不命中 rollout_strategy 时返回此值
|
||
|
||
-- 灰度策略
|
||
rollout_strategy VARCHAR(16) NOT NULL DEFAULT 'tenant'
|
||
CHECK (rollout_strategy IN ('tenant','percentage','user')),
|
||
-- tenant = 仅按 tenants.feature_flags 显式开关,不做百分比
|
||
-- percentage = 按租户 ID 稳定哈希进百分比桶(rollout_config.percentage)
|
||
-- user = 按用户 ID 稳定哈希(rollout_config.percentage),需调用方传 user
|
||
rollout_config JSONB NOT NULL DEFAULT '{}',
|
||
-- percentage / user 策略下:{"percentage": 30}
|
||
-- tenant 策略下:通常为空 {}
|
||
|
||
owner_admin_id UUID REFERENCES public.platform_admins(id) ON DELETE SET NULL,
|
||
-- 责任人(用于到期清理提醒、问题归因)
|
||
|
||
-- 生命周期
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
archived_at TIMESTAMPTZ,
|
||
-- 归档后所有查询永久返回 FALSE;不删除以保留审计链路
|
||
archived_reason TEXT
|
||
);
|
||
|
||
CREATE INDEX idx_ff_def_active ON public.feature_flag_definitions(rollout_strategy)
|
||
WHERE archived_at IS NULL;
|
||
CREATE INDEX idx_ff_def_owner ON public.feature_flag_definitions(owner_admin_id)
|
||
WHERE archived_at IS NULL;
|
||
|
||
-- 租户级 Flag 覆盖(存于 tenants 表的 JSONB 列)
|
||
-- 设计上不单建表,避免每次 is_enabled() 多查一次 JOIN;
|
||
-- public.tenants.feature_flags 列与 idx_tenants_feature_flags_gin 索引已在 §2.1 声明,
|
||
-- 此处用 IF NOT EXISTS 兜底,便于本节单独执行(如分阶段迁移):
|
||
ALTER TABLE public.tenants
|
||
ADD COLUMN IF NOT EXISTS feature_flags JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||
-- 格式:{"flag_key": true|false, ...}
|
||
-- 仅 platform_admin 通过本模块写入;业务代码只读
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_tenants_feature_flags_gin
|
||
ON public.tenants USING gin (feature_flags);
|
||
|
||
-- Flag 变更历史(append-only,与 platform_audit_logs 同等约束)
|
||
CREATE TABLE public.feature_flag_change_log (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
flag_key VARCHAR(64) NOT NULL,
|
||
-- 不加外键引用 feature_flag_definitions(key):归档/重命名时不应连带删除审计
|
||
tenant_id UUID REFERENCES public.tenants(id) ON DELETE SET NULL,
|
||
-- NULL 表示全局变更(如调整 rollout_strategy / percentage / archive)
|
||
-- 非 NULL 表示该租户的覆盖值变更
|
||
change_scope VARCHAR(20) NOT NULL
|
||
CHECK (change_scope IN (
|
||
'definition_create','definition_update','definition_archive',
|
||
'tenant_override_set','tenant_override_clear'
|
||
)),
|
||
old_value JSONB, -- 变更前的完整值(NULL 表示首次创建)
|
||
new_value JSONB NOT NULL, -- 变更后的完整值
|
||
operator_id UUID NOT NULL REFERENCES public.platform_admins(id) ON DELETE RESTRICT,
|
||
-- 强制保留操作人;账号停用不可级联删除审计
|
||
operator_name VARCHAR(100) NOT NULL, -- 快照
|
||
reason TEXT NOT NULL, -- 强制填写变更原因(业务流程要求)
|
||
ip_address INET,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
-- 无 deleted_at,无 UPDATE,append-only
|
||
);
|
||
|
||
CREATE INDEX idx_ff_log_flag ON public.feature_flag_change_log(flag_key, created_at DESC);
|
||
CREATE INDEX idx_ff_log_tenant ON public.feature_flag_change_log(tenant_id, created_at DESC)
|
||
WHERE tenant_id IS NOT NULL;
|
||
CREATE INDEX idx_ff_log_operator ON public.feature_flag_change_log(operator_id, created_at DESC);
|
||
```
|
||
|
||
---
|
||
|
||
## 三、关键约束与禁止操作
|
||
|
||
| 规则 | 说明 |
|
||
|------|------|
|
||
| `tenant_status_logs` append-only | 禁止 UPDATE / DELETE;状态机变更只追加新行 |
|
||
| `platform_audit_logs` append-only | 禁止 UPDATE / DELETE;建议按月 RANGE 分区 |
|
||
| `public.tenants.schema_name` 不可修改 | 创建后禁止 UPDATE,PG schema 绑定 |
|
||
| `public.domains.domain` 不可修改(兼容保留) | `tenant=` 统一路由方案下,`domains` 仅用于历史兼容与迁移窗口;子域名创建后禁止 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` |
|
||
| `client_releases` 唯一 published | `idx_client_releases_published` 部分唯一索引保证同平台+架构下只有一条 `status='published'` |
|
||
| `client_releases` 不可跨租户隔离 | 本表属于 public schema,所有租户共享;禁止在租户 schema 中创建副本 |
|
||
| `client_releases.download_count` 原子递增 | 必须使用 `UPDATE ... SET download_count = download_count + 1`,禁止先读后写 |
|
||
| `client_heartbeats` Upsert 锚点 | 必须使用 `INSERT ... ON CONFLICT (tenant_id, device_id) DO UPDATE SET last_seen_at=NOW(), launch_count=client_heartbeats.launch_count+1, client_version=EXCLUDED.client_version`;禁止先 SELECT 再 INSERT |
|
||
| `client_heartbeats` 仅启动时上报 | 客户端 App 仅在每次启动时上报一次心跳;不做 4h 周期心跳,避免 public schema 写入压力 |
|
||
| `client_heartbeats` 不可跨租户隔离 | 本表属于 public schema,所有租户共享;禁止在租户 schema 中创建副本 |
|
||
| `upgrade_events.gray_tenant_ids` 与 `upgrade_type` 一致性 | A_app 类型必须为空数组(CHECK 约束 `chk_app_upgrade_no_gray` 强制);只有 B_schema 类型才使用灰度名单与批次字段 |
|
||
| `upgrade_events` 状态流转单向 | 状态机详见 §4.2;`succeeded` / `rolled_back` / `failed` 为终态,禁止再写回中间态 |
|
||
| `feature_flag_change_log` append-only | 禁止 UPDATE / DELETE;任何 Flag 变更都必须经此表追溯,含 `reason` 强制非空 |
|
||
| `feature_flag_definitions.key` 不可修改 | 创建后禁止 UPDATE 主键;废弃 Flag 走 `archived_at` 软归档 |
|
||
| `tenants.feature_flags` 写入路径强约束 | 仅允许 `apps.admin_console.services.feature_flags` 写入;业务代码只读,且必须经 `is_enabled()` 服务接口(不得直读 JSONB) |
|
||
|
||
---
|
||
|
||
## 四、状态机
|
||
|
||
### 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 升级事件状态
|
||
|
||
```
|
||
draft ──提交──► pre_check ──健康检查通过──► pre_backup ──全局备份完成──► batch_running
|
||
│
|
||
┌───── 当前批全部租户成功 ───────┘
|
||
▼
|
||
batch_done
|
||
│
|
||
是否还有下一批 ◄┤
|
||
↓ 否 │ 是
|
||
succeeded 下一批 → batch_running
|
||
│
|
||
│ 任一批失败 / 健康门控不通过
|
||
▼
|
||
halted
|
||
│
|
||
超管选择 ◄────┤
|
||
↓ 回滚 │ 继续下一批
|
||
rollback_running batch_running
|
||
↓
|
||
rolled_back
|
||
```
|
||
|
||
**字段映射**(详见技术文档 §8.6):
|
||
- `upgrade_type ∈ {A_app, B_schema, C_feature}`:A_app 类型不参与本状态机(蓝绿全量切换由运维侧执行),仅记录元数据
|
||
- `current_batch_no` / `total_batch_count`:批次进度,UI §4.6 据此渲染进度表
|
||
- `halted_reason`:halted 状态时记录失败指标快照(健康门控不通过原因)
|
||
- `tenant_progress[].status ∈ {pending, running, success, failed, rolled_back}`:单租户细粒度状态
|
||
- 终态 `succeeded` / `rolled_back` / `failed` 一旦写入禁止再变更(应用层 + 审计日志双重保证)
|
||
|
||
### 4.3 Feature Flag 变更流程
|
||
|
||
```
|
||
[admin 创建 Flag definition] ──► reason=必填 ──► 写 feature_flag_change_log(scope=definition_create)
|
||
│
|
||
├─► 调整 rollout_strategy / percentage ──► scope=definition_update
|
||
│
|
||
├─► 租户级覆盖开/关 ──► scope=tenant_override_set / tenant_override_clear
|
||
│ 同时 UPDATE tenants.feature_flags
|
||
│
|
||
└─► 归档 ──► archived_at = NOW(), scope=definition_archive
|
||
归档后 is_enabled() 永久返回 FALSE,但记录保留
|
||
```
|
||
|
||
**强制约束**:所有变更必须由 `apps.admin_console` 服务层写入;任何写操作都必须同时落 `feature_flag_change_log`(含 `reason`)+ `platform_audit_logs`(双重审计)。
|
||
|
||
---
|
||
|
||
## 五、查询模式
|
||
|
||
### 5.1 常用查询
|
||
|
||
```sql
|
||
-- 查询所有活跃租户
|
||
SELECT id, name, plan, paid_until, license_user_limit
|
||
FROM public.tenants
|
||
WHERE status = 'active'
|
||
ORDER BY created_at DESC;
|
||
|
||
-- 查询即将到期的租户(15 天内,对齐平台管理后台 PRD)
|
||
SELECT id, name, contact_email, paid_until, license_user_limit
|
||
FROM public.tenants
|
||
WHERE status = 'active'
|
||
AND paid_until BETWEEN CURRENT_DATE AND CURRENT_DATE + 15;
|
||
|
||
-- 查询“用户数已满/超限”的租户(供运营筛选)
|
||
-- current_user_count 由租户侧聚合任务回填至统计视图或物化视图,此处示例为联表查询形态
|
||
SELECT t.id, t.name, t.tenant_code, t.license_user_limit, u.current_user_count
|
||
FROM public.tenants t
|
||
JOIN public.v_tenant_user_counts u ON u.tenant_id = t.id
|
||
WHERE t.status = 'active'
|
||
AND u.current_user_count >= t.license_user_limit
|
||
ORDER BY u.current_user_count DESC, t.created_at DESC;
|
||
|
||
-- 查询灰度租户(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;
|
||
|
||
-- 客户端更新检测:查询当前生效版本(platform=win32, arch=x64)
|
||
SELECT version, release_type, download_url, portable_url,
|
||
checksum_sha256, file_size_bytes, release_notes, published_at
|
||
FROM public.client_releases
|
||
WHERE platform = 'win32'
|
||
AND arch = 'x64'
|
||
AND status = 'published'
|
||
LIMIT 1;
|
||
|
||
-- 客户端版本管理列表(管理后台,含各状态版本)
|
||
SELECT version, platform, arch, release_type, status,
|
||
download_count, published_at, created_at
|
||
FROM public.client_releases
|
||
ORDER BY created_at DESC;
|
||
|
||
-- 客户端启动心跳 Upsert(每次启动调用,原子更新最后活跃时间与启动次数)
|
||
INSERT INTO public.client_heartbeats (
|
||
tenant_id, device_id, user_id,
|
||
client_version, platform, arch, os_version,
|
||
first_seen_at, last_seen_at, launch_count
|
||
)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW(), 1)
|
||
ON CONFLICT (tenant_id, device_id) DO UPDATE
|
||
SET last_seen_at = NOW(),
|
||
launch_count = public.client_heartbeats.launch_count + 1,
|
||
client_version = EXCLUDED.client_version,
|
||
user_id = EXCLUDED.user_id,
|
||
os_version = EXCLUDED.os_version,
|
||
updated_at = NOW();
|
||
|
||
-- 统计各版本活跃客户端数(活跃 = 最近 24h 内有心跳)
|
||
SELECT cr.version,
|
||
cr.status,
|
||
cr.download_count,
|
||
COUNT(ch.id) FILTER (
|
||
WHERE ch.last_seen_at >= NOW() - INTERVAL '24 hours'
|
||
) AS active_devices_24h,
|
||
COUNT(ch.id) AS total_devices_ever
|
||
FROM public.client_releases cr
|
||
LEFT JOIN public.client_heartbeats ch ON ch.client_version = cr.version
|
||
WHERE cr.status IN ('published', 'archived')
|
||
GROUP BY cr.version, cr.status, cr.download_count, cr.published_at
|
||
ORDER BY cr.published_at DESC;
|
||
|
||
-- 平台总活跃数(最近 24h)
|
||
SELECT COUNT(*) AS active_devices_24h
|
||
FROM public.client_heartbeats
|
||
WHERE last_seen_at >= NOW() - INTERVAL '24 hours';
|
||
|
||
-- 某租户下活跃设备列表(运营排查)
|
||
SELECT device_id, client_version, user_id, last_seen_at, launch_count
|
||
FROM public.client_heartbeats
|
||
WHERE tenant_id = $1
|
||
AND last_seen_at >= NOW() - INTERVAL '24 hours'
|
||
ORDER BY last_seen_at DESC;
|
||
|
||
-- ============================================================
|
||
-- 租户维度安装/活跃统计(Platform Admin 运营看板)
|
||
-- ============================================================
|
||
|
||
-- 某租户当前活跃安装数(最近 24h 内启动过的设备)
|
||
SELECT COUNT(*) AS active_installs_24h
|
||
FROM public.client_heartbeats
|
||
WHERE tenant_id = $1
|
||
AND last_seen_at >= NOW() - INTERVAL '24 hours';
|
||
|
||
-- 某租户历史装机总数(不区分是否活跃,含已离线设备)
|
||
SELECT COUNT(*) AS total_installs_ever
|
||
FROM public.client_heartbeats
|
||
WHERE tenant_id = $1;
|
||
|
||
-- 某租户内部各版本分布(识别该租户内升级覆盖率)
|
||
SELECT client_version,
|
||
COUNT(*) FILTER (WHERE last_seen_at >= NOW() - INTERVAL '24 hours') AS active_devices_24h,
|
||
COUNT(*) AS total_devices_ever
|
||
FROM public.client_heartbeats
|
||
WHERE tenant_id = $1
|
||
GROUP BY client_version
|
||
ORDER BY active_devices_24h DESC;
|
||
|
||
-- 全平台租户活跃榜(PRD §5.5 Story 5:按租户维度查看版本分布,识别落后租户)
|
||
SELECT t.tenant_code,
|
||
t.name,
|
||
COUNT(h.id) FILTER (
|
||
WHERE h.last_seen_at >= NOW() - INTERVAL '24 hours'
|
||
) AS active_installs_24h,
|
||
COUNT(h.id) AS total_installs_ever
|
||
FROM public.tenants t
|
||
LEFT JOIN public.client_heartbeats h ON h.tenant_id = t.id
|
||
WHERE t.status = 'active'
|
||
GROUP BY t.id, t.tenant_code, t.name
|
||
ORDER BY active_installs_24h DESC;
|
||
|
||
-- ============================================================
|
||
-- 升级编排相关查询(B 类 Schema 迁移分批升级)
|
||
-- ============================================================
|
||
|
||
-- 查询当前处于 halted 状态、需要超管处理的升级事件(实时告警查询)
|
||
SELECT id, to_version_id, current_batch_no, total_batch_count,
|
||
halted_reason, started_at, initiated_by
|
||
FROM public.upgrade_events
|
||
WHERE status = 'halted'
|
||
ORDER BY created_at DESC;
|
||
|
||
-- 查询某租户的升级历史(含每次升级的最终状态)
|
||
SELECT ue.id, ue.to_version_id, ue.upgrade_type, ue.status,
|
||
progress->>'status' AS tenant_status,
|
||
progress->>'completed_at' AS tenant_completed_at,
|
||
progress->>'error' AS tenant_error
|
||
FROM public.upgrade_events ue,
|
||
jsonb_array_elements(ue.tenant_progress) AS progress
|
||
WHERE progress->>'tenant_id' = $1::text
|
||
ORDER BY ue.created_at DESC;
|
||
|
||
-- 查询某次升级事件中失败的租户列表(用于人工排查)
|
||
SELECT progress->>'tenant_id' AS tenant_id,
|
||
progress->>'tenant_name' AS tenant_name,
|
||
progress->>'error' AS error,
|
||
progress->>'snapshot_id' AS snapshot_id
|
||
FROM public.upgrade_events ue,
|
||
jsonb_array_elements(ue.tenant_progress) AS progress
|
||
WHERE ue.id = $1
|
||
AND progress->>'status' = 'failed';
|
||
|
||
-- ============================================================
|
||
-- Feature Flag 相关查询(C 类运行时灰度)
|
||
-- ============================================================
|
||
|
||
-- 查询所有未归档的 Flag 定义(管理后台列表)
|
||
SELECT key, description, default_value, rollout_strategy, rollout_config,
|
||
owner_admin_id, created_at
|
||
FROM public.feature_flag_definitions
|
||
WHERE archived_at IS NULL
|
||
ORDER BY created_at DESC;
|
||
|
||
-- 查询某 Flag 当前已显式开启的租户列表(GIN 索引加速)
|
||
SELECT id, name, schema_name, feature_flags->$1 AS flag_value
|
||
FROM public.tenants
|
||
WHERE feature_flags ? $1
|
||
AND status = 'active';
|
||
|
||
-- 查询某 Flag 近 90 天的所有变更(审计回溯)
|
||
SELECT change_scope, tenant_id, old_value, new_value,
|
||
operator_name, reason, created_at
|
||
FROM public.feature_flag_change_log
|
||
WHERE flag_key = $1
|
||
AND created_at >= NOW() - INTERVAL '90 days'
|
||
ORDER BY created_at DESC;
|
||
|
||
-- 查询某管理员近 30 天的 Flag 变更操作(个人操作审计)
|
||
SELECT flag_key, change_scope, tenant_id, reason, created_at
|
||
FROM public.feature_flag_change_log
|
||
WHERE operator_id = $1
|
||
AND created_at >= NOW() - INTERVAL '30 days'
|
||
ORDER BY 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.tenants SET tenant_code = ...` | 12 位识别码一旦发放给用户不可更改,避免经纪人无法登录 |
|
||
| `UPDATE public.domains SET domain = ...` | 域名路由不可变(`domains` 为兼容保留表,主路径使用 `tenant=` 参数) |
|
||
| `UPDATE public.client_releases SET version = ...` | 版本号创建后不可修改,变更须新建记录 |
|
||
| 在租户 schema 中创建 `client_releases` 副本 | 本表属于 public schema,多租户共享,禁止下沉到租户层 |
|
||
| `UPDATE public.feature_flag_change_log` / `DELETE FROM ...` | append-only 审计表 |
|
||
| `UPDATE public.feature_flag_definitions SET key = ...` | Flag key 是主键且为业务标识,禁止修改;废弃用 `archived_at` |
|
||
| 在租户 schema 内重复定义 `feature_flag_definitions` | Flag 体系是平台级控制平面,必须存于 public schema |
|
||
| 业务代码直接读 `tenants.feature_flags` JSONB | 必须经 `apps.admin_console.services.feature_flags.is_enabled()` 服务接口,绕过策略层会导致灰度策略失效 |
|
||
| `UPDATE public.upgrade_events` 修改终态行 | `succeeded` / `rolled_back` / `failed` 状态的事件禁止再次修改,应用层 ORM Manager 强制保护 |
|
||
|
||
---
|
||
|
||
## 六、版本历史
|
||
|
||
| 版本 | 日期 | 变更 |
|
||
|------|------|------|
|
||
| v1.0 | 2026-04-24 | 从 `DATA_MODEL.md §三` 独立拆分;内容等价于 v1.2 DATA_MODEL.md §三 |
|
||
| v1.1 | 2026-04-24 | 新增 §2.6 `client_releases` 表(客户端发布管理);同步更新表清单、约束规则、查询模式 |
|
||
| v1.2 | 2026-04-26 | 配合系统管理技术文档 v1.2 升级分批专节:§2.5 `upgrade_events` 增加 `upgrade_type`、`gray_tenant_ids`、`batch_size`、`batch_concurrency`、`batch_interval_seconds`、`failure_policy`、`current_batch_no`、`total_batch_count`、`health_gate_config`、`pre_backup_record_id`、`halted_reason` 字段,状态机扩展为 10 态;新增 §2.7 Feature Flag 体系(`feature_flag_definitions` / `tenants.feature_flags` JSONB / `feature_flag_change_log`);同步更新表清单、§4.2 升级状态机、§4.3 Flag 流程、§5.1 查询模式、§5.2 禁止操作 |
|
||
| v1.3 | 2026-04-30 | 配合登录管理 PRD v2.0 / 系统管理 PRD:§2.1 `tenants` 新增 `tenant_code` (CHAR(12), 全局唯一对外识别码) 与 `contact_phone` (CHAR(11),Tenant Admin 账号创建数据来源),`contact_email` 改为可 NULL;同步更新约束、查询模式 |
|
||
| v1.4 | 2026-04-30 | 配合客户端发布管理 PRD v1.1 §5.5 Story 5:§2.6 新增 `client_heartbeats` 表(启动时 Upsert by `tenant_id + device_id`,活跃定义为最近 24h 内心跳);同步更新表清单、§三 约束(Upsert 锚点 / 启动上报 / 不可下沉到租户 schema)、§5.1 查询模式(Upsert 模板 + 版本活跃分布 JOIN + 平台总活跃数 + 租户维度排查);MVP 不做升级趋势图(v2 规划) |
|
||
| v1.5 | 2026-04-30 | 配合客户端发布管理 PRD v1.2 §5.5 Story 5 验收标准追加(按租户统计安装数):① §2.6 `client_heartbeats.tenant_id` 注释强化,明确为"按租户统计安装数 / 活跃数 / 版本分布"的核心维度字段;② §5.1 新增"租户维度安装/活跃统计"查询专区(4 个查询:某租户活跃安装数 / 历史装机总数 / 租户内版本分布 / 全平台租户活跃榜) |
|
||
| v1.6 | 2026-05-02 | 对齐平台管理后台 PRD v1.0:① `tenants` 增加 `license_user_limit`(`INTEGER NOT NULL DEFAULT 50 CHECK (>0)`)并新增 `idx_tenants_license_user_limit`;② `suspended_reason` 增加 `license_expired` 枚举;③ 新增 `idx_tenants_paid_until_active` 并将"即将到期"查询窗口由 7 天调整为 15 天;④ 新增"用户数已满/超限"查询示例(`v_tenant_user_counts` 联表);⑤ 审计动作补充 `UPDATE_LICENSE_USER_LIMIT`、`AUTO_SUSPEND_LICENSE_EXPIRED`;⑥ `domains` 标记为统一 `tenant=` 路由下的兼容保留。 |
|
||
| v1.7 | 2026-05-02 | 架构师审核 D-1/D-2 修复:① `platform_audit_logs.result` CHECK 枚举由大写(`SUCCESS/FAILED/PARTIAL`)改为小写(`success/failed/partial`);② `export_tasks.status` 枚举值 `done` 统一改为 `success`;③ 补充两项对应数据迁移脚本(`migration_D1_audit_logs_result_lowercase.sql` / `migration_D2_export_tasks_done_to_success.sql`)。 |
|
||
|
||
---
|
||
|
||
## 附录 M:数据迁移脚本
|
||
|
||
### M.1 D-1 `platform_audit_logs.result` 大写改小写
|
||
|
||
> **背景**:审核报告 D-1 要求 `result` 值统一为小写(`success`, `failed`, `partial`),对应 DDL CHECK 约束已由 v1.7 更新。存量大写数据须一次性迁移。
|
||
>
|
||
> **执行条件**:仅在首次部署(或已有存量大写数据的环境)执行,幂等安全(`LOWER()` 结果与小写值相同)。
|
||
|
||
```sql
|
||
-- migration_D1_audit_logs_result_lowercase.sql
|
||
-- 幂等:多次执行结果一致
|
||
BEGIN;
|
||
|
||
UPDATE public.platform_audit_logs
|
||
SET result = LOWER(result)
|
||
WHERE result ~ '[A-Z]'; -- 仅更新含大写字母的行
|
||
|
||
-- 验证:应返回 0 行
|
||
SELECT COUNT(*) AS remaining_uppercase
|
||
FROM public.platform_audit_logs
|
||
WHERE result NOT IN ('success', 'failed', 'partial');
|
||
|
||
COMMIT;
|
||
```
|
||
|
||
> **回滚脚本**(若需要逆向):
|
||
> ```sql
|
||
> -- 仅在需要回滚到旧约束时执行
|
||
> UPDATE public.platform_audit_logs SET result = UPPER(result);
|
||
> ```
|
||
|
||
### M.2 D-2 `export_tasks.status` `done` → `success`
|
||
|
||
> **背景**:审核报告 D-2 要求 `status` 终态值 `done` 统一改为 `success`,对应 DDL CHECK 约束已由 v1.7 更新。存量 `done` 数据须一次性迁移。
|
||
|
||
```sql
|
||
-- migration_D2_export_tasks_done_to_success.sql
|
||
BEGIN;
|
||
|
||
UPDATE public.export_tasks
|
||
SET status = 'success'
|
||
WHERE status = 'done';
|
||
|
||
-- 验证:应返回 0 行
|
||
SELECT COUNT(*) AS remaining_done
|
||
FROM public.export_tasks
|
||
WHERE status = 'done';
|
||
|
||
COMMIT;
|
||
```
|
||
|
||
> **执行顺序**:先执行本脚本(数据迁移),再部署含新 DDL 的 Django migration(该 migration 会 `ALTER TABLE ... DROP CONSTRAINT ... ADD CONSTRAINT ...` 更新 CHECK)。若顺序颠倒,`done` 存量数据会触发 CHECK 约束违反。
|