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

74 KiB
Raw Blame History

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.mdv1.0
关联数据模型: DATA_MODEL/DATA_MODEL_PUBLIC.md
关联 ADR: ADR-20260502-001ADR-20260502-002ADR-20260430-006ADR-20260430-007ADR-20260430-008ADR-20260430-009
最后更新: 2026-05-02

关键定位:本文件是「平台管理后台」的统一技术方案,取代已删除的 客户端发布管理技术方案.md系统管理技术文档.md(详见 ADR-20260502-002。两份原文档涉及的全部技术口径API 命名空间、client_heartbeats 表结构、SHA256 校验、Argon2id、TOTP、admin_ops 队列、pub: 缓存前缀、django.contrib.admin 全环境弃用等)原样保留并整合到本文件,无任何技术决策变更。


变更历史

日期 变更人 变更内容
2026-05-02 Sisyphus 初版:合并原『客户端发布管理技术方案.md』与原『系统管理技术文档.md』统一三大维度技术选型 / 页面路由表 / API 设计),新增 ADR-20260502-002
2026-05-02 Atlas v1.1:新增 §7.0 平台后台独立子域与会话隔离S-2新增 §6.1.1 创建租户 Saga 与补偿事务PT-B-1

1. 文档定位与边界

1.1 范围

本文件覆盖「平台管理后台」全部实现口径,受众为所有为该后台编码、测试、运维的工程师与 AI Agent

  • 技术选型Django CBV + django-tenantspublic schema + HTMX + Alpine.js + Tailwind + Celery + Redis + R2 + Electron + electron-updater
  • 页面路由表16 张页面 + HTMX Partial 子路由 + 路由守卫 Mixin 链 + 懒加载约定
  • API 设计:双命名空间(/admin/... 后台业务 + /api/release/... 客户端运行时的具体路径、HTTP 方法、请求/响应、错误码、版本控制、认证方式
  • 安全与合规MFA 强制、IP 白名单、CSRF、CSP、审计不可变、Django Admin 全环境弃用
  • 异步任务、缓存策略、文件上传、监控集成、测试规范、部署规范

1.2 边界

  • 本文件不重复 DDLtenants / domains / tenant_status_logs / platform_admins / admin_mfa_devices / admin_sessions / ip_whitelist / platform_audit_logs / backup_schedules / backup_records / export_tasks / system_versions / upgrade_events / client_releases / client_heartbeats / feature_flag_definitions / feature_flag_change_log(合计 17 张表)字段以 DATA_MODEL_PUBLIC.md 为唯一权威。
  • 本文件不描述租户业务模块apps.property / apps.client 等),仅在跨域操作(备份、恢复、导出、升级编排)中通过 with schema_context(tenant.schema_name): 显式切换 schema。
  • 本文件不重复 PRD 业务规则。租户状态机、角色权限矩阵、页面清单的产品口径见 PRD §5.4 / §6 / §7。

1.3 与原两份文档的对应关系

原文档章节 本文件章节
原『系统管理技术文档.md』§1 模块边界 §2.1、§2.2、§2.4
原『系统管理技术文档.md』§2 目录结构 §2.3
原『系统管理技术文档.md』§3 路由命名空间 §2.5、§3
原『系统管理技术文档.md』§4 API 端点设计 §3、§4.1
原『系统管理技术文档.md』§5 权限与认证 §3.4、§7.1§7.3
原『系统管理技术文档.md』§6 缓存策略 §6.2
原『系统管理技术文档.md』§7 文件上传 §6.3
原『系统管理技术文档.md』§8 Celery + 升级 A/B/C §6.1、§6.4§6.5
原『系统管理技术文档.md』§9 监控集成 §8
原『系统管理技术文档.md』§10 测试规范 §9
原『系统管理技术文档.md』§11 安全要点 §7
原『系统管理技术文档.md』§12 部署规范 §10
原『客户端发布管理技术方案.md』§3 模块架构 §2.6
原『客户端发布管理技术方案.md』§5 端点清单 §4.2
原『客户端发布管理技术方案.md』§6 关键 API §4.2
原『客户端发布管理技术方案.md』§9 异步与缓存 §6.1、§6.2
原『客户端发布管理技术方案.md』§10 性能 §5.4
原『客户端发布管理技术方案.md』§11 安全 §7.4
原『客户端发布管理技术方案.md』§12 错误码 §4.4

