From a56d1d52552043adec35b8852900f99f8dc8a0b8 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 18 Aug 2025 19:20:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0AI=E6=95=99=E7=BB=83?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E5=99=A8=EF=BC=8C=E5=A2=9E=E5=8A=A0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=81=8A=E5=A4=A9=E6=AC=A1=E6=95=B0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20-=20=E5=9C=A8AI=E6=95=99=E7=BB=83=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=99=A8=E4=B8=AD=E5=BC=95=E5=85=A5=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=AC=A1=E6=95=B0=E7=9A=84=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=EF=BC=8C=E7=A1=AE=E4=BF=9D=E7=94=A8=E6=88=B7=E5=9C=A8=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E5=AF=B9=E8=AF=9D=E5=89=8D=E6=9C=89=E8=B6=B3=E5=A4=9F?= =?UTF-8?q?=E7=9A=84=E8=81=8A=E5=A4=A9=E6=AC=A1=E6=95=B0=E3=80=82=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=94=A8=E6=88=B7=E6=9C=8D=E5=8A=A1=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E4=BB=A5=E8=8E=B7=E5=8F=96=E5=92=8C=E6=89=A3=E5=87=8F?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=9A=84=E8=81=8A=E5=A4=A9=E6=AC=A1=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=E3=80=82=20-=20=E8=B0=83=E6=95=B4=E9=BB=98=E8=AE=A4=E5=85=8D?= =?UTF-8?q?=E8=B4=B9=E8=81=8A=E5=A4=A9=E6=AC=A1=E6=95=B0=E4=B8=BA5?= =?UTF-8?q?=E6=AC=A1=EF=BC=8C=E6=8F=90=E5=8D=87=E7=B3=BB=E7=BB=9F=E7=9A=84?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E9=99=90=E5=88=B6=E7=AE=A1=E7=90=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai-coach/ai-coach.controller.ts | 18 +++++- src/users/users.controller.ts | 95 ----------------------------- src/users/users.service.ts | 49 ++++++++++++++- 3 files changed, 62 insertions(+), 100 deletions(-) diff --git a/src/ai-coach/ai-coach.controller.ts b/src/ai-coach/ai-coach.controller.ts index 00ebd1d..a4c8427 100644 --- a/src/ai-coach/ai-coach.controller.ts +++ b/src/ai-coach/ai-coach.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Post, Query, Res, StreamableFile, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpException, HttpStatus, Logger, Param, Post, Query, Res, StreamableFile, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger'; import { Response } from 'express'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; @@ -7,12 +7,17 @@ import { AccessTokenPayload } from '../users/services/apple-auth.service'; import { AiCoachService } from './ai-coach.service'; import { AiChatRequestDto, AiChatResponseDto, AiResponseDataDto } from './dto/ai-chat.dto'; import { PostureAssessmentRequestDto, PostureAssessmentResponseDto } from './dto/posture-assessment.dto'; +import { UsersService } from '../users/users.service'; @ApiTags('ai-coach') @Controller('ai-coach') @UseGuards(JwtAuthGuard) export class AiCoachController { - constructor(private readonly aiCoachService: AiCoachService) { } + private readonly logger = new Logger(AiCoachController.name); + constructor( + private readonly aiCoachService: AiCoachService, + private readonly usersService: UsersService, + ) { } @Post('chat') @ApiOperation({ summary: '流式大模型对话(普拉提教练)' }) @@ -26,6 +31,13 @@ export class AiCoachController { const stream = body.stream !== false; // 默认流式 const userContent = body.messages?.[body.messages.length - 1]?.content || ''; + // 判断用户是否有聊天次数 + const usageCount = await this.usersService.getUserUsageCount(userId); + if (usageCount <= 0) { + this.logger.error(`chat: ${userId} has no usage count`); + throw new HttpException('用户没有聊天次数', HttpStatus.FORBIDDEN); + } + // 创建或沿用会话ID,并保存用户消息 const { conversationId } = await this.aiCoachService.createOrAppendMessages({ userId, @@ -45,6 +57,8 @@ export class AiCoachController { confirmationData: body.confirmationData, }); + await this.usersService.deductUserUsageCount(userId); + // 检查是否返回结构化数据(如确认选项) // 结构化数据必须使用非流式模式返回 if (typeof result === 'object' && 'type' in result) { diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 662e187..dbb8b04 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -317,99 +317,4 @@ export class UsersController { } } - /** - * 获取营养汇总分析 - */ - @UseGuards(JwtAuthGuard) - @Get('nutrition-summary') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '获取最近饮食的营养汇总分析' }) - @ApiQuery({ name: 'mealCount', required: false, description: '分析最近几顿饮食,默认10顿' }) - @ApiResponse({ status: 200, description: '成功获取营养汇总', type: DietAnalysisResponseDto }) - async getNutritionSummary( - @Query('mealCount') mealCount: string = '10', - @CurrentUser() user: AccessTokenPayload, - ): Promise { - this.logger.log(`获取营养汇总 - 用户ID: ${user.sub}, 分析${mealCount}顿饮食`); - - const count = Math.min(20, Math.max(1, parseInt(mealCount) || 10)); - const nutritionSummary = await this.usersService.getRecentNutritionSummary(user.sub, count); - - // 获取最近的饮食记录用于分析 - const recentRecords = await this.usersService.getDietHistory(user.sub, { limit: count }); - - // 简单的营养评分算法(可以后续优化) - const nutritionScore = this.calculateNutritionScore(nutritionSummary); - - // 生成基础建议(后续可以接入AI分析) - const recommendations = this.generateBasicRecommendations(nutritionSummary); - - return { - nutritionSummary, - recentRecords: recentRecords.records, - healthAnalysis: '基于您最近的饮食记录,我将为您提供个性化的营养分析和健康建议。', - nutritionScore, - recommendations, - }; - } - - /** - * 简单的营养评分算法 - */ - private calculateNutritionScore(summary: any): number { - let score = 50; // 基础分数 - - // 基于热量是否合理调整分数 - const dailyCalories = summary.totalCalories / (summary.recordCount / 3); // 假设一天3餐 - if (dailyCalories >= 1500 && dailyCalories <= 2500) score += 20; - else if (dailyCalories < 1200 || dailyCalories > 3000) score -= 20; - - // 基于蛋白质摄入调整分数 - const dailyProtein = summary.totalProtein / (summary.recordCount / 3); - if (dailyProtein >= 50 && dailyProtein <= 150) score += 15; - else if (dailyProtein < 30) score -= 15; - - // 基于膳食纤维调整分数 - const dailyFiber = summary.totalFiber / (summary.recordCount / 3); - if (dailyFiber >= 25) score += 15; - else if (dailyFiber < 10) score -= 10; - - return Math.max(0, Math.min(100, score)); - } - - /** - * 生成基础营养建议 - */ - private generateBasicRecommendations(summary: any): string[] { - const recommendations: string[] = []; - - const dailyCalories = summary.totalCalories / (summary.recordCount / 3); - const dailyProtein = summary.totalProtein / (summary.recordCount / 3); - const dailyFiber = summary.totalFiber / (summary.recordCount / 3); - const dailySodium = summary.totalSodium / (summary.recordCount / 3); - - if (dailyCalories < 1200) { - recommendations.push('您的日均热量摄入偏低,建议适当增加营养密度高的食物。'); - } else if (dailyCalories > 2500) { - recommendations.push('您的日均热量摄入偏高,建议控制portion size或选择低热量食物。'); - } - - if (dailyProtein < 50) { - recommendations.push('建议增加优质蛋白质摄入,如鸡胸肉、鱼类、豆制品等。'); - } - - if (dailyFiber < 25) { - recommendations.push('建议增加膳食纤维摄入,多吃蔬菜、水果和全谷物。'); - } - - if (dailySodium > 2000) { - recommendations.push('钠摄入偏高,建议减少加工食品和调味料的使用。'); - } - - if (recommendations.length === 0) { - recommendations.push('您的饮食结构相对均衡,继续保持良好的饮食习惯!'); - } - - return recommendations; - } } \ No newline at end of file diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 68b4f00..76ef3fe 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -36,7 +36,7 @@ import { ActivityLogsService } from '../activity-logs/activity-logs.service'; 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 = 10; +const DEFAULT_FREE_USAGE_COUNT = 5; @Injectable() export class UsersService { @@ -126,6 +126,49 @@ export class UsersService { } } + /** + * @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): Promise { @@ -453,8 +496,8 @@ export class UsersService { } /** - * 获取最近N顿饮食的营养汇总 - */ + * 获取最近N顿饮食的营养汇总 + */ async getRecentNutritionSummary(userId: string, mealCount: number = 10): Promise { const records = await this.userDietHistoryModel.findAll({ where: { userId, deleted: false },