Create challenge sessions with five questions
This commit is contained in:
parent
fd4c2b6361
commit
1d84de8d15
@ -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,
|
||||
|
||||
@ -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 颗 |
|
||||
|
||||
94
src/__tests__/services/learning/challenge-service.test.ts
Normal file
94
src/__tests__/services/learning/challenge-service.test.ts
Normal 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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user