From d71c45b2f132e689fcdf6c5f470d20c07577a0f6 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Wed, 13 May 2026 10:51:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E8=BF=9E=E7=BB=AD=E5=AD=A6?= =?UTF-8?q?=E4=B9=A0=E9=87=8C=E7=A8=8B=E7=A2=91=E5=A5=96=E5=8A=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/gamification-server-plan.md | 3 +- .../learning/challenge-service.test.ts | 2 + .../services/progress/streak-service.test.ts | 68 +++++++++++++++- src/services/gamification/rules.ts | 7 ++ src/services/learning/challenge-service.ts | 7 +- src/services/progress/streak-service.ts | 77 ++++++++++++++++++- src/types/app-api.ts | 9 ++- 7 files changed, 164 insertions(+), 9 deletions(-) diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index e3103be..6434da9 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -57,13 +57,14 @@ | G2-2 | 扩展 XP 奖励来源 | [x] | 支持普通题 10、困难题 15、看解析 3、完成挑战 20、全对 30、首次知识卡 15、每日任务、主题节点奖励 | | G2-3 | 修正连对奖励 | [x] | 3 连对 +5,5 连对 +10,10 连对 +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-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-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:金币、商店和道具 diff --git a/src/__tests__/services/learning/challenge-service.test.ts b/src/__tests__/services/learning/challenge-service.test.ts index 0401d5e..0738518 100644 --- a/src/__tests__/services/learning/challenge-service.test.ts +++ b/src/__tests__/services/learning/challenge-service.test.ts @@ -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 }), ]), ); }); diff --git a/src/__tests__/services/progress/streak-service.test.ts b/src/__tests__/services/progress/streak-service.test.ts index d5d6fcd..b42b5fe 100644 --- a/src/__tests__/services/progress/streak-service.test.ts +++ b/src/__tests__/services/progress/streak-service.test.ts @@ -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(); + }); }); diff --git a/src/services/gamification/rules.ts b/src/services/gamification/rules.ts index 85729b1..4bc7872 100644 --- a/src/services/gamification/rules.ts +++ b/src/services/gamification/rules.ts @@ -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, }); diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts index 61cfc11..7293629 100644 --- a/src/services/learning/challenge-service.ts +++ b/src/services/learning/challenge-service.ts @@ -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 }; } diff --git a/src/services/progress/streak-service.ts b/src/services/progress/streak-service.ts index 9e55bed..8990b58 100644 --- a/src/services/progress/streak-service.ts +++ b/src/services/progress/streak-service.ts @@ -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 { + 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); +} diff --git a/src/types/app-api.ts b/src/types/app-api.ts index 293e45a..b6ea872 100644 --- a/src/types/app-api.ts +++ b/src/types/app-api.ts @@ -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 {