实现每日首次进入红心补给

This commit is contained in:
Wang Zhuoxuan 2026-05-13 10:53:27 +08:00
parent d71c45b2f1
commit c08d3f75b9
3 changed files with 98 additions and 3 deletions

View File

@ -58,13 +58,14 @@
| G2-3 | 修正连对奖励 | [x] | 3 连对 +55 连对 +1010 连对 +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-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 签名问题阻塞。
验证记录2026-05-13G2-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金币、商店和道具

View File

@ -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);
});
});

View File

@ -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<ResourceUser | null> {
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<ResourceUser | null> {
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<boolean> {
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<ProgressSummaryDto> {
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<ProgressSummaryDto> {
await grantDailyFirstVisitHeart(userId);
const [user, hearts, streak, subscription, attempts] = await Promise.all([
getResourceUser(userId),
getHearts(userId),