docs: plan teacher student demo mode

main
chenyuan 1 month ago
parent 2bc3ab05ba
commit 52ba31731c

@ -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 或“按需补充”步骤。
Loading…
Cancel
Save