From 2bc3ab05ba52a49d4c3d407673b3c00cd7288dd6 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Thu, 30 Jul 2026 14:50:33 +0800 Subject: [PATCH 1/3] docs: design teacher student demo mode --- ...-07-30-teacher-student-demo-mode-design.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-teacher-student-demo-mode-design.md diff --git a/docs/superpowers/specs/2026-07-30-teacher-student-demo-mode-design.md b/docs/superpowers/specs/2026-07-30-teacher-student-demo-mode-design.md new file mode 100644 index 0000000..f523b00 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-teacher-student-demo-mode-design.md @@ -0,0 +1,93 @@ +# 教师学生端讲解模式设计 + +## 目标 + +教师可以选择自己创建的教学班,在新窗口以学生端界面讲解任务流程。演示会话读取所选教学班的任务、任务分配和页面配置,但不会创建虚拟学生账号、不会写入教学班成员关系,也不会持久化答案、进度、成绩或其他学生业务数据。 + +## 不采用 `_ys` 账号的原因 + +学生端当前通过 `teaching_class_student` 的有效成员记录确定教学班。为 `张辉_ys` 一类账号提供内容,必须把账号加入教学班;而现有模型只会取该学生最近的一条有效教学班归属。切换讲解班级会导致成员关系迁移,并污染学生名单、学习记录、成绩和账号管理。 + +因此,演示模式使用受控的虚拟会话,不创建 `userinfo` 学生记录,也不新增 `teaching_class_student` 记录。 + +## 会话与单点进入 + +### 教师端 + +教师端新增“学生端讲解”入口。点击后展示本人创建的教学班列表;未选择教学班不能进入。 + +教师选择班级后,前端调用教师专用接口创建一次性演示票据。后端必须验证: + +1. 当前用户为教师; +2. 教学班类型为 `TEACHING`; +3. `school_class.created_by` 等于当前教师用户 ID。 + +前端打开独立学生端窗口,并将一次性票据通过同源 `postMessage` 发送给新窗口,不把 JWT 放入 URL、浏览器历史或 Referer。 + +### 后端 + +新增演示会话接口: + +| 接口 | 用途 | +| --- | --- | +| `POST /api/teacher/student-demo-sessions` | 教师为本人教学班创建短时、单次使用的演示票据。 | +| `POST /api/student-demo-sessions/exchange` | 新窗口使用票据换取演示学生 token。 | + +票据存储字段包括票据值哈希、来源教师 ID、教学班 ID、学校 ID、过期时间、已使用时间和创建时间。票据有效期为 5 分钟,成功兑换后立即失效。演示 token 有效期为 30 分钟。 + +兑换后的 JWT 维持学生端 `roleId=4`,并增加如下声明: + +- `demoMode=true` +- `demoTeachingClassId` +- `sourceTeacherId` + +`JwtUser` 与 `TokenProvider` 解析这些声明,供后续服务统一判断演示上下文。 + +## 教学班解析规则 + +新增统一的学生上下文解析器: + +1. 普通学生:保留现有 `teaching_class_student` 有效成员关系查询。 +2. 演示学生:直接使用 JWT 的 `demoTeachingClassId`;不查询、不创建成员关系。 + +任务详情、任务列表、任务分配、成绩参考、进度展示等学生端读取服务均使用该解析器,确保教师看到的是所选教学班的真实教学内容和配置。 + +演示 token 不允许通过传入其他班级 ID 改变上下文;任何请求中的教学班 ID 必须与 `demoTeachingClassId` 一致,否则拒绝。 + +## 演示数据隔离 + +演示会话不能持久化任何学生业务数据。 + +| 类型 | 演示模式行为 | +| --- | --- | +| 读取任务、任务分配、班级配置 | 使用 `demoTeachingClassId` 正常读取。 | +| 查询答案与进度 | 返回当前窗口的临时数据;首次进入为未开始、空答案。 | +| 保存、提交、重置或删除答案 | 前端写入 `sessionStorage` 并展示成功,不调用持久化接口。 | +| 成绩、排名、进度写入 | 禁止调用或返回演示提示,不写数据库。 | +| AI 助学、AI 评测及外部资源调用 | 在演示模式禁用,避免产生外部调用成本或写入结果。 | + +关闭演示窗口或演示 token 过期后,临时数据自动丢弃。教师端、学生端和后端均不保留演示答案内容。 + +## 前端交互 + +1. 教师端按钮只对教师可见,班级选择框只展示当前教师创建的教学班。 +2. 新窗口进入专用学生端演示路由,等待父窗口发送票据并换取 token;若票据缺失、过期或已使用,展示“演示链接已失效,请返回教师端重新打开”。 +3. 演示学生端顶部展示醒目条:`演示模式:{教学班名称}`,并提供“退出演示”按钮。 +4. 学生端菜单与普通学生一致;被禁用的 AI、成绩提交等操作提供明确的“演示模式不保存数据”提示。 +5. 演示窗口使用独立的 session storage 键保存 token 和临时答案,不覆盖教师窗口或普通学生窗口的登录状态。 + +## 安全与边界 + +- 创建票据和兑换票据均校验有效期、单次使用状态和来源学校。 +- 教师只能创建本人教学班的演示会话,不能进入其他教师的班级。 +- 演示 token 不拥有教师管理权限;仅拥有学生端读取权限和演示上下文。 +- 所有持久化写接口在后端额外识别 `demoMode` 并拒绝写入,不能仅依赖前端禁用。 +- 不创建 `_ys` 用户、密码、学生档案、班级成员或成绩记录。 + +## 验收标准 + +1. 教师可在新窗口进入自己创建的教学班对应的学生端,并看到该班任务内容。 +2. 教师无法为非本人创建的教学班创建演示会话。 +3. 普通学生的登录、任务读取、答案保存和班级归属逻辑保持不变。 +4. 演示过程中填写答案、切换步骤、提交或退出后,数据库的答案、进度、成绩、排名和成员数据均无新增或变更。 +5. 票据不能重复兑换,过期票据无法兑换;演示 token 过期后必须重新从教师端进入。 From 52ba31731cbcc7013753380cb8e76e7e25c74612 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Thu, 30 Jul 2026 14:58:52 +0800 Subject: [PATCH 2/3] docs: plan teacher student demo mode --- .../2026-07-30-teacher-student-demo-mode.md | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-30-teacher-student-demo-mode.md diff --git a/docs/superpowers/plans/2026-07-30-teacher-student-demo-mode.md b/docs/superpowers/plans/2026-07-30-teacher-student-demo-mode.md new file mode 100644 index 0000000..bcf4613 --- /dev/null +++ b/docs/superpowers/plans/2026-07-30-teacher-student-demo-mode.md @@ -0,0 +1,253 @@ +# 教师进入学生端讲解模式实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 教师从教师端选择本人创建的教学班后,可在独立新窗口进入该班对应的学生端进行讲解;演示窗口能读取该教学班的任务与任务分配,但不会创建 `_ys` 学生、不会写入成员关系、答案、进度、成绩、学习时长、访问次数、文件或 AI 评价数据。 + +**Architecture:** 使用短时、一次性的演示票据交换 30 分钟的虚拟学生 JWT。JWT 保持 `roleId=4`,另带 `demoMode`、`demoTeachingClassId` 与 `sourceTeacherId` 声明。后端统一从 JWT 解析学生教学班上下文:普通学生继续读取 `teaching_class_student`,演示学生仅使用声明中的教学班。前端在新窗口的 `sessionStorage` 保存演示令牌和临时答题数据;认证 Cookie、真实学生答案和教师窗口均不受影响。 + +**Tech Stack:** Spring Boot、Spring Security、JWT、MyBatis、MySQL、Vue 3、Element Plus、Axios、JUnit 5 + Mockito、Node `assert`。 + +## Global Constraints + +- 不创建 `userinfo`、`teaching_class_student` 或任何 `_ys` 账号数据。 +- 创建票据者必须是 roleId=3 的教师,且教学班必须为 `TEACHING` 并由该教师创建;管理员教师不获得其他教师教学班的讲解权限。 +- 明文票据只返回给创建它的教师浏览器,数据库只保存 SHA-256 哈希;票据 5 分钟有效且只可兑换一次。 +- 演示 JWT 仅 30 分钟有效,roleId 固定为 4,不能携带教师管理权限。 +- 票据与 JWT 不进入 URL、浏览器历史或 Referer;窗口间仅以相同 origin 的 `postMessage` 传递票据。 +- 普通学生既有的班级归属、答题、提交、成绩与路由逻辑必须保持不变。 +- 演示模式的任何持久化写入都必须在后端再拦截一次,不能只依赖前端禁用按钮。 + +--- + +## 文件结构 + +| 文件 | 责任 | +| --- | --- | +| `docs/sql/2026-07-30-student-demo-session.sql` | 创建一次性演示票据表和必要索引。 | +| `entity/StudentDemoSession.java`、`mapper/StudentDemoSessionMapper.java`、`resources/mappers/StudentDemoSessionMapper.xml` | 演示票据持久化模型及原子兑换 SQL。 | +| `service/StudentDemoSessionService.java`、`service/impl/StudentDemoSessionServiceImpl.java` | 教师班级校验、票据签发与一次性兑换。 | +| `controller/stu/StudentDemoSessionController.java` | 教师创建票据、匿名交换票据为演示 token 的 REST 接口。 | +| `config/security/JwtUser.java`、`config/security/TokenProvider.java` | 演示 JWT 声明、30 分钟签发和解析。 | +| `service/StudentTeachingClassResolver.java`、`service/impl/StudentTeachingClassResolverImpl.java` | 普通学生/演示学生统一解析教学班并验证请求班级。 | +| `service/impl/TrainingTaskServiceImpl.java`、`controller/stu/TrainingTaskController.java` | 学生任务列表与详情改用统一教学班上下文。 | +| `controller/stu/TaskAllocationController.java`、`controller/stu/UserController.java` | 任务分配和当前教学班查询改用 JWT 上下文。 | +| `service/impl/StudentTrainingAnswerServiceImpl.java`、`service/impl/AiTrainingEvaluationServiceImpl.java` | 后端拒绝演示模式写答案或调用 AI。 | +| `src/utils/auth.js`、`src/utils/request.js` | 演示窗口优先使用 sessionStorage 认证,且不会删除父窗口 Cookie。 | +| `src/views/studentDemo/index.vue`、`src/router/index.js`、`src/permission.js` | 独立握手页、票据交换和演示窗口启动路由。 | +| `src/api/studentDemo.js`、`src/views/teacherEnd/trainingTask/index.vue` | 教师端入口、开窗和安全 `postMessage` 握手。 | +| `src/utils/studentDemo.js`、`src/api/studentTrainingAnswer.js`、`src/views/training/GenericTrainingPage.vue` | 演示答案只写 sessionStorage,且阻止上传。 | +| `src/layout/components/AppMain.vue`、`src/layout/components/Sidebar/index.vue`、`src/views/components/TrainingAiEvaluation.vue` | 演示横幅/退出、演示教学班读取、禁用学习行为记录和 AI。 | +| `src/test/java/...`、`e-commerce-internet/tests/student-demo-mode.static.test.cjs` | 后端行为测试和前端静态契约测试。 | + +## Task 1: 演示票据、权限和 JWT + +**Files:** + +- Create: `docs/sql/2026-07-30-student-demo-session.sql` +- Create: `src/main/java/com/sztzjy/linkCommerce/entity/StudentDemoSession.java` +- Create: `src/main/java/com/sztzjy/linkCommerce/mapper/StudentDemoSessionMapper.java` +- Create: `src/main/resources/mappers/StudentDemoSessionMapper.xml` +- Create: `src/main/java/com/sztzjy/linkCommerce/service/StudentDemoSessionService.java` +- Create: `src/main/java/com/sztzjy/linkCommerce/service/impl/StudentDemoSessionServiceImpl.java` +- Create: `src/main/java/com/sztzjy/linkCommerce/controller/stu/StudentDemoSessionController.java` +- Modify: `src/main/java/com/sztzjy/linkCommerce/config/security/JwtUser.java` +- Modify: `src/main/java/com/sztzjy/linkCommerce/config/security/TokenProvider.java` +- Test: `src/test/java/com/sztzjy/linkCommerce/service/impl/StudentDemoSessionServiceImplTest.java` +- Test: `src/test/java/com/sztzjy/linkCommerce/controller/stu/StudentDemoSessionControllerTest.java` + +- [ ] **Step 1: 先写失败测试。** + +覆盖以下契约:教师可为自己创建的 `TEACHING` 班签发票据;非本人班级、行政班、非教师角色均抛出 `FORBIDDEN`;有效票据只能兑换一次;过期票据不能兑换;兑换结果 token 的 roleId 为 4、`demoMode=true`、教学班与来源教师均正确。 + +- [ ] **Step 2: 创建迁移和 Mapper。** + +SQL 创建 `student_demo_session`,字段为 `id`、`ticket_hash`(唯一)、`teacher_user_id`、`school_id`、`teaching_class_id`、`expires_at`、`used_at`、`create_time`。创建 `(expires_at)` 索引用于清理/查询。Mapper 提供插入、按哈希查询,以及以 `used_at is null and expires_at > now()` 为条件的原子 `markUsed`;兑换时只有 update 返回 1 才视为成功,避免并发重复兑换。 + +- [ ] **Step 3: 实现服务与 token。** + +服务用 `SecureRandom` 生成至少 32 字节 base64url 票据,保存 SHA-256 哈希,票据有效期 5 分钟。签发前通过 `SchoolClassMapper.selectByPrimaryKey` 验证 `classType=TEACHING`、`createdBy=teacherUserId` 且 `schoolId` 与教师相同。兑换后生成不落库的虚拟学生 `JwtUser`:`userId` 为 `demo:{sessionId}`,`name` 为“{教师姓名}(演示)”,`roleId=4`,并写入三个演示声明。 + +在 `TokenProvider` 中加入 `DEMO_EXP_TIME = 30 minutes` 的专用签发方法;常规 `createToken` 时长保持 12 小时不变。`getJWTUser` 同时解析 `demoMode`、`demoTeachingClassId`、`sourceTeacherId`,不能影响旧 token。 + +- [ ] **Step 4: 暴露 REST 契约。** + +实现: + +```text +POST /api/teacher/student-demo-sessions +body: { teachingClassId } +response: { ticket, expiresAt, teachingClassId, className } + +POST /api/student-demo-sessions/exchange +body: { ticket } +response: { token, userId, name, username, roleId: "4", schoolId, + classId, className, demoMode: true, demoTeachingClassId } +``` + +创建接口从 `TokenProvider.getJWTUser(request)` 取教师身份;兑换接口标记为匿名,只接受票据,不接受教师或教学班 ID,避免前端伪造上下文。 + +- [ ] **Step 5: 验证。** + +Run: `mvn -q -Dtest=StudentDemoSessionServiceImplTest,StudentDemoSessionControllerTest test` + +Expected: 所有权限、过期、一次性与 JWT 声明测试通过。 + +## Task 2: 统一学生教学班上下文并保护后端写入 + +**Files:** + +- Create: `src/main/java/com/sztzjy/linkCommerce/service/StudentTeachingClassResolver.java` +- Create: `src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTeachingClassResolverImpl.java` +- Modify: `src/main/java/com/sztzjy/linkCommerce/service/TrainingTaskService.java` +- Modify: `src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingTaskServiceImpl.java` +- Modify: `src/main/java/com/sztzjy/linkCommerce/controller/stu/TrainingTaskController.java` +- Modify: `src/main/java/com/sztzjy/linkCommerce/controller/stu/TaskAllocationController.java` +- Modify: `src/main/java/com/sztzjy/linkCommerce/controller/stu/UserController.java` +- Modify: `src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImpl.java` +- Modify: `src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java` +- Test: `src/test/java/com/sztzjy/linkCommerce/service/impl/StudentTeachingClassResolverImplTest.java` +- Test: `src/test/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImplTest.java` +- Test: `src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java` +- Test: `src/test/java/com/sztzjy/linkCommerce/controller/stu/TaskAllocationControllerTest.java` + +- [ ] **Step 1: 先写失败测试。** + +解析器测试普通学生仍从 `selectActiveByStudentUserId` 获得班级;演示学生不查询成员表、直接得到 `demoTeachingClassId`;演示 token 请求其它班级 ID 时拒绝。答案保存/删除测试演示用户得到 `FORBIDDEN`,且不调用任何 answer mapper 或 `JdbcTemplate` 写方法。AI 助学与助评测试演示用户被拒绝,且不调用模型客户端、评价 mapper 或成绩写入。 + +- [ ] **Step 2: 实现 `StudentTeachingClassResolver`。** + +接口提供 `resolveRequired(JwtUser)` 和 `resolveRequested(JwtUser, requestedTeachingClassId)`。两种模式都验证班级存在、类型为 `TEACHING`,且班级学校与 JWT 学校一致: + +1. 普通学生:保留当前有效成员关系查询; +2. 演示学生:仅使用签名 JWT 的 `demoTeachingClassId`,不读取或写入成员关系; +3. 所有带班级参数的演示请求:必须等于 JWT 班级,否则返回 `FORBIDDEN`。 + +- [ ] **Step 3: 接入学生端读取路径。** + +将 `TrainingTaskService` 的学生读取方法改为接收 `JwtUser`,由解析器获得班级后读取该班的任务副本。`TrainingTaskController` 将当前 JWT 传入服务。`TaskAllocationController.selectTaskAllocationByStudentUserId` 和 `UserController.selectCurrentTeachingClass` 改为优先从当前学生 JWT 解析上下文,不再相信请求中的 `userId`;为兼容旧非学生调用保留原查询分支。普通学生返回值和现有 API 路径不变。 + +- [ ] **Step 4: 加后端写入保护。** + +在 `StudentTrainingAnswerServiceImpl` 的 `save` 与 `delete` 开头,以及 `AiTrainingEvaluationServiceImpl` 的 `get`、`generateHelp`、`generateAssessment` 的入口处调用统一的 `requireNotDemo(user)`。演示模式统一返回 `403 / 演示模式不保存数据`,确保即使绕过前端直接请求也不会保存答案、进度、AI 评价或成绩。 + +同时审查所有学生端持久化入口(以 `rg -n "insert|update|delete|JdbcTemplate.*update" src/main/java/com/sztzjy/linkCommerce/controller/stu src/main/java/com/sztzjy/linkCommerce/service` 为清单):凡是可被 roleId=4 调用的写服务,统一补充同一 guard;只读但使用 POST 的旧接口保留可用,不能以 HTTP 方法粗暴拦截。 + +- [ ] **Step 5: 验证。** + +Run: `mvn -q -Dtest=StudentTeachingClassResolverImplTest,StudentTrainingAnswerServiceImplTest,AiTrainingEvaluationServiceImplTest,TaskAllocationControllerTest,TrainingTaskServiceImplTest test` + +Expected: 演示模式读到所选教学班;普通学生的既有测试继续通过;演示写入与 AI 调用均被拒绝。 + +## Task 3: 教师开窗、一次性握手和独立认证 + +**Files (frontend repository `E:\workspace\dianshang\e-commerce-internet`):** + +- Create: `src/api/studentDemo.js` +- Create: `src/utils/studentDemo.js` +- Create: `src/views/studentDemo/index.vue` +- Modify: `src/utils/auth.js` +- Modify: `src/utils/request.js` +- Modify: `src/router/index.js` +- Modify: `src/permission.js` +- Modify: `src/views/teacherEnd/trainingTask/index.vue` +- Test: `tests/student-demo-mode.static.test.cjs` + +- [ ] **Step 1: 先写失败的前端静态契约测试。** + +断言存在教师创建、匿名兑换两个 API;认证工具使用 `sessionStorage` 的演示键且优先于 Cookie;教师端通过 `window.open('/student-demo', ...)` 打开空白演示路由;双方使用固定消息类型、`window.location.origin` 与来源窗口校验;源码中没有将 `ticket` 或 `token` 拼入 URL;演示 token 通过 session 存储后执行 `location.replace('/index')`。 + +- [ ] **Step 2: 实现独立认证存储。** + +在 `studentDemo.js` 固定定义 `STUDENT_DEMO_STORAGE_KEY`,包含 `{ token, userInfo, expiresAt }`,并提供 `isStudentDemo`、读写与清理函数。`auth.js` 的 `getToken`、`getUserInfo` 在当前标签存在有效演示会话时优先读取它;`removeToken`、`removeUserInfo` 在演示标签只清 sessionStorage,绝不删除教师 Cookie。`request.js` 复用 `getToken`,让演示标签自动携带演示 JWT;兑换请求明确 `isToken:false`。 + +- [ ] **Step 3: 实现新窗口握手页。** + +在常量路由中添加隐藏的 `/student-demo` 路由,且在 `permission.js` 白名单放行它。该页加载后向 `window.opener` 发送 `STUDENT_DEMO_READY`;只接受来源等于 `window.location.origin` 且 `event.source === window.opener` 的 `STUDENT_DEMO_TICKET`。接到票据后调用 exchange API,写入演示 session,再以 `location.replace('/index')` 重新启动应用,使 store、路由和侧边栏按 roleId=4 初始化。无 opener、票据失效或兑换失败时显示“演示链接已失效,请返回教师端重新打开”。 + +- [ ] **Step 4: 实现教师端入口。** + +在 `teacherEnd/trainingTask/index.vue` 的已有教学班筛选工具栏中,仅当 `selectedTeachingClass` 存在时显示“学生端讲解”。点击后: + +1. 先同步 `window.open('/student-demo', '_blank')`,使其不被浏览器当作弹窗拦截; +2. 调用创建票据 API; +3. 监听该窗口的 `STUDENT_DEMO_READY`,核对 `event.origin` 与 `event.source` 后,以 `postMessage({ type: 'STUDENT_DEMO_TICKET', ticket }, window.location.origin)` 发送; +4. 创建失败或窗口关闭时关闭演示窗口并给出提示;收到握手成功后移除监听器和超时器。 + +票据与 JWT 都不得出现在 query/hash、控制台日志或通知文本中。 + +- [ ] **Step 5: 验证。** + +Run: `node tests/student-demo-mode.static.test.cjs` + +Expected: 安全开窗、独立认证和无 URL 凭据契约全部通过。 + +## Task 4: 演示界面、临时答案和非持久化行为 + +**Files (frontend repository `E:\workspace\dianshang\e-commerce-internet`):** + +- Modify: `src/api/studentTrainingAnswer.js` +- Modify: `src/views/training/GenericTrainingPage.vue` +- Modify: `src/views/components/TrainingAiEvaluation.vue` +- Modify: `src/layout/components/AppMain.vue` +- Modify: `src/layout/components/Sidebar/index.vue` +- Modify: `src/layout/index.vue` 或新增 `src/components/StudentDemoBanner.vue` +- Modify: `tests/student-demo-mode.static.test.cjs` + +- [ ] **Step 1: 将新实训页答案改为演示内存数据。** + +`studentTrainingAnswer.js` 在演示模式下不发起 `GET/POST/DELETE /api/student-training-answers`:按 `{ demoTeachingClassId, taskKey }` 作为键从 sessionStorage 返回或保存模拟 `StudentTrainingAnswer`。正常学生继续走原 API,函数签名和普通流程不变。`GenericTrainingPage.vue` 复用这三个 API,因此保存、提交、重置在演示窗口有可见反馈但关闭标签即丢失。 + +文件上传是持久化副作用:演示模式下禁用上传按钮并说明“演示模式不上传文件”。导出 JSON 是本地浏览器行为,保留可用。 + +- [ ] **Step 2: 禁用 AI 与学习行为写入。** + +`TrainingAiEvaluation.vue` 在演示模式下显示明确提示并禁用助学、助评和重试,不请求 AI 状态或模型。`AppMain.vue` 在演示模式下不调用 `updateStudyTime`、`updateVisitCount`、`getCurrentTeachingClass`;当前教学班由演示 session 中的 `demoTeachingClassId` 提供。`Sidebar/index.vue` 获取学生任务分配时仍使用已认证请求,由后端上下文解析为所选教学班,不能依赖虚拟 `userId`。 + +- [ ] **Step 3: 加持续可见的演示标识与退出。** + +在学生布局顶部渲染横幅“演示模式:{className}”。“退出演示”只清除本标签的演示 session 并导航至 `/student-demo` 的失效页/关闭窗口提示;不调用全局 logout,也不修改教师 Cookie。普通学生与教师布局不显示该横幅。 + +- [ ] **Step 4: 完成前端构建验证。** + +Run: `node tests/student-demo-mode.static.test.cjs; node tests/training-task-restore.static.test.cjs; node tests/school-default-task.static.test.cjs; npm run build:prod` + +Expected: 所有静态契约与构建通过;允许保留仓库已有 warning,但不得出现新编译错误。 + +## Task 5: 集成回归与人工验收 + +**Files:** + +- Modify: `docs/superpowers/plans/2026-07-30-teacher-student-demo-mode.md` + +- [ ] **Step 1: 后端完整回归。** + +Run: `mvn -q -DforkCount=0 test` + +Expected: PASS。 + +- [ ] **Step 2: 前端完整回归。** + +Run: `node tests/student-demo-mode.static.test.cjs; node tests/training-task-restore.static.test.cjs; node tests/school-default-task.static.test.cjs; npm run build:prod` + +Expected: PASS。 + +- [ ] **Step 3: 人工验收。** + +1. 用教师 A 登录,在实训任务页选中 A 创建的教学班,点击“学生端讲解”。确认新窗口打开学生端、顶部显示该班名称、任务内容是该班自定义任务。 +2. 用教师 A 尝试伪造/选择教师 B 的教学班 ID,确认创建会话返回无权限;对行政班同样被拒绝。 +3. 在演示窗口填写、保存、提交、重置一个任务,关闭窗口并重新打开。确认答案、当前步骤与提交状态均为空;数据库中没有新增 `student_training_answer`、进度、成绩、评价或成员记录。 +4. 点击 AI、文件上传、退出演示,确认前两项有明确禁用说明,退出后教师原窗口仍保持登录。 +5. 用真实学生登录,确认其任务、班级归属、答案保存、上传和 AI 功能与改造前一致。 +6. 对同一票据重复调用 exchange,并在 5 分钟后重试,均确认失败;30 分钟后演示 token 请求受保护资源确认返回未授权。 + +- [ ] **Step 4: 记录实际执行结果。** + +在本计划底部添加实际运行的命令、退出码、已处理的既有 warning 和未完成的人工项;不要把计划中的“Expected”当作实际结果。 + +## 自检 + +- 需求覆盖:教师进入学生端、教学班所有权、无 `_ys` 帐号、同窗隔离、临时答案、AI/学习数据禁写、普通学生不回归均有对应任务。 +- 安全覆盖:票据哈希、短有效期、一次性兑换、JWT 最小权限、JWT 不入 URL、`postMessage` 双向来源验证、后端写入 guard 均有明确实现点。 +- 数据边界:不写 `userinfo`、`teaching_class_student`、答案、进度、成绩、AI、学习时长、访问次数或上传文件。 +- 占位扫描:无 TBD、TODO 或“按需补充”步骤。 From 0ef5edd1f322c4d9bf74e454c2efa4843846c64e Mon Sep 17 00:00:00 2001 From: chenyuan Date: Thu, 30 Jul 2026 15:24:33 +0800 Subject: [PATCH 3/3] feat: add teacher student demo sessions --- docs/sql/2026-07-30-student-demo-session.sql | 15 ++ .../linkCommerce/config/security/JwtUser.java | 3 + .../config/security/TokenProvider.java | 17 ++- .../stu/StudentDemoSessionController.java | 49 +++++++ .../stu/TaskAllocationController.java | 23 ++- .../stu/TrainingTaskController.java | 4 +- .../controller/stu/UserController.java | 14 +- .../entity/StudentDemoSession.java | 37 +++++ .../dto/StudentDemoSessionCreateRequest.java | 8 + .../StudentDemoSessionExchangeRequest.java | 8 + .../entity/dto/StudentDemoSessionLogin.java | 35 +++++ .../entity/dto/StudentDemoSessionTicket.java | 19 +++ .../mapper/StudentDemoSessionMapper.java | 16 ++ .../service/StudentDemoSessionService.java | 11 ++ .../service/StudentTeachingClassResolver.java | 9 ++ .../service/TrainingTaskService.java | 5 + .../impl/AiTrainingEvaluationServiceImpl.java | 7 + .../impl/StudentDemoSessionServiceImpl.java | 131 +++++++++++++++++ .../StudentTeachingClassResolverImpl.java | 65 +++++++++ .../StudentTrainingAnswerServiceImpl.java | 9 ++ .../service/impl/TrainingTaskServiceImpl.java | 32 +++- .../mappers/StudentDemoSessionMapper.xml | 36 +++++ .../stu/StudentDemoSessionControllerTest.java | 79 ++++++++++ .../AiTrainingEvaluationServiceImplTest.java | 13 ++ .../StudentDemoSessionServiceImplTest.java | 138 ++++++++++++++++++ .../StudentTeachingClassResolverImplTest.java | 81 ++++++++++ .../StudentTrainingAnswerServiceImplTest.java | 24 +++ 27 files changed, 881 insertions(+), 7 deletions(-) create mode 100644 docs/sql/2026-07-30-student-demo-session.sql create mode 100644 src/main/java/com/sztzjy/linkCommerce/controller/stu/StudentDemoSessionController.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/entity/StudentDemoSession.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionCreateRequest.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionExchangeRequest.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionLogin.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionTicket.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/mapper/StudentDemoSessionMapper.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/service/StudentDemoSessionService.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/service/StudentTeachingClassResolver.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/service/impl/StudentDemoSessionServiceImpl.java create mode 100644 src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTeachingClassResolverImpl.java create mode 100644 src/main/resources/mappers/StudentDemoSessionMapper.xml create mode 100644 src/test/java/com/sztzjy/linkCommerce/controller/stu/StudentDemoSessionControllerTest.java create mode 100644 src/test/java/com/sztzjy/linkCommerce/service/impl/StudentDemoSessionServiceImplTest.java create mode 100644 src/test/java/com/sztzjy/linkCommerce/service/impl/StudentTeachingClassResolverImplTest.java diff --git a/docs/sql/2026-07-30-student-demo-session.sql b/docs/sql/2026-07-30-student-demo-session.sql new file mode 100644 index 0000000..7a82ed6 --- /dev/null +++ b/docs/sql/2026-07-30-student-demo-session.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS student_demo_session ( + id varchar(64) NOT NULL, + ticket_hash char(64) NOT NULL, + teacher_user_id varchar(64) NOT NULL, + teacher_name varchar(128) NULL, + school_id varchar(64) NOT NULL, + teaching_class_id varchar(64) NOT NULL, + teaching_class_name varchar(255) NULL, + expires_at datetime NOT NULL, + used_at datetime NULL, + create_time datetime NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_student_demo_session_ticket_hash (ticket_hash), + KEY idx_student_demo_session_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/src/main/java/com/sztzjy/linkCommerce/config/security/JwtUser.java b/src/main/java/com/sztzjy/linkCommerce/config/security/JwtUser.java index 1cb870e..d2031d9 100644 --- a/src/main/java/com/sztzjy/linkCommerce/config/security/JwtUser.java +++ b/src/main/java/com/sztzjy/linkCommerce/config/security/JwtUser.java @@ -33,6 +33,9 @@ public class JwtUser implements UserDetails { private Date authorizeTime; private Date authorizeEndTime; private List authorityCodes; + private boolean demoMode; + private String demoTeachingClassId; + private String sourceTeacherId; @Override public Collection getAuthorities() { diff --git a/src/main/java/com/sztzjy/linkCommerce/config/security/TokenProvider.java b/src/main/java/com/sztzjy/linkCommerce/config/security/TokenProvider.java index e9e0243..be61c36 100644 --- a/src/main/java/com/sztzjy/linkCommerce/config/security/TokenProvider.java +++ b/src/main/java/com/sztzjy/linkCommerce/config/security/TokenProvider.java @@ -22,6 +22,7 @@ public class TokenProvider { private final static String SECRET = "MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAghgsx+OLLThi7c+/HgCaRla8n+/QLHt++uscoK22dMeaVa3WJvRb5C5qtHGzj9V74pI96JpxdWWCN3Zr1QtanQIDAQABAkB2fWRUD1jVMBVS+rPctLnPNPMKTRdzXGv0SC5D3tRfGZqdcfsmZg8hzBVndHxngmwZS73d+hPjofcdefDDnXq1AiEAz/r1AMF7fweN70mbKgBJuTja37puJMsH2Szwrvb5jlcCIQCgIaLZ5d9J7OomL0NwH8ghba/xgBEpKzErfGbFnTfeKwIgc8ptczFFV1DWZb2eJIEqinOr79l0jwl2YiQSD+cyw1sCIHqxCvv1Wx5jPgy/zeYNE+Q5DMP3Ii7u/x+Yk436JiI9AiEAio84hFm44YTUuV7YCYsbyvnCn/vtcAaqZlczbeHlP1I="; private final static long EXP_TIME = 1000 * 60 * 60 * 12; + private final static long DEMO_EXP_TIME = 1000 * 60 * 30; private final static String SECRET_ZHIYUN = "zy_szmy_mnjy_fp76ckwuczzmb67w0a8x0"; @@ -63,6 +64,9 @@ public class TokenProvider { jwtUser.setUsername(claims.get("username")==null?"":claims.get("username").toString()); jwtUser.setAuthorityCodes((List) claims.get("authorityCodes")); + jwtUser.setDemoMode(Boolean.TRUE.equals(claims.get("demoMode"))); + jwtUser.setDemoTeachingClassId(claims.get("demoTeachingClassId") == null ? null : claims.get("demoTeachingClassId").toString()); + jwtUser.setSourceTeacherId(claims.get("sourceTeacherId") == null ? null : claims.get("sourceTeacherId").toString()); return jwtUser; } catch (ExpiredJwtException e1) { throw new ExpiredJwtException(null, null, "token过期"); @@ -140,8 +144,16 @@ public class TokenProvider { public static String createToken(JwtUser jwtUser) { + return createToken(jwtUser, EXP_TIME); + } + + public static String createDemoToken(JwtUser jwtUser) { + return createToken(jwtUser, DEMO_EXP_TIME); + } + + private static String createToken(JwtUser jwtUser, long expirationMillis) { Key key = Keys.hmacShaKeyFor(SECRET.getBytes()); - Date expiration = new Date(System.currentTimeMillis() + EXP_TIME); + Date expiration = new Date(System.currentTimeMillis() + expirationMillis); return Jwts.builder() .claim("userId", jwtUser.getUserId()) .claim("roleId", jwtUser.getRoleId()) @@ -150,6 +162,9 @@ public class TokenProvider { .claim("schoolId", jwtUser.getSchoolId()) .claim("username", jwtUser.getUsername()) .claim("authorityCodes", jwtUser.getAuthorityCodes()) + .claim("demoMode", jwtUser.isDemoMode()) + .claim("demoTeachingClassId", jwtUser.getDemoTeachingClassId()) + .claim("sourceTeacherId", jwtUser.getSourceTeacherId()) .setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(expiration) .signWith(key, SignatureAlgorithm.HS512) diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/StudentDemoSessionController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/StudentDemoSessionController.java new file mode 100644 index 0000000..a31e476 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/StudentDemoSessionController.java @@ -0,0 +1,49 @@ +package com.sztzjy.linkCommerce.controller.stu; + +import com.sztzjy.linkCommerce.annotation.AnonymousAccess; +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.config.security.TokenProvider; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionCreateRequest; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionExchangeRequest; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionLogin; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionTicket; +import com.sztzjy.linkCommerce.service.StudentDemoSessionService; +import com.sztzjy.linkCommerce.util.ResultEntity; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletRequest; + +@Api(tags = "教师学生端讲解会话") +@RestController +public class StudentDemoSessionController { + @Autowired + public StudentDemoSessionService studentDemoSessionService; + + @PostMapping("/api/teacher/student-demo-sessions") + @ApiOperation("教师创建学生端讲解票据") + public ResultEntity create(@RequestBody StudentDemoSessionCreateRequest body, + HttpServletRequest request) { + StudentDemoSessionTicket ticket = studentDemoSessionService.create(currentUser(request), + body == null ? null : body.getTeachingClassId()); + return new ResultEntity<>(HttpStatus.OK, "Demo ticket created", ticket); + } + + @PostMapping("/api/student-demo-sessions/exchange") + @AnonymousAccess + @ApiOperation("学生端讲解票据兑换") + public ResultEntity exchange(@RequestBody StudentDemoSessionExchangeRequest body) { + StudentDemoSessionLogin login = studentDemoSessionService.exchange(body == null ? null : body.getTicket()); + return new ResultEntity<>(HttpStatus.OK, "Demo session created", login); + } + + protected JwtUser currentUser(HttpServletRequest request) { + return TokenProvider.getJWTUser(request); + } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/TaskAllocationController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/TaskAllocationController.java index afd43e2..8b99181 100644 --- a/src/main/java/com/sztzjy/linkCommerce/controller/stu/TaskAllocationController.java +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/TaskAllocationController.java @@ -1,6 +1,8 @@ package com.sztzjy.linkCommerce.controller.stu; import com.sztzjy.linkCommerce.annotation.AnonymousAccess; +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.config.security.TokenProvider; import com.sztzjy.linkCommerce.entity.SchoolClass; import com.sztzjy.linkCommerce.entity.TaskAllocation; import com.sztzjy.linkCommerce.entity.TaskAllocationExample; @@ -9,6 +11,7 @@ import com.sztzjy.linkCommerce.mapper.SchoolClassMapper; import com.sztzjy.linkCommerce.mapper.TaskAllocationMapper; import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper; import com.sztzjy.linkCommerce.service.SchoolDefaultTaskService; +import com.sztzjy.linkCommerce.service.StudentTeachingClassResolver; import com.sztzjy.linkCommerce.util.ResultEntity; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; @@ -17,6 +20,8 @@ import org.springframework.http.HttpStatus; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.*; +import javax.servlet.http.HttpServletRequest; + import java.util.Collections; import java.util.List; import java.util.UUID; @@ -33,6 +38,8 @@ public class TaskAllocationController { TeachingClassStudentMapper teachingClassStudentMapper; @Autowired(required = false) SchoolDefaultTaskService schoolDefaultTaskService; + @Autowired(required = false) + StudentTeachingClassResolver studentTeachingClassResolver; @PostMapping("/selectTaskAllocationByClassId") @ApiOperation("根据班级ID查询任务分配") @@ -57,7 +64,12 @@ public class TaskAllocationController { @PostMapping("/selectTaskAllocationByStudentUserId") @ApiOperation("学生端-根据当前教学班查询任务分配") @AnonymousAccess - public ResultEntity> selectTaskAllocationByStudentUserId(@RequestParam String userId) { + public ResultEntity> selectTaskAllocationByStudentUserId(@RequestParam String userId, + HttpServletRequest request) { + JwtUser currentUser = currentStudent(request); + if (currentUser != null && studentTeachingClassResolver != null) { + return selectTaskAllocationByClassId(studentTeachingClassResolver.resolveRequired(currentUser)); + } TeachingClassStudent member = teachingClassStudentMapper == null ? null : teachingClassStudentMapper.selectActiveByStudentUserId(userId); @@ -67,6 +79,15 @@ public class TaskAllocationController { return selectTaskAllocationByClassId(member.getTeachingClassId()); } + private JwtUser currentStudent(HttpServletRequest request) { + try { + JwtUser user = TokenProvider.getJWTUser(request); + return user != null && user.getRoleId() == 4 ? user : null; + } catch (RuntimeException ignored) { + return null; + } + } + @PostMapping("/updateTaskAllocationByClassId") @ApiOperation("编辑任务分配") @AnonymousAccess diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/TrainingTaskController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/TrainingTaskController.java index 34f17c7..3e1a24f 100644 --- a/src/main/java/com/sztzjy/linkCommerce/controller/stu/TrainingTaskController.java +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/TrainingTaskController.java @@ -42,7 +42,7 @@ public class TrainingTaskController { JwtUser user = TokenProvider.getJWTUser(request); if (isStudent(user)) { return new ResultEntity<>(HttpStatus.OK, "Query success", - trainingTaskService.listForStudent(user.getUserId(), enabledOnly)); + trainingTaskService.listForStudent(user, enabledOnly)); } return new ResultEntity<>(HttpStatus.OK, "Query success", trainingTaskService.listDefaults(projectName, taskName, enabledOnly)); @@ -64,7 +64,7 @@ public class TrainingTaskController { public ResultEntity detailByTaskKey(@PathVariable String taskKey, HttpServletRequest request) { JwtUser user = TokenProvider.getJWTUser(request); TrainingTask task = isStudent(user) - ? trainingTaskService.getStudentTaskByTaskKey(taskKey, user.getUserId()) + ? trainingTaskService.getStudentTaskByTaskKey(taskKey, user) : trainingTaskService.getByTaskKey(taskKey); if (task == null) { return new ResultEntity<>(HttpStatus.OK, "Training task not configured", null); diff --git a/src/main/java/com/sztzjy/linkCommerce/controller/stu/UserController.java b/src/main/java/com/sztzjy/linkCommerce/controller/stu/UserController.java index 86e1c86..e05cf48 100644 --- a/src/main/java/com/sztzjy/linkCommerce/controller/stu/UserController.java +++ b/src/main/java/com/sztzjy/linkCommerce/controller/stu/UserController.java @@ -20,6 +20,7 @@ import com.sztzjy.linkCommerce.entity.importDto.TeacherImportDTO; import com.sztzjy.linkCommerce.mapper.*; import com.sztzjy.linkCommerce.service.ImportService; import com.sztzjy.linkCommerce.service.SchoolService; +import com.sztzjy.linkCommerce.service.StudentTeachingClassResolver; import com.sztzjy.linkCommerce.service.SchoolProductConfigService; import com.sztzjy.linkCommerce.service.UserInfoService; import com.sztzjy.linkCommerce.service.ValidateService; @@ -69,6 +70,8 @@ public class UserController { ValidateService validateService; @Autowired ImportService importService; + @Autowired(required = false) + StudentTeachingClassResolver studentTeachingClassResolver; @PostMapping("/addSchool") @@ -2164,7 +2167,16 @@ public class UserController { @PostMapping("/selectCurrentTeachingClass") @ApiOperation("学生-查询当前在读教学班") @AnonymousAccess - public ResultEntity selectCurrentTeachingClass(@RequestParam String userId) { + public ResultEntity selectCurrentTeachingClass(@RequestParam String userId, HttpServletRequest request) { + try { + JwtUser currentUser = TokenProvider.getJWTUser(request); + if (currentUser != null && currentUser.getRoleId() == 4 && studentTeachingClassResolver != null) { + SchoolClass teachingClass = schoolClassMapper.selectByPrimaryKey(studentTeachingClassResolver.resolveRequired(currentUser)); + return new ResultEntity<>(HttpStatus.OK, "查询成功", teachingClass); + } + } catch (RuntimeException ignored) { + // 保留旧版匿名调用的兼容路径。 + } TeachingClassStudent member = teachingClassStudentMapper == null ? null : teachingClassStudentMapper.selectActiveByStudentUserId(userId); diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/StudentDemoSession.java b/src/main/java/com/sztzjy/linkCommerce/entity/StudentDemoSession.java new file mode 100644 index 0000000..30355f9 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/entity/StudentDemoSession.java @@ -0,0 +1,37 @@ +package com.sztzjy.linkCommerce.entity; + +import java.util.Date; + +public class StudentDemoSession { + private String id; + private String ticketHash; + private String teacherUserId; + private String teacherName; + private String schoolId; + private String teachingClassId; + private String teachingClassName; + private Date expiresAt; + private Date usedAt; + private Date createTime; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getTicketHash() { return ticketHash; } + public void setTicketHash(String ticketHash) { this.ticketHash = ticketHash; } + public String getTeacherUserId() { return teacherUserId; } + public void setTeacherUserId(String teacherUserId) { this.teacherUserId = teacherUserId; } + public String getTeacherName() { return teacherName; } + public void setTeacherName(String teacherName) { this.teacherName = teacherName; } + public String getSchoolId() { return schoolId; } + public void setSchoolId(String schoolId) { this.schoolId = schoolId; } + public String getTeachingClassId() { return teachingClassId; } + public void setTeachingClassId(String teachingClassId) { this.teachingClassId = teachingClassId; } + public String getTeachingClassName() { return teachingClassName; } + public void setTeachingClassName(String teachingClassName) { this.teachingClassName = teachingClassName; } + public Date getExpiresAt() { return expiresAt; } + public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; } + public Date getUsedAt() { return usedAt; } + public void setUsedAt(Date usedAt) { this.usedAt = usedAt; } + public Date getCreateTime() { return createTime; } + public void setCreateTime(Date createTime) { this.createTime = createTime; } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionCreateRequest.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionCreateRequest.java new file mode 100644 index 0000000..3371013 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionCreateRequest.java @@ -0,0 +1,8 @@ +package com.sztzjy.linkCommerce.entity.dto; + +public class StudentDemoSessionCreateRequest { + private String teachingClassId; + + public String getTeachingClassId() { return teachingClassId; } + public void setTeachingClassId(String teachingClassId) { this.teachingClassId = teachingClassId; } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionExchangeRequest.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionExchangeRequest.java new file mode 100644 index 0000000..15574a9 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionExchangeRequest.java @@ -0,0 +1,8 @@ +package com.sztzjy.linkCommerce.entity.dto; + +public class StudentDemoSessionExchangeRequest { + private String ticket; + + public String getTicket() { return ticket; } + public void setTicket(String ticket) { this.ticket = ticket; } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionLogin.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionLogin.java new file mode 100644 index 0000000..f356d0a --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionLogin.java @@ -0,0 +1,35 @@ +package com.sztzjy.linkCommerce.entity.dto; + +public class StudentDemoSessionLogin { + private String token; + private String userId; + private String name; + private String username; + private String roleId; + private String schoolId; + private String classId; + private String className; + private boolean demoMode; + private String demoTeachingClassId; + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getUsername() { return username; } + public void setUsername(String username) { this.username = username; } + public String getRoleId() { return roleId; } + public void setRoleId(String roleId) { this.roleId = roleId; } + public String getSchoolId() { return schoolId; } + public void setSchoolId(String schoolId) { this.schoolId = schoolId; } + public String getClassId() { return classId; } + public void setClassId(String classId) { this.classId = classId; } + public String getClassName() { return className; } + public void setClassName(String className) { this.className = className; } + public boolean isDemoMode() { return demoMode; } + public void setDemoMode(boolean demoMode) { this.demoMode = demoMode; } + public String getDemoTeachingClassId() { return demoTeachingClassId; } + public void setDemoTeachingClassId(String demoTeachingClassId) { this.demoTeachingClassId = demoTeachingClassId; } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionTicket.java b/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionTicket.java new file mode 100644 index 0000000..9c5d4e5 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/entity/dto/StudentDemoSessionTicket.java @@ -0,0 +1,19 @@ +package com.sztzjy.linkCommerce.entity.dto; + +import java.util.Date; + +public class StudentDemoSessionTicket { + private String ticket; + private Date expiresAt; + private String teachingClassId; + private String className; + + public String getTicket() { return ticket; } + public void setTicket(String ticket) { this.ticket = ticket; } + public Date getExpiresAt() { return expiresAt; } + public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; } + public String getTeachingClassId() { return teachingClassId; } + public void setTeachingClassId(String teachingClassId) { this.teachingClassId = teachingClassId; } + public String getClassName() { return className; } + public void setClassName(String className) { this.className = className; } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/mapper/StudentDemoSessionMapper.java b/src/main/java/com/sztzjy/linkCommerce/mapper/StudentDemoSessionMapper.java new file mode 100644 index 0000000..475beb2 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/mapper/StudentDemoSessionMapper.java @@ -0,0 +1,16 @@ +package com.sztzjy.linkCommerce.mapper; + +import com.sztzjy.linkCommerce.entity.StudentDemoSession; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Date; + +@Mapper +public interface StudentDemoSessionMapper { + int insertSelective(StudentDemoSession record); + + StudentDemoSession selectByTicketHash(@Param("ticketHash") String ticketHash); + + int markUsedIfActive(@Param("id") String id, @Param("usedAt") Date usedAt); +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/StudentDemoSessionService.java b/src/main/java/com/sztzjy/linkCommerce/service/StudentDemoSessionService.java new file mode 100644 index 0000000..58f1cd7 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/service/StudentDemoSessionService.java @@ -0,0 +1,11 @@ +package com.sztzjy.linkCommerce.service; + +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionLogin; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionTicket; + +public interface StudentDemoSessionService { + StudentDemoSessionTicket create(JwtUser teacher, String teachingClassId); + + StudentDemoSessionLogin exchange(String ticket); +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/StudentTeachingClassResolver.java b/src/main/java/com/sztzjy/linkCommerce/service/StudentTeachingClassResolver.java new file mode 100644 index 0000000..03ce477 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/service/StudentTeachingClassResolver.java @@ -0,0 +1,9 @@ +package com.sztzjy.linkCommerce.service; + +import com.sztzjy.linkCommerce.config.security.JwtUser; + +public interface StudentTeachingClassResolver { + String resolveRequired(JwtUser user); + + String resolveRequested(JwtUser user, String requestedTeachingClassId); +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/TrainingTaskService.java b/src/main/java/com/sztzjy/linkCommerce/service/TrainingTaskService.java index 1561442..6345583 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/TrainingTaskService.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/TrainingTaskService.java @@ -1,5 +1,6 @@ package com.sztzjy.linkCommerce.service; +import com.sztzjy.linkCommerce.config.security.JwtUser; import com.sztzjy.linkCommerce.entity.TrainingTask; import com.sztzjy.linkCommerce.entity.importDto.TrainingTaskImportDTO; @@ -38,5 +39,9 @@ public interface TrainingTaskService { TrainingTask getStudentTaskByTaskKey(String taskKey, String studentUserId); + TrainingTask getStudentTaskByTaskKey(String taskKey, JwtUser student); + List listForStudent(String studentUserId, Boolean enabledOnly); + + List listForStudent(JwtUser student, Boolean enabledOnly); } diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java index 2024178..5bca9fb 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImpl.java @@ -132,6 +132,7 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ private EvaluationContext resolveContext(String taskKey, JwtUser user, boolean requireAnswer) { requireStudent(user); + requireNotDemo(user); TrainingTask task = resolveTask(taskKey); TeachingClassStudent member = teachingClassStudentMapper.selectActiveByStudentUserId(user.getUserId()); if (member == null || StringUtils.isBlank(member.getTeachingClassId())) { @@ -201,6 +202,12 @@ public class AiTrainingEvaluationServiceImpl implements AiTrainingEvaluationServ } } + private void requireNotDemo(JwtUser user) { + if (user != null && user.isDemoMode()) { + throw new ServiceException(HttpStatus.FORBIDDEN, "演示模式不保存数据"); + } + } + private TrainingTask resolveTask(String taskKey) { String normalizedTaskKey = StringUtils.trimToEmpty(taskKey); if (StringUtils.isBlank(normalizedTaskKey)) { diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentDemoSessionServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentDemoSessionServiceImpl.java new file mode 100644 index 0000000..a3e65f9 --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentDemoSessionServiceImpl.java @@ -0,0 +1,131 @@ +package com.sztzjy.linkCommerce.service.impl; + +import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.config.security.TokenProvider; +import com.sztzjy.linkCommerce.entity.SchoolClass; +import com.sztzjy.linkCommerce.entity.StudentDemoSession; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionLogin; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionTicket; +import com.sztzjy.linkCommerce.mapper.SchoolClassMapper; +import com.sztzjy.linkCommerce.mapper.StudentDemoSessionMapper; +import com.sztzjy.linkCommerce.service.StudentDemoSessionService; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Date; +import java.util.UUID; + +@Service +public class StudentDemoSessionServiceImpl implements StudentDemoSessionService { + private static final long TICKET_EXPIRATION_MILLIS = 5 * 60 * 1000L; + private static final SecureRandom RANDOM = new SecureRandom(); + + @Autowired + public SchoolClassMapper schoolClassMapper; + @Autowired + public StudentDemoSessionMapper studentDemoSessionMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public StudentDemoSessionTicket create(JwtUser teacher, String teachingClassId) { + requireTeacher(teacher); + SchoolClass schoolClass = schoolClassMapper.selectByPrimaryKey(StringUtils.trimToEmpty(teachingClassId)); + if (schoolClass == null || !"TEACHING".equals(schoolClass.getClassType()) + || !StringUtils.equals(teacher.getUserId(), schoolClass.getCreatedBy()) + || !StringUtils.equals(teacher.getSchoolId(), schoolClass.getSchoolId())) { + throw new ServiceException(HttpStatus.FORBIDDEN, "Only the creator can demonstrate this teaching class"); + } + + Date now = new Date(); + Date expiresAt = new Date(now.getTime() + TICKET_EXPIRATION_MILLIS); + String ticket = nextTicket(); + StudentDemoSession session = new StudentDemoSession(); + session.setId(UUID.randomUUID().toString()); + session.setTicketHash(hash(ticket)); + session.setTeacherUserId(teacher.getUserId()); + session.setTeacherName(teacher.getName()); + session.setSchoolId(teacher.getSchoolId()); + session.setTeachingClassId(schoolClass.getSchoolClassId()); + session.setTeachingClassName(schoolClass.getClassName()); + session.setExpiresAt(expiresAt); + session.setCreateTime(now); + studentDemoSessionMapper.insertSelective(session); + + StudentDemoSessionTicket result = new StudentDemoSessionTicket(); + result.setTicket(ticket); + result.setExpiresAt(expiresAt); + result.setTeachingClassId(session.getTeachingClassId()); + result.setClassName(session.getTeachingClassName()); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public StudentDemoSessionLogin exchange(String ticket) { + String normalizedTicket = StringUtils.trimToEmpty(ticket); + if (StringUtils.isBlank(normalizedTicket)) { + throw new ServiceException(HttpStatus.BAD_REQUEST, "Demo ticket is required"); + } + StudentDemoSession session = studentDemoSessionMapper.selectByTicketHash(hash(normalizedTicket)); + Date now = new Date(); + if (session == null || session.getUsedAt() != null || session.getExpiresAt() == null || !session.getExpiresAt().after(now) + || studentDemoSessionMapper.markUsedIfActive(session.getId(), now) != 1) { + throw new ServiceException(HttpStatus.FORBIDDEN, "Demo ticket is invalid or expired"); + } + JwtUser demoUser = new JwtUser(); + demoUser.setUserId("demo:" + session.getId()); + demoUser.setName(StringUtils.defaultIfBlank(session.getTeacherName(), "Teacher") + "(演示)"); + demoUser.setUsername("demo:" + session.getTeacherUserId()); + demoUser.setRoleId(4); + demoUser.setSchoolId(session.getSchoolId()); + demoUser.setDemoMode(true); + demoUser.setDemoTeachingClassId(session.getTeachingClassId()); + demoUser.setSourceTeacherId(session.getTeacherUserId()); + + StudentDemoSessionLogin result = new StudentDemoSessionLogin(); + result.setToken(TokenProvider.createDemoToken(demoUser)); + result.setUserId(demoUser.getUserId()); + result.setName(demoUser.getName()); + result.setUsername(demoUser.getUsername()); + result.setRoleId("4"); + result.setSchoolId(session.getSchoolId()); + result.setClassId(session.getTeachingClassId()); + result.setClassName(session.getTeachingClassName()); + result.setDemoMode(true); + result.setDemoTeachingClassId(session.getTeachingClassId()); + return result; + } + + private void requireTeacher(JwtUser teacher) { + if (teacher == null || teacher.getRoleId() != 3 || StringUtils.isBlank(teacher.getUserId()) || StringUtils.isBlank(teacher.getSchoolId())) { + throw new ServiceException(HttpStatus.FORBIDDEN, "Teacher only"); + } + } + + private String nextTicket() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String hash(String value) { + try { + byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(bytes.length * 2); + for (byte valueByte : bytes) { + builder.append(String.format("%02x", valueByte)); + } + return builder.toString(); + } catch (Exception e) { + throw new IllegalStateException("Unable to hash demo ticket", e); + } + } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTeachingClassResolverImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTeachingClassResolverImpl.java new file mode 100644 index 0000000..031a1ad --- /dev/null +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTeachingClassResolverImpl.java @@ -0,0 +1,65 @@ +package com.sztzjy.linkCommerce.service.impl; + +import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.entity.SchoolClass; +import com.sztzjy.linkCommerce.entity.TeachingClassStudent; +import com.sztzjy.linkCommerce.mapper.SchoolClassMapper; +import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper; +import com.sztzjy.linkCommerce.service.StudentTeachingClassResolver; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service +public class StudentTeachingClassResolverImpl implements StudentTeachingClassResolver { + @Autowired + public SchoolClassMapper schoolClassMapper; + @Autowired + public TeachingClassStudentMapper teachingClassStudentMapper; + + @Override + public String resolveRequired(JwtUser user) { + requireStudent(user); + String teachingClassId; + if (user.isDemoMode()) { + teachingClassId = StringUtils.trimToNull(user.getDemoTeachingClassId()); + if (teachingClassId == null) { + throw new ServiceException(HttpStatus.FORBIDDEN, "Demo teaching class is required"); + } + } else { + TeachingClassStudent membership = teachingClassStudentMapper.selectActiveByStudentUserId(user.getUserId()); + teachingClassId = membership == null ? null : StringUtils.trimToNull(membership.getTeachingClassId()); + if (teachingClassId == null) { + throw new ServiceException(HttpStatus.BAD_REQUEST, "Student has no active teaching class"); + } + } + validateTeachingClass(user, teachingClassId); + return teachingClassId; + } + + @Override + public String resolveRequested(JwtUser user, String requestedTeachingClassId) { + String resolved = resolveRequired(user); + String requested = StringUtils.trimToNull(requestedTeachingClassId); + if (requested != null && !StringUtils.equals(resolved, requested)) { + throw new ServiceException(HttpStatus.FORBIDDEN, "Teaching class does not match current student context"); + } + return resolved; + } + + private void validateTeachingClass(JwtUser user, String teachingClassId) { + SchoolClass schoolClass = schoolClassMapper.selectByPrimaryKey(teachingClassId); + if (schoolClass == null || !"TEACHING".equals(schoolClass.getClassType()) + || !StringUtils.equals(user.getSchoolId(), schoolClass.getSchoolId())) { + throw new ServiceException(HttpStatus.FORBIDDEN, "Teaching class is unavailable"); + } + } + + private void requireStudent(JwtUser user) { + if (user == null || user.getRoleId() != 4 || StringUtils.isBlank(user.getUserId())) { + throw new ServiceException(HttpStatus.FORBIDDEN, "Student only"); + } + } +} diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImpl.java index 1fb8e40..5ac6db4 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImpl.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImpl.java @@ -46,6 +46,7 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe public StudentTrainingAnswer get(String taskKey, String teachingClassId, JwtUser user) { ensureStudentTrainingAnswerTable(); requireStudent(user); + requireNotDemo(user); TrainingTask task = resolveTask(taskKey); String resolvedTeachingClassId = resolveTeachingClassId(user.getUserId(), teachingClassId); return studentTrainingAnswerMapper.selectByStudentClassAndTask( @@ -57,6 +58,7 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe public StudentTrainingAnswer save(String taskKey, StudentTrainingAnswer answer, JwtUser user) { ensureStudentTrainingAnswerTable(); requireStudent(user); + requireNotDemo(user); TrainingTask task = resolveTask(taskKey); String resolvedTeachingClassId = resolveTeachingClassId(user.getUserId(), answer == null ? null : answer.getTeachingClassId()); @@ -89,6 +91,7 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe public void delete(String taskKey, String teachingClassId, JwtUser user) { ensureStudentTrainingAnswerTable(); requireStudent(user); + requireNotDemo(user); TrainingTask task = resolveTask(taskKey); String resolvedTeachingClassId = resolveTeachingClassId(user.getUserId(), teachingClassId); studentTrainingAnswerMapper.deleteByStudentClassAndTask(user.getUserId(), resolvedTeachingClassId, task.getId()); @@ -121,6 +124,12 @@ public class StudentTrainingAnswerServiceImpl implements StudentTrainingAnswerSe return task; } + private void requireNotDemo(JwtUser user) { + if (user != null && user.isDemoMode()) { + throw new ServiceException(HttpStatus.FORBIDDEN, "演示模式不保存数据"); + } + } + private String resolveTeachingClassId(String studentUserId, String requestedTeachingClassId) { String trimmedClassId = StringUtils.trimToNull(requestedTeachingClassId); if (trimmedClassId != null) { diff --git a/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingTaskServiceImpl.java b/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingTaskServiceImpl.java index 0956318..9d79d25 100644 --- a/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingTaskServiceImpl.java +++ b/src/main/java/com/sztzjy/linkCommerce/service/impl/TrainingTaskServiceImpl.java @@ -1,6 +1,7 @@ package com.sztzjy.linkCommerce.service.impl; import com.alibaba.fastjson.JSON; +import com.sztzjy.linkCommerce.config.security.JwtUser; import com.sztzjy.linkCommerce.entity.SchoolClass; import com.sztzjy.linkCommerce.entity.SchoolClassExample; import com.sztzjy.linkCommerce.entity.TeachingClassStudent; @@ -12,6 +13,7 @@ import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper; import com.sztzjy.linkCommerce.mapper.TrainingTaskClassConfigMapper; import com.sztzjy.linkCommerce.mapper.TrainingTaskMapper; import com.sztzjy.linkCommerce.service.TrainingTaskService; +import com.sztzjy.linkCommerce.service.StudentTeachingClassResolver; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; @@ -46,6 +48,9 @@ public class TrainingTaskServiceImpl implements TrainingTaskService { @Autowired public TeachingClassStudentMapper teachingClassStudentMapper; + @Autowired(required = false) + public StudentTeachingClassResolver studentTeachingClassResolver; + @Autowired(required = false) public JdbcTemplate jdbcTemplate; @@ -266,9 +271,17 @@ public class TrainingTaskServiceImpl implements TrainingTaskService { @Override public TrainingTask getStudentTaskByTaskKey(String taskKey, String studentUserId) { + return getStudentTaskByTeachingClass(taskKey, resolveStudentTeachingClassId(studentUserId)); + } + + @Override + public TrainingTask getStudentTaskByTaskKey(String taskKey, JwtUser student) { + return getStudentTaskByTeachingClass(taskKey, requireStudentTeachingClass(student)); + } + + private TrainingTask getStudentTaskByTeachingClass(String taskKey, String teachingClassId) { ensureDefaultTasks(null); String normalizedTaskKey = StringUtils.trimToEmpty(taskKey); - String teachingClassId = resolveStudentTeachingClassId(studentUserId); TrainingTask defaultTask = trainingTaskMapper.selectByTaskKey(normalizedTaskKey); if (StringUtils.isNotBlank(teachingClassId)) { ensureClassTasksInitialized(teachingClassId, trainingTaskMapper.selectList(null, null, null), null); @@ -282,9 +295,17 @@ public class TrainingTaskServiceImpl implements TrainingTaskService { @Override public List listForStudent(String studentUserId, Boolean enabledOnly) { + return listForStudentTeachingClass(resolveStudentTeachingClassId(studentUserId), enabledOnly); + } + + @Override + public List listForStudent(JwtUser student, Boolean enabledOnly) { + return listForStudentTeachingClass(requireStudentTeachingClass(student), enabledOnly); + } + + private List listForStudentTeachingClass(String teachingClassId, Boolean enabledOnly) { ensureDefaultTasks(null); List defaults = trainingTaskMapper.selectList(null, null, null); - String teachingClassId = resolveStudentTeachingClassId(studentUserId); if (StringUtils.isBlank(teachingClassId)) { return filterEnabled(sortTasks(defaults), enabledOnly); } @@ -292,6 +313,13 @@ public class TrainingTaskServiceImpl implements TrainingTaskService { return filterEnabled(toTasks(classTasks), enabledOnly); } + private String requireStudentTeachingClass(JwtUser student) { + if (studentTeachingClassResolver == null) { + return resolveStudentTeachingClassId(student == null ? null : student.getUserId()); + } + return studentTeachingClassResolver.resolveRequired(student); + } + private SchoolClass requireOwnedTeachingClass(String teachingClassId, String operatorId) { SchoolClass schoolClass = schoolClassMapper.selectByPrimaryKey(StringUtils.trimToEmpty(teachingClassId)); if (schoolClass == null || !"TEACHING".equals(schoolClass.getClassType())) { diff --git a/src/main/resources/mappers/StudentDemoSessionMapper.xml b/src/main/resources/mappers/StudentDemoSessionMapper.xml new file mode 100644 index 0000000..7b8b182 --- /dev/null +++ b/src/main/resources/mappers/StudentDemoSessionMapper.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + insert into student_demo_session + (id, ticket_hash, teacher_user_id, teacher_name, school_id, teaching_class_id, teaching_class_name, expires_at, used_at, create_time) + values (#{id,jdbcType=VARCHAR}, #{ticketHash,jdbcType=VARCHAR}, #{teacherUserId,jdbcType=VARCHAR}, + #{teacherName,jdbcType=VARCHAR}, #{schoolId,jdbcType=VARCHAR}, #{teachingClassId,jdbcType=VARCHAR}, + #{teachingClassName,jdbcType=VARCHAR}, #{expiresAt,jdbcType=TIMESTAMP}, #{usedAt,jdbcType=TIMESTAMP}, + #{createTime,jdbcType=TIMESTAMP}) + + + + update student_demo_session set used_at = #{usedAt,jdbcType=TIMESTAMP} + where id = #{id,jdbcType=VARCHAR} and used_at is null and expires_at > #{usedAt,jdbcType=TIMESTAMP} + + diff --git a/src/test/java/com/sztzjy/linkCommerce/controller/stu/StudentDemoSessionControllerTest.java b/src/test/java/com/sztzjy/linkCommerce/controller/stu/StudentDemoSessionControllerTest.java new file mode 100644 index 0000000..22652cc --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/controller/stu/StudentDemoSessionControllerTest.java @@ -0,0 +1,79 @@ +package com.sztzjy.linkCommerce.controller.stu; + +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionCreateRequest; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionExchangeRequest; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionLogin; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionTicket; +import com.sztzjy.linkCommerce.service.StudentDemoSessionService; +import com.sztzjy.linkCommerce.util.ResultEntity; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +import javax.servlet.http.HttpServletRequest; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class StudentDemoSessionControllerTest { + + @Test + void teacherCanCreateDemoTicketForSelectedTeachingClass() { + TestController controller = new TestController(teacher()); + controller.studentDemoSessionService = mock(StudentDemoSessionService.class); + StudentDemoSessionTicket ticket = new StudentDemoSessionTicket(); + ticket.setTicket("one-time-ticket"); + ticket.setTeachingClassId("class-1"); + ticket.setExpiresAt(new Date()); + when(controller.studentDemoSessionService.create(controller.user, "class-1")).thenReturn(ticket); + StudentDemoSessionCreateRequest body = new StudentDemoSessionCreateRequest(); + body.setTeachingClassId("class-1"); + + ResultEntity result = controller.create(body, mock(HttpServletRequest.class)); + + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("one-time-ticket", result.getBody().getData().getTicket()); + verify(controller.studentDemoSessionService).create(controller.user, "class-1"); + } + + @Test + void exchangeDoesNotAcceptClassOrTeacherFromBrowser() { + StudentDemoSessionController controller = new StudentDemoSessionController(); + controller.studentDemoSessionService = mock(StudentDemoSessionService.class); + StudentDemoSessionLogin login = new StudentDemoSessionLogin(); + login.setToken("demo-token"); + when(controller.studentDemoSessionService.exchange("one-time-ticket")).thenReturn(login); + StudentDemoSessionExchangeRequest body = new StudentDemoSessionExchangeRequest(); + body.setTicket("one-time-ticket"); + + ResultEntity result = controller.exchange(body); + + assertEquals(HttpStatus.OK, result.getStatusCode()); + assertEquals("demo-token", result.getBody().getData().getToken()); + verify(controller.studentDemoSessionService).exchange("one-time-ticket"); + } + + private JwtUser teacher() { + JwtUser user = new JwtUser(); + user.setUserId("teacher-1"); + user.setRoleId(3); + user.setSchoolId("school-1"); + return user; + } + + private static class TestController extends StudentDemoSessionController { + private final JwtUser user; + + private TestController(JwtUser user) { + this.user = user; + } + + @Override + protected JwtUser currentUser(HttpServletRequest request) { + return user; + } + } +} diff --git a/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java b/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java index d87dd0a..0df2bc4 100644 --- a/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java +++ b/src/test/java/com/sztzjy/linkCommerce/service/impl/AiTrainingEvaluationServiceImplTest.java @@ -188,6 +188,19 @@ class AiTrainingEvaluationServiceImplTest { verify(service.qwenChatClient, never()).completeJson(any(), any()); } + @Test + void demoStudentCannotCallAiHelpOrCreateEvaluationData() { + AiTrainingEvaluationServiceImpl service = serviceWithContext(answer("answer-1", "step one", null, null, null)); + JwtUser demo = student(); + demo.setDemoMode(true); + demo.setDemoTeachingClassId("class-1"); + + assertThrows(ServiceException.class, () -> service.generateHelp("new-product-survey", demo)); + + verify(service.qwenChatClient, never()).completeJson(any(), any()); + verify(service.aiTrainingEvaluationMapper, never()).insertIgnore(any()); + } + @Test void persistsIndependentHelpAndAssessmentFieldsAndAnswerScore() { AiTrainingEvaluation evaluation = new AiTrainingEvaluation(); diff --git a/src/test/java/com/sztzjy/linkCommerce/service/impl/StudentDemoSessionServiceImplTest.java b/src/test/java/com/sztzjy/linkCommerce/service/impl/StudentDemoSessionServiceImplTest.java new file mode 100644 index 0000000..670fc4b --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/service/impl/StudentDemoSessionServiceImplTest.java @@ -0,0 +1,138 @@ +package com.sztzjy.linkCommerce.service.impl; + +import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.config.security.TokenProvider; +import com.sztzjy.linkCommerce.entity.SchoolClass; +import com.sztzjy.linkCommerce.entity.StudentDemoSession; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionLogin; +import com.sztzjy.linkCommerce.entity.dto.StudentDemoSessionTicket; +import com.sztzjy.linkCommerce.mapper.SchoolClassMapper; +import com.sztzjy.linkCommerce.mapper.StudentDemoSessionMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class StudentDemoSessionServiceImplTest { + + @Test + void createsShortLivedTicketForTeachersOwnTeachingClass() { + StudentDemoSessionServiceImpl service = buildService(); + when(service.schoolClassMapper.selectByPrimaryKey("class-1")).thenReturn(teachingClass()); + + StudentDemoSessionTicket result = service.create(teacher(), "class-1"); + + assertNotNull(result.getTicket()); + assertTrue(result.getTicket().length() >= 40); + assertEquals("class-1", result.getTeachingClassId()); + assertEquals("Teaching Class 1", result.getClassName()); + assertTrue(result.getExpiresAt().after(new Date())); + + ArgumentCaptor stored = ArgumentCaptor.forClass(StudentDemoSession.class); + verify(service.studentDemoSessionMapper).insertSelective(stored.capture()); + assertEquals("teacher-1", stored.getValue().getTeacherUserId()); + assertEquals("school-1", stored.getValue().getSchoolId()); + assertEquals("class-1", stored.getValue().getTeachingClassId()); + assertNotEquals(result.getTicket(), stored.getValue().getTicketHash()); + assertEquals(64, stored.getValue().getTicketHash().length()); + } + + @Test + void rejectsTeacherTryingToCreateDemoForAnotherTeachersClass() { + StudentDemoSessionServiceImpl service = buildService(); + SchoolClass anotherTeachersClass = teachingClass(); + anotherTeachersClass.setCreatedBy("teacher-2"); + when(service.schoolClassMapper.selectByPrimaryKey("class-1")).thenReturn(anotherTeachersClass); + + assertThrows(ServiceException.class, () -> service.create(teacher(), "class-1")); + } + + @Test + void rejectsAdministrativeClassForDemo() { + StudentDemoSessionServiceImpl service = buildService(); + SchoolClass administrativeClass = teachingClass(); + administrativeClass.setClassType("ADMIN"); + when(service.schoolClassMapper.selectByPrimaryKey("class-1")).thenReturn(administrativeClass); + + assertThrows(ServiceException.class, () -> service.create(teacher(), "class-1")); + } + + @Test + void exchangesUnusedTicketExactlyOnceForDemoStudentToken() { + StudentDemoSessionServiceImpl service = buildService(); + StudentDemoSession session = activeSession(); + when(service.studentDemoSessionMapper.selectByTicketHash(any())).thenReturn(session); + when(service.studentDemoSessionMapper.markUsedIfActive(eq("session-1"), any(Date.class))).thenReturn(1); + + StudentDemoSessionLogin result = service.exchange("ticket-from-browser"); + JwtUser tokenUser = TokenProvider.getJWTUser(result.getToken()); + + assertEquals(4, tokenUser.getRoleId()); + assertTrue(tokenUser.isDemoMode()); + assertEquals("class-1", tokenUser.getDemoTeachingClassId()); + assertEquals("teacher-1", tokenUser.getSourceTeacherId()); + assertEquals("class-1", result.getDemoTeachingClassId()); + assertEquals("Teaching Class 1", result.getClassName()); + verify(service.studentDemoSessionMapper).markUsedIfActive(eq("session-1"), any(Date.class)); + } + + @Test + void rejectsTicketThatWasAlreadyExchanged() { + StudentDemoSessionServiceImpl service = buildService(); + when(service.studentDemoSessionMapper.selectByTicketHash(any())).thenReturn(activeSession()); + when(service.studentDemoSessionMapper.markUsedIfActive(eq("session-1"), any(Date.class))).thenReturn(0); + + assertThrows(ServiceException.class, () -> service.exchange("ticket-from-browser")); + } + + private StudentDemoSessionServiceImpl buildService() { + StudentDemoSessionServiceImpl service = new StudentDemoSessionServiceImpl(); + service.schoolClassMapper = mock(SchoolClassMapper.class); + service.studentDemoSessionMapper = mock(StudentDemoSessionMapper.class); + return service; + } + + private JwtUser teacher() { + JwtUser user = new JwtUser(); + user.setUserId("teacher-1"); + user.setName("Teacher One"); + user.setUsername("teacher.one"); + user.setSchoolId("school-1"); + user.setRoleId(3); + return user; + } + + private SchoolClass teachingClass() { + SchoolClass schoolClass = new SchoolClass(); + schoolClass.setSchoolClassId("class-1"); + schoolClass.setClassName("Teaching Class 1"); + schoolClass.setSchoolId("school-1"); + schoolClass.setClassType("TEACHING"); + schoolClass.setCreatedBy("teacher-1"); + return schoolClass; + } + + private StudentDemoSession activeSession() { + StudentDemoSession session = new StudentDemoSession(); + session.setId("session-1"); + session.setTeacherUserId("teacher-1"); + session.setTeacherName("Teacher One"); + session.setSchoolId("school-1"); + session.setTeachingClassId("class-1"); + session.setTeachingClassName("Teaching Class 1"); + session.setExpiresAt(new Date(System.currentTimeMillis() + 60_000)); + return session; + } +} diff --git a/src/test/java/com/sztzjy/linkCommerce/service/impl/StudentTeachingClassResolverImplTest.java b/src/test/java/com/sztzjy/linkCommerce/service/impl/StudentTeachingClassResolverImplTest.java new file mode 100644 index 0000000..e4dab1b --- /dev/null +++ b/src/test/java/com/sztzjy/linkCommerce/service/impl/StudentTeachingClassResolverImplTest.java @@ -0,0 +1,81 @@ +package com.sztzjy.linkCommerce.service.impl; + +import com.sztzjy.linkCommerce.config.exception.handler.ServiceException; +import com.sztzjy.linkCommerce.config.security.JwtUser; +import com.sztzjy.linkCommerce.entity.SchoolClass; +import com.sztzjy.linkCommerce.entity.TeachingClassStudent; +import com.sztzjy.linkCommerce.mapper.SchoolClassMapper; +import com.sztzjy.linkCommerce.mapper.TeachingClassStudentMapper; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class StudentTeachingClassResolverImplTest { + + @Test + void normalStudentResolvesTheirActiveTeachingClassMembership() { + StudentTeachingClassResolverImpl resolver = buildResolver(); + TeachingClassStudent membership = new TeachingClassStudent(); + membership.setTeachingClassId("class-1"); + when(resolver.teachingClassStudentMapper.selectActiveByStudentUserId("student-1")).thenReturn(membership); + when(resolver.schoolClassMapper.selectByPrimaryKey("class-1")).thenReturn(teachingClass("class-1")); + + assertEquals("class-1", resolver.resolveRequired(normalStudent())); + } + + @Test + void demoStudentUsesSignedTeachingClassWithoutMembershipLookup() { + StudentTeachingClassResolverImpl resolver = buildResolver(); + when(resolver.schoolClassMapper.selectByPrimaryKey("class-1")).thenReturn(teachingClass("class-1")); + + assertEquals("class-1", resolver.resolveRequired(demoStudent())); + + verify(resolver.teachingClassStudentMapper, never()).selectActiveByStudentUserId("demo:session-1"); + } + + @Test + void demoStudentCannotRequestDifferentTeachingClass() { + StudentTeachingClassResolverImpl resolver = buildResolver(); + when(resolver.schoolClassMapper.selectByPrimaryKey("class-1")).thenReturn(teachingClass("class-1")); + + assertThrows(ServiceException.class, () -> resolver.resolveRequested(demoStudent(), "class-2")); + } + + private StudentTeachingClassResolverImpl buildResolver() { + StudentTeachingClassResolverImpl resolver = new StudentTeachingClassResolverImpl(); + resolver.schoolClassMapper = mock(SchoolClassMapper.class); + resolver.teachingClassStudentMapper = mock(TeachingClassStudentMapper.class); + return resolver; + } + + private JwtUser normalStudent() { + JwtUser user = new JwtUser(); + user.setUserId("student-1"); + user.setSchoolId("school-1"); + user.setRoleId(4); + return user; + } + + private JwtUser demoStudent() { + JwtUser user = new JwtUser(); + user.setUserId("demo:session-1"); + user.setSchoolId("school-1"); + user.setRoleId(4); + user.setDemoMode(true); + user.setDemoTeachingClassId("class-1"); + return user; + } + + private SchoolClass teachingClass(String id) { + SchoolClass schoolClass = new SchoolClass(); + schoolClass.setSchoolClassId(id); + schoolClass.setSchoolId("school-1"); + schoolClass.setClassType("TEACHING"); + return schoolClass; + } +} diff --git a/src/test/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImplTest.java b/src/test/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImplTest.java index ce9d5be..12852a1 100644 --- a/src/test/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImplTest.java +++ b/src/test/java/com/sztzjy/linkCommerce/service/impl/StudentTrainingAnswerServiceImplTest.java @@ -18,6 +18,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.startsWith; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -102,6 +104,21 @@ class StudentTrainingAnswerServiceImplTest { () -> service.save("new-product-survey", new StudentTrainingAnswer(), student())); } + @Test + void demoStudentCannotPersistAnswerEvenWhenTheirDemoClassIsSupplied() { + StudentTrainingAnswerServiceImpl service = buildService(); + StudentTrainingAnswer request = new StudentTrainingAnswer(); + request.setTeachingClassId("class-1"); + request.setSaveAction("SAVE"); + + assertThrows(ServiceException.class, + () -> service.save("new-product-survey", request, demoStudent())); + + verify(service.studentTrainingAnswerMapper, never()).insertSelective(any()); + verify(service.studentTrainingAnswerMapper, never()).updateByPrimaryKeySelective(any()); + verifyNoInteractions(service.trainingTaskMapper); + } + @Test void getInitializesAllAnswerTablesBeforeQuerying() { StudentTrainingAnswerServiceImpl service = buildService(); @@ -144,4 +161,11 @@ class StudentTrainingAnswerServiceImplTest { user.setRoleId(4); return user; } + + private JwtUser demoStudent() { + JwtUser user = student(); + user.setDemoMode(true); + user.setDemoTeachingClassId("class-1"); + return user; + } }