2. 技术选型

2.1 核心技术栈

层级 选型 用途 选型理由
路由 + 视图 Django 4.xASGI+ Class-Based Views 后端路由、页面渲染、JSON API 与租户业务同栈,复用 django-tenants schema 切换CBV Mixin 组合权限/审计/MFA
多租户编排 django-tenants 1.4+SHARED_APPS public schema 注册、schema_context() 切换 物理 schema 隔离 + 后台无感切换
前端交互 HTMX 1.9+ 局部刷新、表单提交、轮询 无重前端框架AGENTS.md §5单进程返回 partial HTML
前端状态 Alpine.js 3.x 弹窗开关、Tab 切换、MFA Modal、表单字数统计 轻量、属性式声明,配合 CSP script-src 'self'
样式 Tailwind CSS 3.x 全部样式 与租户业务共用设计系统
REST API客户端 Django Views手写 JSON /api/release/... 客户端运行时接口 端点少(≤ 10 个)、无需 DRF 全套;JsonResponse + 手写序列化即可
认证(后台) 自建 PlatformAdminBackend + Django Session + Argon2id + django-otp/TOTP 平台管理员独立账号体系 不复用 django.contrib.auth.User;强制 MFA
认证(客户端) 设备签名票据Token in Header 客户端 Heartbeat 鉴权 防伪造上报;与租户登录态解耦
数据库 PostgreSQL 16 + PgBouncer 数据落 public schemaSHARED_APPS 与租户业务同实例不同 schema
缓存 Redis 后台 session 反查、IP 白名单、任务进度、版本分布聚合 Key 前缀 pub:,与租户业务 {schema}: 严格隔离
异步任务 Celery 5.x + Celery Beat 备份/恢复/导出/升级编排/Heartbeat 聚合 独立队列 admin_ops + migration 双队列
对象存储 Cloudflare R2S3 兼容) 升级包 / 备份产出 / 导出产出 / 客户端安装包 后端写入,禁用前端直传(合规 + SHA256 完整性)
CDN Cloudflare CDN 客户端安装包分发 download.fonrey.com 与 R2 原生集成
客户端壳应用 Electron + electron-updater + electron-builder Windows 桌面客户端 壳应用原则:不内嵌业务逻辑,仅渲染 Web URL
代码签名 EV Code Signing Certificate 客户端 EXE / ZIP 签名 Windows SmartScreen 信任
完整性校验 SHA-256 客户端安装包校验(强制) 详见 ADR-20260430-008
服务器 Gunicorn + Uvicorn workers + Nginx ASGI 部署 与租户应用共用进程,按 Host 路由
监控 Sentry独立 DSN+ Grafana iframe + Flower 错误追踪 + 平台指标 + Celery 队列健康 与租户业务监控分离

