Add ad recovery API contract
This commit is contained in:
parent
b46b6c8ae0
commit
2649b24277
27
db/migrations/0002_foamy_rachel_grey.sql
Normal file
27
db/migrations/0002_foamy_rachel_grey.sql
Normal 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`);
|
||||||
1606
db/migrations/meta/0002_snapshot.json
Normal file
1606
db/migrations/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -15,6 +15,13 @@
|
|||||||
"when": 1777827874032,
|
"when": 1777827874032,
|
||||||
"tag": "0001_sturdy_invaders",
|
"tag": "0001_sturdy_invaders",
|
||||||
"breakpoints": true
|
"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
@ -223,6 +223,35 @@ export const subscriptions = mysqlTable('subscriptions', {
|
|||||||
uniqueIndex('uk_subscription_user').on(table.userId),
|
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 ────────────────────────────────────────────────
|
// ── Admin Audit Log ────────────────────────────────────────────────
|
||||||
|
|
||||||
export const adminAuditLog = mysqlTable('admin_audit_log', {
|
export const adminAuditLog = mysqlTable('admin_audit_log', {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { progressRoutes } from './routes/progress.js';
|
|||||||
import { gamificationRoutes } from './routes/gamification.js';
|
import { gamificationRoutes } from './routes/gamification.js';
|
||||||
import { paymentRoutes } from './routes/payment.js';
|
import { paymentRoutes } from './routes/payment.js';
|
||||||
import { appApiRoutes } from './routes/app-api.js';
|
import { appApiRoutes } from './routes/app-api.js';
|
||||||
|
import { rewardsRoutes } from './routes/rewards.js';
|
||||||
import { adminRoutes } from './routes/admin/index.js';
|
import { adminRoutes } from './routes/admin/index.js';
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@ -67,6 +68,7 @@ async function main(): Promise<void> {
|
|||||||
app.register(gamificationRoutes, { prefix: '/v1' });
|
app.register(gamificationRoutes, { prefix: '/v1' });
|
||||||
app.register(paymentRoutes, { prefix: '/v1' });
|
app.register(paymentRoutes, { prefix: '/v1' });
|
||||||
app.register(appApiRoutes, { prefix: '/v1' });
|
app.register(appApiRoutes, { prefix: '/v1' });
|
||||||
|
app.register(rewardsRoutes, { prefix: '/v1' });
|
||||||
|
|
||||||
// Admin routes: higher rate limit (100/min)
|
// Admin routes: higher rate limit (100/min)
|
||||||
app.register(adminRoutes, { prefix: '/v1/admin' });
|
app.register(adminRoutes, { prefix: '/v1/admin' });
|
||||||
|
|||||||
48
src/routes/rewards.ts
Normal file
48
src/routes/rewards.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
475
src/services/rewards/ad-recovery-service.ts
Normal file
475
src/services/rewards/ad-recovery-service.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user