From 1d84de8d1564c0d71fde70bf407d79303300cf4a Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Mon, 11 May 2026 18:32:40 +0800 Subject: [PATCH] Create challenge sessions with five questions --- docs/api-reference.md | 34 ++++--- docs/gamification-server-plan.md | 2 +- .../learning/challenge-service.test.ts | 94 +++++++++++++++++++ src/services/learning/challenge-service.ts | 41 ++++++-- src/types/app-api.ts | 8 ++ 5 files changed, 155 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/services/learning/challenge-service.test.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index 87c13a0..b4aa3e3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -281,24 +281,32 @@ { "success": true, "data": { - "challengeId": "question-uuid", + "challengeId": "challenge-session-uuid", "trackId": "history", "nodeId": "chapter-uuid", - "question": { - "id": "question-uuid", - "prompt": "题目文本", - "options": [ - { "id": "a", "text": "选项 A" }, - { "id": "b", "text": "选项 B" }, - { "id": "c", "text": "选项 C" } - ] - } + "totalQuestions": 5, + "questions": [ + { + "challengeId": "challenge-session-uuid", + "trackId": "history", + "nodeId": "chapter-uuid", + "question": { + "id": "question-uuid-1", + "prompt": "题目文本", + "options": [ + { "id": "a", "text": "选项 A" }, + { "id": "b", "text": "选项 B" }, + { "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, diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 7ecd68c..5248044 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -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 颗 | diff --git a/src/__tests__/services/learning/challenge-service.test.ts b/src/__tests__/services/learning/challenge-service.test.ts new file mode 100644 index 0000000..729dbcb --- /dev/null +++ b/src/__tests__/services/learning/challenge-service.test.ts @@ -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, + })); + }); + }); +}); diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts index 45de540..fe96f4a 100644 --- a/src/services/learning/challenge-service.ts +++ b/src/services/learning/challenge-service.ts @@ -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 { +async function getQuestionsForChapter(userId: string, chapter: ChapterRow): Promise { 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 { @@ -138,7 +139,7 @@ async function getKnowledgeCard(question: QuestionRow): Promise { +export async function getNextChallenge(userId: string, trackId: string): Promise { 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( diff --git a/src/types/app-api.ts b/src/types/app-api.ts index 0b9ffdd..a9597cb 100644 --- a/src/types/app-api.ts +++ b/src/types/app-api.ts @@ -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;