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

463 lines
17 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
教育业务模块,提供课程、练习、题库、考试等教育业务功能。
## 当前状态
此模块提供教育业务功能骨架、题库目录浏览 tracer bullet以及题目预览与练习配置预览。
**已实现**
- 模块骨架与包结构
- 能力探测端点 (`/education/capability`)
- 租户识别端点 (`/education/tenant/resolve`) — 学生端登录前使用
- 教育上下文端点 (`/education/context`) — 学生端已认证状态
- 题库目录端点 (见下方 Catalog API) — 学生端已认证
- 题目浏览与筛选端点 (见下方 Questions API) — 学生端已认证
- 练习配置预览端点 (见下方 Practice API) — 学生端已认证
- 答案保存端点 (见下方 Answer API) — 幂等保存,安全重试
- 题目安全过滤(答案/解析绝不暴露到前端)
- 独立的功能开关配置 + Scalar 数据源配置
- 错误码常量(通用 + 租户 + Catalog/Scalar + 题目/练习)
- 权限与菜单种子数据
## 功能配置
`application.yaml` 或对应 profile 中配置:
## 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"]
}
}
```
### 用户 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`
## SQL 应用
```bash
# 应用基础种子数据(菜单 + 权限定义;执行后由管理员为目标租户角色授权)
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/000-education-seed.sql
# 应用租户识别种子数据
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/001-education-tenant-seed.sql
# 回滚
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/000-education-rollback.sql
mysql -u root -p ruoyi-vue-pro < sql/mysql/education/001-education-tenant-rollback.sql
```
## SQL 交付约定
- 所有 Education SQL 文件存放在 `sql/mysql/education/` 目录下
- 文件命名:`NNN-描述.sql`NNN 为三位递增序号)
- 每个正向脚本应有对应的回滚脚本
- schema 文件仅包含 DDLseed 文件仅包含 DML
- **不修改**项目根目录的 `ruoyi-vue-pro.sql` 巨量全量转储
- Ticket #5 为只读/预览操作,无新增数据库 schema 或 DML
## 前端状态
**当前工作区未检出完整的前端源码。** `yudao-ui/yudao-ui-admin-vue3/` 仅包含部分 MES 相关文件(`src/api/mes/``src/views/mes/`),缺少 `package.json``router/``store/``config/` 等核心框架文件。
因此:
- **管理后台教育菜单项**:基础 SQL 仅注册了 `system_menu` 记录ID 6800-6801。租户解析与当前上下文属于学生端接口不创建虚假的后台权限菜单。前端无路由/页面组件可渲染,菜单在管理后台不会显示。
- **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 | 题目不属于当前会话 |