Settle completed challenge sessions
This commit is contained in:
parent
5bb6ba29a2
commit
9e0f97d162
@ -43,7 +43,7 @@
|
|||||||
|---|------|------|----------|
|
|---|------|------|----------|
|
||||||
| G1-1 | 实现创建挑战组服务 | [x] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter |
|
| G1-1 | 实现创建挑战组服务 | [x] | 一次返回 5 题,题目不泄露正确答案,绑定 track/node/chapter |
|
||||||
| G1-2 | 实现挑战组答题提交 | [x] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 |
|
| G1-2 | 实现挑战组答题提交 | [x] | 单题提交可幂等记录,重复提交不会重复扣资源或发奖励 |
|
||||||
| G1-3 | 实现挑战组完成结算 | [ ] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 |
|
| G1-3 | 实现挑战组完成结算 | [x] | 组内 5 题完成后统一计算完成奖励、全对奖励、主题节点进度和每日高奖励状态 |
|
||||||
| G1-4 | 调整红心扣除边界 | [ ] | 答错扣 1 颗;Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 |
|
| G1-4 | 调整红心扣除边界 | [ ] | 答错扣 1 颗;Plus 用户不被红心阻断;新用户前 3 天最低保留 1 颗 |
|
||||||
| G1-5 | 调整每日高奖励挑战次数 | [ ] | 免费用户每日 3 组,Plus 8 组或按产品确认无限;次数为 0 后仍可继续学习但高价值奖励降级 |
|
| G1-5 | 调整每日高奖励挑战次数 | [ ] | 免费用户每日 3 组,Plus 8 组或按产品确认无限;次数为 0 后仍可继续学习但高价值奖励降级 |
|
||||||
| G1-6 | 更新挑战 API DTO | [ ] | `/challenges/next` 或新增 session API 能表达组、题、组进度、资源状态 |
|
| G1-6 | 更新挑战 API DTO | [ ] | `/challenges/next` 或新增 session API 能表达组、题、组进度、资源状态 |
|
||||||
|
|||||||
@ -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 { getNextChallenge, submitChallengeAnswer } from '../../../services/learning/challenge-service.js';
|
import { getChallengeCompletionRewards, getNextChallenge, submitChallengeAnswer } from '../../../services/learning/challenge-service.js';
|
||||||
|
|
||||||
const category = {
|
const category = {
|
||||||
id: 'history',
|
id: 'history',
|
||||||
@ -61,6 +61,18 @@ describe('challenge-service', () => {
|
|||||||
vi.clearAllMocks();
|
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', () => {
|
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([]);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { db } from '../../db/client.js';
|
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 { 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';
|
||||||
@ -8,11 +8,12 @@ import { deductHeart } from '../progress/hearts-service.js';
|
|||||||
import { updateStreak } from '../progress/streak-service.js';
|
import { updateStreak } from '../progress/streak-service.js';
|
||||||
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
|
import { deductDailyAttempt, getProgressSummary } from './progress-summary-service.js';
|
||||||
import { getTrackCategory } from './tracks-service.js';
|
import { getTrackCategory } from './tracks-service.js';
|
||||||
import { CHALLENGE_RULES } from '../gamification/rules.js';
|
import { CHALLENGE_RULES, XP_RULES } from '../gamification/rules.js';
|
||||||
import type { AnswerResultDto, ChallengeQuestionDto, ChallengeSessionDto } from '../../types/app-api.js';
|
import type { AnswerResultDto, ChallengeQuestionDto, ChallengeSessionDto, ProgressSummaryDto } from '../../types/app-api.js';
|
||||||
|
|
||||||
type QuestionRow = typeof questions.$inferSelect;
|
type QuestionRow = typeof questions.$inferSelect;
|
||||||
type ChapterRow = typeof skillTree.$inferSelect;
|
type ChapterRow = typeof skillTree.$inferSelect;
|
||||||
|
type ChallengeSessionRow = typeof challengeSessions.$inferSelect;
|
||||||
|
|
||||||
interface OptionDto {
|
interface OptionDto {
|
||||||
id: string;
|
id: string;
|
||||||
@ -35,6 +36,14 @@ function hash(value: string): number {
|
|||||||
return result;
|
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[] {
|
function buildOptions(question: QuestionRow): readonly OptionDto[] {
|
||||||
const distractors = Array.isArray(question.distractors) ? question.distractors.filter((item): item is string => typeof item === 'string') : [];
|
const distractors = Array.isArray(question.distractors) ? question.distractors.filter((item): item is string => typeof item === 'string') : [];
|
||||||
const rawOptions = [
|
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> {
|
export async function getNextChallenge(userId: string, trackId: string): Promise<ChallengeSessionDto | null> {
|
||||||
const category = await getTrackCategory(trackId);
|
const category = await getTrackCategory(trackId);
|
||||||
if (!category || category.status !== 'active') {
|
if (!category || category.status !== 'active') {
|
||||||
@ -251,15 +373,32 @@ export async function submitChallengeAnswer(
|
|||||||
.where(eq(questions.id, questionId));
|
.where(eq(questions.id, questionId));
|
||||||
|
|
||||||
let xpDelta = 0;
|
let xpDelta = 0;
|
||||||
|
const rewards: Array<{ type: string; amount?: number; title?: string }> = [];
|
||||||
if (correct) {
|
if (correct) {
|
||||||
xpDelta = calculateXp(BASE_XP, comboCount);
|
xpDelta = calculateXp(BASE_XP, comboCount);
|
||||||
await addXp(userId, xpDelta);
|
await addXp(userId, xpDelta);
|
||||||
await updateStreak(userId, await getCorrectAnswersToday(userId));
|
await updateStreak(userId, await getCorrectAnswersToday(userId));
|
||||||
|
if (xpDelta > 0) {
|
||||||
|
rewards.push({ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await deductHeart(userId);
|
await deductHeart(userId);
|
||||||
await deductDailyAttempt(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([
|
const [progress, knowledgeCard] = await Promise.all([
|
||||||
getProgressSummary(userId),
|
getProgressSummary(userId),
|
||||||
getKnowledgeCard(question),
|
getKnowledgeCard(question),
|
||||||
@ -268,7 +407,7 @@ export async function submitChallengeAnswer(
|
|||||||
const result: AnswerResultDto = {
|
const result: AnswerResultDto = {
|
||||||
answerState: correct ? 'correct' : 'wrong',
|
answerState: correct ? 'correct' : 'wrong',
|
||||||
correctOptionId,
|
correctOptionId,
|
||||||
xpDelta,
|
xpDelta: xpDelta + completionXpDelta,
|
||||||
progress: {
|
progress: {
|
||||||
hearts: progress.hearts,
|
hearts: progress.hearts,
|
||||||
dailyAttemptsLeft: progress.dailyAttemptsLeft,
|
dailyAttemptsLeft: progress.dailyAttemptsLeft,
|
||||||
@ -276,7 +415,7 @@ export async function submitChallengeAnswer(
|
|||||||
streakDays: progress.streakDays,
|
streakDays: progress.streakDays,
|
||||||
},
|
},
|
||||||
knowledgeCard,
|
knowledgeCard,
|
||||||
rewards: correct && xpDelta > 0 ? [{ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }] : [],
|
rewards,
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(challengeSessionAnswers).values({
|
await db.insert(challengeSessionAnswers).values({
|
||||||
@ -296,9 +435,18 @@ export async function submitChallengeAnswer(
|
|||||||
await db
|
await db
|
||||||
.update(challengeSessions)
|
.update(challengeSessions)
|
||||||
.set({
|
.set({
|
||||||
status: 'in_progress',
|
status: completed ? 'completed' : 'in_progress',
|
||||||
answeredCount: sql`LEAST(COALESCE(answered_count, 0) + 1, COALESCE(total_questions, ${CHALLENGE_RULES.questionsPerSession}))`,
|
answeredCount: answeredAfter,
|
||||||
correctCount: correct ? sql`COALESCE(correct_count, 0) + 1` : sql`COALESCE(correct_count, 0)`,
|
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));
|
.where(eq(challengeSessions.id, challengeId));
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user