import { Controller, Get, Post, Body, Param, HttpCode, HttpStatus, Put, Delete, Query, Logger, UseGuards, Inject, Req, NotFoundException, UseInterceptors, UploadedFile, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { Request } from 'express'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { Logger as WinstonLogger } from 'winston'; import { UsersService } from './users.service'; import { UploadImageResponseDto } from './dto/upload-image.dto'; import { CreateUserDto } from './dto/create-user.dto'; import { UserResponseDto } from './dto/user-response.dto'; import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger'; import { UpdateUserDto, UpdateUserResponseDto } from './dto/update-user.dto'; 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 } from './dto/app-store-notification.dto'; import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto'; import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto'; import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto'; import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto'; import { GetUserBadgesResponseDto, GetAvailableBadgesResponseDto, MarkBadgeShownDto, MarkBadgeShownResponseDto } from './dto/badge.dto'; import { Public } from '../common/decorators/public.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { AccessTokenPayload } from './services/apple-auth.service'; import { JwtAuthGuard } from 'src/common/guards/jwt-auth.guard'; import { ResponseCode } from 'src/base.dto'; import { CosService } from './cos.service'; @ApiTags('users') @Controller('users') export class UsersController { private readonly logger = new Logger(UsersController.name); constructor( private readonly usersService: UsersService, @Inject(WINSTON_MODULE_PROVIDER) private readonly winstonLogger: WinstonLogger, private readonly cosService: CosService, ) { } @UseGuards(JwtAuthGuard) @Get('/info') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取用户信息' }) @ApiBody({ type: CreateUserDto }) @ApiResponse({ type: UserResponseDto }) async getProfile(@CurrentUser() user: AccessTokenPayload): Promise { this.logger.log(`get profile: ${JSON.stringify(user)}`); return this.usersService.getProfile(user); } // 获取历史体重记录 @UseGuards(JwtAuthGuard) @Get('/weight-history') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取历史体重记录(按时间倒序)' }) @ApiQuery({ name: 'limit', required: false, description: '返回条数,默认200,最大1000' }) async getWeightHistory( @CurrentUser() user: AccessTokenPayload, ) { const data = await this.usersService.getWeightHistory(user.sub, {}); return { code: ResponseCode.SUCCESS, message: 'success', data }; } // 更新体重记录 @UseGuards(JwtAuthGuard) @Put('/weight-records/:id') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '更新体重记录' }) @ApiBody({ type: UpdateWeightRecordDto }) @ApiResponse({ status: 200, description: '成功更新体重记录', type: WeightRecordResponseDto }) async updateWeightRecord( @Param('id') recordId: string, @Body() updateDto: UpdateWeightRecordDto, @CurrentUser() user: AccessTokenPayload, ): Promise { this.logger.log(`更新体重记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`); return this.usersService.updateWeightRecord(user.sub, parseInt(recordId), updateDto); } // 删除体重记录 @UseGuards(JwtAuthGuard) @Delete('/weight-records/:id') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '删除体重记录' }) @ApiResponse({ status: 200, description: '成功删除体重记录', type: DeleteWeightRecordResponseDto }) async deleteWeightRecord( @Param('id') recordId: string, @CurrentUser() user: AccessTokenPayload, ): Promise { this.logger.log(`删除体重记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`); const success = await this.usersService.deleteWeightRecord(user.sub, parseInt(recordId)); if (!success) { throw new NotFoundException('体重记录不存在'); } return { code: ResponseCode.SUCCESS, message: 'success' }; } // 更新用户昵称、头像 @UseGuards(JwtAuthGuard) @Put('update') async updateUser(@Body() updateUserDto: UpdateUserDto, @CurrentUser() user: AccessTokenPayload): Promise { return this.usersService.updateUser(updateUserDto, user.sub); } // Apple 登录 @Public() @Post('auth/apple/login') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Apple 登录验证' }) @ApiBody({ type: AppleLoginDto }) @ApiResponse({ type: AppleLoginResponseDto }) async appleLogin(@Body() appleLoginDto: AppleLoginDto): Promise { return this.usersService.appleLogin(appleLoginDto); } // 刷新访问令牌 @Public() @Post('auth/refresh') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '刷新访问令牌' }) @ApiBody({ type: RefreshTokenDto }) @ApiResponse({ type: RefreshTokenResponseDto }) async refreshToken(@Body() refreshTokenDto: RefreshTokenDto): Promise { return this.usersService.refreshToken(refreshTokenDto); } // 删除用户账号 @UseGuards(JwtAuthGuard) @Post('delete-account') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '删除用户账号' }) @ApiBody({ type: DeleteAccountDto }) @ApiResponse({ type: DeleteAccountResponseDto }) async deleteAccount(@CurrentUser() user: AccessTokenPayload): Promise { const deleteAccountDto = { userId: user.sub, }; return this.usersService.deleteAccount(deleteAccountDto); } // 游客登录 @Public() @Post('auth/guest/login') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '游客登录' }) @ApiBody({ type: GuestLoginDto }) @ApiResponse({ type: GuestLoginResponseDto }) async guestLogin(@Body() guestLoginDto: GuestLoginDto): Promise { return this.usersService.guestLogin(guestLoginDto); } // 刷新游客令牌 @Public() @Post('auth/guest/refresh') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '刷新游客访问令牌' }) @ApiBody({ type: RefreshGuestTokenDto }) @ApiResponse({ type: RefreshGuestTokenResponseDto }) async refreshGuestToken(@Body() refreshGuestTokenDto: RefreshGuestTokenDto): Promise { return this.usersService.refreshGuestToken(refreshGuestTokenDto); } // 获取COS上传临时密钥 @UseGuards(JwtAuthGuard) @Get('cos/upload-token') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取COS上传临时密钥' }) async getCosUploadToken(@CurrentUser() user: AccessTokenPayload) { try { const data = await this.cosService.getUploadToken(user.sub); return { code: ResponseCode.SUCCESS, message: 'success', data }; } catch (error) { this.winstonLogger.error('获取COS上传临时密钥失败', { context: 'UsersController', userId: user?.sub, error: (error as Error).message, stack: (error as Error).stack, }); return { code: ResponseCode.ERROR, message: (error as Error).message }; } } // 上传图片到COS @UseGuards(JwtAuthGuard) @Post('cos/upload-image') @HttpCode(HttpStatus.OK) @UseInterceptors(FileInterceptor('file')) @ApiOperation({ summary: '上传图片到COS' }) @ApiResponse({ status: 200, description: '图片上传成功', type: UploadImageResponseDto, }) @ApiResponse({ status: 400, description: '上传失败:文件格式不支持或文件过大', }) async uploadImageToCos( @CurrentUser() user: AccessTokenPayload, @UploadedFile() file: Express.Multer.File, ) { try { if (!file) { return { code: ResponseCode.ERROR, message: '请选择要上传的图片文件' }; } const data = await this.cosService.uploadImage(user.sub, file); return data; } catch (error) { this.winstonLogger.error('上传图片失败', { context: 'UsersController', userId: user?.sub, error: (error as Error).message, stack: (error as Error).stack, }); return { code: ResponseCode.ERROR, message: (error as Error).message }; } } // App Store 服务器通知接收接口 @Public() @Post('app-store-notifications') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '接收App Store服务器通知' }) @ApiBody({ type: AppStoreServerNotificationDto }) @ApiResponse({ type: ProcessNotificationResponseDto }) async handleAppStoreNotification(@Body() notificationDto: AppStoreServerNotificationDto): Promise { this.logger.log(`收到App Store服务器通知`); return this.usersService.processAppStoreNotification(notificationDto); } // RevenueCat Webhook @Public() @Post('revenuecat-webhook') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '接收 RevenueCat webhook' }) async handleRevenueCatWebhook(@Body() webhook) { // 使用结构化日志记录webhook接收 this.winstonLogger.info('RevenueCat webhook received', { context: 'UsersController', eventType: webhook.event?.type, eventId: webhook.event?.id, appUserId: webhook.event?.app_user_id, timestamp: new Date().toISOString(), webhookData: { apiVersion: webhook.api_version, eventTimestamp: webhook.event?.event_timestamp_ms } }); try { await this.usersService.handleRevenueCatWebhook(webhook); this.winstonLogger.info('RevenueCat webhook processed successfully', { context: 'UsersController', eventType: webhook.event?.type, eventId: webhook.event?.id, appUserId: webhook.event?.app_user_id }); return { code: ResponseCode.SUCCESS, message: 'success' }; } catch (error) { this.winstonLogger.error('RevenueCat webhook processing failed', { context: 'UsersController', eventType: webhook.event?.type, eventId: webhook.event?.id, appUserId: webhook.event?.app_user_id, error: error.message, stack: error.stack }); return { code: ResponseCode.ERROR, message: error.message }; } } // 恢复购买 @UseGuards(JwtAuthGuard) @Post('restore-purchase') async restorePurchase( @Body() restorePurchaseDto: RestorePurchaseDto, @CurrentUser() user: AccessTokenPayload, @Req() request: Request ): Promise { const clientIp = request.ip || request.connection.remoteAddress || 'unknown'; const userAgent = request.get('User-Agent') || 'unknown'; this.logger.log(`恢复购买请求 - 用户ID: ${user.sub}, IP: ${clientIp}`); // 记录安全相关信息 this.winstonLogger.info('Purchase restore request', { context: 'UsersController', userId: user.sub, clientIp, userAgent, originalAppUserId: restorePurchaseDto.customerInfo?.originalAppUserId, entitlementsCount: Object.keys(restorePurchaseDto.customerInfo?.activeEntitlements || {}).length, nonSubTransactionsCount: restorePurchaseDto.customerInfo?.nonSubscriptionTransactions?.length || 0 }); return this.usersService.restorePurchase(restorePurchaseDto, user.sub, clientIp, userAgent); } // ==================== 用户活跃记录相关接口 ==================== /** * 获取用户最近六个月的活跃情况 */ @UseGuards(JwtAuthGuard) @Get('activity-history') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取用户最近六个月的活跃情况' }) @ApiResponse({ status: 200, description: '成功获取用户活跃历史', type: GetUserActivityHistoryResponseDto }) async getUserActivityHistory( @CurrentUser() user: AccessTokenPayload, ): Promise { this.logger.log(`获取用户活跃历史 - 用户ID: ${user.sub}`); return this.usersService.getUserActivityHistory(user.sub); } // ==================== 围度相关接口 ==================== /** * 更新用户围度信息 */ @UseGuards(JwtAuthGuard) @Put('body-measurements') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '更新用户围度信息' }) @ApiBody({ type: UpdateBodyMeasurementDto }) @ApiResponse({ status: 200, description: '成功更新围度信息', type: UpdateBodyMeasurementResponseDto }) async updateBodyMeasurements( @Body() updateBodyMeasurementDto: UpdateBodyMeasurementDto, @CurrentUser() user: AccessTokenPayload, ): Promise { this.logger.log(`更新用户围度信息 - 用户ID: ${user.sub}, 数据: ${JSON.stringify(updateBodyMeasurementDto)}`); return this.usersService.updateBodyMeasurements(user.sub, updateBodyMeasurementDto); } /** * 获取用户围度历史记录 */ @UseGuards(JwtAuthGuard) @Get('body-measurements/history') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取用户围度历史记录' }) @ApiQuery({ name: 'measurementType', required: false, description: '围度类型筛选' }) @ApiResponse({ status: 200, description: '成功获取围度历史记录', type: GetBodyMeasurementHistoryResponseDto }) async getBodyMeasurementHistory( @CurrentUser() user: AccessTokenPayload, @Query('measurementType') measurementType?: string, ): Promise { this.logger.log(`获取用户围度历史记录 - 用户ID: ${user.sub}, 围度类型: ${measurementType || '全部'}`); return this.usersService.getBodyMeasurementHistory(user.sub, measurementType as any); } /** * 获取用户围度分析报表 */ @UseGuards(JwtAuthGuard) @Get('body-measurements/analysis') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取用户围度分析报表' }) @ApiQuery({ name: 'period', required: true, description: '时间范围 (week: 按周, month: 按月, year: 按年)', enum: ['week', 'month', 'year'] }) @ApiResponse({ status: 200, description: '成功获取围度分析报表', type: GetBodyMeasurementAnalysisResponseDto }) async getBodyMeasurementAnalysis( @CurrentUser() user: AccessTokenPayload, @Query('period') period: 'week' | 'month' | 'year', ): Promise { this.logger.log(`获取用户围度分析报表 - 用户ID: ${user.sub}, 时间范围: ${period}`); return this.usersService.getBodyMeasurementAnalysis(user.sub, period); } // ==================== 勋章相关接口 ==================== /** * 获取用户勋章列表 */ @UseGuards(JwtAuthGuard) @Get('badges') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取用户勋章列表' }) @ApiResponse({ status: 200, description: '成功获取用户勋章列表', type: GetUserBadgesResponseDto }) async getUserBadges( @CurrentUser() user: AccessTokenPayload, ): Promise { this.logger.log(`获取用户勋章列表 - 用户ID: ${user.sub}`); return this.usersService.getUserBadges(user.sub); } /** * 获取所有可用勋章(包含用户是否已获得) */ @UseGuards(JwtAuthGuard) @Get('badges/available') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取所有可用勋章' }) @ApiResponse({ status: 200, description: '成功获取所有可用勋章', type: GetAvailableBadgesResponseDto }) async getAvailableBadges( @CurrentUser() user: AccessTokenPayload, ): Promise { this.logger.log(`获取可用勋章列表 - 用户ID: ${user.sub}`); return this.usersService.getAvailableBadges(user.sub); } /** * 标记勋章已展示 */ @UseGuards(JwtAuthGuard) @Post('badges/mark-shown') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '标记勋章已展示(客户端展示勋章动画后调用)' }) @ApiBody({ type: MarkBadgeShownDto }) @ApiResponse({ status: 200, description: '成功标记勋章已展示', type: MarkBadgeShownResponseDto }) async markBadgeAsShown( @Body() markBadgeShownDto: MarkBadgeShownDto, @CurrentUser() user: AccessTokenPayload, ): Promise { this.logger.log(`标记勋章已展示 - 用户ID: ${user.sub}, 勋章代码: ${markBadgeShownDto.badgeCode}`); return this.usersService.markBadgeAsShown(user.sub, markBadgeShownDto.badgeCode); } }