From c3961150abcfa45b60ad9fdbc5a9e5d4242a8fca Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 27 Aug 2025 14:22:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96AI=E6=95=99=E7=BB=83?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=81=8A=E5=A4=A9=E6=AC=A1=E6=95=B0=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=92=8C=E5=93=8D=E5=BA=94=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在AI教练控制器中添加用户聊天次数检查,若次数用完则返回相应提示信息。 - 更新AI聊天响应DTO,新增用户剩余聊天次数和AI回复文本字段,提升用户体验。 - 修改用户服务,支持初始体重和目标体重字段的更新,增强用户资料的完整性。 --- src/ai-coach/ai-coach.controller.ts | 30 +++++++++++++++++--------- src/ai-coach/ai-coach.service.ts | 4 ++-- src/ai-coach/dto/ai-chat.dto.ts | 8 +++++++ src/users/dto/update-user.dto.ts | 8 +++++++ src/users/dto/user-response.dto.ts | 2 +- src/users/models/user-profile.model.ts | 14 ++++++++++++ src/users/users.service.ts | 18 ++++++++++++++-- 7 files changed, 69 insertions(+), 15 deletions(-) diff --git a/src/ai-coach/ai-coach.controller.ts b/src/ai-coach/ai-coach.controller.ts index a4c8427..7f82faa 100644 --- a/src/ai-coach/ai-coach.controller.ts +++ b/src/ai-coach/ai-coach.controller.ts @@ -28,15 +28,11 @@ export class AiCoachController { @Res({ passthrough: false }) res: Response, ): Promise { const userId = user.sub; + this.logger.log(`chat: ${userId} chat body ${JSON.stringify(body, null, 2)}`); 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({ @@ -45,8 +41,20 @@ export class AiCoachController { userContent, }); - // 体重和饮食指令处理现在已经集成到 streamChat 方法中 - // 通过 # 字符开头的指令系统进行统一处理 + // 判断用户是否有聊天次数 + const usageCount = await this.usersService.getUserUsageCount(userId); + if (usageCount <= 0) { + this.logger.warn(`chat: ${userId} has no usage count`); + + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.send({ + conversationId, + text: '聊天次数用完了,明天再来吧~', + }); + return + } + + const result = await this.aiCoachService.streamChat({ userId, @@ -59,6 +67,9 @@ export class AiCoachController { await this.usersService.deductUserUsageCount(userId); + // 普通流式/非流式响应 + const readable = result as any; + // 检查是否返回结构化数据(如确认选项) // 结构化数据必须使用非流式模式返回 if (typeof result === 'object' && 'type' in result) { @@ -70,8 +81,7 @@ export class AiCoachController { return; } - // 普通流式/非流式响应 - const readable = result as any; + if (!stream) { // 非流式:聚合后一次性返回文本 diff --git a/src/ai-coach/ai-coach.service.ts b/src/ai-coach/ai-coach.service.ts index 9d76185..2a02030 100644 --- a/src/ai-coach/ai-coach.service.ts +++ b/src/ai-coach/ai-coach.service.ts @@ -674,7 +674,7 @@ export class AiCoachService { const cleanText = restText.trim(); // 识别体重记录指令 - if (/^记体重$|^体重$|^称重$/.test(commandPart)) { + if (/^记体重$/.test(commandPart)) { return { isCommand: true, command: 'weight', @@ -684,7 +684,7 @@ export class AiCoachService { } // 识别饮食记录指令 - if (/^记饮食$|^饮食$|^记录饮食$/.test(commandPart)) { + if (/^记饮食$/.test(commandPart)) { return { isCommand: true, command: 'diet', diff --git a/src/ai-coach/dto/ai-chat.dto.ts b/src/ai-coach/dto/ai-chat.dto.ts index beb89f4..8fbdaf5 100644 --- a/src/ai-coach/dto/ai-chat.dto.ts +++ b/src/ai-coach/dto/ai-chat.dto.ts @@ -98,6 +98,14 @@ export class AiChatResponseDto { @ApiProperty({ type: AiResponseDataDto, description: '响应数据(非流式时返回)', required: false }) @IsOptional() data?: AiResponseDataDto; + + @ApiProperty({ description: '用户剩余的AI聊天次数', required: false }) + @IsOptional() + usageCount?: number; + + @ApiProperty({ description: 'AI回复的文本内容(非流式时返回)', required: false }) + @IsOptional() + text?: string; } // 营养分析相关的DTO diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index c3d8cc2..42a1fb5 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -48,6 +48,14 @@ export class UpdateUserDto { @ApiProperty({ description: '体重(公斤)', example: 55.5 }) weight?: number; + @IsOptional() + @ApiProperty({ description: '初始体重(公斤)', example: 60.0 }) + initialWeight?: number; + + @IsOptional() + @ApiProperty({ description: '目标体重(公斤)', example: 50.0 }) + targetWeight?: number; + @IsOptional() @ApiProperty({ description: '身高(厘米)', example: 168 }) height?: number; diff --git a/src/users/dto/user-response.dto.ts b/src/users/dto/user-response.dto.ts index 00cacad..1380bce 100644 --- a/src/users/dto/user-response.dto.ts +++ b/src/users/dto/user-response.dto.ts @@ -25,7 +25,7 @@ export interface UserWithPurchaseStatus { maxUsageCount: number; favoriteTopicCount: number; isVip: boolean; - profile?: Pick; + profile?: Pick; } export class UserResponseDto implements BaseResponseDto { diff --git a/src/users/models/user-profile.model.ts b/src/users/models/user-profile.model.ts index 71f39ca..d621e15 100644 --- a/src/users/models/user-profile.model.ts +++ b/src/users/models/user-profile.model.ts @@ -54,6 +54,20 @@ export class UserProfile extends Model { }) declare weight: number | null; + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '初始体重(公斤)', + }) + declare initialWeight: number | null; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '目标体重(公斤)', + }) + declare targetWeight: number | null; + @Column({ type: DataType.FLOAT, allowNull: true, diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 71bd708..afabb8b 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -108,6 +108,8 @@ export class UsersService { dailyCaloriesGoal: profile?.dailyCaloriesGoal, pilatesPurposes: profile?.pilatesPurposes, weight: profile?.weight, + initialWeight: profile?.initialWeight, + targetWeight: profile?.targetWeight, height: profile?.height, activityLevel: profile?.activityLevel, } @@ -179,7 +181,7 @@ export class UsersService { // 更新用户昵称、头像 async updateUser(updateUserDto: UpdateUserDto): Promise { - const { userId, name, avatar, gender, birthDate, dailyStepsGoal, dailyCaloriesGoal, pilatesPurposes, weight, height, activityLevel } = updateUserDto; + const { userId, name, avatar, gender, birthDate, dailyStepsGoal, dailyCaloriesGoal, pilatesPurposes, weight, initialWeight, targetWeight, height, activityLevel } = updateUserDto; this.logger.log(`updateUser: ${JSON.stringify(updateUserDto, null, 2)}`); @@ -215,7 +217,7 @@ export class UsersService { await user.save(); // 更新或创建扩展信息 - if (dailyStepsGoal !== undefined || dailyCaloriesGoal !== undefined || pilatesPurposes !== undefined || weight !== undefined || height !== undefined || activityLevel !== undefined) { + if (dailyStepsGoal !== undefined || dailyCaloriesGoal !== undefined || pilatesPurposes !== undefined || weight !== undefined || initialWeight !== undefined || targetWeight !== undefined || height !== undefined || activityLevel !== undefined) { const [profile] = await this.userProfileModel.findOrCreate({ where: { userId }, defaults: { userId }, @@ -232,6 +234,14 @@ export class UsersService { } 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; @@ -650,6 +660,8 @@ export class UsersService { dailyCaloriesGoal: profileForLogin.dailyCaloriesGoal, pilatesPurposes: profileForLogin.pilatesPurposes, weight: profileForLogin.weight, + initialWeight: profileForLogin.initialWeight, + targetWeight: profileForLogin.targetWeight, height: profileForLogin.height, activityLevel: profileForLogin.activityLevel, } : undefined, @@ -862,6 +874,8 @@ export class UsersService { dailyCaloriesGoal: profileForGuest.dailyCaloriesGoal, pilatesPurposes: profileForGuest.pilatesPurposes, weight: profileForGuest.weight, + initialWeight: profileForGuest.initialWeight, + targetWeight: profileForGuest.targetWeight, height: profileForGuest.height, activityLevel: profileForGuest.activityLevel, } : undefined,