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,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', () => {
|
function selectReturning(rows: unknown[]) {
|
||||||
it('MAX_FREE_HEARTS is 5', async () => {
|
return {
|
||||||
const { MAX_FREE_HEARTS } = await import('../../../services/progress/hearts-service.js');
|
from: vi.fn().mockReturnValue({
|
||||||
expect(MAX_FREE_HEARTS).toBe(5);
|
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 () => {
|
describe('constants', () => {
|
||||||
const { PRO_HEARTS } = await import('../../../services/progress/hearts-service.js');
|
it('MAX_FREE_HEARTS is 5', async () => {
|
||||||
expect(PRO_HEARTS).toBe(99);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -382,7 +382,10 @@ export async function submitChallengeAnswer(
|
|||||||
rewards.push({ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` });
|
rewards.push({ type: 'xp', amount: xpDelta, title: `+${xpDelta} XP` });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await deductHeart(userId);
|
const heartResult = await deductHeart(userId);
|
||||||
|
if (!heartResult.success && heartResult.remaining === 0) {
|
||||||
|
throw new ValidationError('红心已用完,请等待恢复或观看广告');
|
||||||
|
}
|
||||||
await deductDailyAttempt(userId);
|
await deductDailyAttempt(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { db } from '../../db/client.js';
|
import { db } from '../../db/client.js';
|
||||||
import { users } from '../../db/schema.js';
|
import { users } from '../../db/schema.js';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
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 MAX_FREE_HEARTS = HEART_RULES.freeMax;
|
||||||
const PRO_HEARTS = HEART_RULES.subscribedMax;
|
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.
|
* 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 }> {
|
export async function deductHeart(userId: string): Promise<{ success: boolean; remaining: number }> {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
@ -91,14 +108,20 @@ export async function deductHeart(userId: string): Promise<{ success: boolean; r
|
|||||||
return { success: false, remaining: 0 };
|
return { success: false, remaining: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pro users: no deduction
|
// Pro/ProPlus users: no deduction
|
||||||
if (user.tier === 'pro' || user.tier === 'proplus') {
|
if (user.tier === 'pro' || user.tier === 'proplus') {
|
||||||
return { success: true, remaining: PRO_HEARTS };
|
return { success: true, remaining: PRO_HEARTS };
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = user.heartsRemaining ?? MAX_FREE_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;
|
const newCount = current - 1;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user