Files
ruoyi-vue-pro/yudao-module-education/README.md

546 lines
23 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.

# yudao-module-education
教育业务模块,提供课程、练习、题库、考试等教育业务功能。
## 当前状态
此模块提供教育业务功能骨架、题库目录浏览、题目预览与练习闭环,以及 `JAVA_READ` 下的租户题目草稿、目录放置、发布和归档 tracer bullet。
**已实现**
- 模块骨架与包结构
- 能力探测端点 (`/education/capability`)
- 租户识别端点 (`/education/tenant/resolve`) — 学生端登录前使用
- 教育上下文端点 (`/education/context`) — 学生端已认证状态
- 题库目录端点 (见下方 Catalog API) — 学生端已认证
- 题目浏览与筛选端点 (见下方 Questions API) — 学生端已认证
- 练习配置预览端点 (见下方 Practice API) — 学生端已认证
- 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试
- 题目安全过滤(答案/解析绝不暴露到前端)
- 管理端题目创作、目录放置、发布和归档端点 — 仅在 `JAVA_READ` 下接受写入
- 独立的功能开关配置 + Scalar 数据源配置
- 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习)
- System RBAC 权限注解及 V4080/V4090 条件种子(`education:capability``education:question:author``education:question:classify``education:question:publish``education:question:archive`);迁移不自动向任何角色授权
## 功能配置
`application.yaml` 或对应 profile 中配置:
```yaml
yudao:
education:
enabled: true
# 题库目录与题目读取开关;关闭不会删除已有练习、报告、错题或收藏
catalog-read-enabled: true
# 练习创建、答案保存、交卷写入开关;关闭后历史会话与报告仍可读取
practice-write-enabled: true
# Pilot 灰度租户;空列表表示不限制,生产 Pilot 应显式配置目标租户 ID
pilot-tenant-ids: [1024]
# SCALAR_READ 是默认读取权威;只有 JAVA_READ 允许本地题目创作
catalog-mode: SCALAR_READ
```
`catalog-mode=SCALAR_READ` 时管理端题目写命令会在访问 Mapper 前失败关闭;要使用下述创作 API必须显式配置 `catalog-mode=JAVA_READ`,确保写入 PostgreSQL 的题目也是学生读取的数据源。
灰度与回滚约束:
- `enabled=false`:移除 Education HTTP 能力,不执行任何数据删除。
- `catalog-read-enabled=false`:停止题库数据源读取;已有会话、报告、错题和收藏仍保存在 PostgreSQL。
- `practice-write-enabled=false`:拒绝新建练习、保存答案和交卷;会话恢复、报告与历史查询保持可用。
- `pilot-tenant-ids`:非空时仅允许列表内租户使用题库和练习写入能力。
- 应用回滚只回滚应用版本或开关;不得执行 `*-rollback.sql`。SQL 回滚脚本仅用于明确的数据销毁场景。
- 回滚到 V4090 之前的应用版本前,必须先切离 `JAVA_READ` 以停止原生题目写入;没有可用替代读取权威时应关闭 Education。V4090 Schema 保留并通过后续更高版本向前修正。
## API
### 管理后台 - 能力信息
```
GET /admin-api/education/capability
```
- 权限:`education:capability`
- 响应示例:
```json
{
"code": 0,
"msg": "成功",
"data": {
"module": "education",
"enabled": true,
"version": "1.0.0",
"capabilities": ["shell", "catalog", "questions", "practice-preview"]
}
}
```
### 管理后台 - 题目创作与发布
这些端点只管理当前框架租户的 `TENANT_OWNED` 题目,不提供 PUBLIC/platform-curator 写入口。客户端不能提交 `tenantId``scope`、生命周期状态或 actor新题固定以 `DRAFT/false` 创建,放置到可用 Content Node 后才可发布,并且只允许 `DRAFT → PUBLISHED → ARCHIVED`
| 端点 | 权限 | 说明 |
|------|------|------|
| `POST /admin-api/education/questions/drafts` | `education:question:author` | 创建当前租户草稿,返回题目 ID |
| `PUT /admin-api/education/questions/{id}/placement` | `education:question:classify` | 将草稿放置或重新放置到允许的 Content Node返回放置版本 |
| `PUT /admin-api/education/questions/{id}/publish` | `education:question:publish` | 发布当前租户草稿 |
| `PUT /admin-api/education/questions/{id}/archive` | `education:question:archive` | 归档当前租户已发布题目 |
创建草稿请求示例:
```json
{
"stem": "2 + 2 = ?",
"type": "choice",
"difficulty": "easy",
"options": [
{"label": "A", "content": "4", "order": 1.0},
{"label": "B", "content": "5", "order": 2.0}
],
"correctAnswer": "A",
"explanation": "基础加法",
"analysis": null
}
```
目录放置请求示例:
```json
{
"nodeId": 200,
"expectedPlacementVersion": 0
}
```
放置目标必须是当前租户可读的 PUBLIC 或同租户 Content Node并且处于 active、非 hidden、selectable 状态。放置使用独立乐观版本;发布后不可重新放置。学生按 node 查询时会在同一条 PostgreSQL 查询中再次检查节点可用性。
`correctAnswer``explanation``analysis` 是服务端受保护内容;学生端仍只返回下文列出的安全题目投影。发布和归档采用 tenant/scope/expected-state CAS并在同一事务追加生命周期审计。`SCALAR_READ` 或其他不支持的 provider mode 返回 `1_005_003_070`,不会查询或修改题目。
| 错误码 | 说明 |
|--------|------|
| `1_005_003_070` | 当前 provider mode 不支持本地题目创作 |
| `1_005_003_071` | 题目不存在或无权管理 |
| `1_005_003_072` | 状态已变化或生命周期转换非法 |
| `1_005_003_073` | 题目内容不完整或不安全,不能发布 |
| `1_005_003_074` | 归类目标不存在或不可用 |
| `1_005_003_075` | 归类版本或题目状态已变化 |
| `1_005_003_076` | 题目尚未归类,不能发布 |
### 用户 APP - 教育租户识别
```
GET /app-api/education/tenant/resolve?hostname=school.example.com
GET /app-api/education/tenant/resolve?tenantName=demo-school
GET /app-api/education/tenant/resolve?hostname=school.example.com&tenantName=demo-school
```
- 权限:无需认证(`@PermitAll`
- 说明:通过主机名或租户名解析租户,返回学生端登录引导所需的基础字段。
hostname 和 tenantName 至少提供一个。解析规则:
1. 如果同时提供两者,它们必须解析到同一个租户,否则拒绝请求
2. hostname 经过标准化(保留端口并转为小写),先查 `EducationProperties.hostnameTenantMap` 配置映射,再按 `system_tenant.websites` 的精确 authority 值查询
- 响应示例(成功):
```json
{
"code": 0,
"msg": "成功",
"data": {
"tenantId": 1024,
"tenantName": "demo-school",
"displayName": "demo-school",
"status": "ACTIVE",
"loginMethods": ["PASSWORD", "SMS"]
}
}
```
- 错误响应:
| 错误码 | 说明 |
|--------|------|
| 1_005_001_001 | 租户不存在 |
| 1_005_001_002 | 租户已被禁用 |
| 1_005_001_003 | 租户识别失败hostname 格式不合法等) |
| 1_005_001_004 | 当前租户不可用(过期等) |
### 用户 APP - 教育当前上下文
```
GET /app-api/education/context
```
- 权限:需要认证(登录态)
- 说明:根据当前认证用户和租户上下文返回教育业务信息。不信任请求体中的 userId一切从安全上下文和 TenantContext 派生。TenantSecurityWebFilter 前置完成租户校验,此处二次验证确保租户处于活跃状态。
- 响应示例:
```json
{
"code": 0,
"msg": "成功",
"data": {
"userId": 1024,
"tenantId": 2048,
"tenantName": "demo-school",
"displayName": "demo-school"
}
}
```
### 错误码
| 错误码 | 说明 |
|--------|------|
| 1_005_001_000 | 教育模块未启用 |
| 1_005_001_001 | 租户不存在 |
| 1_005_001_002 | 租户已被禁用 |
| 1_005_001_003 | 租户识别失败:{原因} |
| 1_005_001_004 | 当前租户不可用,请联系管理员 |
## 构建与运行
### 单独编译测试
```bash
# 编译 education 模块
mvn compile -pl yudao-module-education -am
# 运行 education 模块单元测试
mvn test -pl yudao-module-education -am
```
### 整体编译(含 server
```bash
# 编译全量member + education + system + infra + server
mvn compile -pl yudao-server -am
# 打包(跳过测试加速)
mvn package -pl yudao-server -am -DskipTests
```
### 启动验证
1. 确保 `yudao.education.enabled=true`
2. 启动 `yudao-server`
3. 访问 Swagger UI 查看 `education` 分组
4. 调用 `GET /admin-api/education/capability`
## 数据库迁移
Education 运行时 Schema 仅通过模块内 PostgreSQL Flyway migration 交付:
```text
yudao-module-education/src/main/resources/db/migration/education/
```
- `V4010``V4020` 是不可变迁移历史,不得修改。
- `V4030` 创建或接管 Practice 核心闭环表,并在存在旧答案/交卷幂等表时向统一 `education_idempotency` 回填数据。
- `V4070` 对 19 条目录引用边执行历史预校验并安装写入守卫,同时将 11 张目录表的 `tenant_id/scope` 设为插入后不可变,阻止 PUBLIC→租户、跨租户以及父节点归属变更造成的非法关系。
- `V4080` 增加第 20 条目录引用边 `education_question_version.question_id → education_question.id`,并为版本表增加第 12 个 ownership/scope 守卫;版本归属必须与题目完全一致。该迁移还建立 draft-first 生命周期、不可变题目版本、同事务追加式审计及历史已发布内容的 fail-closed 预检。
- `V4090` 增加 Question Placement 乐观版本、PUBLIC 管理拒绝、发布后放置冻结、发布前可用节点约束,以及 `education:question:classify` 权限种子。
- `V4100` 增加租户 Content Node 的 `DRAFT → ACTIVE → ARCHIVED` 生命周期、统一 `authoring_version` CAS、同事务追加式审计和 entry/parent 结构约束;不写入权限或角色种子。
- V4080/V4090 在平台 `system_menu` 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。
-`education_answer_idempotency``education_submit_idempotency` 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。
- `sql/postgresql/education/` 是手工初始化/设计历史,`sql/mysql/education/` 是过时归档;两者都不是运行时交付入口。
- 禁止使用 `flyway clean``*-rollback.sql` 回退共享环境。应用回滚后如有 Schema 兼容问题,通过更高版本向前修复。
首次接管已有 PostgreSQL 平台库时使用既定的 `4009` baseline。任何已存在 Education 表的环境都必须先核对实际表结构和 `flyway_schema_history`,不能仅凭表名视为兼容,也不能伪造 V4010/V4020 执行历史。
编译后确认 migration 已打包:
```bash
mvn -pl yudao-module-education -am -DskipTests clean package
find yudao-module-education/target/classes/db/migration/education -type f -print
```
只有真实 PostgreSQL 上的 Flyway migrate、validate 和历史检查成功后,才能报告数据库迁移成功。
## 前端状态
**当前工作区未检出完整的前端源码。** `yudao-ui/yudao-ui-admin-vue3/` 仅包含部分 MES 相关文件(`src/api/mes/``src/views/mes/`),缺少 `package.json``router/``store/``config/` 等核心框架文件。
因此:
- **管理后台教育菜单项**V4080/V4090 已条件写入 Education 权限/菜单记录,但不分配角色;前端仍无路由或页面组件,因此不声明已有可操作的可见教育页面。
- **Student Web/H5 应用外壳**:前端源码不存在,无法建立。
### Student 端前端集成契约
前端就位后必须实现以下流程(不能伪造静态页面):
1. **租户识别**(登录前)
- URL: `GET /app-api/education/tenant/resolve`
- 从浏览器 `window.location.host` 获取 authority包含非默认端口传入 `hostname` 参数
- 备用:支持手动输入 `tenantName`
- 根据返回的 `loginMethods` 决定展示哪种登录方式PASSWORD/SMS
- 获得 `tenantId` 后,在后续请求中通过 `tenant-id` header 传递
2. **用户认证**(复用 Member 模块)
- 密码登录: `POST /app-api/member/auth/login`
- 短信登录: `POST /app-api/member/auth/sms-login`
- 刷新令牌: `POST /app-api/member/auth/refresh-token`
- 登出: `POST /app-api/member/auth/logout`
- 所有请求携带 `tenant-id: {tenantId}` header
3. **获取上下文**(登录后)
- URL: `GET /app-api/education/context`
- 携带有效 Bearer Token + `tenant-id` header
- 从响应获取 `userId``tenantId``tenantName` 用于页面展示
4. **跨租户防护**
- 前端不应允许用户手动切换 `tenant-id` header
- 后端通过 `TenantSecurityWebFilter` 拒绝认证用户的跨租户 header 操作
**阻塞项**:完整前端源码(含 router、store、package.json是上述前端集成的必要前提。一旦前端源码就位
1. 在 Vue3 admin 的路由中添加 `/education` 路由项,绑定 Education 菜单组件
2. 添加 `src/api/education/` API 封装层(调用上述教育端点)
3. Student Web/H5 端如需要独立入口,需新建对应前端项目
### 用户 APP - 题库目录Catalog
所有端点需要学生登录态Bearer Token`userId``tenantId` 由安全上下文派生,不接受客户端传参。
Scalar 代理层自动注入 `x-tenant-id` header来自 `TenantContextHolder`),前端不发起任何直达 Scalar 的请求。
#### 架构边界
```
Browser → Controller(/education/catalog/*) → CatalogService → CatalogProvider → [Scalar]
↑ 内部 DTO/VO ↑ Scalar DTO 仅此层
```
- **业务层**Controller/Service仅操作内部 Catalog VO`CatalogRegionRespVO` 等)
- **集成层**Scalar DTO + ScalarCatalogProvider封装 Scalar 协议差异DTO 不泄露到上层
#### 端点列表
| 端点 | 说明 | 参数 |
|------|------|------|
| `GET /app-api/education/catalog/regions` | 查询可用地区 | 无 |
| `GET /app-api/education/catalog/categories` | 查询题目分类 | `subjectId` (可选), `nodeId` (可选) |
| `GET /app-api/education/catalog/subjects` | 查询科目目录 | `regionId`, `schoolId`, `majorId`, `moduleId`, `type` (均可选) |
| `GET /app-api/education/catalog/module-nodes` | 查询模块导航节点 | `regionId`, `moduleId`, `parentId` (均可选) |
| `GET /app-api/education/catalog/content-entries` | 查询内容入口 | `regionId`, `entryType`, `includeHidden` (均可选) |
| `GET /app-api/education/catalog/content-nodes` | 查询内容导航节点 | `entryId` (必填), `parentId`, `mode`, `includeInactive`, `markerType` (可选) |
| `GET /app-api/education/catalog/question-collections` | 查询可用题集 | `regionId`, `entryId`, `nodeId`, `collectionType`, `limit` (均可选) |
#### 响应格式
所有成功响应返回 `CommonResult<List<T>>`
```json
{
"code": 0,
"msg": "成功",
"data": [
{"id": "uuid", "name": "全国", "order": 1, "active": true}
]
}
```
#### 错误响应
| HTTP 状态 | 错误码 | 说明 |
|-----------|--------|------|
| 401 | 1_016_000_002 | 未登录或会话过期 |
| 400 | 自定义 | 请求参数不合法 |
| 500 | 1_005_002_000 | 题库数据源未启用 |
| 500 | 1_005_002_001 | 上游题库服务异常 |
| 500 | 1_005_002_002 | 上游认证失败(配置问题) |
| 403 | 1_005_002_003 | 无权限访问上游资源 |
| 404 | 1_005_002_004 | 请求的题库资源不存在 |
| 409 | 1_005_002_005 | 资源状态冲突 |
| 429 | 1_005_002_006 | 请求过于频繁 |
| 500 | 1_005_002_007 | 上游超时 |
| 500 | 1_005_002_008 | 上游返回异常:{状态码} |
| 500 | 1_005_002_009 | 不支持的题库数据源模式 |
- **上游错误不会被转换为空列表或成功响应** — 每个上游非 2xx 均映射为明确的 `ServiceException`
- 日志记录脱敏后的端点名、tenant、上游 requestId、耗时和结果
#### 前端集成提示
前端就位后,学生端学习首页应:
1. 获取上下文(`/education/context`)确认登录态
2. 调用 `/education/catalog/regions` 获取地区筛选器
3. 根据地区调用 `subjects` / `categories` 获取科目分类
4. 调用 `content-entries``content-nodes` 构建目录树
5. 叶子节点调用 `question-collections` 获取题集摘要
**当前阻塞**:完整前端源码不存在,后端目录接口已就绪可通过 Swagger/curl 验证。
### 用户 APP - 题目与练习预览Questions & Practice
所有端点需要学生登录态Bearer Token`userId``tenantId` 由安全上下文派生,不接受客户端传参。
返回的题目数据经过白名单过滤,绝不包含 `correctAnswer``answer``explanation``analysis` 或选项的 `isCorrect` 字段。
#### 架构边界
```
Browser -> Controller(/education/questions/*) -> QuestionCatalogService -> QuestionCatalogProvider -> [Scalar]
↑ SafeQuestionRespVO ↑ CatalogQuestionDTO
```
- **业务层**Controller/Service仅操作安全 VO`SafeQuestionRespVO` 等),答案字段在 DTO→VO 转换时被剥离
- **集成层**Scalar DTO + ScalarCatalogProvider封装 Scalar 协议差异,答案字段在此层被映射但绝不透传到上层
#### 端点列表
| 端点 | 说明 | 参数 |
|------|------|------|
| `GET /app-api/education/questions/page` | 分页查询安全题目 | `collectionId`, `nodeId`, `type`, `difficulty` (可选), `pageNo` (默认1), `pageSize` (默认20) |
| `GET /app-api/education/questions/get` | 获取单个安全题目 | `id` (必填) |
| `GET /app-api/education/questions/collection-questions` | 查询题集中的安全题目 | `collectionId` (必填), `type`, `difficulty` (可选), `pageNo`, `pageSize` |
| `GET /app-api/education/practice-config/preview` | 预览练习配置(不创建会话) | `collectionId` (必填), `nodeId`, `type`, `difficulty` (可选), `questionCount` (默认10, 1-1000) |
#### 分页响应格式
```json
{
"code": 0,
"msg": "成功",
"data": {
"list": [
{
"id": "q-001",
"contentVersion": "v2",
"stem": "1+1等于几",
"type": "choice",
"difficulty": "easy",
"options": [
{"label": "A", "content": "2", "order": 1.0}
]
}
],
"total": 50
}
}
```
#### 练习预览响应格式
```json
{
"code": 0,
"msg": "成功",
"data": {
"eligibleCount": 50,
"totalCount": 100,
"availableTypes": ["choice", "fill"],
"availableDifficulties": ["easy", "medium"],
"minQuestions": 1,
"maxQuestions": 50,
"suggestedCount": 20,
"normalizedCount": 10,
"countWithinRange": true
}
}
```
#### 错误响应
| HTTP 状态 | 错误码 | 说明 |
|-----------|--------|------|
| 401 | 1_016_000_002 | 未登录或会话过期 |
| 404 | 1_005_003_001 | 题目不存在或不可见 |
| 400 | 1_005_003_002 | 无效的练习配置 |
| 400 | 1_005_003_003 | 符合条件的题目数量不足 |
| 500 | 1_005_003_004 | 题库数据源返回不安全内容 |
#### 安全字段白名单
`SafeQuestionRespVO` 仅包含以下字段,前端可安全展示:
- `id`, `contentVersion`, `stem`, `type`, `difficulty`
- `options[]` 中仅包含 `label`, `content`, `order`
以下字段**绝不**出现在响应中:
- `correctAnswer`, `answer`, `explanation`, `analysis`
- 选项的 `isCorrect`
- 任何管理元数据
#### 前端集成提示
前端就位后,学生端练习入口应:
1. 浏览题库目录Catalog API选择题集
2. 调用 `/education/questions/page``/education/questions/collection-questions` 预览题目概要
3. 调用 `/education/practice-config/preview` 获取可用题量范围和建议配置
4. 展示预览结果后用户在可用范围内选择题量开始练习Ticket #6 创建持久会话)
**当前阻塞**:完整前端源码不存在,后端接口已就绪可通过 Swagger/curl 验证。
### 更新后的错误码
| 错误码 | 说明 |
|--------|------|
| 1_005_001_000 | 教育模块未启用 |
| 1_005_001_001 | 租户不存在 |
| 1_005_001_002 | 租户已被禁用 |
| 1_005_001_003 | 租户识别失败:{原因} |
| 1_005_001_004 | 当前租户不可用 |
| 1_005_002_000 | 题库数据源未启用 |
| 1_005_002_001 | 上游题库服务异常 |
| 1_005_002_002 | 上游认证失败 |
| 1_005_002_003 | 无权限访问上游资源 |
| 1_005_002_004 | 题库资源不存在 |
| 1_005_002_005 | 资源状态冲突 |
| 1_005_002_006 | 请求过于频繁 |
| 1_005_002_007 | 上游超时 |
| 1_005_002_008 | 上游返回异常:{状态码} |
| 1_005_002_009 | 不支持的题库数据源模式 |
| 1_005_002_010 | Scalar 数据源未配置 |
| 1_005_002_011 | 上游题库返回数据格式异常 |
| 1_005_002_012 | 上游题库服务不可达 |
| 1_005_003_001 | 题目不存在或不可见 |
| 1_005_003_002 | 无效的练习配置 |
| 1_005_003_003 | 符合条件的题目数量不足 |
| 1_005_003_004 | 题库数据源返回不安全内容 |
### 用户 APP - 答案保存Answer
需要学生登录态。`userId`/`tenantId` 由安全上下文派生。
答案保存具有幂等性:同一 `idempotencyKey` + 相同载荷返回首次结果,相同 key + 不同载荷返回冲突。
服务端乐观锁防止旧版本/旧序号覆盖更新答案。
```
PUT /app-api/education/practice-session/answer
```
**请求体:**
```json
{"sessionId":1001,"questionSequence":3,"selectedAnswer":"A","idempotencyKey":"uuid","clientSequence":5,"expectedSessionVersion":1}
```
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| sessionId | Long | 是 | 练习会话 ID |
| questionSequence | Integer | 是 | 题目序号1-based |
| selectedAnswer | String | 否 | 学生选择的答案null 表示清除 |
| idempotencyKey | String | 是 | 客户端幂等键UUID |
| clientSequence | Integer | 是 | 客户端命令序号(单调递增) |
| expectedSessionVersion | Integer | 是 | 客户端期望的会话版本号 |
**成功响应:** `{"sessionId":1001,"questionSequence":3,"selectedAnswer":"A","serverVersion":2,"acceptedSequence":5}`
**前端保存状态契约**(客户端根据 API 响应派生,后端不提供状态枚举):
| 状态 | 条件 | 说明 |
|------|------|------|
| SAVING | 请求发送中 | 显示保存中指示器 |
| SAVED | code=0 | 更新本地版本号和序号 |
| RETRYING | 网络超时/5xx | 相同 idempotencyKey 安全重试 |
| FAILED | 1\_005\_003\_014/015/016 | 刷新页面获取最新状态后重试 |
刷新页面通过 `GET /practice-session/current` 恢复服务端最后确认的答案。
### 答案保存错误码
| 错误码 | 说明 |
|--------|------|
| 1\_005\_003\_006 | 练习会话不存在 |
| 1\_005\_003\_007 | 无权访问该练习会话 |
| 1\_005\_003\_008 | 练习会话已过期 |
| 1\_005\_003\_009 | 练习会话已提交 |
| 1\_005\_003\_010 | 练习会话已取消 |
| 1\_005\_003\_014 | 幂等键相同但请求内容不一致 |
| 1\_005\_003\_015 | 会话版本已更新,请刷新后重试 |
| 1\_005\_003\_016 | 客户端命令序号已过期 |
| 1\_005\_003\_017 | 无效的选项 |
| 1\_005\_003\_019 | 题目不属于当前会话 |