duoqi-api/src/services/progress/hearts-service.ts
Wang Zhuoxuan c29599daaa
All checks were successful
CI/CD Pipeline / Unit Tests (push) Successful in 19s
CI/CD Pipeline / Build & Deploy Test (push) Has been skipped
CI/CD Pipeline / Build & Deploy Production (push) Successful in 1m15s
fix: clamp negative hearts in bootstrap progress
2026-06-10 13:04:27 +08:00

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 };