You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

254 lines
19 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 教师进入学生端讲解模式实施计划
> **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 或“按需补充”步骤。