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:
parent
708165e121
commit
05b9faa0ea
@ -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', () => {
|
||||
|
||||
@ -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<number> {
|
||||
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']> {
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<ProgressSummar
|
||||
const xp = user?.xpTotal ?? 0;
|
||||
const level = getLevelInfo(xp);
|
||||
const isSubscribed = subscription.status === 'active' && subscription.tier !== 'free';
|
||||
const highRewardQuota = await getHighRewardQuota(userId, user?.tier ?? null);
|
||||
|
||||
return {
|
||||
hearts: hearts.remaining,
|
||||
@ -170,6 +172,8 @@ export async function getProgressSummary(userId: string): Promise<ProgressSummar
|
||||
dailyAttemptsLeft: attempts.left,
|
||||
dailyAttemptsMax: attempts.max,
|
||||
nextAttemptResetAt: attempts.nextResetAt,
|
||||
highRewardSessionsLeft: highRewardQuota.remaining,
|
||||
highRewardSessionsMax: highRewardQuota.max,
|
||||
xp,
|
||||
level: level.level,
|
||||
xpToNextLevel: level.xpToNextLevel,
|
||||
|
||||
@ -27,6 +27,8 @@ export interface ProgressSummaryDto {
|
||||
dailyAttemptsLeft: number;
|
||||
dailyAttemptsMax: number;
|
||||
nextAttemptResetAt: string | null;
|
||||
highRewardSessionsLeft: number;
|
||||
highRewardSessionsMax: number;
|
||||
xp: number;
|
||||
level: number;
|
||||
xpToNextLevel: number;
|
||||
@ -86,6 +88,7 @@ export interface ChallengeSessionDto {
|
||||
trackId: string;
|
||||
nodeId: string;
|
||||
totalQuestions: number;
|
||||
highRewardEligible: boolean;
|
||||
questions: readonly ChallengeQuestionDto[];
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user