From 0488fe62a1025ef869ee9418fa92f08f2dd8f7c6 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 31 Aug 2025 14:14:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A3=9F=E7=89=A9?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E5=9B=BE=E7=89=87URL=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E9=A3=9F=E7=89=A9=E5=B9=B6=E8=BD=AC=E6=8D=A2=E4=B8=BA=E9=A5=AE?= =?UTF-8?q?=E9=A3=9F=E8=AE=B0=E5=BD=95=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai-coach/ai-coach.module.ts | 5 +- src/diet-records/diet-records.controller.ts | 42 +++++++++- src/diet-records/diet-records.module.ts | 4 +- src/diet-records/diet-records.service.ts | 92 ++++++++++++++++++++- src/users/dto/diet-record.dto.ts | 80 ++++++++++++++++++ 5 files changed, 216 insertions(+), 7 deletions(-) diff --git a/src/ai-coach/ai-coach.module.ts b/src/ai-coach/ai-coach.module.ts index 1614d26..06de203 100644 --- a/src/ai-coach/ai-coach.module.ts +++ b/src/ai-coach/ai-coach.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { SequelizeModule } from '@nestjs/sequelize'; import { ConfigModule } from '@nestjs/config'; import { AiCoachController } from './ai-coach.controller'; @@ -14,11 +14,12 @@ import { DietRecordsModule } from '../diet-records/diet-records.module'; imports: [ ConfigModule, UsersModule, - DietRecordsModule, + forwardRef(() => DietRecordsModule), SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]), ], controllers: [AiCoachController], providers: [AiCoachService, DietAnalysisService], + exports: [DietAnalysisService], }) export class AiCoachModule { } diff --git a/src/diet-records/diet-records.controller.ts b/src/diet-records/diet-records.controller.ts index ab61ceb..c30e9f8 100644 --- a/src/diet-records/diet-records.controller.ts +++ b/src/diet-records/diet-records.controller.ts @@ -15,7 +15,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger'; import { DietRecordsService } from './diet-records.service'; -import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto } from '../users/dto/diet-record.dto'; +import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { AccessTokenPayload } from '../users/services/apple-auth.service'; @@ -121,4 +121,44 @@ export class DietRecordsController { const count = mealCount ? parseInt(mealCount) : 10; return this.dietRecordsService.getRecentNutritionSummary(user.sub, count); } + + /** + * 根据图片URL识别食物并转换为饮食记录格式 + */ + @UseGuards(JwtAuthGuard) + @Post('recognize-food-to-records') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '根据图片URL识别食物并转换为饮食记录格式' }) + @ApiBody({ type: FoodRecognitionRequestDto }) + @ApiResponse({ status: 200, description: '成功识别食物并转换为饮食记录格式', type: FoodRecognitionToDietRecordsResponseDto }) + async recognizeFoodToDietRecords( + @Body() requestDto: FoodRecognitionRequestDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`识别食物转饮食记录 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`); + return this.dietRecordsService.recognizeFoodToDietRecords( + requestDto.imageUrl, + requestDto.mealType + ); + } + + /** + * 根据图片URL识别食物(原始格式) + */ + @UseGuards(JwtAuthGuard) + @Post('recognize-food') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '根据图片URL识别食物' }) + @ApiBody({ type: FoodRecognitionRequestDto }) + @ApiResponse({ status: 200, description: '成功识别食物', type: FoodRecognitionResponseDto }) + async recognizeFood( + @Body() requestDto: FoodRecognitionRequestDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`识别食物 - 用户ID: ${user.sub}, 图片URL: ${requestDto.imageUrl}`); + return this.dietRecordsService.recognizeFood( + requestDto.imageUrl, + requestDto.mealType + ); + } } \ No newline at end of file diff --git a/src/diet-records/diet-records.module.ts b/src/diet-records/diet-records.module.ts index a7185b5..5f526b1 100644 --- a/src/diet-records/diet-records.module.ts +++ b/src/diet-records/diet-records.module.ts @@ -1,15 +1,17 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { SequelizeModule } from '@nestjs/sequelize'; import { DietRecordsController } from './diet-records.controller'; import { DietRecordsService } from './diet-records.service'; import { UserDietHistory } from '../users/models/user-diet-history.model'; import { ActivityLog } from '../activity-logs/models/activity-log.model'; import { UsersModule } from '../users/users.module'; +import { AiCoachModule } from '../ai-coach/ai-coach.module'; @Module({ imports: [ SequelizeModule.forFeature([UserDietHistory, ActivityLog]), UsersModule, + forwardRef(() => AiCoachModule), ], controllers: [DietRecordsController], providers: [DietRecordsService], diff --git a/src/diet-records/diet-records.service.ts b/src/diet-records/diet-records.service.ts index f718bbc..3012fbf 100644 --- a/src/diet-records/diet-records.service.ts +++ b/src/diet-records/diet-records.service.ts @@ -1,12 +1,13 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; import { Op, Transaction } from 'sequelize'; import { Sequelize } from 'sequelize-typescript'; import { UserDietHistory } from '../users/models/user-diet-history.model'; import { ActivityActionType, ActivityEntityType, ActivityLog } from '../activity-logs/models/activity-log.model'; -import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto } from '../users/dto/diet-record.dto'; -import { DietRecordSource } from '../users/models/user-diet-history.model'; +import { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, NutritionSummaryDto, FoodRecognitionRequestDto, FoodRecognitionResponseDto, FoodRecognitionToDietRecordsResponseDto } from '../users/dto/diet-record.dto'; +import { DietRecordSource, MealType } from '../users/models/user-diet-history.model'; import { ResponseCode } from '../base.dto'; +import { DietAnalysisService } from '../ai-coach/services/diet-analysis.service'; @Injectable() export class DietRecordsService { @@ -18,6 +19,8 @@ export class DietRecordsService { @InjectModel(ActivityLog) private readonly activityLogModel: typeof ActivityLog, private readonly sequelize: Sequelize, + @Inject(forwardRef(() => DietAnalysisService)) + private readonly dietAnalysisService: DietAnalysisService, ) { } /** @@ -286,6 +289,89 @@ export class DietRecordsService { }; } + /** + * 根据图片URL识别食物并转换为饮食记录格式 + * @param imageUrl 图片URL + * @param suggestedMealType 建议的餐次类型(可选) + * @returns 食物识别结果转换为饮食记录格式 + */ + async recognizeFoodToDietRecords( + imageUrl: string, + suggestedMealType?: MealType + ): Promise { + try { + this.logger.log(`recognizeFoodToDietRecords - imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`); + + // 调用 DietAnalysisService 进行食物识别 + const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl]); + + // 将识别结果转换为 CreateDietRecordDto 格式 + const dietRecords: CreateDietRecordDto[] = recognitionResult.recognizedItems.map(item => ({ + mealType: suggestedMealType || item.mealType, + foodName: item.foodName, + portionDescription: item.portion, + estimatedCalories: item.calories, + proteinGrams: item.nutritionData.proteinGrams, + carbohydrateGrams: item.nutritionData.carbohydrateGrams, + fatGrams: item.nutritionData.fatGrams, + fiberGrams: item.nutritionData.fiberGrams, + source: DietRecordSource.Vision, + imageUrl: imageUrl, + aiAnalysisResult: { + recognitionId: item.id, + confidence: recognitionResult.confidence, + analysisText: recognitionResult.analysisText, + originalLabel: item.label + } + })); + + return { + dietRecords, + analysisText: recognitionResult.analysisText, + confidence: recognitionResult.confidence, + imageUrl: imageUrl + }; + } catch (error) { + this.logger.error(`recognizeFoodToDietRecords error: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + } + + /** + * 根据图片URL识别食物(原始格式) + * @param imageUrl 图片URL + * @param suggestedMealType 建议的餐次类型(可选) + * @returns 食物识别结果 + */ + async recognizeFood( + imageUrl: string, + suggestedMealType?: MealType + ): Promise { + try { + this.logger.log(`recognizeFood - imageUrl: ${imageUrl}, suggestedMealType: ${suggestedMealType}`); + + // 调用 DietAnalysisService 进行食物识别 + const recognitionResult = await this.dietAnalysisService.recognizeFoodForConfirmation([imageUrl]); + + // 如果指定了建议的餐次类型,更新所有识别项的餐次类型 + if (suggestedMealType) { + recognitionResult.recognizedItems.forEach(item => { + item.mealType = suggestedMealType; + }); + } + + return { + recognizedItems: recognitionResult.recognizedItems, + analysisText: recognitionResult.analysisText, + confidence: recognitionResult.confidence, + imageUrl: imageUrl + }; + } catch (error) { + this.logger.error(`recognizeFood error: ${error instanceof Error ? error.message : String(error)}`); + throw error; + } + } + /** * 将数据库记录映射为DTO */ diff --git a/src/users/dto/diet-record.dto.ts b/src/users/dto/diet-record.dto.ts index cb6b825..c31a954 100644 --- a/src/users/dto/diet-record.dto.ts +++ b/src/users/dto/diet-record.dto.ts @@ -375,3 +375,83 @@ export class DietAnalysisResponseDto { @ApiProperty({ description: '改善建议' }) recommendations: string[]; } + +/** + * 食物识别请求 DTO + */ +export class FoodRecognitionRequestDto { + @ApiProperty({ description: '食物图片URL', example: 'https://example.com/food-image.jpg' }) + @IsString() + @IsNotEmpty() + imageUrl: string; + + @ApiProperty({ enum: MealType, description: '餐次类型(可选,系统会根据时间自动推断)', required: false }) + @IsOptional() + @IsEnum(MealType) + mealType?: MealType; +} + +/** + * 食物识别选项 + */ +export class FoodRecognitionOptionDto { + @ApiProperty({ description: '选项ID' }) + id: string; + + @ApiProperty({ description: '显示标签,如"一碗米饭 150卡"' }) + label: string; + + @ApiProperty({ description: '食物名称' }) + foodName: string; + + @ApiProperty({ description: '份量描述' }) + portion: string; + + @ApiProperty({ description: '估算热量' }) + calories: number; + + @ApiProperty({ enum: MealType, description: '餐次类型' }) + mealType: MealType; + + @ApiProperty({ description: '营养数据' }) + nutritionData: { + proteinGrams?: number; + carbohydrateGrams?: number; + fatGrams?: number; + fiberGrams?: number; + }; +} + +/** + * 食物识别响应 DTO + */ +export class FoodRecognitionResponseDto { + @ApiProperty({ type: [FoodRecognitionOptionDto], description: '识别到的食物选项列表' }) + recognizedItems: FoodRecognitionOptionDto[]; + + @ApiProperty({ description: '分析说明文字' }) + analysisText: string; + + @ApiProperty({ description: '识别置信度 0-100' }) + confidence: number; + + @ApiProperty({ description: '原始图片URL' }) + imageUrl: string; +} + +/** + * 食物识别转换为饮食记录的响应 DTO + */ +export class FoodRecognitionToDietRecordsResponseDto { + @ApiProperty({ type: [CreateDietRecordDto], description: '转换后的饮食记录数据列表' }) + dietRecords: CreateDietRecordDto[]; + + @ApiProperty({ description: '分析说明文字' }) + analysisText: string; + + @ApiProperty({ description: '识别置信度 0-100' }) + confidence: number; + + @ApiProperty({ description: '原始图片URL' }) + imageUrl: string; +}