186 lines
5.5 KiB
TypeScript
186 lines
5.5 KiB
TypeScript
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<HeartsInfo> {
|
|
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<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/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<number> {
|
|
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 };
|