实现连续学习里程碑奖励

This commit is contained in:
Wang Zhuoxuan 2026-05-13 10:51:01 +08:00
parent 447cef3dea
commit d71c45b2f1
7 changed files with 164 additions and 9 deletions

View File

@ -57,13 +57,14 @@
| G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
| G2-3 | 修正连对奖励 | [x] | 3 连对 +55 连对 +1010 连对 +25并返回客户端可展示奖励 |
| G2-4 | 将连续学习改为按挑战组完成计算 | [x] | 每天至少完成 1 组挑战才更新 streak不再依赖当天正确题数阈值 |
| G2-5 | 实现连续学习里程碑奖励 | [ ] | 3/7/14/30/100 天奖励可发放且不可重复领取 |
| G2-5 | 实现连续学习里程碑奖励 | [x] | 3/7/14/30/100 天奖励可发放且不可重复领取 |
| G2-6 | 实现每日首次进入送红心 | [ ] | 每日首次 bootstrap 或专用 check-in 最多补 1 颗,不超过上限 |
| 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-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 签名问题阻塞。
验证记录2026-05-13G2-5 已通过 `./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金币、商店和道具

View File

@ -433,6 +433,7 @@ describe('challenge-service', () => {
[], // no existing chapter progress
[], // no existing daily progress
[{ streakDays: 2, streakLastDate: new Date(Date.now() - 86_400_000).toISOString() }], // updateStreakForCompletedChallenge
[], // no existing streak milestone reward
[knowledgeCardRow],
// getProgressSummary (final)
[userAfterXp],
@ -456,6 +457,7 @@ describe('challenge-service', () => {
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
expect.objectContaining({ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }),
expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 XP' }),
expect.objectContaining({ type: 'chest', source: 'streak_milestone', milestoneDays: 3 }),
]),
);
});

View File

@ -1,6 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { db } from '../../../db/client.js';
import { updateStreakForCompletedChallenge } from '../../../services/progress/streak-service.js';
import {
getStreakMilestoneReward,
grantStreakMilestoneReward,
updateStreakForCompletedChallenge,
} from '../../../services/progress/streak-service.js';
// Test the pure logic of date comparison
// The DB-dependent functions are tested via integration tests
@ -28,7 +32,26 @@ describe('Streak service — date logic', () => {
});
describe('Streak service — completed challenge updates', () => {
function selectQueue(queue: unknown[][]) {
let index = 0;
vi.mocked(db.select).mockImplementation((() => {
const rows = queue[index] ?? [];
index += 1;
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue(rows),
}),
}),
};
}) as never);
}
function selectUser(rows: unknown[]) {
selectQueue([rows]);
}
function selectRows(rows: unknown[]) {
vi.mocked(db.select).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
@ -45,17 +68,36 @@ describe('Streak service — completed challenge updates', () => {
return { set, where };
}
function mockInsert() {
const values = vi.fn().mockResolvedValue(undefined);
vi.mocked(db.insert).mockReturnValue({ values } as never);
return { values };
}
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() }]);
selectQueue([
[{ streakDays: 2, streakLastDate: yesterday.toISOString() }],
[],
]);
const update = mockUpdate();
const insert = mockInsert();
const result = await updateStreakForCompletedChallenge('user-1');
expect(result.days).toBe(3);
expect(result.lastDate).toBe(new Date().toISOString().slice(0, 10));
expect(result.rewards).toEqual([
expect.objectContaining({ type: 'chest', source: 'streak_milestone', milestoneDays: 3 }),
]);
expect(update.set).toHaveBeenCalled();
expect(insert.values).toHaveBeenCalledWith(expect.objectContaining({
sourceType: 'streak_milestone',
sourceId: '3',
idempotencyKey: 'streak_milestone:3',
status: 'completed',
}));
});
it('does not increment more than once on the same day', async () => {
@ -79,4 +121,26 @@ describe('Streak service — completed challenge updates', () => {
expect(result.days).toBe(1);
expect(update.set).toHaveBeenCalled();
});
it('returns milestone reward definitions for configured days', () => {
expect(getStreakMilestoneReward(7)).toEqual({
type: 'item',
source: 'streak_milestone',
milestoneDays: 7,
itemId: 'streak_shield',
quantity: 1,
title: '连续学习 7 天连胜护盾 x1',
});
expect(getStreakMilestoneReward(8)).toBeNull();
});
it('does not grant duplicate milestone rewards', async () => {
selectRows([{ id: 'ledger-1' }]);
mockInsert();
const rewards = await grantStreakMilestoneReward('user-1', 7);
expect(rewards).toEqual([]);
expect(db.insert).not.toHaveBeenCalled();
});
});

View File

@ -61,6 +61,13 @@ export const LEVEL_RULES = Object.freeze({
export const STREAK_RULES = Object.freeze({
countedByCompletedChallengeSessions: 1,
milestoneDays: Object.freeze([3, 7, 14, 30, 100] as const),
milestoneRewards: Object.freeze({
3: Object.freeze({ type: 'chest', title: '连续学习 3 天小宝箱' }),
7: Object.freeze({ type: 'item', itemId: 'streak_shield', quantity: 1, title: '连续学习 7 天连胜护盾 x1' }),
14: Object.freeze({ type: 'item', itemId: 'double_xp_potion', quantity: 1, title: '连续学习 14 天双倍 XP 药水 x1' }),
30: Object.freeze({ type: 'cosmetic', itemId: 'streak_badge_30', quantity: 1, title: '连续学习 30 天限定徽章' }),
100: Object.freeze({ type: 'cosmetic', itemId: 'streak_title_100', quantity: 1, title: '连续学习 100 天稀有称号' }),
} as const),
comboChestBoostAt: 10,
});

View File

@ -279,13 +279,16 @@ async function settleCompletedChallenge(
await addXp(userId, xpDelta);
}
await Promise.all([
const [, , streak] = await Promise.all([
updateChapterProgress(userId, session, correctCount, totalQuestions),
updateDailyProgress(userId, session, xpDelta),
updateStreakForCompletedChallenge(userId),
]);
const rewards = getChallengeCompletionRewards(correctCount, totalQuestions, multiplier);
const rewards = [
...getChallengeCompletionRewards(correctCount, totalQuestions, multiplier),
...(streak.rewards ?? []),
];
return { rewards, xpDelta, progressBefore };
}

View File

@ -1,11 +1,25 @@
import { db } from '../../db/client.js';
import { users } from '../../db/schema.js';
import { eq, sql } from 'drizzle-orm';
import { rewardLedger, users } from '../../db/schema.js';
import { and, eq, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import { STREAK_RULES } from '../gamification/rules.js';
type StreakMilestoneDay = typeof STREAK_RULES.milestoneDays[number];
export interface StreakMilestoneReward {
type: string;
source: 'streak_milestone';
milestoneDays: StreakMilestoneDay;
itemId?: string;
quantity?: number;
title: string;
}
export interface StreakInfo {
days: number;
lastDate: string | null;
frozen: boolean;
rewards?: readonly StreakMilestoneReward[];
}
/**
@ -83,7 +97,60 @@ export async function updateStreakForCompletedChallenge(userId: string): Promise
.set({ streakDays: newDays, streakLastDate: sql`CAST(${today} AS DATE)` })
.where(eq(users.id, userId));
return { days: newDays, lastDate: today, frozen: false };
const rewards = await grantStreakMilestoneReward(userId, newDays);
return { days: newDays, lastDate: today, frozen: false, rewards };
}
export function getStreakMilestoneReward(days: number): StreakMilestoneReward | null {
if (!isStreakMilestoneDay(days)) return null;
const reward = STREAK_RULES.milestoneRewards[days];
return {
type: reward.type,
source: 'streak_milestone',
milestoneDays: days,
itemId: 'itemId' in reward ? reward.itemId : undefined,
quantity: 'quantity' in reward ? reward.quantity : undefined,
title: reward.title,
};
}
export async function grantStreakMilestoneReward(
userId: string,
days: number,
): Promise<readonly StreakMilestoneReward[]> {
const reward = getStreakMilestoneReward(days);
if (!reward) return [];
const idempotencyKey = `streak_milestone:${days}`;
const [existing] = await db
.select({ id: rewardLedger.id })
.from(rewardLedger)
.where(and(
eq(rewardLedger.userId, userId),
eq(rewardLedger.idempotencyKey, idempotencyKey),
))
.limit(1);
if (existing) return [];
await db.insert(rewardLedger).values({
id: uuid(),
userId,
sourceType: 'streak_milestone',
sourceId: String(days),
idempotencyKey,
status: 'completed',
rewardSnapshot: {
rewards: [reward],
milestoneDays: days,
},
resourceDeltas: {
rewards: [reward],
},
settledAt: sql`NOW()`,
});
return [reward];
}
/**
@ -115,3 +182,7 @@ function yesterdayUtc(): string {
d.setDate(d.getDate() - 1);
return d.toISOString().slice(0, 10);
}
function isStreakMilestoneDay(days: number): days is StreakMilestoneDay {
return (STREAK_RULES.milestoneDays as readonly number[]).includes(days);
}

View File

@ -118,7 +118,14 @@ export interface AnswerResultDto {
summary: string;
fact: string;
};
rewards: ReadonlyArray<{ type: string; source?: string; amount?: number; title?: string }>;
rewards: ReadonlyArray<{
type: string;
source?: string;
amount?: number;
itemId?: string;
quantity?: number;
title?: string;
}>;
}
export interface LeaderboardEntryDto {