feat: enforce daily high-reward session limits with tier-based quotas

Free users get 3 high-reward sessions/day, Plus users get 8. Sessions
after quota are still playable but with degraded XP rewards.
This commit is contained in:
Wang Zhuoxuan 2026-05-12 00:01:31 +08:00
parent 708165e121
commit 05b9faa0ea
4 changed files with 172 additions and 9 deletions

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js'; 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 = { const category = {
id: 'history', 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', () => { describe('challenge-service', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -71,17 +81,82 @@ describe('challenge-service', () => {
{ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }, { 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', () => { describe('getNextChallenge', () => {
it('creates a challenge session with five questions and hides correct answers', async () => { it('creates a challenge session with five questions and hides correct answers', async () => {
const insertedValues = vi.fn().mockResolvedValue([]); const insertedValues = vi.fn().mockResolvedValue([]);
vi.mocked(db.select) vi.mocked(db.select)
// getTrackCategory
.mockReturnValueOnce(selectChain([category]) as never) .mockReturnValueOnce(selectChain([category]) as never)
// getCurrentChapter → chapters
.mockReturnValueOnce(selectChain([chapter]) as never) .mockReturnValueOnce(selectChain([chapter]) as never)
// getCurrentChapter → progress
.mockReturnValueOnce(selectChain([]) as never) .mockReturnValueOnce(selectChain([]) as never)
// getQuestionsForChapter → answered
.mockReturnValueOnce(selectChain([]) as never) .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); vi.mocked(db.insert).mockReturnValue({ values: insertedValues } as never);
const result = await getNextChallenge('user-1', 'history'); const result = await getNextChallenge('user-1', 'history');
@ -90,6 +165,7 @@ describe('challenge-service', () => {
expect(result?.trackId).toBe('history'); expect(result?.trackId).toBe('history');
expect(result?.nodeId).toBe('chapter-1'); expect(result?.nodeId).toBe('chapter-1');
expect(result?.questions).toHaveLength(5); expect(result?.questions).toHaveLength(5);
expect(result?.highRewardEligible).toBe(true);
expect(result?.questions.every((item) => item.challengeId === result.challengeId)).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(result?.questions[0]?.question.options[0]).toEqual(expect.not.objectContaining({ isCorrect: expect.any(Boolean) }));
expect(insertedValues).toHaveBeenCalledWith(expect.objectContaining({ expect(insertedValues).toHaveBeenCalledWith(expect.objectContaining({
@ -100,8 +176,49 @@ describe('challenge-service', () => {
status: 'pending', status: 'pending',
questionIds: questions.map((question) => question.id), questionIds: questions.map((question) => question.id),
totalQuestions: 5, 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', () => { describe('submitChallengeAnswer', () => {

View File

@ -1,5 +1,5 @@
import { db } from '../../db/client.js'; 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 { and, asc, eq, notInArray, or, 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';
@ -124,6 +124,31 @@ async function getCorrectAnswersToday(userId: string): Promise<number> {
return rows.length; 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<AnswerResultDto['knowledgeCard']> { async function getKnowledgeCard(question: QuestionRow): Promise<AnswerResultDto['knowledgeCard']> {
const [card] = await db const [card] = await db
.select() .select()
@ -229,10 +254,12 @@ async function updateDailyProgress(userId: string, session: ChallengeSessionRow,
.where(eq(userDailyProgress.id, daily.id)); .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 [ return [
{ type: 'xp', amount: XP_RULES.completeChallenge, title: `完成挑战 +${XP_RULES.completeChallenge} XP` }, { type: 'xp', amount: completeXp, title: `完成挑战 +${completeXp} XP` },
...(correctCount >= totalQuestions ? [{ type: 'xp', amount: XP_RULES.perfectChallengeBonus, title: `全对奖励 +${XP_RULES.perfectChallengeBonus} XP` }] : []), ...(perfectXp > 0 ? [{ type: 'xp' as const, amount: perfectXp, title: `全对奖励 +${perfectXp} XP` }] : []),
]; ];
} }
@ -243,8 +270,9 @@ async function settleCompletedChallenge(
totalQuestions: number, totalQuestions: number,
): Promise<{ rewards: AnswerResultDto['rewards']; xpDelta: number; progressBefore: ProgressSummaryDto }> { ): Promise<{ rewards: AnswerResultDto['rewards']; xpDelta: number; progressBefore: ProgressSummaryDto }> {
const progressBefore = await getProgressSummary(userId); const progressBefore = await getProgressSummary(userId);
const completeXp = XP_RULES.completeChallenge; const multiplier = session.highRewardEligible ? 1 : CHALLENGE_RULES.highRewardExhaustedXpMultiplier;
const perfectXp = correctCount >= totalQuestions ? XP_RULES.perfectChallengeBonus : 0; const completeXp = Math.floor(XP_RULES.completeChallenge * multiplier);
const perfectXp = correctCount >= totalQuestions ? Math.floor(XP_RULES.perfectChallengeBonus * multiplier) : 0;
const xpDelta = completeXp + perfectXp; const xpDelta = completeXp + perfectXp;
if (xpDelta > 0) { if (xpDelta > 0) {
@ -256,7 +284,7 @@ async function settleCompletedChallenge(
updateDailyProgress(userId, session, xpDelta), updateDailyProgress(userId, session, xpDelta),
]); ]);
const rewards = getChallengeCompletionRewards(correctCount, totalQuestions); const rewards = getChallengeCompletionRewards(correctCount, totalQuestions, multiplier);
return { rewards, xpDelta, progressBefore }; return { rewards, xpDelta, progressBefore };
} }
@ -273,6 +301,15 @@ export async function getNextChallenge(userId: string, trackId: string): Promise
const sessionQuestions = await getQuestionsForChapter(userId, chapter); const sessionQuestions = await getQuestionsForChapter(userId, chapter);
if (sessionQuestions.length < CHALLENGE_RULES.questionsPerSession) return null; 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 sessionId = uuid();
const resolvedTrackId = category.slug || category.id; const resolvedTrackId = category.slug || category.id;
await db.insert(challengeSessions).values({ await db.insert(challengeSessions).values({
@ -285,6 +322,7 @@ export async function getNextChallenge(userId: string, trackId: string): Promise
clientRequestId: sessionId, clientRequestId: sessionId,
questionIds: sessionQuestions.map((question) => question.id), questionIds: sessionQuestions.map((question) => question.id),
totalQuestions: sessionQuestions.length, totalQuestions: sessionQuestions.length,
highRewardEligible: eligible,
}); });
return { return {
@ -292,6 +330,7 @@ export async function getNextChallenge(userId: string, trackId: string): Promise
trackId: resolvedTrackId, trackId: resolvedTrackId,
nodeId: chapter.id, nodeId: chapter.id,
totalQuestions: sessionQuestions.length, totalQuestions: sessionQuestions.length,
highRewardEligible: eligible === 1,
questions: sessionQuestions.map((question) => toChallengeDto(sessionId, resolvedTrackId, chapter, question)), questions: sessionQuestions.map((question) => toChallengeDto(sessionId, resolvedTrackId, chapter, question)),
}; };
} }

View File

@ -4,6 +4,7 @@ import { eq, sql } from 'drizzle-orm';
import { getHearts } from '../progress/hearts-service.js'; import { getHearts } from '../progress/hearts-service.js';
import { calculateStreak, freezeStreak } from '../progress/streak-service.js'; import { calculateStreak, freezeStreak } from '../progress/streak-service.js';
import { getSubscriptionStatus } from '../payment/subscription-service.js'; import { getSubscriptionStatus } from '../payment/subscription-service.js';
import { getHighRewardQuota } from './challenge-service.js';
import type { ProgressSummaryDto, RewardSource } from '../../types/app-api.js'; import type { ProgressSummaryDto, RewardSource } from '../../types/app-api.js';
export const FREE_DAILY_ATTEMPTS = 5; export const FREE_DAILY_ATTEMPTS = 5;
@ -162,6 +163,7 @@ export async function getProgressSummary(userId: string): Promise<ProgressSummar
const xp = user?.xpTotal ?? 0; const xp = user?.xpTotal ?? 0;
const level = getLevelInfo(xp); const level = getLevelInfo(xp);
const isSubscribed = subscription.status === 'active' && subscription.tier !== 'free'; const isSubscribed = subscription.status === 'active' && subscription.tier !== 'free';
const highRewardQuota = await getHighRewardQuota(userId, user?.tier ?? null);
return { return {
hearts: hearts.remaining, hearts: hearts.remaining,
@ -170,6 +172,8 @@ export async function getProgressSummary(userId: string): Promise<ProgressSummar
dailyAttemptsLeft: attempts.left, dailyAttemptsLeft: attempts.left,
dailyAttemptsMax: attempts.max, dailyAttemptsMax: attempts.max,
nextAttemptResetAt: attempts.nextResetAt, nextAttemptResetAt: attempts.nextResetAt,
highRewardSessionsLeft: highRewardQuota.remaining,
highRewardSessionsMax: highRewardQuota.max,
xp, xp,
level: level.level, level: level.level,
xpToNextLevel: level.xpToNextLevel, xpToNextLevel: level.xpToNextLevel,

View File

@ -27,6 +27,8 @@ export interface ProgressSummaryDto {
dailyAttemptsLeft: number; dailyAttemptsLeft: number;
dailyAttemptsMax: number; dailyAttemptsMax: number;
nextAttemptResetAt: string | null; nextAttemptResetAt: string | null;
highRewardSessionsLeft: number;
highRewardSessionsMax: number;
xp: number; xp: number;
level: number; level: number;
xpToNextLevel: number; xpToNextLevel: number;
@ -86,6 +88,7 @@ export interface ChallengeSessionDto {
trackId: string; trackId: string;
nodeId: string; nodeId: string;
totalQuestions: number; totalQuestions: number;
highRewardEligible: boolean;
questions: readonly ChallengeQuestionDto[]; questions: readonly ChallengeQuestionDto[];
} }