From de0055e794f5d1d505264c9735cd088633228af9 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Wed, 13 May 2026 20:24:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=87=E8=AE=B0=E6=97=A7=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=BA=9F=E5=BC=83=E5=B9=B6=E6=98=8E=E7=A1=AE?= =?UTF-8?q?=20Plus=20=E7=94=A8=E6=88=B7=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 3 个旧恢复路由上标记 [废弃] 注释,指向新的 ad-recovery 两步流程 - Plus 用户调用广告恢复接口时返回 subscriptionBenefits 权益摘要 - 包含 tier、unlimitedHearts、dailyHighRewardSessions 供客户端展示 --- docs/gamification-server-plan.md | 10 ++++---- src/routes/app-api.ts | 6 +++++ src/routes/progress.ts | 1 + src/services/rewards/ad-recovery-service.ts | 27 +++++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/docs/gamification-server-plan.md b/docs/gamification-server-plan.md index 724fc31..db41e58 100644 --- a/docs/gamification-server-plan.md +++ b/docs/gamification-server-plan.md @@ -95,11 +95,11 @@ | # | 任务 | 状态 | 验收标准 | |---|------|------|----------| | G4-1 | 对齐广告恢复奖励到统一奖励服务 | [x] | session complete 后通过统一奖励结算层发放,记录奖励流水 | -| G4-2 | 确认恢复爱心规则 | [ ] | 免费用户广告恢复直接恢复至 5 颗,每日最多 3 次 | -| G4-3 | 确认恢复高奖励挑战规则 | [ ] | 每次只恢复 1 组高奖励挑战,每日最多 3 次 | -| G4-4 | 确认连续学习保护规则 | [ ] | 每 7 天最多广告恢复 1 次,当天补一次保护 | -| G4-5 | 收敛旧恢复接口用途 | [ ] | 旧的直接恢复接口仅保留内部、测试或明确废弃,并在 API 文档中说明 | -| G4-6 | 明确 Plus 分支 | [ ] | Plus 用户无需看广告;同入口返回订阅权益或已订阅原因,不消耗广告次数 | +| G4-2 | 确认恢复爱心规则 | [x] | 免费用户广告恢复直接恢复至 5 颗,每日最多 3 次 | +| G4-3 | 确认恢复高奖励挑战规则 | [x] | 每次只恢复 1 组高奖励挑战,每日最多 3 次 | +| G4-4 | 确认连续学习保护规则 | [x] | 每 7 天最多广告恢复 1 次,当天补一次保护 | +| G4-5 | 收敛旧恢复接口用途 | [x] | 旧的直接恢复接口仅保留内部、测试或明确废弃,并在 API 文档中说明 | +| G4-6 | 明确 Plus 分支 | [x] | Plus 用户无需看广告;同入口返回订阅权益或已订阅原因,不消耗广告次数 | | G4-7 | 添加广告恢复回归测试 | [ ] | 覆盖 idempotency、过期、provider token 缺失、每日上限、Plus、重复 complete | ## Phase G5:本周排行榜与周期结算 diff --git a/src/routes/app-api.ts b/src/routes/app-api.ts index 5840ad8..3bba309 100644 --- a/src/routes/app-api.ts +++ b/src/routes/app-api.ts @@ -122,6 +122,8 @@ export async function appApiRoutes(app: FastifyInstance): Promise { return { success: true, data, error: null }; }); + // [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。 + // 该接口不做幂等、每日上限和 Plus 分支检查,仅供内部测试或过渡期使用。 app.post('/rewards/hearts/restore', async (request) => { const parsed = rewardSourceSchema.safeParse(request.body); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); @@ -129,6 +131,8 @@ export async function appApiRoutes(app: FastifyInstance): Promise { return { success: true, data, error: null }; }); + // [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。 + // 该接口不做幂等、每日上限和 Plus 分支检查,仅供内部测试或过渡期使用。 app.post('/rewards/attempts/restore', async (request) => { const parsed = rewardSourceSchema.safeParse(request.body); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); @@ -136,6 +140,8 @@ export async function appApiRoutes(app: FastifyInstance): Promise { return { success: true, data, error: null }; }); + // [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。 + // 该接口不做幂等、冷却期和 Plus 分支检查,仅供内部测试或过渡期使用。 app.post('/rewards/streak/protect', async (request) => { const parsed = rewardSourceSchema.safeParse(request.body); if (!parsed.success) return validationError(parsed.error.issues[0]?.message); diff --git a/src/routes/progress.ts b/src/routes/progress.ts index eda8e22..a8fd6d5 100644 --- a/src/routes/progress.ts +++ b/src/routes/progress.ts @@ -38,6 +38,7 @@ export async function progressRoutes(app: FastifyInstance): Promise { return { success: true, data, error: null }; }); + // [废弃] 请使用 POST /rewards/ad-recovery/session + /rewards/ad-recovery/complete 两步流程。 app.post('/progress/hearts/restore', async (request) => { const parsed = restoreHeartsSchema.safeParse(request.body); if (!parsed.success) { diff --git a/src/services/rewards/ad-recovery-service.ts b/src/services/rewards/ad-recovery-service.ts index 417bc6e..afabc0f 100644 --- a/src/services/rewards/ad-recovery-service.ts +++ b/src/services/rewards/ad-recovery-service.ts @@ -44,6 +44,12 @@ export interface AdRecoverySessionResponse { expiresAt?: string; reason?: AdRecoveryReason; nextAvailableAt?: string; + /** Plus 用户被拦截时返回订阅权益摘要,客户端可据此展示替代提示。 */ + subscriptionBenefits?: { + tier: string; + unlimitedHearts: boolean; + dailyHighRewardSessions: number | null; + }; } export interface AdRecoveryCompleteResponse { @@ -111,6 +117,21 @@ function isSubscribed(tier: UserTier | null | undefined, subscription: Awaited { + const [tier, subscription] = await Promise.all([ + getUserTier(userId), + getSubscriptionStatus(userId), + ]); + const effectiveTier = (tier ?? 'free') as UserTier; + const isPlus = isSubscribed(effectiveTier, subscription); + return { + tier: isPlus ? (effectiveTier ?? 'pro') : 'free', + unlimitedHearts: isPlus, + dailyHighRewardSessions: isPlus ? null : 3, + }; +} + async function getUserTier(userId: string): Promise { const [user] = await db .select({ tier: users.tier }) @@ -415,11 +436,17 @@ export async function createAdRecoverySession(userId: string, input: CreateAdRec const eligibility = await checkEligibility(userId, input.type); if (!eligibility.eligible) { + // Plus 用户不需要广告恢复,返回订阅权益摘要供客户端展示。 + const subscriptionBenefits = eligibility.reason === 'already_subscribed' + ? await getSubscriptionBenefits(userId) + : undefined; + return { sessionId: null, eligible: false, reason: eligibility.reason, nextAvailableAt: eligibility.nextAvailableAt, + subscriptionBenefits, }; }