feat(docs): 添加 V2 题库与原版 tiku-web 查询架构对比文档
Some checks failed
ci / release-gate (push) Has been cancelled
Some checks failed
ci / release-gate (push) Has been cancelled
This commit is contained in:
406
docs/architecture/content-domain-v2-vs-legacy-tiku-web.md
Normal file
406
docs/architecture/content-domain-v2-vs-legacy-tiku-web.md
Normal 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_nodes:parentId = 科目 ID
|
||||
PB-->>UI: 返回章节和试卷
|
||||
U->>UI: 点击“导数计算”
|
||||
UI->>PB: 查询 questions:nodeId = 章节 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:父节点
|
||||
├─ type:category / 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)。
|
||||
Reference in New Issue
Block a user