对齐广告恢复奖励到统一奖励结算层

将 ad-recovery-service 的 applyReward() 从直接操作 users 表改为通过
rewardLedger 统一结算层发放,使用 ad_recovery:{sessionId} 幂等 key
防止重复结算,记录 stateBefore/After 资源快照便于审计追溯。
This commit is contained in:
Wang Zhuoxuan 2026-05-13 20:04:32 +08:00
parent 7aa53657fc
commit 8401d8c714
2 changed files with 106 additions and 38 deletions

View File

@ -90,9 +90,11 @@
## Phase G4广告恢复与订阅权益对齐 ## Phase G4广告恢复与订阅权益对齐
验证记录2026-05-13G4-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-2 | 确认恢复爱心规则 | [ ] | 免费用户广告恢复直接恢复至 5 颗,每日最多 3 次 |
| G4-3 | 确认恢复高奖励挑战规则 | [ ] | 每次只恢复 1 组高奖励挑战,每日最多 3 次 | | G4-3 | 确认恢复高奖励挑战规则 | [ ] | 每次只恢复 1 组高奖励挑战,每日最多 3 次 |
| G4-4 | 确认连续学习保护规则 | [ ] | 每 7 天最多广告恢复 1 次,当天补一次保护 | | G4-4 | 确认连续学习保护规则 | [ ] | 每 7 天最多广告恢复 1 次,当天补一次保护 |

View File

@ -1,7 +1,7 @@
import { and, desc, eq, gte, lt, sql } from 'drizzle-orm'; import { and, desc, eq, gte, lt, sql } from 'drizzle-orm';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { db } from '../../db/client.js'; 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 { AD_RECOVERY_RULES, HEART_RULES } from '../gamification/rules.js';
import { getDailyAttempts, getProgressSummary } from '../learning/progress-summary-service.js'; import { getDailyAttempts, getProgressSummary } from '../learning/progress-summary-service.js';
import { getSubscriptionStatus } from '../payment/subscription-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<AdRecoveryCompleteResponse['reward']>; progress: ProgressSummaryDto }> { /**
* 广
*
* 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') { if (type === 'hearts') {
// 恢复爱心到上限。
const heartsBefore = before.hearts;
await db await db
.update(users) .update(users)
.set({ .set({
@ -280,19 +331,13 @@ async function applyReward(userId: string, type: AdRecoveryType, before: Progres
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
const progress = await getProgressSummary(userId); const progress = await getProgressSummary(userId);
return { const heartsDelta = Math.max(0, progress.hearts - heartsBefore);
reward: { reward = { heartsDelta, dailyAttemptsDelta: 0, streakProtectionGranted: false };
heartsDelta: Math.max(0, progress.hearts - before.hearts), resourceDeltas = { hearts: heartsDelta };
dailyAttemptsDelta: 0, } else if (type === 'bonusAttempts') {
streakProtectionGranted: false, // 恢复 1 组高奖励挑战次数。
},
progress,
};
}
if (type === 'bonusAttempts') {
const attempts = await getDailyAttempts(userId); 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 await db
.update(users) .update(users)
.set({ .set({
@ -301,16 +346,11 @@ async function applyReward(userId: string, type: AdRecoveryType, before: Progres
}) })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
const progress = await getProgressSummary(userId); const progress = await getProgressSummary(userId);
return { const attemptsDelta = Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft);
reward: { reward = { heartsDelta: 0, dailyAttemptsDelta: attemptsDelta, streakProtectionGranted: false };
heartsDelta: 0, resourceDeltas = { bonusAttempts: attemptsDelta };
dailyAttemptsDelta: Math.max(0, progress.dailyAttemptsLeft - before.dailyAttemptsLeft), } else {
streakProtectionGranted: false, // 连胜保护:冻结签到并设置保护期。
},
progress,
};
}
await freezeStreak(userId); await freezeStreak(userId);
const protectedUntil = new Date(); const protectedUntil = new Date();
protectedUntil.setUTCHours(24, 0, 0, 0); protectedUntil.setUTCHours(24, 0, 0, 0);
@ -318,15 +358,41 @@ async function applyReward(userId: string, type: AdRecoveryType, before: Progres
.update(users) .update(users)
.set({ streakProtectedUntil: protectedUntil }) .set({ streakProtectedUntil: protectedUntil })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
reward = { heartsDelta: 0, dailyAttemptsDelta: 0, streakProtectionGranted: true };
resourceDeltas = { streakProtection: true };
}
const progress = await getProgressSummary(userId); const progress = await getProgressSummary(userId);
return {
reward: { // 记录发放后的资源快照。
heartsDelta: 0, const stateAfter = {
dailyAttemptsDelta: 0, hearts: progress.hearts,
streakProtectionGranted: true, 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,
}, },
progress, resourceDeltas,
}; stateBefore,
stateAfter,
settledAt: sql`NOW()`,
});
return { reward, progress };
} }
export async function createAdRecoverySession(userId: string, input: CreateAdRecoverySessionInput): Promise<AdRecoverySessionResponse> { export async function createAdRecoverySession(userId: string, input: CreateAdRecoverySessionInput): Promise<AdRecoverySessionResponse> {
@ -448,7 +514,7 @@ export async function completeAdRecoverySession(userId: string, input: CompleteA
} }
const before = progress; 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 completedAt = new Date(input.completedAt);
const safeCompletedAt = Number.isNaN(completedAt.getTime()) ? new Date() : completedAt; const safeCompletedAt = Number.isNaN(completedAt.getTime()) ? new Date() : completedAt;