Files
nexus/Project/fonrey/TECH_STACK/系统管理技术文档.md
2026-04-26 20:33:29 +08:00

890 lines
54 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.2 **最后更新**: 2026-04-26
**模块**: 系统管理Admin Console / Platform Operations
**范围依据**:
- PRD: [`PRD/系统管理/系统管理模块PRD.md`](../PRD/系统管理/系统管理模块PRD.md)
- 数据模型: [`DATA_MODEL/DATA_MODEL_PUBLIC.md`](../DATA_MODEL/DATA_MODEL_PUBLIC.md)
- 技术总纲: [`TECH_STACK.md`](./TECH_STACK.md)
> **关键定位**:本模块是 **平台运营后台**`admin.fonrey.com`),数据全部存于 PostgreSQL `public` schema归属 `django-tenants` 的 `SHARED_APPS`**不参与租户 schema 切换**。所有功能仅限平台管理员(`platform_admins`)访问,与租户应用(`*.fonrey.com`的认证体系、Session、URL 命名空间完全隔离。
---
## 1. 模块边界与定位
| 维度 | 说明 |
|------|------|
| 部署域名 | `admin.fonrey.com`独立子域Nginx 层 IP 白名单) |
| Schema 归属 | `public``SHARED_APPS`),所有 ORM 查询走 `public_schema_urlconf` |
| 认证体系 | `platform_admins` 独立账号 + 强制 TOTP MFA与租户 `staff` 不共享 |
| 受众 | 超级管理员 / 运营人员 / 只读审计员(详见 PRD §9.2 权限矩阵) |
| URL 前缀 | `/admin/...`(自建 CBV 后台,**Django 自带 `django.contrib.admin` 全环境弃用** |
| Celery 队列 | 独立队列 `admin_ops`,与租户业务队列隔离避免相互干扰 |
**与租户业务模块的隔离原则**
- ❌ 严禁本模块代码导入 `apps.property` / `apps.client` / `apps.org` 等租户 App 的 Model
- ❌ 严禁本模块视图、任务直接访问租户 schema 中的表
- ✅ 跨租户数据操作(备份、恢复、导出)必须通过 `tenant_context(tenant)` 显式切换 schema 后再操作
### 1.5 与 `django.contrib.admin` 的关系(强制弃用)
**全环境弃用 Django Admin**,包括开发、预发、生产。理由:
| 冲突点 | 说明 |
|---|---|
| 多租户编排 | Django Admin 假设单 schema无 schema 切换钩子;本模块需跨 `public` ↔ 租户 schema 编排(备份/恢复/重置租户用户密码Admin 框架无法承载 |
| 认证体系 | Admin 强绑定 `django.contrib.auth.User`;本模块要求独立 `platform_admins` 表 + 强制 TOTP二者不可共存于同一登录入口 |
| 审计强度 | Admin 自带 `LogEntry` 仅记录 add/change/delete且允许 UPDATE/DELETE本模块要求 append-only + 覆盖读操作PRD §3.2 |
| 交互范式 | Admin 模板基于整页表单刷新;本模块要求 HTMX 局部刷新 + Alpine.js 二次确认 ModalUI_SYSTEM 规范) |
| 业务流页面 | 升级灰度进度、备份恢复 MFA step-up、监控大盘等非 CRUD 页面无法用 ModelAdmin 表达 |
| 受众 | Admin 面向「懂 Django ORM 概念」的开发者;本模块运营人员为非技术背景,需要业务化 UI |
**强制措施**
- `INSTALLED_APPS` **不注册** `django.contrib.admin``django.contrib.admin.apps.SimpleAdminConfig`
- `INSTALLED_APPS` **不注册** `django.contrib.auth.AuthenticationMiddleware` 之外的 Admin 相关中间件
- `urls_public.py` **不导入** `django.contrib.admin`,无 `admin.site.urls` 路由
- 项目根 `settings/base.py` 增加启动断言:`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/` 命中即构建失败
- 紧急数据修复一律走 `python manage.py shell_plus` + 审计日志手工补录,**不开后门**
---
## 2. Django App 目录结构
本模块对应单个 App`apps/admin_console/`,归属 `SHARED_APPS`。受 PRD §5 划分驱动,内部按子域拆分 `views/` `tasks/` `services/`,避免单文件膨胀。
```
apps/admin_console/
├── apps.py
├── urls.py # 仅注册到 PUBLIC_SCHEMA_URLCONF
├── signals.py # 状态变更 → 写 audit_log备份完成 → 发邮件
├── forms.py # 所有表单TenantForm / BackupScheduleForm / AdminForm ...
├── serializers.py # 仅给 Celery 任务状态轮询的极少 JSON 端点用
├── permissions.py # 角色 Mixin / 装饰器 / IP 白名单 Middleware 接口
├── middleware.py # IpWhitelistMiddleware / AdminSessionMiddleware
├── auth_backends.py # PlatformAdminBackend独立认证不复用 Django auth User
├── models/
│ ├── __init__.py
│ ├── tenant.py # Tenant / Domain / TenantStatusLog继承 django-tenants
│ ├── platform_admin.py # PlatformAdmin / AdminMfaDevice / AdminSession / IpWhitelist
│ ├── audit.py # PlatformAuditLogappend-only Manager
│ ├── backup.py # BackupSchedule / BackupRecord
│ ├── export.py # ExportTask
│ └── version.py # SystemVersion / UpgradeEvent
├── views/
│ ├── __init__.py
│ ├── auth.py # 登录 / MFA 校验 / 登出
│ ├── dashboard.py # DashboardView
│ ├── tenants.py # TenantListView / TenantDetailView / TenantCreateView / SuspendView / DeleteView ...
│ ├── tenant_users.py # 租户内 Tenant Admin 列表 / 密码重置
│ ├── backups.py # BackupScheduleView / BackupListView / TriggerBackupView / RestoreView
│ ├── exports.py # ExportTaskCreateView / ExportTaskListView / DownloadView签名链接
│ ├── versions.py # SystemVersionListView / UpgradeView / RollbackView
│ ├── monitoring.py # MonitoringView嵌入 Grafana iframe
│ ├── audit.py # AuditLogListView / AuditLogExportView
│ └── settings.py # AdminAccountView / RoleView / IpWhitelistView / SessionView
├── tasks/
│ ├── __init__.py
│ ├── tenant_lifecycle.py # provision_tenant / suspend_tenant / hard_delete_tenant / auto_resume_suspended
│ ├── backup.py # run_backup / cleanup_old_backups
│ ├── restore.py # run_restore含前置自动快照
│ ├── export.py # run_exportCSV/JSON/SQL Dump
│ ├── upgrade.py # run_upgrade / run_rollback / health_check
│ ├── notifications.py # send_welcome_email / send_suspend_notice / send_export_ready
│ └── housekeeping.py # purge_pending_delete / expire_export_links / cleanup_admin_sessions
├── services/
│ ├── __init__.py
│ ├── tenant_service.py # 业务逻辑状态机迁移、Schema 创建/销毁
│ ├── audit_service.py # 统一写审计日志的入口write_audit
│ ├── mfa_service.py # TOTP 生成 / 校验 / 二维码
│ ├── permission_service.py # 三角色权限矩阵决策
│ ├── backup_service.py # 调度计划解析、保留策略
│ └── version_service.py # 升级状态机、灰度租户进度合成
├── tests/
│ ├── __init__.py
│ ├── factories.py # factory_boy 工厂
│ ├── test_models.py # 字段约束、状态机、append-only
│ ├── test_views_tenants.py # 三角色 200/403 矩阵
│ ├── test_views_audit.py # 审计日志只读、导出
│ ├── test_views_settings.py # MFA、IP 白名单、强制登出
│ ├── test_tasks_tenant.py # provision / suspend / hard_delete
│ ├── test_tasks_backup.py
│ ├── test_tasks_export.py
│ ├── test_tasks_upgrade.py
│ ├── test_middleware.py # IP 白名单、Session 滚动超时
│ └── test_audit_service.py
└── templates/admin_console/
├── base.html # 管理后台独立 base不与租户 base 共享)
├── auth/
│ ├── login.html
│ └── mfa_challenge.html
├── dashboard.html
├── tenants/
│ ├── list.html
│ ├── detail.html # 含 Tab基本信息/用户/套餐/监控/备份/操作历史
│ ├── create.html
│ └── partials/
│ ├── row.html # 列表行HTMX swap target
│ ├── filter_bar.html
│ ├── pagination.html
│ ├── status_badge.html
│ ├── suspend_form.html
│ ├── delete_confirm.html # Confirm Modal partial
│ └── tenant_admins_table.html
├── backups/
│ ├── list.html
│ ├── schedule_form.html
│ └── partials/
│ ├── record_row.html
│ ├── progress_cell.html # HTMX 轮询任务状态
│ └── restore_confirm.html
├── exports/
│ ├── list.html
│ └── partials/
│ ├── task_row.html
│ └── progress_cell.html
├── versions/
│ ├── list.html
│ ├── upgrade_form.html
│ └── partials/
│ ├── tenant_progress_table.html # 灰度升级实时进度
│ └── rollback_confirm.html
├── monitoring/
│ └── index.html # Grafana iframe 容器
├── audit/
│ ├── list.html
│ └── partials/
│ ├── log_row.html
│ └── filter_bar.html
├── settings/
│ ├── admins.html
│ ├── ip_whitelist.html
│ ├── sessions.html
│ └── partials/
│ ├── admin_row.html
│ ├── mfa_setup_modal.html
│ └── session_row.html
└── components/
├── confirm_modal.html # Danger Confirm删除/回滚/恢复)
├── mfa_challenge_modal.html # 高危操作二次 MFA
├── toast.html # HX-Trigger payload 渲染
└── stat_card.html
```
**目录约定**
- `models/` 一表一文件,对应 DATA_MODEL_PUBLIC.md §2.x 章节
- `views/` 全部使用 Class-Based Views`ListView` / `DetailView` / `FormView`),禁止函数视图
- `tasks/``services/` 分离:`tasks/` 是 Celery 入口(薄壳),业务逻辑落在 `services/`,便于单测
- `templates/admin_console/partials/` 命名以 `_partial`/`partials` 区分完整页 vs HTMX 局部模板
---
## 3. 路由命名空间与设置
### 3.1 settings 关键配置
```python
# config/settings/base.py
SHARED_APPS = [
'django_tenants',
'apps.tenant', # 租户路由 App
'apps.admin_console', # 本模块(自建后台,主体)
'apps.release', # 客户端发布
'django.contrib.contenttypes',
'django.contrib.staticfiles',
# ⚠️ 注意:不注册 'django.contrib.admin',全环境弃用
]
# 启动期硬约束:防止任何人误把 Admin 加回来
assert 'django.contrib.admin' not in SHARED_APPS, \
"Django Admin 已全环境弃用,平台后台请走 apps.admin_console"
assert 'django.contrib.admin' not in (TENANT_APPS if 'TENANT_APPS' in dir() else []), \
"Django Admin 不应在租户 App 中出现"
PUBLIC_SCHEMA_URLCONF = 'config.urls_public' # 管理后台 URL 注册位置
ROOT_URLCONF = 'config.urls_tenant' # 租户业务 URL
# 管理后台域名识别Nginx 已按 host 路由到同一 Django 进程,由中间件区分)
ADMIN_CONSOLE_HOSTS = ['admin.fonrey.com', 'admin.localhost']
# Celery 队列隔离
CELERY_TASK_ROUTES = {
'apps.admin_console.tasks.*': {'queue': 'admin_ops'},
}
```
### 3.2 URL 命名空间
```python
# config/urls_public.py
from django.urls import path, include
# ⚠️ 严禁 from django.contrib import admin —— Django Admin 全环境弃用
urlpatterns = [
path('admin/', include(('apps.admin_console.urls', 'admin_console'),
namespace='admin_console')),
]
```
`apps/admin_console/urls.py` 内顶层 `app_name = 'admin_console'`;所有反向解析使用 `admin_console:tenants:list` 等命名空间。
---
## 4. API 端点设计
> 端点全部映射 PRD §4 用户故事 + §5.3 页面规格 + §9.3 路由表。
> **响应类型约定**:标 `HTML(Page)` 表示返回完整模板(首屏/直链);`HTML(Partial)` 表示 HTMX 局部刷新(仅返回片段,前端 swap`JSON` 仅用于 Celery 任务状态轮询。
### 4.1 认证与会话
| URL Pattern | HTTP | 视图 | 触发场景 | 响应 | 权限 |
|---|---|---|---|---|---|
| `/admin/login/` | GET | `AdminLoginView` | 登录页 | HTML(Page) | 匿名 |
| `/admin/login/` | POST | `AdminLoginView` | 提交账号密码 | HTML(Partial) 跳转或表单错误422 | 匿名 |
| `/admin/login/mfa/` | GET | `MfaChallengeView` | 提示输入 TOTP | HTML(Page) | 已通过密码校验 |
| `/admin/login/mfa/` | POST | `MfaChallengeView` | 校验 TOTP | 302 跳转 dashboard / 422 | 同上 |
| `/admin/login/mfa/setup/` | GET | `MfaSetupView` | 首次绑定 TOTP强制 | HTML(Page) + 二维码 | 首登态 |
| `/admin/login/mfa/setup/` | POST | `MfaSetupView` | 确认绑定 | 302 | 同上 |
| `/admin/logout/` | POST | `AdminLogoutView` | 主动登出 | 302 | 已登录 |
### 4.2 仪表盘
| URL Pattern | HTTP | 视图 | 触发场景 | 响应 | 权限 |
|---|---|---|---|---|---|
| `/admin/` | GET | `DashboardView` | 进入仪表盘 | HTML(Page) | 已登录 |
| `/admin/dashboard/health/` | GET | `HealthStatusPartialView` | 卡片每 30s 轮询服务健康 | HTML(Partial) | 已登录 |
| `/admin/dashboard/recent-actions/` | GET | `RecentActionsPartialView` | 最近 10 条审计 | HTML(Partial) | 已登录 |
### 4.3 租户管理
| URL Pattern | HTTP | 视图 | 触发场景 | 响应 | 权限 |
|---|---|---|---|---|---|
| `/admin/tenants/` | GET | `TenantListView` | 列表页(首屏) | HTML(Page) | 已登录 |
| `/admin/tenants/rows/` | GET | `TenantRowsPartialView` | HTMX 筛选/翻页/搜索 | HTML(Partial) `<tbody>` | 已登录 |
| `/admin/tenants/new/` | GET | `TenantCreateView` | 新建表单 | HTML(Page) | 运营+ |
| `/admin/tenants/new/` | POST | `TenantCreateView` | 提交开通 | HTML(Partial) 表单/422 + `HX-Trigger: showToast` | 运营+ |
| `/admin/tenants/<uuid:pk>/` | GET | `TenantDetailView` | 进入详情 | HTML(Page) | 已登录 |
| `/admin/tenants/<uuid:pk>/edit/` | POST | `TenantUpdateView` | 编辑可变字段 | HTML(Partial) | 运营+ |
| `/admin/tenants/<uuid:pk>/suspend/` | POST | `TenantSuspendView` | 挂起 | HTML(Partial) 状态徽章 + Toast | 运营+ |
| `/admin/tenants/<uuid:pk>/resume/` | POST | `TenantResumeView` | 恢复 | HTML(Partial) | 运营+ |
| `/admin/tenants/<uuid:pk>/soft-delete/` | POST | `TenantSoftDeleteView` | 软删除 | HTML(Partial) | 运营+ |
| `/admin/tenants/<uuid:pk>/hard-delete/` | POST | `TenantHardDeleteView` | 硬删除(需 MFA 二次) | HTML(Partial) | **超级管理员** |
| `/admin/tenants/<uuid:pk>/restore-deletion/` | POST | `TenantRestoreDeletionView` | 撤销软删除(冷静期内) | HTML(Partial) | 运营+ |
| `/admin/tenants/<uuid:pk>/users/` | GET | `TenantUserListPartialView` | 详情页 Tab用户 | HTML(Partial) | 已登录 |
| `/admin/tenants/<uuid:pk>/users/<uuid:user_id>/reset-password/` | POST | `TenantUserResetPasswordView` | 重置租户用户密码 | HTML(Partial) | 运营+ |
| `/admin/tenants/<uuid:pk>/admins/` | GET | `TenantAdminListPartialView` | Tenant Admin 列表 | HTML(Partial) | 运营+ |
| `/admin/tenants/<uuid:pk>/admins/grant/` | POST | `TenantAdminGrantView` | 赋予管理员角色 | HTML(Partial) | 运营+ |
| `/admin/tenants/<uuid:pk>/admins/revoke/` | POST | `TenantAdminRevokeView` | 撤销管理员 | HTML(Partial) | 运营+ |
| `/admin/tenants/<uuid:pk>/plan/upgrade/` | POST | `TenantPlanUpgradeView` | 套餐升级 | HTML(Partial) | 运营+ |
| `/admin/tenants/<uuid:pk>/monitoring/` | GET | `TenantMonitoringPartialView` | 监控 TabGrafana iframe | HTML(Partial) | 已登录 |
| `/admin/tenants/<uuid:pk>/history/` | GET | `TenantHistoryPartialView` | 操作历史 Tab | HTML(Partial) | 已登录 |
### 4.4 备份与恢复
| URL Pattern | HTTP | 视图 | 触发场景 | 响应 | 权限 |
|---|---|---|---|---|---|
| `/admin/system/backups/` | GET | `BackupListView` | 备份任务列表 | HTML(Page) | 已登录 |
| `/admin/system/backups/rows/` | GET | `BackupRowsPartialView` | 筛选/翻页 | HTML(Partial) | 已登录 |
| `/admin/system/backups/schedule/` | GET | `BackupScheduleView` | 全局策略表单 | HTML(Page) | 超级管理员 |
| `/admin/system/backups/schedule/` | POST | `BackupScheduleView` | 提交策略 | HTML(Partial) | 超级管理员 |
| `/admin/tenants/<uuid:pk>/backups/` | GET | `TenantBackupListPartialView` | 详情页 Tab备份 | HTML(Partial) | 已登录 |
| `/admin/tenants/<uuid:pk>/backups/trigger/` | POST | `TenantBackupTriggerView` | 手动触发备份 | HTML(Partial) + `HX-Trigger: showToast` | 运营+ |
| `/admin/system/backups/<uuid:pk>/status/` | GET | `BackupStatusPartialView` | HTMX 每 5s 轮询任务进度 | HTML(Partial) 行 | 已登录 |
| `/admin/system/backups/<uuid:pk>/restore/` | POST | `BackupRestoreView` | 数据恢复(需 MFA 二次) | HTML(Partial) | **超级管理员** |
### 4.5 数据导出
| URL Pattern | HTTP | 视图 | 触发场景 | 响应 | 权限 |
|---|---|---|---|---|---|
| `/admin/system/exports/` | GET | `ExportListView` | 导出任务列表 | HTML(Page) | 已登录 |
| `/admin/system/exports/new/` | GET | `ExportCreateView` | 表单 | HTML(Page) | 运营+ |
| `/admin/system/exports/new/` | POST | `ExportCreateView` | 提交导出 | HTML(Partial) + Toast | 运营+ |
| `/admin/system/exports/<uuid:pk>/status/` | GET | `ExportStatusPartialView` | 轮询任务进度 | HTML(Partial) | 已登录 |
| `/admin/system/exports/<uuid:pk>/download/` | GET | `ExportDownloadRedirectView` | 跳转 R2 签名链接 | 302 | 触发人 + 超级管理员 |
### 4.6 系统升级
| URL Pattern | HTTP | 视图 | 触发场景 | 响应 | 权限 |
|---|---|---|---|---|---|
| `/admin/system/versions/` | GET | `SystemVersionListView` | 版本列表 | HTML(Page) | 已登录 |
| `/admin/system/versions/upgrade/` | GET | `UpgradeFormView` | 升级表单(选灰度名单) | HTML(Page) | 超级管理员 |
| `/admin/system/versions/upgrade/` | POST | `UpgradeFormView` | 触发升级(需 MFA 二次) | HTML(Partial) + Toast | 超级管理员 |
| `/admin/system/versions/<uuid:event_id>/progress/` | GET | `UpgradeProgressPartialView` | HTMX 每 3s 轮询进度 | HTML(Partial) 表格 | 已登录 |
| `/admin/system/versions/<uuid:event_id>/rollback/` | POST | `RollbackView` | 回滚(需 MFA 二次) | HTML(Partial) | 超级管理员 |
| `/admin/system/versions/<uuid:event_id>/incident/` | GET | `IncidentReportView` | 查看事件报告 | HTML(Page) | 已登录 |
### 4.7 监控与审计
| URL Pattern | HTTP | 视图 | 触发场景 | 响应 | 权限 |
|---|---|---|---|---|---|
| `/admin/monitoring/` | GET | `MonitoringView` | 监控大盘(嵌 Grafana | HTML(Page) | 已登录 |
| `/admin/monitoring/alerts/` | GET | `AlertRuleListView` | 告警规则 | HTML(Page) | 运营+ |
| `/admin/monitoring/alerts/<uuid:pk>/edit/` | POST | `AlertRuleUpdateView` | 编辑规则 | HTML(Partial) | 运营+ |
| `/admin/audit-logs/` | GET | `AuditLogListView` | 审计日志列表 | HTML(Page) | 已登录(含审计员) |
| `/admin/audit-logs/rows/` | GET | `AuditLogRowsPartialView` | HTMX 筛选/翻页 | HTML(Partial) | 同上 |
| `/admin/audit-logs/export/` | POST | `AuditLogExportView` | 异步导出 CSV | HTML(Partial) + Toast任务 ID | 同上 |
### 4.8 管理员设置
| URL Pattern | HTTP | 视图 | 触发场景 | 响应 | 权限 |
|---|---|---|---|---|---|
| `/admin/settings/admins/` | GET | `AdminAccountListView` | 管理员列表 | HTML(Page) | 超级管理员 |
| `/admin/settings/admins/new/` | POST | `AdminAccountCreateView` | 新增管理员 | HTML(Partial) | 超级管理员 |
| `/admin/settings/admins/<uuid:pk>/deactivate/` | POST | `AdminDeactivateView` | 停用 | HTML(Partial) | 超级管理员 |
| `/admin/settings/admins/<uuid:pk>/sessions/revoke/` | POST | `ForceLogoutView` | 强制登出该管理员 | HTML(Partial) | 超级管理员 |
| `/admin/settings/ip-whitelist/` | GET | `IpWhitelistListView` | 白名单 | HTML(Page) | 超级管理员 |
| `/admin/settings/ip-whitelist/new/` | POST | `IpWhitelistCreateView` | 新增 CIDR | HTML(Partial) | 超级管理员 |
| `/admin/settings/ip-whitelist/<uuid:pk>/toggle/` | POST | `IpWhitelistToggleView` | 启停 | HTML(Partial) | 超级管理员 |
| `/admin/settings/sessions/` | GET | `MyActiveSessionListView` | 当前管理员的活跃会话 | HTML(Page) | 已登录 |
### 4.9 HTMX 响应规范
| 场景 | HTTP 状态 | 响应内容 | 响应头 |
|------|----------|---------|--------|
| 操作成功 | 200 | 更新后的 HTML Partial | `HX-Trigger: {"fonrey:toast":{"type":"success","message":"..."}}` |
| 表单校验失败 | 422 | 含错误信息的表单 Partial保留用户输入 | 不发 Toast错误信息已嵌在表单内 |
| 业务规则拒绝(如未导出就硬删) | 422 | 表单 Partial + 顶部 Alert 块 | 可选 `HX-Trigger: {"fonrey:toast":{"type":"warning",...}}` |
| 权限不足 | 403 | 极简 Partial`<div class="alert alert-danger">无权限</div>` | `HX-Trigger: {"fonrey:toast":{"type":"error","message":"权限不足"}}` |
| 需要 MFA 二次确认 | 401 | 触发前端打开 MFA Modal | `HX-Trigger: {"fonrey:mfa-required":{"action":"hard_delete_tenant","target":"<id>"}}` |
| 未登录 / Session 过期 | 401 | 空 Partial | `HX-Redirect: /admin/login/` |
| 服务器异常 | 500 | 错误页 Partial | `HX-Trigger: {"fonrey:toast":{"type":"error","message":"系统异常,请重试"}}` |
**HTMX 相关请求头约定**
- 所有视图必须区分 `request.htmx`(来自 `django-htmx`):是 → 返回 partial 模板;否 → 返回完整页面或 302 跳转 list
- `hx-target` 命名以 `#tenant-list-tbody``#tenant-row-{id}``#dialog` 等结构化 ID 为约定
### 4.10 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 结果过期丢失)
---
## 5. 权限与认证实现
### 5.1 角色体系PRD §9.2
```python
# apps/admin_console/permissions.py
class AdminRole:
SUPER = 'super_admin'
OPS = 'ops_operator'
AUDITOR = 'read_only_auditor'
# 操作 → 最低角色 映射
ACTION_REQUIRED_ROLE = {
'tenant.create': AdminRole.OPS,
'tenant.suspend': AdminRole.OPS,
'tenant.soft_delete': AdminRole.OPS,
'tenant.hard_delete': AdminRole.SUPER,
'tenant.restore': AdminRole.SUPER,
'system.upgrade': AdminRole.SUPER,
'system.rollback': AdminRole.SUPER,
'admin.manage': AdminRole.SUPER,
'admin.force_logout': AdminRole.SUPER,
'ip_whitelist.manage': AdminRole.SUPER,
'audit_log.read': AdminRole.AUDITOR,
'audit_log.export': AdminRole.AUDITOR,
'export.create': AdminRole.OPS,
'backup.trigger': AdminRole.OPS,
'backup.schedule_edit': AdminRole.SUPER,
# ... 完整映射详见 9.2 矩阵
}
```
### 5.2 认证后端
**独立 Auth Backend**`PlatformAdminBackend`,校验 `platform_admins.password_hash`Django PBKDF2/Argon2登录成功后写 `admin_sessions`,并把 `request.session['admin_id']``session_token` 关联。
**不复用** `django.contrib.auth.User`
- 租户业务用 `django.contrib.auth` + `apps.account.User`(在租户 schema
- 平台管理后台完全独立,避免角色/Session 跨域污染
### 5.3 中间件链(顺序敏感)
```python
MIDDLEWARE = [
'django_tenants.middleware.main.TenantMainMiddleware',
'apps.admin_console.middleware.IpWhitelistMiddleware', # 仅对 admin.* 域名生效
'apps.admin_console.middleware.AdminSessionMiddleware', # 校验 session_token滚动续 30min
'django.middleware.security.SecurityMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django_htmx.middleware.HtmxMiddleware',
# ...
]
```
- `IpWhitelistMiddleware`:只在 `request.host in ADMIN_CONSOLE_HOSTS` 时启用,查 `ip_whitelist`Redis 缓存),未命中返回 403 静态页(不暴露后台存在)
- `AdminSessionMiddleware`:每次请求把 `admin_sessions.expires_at = NOW() + 30min`,过期则清 cookie 并 302 到 `/admin/login/`
### 5.4 视图层 Mixin
```python
class AdminLoginRequiredMixin:
"""检查 request.platform_admin 是否存在;否则 302 /admin/login/"""
class RoleRequiredMixin(AdminLoginRequiredMixin):
required_role = None # AdminRole.SUPER / OPS / AUDITOR
# AUDITOR < OPS < SUPERSUPER 可访问全部
class ReadOnlyAuditorAllowedMixin(AdminLoginRequiredMixin):
"""允许审计员访问只读端点(仅 GET 安全方法)"""
class MfaConfirmedRequiredMixin(AdminLoginRequiredMixin):
"""要求当前 session 在最近 5 分钟内通过 MFA step-up 验证。
未通过 → 返回 401 + HX-Trigger: fonrey:mfa-required前端弹 Modal"""
class AuditedActionMixin:
"""form_valid 成功后通过 audit_service.write_audit() 记录日志,
自动从 self.request 提取 operator/ip/payload_summary"""
```
**典型组合**
```python
class TenantHardDeleteView(MfaConfirmedRequiredMixin, RoleRequiredMixin, AuditedActionMixin, FormView):
required_role = AdminRole.SUPER
audit_action = 'HARD_DELETE_TENANT'
```
### 5.5 数据范围控制
平台管理后台**不存在租户层数据范围限制**(与租户业务"经纪人只看自己名下"不同):
- 超级 / 运营:可见所有租户
- 审计员:可见所有数据但全局只读(通过 Mixin 拦截非 GET 请求 → 403
- Manager 层无需自动过滤;`PlatformAuditLog` 的 Manager 强制 `objects.append_only_create()`,禁用 `update`/`delete`
### 5.6 高危操作二次 MFA 流程
```
用户点击「硬删除」
→ HTMX POST /admin/tenants/<id>/hard-delete/
→ MfaConfirmedRequiredMixin 检测 session.mfa_confirmed_at 已过 5 min
→ 返回 401 + HX-Trigger: {"fonrey:mfa-required":{"action":"hard_delete","return_to":"<原 URL>"}}
→ 前端 Alpine.js 监听该事件 → 打开 MFA Modal
→ 用户输入 TOTP → POST /admin/login/mfa/step-up/ → 后端写 session.mfa_confirmed_at = now()
→ 前端拿到成功响应 → 重新发起原始 POST带 X-Mfa-Step-Up: 1→ 通过执行
```
---
## 6. 缓存策略
> **Key 规范**:本模块所有 Key 以 `pub:` 前缀public schema与租户业务的 `{schema}:` 前缀严格隔离。
| 缓存对象 | 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:tenant:count:{filter_hash}` | 30s | 短 TTL避免 COUNT(*) 全扫 |
| 租户基本信息 | `pub:tenant:{tenant_id}` | 10 min | 编辑、状态变更后主动清除 |
| 系统当前版本 | `pub:sys:current_version` | 1 h | 升级 / 回滚成功后清除 |
| 全局备份策略 | `pub:backup:schedule:global` | 1 h | 策略保存后清除 |
| 备份任务进度(前端轮询热点) | `pub:backup:status:{record_id}` | 5s | 任务结束后立即清 |
| 导出任务进度 | `pub:export:status:{task_id}` | 5s | 同上 |
| 升级事件聚合进度 | `pub:upgrade:progress:{event_id}` | 3s | 任务结束后清 |
| 仪表盘统计(总租户/活跃/本月新增) | `pub:dashboard:stats` | 1 min | 自然过期 |
| 服务健康状态 | `pub:health:{service}` | 30s | 自然过期 |
**失效策略**
- 通过 Django Signals 在 model `post_save` / `post_delete` 时主动 `cache.delete_many([...])`
- 任务进度类缓存(备份/导出/升级)只用作"减少 DB 压力",前端**仍以 DB 状态为准**:缓存 miss 直接查 DB
- IP 白名单缓存命中失败时不能放行,必须 fail-closed拒绝访问
---
## 7. 文件上传规范Cloudflare R2
### 7.1 本模块涉及的文件流向
| 场景 | 上传方 | 存储桶 | 路径模板 |
|---|---|---|---|
| 升级包 artifact | 超级管理员 → 后端 → R2 | `releases` | `releases/system/{version}/{filename}` |
| 备份产出pg_dump + R2 文件清单) | Celery worker → R2 | `backups` | `backups/{tenant_schema}/{record_id}.tar.gz` |
| 导出产出CSV/JSON/SQL Dump 压缩包) | Celery worker → R2 | `exports` | `exports/{tenant_schema}/{task_id}.zip` |
| 审计日志导出 CSV | Celery worker → R2 | `exports` | `exports/audit/{task_id}.csv` |
> 系统管理模块**不接收用户图片上传**(管理员头像可选,用 Gravatar/字母头像即可)。所有 R2 写入由后端 Celery 完成,**不使用前端直传 Presigned URL**。
> **选型理由**
> - 升级包/备份/导出均为大文件且涉及合规与完整性校验SHA256必须由可信后端校验后写入前端直传无法保证文件完整性与权限链
> - 频次极低(每日 < 100 次),中转带宽成本可忽略
> - 反之,租户业务模块(房源图片)才是高频次小文件,使用前端直传 Presigned URL属于其他模块的范畴
### 7.2 下载链接Presigned GET URL
- 导出包/备份包对外下载使用 R2 Presigned GET URLTTL = **24 小时**(与 `export_tasks.expires_at` 一致)
- 视图 `ExportDownloadRedirectView` 不返回链接给前端,而是 302 重定向到当时生成的签名 URL避免链接被前端日志采集泄漏
- 链接生成使用 boto3-S3 兼容 API密钥仅注入到 Celery worker 容器(管理后台 Web 容器无写权限)
### 7.3 文件命名
`{bucket}/{tenant_schema}/{model_id}/{uuid}.{ext}` —— 与 TECH_STACK.md 总纲一致;其中 `tenant_schema` 在 public schema 数据中对应 `tenants.schema_name`
### 7.4 类型与大小限制
| 文件类型 | MIME 二次校验 | Django 视图大小限制 | Nginx `client_max_body_size` |
|---|---|---|---|
| 升级包 `.zip` / `.tar.gz` | `application/zip` / `application/gzip` | 500 MB | 600 MB |
| 备份产出 | 后端生成,无上传 | — | — |
| 导出产出 | 后端生成,无上传 | — | — |
- 升级包视图使用 `python-magic` 读取头部字节做 MIME 校验,**不信任** `Content-Type` header 或文件扩展名
- 升级包 SHA256 在上传完成后由后端计算并落库(`system_versions.artifact_url` 同时写入校验码),客户端拉取时校验
---
## 8. Celery 异步任务规范
队列:所有任务统一进入 `admin_ops` 队列,避免与租户业务队列竞争。
| 任务名称 | 触发场景 | 预估耗时 | 重试策略 | 失败处理 |
|---|---|---|---|---|
| `provision_tenant` | 创建租户后异步执行 Schema 创建 + 迁移 + 默认数据注入 | 3060s | 不重试(失败必须人工介入) | 标记 `tenants.status='failed'`,事务回滚已创建资源,发邮件通知管理员;写审计 `CREATE_TENANT result=FAILED` |
| `auto_resume_suspended` | Celery Beat 每 10 min 扫描 `suspended_until <= NOW()` | < 5s | 最多 3 次60s 间隔 | Sentry 告警,保留 `suspended` 状态由人工兜底 |
| `purge_pending_delete` | Beat 每天 03:00 扫描冷静期到期租户 | 取决于租户大小110 min | 不重试 | 标记 `failed_to_purge`,告警 |
| `hard_delete_tenant` | 视图触发 | 110 min | 不重试 | 部分删除标记告警DROP SCHEMA 必须用事务 + SAVEPOINT |
| `run_backup` | 调度器 + 升级前 + 手动 | 1 min 2h取决数据量 | 最多 2 次指数退避5/30 min | 标记 `backup_records.status='failed'`,发邮件 |
| `cleanup_old_backups` | Beat 每天 04:00 | < 5 min | 最多 3 次 | 告警 |
| `run_restore` | 视图触发(高危) | 530 min | **不重试** | 失败 → 自动回滚到恢复前快照;事件报告写 `upgrade_events.incident_report` |
| `run_export` | 视图触发 | 115 min | 最多 2 次60s 间隔 | 标记 `failed`,邮件通知触发人 |
| `expire_export_links` | Beat 每小时 | < 1 min | 最多 3 次 | 告警 |
| `health_check` | 升级前 | < 30s | 最多 1 次 | 失败阻断升级,返回前端 422 |
| `run_upgrade` | 升级表单触发 | 5 min 2h | **不重试** | `upgrade_events.status='failed'`,前端按钮自动转换为「立即回滚」 |
| `run_rollback` | 升级失败 / 手动 | 530 min | **不重试** | 写 `incident_report`,自动 + 人工双重告警 |
| `send_welcome_email` | 租户开通成功后 | < 5s | 最多 5 次,指数退避 | 失败仅告警,不阻塞主流程 |
| `send_export_ready` | 导出完成后 | < 5s | 最多 5 次 | 同上 |
| `cleanup_admin_sessions` | Beat 每 30 min | < 5s | 最多 3 次 | 告警 |
| `aggregate_dashboard_stats` | Beat 每 1 min | < 10s | 最多 2 次 | 失败时仪表盘读旧缓存 |
**通用约定**
- 所有任务使用 `bind=True`,前置统一 `audit_service.write_audit()`(成功/失败均落审计)
- 涉及租户 schema 操作的任务必须 `with schema_context(tenant.schema_name):`
- 长任务(> 5 min必须周期性 `task.update_state(state='PROGRESS', meta={...})`,前端轮询读取
- 重试策略统一通过装饰器 `@retry(max_retries=N, backoff='exponential', initial_delay=...)` 实现
- 不重试任务必须显式 `acks_late=True, autoretry_for=()`
### 8.5 升级类型分级A / B / C
「升级」必须先按内容分类,不同类型的分批能力天然不同。混淆三者会导致设计错误。
| 类型 | 内容 | 是否可分批到租户级 | 编排路径 |
|---|---|---|---|
| **A. 应用代码升级** | Python 代码、模板、JS/CSS 包、Celery Worker 镜像 | ❌ 单进程多租户架构下物理上不可分批;只能整体蓝绿切换 | 运维侧K8s/Compose 切流),不在本模块编排;本模块仅记录 `system_versions` 元数据 |
| **B. 租户 Schema 迁移** | `apps.property` / `apps.client` 等租户 App 的 `migrations/*.py` | ✅ 按 `schema_name` 分批迁移 | 本模块 `run_upgrade` 编排(详见 §8.6 |
| **C. Feature Flag 灰度** | 新功能的运行时启停(双路径分支) | ✅ 按租户 / 用户 / 百分比 | 本模块 `feature_flags` 服务(详见 §8.7 |
**强制纪律**
- A 类的「灰度名单」字段(`upgrade_events.gray_tenant_ids`)在 PRD §5.1.6 表单中**必须置灰并提示**:「应用代码升级影响全部租户,本字段仅对 schema 迁移类型升级生效」
- 系统升级表单上必须先选择类型 `upgrade_type ∈ {A_app, B_schema, C_feature}`UI 据此切换可填字段
- 真实生产中绝大多数版本是 A+B 混合A 部分先全量切换蓝绿B 部分按本模块编排分批迁移C 部分(功能开关)独立于版本号
### 8.6 B 类Schema 迁移)分批编排详解
#### 8.6.1 编排状态机
```
[draft] ──提交──→ [pre_check] ──健康通过──→ [pre_backup] ──备份完成──→ [batch_running]
批次成功 ↓ │ 批次失败
[batch_done] ↓
↓ [halted]──人工──→ [rollback] / [resume]
下一批 / 完成 ↓
[succeeded]
```
`upgrade_events.status` 字段取值与上图一致;前端 §4.6 进度页根据状态控制按钮可见性halted 状态显示「继续 / 回滚」二选一)。
#### 8.6.2 Celery 任务结构
新增/细化以下任务(替换 §8 表中粗粒度 `run_upgrade`
| 任务名称 | 职责 | 队列 | 重试 |
|---|---|---|---|
| `orchestrate_upgrade(event_id)` | 顶层编排器:跑 pre-check → pre-backup → 按批次循环派发 → 健康门控 → 终态 | `admin_ops` | 不重试(失败 → halted |
| `migrate_single_tenant(event_id, tenant_id)` | 单租户:创建轻量快照 → `schema_context``call_command('migrate')` → smoke test → 失败回滚该租户 | `migration`(独立队列限并发) | 不重试 |
| `tenant_smoke_test(tenant_id)` | 单租户健康检查:跑预定义关键 ORM 查询、HTTP 探活 | `admin_ops` | 不重试 |
| `post_batch_health_gate(event_id, batch_no)` | 批后门控:从 Prometheus + Sentry + Flower 拉指标,判断是否进入下一批 | `admin_ops` | 不重试 |
| `rollback_single_tenant(tenant_id, snapshot_id)` | 单租户回滚到本次升级前快照 | `migration` | 不重试 |
| `rollback_upgrade(event_id)` | 整体回滚:对所有 `progress.status='success'` 的租户依次调用 `rollback_single_tenant` | `admin_ops` | 不重试 |
**专用队列 `migration`**:与 `admin_ops` 分开,限制 `--concurrency=2 --prefetch-multiplier=1`,避免并发 `migrate` 打爆 PostgreSQL 连接池或触发 DDL 锁竞争。
#### 8.6.3 批次与并发参数(`upgrade_events` 表字段建议)
数据模型补充建议(提交给 DATA_MODEL_PUBLIC.md 维护者):
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `upgrade_type` | varchar(16) | `B_schema` | A_app / B_schema / C_feature |
| `batch_size` | int | 5 | 每批包含的租户数 |
| `batch_concurrency` | int | 2 | 批内并发执行的租户数(≤ batch_size |
| `batch_interval_seconds` | int | 300 | 批间观察窗口(秒),让监控指标稳定 |
| `failure_policy` | varchar(16) | `halt_batch` | `halt_batch`(任一租户失败即中断本批)/ `continue`(其他租户继续,仅标记失败) |
| `health_gate_config` | jsonb | `{}` | 门控阈值覆盖(默认值见 §8.6.5 |
#### 8.6.4 单租户快照策略
不能用全局 `pg_dump`(恢复粒度太粗)。分级方案:
| 时机 | 方案 | 用途 |
|---|---|---|
| 升级开始前(一次) | 全租户 `pg_dump`(即 `pre_backup_record_id` | 兜底;用于「整体灾难回滚」 |
| 单租户迁移开始前 | `pg_dump -n {schema}` 快照到独立文件,约 110s | 用于该租户失败时秒级回滚drop schema + restore |
| 迁移完成后 7 天 | 自动清理单租户快照 | 缩短保留期,节省存储 |
实现:`migrate_single_tenant` 第一步调用 `backup_service.snapshot_tenant(tenant)`,返回 `snapshot_id` 写入 `upgrade_events.progress[tenant_id].snapshot_id`
#### 8.6.5 批后健康门控Health Gate
每批结束后,`post_batch_health_gate` 任务检查以下指标,**任一不通过即 halt**
| 指标 | 来源 | 默认阈值 | 含义 |
|---|---|---|---|
| `error_rate_5xx_5m` | Prometheus | < 0.5% | 近 5 分钟 5xx 比例 |
| `p95_latency_5m` | Prometheus | < 2000 ms | 近 5 分钟 P95 延迟 |
| `celery_queue_pending` | Flower | < 1000 | 任务队列积压 |
| `sentry_new_issues_5m` | Sentry API | < 5 | 近 5 分钟新错误数 |
| `migrated_tenant_smoke_pass_rate` | DBprogress 字段) | = 100% | 本批所有租户 smoke test 通过 |
阈值可在 `upgrade_events.health_gate_config` 中按本次升级覆盖。门控不通过时:
- `event.status = 'halted'``halted_reason` 写入失败指标快照
- 推送企业微信 + 邮件给所有超级管理员
- 前端进度页弹出 Modal超管二选一「继续下一批」/「立即回滚已升级租户」
#### 8.6.6 DDL 兼容性纪律(**最重要的工程约束**
分批升级期间,新代码会同时面对**新旧两种 schema**(已迁移租户用新 schema未迁移租户仍是旧 schema。这要求**所有 migration 必须向后兼容**。
| 类型 | 是否安全 | 备注 |
|---|---|---|
| `ADD COLUMN` 带 NULL 或默认值 | ✅ | 默认值不要用大表 `UPDATE`,改用应用层填充 |
| `CREATE INDEX CONCURRENTLY` | ✅ | 必须 `CONCURRENTLY`,否则锁表 |
| `ADD CONSTRAINT ... NOT VALID` + 后续 `VALIDATE` | ✅ | 拆两次发布 |
| 新增表 / 新增视图 | ✅ | 旧代码不感知即可 |
| `DROP COLUMN` | ❌ | 旧代码可能仍在写;必须拆两次发布 |
| `RENAME COLUMN` / `RENAME TABLE` | ❌ | 同上 |
| `ALTER COLUMN TYPE` 不兼容类型 | ❌ | 必须用「新增列 + 双写 + 切读 + 删旧列」分四步 |
| 删除唯一约束 / 主键 | ❌ | 必须拆 |
**两阶段发布范式**
1. **v_n扩展**:新增字段 / 表 / 索引;代码层「双写双读」(同时维护新旧字段,读写都兼容)
2. **v_{n+1}(清理)**:在 v_n 全量上线 ≥ 1 周且监控正常后,再发布迁移删除旧字段;此阶段允许出现破坏性 DDL但因为旧代码已经下线安全
**强制门禁**
- 所有租户 App 的 `migrations/*.py` 必须由 `engineering-backend-architect` 在 PR review 时检查兼容性
- CI 增加迁移静态扫描(`django-migration-linter`),命中破坏性操作直接阻断 merge
- migration 文件提交时强制附带 `# UPGRADE_TYPE: expand|cleanup` 注释CI 据此区分门禁规则
### 8.7 C 类Feature Flag灰度体系
#### 8.7.1 适用场景
- 新业务功能上线(如「房源 AI 描述生成」),先开 5 个租户 → 50 个 → 全量
- 重构高风险模块(如搜索算法),需要 A/B 对比
- 商业策略(如「企业版独享某功能」)
- B 类升级双写阶段切读:先用新字段服务部分租户,验证后全量
#### 8.7.2 数据模型(建议提交 DATA_MODEL_PUBLIC.md
```sql
-- public.tenants 表新增列
ALTER TABLE public.tenants
ADD COLUMN feature_flags JSONB NOT NULL DEFAULT '{}'::jsonb;
-- 全局 Flag 注册表(控制平面,非按租户)
CREATE TABLE public.feature_flag_definitions (
key varchar(64) PRIMARY KEY, -- 如 'ai_description_v2'
description text NOT NULL,
default_value boolean NOT NULL DEFAULT false, -- 未在 tenant.feature_flags 显式覆盖时的默认
rollout_strategy varchar(16) NOT NULL DEFAULT 'tenant', -- tenant | percentage | user
rollout_config jsonb NOT NULL DEFAULT '{}', -- e.g. {"percentage": 30}
owner_admin_id uuid REFERENCES public.platform_admins(id),
created_at timestamptz NOT NULL DEFAULT NOW(),
archived_at timestamptz NULL -- 归档时间;归档后视为永久关闭
);
-- 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,
tenant_id uuid NULL REFERENCES public.tenants(id), -- NULL 表示全局变更
old_value jsonb,
new_value jsonb NOT NULL,
operator_id uuid NOT NULL REFERENCES public.platform_admins(id),
reason text NOT NULL, -- 强制填写变更原因
created_at timestamptz NOT NULL DEFAULT NOW()
);
```
#### 8.7.3 服务层 API
```python
# apps/admin_console/services/feature_flags.py
def is_enabled(tenant, flag_key: str, *, user=None) -> bool:
"""运行时查询 Flag。
优先级tenant.feature_flags 显式覆盖 > 全局 rollout_strategy > default_value
"""
# 1. 租户级显式覆盖
if flag_key in tenant.feature_flags:
return bool(tenant.feature_flags[flag_key])
# 2. 全局策略
definition = _get_definition_cached(flag_key)
if definition is None or definition.archived_at:
return False
if definition.rollout_strategy == 'percentage':
# 稳定哈希:同一 tenant 永远落到同一桶,避免抖动
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
```
**关键约束**
- `_get_definition_cached` 走 Redis 缓存(`pub:ff:def:{key}`TTL 1 min避免每个请求查 DB
- 业务代码必须用 `is_enabled(...)` 接口,**严禁**直接读 `tenant.feature_flags[...]`(绕过策略层)
- `stable_hash` 使用 `xxhash`,租户 ID 在内的 key 长期稳定,避免百分比策略下租户被频繁挤进/挤出
#### 8.7.4 缓存策略(补充 §6 缓存表)
| 缓存对象 | Key | TTL | 失效条件 |
|---|---|---|---|
| Flag 全局定义 | `pub:ff:def:{flag_key}` | 1 min | 定义变更后立即清 |
| 租户 Flag 覆盖 | `pub:ff:tenant:{tenant_id}` | 5 min | 租户 Flag 变更后清 |
#### 8.7.5 管理界面(补充 §4 路由表)
| URL Pattern | HTTP | 视图 | 权限 |
|---|---|---|---|
| `/admin/feature-flags/` | GET | `FeatureFlagListView`(列出所有 Flag 定义) | 已登录 |
| `/admin/feature-flags/new/` | POST | `FeatureFlagCreateView`(新增 Flag | 超级管理员 |
| `/admin/feature-flags/<key>/rollout/` | POST | `FeatureFlagRolloutView`(调整百分比 / 策略) | 超级管理员 |
| `/admin/feature-flags/<key>/archive/` | POST | `FeatureFlagArchiveView`(归档) | 超级管理员 |
| `/admin/tenants/<uuid:pk>/feature-flags/` | GET | `TenantFlagsPartialView`(详情页 Tab租户级覆盖 | 已登录 |
| `/admin/tenants/<uuid:pk>/feature-flags/toggle/` | POST | `TenantFlagToggleView`(覆盖某 Flag | 运营+ |
所有写操作必填 `reason`(变更原因),写入 `feature_flag_change_log``platform_audit_logs`
#### 8.7.6 与 B 类的最佳实践组合
发布破坏性变更(如重命名字段、改变行为)的标准 4 步流程:
1. **B 类(扩展)**:新增字段,代码双写双读(旧字段为权威)
2. **C 类(切读灰度)**:开 Flag `read_from_new_field`,按租户 5% → 50% → 100% 灰度
3. **C 类(切写灰度)**:开 Flag `write_to_new_field_only`,按租户灰度
4. **B 类(清理)**:在 v_{n+1} 删除旧字段;归档相关 Flag
这样 B 类只承担「结构准备」低风险、可分批C 类承担「行为切换」(可即时关停),是 SaaS 多租户系统最稳健的演进模式。
---
## 9. 监控集成
| 维度 | 实现 |
|---|---|
| Grafana 嵌入 | `MonitoringView` 渲染含 Grafana iframe 的页面URL 含短期签名 token避免暴露 Grafana 公网入口 |
| 告警接收 | Grafana → Webhook → `apps.admin_console.views.alerts.GrafanaWebhookView`HMAC 签名校验) |
| Sentry | 独立 DSN与租户业务分离方便定位平台层 Bug |
| Celery 队列健康 | Flower 部署在 `admin.fonrey.com/flower/`,仅超级管理员可访问 |
| 审计日志告警 | 任意 `result='FAILED'` 的高危操作HARD_DELETE / RESTORE / UPGRADE / ROLLBACK实时推送企业微信 / 邮件 |
监控数据采集来源PRD §5.1.5CPU/内存来自 Prometheus node_exporter存储/API/活跃数来自应用埋点写 PostgreSQL `tenant_metrics_daily` 物化视图(属租户业务模块范畴,本模块仅消费)。
---
## 10. 测试规范
### 10.1 覆盖矩阵
| 类别 | 工具 | 必测内容 |
|------|------|---------|
| Model | pytest-django + factory_boy | UUID 默认值、状态机 CHECK 约束、唯一索引(`schema_name` / `email` / `published current`、append-only Manager、软删除标记 |
| Service | pytest-django + Mock R2 / Mock Celery | `tenant_service.provision()` 失败回滚、`audit_service.write_audit()` 字段完整、状态机非法迁移抛错 |
| ViewHTTP | `django-tenants` 公共 schema TestCase + `Client(HTTP_HOST='admin.fonrey.com')` | 三角色 × 关键端点的 200/403/401未登录三场景MFA step-up 拦截CSRF |
| ViewHTMX | 同上 + `HTTP_HX_REQUEST='true'` | 验证返回为 partial不含 `<html>` 根标签),且响应头包含约定的 `HX-Trigger` |
| Middleware | pytest-django | IP 白名单命中/未命中Session 滚动续期;过期 302 |
| Celery 任务 | `CELERY_TASK_ALWAYS_EAGER=True` + R2/邮件 Mock | 主流程 + 失败回滚 + 重试次数 + 不重试任务的 `acks_late` 行为 |
| 安全回归 | 集成测试 | 跨域名访问(用 `*.fonrey.com` host 访问 `/admin/...` → 404 或重定向);租户用户身份不能登入管理后台;管理后台 Cookie 不能在租户域名下生效;`'django.contrib.admin' not in settings.INSTALLED_APPS` 断言;`apps/``config/` 全文 grep 不应命中 `from django.contrib import admin`CI 守门) |
### 10.2 关键测试约束
- 禁止使用 Django 原生 `Client()`,统一使用 `django_tenants.test.client.TenantClient` 配合 `public` schema fixture
- 所有受角色保护的 View 必须覆盖超级200/204、运营200 或 403、审计员GET 200 / 非 GET 403、未登录302 → /admin/login/
- 高危操作测试必须包含 MFA step-up 已通过 / 未通过两个分支
- `platform_audit_logs` 测试:执行任意写操作后断言审计行存在且字段一致;尝试 UPDATE / DELETE 该表必须抛 `IntegrityError`(通过 Manager 限制 + 数据库 trigger 双重保险)
- Celery 异步测试覆盖率:`tasks/` 模块 ≥ 85%`services/` 模块 ≥ 90%`views/` 模块 ≥ 75%
### 10.3 测试数据约定
- `factories.py` 提供 `PlatformAdminFactory(role=...)` / `TenantFactory(status=...)` / `AuditLogFactory()` / `BackupRecordFactory()`
- 租户工厂创建后自动调用 `provision_tenant.delay()`(在 EAGER 模式下同步执行 Schema 创建),便于跨 schema 测试
---
## 11. 安全要点(强制执行)
| 项 | 要求 |
|---|---|
| MFA 强制 | `platform_admins.mfa_enabled=False` 时除 `MfaSetupView` 外所有视图 302 强制跳转设置 |
| TOTP 密钥 | `admin_mfa_devices.totp_secret` AES-256-GCM 加密存储,密钥来自环境变量 `ADMIN_MFA_KEY`,不与租户加密密钥共用 |
| 密码哈希 | Argon2idDjango `ARGON2_PASSWORD_HASHER`),不允许降级 PBKDF2 |
| 暴力破解防护 | 登录失败 5 次锁定账号 15 minRedis 计数器Key `pub:login:fail:{username}`);同 IP 失败 20 次锁定 IP 1h |
| Session 安全 | Cookie `Secure``HttpOnly``SameSite=Strict`Cookie domain 限定 `admin.fonrey.com`(不允许跨子域) |
| CSRF | 所有写操作启用 CSRFHTMX 通过 `hx-headers='{"X-CSRFToken": "..."}'` 在 base 模板注入 |
| CSP | `default-src 'self'`Grafana iframe 域加入 `frame-src` 白名单;禁止 `unsafe-inline` 脚本HTMX/Alpine 的内联事件已用 attribute 模式,符合) |
| 高危操作 | 硬删除/恢复/升级/回滚必须 MFA step-up5 min 时效) |
| 审计日志不可变 | DB 层 trigger`BEFORE UPDATE OR DELETE ON platform_audit_logs ... RAISE EXCEPTION`ORM 层 Manager 重写 `update()` `delete()` 抛错 |
| 跨域名严禁串台 | 租户 host 上访问 `/admin/...` 必须 404管理 host 上访问租户 URL 必须 404`IpWhitelistMiddleware` + URLConf 双重保证 |
| Django Admin 全环境弃用 | `INSTALLED_APPS` 不包含 `django.contrib.admin``urls_public.py` / `urls_tenant.py` 不导入 `admin.site`;启动期 `assert` 兜底CI 步骤 `grep` 命中 `from django.contrib import admin` 即构建失败。原因详见 §1.5 |
| 紧急数据修复流程 | 不开 Admin 后门;统一走 `manage.py shell_plus` 在堡垒机执行,操作前后由超管在本模块 `/admin/audit-logs/` 手工补录审计条目(`source='manual_shell'` |
---
## 12. 部署规范
| 项 | 配置 |
|---|---|
| 域名 | `admin.fonrey.com` 解析到与租户应用相同的 Gunicorn/Uvicorn 集群(共用进程,省运维成本) |
| Nginx | `server_name admin.fonrey.com` 单独 server block① IP 白名单 `allow / deny`(与应用层双重保险)② `client_max_body_size 600M` 仅限 `/admin/system/versions/upgrade/`;其他端点 10M |
| Celery worker | 独立部署 worker 监听 `admin_ops` 队列,`--concurrency=2 --max-tasks-per-child=50`(任务多为 IO 密集长任务) |
| Celery beat | 单实例运行所有调度任务auto_resume / purge / cleanup_old_backups / expire_export_links / aggregate_dashboard_stats注册于此 |
| 密钥管理 | `ADMIN_MFA_KEY` / `R2_ADMIN_KEY` / `GRAFANA_SIGN_KEY` 通过 Docker Secret 注入;不出现在 `.env` 文件 |
| 日志 | Web 访问日志 / 审计日志 / Sentry 三路独立审计日志同时落库DB+ 落对象存储R2 月归档) |
| 备份的备份 | 备份元数据(`backup_records`)随 `public` schema 每日 02:00 全量 dump 到独立 R2 桶 `meta-backups`,灾难场景下用于重建 |
---
## 13. 文档变更记录
| 版本 | 日期 | 变更 |
|------|------|------|
| v1.0 | 2026-04-26 | 初稿。基于 PRD v1.0 + DATA_MODEL_PUBLIC v1.1 编制 |
| v1.1 | 2026-04-26 | 明确全环境弃用 `django.contrib.admin`;新增 §1.5 章节、settings 启动断言、安全要点 2 条、CI 守门测试 |
| v1.2 | 2026-04-26 | 新增 §8.5§8.7:升级类型 A/B/C 分级、B 类 Schema 迁移分批编排(状态机/任务表/快照/健康门控/DDL 兼容性纪律、C 类 Feature Flag 灰度体系(数据模型 + 服务 API + 管理界面 + 与 B 类组合最佳实践) |