对齐广告恢复奖励到统一奖励结算层
将 ad-recovery-service 的 applyReward() 从直接操作 users 表改为通过
rewardLedger 统一结算层发放,使用 ad_recovery:{sessionId} 幂等 key
防止重复结算,记录 stateBefore/After 资源快照便于审计追溯。
This commit is contained in:
parent
7aa53657fc
commit
8401d8c714
@ -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 次,当天补一次保护 |
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user