Record idempotent challenge answers

This commit is contained in:
Wang Zhuoxuan 2026-05-11 21:34:27 +08:00
parent 1d84de8d15
commit 5bb6ba29a2
6 changed files with 113 additions and 6 deletions

View File

@ -320,10 +320,13 @@
"questionId": "question-uuid",
"selectedOptionId": "a",
"timeMs": 1500,
"comboCount": 0
"comboCount": 0,
"submitRequestId": "client-submit-id"
}
```
`submitRequestId` 可选;未传时服务端会使用 `challengeId + questionId` 作为默认幂等 key。同一挑战组内重复提交同一道题会返回第一次裁决结果不会重复扣资源或重复发放奖励。
响应:
```json

View File

@ -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 后仍可继续学习但高价值奖励降级 |

View File

@ -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();
});
});
});

View File

@ -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<void> {
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 };
});

View File

@ -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<AnswerResultDto> {
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<string, unknown>,
});
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;
}

View File

@ -94,6 +94,7 @@ export interface AnswerRequestDto {
questionId: string;
selectedOptionId: string;
timeMs: number;
submitRequestId?: string;
}
export interface AnswerResultDto {