import { Controller, Get, Post, Body, Param, HttpCode, HttpStatus, Put, Delete, Query, Logger, UseGuards, Inject, Req, NotFoundException, UseInterceptors, UploadedFile, forwardRef, } 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 { VersionCheckDto, VersionCheckResponseDto } from './dto/version-check.dto'; import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto'; import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto'; import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto'; import { GetUserBadgesResponseDto, GetAvailableBadgesResponseDto, MarkBadgeShownDto, MarkBadgeShownResponseDto } from './dto/badge.dto'; import { UpdateDailyHealthDto, UpdateDailyHealthResponseDto } from './dto/daily-health.dto'; import { Public } from '../common/decorators/public.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { AppVersion } from '../common/decorators/app-version.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'; import { AiReportService } from '../ai-coach/services/ai-report.service'; import { GetAiReportHistoryQueryDto, GetAiReportHistoryResponseDto } from '../ai-coach/dto/ai-report-history.dto'; @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, @Inject(forwardRef(() => AiReportService)) private readonly aiReportService: AiReportService, ) { } @UseGuards(JwtAuthGuard) @Get('/info') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取用户信息' }) @ApiBody({ type: CreateUserDto }) @ApiResponse({ type: UserResponseDto }) async getProfile( @CurrentUser() user: AccessTokenPayload, @AppVersion() appVersion: string | undefined, ): Promise { this.logger.log(`get profile: ${JSON.stringify(user)}, appVersion: ${appVersion}`); return this.usersService.getProfile(user, appVersion); } // 获取历史体重记录 @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) @Public() @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); } // ==================== 版本检查相关接口 ==================== /** * 检查应用版本更新 */ @Public() @Get('version-check') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '检查应用版本更新' }) @ApiQuery({ name: 'platform', required: false, description: '设备平台', enum: ['ios', 'android'] }) @ApiResponse({ status: 200, description: '成功获取版本信息', type: VersionCheckResponseDto }) async checkVersion( @AppVersion() appVersion: string | undefined, @Query('platform') platform?: string, ): Promise { this.logger.log(`版本检查请求 - 当前版本: ${appVersion}, 平台: ${platform}`); // 构造查询对象,保持与原有服务的兼容性 const query: VersionCheckDto = { currentVersion: appVersion, platform: platform, }; return this.usersService.checkVersion(query); } // ==================== AI 健康报告 ==================== /** * 生成用户的 AI 健康报告图片 */ @UseGuards(JwtAuthGuard) @Post('ai-report') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '生成用户的 AI 健康报告图片' }) @ApiBody({ type: Object, required: false, description: '请求体,可以传入 date 指定日期,格式 YYYY-MM-DD' }) @ApiResponse({ status: 200, description: '成功生成 AI 报告图片', schema: { type: 'object', properties: { code: { type: 'number', example: 0 }, message: { type: 'string', example: 'success' }, data: { type: 'object', properties: { id: { type: 'string', example: '550e8400-e29b-41d4-a716-446655440000' }, imageUrl: { type: 'string', example: 'https://example.com/generated-image.png' } } } } } }) async generateAiHealthReport( @CurrentUser() user: AccessTokenPayload, @Body() body: { date?: string }, ): Promise<{ code: ResponseCode; message: string; data: { id: string; imageUrl: string } }> { try { this.logger.log(`生成AI健康报告请求 - 用户ID: ${user.sub}, 日期: ${body.date || '今天'}`); const result = await this.aiReportService.generateHealthReportImage(user.sub, body.date); return { code: ResponseCode.SUCCESS, message: 'AI健康报告生成成功', data: { id: result.id, imageUrl: result.imageUrl, }, }; } catch (error) { this.winstonLogger.error('生成AI健康报告失败', { context: 'UsersController', userId: user?.sub, error: (error as Error).message, stack: (error as Error).stack, }); return { code: ResponseCode.ERROR, message: `生成失败: ${(error as Error).message}`, data: { id: '', imageUrl: '', }, }; } } /** * 获取用户的 AI 健康报告历史列表 */ @UseGuards(JwtAuthGuard) @Get('ai-report/history') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取用户的 AI 健康报告历史列表(分页)' }) @ApiQuery({ name: 'page', required: false, description: '页码,从1开始,默认1' }) @ApiQuery({ name: 'pageSize', required: false, description: '每页条数,默认10,最大50' }) @ApiQuery({ name: 'startDate', required: false, description: '开始日期,格式 YYYY-MM-DD' }) @ApiQuery({ name: 'endDate', required: false, description: '结束日期,格式 YYYY-MM-DD' }) @ApiQuery({ name: 'status', required: false, description: '状态筛选: pending | processing | success | failed,不传则返回所有状态' }) @ApiResponse({ status: 200, description: '成功获取报告历史列表', type: GetAiReportHistoryResponseDto }) async getAiReportHistory( @CurrentUser() user: AccessTokenPayload, @Query() query: GetAiReportHistoryQueryDto, ): Promise { try { this.logger.log(`获取AI健康报告历史 - 用户ID: ${user.sub}, 页码: ${query.page}, 每页: ${query.pageSize}`); const result = await this.aiReportService.getReportHistory(user.sub, { page: query.page, pageSize: query.pageSize, startDate: query.startDate, endDate: query.endDate, status: query.status, }); return { code: ResponseCode.SUCCESS, message: 'success', data: { records: result.records.map(r => ({ id: r.id, reportDate: r.reportDate, imageUrl: r.imageUrl || '', // 成功记录一定有 imageUrl status: r.status, createdAt: r.createdAt, })), total: result.total, page: result.page, pageSize: result.pageSize, totalPages: result.totalPages, }, }; } catch (error) { this.winstonLogger.error('获取AI健康报告历史失败', { context: 'UsersController', userId: user?.sub, error: (error as Error).message, stack: (error as Error).stack, }); return { code: ResponseCode.ERROR, message: `获取失败: ${(error as Error).message}`, data: { records: [], total: 0, page: 1, pageSize: 10, totalPages: 0, }, }; } } // ==================== 每日健康数据相关接口 ==================== /** * 更新用户每日健康数据 */ @UseGuards(JwtAuthGuard) @Put('daily-health') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '更新用户每日健康数据(每日每用户一条记录,存在则更新)' }) @ApiBody({ type: UpdateDailyHealthDto }) @ApiResponse({ status: 200, description: '成功更新每日健康数据', type: UpdateDailyHealthResponseDto }) async updateDailyHealth( @Body() updateDto: UpdateDailyHealthDto, @CurrentUser() user: AccessTokenPayload, ): Promise { this.logger.log(`更新每日健康数据 - 用户ID: ${user.sub}, 数据: ${JSON.stringify(updateDto)}`); return this.usersService.updateDailyHealth(user.sub, updateDto); } // ==================== 健康邀请码相关接口 ==================== /** * 获取用户健康邀请码 */ @UseGuards(JwtAuthGuard) @Get('health-invite-code') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '获取用户健康邀请码(如果没有则自动生成)' }) @ApiResponse({ status: 200, description: '成功获取健康邀请码', schema: { type: 'object', properties: { code: { type: 'number', example: 0 }, message: { type: 'string', example: 'success' }, data: { type: 'object', properties: { healthInviteCode: { type: 'string', example: 'ABC12345' }, }, }, }, }, }) async getHealthInviteCode( @CurrentUser() user: AccessTokenPayload, ): Promise<{ code: ResponseCode; message: string; data: { healthInviteCode: string } }> { this.logger.log(`获取健康邀请码 - 用户ID: ${user.sub}`); return this.usersService.getHealthInviteCode(user.sub); } }