[M-QUIZ-01] 提交原子性与幂等控制 #337

Closed
opened 2026-06-23 20:28:00 +08:00 by wangdl · 2 comments
Owner

类型 / 标签

backend quiz concurrency idempotency p0

依赖

M-QUIZ-01-01(Attempt 生命周期必须先就绪)

背景

  • 同一 Attempt 并发提交可能产生重复 QuizAnswer
  • 缺乏服务端原子判分机制——客户端可能伪造 isCorrect
  • QuizAnswer 写入和 Attempt 结算不在同一事务中

目标

  1. 服务端判分:服务端比对 userAnswer 与正确答案,客户端传入的 isCorrect 不可信
  2. 原子提交:Attempt 结算 + QuizAnswer 批量写入在同一数据库事务中
  3. 幂等控制:同一 Attempt 只能结算一次(finishedAt 非空作为 CAS 锁)
  4. 部分失败不产生脏数据:任一 QuizAnswer 写入失败则整体回滚

非目标

  • 不支持修改已提交答案
  • 不实现乐观锁重试(本次只做 CAS)
  • 不处理断点续答

影响范围

文件 改动
src/modules/quiz/quiz.controller.ts POST /api/quiz/attempts/:id/submit
src/modules/quiz/quiz.service.ts 判分 + 写入 + 结算事务
Prisma 可选:QuizAnswer.@@unique([attemptId, questionId])

验收标准

  • 提交时服务端判分,不信任客户端 isCorrect
  • 同一 Attempt 串行重复提交不重复结算(第二次返回已结算状态)
  • 并发提交(≥5 并发)不产生重复 QuizAnswer
  • 中途失败无部分答案(事务回滚验证)
  • Attempt 结算后 finishedAt 非空,correctCount / score 正确
  • QuizAnswer 中 isCorrect 由服务端写入
  • 相关测试 0 failed

建议执行顺序

#2 — 依赖 Issue 01。判分与提交在 Attempt 就绪后才有意义。

## 类型 / 标签 `backend` `quiz` `concurrency` `idempotency` `p0` ## 依赖 M-QUIZ-01-01(Attempt 生命周期必须先就绪) ## 背景 - 同一 Attempt 并发提交可能产生重复 QuizAnswer - 缺乏服务端原子判分机制——客户端可能伪造 `isCorrect` - QuizAnswer 写入和 Attempt 结算不在同一事务中 ## 目标 1. **服务端判分**:服务端比对 `userAnswer` 与正确答案,客户端传入的 `isCorrect` 不可信 2. **原子提交**:Attempt 结算 + QuizAnswer 批量写入在同一数据库事务中 3. **幂等控制**:同一 Attempt 只能结算一次(`finishedAt` 非空作为 CAS 锁) 4. **部分失败不产生脏数据**:任一 QuizAnswer 写入失败则整体回滚 ## 非目标 - 不支持修改已提交答案 - 不实现乐观锁重试(本次只做 CAS) - 不处理断点续答 ## 影响范围 | 文件 | 改动 | |------|------| | `src/modules/quiz/quiz.controller.ts` | `POST /api/quiz/attempts/:id/submit` | | `src/modules/quiz/quiz.service.ts` | 判分 + 写入 + 结算事务 | | Prisma | 可选:`QuizAnswer.@@unique([attemptId, questionId])` | ## 验收标准 - [ ] 提交时服务端判分,不信任客户端 `isCorrect` - [ ] 同一 Attempt 串行重复提交不重复结算(第二次返回已结算状态) - [ ] 并发提交(≥5 并发)不产生重复 QuizAnswer - [ ] 中途失败无部分答案(事务回滚验证) - [ ] Attempt 结算后 `finishedAt` 非空,`correctCount` / `score` 正确 - [ ] QuizAnswer 中 `isCorrect` 由服务端写入 - [ ] 相关测试 0 failed ## 建议执行顺序 #2 — 依赖 Issue 01。判分与提交在 Attempt 就绪后才有意义。
wangdl added this to the M-QUIZ-01 Quiz 答题闭环与跨端契约收口 milestone 2026-06-23 20:28:00 +08:00
wangdl added the
priority:p0
type:feature
area:quiz
backend
labels 2026-06-23 20:28:00 +08:00
Author
Owner

