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.
This commit is contained in:
parent
9e0f97d162
commit
6ea5ed9de0
@ -1,6 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { db } from '../../../db/client.js';
|
||||
|
||||
describe('Hearts service — constants', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
@ -11,3 +31,111 @@ describe('Hearts service — constants', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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<HeartsInfo> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user