import { db } from '../../db/client.js'; import { users } from '../../db/schema.js'; import { eq, sql } from 'drizzle-orm'; import { HEART_RULES, MS_PER_DAY } from '../gamification/rules.js'; const MAX_FREE_HEARTS = HEART_RULES.freeMax; const PRO_HEARTS = HEART_RULES.subscribedMax; const RESTORE_INTERVAL_MS = HEART_RULES.restoreIntervalMs; export type RestoreMethod = 'ad' | 'wait' | 'upgrade'; export interface HeartsInfo { remaining: number; max: number; lastRestore: string | null; } function toMs(value: Date | string | null): number | null { if (!value) return null; if (typeof value === 'string') return new Date(value).getTime(); return value.getTime(); } function clampHearts(value: number | null | undefined, max: number): number { const current = value ?? max; return Math.min(Math.max(current, 0), max); } /** * Get the user's current hearts, accounting for auto-restore. * Auto-restore: 1 heart per 30 minutes if below MAX_FREE_HEARTS. */ export async function getHearts(userId: string): Promise { const [user] = await db .select({ tier: users.tier, heartsRemaining: users.heartsRemaining, heartsLastRestore: users.heartsLastRestore, }) .from(users) .where(eq(users.id, userId)) .limit(1); if (!user) { return { remaining: MAX_FREE_HEARTS, max: MAX_FREE_HEARTS, lastRestore: null }; } // Pro/Pro+ users have unlimited hearts if (user.tier === 'pro' || user.tier === 'proplus') { const lastMs = toMs(user.heartsLastRestore); return { remaining: PRO_HEARTS, max: PRO_HEARTS, lastRestore: lastMs ? new Date(lastMs).toISOString() : null, }; } const rawRemaining = user.heartsRemaining ?? MAX_FREE_HEARTS; let remaining = clampHearts(rawRemaining, MAX_FREE_HEARTS); const lastMs = toMs(user.heartsLastRestore); // Calculate auto-restore if (lastMs !== null && remaining < MAX_FREE_HEARTS) { const elapsed = Date.now() - lastMs; const restored = Math.floor(elapsed / RESTORE_INTERVAL_MS); remaining = Math.min(remaining + restored, MAX_FREE_HEARTS); if (restored > 0) { const newLastRestore = new Date(lastMs + restored * RESTORE_INTERVAL_MS); await db .update(users) .set({ heartsRemaining: remaining, heartsLastRestore: newLastRestore }) .where(eq(users.id, userId)); return { remaining, max: MAX_FREE_HEARTS, lastRestore: newLastRestore.toISOString() }; } } // 历史脏数据或外部写入可能留下负数/超上限;读取时修正,避免 bootstrap 透传异常值。 if (rawRemaining !== remaining) { await db .update(users) .set({ heartsRemaining: remaining }) .where(eq(users.id, userId)); } return { remaining, max: MAX_FREE_HEARTS, lastRestore: lastMs ? new Date(lastMs).toISOString() : null, }; } /** * 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/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 .select({ tier: users.tier, heartsRemaining: users.heartsRemaining }) .from(users) .where(eq(users.id, userId)) .limit(1); if (!user) { return { success: false, remaining: 0 }; } // Pro/ProPlus users: no deduction if (user.tier === 'pro' || user.tier === 'proplus') { return { success: true, remaining: PRO_HEARTS }; } const current = clampHearts(user.heartsRemaining, MAX_FREE_HEARTS); // 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; await db .update(users) .set({ heartsRemaining: newCount, heartsLastRestore: newCount < MAX_FREE_HEARTS ? sql`NOW()` : undefined, }) .where(eq(users.id, userId)); return { success: true, remaining: newCount }; } /** * Restore hearts by a specific method. */ export async function restoreHeart(userId: string, method: RestoreMethod): Promise { if (method === 'upgrade') { await db .update(users) .set({ heartsRemaining: PRO_HEARTS, tier: 'pro' }) .where(eq(users.id, userId)); return PRO_HEARTS; } if (method === 'ad') { await db .update(users) .set({ heartsRemaining: sql`LEAST(COALESCE(hearts_remaining, 0) + 1, ${MAX_FREE_HEARTS})`, }) .where(eq(users.id, userId)); const [updated] = await db .select({ heartsRemaining: users.heartsRemaining }) .from(users) .where(eq(users.id, userId)) .limit(1); return updated?.heartsRemaining ?? MAX_FREE_HEARTS; } // 'wait' — return current count (auto-restore handles it in getHearts) const info = await getHearts(userId); return info.remaining; } export { MAX_FREE_HEARTS, PRO_HEARTS };