Create challenge sessions with five questions

This commit is contained in:
Wang Zhuoxuan 2026-05-11 18:32:40 +08:00
parent fd4c2b6361
commit 1d84de8d15
5 changed files with 155 additions and 24 deletions

View File

@ -281,24 +281,32 @@
{ {
"success": true, "success": true,
"data": { "data": {
"challengeId": "question-uuid", "challengeId": "challenge-session-uuid",
"trackId": "history", "trackId": "history",
"nodeId": "chapter-uuid", "nodeId": "chapter-uuid",
"question": { "totalQuestions": 5,
"id": "question-uuid", "questions": [
"prompt": "题目文本", {
"options": [ "challengeId": "challenge-session-uuid",
{ "id": "a", "text": "选项 A" }, "trackId": "history",
{ "id": "b", "text": "选项 B" }, "nodeId": "chapter-uuid",
{ "id": "c", "text": "选项 C" } "question": {
] "id": "question-uuid-1",
} "prompt": "题目文本",
"options": [
{ "id": "a", "text": "选项 A" },
{ "id": "b", "text": "选项 B" },
{ "id": "c", "text": "选项 C" }
]
}
}
]
}, },
"error": null "error": null
} }
``` ```
没有可用题目时 `data``null` 服务端会创建挑战组会话并一次返回 5 题,题目选项不包含正确答案标记。题库不足 5 题或没有可用题目时 `data``null`
#### POST /challenges/answer #### POST /challenges/answer
@ -308,7 +316,7 @@
```json ```json
{ {
"challengeId": "question-uuid", "challengeId": "challenge-session-uuid",
"questionId": "question-uuid", "questionId": "question-uuid",
"selectedOptionId": "a", "selectedOptionId": "a",
"timeMs": 1500, "timeMs": 1500,
@ -322,7 +330,7 @@
{ {
"success": true, "success": true,
"data": { "data": {
"challengeId": "question-uuid", "challengeId": "challenge-session-uuid",
"answerState": "correct", "answerState": "correct",
"correctOptionId": "a", "correctOptionId": "a",
"xpDelta": 10, "xpDelta": 10,

View File

@ -41,7 +41,7 @@
| # | 任务 | 状态 | 验收标准 | | # | 任务 | 状态 | 验收标准 |
|---|------|------|----------| |---|------|------|----------|
| G1-1 | 实现创建挑战组服务 | [ ] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter | | G1-1 | 实现创建挑战组服务 | [x] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter |
| G1-2 | 实现挑战组答题提交 | [ ] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 | | G1-2 | 实现挑战组答题提交 | [ ] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 |
| G1-3 | 实现挑战组完成结算 | [ ] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 | | G1-3 | 实现挑战组完成结算 | [ ] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 |
| G1-4 | 调整红心扣除边界 | [ ] | 答错扣 1 颗Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 | | G1-4 | 调整红心扣除边界 | [ ] | 答错扣 1 颗Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 |

View File

@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js';
import { getNextChallenge } from '../../../services/learning/challenge-service.js';
const category = {
id: 'history',
name: '历史',
slug: 'history',
parentId: null,
sortOrder: 0,
questionCount: 5,
status: 'active',
createdAt: null,
updatedAt: null,
};
const chapter = {
id: 'chapter-1',
categoryId: 'history',
title: '第一关',
parentId: null,
sortOrder: 1,
questionsRequired: 5,
passThreshold: 3,
createdAt: null,
};
const questions = Array.from({ length: 5 }, (_, index) => ({
id: `question-${index + 1}`,
stem: { text: `题目 ${index + 1}` },
contentType: 'text',
correctAnswer: `正确答案 ${index + 1}`,
distractors: [`干扰项 ${index + 1}-1`, `干扰项 ${index + 1}-2`],
categoryId: 'history',
difficulty: 1,
dynamicDifficulty: null,
source: 'system',
creatorId: null,
status: 'published',
stats: { timesAnswered: 0, correctRate: 0, avgTimeMs: 0 },
createdAt: null,
updatedAt: null,
}));
function selectChain(result: unknown) {
const whereChain = {
orderBy: vi.fn().mockResolvedValue(result),
limit: vi.fn().mockResolvedValue(result),
then: (resolve: (value: unknown) => unknown) => Promise.resolve(result).then(resolve),
};
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue(whereChain),
orderBy: vi.fn().mockResolvedValue(result),
}),
};
}
describe('challenge-service', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getNextChallenge', () => {
it('creates a challenge session with five questions and hides correct answers', async () => {
const insertedValues = vi.fn().mockResolvedValue([]);
vi.mocked(db.select)
.mockReturnValueOnce(selectChain([category]) as never)
.mockReturnValueOnce(selectChain([chapter]) as never)
.mockReturnValueOnce(selectChain([]) as never)
.mockReturnValueOnce(selectChain([]) as never)
.mockReturnValueOnce(selectChain(questions) as never);
vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
const result = await getNextChallenge('user-1', 'history');
expect(result).not.toBeNull();
expect(result?.trackId).toBe('history');
expect(result?.nodeId).toBe('chapter-1');
expect(result?.questions).toHaveLength(5);
expect(result?.questions.every((item) => item.challengeId === result.challengeId)).toBe(true);
expect(result?.questions[0]?.question.options[0]).toEqual(expect.not.objectContaining({ isCorrect: expect.any(Boolean) }));
expect(insertedValues).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
trackId: 'history',
categoryId: 'history',
chapterId: 'chapter-1',
status: 'pending',
questionIds: questions.map((question) => question.id),
totalQuestions: 5,
}));
});
});
});

