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 { 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', () => {
|
||||||
|
|||||||
@ -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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user