74 KiB
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(v1.0)
关联数据模型: 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启动断言:
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 关键配置
# 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 路由组织与注册
# config/urls_public.py
from django.urls import path, include
# 严禁 from django.contrib import admin
urlpatterns = [
path('admin/', include(('apps.admin_console.urls', 'admin_console'), namespace='admin_console')),
path('admin/client-releases/', include(('apps.release.admin_urls', 'release_admin'), namespace='release_admin')),
path('api/release/', include(('apps.release.api_urls', 'release_api'), namespace='release_api')),
]
apps.admin_console.urls 内顶层 app_name = 'admin_console';反向解析使用 admin_console:tenants:list 等命名空间。
3.2 16 张主页面路由表(与 PRD §5.4.1 对齐)
| # | PRD 页面 | 后端 URL 模式 | 视图类 | 模板 | 路由守卫(Mixin 链) |
|---|---|---|---|---|---|
| 1 | 登录页 | /admin/login/ |
AdminLoginView(GET/POST) |
admin_console/auth/login.html |
匿名(中间件 IpWhitelistMiddleware 兜底) |
| 1.5 | MFA 校验 | /admin/login/mfa/ |
MfaChallengeView |
admin_console/auth/mfa_challenge.html |
已通过密码校验(session 标志) |
| 1.6 | MFA 首次绑定 | /admin/login/mfa/setup/ |
MfaSetupView |
admin_console/auth/mfa_setup.html |
首登态(platform_admin.mfa_enabled=False) |
| 1.7 | MFA Step-up | /admin/login/mfa/step-up/ |
MfaStepUpView(POST) |
—(仅写 session.mfa_confirmed_at) | AdminLoginRequiredMixin |
| 2 | 仪表盘 | /admin/ |
DashboardView |
admin_console/dashboard.html |
AdminLoginRequiredMixin |
| 3 | 租户列表 | /admin/tenants/ |
TenantListView |
admin_console/tenants/list.html |
AdminLoginRequiredMixin |
| 4 | 新建租户 | /admin/tenants/new/ |
TenantCreateView |
admin_console/tenants/create.html |
RoleRequiredMixin(OPS) + AuditedActionMixin |
| 5 | 租户详情:基本信息 | /admin/tenants/<uuid:pk>/ |
TenantDetailView |
admin_console/tenants/detail.html |
AdminLoginRequiredMixin |
| 6 | 租户详情:用户管理 | /admin/tenants/<uuid:pk>/users/ |
TenantUserTabView |
admin_console/tenants/detail.html(Tab) |
同上 |
| 7 | 租户详情:套餐信息 | /admin/tenants/<uuid:pk>/plan/ |
TenantPlanTabView |
同上 | 同上 |
| 8 | 租户详情:监控 | /admin/tenants/<uuid:pk>/monitoring/ |
TenantMonitoringTabView |
同上 | 同上 |
| 9 | 租户详情:备份记录 | /admin/tenants/<uuid:pk>/backups/ |
TenantBackupTabView |
同上 | 同上(恢复操作另需 MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)) |
| 10 | 租户详情:操作历史 | /admin/tenants/<uuid:pk>/history/ |
TenantHistoryTabView |
同上 | 同上 |
| 11 | 系统版本管理 | /admin/system/versions/ |
SystemVersionListView |
admin_console/versions/list.html |
AdminLoginRequiredMixin(写操作另需 RoleRequiredMixin(SUPER) + MfaConfirmedRequiredMixin) |
| 12 | 备份管理 | /admin/system/backups/ |
BackupListView |
admin_console/backups/list.html |
AdminLoginRequiredMixin |
| 13 | 监控与告警 | /admin/monitoring/ |
MonitoringView |
admin_console/monitoring/index.html |
AdminLoginRequiredMixin |
| 14 | 客户端版本管理 | /admin/client-releases/ |
ClientReleaseListView |
release/admin/list.html |
AdminLoginRequiredMixin(写操作另需 RoleRequiredMixin(SUPER) + MfaConfirmedRequiredMixin) |
| 15 | 审计日志 | /admin/audit-logs/ |
AuditLogListView |
admin_console/audit/list.html |
ReadOnlyAuditorAllowedMixin(审计员可读) |
| 16 | 管理员设置 | /admin/settings/admins/ |
AdminAccountListView |
admin_console/settings/admins.html |
RoleRequiredMixin(SUPER) |
动态参数:
<uuid:pk>用于租户 ID、版本 ID、备份 ID、导出任务 ID、客户端发布 ID;<uuid:event_id>用于升级事件 ID。统一使用 UUID v4,禁止自增整数(AGENTS.md §4.4)。
3.3 HTMX Partial 子路由(页面内局部刷新)
命名约定:
<父页面>/rows/(列表筛选/翻页)、<父页面>/<id>/<动作>/(行内动作)。所有 partial 视图必须校验request.htmx(django-htmx),非 HTMX 直访返回 404 或重定向到父页。
3.3.1 仪表盘(懒加载 + 轮询)
| URL | 方法 | 视图 | 触发 | 响应 |
|---|---|---|---|---|
/admin/dashboard/health/ |
GET | HealthStatusPartialView |
hx-trigger="load, every 30s" 轮询服务健康 |
HTML(Partial) |
/admin/dashboard/recent-actions/ |
GET | RecentActionsPartialView |
hx-trigger="revealed" 懒加载(首屏不阻塞) |
HTML(Partial) |
/admin/dashboard/stats/ |
GET | DashboardStatsPartialView |
hx-trigger="load" 单次加载 |
HTML(Partial) |
/admin/dashboard/client-coverage/ |
GET | ClientCoveragePartialView |
hx-trigger="revealed" 懒加载 |
HTML(Partial) |
3.3.2 租户管理(详情页 Tab 与行内动作)
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
/admin/tenants/rows/ |
GET | TenantRowsPartialView |
HTMX 筛选/翻页/搜索 | AdminLoginRequiredMixin |
/admin/tenants/<uuid:pk>/edit/ |
POST | TenantUpdateView |
内联编辑 | RoleRequiredMixin(OPS) |
/admin/tenants/<uuid:pk>/suspend/ |
POST | TenantSuspendView |
挂起 | RoleRequiredMixin(OPS) |
/admin/tenants/<uuid:pk>/resume/ |
POST | TenantResumeView |
恢复 | RoleRequiredMixin(OPS) |
/admin/tenants/<uuid:pk>/soft-delete/ |
POST | TenantSoftDeleteView |
软删除 | RoleRequiredMixin(OPS) |
/admin/tenants/<uuid:pk>/hard-delete/ |
POST | TenantHardDeleteView |
硬删除 | MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER) |
/admin/tenants/<uuid:pk>/restore-deletion/ |
POST | TenantRestoreDeletionView |
撤销软删除 | RoleRequiredMixin(OPS) |
/admin/tenants/<uuid:pk>/users/<uuid:user_id>/reset-password/ |
POST | TenantUserResetPasswordView |
重置租户用户密码 | RoleRequiredMixin(OPS) |
/admin/tenants/<uuid:pk>/admins/grant/ |
POST | TenantAdminGrantView |
赋予 Tenant Admin | RoleRequiredMixin(OPS) |
/admin/tenants/<uuid:pk>/admins/revoke/ |
POST | TenantAdminRevokeView |
撤销 Tenant Admin | RoleRequiredMixin(OPS) |
/admin/tenants/<uuid:pk>/plan/upgrade/ |
POST | TenantPlanUpgradeView |
套餐升级 | RoleRequiredMixin(OPS) |
/admin/tenants/<uuid:pk>/plan/license/ |
POST | TenantLicenseUpdateView |
调整 License 到期/上限 | RoleRequiredMixin(OPS) |
3.3.3 备份与恢复
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
/admin/system/backups/rows/ |
GET | BackupRowsPartialView |
筛选/翻页 | AdminLoginRequiredMixin |
/admin/system/backups/schedule/ |
GET/POST | BackupScheduleView |
全局策略 | RoleRequiredMixin(SUPER) |
/admin/tenants/<uuid:pk>/backups/trigger/ |
POST | TenantBackupTriggerView |
手动触发备份 | RoleRequiredMixin(OPS) |
/admin/system/backups/<uuid:pk>/status/ |
GET | BackupStatusPartialView |
hx-trigger="every 5s" 轮询任务进度;任务终态返回去 trigger 的 HTML |
AdminLoginRequiredMixin |
/admin/system/backups/<uuid:pk>/restore/ |
POST | BackupRestoreView |
数据恢复 | MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER) |
3.3.4 数据导出
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
/admin/system/exports/ |
GET | ExportListView |
列表(首屏) | AdminLoginRequiredMixin |
/admin/system/exports/new/ |
GET/POST | ExportCreateView |
新建导出 | RoleRequiredMixin(OPS) |
/admin/system/exports/<uuid:pk>/status/ |
GET | ExportStatusPartialView |
hx-trigger="every 5s" 轮询;终态去 trigger |
AdminLoginRequiredMixin |
/admin/system/exports/<uuid:pk>/download/ |
GET | ExportDownloadRedirectView |
302 跳 R2 Presigned URL | 触发人 + RoleRequiredMixin(SUPER) |
3.3.5 系统升级(A / B / C 三类,详见 §6.4)
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
/admin/system/versions/upgrade/ |
GET/POST | UpgradeFormView |
升级表单(含类型/灰度/批次参数) | MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER)(POST) |
/admin/system/versions/<uuid:event_id>/progress/ |
GET | UpgradeProgressPartialView |
hx-trigger="every 3s" 轮询;状态 halted/succeeded/failed 终态后去 trigger |
AdminLoginRequiredMixin |
/admin/system/versions/<uuid:event_id>/rollback/ |
POST | RollbackView |
全量/单租户回滚 | MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER) |
/admin/system/versions/<uuid:event_id>/incident/ |
GET | IncidentReportView |
事件报告 | AdminLoginRequiredMixin |
/admin/feature-flags/ |
GET | FeatureFlagListView |
Flag 定义列表 | AdminLoginRequiredMixin |
/admin/feature-flags/new/ |
POST | FeatureFlagCreateView |
新增 Flag | RoleRequiredMixin(SUPER) |
/admin/feature-flags/<key>/rollout/ |
POST | FeatureFlagRolloutView |
调整百分比 | RoleRequiredMixin(SUPER) |
/admin/feature-flags/<key>/archive/ |
POST | FeatureFlagArchiveView |
归档 | RoleRequiredMixin(SUPER) |
/admin/tenants/<uuid:pk>/feature-flags/toggle/ |
POST | TenantFlagToggleView |
租户级 Flag 覆盖 | RoleRequiredMixin(OPS) |
3.3.6 监控与审计
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
/admin/monitoring/alerts/ |
GET | AlertRuleListView |
告警规则 | RoleRequiredMixin(OPS) |
/admin/monitoring/alerts/<uuid:pk>/edit/ |
POST | AlertRuleUpdateView |
编辑规则 | RoleRequiredMixin(OPS) |
/admin/monitoring/grafana-webhook/ |
POST | GrafanaWebhookView |
Grafana 推送(HMAC 校验) | 公开(HMAC 鉴权) |
/admin/audit-logs/rows/ |
GET | AuditLogRowsPartialView |
筛选/翻页 | ReadOnlyAuditorAllowedMixin |
/admin/audit-logs/export/ |
POST | AuditLogExportView |
异步导出 CSV | ReadOnlyAuditorAllowedMixin |
3.3.7 管理员设置
| URL | 方法 | 视图 | 权限 |
|---|---|---|---|
/admin/settings/admins/new/ |
POST | AdminAccountCreateView |
RoleRequiredMixin(SUPER) |
/admin/settings/admins/<uuid:pk>/deactivate/ |
POST | AdminDeactivateView |
RoleRequiredMixin(SUPER) |
/admin/settings/admins/<uuid:pk>/sessions/revoke/ |
POST | ForceLogoutView |
RoleRequiredMixin(SUPER) |
/admin/settings/ip-whitelist/ |
GET | IpWhitelistListView |
RoleRequiredMixin(SUPER) |
/admin/settings/ip-whitelist/new/ |
POST | IpWhitelistCreateView |
RoleRequiredMixin(SUPER) |
/admin/settings/ip-whitelist/<uuid:pk>/toggle/ |
POST | IpWhitelistToggleView |
RoleRequiredMixin(SUPER) |
/admin/settings/sessions/ |
GET | MyActiveSessionListView |
AdminLoginRequiredMixin |
3.3.8 客户端版本管理(apps.release 后台 UI)
| URL | 方法 | 视图 | 触发 | 权限 |
|---|---|---|---|---|
/admin/client-releases/rows/ |
GET | ClientReleaseRowsPartialView |
筛选/翻页 | AdminLoginRequiredMixin |
/admin/client-releases/new/ |
GET/POST | ClientReleaseCreateView |
新建版本(草稿/发布) | RoleRequiredMixin(SUPER) + MfaConfirmedRequiredMixin(POST) |
/admin/client-releases/<uuid:pk>/edit/ |
POST | ClientReleaseUpdateView |
修改元数据 | RoleRequiredMixin(SUPER) |
/admin/client-releases/<uuid:pk>/publish/ |
POST | ClientReleasePublishView |
发布 | MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER) |
/admin/client-releases/<uuid:pk>/unpublish/ |
POST | ClientReleaseUnpublishView |
下线 | MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER) |
/admin/client-releases/<uuid:pk>/rollback/ |
POST | ClientReleaseRollbackView |
回滚到该版本 | MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER) |
/admin/client-releases/<uuid:pk>/force-update/ |
POST | ClientReleaseForceUpdateView |
推送强制更新标记 | MfaConfirmedRequiredMixin + RoleRequiredMixin(SUPER) |
/admin/client-releases/metrics/version-distribution/ |
GET | VersionDistributionPartialView |
hx-trigger="revealed" 懒加载 |
AdminLoginRequiredMixin |
/admin/client-releases/metrics/tenant-leaderboard/ |
GET | TenantLeaderboardPartialView |
hx-trigger="revealed" 懒加载 + 翻页 |
AdminLoginRequiredMixin |
3.4 路由守卫(视图层 Mixin 链)
# 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"""
典型组合:
class TenantHardDeleteView(MfaConfirmedRequiredMixin, RoleRequiredMixin, AuditedActionMixin, FormView):
required_role = AdminRole.SUPER
audit_action = 'HARD_DELETE_TENANT'
MFA Step-up 流程(用户点击「硬删除」):
1. HTMX POST /admin/tenants/<id>/hard-delete/
2. MfaConfirmedRequiredMixin 检测 session.mfa_confirmed_at 已过 5 min
3. 401 + HX-Trigger: {"fonrey:mfa-required":{"action":"hard_delete","return_to":"<原 URL>"}}
4. 前端 Alpine.js 监听该事件 → 打开 MFA Modal
5. 用户输入 TOTP → POST /admin/login/mfa/step-up/ → 后端写 session.mfa_confirmed_at = now()
6. 前端拿到成功响应 → 重新发起原始 POST(带 X-Mfa-Step-Up: 1)→ 通过执行
3.5 懒加载策略
| 场景 | HTMX 实现 | 触发时机 |
|---|---|---|
| 详情页 Tab 内容 | 容器 <div hx-get="..." hx-trigger="revealed" hx-swap="innerHTML">;Tab 激活后第一次进入视口才请求 |
首屏不阻塞 |
| 仪表盘图表 | hx-trigger="revealed" + 加载占位骨架屏 |
滚动到位时加载 |
| 客户端版本分布 / 租户活跃榜 | 同上 | 同上 |
| 长轮询任务(备份/导出/升级) | hx-trigger="every Ns";后端在终态 partial 中移除 hx-trigger="every"(替换为 hx-trigger="load"),避免持续轮询 |
进入页面 → 终态停 |
| 列表懒加载(追加) | Keyset 分页 + hx-trigger="revealed" hx-swap="afterend" 在最后一行触发 |
滚动到底部加载下页 |
| 模态框组件(MFA Modal、删除确认) | hx-get 拉 partial → swap 到 #dialog;不预渲染 |
用户点击触发 |
反模式:
- ❌ 全量预渲染所有 Tab(首屏慢、浪费请求)。
- ❌ 终态后仍
every Ns轮询(资源泄漏)。 - ❌ OFFSET 分页(AGENTS.md §4.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/<uuid:pk>/ |
PATCH | Session(SUPER) + MFA | 修改状态 / 类型 / 日志 |
/admin/api/client-releases/<uuid:pk>/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,有更新):
{
"has_update": true,
"latest_version": "1.3.0",
"force_update": false,
"min_required_version": "1.0.0",
"download_url": "https://download.fonrey.com/releases/system/v1.3.0/fonrey-setup-1.3.0-win.exe",
"portable_url": "https://download.fonrey.com/releases/system/v1.3.0/fonrey-portable-1.3.0-win.zip",
"checksum_sha256": "<exe_sha256>",
"portable_checksum_sha256": "<zip_sha256>",
"file_size_bytes": 157286400,
"release_notes": "## v1.3.0\n- ...",
"release_date": "2026-05-01"
}
响应(200,无更新):
{ "has_update": false, "latest_version": "1.3.0" }
性能要求:缓存命中 p95 < 120ms(缓存键 pub:release:latest:{platform}:{arch},TTL 60s)。
POST /api/release/v1/heartbeats/
请求头:
Authorization: Bearer <device_token>(设备票据,由客户端登录时由租户业务后端签发,包含tenant_id+user_id+device_id,exp7 天)Content-Type: application/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):
{ "ok": true }
性能要求:写入 p95 < 80ms。
GET /api/release/v1/metrics/version-distribution/
响应(200):
{
"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
请求体:
{
"version": "1.3.0",
"platform": "win32",
"arch": "x64",
"release_type": "normal",
"min_required_version": "1.0.0",
"download_url": "https://download.fonrey.com/releases/system/v1.3.0/fonrey-setup-1.3.0-win.exe",
"portable_url": "https://download.fonrey.com/releases/system/v1.3.0/fonrey-portable-1.3.0-win.zip",
"checksum_sha256": "<exe_sha256>",
"portable_checksum_sha256": "<zip_sha256>",
"release_notes": "## v1.3.0\n- ...",
"status": "draft"
}
响应(201):返回创建的资源;status='published' 必须在事务中将旧 published 版本切为 unpublished(确保单一生效约束)。
4.3 后台业务接口(/admin/...,HTMX 局刷为主)
完整路径见 §3.3。所有路径返回 HTML(Partial),不返回 JSON(除轮询任务进度的 Celery 状态接口)。
4.3.1 HTMX 响应规范
| 场景 | HTTP | 响应内容 | 响应头 |
|---|---|---|---|
| 操作成功 | 200 | 更新后的 HTML Partial | HX-Trigger: {"fonrey:toast":{"type":"success","message":"..."}} |
| 表单校验失败 | 422 | 含错误信息的表单 Partial(保留用户输入) | 不发 Toast |
| 业务规则拒绝(如未导出就硬删) | 422 | 表单 Partial + 顶部 Alert 块 | 可选 HX-Trigger warning Toast |
| 权限不足 | 403 | <div class="alert alert-danger">无权限</div> Partial |
HX-Trigger: {"fonrey:toast":{"type":"error","message":"权限不足"}} |
| 需要 MFA 二次确认 | 401 | 空 Partial | HX-Trigger: {"fonrey:mfa-required":{"action":"...","return_to":"..."}} |
| 未登录 / Session 过期 | 401 | 空 Partial | HX-Redirect: /admin/login/ |
| 服务器异常 | 500 | 错误页 Partial | HX-Trigger: {"fonrey:toast":{"type":"error","message":"系统异常,请重试"}} |
4.3.2 Celery 任务前端协议
POST /admin/system/exports/new/
HX-Request: true
Content-Type: application/x-www-form-urlencoded
→ 200 OK
HX-Trigger: {"fonrey:toast":{"type":"info","message":"导出任务已提交"}}
<tr id="export-row-{task_id}"
hx-get="/admin/system/exports/{task_id}/status/"
hx-trigger="every 5s" hx-swap="outerHTML">
<td>{{ task.modules }}</td><td>排队中…</td><td>—</td>
</tr>
轮询规约:
- 轮询间隔:备份/导出 = 5s,升级进度 = 3s。
- 终态后端必须移除
hx-trigger="every"(替换为hx-trigger="load"或不附 trigger)。 - 进度展示字段统一来自 Celery
AsyncResult+ DB 状态(DB 优先,避免 Celery 结果过期丢失)。
4.4 错误码
所有 JSON 响应遵循
API_CONTRACT.md包络规范:{"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 任务实现
# 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() |
purge_pending_delete |
Beat 每天 03:00 扫描冷静期到期 |
hard_delete_tenant |
视图触发 |
run_backup |
调度器 + 升级前 + 手动 |
cleanup_old_backups |
Beat 每天 04:00 |
run_restore |
视图触发(高危) |
run_export |
视图触发 |
expire_export_links |
Beat 每小时 |
health_check |
升级前 |
orchestrate_upgrade |
升级表单触发 |
migrate_single_tenant |
编排器派发 |
tenant_smoke_test |
单租户迁移后 |
post_batch_health_gate |
批后门控 |
rollback_single_tenant |
单租户回滚 |
rollback_upgrade |
整体回滚 |
release_compute_checksum_task |
客户端发布包上传后 |
release_publish_cdn_warmup_task |
版本发布后(可选) |
release_scan_artifact_task |
客户端发布包上传后(可选) |
aggregate_dashboard_stats |
Beat 每 1 min |
aggregate_release_metrics |
Beat 每 1 min |
cleanup_admin_sessions |
Beat 每 30 min |
send_welcome_email / send_export_ready / send_suspend_notice |
业务事件后 |
通用约定:
- 所有任务
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-Typeheader 或文件扩展名。 - 下载链接(导出/备份)使用 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 服务
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 隔离配置
# 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 时,中间件执行以下校验序列:
# 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 物理防线
# /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 层
PlatformAuditLogManager 重写update()/delete()抛IntegrityError。 - 只允许
objects.append_only_create()。 - 紧急数据修复走
manage.py shell_plus,事后由超管在/admin/audit-logs/手工补录条目(source='manual_shell'),不开后门。
7.4 客户端运行时安全
- Electron 必须启用:
contextIsolation=true、nodeIntegration=false、sandbox=true。 - 更新包仅允许 HTTPS 下载;域名白名单固定为
download.fonrey.com。 - EV 证书私钥仅在 CI 密钥库中可用,禁止落盘到开发机。
- 校验值由服务端生成并签名传输(至少 TLS + 服务端可信源)。
- Heartbeat 接口必须防重放/防刷(设备票据 + 频控 + 审计)。
- 管理端操作(发布、回滚、下线、强制更新)全部记录审计日志。
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(不含 <html> 根标签),且响应头包含约定的 HX-Trigger |
| 客户端 API | Client(HTTP_HOST='app.fonrey.com') |
/api/release/v1/updates/latest/ 公开访问;/api/release/v1/heartbeats/ 设备票据校验;版本分布 Session |
| Middleware | pytest-django | IP 白名单命中/未命中;Session 滚动续期;过期 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. 落地顺序建议
apps/admin_console模型/服务/Mixin 基础骨架 + 独立认证 + MFA 设置 + 仪表盘空壳(页面 1–2)。- 租户管理(页面 3–10)+ 审计基础 + IP 白名单(页面 16 子集)。
- 备份 + 导出 + 监控(页面 12 / 13)。
- 系统升级(页面 11 + Feature Flag)。
apps/release后台 UI(页面 14)+ 客户端运行时 API(/api/release/v1/updates/latest/、/heartbeats/)。- Electron 壳应用最小可运行版 + electron-updater 接入 + EV 签名 CI。
- 官网下载页(公司域名)+ 便携版 ZIP。
- 完整审计页面 + 平台管理员设置(页面 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+ 处);按用户决定本轮不处理,后续单独发起治理。 |