Files
ruoyi-vue-pro/yudao-module-education

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:capabilityeducation:question:authoreducation:question:classifyeducation:question:publisheducation: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 写入口。客户端不能提交 tenantIdscope、生命周期状态或 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 查询中再次检查节点可用性。

correctAnswerexplanationanalysis 是服务端受保护内容;学生端仍只返回下文列出的安全题目投影。发布和归档采用 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 值查询
  • 响应示例(成功):
{
  "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

启动验证

  1. 确保 yudao.education.enabled=true
  2. 启动 yudao-server
  3. 访问 Swagger UI 查看 education 分组
  4. 调用 GET /admin-api/education/capability

数据库迁移

Education 运行时 Schema 仅通过模块内 PostgreSQL Flyway migration 交付:

yudao-module-education/src/main/resources/db/migration/education/
  • V4010V4020 是不可变迁移历史,不得修改。
  • 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 结构约束;不写入权限或角色种子。
  • V4110 增加租户 Manual Question Collection 生命周期、ordered replace-all membership 和受保护的生命周期/成员变更 tokenV4210 通过向前 migration 将物理删除保护扩展到历史 PUBLIC 题集。
  • V4120 增加租户 Category 与 bounded Practice Blueprint 的 DRAFT → ACTIVE → ARCHIVED 生命周期、统一 authoring_version CAS、事务内追加式审计、独立 RBAC 和学生端路由可见性约束;不授予任何角色。
  • V4080/V4090 在平台 system_menu 已存在时条件写入 Education 权限种子;固定 ID 已被不同 permission 占用时迁移失败,且迁移不会向角色写入授权关系。
  • education_answer_idempotencyeducation_submit_idempotency 在首次接管时保留,后续清理必须使用更高版本的独立向前 migration。
  • sql/postgresql/education/ 是手工初始化/设计历史,sql/mysql/education/ 是过时归档;两者都不是运行时交付入口。
  • 禁止使用 flyway clean*-rollback.sql 回退共享环境。应用回滚后如有 Schema 兼容问题,通过更高版本向前修复。

首次接管已有 PostgreSQL 平台库时使用既定的 4009 baseline。任何已存在 Education 表的环境都必须先核对实际表结构和 flyway_schema_history,不能仅凭表名视为兼容,也不能伪造 V4010/V4020 执行历史。

Flyway 必须使用显式的 FLYWAY_USER/FLYWAY_PASSWORD 迁移所有者账号,且该账号必须与 master 运行时 datasource 账号不同。运维先创建两个互不继承的 LOGIN role由 Flyway role 拥有目标 schema 和 migration 对象,再仅向 runtime role 授予业务表所需权限;不得授予、继承或拥有 V4080/V4100/V4110 的四张 *_token 表。应用启动会检查当前 runtime role 及其成员角色既不是这些表的 owner也没有 INSERT/UPDATE/DELETE/TRUNCATE 权限;缺表、同角色、继承 owner 或错误授权都会 fail closed。角色创建、密码轮换与 schema 授权由部署平台预置,不由 migration 创建或转移。

编译后确认 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.jsonrouter/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
    • 从响应获取 userIdtenantIdtenantName 用于页面展示
  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 TokenuserIdtenantId 由安全上下文派生,不接受客户端传参。 Scalar 代理层自动注入 x-tenant-id header来自 TenantContextHolder),前端不发起任何直达 Scalar 的请求。

架构边界

Browser → Controller(/education/catalog/*) → CatalogService → CatalogProvider → [Scalar]
                                              ↑ 内部 DTO/VO    ↑ Scalar DTO 仅此层
  • 业务层Controller/Service仅操作内部 Catalog VOCatalogRegionRespVO 等)
  • 集成层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、耗时和结果

前端集成提示

前端就位后,学生端学习首页应:

  1. 获取上下文(/education/context)确认登录态
  2. 调用 /education/catalog/regions 获取地区筛选器
  3. 根据地区调用 subjects / categories 获取科目分类
  4. 调用 content-entriescontent-nodes 构建目录树
  5. 叶子节点调用 question-collections 获取题集摘要

当前阻塞:完整前端源码不存在,后端目录接口已就绪可通过 Swagger/curl 验证。

用户 APP - 题目与练习预览Questions & Practice

所有端点需要学生登录态Bearer TokenuserIdtenantId 由安全上下文派生,不接受客户端传参。 返回的题目数据经过白名单过滤,绝不包含 correctAnsweranswerexplanationanalysis 或选项的 isCorrect 字段。

架构边界

Browser -> Controller(/education/questions/*) -> QuestionCatalogService -> QuestionCatalogProvider -> [Scalar]
                                                   ↑ SafeQuestionRespVO    ↑ CatalogQuestionDTO
  • 业务层Controller/Service仅操作安全 VOSafeQuestionRespVO 等),答案字段在 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, 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

请求体:

{"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 题目不属于当前会话