From 6ea5ed9de06959457b6381e2a1c1891506c35679 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Mon, 11 May 2026 23:44:45 +0800 Subject: [PATCH] feat: add heart deduction boundaries with new user protection Add 3-day new user heart protection (minimum 1 heart) and block answering when hearts are exhausted for free-tier users. --- .../services/progress/hearts-service.test.ts | 144 +++++++++++++++++- src/services/learning/challenge-service.ts | 5 +- src/services/progress/hearts-service.ts | 33 +++- 3 files changed, 168 insertions(+), 14 deletions(-) diff --git a/src/__tests__/services/progress/hearts-service.test.ts b/src/__tests__/services/progress/hearts-service.test.ts index e0d415f..d7695a4 100644 --- a/src/__tests__/services/progress/hearts-service.test.ts +++ b/src/__tests__/services/progress/hearts-service.test.ts @@ -1,13 +1,141 @@ -import { describe, it, expect } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { db } from '../../../db/client.js'; -describe('Hearts service — constants', () => { - it('MAX_FREE_HEARTS is 5', async () => { - const { MAX_FREE_HEARTS } = await import('../../../services/progress/hearts-service.js'); - expect(MAX_FREE_HEARTS).toBe(5); +function selectReturning(rows: unknown[]) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue(rows), + }), + }), + }; +} + +function updateReturning() { + return { set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) }; +} + +describe('hearts-service', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - it('PRO_HEARTS is 99', async () => { - const { PRO_HEARTS } = await import('../../../services/progress/hearts-service.js'); - expect(PRO_HEARTS).toBe(99); + describe('constants', () => { + it('MAX_FREE_HEARTS is 5', async () => { + const { MAX_FREE_HEARTS } = await import('../../../services/progress/hearts-service.js'); + expect(MAX_FREE_HEARTS).toBe(5); + }); + + it('PRO_HEARTS is 99', async () => { + const { PRO_HEARTS } = await import('../../../services/progress/hearts-service.js'); + expect(PRO_HEARTS).toBe(99); + }); + }); + + describe('deductHeart', () => { + it('deducts 1 heart for free-tier users with hearts > 1', async () => { + vi.mocked(db.select) + // deductHeart: tier + heartsRemaining + .mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 3 }]) as never) + // isNewUserProtected: createdAt (4 days ago → not protected) + .mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 4 * 86_400_000).toISOString() }]) as never); + vi.mocked(db.update).mockReturnValue(updateReturning() as never); + + const { deductHeart } = await import('../../../services/progress/hearts-service.js'); + const result = await deductHeart('user-1'); + + expect(result.success).toBe(true); + expect(result.remaining).toBe(2); + }); + + it('deducts to 0 for old free-tier users', async () => { + vi.mocked(db.select) + .mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 1 }]) as never) + .mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }]) as never); + vi.mocked(db.update).mockReturnValue(updateReturning() as never); + + const { deductHeart } = await import('../../../services/progress/hearts-service.js'); + const result = await deductHeart('user-1'); + + expect(result.success).toBe(true); + expect(result.remaining).toBe(0); + }); + + it('returns failure when hearts = 0 for old free-tier users', async () => { + vi.mocked(db.select) + .mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 0 }]) as never) + .mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 10 * 86_400_000).toISOString() }]) as never); + + const { deductHeart } = await import('../../../services/progress/hearts-service.js'); + const result = await deductHeart('user-1'); + + expect(result.success).toBe(false); + expect(result.remaining).toBe(0); + }); + + it('does not deduct for Pro users', async () => { + vi.mocked(db.select).mockReturnValueOnce( + selectReturning([{ tier: 'pro', heartsRemaining: 99 }]) as never, + ); + + const { deductHeart } = await import('../../../services/progress/hearts-service.js'); + const result = await deductHeart('user-1'); + + expect(result.success).toBe(true); + expect(result.remaining).toBe(99); + expect(db.update).not.toHaveBeenCalled(); + }); + + it('does not deduct for ProPlus users', async () => { + vi.mocked(db.select).mockReturnValueOnce( + selectReturning([{ tier: 'proplus', heartsRemaining: 99 }]) as never, + ); + + const { deductHeart } = await import('../../../services/progress/hearts-service.js'); + const result = await deductHeart('user-1'); + + expect(result.success).toBe(true); + expect(result.remaining).toBe(99); + expect(db.update).not.toHaveBeenCalled(); + }); + + it('protects new users (≤3 days) with minimum 1 heart', async () => { + vi.mocked(db.select) + // deductHeart: user has 1 heart + .mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 1 }]) as never) + // isNewUserProtected: created 1 day ago + .mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 1 * 86_400_000).toISOString() }]) as never); + + const { deductHeart } = await import('../../../services/progress/hearts-service.js'); + const result = await deductHeart('user-1'); + + expect(result.success).toBe(false); + expect(result.remaining).toBe(1); + }); + + it('allows deduction from 2→1 for new users (≤3 days)', async () => { + vi.mocked(db.select) + .mockReturnValueOnce(selectReturning([{ tier: 'free', heartsRemaining: 2 }]) as never) + .mockReturnValueOnce(selectReturning([{ createdAt: new Date(Date.now() - 2 * 86_400_000).toISOString() }]) as never); + vi.mocked(db.update).mockReturnValue(updateReturning() as never); + + const { deductHeart } = await import('../../../services/progress/hearts-service.js'); + const result = await deductHeart('user-1'); + + expect(result.success).toBe(true); + expect(result.remaining).toBe(1); + }); + + it('returns failure for non-existent user', async () => { + vi.mocked(db.select).mockReturnValueOnce( + selectReturning([]) as never, + ); + + const { deductHeart } = await import('../../../services/progress/hearts-service.js'); + const result = await deductHeart('nonexistent'); + + expect(result.success).toBe(false); + expect(result.remaining).toBe(0); + }); }); }); diff --git a/src/services/learning/challenge-service.ts b/src/services/learning/challenge-service.ts index 2cb7ba9..11747f7 100644 --- a/src/services/learning/challenge-service.ts +++ b/src/services/learning/challenge-service.ts @@ -382,7 +382,10 @@ export async function submitChallengeAnswer( rewards.push({ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` }); } } else { - await deductHeart(userId); + const heartResult = await deductHeart(userId); + if (!heartResult.success && heartResult.remaining === 0) { + throw new ValidationError('红心已用完,请等待恢复或观看广告'); + } await deductDailyAttempt(userId); } diff --git a/src/services/progress/hearts-service.ts b/src/services/progress/hearts-service.ts index fc6dea8..c940b0e 100644 --- a/src/services/progress/hearts-service.ts +++ b/src/services/progress/hearts-service.ts @@ -1,7 +1,7 @@ import { db } from '../../db/client.js'; import { users } from '../../db/schema.js'; import { eq, sql } from 'drizzle-orm'; -import { HEART_RULES } from '../gamification/rules.js'; +import { HEART_RULES, MS_PER_DAY } from '../gamification/rules.js'; const MAX_FREE_HEARTS = HEART_RULES.freeMax; const PRO_HEARTS = HEART_RULES.subscribedMax; @@ -76,9 +76,26 @@ export async function getHearts(userId: string): Promise { }; } +/** + * Check if a free-tier user is within the new-user protection window. + * New users (account age ≤ newUserProtectionDays) have a minimum hearts floor. + */ +async function isNewUserProtected(userId: string): Promise { + const [user] = await db + .select({ createdAt: users.createdAt }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user?.createdAt) return false; + const accountAgeMs = Date.now() - new Date(user.createdAt).getTime(); + return accountAgeMs <= HEART_RULES.newUserProtectionDays * MS_PER_DAY; +} + /** * Deduct a heart from the user. Returns success status and remaining count. - * Pro users are not deducted. + * Pro/ProPlus users are not deducted. + * New users (≤3 days) have a minimum floor of 1 heart. */ export async function deductHeart(userId: string): Promise<{ success: boolean; remaining: number }> { const [user] = await db @@ -91,14 +108,20 @@ export async function deductHeart(userId: string): Promise<{ success: boolean; r return { success: false, remaining: 0 }; } - // Pro users: no deduction + // Pro/ProPlus users: no deduction if (user.tier === 'pro' || user.tier === 'proplus') { return { success: true, remaining: PRO_HEARTS }; } const current = user.heartsRemaining ?? MAX_FREE_HEARTS; - if (current <= 0) { - return { success: false, remaining: 0 }; + + // New-user protection: floor = 1 heart for accounts ≤3 days old + const protectedFloor = await isNewUserProtected(userId) + ? HEART_RULES.newUserMinimumHearts + : 0; + + if (current <= protectedFloor) { + return { success: false, remaining: current }; } const newCount = current - 1;