feat(users): 添加会员状态自动同步验证功能

- 新增登录时异步触发会员状态验证机制,不阻塞响应
- 实现5分钟频率限制,避免过度调用RevenueCat API
- 重构API调用方法,分别获取subscriptions和purchases数据
- 支持终身会员识别和有效期自动更新
- 添加内存缓存记录最后验证时间
- 完善错误处理和日志记录,确保主流程不受影响
This commit is contained in:
richarjiang
2025-11-18 15:31:32 +08:00
parent 1c033cd801
commit bbc6924f5b

View File

@@ -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
});
}
}