Create challenge sessions with five questions
This commit is contained in:
parent
fd4c2b6361
commit
1d84de8d15
@ -281,11 +281,17 @@
|
|||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"challengeId": "question-uuid",
|
"challengeId": "challenge-session-uuid",
|
||||||
|
"trackId": "history",
|
||||||
|
"nodeId": "chapter-uuid",
|
||||||
|
"totalQuestions": 5,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"challengeId": "challenge-session-uuid",
|
||||||
"trackId": "history",
|
"trackId": "history",
|
||||||
"nodeId": "chapter-uuid",
|
"nodeId": "chapter-uuid",
|
||||||
"question": {
|
"question": {
|
||||||
"id": "question-uuid",
|
"id": "question-uuid-1",
|
||||||
"prompt": "题目文本",
|
"prompt": "题目文本",
|
||||||
"options": [
|
"options": [
|
||||||
{ "id": "a", "text": "选项 A" },
|
{ "id": "a", "text": "选项 A" },
|
||||||
@ -293,12 +299,14 @@
|
|||||||
{ "id": "c", "text": "选项 C" }
|
{ "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,
|
||||||
|
|||||||
@ -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 颗 |
|
||||||
|
|||||||
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 { 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(
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user