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

将 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广告恢复与订阅权益对齐
验证记录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-3 | 确认恢复高奖励挑战规则 | [ ] | 每次只恢复 1 组高奖励挑战,每日最多 3 次 |
| G4-4 | 确认连续学习保护规则 | [ ] | 每 7 天最多广告恢复 1 次,当天补一次保护 |

View File

@ -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<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') {
// 恢复爱心到上限。
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,16 +346,11 @@ 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);
@ -318,15 +358,41 @@ async function applyReward(userId: string, type: AdRecoveryType, before: Progres
.update(users)
.set({ streakProtectedUntil: protectedUntil })
.where(eq(users.id, userId));
reward = { heartsDelta: 0, dailyAttemptsDelta: 0, streakProtectionGranted: true };
resourceDeltas = { streakProtection: true };
}
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<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> {
@ -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;