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;
|
const DEFAULT_FREE_USAGE_COUNT = 5;
|
||||||
|
|
||||||
|
// 会员同步验证的频率限制(5分钟)
|
||||||
|
const MEMBERSHIP_SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
private readonly logger = new Logger(UsersService.name);
|
private readonly logger = new Logger(UsersService.name);
|
||||||
|
// 用于存储最后一次验证时间的内存缓存
|
||||||
|
private lastSyncTimestamps: Map<string, number> = new Map();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger,
|
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger,
|
||||||
@InjectModel(User)
|
@InjectModel(User)
|
||||||
@@ -109,6 +115,15 @@ export class UsersService {
|
|||||||
// 检查并记录今日登录活跃
|
// 检查并记录今日登录活跃
|
||||||
await this.userActivityService.checkAndRecordTodayLogin(existingUser.id);
|
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 = {
|
const returnData = {
|
||||||
...existingUser.toJSON(),
|
...existingUser.toJSON(),
|
||||||
maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
|
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 {
|
try {
|
||||||
const REVENUECAT_PUBLIC_API_KEY = process.env.REVENUECAT_PUBLIC_API_KEY;
|
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) {
|
if (!REVENUECAT_PUBLIC_API_KEY) {
|
||||||
throw new Error('RevenueCat API key 未配置');
|
throw new Error('RevenueCat API key 未配置');
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevenueCat REST API v1 endpoint
|
const url = `https://api.revenuecat.com/v2/projects/${REVENUECAT_PROJECT_ID}/customers/${userId}/subscriptions`;
|
||||||
const url = `https://api.revenuecat.com/v2/subscribers/${REVENUECAT_APP_USER_ID}`;
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -2224,19 +2238,224 @@ export class UsersService {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
this.logger.warn(`RevenueCat 中未找到用户: ${userId}`);
|
this.logger.warn(`RevenueCat subscriptions 中未找到用户: ${userId}`);
|
||||||
return null;
|
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();
|
const data = await response.json();
|
||||||
this.logger.log(`RevenueCat API 响应: ${JSON.stringify(data)}`);
|
this.logger.log(`RevenueCat Subscriptions API 响应: ${JSON.stringify(data)}`);
|
||||||
return data;
|
return data;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`调用 RevenueCat API 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
this.logger.error(`调用 RevenueCat Subscriptions API 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
throw error;
|
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