feat(users): 添加会员状态自动同步验证功能
- 新增登录时异步触发会员状态验证机制,不阻塞响应 - 实现5分钟频率限制,避免过度调用RevenueCat API - 重构API调用方法,分别获取subscriptions和purchases数据 - 支持终身会员识别和有效期自动更新 - 添加内存缓存记录最后验证时间 - 完善错误处理和日志记录,确保主流程不受影响
This commit is contained in:
@@ -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<string, number> = 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<any> {
|
||||
private async getRevenueCatSubscriptions(userId: string): Promise<any> {
|
||||
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<any> {
|
||||
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<void> {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user