From a1c21d8a23c132872c4ae0a2530a453dc993d3dc Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 29 Aug 2025 08:48:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=A5=AE=E9=A3=9F?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=A8=A1=E5=9D=97=EF=BC=8C=E5=90=AB=E5=A2=9E?= =?UTF-8?q?=E5=88=A0=E6=94=B9=E6=9F=A5=E5=8F=8A=E8=90=A5=E5=85=BB=E6=B1=87?= =?UTF-8?q?=E6=80=BB=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ai-coach/ai-coach.module.ts | 2 + .../services/diet-analysis.service.spec.ts | 16 +- .../services/diet-analysis.service.ts | 12 +- src/app.module.ts | 2 + src/diet-records/diet-records.controller.ts | 124 +++++++ src/diet-records/diet-records.module.ts | 18 + src/diet-records/diet-records.service.ts | 310 ++++++++++++++++++ src/users/dto/diet-record.dto.ts | 6 + src/users/users.controller.ts | 78 ----- src/users/users.service.ts | 266 +-------------- 10 files changed, 479 insertions(+), 355 deletions(-) create mode 100644 src/diet-records/diet-records.controller.ts create mode 100644 src/diet-records/diet-records.module.ts create mode 100644 src/diet-records/diet-records.service.ts diff --git a/src/ai-coach/ai-coach.module.ts b/src/ai-coach/ai-coach.module.ts index 2a19b23..1614d26 100644 --- a/src/ai-coach/ai-coach.module.ts +++ b/src/ai-coach/ai-coach.module.ts @@ -8,11 +8,13 @@ import { AiMessage } from './models/ai-message.model'; import { AiConversation } from './models/ai-conversation.model'; import { PostureAssessment } from './models/posture-assessment.model'; import { UsersModule } from '../users/users.module'; +import { DietRecordsModule } from '../diet-records/diet-records.module'; @Module({ imports: [ ConfigModule, UsersModule, + DietRecordsModule, SequelizeModule.forFeature([AiConversation, AiMessage, PostureAssessment]), ], controllers: [AiCoachController], diff --git a/src/ai-coach/services/diet-analysis.service.spec.ts b/src/ai-coach/services/diet-analysis.service.spec.ts index 9aca897..65caa47 100644 --- a/src/ai-coach/services/diet-analysis.service.spec.ts +++ b/src/ai-coach/services/diet-analysis.service.spec.ts @@ -1,16 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; import { DietAnalysisService } from './diet-analysis.service'; -import { UsersService } from '../../users/users.service'; +import { DietRecordsService } from '../../diet-records/diet-records.service'; describe('DietAnalysisService - Text Analysis', () => { let service: DietAnalysisService; - let mockUsersService: Partial; + let mockDietRecordsService: Partial; let mockConfigService: Partial; beforeEach(async () => { // Mock services - mockUsersService = { + mockDietRecordsService = { addDietRecord: jest.fn().mockResolvedValue({}), getDietHistory: jest.fn().mockResolvedValue({ total: 0, records: [] }), getRecentNutritionSummary: jest.fn().mockResolvedValue({ @@ -41,7 +41,7 @@ describe('DietAnalysisService - Text Analysis', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ DietAnalysisService, - { provide: UsersService, useValue: mockUsersService }, + { provide: DietRecordsService, useValue: mockDietRecordsService }, { provide: ConfigService, useValue: mockConfigService }, ], }).compile(); @@ -57,7 +57,7 @@ describe('DietAnalysisService - Text Analysis', () => { it('should build a proper prompt for text analysis', () => { // 通过反射访问私有方法进行测试 const prompt = (service as any).buildTextDietAnalysisPrompt('breakfast'); - + expect(prompt).toContain('作为专业营养分析师'); expect(prompt).toContain('breakfast'); expect(prompt).toContain('shouldRecord'); @@ -130,12 +130,12 @@ describe('DietAnalysisService - Text Analysis', () => { }; const result = await service.processDietRecord('test-user-id', mockAnalysisResult); - + expect(result).toBeDefined(); expect(result?.foodName).toBe('燕麦粥'); expect(result?.source).toBe('manual'); // 文本记录应该是manual源 expect(result?.imageUrl).toBeUndefined(); - expect(mockUsersService.addDietRecord).toHaveBeenCalledWith('test-user-id', expect.objectContaining({ + expect(mockDietRecordsService.addDietRecord).toHaveBeenCalledWith('test-user-id', expect.objectContaining({ foodName: '燕麦粥', source: 'manual' })); @@ -164,7 +164,7 @@ describe('DietAnalysisService - Text Analysis', () => { }; const result = await service.processDietRecord('test-user-id', mockAnalysisResult, 'https://example.com/image.jpg'); - + expect(result).toBeDefined(); expect(result?.foodName).toBe('鸡胸肉沙拉'); expect(result?.source).toBe('vision'); // 有图片URL应该是vision源 diff --git a/src/ai-coach/services/diet-analysis.service.ts b/src/ai-coach/services/diet-analysis.service.ts index 3737184..9bd675d 100644 --- a/src/ai-coach/services/diet-analysis.service.ts +++ b/src/ai-coach/services/diet-analysis.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { OpenAI } from 'openai'; -import { UsersService } from '../../users/users.service'; +import { DietRecordsService } from '../../diet-records/diet-records.service'; import { CreateDietRecordDto } from '../../users/dto/diet-record.dto'; import { MealType, DietRecordSource } from '../../users/models/user-diet-history.model'; @@ -65,7 +65,7 @@ export class DietAnalysisService { constructor( private readonly configService: ConfigService, - private readonly usersService: UsersService, + private readonly dietRecordsService: DietRecordsService, ) { const dashScopeApiKey = this.configService.get('DASHSCOPE_API_KEY') || 'sk-e3ff4494c2f1463a8910d5b3d05d3143'; const baseURL = this.configService.get('DASHSCOPE_BASE_URL') || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; @@ -240,7 +240,7 @@ export class DietAnalysisService { } }; - await this.usersService.addDietRecord(userId, createDto); + await this.dietRecordsService.addDietRecord(userId, createDto); return createDto; } catch (error) { this.logger.error(`用户确认添加饮食记录失败: ${error instanceof Error ? error.message : String(error)}`); @@ -278,7 +278,7 @@ export class DietAnalysisService { aiAnalysisResult: analysisResult, }; - await this.usersService.addDietRecord(userId, createDto); + await this.dietRecordsService.addDietRecord(userId, createDto); return createDto; } catch (error) { this.logger.error(`自动添加饮食记录失败: ${error instanceof Error ? error.message : String(error)}`); @@ -294,7 +294,7 @@ export class DietAnalysisService { async buildUserNutritionContext(userId: string): Promise { try { // 获取最近10顿饮食记录 - const recentDietHistory = await this.usersService.getDietHistory(userId, { limit: 10 }); + const recentDietHistory = await this.dietRecordsService.getDietHistory(userId, { limit: 10 }); if (recentDietHistory.total === 0) { return '\n\n=== 用户营养信息 ===\n这是用户的第一次饮食记录,请给予鼓励并介绍饮食记录的价值。\n'; @@ -303,7 +303,7 @@ export class DietAnalysisService { let context = '\n\n=== 用户最近饮食记录分析 ===\n'; // 获取营养汇总 - const nutritionSummary = await this.usersService.getRecentNutritionSummary(userId, 10); + const nutritionSummary = await this.dietRecordsService.getRecentNutritionSummary(userId, 10); context += this.buildNutritionSummaryText(nutritionSummary); context += this.buildMealDistributionText(recentDietHistory.records); diff --git a/src/app.module.ts b/src/app.module.ts index 4a79ad6..495df97 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { ExercisesModule } from './exercises/exercises.module'; import { WorkoutsModule } from './workouts/workouts.module'; import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module'; import { GoalsModule } from './goals/goals.module'; +import { DietRecordsModule } from './diet-records/diet-records.module'; @Module({ imports: [ @@ -35,6 +36,7 @@ import { GoalsModule } from './goals/goals.module'; WorkoutsModule, MoodCheckinsModule, GoalsModule, + DietRecordsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/diet-records/diet-records.controller.ts b/src/diet-records/diet-records.controller.ts new file mode 100644 index 0000000..ab61ceb --- /dev/null +++ b/src/diet-records/diet-records.controller.ts @@ -0,0 +1,124 @@ +import { + Controller, + Get, + Post, + Body, + Param, + HttpCode, + HttpStatus, + Put, + Delete, + Query, + Logger, + UseGuards, + NotFoundException, +} 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 { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { AccessTokenPayload } from '../users/services/apple-auth.service'; + +@ApiTags('diet-records') +@Controller('diet-records') +export class DietRecordsController { + private readonly logger = new Logger(DietRecordsController.name); + + constructor( + private readonly dietRecordsService: DietRecordsService, + ) { } + + /** + * 添加饮食记录 + */ + @UseGuards(JwtAuthGuard) + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: '添加饮食记录' }) + @ApiBody({ type: CreateDietRecordDto }) + @ApiResponse({ status: 201, description: '成功添加饮食记录', type: DietRecordResponseDto }) + async addDietRecord( + @Body() createDto: CreateDietRecordDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`添加饮食记录 - 用户ID: ${user.sub}, 食物: ${createDto.foodName}`); + return this.dietRecordsService.addDietRecord(user.sub, createDto); + } + + /** + * 获取饮食记录历史 + */ + @UseGuards(JwtAuthGuard) + @Get() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取饮食记录历史' }) + @ApiQuery({ name: 'startDate', required: false, description: '开始日期' }) + @ApiQuery({ name: 'endDate', required: false, description: '结束日期' }) + @ApiQuery({ name: 'mealType', required: false, description: '餐次类型' }) + @ApiQuery({ name: 'page', required: false, description: '页码' }) + @ApiQuery({ name: 'limit', required: false, description: '每页数量' }) + @ApiResponse({ status: 200, description: '成功获取饮食记录', type: DietHistoryResponseDto }) + async getDietHistory( + @Query() query: GetDietHistoryQueryDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`获取饮食记录 - 用户ID: ${user.sub}`); + return this.dietRecordsService.getDietHistory(user.sub, query); + } + + /** + * 更新饮食记录 + */ + @UseGuards(JwtAuthGuard) + @Put(':id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新饮食记录' }) + @ApiBody({ type: UpdateDietRecordDto }) + @ApiResponse({ status: 200, description: '成功更新饮食记录', type: DietRecordResponseDto }) + async updateDietRecord( + @Param('id') recordId: string, + @Body() updateDto: UpdateDietRecordDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`更新饮食记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`); + return this.dietRecordsService.updateDietRecord(user.sub, parseInt(recordId), updateDto); + } + + /** + * 删除饮食记录 + */ + @UseGuards(JwtAuthGuard) + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: '删除饮食记录' }) + @ApiResponse({ status: 204, description: '成功删除饮食记录' }) + async deleteDietRecord( + @Param('id') recordId: string, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`删除饮食记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`); + const success = await this.dietRecordsService.deleteDietRecord(user.sub, parseInt(recordId)); + if (!success) { + throw new NotFoundException('饮食记录不存在'); + } + } + + /** + * 获取营养汇总分析 + */ + @UseGuards(JwtAuthGuard) + @Get('nutrition-summary') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取营养汇总分析' }) + @ApiQuery({ name: 'mealCount', required: false, description: '分析的餐次数量,默认10' }) + @ApiResponse({ status: 200, description: '成功获取营养汇总', type: NutritionSummaryDto }) + async getNutritionSummary( + @Query('mealCount') mealCount: string, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`获取营养汇总 - 用户ID: ${user.sub}`); + const count = mealCount ? parseInt(mealCount) : 10; + return this.dietRecordsService.getRecentNutritionSummary(user.sub, count); + } +} \ No newline at end of file diff --git a/src/diet-records/diet-records.module.ts b/src/diet-records/diet-records.module.ts new file mode 100644 index 0000000..a7185b5 --- /dev/null +++ b/src/diet-records/diet-records.module.ts @@ -0,0 +1,18 @@ +import { Module } 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'; + +@Module({ + imports: [ + SequelizeModule.forFeature([UserDietHistory, ActivityLog]), + UsersModule, + ], + controllers: [DietRecordsController], + providers: [DietRecordsService], + exports: [DietRecordsService], +}) +export class DietRecordsModule { } \ No newline at end of file diff --git a/src/diet-records/diet-records.service.ts b/src/diet-records/diet-records.service.ts new file mode 100644 index 0000000..1d8c1c1 --- /dev/null +++ b/src/diet-records/diet-records.service.ts @@ -0,0 +1,310 @@ +import { Injectable, Logger, NotFoundException } 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 { 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 { ResponseCode } from '../base.dto'; + +@Injectable() +export class DietRecordsService { + private readonly logger = new Logger(DietRecordsService.name); + + constructor( + @InjectModel(UserDietHistory) + private readonly userDietHistoryModel: typeof UserDietHistory, + @InjectModel(ActivityLog) + private readonly activityLogModel: typeof ActivityLog, + private readonly sequelize: Sequelize, + ) { } + + /** + * 添加饮食记录 + */ + async addDietRecord(userId: string, createDto: CreateDietRecordDto): Promise { + const t = await this.sequelize.transaction(); + try { + // 创建饮食记录 + const dietRecord = await this.userDietHistoryModel.create({ + userId, + mealType: createDto.mealType, + foodName: createDto.foodName, + foodDescription: createDto.foodDescription, + weightGrams: createDto.weightGrams, + portionDescription: createDto.portionDescription, + estimatedCalories: createDto.estimatedCalories, + proteinGrams: createDto.proteinGrams, + carbohydrateGrams: createDto.carbohydrateGrams, + fatGrams: createDto.fatGrams, + fiberGrams: createDto.fiberGrams, + sugarGrams: createDto.sugarGrams, + sodiumMg: createDto.sodiumMg, + additionalNutrition: createDto.additionalNutrition, + source: createDto.source || DietRecordSource.Manual, + mealTime: createDto.mealTime ? new Date(createDto.mealTime) : new Date(), + imageUrl: createDto.imageUrl, + aiAnalysisResult: createDto.aiAnalysisResult, + notes: createDto.notes, + }, { transaction: t }); + + // 记录活动日志 + await this.activityLogModel.create({ + userId, + action: 'diet_record_added', + details: { + recordId: dietRecord.id, + foodName: createDto.foodName, + mealType: createDto.mealType, + calories: createDto.estimatedCalories, + source: createDto.source || DietRecordSource.Manual, + }, + }, { transaction: t }); + + await t.commit(); + + return this.mapDietRecordToDto(dietRecord) + } catch (e) { + await t.rollback(); + this.logger.error(`addDietRecord error: ${e instanceof Error ? e.message : String(e)}`); + throw e; + } + } + + /** + * 通过视觉识别添加饮食记录 + */ + async addDietRecordByVision(userId: string, dietData: CreateDietRecordDto): Promise { + return this.addDietRecord(userId, { + ...dietData, + source: DietRecordSource.Vision + }); + } + + /** + * 获取饮食记录历史 + */ + async getDietHistory(userId: string, query: GetDietHistoryQueryDto): Promise { + const where: any = { userId, deleted: false }; + + // 日期过滤 + if (query.startDate || query.endDate) { + where.createdAt = {} as any; + if (query.startDate) where.createdAt[Op.gte] = new Date(query.startDate); + if (query.endDate) where.createdAt[Op.lte] = new Date(query.endDate); + } + + // 餐次类型过滤 + if (query.mealType) { + where.mealType = query.mealType; + } + + const limit = Math.min(100, Math.max(1, query.limit || 20)); + const page = Math.max(1, query.page || 1); + const offset = (page - 1) * limit; + + const { rows, count } = await this.userDietHistoryModel.findAndCountAll({ + where, + order: [['created_at', 'DESC']], + limit, + offset, + }); + + const totalPages = Math.ceil(count / limit); + + return { + records: rows.map(record => this.mapDietRecordToDto(record)), + total: count, + page, + limit, + totalPages, + }; + } + + /** + * 更新饮食记录 + */ + async updateDietRecord(userId: string, recordId: number, updateDto: UpdateDietRecordDto): Promise { + const t = await this.sequelize.transaction(); + try { + const record = await this.userDietHistoryModel.findOne({ + where: { id: recordId, userId, deleted: false }, + transaction: t, + }); + + if (!record) { + throw new NotFoundException('饮食记录不存在'); + } + + // 更新记录 + await record.update({ + mealType: updateDto.mealType ?? record.mealType, + foodName: updateDto.foodName ?? record.foodName, + foodDescription: updateDto.foodDescription ?? record.foodDescription, + weightGrams: updateDto.weightGrams ?? record.weightGrams, + portionDescription: updateDto.portionDescription ?? record.portionDescription, + estimatedCalories: updateDto.estimatedCalories ?? record.estimatedCalories, + proteinGrams: updateDto.proteinGrams ?? record.proteinGrams, + carbohydrateGrams: updateDto.carbohydrateGrams ?? record.carbohydrateGrams, + fatGrams: updateDto.fatGrams ?? record.fatGrams, + fiberGrams: updateDto.fiberGrams ?? record.fiberGrams, + sugarGrams: updateDto.sugarGrams ?? record.sugarGrams, + sodiumMg: updateDto.sodiumMg ?? record.sodiumMg, + additionalNutrition: updateDto.additionalNutrition ?? record.additionalNutrition, + mealTime: updateDto.mealTime ? new Date(updateDto.mealTime) : record.mealTime, + imageUrl: updateDto.imageUrl ?? record.imageUrl, + notes: updateDto.notes ?? record.notes, + }, { transaction: t }); + + // 记录活动日志 + await this.activityLogModel.create({ + userId, + action: 'diet_record_updated', + details: { + recordId: record.id, + foodName: record.foodName, + changes: updateDto, + }, + }, { transaction: t }); + + await t.commit(); + + return this.mapDietRecordToDto(record) + } catch (e) { + await t.rollback(); + this.logger.error(`updateDietRecord error: ${e instanceof Error ? e.message : String(e)}`); + throw e; + } + } + + /** + * 删除饮食记录 + */ + async deleteDietRecord(userId: string, recordId: number): Promise { + const t = await this.sequelize.transaction(); + try { + const record = await this.userDietHistoryModel.findOne({ + where: { id: recordId, userId, deleted: false }, + transaction: t, + }); + + if (!record) { + return false; + } + + // 软删除 + await record.update({ deleted: true }, { transaction: t }); + + // 记录活动日志 + await this.activityLogModel.create({ + userId, + action: 'diet_record_deleted', + details: { + recordId: record.id, + foodName: record.foodName, + mealType: record.mealType, + }, + }, { transaction: t }); + + await t.commit(); + return true; + } catch (e) { + await t.rollback(); + this.logger.error(`deleteDietRecord error: ${e instanceof Error ? e.message : String(e)}`); + throw e; + } + } + + /** + * 获取最近的营养汇总 + */ + async getRecentNutritionSummary(userId: string, mealCount: number = 10): Promise { + const records = await this.userDietHistoryModel.findAll({ + where: { userId, deleted: false }, + order: [['created_at', 'DESC']], + limit: mealCount, + }); + + if (records.length === 0) { + const now = new Date(); + return { + totalCalories: 0, + totalProtein: 0, + totalCarbohydrates: 0, + totalFat: 0, + totalFiber: 0, + totalSugar: 0, + totalSodium: 0, + recordCount: 0, + dateRange: { + start: now, + end: now, + }, + averageCaloriesPerMeal: 0, + mealTypeDistribution: {}, + }; + } + + const totalCalories = records.reduce((sum, r) => sum + (r.estimatedCalories || 0), 0); + const totalProtein = records.reduce((sum, r) => sum + (r.proteinGrams || 0), 0); + const totalCarbohydrates = records.reduce((sum, r) => sum + (r.carbohydrateGrams || 0), 0); + const totalFat = records.reduce((sum, r) => sum + (r.fatGrams || 0), 0); + const totalFiber = records.reduce((sum, r) => sum + (r.fiberGrams || 0), 0); + const totalSugar = records.reduce((sum, r) => sum + (r.sugarGrams || 0), 0); + const totalSodium = records.reduce((sum, r) => sum + (r.sodiumMg || 0), 0); + + // 餐次分布统计 + const mealTypeDistribution = records.reduce((dist, record) => { + const mealType = record.mealType; + dist[mealType] = (dist[mealType] || 0) + 1; + return dist; + }, {} as Record); + + return { + totalCalories, + totalProtein, + totalCarbohydrates, + totalFat, + totalFiber, + totalSugar, + totalSodium, + recordCount: records.length, + dateRange: { + start: records[records.length - 1].createdAt, + end: records[0].createdAt, + }, + averageCaloriesPerMeal: records.length > 0 ? totalCalories / records.length : 0, + mealTypeDistribution, + }; + } + + /** + * 将数据库记录映射为DTO + */ + private mapDietRecordToDto(record: UserDietHistory): any { + return { + id: record.id, + mealType: record.mealType, + foodName: record.foodName, + foodDescription: record.foodDescription, + weightGrams: record.weightGrams, + portionDescription: record.portionDescription, + estimatedCalories: record.estimatedCalories, + proteinGrams: record.proteinGrams, + carbohydrateGrams: record.carbohydrateGrams, + fatGrams: record.fatGrams, + fiberGrams: record.fiberGrams, + sugarGrams: record.sugarGrams, + sodiumMg: record.sodiumMg, + additionalNutrition: record.additionalNutrition, + source: record.source, + mealTime: record.mealTime, + imageUrl: record.imageUrl, + aiAnalysisResult: record.aiAnalysisResult, + notes: record.notes, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; + } +} \ No newline at end of file diff --git a/src/users/dto/diet-record.dto.ts b/src/users/dto/diet-record.dto.ts index cd5b80b..cb6b825 100644 --- a/src/users/dto/diet-record.dto.ts +++ b/src/users/dto/diet-record.dto.ts @@ -351,6 +351,12 @@ export class NutritionSummaryDto { start: Date; end: Date; }; + + @ApiProperty({ description: '平均每餐热量' }) + averageCaloriesPerMeal: number; + + @ApiProperty({ description: '餐次类型分布', example: { 'breakfast': 5, 'lunch': 3, 'dinner': 2 } }) + mealTypeDistribution: Record; } export class DietAnalysisResponseDto { diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index c5b088c..8e11ca4 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -25,7 +25,6 @@ import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/s 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 { CreateDietRecordDto, UpdateDietRecordDto, GetDietHistoryQueryDto, DietRecordResponseDto, DietHistoryResponseDto, DietAnalysisResponseDto } from './dto/diet-record.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'; @@ -276,83 +275,6 @@ export class UsersController { return this.usersService.restorePurchase(restorePurchaseDto, user.sub, clientIp, userAgent); } - // ==================== 饮食记录相关接口 ==================== - - /** - * 添加饮食记录 - */ - @UseGuards(JwtAuthGuard) - @Post('diet-records') - @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: '添加饮食记录' }) - @ApiBody({ type: CreateDietRecordDto }) - @ApiResponse({ status: 201, description: '成功添加饮食记录', type: DietRecordResponseDto }) - async addDietRecord( - @Body() createDto: CreateDietRecordDto, - @CurrentUser() user: AccessTokenPayload, - ): Promise { - this.logger.log(`添加饮食记录 - 用户ID: ${user.sub}, 食物: ${createDto.foodName}`); - return this.usersService.addDietRecord(user.sub, createDto); - } - - /** - * 获取饮食记录历史 - */ - @UseGuards(JwtAuthGuard) - @Get('diet-records') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '获取饮食记录历史' }) - @ApiQuery({ name: 'startDate', required: false, description: '开始日期' }) - @ApiQuery({ name: 'endDate', required: false, description: '结束日期' }) - @ApiQuery({ name: 'mealType', required: false, description: '餐次类型' }) - @ApiQuery({ name: 'page', required: false, description: '页码' }) - @ApiQuery({ name: 'limit', required: false, description: '每页数量' }) - @ApiResponse({ status: 200, description: '成功获取饮食记录', type: DietHistoryResponseDto }) - async getDietHistory( - @Query() query: GetDietHistoryQueryDto, - @CurrentUser() user: AccessTokenPayload, - ): Promise { - this.logger.log(`获取饮食记录 - 用户ID: ${user.sub}`); - return this.usersService.getDietHistory(user.sub, query); - } - - /** - * 更新饮食记录 - */ - @UseGuards(JwtAuthGuard) - @Put('diet-records/:id') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '更新饮食记录' }) - @ApiBody({ type: UpdateDietRecordDto }) - @ApiResponse({ status: 200, description: '成功更新饮食记录', type: DietRecordResponseDto }) - async updateDietRecord( - @Param('id') recordId: string, - @Body() updateDto: UpdateDietRecordDto, - @CurrentUser() user: AccessTokenPayload, - ): Promise { - this.logger.log(`更新饮食记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`); - return this.usersService.updateDietRecord(user.sub, parseInt(recordId), updateDto); - } - - /** - * 删除饮食记录 - */ - @UseGuards(JwtAuthGuard) - @Delete('diet-records/:id') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: '删除饮食记录' }) - @ApiResponse({ status: 204, description: '成功删除饮食记录' }) - async deleteDietRecord( - @Param('id') recordId: string, - @CurrentUser() user: AccessTokenPayload, - ): Promise { - this.logger.log(`删除饮食记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`); - const success = await this.usersService.deleteDietRecord(user.sub, parseInt(recordId)); - if (!success) { - throw new NotFoundException('饮食记录不存在'); - } - } - // ==================== 用户活跃记录相关接口 ==================== /** diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 23664d0..2678bbb 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -31,12 +31,12 @@ import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, A import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model'; import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model'; import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model'; -import { UserDietHistory, DietRecordSource, MealType } from './models/user-diet-history.model'; + import { ActivityLogsService } from '../activity-logs/activity-logs.service'; import { UserActivityService } from './services/user-activity.service'; import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto'; 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 = 5; @@ -61,8 +61,7 @@ export class UsersService { private userProfileModel: typeof UserProfile, @InjectModel(UserWeightHistory) private userWeightHistoryModel: typeof UserWeightHistory, - @InjectModel(UserDietHistory) - private userDietHistoryModel: typeof UserDietHistory, + @InjectConnection() private sequelize: Sequelize, private readonly activityLogsService: ActivityLogsService, @@ -472,274 +471,15 @@ export class UsersService { } } - /** - * 添加饮食记录 - */ - async addDietRecord(userId: string, createDto: CreateDietRecordDto): Promise { - const t = await this.sequelize.transaction(); - try { - const dietRecord = await this.userDietHistoryModel.create({ - userId, - mealType: createDto.mealType, - foodName: createDto.foodName, - foodDescription: createDto.foodDescription || null, - weightGrams: createDto.weightGrams || null, - portionDescription: createDto.portionDescription || null, - estimatedCalories: createDto.estimatedCalories || null, - proteinGrams: createDto.proteinGrams || null, - carbohydrateGrams: createDto.carbohydrateGrams || null, - fatGrams: createDto.fatGrams || null, - fiberGrams: createDto.fiberGrams || null, - sugarGrams: createDto.sugarGrams || null, - sodiumMg: createDto.sodiumMg || null, - additionalNutrition: createDto.additionalNutrition || null, - source: createDto.source || DietRecordSource.Manual, - mealTime: createDto.mealTime ? new Date(createDto.mealTime) : null, - imageUrl: createDto.imageUrl || null, - aiAnalysisResult: createDto.aiAnalysisResult || null, - notes: createDto.notes || null, - deleted: false, - }, { transaction: t }); - await t.commit(); - // 记录活动日志 - await this.activityLogsService.record({ - userId, - entityType: ActivityEntityType.USER_PROFILE, - entityId: userId, - action: ActivityActionType.UPDATE, - changes: { diet_record_added: dietRecord.id }, - metadata: { - source: createDto.source || 'manual', - mealType: createDto.mealType, - foodName: createDto.foodName - }, - }); - return this.mapDietRecordToDto(dietRecord); - } catch (e) { - await t.rollback(); - this.logger.error(`addDietRecord error: ${e instanceof Error ? e.message : String(e)}`); - throw e; - } - } - /** - * 通过视觉识别添加饮食记录 - */ - async addDietRecordByVision(userId: string, dietData: CreateDietRecordDto): Promise { - return this.addDietRecord(userId, { - ...dietData, - source: DietRecordSource.Vision - }); - } - /** - * 获取饮食记录历史 - */ - async getDietHistory(userId: string, query: GetDietHistoryQueryDto): Promise { - const where: any = { userId, deleted: false }; - // 日期过滤 - if (query.startDate || query.endDate) { - where.createdAt = {} as any; - if (query.startDate) where.createdAt[Op.gte] = new Date(query.startDate); - if (query.endDate) where.createdAt[Op.lte] = new Date(query.endDate); - } - // 餐次类型过滤 - if (query.mealType) { - where.mealType = query.mealType; - } - const limit = Math.min(100, Math.max(1, query.limit || 20)); - const page = Math.max(1, query.page || 1); - const offset = (page - 1) * limit; - const { rows, count } = await this.userDietHistoryModel.findAndCountAll({ - where, - order: [['created_at', 'DESC']], - limit, - offset, - }); - - const totalPages = Math.ceil(count / limit); - - return { - records: rows.map(record => this.mapDietRecordToDto(record)), - total: count, - page, - limit, - totalPages, - }; - } - - /** - * 更新饮食记录 - */ - async updateDietRecord(userId: string, recordId: number, updateDto: UpdateDietRecordDto): Promise { - const t = await this.sequelize.transaction(); - try { - const record = await this.userDietHistoryModel.findOne({ - where: { id: recordId, userId, deleted: false }, - transaction: t, - }); - - if (!record) { - throw new NotFoundException('饮食记录不存在'); - } - - // 更新字段 - if (updateDto.mealType !== undefined) record.mealType = updateDto.mealType; - if (updateDto.foodName !== undefined) record.foodName = updateDto.foodName; - if (updateDto.foodDescription !== undefined) record.foodDescription = updateDto.foodDescription; - if (updateDto.weightGrams !== undefined) record.weightGrams = updateDto.weightGrams; - if (updateDto.portionDescription !== undefined) record.portionDescription = updateDto.portionDescription; - if (updateDto.estimatedCalories !== undefined) record.estimatedCalories = updateDto.estimatedCalories; - if (updateDto.proteinGrams !== undefined) record.proteinGrams = updateDto.proteinGrams; - if (updateDto.carbohydrateGrams !== undefined) record.carbohydrateGrams = updateDto.carbohydrateGrams; - if (updateDto.fatGrams !== undefined) record.fatGrams = updateDto.fatGrams; - if (updateDto.fiberGrams !== undefined) record.fiberGrams = updateDto.fiberGrams; - if (updateDto.sugarGrams !== undefined) record.sugarGrams = updateDto.sugarGrams; - if (updateDto.sodiumMg !== undefined) record.sodiumMg = updateDto.sodiumMg; - if (updateDto.additionalNutrition !== undefined) record.additionalNutrition = updateDto.additionalNutrition; - if (updateDto.mealTime !== undefined) record.mealTime = updateDto.mealTime ? new Date(updateDto.mealTime) : null; - if (updateDto.imageUrl !== undefined) record.imageUrl = updateDto.imageUrl; - if (updateDto.aiAnalysisResult !== undefined) record.aiAnalysisResult = updateDto.aiAnalysisResult; - if (updateDto.notes !== undefined) record.notes = updateDto.notes; - - await record.save({ transaction: t }); - await t.commit(); - - // 记录活动日志 - await this.activityLogsService.record({ - userId, - entityType: ActivityEntityType.USER_PROFILE, - entityId: userId, - action: ActivityActionType.UPDATE, - changes: { diet_record_updated: recordId }, - metadata: { updateDto }, - }); - - return this.mapDietRecordToDto(record); - } catch (e) { - await t.rollback(); - this.logger.error(`updateDietRecord error: ${e instanceof Error ? e.message : String(e)}`); - throw e; - } - } - - /** - * 删除饮食记录 - */ - async deleteDietRecord(userId: string, recordId: number): Promise { - const t = await this.sequelize.transaction(); - try { - const record = await this.userDietHistoryModel.findOne({ - where: { id: recordId, userId, deleted: false }, - transaction: t, - }); - - if (!record) { - return false; - } - - record.deleted = true; - await record.save({ transaction: t }); - await t.commit(); - - // 记录活动日志 - await this.activityLogsService.record({ - userId, - entityType: ActivityEntityType.USER_PROFILE, - entityId: userId, - action: ActivityActionType.DELETE, - changes: { diet_record_deleted: recordId }, - metadata: { foodName: record.foodName, mealType: record.mealType }, - }); - - return true; - } catch (e) { - await t.rollback(); - this.logger.error(`deleteDietRecord error: ${e instanceof Error ? e.message : String(e)}`); - throw e; - } - } - - /** - * 获取最近N顿饮食的营养汇总 - */ - async getRecentNutritionSummary(userId: string, mealCount: number = 10): Promise { - const records = await this.userDietHistoryModel.findAll({ - where: { userId, deleted: false }, - order: [['created_at', 'DESC']], - limit: mealCount, - }); - - if (records.length === 0) { - throw new NotFoundException('暂无饮食记录'); - } - - const summary = records.reduce((acc, record) => { - acc.totalCalories += record.estimatedCalories || 0; - acc.totalProtein += record.proteinGrams || 0; - acc.totalCarbohydrates += record.carbohydrateGrams || 0; - acc.totalFat += record.fatGrams || 0; - acc.totalFiber += record.fiberGrams || 0; - acc.totalSugar += record.sugarGrams || 0; - acc.totalSodium += record.sodiumMg || 0; - return acc; - }, { - totalCalories: 0, - totalProtein: 0, - totalCarbohydrates: 0, - totalFat: 0, - totalFiber: 0, - totalSugar: 0, - totalSodium: 0, - }); - - const oldestRecord = records[records.length - 1]; - const newestRecord = records[0]; - - return { - ...summary, - recordCount: records.length, - dateRange: { - start: oldestRecord.createdAt, - end: newestRecord.createdAt, - }, - }; - } - - /** - * 将数据库模型转换为DTO - */ - private mapDietRecordToDto(record: UserDietHistory): DietRecordResponseDto { - return { - id: record.id, - mealType: record.mealType, - foodName: record.foodName, - foodDescription: record.foodDescription || undefined, - weightGrams: record.weightGrams || undefined, - portionDescription: record.portionDescription || undefined, - estimatedCalories: record.estimatedCalories || undefined, - proteinGrams: record.proteinGrams || undefined, - carbohydrateGrams: record.carbohydrateGrams || undefined, - fatGrams: record.fatGrams || undefined, - fiberGrams: record.fiberGrams || undefined, - sugarGrams: record.sugarGrams || undefined, - sodiumMg: record.sodiumMg || undefined, - additionalNutrition: record.additionalNutrition || undefined, - source: record.source, - mealTime: record.mealTime || undefined, - imageUrl: record.imageUrl || undefined, - notes: record.notes || undefined, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - }; - } /** * Apple 登录