Files
nexus/Project/fonrey/TECH_STACK/平台管理后台技术方案.md
2026-05-02 16:21:46 +08:00

1321 lines
74 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 平台管理后台技术方案
**版本**: v1.1
**项目**: Fonrey 房产经纪管理系统
**模块**: 平台管理后台(`apps/admin_console` + `apps/release`
**关联 PRD**: [`PRD/平台管理后台/平台管理后台PRD.md`](../PRD/平台管理后台/平台管理后台PRD.md)v1.0
**关联数据模型**: [`DATA_MODEL/DATA_MODEL_PUBLIC.md`](../DATA_MODEL/DATA_MODEL_PUBLIC.md)
**关联 ADR**: `ADR-20260502-001``ADR-20260502-002``ADR-20260430-006``ADR-20260430-007``ADR-20260430-008``ADR-20260430-009`
**最后更新**: 2026-05-02
> **关键定位**:本文件是「平台管理后台」的统一技术方案,**取代**已删除的 `客户端发布管理技术方案.md` 与 `系统管理技术文档.md`(详见 `ADR-20260502-002`。两份原文档涉及的全部技术口径API 命名空间、`client_heartbeats` 表结构、SHA256 校验、Argon2id、TOTP、`admin_ops` 队列、`pub:` 缓存前缀、`django.contrib.admin` 全环境弃用等)原样保留并整合到本文件,无任何技术决策变更。
---
## 变更历史
| 日期 | 变更人 | 变更内容 |
|---|---|---|
| 2026-05-02 | Sisyphus | 初版:合并原『客户端发布管理技术方案.md』与原『系统管理技术文档.md』统一三大维度技术选型 / 页面路由表 / API 设计),新增 `ADR-20260502-002` |
| 2026-05-02 | Atlas | v1.1:新增 §7.0 平台后台独立子域与会话隔离S-2新增 §6.1.1 创建租户 Saga 与补偿事务PT-B-1|
---
## 1. 文档定位与边界
### 1.1 范围
本文件覆盖「平台管理后台」全部实现口径,受众为所有为该后台编码、测试、运维的工程师与 AI Agent
- 技术选型Django CBV + django-tenantspublic schema + HTMX + Alpine.js + Tailwind + Celery + Redis + R2 + Electron + electron-updater
- 页面路由表16 张页面 + HTMX Partial 子路由 + 路由守卫 Mixin 链 + 懒加载约定
- API 设计:双命名空间(`/admin/...` 后台业务 + `/api/release/...` 客户端运行时的具体路径、HTTP 方法、请求/响应、错误码、版本控制、认证方式
- 安全与合规MFA 强制、IP 白名单、CSRF、CSP、审计不可变、Django Admin 全环境弃用
- 异步任务、缓存策略、文件上传、监控集成、测试规范、部署规范
### 1.2 边界
- **本文件不重复 DDL**。`tenants` / `domains` / `tenant_status_logs` / `platform_admins` / `admin_mfa_devices` / `admin_sessions` / `ip_whitelist` / `platform_audit_logs` / `backup_schedules` / `backup_records` / `export_tasks` / `system_versions` / `upgrade_events` / `client_releases` / `client_heartbeats` / `feature_flag_definitions` / `feature_flag_change_log`(合计 17 张表)字段以 `DATA_MODEL_PUBLIC.md` 为唯一权威。
- **本文件不描述租户业务模块**`apps.property` / `apps.client` 等),仅在跨域操作(备份、恢复、导出、升级编排)中通过 `with schema_context(tenant.schema_name):` 显式切换 schema。
- **本文件不重复 PRD 业务规则**。租户状态机、角色权限矩阵、页面清单的产品口径见 PRD §5.4 / §6 / §7。
### 1.3 与原两份文档的对应关系
| 原文档章节 | 本文件章节 |
|---|---|
| 原『系统管理技术文档.md』§1 模块边界 | §2.1、§2.2、§2.4 |
| 原『系统管理技术文档.md』§2 目录结构 | §2.3 |
| 原『系统管理技术文档.md』§3 路由命名空间 | §2.5、§3 |
| 原『系统管理技术文档.md』§4 API 端点设计 | §3、§4.1 |
| 原『系统管理技术文档.md』§5 权限与认证 | §3.4、§7.1§7.3 |
| 原『系统管理技术文档.md』§6 缓存策略 | §6.2 |
| 原『系统管理技术文档.md』§7 文件上传 | §6.3 |
| 原『系统管理技术文档.md』§8 Celery + 升级 A/B/C | §6.1、§6.4§6.5 |
| 原『系统管理技术文档.md』§9 监控集成 | §8 |
| 原『系统管理技术文档.md』§10 测试规范 | §9 |
| 原『系统管理技术文档.md』§11 安全要点 | §7 |
| 原『系统管理技术文档.md』§12 部署规范 | §10 |
| 原『客户端发布管理技术方案.md』§3 模块架构 | §2.6 |
| 原『客户端发布管理技术方案.md』§5 端点清单 | §4.2 |
| 原『客户端发布管理技术方案.md』§6 关键 API | §4.2 |
| 原『客户端发布管理技术方案.md』§9 异步与缓存 | §6.1、§6.2 |
| 原『客户端发布管理技术方案.md』§10 性能 | §5.4 |
| 原『客户端发布管理技术方案.md』§11 安全 | §7.4 |
| 原『客户端发布管理技术方案.md』§12 错误码 | §4.4 |
---
## 2. 技术选型
### 2.1 核心技术栈
| 层级 | 选型 | 用途 | 选型理由 |
|---|---|---|---|
| **路由 + 视图** | Django 4.xASGI+ Class-Based Views | 后端路由、页面渲染、JSON API | 与租户业务同栈,复用 `django-tenants` schema 切换CBV Mixin 组合权限/审计/MFA |
| **多租户编排** | `django-tenants` 1.4+`SHARED_APPS` | `public` schema 注册、`schema_context()` 切换 | 物理 schema 隔离 + 后台无感切换 |
| **前端交互** | HTMX 1.9+ | 局部刷新、表单提交、轮询 | 无重前端框架AGENTS.md §5单进程返回 partial HTML |
| **前端状态** | Alpine.js 3.x | 弹窗开关、Tab 切换、MFA Modal、表单字数统计 | 轻量、属性式声明,配合 CSP `script-src 'self'` |
| **样式** | Tailwind CSS 3.x | 全部样式 | 与租户业务共用设计系统 |
| **REST API客户端** | Django Views手写 JSON | `/api/release/...` 客户端运行时接口 | 端点少(≤ 10 个)、无需 DRF 全套;`JsonResponse` + 手写序列化即可 |
| **认证(后台)** | 自建 `PlatformAdminBackend` + Django Session + Argon2id + django-otp/TOTP | 平台管理员独立账号体系 | 不复用 `django.contrib.auth.User`;强制 MFA |
| **认证(客户端)** | 设备签名票据Token in Header | 客户端 Heartbeat 鉴权 | 防伪造上报;与租户登录态解耦 |
| **数据库** | PostgreSQL 16 + PgBouncer | 数据落 `public` schema`SHARED_APPS` | 与租户业务同实例不同 schema |
| **缓存** | Redis | 后台 session 反查、IP 白名单、任务进度、版本分布聚合 | Key 前缀 `pub:`,与租户业务 `{schema}:` 严格隔离 |
| **异步任务** | Celery 5.x + Celery Beat | 备份/恢复/导出/升级编排/Heartbeat 聚合 | 独立队列 `admin_ops` + `migration` 双队列 |
| **对象存储** | Cloudflare R2S3 兼容) | 升级包 / 备份产出 / 导出产出 / 客户端安装包 | 后端写入,禁用前端直传(合规 + SHA256 完整性) |
| **CDN** | Cloudflare CDN | 客户端安装包分发 `download.fonrey.com` | 与 R2 原生集成 |
| **客户端壳应用** | Electron + electron-updater + electron-builder | Windows 桌面客户端 | 壳应用原则:不内嵌业务逻辑,仅渲染 Web URL |
| **代码签名** | EV Code Signing Certificate | 客户端 EXE / ZIP 签名 | Windows SmartScreen 信任 |
| **完整性校验** | SHA-256 | 客户端安装包校验(强制) | 详见 `ADR-20260430-008` |
| **服务器** | Gunicorn + Uvicorn workers + Nginx | ASGI 部署 | 与租户应用共用进程,按 `Host` 路由 |
| **监控** | Sentry独立 DSN+ Grafana iframe + Flower | 错误追踪 + 平台指标 + Celery 队列健康 | 与租户业务监控分离 |
**禁止项(违反视为 Bug**
- ❌ 引入 Django REST Framework 仅为本模块(端点少,开销过大)。
- ❌ 引入 React/Vue/Angular 等重前端AGENTS.md §5
- ❌ 注册 `django.contrib.admin`(全环境弃用,详见 §2.4)。
- ❌ 复用 `django.contrib.auth.User` 作为平台管理员主体(必须独立 `platform_admins`)。
- ❌ 客户端渲染进程开启 `nodeIntegration: true`(壳应用安全边界)。
- ❌ 前端直传 Presigned URL 上传升级包/备份/导出(必须后端中转 + SHA-256 校验)。
### 2.2 部署边界
| 维度 | 说明 |
|---|---|
| 部署域名 | `admin.fonrey.com`独立子域Nginx 层 IP 白名单 + 应用层 `IpWhitelistMiddleware` 双重保险) |
| Schema 归属 | `public``SHARED_APPS`),所有 ORM 查询走 `public_schema_urlconf` |
| 客户端运行时域名 | `download.fonrey.com`CDN 边缘)+ 业务接口走 `app.fonrey.com/api/release/...` |
| URL 前缀(后台业务) | `/admin/...` |
| URL 前缀(客户端 API | `/api/release/...`(沿用 `ADR-20260430-009` |
| Celery 队列 | `admin_ops`(默认)、`migration`(独立限并发,仅 B 类升级使用) |
| Cookie 域 | `admin.fonrey.com`Strict禁止跨子域|
### 2.3 Django App 与目录结构
本后台跨两个 App均在 `SHARED_APPS`
- `apps/admin_console/` — 系统管理主体(租户/备份/导出/升级/审计/告警/平台管理员设置/Feature Flag
- `apps/release/` — 客户端发布(系统版本元数据、客户端 Heartbeat、版本分布统计、自动更新接口
两者共用:`apps.admin_console.permissions`(角色 Mixin`apps.admin_console.middleware`IP 白名单 + Session`apps.admin_console.services.audit_service`(统一审计入口)。`apps/release` **不得**反向依赖 `apps.admin_console.views`,仅依赖其权限与审计基础件。
```
apps/admin_console/
├── apps.py
├── urls.py # 注册到 PUBLIC_SCHEMA_URLCONFnamespace='admin_console'
├── auth_backends.py # PlatformAdminBackend独立认证
├── middleware.py # IpWhitelistMiddleware / AdminSessionMiddleware
├── permissions.py # AdminRole 枚举 / Mixin / ACTION_REQUIRED_ROLE
├── signals.py
├── forms.py
├── models/ # tenant / platform_admin / audit / backup / export / version / feature_flag
├── views/ # 全部 CBVauth/dashboard/tenants/backups/exports/versions/monitoring/audit/settings/feature_flags
├── tasks/ # tenant_lifecycle / backup / restore / export / upgrade / notifications / housekeeping
├── services/ # tenant_service / audit_service / mfa_service / permission_service / backup_service / version_service / feature_flags
├── tests/
└── templates/admin_console/ # base.html + 各页面 + partials/
apps/release/
├── apps.py
├── urls.py # 同时挂到 /admin/client-releases/(后台 UI与 /api/release/(客户端 API
├── models/ # client_release / client_heartbeat
├── views/
│ ├── admin.py # 后台 CBV与 admin_console 同款 Mixin 链)
│ └── api.py # 客户端 JSON APIlatest / heartbeats / metrics
├── serializers.py # 极简 dataclass + asdict(),不引入 DRF
├── tasks/ # release_compute_checksum / release_publish_cdn_warmup / release_scan_artifact
├── services/ # release_service / heartbeat_service / metrics_service
├── tests/
└── templates/release/ # 后台 UI partials与 admin_console templates/ 同风格)
```
**目录约定**
- `models/` 一表一文件。
- `views/` 全部 CBV`ListView` / `DetailView` / `FormView` / `View`);禁止函数视图。
- `tasks/` 是 Celery 入口(薄壳),业务逻辑落在 `services/`,便于单测。
- `templates/.../partials/` 命名以 `partials/` 区分完整页 vs HTMX 局部模板。
### 2.4 与 `django.contrib.admin` 的关系(强制全环境弃用)
理由(沿用原『系统管理技术文档.md』§1.5
| 冲突点 | 说明 |
|---|---|
| 多租户编排 | Django Admin 假设单 schema无 schema 切换钩子 |
| 认证体系 | Admin 强绑定 `auth.User`;本模块要求独立 `platform_admins` + 强制 TOTP |
| 审计强度 | Admin `LogEntry` 允许 UPDATE/DELETE且不覆盖读操作 |
| 交互范式 | Admin 模板整页刷新;本模块要求 HTMX 局刷 + Alpine 二次确认 Modal |
| 业务流页面 | 升级灰度进度、备份恢复 MFA step-up、监控大盘等无法用 ModelAdmin 表达 |
**强制措施**
- `INSTALLED_APPS` 不注册 `django.contrib.admin`
- `urls_public.py` 不导入 `django.contrib.admin`,无 `admin.site.urls` 路由。
- `config/settings/base.py` 启动断言:
```python
assert 'django.contrib.admin' not in INSTALLED_APPS, \
"Django Admin 已全环境弃用,平台后台请走 apps.admin_console"
```
- CI 检查:`grep -rn "from django.contrib import admin\|admin.site.register" apps/ config/` 命中即构建失败。
- 紧急数据修复一律走 `manage.py shell_plus` + 由超管在本模块 `/admin/audit-logs/` 手工补录审计条目(`source='manual_shell'`**不开后门**。
### 2.5 Settings 关键配置
```python
# config/settings/base.py
SHARED_APPS = [
'django_tenants',
'apps.tenant',
'apps.admin_console',
'apps.release',
'django.contrib.contenttypes',
'django.contrib.staticfiles',
# 注意:不注册 'django.contrib.admin'
]
assert 'django.contrib.admin' not in SHARED_APPS, \
"Django Admin 已全环境弃用,平台后台请走 apps.admin_console"
PUBLIC_SCHEMA_URLCONF = 'config.urls_public' # 平台后台 URL
ROOT_URLCONF = 'config.urls_tenant' # 租户业务 URL
ADMIN_CONSOLE_HOSTS = ['admin.fonrey.com', 'admin.localhost']
RELEASE_DOWNLOAD_HOST = 'download.fonrey.com'
CELERY_TASK_ROUTES = {
'apps.admin_console.tasks.*': {'queue': 'admin_ops'},
'apps.release.tasks.*': {'queue': 'admin_ops'},
'apps.admin_console.tasks.upgrade.migrate_single_tenant': {'queue': 'migration'},
'apps.admin_console.tasks.upgrade.rollback_single_tenant': {'queue': 'migration'},
}
# Cookie / CSRF / CSP
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Strict'
SESSION_COOKIE_DOMAIN = '.fonrey.com' # 但 admin Cookie 走独立 Cookie 名 + 限定 admin.fonrey.com详见 §7.2
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = False # HTMX 需读取
CSRF_COOKIE_SAMESITE = 'Strict'
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.Argon2PasswordHasher',
# 不允许降级 PBKDF2/SHA1
]
```
### 2.6 与租户业务的隔离原则
- ❌ 严禁本模块代码导入 `apps.property` / `apps.client` / `apps.org` 等租户 App 的 Model。
- ❌ 严禁本模块视图、任务直接访问租户 schema 中的表。
- ✅ 跨租户数据操作(备份、恢复、导出、升级编排)必须 `with schema_context(tenant.schema_name):` 显式切换。
- ✅ Celery 任务必须在参数中传入 `tenant_schema_name`,任务开头切换 schema**不得依赖外部上下文传递**AGENTS.md §4.1)。
---
## 3. 页面路由表
### 3.1 路由组织与注册
```python
# config/urls_public.py
from django.urls import path, include
# 严禁 from django.contrib import admin
urlpatterns = [
path('admin/', include(('apps.admin_console.urls', 'admin_console'), namespace='admin_console')),
path('admin/client-releases/', include(('apps.release.admin_urls', 'release_admin'), namespace='release_admin')),
path('api/release/', include(('apps.release.api_urls', 'release_api'), namespace='release_api')),
]
```
`apps.admin_console.urls` 内顶层 `app_name = 'admin_console'`;反向解析使用 `admin_console:tenants:list` 等命名空间。
### 3.2 16 张主页面路由表(与 PRD §5.4.1 对齐)
| # | PRD 页面 | 后端 URL 模式 | 视图类 | 模板 | 路由守卫Mixin 链) |
|---|---|---|---|---|---|
| 1 | 登录页 | `/admin/login/` | `AdminLoginView`GET/POST| `admin_console/auth/login.html` | 匿名(中间件 `IpWhitelistMiddleware` 兜底) |
| 1.5 | MFA 校验 | `/admin/login/mfa/` | `MfaChallengeView` | `admin_console/auth/mfa_challenge.html` | 已通过密码校验session 标志) |
| 1.6 | MFA 首次绑定 | `/admin/login/mfa/setup/` | `MfaSetupView` | `admin_console/auth/mfa_setup.html` | 首登态(`platform_admin.mfa_enabled=False` |
| 1.7 | MFA Step-up | `/admin/login/mfa/step-up/` | `MfaStepUpView`POST| —(仅写 session.mfa_confirmed_at | `AdminLoginRequiredMixin` |
| 2 | 仪表盘 | `/admin/` | `DashboardView` | `admin_console/dashboard.html` | `AdminLoginRequiredMixin` |
| 3 | 租户列表 | `/admin/tenants/` | `TenantListView` | `admin_console/tenants/list.html` | `AdminLoginRequiredMixin` |
| 4 | 新建租户 | `/admin/tenants/new/` | `TenantCreateView` | `admin_console/tenants/create.html` | `RoleRequiredMixin(OPS)` + `AuditedActionMixin` |
| 5 | 租户详情:基本信息 | `/admin/tenants/<uuid:pk>/` | `TenantDetailView` | `admin_console/tenants/detail.html` | `AdminLoginRequiredMixin` |
| 6 | 租户详情:用户管理 | `/admin/tenants/<uuid:pk>/users/` | `TenantUserTabView` | `admin_console/tenants/detail.html`Tab| 同上 |
| 7 | 租户详情:套餐信息 | `/admin/tenants/<uuid:pk>/plan/` | `TenantPlanTabView` | 同上 | 同上 |
| 8 | 租户详情:监控 | `/admin/tenants/<uuid:pk>/monitoring/` | `TenantMonitoringTabView` | 同上 | 同上 |
| 9 | 租户详情:备份记录 | `/admin/tenants/<uuid:pk>/backups/` | `TenantBackupTabView` | 同上 | 同上(恢复操作另需 `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` |
| 10 | 租户详情:操作历史 | `/admin/tenants/<uuid:pk>/history/` | `TenantHistoryTabView` | 同上 | 同上 |
| 11 | 系统版本管理 | `/admin/system/versions/` | `SystemVersionListView` | `admin_console/versions/list.html` | `AdminLoginRequiredMixin`(写操作另需 `RoleRequiredMixin(SUPER) + MfaConfirmedRequiredMixin`|
| 12 | 备份管理 | `/admin/system/backups/` | `BackupListView` | `admin_console/backups/list.html` | `AdminLoginRequiredMixin` |
| 13 | 监控与告警 | `/admin/monitoring/` | `MonitoringView` | `admin_console/monitoring/index.html` | `AdminLoginRequiredMixin` |
| 14 | **客户端版本管理** | `/admin/client-releases/` | `ClientReleaseListView` | `release/admin/list.html` | `AdminLoginRequiredMixin`(写操作另需 `RoleRequiredMixin(SUPER) + MfaConfirmedRequiredMixin`|
| 15 | 审计日志 | `/admin/audit-logs/` | `AuditLogListView` | `admin_console/audit/list.html` | `ReadOnlyAuditorAllowedMixin`(审计员可读) |
| 16 | 管理员设置 | `/admin/settings/admins/` | `AdminAccountListView` | `admin_console/settings/admins.html` | `RoleRequiredMixin(SUPER)` |
> 动态参数:`<uuid:pk>` 用于租户 ID、版本 ID、备份 ID、导出任务 ID、客户端发布 ID`<uuid:event_id>` 用于升级事件 ID。统一使用 UUID v4禁止自增整数AGENTS.md §4.4)。
### 3.3 HTMX Partial 子路由(页面内局部刷新)
> 命名约定:`<父页面>/rows/`(列表筛选/翻页)、`<父页面>/<id>/<动作>/`(行内动作)。所有 partial 视图必须校验 `request.htmx``django-htmx`),非 HTMX 直访返回 404 或重定向到父页。
#### 3.3.1 仪表盘(懒加载 + 轮询)
| URL | 方法 | 视图 | 触发 | 响应 |
|---|---|---|---|---|
| `/admin/dashboard/health/` | GET | `HealthStatusPartialView` | `hx-trigger="load, every 30s"` 轮询服务健康 | HTML(Partial) |
| `/admin/dashboard/recent-actions/` | GET | `RecentActionsPartialView` | `hx-trigger="revealed"` 懒加载(首屏不阻塞) | HTML(Partial) |
| `/admin/dashboard/stats/` | GET | `DashboardStatsPartialView` | `hx-trigger="load"` 单次加载 | HTML(Partial) |
| `/admin/dashboard/client-coverage/` | GET | `ClientCoveragePartialView` | `hx-trigger="revealed"` 懒加载 | HTML(Partial) |
#### 3.3.2 租户管理(详情页 Tab 与行内动作)
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
| `/admin/tenants/rows/` | GET | `TenantRowsPartialView` | HTMX 筛选/翻页/搜索 | `AdminLoginRequiredMixin` |
| `/admin/tenants/<uuid:pk>/edit/` | POST | `TenantUpdateView` | 内联编辑 | `RoleRequiredMixin(OPS)` |
| `/admin/tenants/<uuid:pk>/suspend/` | POST | `TenantSuspendView` | 挂起 | `RoleRequiredMixin(OPS)` |
| `/admin/tenants/<uuid:pk>/resume/` | POST | `TenantResumeView` | 恢复 | `RoleRequiredMixin(OPS)` |
| `/admin/tenants/<uuid:pk>/soft-delete/` | POST | `TenantSoftDeleteView` | 软删除 | `RoleRequiredMixin(OPS)` |
| `/admin/tenants/<uuid:pk>/hard-delete/` | POST | `TenantHardDeleteView` | 硬删除 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` |
| `/admin/tenants/<uuid:pk>/restore-deletion/` | POST | `TenantRestoreDeletionView` | 撤销软删除 | `RoleRequiredMixin(OPS)` |
| `/admin/tenants/<uuid:pk>/users/<uuid:user_id>/reset-password/` | POST | `TenantUserResetPasswordView` | 重置租户用户密码 | `RoleRequiredMixin(OPS)` |
| `/admin/tenants/<uuid:pk>/admins/grant/` | POST | `TenantAdminGrantView` | 赋予 Tenant Admin | `RoleRequiredMixin(OPS)` |
| `/admin/tenants/<uuid:pk>/admins/revoke/` | POST | `TenantAdminRevokeView` | 撤销 Tenant Admin | `RoleRequiredMixin(OPS)` |
| `/admin/tenants/<uuid:pk>/plan/upgrade/` | POST | `TenantPlanUpgradeView` | 套餐升级 | `RoleRequiredMixin(OPS)` |
| `/admin/tenants/<uuid:pk>/plan/license/` | POST | `TenantLicenseUpdateView` | 调整 License 到期/上限 | `RoleRequiredMixin(OPS)` |
#### 3.3.3 备份与恢复
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
| `/admin/system/backups/rows/` | GET | `BackupRowsPartialView` | 筛选/翻页 | `AdminLoginRequiredMixin` |
| `/admin/system/backups/schedule/` | GET/POST | `BackupScheduleView` | 全局策略 | `RoleRequiredMixin(SUPER)` |
| `/admin/tenants/<uuid:pk>/backups/trigger/` | POST | `TenantBackupTriggerView` | 手动触发备份 | `RoleRequiredMixin(OPS)` |
| `/admin/system/backups/<uuid:pk>/status/` | GET | `BackupStatusPartialView` | `hx-trigger="every 5s"` 轮询任务进度;任务终态返回去 trigger 的 HTML | `AdminLoginRequiredMixin` |
| `/admin/system/backups/<uuid:pk>/restore/` | POST | `BackupRestoreView` | 数据恢复 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` |
#### 3.3.4 数据导出
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
| `/admin/system/exports/` | GET | `ExportListView` | 列表(首屏) | `AdminLoginRequiredMixin` |
| `/admin/system/exports/new/` | GET/POST | `ExportCreateView` | 新建导出 | `RoleRequiredMixin(OPS)` |
| `/admin/system/exports/<uuid:pk>/status/` | GET | `ExportStatusPartialView` | `hx-trigger="every 5s"` 轮询;终态去 trigger | `AdminLoginRequiredMixin` |
| `/admin/system/exports/<uuid:pk>/download/` | GET | `ExportDownloadRedirectView` | 302 跳 R2 Presigned URL | 触发人 + `RoleRequiredMixin(SUPER)` |
#### 3.3.5 系统升级A / B / C 三类,详见 §6.4
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
| `/admin/system/versions/upgrade/` | GET/POST | `UpgradeFormView` | 升级表单(含类型/灰度/批次参数) | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)`POST |
| `/admin/system/versions/<uuid:event_id>/progress/` | GET | `UpgradeProgressPartialView` | `hx-trigger="every 3s"` 轮询;状态 `halted/succeeded/failed` 终态后去 trigger | `AdminLoginRequiredMixin` |
| `/admin/system/versions/<uuid:event_id>/rollback/` | POST | `RollbackView` | 全量/单租户回滚 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` |
| `/admin/system/versions/<uuid:event_id>/incident/` | GET | `IncidentReportView` | 事件报告 | `AdminLoginRequiredMixin` |
| `/admin/feature-flags/` | GET | `FeatureFlagListView` | Flag 定义列表 | `AdminLoginRequiredMixin` |
| `/admin/feature-flags/new/` | POST | `FeatureFlagCreateView` | 新增 Flag | `RoleRequiredMixin(SUPER)` |
| `/admin/feature-flags/<key>/rollout/` | POST | `FeatureFlagRolloutView` | 调整百分比 | `RoleRequiredMixin(SUPER)` |
| `/admin/feature-flags/<key>/archive/` | POST | `FeatureFlagArchiveView` | 归档 | `RoleRequiredMixin(SUPER)` |
| `/admin/tenants/<uuid:pk>/feature-flags/toggle/` | POST | `TenantFlagToggleView` | 租户级 Flag 覆盖 | `RoleRequiredMixin(OPS)` |
#### 3.3.6 监控与审计
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
| `/admin/monitoring/alerts/` | GET | `AlertRuleListView` | 告警规则 | `RoleRequiredMixin(OPS)` |
| `/admin/monitoring/alerts/<uuid:pk>/edit/` | POST | `AlertRuleUpdateView` | 编辑规则 | `RoleRequiredMixin(OPS)` |
| `/admin/monitoring/grafana-webhook/` | POST | `GrafanaWebhookView` | Grafana 推送HMAC 校验) | 公开HMAC 鉴权) |
| `/admin/audit-logs/rows/` | GET | `AuditLogRowsPartialView` | 筛选/翻页 | `ReadOnlyAuditorAllowedMixin` |
| `/admin/audit-logs/export/` | POST | `AuditLogExportView` | 异步导出 CSV | `ReadOnlyAuditorAllowedMixin` |
#### 3.3.7 管理员设置
| URL | 方法 | 视图 | 权限 |
|---|---|---|---|
| `/admin/settings/admins/new/` | POST | `AdminAccountCreateView` | `RoleRequiredMixin(SUPER)` |
| `/admin/settings/admins/<uuid:pk>/deactivate/` | POST | `AdminDeactivateView` | `RoleRequiredMixin(SUPER)` |
| `/admin/settings/admins/<uuid:pk>/sessions/revoke/` | POST | `ForceLogoutView` | `RoleRequiredMixin(SUPER)` |
| `/admin/settings/ip-whitelist/` | GET | `IpWhitelistListView` | `RoleRequiredMixin(SUPER)` |
| `/admin/settings/ip-whitelist/new/` | POST | `IpWhitelistCreateView` | `RoleRequiredMixin(SUPER)` |
| `/admin/settings/ip-whitelist/<uuid:pk>/toggle/` | POST | `IpWhitelistToggleView` | `RoleRequiredMixin(SUPER)` |
| `/admin/settings/sessions/` | GET | `MyActiveSessionListView` | `AdminLoginRequiredMixin` |
#### 3.3.8 客户端版本管理(`apps.release` 后台 UI
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
| `/admin/client-releases/rows/` | GET | `ClientReleaseRowsPartialView` | 筛选/翻页 | `AdminLoginRequiredMixin` |
| `/admin/client-releases/new/` | GET/POST | `ClientReleaseCreateView` | 新建版本(草稿/发布) | `RoleRequiredMixin(SUPER) + MfaConfirmedRequiredMixin`POST |
| `/admin/client-releases/<uuid:pk>/edit/` | POST | `ClientReleaseUpdateView` | 修改元数据 | `RoleRequiredMixin(SUPER)` |
| `/admin/client-releases/<uuid:pk>/publish/` | POST | `ClientReleasePublishView` | 发布 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` |
| `/admin/client-releases/<uuid:pk>/unpublish/` | POST | `ClientReleaseUnpublishView` | 下线 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` |
| `/admin/client-releases/<uuid:pk>/rollback/` | POST | `ClientReleaseRollbackView` | 回滚到该版本 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` |
| `/admin/client-releases/<uuid:pk>/force-update/` | POST | `ClientReleaseForceUpdateView` | 推送强制更新标记 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` |
| `/admin/client-releases/metrics/version-distribution/` | GET | `VersionDistributionPartialView` | `hx-trigger="revealed"` 懒加载 | `AdminLoginRequiredMixin` |
| `/admin/client-releases/metrics/tenant-leaderboard/` | GET | `TenantLeaderboardPartialView` | `hx-trigger="revealed"` 懒加载 + 翻页 | `AdminLoginRequiredMixin` |
### 3.4 路由守卫(视图层 Mixin 链)
```python
# apps/admin_console/permissions.py
class AdminRole:
SUPER = 'super_admin'
OPS = 'ops_operator'
AUDITOR = 'read_only_auditor'
ROLE_RANK = {AdminRole.AUDITOR: 1, AdminRole.OPS: 2, AdminRole.SUPER: 3}
class AdminLoginRequiredMixin:
"""检查 request.platform_admin否则 302 /admin/login/。
HTMX 请求返回 401 + HX-Redirect: /admin/login/"""
class RoleRequiredMixin(AdminLoginRequiredMixin):
required_role: str = None # AdminRole.SUPER / OPS / AUDITOR
# ROLE_RANK[user.role] >= ROLE_RANK[required_role] 才放行;否则 403 Partial + Toast
class ReadOnlyAuditorAllowedMixin(AdminLoginRequiredMixin):
"""允许 AUDITOR 访问 GET其他角色无差别放行非 GET 方法对 AUDITOR 返回 403"""
class MfaConfirmedRequiredMixin(AdminLoginRequiredMixin):
"""要求 session.mfa_confirmed_at 在最近 5 分钟内。
未通过 → 401 + HX-Trigger: {"fonrey:mfa-required":{"action":"...","return_to":"..."}}"""
class AuditedActionMixin:
"""form_valid / 成功响应后调用 audit_service.write_audit()
自动从 request 提取 operator/ip/user_agent/payload_summary"""
```
**典型组合**
```python
class TenantHardDeleteView(MfaConfirmedRequiredMixin, RoleRequiredMixin, AuditedActionMixin, FormView):
required_role = AdminRole.SUPER
audit_action = 'HARD_DELETE_TENANT'
```
**MFA Step-up 流程**(用户点击「硬删除」):
```
1. HTMX POST /admin/tenants/<id>/hard-delete/
2. MfaConfirmedRequiredMixin 检测 session.mfa_confirmed_at 已过 5 min
3. 401 + HX-Trigger: {"fonrey:mfa-required":{"action":"hard_delete","return_to":"<原 URL>"}}
4. 前端 Alpine.js 监听该事件 → 打开 MFA Modal
5. 用户输入 TOTP → POST /admin/login/mfa/step-up/ → 后端写 session.mfa_confirmed_at = now()
6. 前端拿到成功响应 → 重新发起原始 POST带 X-Mfa-Step-Up: 1→ 通过执行
```
### 3.5 懒加载策略
| 场景 | HTMX 实现 | 触发时机 |
|---|---|---|
| 详情页 Tab 内容 | 容器 `<div hx-get="..." hx-trigger="revealed" hx-swap="innerHTML">`Tab 激活后第一次进入视口才请求 | 首屏不阻塞 |
| 仪表盘图表 | `hx-trigger="revealed"` + 加载占位骨架屏 | 滚动到位时加载 |
| 客户端版本分布 / 租户活跃榜 | 同上 | 同上 |
| 长轮询任务(备份/导出/升级) | `hx-trigger="every Ns"`;后端在终态 partial 中**移除** `hx-trigger="every"`(替换为 `hx-trigger="load"`),避免持续轮询 | 进入页面 → 终态停 |
| 列表懒加载(追加) | Keyset 分页 + `hx-trigger="revealed" hx-swap="afterend"` 在最后一行触发 | 滚动到底部加载下页 |
| 模态框组件MFA Modal、删除确认 | `hx-get` 拉 partial → swap 到 `#dialog`;不预渲染 | 用户点击触发 |
**反模式**
- ❌ 全量预渲染所有 Tab首屏慢、浪费请求
- ❌ 终态后仍 `every Ns` 轮询(资源泄漏)。
- ❌ OFFSET 分页AGENTS.md §4.51000+ 数据集禁用)。
---
## 4. API 设计
### 4.1 双命名空间策略
| 命名空间 | 用途 | 受众 | 认证方式 | 响应类型 | 版本控制 |
|---|---|---|---|---|---|
| `/admin/...` | 平台管理后台业务CRUD + HTMX 局刷) | 平台管理员浏览器 | Django SessionHttpOnly Cookie + CSRF + TOTP MFA | HTML(Page) / HTML(Partial) / 偶发 JSON仅 Celery 任务状态轮询) | 不带版本号;通过 Mixin 灰度 |
| `/api/release/...` | 客户端运行时接口(更新检测 / Heartbeat / 统计) | Electron 客户端 + 平台后台(统计) | 公开(更新检测) / 设备签名票据Heartbeat / Session管理端 | JSON | URL 路径携带 `v1`(沿用 `ADR-20260430-009` |
**版本控制策略**
- `/admin/...`:内部接口,不提供向后兼容承诺,随发版滚动升级。
- `/api/release/v1/...`:客户端长期使用,必须遵守向后兼容;破坏性变更必须新增 `/api/release/v2/...` 并允许 `v1` 至少共存 6 个月。
### 4.2 客户端运行时 API`/api/release/v1/...`
> 沿用 `ADR-20260430-009`(统一命名空间)+ `ADR-20260430-008`SHA-256 强制)+ `ADR-20260430-007`Heartbeat Upsert + 24h 活跃口径)。
#### 4.2.1 端点清单
| 端点 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
| `/api/release/v1/updates/latest/` | GET | 公开 | 客户端检查最新版本(仅返回 `published` 版本) |
| `/api/release/v1/heartbeats/` | POST | 设备票据 | 客户端启动上报Upsert |
| `/api/release/v1/metrics/version-distribution/` | GET | SessionPlatform Admin | 全平台版本活跃分布 |
| `/api/release/v1/metrics/tenant-installs/` | GET | SessionPlatform Admin | 指定租户活跃安装数 + 历史装机数 |
| `/api/release/v1/metrics/tenant-leaderboard/` | GET | SessionPlatform Admin | 全平台租户活跃榜 |
| `/admin/api/client-releases/` | POST | SessionSUPER + MFA | 管理端创建版本 |
| `/admin/api/client-releases/<uuid:pk>/` | PATCH | SessionSUPER + MFA | 修改状态 / 类型 / 日志 |
| `/admin/api/client-releases/<uuid:pk>/rollback/` | POST | SessionSUPER + MFA | 原子切换 published |
> 管理端写操作走 `/admin/api/client-releases/...`(与 `/admin/...` HTMX 路由共享 Session/CSRF/MFA 链路,但返回 JSON与客户端 `/api/release/v1/` 解耦:客户端永远只读公开版本。
#### 4.2.2 请求 / 响应规范
##### `GET /api/release/v1/updates/latest/`
**请求参数querystring**
| 参数 | 必填 | 说明 |
|---|---|---|
| `platform` | 是 | `win32`MVP 仅 Windows |
| `arch` | 是 | `x64` / `x86` |
| `current_version` | 是 | SemVer `X.Y.Z` |
**响应200有更新**
```json
{
"has_update": true,
"latest_version": "1.3.0",
"force_update": false,
"min_required_version": "1.0.0",
"download_url": "https://download.fonrey.com/releases/system/v1.3.0/fonrey-setup-1.3.0-win.exe",
"portable_url": "https://download.fonrey.com/releases/system/v1.3.0/fonrey-portable-1.3.0-win.zip",
"checksum_sha256": "<exe_sha256>",
"portable_checksum_sha256": "<zip_sha256>",
"file_size_bytes": 157286400,
"release_notes": "## v1.3.0\n- ...",
"release_date": "2026-05-01"
}
```
**响应200无更新**
```json
{ "has_update": false, "latest_version": "1.3.0" }
```
**性能要求**:缓存命中 `p95 < 120ms`(缓存键 `pub:release:latest:{platform}:{arch}`TTL 60s
##### `POST /api/release/v1/heartbeats/`
**请求头**
- `Authorization: Bearer <device_token>`(设备票据,由客户端登录时由租户业务后端签发,包含 `tenant_id` + `user_id` + `device_id``exp` 7 天)
- `Content-Type: application/json`
**请求体**
```json
{
"device_id": "9e6de37b-8c49-4f9b-af47-52f4e5b8b7f2",
"client_version": "1.3.0",
"platform": "win32",
"arch": "x64",
"os_version": "Windows 11 23H2"
}
```
**处理要求**
- 服务端从 `Authorization` 解析 `tenant_id``user_id`,与请求体的 `device_id` 一起作为唯一键。
- `INSERT ... ON CONFLICT (tenant_id, device_id) DO UPDATE SET last_seen_at=NOW(), launch_count=launch_count+1, client_version=EXCLUDED.client_version, os_version=EXCLUDED.os_version`
- 频控:单 `device_id` 每分钟 ≤ 12 次(启动场景),超限 429。
**响应202 Accepted**
```json
{ "ok": true }
```
**性能要求**:写入 `p95 < 80ms`
##### `GET /api/release/v1/metrics/version-distribution/`
**响应200**
```json
{
"as_of": "2026-05-02T03:21:00Z",
"active_window_hours": 24,
"total_active_devices": 12480,
"items": [
{ "version": "1.3.0", "active_devices": 9824, "share": 0.787 },
{ "version": "1.2.0", "active_devices": 1856, "share": 0.149 },
{ "version": "1.1.0", "active_devices": 800, "share": 0.064 }
]
}
```
##### `POST /admin/api/client-releases/`
**请求头**`Cookie: sessionid=...; csrftoken=...``X-CSRFToken: ...``X-Mfa-Step-Up: 1`
**请求体**
```json
{
"version": "1.3.0",
"platform": "win32",
"arch": "x64",
"release_type": "normal",
"min_required_version": "1.0.0",
"download_url": "https://download.fonrey.com/releases/system/v1.3.0/fonrey-setup-1.3.0-win.exe",
"portable_url": "https://download.fonrey.com/releases/system/v1.3.0/fonrey-portable-1.3.0-win.zip",
"checksum_sha256": "<exe_sha256>",
"portable_checksum_sha256": "<zip_sha256>",
"release_notes": "## v1.3.0\n- ...",
"status": "draft"
}
```
**响应201**:返回创建的资源;`status='published'` 必须在事务中将旧 published 版本切为 `unpublished`(确保单一生效约束)。
### 4.3 后台业务接口(`/admin/...`HTMX 局刷为主)
> 完整路径见 §3.3。所有路径返回 HTML(Partial)**不返回 JSON**(除轮询任务进度的 Celery 状态接口)。
#### 4.3.1 HTMX 响应规范
| 场景 | HTTP | 响应内容 | 响应头 |
|---|---|---|---|
| 操作成功 | 200 | 更新后的 HTML Partial | `HX-Trigger: {"fonrey:toast":{"type":"success","message":"..."}}` |
| 表单校验失败 | 422 | 含错误信息的表单 Partial保留用户输入 | 不发 Toast |
| 业务规则拒绝(如未导出就硬删) | 422 | 表单 Partial + 顶部 Alert 块 | 可选 `HX-Trigger` warning Toast |
| 权限不足 | 403 | `<div class="alert alert-danger">无权限</div>` Partial | `HX-Trigger: {"fonrey:toast":{"type":"error","message":"权限不足"}}` |
| 需要 MFA 二次确认 | 401 | 空 Partial | `HX-Trigger: {"fonrey:mfa-required":{"action":"...","return_to":"..."}}` |
| 未登录 / Session 过期 | 401 | 空 Partial | `HX-Redirect: /admin/login/` |
| 服务器异常 | 500 | 错误页 Partial | `HX-Trigger: {"fonrey:toast":{"type":"error","message":"系统异常,请重试"}}` |
#### 4.3.2 Celery 任务前端协议
```http
POST /admin/system/exports/new/
HX-Request: true
Content-Type: application/x-www-form-urlencoded
200 OK
HX-Trigger: {"fonrey:toast":{"type":"info","message":""}}
<tr id="export-row-{task_id}"
hx-get="/admin/system/exports/{task_id}/status/"
hx-trigger="every 5s" hx-swap="outerHTML">
<td>{{ task.modules }}</td><td></td><td></td>
</tr>
```
**轮询规约**
- 轮询间隔:备份/导出 = 5s升级进度 = 3s。
- 终态后端必须**移除** `hx-trigger="every"`(替换为 `hx-trigger="load"` 或不附 trigger
- 进度展示字段统一来自 Celery `AsyncResult` + DB 状态DB 优先,避免 Celery 结果过期丢失)。
### 4.4 错误码
> 所有 JSON 响应遵循 [`API_CONTRACT.md`](./API_CONTRACT.md) 包络规范:`{"error": "...", "code": "SNAKE_CASE_CODE"}`AGENTS.md §4.7)。
#### 4.4.1 客户端运行时(`/api/release/v1/...`
| code | HTTP | 中文含义 |
|---|---|---|
| `RELEASE_VERSION_INVALID` | 400 | 版本号不符合 SemVer |
| `RELEASE_PUBLISHED_CONFLICT` | 409 | 当前 `platform + arch` 已存在 published 版本 |
| `RELEASE_ARTIFACT_NOT_FOUND` | 404 | 发布包不存在或不可访问 |
| `RELEASE_CHECKSUM_MISMATCH` | 400 | 安装包完整性校验失败 |
| `RELEASE_HEARTBEAT_INVALID` | 400 | 心跳参数非法 |
| `RELEASE_DEVICE_TOKEN_INVALID` | 401 | 设备票据无效 / 过期 |
| `RELEASE_PERMISSION_DENIED` | 403 | 权限不足 |
| `RELEASE_RATE_LIMITED` | 429 | 请求过于频繁 |
#### 4.4.2 后台业务(`/admin/...` 与 `/admin/api/...`
| code | HTTP | 中文含义 |
|---|---|---|
| `ADMIN_LOGIN_REQUIRED` | 401 | 未登录或 Session 过期 |
| `ADMIN_MFA_REQUIRED` | 401 | 高危操作需 MFA step-up |
| `ADMIN_MFA_INVALID` | 422 | TOTP 校验失败 |
| `ADMIN_ROLE_DENIED` | 403 | 当前角色不足以执行此操作 |
| `ADMIN_IP_NOT_WHITELISTED` | 403 | 来源 IP 未在白名单(中间件层返回静态 403 页,不暴露后台存在) |
| `TENANT_NOT_FOUND` | 404 | 租户不存在 |
| `TENANT_INVALID_TRANSITION` | 422 | 状态机非法迁移 |
| `TENANT_HAS_ACTIVE_USERS` | 422 | 软删除前需先清空活跃用户 |
| `TENANT_NOT_EXPORTED` | 422 | 硬删除前必须先完成数据导出 |
| `BACKUP_IN_PROGRESS` | 409 | 同租户存在进行中的备份 |
| `RESTORE_PRECHECK_FAILED` | 422 | 恢复前置检查失败(备份哈希不匹配 / 目标 schema 异常) |
| `EXPORT_TOO_LARGE` | 422 | 单次导出数据量超限(建议拆分) |
| `UPGRADE_HEALTH_GATE_FAILED` | 422 | 批后健康门控指标不通过,已进入 `halted` 态 |
| `UPGRADE_INVALID_TYPE` | 422 | `upgrade_type` 与字段组合非法(如 A 类填了 `gray_tenant_ids` |
| `AUDIT_IMMUTABLE` | 422 | 审计日志禁止修改/删除DB trigger 兜底) |
| `FEATURE_FLAG_DEFINITION_ARCHIVED` | 422 | Flag 已归档,禁止变更 |
| `RATE_LIMITED` | 429 | 请求过于频繁 |
---
## 5. 性能与可靠性约束
### 5.1 客户端运行时 API
- `GET /api/release/v1/updates/latest/``p95 < 120ms`(缓存命中)。
- `POST /api/release/v1/heartbeats/`:写入 `p95 < 80ms`;同 `device_id` 限频 12 次/分钟。
- 任意更新失败不影响当前版本继续运行(可恢复原则)。
- SHA-256 校验失败禁止安装并保留当前版本可用(`ADR-20260430-008`)。
### 5.2 后台业务
- 列表页(租户 / 备份 / 审计 / 客户端版本):`p95 < 200ms`(首屏,`tenants` < 5000 行级别;超过需走 Keyset 分页 + Redis 计数)。
- HTMX Partial 响应:`p95 < 150ms`(不含轮询接口)。
- 任务进度轮询:`p95 < 50ms`(命中 `pub:backup:status:{id}` / `pub:export:status:{id}` 缓存)。
- 升级状态切换使用事务,保证「下线旧版 + 发布新版」原子完成。
- 客户端发布状态切换使用事务,保证「同 `(platform, arch)` 单一 published」约束。
### 5.3 跨租户操作
- 备份单租户1 min 2h取决数据量异步 + 进度上报。
- 恢复单租户530 min**不重试**,失败自动回滚到前置快照。
- 单租户 schema 迁移110s 快照 + `migrate` 主体。
- 整体硬删除110 minDROP SCHEMA 必须事务 + SAVEPOINT
---
## 6. 异步任务、缓存与文件上传
### 6.1 Celery 任务清单
> 队列:`admin_ops`(默认)、`migration`(独立限并发,仅 B 类升级使用)。
| 任务 | 触发场景 | 队列 | 重试 | 失败处理 |
|---|---|---|---|---|
| `provision_tenant` | 创建租户后异步执行 schema 创建 + 迁移 + 默认数据 | `admin_ops` | 不重试 | 标记 `tenants.status='failed'`,事务回滚,邮件告警 |
### 6.1.1 创建租户 Saga 与补偿事务PT-B-1 回应)
> **背景**:审核报告 PT-B-1 指出,`provision_tenant` 任务跨越"DB 行写入 → schema 创建 → 迁移 → 发送欢迎邮件"多个步骤,任意步骤失败若无补偿事务,会导致 `tenants` 表存在悬空行、schema 孤儿或账号不一致。本节定义完整 Saga 流程及每步补偿动作。
#### Saga 步骤与补偿矩阵
| 步骤 # | 动作 | 成功后状态 | 补偿动作(失败时回滚) |
|---|---|---|---|
| **S1** | 写入 `public.tenants``status='provisioning'`+ 写审计行 | DB 行存在 | 将 `status` 改为 `'failed'`**不删行**(保留审计溯源) |
| **S2** | `CREATE SCHEMA {schema_name}` | schema 已创建 | `DROP SCHEMA {schema_name} CASCADE`(若存在) |
| **S3** | `django-tenants migrate --schema={schema_name}` | 所有 migration 应用完成 | `DROP SCHEMA {schema_name} CASCADE`schema 已损坏,丢弃重建) |
| **S4** | 写入租户 schema 默认数据(角色、系统配置等) | 默认数据就绪 | 同 S3 补偿(整个 schema 丢弃) |
| **S5** | 在 `{schema_name}.users` 创建初始 Tenant Admin 账号 | 账号可用 | 同 S3 补偿(账号随 schema 丢弃) |
| **S6** | 更新 `public.tenants.status = 'active'` | 租户对外可用 | 将 `status` 改为 `'failed'`;发送平台告警(已运行 S2S5 资源已清理) |
| **S7** | 异步发送欢迎邮件(`send_welcome_email` | 邮件入队 | 仅记录失败日志 + Sentry 告警;**不回滚整个 Saga**(邮件失败不影响租户可用性) |
> **原则**
> - S1S6 为"原子序列",任一步失败必须逆序执行已完成步骤的补偿。
> - S7 为"幂等尾步骤",独立重试,不触发 Saga 回滚。
> - 补偿动作本身不可再失败——若补偿失败(如 DROP SCHEMA 超时),写入 `platform_audit_logs``action_type='PROVISION_COMPENSATION_FAILED'`)并触发 PagerDuty 告警,由运维人工干预。
#### `provision_tenant` Celery 任务实现
```python
# apps/admin_console/tasks/provision.py
from celery import shared_task
from django.db import transaction, connection
from django_tenants.utils import schema_context, get_tenant_model
import logging
logger = logging.getLogger("provision")
@shared_task(bind=True, acks_late=True, autoretry_for=(), max_retries=0)
def provision_tenant(self, tenant_id: str):
"""
创建租户 Saga。
不重试max_retries=0——失败后由运维根据审计日志判断是否重新触发。
"""
from apps.admin_console.models import Tenant
from apps.admin_console.services import audit_service
tenant = Tenant.objects.get(id=tenant_id)
completed_steps = []
try:
# S1: tenants 行已在 View 层写入status='provisioning'),记录已完成
completed_steps.append("S1_row_written")
# S2: CREATE SCHEMA
_create_schema(tenant)
completed_steps.append("S2_schema_created")
# S3: migrate
_run_migrations(tenant)
completed_steps.append("S3_migrated")
# S4: 默认数据
_seed_default_data(tenant)
completed_steps.append("S4_seeded")
# S5: 初始 Tenant Admin
_create_initial_admin(tenant)
completed_steps.append("S5_admin_created")
# S6: 激活
with transaction.atomic():
tenant.status = "active"
tenant.save(update_fields=["status", "updated_at"])
audit_service.write_audit(
action_type="CREATE_TENANT",
target_type="Tenant",
target_id=str(tenant.id),
result="success",
)
completed_steps.append("S6_activated")
# S7: 欢迎邮件(幂等尾步骤,独立重试,不纳入 Saga 回滚)
from apps.admin_console.tasks.notifications import send_welcome_email
send_welcome_email.apply_async(
kwargs={"tenant_id": tenant_id},
countdown=5,
retry=True,
max_retries=5,
)
except Exception as exc:
logger.exception("provision_tenant failed at steps=%s tenant=%s", completed_steps, tenant_id)
_compensate(tenant, completed_steps, exc)
raise # 保留 Celery 任务失败状态,触发 Sentry
def _create_schema(tenant):
from django_tenants.utils import get_public_schema_name
from django.db import connection
with connection.cursor() as cur:
schema = tenant.schema_name
# 幂等:若 schema 已存在(上次 Saga 补偿不完整),先 DROP 再 CREATE
cur.execute(f"DROP SCHEMA IF EXISTS {schema} CASCADE")
cur.execute(f"CREATE SCHEMA {schema}")
def _run_migrations(tenant):
from django.core.management import call_command
call_command("migrate_schemas", schema_name=tenant.schema_name, interactive=False, verbosity=0)
def _seed_default_data(tenant):
with schema_context(tenant.schema_name):
from apps.admin_console.seeds import seed_tenant_defaults
seed_tenant_defaults(tenant)
def _create_initial_admin(tenant):
with schema_context(tenant.schema_name):
from apps.account.services import account_service
account_service.create_initial_admin(
tenant=tenant,
phone=tenant.contact_phone,
)
def _compensate(tenant, completed_steps: list, exc: Exception):
"""
逆序执行已完成步骤的补偿动作。
"""
from apps.admin_console.services import audit_service
from django.db import connection
# S2S5若 schema 已创建,丢弃整个 schema
if any(s in completed_steps for s in ("S2_schema_created", "S3_migrated", "S4_seeded", "S5_admin_created")):
try:
with connection.cursor() as cur:
cur.execute(f"DROP SCHEMA IF EXISTS {tenant.schema_name} CASCADE")
logger.info("compensation: dropped schema %s", tenant.schema_name)
except Exception as comp_exc:
logger.error("compensation FAILED (DROP SCHEMA): %s", comp_exc)
audit_service.write_audit(
action_type="PROVISION_COMPENSATION_FAILED",
target_type="Tenant",
target_id=str(tenant.id),
result="failed",
error_message=str(comp_exc),
)
# 发 PagerDuty 告警
from apps.admin_console.alerts import trigger_pagerduty
trigger_pagerduty(
title=f"provision_tenant compensation failed: {tenant.schema_name}",
body=str(comp_exc),
)
# S1将 tenants 行标记为 failed不删行保留审计溯源
try:
tenant.status = "failed"
tenant.save(update_fields=["status", "updated_at"])
audit_service.write_audit(
action_type="CREATE_TENANT",
target_type="Tenant",
target_id=str(tenant.id),
result="failed",
error_message=str(exc),
)
except Exception as comp_exc:
logger.error("compensation FAILED (mark tenant failed): %s", comp_exc)
```
#### 幂等性保证
- `_create_schema` 前先 `DROP SCHEMA IF EXISTS ... CASCADE`,确保重新触发 Saga 时不因 schema 残留而报错。
- `provision_tenant` 任务 ID 绑定 `tenant_id`;同一 `tenant_id` 若任务已在 `PROGRESS` / `SUCCESS` 状态View 层拒绝重复入队。
- `create_initial_admin` 内部以 `phone` 为唯一键做 `get_or_create`,幂等安全。
#### 可观测性
| 观测点 | 实现 |
|---|---|
| Saga 步骤进度 | `task.update_state(state='PROGRESS', meta={'step': step_name})` |
| 最终状态 | `platform_audit_logs``action_type='CREATE_TENANT'`, `result='success'/'failed'` |
| 补偿失败告警 | `action_type='PROVISION_COMPENSATION_FAILED'` + PagerDuty |
| 任务耗时监控 | Celery Flower + Prometheus `celery_task_runtime_seconds{name="provision_tenant"}` |
| `auto_resume_suspended` | Beat 每 10 min 扫描 `suspended_until <= NOW()` | `admin_ops` | 3 次 / 60s | Sentry 告警 |
| `purge_pending_delete` | Beat 每天 03:00 扫描冷静期到期 | `admin_ops` | 不重试 | 标记 `failed_to_purge` |
| `hard_delete_tenant` | 视图触发 | `admin_ops` | 不重试 | 部分删除标记 + 告警 |
| `run_backup` | 调度器 + 升级前 + 手动 | `admin_ops` | 2 次 / 5+30 min 指数退避 | `backup_records.status='failed'` |
| `cleanup_old_backups` | Beat 每天 04:00 | `admin_ops` | 3 次 | 告警 |
| `run_restore` | 视图触发(高危) | `admin_ops` | **不重试** | 自动回滚到恢复前快照 |
| `run_export` | 视图触发 | `admin_ops` | 2 次 / 60s | 标记 `failed`,邮件触发人 |
| `expire_export_links` | Beat 每小时 | `admin_ops` | 3 次 | 告警 |
| `health_check` | 升级前 | `admin_ops` | 1 次 | 阻断升级,前端 422 |
| `orchestrate_upgrade` | 升级表单触发 | `admin_ops` | **不重试** | 失败 → `halted` |
| `migrate_single_tenant` | 编排器派发 | `migration` | 不重试 | 单租户回滚到快照 |
| `tenant_smoke_test` | 单租户迁移后 | `admin_ops` | 不重试 | 计入失败率 |
| `post_batch_health_gate` | 批后门控 | `admin_ops` | 不重试 | 任一指标失败 → `halted` |
| `rollback_single_tenant` | 单租户回滚 | `migration` | 不重试 | 写 incident |
| `rollback_upgrade` | 整体回滚 | `admin_ops` | 不重试 | 写 incident |
| `release_compute_checksum_task` | 客户端发布包上传后 | `admin_ops` | 3 次 | 标记发布包不可用 |
| `release_publish_cdn_warmup_task` | 版本发布后(可选) | `admin_ops` | 3 次 | 仅告警,不阻塞 |
| `release_scan_artifact_task` | 客户端发布包上传后(可选) | `admin_ops` | 3 次 | 命中即标记不可发布 |
| `aggregate_dashboard_stats` | Beat 每 1 min | `admin_ops` | 2 次 | 仪表盘读旧缓存 |
| `aggregate_release_metrics` | Beat 每 1 min | `admin_ops` | 2 次 | 版本分布读旧缓存 |
| `cleanup_admin_sessions` | Beat 每 30 min | `admin_ops` | 3 次 | 告警 |
| `send_welcome_email` / `send_export_ready` / `send_suspend_notice` | 业务事件后 | `admin_ops` | 5 次 / 指数退避 | 仅告警,不阻塞 |
**通用约定**
- 所有任务 `bind=True`,前置统一 `audit_service.write_audit()`(成功/失败均落审计)。
- 涉及租户 schema 操作的任务必须 `with schema_context(tenant.schema_name):`
- 长任务(> 5 min必须周期性 `task.update_state(state='PROGRESS', meta={...})`
- 不重试任务必须显式 `acks_late=True, autoretry_for=()`
### 6.2 Redis 缓存策略
> Key 前缀 `pub:`public schema与租户业务 `{schema}:` 严格隔离AGENTS.md §4.6)。
| 缓存对象 | Key 格式 | TTL | 失效条件 |
|---|---|---|---|
| 平台管理员对象(含 role | `pub:admin:{admin_id}` | 30 min | 角色变更、停用、强制登出 |
| 管理员 Session 反查 | `pub:session:{session_token}` | 30 min与 expires_at 同步) | 强制登出、活动续期 |
| IP 白名单CIDR 列表) | `pub:ipwl:active` | 5 min | 新增/启停白名单 |
| MFA step-up 时间戳 | `pub:mfa:stepup:{session_token}` | 5 min | 自然过期 |
| 登录失败计数 | `pub:login:fail:{username}` | 15 min | 自然过期或登录成功清零 |
| 同 IP 失败计数 | `pub:login:fail:ip:{ip}` | 1 h | 同上 |
| 租户列表筛选总数 | `pub:tenant:count:{filter_hash}` | 30s | 短 TTL |
| 租户基本信息 | `pub:tenant:{tenant_id}` | 10 min | 编辑、状态变更后主动清 |
| 系统当前版本 | `pub:sys:current_version` | 1 h | 升级 / 回滚成功后清 |
| 备份/导出/升级任务进度 | `pub:backup:status:{id}` / `pub:export:status:{id}` / `pub:upgrade:progress:{event_id}` | 5s / 5s / 3s | 任务结束后立即清 |
| 全局备份策略 | `pub:backup:schedule:global` | 1 h | 策略保存后清 |
| 仪表盘统计 | `pub:dashboard:stats` | 1 min | 自然过期 |
| 服务健康状态 | `pub:health:{service}` | 30s | 自然过期 |
| 客户端最新发布版本 | `pub:release:latest:{platform}:{arch}` | 60s | 发布/下线/回滚后立即清 |
| 客户端版本分布聚合 | `pub:release:metrics:version_distribution` | 60s | 自然过期 |
| 单租户安装/活跃统计 | `pub:release:metrics:tenant:{tenant_id}` | 60s | 自然过期 |
| 客户端下载接口限流 | `pub:release:download:ratelimit:{ip}` | 60s | 自然过期 |
| Feature Flag 全局定义 | `pub:ff:def:{flag_key}` | 1 min | 定义变更后清 |
| 租户 Flag 覆盖 | `pub:ff:tenant:{tenant_id}` | 5 min | 租户 Flag 变更后清 |
**失效策略**
- Django Signals 在 `post_save` / `post_delete` 时主动 `cache.delete_many([...])`
- 任务进度类缓存仅作"减少 DB 压力",前端**仍以 DB 状态为准**:缓存 miss 直接查 DB。
- IP 白名单缓存命中失败时**不能放行**,必须 fail-closed拒绝访问
### 6.3 文件上传规范Cloudflare R2
| 场景 | 上传方 | Bucket | 路径模板 |
|---|---|---|---|
| 系统升级包 artifact | 超级管理员 → 后端 → R2 | `releases-system` | `releases/system/{version}/{filename}` |
| 客户端发布包EXE/ZIP| 超级管理员 → 后端 → R2 | `releases-client` | `releases/system/v{version}/fonrey-setup-{version}-win.exe``fonrey-portable-{version}-win.zip` |
| 备份产出 | Celery worker → R2 | `backups` | `backups/{tenant_schema}/{record_id}.tar.gz` |
| 导出产出 | Celery worker → R2 | `exports` | `exports/{tenant_schema}/{task_id}.zip` |
| 审计日志导出 CSV | Celery worker → R2 | `exports` | `exports/audit/{task_id}.csv` |
**强制规则**
- 所有 R2 写入由后端 Celery 完成;**禁用**前端直传 Presigned URL合规 + SHA-256 完整性 + 频次极低)。
- 升级包 / 客户端发布包必须 SHA-256 双校验:上传完成后由 `release_compute_checksum_task` 计算并落库(`system_versions.checksum_sha256` / `client_releases.checksum_sha256`);客户端拉取后必须校验,失败禁止安装(`ADR-20260430-008`)。
- 升级包视图使用 `python-magic` 读取头部字节做 MIME 校验,**不信任** `Content-Type` header 或文件扩展名。
- 下载链接(导出/备份)使用 R2 Presigned GET URLTTL = 24 小时;视图 302 重定向,不返回链接给前端。
- Nginx `client_max_body_size`:升级包/发布包路径 600M其他路径 10M。
### 6.4 系统升级 A / B / C 三类编排
> 沿用原『系统管理技术文档.md』§8.5§8.6。摘要:
| 类型 | 内容 | 是否可分批到租户级 | 编排路径 |
|---|---|---|---|
| **A. 应用代码升级** | Python 代码、模板、JS/CSS、Worker 镜像 | ❌ 单进程多租户架构下物理上不可分批;蓝绿切换 | 运维侧 K8s/Compose本模块仅记录 `system_versions` 元数据 |
| **B. 租户 Schema 迁移** | `apps.property` / `apps.client``migrations/*.py` | ✅ 按 `schema_name` 分批 | 本模块 `orchestrate_upgrade` 编排 |
| **C. Feature Flag 灰度** | 运行时启停(双路径分支) | ✅ 按租户/用户/百分比 | 本模块 `feature_flags` 服务 |
**B 类状态机**`draft → pre_check → pre_backup → batch_running ⇄ batch_done → succeeded`;任一批次失败 → `halted`,超管二选一「继续 / 回滚」。
**B 类批次参数(`upgrade_events` 字段)**`upgrade_type``batch_size`(默认 5`batch_concurrency`(默认 2`batch_interval_seconds`(默认 300`failure_policy``halt_batch` / `continue`)、`health_gate_config`jsonb 阈值覆盖)。
**批后健康门控**`error_rate_5xx_5m < 0.5%``p95_latency_5m < 2000ms``celery_queue_pending < 1000``sentry_new_issues_5m < 5``migrated_tenant_smoke_pass_rate = 100%` 才进入下一批。
**DDL 兼容性纪律**:所有租户 App migration 必须向后兼容(`ADD COLUMN`/`CREATE INDEX CONCURRENTLY`/`ADD CONSTRAINT NOT VALID` 安全;`DROP COLUMN`/`RENAME`/`ALTER TYPE 不兼容` 必须拆两次发布CI `django-migration-linter` 兜底;强制注释 `# UPGRADE_TYPE: expand|cleanup`
### 6.5 Feature Flag 服务
```python
def is_enabled(tenant, flag_key: str, *, user=None) -> bool:
if flag_key in tenant.feature_flags:
return bool(tenant.feature_flags[flag_key])
definition = _get_definition_cached(flag_key)
if definition is None or definition.archived_at:
return False
if definition.rollout_strategy == 'percentage':
bucket = stable_hash(f"{flag_key}:{tenant.id}") % 100
return bucket < definition.rollout_config.get('percentage', 0)
if definition.rollout_strategy == 'user' and user:
bucket = stable_hash(f"{flag_key}:{user.id}") % 100
return bucket < definition.rollout_config.get('percentage', 0)
return definition.default_value
```
**约束**
- 业务代码必须用 `is_enabled(...)`**严禁**直接读 `tenant.feature_flags[...]`
- `stable_hash` 使用 `xxhash`,租户 ID 长期稳定,避免百分比策略下租户被频繁挤进/挤出。
- 写操作必填 `reason`,写入 `feature_flag_change_log``platform_audit_logs`
---
## 7. 安全与合规
### 7.0 平台后台独立子域与会话隔离S-2 回应)
> **背景**:审核报告 S-2 指出平台管理员PlatformAdmin会话与租户用户会话若共用 Cookie 域,存在越权同会话风险。本节明确隔离边界与实施机制。
#### 7.0.1 域名分离
| 角色 | 域名 | 说明 |
|---|---|---|
| 租户业务用户 | `*.fonrey.com`(各租户子域) | django-tenants 按 Host 路由至租户 schema |
| 平台管理后台 | `admin.fonrey.com` | 独立 server block物理分离 Cookie 域 |
| 客户端 API | `app.fonrey.com` | 客户端运行时 API独立 server block |
#### 7.0.2 Cookie 隔离配置
```python
# settings/admin.py
SESSION_COOKIE_DOMAIN = "admin.fonrey.com" # 严格限定,不允许 .fonrey.com 通配
SESSION_COOKIE_SECURE = True # HTTPS only
SESSION_COOKIE_HTTPONLY = True # 禁止 JS 访问
SESSION_COOKIE_SAMESITE = "Strict" # 阻止跨站携带
SESSION_COOKIE_NAME = "adminSessionId" # 与租户域 sessionid 命名隔离
CSRF_COOKIE_DOMAIN = "admin.fonrey.com"
```
> 租户业务侧 `SESSION_COOKIE_NAME = "sessionid"`;两侧 Cookie 名和 Domain 双重隔离,即使浏览器同时打开两个域名,也不会互相携带。
#### 7.0.3 `AdminSessionMiddleware` 会话隔离中间件
每次请求到达 `admin.fonrey.com` 时,中间件执行以下校验序列:
```python
# apps/admin_console/middleware.py
class AdminSessionMiddleware:
"""
会话隔离守门中间件。
必须放在 MIDDLEWARE 列表中 SessionMiddleware 之后、
所有 View 处理之前。
"""
EXEMPT_PATHS = {
"/admin/login/",
"/admin/mfa/setup/",
"/admin/mfa/verify/",
"/health/",
}
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path not in self.EXEMPT_PATHS:
self._enforce_isolation(request)
return self.get_response(request)
def _enforce_isolation(self, request):
"""
三层校验:
1. schema 必须是 public租户 schema 不得进入)
2. session 中必须存在 platform_admin_id
3. admin_sessions 记录必须存在且未过期
失败时 fail-closed → 302 跳登录页,同时清空 session。
"""
from django_tenants.utils import get_public_schema_name
from django.db import connection
# 1. schema 隔离:只允许 public schema 进入后台
if connection.schema_name != get_public_schema_name():
self._reject(request, "non-public schema access denied")
return
# 2. session 中必须有 platform_admin_id
admin_id = request.session.get("platform_admin_id")
if not admin_id:
self._reject(request, "no platform_admin_id in session")
return
# 3. admin_sessions 记录有效性(滚动续期)
from apps.admin_console.models import AdminSession
from django.utils import timezone
session = AdminSession.objects.filter(
admin_id=admin_id,
session_key=request.session.session_key,
is_active=True,
expires_at__gt=timezone.now(),
).first()
if not session:
self._reject(request, "admin session expired or revoked")
return
# 4. 滚动续期:每次合法请求把 expires_at 向后延 30 min
session.expires_at = timezone.now() + timedelta(minutes=30)
session.save(update_fields=["expires_at"])
# 5. 把 admin 对象注入 request供 View 直接使用
request.platform_admin = session.admin
@staticmethod
def _reject(request, reason: str):
import logging
from django.http import HttpResponseRedirect
logging.getLogger("security").warning(
"AdminSessionMiddleware rejected: %s | path=%s | ip=%s",
reason, request.path, request.META.get("REMOTE_ADDR"),
)
request.session.flush() # 清空 session防止残留
# 注raise 方式在 MIDDLEWARE 中无效,直接修改 request._reject 标记
request._admin_session_rejected = True
def process_view(self, request, view_func, view_args, view_kwargs):
if getattr(request, "_admin_session_rejected", False):
from django.shortcuts import redirect
return redirect("/admin/login/")
return None
```
#### 7.0.4 Nginx 物理防线
```nginx
# /etc/nginx/conf.d/admin.fonrey.com.conf
server {
listen 443 ssl http2;
server_name admin.fonrey.com;
# IP 白名单(与应用层 AdminIPWhitelistMiddleware 双重防线)
include /etc/nginx/conf.d/admin_ip_whitelist.conf;
deny all;
# 禁止租户子域访问 /admin/ 路径(防跨域探测)
if ($host ~* "^(?!admin\.).*\.fonrey\.com$") {
return 404;
}
location / {
proxy_pass http://gunicorn_cluster;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
#### 7.0.5 安全回归测试要点
| 场景 | 期望结果 |
|---|---|
| 使用租户用户 Cookie 访问 `admin.fonrey.com` | `AdminSessionMiddleware` 拒绝302 → 登录页 |
| 使用平台管理员 Cookie 访问租户域 | Cookie Domain 不匹配,浏览器不携带,鉴权失败 |
| `*.fonrey.com` 任意 Host 访问 `/admin/...` | Nginx 404`if $host` 规则) |
| Session 超过 30 min 无活动后访问 | `expires_at` 超时中间件拒绝302 → 登录页 |
| `connection.schema_name != 'public'` 下访问 | 中间件拒绝302 → 登录页 |
### 7.1 认证与会话
| 项 | 要求 |
|---|---|
| 独立 Auth Backend | `PlatformAdminBackend`,校验 `platform_admins.password_hash`Argon2id登录成功后写 `admin_sessions` |
| 不复用 `auth.User` | 租户业务用 `apps.account.User`(租户 schema平台后台完全独立 |
| Session 超时 | 滚动续 30 min`AdminSessionMiddleware` 每次请求把 `expires_at = NOW() + 30min` |
| Cookie | `Secure``HttpOnly``SameSite=Strict`Cookie domain 限定 `admin.fonrey.com`(不允许跨子域,与租户业务 Cookie 物理分离) |
| CSRF | 所有写操作启用HTMX 通过 `hx-headers='{"X-CSRFToken": "..."}'` 在 base 模板注入 |
| MFA 强制 | `platform_admins.mfa_enabled=False` 时除 `MfaSetupView` 外所有视图 302 强制跳设置 |
| TOTP 密钥 | `admin_mfa_devices.totp_secret` AES-256-GCM 加密存储,密钥来自环境变量 `ADMIN_MFA_KEY`,不与租户加密密钥共用 |
| 高危操作 | 硬删除/恢复/升级/回滚/客户端发布/下线/强制更新推送必须 MFA step-up5 min 时效) |
| 暴力破解防护 | 登录失败 5 次锁账号 15 min`pub:login:fail:{username}`);同 IP 失败 20 次锁 IP 1h |
### 7.2 IP 白名单
- `IpWhitelistMiddleware` 仅在 `request.host in ADMIN_CONSOLE_HOSTS` 时启用。
-`ip_whitelist`Redis 缓存 `pub:ipwl:active`);未命中返回 403 静态页(不暴露后台存在)。
- Nginx 层 `allow / deny` + 应用层中间件双重保险。
### 7.3 审计不可变
- DB trigger`BEFORE UPDATE OR DELETE ON platform_audit_logs ... RAISE EXCEPTION`
- ORM 层 `PlatformAuditLog` Manager 重写 `update()` / `delete()``IntegrityError`
- 只允许 `objects.append_only_create()`
- 紧急数据修复走 `manage.py shell_plus`,事后由超管在 `/admin/audit-logs/` 手工补录条目(`source='manual_shell'`),不开后门。
### 7.4 客户端运行时安全
1. Electron 必须启用:`contextIsolation=true``nodeIntegration=false``sandbox=true`
2. 更新包仅允许 HTTPS 下载;域名白名单固定为 `download.fonrey.com`
3. EV 证书私钥仅在 CI 密钥库中可用,禁止落盘到开发机。
4. 校验值由服务端生成并签名传输(至少 TLS + 服务端可信源)。
5. Heartbeat 接口必须防重放/防刷(设备票据 + 频控 + 审计)。
6. 管理端操作(发布、回滚、下线、强制更新)全部记录审计日志。
### 7.5 CSP 与跨域名隔离
- `Content-Security-Policy: default-src 'self'`Grafana iframe 域加入 `frame-src` 白名单;禁止 `unsafe-inline`HTMX/Alpine 已用 attribute 模式)。
- 跨域名严禁串台:租户 host 上访问 `/admin/...` 必须 404管理 host 上访问租户 URL 必须 404。由 `IpWhitelistMiddleware` + URLConf 双重保证。
- Django Admin 全环境弃用(详见 §2.4)。
---
## 8. 监控集成
| 维度 | 实现 |
|---|---|
| Grafana 嵌入 | `MonitoringView` 渲染含 Grafana iframe 的页面URL 含短期签名 token避免暴露 Grafana 公网入口 |
| 告警接收 | Grafana → Webhook → `GrafanaWebhookView`HMAC 签名校验) |
| Sentry | 独立 DSN与租户业务分离 |
| Celery 队列健康 | Flower 部署在 `admin.fonrey.com/flower/`,仅超管可访问 |
| 审计告警 | 任意 `result='FAILED'` 的高危操作HARD_DELETE / RESTORE / UPGRADE / ROLLBACK / FORCE_UPDATE_PUSH实时推送企业微信 + 邮件 |
| 客户端发布告警 | 发布失败 / SHA-256 校验失败 / 客户端版本分布异常波动24h 内某版本占比骤降 > 30%)实时告警 |
监控数据采集来源PRD §5.1.5CPU/内存来自 Prometheus node_exporter存储/API/活跃数来自应用埋点写 PostgreSQL `tenant_metrics_daily` 物化视图(属租户业务模块范畴,本模块仅消费)。
---
## 9. 测试规范
### 9.1 覆盖矩阵
| 类别 | 工具 | 必测内容 |
|---|---|---|
| Model | pytest-django + factory_boy | UUID 默认值、状态机 CHECK、唯一索引`schema_name` / `email` / `(platform,arch,status='published')` 单一约束、append-only Manager、软删除标记 |
| Service | pytest-django + Mock R2 / Mock Celery | `tenant_service.provision()` 失败回滚、`audit_service.write_audit()` 字段完整、状态机非法迁移抛错、`release_service.publish()` 单一 published 原子切换 |
| ViewHTTP | `django_tenants.test.client.TenantClient`(公共 schema+ `HTTP_HOST='admin.fonrey.com'` | 三角色 × 关键端点的 200/403/401未登录三场景MFA step-up 拦截CSRF |
| ViewHTMX | 同上 + `HTTP_HX_REQUEST='true'` | 验证返回 partial不含 `<html>` 根标签),且响应头包含约定的 `HX-Trigger` |
| 客户端 API | `Client(HTTP_HOST='app.fonrey.com')` | `/api/release/v1/updates/latest/` 公开访问;`/api/release/v1/heartbeats/` 设备票据校验;版本分布 Session |
| Middleware | pytest-django | IP 白名单命中/未命中Session 滚动续期;过期 302fail-closed |
| Celery 任务 | `CELERY_TASK_ALWAYS_EAGER=True` + R2/邮件 Mock | 主流程 + 失败回滚 + 重试次数 + 不重试任务的 `acks_late` 行为 |
| 安全回归 | 集成测试 | 跨域名(`*.fonrey.com` host 访问 `/admin/...` → 404租户用户身份不能登入管理后台管理后台 Cookie 不能在租户域名下生效;`'django.contrib.admin' not in settings.INSTALLED_APPS` 断言CI grep 守门 |
### 9.2 关键测试约束AGENTS.md §6
- 禁止使用 Django 原生 `Client()`,统一使用 `TenantClient`
- 所有受角色保护的 View 必须覆盖超级200/204、运营200 或 403、审计员GET 200 / 非 GET 403、未登录302 → /admin/login/)。
- 高危操作测试必须包含 MFA step-up 已通过 / 未通过两个分支。
- `platform_audit_logs` 测试:写操作后断言审计行存在;尝试 UPDATE / DELETE 必须抛 `IntegrityError`Manager + DB trigger 双重保险)。
- 客户端 API 集成测试SHA-256 校验失败禁止安装;强制更新分支;启动 + 4h 检测周期;失败可恢复。
- Celery 异步测试覆盖率:`tasks/` ≥ 85%`services/` ≥ 90%`views/` ≥ 75%。
### 9.3 测试文件路径
- `tests/integration/admin_console/test_us_admin_console.py`(租户/备份/导出/升级/审计/管理员设置)
- `tests/integration/release/test_us_release.py`(客户端发布管理 + 客户端运行时 API + Heartbeat
### 9.4 测试映射(与 PRD Story 对齐)
| PRD Persona / Story | 测试关注点 |
|---|---|
| Persona A — 运营人员 Lily | 租户 CRUD、挂起/恢复、备份触发、导出 |
| Persona B — David系统升级 | A/B/C 三类升级、灰度名单可填性、健康门控、回滚 |
| Persona C — David客户端发布 | 版本创建/发布/下线/回滚、单一 published 约束、强制更新推送、SHA-256 校验 |
| Persona D — 审计员 Carol | 审计日志只读、导出 CSV、非 GET → 403 |
| 客户端 HeartbeatPRD 5.5.5 + ADR-20260430-007| Upsert 幂等、24h 活跃口径、租户活跃榜排序 |
| 客户端自动升级 | 启动 + 4h 检测、普通/强制分支、失败可恢复、SHA-256 失败禁止安装 |
---
## 10. 部署规范
| 项 | 配置 |
|---|---|
| 域名 | `admin.fonrey.com` 解析到与租户应用相同的 Gunicorn/Uvicorn 集群(共用进程,按 Host 路由) |
| 客户端下载域名 | `download.fonrey.com`Cloudflare CDN + R2 origin |
| Nginx | `server_name admin.fonrey.com` 单独 server block① IP 白名单 `allow / deny`(与应用层双重保险)② `client_max_body_size 600M` 仅限 `/admin/system/versions/upgrade/``/admin/client-releases/new/`;其他端点 10M |
| Celery workeradmin_ops | 独立部署 worker`--concurrency=2 --max-tasks-per-child=50`(任务多为 IO 密集长任务) |
| Celery workermigration | 独立部署 worker`--concurrency=2 --prefetch-multiplier=1`(避免并发 migrate 打爆连接池) |
| Celery beat | 单实例运行;调度任务:`auto_resume_suspended` / `purge_pending_delete` / `cleanup_old_backups` / `expire_export_links` / `aggregate_dashboard_stats` / `aggregate_release_metrics` / `cleanup_admin_sessions` |
| 密钥管理 | `ADMIN_MFA_KEY` / `R2_ADMIN_KEY` / `GRAFANA_SIGN_KEY` / `EV_SIGN_KEY` 通过 Docker Secret 注入;不出现在 `.env` 文件 |
| 日志 | Web 访问日志 / 审计日志 / Sentry 三路独立审计日志同时落库DB+ 落对象存储R2 月归档) |
| 备份的备份 | 备份元数据(`backup_records`)随 `public` schema 每日 02:00 全量 dump 到独立 R2 桶 `meta-backups`,灾难场景下用于重建 |
| 客户端 EV 签名 | EV 证书私钥仅在 CI 密钥库签名步骤electron-builder 输出 → CI 调用 `signtool.exe` → 上传 R2 |
---
## 11. 落地顺序建议
1. `apps/admin_console` 模型/服务/Mixin 基础骨架 + 独立认证 + MFA 设置 + 仪表盘空壳(页面 12
2. 租户管理(页面 310+ 审计基础 + IP 白名单(页面 16 子集)。
3. 备份 + 导出 + 监控(页面 12 / 13
4. 系统升级(页面 11 + Feature Flag
5. `apps/release` 后台 UI页面 14+ 客户端运行时 API`/api/release/v1/updates/latest/``/heartbeats/`)。
6. Electron 壳应用最小可运行版 + electron-updater 接入 + EV 签名 CI。
7. 官网下载页(公司域名)+ 便携版 ZIP。
8. 完整审计页面 + 平台管理员设置(页面 15 / 16 全集)。
---
## 12. 文档同步规则
- PRD 平台管理后台变更:同步本文件。
- `tenants` / `platform_admins` / `client_releases` / `client_heartbeats` / `upgrade_events` / `feature_flag_*` 字段变更:同步 `DATA_MODEL_PUBLIC.md`
- 枚举值变更:同步 `DATA_MODEL/ENUMS.md`(含中文标签)。
- API 包络/错误契约变更:同步 `TECH_STACK/API_CONTRACT.md`
- 测试用例新增:同步 `TECH_STACK/测试规范.md``TEST_CASES/TEST_CASE_REGISTRY.md`
---
## 13. 待解决问题
| 编号 | 级别 | 问题描述 |
| ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **TS-PA-01** | 🟢 Minor | 客户端管理 API 是否将 `/admin/api/client-releases/...` 升级为 `/admin/api/v1/client-releases/...``/api/release/v1/...` 对齐版本号?决议:**保持现状**(管理端非长期对外契约)。 |
| **TS-PA-02** | 🟢 Minor | Feature Flag 与 `tenants.feature_flags` 字段的 DDL 是否需要 ADR 单列?决议:合并到 B 类升级 ADR`ADR-20260430-007` 范围内),不另列。 |
| **TS-PA-03** | 🟠 Major | 旧 PRD 的反向引用README、其他 TECH_STACK、ADR 早期条目尚未全量清理35+ 处);按用户决定本轮不处理,后续单独发起治理。 |