Add ad recovery API contract

This commit is contained in:
Wang Zhuoxuan 2026-05-05 16:12:04 +08:00
parent b46b6c8ae0
commit 2649b24277
8 changed files with 2745 additions and 1376 deletions

View File

@ -0,0 +1,27 @@
CREATE TABLE `ad_recovery_sessions` (
`id` char(36) NOT NULL,
`user_id` char(36) NOT NULL,
`type` enum('hearts','bonusAttempts','streakProtection') NOT NULL,
`status` enum('pending','settling','completed','failed','expired') DEFAULT 'pending',
`client_request_id` varchar(80) NOT NULL,
`complete_request_id` varchar(80),
`platform` enum('ios','android','harmony','web') NOT NULL,
`ad_provider` varchar(50) NOT NULL,
`ad_placement_id` varchar(120) NOT NULL,
`provider_reward_token` varchar(500),
`reward_snapshot` json,
`progress_before` json,
`progress_after` json,
`failure_reason` varchar(80),
`provider_error` varchar(500),
`duplicate_count` int DEFAULT 0,
`expires_at` datetime NOT NULL,
`completed_at` datetime,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `ad_recovery_sessions_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_ad_recovery_user_client_request` UNIQUE(`user_id`,`client_request_id`)
);
--> statement-breakpoint
ALTER TABLE `ad_recovery_sessions` ADD CONSTRAINT `ad_recovery_sessions_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX `idx_ad_recovery_user_type_status_created` ON `ad_recovery_sessions` (`user_id`,`type`,`status`,`created_at`);

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,13 @@
"when": 1777827874032,
"tag": "0001_sturdy_invaders",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1777965665440,
"tag": "0002_foamy_rachel_grey",
"breakpoints": true
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -223,6 +223,35 @@ export const subscriptions = mysqlTable('subscriptions', {
uniqueIndex('uk_subscription_user').on(table.userId),
]);
// ── Rewarded Ad Recovery Sessions ─────────────────────────────────
export const adRecoverySessions = mysqlTable('ad_recovery_sessions', {
id: char('id', { length: 36 }).primaryKey(),
userId: char('user_id', { length: 36 }).notNull(),
type: mysqlEnum('type', ['hearts', 'bonusAttempts', 'streakProtection']).notNull(),
status: mysqlEnum('status', ['pending', 'settling', 'completed', 'failed', 'expired']).default('pending'),
clientRequestId: varchar('client_request_id', { length: 80 }).notNull(),
completeRequestId: varchar('complete_request_id', { length: 80 }),
platform: mysqlEnum('platform', ['ios', 'android', 'harmony', 'web']).notNull(),
adProvider: varchar('ad_provider', { length: 50 }).notNull(),
adPlacementId: varchar('ad_placement_id', { length: 120 }).notNull(),
providerRewardToken: varchar('provider_reward_token', { length: 500 }),
rewardSnapshot: json('reward_snapshot').$type<Record<string, unknown>>(),
progressBefore: json('progress_before').$type<Record<string, unknown>>(),
progressAfter: json('progress_after').$type<Record<string, unknown>>(),
failureReason: varchar('failure_reason', { length: 80 }),
providerError: varchar('provider_error', { length: 500 }),
duplicateCount: int('duplicate_count').default(0),
expiresAt: datetime('expires_at').notNull(),
completedAt: datetime('completed_at'),
createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: datetime('updated_at').default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
}, (table) => [
uniqueIndex('uk_ad_recovery_user_client_request').on(table.userId, table.clientRequestId),
index('idx_ad_recovery_user_type_status_created').on(table.userId, table.type, table.status, table.createdAt),
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
]);
// ── Admin Audit Log ────────────────────────────────────────────────
export const adminAuditLog = mysqlTable('admin_audit_log', {

View File

@ -18,6 +18,7 @@ import { progressRoutes } from './routes/progress.js';
import { gamificationRoutes } from './routes/gamification.js';
import { paymentRoutes } from './routes/payment.js';
import { appApiRoutes } from './routes/app-api.js';
import { rewardsRoutes } from './routes/rewards.js';
import { adminRoutes } from './routes/admin/index.js';
async function main(): Promise<void> {
@ -67,6 +68,7 @@ async function main(): Promise<void> {
app.register(gamificationRoutes, { prefix: '/v1' });
app.register(paymentRoutes, { prefix: '/v1' });
app.register(appApiRoutes, { prefix: '/v1' });
app.register(rewardsRoutes, { prefix: '/v1' });
// Admin routes: higher rate limit (100/min)
app.register(adminRoutes, { prefix: '/v1/admin' });

48
src/routes/rewards.ts Normal file
View File

@ -0,0 +1,48 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
completeAdRecoverySession,
createAdRecoverySession,
} from '../services/rewards/ad-recovery-service.js';
const adRecoveryTypeSchema = z.enum(['hearts', 'bonusAttempts', 'streakProtection']);
const platformSchema = z.enum(['ios', 'android', 'harmony', 'web']);
const createAdRecoverySessionSchema = z.object({
type: adRecoveryTypeSchema,
clientRequestId: z.string().min(1).max(80),
platform: platformSchema,
adProvider: z.string().min(1).max(50),
});
const completeAdRecoverySessionSchema = z.object({
sessionId: z.string().uuid(),
clientRequestId: z.string().min(1).max(80),
adProvider: z.string().min(1).max(50),
providerRewardToken: z.string().max(500).optional(),
completedAt: z.string().datetime(),
});
function getUserId(request: { user: unknown }): string {
return (request.user as { userId: string }).userId;
}
function validationError(message: string | undefined) {
return { success: false, data: null, error: { code: 'VALIDATION_ERROR', message } };
}
export async function rewardsRoutes(app: FastifyInstance): Promise<void> {
app.post('/rewards/ad-recovery/session', async (request) => {
const parsed = createAdRecoverySessionSchema.safeParse(request.body);
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
const data = await createAdRecoverySession(getUserId(request), parsed.data);
return { success: true, data, error: null };
});
app.post('/rewards/ad-recovery/complete', async (request) => {
const parsed = completeAdRecoverySessionSchema.safeParse(request.body);
if (!parsed.success) return validationError(parsed.error.issues[0]?.message);
const data = await completeAdRecoverySession(getUserId(request), parsed.data);
return { success: true, data, error: null };
});
}

View File

@ -0,0 +1,475 @@
import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid';
import { db } from '../../db/client.js';
import { adRecoverySessions, users } from '../../db/schema.js';
import { MAX_FREE_HEARTS } from '../progress/hearts-service.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;
}
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 FREE_DAILY_RECOVERY_LIMIT = 3;
const SESSION_TTL_MS = 30 * 60 * 1000;
const STREAK_PROTECTION_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
const TRUSTED_TEST_PROVIDERS = new Set(['mock']);
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');
}
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, FREE_DAILY_RECOVERY_LIMIT - heartCount),
remainingAttemptRecoveriesToday: Math.max(0, FREE_DAILY_RECOVERY_LIMIT - 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),
};
}
async function applyReward(userId: string, type: AdRecoveryType, before: ProgressSummaryDto): Promise<{ reward: NonNullable<AdRecoveryCompleteResponse['reward']>; progress: ProgressSummaryDto }> {
if (type === 'hearts') {
await db
.update(users)
.set({
heartsRemaining: MAX_FREE_HEARTS,
heartsLastRestore: sql`NOW()`,
})
.where(eq(users.id, userId));
const progress = await getProgressSummary(userId);
return {
reward: {
heartsDelta: Math.max(0, progress.hearts - before.hearts),
dailyAttemptsDelta: 0,
streakProtectionGranted: false,
},
progress,
};
}
if (type === 'bonusAttempts') {
const attempts = await getDailyAttempts(userId);
const next = Math.min(attempts.left + 1, 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);
return {
reward: {
heartsDelta: 0,
dailyAttemptsDelta: Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft),
streakProtectionGranted: false,
},
progress,
};
}
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));
const progress = await getProgressSummary(userId);
return {
reward: {
heartsDelta: 0,
dailyAttemptsDelta: 0,
streakProtectionGranted: true,
},
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) {
return {
sessionId: null,
eligible: false,
reason: eligibility.reason,
nextAvailableAt: eligibility.nextAvailableAt,
};
}
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(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),
};
}