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 { 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 { 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 { 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 { 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 = {}; const userChanges: Record = {}; 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 { 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 { 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 { 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 { return this.addDietRecord(userId, { ...dietData, source: DietRecordSource.Vision }); } /** * 获取饮食记录历史 */ async getDietHistory(userId: string, query: GetDietHistoryQueryDto): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 输入验证 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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: [], }; } } }