> **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 二次确认 Modal(UI_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 # PlatformAuditLog(append-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_export(CSV/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) `` | 已登录 | | `/admin/tenants/new/` | GET | `TenantCreateView` | 新建表单 | HTML(Page) | 运营+ | | `/admin/tenants/new/` | POST | `TenantCreateView` | 提交开通 | HTML(Partial) 表单/422 + `HX-Trigger: showToast` | 运营+ | | `/admin/tenants//` | GET | `TenantDetailView` | 进入详情 | HTML(Page) | 已登录 | | `/admin/tenants//edit/` | POST | `TenantUpdateView` | 编辑可变字段 | HTML(Partial) | 运营+ | | `/admin/tenants//suspend/` | POST | `TenantSuspendView` | 挂起 | HTML(Partial) 状态徽章 + Toast | 运营+ | | `/admin/tenants//resume/` | POST | `TenantResumeView` | 恢复 | HTML(Partial) | 运营+ | | `/admin/tenants//soft-delete/` | POST | `TenantSoftDeleteView` | 软删除 | HTML(Partial) | 运营+ | | `/admin/tenants//hard-delete/` | POST | `TenantHardDeleteView` | 硬删除(需 MFA 二次) | HTML(Partial) | **超级管理员** | | `/admin/tenants//restore-deletion/` | POST | `TenantRestoreDeletionView` | 撤销软删除(冷静期内) | HTML(Partial) | 运营+ | | `/admin/tenants//users/` | GET | `TenantUserListPartialView` | 详情页 Tab:用户 | HTML(Partial) | 已登录 | | `/admin/tenants//users//reset-password/` | POST | `TenantUserResetPasswordView` | 重置租户用户密码 | HTML(Partial) | 运营+ | | `/admin/tenants//admins/` | GET | `TenantAdminListPartialView` | Tenant Admin 列表 | HTML(Partial) | 运营+ | | `/admin/tenants//admins/grant/` | POST | `TenantAdminGrantView` | 赋予管理员角色 | HTML(Partial) | 运营+ | | `/admin/tenants//admins/revoke/` | POST | `TenantAdminRevokeView` | 撤销管理员 | HTML(Partial) | 运营+ | | `/admin/tenants//plan/upgrade/` | POST | `TenantPlanUpgradeView` | 套餐升级 | HTML(Partial) | 运营+ | | `/admin/tenants//monitoring/` | GET | `TenantMonitoringPartialView` | 监控 Tab(Grafana iframe) | HTML(Partial) | 已登录 | | `/admin/tenants//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//backups/` | GET | `TenantBackupListPartialView` | 详情页 Tab:备份 | HTML(Partial) | 已登录 | | `/admin/tenants//backups/trigger/` | POST | `TenantBackupTriggerView` | 手动触发备份 | HTML(Partial) + `HX-Trigger: showToast` | 运营+ | | `/admin/system/backups//status/` | GET | `BackupStatusPartialView` | HTMX 每 5s 轮询任务进度 | HTML(Partial) 行 | 已登录 | | `/admin/system/backups//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//status/` | GET | `ExportStatusPartialView` | 轮询任务进度 | HTML(Partial) | 已登录 | | `/admin/system/exports//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//progress/` | GET | `UpgradeProgressPartialView` | HTMX 每 3s 轮询进度 | HTML(Partial) 表格 | 已登录 | | `/admin/system/versions//rollback/` | POST | `RollbackView` | 回滚(需 MFA 二次) | HTML(Partial) | 超级管理员 | | `/admin/system/versions//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//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//deactivate/` | POST | `AdminDeactivateView` | 停用 | HTML(Partial) | 超级管理员 | | `/admin/settings/admins//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//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:`
无权限
` | `HX-Trigger: {"fonrey:toast":{"type":"error","message":"权限不足"}}` | | 需要 MFA 二次确认 | 401 | 触发前端打开 MFA Modal | `HX-Trigger: {"fonrey:mfa-required":{"action":"hard_delete_tenant","target":""}}` | | 未登录 / 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":"导出任务已提交"}} {{ task.modules }}排队中…— ``` **轮询规约**: - 轮询间隔:备份/导出 = 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 < SUPER;SUPER 可访问全部 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//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 URL,TTL = **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 创建 + 迁移 + 默认数据注入 | 30–60s | 不重试(失败必须人工介入) | 标记 `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 扫描冷静期到期租户 | 取决于租户大小,1–10 min | 不重试 | 标记 `failed_to_purge`,告警 | | `hard_delete_tenant` | 视图触发 | 1–10 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` | 视图触发(高危) | 5–30 min | **不重试** | 失败 → 自动回滚到恢复前快照;事件报告写 `upgrade_events.incident_report` | | `run_export` | 视图触发 | 1–15 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` | 升级失败 / 手动 | 5–30 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}` 快照到独立文件,约 1–10s | 用于该租户失败时秒级回滚(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` | DB(progress 字段) | = 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//rollout/` | POST | `FeatureFlagRolloutView`(调整百分比 / 策略) | 超级管理员 | | `/admin/feature-flags//archive/` | POST | `FeatureFlagArchiveView`(归档) | 超级管理员 | | `/admin/tenants//feature-flags/` | GET | `TenantFlagsPartialView`(详情页 Tab:租户级覆盖) | 已登录 | | `/admin/tenants//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.5):CPU/内存来自 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()` 字段完整、状态机非法迁移抛错 | | View(HTTP) | `django-tenants` 公共 schema TestCase + `Client(HTTP_HOST='admin.fonrey.com')` | 三角色 × 关键端点的 200/403/401(未登录)三场景;MFA step-up 拦截;CSRF | | View(HTMX) | 同上 + `HTTP_HX_REQUEST='true'` | 验证返回为 partial(不含 `` 根标签),且响应头包含约定的 `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`,不与租户加密密钥共用 | | 密码哈希 | Argon2id(Django `ARGON2_PASSWORD_HASHER`),不允许降级 PBKDF2 | | 暴力破解防护 | 登录失败 5 次锁定账号 15 min(Redis 计数器,Key `pub:login:fail:{username}`);同 IP 失败 20 次锁定 IP 1h | | Session 安全 | Cookie `Secure`、`HttpOnly`、`SameSite=Strict`;Cookie domain 限定 `admin.fonrey.com`(不允许跨子域) | | CSRF | 所有写操作启用 CSRF;HTMX 通过 `hx-headers='{"X-CSRFToken": "..."}'` 在 base 模板注入 | | CSP | `default-src 'self'`;Grafana iframe 域加入 `frame-src` 白名单;禁止 `unsafe-inline` 脚本(HTMX/Alpine 的内联事件已用 attribute 模式,符合) | | 高危操作 | 硬删除/恢复/升级/回滚必须 MFA step-up(5 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 类组合最佳实践) |