duoqi-api/src/services/rewards/ad-recovery-service.ts
Wang Zhuoxuan de0055e794 标记旧恢复接口废弃并明确 Plus 用户分支
- 在 3 个旧恢复路由上标记 [废弃] 注释,指向新的 ad-recovery 两步流程
- Plus 用户调用广告恢复接口时返回 subscriptionBenefits 权益摘要
- 包含 tier、unlimitedHearts、dailyHighRewardSessions 供客户端展示
2026-05-13 20:24:32 +08:00

568 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string> = 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<AdRecoveryType, string> = {
hearts: 'restore_hearts',
bonusAttempts: 'restore_bonus_attempts',
streakProtection: 'streak_protection',
};
return `duoqi_${suffixByType[type]}_${platform}`;
}
function isSubscribed(tier: UserTier | null | undefined, subscription: Awaited<ReturnType<typeof getSubscriptionStatus>>): 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<UserTier | null> {
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<AdRecoveryType, 'hearts' | 'bonusAttempts'>): Promise<number> {
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<SessionRecord | null> {
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<AdRecoveryLimits> {
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<AdRecoveryCompleteResponse> {
await db
.update(adRecoverySessions)
.set({
status: reason === 'session_expired' ? 'expired' : 'failed',
failureReason: reason,
providerError,
progressAfter: progress as unknown as Record<string, unknown>,
})
.where(eq(adRecoverySessions.id, session.id));
return completionFailed(reason, progress, reason === 'session_expired' ? '广告会话已过期,请重新加载广告。' : undefined);
}
async function getSession(userId: string, sessionId: string): Promise<SessionRecord | null> {
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<AdRecoveryCompleteResponse> {
return {
status: 'completed',
type: session.type,
reward: session.rewardSnapshot as NonNullable<AdRecoveryCompleteResponse['reward']>,
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<AdRecoveryCompleteResponse['reward']>; 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<string, unknown>;
let reward: NonNullable<AdRecoveryCompleteResponse['reward']>;
let resourceDeltas: Record<string, unknown>;
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<string, unknown>;
// 写入统一奖励流水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<AdRecoverySessionResponse> {
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<AdRecoveryCompleteResponse> {
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<string, unknown>,
progressAfter: after as unknown as Record<string, unknown>,
completedAt: safeCompletedAt,
})
.where(eq(adRecoverySessions.id, session.id));
return {
status: 'completed',
type: session.type,
reward,
progress: after,
limits: await getLimits(userId),
};
}