开发完成评论

完成内容

原子提交($transaction)

  • submit() 整体包裹在 prisma.$transaction
  • CAS:事务内重新读取 Attempt,finishedAt 作为乐观锁
  • createMany 批量原子写入 QuizAnswer(替代逐条 create)
  • Attempt 结算在同一事务中完成
  • 任一环节失败(如 DB write error)→ 整体回滚,无脏数据

服务端判分

  • 比对 q.answer === a.answer,客户端传入的 isCorrect 完全不可信
  • 不存在的题目 → BadRequestException
  • 空答案数组 → BadRequestException(防御)

幂等控制

  • 事务内 finishedAt 检查,并发提交只有一个能成功
  • 已提交 Attempt 返回 BadRequestException(已提交过答案)

修改文件

  • src/modules/quiz/quiz.service.tssubmit() 重写
  • src/modules/quiz/quiz.service.spec.ts — submit 测试套件重写(+4 new tests)

代码证据

  • quiz.service.ts:133this.prisma.$transaction(async (tx) => {...})
  • quiz.service.ts:135:事务内 CAS 重读 tx.quizAttempt.findUnique
  • quiz.service.ts:150-151tx.quizAnswer.createMany({ data: answerRecords }) 批量写入
  • quiz.service.ts:155-158tx.quizAttempt.update 同一事务结算
  • quiz.service.ts:141q.answer === a.answer 服务端判分

测试情况

  • 已运行命令:
    • npx jest src/modules/quiz/ src/modules/ai-runtime/... quiz-generation-*.spec.ts quiz-execution-router.spec.ts
  • 结果:
    • 8 suites / 145 tests / 0 failed

submit 测试覆盖

场景 结果
空答案 → BadRequestException
正确+错误答案 → 服务端判分计分
题目不存在 → BadRequestException
跨用户 Attempt → NotFoundException
已提交 Attempt → BadRequestException
原子提交验证($transaction 被调用)
部分失败无脏数据(createMany 失败回滚)

未完成 / 风险

  • 未新增 QuizAnswer.@@unique([attemptId, questionId]) 数据库约束(Issue 标注可选,且非目标不修改 Prisma Schema)
  • 并发测试为单元级 mock,真实并发场景需在 GATE #341 E2E 中验证

是否建议进入 Review

