diff --git a/docs/architecture/content-domain-v2-vs-legacy-tiku-web.md b/docs/architecture/content-domain-v2-vs-legacy-tiku-web.md new file mode 100644 index 0000000..48a6fbc --- /dev/null +++ b/docs/architecture/content-domain-v2-vs-legacy-tiku-web.md @@ -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)。