# 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 文件仅包含 DDL,seed 文件仅包含 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>`: ```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 | 题目不属于当前会话 |