2539 lines
83 KiB
TypeScript
2539 lines
83 KiB
TypeScript
import {
|
||
Injectable,
|
||
NotFoundException,
|
||
ConflictException,
|
||
Logger,
|
||
BadRequestException,
|
||
Inject,
|
||
} from '@nestjs/common';
|
||
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';
|
||
import { Logger as WinstonLogger } from 'winston';
|
||
import { InjectModel, InjectConnection } from '@nestjs/sequelize';
|
||
import { Gender, User } from './models/user.model';
|
||
import { UserResponseDto } from './dto/user-response.dto';
|
||
import { ResponseCode } from 'src/base.dto';
|
||
import { Transaction, Op } from 'sequelize';
|
||
import { Sequelize } from 'sequelize-typescript';
|
||
import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto';
|
||
|
||
import { UserPurchase, PurchaseType, PurchaseStatus, PurchasePlatform } from './models/user-purchase.model';
|
||
import { ApplePurchaseService } from './services/apple-purchase.service';
|
||
import * as dayjs from 'dayjs';
|
||
import { AccessTokenPayload, AppleAuthService, AppleTokenPayload } from './services/apple-auth.service';
|
||
import { AppleLoginDto, AppleLoginResponseDto, RefreshTokenDto, RefreshTokenResponseDto } from './dto/apple-login.dto';
|
||
import { DeleteAccountDto, DeleteAccountResponseDto } from './dto/delete-account.dto';
|
||
import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGuestTokenResponseDto } from './dto/guest-login.dto';
|
||
import { AppStoreServerNotificationDto, ProcessNotificationResponseDto, NotificationType } from './dto/app-store-notification.dto';
|
||
import { RevenueCatEvent } from './models/revenue-cat-event.model';
|
||
import { UserProfile } from './models/user-profile.model';
|
||
import { RevenueCatWebhookDto, RevenueCatEventType } from './dto/revenue-cat-webhook.dto';
|
||
import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, ActiveEntitlement, NonSubscriptionTransaction } from './dto/restore-purchase.dto';
|
||
import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model';
|
||
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
|
||
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model';
|
||
import { UserDietHistory, DietRecordSource, MealType } from './models/user-diet-history.model';
|
||
import { ActivityLogsService } from '../activity-logs/activity-logs.service';
|
||
import { UserActivityService } from './services/user-activity.service';
|
||
import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
|
||
import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model';
|
||
import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto } from './dto/diet-record.dto';
|
||
|
||
const DEFAULT_FREE_USAGE_COUNT = 5;
|
||
|
||
@Injectable()
|
||
export class UsersService {
|
||
private readonly logger = new Logger(UsersService.name);
|
||
constructor(
|
||
@Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger,
|
||
@InjectModel(User)
|
||
private userModel: typeof User,
|
||
@InjectModel(UserPurchase)
|
||
private userPurchaseModel: typeof UserPurchase,
|
||
@InjectModel(RevenueCatEvent)
|
||
private revenueCatEventModel: typeof RevenueCatEvent,
|
||
@InjectModel(PurchaseRestoreLog)
|
||
private purchaseRestoreLogModel: typeof PurchaseRestoreLog,
|
||
private appleAuthService: AppleAuthService,
|
||
private applePurchaseService: ApplePurchaseService,
|
||
@InjectModel(BlockedTransaction)
|
||
private blockedTransactionModel: typeof BlockedTransaction,
|
||
@InjectModel(UserProfile)
|
||
private userProfileModel: typeof UserProfile,
|
||
@InjectModel(UserWeightHistory)
|
||
private userWeightHistoryModel: typeof UserWeightHistory,
|
||
@InjectModel(UserDietHistory)
|
||
private userDietHistoryModel: typeof UserDietHistory,
|
||
@InjectConnection()
|
||
private sequelize: Sequelize,
|
||
private readonly activityLogsService: ActivityLogsService,
|
||
private readonly userActivityService: UserActivityService,
|
||
) { }
|
||
|
||
async getProfile(user: AccessTokenPayload): Promise<UserResponseDto> {
|
||
try {
|
||
// 使用NestJS Logger (会通过winston输出)
|
||
this.logger.log(`getProfile: ${JSON.stringify(user)}`);
|
||
|
||
// 也可以直接使用winston logger
|
||
this.winstonLogger.info('getProfile method called', {
|
||
context: 'UsersService',
|
||
userId: user.sub,
|
||
email: user.email
|
||
});
|
||
// 检查email是否已存在
|
||
const existingUser = await this.userModel.findOne({
|
||
where: { mail: user.email },
|
||
});
|
||
|
||
if (!existingUser) {
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: `用户不存在,请先注册`,
|
||
data: null as any,
|
||
};
|
||
}
|
||
|
||
const [profile] = await this.userProfileModel.findOrCreate({
|
||
where: { userId: existingUser.id },
|
||
defaults: { userId: existingUser.id },
|
||
});
|
||
// 检查并记录今日登录活跃
|
||
await this.userActivityService.checkAndRecordTodayLogin(existingUser.id);
|
||
|
||
const returnData = {
|
||
...existingUser.toJSON(),
|
||
maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
|
||
isVip: existingUser.isVip,
|
||
dailyStepsGoal: profile?.dailyStepsGoal,
|
||
dailyCaloriesGoal: profile?.dailyCaloriesGoal,
|
||
pilatesPurposes: profile?.pilatesPurposes,
|
||
weight: profile?.weight,
|
||
initialWeight: profile?.initialWeight,
|
||
targetWeight: profile?.targetWeight,
|
||
height: profile?.height,
|
||
activityLevel: profile?.activityLevel,
|
||
}
|
||
|
||
this.logger.log(`getProfile returnData: ${JSON.stringify(returnData, null, 2)}`);
|
||
|
||
// 返回用户信息,包含购买状态
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: 'success',
|
||
data: returnData,
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(`getProfile error: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
if (error instanceof ConflictException) {
|
||
throw error;
|
||
}
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: `获取用户信息失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||
data: null as any,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @desc 获取用户剩余的聊天次数
|
||
*/
|
||
async getUserUsageCount(userId: string): Promise<number> {
|
||
try {
|
||
const user = await this.userModel.findOne({ where: { id: userId } });
|
||
|
||
if (!user) {
|
||
this.logger.log(`getUserUsageCount: ${userId} not found, return 0`);
|
||
return 0
|
||
}
|
||
|
||
if (user.isVip) {
|
||
// 会员用户无限次
|
||
this.logger.log(`getUserUsageCount: ${userId} is vip, return 999`);
|
||
return 999
|
||
}
|
||
|
||
this.logger.log(`getUserUsageCount: ${userId} freeUsageCount: ${user.freeUsageCount}`);
|
||
|
||
return user.freeUsageCount || 0;
|
||
} catch (error) {
|
||
this.logger.error(`getUserUsageCount error: ${error instanceof Error ? error.message : String(error)}`);
|
||
return 0
|
||
}
|
||
}
|
||
|
||
// 扣减用户免费次数
|
||
async deductUserUsageCount(userId: string, count: number = 1): Promise<void> {
|
||
try {
|
||
this.logger.log(`deductUserUsageCount: ${userId} deduct ${count} times`);
|
||
const user = await this.userModel.findOne({ where: { id: userId } });
|
||
if (!user) {
|
||
throw new NotFoundException(`ID为${userId}的用户不存在`);
|
||
}
|
||
user.freeUsageCount -= count;
|
||
await user.save();
|
||
} catch (error) {
|
||
this.logger.error(`deductUserUsageCount error: ${error instanceof Error ? error.message : String(error)}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
|
||
// 更新用户昵称、头像
|
||
async updateUser(updateUserDto: UpdateUserDto, userId: string): Promise<UpdateUserResponseDto> {
|
||
const { name, avatar, gender, birthDate, dailyStepsGoal, dailyCaloriesGoal, pilatesPurposes, weight, initialWeight, targetWeight, height, activityLevel } = updateUserDto;
|
||
|
||
this.logger.log(`updateUser: ${JSON.stringify(updateUserDto, null, 2)}`);
|
||
|
||
const user = await this.userModel.findOne({
|
||
where: { id: userId },
|
||
});
|
||
|
||
if (!user) {
|
||
throw new NotFoundException(`ID为${userId}的用户不存在`);
|
||
}
|
||
|
||
const profileChanges: Record<string, any> = {};
|
||
const userChanges: Record<string, any> = {};
|
||
if (name) {
|
||
user.name = name;
|
||
userChanges.name = name;
|
||
}
|
||
if (avatar) {
|
||
user.avatar = avatar;
|
||
userChanges.avatar = avatar;
|
||
}
|
||
if (gender) {
|
||
user.gender = gender;
|
||
userChanges.gender = gender;
|
||
}
|
||
if (birthDate) {
|
||
user.birthDate = dayjs(birthDate * 1000).startOf('day').toDate();
|
||
userChanges.birthDate = birthDate;
|
||
}
|
||
|
||
this.logger.log(`updateUser user: ${JSON.stringify(user, null, 2)}`);
|
||
|
||
await user.save();
|
||
|
||
const [profile] = await this.userProfileModel.findOrCreate({
|
||
where: { userId },
|
||
defaults: { userId },
|
||
});
|
||
|
||
// 更新或创建扩展信息
|
||
if (dailyStepsGoal !== undefined || dailyCaloriesGoal !== undefined || pilatesPurposes !== undefined || weight !== undefined || initialWeight !== undefined || targetWeight !== undefined || height !== undefined || activityLevel !== undefined) {
|
||
if (dailyStepsGoal !== undefined) { profile.dailyStepsGoal = dailyStepsGoal as any; profileChanges.dailyStepsGoal = dailyStepsGoal; }
|
||
if (dailyCaloriesGoal !== undefined) { profile.dailyCaloriesGoal = dailyCaloriesGoal as any; profileChanges.dailyCaloriesGoal = dailyCaloriesGoal; }
|
||
if (pilatesPurposes !== undefined) { profile.pilatesPurposes = pilatesPurposes as any; profileChanges.pilatesPurposes = pilatesPurposes; }
|
||
if (weight !== undefined) {
|
||
profile.weight = weight;
|
||
try {
|
||
await this.userWeightHistoryModel.create({ userId, weight, source: WeightUpdateSource.Manual });
|
||
} catch (e) {
|
||
this.logger.error(`记录体重历史失败: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
profileChanges.weight = weight;
|
||
}
|
||
if (initialWeight !== undefined) {
|
||
profile.initialWeight = initialWeight;
|
||
profileChanges.initialWeight = initialWeight;
|
||
}
|
||
if (targetWeight !== undefined) {
|
||
profile.targetWeight = targetWeight;
|
||
profileChanges.targetWeight = targetWeight;
|
||
}
|
||
if (height !== undefined) {
|
||
profile.height = height;
|
||
profileChanges.height = height;
|
||
}
|
||
if (activityLevel !== undefined) {
|
||
profile.activityLevel = activityLevel;
|
||
profileChanges.activityLevel = activityLevel;
|
||
}
|
||
await profile.save();
|
||
}
|
||
|
||
// 记录用户基础与扩展信息更新
|
||
if (Object.keys(userChanges).length > 0) {
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.USER,
|
||
action: ActivityActionType.UPDATE,
|
||
entityId: userId,
|
||
changes: userChanges,
|
||
});
|
||
}
|
||
if (Object.keys(profileChanges).length > 0) {
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.USER_PROFILE,
|
||
action: ActivityActionType.UPDATE,
|
||
entityId: userId,
|
||
changes: profileChanges,
|
||
});
|
||
}
|
||
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: 'success',
|
||
data: {
|
||
...user.toJSON(),
|
||
...profile.toJSON(),
|
||
isNew: false,
|
||
} as any,
|
||
};
|
||
}
|
||
|
||
async addWeightByVision(userId: string, weight: number): Promise<void> {
|
||
const t = await this.sequelize.transaction();
|
||
try {
|
||
const [profile] = await this.userProfileModel.findOrCreate({ where: { userId }, defaults: { userId }, transaction: t });
|
||
profile.weight = weight;
|
||
await profile.save({ transaction: t });
|
||
await this.userWeightHistoryModel.create({ userId, weight, source: WeightUpdateSource.Vision }, { transaction: t });
|
||
await t.commit();
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.USER_PROFILE,
|
||
action: ActivityActionType.UPDATE,
|
||
entityId: userId,
|
||
changes: { weight },
|
||
metadata: { source: 'vision' },
|
||
});
|
||
} catch (e) {
|
||
await t.rollback();
|
||
this.logger.error(`addWeightByVision error: ${e instanceof Error ? e.message : String(e)}`);
|
||
}
|
||
}
|
||
|
||
async getWeightHistory(userId: string, params: { start?: Date; end?: Date; limit?: number } = {}) {
|
||
const where: any = { userId };
|
||
if (params.start || params.end) {
|
||
where.createdAt = {} as any;
|
||
if (params.start) where.createdAt.$gte = params.start as any;
|
||
if (params.end) where.createdAt.$lte = params.end as any;
|
||
}
|
||
const limit = params.limit && params.limit > 0 ? Math.min(1000, params.limit) : 200;
|
||
const rows = await this.userWeightHistoryModel.findAll({
|
||
where,
|
||
order: [['created_at', 'DESC']],
|
||
limit,
|
||
});
|
||
return rows.map(r => ({
|
||
id: r.id,
|
||
weight: r.weight,
|
||
source: r.source,
|
||
createdAt: r.createdAt
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* 更新体重记录
|
||
*/
|
||
async updateWeightRecord(userId: string, recordId: number, updateData: { weight: number; source?: WeightUpdateSource }) {
|
||
const t = await this.sequelize.transaction();
|
||
try {
|
||
// 查找并验证体重记录是否存在且属于当前用户
|
||
const weightRecord = await this.userWeightHistoryModel.findOne({
|
||
where: { id: recordId, userId },
|
||
transaction: t,
|
||
});
|
||
|
||
if (!weightRecord) {
|
||
throw new NotFoundException('体重记录不存在');
|
||
}
|
||
|
||
const oldWeight = weightRecord.weight;
|
||
const oldSource = weightRecord.source;
|
||
|
||
// 更新体重记录
|
||
await weightRecord.update({
|
||
weight: updateData.weight,
|
||
source: updateData.source || weightRecord.source,
|
||
}, { transaction: t });
|
||
|
||
// 如果这是最新的体重记录,同时更新用户档案中的体重
|
||
const latestRecord = await this.userWeightHistoryModel.findOne({
|
||
where: { userId },
|
||
order: [['created_at', 'DESC']],
|
||
transaction: t,
|
||
});
|
||
|
||
if (latestRecord && latestRecord.id === recordId) {
|
||
const profile = await this.userProfileModel.findOne({
|
||
where: { userId },
|
||
transaction: t,
|
||
});
|
||
if (profile) {
|
||
profile.weight = updateData.weight;
|
||
await profile.save({ transaction: t });
|
||
}
|
||
}
|
||
|
||
await t.commit();
|
||
|
||
// 记录活动日志
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.USER_PROFILE,
|
||
action: ActivityActionType.UPDATE,
|
||
entityId: recordId.toString(),
|
||
changes: {
|
||
weight: { from: oldWeight, to: updateData.weight },
|
||
...(updateData.source && updateData.source !== oldSource ? { source: { from: oldSource, to: updateData.source } } : {}),
|
||
},
|
||
});
|
||
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: 'success',
|
||
data: {
|
||
id: weightRecord.id,
|
||
userId: weightRecord.userId,
|
||
weight: weightRecord.weight,
|
||
source: weightRecord.source,
|
||
createdAt: weightRecord.createdAt,
|
||
updatedAt: weightRecord.updatedAt,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
await t.rollback();
|
||
this.logger.error(`更新体重记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除体重记录
|
||
*/
|
||
async deleteWeightRecord(userId: string, recordId: number): Promise<boolean> {
|
||
const t = await this.sequelize.transaction();
|
||
try {
|
||
// 查找并验证体重记录是否存在且属于当前用户
|
||
const weightRecord = await this.userWeightHistoryModel.findOne({
|
||
where: { id: recordId, userId },
|
||
transaction: t,
|
||
});
|
||
|
||
if (!weightRecord) {
|
||
return false;
|
||
}
|
||
|
||
const recordData = {
|
||
id: weightRecord.id,
|
||
weight: weightRecord.weight,
|
||
source: weightRecord.source,
|
||
createdAt: weightRecord.createdAt,
|
||
};
|
||
|
||
// 删除体重记录
|
||
await weightRecord.destroy({ transaction: t });
|
||
|
||
// 如果删除的是最新记录,需要更新用户档案中的体重为倒数第二新的记录
|
||
const latestRecord = await this.userWeightHistoryModel.findOne({
|
||
where: { userId },
|
||
order: [['created_at', 'DESC']],
|
||
transaction: t,
|
||
});
|
||
|
||
const profile = await this.userProfileModel.findOne({
|
||
where: { userId },
|
||
transaction: t,
|
||
});
|
||
|
||
if (profile) {
|
||
if (latestRecord) {
|
||
// 有其他体重记录,更新为最新的体重
|
||
profile.weight = latestRecord.weight;
|
||
} else {
|
||
// 没有其他体重记录,清空体重字段
|
||
profile.weight = null;
|
||
}
|
||
await profile.save({ transaction: t });
|
||
}
|
||
|
||
await t.commit();
|
||
|
||
// 记录活动日志
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.USER_PROFILE,
|
||
action: ActivityActionType.DELETE,
|
||
entityId: recordId.toString(),
|
||
changes: recordData,
|
||
});
|
||
|
||
return true;
|
||
} catch (error) {
|
||
await t.rollback();
|
||
this.logger.error(`删除体重记录失败: ${error instanceof Error ? error.message : String(error)}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 添加饮食记录
|
||
*/
|
||
async addDietRecord(userId: string, createDto: CreateDietRecordDto): Promise<DietRecordResponseDto> {
|
||
const t = await this.sequelize.transaction();
|
||
try {
|
||
const dietRecord = await this.userDietHistoryModel.create({
|
||
userId,
|
||
mealType: createDto.mealType,
|
||
foodName: createDto.foodName,
|
||
foodDescription: createDto.foodDescription || null,
|
||
weightGrams: createDto.weightGrams || null,
|
||
portionDescription: createDto.portionDescription || null,
|
||
estimatedCalories: createDto.estimatedCalories || null,
|
||
proteinGrams: createDto.proteinGrams || null,
|
||
carbohydrateGrams: createDto.carbohydrateGrams || null,
|
||
fatGrams: createDto.fatGrams || null,
|
||
fiberGrams: createDto.fiberGrams || null,
|
||
sugarGrams: createDto.sugarGrams || null,
|
||
sodiumMg: createDto.sodiumMg || null,
|
||
additionalNutrition: createDto.additionalNutrition || null,
|
||
source: createDto.source || DietRecordSource.Manual,
|
||
mealTime: createDto.mealTime ? new Date(createDto.mealTime) : null,
|
||
imageUrl: createDto.imageUrl || null,
|
||
aiAnalysisResult: createDto.aiAnalysisResult || null,
|
||
notes: createDto.notes || null,
|
||
deleted: false,
|
||
}, { transaction: t });
|
||
|
||
await t.commit();
|
||
|
||
// 记录活动日志
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.USER_PROFILE,
|
||
entityId: userId,
|
||
action: ActivityActionType.UPDATE,
|
||
changes: { diet_record_added: dietRecord.id },
|
||
metadata: {
|
||
source: createDto.source || 'manual',
|
||
mealType: createDto.mealType,
|
||
foodName: createDto.foodName
|
||
},
|
||
});
|
||
|
||
return this.mapDietRecordToDto(dietRecord);
|
||
} catch (e) {
|
||
await t.rollback();
|
||
this.logger.error(`addDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通过视觉识别添加饮食记录
|
||
*/
|
||
async addDietRecordByVision(userId: string, dietData: CreateDietRecordDto): Promise<DietRecordResponseDto> {
|
||
return this.addDietRecord(userId, {
|
||
...dietData,
|
||
source: DietRecordSource.Vision
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取饮食记录历史
|
||
*/
|
||
async getDietHistory(userId: string, query: GetDietHistoryQueryDto): Promise<DietHistoryResponseDto> {
|
||
const where: any = { userId, deleted: false };
|
||
|
||
// 日期过滤
|
||
if (query.startDate || query.endDate) {
|
||
where.createdAt = {} as any;
|
||
if (query.startDate) where.createdAt[Op.gte] = new Date(query.startDate);
|
||
if (query.endDate) where.createdAt[Op.lte] = new Date(query.endDate);
|
||
}
|
||
|
||
// 餐次类型过滤
|
||
if (query.mealType) {
|
||
where.mealType = query.mealType;
|
||
}
|
||
|
||
const limit = Math.min(100, Math.max(1, query.limit || 20));
|
||
const page = Math.max(1, query.page || 1);
|
||
const offset = (page - 1) * limit;
|
||
|
||
const { rows, count } = await this.userDietHistoryModel.findAndCountAll({
|
||
where,
|
||
order: [['created_at', 'DESC']],
|
||
limit,
|
||
offset,
|
||
});
|
||
|
||
const totalPages = Math.ceil(count / limit);
|
||
|
||
return {
|
||
records: rows.map(record => this.mapDietRecordToDto(record)),
|
||
total: count,
|
||
page,
|
||
limit,
|
||
totalPages,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 更新饮食记录
|
||
*/
|
||
async updateDietRecord(userId: string, recordId: number, updateDto: UpdateDietRecordDto): Promise<DietRecordResponseDto> {
|
||
const t = await this.sequelize.transaction();
|
||
try {
|
||
const record = await this.userDietHistoryModel.findOne({
|
||
where: { id: recordId, userId, deleted: false },
|
||
transaction: t,
|
||
});
|
||
|
||
if (!record) {
|
||
throw new NotFoundException('饮食记录不存在');
|
||
}
|
||
|
||
// 更新字段
|
||
if (updateDto.mealType !== undefined) record.mealType = updateDto.mealType;
|
||
if (updateDto.foodName !== undefined) record.foodName = updateDto.foodName;
|
||
if (updateDto.foodDescription !== undefined) record.foodDescription = updateDto.foodDescription;
|
||
if (updateDto.weightGrams !== undefined) record.weightGrams = updateDto.weightGrams;
|
||
if (updateDto.portionDescription !== undefined) record.portionDescription = updateDto.portionDescription;
|
||
if (updateDto.estimatedCalories !== undefined) record.estimatedCalories = updateDto.estimatedCalories;
|
||
if (updateDto.proteinGrams !== undefined) record.proteinGrams = updateDto.proteinGrams;
|
||
if (updateDto.carbohydrateGrams !== undefined) record.carbohydrateGrams = updateDto.carbohydrateGrams;
|
||
if (updateDto.fatGrams !== undefined) record.fatGrams = updateDto.fatGrams;
|
||
if (updateDto.fiberGrams !== undefined) record.fiberGrams = updateDto.fiberGrams;
|
||
if (updateDto.sugarGrams !== undefined) record.sugarGrams = updateDto.sugarGrams;
|
||
if (updateDto.sodiumMg !== undefined) record.sodiumMg = updateDto.sodiumMg;
|
||
if (updateDto.additionalNutrition !== undefined) record.additionalNutrition = updateDto.additionalNutrition;
|
||
if (updateDto.mealTime !== undefined) record.mealTime = updateDto.mealTime ? new Date(updateDto.mealTime) : null;
|
||
if (updateDto.imageUrl !== undefined) record.imageUrl = updateDto.imageUrl;
|
||
if (updateDto.aiAnalysisResult !== undefined) record.aiAnalysisResult = updateDto.aiAnalysisResult;
|
||
if (updateDto.notes !== undefined) record.notes = updateDto.notes;
|
||
|
||
await record.save({ transaction: t });
|
||
await t.commit();
|
||
|
||
// 记录活动日志
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.USER_PROFILE,
|
||
entityId: userId,
|
||
action: ActivityActionType.UPDATE,
|
||
changes: { diet_record_updated: recordId },
|
||
metadata: { updateDto },
|
||
});
|
||
|
||
return this.mapDietRecordToDto(record);
|
||
} catch (e) {
|
||
await t.rollback();
|
||
this.logger.error(`updateDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除饮食记录
|
||
*/
|
||
async deleteDietRecord(userId: string, recordId: number): Promise<boolean> {
|
||
const t = await this.sequelize.transaction();
|
||
try {
|
||
const record = await this.userDietHistoryModel.findOne({
|
||
where: { id: recordId, userId, deleted: false },
|
||
transaction: t,
|
||
});
|
||
|
||
if (!record) {
|
||
return false;
|
||
}
|
||
|
||
record.deleted = true;
|
||
await record.save({ transaction: t });
|
||
await t.commit();
|
||
|
||
// 记录活动日志
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.USER_PROFILE,
|
||
entityId: userId,
|
||
action: ActivityActionType.DELETE,
|
||
changes: { diet_record_deleted: recordId },
|
||
metadata: { foodName: record.foodName, mealType: record.mealType },
|
||
});
|
||
|
||
return true;
|
||
} catch (e) {
|
||
await t.rollback();
|
||
this.logger.error(`deleteDietRecord error: ${e instanceof Error ? e.message : String(e)}`);
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取最近N顿饮食的营养汇总
|
||
*/
|
||
async getRecentNutritionSummary(userId: string, mealCount: number = 10): Promise<NutritionSummaryDto> {
|
||
const records = await this.userDietHistoryModel.findAll({
|
||
where: { userId, deleted: false },
|
||
order: [['created_at', 'DESC']],
|
||
limit: mealCount,
|
||
});
|
||
|
||
if (records.length === 0) {
|
||
throw new NotFoundException('暂无饮食记录');
|
||
}
|
||
|
||
const summary = records.reduce((acc, record) => {
|
||
acc.totalCalories += record.estimatedCalories || 0;
|
||
acc.totalProtein += record.proteinGrams || 0;
|
||
acc.totalCarbohydrates += record.carbohydrateGrams || 0;
|
||
acc.totalFat += record.fatGrams || 0;
|
||
acc.totalFiber += record.fiberGrams || 0;
|
||
acc.totalSugar += record.sugarGrams || 0;
|
||
acc.totalSodium += record.sodiumMg || 0;
|
||
return acc;
|
||
}, {
|
||
totalCalories: 0,
|
||
totalProtein: 0,
|
||
totalCarbohydrates: 0,
|
||
totalFat: 0,
|
||
totalFiber: 0,
|
||
totalSugar: 0,
|
||
totalSodium: 0,
|
||
});
|
||
|
||
const oldestRecord = records[records.length - 1];
|
||
const newestRecord = records[0];
|
||
|
||
return {
|
||
...summary,
|
||
recordCount: records.length,
|
||
dateRange: {
|
||
start: oldestRecord.createdAt,
|
||
end: newestRecord.createdAt,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 将数据库模型转换为DTO
|
||
*/
|
||
private mapDietRecordToDto(record: UserDietHistory): DietRecordResponseDto {
|
||
return {
|
||
id: record.id,
|
||
mealType: record.mealType,
|
||
foodName: record.foodName,
|
||
foodDescription: record.foodDescription || undefined,
|
||
weightGrams: record.weightGrams || undefined,
|
||
portionDescription: record.portionDescription || undefined,
|
||
estimatedCalories: record.estimatedCalories || undefined,
|
||
proteinGrams: record.proteinGrams || undefined,
|
||
carbohydrateGrams: record.carbohydrateGrams || undefined,
|
||
fatGrams: record.fatGrams || undefined,
|
||
fiberGrams: record.fiberGrams || undefined,
|
||
sugarGrams: record.sugarGrams || undefined,
|
||
sodiumMg: record.sodiumMg || undefined,
|
||
additionalNutrition: record.additionalNutrition || undefined,
|
||
source: record.source,
|
||
mealTime: record.mealTime || undefined,
|
||
imageUrl: record.imageUrl || undefined,
|
||
notes: record.notes || undefined,
|
||
createdAt: record.createdAt,
|
||
updatedAt: record.updatedAt,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Apple 登录
|
||
*/
|
||
async appleLogin(appleLoginDto: AppleLoginDto): Promise<AppleLoginResponseDto> {
|
||
try {
|
||
this.logger.log(`appleLogin: ${JSON.stringify(appleLoginDto)}`);
|
||
|
||
// 验证 Apple Identity Token
|
||
const applePayload: AppleTokenPayload = await this.appleAuthService.verifyAppleToken(appleLoginDto.identityToken);
|
||
|
||
// 构造用户ID(使用 Apple 的 sub 作为用户ID,添加前缀以区分)
|
||
const userId = applePayload.sub
|
||
|
||
this.logger.log(`appleLogin userId: ${userId}`);
|
||
|
||
// 查找或创建用户
|
||
let user = await this.userModel.findOne({
|
||
where: { id: userId },
|
||
});
|
||
|
||
let isNewUser = false;
|
||
|
||
if (!user) {
|
||
// 创建新用户
|
||
const userName = appleLoginDto.name || applePayload.email?.split('@')[0] || '用户';
|
||
const userEmail = appleLoginDto.email || applePayload.email || '';
|
||
|
||
if (!userEmail) {
|
||
throw new BadRequestException('无法获取用户邮箱信息');
|
||
}
|
||
|
||
user = await this.userModel.create({
|
||
id: userId,
|
||
name: userName,
|
||
mail: userEmail,
|
||
gender: Gender.MALE,
|
||
freeUsageCount: DEFAULT_FREE_USAGE_COUNT,
|
||
lastLogin: new Date(),
|
||
avatar: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/seal-avatar/1.jpeg'
|
||
});
|
||
|
||
isNewUser = true;
|
||
this.logger.log(`创建新的Apple用户: ${userId}`);
|
||
|
||
// 创建默认扩展记录
|
||
await this.userProfileModel.findOrCreate({ where: { userId }, defaults: { userId } });
|
||
|
||
} else {
|
||
// 更新现有用户的登录时间
|
||
user.lastLogin = new Date();
|
||
await user.save();
|
||
this.logger.log(`Apple用户登录: ${userId}`);
|
||
}
|
||
|
||
// 生成访问令牌和刷新令牌
|
||
const accessToken = this.appleAuthService.generateAccessToken(userId, user.mail);
|
||
const refreshToken = this.appleAuthService.generateRefreshToken(userId);
|
||
|
||
// 构造用户数据
|
||
const profileForLogin = await this.userProfileModel.findByPk(user.id);
|
||
const userData = {
|
||
...user.toJSON(),
|
||
isNew: isNewUser,
|
||
isVip: user.isVip,
|
||
maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
|
||
profile: profileForLogin ? {
|
||
dailyStepsGoal: profileForLogin.dailyStepsGoal,
|
||
dailyCaloriesGoal: profileForLogin.dailyCaloriesGoal,
|
||
pilatesPurposes: profileForLogin.pilatesPurposes,
|
||
weight: profileForLogin.weight,
|
||
initialWeight: profileForLogin.initialWeight,
|
||
targetWeight: profileForLogin.targetWeight,
|
||
height: profileForLogin.height,
|
||
activityLevel: profileForLogin.activityLevel,
|
||
} : undefined,
|
||
};
|
||
|
||
// 登录行为也可视为活动(可选,记录创建或登录行为)
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.USER,
|
||
action: ActivityActionType.UPDATE,
|
||
entityId: userId,
|
||
changes: { lastLogin: user.lastLogin },
|
||
metadata: { source: 'appleLogin' },
|
||
});
|
||
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: 'success',
|
||
data: {
|
||
accessToken,
|
||
refreshToken,
|
||
expiresIn: this.appleAuthService.getAccessTokenExpiresIn(),
|
||
user: userData,
|
||
},
|
||
};
|
||
|
||
} catch (error) {
|
||
this.logger.error(`Apple登录失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: `Apple登录失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||
data: null as any,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 刷新访问令牌
|
||
*/
|
||
async refreshToken(refreshTokenDto: RefreshTokenDto): Promise<RefreshTokenResponseDto> {
|
||
try {
|
||
this.logger.log(`refreshToken: ${JSON.stringify(refreshTokenDto)}`);
|
||
|
||
// 验证刷新令牌并获取用户信息
|
||
const payload = this.appleAuthService.verifyRefreshToken(refreshTokenDto.refreshToken);
|
||
|
||
// 查找用户以获取邮箱信息
|
||
const user = await this.userModel.findByPk(payload.sub);
|
||
if (!user) {
|
||
throw new BadRequestException('用户不存在');
|
||
}
|
||
|
||
// 生成新的访问令牌
|
||
const result = await this.appleAuthService.refreshAccessToken(refreshTokenDto.refreshToken, user.mail);
|
||
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: 'success',
|
||
data: result,
|
||
};
|
||
|
||
} catch (error) {
|
||
this.logger.error(`刷新令牌失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: `刷新令牌失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||
data: null as any,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 删除用户账号
|
||
*/
|
||
async deleteAccount(deleteAccountDto: DeleteAccountDto): Promise<DeleteAccountResponseDto> {
|
||
this.logger.log(`deleteAccount: ${JSON.stringify(deleteAccountDto)}`);
|
||
const transaction: Transaction = await this.sequelize.transaction();
|
||
try {
|
||
|
||
const { userId } = deleteAccountDto;
|
||
|
||
// 查找用户
|
||
const user = await this.userModel.findByPk(userId, { transaction });
|
||
if (!user) {
|
||
await transaction.rollback();
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: `ID为${userId}的用户不存在`,
|
||
data: { success: false },
|
||
};
|
||
}
|
||
|
||
// 开始删除用户相关数据(使用事务确保数据一致性)
|
||
// 1. 删除用户购买记录
|
||
await this.userPurchaseModel.destroy({
|
||
where: { userId },
|
||
transaction,
|
||
});
|
||
|
||
// 2. 删除用户扩展信息
|
||
await this.userProfileModel.destroy({
|
||
where: { userId },
|
||
transaction,
|
||
});
|
||
|
||
// 最后删除用户本身
|
||
await this.userModel.destroy({
|
||
where: { id: userId },
|
||
transaction,
|
||
});
|
||
|
||
|
||
// 提交事务
|
||
await transaction.commit();
|
||
|
||
this.logger.log(`用户账号删除成功: ${userId}`);
|
||
|
||
// 记录删除账户行为
|
||
await this.activityLogsService.record({
|
||
userId,
|
||
entityType: ActivityEntityType.USER,
|
||
action: ActivityActionType.DELETE,
|
||
entityId: userId,
|
||
changes: null,
|
||
metadata: { reason: 'deleteAccount' },
|
||
});
|
||
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: '账号删除成功',
|
||
data: { success: true },
|
||
};
|
||
|
||
} catch (error) {
|
||
// 回滚事务
|
||
await transaction.rollback();
|
||
|
||
this.logger.error(`删除账号失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: `删除账号失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||
data: { success: false },
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 游客登录
|
||
*/
|
||
async guestLogin(guestLoginDto: GuestLoginDto): Promise<GuestLoginResponseDto> {
|
||
try {
|
||
this.logger.log(`guestLogin: ${JSON.stringify(guestLoginDto)}`);
|
||
|
||
const { deviceId, deviceName, deviceModel, iosVersion } = guestLoginDto;
|
||
|
||
// 构造游客用户ID(使用设备ID加前缀)
|
||
const guestUserId = `guest_${deviceId}`;
|
||
|
||
// 查找是否已存在该游客用户
|
||
let user = await this.userModel.findOne({
|
||
where: { id: guestUserId },
|
||
});
|
||
|
||
let isNewUser = false;
|
||
|
||
if (!user) {
|
||
// 创建新的游客用户
|
||
user = await this.userModel.create({
|
||
id: guestUserId,
|
||
name: `游客_${deviceName}`,
|
||
gender: Gender.MALE, // 默认性别
|
||
isGuest: true,
|
||
deviceId,
|
||
deviceName,
|
||
deviceModel,
|
||
iosVersion,
|
||
freeUsageCount: DEFAULT_FREE_USAGE_COUNT,
|
||
lastLogin: new Date(),
|
||
mail: `guest_${deviceId}@gmail.com`,
|
||
});
|
||
|
||
isNewUser = true;
|
||
this.logger.log(`创建新的游客用户: ${guestUserId}`);
|
||
|
||
await this.userProfileModel.findOrCreate({ where: { userId: guestUserId }, defaults: { userId: guestUserId } });
|
||
|
||
} else {
|
||
// 更新现有游客用户的登录时间和设备信息
|
||
user.lastLogin = new Date();
|
||
user.deviceName = deviceName;
|
||
|
||
await user.save();
|
||
this.logger.log(`游客用户登录: ${guestUserId}`);
|
||
}
|
||
|
||
// 生成访问令牌和刷新令牌
|
||
const accessToken = this.appleAuthService.generateAccessToken(guestUserId, user.mail);
|
||
const refreshToken = this.appleAuthService.generateRefreshToken(guestUserId);
|
||
|
||
// 构造用户数据
|
||
const profileForGuest = await this.userProfileModel.findByPk(user.id);
|
||
const userData = {
|
||
...user.toJSON(),
|
||
isNew: isNewUser,
|
||
isVip: user.membershipExpiration ? dayjs(user.membershipExpiration).isAfter(dayjs()) : false,
|
||
isGuest: true,
|
||
maxUsageCount: DEFAULT_FREE_USAGE_COUNT,
|
||
profile: profileForGuest ? {
|
||
dailyStepsGoal: profileForGuest.dailyStepsGoal,
|
||
dailyCaloriesGoal: profileForGuest.dailyCaloriesGoal,
|
||
pilatesPurposes: profileForGuest.pilatesPurposes,
|
||
weight: profileForGuest.weight,
|
||
initialWeight: profileForGuest.initialWeight,
|
||
targetWeight: profileForGuest.targetWeight,
|
||
height: profileForGuest.height,
|
||
activityLevel: profileForGuest.activityLevel,
|
||
} : undefined,
|
||
};
|
||
|
||
await this.activityLogsService.record({
|
||
userId: guestUserId,
|
||
entityType: ActivityEntityType.USER,
|
||
action: ActivityActionType.UPDATE,
|
||
entityId: guestUserId,
|
||
changes: { lastLogin: user.lastLogin },
|
||
metadata: { source: 'guestLogin' },
|
||
});
|
||
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: 'success',
|
||
data: {
|
||
accessToken,
|
||
refreshToken,
|
||
expiresIn: this.appleAuthService.getAccessTokenExpiresIn(),
|
||
user: userData,
|
||
},
|
||
};
|
||
|
||
} catch (error) {
|
||
this.logger.error(`游客登录失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: `游客登录失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||
data: null as any,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 刷新游客令牌
|
||
*/
|
||
async refreshGuestToken(refreshGuestTokenDto: RefreshGuestTokenDto): Promise<RefreshGuestTokenResponseDto> {
|
||
try {
|
||
this.logger.log(`refreshGuestToken: ${JSON.stringify(refreshGuestTokenDto)}`);
|
||
|
||
// 验证刷新令牌并获取用户信息
|
||
const payload = this.appleAuthService.verifyRefreshToken(refreshGuestTokenDto.refreshToken);
|
||
|
||
// 查找用户以获取邮箱信息
|
||
const user = await this.userModel.findByPk(payload.sub);
|
||
if (!user) {
|
||
throw new BadRequestException('用户不存在');
|
||
}
|
||
|
||
// 验证是否为游客用户
|
||
if (!user.isGuest) {
|
||
throw new BadRequestException('非游客用户不能使用此接口');
|
||
}
|
||
|
||
// 生成新的访问令牌
|
||
const result = await this.appleAuthService.refreshAccessToken(refreshGuestTokenDto.refreshToken, user.mail);
|
||
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: 'success',
|
||
data: result,
|
||
};
|
||
|
||
} catch (error) {
|
||
this.logger.error(`刷新游客令牌失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: `刷新游客令牌失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||
data: null as any,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理App Store服务器通知
|
||
*/
|
||
async processAppStoreNotification(notificationDto: AppStoreServerNotificationDto): Promise<ProcessNotificationResponseDto> {
|
||
try {
|
||
this.logger.log(`processAppStoreNotification start: ${JSON.stringify(notificationDto)}`);
|
||
|
||
// 使用ApplePurchaseService处理通知
|
||
const { notificationPayload, transactionInfo, renewalInfo } = await this.applePurchaseService.processServerNotification(
|
||
notificationDto.signedPayload
|
||
);
|
||
|
||
if (!notificationPayload) {
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: '无法解析通知负载',
|
||
data: { processed: false },
|
||
};
|
||
}
|
||
|
||
// 提取基本信息
|
||
const notificationType = notificationPayload.notificationType as NotificationType;
|
||
const notificationUUID = notificationPayload.notificationUUID;
|
||
const bundleId = notificationPayload.data?.bundleId;
|
||
|
||
this.logger.log(`processAppStoreNotification 通知类型: ${notificationType}, UUID: ${notificationUUID}, Bundle ID: ${bundleId}`);
|
||
this.logger.log(`processAppStoreNotification transactionInfo: ${JSON.stringify(transactionInfo)}`);
|
||
this.logger.log(`processAppStoreNotification renewalInfo: ${JSON.stringify(renewalInfo)}`);
|
||
|
||
// 根据通知类型进行具体处理
|
||
const handlerResult = await this.applePurchaseService.handleNotificationByType(
|
||
notificationType,
|
||
transactionInfo,
|
||
renewalInfo
|
||
);
|
||
|
||
// 如果有交易信息,尝试找到对应的用户
|
||
let userId: string | undefined;
|
||
let transactionId: string | undefined;
|
||
|
||
if (transactionInfo) {
|
||
transactionId = transactionInfo.transactionId || transactionInfo.originalTransactionId;
|
||
|
||
// 通过appAccountToken查找用户(如果有的话)
|
||
if (transactionInfo.appAccountToken) {
|
||
const user = await this.userModel.findOne({
|
||
where: { id: transactionInfo.appAccountToken },
|
||
});
|
||
if (user) {
|
||
userId = user.id;
|
||
}
|
||
}
|
||
}
|
||
|
||
this.logger.log(`processAppStoreNotification userId: ${userId}`);
|
||
|
||
if (!userId) {
|
||
this.logger.error(`未找到用户ID,通知处理失败`);
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: '未找到用户ID,通知处理失败',
|
||
data: {
|
||
processed: false,
|
||
},
|
||
};
|
||
}
|
||
|
||
// 根据通知类型执行相应的业务逻辑
|
||
await this.handleNotificationBusinessLogic(notificationType, transactionInfo, renewalInfo, userId);
|
||
|
||
this.logger.log(`processAppStoreNotification App Store通知处理完成: ${JSON.stringify(handlerResult)}`);
|
||
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: '通知处理成功',
|
||
data: {
|
||
processed: true,
|
||
notificationType,
|
||
notificationUUID,
|
||
userId,
|
||
transactionId,
|
||
},
|
||
};
|
||
|
||
} catch (error) {
|
||
this.logger.error(`processAppStoreNotification 处理App Store服务器通知失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: `处理通知失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||
data: { processed: false },
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据通知类型执行具体的业务逻辑
|
||
*/
|
||
private async handleNotificationBusinessLogic(
|
||
notificationType: NotificationType,
|
||
transactionInfo: any,
|
||
renewalInfo: any,
|
||
userId?: string
|
||
): Promise<void> {
|
||
this.logger.log(`执行业务逻辑 - 通知类型: ${notificationType}, 用户ID: ${userId}`);
|
||
|
||
try {
|
||
switch (notificationType) {
|
||
case NotificationType.SUBSCRIBED:
|
||
await this.handleSubscribedNotification(transactionInfo, renewalInfo, userId);
|
||
break;
|
||
|
||
case NotificationType.DID_RENEW:
|
||
await this.handleRenewalNotification(transactionInfo, renewalInfo, userId);
|
||
break;
|
||
|
||
case NotificationType.EXPIRED:
|
||
await this.handleExpirationNotification(transactionInfo, renewalInfo, userId);
|
||
break;
|
||
|
||
case NotificationType.DID_FAIL_TO_RENEW:
|
||
await this.handleRenewalFailureNotification(transactionInfo, renewalInfo, userId);
|
||
break;
|
||
|
||
case NotificationType.REFUND:
|
||
await this.handleRefundNotification(transactionInfo, renewalInfo, userId);
|
||
break;
|
||
|
||
case NotificationType.REVOKE:
|
||
await this.handleRevokeNotification(transactionInfo, renewalInfo, userId);
|
||
break;
|
||
|
||
case NotificationType.DID_CHANGE_RENEWAL_STATUS:
|
||
await this.handleRenewalStatusChangeNotification(transactionInfo, renewalInfo, userId);
|
||
break;
|
||
|
||
case NotificationType.TEST:
|
||
this.logger.log('收到测试通知,无需特殊处理');
|
||
break;
|
||
|
||
default:
|
||
this.logger.warn(`未处理的通知类型: ${notificationType}`);
|
||
}
|
||
} catch (error) {
|
||
this.logger.error(`执行业务逻辑失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理订阅通知
|
||
*/
|
||
private async handleSubscribedNotification(transactionInfo: any, renewalInfo: any, userId?: string): Promise<void> {
|
||
this.logger.log(`handleSubscribedNotification 处理订阅通知 - 用户ID: ${userId}`);
|
||
|
||
if (!userId || !transactionInfo) {
|
||
this.logger.warn('handleSubscribedNotification 缺少用户ID或交易信息,跳过订阅处理');
|
||
return;
|
||
}
|
||
|
||
// 更新用户订阅状态
|
||
const user = await this.userModel.findByPk(userId);
|
||
if (user && transactionInfo.expiresDate) {
|
||
user.membershipExpiration = new Date(transactionInfo.expiresDate);
|
||
await user.save();
|
||
this.logger.log(`handleSubscribedNotification 用户 ${userId} 订阅状态已更新,到期时间: ${user.membershipExpiration}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理续订通知
|
||
*/
|
||
private async handleRenewalNotification(transactionInfo: any, renewalInfo: any, userId?: string): Promise<void> {
|
||
this.logger.log(`handleRenewalNotification 处理续订通知 - 用户ID: ${userId}`);
|
||
|
||
if (!userId || !transactionInfo) {
|
||
this.logger.warn('handleRenewalNotification 缺少用户ID或交易信息,跳过续订处理');
|
||
return;
|
||
}
|
||
|
||
// 更新用户订阅到期时间
|
||
const user = await this.userModel.findByPk(userId);
|
||
if (user && transactionInfo.expiresDate) {
|
||
user.membershipExpiration = new Date(transactionInfo.expiresDate);
|
||
await user.save();
|
||
this.logger.log(`handleRenewalNotification 用户 ${userId} 订阅已续订,新的到期时间: ${user.membershipExpiration}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理过期通知
|
||
*/
|
||
private async handleExpirationNotification(transactionInfo: any, renewalInfo: any, userId?: string): Promise<void> {
|
||
this.logger.log(`handleExpirationNotification 处理过期通知 - 用户ID: ${userId}`);
|
||
|
||
if (!userId) {
|
||
this.logger.warn('handleExpirationNotification 缺少用户ID,跳过过期处理');
|
||
return;
|
||
}
|
||
|
||
// 可以在这里实现过期后的处理逻辑
|
||
// 例如:发送邮件通知、降级用户权限等
|
||
this.logger.log(`用户 ${userId} 的订阅已过期`);
|
||
}
|
||
|
||
/**
|
||
* 处理续订失败通知
|
||
*/
|
||
private async handleRenewalFailureNotification(transactionInfo: any, renewalInfo: any, userId?: string): Promise<void> {
|
||
this.logger.log(`处理续订失败通知 - 用户ID: ${userId}`);
|
||
|
||
if (!userId) {
|
||
this.logger.warn('缺少用户ID,跳过续订失败处理');
|
||
return;
|
||
}
|
||
|
||
// 可以在这里实现续订失败的处理逻辑
|
||
// 例如:发送付款提醒邮件
|
||
this.logger.log(`用户 ${userId} 的订阅续订失败`);
|
||
}
|
||
|
||
/**
|
||
* 处理退款通知
|
||
*/
|
||
private async handleRefundNotification(transactionInfo: any, renewalInfo: any, userId?: string): Promise<void> {
|
||
this.logger.log(`处理退款通知 - 用户ID: ${userId}`);
|
||
|
||
if (!userId) {
|
||
this.logger.warn('缺少用户ID,跳过退款处理');
|
||
return;
|
||
}
|
||
|
||
// 处理退款逻辑:撤销用户的订阅权限
|
||
const user = await this.userModel.findByPk(userId);
|
||
if (user) {
|
||
// 将会员到期时间设置为当前时间,立即取消会员权限
|
||
user.membershipExpiration = new Date();
|
||
await user.save();
|
||
this.logger.log(`用户 ${userId} 的订阅因退款已被撤销`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理撤销通知
|
||
*/
|
||
private async handleRevokeNotification(transactionInfo: any, renewalInfo: any, userId?: string): Promise<void> {
|
||
this.logger.log(`处理撤销通知 - 用户ID: ${userId}`);
|
||
|
||
if (!userId) {
|
||
this.logger.warn('缺少用户ID,跳过撤销处理');
|
||
return;
|
||
}
|
||
|
||
// 处理撤销逻辑:撤销用户的订阅权限
|
||
const user = await this.userModel.findByPk(userId);
|
||
if (user) {
|
||
user.membershipExpiration = new Date();
|
||
await user.save();
|
||
this.logger.log(`用户 ${userId} 的订阅已被撤销`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理续订状态变更通知
|
||
*/
|
||
private async handleRenewalStatusChangeNotification(transactionInfo: any, renewalInfo: any, userId?: string): Promise<void> {
|
||
this.logger.log(`处理续订状态变更通知 - 用户ID: ${userId}`);
|
||
|
||
if (!userId || !renewalInfo) {
|
||
this.logger.warn('缺少用户ID或续订信息,跳过续订状态变更处理');
|
||
return;
|
||
}
|
||
|
||
// 可以在这里实现续订状态变更的处理逻辑
|
||
// 例如:根据autoRenewStatus更新用户的自动续订设置
|
||
const autoRenewEnabled = renewalInfo.autoRenewStatus === 1;
|
||
this.logger.log(`用户 ${userId} 的自动续订状态: ${autoRenewEnabled ? '启用' : '禁用'}`);
|
||
}
|
||
|
||
async handleRevenueCatWebhook(webhook: RevenueCatWebhookDto): Promise<void> {
|
||
const { event } = webhook;
|
||
|
||
// 1. Check for duplicate event
|
||
const existingEvent = await this.revenueCatEventModel.findOne({
|
||
where: { eventId: event.id },
|
||
});
|
||
|
||
if (existingEvent) {
|
||
this.winstonLogger.warn('RevenueCat event already processed', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
reason: 'duplicate_event'
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.winstonLogger.info('RevenueCat event processing started', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
eventTimestamp: event.event_timestamp_ms
|
||
});
|
||
|
||
// 2. Save the event to the database
|
||
await this.revenueCatEventModel.create({
|
||
eventId: event.id,
|
||
type: event.type,
|
||
appUserId: event.app_user_id,
|
||
eventTimestamp: new Date(event.event_timestamp_ms),
|
||
payload: event,
|
||
});
|
||
|
||
// 3. Process the event based on its type
|
||
const user = await this.userModel.findByPk(event.app_user_id);
|
||
|
||
if (!user) {
|
||
this.winstonLogger.warn('User not found for RevenueCat event', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
reason: 'user_not_found'
|
||
});
|
||
// Even if user not found, we mark event as processed to avoid retries
|
||
return;
|
||
}
|
||
|
||
this.winstonLogger.info('User found for RevenueCat event', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
userId: user.id,
|
||
userEmail: user.mail,
|
||
currentMembershipExpiration: user.membershipExpiration
|
||
});
|
||
|
||
switch (event.type) {
|
||
case RevenueCatEventType.INITIAL_PURCHASE:
|
||
case RevenueCatEventType.RENEWAL:
|
||
case RevenueCatEventType.UNCANCELLATION:
|
||
case RevenueCatEventType.PRODUCT_CHANGE:
|
||
case RevenueCatEventType.NON_RENEWING_PURCHASE:
|
||
case RevenueCatEventType.TRANSFER:
|
||
if (user && typeof event.expiration_at_ms === 'number') {
|
||
const previousExpiration = user.membershipExpiration;
|
||
user.membershipExpiration = new Date(event.expiration_at_ms);
|
||
await user.save();
|
||
|
||
this.winstonLogger.info('Membership updated for purchase event', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
userId: user.id,
|
||
previousExpiration: previousExpiration,
|
||
newExpiration: user.membershipExpiration,
|
||
productId: event.product_id,
|
||
expirationAtMs: event.expiration_at_ms
|
||
});
|
||
}
|
||
break;
|
||
|
||
case RevenueCatEventType.CANCELLATION:
|
||
// For cancellations, you might want to record the cancellation reason
|
||
// but not immediately revoke access. Access is typically revoked on EXPIRATION.
|
||
this.winstonLogger.warn('Subscription cancelled', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
userId: user.id,
|
||
cancelReason: event.cancel_reason,
|
||
productId: event.product_id,
|
||
currentMembershipExpiration: user.membershipExpiration,
|
||
note: 'Access not immediately revoked - will be revoked on EXPIRATION event'
|
||
});
|
||
break;
|
||
|
||
case RevenueCatEventType.EXPIRATION:
|
||
// The subscription has truly expired.
|
||
// You could set membershipExpiration to now or a past date.
|
||
if (user) {
|
||
// Check if the current expiration date is in the future.
|
||
// If so, it means the user might have had a longer plan from a previous purchase
|
||
// and we should not shorten it.
|
||
const now = new Date();
|
||
const previousExpiration = user.membershipExpiration;
|
||
|
||
if (user.membershipExpiration && user.membershipExpiration > now) {
|
||
this.winstonLogger.info('Expiration event ignored - user has future expiration', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
userId: user.id,
|
||
currentExpiration: user.membershipExpiration,
|
||
eventExpirationMs: event.expiration_at_ms,
|
||
reason: 'current_expiration_in_future'
|
||
});
|
||
} else if (typeof event.expiration_at_ms === 'number') {
|
||
user.membershipExpiration = new Date(event.expiration_at_ms);
|
||
await user.save();
|
||
|
||
this.winstonLogger.warn('Membership expired', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
userId: user.id,
|
||
previousExpiration: previousExpiration,
|
||
newExpiration: user.membershipExpiration,
|
||
productId: event.product_id,
|
||
expirationAtMs: event.expiration_at_ms
|
||
});
|
||
}
|
||
}
|
||
break;
|
||
|
||
case RevenueCatEventType.TEST:
|
||
this.winstonLogger.info('RevenueCat test event received', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
note: 'Test event - no processing required'
|
||
});
|
||
break;
|
||
|
||
default:
|
||
this.winstonLogger.warn('Unhandled RevenueCat event type', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
reason: 'unknown_event_type',
|
||
eventData: event
|
||
});
|
||
}
|
||
|
||
// 4. Mark event as processed
|
||
const savedEvent = await this.revenueCatEventModel.findOne({ where: { eventId: event.id } });
|
||
if (savedEvent) {
|
||
savedEvent.processed = true;
|
||
savedEvent.processedAt = new Date();
|
||
await savedEvent.save();
|
||
|
||
this.winstonLogger.info('RevenueCat event processing completed', {
|
||
context: 'UsersService',
|
||
method: 'handleRevenueCatWebhook',
|
||
eventId: event.id,
|
||
eventType: event.type,
|
||
appUserId: event.app_user_id,
|
||
userId: user?.id,
|
||
processedAt: savedEvent.processedAt,
|
||
status: 'completed'
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 恢复购买 - 根据 RevenueCat 客户信息恢复用户的购买记录(安全版本)
|
||
*/
|
||
async restorePurchase(
|
||
restorePurchaseDto: RestorePurchaseDto,
|
||
currentUserId: string,
|
||
clientIp?: string,
|
||
userAgent?: string
|
||
): Promise<RestorePurchaseResponseDto> {
|
||
// 输入验证
|
||
if (!restorePurchaseDto?.customerInfo) {
|
||
return this.createErrorResponse('客户信息不能为空');
|
||
}
|
||
|
||
if (!currentUserId?.trim()) {
|
||
return this.createErrorResponse('用户ID不能为空');
|
||
}
|
||
|
||
const dbTransaction = await this.sequelize.transaction();
|
||
|
||
try {
|
||
this.winstonLogger.info('Restoring purchase', {
|
||
context: 'UsersService',
|
||
method: 'restorePurchase',
|
||
customerInfo: restorePurchaseDto.customerInfo
|
||
});
|
||
|
||
// 验证用户存在性
|
||
const user = await this.validateUserExists(currentUserId, dbTransaction);
|
||
if (!user) {
|
||
await dbTransaction.rollback();
|
||
return this.createErrorResponse('用户不存在');
|
||
}
|
||
|
||
const { customerInfo } = restorePurchaseDto;
|
||
|
||
this.winstonLogger.info('RevenueCat customer info', {
|
||
context: 'UsersService',
|
||
method: 'restorePurchase',
|
||
customerInfo: customerInfo
|
||
});
|
||
|
||
// 🔒 安全验证:检查RevenueCat原始用户ID关联性
|
||
const securityValidation = await this.validateRevenueCatUserAssociation(
|
||
customerInfo.originalAppUserId,
|
||
currentUserId,
|
||
dbTransaction
|
||
);
|
||
|
||
this.winstonLogger.info('RevenueCat security validation', {
|
||
context: 'UsersService',
|
||
method: 'restorePurchase',
|
||
customerInfo: customerInfo,
|
||
securityValidation: securityValidation
|
||
});
|
||
|
||
if (!securityValidation.isValid) {
|
||
await this.logRestoreAttempt(
|
||
currentUserId,
|
||
'',
|
||
'',
|
||
customerInfo.originalAppUserId,
|
||
RestoreStatus.FRAUD_DETECTED,
|
||
securityValidation.reason || '安全验证失败',
|
||
restorePurchaseDto,
|
||
clientIp,
|
||
userAgent,
|
||
dbTransaction
|
||
);
|
||
await dbTransaction.rollback();
|
||
return this.createErrorResponse('安全验证失败:用户身份验证不通过');
|
||
}
|
||
|
||
// 🔒 安全验证:检查被阻止的交易
|
||
const blockedCheck = await this.checkForBlockedTransactions(
|
||
customerInfo,
|
||
dbTransaction
|
||
);
|
||
|
||
this.winstonLogger.info('RevenueCat blocked check', {
|
||
context: 'UsersService',
|
||
method: 'restorePurchase',
|
||
customerInfo: customerInfo,
|
||
blockedCheck: blockedCheck
|
||
});
|
||
|
||
if (blockedCheck.hasBlocked) {
|
||
await this.logRestoreAttempt(
|
||
currentUserId,
|
||
'',
|
||
'',
|
||
customerInfo.originalAppUserId,
|
||
RestoreStatus.FRAUD_DETECTED,
|
||
`检测到被阻止的交易: ${blockedCheck.blockedTransactions.join(', ')}`,
|
||
restorePurchaseDto,
|
||
clientIp,
|
||
userAgent,
|
||
dbTransaction
|
||
);
|
||
await dbTransaction.rollback();
|
||
return this.createErrorResponse(`检测到被阻止的交易,恢复失败。涉及交易: ${blockedCheck.blockedTransactions.join(', ')}`);
|
||
}
|
||
|
||
this.winstonLogger.info('RevenueCat blocked check', {
|
||
context: 'UsersService',
|
||
method: 'restorePurchase',
|
||
customerInfo: customerInfo,
|
||
blockedCheck: blockedCheck
|
||
});
|
||
|
||
// 🔒 安全验证:检查交易ID全局唯一性
|
||
const duplicateCheck = await this.checkForDuplicateTransactions(
|
||
customerInfo,
|
||
currentUserId,
|
||
dbTransaction
|
||
);
|
||
|
||
if (duplicateCheck.hasDuplicates) {
|
||
// 自动阻止重复使用的交易
|
||
await this.autoBlockDuplicateTransactions(
|
||
duplicateCheck.duplicateTransactions,
|
||
'Automatic block due to duplicate usage across multiple accounts',
|
||
dbTransaction
|
||
);
|
||
|
||
await this.logRestoreAttempt(
|
||
currentUserId,
|
||
'',
|
||
'',
|
||
customerInfo.originalAppUserId,
|
||
RestoreStatus.DUPLICATE,
|
||
`检测到重复交易: ${duplicateCheck.duplicateTransactions.join(', ')}`,
|
||
restorePurchaseDto,
|
||
clientIp,
|
||
userAgent,
|
||
dbTransaction
|
||
);
|
||
await dbTransaction.rollback();
|
||
return this.createErrorResponse(`检测到重复交易,恢复失败。涉及交易: ${duplicateCheck.duplicateTransactions.join(', ')}`);
|
||
}
|
||
|
||
const restoredPurchases: RestoredPurchaseInfo[] = [];
|
||
|
||
// 处理活跃权益(带安全日志)
|
||
const activeEntitlementResults = await this.processActiveEntitlements(
|
||
customerInfo.activeEntitlements,
|
||
currentUserId,
|
||
dbTransaction
|
||
);
|
||
|
||
this.winstonLogger.info('RevenueCat active entitlements', {
|
||
context: 'UsersService',
|
||
method: 'restorePurchase',
|
||
customerInfo: customerInfo,
|
||
activeEntitlements: activeEntitlementResults
|
||
});
|
||
|
||
// 记录成功的恢复日志
|
||
await this.logSuccessfulRestores(
|
||
activeEntitlementResults.purchases,
|
||
currentUserId,
|
||
customerInfo.originalAppUserId,
|
||
restorePurchaseDto,
|
||
clientIp,
|
||
userAgent,
|
||
dbTransaction
|
||
);
|
||
|
||
restoredPurchases.push(...activeEntitlementResults.purchases);
|
||
|
||
// 处理非订阅交易(带安全日志)
|
||
const nonSubResults = await this.processNonSubscriptionTransactions(
|
||
customerInfo.nonSubscriptionTransactions,
|
||
currentUserId,
|
||
dbTransaction
|
||
);
|
||
|
||
// 记录成功的恢复日志
|
||
await this.logSuccessfulRestores(
|
||
nonSubResults.purchases,
|
||
currentUserId,
|
||
customerInfo.originalAppUserId,
|
||
restorePurchaseDto,
|
||
clientIp,
|
||
userAgent,
|
||
dbTransaction
|
||
);
|
||
|
||
restoredPurchases.push(...nonSubResults.purchases);
|
||
|
||
// 更新用户会员状态
|
||
const allExpirationDates = [
|
||
...activeEntitlementResults.expirationDates,
|
||
...nonSubResults.expirationDates
|
||
];
|
||
const membershipExpiration = await this.updateUserMembership(
|
||
user,
|
||
allExpirationDates,
|
||
dbTransaction
|
||
);
|
||
|
||
await dbTransaction.commit();
|
||
|
||
const totalRestoredCount = restoredPurchases.length;
|
||
const message = this.generateSuccessMessage(totalRestoredCount, membershipExpiration);
|
||
|
||
this.logger.log(`恢复购买完成 - 用户ID: ${currentUserId}, 恢复了 ${totalRestoredCount} 个购买记录`);
|
||
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: totalRestoredCount > 0 ? '购买记录恢复成功' : '未找到可恢复的购买记录',
|
||
data: {
|
||
restoredPurchases,
|
||
membershipExpiration: membershipExpiration?.toISOString(),
|
||
message,
|
||
totalRestoredCount
|
||
}
|
||
};
|
||
|
||
} catch (error) {
|
||
await dbTransaction.rollback();
|
||
this.logger.error(`恢复购买失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return this.createErrorResponse(`恢复购买失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 🔒 验证RevenueCat用户关联性
|
||
*/
|
||
private async validateRevenueCatUserAssociation(
|
||
originalAppUserId: string,
|
||
currentUserId: string,
|
||
transaction: Transaction
|
||
): Promise<{ isValid: boolean; reason?: string }> {
|
||
// 检查是否是同一个用户ID(直接匹配)
|
||
if (originalAppUserId === currentUserId) {
|
||
return { isValid: true };
|
||
}
|
||
|
||
// 检查是否是游客用户升级为正式用户的情况
|
||
if (originalAppUserId.startsWith('guest_') && !currentUserId.startsWith('guest_')) {
|
||
// 可以添加更复杂的验证逻辑,比如设备ID匹配等
|
||
this.winstonLogger.warn(`游客用户升级检测: ${originalAppUserId} -> ${currentUserId}`);
|
||
return { isValid: true }; // 暂时允许,但应该添加更严格的验证
|
||
}
|
||
|
||
// 检查是否已经有其他用户使用过这个RevenueCat用户ID
|
||
const existingLog = await this.purchaseRestoreLogModel.findOne({
|
||
where: {
|
||
originalAppUserId,
|
||
status: RestoreStatus.SUCCESS
|
||
},
|
||
transaction
|
||
});
|
||
|
||
if (existingLog && existingLog.userId !== currentUserId) {
|
||
return {
|
||
isValid: false,
|
||
reason: `RevenueCat用户ID ${originalAppUserId} 已被其他用户 ${existingLog.userId} 使用`
|
||
};
|
||
}
|
||
|
||
return { isValid: true };
|
||
}
|
||
|
||
/**
|
||
* 🔒 检查交易ID重复使用
|
||
*/
|
||
private async checkForDuplicateTransactions(
|
||
customerInfo: any,
|
||
currentUserId: string,
|
||
transaction: Transaction
|
||
): Promise<{ hasDuplicates: boolean; duplicateTransactions: string[] }> {
|
||
const allTransactionIds: string[] = [];
|
||
|
||
// 收集所有交易ID
|
||
Object.values(customerInfo.activeEntitlements).forEach((entitlement: any) => {
|
||
if (entitlement.latestPurchaseDate) {
|
||
allTransactionIds.push(entitlement.latestPurchaseDate);
|
||
}
|
||
});
|
||
|
||
customerInfo.nonSubscriptionTransactions.forEach((nonSub: any) => {
|
||
if (nonSub.transactionIdentifier) {
|
||
allTransactionIds.push(nonSub.transactionIdentifier);
|
||
}
|
||
});
|
||
|
||
if (allTransactionIds.length === 0) {
|
||
return { hasDuplicates: false, duplicateTransactions: [] };
|
||
}
|
||
|
||
// 检查这些交易ID是否已被其他用户使用
|
||
const existingLogs = await this.purchaseRestoreLogModel.findAll({
|
||
where: {
|
||
transactionId: allTransactionIds,
|
||
status: RestoreStatus.SUCCESS,
|
||
userId: { [Op.ne]: currentUserId } // 排除当前用户
|
||
},
|
||
transaction
|
||
});
|
||
|
||
const duplicateTransactions = existingLogs.map(log => log.transactionId);
|
||
|
||
return {
|
||
hasDuplicates: duplicateTransactions.length > 0,
|
||
duplicateTransactions
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 🔒 记录恢复尝试日志
|
||
*/
|
||
private async logRestoreAttempt(
|
||
userId: string,
|
||
transactionId: string,
|
||
productId: string,
|
||
originalAppUserId: string,
|
||
status: RestoreStatus,
|
||
failureReason: string | null,
|
||
rawData: any,
|
||
clientIp?: string,
|
||
userAgent?: string,
|
||
transaction?: Transaction
|
||
): Promise<void> {
|
||
try {
|
||
await this.purchaseRestoreLogModel.create({
|
||
userId,
|
||
transactionId,
|
||
productId,
|
||
originalAppUserId,
|
||
status,
|
||
source: RestoreSource.REVENUE_CAT,
|
||
rawData: JSON.stringify(rawData),
|
||
clientIp,
|
||
userAgent,
|
||
failureReason
|
||
}, { transaction });
|
||
} catch (error) {
|
||
this.logger.error(`记录恢复日志失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 🔒 记录成功的恢复日志
|
||
*/
|
||
private async logSuccessfulRestores(
|
||
restoredPurchases: RestoredPurchaseInfo[],
|
||
userId: string,
|
||
originalAppUserId: string,
|
||
rawData: any,
|
||
clientIp?: string,
|
||
userAgent?: string,
|
||
transaction?: Transaction
|
||
): Promise<void> {
|
||
try {
|
||
const logPromises = restoredPurchases.map(purchase =>
|
||
this.purchaseRestoreLogModel.create({
|
||
userId,
|
||
transactionId: purchase.transactionId,
|
||
productId: purchase.productId,
|
||
originalAppUserId,
|
||
status: RestoreStatus.SUCCESS,
|
||
source: RestoreSource.REVENUE_CAT,
|
||
rawData: JSON.stringify(rawData),
|
||
clientIp,
|
||
userAgent,
|
||
failureReason: null
|
||
}, { transaction })
|
||
);
|
||
|
||
await Promise.all(logPromises);
|
||
this.logger.log(`记录了 ${restoredPurchases.length} 个成功恢复的购买日志`);
|
||
} catch (error) {
|
||
this.logger.error(`记录成功恢复日志失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 🔒 检查被阻止的交易
|
||
*/
|
||
private async checkForBlockedTransactions(
|
||
customerInfo: any,
|
||
transaction: Transaction
|
||
): Promise<{ hasBlocked: boolean; blockedTransactions: string[] }> {
|
||
const allTransactionIds: string[] = [];
|
||
|
||
// 收集所有交易ID
|
||
Object.values(customerInfo.activeEntitlements).forEach((entitlement: any) => {
|
||
if (entitlement.latestPurchaseDate) {
|
||
allTransactionIds.push(entitlement.latestPurchaseDate);
|
||
}
|
||
});
|
||
|
||
customerInfo.nonSubscriptionTransactions.forEach((nonSub: any) => {
|
||
if (nonSub.transactionIdentifier) {
|
||
allTransactionIds.push(nonSub.transactionIdentifier);
|
||
}
|
||
});
|
||
|
||
if (allTransactionIds.length === 0) {
|
||
return { hasBlocked: false, blockedTransactions: [] };
|
||
}
|
||
|
||
// 检查这些交易ID是否被阻止
|
||
const blockedTransactions = await this.blockedTransactionModel.findAll({
|
||
where: {
|
||
transactionId: allTransactionIds,
|
||
isActive: true,
|
||
[Op.or]: [
|
||
{ expiresAt: null }, // 永久阻止
|
||
{ expiresAt: { [Op.gt]: new Date() } } // 未过期的临时阻止
|
||
]
|
||
},
|
||
transaction
|
||
});
|
||
|
||
const blockedTransactionIds = blockedTransactions.map(bt => bt.transactionId);
|
||
|
||
return {
|
||
hasBlocked: blockedTransactionIds.length > 0,
|
||
blockedTransactions: blockedTransactionIds
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 🔒 自动阻止重复使用的交易
|
||
*/
|
||
private async autoBlockDuplicateTransactions(
|
||
transactionIds: string[],
|
||
reason: string,
|
||
transaction?: Transaction
|
||
): Promise<void> {
|
||
try {
|
||
const blockPromises = transactionIds.map(transactionId =>
|
||
this.blockedTransactionModel.findOrCreate({
|
||
where: { transactionId },
|
||
defaults: {
|
||
transactionId,
|
||
blockReason: BlockReason.DUPLICATE_USAGE,
|
||
description: reason,
|
||
isActive: true,
|
||
expiresAt: null // 永久阻止
|
||
},
|
||
transaction
|
||
})
|
||
);
|
||
|
||
await Promise.all(blockPromises);
|
||
this.logger.warn(`自动阻止了 ${transactionIds.length} 个重复使用的交易: ${transactionIds.join(', ')}`);
|
||
} catch (error) {
|
||
this.logger.error(`自动阻止交易失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 🔒 手动阻止交易
|
||
*/
|
||
async blockTransaction(
|
||
transactionId: string,
|
||
reason: string,
|
||
operatorId: string,
|
||
expiresAt?: Date
|
||
): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const [blockedTransaction, created] = await this.blockedTransactionModel.findOrCreate({
|
||
where: { transactionId },
|
||
defaults: {
|
||
transactionId,
|
||
blockReason: BlockReason.MANUAL_BLOCK,
|
||
description: reason,
|
||
operatorId,
|
||
isActive: true,
|
||
expiresAt: expiresAt || null
|
||
}
|
||
});
|
||
|
||
if (!created) {
|
||
// 更新现有记录
|
||
blockedTransaction.description = reason;
|
||
blockedTransaction.operatorId = operatorId;
|
||
blockedTransaction.isActive = true;
|
||
if (expiresAt) {
|
||
blockedTransaction.expiresAt = expiresAt;
|
||
} else {
|
||
blockedTransaction.expiresAt = null;
|
||
}
|
||
await blockedTransaction.save();
|
||
}
|
||
|
||
this.logger.warn(`交易 ${transactionId} 已被手动阻止,操作员: ${operatorId}, 原因: ${reason}`);
|
||
return { success: true, message: '交易已成功阻止' };
|
||
} catch (error) {
|
||
this.logger.error(`阻止交易失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return { success: false, message: `阻止交易失败: ${error instanceof Error ? error.message : '未知错误'}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 🔒 解除交易阻止
|
||
*/
|
||
async unblockTransaction(
|
||
transactionId: string,
|
||
operatorId: string
|
||
): Promise<{ success: boolean; message: string }> {
|
||
try {
|
||
const blockedTransaction = await this.blockedTransactionModel.findOne({
|
||
where: { transactionId, isActive: true }
|
||
});
|
||
|
||
if (!blockedTransaction) {
|
||
return { success: false, message: '未找到被阻止的交易' };
|
||
}
|
||
|
||
blockedTransaction.isActive = false;
|
||
blockedTransaction.operatorId = operatorId;
|
||
await blockedTransaction.save();
|
||
|
||
this.logger.log(`交易 ${transactionId} 已被解除阻止,操作员: ${operatorId}`);
|
||
return { success: true, message: '交易阻止已成功解除' };
|
||
} catch (error) {
|
||
this.logger.error(`解除交易阻止失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return { success: false, message: `解除交易阻止失败: ${error instanceof Error ? error.message : '未知错误'}` };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证用户是否存在
|
||
*/
|
||
private async validateUserExists(userId: string, transaction: Transaction): Promise<User | null> {
|
||
return await this.userModel.findByPk(userId, { transaction });
|
||
}
|
||
|
||
/**
|
||
* 处理活跃权益
|
||
*/
|
||
private async processActiveEntitlements(
|
||
activeEntitlements: { [key: string]: ActiveEntitlement },
|
||
userId: string,
|
||
transaction: Transaction
|
||
): Promise<{ purchases: RestoredPurchaseInfo[]; expirationDates: Date[] }> {
|
||
const purchases: RestoredPurchaseInfo[] = [];
|
||
const expirationDates: Date[] = [];
|
||
|
||
this.logger.log(`处理活跃权益 - 数量: ${Object.keys(activeEntitlements).length}`);
|
||
|
||
// 批量查询现有购买记录以避免N+1问题
|
||
const entitlementKeys = Object.keys(activeEntitlements);
|
||
const existingPurchases = await this.getExistingPurchasesForEntitlements(
|
||
userId,
|
||
activeEntitlements,
|
||
transaction
|
||
);
|
||
|
||
for (const [entitlementKey, entitlement] of Object.entries(activeEntitlements)) {
|
||
if (!entitlement.isActive) continue;
|
||
|
||
this.logger.log(`处理权益: ${entitlementKey}, 产品ID: ${entitlement.productIdentifier}`);
|
||
|
||
const purchaseType = this.determinePurchaseType(entitlement.productIdentifier);
|
||
const existingPurchase = existingPurchases.find(p =>
|
||
p.productId === entitlement.productIdentifier &&
|
||
p.transactionId === entitlement.latestPurchaseDate
|
||
);
|
||
|
||
if (!existingPurchase) {
|
||
const expirationDate = await this.createPurchaseRecord(
|
||
userId,
|
||
entitlement,
|
||
purchaseType,
|
||
transaction
|
||
);
|
||
|
||
if (expirationDate) {
|
||
expirationDates.push(expirationDate);
|
||
}
|
||
}
|
||
|
||
// 添加到恢复列表
|
||
purchases.push(this.createRestoredPurchaseInfo(entitlement, entitlementKey, purchaseType));
|
||
}
|
||
|
||
return { purchases, expirationDates };
|
||
}
|
||
|
||
/**
|
||
* 处理非订阅交易
|
||
*/
|
||
private async processNonSubscriptionTransactions(
|
||
nonSubTransactions: NonSubscriptionTransaction[],
|
||
userId: string,
|
||
transaction: Transaction
|
||
): Promise<{ purchases: RestoredPurchaseInfo[]; expirationDates: Date[] }> {
|
||
const purchases: RestoredPurchaseInfo[] = [];
|
||
const expirationDates: Date[] = [];
|
||
|
||
this.logger.log(`处理非订阅交易 - 数量: ${nonSubTransactions.length}`);
|
||
|
||
// 批量查询现有购买记录
|
||
const existingPurchases = await this.getExistingPurchasesForNonSub(
|
||
userId,
|
||
nonSubTransactions,
|
||
transaction
|
||
);
|
||
|
||
for (const nonSubTransaction of nonSubTransactions) {
|
||
this.logger.log(`处理非订阅交易: ${nonSubTransaction.productIdentifier}`);
|
||
|
||
const purchaseType = this.determinePurchaseType(nonSubTransaction.productIdentifier);
|
||
const existingPurchase = existingPurchases.find(p =>
|
||
p.productId === nonSubTransaction.productIdentifier &&
|
||
p.transactionId === nonSubTransaction.transactionIdentifier
|
||
);
|
||
|
||
if (!existingPurchase) {
|
||
const expirationDate = await this.createNonSubPurchaseRecord(
|
||
userId,
|
||
nonSubTransaction,
|
||
purchaseType,
|
||
transaction
|
||
);
|
||
|
||
if (expirationDate) {
|
||
expirationDates.push(expirationDate);
|
||
}
|
||
}
|
||
|
||
// 添加到恢复列表
|
||
purchases.push(this.createRestoredPurchaseInfoFromNonSub(nonSubTransaction, purchaseType));
|
||
}
|
||
|
||
return { purchases, expirationDates };
|
||
}
|
||
|
||
/**
|
||
* 批量查询活跃权益的现有购买记录
|
||
*/
|
||
private async getExistingPurchasesForEntitlements(
|
||
userId: string,
|
||
entitlements: { [key: string]: ActiveEntitlement },
|
||
transaction: Transaction
|
||
): Promise<UserPurchase[]> {
|
||
const conditions = Object.values(entitlements).map(entitlement => ({
|
||
userId,
|
||
productId: entitlement.productIdentifier,
|
||
transactionId: entitlement.latestPurchaseDate
|
||
}));
|
||
|
||
if (conditions.length === 0) return [];
|
||
|
||
return await this.userPurchaseModel.findAll({
|
||
where: {
|
||
[Op.or]: conditions
|
||
},
|
||
transaction
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 批量查询非订阅交易的现有购买记录
|
||
*/
|
||
private async getExistingPurchasesForNonSub(
|
||
userId: string,
|
||
nonSubTransactions: NonSubscriptionTransaction[],
|
||
transaction: Transaction
|
||
): Promise<UserPurchase[]> {
|
||
const conditions = nonSubTransactions.map(transaction => ({
|
||
userId,
|
||
productId: transaction.productIdentifier,
|
||
transactionId: transaction.transactionIdentifier
|
||
}));
|
||
|
||
if (conditions.length === 0) return [];
|
||
|
||
return await this.userPurchaseModel.findAll({
|
||
where: {
|
||
[Op.or]: conditions
|
||
},
|
||
transaction
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 确定购买类型
|
||
*/
|
||
private determinePurchaseType(productIdentifier: string): PurchaseType {
|
||
const productId = productIdentifier.toLowerCase();
|
||
|
||
if (productId.includes('lifetime')) {
|
||
return PurchaseType.LIFETIME;
|
||
} else if (productId.includes('quarterly')) {
|
||
return PurchaseType.QUARTERLY;
|
||
} else if (productId.includes('weekly')) {
|
||
return PurchaseType.WEEKLY;
|
||
}
|
||
|
||
return PurchaseType.WEEKLY; // 默认值
|
||
}
|
||
|
||
/**
|
||
* 创建购买记录(活跃权益)
|
||
*/
|
||
private async createPurchaseRecord(
|
||
userId: string,
|
||
entitlement: ActiveEntitlement,
|
||
purchaseType: PurchaseType,
|
||
transaction: Transaction
|
||
): Promise<Date | null> {
|
||
const expirationDate = entitlement.expirationDate ? new Date(entitlement.expirationDate) : null;
|
||
let finalExpirationDate: Date;
|
||
|
||
if (purchaseType === PurchaseType.LIFETIME && !expirationDate) {
|
||
finalExpirationDate = this.getLifetimeExpirationDate();
|
||
} else if (expirationDate) {
|
||
finalExpirationDate = expirationDate;
|
||
} else {
|
||
return null;
|
||
}
|
||
|
||
await this.userPurchaseModel.create({
|
||
userId,
|
||
purchaseType,
|
||
status: PurchaseStatus.ACTIVE,
|
||
platform: PurchasePlatform.IOS,
|
||
transactionId: entitlement.latestPurchaseDate,
|
||
productId: entitlement.productIdentifier,
|
||
expiresAt: finalExpirationDate
|
||
}, { transaction });
|
||
|
||
this.logger.log(`创建购买记录: ${entitlement.productIdentifier}, 类型: ${purchaseType}`);
|
||
return finalExpirationDate;
|
||
}
|
||
|
||
/**
|
||
* 创建非订阅购买记录
|
||
*/
|
||
private async createNonSubPurchaseRecord(
|
||
userId: string,
|
||
nonSubTransaction: NonSubscriptionTransaction,
|
||
purchaseType: PurchaseType,
|
||
transaction: Transaction
|
||
): Promise<Date> {
|
||
const lifetimeExpiration = this.getLifetimeExpirationDate();
|
||
|
||
await this.userPurchaseModel.create({
|
||
userId,
|
||
purchaseType,
|
||
status: PurchaseStatus.ACTIVE,
|
||
platform: PurchasePlatform.IOS,
|
||
transactionId: nonSubTransaction.transactionIdentifier,
|
||
productId: nonSubTransaction.productIdentifier,
|
||
expiresAt: lifetimeExpiration
|
||
}, { transaction });
|
||
|
||
this.logger.log(`创建非订阅购买记录: ${nonSubTransaction.productIdentifier}, 类型: ${purchaseType}`);
|
||
return lifetimeExpiration;
|
||
}
|
||
|
||
/**
|
||
* 获取终身购买的过期时间
|
||
*/
|
||
private getLifetimeExpirationDate(): Date {
|
||
const LIFETIME_YEARS = 100; // 常量化魔法数字
|
||
const lifetimeExpiration = new Date();
|
||
lifetimeExpiration.setFullYear(lifetimeExpiration.getFullYear() + LIFETIME_YEARS);
|
||
return lifetimeExpiration;
|
||
}
|
||
|
||
/**
|
||
* 创建恢复购买信息(活跃权益)
|
||
*/
|
||
private createRestoredPurchaseInfo(
|
||
entitlement: ActiveEntitlement,
|
||
entitlementKey: string,
|
||
purchaseType: PurchaseType
|
||
): RestoredPurchaseInfo {
|
||
return {
|
||
productId: entitlement.productIdentifier,
|
||
transactionId: entitlement.latestPurchaseDate,
|
||
purchaseDate: entitlement.originalPurchaseDate,
|
||
expirationDate: entitlement.expirationDate || undefined,
|
||
entitlementId: entitlementKey,
|
||
store: entitlement.store,
|
||
purchaseType: purchaseType
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 创建恢复购买信息(非订阅交易)
|
||
*/
|
||
private createRestoredPurchaseInfoFromNonSub(
|
||
nonSubTransaction: NonSubscriptionTransaction,
|
||
purchaseType: PurchaseType
|
||
): RestoredPurchaseInfo {
|
||
return {
|
||
productId: nonSubTransaction.productIdentifier,
|
||
transactionId: nonSubTransaction.transactionIdentifier,
|
||
purchaseDate: nonSubTransaction.purchaseDate,
|
||
entitlementId: nonSubTransaction.productIdentifier,
|
||
store: 'APP_STORE',
|
||
purchaseType: purchaseType
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 更新用户会员状态
|
||
*/
|
||
private async updateUserMembership(
|
||
user: User,
|
||
expirationDates: Date[],
|
||
transaction: Transaction
|
||
): Promise<Date | null> {
|
||
if (expirationDates.length === 0) return null;
|
||
|
||
const latestExpirationDate = new Date(Math.max(...expirationDates.map(date => date.getTime())));
|
||
const currentExpiration = user.membershipExpiration;
|
||
|
||
if (!currentExpiration || latestExpirationDate > currentExpiration) {
|
||
user.membershipExpiration = latestExpirationDate;
|
||
await user.save({ transaction });
|
||
|
||
this.winstonLogger.info(`用户 ${user.id} 的会员状态已更新,过期时间: ${latestExpirationDate}`, {
|
||
context: 'UsersService',
|
||
method: 'updateUserMembership',
|
||
user
|
||
});
|
||
return latestExpirationDate;
|
||
}
|
||
|
||
return currentExpiration;
|
||
}
|
||
|
||
/**
|
||
* 生成成功消息
|
||
*/
|
||
private generateSuccessMessage(totalRestoredCount: number, membershipExpiration: Date | null): string {
|
||
if (totalRestoredCount === 0) {
|
||
return '未找到可恢复的购买记录';
|
||
}
|
||
|
||
let message = `成功恢复 ${totalRestoredCount} 个购买记录`;
|
||
if (membershipExpiration) {
|
||
message += `,会员有效期至 ${dayjs(membershipExpiration).format('YYYY-MM-DD HH:mm:ss')}`;
|
||
}
|
||
return message;
|
||
}
|
||
|
||
/**
|
||
* 创建错误响应
|
||
*/
|
||
private createErrorResponse(message: string): RestorePurchaseResponseDto {
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message,
|
||
data: {
|
||
restoredPurchases: [],
|
||
message,
|
||
totalRestoredCount: 0
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 调用 RevenueCat API 获取用户信息
|
||
*/
|
||
private async getRevenueCatCustomerInfo(userId: string): Promise<any> {
|
||
try {
|
||
const REVENUECAT_PUBLIC_API_KEY = process.env.REVENUECAT_PUBLIC_API_KEY;
|
||
const REVENUECAT_APP_USER_ID = userId;
|
||
|
||
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 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 中未找到用户: ${userId}`);
|
||
return null;
|
||
}
|
||
throw new Error(`RevenueCat API 请求失败: ${response.status} ${response.statusText}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
this.logger.log(`RevenueCat API 响应: ${JSON.stringify(data)}`);
|
||
return data;
|
||
|
||
} catch (error) {
|
||
this.logger.error(`调用 RevenueCat API 失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 关联 RevenueCat 用户ID 与当前用户ID
|
||
*/
|
||
private async linkRevenueCatUser(oldRevenueCatUserId: string, newUserId: string): Promise<void> {
|
||
try {
|
||
const REVENUECAT_SECRET_API_KEY = process.env.REVENUECAT_SECRET_API_KEY;
|
||
|
||
if (!REVENUECAT_SECRET_API_KEY) {
|
||
this.logger.warn('RevenueCat Secret API key 未配置,跳过用户关联');
|
||
return;
|
||
}
|
||
|
||
// 调用 RevenueCat API 创建用户别名
|
||
const url = `https://api.revenuecat.com/v1/subscribers/${oldRevenueCatUserId}/alias`;
|
||
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${REVENUECAT_SECRET_API_KEY}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
new_app_user_id: newUserId
|
||
}),
|
||
});
|
||
|
||
if (response.ok) {
|
||
this.logger.log(`成功关联 RevenueCat 用户: ${oldRevenueCatUserId} -> ${newUserId}`);
|
||
} else {
|
||
this.logger.warn(`RevenueCat 用户关联失败: ${response.status} ${response.statusText}`);
|
||
}
|
||
|
||
} catch (error) {
|
||
this.logger.error(`关联 RevenueCat 用户失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取用户最近六个月的活跃情况
|
||
*/
|
||
async getUserActivityHistory(userId: string): Promise<GetUserActivityHistoryResponseDto> {
|
||
try {
|
||
const activityHistory = await this.userActivityService.getUserActivityHistory(userId);
|
||
|
||
return {
|
||
code: ResponseCode.SUCCESS,
|
||
message: 'success',
|
||
data: activityHistory,
|
||
};
|
||
} catch (error) {
|
||
this.logger.error(`获取用户活跃历史失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||
return {
|
||
code: ResponseCode.ERROR,
|
||
message: `获取用户活跃历史失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||
data: [],
|
||
};
|
||
}
|
||
}
|
||
}
|