禁止项(违反视为 Bug

  • 引入 Django REST Framework 仅为本模块(端点少,开销过大)。
  • 引入 React/Vue/Angular 等重前端AGENTS.md §5
  • 注册 django.contrib.admin(全环境弃用,详见 §2.4)。
  • 复用 django.contrib.auth.User 作为平台管理员主体(必须独立 platform_admins)。
  • 客户端渲染进程开启 nodeIntegration: true(壳应用安全边界)。
  • 前端直传 Presigned URL 上传升级包/备份/导出(必须后端中转 + SHA-256 校验)。

2.2 部署边界

维度 说明
部署域名 admin.fonrey.com独立子域Nginx 层 IP 白名单 + 应用层 IpWhitelistMiddleware 双重保险)
Schema 归属 publicSHARED_APPS),所有 ORM 查询走 public_schema_urlconf
客户端运行时域名 download.fonrey.comCDN 边缘)+ 业务接口走 app.fonrey.com/api/release/...
URL 前缀(后台业务) /admin/...
URL 前缀(客户端 API /api/release/...(沿用 ADR-20260430-009
Celery 队列 admin_ops(默认)、migration(独立限并发,仅 B 类升级使用)
Cookie 域 admin.fonrey.comStrict禁止跨子域

2.3 Django App 与目录结构

本后台跨两个 App均在 SHARED_APPS

  • apps/admin_console/ — 系统管理主体(租户/备份/导出/升级/审计/告警/平台管理员设置/Feature Flag
  • apps/release/ — 客户端发布(系统版本元数据、客户端 Heartbeat、版本分布统计、自动更新接口

两者共用:apps.admin_console.permissions(角色 Mixinapps.admin_console.middlewareIP 白名单 + Sessionapps.admin_console.services.audit_service(统一审计入口)。apps/release 不得反向依赖 apps.admin_console.views,仅依赖其权限与审计基础件。

apps/admin_console/
├── apps.py
├── urls.py                          # 注册到 PUBLIC_SCHEMA_URLCONFnamespace='admin_console'
├── auth_backends.py                 # PlatformAdminBackend独立认证
├── middleware.py                    # IpWhitelistMiddleware / AdminSessionMiddleware
├── permissions.py                   # AdminRole 枚举 / Mixin / ACTION_REQUIRED_ROLE
├── signals.py
├── forms.py
├── models/                          # tenant / platform_admin / audit / backup / export / version / feature_flag
├── views/                           # 全部 CBVauth/dashboard/tenants/backups/exports/versions/monitoring/audit/settings/feature_flags
├── tasks/                           # tenant_lifecycle / backup / restore / export / upgrade / notifications / housekeeping
├── services/                        # tenant_service / audit_service / mfa_service / permission_service / backup_service / version_service / feature_flags
├── tests/
└── templates/admin_console/         # base.html + 各页面 + partials/

apps/release/
├── apps.py
├── urls.py                          # 同时挂到 /admin/client-releases/(后台 UI与 /api/release/(客户端 API
├── models/                          # client_release / client_heartbeat
├── views/
│   ├── admin.py                     # 后台 CBV与 admin_console 同款 Mixin 链)
│   └── api.py                       # 客户端 JSON APIlatest / heartbeats / metrics
├── serializers.py                   # 极简 dataclass + asdict(),不引入 DRF
├── tasks/                           # release_compute_checksum / release_publish_cdn_warmup / release_scan_artifact
├── services/                        # release_service / heartbeat_service / metrics_service
├── tests/
└── templates/release/               # 后台 UI partials与 admin_console templates/ 同风格)

目录约定

  • models/ 一表一文件。
  • views/ 全部 CBVListView / 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/ AdminLoginViewGET/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/ MfaStepUpViewPOST —(仅写 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.htmlTab 同上
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.htmxdjango-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) + MfaConfirmedRequiredMixinPOST
/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.51000+ 数据集禁用)。

4. API 设计

4.1 双命名空间策略

命名空间 用途 受众 认证方式 响应类型 版本控制
/admin/... 平台管理后台业务CRUD + HTMX 局刷) 平台管理员浏览器 Django SessionHttpOnly Cookie + CSRF + TOTP MFA HTML(Page) / HTML(Partial) / 偶发 JSON仅 Celery 任务状态轮询) 不带版本号;通过 Mixin 灰度
/api/release/... 客户端运行时接口(更新检测 / Heartbeat / 统计) Electron 客户端 + 平台后台(统计) 公开(更新检测) / 设备签名票据Heartbeat / Session管理端 JSON URL 路径携带 v1(沿用 ADR-20260430-009

版本控制策略

  • /admin/...:内部接口,不提供向后兼容承诺,随发版滚动升级。
  • /api/release/v1/...:客户端长期使用,必须遵守向后兼容;破坏性变更必须新增 /api/release/v2/... 并允许 v1 至少共存 6 个月。

4.2 客户端运行时 API/api/release/v1/...

沿用 ADR-20260430-009(统一命名空间)+ ADR-20260430-008SHA-256 强制)+ ADR-20260430-007Heartbeat Upsert + 24h 活跃口径)。

4.2.1 端点清单

端点 方法 鉴权 说明
/api/release/v1/updates/latest/ GET 公开 客户端检查最新版本(仅返回 published 版本)
/api/release/v1/heartbeats/ POST 设备票据 客户端启动上报Upsert
/api/release/v1/metrics/version-distribution/ GET SessionPlatform Admin 全平台版本活跃分布
/api/release/v1/metrics/tenant-installs/ GET SessionPlatform Admin 指定租户活跃安装数 + 历史装机数
/api/release/v1/metrics/tenant-leaderboard/ GET SessionPlatform Admin 全平台租户活跃榜
/admin/api/client-releases/ POST SessionSUPER + MFA 管理端创建版本
/admin/api/client-releases/<uuid:pk>/ PATCH SessionSUPER + MFA 修改状态 / 类型 / 日志
/admin/api/client-releases/<uuid:pk>/rollback/ POST SessionSUPER + MFA 原子切换 published

管理端写操作走 /admin/api/client-releases/...(与 /admin/... HTMX 路由共享 Session/CSRF/MFA 链路,但返回 JSON与客户端 /api/release/v1/ 解耦:客户端永远只读公开版本。

4.2.2 请求 / 响应规范

GET /api/release/v1/updates/latest/

请求参数querystring

参数 必填 说明
platform win32MVP 仅 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_idexp 7 天)
  • 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_iduser_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取决数据量异步 + 进度上报。
  • 恢复单租户530 min不重试,失败自动回滚到前置快照。
  • 单租户 schema 迁移110s 快照 + migrate 主体。
  • 整体硬删除110 minDROP SCHEMA 必须事务 + SAVEPOINT

6. 异步任务、缓存与文件上传

6.1 Celery 任务清单

队列:admin_ops(默认)、migration(独立限并发,仅 B 类升级使用)。

任务 触发场景 队列 重试 失败处理
provision_tenant 创建租户后异步执行 schema 创建 + 迁移 + 默认数据 admin_ops 不重试 标记 tenants.status='failed',事务回滚,邮件告警

6.1.1 创建租户 Saga 与补偿事务PT-B-1 回应)

