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,
|
||||
"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
@ -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', {
|
||||
|
||||
@ -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
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