From 9e0f97d162c69ec52f0b58645db752b2845116c5 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Mon, 11 May 2026 21:40:41 +0800 Subject: [PATCH] Settle completed challenge sessions --- docs/gamification-server-plan.md | 2 +- .../learning/challenge-service.test.ts | 14 +- src/services/learning/challenge-service.ts | 164 +++++++++++++++++- 3 files changed, 170 insertions(+), 10 deletions(-) diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 997b81d..a7ba610 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -43,7 +43,7 @@ |---|------|------|----------| | G1-1 | 实现创建挑战组服务 | [x] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter | | G1-2 | 实现挑战组答题提交 | [x] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 | -| G1-3 | 实现挑战组完成结算 | [ ] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 | +| G1-3 | 实现挑战组完成结算 | [x] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 | | G1-4 | 调整红心扣除边界 | [ ] | 答错扣 1 颗;Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 | | G1-5 | 调整每日高奖励挑战次数 | [ ] | 免费用户每日 3 组,Plus 8 组或按产品确认无限;次数为 0 后仍可继续学习但高价值奖励降级 | | G1-6 | 更新挑战 API DTO | [ ] | `/challenges/next` 或新增 session API 能表达组、题、组进度、资源状态 | diff --git a/src/__tests__/services/learning/challenge-service.test.ts b/src/__tests__/services/learning/challenge-service.test.ts index 3678ee8..19dcbd0 100644 --- a/src/__tests__/services/learning/challenge-service.test.ts +++ b/src/__tests__/services/learning/challenge-service.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { db } from '../../../db/client.js'; -import { getNextChallenge, submitChallengeAnswer } from '../../../services/learning/challenge-service.js'; +import { getChallengeCompletionRewards, getNextChallenge, submitChallengeAnswer } from '../../../services/learning/challenge-service.js'; const category = { id: 'history', @@ -61,6 +61,18 @@ describe('challenge-service', () => { vi.clearAllMocks(); }); + describe('getChallengeCompletionRewards', () => { + it('adds the perfect bonus only when all questions are correct', () => { + expect(getChallengeCompletionRewards(5, 5)).toEqual([ + { type: 'xp', amount: 20, title: '完成挑战 +20 XP' }, + { type: 'xp', amount: 30, title: '全对奖励 +30 XP' }, + ]); + expect(getChallengeCompletionRewards(4, 5)).toEqual([ + { type: 'xp', amount: 20, title: '完成挑战 +20 XP' }, + ]); + }); + }); + describe('getNextChallenge', () => { it('creates a challenge session with five questions and hides correct answers', async () => { const insertedValues = vi.fn().mockResolvedValue([]); diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts index 80197dd..2cb7ba9 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 { challengeSessionAnswers, challengeSessions, knowledgeCards, questions, skillTree, userChapterProgress, userProgress } from '../../db/schema.js'; +import { challengeSessionAnswers, challengeSessions, knowledgeCards, questions, skillTree, userChapterProgress, userDailyProgress, userProgress } from '../../db/schema.js'; import { and, asc, eq, notInArray, or, sql } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { NotFoundError, ValidationError } from '../../utils/errors.js'; @@ -8,11 +8,12 @@ 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 { CHALLENGE_RULES } from '../gamification/rules.js'; -import type { AnswerResultDto, ChallengeQuestionDto, ChallengeSessionDto } from '../../types/app-api.js'; +import { CHALLENGE_RULES, XP_RULES } from '../gamification/rules.js'; +import type { AnswerResultDto, ChallengeQuestionDto, ChallengeSessionDto, ProgressSummaryDto } from '../../types/app-api.js'; type QuestionRow = typeof questions.$inferSelect; type ChapterRow = typeof skillTree.$inferSelect; +type ChallengeSessionRow = typeof challengeSessions.$inferSelect; interface OptionDto { id: string; @@ -35,6 +36,14 @@ function hash(value: string): number { return result; } +function todayUtc(): string { + return new Date().toISOString().slice(0, 10); +} + +function toRecord(value: unknown): Record { + return value as Record; +} + function buildOptions(question: QuestionRow): readonly OptionDto[] { const distractors = Array.isArray(question.distractors) ? question.distractors.filter((item): item is string => typeof item === 'string') : []; const rawOptions = [ @@ -139,6 +148,119 @@ async function getKnowledgeCard(question: QuestionRow): Promise { + if (!session.chapterId) return; + + const [chapter] = await db + .select() + .from(skillTree) + .where(eq(skillTree.id, session.chapterId)) + .limit(1); + const passThreshold = chapter?.passThreshold ?? Math.ceil(totalQuestions / 2); + const nextStatus = correctCount >= totalQuestions ? 'perfect' : correctCount >= passThreshold ? 'passed' : 'unlocked'; + + const [current] = await db + .select() + .from(userChapterProgress) + .where(and(eq(userChapterProgress.userId, userId), eq(userChapterProgress.chapterId, session.chapterId))) + .limit(1); + + if (!current) { + await db.insert(userChapterProgress).values({ + id: uuid(), + userId, + chapterId: session.chapterId, + status: nextStatus, + bestCorrectCount: correctCount, + attempts: 1, + completedAt: nextStatus === 'passed' || nextStatus === 'perfect' ? sql`NOW()` : undefined, + }); + return; + } + + await db + .update(userChapterProgress) + .set({ + status: sql`CASE + WHEN status = 'perfect' THEN 'perfect' + WHEN status = 'passed' AND ${nextStatus} = 'unlocked' THEN 'passed' + ELSE ${nextStatus} + END`, + bestCorrectCount: sql`GREATEST(COALESCE(best_correct_count, 0), ${correctCount})`, + attempts: sql`COALESCE(attempts, 0) + 1`, + completedAt: nextStatus === 'passed' || nextStatus === 'perfect' ? sql`NOW()` : undefined, + }) + .where(and(eq(userChapterProgress.userId, userId), eq(userChapterProgress.chapterId, session.chapterId))); +} + +async function updateDailyProgress(userId: string, session: ChallengeSessionRow, xpDelta: number): Promise { + const progressDate = todayUtc(); + const [daily] = await db + .select() + .from(userDailyProgress) + .where(and(eq(userDailyProgress.userId, userId), eq(userDailyProgress.progressDate, sql`CAST(${progressDate} AS DATE)`))) + .limit(1); + + if (!daily) { + await db.insert(userDailyProgress).values({ + id: uuid(), + userId, + progressDate: sql`CAST(${progressDate} AS DATE)`, + firstChallengeSessionId: session.id, + firstChallengeCompletedAt: sql`NOW()`, + challengeSessionsCompleted: 1, + highRewardSessionsUsed: session.highRewardEligible ? 1 : 0, + xpEarned: xpDelta, + streakCounted: 1, + }); + return; + } + + await db + .update(userDailyProgress) + .set({ + firstChallengeSessionId: daily.firstChallengeSessionId ?? session.id, + firstChallengeCompletedAt: daily.firstChallengeCompletedAt ?? sql`NOW()`, + challengeSessionsCompleted: sql`COALESCE(challenge_sessions_completed, 0) + 1`, + highRewardSessionsUsed: sql`COALESCE(high_reward_sessions_used, 0) + ${session.highRewardEligible ? 1 : 0}`, + xpEarned: sql`COALESCE(xp_earned, 0) + ${xpDelta}`, + streakCounted: 1, + }) + .where(eq(userDailyProgress.id, daily.id)); +} + +export function getChallengeCompletionRewards(correctCount: number, totalQuestions: number): AnswerResultDto['rewards'] { + return [ + { type: 'xp', amount: XP_RULES.completeChallenge, title: `完成挑战 +${XP_RULES.completeChallenge} XP` }, + ...(correctCount >= totalQuestions ? [{ type: 'xp', amount: XP_RULES.perfectChallengeBonus, title: `全对奖励 +${XP_RULES.perfectChallengeBonus} XP` }] : []), + ]; +} + +async function settleCompletedChallenge( + userId: string, + session: ChallengeSessionRow, + correctCount: number, + totalQuestions: number, +): Promise<{ rewards: AnswerResultDto['rewards']; xpDelta: number; progressBefore: ProgressSummaryDto }> { + const progressBefore = await getProgressSummary(userId); + const completeXp = XP_RULES.completeChallenge; + const perfectXp = correctCount >= totalQuestions ? XP_RULES.perfectChallengeBonus : 0; + const xpDelta = completeXp + perfectXp; + + if (xpDelta > 0) { + await addXp(userId, xpDelta); + } + + await Promise.all([ + updateChapterProgress(userId, session, correctCount, totalQuestions), + updateDailyProgress(userId, session, xpDelta), + ]); + + const rewards = getChallengeCompletionRewards(correctCount, totalQuestions); + + return { rewards, xpDelta, progressBefore }; +} + export async function getNextChallenge(userId: string, trackId: string): Promise { const category = await getTrackCategory(trackId); if (!category || category.status !== 'active') { @@ -251,15 +373,32 @@ export async function submitChallengeAnswer( .where(eq(questions.id, questionId)); let xpDelta = 0; + const rewards: Array<{ type: string; amount?: number; title?: string }> = []; if (correct) { xpDelta = calculateXp(BASE_XP, comboCount); await addXp(userId, xpDelta); await updateStreak(userId, await getCorrectAnswersToday(userId)); + if (xpDelta > 0) { + rewards.push({ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }); + } } else { await deductHeart(userId); await deductDailyAttempt(userId); } + const totalQuestions = session.totalQuestions ?? CHALLENGE_RULES.questionsPerSession; + const answeredAfter = Math.min((session.answeredCount ?? 0) + 1, totalQuestions); + const correctAfter = (session.correctCount ?? 0) + (correct ? 1 : 0); + const completed = answeredAfter >= totalQuestions; + let completionProgressBefore: ProgressSummaryDto | null = null; + let completionXpDelta = 0; + if (completed) { + const completion = await settleCompletedChallenge(userId, session, correctAfter, totalQuestions); + completionProgressBefore = completion.progressBefore; + completionXpDelta = completion.xpDelta; + rewards.push(...completion.rewards); + } + const [progress, knowledgeCard] = await Promise.all([ getProgressSummary(userId), getKnowledgeCard(question), @@ -268,7 +407,7 @@ export async function submitChallengeAnswer( const result: AnswerResultDto = { answerState: correct ? 'correct' : 'wrong', correctOptionId, - xpDelta, + xpDelta: xpDelta + completionXpDelta, progress: { hearts: progress.hearts, dailyAttemptsLeft: progress.dailyAttemptsLeft, @@ -276,7 +415,7 @@ export async function submitChallengeAnswer( streakDays: progress.streakDays, }, knowledgeCard, - rewards: correct && xpDelta > 0 ? [{ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }] : [], + rewards, }; await db.insert(challengeSessionAnswers).values({ @@ -296,9 +435,18 @@ export async function submitChallengeAnswer( await db .update(challengeSessions) .set({ - status: 'in_progress', - answeredCount: sql`LEAST(COALESCE(answered_count, 0) + 1, COALESCE(total_questions, ${CHALLENGE_RULES.questionsPerSession}))`, - correctCount: correct ? sql`COALESCE(correct_count, 0) + 1` : sql`COALESCE(correct_count, 0)`, + status: completed ? 'completed' : 'in_progress', + answeredCount: answeredAfter, + correctCount: correctAfter, + rewardSnapshot: completed ? toRecord({ + rewards, + xpDelta: completionXpDelta, + correctCount: correctAfter, + totalQuestions, + }) : undefined, + progressBefore: completionProgressBefore ? toRecord(completionProgressBefore) : undefined, + progressAfter: completed ? toRecord(progress) : undefined, + completedAt: completed ? sql`NOW()` : undefined, }) .where(eq(challengeSessions.id, challengeId));