Record idempotent challenge answers
This commit is contained in:
parent
1d84de8d15
commit
5bb6ba29a2
@ -320,10 +320,13 @@
|
|||||||
"questionId": "question-uuid",
|
"questionId": "question-uuid",
|
||||||
"selectedOptionId": "a",
|
"selectedOptionId": "a",
|
||||||
"timeMs": 1500,
|
"timeMs": 1500,
|
||||||
"comboCount": 0
|
"comboCount": 0,
|
||||||
|
"submitRequestId": "client-submit-id"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`submitRequestId` 可选;未传时服务端会使用 `challengeId + questionId` 作为默认幂等 key。同一挑战组内重复提交同一道题会返回第一次裁决结果,不会重复扣资源或重复发放奖励。
|
||||||
|
|
||||||
响应:
|
响应:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@ -42,7 +42,7 @@
|
|||||||
| # | 任务 | 状态 | 验收标准 |
|
| # | 任务 | 状态 | 验收标准 |
|
||||||
|---|------|------|----------|
|
|---|------|------|----------|
|
||||||
| G1-1 | 实现创建挑战组服务 | [x] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter |
|
| G1-1 | 实现创建挑战组服务 | [x] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter |
|
||||||
| G1-2 | 实现挑战组答题提交 | [ ] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 |
|
| G1-2 | 实现挑战组答题提交 | [x] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 |
|
||||||
| G1-3 | 实现挑战组完成结算 | [ ] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 |
|
| G1-3 | 实现挑战组完成结算 | [ ] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 |
|
||||||
| G1-4 | 调整红心扣除边界 | [ ] | 答错扣 1 颗;Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 |
|
| G1-4 | 调整红心扣除边界 | [ ] | 答错扣 1 颗;Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 |
|
||||||
| G1-5 | 调整每日高奖励挑战次数 | [ ] | 免费用户每日 3 组,Plus 8 组或按产品确认无限;次数为 0 后仍可继续学习但高价值奖励降级 |
|
| G1-5 | 调整每日高奖励挑战次数 | [ ] | 免费用户每日 3 组,Plus 8 组或按产品确认无限;次数为 0 后仍可继续学习但高价值奖励降级 |
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { db } from '../../../db/client.js';
|
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 = {
|
const category = {
|
||||||
id: 'history',
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const answerSchema = z.object({
|
|||||||
selectedOptionId: z.string().min(1),
|
selectedOptionId: z.string().min(1),
|
||||||
timeMs: z.number().min(0),
|
timeMs: z.number().min(0),
|
||||||
comboCount: z.number().int().min(0).optional(),
|
comboCount: z.number().int().min(0).optional(),
|
||||||
|
submitRequestId: z.string().min(1).max(80).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const preferencesSchema = z.object({
|
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);
|
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
|
||||||
const data = await submitChallengeAnswer(
|
const data = await submitChallengeAnswer(
|
||||||
getUserId(request),
|
getUserId(request),
|
||||||
|
parsed.data.challengeId,
|
||||||
parsed.data.questionId,
|
parsed.data.questionId,
|
||||||
parsed.data.selectedOptionId,
|
parsed.data.selectedOptionId,
|
||||||
parsed.data.timeMs,
|
parsed.data.timeMs,
|
||||||
parsed.data.comboCount,
|
parsed.data.comboCount,
|
||||||
|
parsed.data.submitRequestId,
|
||||||
);
|
);
|
||||||
return { success: true, data: { ...data, challengeId: parsed.data.challengeId }, error: null };
|
return { success: true, data: { ...data, challengeId: parsed.data.challengeId }, error: null };
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { db } from '../../db/client.js';
|
import { db } from '../../db/client.js';
|
||||||
import { challengeSessions, knowledgeCards, questions, skillTree, userChapterProgress, userProgress } from '../../db/schema.js';
|
import { challengeSessionAnswers, challengeSessions, knowledgeCards, questions, skillTree, userChapterProgress, userProgress } from '../../db/schema.js';
|
||||||
import { and, asc, eq, notInArray, sql } from 'drizzle-orm';
|
import { and, asc, eq, notInArray, or, sql } from 'drizzle-orm';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { NotFoundError, ValidationError } from '../../utils/errors.js';
|
import { NotFoundError, ValidationError } from '../../utils/errors.js';
|
||||||
import { addXp, BASE_XP, calculateXp } from '../progress/xp-service.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(
|
export async function submitChallengeAnswer(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
challengeId: string,
|
||||||
questionId: string,
|
questionId: string,
|
||||||
selectedOptionId: string,
|
selectedOptionId: string,
|
||||||
timeMs: number,
|
timeMs: number,
|
||||||
comboCount = 0,
|
comboCount = 0,
|
||||||
|
submitRequestId = `${challengeId}:${questionId}`,
|
||||||
): Promise<AnswerResultDto> {
|
): 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);
|
const [question] = await db.select().from(questions).where(eq(questions.id, questionId)).limit(1);
|
||||||
if (!question) throw new NotFoundError('Question');
|
if (!question) throw new NotFoundError('Question');
|
||||||
|
|
||||||
@ -233,7 +265,7 @@ export async function submitChallengeAnswer(
|
|||||||
getKnowledgeCard(question),
|
getKnowledgeCard(question),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
const result: AnswerResultDto = {
|
||||||
answerState: correct ? 'correct' : 'wrong',
|
answerState: correct ? 'correct' : 'wrong',
|
||||||
correctOptionId,
|
correctOptionId,
|
||||||
xpDelta,
|
xpDelta,
|
||||||
@ -246,4 +278,29 @@ export async function submitChallengeAnswer(
|
|||||||
knowledgeCard,
|
knowledgeCard,
|
||||||
rewards: correct && xpDelta > 0 ? [{ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }] : [],
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -94,6 +94,7 @@ export interface AnswerRequestDto {
|
|||||||
questionId: string;
|
questionId: string;
|
||||||
selectedOptionId: string;
|
selectedOptionId: string;
|
||||||
timeMs: number;
|
timeMs: number;
|
||||||
|
submitRequestId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnswerResultDto {
|
export interface AnswerResultDto {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user