实现连续学习里程碑奖励
This commit is contained in:
parent
447cef3dea
commit
d71c45b2f1
@ -57,13 +57,14 @@
|
|||||||
| G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
|
| G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 |
|
||||||
| G2-3 | 修正连对奖励 | [x] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 |
|
| G2-3 | 修正连对奖励 | [x] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 |
|
||||||
| G2-4 | 将连续学习改为按挑战组完成计算 | [x] | 每天至少完成 1 组挑战才更新 streak,不再依赖当天正确题数阈值 |
|
| 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-6 | 实现每日首次进入送红心 | [ ] | 每日首次 bootstrap 或专用 check-in 最多补 1 颗,不超过上限 |
|
||||||
| G2-7 | 添加 XP/streak 测试 | [ ] | 覆盖等级边界、首次知识卡、完成组奖励、全对奖励、看解析奖励、连续学习保护 |
|
| G2-7 | 添加 XP/streak 测试 | [ ] | 覆盖等级边界、首次知识卡、完成组奖励、全对奖励、看解析奖励、连续学习保护 |
|
||||||
|
|
||||||
验证记录(2026-05-13):G2-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-13):G2-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-13):G2-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-13):G2-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-13):G2-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-13):G2-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-13):G2-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:金币、商店和道具
|
## Phase G3:金币、商店和道具
|
||||||
|
|
||||||
|
|||||||
@ -433,6 +433,7 @@ describe('challenge-service', () => {
|
|||||||
[], // 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
|
[{ streakDays: 2, streakLastDate: new Date(Date.now() - 86_400_000).toISOString() }], // updateStreakForCompletedChallenge
|
||||||
|
[], // no existing streak milestone reward
|
||||||
[knowledgeCardRow],
|
[knowledgeCardRow],
|
||||||
// getProgressSummary (final)
|
// getProgressSummary (final)
|
||||||
[userAfterXp],
|
[userAfterXp],
|
||||||
@ -456,6 +457,7 @@ describe('challenge-service', () => {
|
|||||||
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
|
expect.objectContaining({ type: 'xp', source: 'first_knowledge_card', amount: 15 }),
|
||||||
expect.objectContaining({ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }),
|
expect.objectContaining({ type: 'xp', amount: 20, title: '完成挑战 +20 XP' }),
|
||||||
expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 XP' }),
|
expect.objectContaining({ type: 'xp', amount: 30, title: '全对奖励 +30 XP' }),
|
||||||
|
expect.objectContaining({ type: 'chest', source: 'streak_milestone', milestoneDays: 3 }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
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 { updateStreakForCompletedChallenge } from '../../../services/progress/streak-service.js';
|
import {
|
||||||
|
getStreakMilestoneReward,
|
||||||
|
grantStreakMilestoneReward,
|
||||||
|
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
|
||||||
@ -28,7 +32,26 @@ describe('Streak service — date logic', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Streak service — completed challenge updates', () => {
|
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[]) {
|
function selectUser(rows: unknown[]) {
|
||||||
|
selectQueue([rows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRows(rows: unknown[]) {
|
||||||
vi.mocked(db.select).mockReturnValue({
|
vi.mocked(db.select).mockReturnValue({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
@ -45,17 +68,36 @@ describe('Streak service — completed challenge updates', () => {
|
|||||||
return { set, where };
|
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 () => {
|
it('increments streak after completing the first challenge session of a consecutive day', async () => {
|
||||||
const yesterday = new Date();
|
const yesterday = new Date();
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
selectUser([{ streakDays: 2, streakLastDate: yesterday.toISOString() }]);
|
selectQueue([
|
||||||
|
[{ streakDays: 2, streakLastDate: yesterday.toISOString() }],
|
||||||
|
[],
|
||||||
|
]);
|
||||||
const update = mockUpdate();
|
const update = mockUpdate();
|
||||||
|
const insert = mockInsert();
|
||||||
|
|
||||||
const result = await updateStreakForCompletedChallenge('user-1');
|
const result = await updateStreakForCompletedChallenge('user-1');
|
||||||
|
|
||||||
expect(result.days).toBe(3);
|
expect(result.days).toBe(3);
|
||||||
expect(result.lastDate).toBe(new Date().toISOString().slice(0, 10));
|
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(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 () => {
|
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(result.days).toBe(1);
|
||||||
expect(update.set).toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -61,6 +61,13 @@ export const LEVEL_RULES = Object.freeze({
|
|||||||
export const STREAK_RULES = Object.freeze({
|
export const STREAK_RULES = Object.freeze({
|
||||||
countedByCompletedChallengeSessions: 1,
|
countedByCompletedChallengeSessions: 1,
|
||||||
milestoneDays: Object.freeze([3, 7, 14, 30, 100] as const),
|
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,
|
comboChestBoostAt: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -279,13 +279,16 @@ async function settleCompletedChallenge(
|
|||||||
await addXp(userId, xpDelta);
|
await addXp(userId, xpDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all([
|
const [, , streak] = await Promise.all([
|
||||||
updateChapterProgress(userId, session, correctCount, totalQuestions),
|
updateChapterProgress(userId, session, correctCount, totalQuestions),
|
||||||
updateDailyProgress(userId, session, xpDelta),
|
updateDailyProgress(userId, session, xpDelta),
|
||||||
updateStreakForCompletedChallenge(userId),
|
updateStreakForCompletedChallenge(userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const rewards = getChallengeCompletionRewards(correctCount, totalQuestions, multiplier);
|
const rewards = [
|
||||||
|
...getChallengeCompletionRewards(correctCount, totalQuestions, multiplier),
|
||||||
|
...(streak.rewards ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
return { rewards, xpDelta, progressBefore };
|
return { rewards, xpDelta, progressBefore };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,25 @@
|
|||||||
import { db } from '../../db/client.js';
|
import { db } from '../../db/client.js';
|
||||||
import { users } from '../../db/schema.js';
|
import { rewardLedger, users } from '../../db/schema.js';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
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 {
|
export interface StreakInfo {
|
||||||
days: number;
|
days: number;
|
||||||
lastDate: string | null;
|
lastDate: string | null;
|
||||||
frozen: boolean;
|
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)` })
|
.set({ streakDays: newDays, streakLastDate: sql`CAST(${today} AS DATE)` })
|
||||||
.where(eq(users.id, userId));
|
.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);
|
d.setDate(d.getDate() - 1);
|
||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStreakMilestoneDay(days: number): days is StreakMilestoneDay {
|
||||||
|
return (STREAK_RULES.milestoneDays as readonly number[]).includes(days);
|
||||||
|
}
|
||||||
|
|||||||
@ -118,7 +118,14 @@ export interface AnswerResultDto {
|
|||||||
summary: string;
|
summary: string;
|
||||||
fact: 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 {
|
export interface LeaderboardEntryDto {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user