From 17ee96638e532662f75a37b1d1673c312d9680df Mon Sep 17 00:00:00 2001 From: richarjiang Date: Thu, 28 Aug 2025 09:46:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BD=93=E9=87=8D?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=8E=A5=E5=8F=A3=E5=8F=8A=E6=9E=9A=E4=B8=BE?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96AI=E6=95=99=E7=BB=83=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E9=A1=B9=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../models/activity-log.model.ts | 3 +- src/ai-coach/ai-coach.service.ts | 17 +- src/ai-coach/dto/ai-chat.dto.ts | 2 +- src/users/dto/weight-record.dto.ts | 72 ++++++++ src/users/users.controller.ts | 35 ++++ src/users/users.service.ts | 161 +++++++++++++++++- 6 files changed, 277 insertions(+), 13 deletions(-) create mode 100644 src/users/dto/weight-record.dto.ts diff --git a/src/activity-logs/models/activity-log.model.ts b/src/activity-logs/models/activity-log.model.ts index 749065e..d30d455 100644 --- a/src/activity-logs/models/activity-log.model.ts +++ b/src/activity-logs/models/activity-log.model.ts @@ -4,6 +4,7 @@ import { User } from '../../users/models/user.model'; export enum ActivityEntityType { USER = 'USER', USER_PROFILE = 'USER_PROFILE', + USER_WEIGHT_HISTORY = 'USER_WEIGHT_HISTORY', CHECKIN = 'CHECKIN', TRAINING_PLAN = 'TRAINING_PLAN', WORKOUT = 'WORKOUT', @@ -37,7 +38,7 @@ export class ActivityLog extends Model { declare user?: User; @Column({ - type: DataType.ENUM('USER', 'USER_PROFILE', 'CHECKIN', 'TRAINING_PLAN'), + type: DataType.ENUM('USER', 'USER_PROFILE', 'USER_WEIGHT_HISTORY', 'CHECKIN', 'TRAINING_PLAN', 'WORKOUT'), allowNull: false, comment: '实体类型', }) diff --git a/src/ai-coach/ai-coach.service.ts b/src/ai-coach/ai-coach.service.ts index 5f51f3f..010a308 100644 --- a/src/ai-coach/ai-coach.service.ts +++ b/src/ai-coach/ai-coach.service.ts @@ -9,6 +9,11 @@ import { UserProfile } from '../users/models/user-profile.model'; import { UsersService } from '../users/users.service'; import { DietAnalysisService, DietAnalysisResult, FoodRecognitionResult, FoodConfirmationOption } from './services/diet-analysis.service'; +enum SelectChoiceId { + Diet = 'diet_confirmation', + TrendAnalysis = 'trend_analysis' +} + const SYSTEM_PROMPT = `作为一名资深的健康管家兼营养分析师(Nutrition Analyst)和健身教练,我拥有丰富的专业知识,包括但不限于: 运动领域:运动解剖学、体态评估、疼痛预防、功能性训练、力量与柔韧性训练、运动损伤预防与恢复。 @@ -158,13 +163,13 @@ export class AiCoachService { userContent: string; systemNotice?: string; imageUrls?: string[]; - selectedChoiceId?: string; + selectedChoiceId?: SelectChoiceId; confirmationData?: any; }): Promise { try { // 1. 优先处理用户选择(选择逻辑) - if (params.selectedChoiceId) { + if (params.selectedChoiceId && [SelectChoiceId.Diet, SelectChoiceId.TrendAnalysis].includes(params.selectedChoiceId)) { return await this.handleUserChoice({ userId: params.userId, conversationId: params.conversationId, @@ -203,7 +208,7 @@ export class AiCoachService { userId: string; conversationId: string; userContent: string; - selectedChoiceId: string; + selectedChoiceId: SelectChoiceId; confirmationData?: any; }): Promise { @@ -217,7 +222,7 @@ export class AiCoachService { } // 处理饮食确认选择 - if (params.selectedChoiceId && params.confirmationData) { + if (params.selectedChoiceId === 'diet_confirmation' && params.confirmationData) { return await this.handleDietConfirmation({ userId: params.userId, conversationId: params.conversationId, @@ -535,8 +540,8 @@ export class AiCoachService { model: this.model, messages, stream: true, - temperature: 0.7, - max_tokens: 500, + temperature: 1, + max_completion_tokens: 500, }); const readable = new Readable({ read() { } }); diff --git a/src/ai-coach/dto/ai-chat.dto.ts b/src/ai-coach/dto/ai-chat.dto.ts index 8fbdaf5..a75576e 100644 --- a/src/ai-coach/dto/ai-chat.dto.ts +++ b/src/ai-coach/dto/ai-chat.dto.ts @@ -37,7 +37,7 @@ export class AiChatRequestDto { @ApiProperty({ required: false, description: '用户选择的选项ID(用于确认流程)' }) @IsOptional() @IsString() - selectedChoiceId?: string; + selectedChoiceId?: any; @ApiProperty({ required: false, description: '用户确认的数据(用于确认流程)' }) @IsOptional() diff --git a/src/users/dto/weight-record.dto.ts b/src/users/dto/weight-record.dto.ts new file mode 100644 index 0000000..74cb41a --- /dev/null +++ b/src/users/dto/weight-record.dto.ts @@ -0,0 +1,72 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsEnum, Min, Max } from 'class-validator'; +import { ResponseCode } from 'src/base.dto'; +import { WeightUpdateSource } from '../models/user-weight-history.model'; + +/** + * 更新体重记录请求DTO + */ +export class UpdateWeightRecordDto { + @ApiProperty({ + description: '体重(kg)', + example: 65.5, + minimum: 20, + maximum: 400, + }) + @IsNumber({}, { message: '体重必须是数字' }) + @Min(20, { message: '体重不能小于20kg' }) + @Max(400, { message: '体重不能大于400kg' }) + weight: number; + + @ApiProperty({ + description: '更新来源', + enum: WeightUpdateSource, + example: WeightUpdateSource.Manual, + required: false, + }) + @IsOptional() + @IsEnum(WeightUpdateSource, { message: '更新来源必须是有效值' }) + source?: WeightUpdateSource; +} + +/** + * 体重记录响应DTO + */ +export class WeightRecordResponseDto { + @ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息', example: 'success' }) + message: string; + + @ApiProperty({ + description: '体重记录数据', + example: { + id: 1, + userId: 'user123', + weight: 65.5, + source: 'manual', + createdAt: '2023-12-01T10:00:00.000Z', + updatedAt: '2023-12-01T10:00:00.000Z', + }, + }) + data: { + id: number; + userId: string; + weight: number; + source: WeightUpdateSource; + createdAt: Date; + updatedAt: Date; + }; +} + +/** + * 删除体重记录响应DTO + */ +export class DeleteWeightRecordResponseDto { + @ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS }) + code: ResponseCode; + + @ApiProperty({ description: '响应消息', example: 'success' }) + message: string; +} \ No newline at end of file diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 3007b95..c5b088c 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -30,6 +30,7 @@ import { GuestLoginDto, GuestLoginResponseDto, RefreshGuestTokenDto, RefreshGues 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 { Public } from '../common/decorators/public.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { AccessTokenPayload } from './services/apple-auth.service'; @@ -72,6 +73,40 @@ export class UsersController { 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) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 9b5d94b..23664d0 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -216,12 +216,13 @@ export class UsersService { 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) { - const [profile] = await this.userProfileModel.findOrCreate({ - where: { userId }, - defaults: { userId }, - }); 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; } @@ -278,6 +279,7 @@ export class UsersService { message: 'success', data: { ...user.toJSON(), + ...profile.toJSON(), isNew: false, } as any, }; @@ -318,7 +320,156 @@ export class UsersService { order: [['created_at', 'DESC']], limit, }); - return rows.map(r => ({ weight: r.weight, source: r.source, createdAt: r.createdAt })); + 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; + } } /**