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

View File

@ -41,7 +41,7 @@
| # | 任务 | 状态 | 验收标准 |
|---|------|------|----------|
| G1-1 | 实现创建挑战组服务 | [ ] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter |
| G1-1 | 实现创建挑战组服务 | [x] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter |
| G1-2 | 实现挑战组答题提交 | [ ] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 |
| G1-3 | 实现挑战组完成结算 | [ ] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 |
| 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 { 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 { v4 as uuid } from 'uuid';
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 { deductDailyAttempt, getProgressSummary } from './progress-summary-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 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 {
challengeId: question.id,
challengeId,
trackId,
nodeId: chapter.id,
question: {
@ -82,7 +83,7 @@ async function getCurrentChapter(userId: string, categoryId: string): Promise<Ch
?? 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
.select({ questionId: userProgress.questionId })
.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)).limit(20);
return available[0] ?? null;
return available.slice(0, CHALLENGE_RULES.questionsPerSession);
}
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);
if (!category || category.status !== 'active') {
throw new NotFoundError('Track');
@ -147,10 +148,30 @@ export async function getNextChallenge(userId: string, trackId: string): Promise
const chapter = await getCurrentChapter(userId, category.id);
if (!chapter) return null;
const question = await getQuestionForChapter(userId, chapter);
if (!question) return null;
const sessionQuestions = await getQuestionsForChapter(userId, chapter);
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(

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 {
challengeId: string;
questionId: string;