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 中配置:
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 - 响应示例:
{
"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 |
归档当前租户已发布题目 |
创建草稿请求示例:
{
"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
}
目录放置请求示例:
{
"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 至少提供一个。解析规则:
- 如果同时提供两者,它们必须解析到同一个租户,否则拒绝请求
- hostname 经过标准化(保留端口并转为小写),先查
EducationProperties.hostnameTenantMap配置映射,再按system_tenant.websites的精确 authority 值查询
- 响应示例(成功):
{
"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 前置完成租户校验,此处二次验证确保租户处于活跃状态。
- 响应示例:
{
"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 | 当前租户不可用,请联系管理员 |
构建与运行
单独编译测试
# 编译 education 模块
mvn compile -pl yudao-module-education -am
# 运行 education 模块单元测试
mvn test -pl yudao-module-education -am
整体编译(含 server)
# 编译全量(member + education + system + infra + server)
mvn compile -pl yudao-server -am
# 打包(跳过测试加速)
mvn package -pl yudao-server -am -DskipTests
启动验证
- 确保
yudao.education.enabled=true - 启动
yudao-server - 访问 Swagger UI 查看
education分组 - 调用
GET /admin-api/education/capability
数据库迁移
Education 运行时 Schema 仅通过模块内 PostgreSQL Flyway migration 交付:
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_versionCAS、同事务追加式审计和 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 已打包:
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 端前端集成契约
前端就位后必须实现以下流程(不能伪造静态页面):
-
租户识别(登录前)
- URL:
GET /app-api/education/tenant/resolve - 从浏览器
window.location.host获取 authority(包含非默认端口),传入hostname参数 - 备用:支持手动输入
tenantName - 根据返回的
loginMethods决定展示哪种登录方式(PASSWORD/SMS) - 获得
tenantId后,在后续请求中通过tenant-idheader 传递
- URL:
-
用户认证(复用 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
- 密码登录:
-
获取上下文(登录后)
- URL:
GET /app-api/education/context - 携带有效 Bearer Token +
tenant-idheader - 从响应获取
userId、tenantId、tenantName用于页面展示
- URL:
-
跨租户防护
- 前端不应允许用户手动切换
tenant-idheader - 后端通过
TenantSecurityWebFilter拒绝认证用户的跨租户 header 操作
- 前端不应允许用户手动切换
阻塞项:完整前端源码(含 router、store、package.json)是上述前端集成的必要前提。一旦前端源码就位,需:
- 在 Vue3 admin 的路由中添加
/education路由项,绑定 Education 菜单组件 - 添加
src/api/education/API 封装层(调用上述教育端点) - 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>>:
{
"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、耗时和结果
前端集成提示
前端就位后,学生端学习首页应:
- 获取上下文(
/education/context)确认登录态 - 调用
/education/catalog/regions获取地区筛选器 - 根据地区调用
subjects/categories获取科目分类 - 调用
content-entries→content-nodes构建目录树 - 叶子节点调用
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) |
分页响应格式
{
"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
}
}
练习预览响应格式
{
"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,difficultyoptions[]中仅包含label,content,order
以下字段绝不出现在响应中:
correctAnswer,answer,explanation,analysis- 选项的
isCorrect - 任何管理元数据
前端集成提示
前端就位后,学生端练习入口应:
- 浏览题库目录(Catalog API)选择题集
- 调用
/education/questions/page或/education/questions/collection-questions预览题目概要 - 调用
/education/practice-config/preview获取可用题量范围和建议配置 - 展示预览结果后,用户在可用范围内选择题量开始练习(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
请求体:
{"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 | 题目不属于当前会话 |