[M-QUIZ-01] Attempt 生命周期与答案可见性控制 #336

Closed
opened 2026-06-23 20:27:54 +08:00 by wangdl · 3 comments
Owner

类型 / 标签

backend quiz attempt security p0

依赖

无(M-AI-07 Quiz 生成已就绪)

背景

审计发现(m-ai-07-quiz-generation-migration-contract.md §1.7):

  1. getQuizQuestions() 在 SELECT 中包含 answerexplanation 字段——任何已认证用户可在未开始答题前获取正确答案
  2. findOne() include questions 同样泄漏答案
  3. 历史 Attempt 完成后其答案对新 Attempt 可见——新 Attempt 应完全隔离

目标

  1. 创建独立 Attempt(POST /api/quiz/:id/attempts),每次答题启动新 Attempt
  2. 加载题目时按 Attempt 状态控制字段返回:未提交前不返回 answerexplanation
  3. 提交后结果查询接口才返回 answerexplanation
  4. 新 Attempt 不受历史 Attempt 答案影响

非目标

  • 不修改 Legacy Procedural Quiz 路径(quiz.service.ts
  • 不处理 Attempt 并发提交(由 M-QUIZ-01-02 负责)
  • 不修改 Prisma Schema(复用现有 QuizAttempt / QuizAnswer 模型)

影响范围

文件 改动
src/modules/quiz/quiz.controller.ts 新增 attempt 端点
src/modules/quiz/quiz.service.ts attempt 创建 + 题目脱敏查询
src/modules/ai-runtime/user-ai.service.ts:546-556 getQuizQuestions() 脱敏
DTO 层 区分 QuestionForAttempt vs QuestionWithAnswer

验收标准

  • POST /api/quiz/:id/attempts 创建独立 Attempt,返回 attemptId
  • 新 Attempt 查询题目不返回 answer / explanation
  • 未提交 Attempt 查询题目不返回 answer / explanation
  • 已完成 Attempt 的结果查询返回 answer / explanation
  • 历史 Attempt A 的答案对当前 Attempt B 不可见
  • 跨用户 Attempt 返回 403
  • 相关测试 0 failed

建议执行顺序

#1 — 最先执行。Attempt 是整个答题闭环的基础。

## 类型 / 标签 `backend` `quiz` `attempt` `security` `p0` ## 依赖 无(M-AI-07 Quiz 生成已就绪) ## 背景 审计发现(`m-ai-07-quiz-generation-migration-contract.md §1.7`): 1. `getQuizQuestions()` 在 SELECT 中包含 `answer` 和 `explanation` 字段——任何已认证用户可在未开始答题前获取正确答案 2. `findOne()` include questions 同样泄漏答案 3. 历史 Attempt 完成后其答案对新 Attempt 可见——新 Attempt 应完全隔离 ## 目标 1. 创建独立 Attempt(`POST /api/quiz/:id/attempts`),每次答题启动新 Attempt 2. 加载题目时按 Attempt 状态控制字段返回:**未提交前不返回 `answer` 和 `explanation`** 3. 提交后结果查询接口才返回 `answer` 和 `explanation` 4. 新 Attempt 不受历史 Attempt 答案影响 ## 非目标 - 不修改 Legacy Procedural Quiz 路径(`quiz.service.ts`) - 不处理 Attempt 并发提交(由 M-QUIZ-01-02 负责) - 不修改 Prisma Schema(复用现有 QuizAttempt / QuizAnswer 模型) ## 影响范围 | 文件 | 改动 | |------|------| | `src/modules/quiz/quiz.controller.ts` | 新增 attempt 端点 | | `src/modules/quiz/quiz.service.ts` | attempt 创建 + 题目脱敏查询 | | `src/modules/ai-runtime/user-ai.service.ts:546-556` | `getQuizQuestions()` 脱敏 | | DTO 层 | 区分 `QuestionForAttempt` vs `QuestionWithAnswer` | ## 验收标准 - [ ] `POST /api/quiz/:id/attempts` 创建独立 Attempt,返回 `attemptId` - [ ] 新 Attempt 查询题目不返回 `answer` / `explanation` - [ ] 未提交 Attempt 查询题目不返回 `answer` / `explanation` - [ ] 已完成 Attempt 的结果查询返回 `answer` / `explanation` - [ ] 历史 Attempt A 的答案对当前 Attempt B 不可见 - [ ] 跨用户 Attempt 返回 403 - [ ] 相关测试 0 failed ## 建议执行顺序 #1 — 最先执行。Attempt 是整个答题闭环的基础。
wangdl added this to the M-QUIZ-01 Quiz 答题闭环与跨端契约收口 milestone 2026-06-23 20:27:54 +08:00
wangdl added the
priority:p0
type:feature
area:quiz
area:security
backend
labels 2026-06-23 20:27:54 +08:00
Author
Owner

开发完成评论

完成内容

  • 新增 POST /quizzes/:id/attempts 端点——创建独立 Attempt,每次答题启动新 Attempt
  • 新增 GET /quizzes/attempts/:id/questions 端点——按 Attempt 加载题目,未提交前不返回 answerexplanation
  • 修复 quiz.service.ts:findOne()——增加 userId 校验,跨用户访问返回 403
  • 修复 quiz.service.ts:start()——增加 quiz 所有权校验,跨用户创建 Attempt 返回 403
  • 新增 quiz.service.ts:getAttemptQuestions()——per-attempt 答案可见性控制,新 Attempt 不受历史 Attempt 影响
  • 修复 quiz.service.ts:getResults()——增加 userId 校验
  • 修复 user-ai.service.ts:getQuizQuestions()——支持可选 attemptId 参数,改为 per-attempt 答案可见性(不再基于"任意历史已完成 Attempt")
  • 更新 user-ai.controller.ts:getQuizQuestions()——透传可选 attemptId query 参数

修改文件

  • src/modules/quiz/quiz.service.ts
  • src/modules/quiz/quiz.controller.ts
  • src/modules/ai-runtime/user-ai.service.ts
  • src/modules/ai-runtime/user-ai.controller.ts
  • src/modules/ai-runtime/user-ai.controller.spec.ts(测试签名修复)

代码证据

  • quiz.service.ts:91findOne() 增加 if (quiz.userId !== userId) throw new ForbiddenException(...)
  • quiz.service.ts:100start() 增加 quiz 所有权校验 ForbiddenException
  • quiz.service.ts:108-130:新增 getAttemptQuestions()——按 attempt.finishedAt 控制 answer/explanation 返回
  • quiz.service.ts:166getResults() 增加 userId 校验
  • user-ai.service.ts:550-563getQuizQuestions() 改为接收可选 attemptId,仅当该 attempt 已完成才返回答案;未提供 attemptId 时不返回答案(安全默认)
  • quiz.controller.ts:43-47:新增 POST :id/attempts 端点
  • quiz.controller.ts:56-60:新增 GET attempts/:id/questions 端点

测试情况

  • 已运行命令:
    • npx jest src/modules/ai-job/quiz-generation-*.spec.ts src/modules/ai-runtime/quiz-execution-router.spec.ts src/modules/ai-runtime/user-ai.service.spec.ts src/modules/ai-runtime/user-ai.controller.spec.ts
  • 结果:
    • 6 suites, 111 tests, 0 failed

关键行为变更

场景 旧行为 新行为
未提供 attemptId 查题目 检查"任意已完成 Attempt"→有则返回答案 安全默认:不返回答案
提供 attemptId 查题目 不支持 按该 Attempt 状态:finishedAt 非空才返回答案
跨用户访问 Quiz 详情 允许(泄漏 quiz 元数据) 403 ForbiddenException
跨用户访问 Attempt 结果 允许(泄漏答案) 403 ForbiddenException
跨用户创建 Attempt 允许 403 ForbiddenException

未完成 / 风险

  • 未修改 Legacy Procedural Quiz 生成路径(quiz.service.ts:create() 保留原样)
  • 未处理 Attempt 并发提交原子性(由 #337 负责)
  • E2E 测试需在 #341 GATE 阶段完整验证 per-attempt 场景

是否建议进入 Review

  • — 核心安全缺陷(答案泄漏、跨用户访问)已修复,per-attempt 答案可见性已建立,相关单元测试全量通过。
## 开发完成评论 ### 完成内容 - ✅ 新增 `POST /quizzes/:id/attempts` 端点——创建独立 Attempt,每次答题启动新 Attempt - ✅ 新增 `GET /quizzes/attempts/:id/questions` 端点——按 Attempt 加载题目,**未提交前不返回 `answer` 和 `explanation`** - ✅ 修复 `quiz.service.ts:findOne()`——增加 `userId` 校验,跨用户访问返回 403 - ✅ 修复 `quiz.service.ts:start()`——增加 quiz 所有权校验,跨用户创建 Attempt 返回 403 - ✅ 新增 `quiz.service.ts:getAttemptQuestions()`——per-attempt 答案可见性控制,新 Attempt 不受历史 Attempt 影响 - ✅ 修复 `quiz.service.ts:getResults()`——增加 `userId` 校验 - ✅ 修复 `user-ai.service.ts:getQuizQuestions()`——支持可选 `attemptId` 参数,改为 per-attempt 答案可见性(不再基于"任意历史已完成 Attempt") - ✅ 更新 `user-ai.controller.ts:getQuizQuestions()`——透传可选 `attemptId` query 参数 ### 修改文件 - `src/modules/quiz/quiz.service.ts` - `src/modules/quiz/quiz.controller.ts` - `src/modules/ai-runtime/user-ai.service.ts` - `src/modules/ai-runtime/user-ai.controller.ts` - `src/modules/ai-runtime/user-ai.controller.spec.ts`(测试签名修复) ### 代码证据 - `quiz.service.ts:91`:`findOne()` 增加 `if (quiz.userId !== userId) throw new ForbiddenException(...)` - `quiz.service.ts:100`:`start()` 增加 quiz 所有权校验 `ForbiddenException` - `quiz.service.ts:108-130`:新增 `getAttemptQuestions()`——按 attempt.finishedAt 控制 `answer`/`explanation` 返回 - `quiz.service.ts:166`:`getResults()` 增加 `userId` 校验 - `user-ai.service.ts:550-563`:`getQuizQuestions()` 改为接收可选 `attemptId`,仅当该 attempt 已完成才返回答案;未提供 attemptId 时不返回答案(安全默认) - `quiz.controller.ts:43-47`:新增 `POST :id/attempts` 端点 - `quiz.controller.ts:56-60`:新增 `GET attempts/:id/questions` 端点 ### 测试情况 - 已运行命令: - `npx jest src/modules/ai-job/quiz-generation-*.spec.ts src/modules/ai-runtime/quiz-execution-router.spec.ts src/modules/ai-runtime/user-ai.service.spec.ts src/modules/ai-runtime/user-ai.controller.spec.ts` - 结果: - **6 suites, 111 tests, 0 failed** ✅ ### 关键行为变更 | 场景 | 旧行为 | 新行为 | |------|--------|--------| | 未提供 attemptId 查题目 | 检查"任意已完成 Attempt"→有则返回答案 | 安全默认:不返回答案 | | 提供 attemptId 查题目 | 不支持 | 按该 Attempt 状态:finishedAt 非空才返回答案 | | 跨用户访问 Quiz 详情 | 允许(泄漏 quiz 元数据) | 403 ForbiddenException | | 跨用户访问 Attempt 结果 | 允许(泄漏答案) | 403 ForbiddenException | | 跨用户创建 Attempt | 允许 | 403 ForbiddenException | ### 未完成 / 风险 - 未修改 Legacy Procedural Quiz 生成路径(`quiz.service.ts:create()` 保留原样) - 未处理 Attempt 并发提交原子性(由 #337 负责) - E2E 测试需在 #341 GATE 阶段完整验证 per-attempt 场景 ### 是否建议进入 Review - **是** — 核心安全缺陷(答案泄漏、跨用户访问)已修复,per-attempt 答案可见性已建立,相关单元测试全量通过。
Author
Owner

第二轮:N1/B1 修复 + 测试补齐

N1 修复:getResults 信息泄漏

  • quiz.service.ts:159-165userId 进入 Prisma 查询条件,跨用户统一返回 404(与 submit() 一致)

B1 修复:getQuizQuestions 跨 Quiz 答案泄漏

  • user-ai.service.ts:555attempt.findFirst() 增加 quizId 过滤,防止用 Quiz B 的 Attempt 查 Quiz A 的答案

测试补齐(N2/N3 解决)

quiz.service.spec.ts(新增 18 tests)

  • findOne:正常/不存在/跨用户 403
  • start:正常创建/不存在/跨用户 403
  • getAttemptQuestions:未提交无答案/已提交有答案/不存在 404/跨用户 403/历史 Attempt 不影响新 Attempt
  • getResults:正常/N1 修复(跨用户 404)/不存在
  • submit:正常判分/跨用户/已提交拒绝
  • getHistory:正常

quiz.controller.spec.ts(新增 7 tests)

  • POST :id/attempts 创建 Attempt
  • GET attempts/:id/questions 返回题目
  • GET :id / GET :id/results 传入 userId
  • 旧端点兼容验证

user-ai.service.spec.ts(新增 5 tests)

  • 无 attemptId 安全默认
  • 已完成/未完成 attemptId 控制
  • B1:跨 Quiz attempt 不泄漏答案
  • 不存在 quizId

测试统计

  • src/modules/quiz/quiz.service.spec.ts:18 tests
  • src/modules/quiz/quiz.controller.spec.ts:7 tests
  • src/modules/ai-runtime/user-ai.service.spec.ts:+5 tests(新增 describe 块)
  • 全量回归:8 suites / 141 tests / 0 failed

修改文件(本轮新增)

  • src/modules/quiz/quiz.service.ts(N1 修复)
  • src/modules/ai-runtime/user-ai.service.ts(B1 修复)
  • src/modules/quiz/quiz.service.spec.ts(新建)
  • src/modules/quiz/quiz.controller.spec.ts(新建)
  • src/modules/ai-runtime/user-ai.service.spec.ts(新增 getQuizQuestions 测试套件)

遗留(非阻塞)

  • N4:getAttemptQuestionsgetQuizQuestions 功能重叠,建议在后续里程碑中统一为单一入口
## 第二轮:N1/B1 修复 + 测试补齐 ### N1 修复:getResults 信息泄漏 - `quiz.service.ts:159-165`:`userId` 进入 Prisma 查询条件,跨用户统一返回 404(与 `submit()` 一致) ### B1 修复:getQuizQuestions 跨 Quiz 答案泄漏 - `user-ai.service.ts:555`:`attempt.findFirst()` 增加 `quizId` 过滤,防止用 Quiz B 的 Attempt 查 Quiz A 的答案 ### 测试补齐(N2/N3 解决) #### quiz.service.spec.ts(新增 18 tests) - `findOne`:正常/不存在/跨用户 403 - `start`:正常创建/不存在/跨用户 403 - `getAttemptQuestions`:未提交无答案/已提交有答案/不存在 404/跨用户 403/历史 Attempt 不影响新 Attempt - `getResults`:正常/N1 修复(跨用户 404)/不存在 - `submit`:正常判分/跨用户/已提交拒绝 - `getHistory`:正常 #### quiz.controller.spec.ts(新增 7 tests) - `POST :id/attempts` 创建 Attempt - `GET attempts/:id/questions` 返回题目 - `GET :id` / `GET :id/results` 传入 userId - 旧端点兼容验证 #### user-ai.service.spec.ts(新增 5 tests) - 无 attemptId 安全默认 - 已完成/未完成 attemptId 控制 - B1:跨 Quiz attempt 不泄漏答案 - 不存在 quizId ### 测试统计 - `src/modules/quiz/quiz.service.spec.ts`:18 tests ✅ - `src/modules/quiz/quiz.controller.spec.ts`:7 tests ✅ - `src/modules/ai-runtime/user-ai.service.spec.ts`:+5 tests(新增 describe 块)✅ - 全量回归:8 suites / 141 tests / 0 failed ✅ ### 修改文件(本轮新增) - `src/modules/quiz/quiz.service.ts`(N1 修复) - `src/modules/ai-runtime/user-ai.service.ts`(B1 修复) - `src/modules/quiz/quiz.service.spec.ts`(新建) - `src/modules/quiz/quiz.controller.spec.ts`(新建) - `src/modules/ai-runtime/user-ai.service.spec.ts`(新增 getQuizQuestions 测试套件) ### 遗留(非阻塞) - N4:`getAttemptQuestions` 与 `getQuizQuestions` 功能重叠,建议在后续里程碑中统一为单一入口
Author
Owner

N2/N3 修复:统一跨用户信息泄漏模式

修改

  • N2 getAttemptQuestionsuserId 进入 Prisma 查询条件,删除独立 ForbiddenException,跨用户统一 404
  • N3 findOne:同上,where: { id, userId }
  • N3 start:同上,where: { id: quizId, userId }
  • 移除未使用的 ForbiddenException import

最终一致性

所有查询方法统一为 findUnique({ where: { ..., userId } }) 模式,跨用户访问不再暴露 403 vs 404 差异:

方法 where 异常
findOne { id, userId } NotFoundException
start { id: quizId, userId } NotFoundException
getAttemptQuestions { id: attemptId, userId } NotFoundException
getResults { id: attemptId, userId } NotFoundException
submit { id: attemptId } + 内联 userId 检查 NotFoundException

测试更新

  • 跨用户测试:ForbiddenExceptionNotFoundException
  • 增加 where 条件中 userId 的断言验证
  • 8 suites / 141 tests / 0 failed
## N2/N3 修复:统一跨用户信息泄漏模式 ### 修改 - **N2** `getAttemptQuestions`:`userId` 进入 Prisma 查询条件,删除独立 `ForbiddenException`,跨用户统一 404 - **N3** `findOne`:同上,`where: { id, userId }` - **N3** `start`:同上,`where: { id: quizId, userId }` - 移除未使用的 `ForbiddenException` import ### 最终一致性 所有查询方法统一为 `findUnique({ where: { ..., userId } })` 模式,跨用户访问不再暴露 403 vs 404 差异: | 方法 | where | 异常 | |------|-------|------| | `findOne` | `{ id, userId }` | NotFoundException | | `start` | `{ id: quizId, userId }` | NotFoundException | | `getAttemptQuestions` | `{ id: attemptId, userId }` | NotFoundException | | `getResults` | `{ id: attemptId, userId }` | NotFoundException | | `submit` | `{ id: attemptId }` + 内联 userId 检查 | NotFoundException | ### 测试更新 - 跨用户测试:`ForbiddenException` → `NotFoundException` - 增加 `where` 条件中 `userId` 的断言验证 - 8 suites / 141 tests / 0 failed ✅
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wangdl/api-server#336
No description provided.