Files
tiku-backend.net/docs/migration/phase-3-tenant-isolation-and-shared-question-bank.md

220 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 第三阶段:强租户隔离、共享题库与租户前端运行时
状态:已实施并通过真实 PostgreSQL 验收2026-07-27
本阶段按 greenfield 项目实施,不兼容现有错误模型,也不承担正式业务数据迁移。阶段目标不是简单给现有查询补 `TenantId`而是同时建立请求、EF Core、写入和 PostgreSQL 四层租户边界,并让公共题库能够完整参与租户学生的组卷、答题、收藏和错题闭环。
原先预计的 35 个工作日只覆盖基础租户过滤。加入共享题库、版本锁定、域名入口和租户前端运行时后,完整阶段预计 1014 个工作日。
## 已确认的产品规则
- 系统只存在一个 `TenantMode.PlatformOwned` 平台主体,平台主体拥有全部公共题库。
- `Trial``Active` 且未过期的租户自动拥有全部公共题库访问权,不逐库授权。
- 租户可以上传自己的私有题库,私有题只对本租户学生和管理员可见。
- 公共题和租户私有题可以混合进入同一试卷、章节练习或题单。
- 平台分类是公共主干,租户可以在公共节点下增加仅本租户可见的扩展节点。
- 新练习使用题目当前已发布版本;已开始练习锁定原版本,历史答题永久按原版本回放。
- `PastDue``Cancelled` 租户不能浏览公共题答案或创建新的公共题练习;未过期的既有会话可以完成,历史答题、收藏、错题和统计继续可见。
- 租户自定义域名通过 CNAME 指向统一前端和网关,浏览器使用同域 `/api` 访问后端。
- 第一版前端配置覆盖品牌、主题令牌、功能开关、导航和首页模块,不实现任意低代码页面或脚本注入。
- 本阶段不引入 PostgreSQL RLS。隔离由请求上下文、EF Core Query Filter、写入拦截器、数据库约束和真实 PostgreSQL 测试共同保证。
## 3A请求租户与持久化边界
### 请求上下文
使用只读 `ITenantContext` 替换可由业务代码调用 `Load``ICurrentTenant`。公开属性固定为:
- `TenantId`
- `TenantCode`
- `ResolutionSource`
- `IsResolved`
- `IsSystem`
只有 API 入口的内部初始化器可以设置请求租户。普通 Controller 和 Service 只能读取上下文,不能切换租户。
平台任务、Worker 和公共题库基础设施通过 `ITenantExecutionScope` 创建新的 DI scope。System scope 必须提供调用方和原因,记录结构化审计;不允许在现有请求 scope 内临时改写租户。
### EF Core 防护
- 生产和测试均从 `AddDbContextPool<TikuDbContext>` 改为 scoped `AddDbContext<TikuDbContext>`
- 所有租户实体实现统一 marker interface。
- 全局 Query Filter 自动添加当前 `TenantId`;未解析租户时查询默认返回空集合。
- 普通租户 Query Filter 永远不自动包含平台主体数据,公共内容只能通过专用服务访问。
- `SaveChangesInterceptor` 对 Added 自动写入当前租户,并拒绝伪造租户、跨租户修改或删除以及修改已有记录的租户归属。
- 未解析租户的普通写入直接失败System scope 也不能把已有记录改归其他租户。
模型启动审计必须拒绝以下情况:
- 实体存在 `TenantId` 却没有明确分类。
- 租户范围内的唯一索引未包含 `TenantId`
- 租户实体之间的外键未包含组合租户键。
- 新增租户实体却没有对应 Query Filter。
普通业务代码禁止调用 `IgnoreQueryFilters()``FromSql``ExecuteSql` 或直接创建 `NpgsqlCommand`。确有需要的实现只能位于集中基础设施边界,并由架构测试维护允许列表。
## 3B域名入口与租户解析
租户解析必须在认证和任何租户数据库查询之前完成:
1. 网关移除客户端提供的 `Forwarded``X-Forwarded-Host` 等头。
2. 只接收受信代理重写的 Host 信息。
3. `TenantResolutionMiddleware` 通过只读 `ITenantDirectory` 查询 Active 域名和 Active 租户。
4. 未知、未验证、已禁用域名在认证前返回 404。
5. JWT tenant claim 与 Host 解析结果不一致时返回 403。
自定义域名请求不得通过 `host``tenantCode` 查询参数或 `x-tenant-code` 覆盖租户。显式 tenant code 只允许平台控制域名上的登录引导接口使用,多来源冲突必须拒绝。
域名生命周期固定为:
1. 创建 Pending 域名并生成验证令牌。
2. 租户设置 CNAME 和 TXT 所有权记录。
3. Worker 验证 DNS。
4. 网关完成 TLS 配置。
5. 域名变为 Active 后才参与请求解析。
`TenantDomain.Host` 全局唯一;每个租户最多一个 Active 主域名。解析结果使用短 TTL 缓存,域名或租户状态变化时主动失效。
## 3C共享题库、租户私库与学习闭环
### 内容所有权
- 公共题库、题目和题目版本的 `TenantId` 为平台主体 ID。
- 私有题库、题目和题目版本的 `TenantId` 为上传租户 ID。
- 删除容易与真实所有权冲突的 `QuestionBank.SourceScope`
- 删除 `QuestionBankGrant``TenantQuestionBankAdoption` 作为访问控制主模型。
- 新增 `TenantQuestionBankPreference`,只保存租户对公共题库的显示/隐藏、别名、排序和导航位置。
- `IPublicQuestionAccessPolicy` 根据租户状态和订阅状态统一判断能否开始新的公共题练习。
数据库对平台主体增加唯一约束,保证只能存在一个 `PlatformOwned` 租户。
### 公共分类主干与租户扩展
分类节点具有明确的 `OwnerTenantId`。父节点引用保存 `ParentOwnerTenantId + ParentId`
- 平台节点只能组成公共主干。
- 租户扩展节点可以挂到平台节点或本租户节点。
- 不允许租户 A 的节点挂到租户 B 的节点。
- 公共题只能关联平台分类。
- 租户私题可以关联平台分类或本租户扩展分类。
所有分类、题库和题目关联使用包含所有者 ID 的组合外键,并通过 PostgreSQL 约束或集中触发器验证“平台或本租户”规则。
### 受控题目引用
新增 `TenantQuestionReference`
- `TenantId`:消费题目的租户。
- `QuestionOwnerTenantId`:平台主体或当前租户。
- `QuestionId`:实际题目。
三列建立唯一约束和组合外键。引用按需创建,不预生成“所有租户 × 全部公共题”。数据库只允许当前租户引用平台公共题或本租户私题,永远拒绝引用其他租户私题。
公开查询返回 `QuestionLocator { source, questionId }`,其中 `source` 只允许 `platform``tenant`。客户端不能提交任意 owner tenant ID写操作由专用服务把 Locator 解析为受控题目引用。
### 组卷、练习和历史版本
- `QuestionCollectionItem` 改为引用 `TenantQuestionReference`,允许公共题和私题混合组卷。
- 新增规范化 `PracticeSessionQuestion`,删除 `PracticeSession.QuestionIds` JSON。
- SessionQuestion 保存题目顺序、分值、题目所有者、题目 ID 和创建会话时锁定的版本 ID。
- 答题接口只接受 `sessionQuestionId`,不再接受裸 `QuestionId`;单题练习也创建轻量会话。
- `AnswerRecord` 关联 SessionQuestion。
- `FavoriteQuestion``WrongQuestion` 关联受控题目引用,不再假设学习租户和题目所有者相同。
- 旧题目版本只能归档,不能物理删除。
## 3D租户前端运行时配置
整合现有 Branding、Theme 和 PublicConfig形成版本化 `TenantFrontendConfig`,配置范围包括:
- 品牌名称、Logo、Favicon 和客服信息。
- 颜色、字体、间距和圆角等主题令牌。
- 学生端功能开关。
- 导航菜单。
- 首页模块及排序。
配置采用 Draft → Preview → Publish。发布使用乐观并发版本号服务端在发布前验证结构不接受任意 HTML、JavaScript、外部脚本或管理端功能开关进入公开配置。
新增 `GET /api/runtime/bootstrap`
- 只根据当前可信 Host 返回配置。
- 返回 `schemaVersion``configVersion`、租户代码、品牌、主题、功能、导航和首页模块。
- 不返回内部租户 ID、密钥或管理端配置。
- 支持 ETag / 304。
- 配置发布、域名停用或租户暂停时主动失效缓存。
现有 `tenant/resolve?host=` 不再作为自定义域名的公开切租户入口,只保留给受控平台登录引导流程。
## 3E重建迁移与验收
项目尚无正式业务数据,因此不编写兼容迁移或双写逻辑:
- 完成模型重构后删除现有 Migration 历史和 Snapshot。
- 重新生成单一 Initial Schema。
- 重建本地开发数据库。
- 使用 `Tiku.DbMigrator` 从空 PostgreSQL 执行完整迁移。
- API 和 Worker 继续禁止启动时自动执行 Migration。
数据库必须落地以下硬约束:
- 唯一平台主体。
- 域名全局唯一。
- 租户唯一索引包含租户键。
- 题目、版本和分类使用所有者组合键。
- 题目引用只能指向平台或本租户。
- SessionQuestion 的版本属于对应题目。
- 学习记录与当前租户、学生和会话一致。
### EF Core 与 PostgreSQL 约束分工
EF Core 负责实体、Fluent Configuration、Migration 生成和迁移执行入口,迁移仍然是 code-first 管理,不允许去生产库手工补结构。
但以下跨表、跨租户不变量不能指望 ORM 自动推导:
- `TenantQuestionReference` 只能引用平台主体公共题或当前租户私题,不能引用其他租户私题。
- `TaxonomyNode` 的父节点只能属于平台主体或当前租户,不能挂到其他租户节点。
- 需要读取 `tenants.mode` 或比较多列 owner 关系的规则。
原因是 PostgreSQL `CHECK` 不能跨表查询,普通 FK 只能证明目标记录存在,不能表达“目标 owner 必须是平台主体或本租户”。这类规则必须落到 PostgreSQL trigger / constraint trigger。
实现要求:
- 触发器 SQL 必须集中在基础设施层,例如 `Tiku.Infrastructure/Persistence/PostgreSqlTenantConstraintSql.cs`
- Migration 只调用集中 SQL helper例如 `migrationBuilder.Sql(PostgreSqlTenantConstraintSql.CreateTenantQuestionReferenceGuard)`
- 不允许把触发器 SQL 零散复制到多个 migration。
- 每个触发器必须有真实 PostgreSQL 集成测试覆盖允许路径和拒绝路径。
- 如果将来新增类似“平台或本租户”的 owner 规则,优先补集中 SQL helper 和模型/集成测试,不要只靠 Service 手写校验。
## 测试矩阵
真实 PostgreSQL 测试创建平台主体、租户 A、租户 B 及三套内容,至少覆盖:
- A 默认不能读取、修改、删除或 Attach 伪造 B 的租户数据。
- A 可以访问公共题和 A 私题,不能构造 B 私题引用。
- 公共题和私题可以混合组卷,并完成答题、收藏、错题和统计。
- 公共题 V1 创建会话后发布 V2旧会话仍用 V1新会话使用 V2历史答案可回放。
- Trial/Active 可以创建公共题练习PastDue/Cancelled 只能完成未过期会话并查看历史。
- 域名 A 携带租户 B JWT 时被拒绝;伪造 Host、tenantCode 和转发头不能切租户。
- Pending 域名、未知域名和 Suspended 租户无法获取运行时配置。
- 配置草稿不影响线上;发布后 ETag 和缓存版本改变。
- 新增租户实体漏写 Filter、组合外键或租户索引时模型测试失败。
- 架构测试发现未经允许的 Query Filter 绕过或原生 SQL 时失败。
- 从空 PostgreSQL 执行 Initial Schema、启动 API 并通过全量测试。
## 实施顺序和退出条件
实施顺序固定为:
1. 3A只读租户上下文、Query Filter、写入拦截器。
2. 3BHost 解析、域名生命周期和 JWT 租户一致性。
3. 3C公私内容所有权、分类扩展、题目引用和学习闭环。
4. 3D前端运行时配置、发布和缓存。
5. 3E重建 Migration、真实 PostgreSQL 隔离测试和全量验证。
第三阶段只有在以下条件全部满足后才能结束:
- 新增普通租户实体即使开发者忘记手写 `TenantId` 条件也不会跨租户读取。
- API 无法通过 DTO、Host、Header、JWT 或 Attach 伪造其他租户数据。
- 公共题库和租户私库能够混合完成组卷、答题、收藏和错题闭环。
- 题目发布新版本不会改变进行中会话或历史答案。
- 租户自定义域名能够安全获得已发布的前端运行时配置。