feat(docs): 添加 V2 题库与原版 tiku-web 查询架构对比文档
Some checks failed
ci / release-gate (push) Has been cancelled

This commit is contained in:
2026-08-06 10:56:18 +08:00
parent 42da977655
commit b3b8fbd2b9

View File

@@ -0,0 +1,406 @@
# V2 题库与原版 tiku-web 查询架构对比
- 文档状态Current Comparison
- 对比日期2026-08-06
- 新系统:`tiku-backend.net` Content V2
- 原版系统:`tiku-web`PocketBase历史源码仓库目录名仍为 `tiki-web`
## 文档目的
本文解释原版 `tiku-web` 和当前 V2 题库在内容组织、章节查询、随机取题、访问授权、版本稳定性和判分方式上的
差异,帮助后续开发迁移原版交互时保留用户体验,但不照搬旧的数据访问方式。
对比基于当前本地代码快照。原版后来已经从 `schools / majors / subjects / categories` 多表结构逐步迁移到
`module_nodes` 统一树,因此不能简单概括为“旧系统没有树”。两者真正的差别是:
> 原版使用树定位后直接查询实时题目V2 使用树编辑内容,发布后查询不可变候选投影。
## 一张图看懂主要区别
```mermaid
flowchart LR
subgraph Legacy["原版 tiku-web实时树 + 实时题目"]
direction TB
L1["选择地区 regions"]
L2["查询入口 region_modules"]
L3["读取 module_nodes\n前端构造树"]
L4["选择科目 / 章节"]
L5["直接查询 questions\nsubjectId / nodeId / categoryId"]
L6["题目和答案返回前端"]
L7["前端排序、随机和判分"]
L1 --> L2 --> L3 --> L4 --> L5 --> L6 --> L7
end
subgraph V2["当前 V2编辑树 + 发布投影"]
direction TB
V1["编辑 CurriculumNode 树"]
V2P["QuestionPlacement\n题目投放到节点"]
V3["ContentReleaseCompiler\n发布时计算 Profile 和版本"]
V4["ContentReleaseQuestion\n扁平不可变候选"]
V5["EffectiveAccessProjection\n学生有效访问快照"]
V6["服务端稳定取题并创建会话"]
V7["服务端按会话快照判分"]
V1 --> V2P --> V3 --> V4 --> V6 --> V7
V5 --> V6
end
```
## 使用同一个场景比较
场景:学生点击“高等数学 → 第二章导数 → 导数计算”,开始随机刷题。
### 原版 tiku-web
```mermaid
sequenceDiagram
autonumber
participant U as "学生"
participant UI as "React 页面"
participant PB as "PocketBase"
U->>UI: 进入高等数学
UI->>PB: 查询 module_nodesparentId = 科目 ID
PB-->>UI: 返回章节和试卷
U->>UI: 点击“导数计算”
UI->>PB: 查询 questionsnodeId = 章节 ID OR categoryId = 章节 ID
PB-->>UI: 返回该章节全部题目及答案字段
UI->>UI: 随机模式在浏览器打乱题目
UI->>UI: 用户答题后在前端比较正确答案
UI->>PB: 保存进度、答题记录并递增额度
```
原版章节查询的实际特点:
- 优先从 `module_nodes` 读取章节;如果没有数据,会回退旧 `categories` 表;
- 章节查询兼容 `questions.nodeId` 和旧 `questions.categoryId`
- 选择章节后一次加载该章节全部题目,代码假设“章节题量可控”;
- 随机模式使用浏览器数组打乱;
- 正确答案随题目进入前端,由前端计算客观题是否正确。
### 当前 V2
```mermaid
sequenceDiagram
autonumber
participant U as "学生"
participant API as ".NET 学习 API"
participant A as "有效访问服务"
participant R as "候选读取器"
participant DB as "PostgreSQL"
U->>API: 请求创建“导数计算”练习会话
API->>DB: 由 ProductManifestResource 确认资源和业务线
API->>A: 获取学生的 Release / Segment / Manifest 快照
A-->>API: 返回有效访问投影和版本号
API->>DB: 验证个人权益或班级 Grant 覆盖该资源
API->>R: ReleaseIds + SegmentIds + CurriculumNodeId + 稳定种子
R->>DB: 过滤 ContentReleaseQuestion
DB-->>R: 返回已发布扁平候选
API->>DB: 批量加载锁定 Revision 和评分政策
API->>DB: 原子预留题量并创建 PracticeSessionQuestion 快照
API-->>U: 返回不含答案和解析的会话题目
U->>API: 提交答案
API->>API: 按会话内评分快照判分
```
V2 不从当前题目表临时寻找“这个章节现在有哪些题”,而是从已经发布的候选中选择“这个学生当前有权访问的
题目版本”。
## 核心差异表
| 对比项 | 原版 tiku-web | 当前 V2 |
| --- | --- | --- |
| 基本模式 | 实时业务表查询 | 事实模型 + 发布编译 + 在线投影 |
| 树的用途 | 导航、编辑和在线定位都使用 | 主要用于内容编辑和 Placement在线读取发布候选 |
| 章节来源 | `module_nodes` 直接子节点,必要时回退 `categories` | `CurriculumNode` 版本化大纲 |
| 题目关联 | 题目直接保存 `subjectId/nodeId/categoryId` | 规范题目独立,通过 `QuestionPlacement` 放入节点 |
| 题目查询 | 直接过滤实时 `questions` | 过滤 `ContentReleaseQuestion` |
| 题目版本 | 读取当前题目记录 | 锁定不可变 `QuestionRevision` |
| 地区和目标 | 页面路径、用户地区、节点和 SVIP 地区共同影响 | Profile 规则在发布时编译成 `AudienceSegment` |
| 内容授权 | 刷题前检查用户、SVIP 地区和免费额度 | Manifest、个人权益、班级 Grant、目标和 Release 共同校验 |
| 科目级随机 | 先取得全部题目 ID浏览器打乱再分批加载 | 服务端按稳定种子、Ordinal 起点和区间回绕取题 |
| 章节级加载 | 一次返回章节全部题目 | 当前创建会话最多选择 100 道候选 |
| 答案保护 | 正确答案进入前端 | 活跃会话不返回答案和解析 |
| 判分位置 | 客观题主要由前端比较答案 | 服务端按 `GradingRulesSnapshot` 判分 |
| 历史稳定性 | 修改实时题目会影响之后重新加载 | 已开始会话锁定 Revision、Placement 和评分证据 |
| 内容生效 | 修改记录后基本立即影响新查询 | 必须发布新 Release 才影响新会话 |
| 共享题目 | 共享科目节点通过 `metadata.sharedFromId` 透传源科目 | 显式记录题目所有者、Asset、Revision 和 Placement |
| 兼容成本 | 新旧节点和题目字段并存,包含多处回退 | V2 主链路不依赖旧 `QuestionBankId`、地区字段或 `ContentSlice` |
## 树形结构的区别
### 原版:树本身也是查询入口
原版会读取某个 `moduleId` 下全部 `module_nodes`,在前端递归组装树;进入科目后,再通过 `parentId` 查询章节。
```text
module_nodes
├─ moduleId属于哪个入口
├─ parentId父节点
├─ typecategory / subject / chapter / paper
├─ name
└─ order
```
题目表同时保留:
```text
questions
├─ subjectId
├─ nodeId 新树节点
└─ categoryId 旧章节兼容字段
```
这种方式直观,但树迁移不完整时,查询代码需要不断兼容新旧 ID。例如章节查询使用
```text
nodeId = 章节 ID OR categoryId = 章节 ID
```
### V2树是编辑事实Release 是查询事实
V2 使用两个结构维护大纲:
```text
CurriculumNode.ParentId
保存直接父子关系,用于编辑和还原树
CurriculumNodeClosure
保存祖先、后代和 Depth用于批量查询子树
```
题目本身不保存章节。`QuestionPlacement` 记录某道题在某版大纲中的位置,发布后再生成:
```text
ContentReleaseQuestion
├─ ContentReleaseId
├─ AudienceSegmentId
├─ CurriculumNodeId
├─ QuestionAssetId
├─ QuestionRevisionId
├─ QuestionPlacementId
├─ AssessmentPolicyVersionId
└─ Ordinal
```
因此修改大纲树不会直接改写规范题目,也不会改变已经发布或已经开始的练习。
## 随机取题和数据量差异
### 原版科目级随机
```text
按 subjectId 分页读取全部题目 ID
→ 每页最多 500 个
→ 浏览器持有完整 ID 数组
→ 使用数组随机排序
→ 先加载第一批,接近末尾再加载下一批
```
这个方案在题量不大时简单有效,但科目包含大量题目时会产生:
- 为一次练习传输大量 ID
- 浏览器内存和打乱成本随题量增长;
- 每个客户端重复做相同的全集读取;
- 内容实时变化时ID 集合与后续题目读取可能处于不同时间点。
### V2 稳定候选读取
```text
确定学生可访问的 Release 和 Segment
→ 数据库按 Release / Segment / Node / Ordinal / ID 排序
→ 根据 UserId + ResourceType + ResourceId 计算稳定起点
→ 从起点读取并在末尾回绕
→ 只返回本次会话需要的候选
```
V2 当前不是全局意义上的随机抽样算法,而是可重复、可审计的稳定轮转。它避免 `ORDER BY random()` 和完整 ID
下发但真实大规模性能仍需用代表性数据验证索引、候选基数、p95/p99 和连接池压力。
## 授权模型的区别
### 原版:先判断“能不能刷题”
原版 `quiz_validation.pb.js` 主要检查:
- 是否登录;
- 是否管理员;
- 当前地区是否有有效 SVIP
- 免费题量是否耗尽。
然后页面再按 `subjectId``nodeId` 查询题目。授权判断和具体内容集合之间相对独立,回答的是:
> 这个用户现在能不能继续刷题?
### V2判断“为什么能访问这个具体版本”
V2 创建会话时同时验证:
```text
租户有没有该业务许可
+ 学生选择的目标是否被租户许可
+ 商品 Manifest 是否允许该目标和资源
+ 学生个人权益或班级 Grant 是否有效
+ Release 是否已发布且属于相同业务线
+ 当前 Profile 是否命中 Audience Segment
```
它回答的是:
> 这个租户的这个学生,基于哪个权益,为什么能访问这个资源和这个内容版本?
这也是 V2 模型比原版复杂的主要原因。
## 判分和历史数据的区别
### 原版
原版题目模型把正确选项和参考答案返回给页面。页面从当前题目中读取正确答案,比较用户选择,随后保存答题记录、
错题和进度。
优点是交互简单、响应快;风险是:
- 客户端可以看到正确答案;
- 判分逻辑分散在前端;
- 题目答案修改后,历史练习缺少完整的版本和评分证据;
- 多端实现容易出现不同判分结果。
### V2
V2 创建会话时把题目 Revision、Placement、评分政策和 `GradingRulesSnapshot` 锁入
`PracticeSessionQuestion`。活跃会话不返回答案和解析,答案提交到服务端后统一评分。
```mermaid
flowchart LR
START["学生开始练习\n锁定 Revision 1 + Policy 1"]
EDIT["管理员发布\nRevision 2 + Policy 2"]
ANSWER["学生提交旧会话答案"]
SCORE["仍按 Revision 1\n和 Policy 1 判分"]
START --> ANSWER --> SCORE
EDIT -.->|"不能改变已开始会话"| SCORE
```
## 共享题目的区别
### 原版共享科目
原版通过 `module_nodes.metadata.sharedFromId` 表达共享科目节点。查询共享节点时,前端服务先解析真实源科目 ID
再查询源科目的章节和题目。
这种方式共享的是“科目查询入口”,题目身份仍与源科目和实时题目记录紧密相关。
### V2 共享规范题目
V2 共享的是 `QuestionAsset` 和不可变 `QuestionRevision`
```mermaid
flowchart LR
Q["平台公共 QuestionAsset\n同一道规范题目"]
A["租户 A Placement\n自己的大纲和目标规则"]
B["租户 B Placement\n自己的大纲和目标规则"]
RA["租户 A Release"]
RB["租户 B Release"]
Q --> A --> RA
Q --> B --> RB
```
地区、院校、章节和套餐不会决定题目身份。同一道题可以被不同租户放入不同大纲、绑定不同适用目标和评分政策,
又不会复制题干与答案。
## 两种架构各自适合什么场景
### 原版模式更适合
- 单一运营主体;
- 地区和商品规则简单;
- 题目规模有限;
- 内容修改需要立即生效;
- 可以接受前端承担随机、判分和进度逻辑;
- 团队更重视快速交付而不是版本审计。
原版的优势是实体少、理解成本低、CRUD 开发快。对于小规模系统,它并不是错误设计。
### V2 模式更适合
- 多租户 SaaS
- 平台公共题和租户私题混合;
- 同一道题需要跨地区、院校或业务复用;
- 商品、班级和学生目标共同影响访问范围;
- 已开始会话必须保持题目和评分稳定;
- 服务端可信判分、幂等答题和审计证据是硬要求;
- 在线高并发路径不能遍历完整权益和内容关系链。
V2 的代价是发布流程、版本状态、缓存失效和管理端建设更复杂。它不是为了让简单查询“看起来高级”,而是为了
把原版依靠页面约定维持的规则变成可验证的后端不变量。
## 当前共同限制
原版和当前 V2 按章节查询都默认精确节点匹配:
```text
请求父章节
自动包含所有子章节题目
```
原版直接按选中 `nodeId/categoryId` 查询V2 当前按 `CurriculumNodeId` 精确过滤发布候选。因此 V2 的提升不是
“自动递归子树”,而是查询对象从实时题目变成了带版本、人群、授权和评分证据的发布候选。
如果 V2 产品要求父章节覆盖整个子树,仍需选择:发布时预展开、闭包表批量过滤或发布章节题集。
## 迁移原则
从原版迁移到 SaaS 前端时,应保留:
- 地区、入口、科目、章节的渐进式选择体验;
- 顺序、随机、章节、试卷、错题和收藏等用户心智;
- Markdown、数学公式、图片和历史图形内容的安全渲染
- 最近练习、进度恢复和移动端交互。
不应照搬:
- 浏览器直接访问 PocketBase Collection
- `MockBackend` 聚合业务规则;
- `subjectId/nodeId/categoryId` 新旧字段回退;
- 把全部题目 ID 下发浏览器后随机;
- 客户端持有正确答案并作为权威判分方;
- 仅凭当前地区 SVIP 判断具体内容授权;
- 修改实时题目直接影响后续练习。
迁移目标应是:
> 保留原版成熟的业务交互,重建为 Host 租户解析、真实 .NET API、发布版本、后端授权和服务端可信学习记录。
## 源码证据入口
### 原版 tiku-web
| 关注点 | 文件与位置 |
| --- | --- |
| 读取整个模块节点并在前端构树 | `src/services/mockBackend.ts``getModuleTree` |
| 查询科目直接子节点并回退旧分类 | `src/services/mockBackend.ts``getCategories` |
| 按章节直接查询实时题目 | `src/services/mockBackend.ts``getQuestions` |
| 科目级先取全部 ID 再批量加载 | `src/services/mockBackend.ts``getQuestionIds``getQuestionsByIds` |
| 页面选择章节、随机和懒加载 | `src/pages/SubjectDetail.tsx``src/pages/Quiz.tsx` |
| 前端客观题判分 | `src/pages/Quiz.tsx``submitChoice` |
| 登录、SVIP 地区和免费额度检查 | `pb_hooks/quiz_validation.pb.js` |
| PocketBase 题目 ID/count 接口 | `pb_hooks/questions_api.pb.js` |
### 当前 tiku-backend.net V2
| 关注点 | 文件与位置 |
| --- | --- |
| 题目资产、Revision 和 Placement | `Tiku.Domain/Content/ContentV2QuestionEntities.cs` |
| 大纲树和闭包表 | `Tiku.Domain/Content/ContentV2CurriculumEntities.cs` |
| 目标 Profile 和业务策略 | `Tiku.Domain/Content/ContentV2TargetEntities.cs` |
| Release、Segment 和候选 | `Tiku.Domain/Content/ContentV2ReleaseEntities.cs` |
| 导入、查重、Revision 和 Placement | `Tiku.Infrastructure/ContentV2/ContentV2AuthoringService.cs` |
| 发布编译 | `Tiku.Infrastructure/ContentV2/ContentReleaseCompiler.cs` |
| 在线候选读取 | `Tiku.Infrastructure/ContentV2/ContentCandidateReader.cs` |
| 学生访问投影 | `Tiku.Infrastructure/Learning/Access/EffectiveLearningAccessService.cs` |
| V2 会话创建和快照 | `Tiku.Infrastructure/Learning/PracticeSessions/V2PracticeSessionService.cs` |
| 服务端答题 | `Tiku.Infrastructure/Learning/Answering/AnsweringService.cs` |
V2 自身的完整架构说明见 [V2 题库架构:从租户导入到学生刷题](content-domain-v2.md)。