背景:审核报告 PT-B-1 指出,provision_tenant 任务跨越"DB 行写入 → schema 创建 → 迁移 → 发送欢迎邮件"多个步骤,任意步骤失败若无补偿事务,会导致 tenants 表存在悬空行、schema 孤儿或账号不一致。本节定义完整 Saga 流程及每步补偿动作。

Saga 步骤与补偿矩阵

步骤 # 动作 成功后状态 补偿动作(失败时回滚)
S1 写入 public.tenantsstatus='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} CASCADEschema 已损坏,丢弃重建)
S4 写入租户 schema 默认数据(角色、系统配置等) 默认数据就绪 同 S3 补偿(整个 schema 丢弃)
S5 {schema_name}.users 创建初始 Tenant Admin 账号 账号可用 同 S3 补偿(账号随 schema 丢弃)
S6 更新 public.tenants.status = 'active' 租户对外可用 status 改为 'failed';发送平台告警(已运行 S2S5 资源已清理)
S7 异步发送欢迎邮件(send_welcome_email 邮件入队 仅记录失败日志 + Sentry 告警;不回滚整个 Saga(邮件失败不影响租户可用性)

原则

  • S1S6 为"原子序列",任一步失败必须逆序执行已完成步骤的补偿。
  • S7 为"幂等尾步骤",独立重试,不触发 Saga 回滚。
  • 补偿动作本身不可再失败——若补偿失败(如 DROP SCHEMA 超时),写入 platform_audit_logsaction_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

    # S2S5若 schema 已创建,丢弃整个 schema
    if any(s in completed_steps for s in ("S2_schema_created", "S3_migrated", "S4_seeded", "S5_admin_created")):
        try:
            with connection.cursor() as cur:
                cur.execute(f"DROP SCHEMA IF EXISTS {tenant.schema_name} CASCADE")
            logger.info("compensation: dropped schema %s", tenant.schema_name)
        except Exception as comp_exc:
            logger.error("compensation FAILED (DROP SCHEMA): %s", comp_exc)
            audit_service.write_audit(
                action_type="PROVISION_COMPENSATION_FAILED",
                target_type="Tenant",
                target_id=str(tenant.id),
                result="failed",
                error_message=str(comp_exc),
            )
            # 发 PagerDuty 告警
            from apps.admin_console.alerts import trigger_pagerduty
            trigger_pagerduty(
                title=f"provision_tenant compensation failed: {tenant.schema_name}",
                body=str(comp_exc),
            )

    # S1将 tenants 行标记为 failed不删行保留审计溯源
    try:
        tenant.status = "failed"
        tenant.save(update_fields=["status", "updated_at"])
        audit_service.write_audit(
            action_type="CREATE_TENANT",
            target_type="Tenant",
            target_id=str(tenant.id),
            result="failed",
            error_message=str(exc),
        )
    except Exception as comp_exc:
        logger.error("compensation FAILED (mark tenant failed): %s", comp_exc)

幂等性保证

  • _create_schema 前先 DROP SCHEMA IF EXISTS ... CASCADE,确保重新触发 Saga 时不因 schema 残留而报错。
  • provision_tenant 任务 ID 绑定 tenant_id;同一 tenant_id 若任务已在 PROGRESS / SUCCESS 状态View 层拒绝重复入队。
  • create_initial_admin 内部以 phone 为唯一键做 get_or_create,幂等安全。

可观测性

观测点 实现
Saga 步骤进度 task.update_state(state='PROGRESS', meta={'step': step_name})
最终状态 platform_audit_logsaction_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.exefonrey-portable-{version}-win.zip
备份产出 Celery worker → R2 backups backups/{tenant_schema}/{record_id}.tar.gz
导出产出 Celery worker → R2 exports exports/{tenant_schema}/{task_id}.zip
审计日志导出 CSV Celery worker → R2 exports exports/audit/{task_id}.csv

强制规则

  • 所有 R2 写入由后端 Celery 完成;禁用前端直传 Presigned URL合规 + SHA-256 完整性 + 频次极低)。
  • 升级包 / 客户端发布包必须 SHA-256 双校验:上传完成后由 release_compute_checksum_task 计算并落库(system_versions.checksum_sha256 / client_releases.checksum_sha256);客户端拉取后必须校验,失败禁止安装(ADR-20260430-008)。
  • 升级包视图使用 python-magic 读取头部字节做 MIME 校验,不信任 Content-Type header 或文件扩展名。
  • 下载链接(导出/备份)使用 R2 Presigned GET URLTTL = 24 小时;视图 302 重定向,不返回链接给前端。
  • Nginx client_max_body_size:升级包/发布包路径 600M其他路径 10M。

