diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 6434da9..b2c4bfd 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -58,13 +58,14 @@ | G2-3 | 修正连对奖励 | [x] | 3 连对 +5,5 连对 +10,10 连对 +25,并返回客户端可展示奖励 | | G2-4 | 将连续学习改为按挑战组完成计算 | [x] | 每天至少完成 1 组挑战才更新 streak,不再依赖当天正确题数阈值 | | G2-5 | 实现连续学习里程碑奖励 | [x] | 3/7/14/30/100 天奖励可发放且不可重复领取 | -| G2-6 | 实现每日首次进入送红心 | [ ] | 每日首次 bootstrap 或专用 check-in 最多补 1 颗,不超过上限 | +| G2-6 | 实现每日首次进入送红心 | [x] | 每日首次 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 签名问题阻塞。 +验证记录(2026-05-13):G2-6 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint .`;定向运行 `./node_modules/.bin/vitest run src/__tests__/services/learning/progress-summary-service.test.ts` 仍在启动阶段被同一个 `@rolldown/binding-darwin-x64` 原生 binding 签名问题阻塞。 ## Phase G3:金币、商店和道具 diff --git a/src/__tests__/services/learning/progress-summary-service.test.ts b/src/__tests__/services/learning/progress-summary-service.test.ts index 68eaf25..b779c14 100644 --- a/src/__tests__/services/learning/progress-summary-service.test.ts +++ b/src/__tests__/services/learning/progress-summary-service.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { getDailyAttemptsMax, getLevelInfo, getNextHeartRestoreAt } from '../../../services/learning/progress-summary-service.js'; +import { + getDailyAttemptsMax, + getLevelInfo, + getNextHeartRestoreAt, + shouldGrantDailyFirstVisitHeart, +} from '../../../services/learning/progress-summary-service.js'; describe('progress-summary-service', () => { it('calculates level and remaining XP using the non-linear 50-level curve', () => { @@ -29,4 +34,58 @@ describe('progress-summary-service', () => { expect(getNextHeartRestoreAt('2026-05-04T00:00:00.000Z', 4, 5)).toBe('2026-05-04T00:30:00.000Z'); expect(getNextHeartRestoreAt('2026-05-04T00:00:00.000Z', 5, 5)).toBeNull(); }); + + it('grants one daily first-visit heart only for free users below max before check-in', () => { + expect(shouldGrantDailyFirstVisitHeart({ + id: 'user-1', + tier: 'free', + xpTotal: 0, + activeTrackId: null, + dailyAttemptsLeft: 5, + dailyAttemptsDate: null, + checkInDays: 0, + lastCheckInDate: null, + streakProtectedUntil: null, + heartsRemaining: 4, + })).toBe(true); + + expect(shouldGrantDailyFirstVisitHeart({ + id: 'user-1', + tier: 'free', + xpTotal: 0, + activeTrackId: null, + dailyAttemptsLeft: 5, + dailyAttemptsDate: null, + checkInDays: 0, + lastCheckInDate: null, + streakProtectedUntil: null, + heartsRemaining: 5, + })).toBe(false); + + expect(shouldGrantDailyFirstVisitHeart({ + id: 'user-1', + tier: 'pro', + xpTotal: 0, + activeTrackId: null, + dailyAttemptsLeft: 10, + dailyAttemptsDate: null, + checkInDays: 0, + lastCheckInDate: null, + streakProtectedUntil: null, + heartsRemaining: 1, + })).toBe(false); + + expect(shouldGrantDailyFirstVisitHeart({ + id: 'user-1', + tier: 'free', + xpTotal: 0, + activeTrackId: null, + dailyAttemptsLeft: 5, + dailyAttemptsDate: null, + checkInDays: 0, + lastCheckInDate: new Date().toISOString(), + streakProtectedUntil: null, + heartsRemaining: 1, + })).toBe(false); + }); }); diff --git a/src/services/learning/progress-summary-service.ts b/src/services/learning/progress-summary-service.ts index b57d9b8..7595e0e 100644 --- a/src/services/learning/progress-summary-service.ts +++ b/src/services/learning/progress-summary-service.ts @@ -5,7 +5,7 @@ import { getHearts } from '../progress/hearts-service.js'; import { calculateStreak, freezeStreak } from '../progress/streak-service.js'; import { getSubscriptionStatus } from '../payment/subscription-service.js'; import { getHighRewardQuota } from './challenge-service.js'; -import { LEVEL_RULES } from '../gamification/rules.js'; +import { HEART_RULES, LEVEL_RULES } from '../gamification/rules.js'; import type { ProgressSummaryDto, RewardSource } from '../../types/app-api.js'; export const FREE_DAILY_ATTEMPTS = 5; @@ -34,6 +34,7 @@ interface ResourceUser { checkInDays: number | null; lastCheckInDate: Date | string | null; streakProtectedUntil: Date | string | null; + heartsRemaining?: number | null; } function today(): string { @@ -114,6 +115,7 @@ async function getResourceUser(userId: string): Promise { checkInDays: users.checkInDays, lastCheckInDate: users.lastCheckInDate, streakProtectedUntil: users.streakProtectedUntil, + heartsRemaining: users.heartsRemaining, }) .from(users) .where(eq(users.id, userId)) @@ -122,6 +124,28 @@ async function getResourceUser(userId: string): Promise { return user ?? null; } +export function shouldGrantDailyFirstVisitHeart(user: ResourceUser | null): boolean { + if (!user) return false; + if (user.tier === 'pro' || user.tier === 'proplus') return false; + if (toDateString(user.lastCheckInDate) === today()) return false; + return (user.heartsRemaining ?? HEART_RULES.freeMax) < HEART_RULES.freeMax; +} + +export async function grantDailyFirstVisitHeart(userId: string): Promise { + const user = await getResourceUser(userId); + if (!shouldGrantDailyFirstVisitHeart(user)) return false; + + await db + .update(users) + .set({ + heartsRemaining: sql`LEAST(COALESCE(hearts_remaining, 0) + ${HEART_RULES.dailyFirstVisitGrant}, ${HEART_RULES.freeMax})`, + heartsLastRestore: sql`NOW()`, + }) + .where(eq(users.id, userId)); + + return true; +} + export async function getDailyAttempts(userId: string): Promise<{ left: number; max: number; nextResetAt: string }> { const user = await getResourceUser(userId); const max = getDailyAttemptsMax(user?.tier); @@ -169,6 +193,15 @@ export async function updateProgressPreferences(userId: string, activeTrackId: s export async function checkIn(userId: string): Promise { const user = await getResourceUser(userId); const alreadyCheckedIn = toDateString(user?.lastCheckInDate ?? null) === today(); + if (shouldGrantDailyFirstVisitHeart(user)) { + await db + .update(users) + .set({ + heartsRemaining: sql`LEAST(COALESCE(hearts_remaining, 0) + ${HEART_RULES.dailyFirstVisitGrant}, ${HEART_RULES.freeMax})`, + heartsLastRestore: sql`NOW()`, + }) + .where(eq(users.id, userId)); + } if (!alreadyCheckedIn) { await db .update(users) @@ -190,6 +223,8 @@ export async function protectStreak(userId: string, _source: RewardSource): Prom } export async function getProgressSummary(userId: string): Promise { + await grantDailyFirstVisitHeart(userId); + const [user, hearts, streak, subscription, attempts] = await Promise.all([ getResourceUser(userId), getHearts(userId),