From bbc6924f5ba829d3873c36d7a7683117700b5b73 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 18 Nov 2025 15:31:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(users):=20=E6=B7=BB=E5=8A=A0=E4=BC=9A?= =?UTF-8?q?=E5=91=98=E7=8A=B6=E6=80=81=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增登录时异步触发会员状态验证机制,不阻塞响应 - 实现5分钟频率限制,避免过度调用RevenueCat API - 重构API调用方法,分别获取subscriptions和purchases数据 - 支持终身会员识别和有效期自动更新 - 添加内存缓存记录最后验证时间 - 完善错误处理和日志记录,确保主流程不受影响 --- src/users/users.service.ts | 239 +++++++++++++++++++++++++++++++++++-- 1 file changed, 229 insertions(+), 10 deletions(-) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index d2d42e5..391ed59 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -47,9 +47,15 @@ import { BadgeService } from './services/badge.service'; const DEFAULT_FREE_USAGE_COUNT = 5; +// 会员同步验证的频率限制(5分钟) +const MEMBERSHIP_SYNC_INTERVAL_MS = 5 * 60 * 1000; + @Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name); + // 用于存储最后一次验证时间的内存缓存 + private lastSyncTimestamps: Map = new Map(); + constructor( @Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger, @InjectModel(User) @@ -109,6 +115,15 @@ export class UsersService { // 检查并记录今日登录活跃 await this.userActivityService.checkAndRecordTodayLogin(existingUser.id); + // 异步触发会员状态同步验证(不等待结果,不阻塞响应) + // 使用 Promise.resolve().then() 确保在当前事件循环后执行 + Promise.resolve().then(() => { + this.syncMembershipFromRevenueCat(existingUser.id).catch(err => { + // 错误已在方法内部处理,这里只是确保不会有未捕获的 Promise rejection + this.logger.error(`异步会员验证出错: ${err instanceof Error ? err.message : '未知错误'}`); + }); + }); + const returnData = { ...existingUser.toJSON(), maxUsageCount: DEFAULT_FREE_USAGE_COUNT, @@ -2200,19 +2215,18 @@ export class UsersService { } /** - * 调用 RevenueCat API 获取用户信息 + * 调用 RevenueCat API 获取用户订阅信息(subscriptions endpoint) */ - private async getRevenueCatCustomerInfo(userId: string): Promise { + private async getRevenueCatSubscriptions(userId: string): Promise { try { const REVENUECAT_PUBLIC_API_KEY = process.env.REVENUECAT_PUBLIC_API_KEY; - const REVENUECAT_APP_USER_ID = userId; + const REVENUECAT_PROJECT_ID = process.env.REVENUECAT_PROJECT_ID || 'proje92e464f'; if (!REVENUECAT_PUBLIC_API_KEY) { throw new Error('RevenueCat API key 未配置'); } - // RevenueCat REST API v1 endpoint - const url = `https://api.revenuecat.com/v2/subscribers/${REVENUECAT_APP_USER_ID}`; + const url = `https://api.revenuecat.com/v2/projects/${REVENUECAT_PROJECT_ID}/customers/${userId}/subscriptions`; const response = await fetch(url, { method: 'GET', @@ -2224,19 +2238,224 @@ export class UsersService { if (!response.ok) { if (response.status === 404) { - this.logger.warn(`RevenueCat 中未找到用户: ${userId}`); + this.logger.warn(`RevenueCat subscriptions 中未找到用户: ${userId}`); return null; } - throw new Error(`RevenueCat API 请求失败: ${response.status} ${response.statusText}`); + throw new Error(`RevenueCat Subscriptions API 请求失败: ${response.status} ${response.statusText}`); } const data = await response.json(); - this.logger.log(`RevenueCat API 响应: ${JSON.stringify(data)}`); + this.logger.log(`RevenueCat Subscriptions API 响应: ${JSON.stringify(data)}`); return data; } catch (error) { - this.logger.error(`调用 RevenueCat API 失败: ${error instanceof Error ? error.message : '未知错误'}`); - throw error; + this.logger.error(`调用 RevenueCat Subscriptions API 失败: ${error instanceof Error ? error.message : '未知错误'}`); + return null; // 返回 null 而不是抛出错误,让调用方继续处理 + } + } + + /** + * 调用 RevenueCat API 获取用户购买信息(purchases endpoint) + */ + private async getRevenueCatPurchases(userId: string): Promise { + try { + const REVENUECAT_PUBLIC_API_KEY = process.env.REVENUECAT_PUBLIC_API_KEY; + const REVENUECAT_PROJECT_ID = process.env.REVENUECAT_PROJECT_ID || 'proje92e464f'; + + if (!REVENUECAT_PUBLIC_API_KEY) { + throw new Error('RevenueCat API key 未配置'); + } + + const url = `https://api.revenuecat.com/v2/projects/${REVENUECAT_PROJECT_ID}/customers/${userId}/purchases`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${REVENUECAT_PUBLIC_API_KEY}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + if (response.status === 404) { + this.logger.warn(`RevenueCat purchases 中未找到用户: ${userId}`); + return null; + } + throw new Error(`RevenueCat Purchases API 请求失败: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + this.logger.log(`RevenueCat Purchases API 响应: ${JSON.stringify(data)}`); + return data; + + } catch (error) { + this.logger.error(`调用 RevenueCat Purchases API 失败: ${error instanceof Error ? error.message : '未知错误'}`); + return null; // 返回 null 而不是抛出错误,让调用方继续处理 + } + } + + /** + * 异步同步用户会员状态(从 RevenueCat 获取最新数据) + * 带频率限制,避免过度调用 API + */ + private async syncMembershipFromRevenueCat(userId: string): Promise { + try { + // 检查频率限制 + const lastSyncTime = this.lastSyncTimestamps.get(userId); + const now = Date.now(); + + if (lastSyncTime && (now - lastSyncTime) < MEMBERSHIP_SYNC_INTERVAL_MS) { + this.logger.log(`用户 ${userId} 在频率限制内,跳过会员验证(距上次验证 ${Math.floor((now - lastSyncTime) / 1000)} 秒)`); + return; + } + + this.winstonLogger.info('开始同步用户会员状态', { + context: 'UsersService', + method: 'syncMembershipFromRevenueCat', + userId, + lastSyncTime: lastSyncTime ? new Date(lastSyncTime).toISOString() : 'never' + }); + + // 并行调用两个 RevenueCat API 接口获取用户信息 + const [subscriptionsData, purchasesData] = await Promise.all([ + this.getRevenueCatSubscriptions(userId), + this.getRevenueCatPurchases(userId) + ]); + + // 更新最后验证时间 + this.lastSyncTimestamps.set(userId, now); + + // 如果两个接口都没有数据,说明用户没有任何购买记录 + if (!subscriptionsData && !purchasesData) { + this.logger.log(`用户 ${userId} 在 RevenueCat 中未找到任何购买信息`); + return; + } + + let latestExpirationDate: Date | null = null; + let isLifetimeMember = false; + + // 1. 处理订阅数据(subscriptions endpoint) + if (subscriptionsData && subscriptionsData.items) { + this.logger.log(`用户 ${userId} 订阅数据: ${subscriptionsData.items.length} 个订阅`); + + for (const subscription of subscriptionsData.items) { + // subscription 可能包含 current_period_ends_at 字段 + const currentPeriodEndsAt = subscription.current_period_ends_at; + + if (currentPeriodEndsAt) { + const expiration = new Date(currentPeriodEndsAt); + + // 只考虑未过期的订阅 + if (expiration > new Date()) { + if (!latestExpirationDate || expiration > latestExpirationDate) { + latestExpirationDate = expiration; + this.logger.log(`找到有效订阅,过期时间: ${expiration.toISOString()}`); + } + } + } + } + } + + // 2. 处理一次性购买数据(purchases endpoint) + if (purchasesData && purchasesData.items) { + this.logger.log(`用户 ${userId} 购买数据: ${purchasesData.items.length} 个购买`); + + for (const purchase of purchasesData.items) { + // 一次性购买通常没有过期时间,或者 status 为 "owned" + // 如果有 store_transaction_id 且 status 为 active/owned,认为是有效的终身购买 + if (purchase.status === 'owned' || purchase.status === 'active') { + // 检查是否是终身购买(通常一次性购买没有 expires_at 或者 expires_at 是很远的未来) + const expiresAt = purchase.expires_at; + + if (!expiresAt) { + // 没有过期时间,认为是终身购买 + isLifetimeMember = true; + this.logger.log(`找到终身购买: ${purchase.product_id || 'unknown'}`); + break; // 找到终身购买就不需要继续了 + } else { + // 有过期时间,比较是否比当前最晚的更晚 + const expiration = new Date(expiresAt); + if (expiration > new Date()) { + if (!latestExpirationDate || expiration > latestExpirationDate) { + latestExpirationDate = expiration; + this.logger.log(`找到有效购买,过期时间: ${expiration.toISOString()}`); + } + } + } + } + } + } + + // 获取用户当前数据 + const user = await this.userModel.findByPk(userId); + if (!user) { + this.logger.warn(`用户 ${userId} 在数据库中不存在`); + return; + } + + const currentExpiration = user.membershipExpiration; + + // 决定最终的会员过期时间 + let finalExpirationDate: Date | null = null; + + if (isLifetimeMember) { + // 终身会员:设置为100年后 + finalExpirationDate = new Date(); + finalExpirationDate.setFullYear(finalExpirationDate.getFullYear() + 100); + this.logger.log(`用户 ${userId} 是终身会员`); + } else if (latestExpirationDate) { + finalExpirationDate = latestExpirationDate; + } + + // 比较并更新 + if (finalExpirationDate) { + // RevenueCat 显示用户有有效的会员 + const needsUpdate = !currentExpiration || + Math.abs(finalExpirationDate.getTime() - currentExpiration.getTime()) > 60000; // 允许1分钟误差 + + if (needsUpdate) { + const oldExpiration = currentExpiration?.toISOString() || 'null'; + user.membershipExpiration = finalExpirationDate; + await user.save(); + + this.winstonLogger.info('会员状态已同步更新', { + context: 'UsersService', + method: 'syncMembershipFromRevenueCat', + userId, + oldExpiration, + newExpiration: finalExpirationDate.toISOString(), + isLifetimeMember, + source: 'revenuecat_sync' + }); + } else { + this.logger.log(`用户 ${userId} 会员状态一致,无需更新`); + } + } else { + // RevenueCat 显示没有有效会员 + if (currentExpiration && currentExpiration > new Date()) { + // 但数据库显示会员未过期,可能需要人工确认 + this.winstonLogger.warn('会员状态不一致:数据库显示有效但 RevenueCat 无有效权益', { + context: 'UsersService', + method: 'syncMembershipFromRevenueCat', + userId, + dbExpiration: currentExpiration.toISOString(), + subscriptionsCount: subscriptionsData?.items?.length || 0, + purchasesCount: purchasesData?.items?.length || 0 + }); + } else { + this.logger.log(`用户 ${userId} 在 RevenueCat 和数据库中均无有效会员`); + } + } + + } catch (error) { + // 错误不应影响主流程,只记录日志 + this.winstonLogger.error('同步会员状态失败', { + context: 'UsersService', + method: 'syncMembershipFromRevenueCat', + userId, + error: error instanceof Error ? error.message : '未知错误', + stack: error instanceof Error ? error.stack : undefined + }); } }