import { and, desc, eq, gte, lt, sql } from 'drizzle-orm'; import { v4 as uuid } from 'uuid'; import { db } from '../../db/client.js'; import { adRecoverySessions, rewardLedger, users } from '../../db/schema.js'; import { AD_RECOVERY_RULES, HEART_RULES } from '../gamification/rules.js'; import { getDailyAttempts, getProgressSummary } from '../learning/progress-summary-service.js'; import { getSubscriptionStatus } from '../payment/subscription-service.js'; import { freezeStreak } from '../progress/streak-service.js'; import type { ProgressSummaryDto } from '../../types/app-api.js'; export type AdRecoveryType = 'hearts' | 'bonusAttempts' | 'streakProtection'; export type AdRecoveryPlatform = 'ios' | 'android' | 'harmony' | 'web'; export type AdRecoveryReason = | 'ad_not_completed' | 'provider_verification_failed' | 'session_expired' | 'daily_limit_reached' | 'cooldown_active' | 'already_subscribed' | 'invalid_type'; export interface CreateAdRecoverySessionInput { type: AdRecoveryType; clientRequestId: string; platform: AdRecoveryPlatform; adProvider: string; } export interface CompleteAdRecoveryInput { sessionId: string; clientRequestId: string; adProvider: string; providerRewardToken?: string; completedAt: string; } export interface AdRecoverySessionResponse { sessionId: string | null; eligible: boolean; type?: AdRecoveryType; adPlacementId?: string; remainingToday?: number; expiresAt?: string; reason?: AdRecoveryReason; nextAvailableAt?: string; /** Plus 用户被拦截时返回订阅权益摘要,客户端可据此展示替代提示。 */ subscriptionBenefits?: { tier: string; unlimitedHearts: boolean; dailyHighRewardSessions: number | null; }; } export interface AdRecoveryCompleteResponse { status: 'completed' | 'failed'; type?: AdRecoveryType; reward?: { heartsDelta: number; dailyAttemptsDelta: number; streakProtectionGranted: boolean; }; reason?: AdRecoveryReason; message?: string; progress: ProgressSummaryDto; limits?: AdRecoveryLimits; } export interface AdRecoveryLimits { remainingHeartsRecoveriesToday: number; remainingAttemptRecoveriesToday: number; nextStreakProtectionAvailableAt: string | null; } const SESSION_TTL_MS = AD_RECOVERY_RULES.sessionTtlMs; const STREAK_PROTECTION_COOLDOWN_MS = AD_RECOVERY_RULES.streakProtectionCooldownMs; const TRUSTED_TEST_PROVIDERS: ReadonlySet = new Set(AD_RECOVERY_RULES.trustedTestProviders); type SessionRecord = typeof adRecoverySessions.$inferSelect; type UserTier = 'free' | 'pro' | 'proplus'; function now(): Date { return new Date(); } function todayStart(): Date { const date = now(); date.setUTCHours(0, 0, 0, 0); return date; } function tomorrowStart(): Date { const date = todayStart(); date.setUTCDate(date.getUTCDate() + 1); return date; } function toIso(value: Date | string | null): string | null { if (!value) return null; return typeof value === 'string' ? new Date(value).toISOString() : value.toISOString(); } function toDate(value: Date | string): Date { return typeof value === 'string' ? new Date(value) : value; } function placementId(type: AdRecoveryType, platform: AdRecoveryPlatform): string { const suffixByType: Record = { hearts: 'restore_hearts', bonusAttempts: 'restore_bonus_attempts', streakProtection: 'streak_protection', }; return `duoqi_${suffixByType[type]}_${platform}`; } function isSubscribed(tier: UserTier | null | undefined, subscription: Awaited>): boolean { return tier === 'pro' || tier === 'proplus' || (subscription.status === 'active' && subscription.tier !== 'free'); } /** 获取 Plus 用户的订阅权益摘要,供广告恢复接口在被拦截时返回给客户端。 */ async function getSubscriptionBenefits(userId: string): Promise<{ tier: string; unlimitedHearts: boolean; dailyHighRewardSessions: number | null }> { const [tier, subscription] = await Promise.all([ getUserTier(userId), getSubscriptionStatus(userId), ]); const effectiveTier = (tier ?? 'free') as UserTier; const isPlus = isSubscribed(effectiveTier, subscription); return { tier: isPlus ? (effectiveTier ?? 'pro') : 'free', unlimitedHearts: isPlus, dailyHighRewardSessions: isPlus ? null : 3, }; } async function getUserTier(userId: string): Promise { const [user] = await db .select({ tier: users.tier }) .from(users) .where(eq(users.id, userId)) .limit(1); return (user?.tier ?? 'free') as UserTier; } async function completedCountToday(userId: string, type: Extract): Promise { const rows = await db .select({ id: adRecoverySessions.id }) .from(adRecoverySessions) .where(and( eq(adRecoverySessions.userId, userId), eq(adRecoverySessions.type, type), eq(adRecoverySessions.status, 'completed'), gte(adRecoverySessions.completedAt, todayStart()), lt(adRecoverySessions.completedAt, tomorrowStart()), )); return rows.length; } async function getLastStreakProtection(userId: string): Promise { const [session] = await db .select() .from(adRecoverySessions) .where(and( eq(adRecoverySessions.userId, userId), eq(adRecoverySessions.type, 'streakProtection'), eq(adRecoverySessions.status, 'completed'), )) .orderBy(desc(adRecoverySessions.completedAt)) .limit(1); return session ?? null; } async function getLimits(userId: string): Promise { const [heartCount, attemptCount, lastStreak] = await Promise.all([ completedCountToday(userId, 'hearts'), completedCountToday(userId, 'bonusAttempts'), getLastStreakProtection(userId), ]); const lastCompletedAt = lastStreak?.completedAt ? toDate(lastStreak.completedAt) : null; const nextStreakProtectionAvailableAt = lastCompletedAt ? new Date(lastCompletedAt.getTime() + STREAK_PROTECTION_COOLDOWN_MS).toISOString() : null; return { remainingHeartsRecoveriesToday: Math.max(0, AD_RECOVERY_RULES.heartsDailyLimit - heartCount), remainingAttemptRecoveriesToday: Math.max(0, AD_RECOVERY_RULES.bonusAttemptsDailyLimit - attemptCount), nextStreakProtectionAvailableAt, }; } async function checkEligibility(userId: string, type: AdRecoveryType): Promise<{ eligible: true; remainingToday?: number } | { eligible: false; reason: AdRecoveryReason; nextAvailableAt?: string }> { const [tier, subscription, progress, limits] = await Promise.all([ getUserTier(userId), getSubscriptionStatus(userId), getProgressSummary(userId), getLimits(userId), ]); if (isSubscribed(tier, subscription)) { return { eligible: false, reason: 'already_subscribed' }; } if (type === 'hearts') { if (limits.remainingHeartsRecoveriesToday <= 0) { return { eligible: false, reason: 'daily_limit_reached', nextAvailableAt: tomorrowStart().toISOString() }; } if (progress.hearts >= progress.maxHearts) { return { eligible: false, reason: 'invalid_type' }; } return { eligible: true, remainingToday: limits.remainingHeartsRecoveriesToday - 1 }; } if (type === 'bonusAttempts') { if (limits.remainingAttemptRecoveriesToday <= 0) { return { eligible: false, reason: 'daily_limit_reached', nextAvailableAt: tomorrowStart().toISOString() }; } if (progress.dailyAttemptsLeft >= progress.dailyAttemptsMax) { return { eligible: false, reason: 'invalid_type' }; } return { eligible: true, remainingToday: limits.remainingAttemptRecoveriesToday - 1 }; } const nextAvailableAt = limits.nextStreakProtectionAvailableAt; if (nextAvailableAt && new Date(nextAvailableAt).getTime() > Date.now()) { return { eligible: false, reason: 'cooldown_active', nextAvailableAt }; } return { eligible: true }; } function sessionToCreateResponse(session: SessionRecord): AdRecoverySessionResponse { return { sessionId: session.id, eligible: true, type: session.type, adPlacementId: session.adPlacementId, expiresAt: toIso(session.expiresAt) ?? undefined, }; } function completionFailed(reason: AdRecoveryReason, progress: ProgressSummaryDto, message = '广告未完整播放,未发放奖励。'): AdRecoveryCompleteResponse { return { status: 'failed', reason, message, progress }; } function providerCompletionVerified(adProvider: string, providerRewardToken?: string): boolean { return TRUSTED_TEST_PROVIDERS.has(adProvider) || Boolean(providerRewardToken?.trim()); } function affectedRows(result: unknown): number | null { if (Array.isArray(result)) return affectedRows(result[0]); if (result && typeof result === 'object') { const value = 'affectedRows' in result ? (result as { affectedRows?: unknown }).affectedRows : (result as { rowsAffected?: unknown }).rowsAffected; return typeof value === 'number' ? value : null; } return null; } async function markFailed(session: SessionRecord, reason: AdRecoveryReason, progress: ProgressSummaryDto, providerError?: string): Promise { await db .update(adRecoverySessions) .set({ status: reason === 'session_expired' ? 'expired' : 'failed', failureReason: reason, providerError, progressAfter: progress as unknown as Record, }) .where(eq(adRecoverySessions.id, session.id)); return completionFailed(reason, progress, reason === 'session_expired' ? '广告会话已过期,请重新加载广告。' : undefined); } async function getSession(userId: string, sessionId: string): Promise { const [session] = await db .select() .from(adRecoverySessions) .where(and( eq(adRecoverySessions.id, sessionId), eq(adRecoverySessions.userId, userId), )) .limit(1); return session ?? null; } async function completedResponse(userId: string, session: SessionRecord): Promise { return { status: 'completed', type: session.type, reward: session.rewardSnapshot as NonNullable, progress: session.progressAfter as unknown as ProgressSummaryDto, limits: await getLimits(userId), }; } /** * 通过统一奖励结算层发放广告恢复奖励。 * * 每次恢复都会写入 rewardLedger,以 ad_recovery:{sessionId} 为幂等 key, * 确保同一 session 只能结算一次。流水记录包含奖励快照和发放前后状态, * 方便审计追溯。 */ async function applyReward( sessionId: string, userId: string, type: AdRecoveryType, before: ProgressSummaryDto, ): Promise<{ reward: NonNullable; progress: ProgressSummaryDto }> { // 幂等 key 绑定 sessionId,与 adRecoverySessions 的 CAS 状态机配合双保险。 const idempotencyKey = `ad_recovery:${sessionId}`; const [existingLedger] = await db .select({ id: rewardLedger.id }) .from(rewardLedger) .where(and( eq(rewardLedger.userId, userId), eq(rewardLedger.idempotencyKey, idempotencyKey), )) .limit(1); // 已有流水记录说明该 session 已结算过,直接返回当前状态,不重复发放。 if (existingLedger) { const progress = await getProgressSummary(userId); return { reward: { heartsDelta: Math.max(0, progress.hearts - before.hearts), dailyAttemptsDelta: Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft), streakProtectionGranted: false, }, progress, }; } // 记录发放前的资源快照。 const stateBefore = { hearts: before.hearts, maxHearts: before.maxHearts, dailyAttemptsLeft: before.dailyAttemptsLeft, dailyAttemptsMax: before.dailyAttemptsMax, streakDays: before.streakDays, streakProtectedUntil: before.streakProtectedUntil, } as Record; let reward: NonNullable; let resourceDeltas: Record; if (type === 'hearts') { // 恢复爱心到上限。 const heartsBefore = before.hearts; await db .update(users) .set({ heartsRemaining: HEART_RULES.freeMax, heartsLastRestore: sql`NOW()`, }) .where(eq(users.id, userId)); const progress = await getProgressSummary(userId); const heartsDelta = Math.max(0, progress.hearts - heartsBefore); reward = { heartsDelta, dailyAttemptsDelta: 0, streakProtectionGranted: false }; resourceDeltas = { hearts: heartsDelta }; } else if (type === 'bonusAttempts') { // 恢复 1 组高奖励挑战次数。 const attempts = await getDailyAttempts(userId); const next = Math.min(attempts.left + AD_RECOVERY_RULES.bonusAttemptsPerRecovery, attempts.max); await db .update(users) .set({ dailyAttemptsLeft: next, dailyAttemptsDate: sql`CAST(${new Date().toISOString().slice(0, 10)} AS DATE)`, }) .where(eq(users.id, userId)); const progress = await getProgressSummary(userId); const attemptsDelta = Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft); reward = { heartsDelta: 0, dailyAttemptsDelta: attemptsDelta, streakProtectionGranted: false }; resourceDeltas = { bonusAttempts: attemptsDelta }; } else { // 连胜保护:冻结签到并设置保护期。 await freezeStreak(userId); const protectedUntil = new Date(); protectedUntil.setUTCHours(24, 0, 0, 0); await db .update(users) .set({ streakProtectedUntil: protectedUntil }) .where(eq(users.id, userId)); reward = { heartsDelta: 0, dailyAttemptsDelta: 0, streakProtectionGranted: true }; resourceDeltas = { streakProtection: true }; } const progress = await getProgressSummary(userId); // 记录发放后的资源快照。 const stateAfter = { hearts: progress.hearts, maxHearts: progress.maxHearts, dailyAttemptsLeft: progress.dailyAttemptsLeft, dailyAttemptsMax: progress.dailyAttemptsMax, streakDays: progress.streakDays, streakProtectedUntil: progress.streakProtectedUntil, } as Record; // 写入统一奖励流水,sourceType 为 ad_recovery,与 schema 中的枚举一致。 await db.insert(rewardLedger).values({ id: uuid(), userId, sourceType: 'ad_recovery', sourceId: sessionId, idempotencyKey, status: 'completed', rewardSnapshot: { type, reward, }, resourceDeltas, stateBefore, stateAfter, settledAt: sql`NOW()`, }); return { reward, progress }; } export async function createAdRecoverySession(userId: string, input: CreateAdRecoverySessionInput): Promise { const [existing] = await db .select() .from(adRecoverySessions) .where(and( eq(adRecoverySessions.userId, userId), eq(adRecoverySessions.clientRequestId, input.clientRequestId), )) .limit(1); if (existing) { await db .update(adRecoverySessions) .set({ duplicateCount: sql`COALESCE(duplicate_count, 0) + 1` }) .where(eq(adRecoverySessions.id, existing.id)); return sessionToCreateResponse(existing); } const eligibility = await checkEligibility(userId, input.type); if (!eligibility.eligible) { // Plus 用户不需要广告恢复,返回订阅权益摘要供客户端展示。 const subscriptionBenefits = eligibility.reason === 'already_subscribed' ? await getSubscriptionBenefits(userId) : undefined; return { sessionId: null, eligible: false, reason: eligibility.reason, nextAvailableAt: eligibility.nextAvailableAt, subscriptionBenefits, }; } const id = uuid(); const expiresAt = new Date(Date.now() + SESSION_TTL_MS); const adPlacementId = placementId(input.type, input.platform); await db.insert(adRecoverySessions).values({ id, userId, type: input.type, status: 'pending', clientRequestId: input.clientRequestId, platform: input.platform, adProvider: input.adProvider, adPlacementId, expiresAt, }); return { sessionId: id, eligible: true, type: input.type, adPlacementId, remainingToday: eligibility.remainingToday, expiresAt: expiresAt.toISOString(), }; } export async function completeAdRecoverySession(userId: string, input: CompleteAdRecoveryInput): Promise { const session = await getSession(userId, input.sessionId); const progress = await getProgressSummary(userId); if (!session) { return completionFailed('invalid_type', progress, '广告恢复会话不存在。'); } if (session.status === 'completed' && session.progressAfter && session.rewardSnapshot) { await db .update(adRecoverySessions) .set({ duplicateCount: sql`COALESCE(duplicate_count, 0) + 1` }) .where(eq(adRecoverySessions.id, session.id)); return completedResponse(userId, session); } if (session.status === 'failed' || session.status === 'expired') { await db .update(adRecoverySessions) .set({ duplicateCount: sql`COALESCE(duplicate_count, 0) + 1` }) .where(eq(adRecoverySessions.id, session.id)); return completionFailed((session.failureReason as AdRecoveryReason | null) ?? 'ad_not_completed', progress); } if (session.clientRequestId !== input.clientRequestId) { return markFailed(session, 'provider_verification_failed', progress, 'clientRequestId mismatch'); } if (session.adProvider !== input.adProvider) { return markFailed(session, 'provider_verification_failed', progress, 'adProvider mismatch'); } if (toDate(session.expiresAt).getTime() < Date.now()) { return markFailed(session, 'session_expired', progress); } if (!providerCompletionVerified(input.adProvider, input.providerRewardToken)) { return markFailed(session, 'provider_verification_failed', progress, 'missing provider reward token'); } const claimResult = await db .update(adRecoverySessions) .set({ status: 'settling', completeRequestId: input.clientRequestId, providerRewardToken: input.providerRewardToken ?? null, }) .where(and( eq(adRecoverySessions.id, session.id), eq(adRecoverySessions.status, 'pending'), )); const claimedRows = affectedRows(claimResult); if (claimedRows === 0) { const current = await getSession(userId, input.sessionId); if (current?.status === 'completed' && current.progressAfter && current.rewardSnapshot) { return completedResponse(userId, current); } return completionFailed('ad_not_completed', progress, '广告恢复会话正在结算,请稍后重试。'); } const eligibility = await checkEligibility(userId, session.type); if (!eligibility.eligible) { return markFailed(session, eligibility.reason, progress); } const before = progress; const { reward, progress: after } = await applyReward(session.id, userId, session.type, before); const completedAt = new Date(input.completedAt); const safeCompletedAt = Number.isNaN(completedAt.getTime()) ? new Date() : completedAt; await db .update(adRecoverySessions) .set({ status: 'completed', completeRequestId: input.clientRequestId, rewardSnapshot: reward, progressBefore: before as unknown as Record, progressAfter: after as unknown as Record, completedAt: safeCompletedAt, }) .where(eq(adRecoverySessions.id, session.id)); return { status: 'completed', type: session.type, reward, progress: after, limits: await getLimits(userId), }; }