diff --git a/src/__tests__/services/learning/challenge-service.test.ts b/src/__tests__/services/learning/challenge-service.test.ts index 19dcbd0..270a271 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 { getChallengeCompletionRewards, getNextChallenge, submitChallengeAnswer } from '../../../services/learning/challenge-service.js'; +import { getChallengeCompletionRewards, getHighRewardQuota, getNextChallenge, submitChallengeAnswer } from '../../../services/learning/challenge-service.js'; const category = { id: 'history', @@ -56,6 +56,16 @@ function selectChain(result: unknown) { }; } +function selectWithWhere(result: unknown) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(result), + }), + }), + }; +} + describe('challenge-service', () => { beforeEach(() => { vi.clearAllMocks(); @@ -71,17 +81,82 @@ describe('challenge-service', () => { { type: 'xp', amount: 20, title: '完成挑战 +20 XP' }, ]); }); + + it('applies XP multiplier for degraded rewards', () => { + // multiplier = 0.5 as an example future value; current default is 1 + expect(getChallengeCompletionRewards(5, 5, 0.5)).toEqual([ + { type: 'xp', amount: 10, title: '完成挑战 +10 XP' }, + { type: 'xp', amount: 15, title: '全对奖励 +15 XP' }, + ]); + }); + }); + + describe('getHighRewardQuota', () => { + it('returns full quota when no daily progress exists for free user', async () => { + // getHighRewardQuota does: select from userDailyProgress + vi.mocked(db.select).mockReturnValueOnce( + selectWithWhere([]) as never, + ); + + const quota = await getHighRewardQuota('user-1', 'free'); + expect(quota).toEqual({ max: 3, used: 0, remaining: 3 }); + }); + + it('returns full quota when no daily progress exists for pro user', async () => { + vi.mocked(db.select).mockReturnValueOnce( + selectWithWhere([]) as never, + ); + + const quota = await getHighRewardQuota('user-1', 'pro'); + expect(quota).toEqual({ max: 8, used: 0, remaining: 8 }); + }); + + it('returns correct remaining for free user with some used', async () => { + vi.mocked(db.select).mockReturnValueOnce( + selectWithWhere([{ used: 2, restored: 0 }]) as never, + ); + + const quota = await getHighRewardQuota('user-1', 'free'); + expect(quota).toEqual({ max: 3, used: 2, remaining: 1 }); + }); + + it('returns zero remaining when quota exhausted', async () => { + vi.mocked(db.select).mockReturnValueOnce( + selectWithWhere([{ used: 3, restored: 0 }]) as never, + ); + + const quota = await getHighRewardQuota('user-1', 'free'); + expect(quota).toEqual({ max: 3, used: 3, remaining: 0 }); + }); + + it('accounts for restored sessions', async () => { + vi.mocked(db.select).mockReturnValueOnce( + selectWithWhere([{ used: 3, restored: 1 }]) as never, + ); + + const quota = await getHighRewardQuota('user-1', 'free'); + expect(quota).toEqual({ max: 3, used: 2, remaining: 1 }); + }); }); describe('getNextChallenge', () => { it('creates a challenge session with five questions and hides correct answers', async () => { const insertedValues = vi.fn().mockResolvedValue([]); vi.mocked(db.select) + // getTrackCategory .mockReturnValueOnce(selectChain([category]) as never) + // getCurrentChapter → chapters .mockReturnValueOnce(selectChain([chapter]) as never) + // getCurrentChapter → progress .mockReturnValueOnce(selectChain([]) as never) + // getQuestionsForChapter → answered .mockReturnValueOnce(selectChain([]) as never) - .mockReturnValueOnce(selectChain(questions) as never); + // getQuestionsForChapter → questions + .mockReturnValueOnce(selectChain(questions) as never) + // user tier lookup + .mockReturnValueOnce(selectWithWhere([{ tier: 'free' }]) as never) + // getHighRewardQuota → no daily progress (full quota) + .mockReturnValueOnce(selectWithWhere([]) as never); vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never); const result = await getNextChallenge('user-1', 'history'); @@ -90,6 +165,7 @@ describe('challenge-service', () => { expect(result?.trackId).toBe('history'); expect(result?.nodeId).toBe('chapter-1'); expect(result?.questions).toHaveLength(5); + expect(result?.highRewardEligible).toBe(true); 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({ @@ -100,8 +176,49 @@ describe('challenge-service', () => { status: 'pending', questionIds: questions.map((question) => question.id), totalQuestions: 5, + highRewardEligible: 1, })); }); + + it('sets highRewardEligible to false when quota exhausted', 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) + // user tier + .mockReturnValueOnce(selectWithWhere([{ tier: 'free' }]) as never) + // getHighRewardQuota → used=3, restored=0 (exhausted) + .mockReturnValueOnce(selectWithWhere([{ used: 3, restored: 0 }]) as never); + vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never); + + const result = await getNextChallenge('user-1', 'history'); + + expect(result?.highRewardEligible).toBe(false); + expect(insertedValues).toHaveBeenCalledWith(expect.objectContaining({ + highRewardEligible: 0, + })); + }); + + it('uses plus quota (8) for pro users', 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) + .mockReturnValueOnce(selectWithWhere([{ tier: 'pro' }]) as never) + .mockReturnValueOnce(selectWithWhere([{ used: 5, restored: 0 }]) as never); + vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never); + + const result = await getNextChallenge('user-1', 'history'); + + // Pro user with 5 used out of 8 → still eligible + expect(result?.highRewardEligible).toBe(true); + }); }); describe('submitChallengeAnswer', () => { diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts index 11747f7..13dc185 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, userDailyProgress, userProgress } from '../../db/schema.js'; +import { challengeSessionAnswers, challengeSessions, knowledgeCards, questions, skillTree, userChapterProgress, userDailyProgress, userProgress, users } 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'; @@ -124,6 +124,31 @@ async function getCorrectAnswersToday(userId: string): Promise { return rows.length; } +export async function getHighRewardQuota(userId: string, tier: string | null): Promise<{ + max: number; + used: number; + remaining: number; +}> { + const max = tier === 'pro' || tier === 'proplus' + ? CHALLENGE_RULES.plusDailyHighRewardSessions + : CHALLENGE_RULES.freeDailyHighRewardSessions; + + const today = todayUtc(); + const [daily] = await db + .select({ + used: userDailyProgress.highRewardSessionsUsed, + restored: userDailyProgress.highRewardSessionsRestored, + }) + .from(userDailyProgress) + .where(and(eq(userDailyProgress.userId, userId), eq(userDailyProgress.progressDate, sql`CAST(${today} AS DATE)`))) + .limit(1); + + if (!daily) return { max, used: 0, remaining: max }; + + const effectiveUsed = Math.max(0, (daily.used ?? 0) - (daily.restored ?? 0)); + return { max, used: effectiveUsed, remaining: Math.max(0, max - effectiveUsed) }; +} + async function getKnowledgeCard(question: QuestionRow): Promise { const [card] = await db .select() @@ -229,10 +254,12 @@ async function updateDailyProgress(userId: string, session: ChallengeSessionRow, .where(eq(userDailyProgress.id, daily.id)); } -export function getChallengeCompletionRewards(correctCount: number, totalQuestions: number): AnswerResultDto['rewards'] { +export function getChallengeCompletionRewards(correctCount: number, totalQuestions: number, multiplier = 1): AnswerResultDto['rewards'] { + const completeXp = Math.floor(XP_RULES.completeChallenge * multiplier); + const perfectXp = correctCount >= totalQuestions ? Math.floor(XP_RULES.perfectChallengeBonus * multiplier) : 0; 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` }] : []), + { type: 'xp', amount: completeXp, title: `完成挑战 +${completeXp} XP` }, + ...(perfectXp > 0 ? [{ type: 'xp' as const, amount: perfectXp, title: `全对奖励 +${perfectXp} XP` }] : []), ]; } @@ -243,8 +270,9 @@ async function settleCompletedChallenge( 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 multiplier = session.highRewardEligible ? 1 : CHALLENGE_RULES.highRewardExhaustedXpMultiplier; + const completeXp = Math.floor(XP_RULES.completeChallenge * multiplier); + const perfectXp = correctCount >= totalQuestions ? Math.floor(XP_RULES.perfectChallengeBonus * multiplier) : 0; const xpDelta = completeXp + perfectXp; if (xpDelta > 0) { @@ -256,7 +284,7 @@ async function settleCompletedChallenge( updateDailyProgress(userId, session, xpDelta), ]); - const rewards = getChallengeCompletionRewards(correctCount, totalQuestions); + const rewards = getChallengeCompletionRewards(correctCount, totalQuestions, multiplier); return { rewards, xpDelta, progressBefore }; } @@ -273,6 +301,15 @@ export async function getNextChallenge(userId: string, trackId: string): Promise const sessionQuestions = await getQuestionsForChapter(userId, chapter); if (sessionQuestions.length < CHALLENGE_RULES.questionsPerSession) return null; + const [user] = await db + .select({ tier: users.tier }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + const quota = await getHighRewardQuota(userId, user?.tier ?? null); + const eligible = quota.remaining > 0 ? 1 : 0; + const sessionId = uuid(); const resolvedTrackId = category.slug || category.id; await db.insert(challengeSessions).values({ @@ -285,6 +322,7 @@ export async function getNextChallenge(userId: string, trackId: string): Promise clientRequestId: sessionId, questionIds: sessionQuestions.map((question) => question.id), totalQuestions: sessionQuestions.length, + highRewardEligible: eligible, }); return { @@ -292,6 +330,7 @@ export async function getNextChallenge(userId: string, trackId: string): Promise trackId: resolvedTrackId, nodeId: chapter.id, totalQuestions: sessionQuestions.length, + highRewardEligible: eligible === 1, questions: sessionQuestions.map((question) => toChallengeDto(sessionId, resolvedTrackId, chapter, question)), }; } diff --git a/src/services/learning/progress-summary-service.ts b/src/services/learning/progress-summary-service.ts index 88ed247..6ae9a16 100644 --- a/src/services/learning/progress-summary-service.ts +++ b/src/services/learning/progress-summary-service.ts @@ -4,6 +4,7 @@ import { eq, sql } from 'drizzle-orm'; import { getHearts } from '../progress/hearts-service.js'; import { calculateStreak, freezeStreak } from '../progress/streak-service.js'; import { getSubscriptionStatus } from '../payment/subscription-service.js'; +import { getHighRewardQuota } from './challenge-service.js'; import type { ProgressSummaryDto, RewardSource } from '../../types/app-api.js'; export const FREE_DAILY_ATTEMPTS = 5; @@ -162,6 +163,7 @@ export async function getProgressSummary(userId: string): Promise