Settle completed challenge sessions

This commit is contained in:
Wang Zhuoxuan 2026-05-11 21:40:41 +08:00
parent 5bb6ba29a2
commit 9e0f97d162
3 changed files with 170 additions and 10 deletions

View File

@ -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 能表达组、题、组进度、资源状态 |

View File

@ -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([]);

View File

@ -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<string, unknown> {
return value as Record<string, unknown>;
}
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<AnswerResultDto[
};
}
async function updateChapterProgress(userId: string, session: ChallengeSessionRow, correctCount: number, totalQuestions: number): Promise<void> {
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<void> {
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<ChallengeSessionDto | null> {
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));