diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 6f6e85c..724fc31 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -90,9 +90,11 @@ ## Phase G4:广告恢复与订阅权益对齐 +验证记录(2026-05-13):G4-1 已通过 `./node_modules/.bin/tsc --noEmit` 和 `./node_modules/.bin/eslint src/services/rewards/ad-recovery-service.ts`;广告恢复奖励现已通过 `rewardLedger` 统一结算层发放,使用 `ad_recovery:{sessionId}` 幂等 key,记录 stateBefore/After 快照。 + | # | 任务 | 状态 | 验收标准 | |---|------|------|----------| -| G4-1 | 对齐广告恢复奖励到统一奖励服务 | [ ] | session complete 后通过统一奖励结算层发放,记录奖励流水 | +| G4-1 | 对齐广告恢复奖励到统一奖励服务 | [x] | session complete 后通过统一奖励结算层发放,记录奖励流水 | | G4-2 | 确认恢复爱心规则 | [ ] | 免费用户广告恢复直接恢复至 5 颗,每日最多 3 次 | | G4-3 | 确认恢复高奖励挑战规则 | [ ] | 每次只恢复 1 组高奖励挑战,每日最多 3 次 | | G4-4 | 确认连续学习保护规则 | [ ] | 每 7 天最多广告恢复 1 次,当天补一次保护 | diff --git a/src/services/rewards/ad-recovery-service.ts b/src/services/rewards/ad-recovery-service.ts index c11acec..417bc6e 100644 --- a/src/services/rewards/ad-recovery-service.ts +++ b/src/services/rewards/ad-recovery-service.ts @@ -1,7 +1,7 @@ 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 { 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'; @@ -270,8 +270,59 @@ async function completedResponse(userId: string, session: SessionRecord): Promis }; } -async function applyReward(userId: string, type: AdRecoveryType, before: ProgressSummaryDto): Promise<{ reward: NonNullable; progress: ProgressSummaryDto }> { +/** + * 通过统一奖励结算层发放广告恢复奖励。 + * + * 每次恢复都会写入 rewardLedger,以 ad_recovery:{sessionId} 为幂等 key, + * 确保同一 session 只能结算一次。流水记录包含奖励快照和发放前后状态, + * 方便审计追溯。 + */ +async function applyReward( + sessionId: string, + userId: string, + type: AdRecoveryType, + before: ProgressSummaryDto, +): Promise<{ reward: NonNullable; progress: ProgressSummaryDto }> { + // 幂等 key 绑定 sessionId,与 adRecoverySessions 的 CAS 状态机配合双保险。 + const idempotencyKey = `ad_recovery:${sessionId}`; + const [existingLedger] = await db + .select({ id: rewardLedger.id }) + .from(rewardLedger) + .where(and( + eq(rewardLedger.userId, userId), + eq(rewardLedger.idempotencyKey, idempotencyKey), + )) + .limit(1); + + // 已有流水记录说明该 session 已结算过,直接返回当前状态,不重复发放。 + if (existingLedger) { + const progress = await getProgressSummary(userId); + return { + reward: { + heartsDelta: Math.max(0, progress.hearts - before.hearts), + dailyAttemptsDelta: Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft), + streakProtectionGranted: false, + }, + progress, + }; + } + + // 记录发放前的资源快照。 + const stateBefore = { + hearts: before.hearts, + maxHearts: before.maxHearts, + dailyAttemptsLeft: before.dailyAttemptsLeft, + dailyAttemptsMax: before.dailyAttemptsMax, + streakDays: before.streakDays, + streakProtectedUntil: before.streakProtectedUntil, + } as Record; + + let reward: NonNullable; + let resourceDeltas: Record; + if (type === 'hearts') { + // 恢复爱心到上限。 + const heartsBefore = before.hearts; await db .update(users) .set({ @@ -280,19 +331,13 @@ async function applyReward(userId: string, type: AdRecoveryType, before: Progres }) .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 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 + 1, attempts.max); + const next = Math.min(attempts.left + AD_RECOVERY_RULES.bonusAttemptsPerRecovery, attempts.max); await db .update(users) .set({ @@ -301,32 +346,53 @@ async function applyReward(userId: string, type: AdRecoveryType, before: Progres }) .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, - }; + 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 }; } - 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, + + // 记录发放后的资源快照。 + const stateAfter = { + hearts: progress.hearts, + maxHearts: progress.maxHearts, + dailyAttemptsLeft: progress.dailyAttemptsLeft, + dailyAttemptsMax: progress.dailyAttemptsMax, + streakDays: progress.streakDays, + streakProtectedUntil: progress.streakProtectedUntil, + } as Record; + + // 写入统一奖励流水,sourceType 为 ad_recovery,与 schema 中的枚举一致。 + await db.insert(rewardLedger).values({ + id: uuid(), + userId, + sourceType: 'ad_recovery', + sourceId: sessionId, + idempotencyKey, + status: 'completed', + rewardSnapshot: { + type, + reward, }, - progress, - }; + resourceDeltas, + stateBefore, + stateAfter, + settledAt: sql`NOW()`, + }); + + return { reward, progress }; } export async function createAdRecoverySession(userId: string, input: CreateAdRecoverySessionInput): Promise { @@ -448,7 +514,7 @@ export async function completeAdRecoverySession(userId: string, input: CompleteA } const before = progress; - const { reward, progress: after } = await applyReward(userId, session.type, before); + 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;