实现每日首次进入红心补给
This commit is contained in:
parent
d71c45b2f1
commit
c08d3f75b9
@ -58,13 +58,14 @@
|
|||||||
| 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 | 实现连续学习里程碑奖励 | [x] | 3/7/14/30/100 天奖励可发放且不可重复领取 |
|
| 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 测试 | [ ] | 覆盖等级边界、首次知识卡、完成组奖励、全对奖励、看解析奖励、连续学习保护 |
|
| 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 签名问题阻塞。
|
验证记录(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:金币、商店和道具
|
## Phase G3:金币、商店和道具
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('progress-summary-service', () => {
|
||||||
it('calculates level and remaining XP using the non-linear 50-level curve', () => {
|
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', 4, 5)).toBe('2026-05-04T00:30:00.000Z');
|
||||||
expect(getNextHeartRestoreAt('2026-05-04T00:00:00.000Z', 5, 5)).toBeNull();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { getHearts } from '../progress/hearts-service.js';
|
|||||||
import { calculateStreak, freezeStreak } from '../progress/streak-service.js';
|
import { calculateStreak, freezeStreak } from '../progress/streak-service.js';
|
||||||
import { getSubscriptionStatus } from '../payment/subscription-service.js';
|
import { getSubscriptionStatus } from '../payment/subscription-service.js';
|
||||||
import { getHighRewardQuota } from './challenge-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';
|
import type { ProgressSummaryDto, RewardSource } from '../../types/app-api.js';
|
||||||
|
|
||||||
export const FREE_DAILY_ATTEMPTS = 5;
|
export const FREE_DAILY_ATTEMPTS = 5;
|
||||||
@ -34,6 +34,7 @@ interface ResourceUser {
|
|||||||
checkInDays: number | null;
|
checkInDays: number | null;
|
||||||
lastCheckInDate: Date | string | null;
|
lastCheckInDate: Date | string | null;
|
||||||
streakProtectedUntil: Date | string | null;
|
streakProtectedUntil: Date | string | null;
|
||||||
|
heartsRemaining?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function today(): string {
|
function today(): string {
|
||||||
@ -114,6 +115,7 @@ async function getResourceUser(userId: string): Promise<ResourceUser | null> {
|
|||||||
checkInDays: users.checkInDays,
|
checkInDays: users.checkInDays,
|
||||||
lastCheckInDate: users.lastCheckInDate,
|
lastCheckInDate: users.lastCheckInDate,
|
||||||
streakProtectedUntil: users.streakProtectedUntil,
|
streakProtectedUntil: users.streakProtectedUntil,
|
||||||
|
heartsRemaining: users.heartsRemaining,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, userId))
|
.where(eq(users.id, userId))
|
||||||
@ -122,6 +124,28 @@ async function getResourceUser(userId: string): Promise<ResourceUser | null> {
|
|||||||
return user ?? 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 }> {
|
export async function getDailyAttempts(userId: string): Promise<{ left: number; max: number; nextResetAt: string }> {
|
||||||
const user = await getResourceUser(userId);
|
const user = await getResourceUser(userId);
|
||||||
const max = getDailyAttemptsMax(user?.tier);
|
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> {
|
export async function checkIn(userId: string): Promise<ProgressSummaryDto> {
|
||||||
const user = await getResourceUser(userId);
|
const user = await getResourceUser(userId);
|
||||||
const alreadyCheckedIn = toDateString(user?.lastCheckInDate ?? null) === today();
|
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) {
|
if (!alreadyCheckedIn) {
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
@ -190,6 +223,8 @@ export async function protectStreak(userId: string, _source: RewardSource): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getProgressSummary(userId: string): Promise<ProgressSummaryDto> {
|
export async function getProgressSummary(userId: string): Promise<ProgressSummaryDto> {
|
||||||
|
await grantDailyFirstVisitHeart(userId);
|
||||||
|
|
||||||
const [user, hearts, streak, subscription, attempts] = await Promise.all([
|
const [user, hearts, streak, subscription, attempts] = await Promise.all([
|
||||||
getResourceUser(userId),
|
getResourceUser(userId),
|
||||||
getHearts(userId),
|
getHearts(userId),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user