> **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-tenants(public 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.x(ASGI)+ 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 R2(S3 兼容) | 升级包 / 备份产出 / 导出产出 / 客户端安装包 | 后端写入,禁用前端直传(合规 + 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_URLCONF,namespace='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/ # 全部 CBV(auth/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 API(latest / 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//` | `TenantDetailView` | `admin_console/tenants/detail.html` | `AdminLoginRequiredMixin` | | 6 | 租户详情:用户管理 | `/admin/tenants//users/` | `TenantUserTabView` | `admin_console/tenants/detail.html`(Tab)| 同上 | | 7 | 租户详情:套餐信息 | `/admin/tenants//plan/` | `TenantPlanTabView` | 同上 | 同上 | | 8 | 租户详情:监控 | `/admin/tenants//monitoring/` | `TenantMonitoringTabView` | 同上 | 同上 | | 9 | 租户详情:备份记录 | `/admin/tenants//backups/` | `TenantBackupTabView` | 同上 | 同上(恢复操作另需 `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)`) | | 10 | 租户详情:操作历史 | `/admin/tenants//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)` | > 动态参数:`` 用于租户 ID、版本 ID、备份 ID、导出任务 ID、客户端发布 ID;`` 用于升级事件 ID。统一使用 UUID v4,禁止自增整数(AGENTS.md §4.4)。 ### 3.3 HTMX Partial 子路由(页面内局部刷新) > 命名约定:`<父页面>/rows/`(列表筛选/翻页)、`<父页面>//<动作>/`(行内动作)。所有 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//edit/` | POST | `TenantUpdateView` | 内联编辑 | `RoleRequiredMixin(OPS)` | | `/admin/tenants//suspend/` | POST | `TenantSuspendView` | 挂起 | `RoleRequiredMixin(OPS)` | | `/admin/tenants//resume/` | POST | `TenantResumeView` | 恢复 | `RoleRequiredMixin(OPS)` | | `/admin/tenants//soft-delete/` | POST | `TenantSoftDeleteView` | 软删除 | `RoleRequiredMixin(OPS)` | | `/admin/tenants//hard-delete/` | POST | `TenantHardDeleteView` | 硬删除 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` | | `/admin/tenants//restore-deletion/` | POST | `TenantRestoreDeletionView` | 撤销软删除 | `RoleRequiredMixin(OPS)` | | `/admin/tenants//users//reset-password/` | POST | `TenantUserResetPasswordView` | 重置租户用户密码 | `RoleRequiredMixin(OPS)` | | `/admin/tenants//admins/grant/` | POST | `TenantAdminGrantView` | 赋予 Tenant Admin | `RoleRequiredMixin(OPS)` | | `/admin/tenants//admins/revoke/` | POST | `TenantAdminRevokeView` | 撤销 Tenant Admin | `RoleRequiredMixin(OPS)` | | `/admin/tenants//plan/upgrade/` | POST | `TenantPlanUpgradeView` | 套餐升级 | `RoleRequiredMixin(OPS)` | | `/admin/tenants//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//backups/trigger/` | POST | `TenantBackupTriggerView` | 手动触发备份 | `RoleRequiredMixin(OPS)` | | `/admin/system/backups//status/` | GET | `BackupStatusPartialView` | `hx-trigger="every 5s"` 轮询任务进度;任务终态返回去 trigger 的 HTML | `AdminLoginRequiredMixin` | | `/admin/system/backups//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//status/` | GET | `ExportStatusPartialView` | `hx-trigger="every 5s"` 轮询;终态去 trigger | `AdminLoginRequiredMixin` | | `/admin/system/exports//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//progress/` | GET | `UpgradeProgressPartialView` | `hx-trigger="every 3s"` 轮询;状态 `halted/succeeded/failed` 终态后去 trigger | `AdminLoginRequiredMixin` | | `/admin/system/versions//rollback/` | POST | `RollbackView` | 全量/单租户回滚 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` | | `/admin/system/versions//incident/` | GET | `IncidentReportView` | 事件报告 | `AdminLoginRequiredMixin` | | `/admin/feature-flags/` | GET | `FeatureFlagListView` | Flag 定义列表 | `AdminLoginRequiredMixin` | | `/admin/feature-flags/new/` | POST | `FeatureFlagCreateView` | 新增 Flag | `RoleRequiredMixin(SUPER)` | | `/admin/feature-flags//rollout/` | POST | `FeatureFlagRolloutView` | 调整百分比 | `RoleRequiredMixin(SUPER)` | | `/admin/feature-flags//archive/` | POST | `FeatureFlagArchiveView` | 归档 | `RoleRequiredMixin(SUPER)` | | `/admin/tenants//feature-flags/toggle/` | POST | `TenantFlagToggleView` | 租户级 Flag 覆盖 | `RoleRequiredMixin(OPS)` | #### 3.3.6 监控与审计 | URL | 方法 | 视图 | 触发 | 权限 | |---|---|---|---|---| | `/admin/monitoring/alerts/` | GET | `AlertRuleListView` | 告警规则 | `RoleRequiredMixin(OPS)` | | `/admin/monitoring/alerts//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//deactivate/` | POST | `AdminDeactivateView` | `RoleRequiredMixin(SUPER)` | | `/admin/settings/admins//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//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//edit/` | POST | `ClientReleaseUpdateView` | 修改元数据 | `RoleRequiredMixin(SUPER)` | | `/admin/client-releases//publish/` | POST | `ClientReleasePublishView` | 发布 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` | | `/admin/client-releases//unpublish/` | POST | `ClientReleaseUnpublishView` | 下线 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` | | `/admin/client-releases//rollback/` | POST | `ClientReleaseRollbackView` | 回滚到该版本 | `MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)` | | `/admin/client-releases//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//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 内容 | 容器 `
`;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.5:1000+ 数据集禁用)。 --- ## 4. API 设计 ### 4.1 双命名空间策略 | 命名空间 | 用途 | 受众 | 认证方式 | 响应类型 | 版本控制 | |---|---|---|---|---|---| | `/admin/...` | 平台管理后台业务(CRUD + HTMX 局刷) | 平台管理员浏览器 | Django Session(HttpOnly 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 | Session(Platform Admin) | 全平台版本活跃分布 | | `/api/release/v1/metrics/tenant-installs/` | GET | Session(Platform Admin) | 指定租户活跃安装数 + 历史装机数 | | `/api/release/v1/metrics/tenant-leaderboard/` | GET | Session(Platform Admin) | 全平台租户活跃榜 | | `/admin/api/client-releases/` | POST | Session(SUPER) + MFA | 管理端创建版本 | | `/admin/api/client-releases//` | PATCH | Session(SUPER) + MFA | 修改状态 / 类型 / 日志 | | `/admin/api/client-releases//rollback/` | POST | Session(SUPER) + 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": "", "portable_checksum_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 `(设备票据,由客户端登录时由租户业务后端签发,包含 `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": "", "portable_checksum_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 | `
无权限
` 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":"导出任务已提交"}} {{ task.modules }}排队中…— ``` **轮询规约**: - 轮询间隔:备份/导出 = 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(取决数据量),异步 + 进度上报。 - 恢复单租户:5–30 min,**不重试**,失败自动回滚到前置快照。 - 单租户 schema 迁移:1–10s 快照 + `migrate` 主体。 - 整体硬删除:1–10 min(DROP 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'`;发送平台告警(已运行 S2–S5 资源已清理) | | **S7** | 异步发送欢迎邮件(`send_welcome_email`) | 邮件入队 | 仅记录失败日志 + Sentry 告警;**不回滚整个 Saga**(邮件失败不影响租户可用性) | > **原则**: > - S1–S6 为"原子序列",任一步失败必须逆序执行已完成步骤的补偿。 > - 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 # S2–S5:若 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 URL,TTL = 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-up(5 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.5):CPU/内存来自 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 原子切换 | | View(HTTP) | `django_tenants.test.client.TenantClient`(公共 schema)+ `HTTP_HOST='admin.fonrey.com'` | 三角色 × 关键端点的 200/403/401(未登录)三场景;MFA step-up 拦截;CSRF | | View(HTMX) | 同上 + `HTTP_HX_REQUEST='true'` | 验证返回 partial(不含 `` 根标签),且响应头包含约定的 `HX-Trigger` | | 客户端 API | `Client(HTTP_HOST='app.fonrey.com')` | `/api/release/v1/updates/latest/` 公开访问;`/api/release/v1/heartbeats/` 设备票据校验;版本分布 Session | | Middleware | pytest-django | IP 白名单命中/未命中;Session 滚动续期;过期 302;fail-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 | | 客户端 Heartbeat(PRD 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 worker(admin_ops) | 独立部署 worker,`--concurrency=2 --max-tasks-per-child=50`(任务多为 IO 密集长任务) | | Celery worker(migration) | 独立部署 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 设置 + 仪表盘空壳(页面 1–2)。 2. 租户管理(页面 3–10)+ 审计基础 + 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+ 处);按用户决定本轮不处理,后续单独发起治理。 |