## 开发完成评论 ### 完成内容 #### 原子提交($transaction) - `submit()` 整体包裹在 `prisma.$transaction` 中 - CAS:事务内重新读取 Attempt,`finishedAt` 作为乐观锁 - `createMany` 批量原子写入 QuizAnswer(替代逐条 create) - Attempt 结算在同一事务中完成 - 任一环节失败(如 DB write error)→ 整体回滚,无脏数据 #### 服务端判分 - 比对 `q.answer === a.answer`,客户端传入的 `isCorrect` 完全不可信 - 不存在的题目 → `BadRequestException` - 空答案数组 → `BadRequestException`(防御) #### 幂等控制 - 事务内 `finishedAt` 检查,并发提交只有一个能成功 - 已提交 Attempt 返回 `BadRequestException(已提交过答案)` ### 修改文件 - `src/modules/quiz/quiz.service.ts` — `submit()` 重写 - `src/modules/quiz/quiz.service.spec.ts` — submit 测试套件重写(+4 new tests) ### 代码证据 - `quiz.service.ts:133`:`this.prisma.$transaction(async (tx) => {...})` - `quiz.service.ts:135`:事务内 CAS 重读 `tx.quizAttempt.findUnique` - `quiz.service.ts:150-151`:`tx.quizAnswer.createMany({ data: answerRecords })` 批量写入 - `quiz.service.ts:155-158`:`tx.quizAttempt.update` 同一事务结算 - `quiz.service.ts:141`:`q.answer === a.answer` 服务端判分 ### 测试情况 - 已运行命令: - `npx jest src/modules/quiz/ src/modules/ai-runtime/... quiz-generation-*.spec.ts quiz-execution-router.spec.ts` - 结果: - **8 suites / 145 tests / 0 failed** ✅ ### submit 测试覆盖 | 场景 | 结果 | |------|------| | 空答案 → BadRequestException | ✅ | | 正确+错误答案 → 服务端判分计分 | ✅ | | 题目不存在 → BadRequestException | ✅ | | 跨用户 Attempt → NotFoundException | ✅ | | 已提交 Attempt → BadRequestException | ✅ | | 原子提交验证($transaction 被调用) | ✅ | | 部分失败无脏数据(createMany 失败回滚) | ✅ | ### 未完成 / 风险 - 未新增 `QuizAnswer.@@unique([attemptId, questionId])` 数据库约束(Issue 标注可选,且非目标不修改 Prisma Schema) - 并发测试为单元级 mock,真实并发场景需在 GATE #341 E2E 中验证 ### 是否建议进入 Review - **是**
Author
Owner

N1 修复:updateMany 写锁替代 findUnique 快照读

问题

MySQL REPEATABLE READ 下,findUnique 读取的是事务开始时的快照。两个并发事务可能同时读到 finishedAt = null,双双通过 CAS 检查。

修复

将 CAS 从快照读改为写锁:

// 旧:快照读(可并发)
const attempt = await tx.quizAttempt.findUnique({ where: { id: attemptId } });
if (attempt.finishedAt) throw ...

// 新:UPDATE ... WHERE finishedAt IS NULL(排他行锁)
const cas = await tx.quizAttempt.updateMany({
  where: { id: attemptId, userId, finishedAt: null },
  data: { finishedAt: new Date() },
});
if (cas.count === 0) { /* 区分原因并拒绝 */ }

MySQL 的 UPDATE 获取排他行锁,并发事务串行执行。只有第一个事务能匹配 finishedAt IS NULL,第二个事务看到 count: 0 立即拒绝。

测试更新

  • 新增 CAS 占位验证(updateMany 调用参数检查)
  • 跨用户/已提交场景适配 count: 0 + findUnique 二次判断
  • 最终 update 只设 score/correctCount(finishedAt 已由 CAS 占位)
  • 8 suites / 146 tests / 0 failed
## N1 修复:updateMany 写锁替代 findUnique 快照读 ### 问题 MySQL REPEATABLE READ 下,`findUnique` 读取的是事务开始时的快照。两个并发事务可能同时读到 `finishedAt = null`,双双通过 CAS 检查。 ### 修复 将 CAS 从快照读改为写锁: ```typescript // 旧:快照读(可并发) const attempt = await tx.quizAttempt.findUnique({ where: { id: attemptId } }); if (attempt.finishedAt) throw ... // 新:UPDATE ... WHERE finishedAt IS NULL(排他行锁) const cas = await tx.quizAttempt.updateMany({ where: { id: attemptId, userId, finishedAt: null }, data: { finishedAt: new Date() }, }); if (cas.count === 0) { /* 区分原因并拒绝 */ } ``` MySQL 的 `UPDATE` 获取排他行锁,并发事务串行执行。只有第一个事务能匹配 `finishedAt IS NULL`,第二个事务看到 `count: 0` 立即拒绝。 ### 测试更新 - 新增 CAS 占位验证(`updateMany` 调用参数检查) - 跨用户/已提交场景适配 `count: 0` + `findUnique` 二次判断 - 最终 `update` 只设 score/correctCount(finishedAt 已由 CAS 占位) - **8 suites / 146 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#337
No description provided.