6.4 系统升级 A / B / C 三类编排

沿用原『系统管理技术文档.md』§8.5§8.6。摘要:

类型 内容 是否可分批到租户级 编排路径
A. 应用代码升级 Python 代码、模板、JS/CSS、Worker 镜像 单进程多租户架构下物理上不可分批;蓝绿切换 运维侧 K8s/Compose本模块仅记录 system_versions 元数据
B. 租户 Schema 迁移 apps.property / apps.clientmigrations/*.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_typebatch_size(默认 5batch_concurrency(默认 2batch_interval_seconds(默认 300failure_policyhalt_batch / continue)、health_gate_configjsonb 阈值覆盖)。

批后健康门控error_rate_5xx_5m < 0.5%p95_latency_5m < 2000mscelery_queue_pending < 1000sentry_new_issues_5m < 5migrated_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_logplatform_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
# 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 404if $host 规则)
Session 超过 30 min 无活动后访问 expires_at 超时中间件拒绝302 → 登录页
connection.schema_name != 'public' 下访问 中间件拒绝302 → 登录页

7.1 认证与会话

要求
独立 Auth Backend PlatformAdminBackend,校验 platform_admins.password_hashArgon2id登录成功后写 admin_sessions
不复用 auth.User 租户业务用 apps.account.User(租户 schema平台后台完全独立
Session 超时 滚动续 30 minAdminSessionMiddleware 每次请求把 expires_at = NOW() + 30min
Cookie SecureHttpOnlySameSite=StrictCookie domain 限定 admin.fonrey.com(不允许跨子域,与租户业务 Cookie 物理分离)
CSRF 所有写操作启用HTMX 通过 hx-headers='{"X-CSRFToken": "..."}' 在 base 模板注入
MFA 强制 platform_admins.mfa_enabled=False 时除 MfaSetupView 外所有视图 302 强制跳设置
TOTP 密钥 admin_mfa_devices.totp_secret AES-256-GCM 加密存储,密钥来自环境变量 ADMIN_MFA_KEY,不与租户加密密钥共用
高危操作 硬删除/恢复/升级/回滚/客户端发布/下线/强制更新推送必须 MFA step-up5 min 时效)
暴力破解防护 登录失败 5 次锁账号 15 minpub:login:fail:{username});同 IP 失败 20 次锁 IP 1h

7.2 IP 白名单

  • IpWhitelistMiddleware 仅在 request.host in ADMIN_CONSOLE_HOSTS 时启用。
  • ip_whitelistRedis 缓存 pub:ipwl:active);未命中返回 403 静态页(不暴露后台存在)。
  • Nginx 层 allow / deny + 应用层中间件双重保险。

7.3 审计不可变

  • DB triggerBEFORE 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=truenodeIntegration=falsesandbox=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-inlineHTMX/Alpine 已用 attribute 模式)。
  • 跨域名严禁串台:租户 host 上访问 /admin/... 必须 404管理 host 上访问租户 URL 必须 404。由 IpWhitelistMiddleware + URLConf 双重保证。
  • Django Admin 全环境弃用(详见 §2.4)。

8. 监控集成

维度 实现
Grafana 嵌入 MonitoringView 渲染含 Grafana iframe 的页面URL 含短期签名 token避免暴露 Grafana 公网入口
告警接收 Grafana → Webhook → GrafanaWebhookViewHMAC 签名校验)
Sentry 独立 DSN与租户业务分离
Celery 队列健康 Flower 部署在 admin.fonrey.com/flower/,仅超管可访问
审计告警 任意 result='FAILED' 的高危操作HARD_DELETE / RESTORE / UPGRADE / ROLLBACK / FORCE_UPDATE_PUSH实时推送企业微信 + 邮件
客户端发布告警 发布失败 / SHA-256 校验失败 / 客户端版本分布异常波动24h 内某版本占比骤降 > 30%)实时告警

监控数据采集来源PRD §5.1.5CPU/内存来自 Prometheus node_exporter存储/API/活跃数来自应用埋点写 PostgreSQL tenant_metrics_daily 物化视图(属租户业务模块范畴,本模块仅消费)。


9. 测试规范

9.1 覆盖矩阵

类别 工具 必测内容
Model pytest-django + factory_boy UUID 默认值、状态机 CHECK、唯一索引schema_name / email / (platform,arch,status='published') 单一约束、append-only Manager、软删除标记
Service pytest-django + Mock R2 / Mock Celery tenant_service.provision() 失败回滚、audit_service.write_audit() 字段完整、状态机非法迁移抛错、release_service.publish() 单一 published 原子切换
ViewHTTP django_tenants.test.client.TenantClient(公共 schema+ HTTP_HOST='admin.fonrey.com' 三角色 × 关键端点的 200/403/401未登录三场景MFA step-up 拦截CSRF
ViewHTMX 同上 + HTTP_HX_REQUEST='true' 验证返回 partial不含 <html> 根标签),且响应头包含约定的 HX-Trigger
客户端 API Client(HTTP_HOST='app.fonrey.com') /api/release/v1/updates/latest/ 公开访问;/api/release/v1/heartbeats/ 设备票据校验;版本分布 Session
Middleware pytest-django IP 白名单命中/未命中Session 滚动续期;过期 302fail-closed
Celery 任务 CELERY_TASK_ALWAYS_EAGER=True + R2/邮件 Mock 主流程 + 失败回滚 + 重试次数 + 不重试任务的 acks_late 行为
安全回归 集成测试 跨域名(*.fonrey.com host 访问 /admin/... → 404租户用户身份不能登入管理后台管理后台 Cookie 不能在租户域名下生效;'django.contrib.admin' not in settings.INSTALLED_APPS 断言CI grep 守门

9.2 关键测试约束AGENTS.md §6

  • 禁止使用 Django 原生 Client(),统一使用 TenantClient
  • 所有受角色保护的 View 必须覆盖超级200/204、运营200 或 403、审计员GET 200 / 非 GET 403、未登录302 → /admin/login/)。
  • 高危操作测试必须包含 MFA step-up 已通过 / 未通过两个分支。
  • platform_audit_logs 测试:写操作后断言审计行存在;尝试 UPDATE / DELETE 必须抛 IntegrityErrorManager + DB trigger 双重保险)。
  • 客户端 API 集成测试SHA-256 校验失败禁止安装;强制更新分支;启动 + 4h 检测周期;失败可恢复。
  • Celery 异步测试覆盖率:tasks/ ≥ 85%services/ ≥ 90%views/ ≥ 75%。

9.3 测试文件路径

  • tests/integration/admin_console/test_us_admin_console.py(租户/备份/导出/升级/审计/管理员设置)
  • tests/integration/release/test_us_release.py(客户端发布管理 + 客户端运行时 API + Heartbeat

9.4 测试映射(与 PRD Story 对齐)

PRD Persona / Story 测试关注点
Persona A — 运营人员 Lily 租户 CRUD、挂起/恢复、备份触发、导出
Persona B — David系统升级 A/B/C 三类升级、灰度名单可填性、健康门控、回滚
Persona C — David客户端发布 版本创建/发布/下线/回滚、单一 published 约束、强制更新推送、SHA-256 校验
Persona D — 审计员 Carol 审计日志只读、导出 CSV、非 GET → 403
客户端 HeartbeatPRD 5.5.5 + ADR-20260430-007 Upsert 幂等、24h 活跃口径、租户活跃榜排序
客户端自动升级 启动 + 4h 检测、普通/强制分支、失败可恢复、SHA-256 失败禁止安装

10. 部署规范

配置
域名 admin.fonrey.com 解析到与租户应用相同的 Gunicorn/Uvicorn 集群(共用进程,按 Host 路由)
客户端下载域名 download.fonrey.comCloudflare CDN + R2 origin
Nginx server_name admin.fonrey.com 单独 server block① IP 白名单 allow / deny(与应用层双重保险)② client_max_body_size 600M 仅限 /admin/system/versions/upgrade//admin/client-releases/new/;其他端点 10M
Celery workeradmin_ops 独立部署 worker--concurrency=2 --max-tasks-per-child=50(任务多为 IO 密集长任务)
Celery workermigration 独立部署 worker--concurrency=2 --prefetch-multiplier=1(避免并发 migrate 打爆连接池)
Celery beat 单实例运行;调度任务:auto_resume_suspended / purge_pending_delete / cleanup_old_backups / expire_export_links / aggregate_dashboard_stats / aggregate_release_metrics / cleanup_admin_sessions
密钥管理 ADMIN_MFA_KEY / R2_ADMIN_KEY / GRAFANA_SIGN_KEY / EV_SIGN_KEY 通过 Docker Secret 注入;不出现在 .env 文件
日志 Web 访问日志 / 审计日志 / Sentry 三路独立审计日志同时落库DB+ 落对象存储R2 月归档)
备份的备份 备份元数据(backup_records)随 public schema 每日 02:00 全量 dump 到独立 R2 桶 meta-backups,灾难场景下用于重建
客户端 EV 签名 EV 证书私钥仅在 CI 密钥库签名步骤electron-builder 输出 → CI 调用 signtool.exe → 上传 R2

11. 落地顺序建议

  1. apps/admin_console 模型/服务/Mixin 基础骨架 + 独立认证 + MFA 设置 + 仪表盘空壳(页面 12
  2. 租户管理(页面 310+ 审计基础 + IP 白名单(页面 16 子集)。
  3. 备份 + 导出 + 监控(页面 12 / 13
  4. 系统升级(页面 11 + Feature Flag
  5. apps/release 后台 UI页面 14+ 客户端运行时 API/api/release/v1/updates/latest//heartbeats/)。
  6. Electron 壳应用最小可运行版 + electron-updater 接入 + EV 签名 CI。
  7. 官网下载页(公司域名)+ 便携版 ZIP。
  8. 完整审计页面 + 平台管理员设置(页面 15 / 16 全集)。

12. 文档同步规则

  • PRD 平台管理后台变更:同步本文件。
  • tenants / platform_admins / client_releases / client_heartbeats / upgrade_events / feature_flag_* 字段变更:同步 DATA_MODEL_PUBLIC.md
  • 枚举值变更:同步 DATA_MODEL/ENUMS.md(含中文标签)。
  • API 包络/错误契约变更:同步 TECH_STACK/API_CONTRACT.md
  • 测试用例新增:同步 TECH_STACK/测试规范.mdTEST_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 类升级 ADRADR-20260430-007 范围内),不另列。
TS-PA-03 🟠 Major 旧 PRD 的反向引用README、其他 TECH_STACK、ADR 早期条目尚未全量清理35+ 处);按用户决定本轮不处理,后续单独发起治理。