按挑战组完成更新连续学习

This commit is contained in:
Wang Zhuoxuan 2026-05-13 10:47:46 +08:00
parent b5b3aaf3a7
commit 447cef3dea
5 changed files with 68 additions and 39 deletions

View File

@ -56,13 +56,14 @@
| G2-1 | 实现 50 级等级曲线 | [x] | Lv.1-50 按设计表计算,`xpToNextLevel` 准确,超过 50 级有明确封顶或溢出策略 | | G2-1 | 实现 50 级等级曲线 | [x] | Lv.1-50 按设计表计算,`xpToNextLevel` 准确,超过 50 级有明确封顶或溢出策略 |
| G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 | | G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
| G2-3 | 修正连对奖励 | [x] | 3 连对 +55 连对 +1010 连对 +25并返回客户端可展示奖励 | | G2-3 | 修正连对奖励 | [x] | 3 连对 +55 连对 +1010 连对 +25并返回客户端可展示奖励 |
| G2-4 | 将连续学习改为按挑战组完成计算 | [ ] | 每天至少完成 1 组挑战才更新 streak不再依赖当天正确题数阈值 | | G2-4 | 将连续学习改为按挑战组完成计算 | [x] | 每天至少完成 1 组挑战才更新 streak不再依赖当天正确题数阈值 |
| G2-5 | 实现连续学习里程碑奖励 | [ ] | 3/7/14/30/100 天奖励可发放且不可重复领取 | | G2-5 | 实现连续学习里程碑奖励 | [ ] | 3/7/14/30/100 天奖励可发放且不可重复领取 |
| G2-6 | 实现每日首次进入送红心 | [ ] | 每日首次 bootstrap 或专用 check-in 最多补 1 颗,不超过上限 | | G2-6 | 实现每日首次进入送红心 | [ ] | 每日首次 bootstrap 或专用 check-in 最多补 1 颗,不超过上限 |
| G2-7 | 添加 XP/streak 测试 | [ ] | 覆盖等级边界、首次知识卡、完成组奖励、全对奖励、看解析奖励、连续学习保护 | | G2-7 | 添加 XP/streak 测试 | [ ] | 覆盖等级边界、首次知识卡、完成组奖励、全对奖励、看解析奖励、连续学习保护 |
验证记录2026-05-13G2-2 已通过 `./node_modules/.bin/tsc --noEmit``./node_modules/.bin/eslint .``bun` 当前 shell 不在 PATH`./node_modules/.bin/vitest run` 启动阶段被 macOS 拒绝加载未签名的 `@rolldown/binding-darwin-x64` 原生 binding需修复本地依赖安装或签名后复跑。 验证记录2026-05-13G2-2 已通过 `./node_modules/.bin/tsc --noEmit``./node_modules/.bin/eslint .``bun` 当前 shell 不在 PATH`./node_modules/.bin/vitest run` 启动阶段被 macOS 拒绝加载未签名的 `@rolldown/binding-darwin-x64` 原生 binding需修复本地依赖安装或签名后复跑。
验证记录2026-05-13G2-3 已通过 `./node_modules/.bin/tsc --noEmit``./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/progress/xp-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。 验证记录2026-05-13G2-3 已通过 `./node_modules/.bin/tsc --noEmit``./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/progress/xp-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。
验证记录2026-05-13G2-4 已通过 `./node_modules/.bin/tsc --noEmit``./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/progress/streak-service.test.ts src/__tests__/services/learning/challenge-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。
## Phase G3金币、商店和道具 ## Phase G3金币、商店和道具

View File

@ -320,8 +320,6 @@ describe('challenge-service', () => {
[], // no existing answer [], // no existing answer
[testQuestion], // question [testQuestion], // question
[], // no previous correct answer for first knowledge card [], // no previous correct answer for first knowledge card
[{ id: 'up-1' }], // getCorrectAnswersToday
[freeUserRow], // updateStreak
[knowledgeCardRow], // getKnowledgeCard [knowledgeCardRow], // getKnowledgeCard
[freeUserRow], // getResourceUser (getProgressSummary) [freeUserRow], // getResourceUser (getProgressSummary)
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
@ -423,8 +421,6 @@ describe('challenge-service', () => {
[], // no existing answer [], // no existing answer
[testQuestion], // question (but we submit q-5) [testQuestion], // question (but we submit q-5)
[], // no previous correct answer for first knowledge card [], // no previous correct answer for first knowledge card
[{ id: 'up-1' }], // getCorrectAnswersToday
[freeUserRow], // updateStreak
// settleCompletedChallenge → getProgressSummary (before) // settleCompletedChallenge → getProgressSummary (before)
[freeUserRow], // getResourceUser [freeUserRow], // getResourceUser
[{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts [{ tier: 'free', heartsRemaining: 5, heartsLastRestore: null }], // getHearts
@ -436,6 +432,7 @@ describe('challenge-service', () => {
[{ id: 'chapter-1', passThreshold: 3 }], [{ id: 'chapter-1', passThreshold: 3 }],
[], // no existing chapter progress [], // no existing chapter progress
[], // no existing daily progress [], // no existing daily progress
[{ streakDays: 2, streakLastDate: new Date(Date.now() - 86_400_000).toISOString() }], // updateStreakForCompletedChallenge
[knowledgeCardRow], [knowledgeCardRow],
// getProgressSummary (final) // getProgressSummary (final)
[userAfterXp], [userAfterXp],
@ -485,6 +482,7 @@ describe('challenge-service', () => {
[{ id: 'chapter-1', passThreshold: 3 }], [{ id: 'chapter-1', passThreshold: 3 }],
[], [],
[], // updateDailyProgress [], // updateDailyProgress
[{ streakDays: 0, streakLastDate: null }], // updateStreakForCompletedChallenge
[knowledgeCardRow], [knowledgeCardRow],
// getProgressSummary (final) // getProgressSummary (final)
[userFinal], [userFinal],

View File

@ -1,9 +1,15 @@
import { describe, it, expect } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js';
import { updateStreakForCompletedChallenge } from '../../../services/progress/streak-service.js';
// Test the pure logic of date comparison // Test the pure logic of date comparison
// The DB-dependent functions are tested via integration tests // The DB-dependent functions are tested via integration tests
describe('Streak service — date logic', () => { describe('Streak service — date logic', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('todayUtc returns YYYY-MM-DD format', () => { it('todayUtc returns YYYY-MM-DD format', () => {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(today).toMatch(/^\d{4}-\d{2}-\d{2}$/);
@ -20,3 +26,57 @@ describe('Streak service — date logic', () => {
expect(todayStr).not.toBe(yesterdayStr); expect(todayStr).not.toBe(yesterdayStr);
}); });
}); });
describe('Streak service — completed challenge updates', () => {
function selectUser(rows: unknown[]) {
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
}),
}),
} as never);
}
function mockUpdate() {
const where = vi.fn().mockResolvedValue(undefined);
const set = vi.fn().mockReturnValue({ where });
vi.mocked(db.update).mockReturnValue({ set } as never);
return { set, where };
}
it('increments streak after completing the first challenge session of a consecutive day', async () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
selectUser([{ streakDays: 2, streakLastDate: yesterday.toISOString() }]);
const update = mockUpdate();
const result = await updateStreakForCompletedChallenge('user-1');
expect(result.days).toBe(3);
expect(result.lastDate).toBe(new Date().toISOString().slice(0, 10));
expect(update.set).toHaveBeenCalled();
});
it('does not increment more than once on the same day', async () => {
selectUser([{ streakDays: 4, streakLastDate: new Date().toISOString() }]);
mockUpdate();
const result = await updateStreakForCompletedChallenge('user-1');
expect(result.days).toBe(4);
expect(db.update).not.toHaveBeenCalled();
});
it('starts a new streak when the previous completion was not yesterday', async () => {
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 3);
selectUser([{ streakDays: 8, streakLastDate: oldDate.toISOString() }]);
const update = mockUpdate();
const result = await updateStreakForCompletedChallenge('user-1');
expect(result.days).toBe(1);
expect(update.set).toHaveBeenCalled();
});
});

View File

@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid';
import { NotFoundError, ValidationError } from '../../utils/errors.js'; import { NotFoundError, ValidationError } from '../../utils/errors.js';
import { addXp, createCorrectAnswerXpRewards, createXpReward } from '../progress/xp-service.js'; import { addXp, createCorrectAnswerXpRewards, createXpReward } from '../progress/xp-service.js';
import { deductHeart } from '../progress/hearts-service.js'; import { deductHeart } from '../progress/hearts-service.js';
import { updateStreak } from '../progress/streak-service.js'; import { updateStreakForCompletedChallenge } 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, XP_RULES } from '../gamification/rules.js'; import { CHALLENGE_RULES, XP_RULES } from '../gamification/rules.js';
@ -111,19 +111,6 @@ async function getQuestionsForChapter(userId: string, chapter: ChapterRow): Prom
return available.slice(0, CHALLENGE_RULES.questionsPerSession); return available.slice(0, CHALLENGE_RULES.questionsPerSession);
} }
async function getCorrectAnswersToday(userId: string): Promise<number> {
const today = new Date().toISOString().slice(0, 10);
const rows = await db
.select({ id: userProgress.id })
.from(userProgress)
.where(and(
eq(userProgress.userId, userId),
eq(userProgress.correct, 1),
sql`DATE(${userProgress.answeredAt}) = ${today}`,
));
return rows.length;
}
async function hasPreviousCorrectAnswer(userId: string, questionId: string): Promise<boolean> { async function hasPreviousCorrectAnswer(userId: string, questionId: string): Promise<boolean> {
const rows = await db const rows = await db
.select({ id: userProgress.id }) .select({ id: userProgress.id })
@ -295,6 +282,7 @@ async function settleCompletedChallenge(
await Promise.all([ await Promise.all([
updateChapterProgress(userId, session, correctCount, totalQuestions), updateChapterProgress(userId, session, correctCount, totalQuestions),
updateDailyProgress(userId, session, xpDelta), updateDailyProgress(userId, session, xpDelta),
updateStreakForCompletedChallenge(userId),
]); ]);
const rewards = getChallengeCompletionRewards(correctCount, totalQuestions, multiplier); const rewards = getChallengeCompletionRewards(correctCount, totalQuestions, multiplier);
@ -431,7 +419,6 @@ export async function submitChallengeAnswer(
const answerRewards = createCorrectAnswerXpRewards(question.difficulty, comboCount); const answerRewards = createCorrectAnswerXpRewards(question.difficulty, comboCount);
xpDelta = answerRewards.reduce((total, reward) => total + reward.amount, 0); xpDelta = answerRewards.reduce((total, reward) => total + reward.amount, 0);
await addXp(userId, xpDelta); await addXp(userId, xpDelta);
await updateStreak(userId, await getCorrectAnswersToday(userId));
if (xpDelta > 0) { if (xpDelta > 0) {
rewards.push(...answerRewards); rewards.push(...answerRewards);
} }

View File

@ -8,9 +8,6 @@ export interface StreakInfo {
frozen: boolean; frozen: boolean;
} }
/** Minimum correct answers per day to count toward streak */
const STREAK_THRESHOLD = 3;
/** /**
* Normalize a date value (Date or string from mysql2) to 'YYYY-MM-DD' string. * Normalize a date value (Date or string from mysql2) to 'YYYY-MM-DD' string.
*/ */
@ -54,10 +51,7 @@ export async function calculateStreak(userId: string): Promise<StreakInfo> {
return { days: 0, lastDate, frozen: false }; return { days: 0, lastDate, frozen: false };
} }
/** export async function updateStreakForCompletedChallenge(userId: string): Promise<StreakInfo> {
* Update the user's streak after answering questions.
*/
export async function updateStreak(userId: string, correctAnswersToday: number): Promise<StreakInfo> {
const today = todayUtc(); const today = todayUtc();
const [user] = await db const [user] = await db
@ -80,15 +74,6 @@ export async function updateStreak(userId: string, correctAnswersToday: number):
return { days: user.streakDays ?? 0, lastDate: today, frozen: false }; return { days: user.streakDays ?? 0, lastDate: today, frozen: false };
} }
// Check if threshold is met
if (correctAnswersToday < STREAK_THRESHOLD) {
return {
days: user.streakDays ?? 0,
lastDate,
frozen: false,
};
}
const yesterday = yesterdayUtc(); const yesterday = yesterdayUtc();
const isConsecutive = lastDate === yesterday; const isConsecutive = lastDate === yesterday;
const newDays = isConsecutive ? (user.streakDays ?? 0) + 1 : 1; const newDays = isConsecutive ? (user.streakDays ?? 0) + 1 : 1;
@ -130,5 +115,3 @@ function yesterdayUtc(): string {
d.setDate(d.getDate() - 1); d.setDate(d.getDate() - 1);
return d.toISOString().slice(0, 10); return d.toISOString().slice(0, 10);
} }
export { STREAK_THRESHOLD };