标记旧恢复接口废弃并明确 Plus 用户分支

- 在 3 个旧恢复路由上标记 [废弃] 注释,指向新的 ad-recovery 两步流程
- Plus 用户调用广告恢复接口时返回 subscriptionBenefits 权益摘要
- 包含 tier、unlimitedHearts、dailyHighRewardSessions 供客户端展示
This commit is contained in:
Wang Zhuoxuan 2026-05-13 20:24:32 +08:00
parent 8401d8c714
commit de0055e794
4 changed files with 39 additions and 5 deletions

View File

@ -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本周排行榜与周期结算

View File

@ -122,6 +122,8 @@ export async function appApiRoutes(app: FastifyInstance): Promise<void> {
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<void> {
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<void> {
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);

View File

@ -38,6 +38,7 @@ export async function progressRoutes(app: FastifyInstance): Promise<void> {
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) {

View File

@ -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<R
return tier === 'pro' || tier === 'proplus' || (subscription.status === 'active' && subscription.tier !== 'free');
}
/** 获取 Plus 用户的订阅权益摘要,供广告恢复接口在被拦截时返回给客户端。 */
async function getSubscriptionBenefits(userId: string): Promise<{ tier: string; unlimitedHearts: boolean; dailyHighRewardSessions: number | null }> {
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<UserTier | null> {
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,
};
}