From 5bb6ba29a28c3e26008e35438656e77ced50c654 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Mon, 11 May 2026 21:34:27 +0800 Subject: [PATCH] Record idempotent challenge answers --- docs/api-reference.md | 5 +- docs/gamification-server-plan.md | 2 +- .../learning/challenge-service.test.ts | 45 ++++++++++++- src/routes/app-api.ts | 3 + src/services/learning/challenge-service.ts | 63 ++++++++++++++++++- src/types/app-api.ts | 1 + 6 files changed, 113 insertions(+), 6 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index b4aa3e3..142ff18 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -320,10 +320,13 @@ "questionId": "question-uuid", "selectedOptionId": "a", "timeMs": 1500, - "comboCount": 0 + "comboCount": 0, + "submitRequestId": "client-submit-id" } ``` +`submitRequestId` 可选;未传时服务端会使用 `challengeId + questionId` 作为默认幂等 key。同一挑战组内重复提交同一道题会返回第一次裁决结果,不会重复扣资源或重复发放奖励。 + 响应: ```json diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 5248044..997b81d 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -42,7 +42,7 @@ | # | 任务 | 状态 | 验收标准 | |---|------|------|----------| | G1-1 | 实现创建挑战组服务 | [x] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter | -| G1-2 | 实现挑战组答题提交 | [ ] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 | +| G1-2 | 实现挑战组答题提交 | [x] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 | | G1-3 | 实现挑战组完成结算 | [ ] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 | | G1-4 | 调整红心扣除边界 | [ ] | 答错扣 1 颗;Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 | | G1-5 | 调整每日高奖励挑战次数 | [ ] | 免费用户每日 3 组,Plus 8 组或按产品确认无限;次数为 0 后仍可继续学习但高价值奖励降级 | diff --git a/src/__tests__/services/learning/challenge-service.test.ts b/src/__tests__/services/learning/challenge-service.test.ts index 729dbcb..3678ee8 100644 --- a/src/__tests__/services/learning/challenge-service.test.ts +++ b/src/__tests__/services/learning/challenge-service.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '../../../db/client.js'; -import { getNextChallenge } from '../../../services/learning/challenge-service.js'; +import { getNextChallenge, submitChallengeAnswer } from '../../../services/learning/challenge-service.js'; const category = { id: 'history', @@ -91,4 +91,47 @@ describe('challenge-service', () => { })); }); }); + + describe('submitChallengeAnswer', () => { + it('returns the stored result for duplicate question submissions without side effects', async () => { + const resultSnapshot = { + answerState: 'correct', + correctOptionId: 'a', + xpDelta: 10, + progress: { + hearts: 5, + dailyAttemptsLeft: 3, + xp: 120, + streakDays: 2, + }, + knowledgeCard: { + id: 'card-1', + title: '知识点', + summary: '知识点', + fact: '解析', + }, + rewards: [{ type: 'xp', amount: 10, title: '+10 XP' }], + }; + vi.mocked(db.select) + .mockReturnValueOnce(selectChain([{ + id: 'challenge-1', + userId: 'user-1', + status: 'pending', + questionIds: ['question-1'], + }]) as never) + .mockReturnValueOnce(selectChain([{ + id: 'answer-1', + sessionId: 'challenge-1', + questionId: 'question-1', + submitRequestId: 'submit-1', + resultSnapshot, + }]) as never); + + const result = await submitChallengeAnswer('user-1', 'challenge-1', 'question-1', 'a', 1200, 0, 'submit-1'); + + expect(result).toEqual(resultSnapshot); + expect(db.insert).not.toHaveBeenCalled(); + expect(db.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/routes/app-api.ts b/src/routes/app-api.ts index f9d4aca..8ad3266 100644 --- a/src/routes/app-api.ts +++ b/src/routes/app-api.ts @@ -25,6 +25,7 @@ const answerSchema = z.object({ selectedOptionId: z.string().min(1), timeMs: z.number().min(0), comboCount: z.number().int().min(0).optional(), + submitRequestId: z.string().min(1).max(80).optional(), }); const preferencesSchema = z.object({ @@ -82,10 +83,12 @@ export async function appApiRoutes(app: FastifyInstance): Promise { if (!parsed.success) return validationError(parsed.error.issues[0]?.message); const data = await submitChallengeAnswer( getUserId(request), + parsed.data.challengeId, parsed.data.questionId, parsed.data.selectedOptionId, parsed.data.timeMs, parsed.data.comboCount, + parsed.data.submitRequestId, ); return { success: true, data: { ...data, challengeId: parsed.data.challengeId }, error: null }; }); diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts index fe96f4a..80197dd 100644 --- a/src/services/learning/challenge-service.ts +++ b/src/services/learning/challenge-service.ts @@ -1,6 +1,6 @@ import { db } from '../../db/client.js'; -import { challengeSessions, knowledgeCards, questions, skillTree, userChapterProgress, userProgress } from '../../db/schema.js'; -import { and, asc, eq, notInArray, sql } from 'drizzle-orm'; +import { challengeSessionAnswers, challengeSessions, knowledgeCards, questions, skillTree, userChapterProgress, userProgress } from '../../db/schema.js'; +import { and, asc, eq, notInArray, or, sql } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { NotFoundError, ValidationError } from '../../utils/errors.js'; import { addXp, BASE_XP, calculateXp } from '../progress/xp-service.js'; @@ -176,11 +176,43 @@ export async function getNextChallenge(userId: string, trackId: string): Promise export async function submitChallengeAnswer( userId: string, + challengeId: string, questionId: string, selectedOptionId: string, timeMs: number, comboCount = 0, + submitRequestId = `${challengeId}:${questionId}`, ): Promise { + const [session] = await db + .select() + .from(challengeSessions) + .where(and(eq(challengeSessions.id, challengeId), eq(challengeSessions.userId, userId))) + .limit(1); + if (!session) throw new NotFoundError('Challenge'); + if (session.status === 'completed' || session.status === 'expired' || session.status === 'abandoned') { + throw new ValidationError('Challenge session is not accepting answers'); + } + + const sessionQuestionIds = Array.isArray(session.questionIds) ? session.questionIds : []; + const answerOrder = sessionQuestionIds.indexOf(questionId) + 1; + if (answerOrder <= 0) throw new ValidationError('Question does not belong to challenge session'); + + const [existingAnswer] = await db + .select() + .from(challengeSessionAnswers) + .where(and( + eq(challengeSessionAnswers.sessionId, challengeId), + or( + eq(challengeSessionAnswers.questionId, questionId), + eq(challengeSessionAnswers.submitRequestId, submitRequestId), + ), + )) + .limit(1); + + if (existingAnswer?.resultSnapshot) { + return existingAnswer.resultSnapshot as unknown as AnswerResultDto; + } + const [question] = await db.select().from(questions).where(eq(questions.id, questionId)).limit(1); if (!question) throw new NotFoundError('Question'); @@ -233,7 +265,7 @@ export async function submitChallengeAnswer( getKnowledgeCard(question), ]); - return { + const result: AnswerResultDto = { answerState: correct ? 'correct' : 'wrong', correctOptionId, xpDelta, @@ -246,4 +278,29 @@ export async function submitChallengeAnswer( knowledgeCard, rewards: correct && xpDelta > 0 ? [{ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }] : [], }; + + await db.insert(challengeSessionAnswers).values({ + id: uuid(), + sessionId: challengeId, + userId, + questionId, + submitRequestId, + answerOrder, + answer: selectedOptionId, + correct: correct ? 1 : 0, + timeMs, + comboCount, + resultSnapshot: result as unknown as Record, + }); + + await db + .update(challengeSessions) + .set({ + status: 'in_progress', + answeredCount: sql`LEAST(COALESCE(answered_count, 0) + 1, COALESCE(total_questions, ${CHALLENGE_RULES.questionsPerSession}))`, + correctCount: correct ? sql`COALESCE(correct_count, 0) + 1` : sql`COALESCE(correct_count, 0)`, + }) + .where(eq(challengeSessions.id, challengeId)); + + return result; } diff --git a/src/types/app-api.ts b/src/types/app-api.ts index a9597cb..0b93463 100644 --- a/src/types/app-api.ts +++ b/src/types/app-api.ts @@ -94,6 +94,7 @@ export interface AnswerRequestDto { questionId: string; selectedOptionId: string; timeMs: number; + submitRequestId?: string; } export interface AnswerResultDto {