View File

@ -1,5 +1,5 @@
import { db } from '../../db/client.js'; import { db } from '../../db/client.js';
import { knowledgeCards, questions, skillTree, userChapterProgress, userProgress } from '../../db/schema.js'; import { challengeSessions, knowledgeCards, questions, skillTree, userChapterProgress, userProgress } from '../../db/schema.js';
import { and, asc, eq, notInArray, sql } from 'drizzle-orm'; import { and, asc, eq, notInArray, 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';
@ -8,7 +8,8 @@ import { deductHeart } from '../progress/hearts-service.js';
import { updateStreak } from '../progress/streak-service.js'; import { updateStreak } from '../progress/streak-service.js';
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js'; import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
import { getTrackCategory } from './tracks-service.js'; import { getTrackCategory } from './tracks-service.js';
import type { AnswerResultDto, ChallengeQuestionDto } from '../../types/app-api.js'; import { CHALLENGE_RULES } from '../gamification/rules.js';
import type { AnswerResultDto, ChallengeQuestionDto, ChallengeSessionDto } from '../../types/app-api.js';
type QuestionRow = typeof questions.$inferSelect; type QuestionRow = typeof questions.$inferSelect;
type ChapterRow = typeof skillTree.$inferSelect; type ChapterRow = typeof skillTree.$inferSelect;
@ -49,9 +50,9 @@ function buildOptions(question: QuestionRow): readonly OptionDto[] {
})); }));
} }
function toChallengeDto(trackId: string, chapter: ChapterRow, question: QuestionRow): ChallengeQuestionDto { function toChallengeDto(challengeId: string, trackId: string, chapter: ChapterRow, question: QuestionRow): ChallengeQuestionDto {
return { return {
challengeId: question.id, challengeId,
trackId, trackId,
nodeId: chapter.id, nodeId: chapter.id,
question: { question: {
@ -82,7 +83,7 @@ async function getCurrentChapter(userId: string, categoryId: string): Promise<Ch
?? chapters[0]!; ?? chapters[0]!;
} }
async function getQuestionForChapter(userId: string, chapter: ChapterRow): Promise<QuestionRow | null> { async function getQuestionsForChapter(userId: string, chapter: ChapterRow): Promise<QuestionRow[]> {
const answered = await db const answered = await db
.select({ questionId: userProgress.questionId }) .select({ questionId: userProgress.questionId })
.from(userProgress) .from(userProgress)
@ -98,7 +99,7 @@ async function getQuestionForChapter(userId: string, chapter: ChapterRow): Promi
? await db.select().from(questions).where(and(...conditions, notInArray(questions.id, answeredIds))).limit(20) ? await db.select().from(questions).where(and(...conditions, notInArray(questions.id, answeredIds))).limit(20)
: await db.select().from(questions).where(and(...conditions)).limit(20); : await db.select().from(questions).where(and(...conditions)).limit(20);
return available[0] ?? null; return available.slice(0, CHALLENGE_RULES.questionsPerSession);
} }
async function getCorrectAnswersToday(userId: string): Promise<number> { async function getCorrectAnswersToday(userId: string): Promise<number> {
@ -138,7 +139,7 @@ async function getKnowledgeCard(question: QuestionRow): Promise<AnswerResultDto[
}; };
} }
export async function getNextChallenge(userId: string, trackId: string): Promise<ChallengeQuestionDto | null> { export async function getNextChallenge(userId: string, trackId: string): Promise<ChallengeSessionDto | null> {
const category = await getTrackCategory(trackId); const category = await getTrackCategory(trackId);
if (!category || category.status !== 'active') { if (!category || category.status !== 'active') {
throw new NotFoundError('Track'); throw new NotFoundError('Track');
@ -147,10 +148,30 @@ export async function getNextChallenge(userId: string, trackId: string): Promise
const chapter = await getCurrentChapter(userId, category.id); const chapter = await getCurrentChapter(userId, category.id);
if (!chapter) return null; if (!chapter) return null;
const question = await getQuestionForChapter(userId, chapter); const sessionQuestions = await getQuestionsForChapter(userId, chapter);
if (!question) return null; if (sessionQuestions.length < CHALLENGE_RULES.questionsPerSession) return null;
return toChallengeDto(category.slug || category.id, chapter, question); const sessionId = uuid();
const resolvedTrackId = category.slug || category.id;
await db.insert(challengeSessions).values({
id: sessionId,
userId,
trackId: resolvedTrackId,
categoryId: category.id,
chapterId: chapter.id,
status: 'pending',
clientRequestId: sessionId,
questionIds: sessionQuestions.map((question) => question.id),
totalQuestions: sessionQuestions.length,
});
return {
challengeId: sessionId,
trackId: resolvedTrackId,
nodeId: chapter.id,
totalQuestions: sessionQuestions.length,
questions: sessionQuestions.map((question) => toChallengeDto(sessionId, resolvedTrackId, chapter, question)),
};
} }
export async function submitChallengeAnswer( export async function submitChallengeAnswer(

View File

@ -81,6 +81,14 @@ export interface ChallengeQuestionDto {
}; };
} }
export interface ChallengeSessionDto {
challengeId: string;
trackId: string;
nodeId: string;
totalQuestions: number;
questions: readonly ChallengeQuestionDto[];
}
export interface AnswerRequestDto { export interface AnswerRequestDto {
challengeId: string; challengeId: string;
questionId: string; questionId: string;