Files
nexus/Project/fonrey/DATA_MODEL/DATA_MODEL_PUBLIC.md
2026-05-02 16:21:46 +08:00

1107 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> **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, -- 操作管理员 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 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,
-- 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), -- 操作对象 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,
-- 无 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.6A/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_appspublic 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_appspublic 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.7C 类升级 / 运行时功能灰度)
-- 说明: 与 §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无 UPDATEappend-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` 不可修改 | 创建后禁止 UPDATEPG 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 约束违反。